From 59d34d0abeb34094ade187480a94c8c957c69f49 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 20 Jun 2026 20:22:07 +0300 Subject: [PATCH 01/69] work in progress --- .gitignore | 3 + .../Data/Finnhub/ApiFinnhubQuoteDto.cs | 18 ++ .../Data/Finnhub/FinnhubMarketDataProvider.cs | 94 ++++++++ MarketExtension/Data/IMarketDataProvider.cs | 18 +- MarketExtension/Data/InstrumentCatalog.cs | 30 +++ MarketExtension/Data/MarketRepository.cs | 54 +++++ .../Data/MockMarketDataProvider.cs | 49 ++-- MarketExtension/Helpers/Log.cs | 35 +-- MarketExtension/MarketExtension.csproj | 15 ++ .../MarketExtensionCommandsProvider.cs | 11 +- MarketExtension/Models/AssetCategory.cs | 10 + MarketExtension/Models/DomainInstrument.cs | 6 + MarketExtension/Models/DomainQuote.cs | 13 + MarketExtension/Models/Quote.cs | 33 --- MarketExtension/Models/UiQuote.cs | 30 +++ MarketExtension/Pages/FavoritesDockPage.cs | 12 +- MarketExtension/Pages/MarketsPage.cs | 18 +- MarketExtension/Program.cs | 12 +- MarketExtension/secrets.props.template | 10 + reference/finnhub-spec.md | 224 ++++++++++++++++++ 20 files changed, 588 insertions(+), 107 deletions(-) create mode 100644 MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs create mode 100644 MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs create mode 100644 MarketExtension/Data/InstrumentCatalog.cs create mode 100644 MarketExtension/Data/MarketRepository.cs create mode 100644 MarketExtension/Models/AssetCategory.cs create mode 100644 MarketExtension/Models/DomainInstrument.cs create mode 100644 MarketExtension/Models/DomainQuote.cs delete mode 100644 MarketExtension/Models/Quote.cs create mode 100644 MarketExtension/Models/UiQuote.cs create mode 100644 MarketExtension/secrets.props.template create mode 100644 reference/finnhub-spec.md diff --git a/.gitignore b/.gitignore index 83632d6..52bccc2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Signing certificate — never commit *.pfx +# Local API keys — the .NET "local.properties" (build bakes it into a generated const) +MarketExtension/secrets.props + # Build output bin/ obj/ diff --git a/MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs b/MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs new file mode 100644 index 0000000..e7f042d --- /dev/null +++ b/MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs @@ -0,0 +1,18 @@ +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))] +internal sealed partial class FinnhubJsonContext : JsonSerializerContext; diff --git a/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs b/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs new file mode 100644 index 0000000..14d5047 --- /dev/null +++ b/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs @@ -0,0 +1,94 @@ +using System; +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 +{ + // Loaded from the gitignored secrets.props at build time (see MarketExtension.csproj's + // GenerateSecrets target + secrets.props.template). TODO: move to per-user settings / BYO-key. + private const string ApiKey = Secrets.FinnhubApiKey; + + // 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/") }; + + // 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) + { + // 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); + } + + 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 Http.GetAsync( + $"quote?symbol={Uri.EscapeDataString(symbol)}&token={ApiKey}", 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); + } + + var quote = new DomainQuote( + instrument.Symbol, instrument.Name, instrument.Category, + price, dto!.Change ?? 0m, dto.ChangePercent ?? 0m, IsValid: true); + Log.Info("Finnhub", $"{symbol} -> price={quote.Price} change%={quote.ChangePercent}"); + 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); + } + } + + private static DomainQuote Invalid(DomainInstrument i) => + new(i.Symbol, i.Name, i.Category, 0m, 0m, 0m, IsValid: false); + + // 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/IMarketDataProvider.cs b/MarketExtension/Data/IMarketDataProvider.cs index 78e5ec4..b315082 100644 --- a/MarketExtension/Data/IMarketDataProvider.cs +++ b/MarketExtension/Data/IMarketDataProvider.cs @@ -1,14 +1,18 @@ 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); + + Task> GetQuotesAsync( + IReadOnlyList instruments, CancellationToken ct = default); } diff --git a/MarketExtension/Data/InstrumentCatalog.cs b/MarketExtension/Data/InstrumentCatalog.cs new file mode 100644 index 0000000..8717967 --- /dev/null +++ b/MarketExtension/Data/InstrumentCatalog.cs @@ -0,0 +1,30 @@ +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. +// +// NOTE: Forex pairs are intentionally omitted for now — Finnhub's free tier gates /quote for +// OANDA:* behind a paid plan ("You don't have access to this resource."). AssetCategory.Currency +// and the providers' symbol formatting stay in place, so FX can be re-added in one spot once we +// either pay for Finnhub forex or slot in a free keyless FX provider (e.g. Frankfurter) behind +// the same IMarketDataProvider seam. +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) — omitted until a forex-capable provider is wired; see note above. + ]; +} diff --git a/MarketExtension/Data/MarketRepository.cs b/MarketExtension/Data/MarketRepository.cs new file mode 100644 index 0000000..567b0b7 --- /dev/null +++ b/MarketExtension/Data/MarketRepository.cs @@ -0,0 +1,54 @@ +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) +{ + public async Task> GetQuotesAsync( + IReadOnlyList instruments, CancellationToken ct = default) + { + // Route each instrument to the first provider that can serve its asset class. + var batches = new Dictionary>(); + var unserviceable = new List(); + foreach (var instrument in instruments) + { + var provider = providers.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; + } +} diff --git a/MarketExtension/Data/MockMarketDataProvider.cs b/MarketExtension/Data/MockMarketDataProvider.cs index 1f40f4f..15a3568 100644 --- a/MarketExtension/Data/MockMarketDataProvider.cs +++ b/MarketExtension/Data/MockMarketDataProvider.cs @@ -1,27 +1,40 @@ 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. +// Offline/dev fallback source. Returns deterministic seed prices for the requested instruments so +// the UI works without network access. No longer the active provider (FinnhubMarketDataProvider +// is) — kept to prove the seam stays swappable and as a no-network fallback. Supports every asset +// class. 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), + private static readonly Dictionary Seed = new() + { + ["AAPL"] = (189.20m, 2.24m, 1.20m), + ["MSFT"] = (421.10m, 1.68m, 0.40m), + ["NVDA"] = (118.45m, -3.10m, -2.55m), + ["BTC"] = (64210.00m, -512.00m, -0.80m), + ["ETH"] = (3420.50m, 45.20m, 1.34m), + ["SOL"] = (148.30m, -2.10m, -1.40m), + ["EURUSD"] = (1.0832m, -0.0011m, -0.10m), + ["GBPUSD"] = (1.2715m, 0.0024m, 0.19m), + ["USDJPY"] = (157.420m, 0.310m, 0.20m), + }; - // 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), + public bool Supports(AssetCategory category) => true; - // 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), - ]; + public Task> GetQuotesAsync( + IReadOnlyList instruments, CancellationToken ct = default) + { + IReadOnlyList quotes = + [ + .. 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) + : new DomainQuote(i.Symbol, i.Name, i.Category, 0m, 0m, 0m, IsValid: false)), + ]; + return Task.FromResult(quotes); + } } diff --git a/MarketExtension/Helpers/Log.cs b/MarketExtension/Helpers/Log.cs index cec219c..b9eaa7d 100644 --- a/MarketExtension/Helpers/Log.cs +++ b/MarketExtension/Helpers/Log.cs @@ -1,37 +1,26 @@ using System; using System.Diagnostics; -using System.IO; namespace MarketExtension; +// Lightweight tagged logger — writes to the Trace stream only (no files). That means +// OutputDebugString, which you can watch live with Sysinternals DebugView (run as admin → +// Capture → "Capture Global Win32"), an attached debugger, or the VS Output window. +// +// Format: "[time] [LEVEL] [Tag] message". The is a component name (e.g. "Finnhub", +// "Repository", "ComServer"). 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"); + 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 */ } - } - - public static void Info(string message) => Write("INFO", message, writeToFile: false); + public static void Warn(string tag, string message) => Write("WARN", tag, message); - public static void Error(string message, Exception? ex = null) + public static void Error(string tag, 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); + Write("ERROR", tag, text); } - 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 */ } - } - } + private static void Write(string level, string tag, string message) => + Trace.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [{level}] [{tag}] {message}"); } diff --git a/MarketExtension/MarketExtension.csproj b/MarketExtension/MarketExtension.csproj index eb96171..5fb4beb 100644 --- a/MarketExtension/MarketExtension.csproj +++ b/MarketExtension/MarketExtension.csproj @@ -105,5 +105,20 @@ true + + + + + + + <_SecretsCs>$(IntermediateOutputPath)Secrets.g.cs + + + + + + diff --git a/MarketExtension/MarketExtensionCommandsProvider.cs b/MarketExtension/MarketExtensionCommandsProvider.cs index 31b2790..abe2ef1 100644 --- a/MarketExtension/MarketExtensionCommandsProvider.cs +++ b/MarketExtension/MarketExtensionCommandsProvider.cs @@ -5,9 +5,10 @@ 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. Add a provider here to extend coverage (e.g. a forex source); + // MockMarketDataProvider is the offline fallback. + private readonly MarketRepository _repository = new(new FinnhubMarketDataProvider()); private readonly ICommandItem[] _commands; private readonly ICommandItem[] _dockBands; @@ -23,12 +24,12 @@ public MarketExtensionCommandsProvider() // See reference/settings/AdbSettingsManager.cs for an example. _commands = [ - new CommandItem(new MarketsPage(_dataProvider)) { Title = "Markets" }, + new CommandItem(new MarketsPage(_repository)) { Title = "Markets" }, ]; // Dock band: a ticker strip of up to 3 favorited instruments. Pinned from the Dock. _dockBands = [ - new CommandItem(new FavoritesDockPage(_dataProvider)) { Title = "Markets" }, + new CommandItem(new FavoritesDockPage(_repository)) { Title = "Markets" }, ]; } 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/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/DomainQuote.cs b/MarketExtension/Models/DomainQuote.cs new file mode 100644 index 0000000..e369fc7 --- /dev/null +++ b/MarketExtension/Models/DomainQuote.cs @@ -0,0 +1,13 @@ +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. +internal sealed record DomainQuote( + string Symbol, + string Name, + AssetCategory Category, + decimal Price, + decimal Change, + decimal ChangePercent, + bool IsValid = true); 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/UiQuote.cs b/MarketExtension/Models/UiQuote.cs new file mode 100644 index 0000000..0321ee1 --- /dev/null +++ b/MarketExtension/Models/UiQuote.cs @@ -0,0 +1,30 @@ +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); stocks/crypto as money. 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) + : Source.Price.ToString("$#,##0.00", CultureInfo.InvariantCulture); + + // 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/Pages/FavoritesDockPage.cs b/MarketExtension/Pages/FavoritesDockPage.cs index 45bbe26..1266106 100644 --- a/MarketExtension/Pages/FavoritesDockPage.cs +++ b/MarketExtension/Pages/FavoritesDockPage.cs @@ -19,8 +19,8 @@ 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; @@ -33,9 +33,9 @@ event TypedEventHandler INotifyItemsChanged.Item protected 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"); @@ -81,9 +81,9 @@ internal void RefreshQuotes() Task.Run(LoadQuotes); } - private void LoadQuotes() + private async Task LoadQuotes() { - _quotes = [.. _provider.GetQuotes()]; + _quotes = [.. (await _repository.GetQuotesAsync(InstrumentCatalog.All)).Select(UiQuote.From)]; IsLoading = false; RaiseItemsChanged(0); } diff --git a/MarketExtension/Pages/MarketsPage.cs b/MarketExtension/Pages/MarketsPage.cs index 41cdb89..5462747 100644 --- a/MarketExtension/Pages/MarketsPage.cs +++ b/MarketExtension/Pages/MarketsPage.cs @@ -16,10 +16,10 @@ namespace MarketExtension; // a real API. Favorites are pinned into their own section at the top. internal sealed partial class MarketsPage : DynamicListPage, 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; + // Injected so the palette page and the dock band share one repository — the coordinator over + // market-data providers (see Data/MarketRepository.cs). + private readonly MarketRepository _repository; + private UiQuote[]? _quotes; private event TypedEventHandler? _itemsChanged; @@ -32,9 +32,9 @@ event TypedEventHandler INotifyItemsChanged.Item protected new void RaiseItemsChanged(int totalItems = -1) => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); - public MarketsPage(IMarketDataProvider provider) + public MarketsPage(MarketRepository repository) { - _provider = provider; + _repository = repository; Icon = new IconInfo("https://github.com/favicon.ico"); Title = "Markets"; Name = "Open"; @@ -77,7 +77,7 @@ public override IListItem[] GetItems() return items.ToArray(); } - private ListItem BuildItem(Quote q, string section) => + private ListItem BuildItem(UiQuote q, string section) => new(new ToggleFavoriteCommand(q.Symbol, () => RaiseItemsChanged(0))) { Title = $"{q.Symbol} · {q.Name}", @@ -105,9 +105,9 @@ internal void RefreshQuotes() Task.Run(LoadQuotes); } - private void LoadQuotes() + private async Task LoadQuotes() { - _quotes = [.. _provider.GetQuotes()]; + _quotes = [.. (await _repository.GetQuotesAsync(InstrumentCatalog.All)).Select(UiQuote.From)]; IsLoading = false; RaiseItemsChanged(0); } diff --git a/MarketExtension/Program.cs b/MarketExtension/Program.cs index 48683e0..e230bbe 100644 --- a/MarketExtension/Program.cs +++ b/MarketExtension/Program.cs @@ -25,28 +25,28 @@ public static void Main(string[] args) 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 diff --git a/MarketExtension/secrets.props.template b/MarketExtension/secrets.props.template new file mode 100644 index 0000000..0ad20f1 --- /dev/null +++ b/MarketExtension/secrets.props.template @@ -0,0 +1,10 @@ + + + + + + + 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` From 5ddd7821a4f0de2643d7ee5d81dbc3974082d8ed Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 20 Jun 2026 20:26:51 +0300 Subject: [PATCH 02/69] wip --- MarketExtension/Helpers/Log.cs | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/MarketExtension/Helpers/Log.cs b/MarketExtension/Helpers/Log.cs index b9eaa7d..da23f1f 100644 --- a/MarketExtension/Helpers/Log.cs +++ b/MarketExtension/Helpers/Log.cs @@ -1,26 +1,41 @@ using System; using System.Diagnostics; +using Sentry; namespace MarketExtension; -// Lightweight tagged logger — writes to the Trace stream only (no files). That means -// OutputDebugString, which you can watch live with Sysinternals DebugView (run as admin → -// Capture → "Capture Global Win32"), an attached debugger, or the VS Output window. +// Lightweight tagged logger. // -// Format: "[time] [LEVEL] [Tag] message". The is a component name (e.g. "Finnhub", -// "Repository", "ComServer"). NEVER pass secrets (API tokens) into a message. +// Info/Warn write to the Debug stream (OutputDebugString) — watch them live in the VS Output +// window or Sysinternals DebugView. They are marked [Conditional("DEBUG")], so in a Release build +// the calls (and their argument evaluation) are compiled away entirely — a shipped MSIX emits +// nothing here: no overhead, and nothing for a local DebugView to capture. +// +// Error is NOT conditional: its Debug line is still stripped in Release, but it additionally +// reports to Sentry, which is the production diagnostics channel (a no-op until a DSN is set in +// Program.cs). 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 { + [Conditional("DEBUG")] public static void Info(string tag, string message) => Write("INFO", tag, message); + [Conditional("DEBUG")] public static void Warn(string tag, string message) => Write("WARN", tag, message); public static void Error(string tag, string message, Exception? ex = null) { - var text = ex is null ? message : $"{message} — {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}"; - Write("ERROR", tag, text); + // Debug-only line (stripped in Release). + Write("ERROR", tag, ex is null ? message : $"{message} — {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}"); + + // Production error channel — survives into Release (no-op while the Sentry DSN is empty). + if (ex is not null) + SentrySdk.CaptureException(ex); + else + SentrySdk.CaptureMessage($"[{tag}] {message}", SentryLevel.Error); } + [Conditional("DEBUG")] private static void Write(string level, string tag, string message) => - Trace.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [{level}] [{tag}] {message}"); + Debug.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [{level}] [{tag}] {message}"); } From 96b064e475ffef070286ddc4cb7f503158a09660 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 20 Jun 2026 20:42:11 +0300 Subject: [PATCH 03/69] wip --- CLAUDE.md | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6453171..08df6dc 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/MarketsPage.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 | @@ -42,6 +46,76 @@ the highest-value examples: - 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 +## 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) +``` + +**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`). +- `Ui*` — presentation; the ONLY place `FormatPrice()`/`FormatChange()` live (`UiQuote`). +- `AssetCategory` (enum) stays **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. No provider for a category → `IsValid:false` placeholder. | +| `Data/IMarketDataProvider.cs` | ONE data source: `bool Supports(AssetCategory)` + `Task> GetQuotesAsync(IReadOnlyList, ct)`. | +| `Data/Finnhub/FinnhubMarketDataProvider.cs` | Active provider (`Supports` Stock+Crypto). Maps `ApiFinnhubQuoteDto` → `DomainQuote`. | +| `Data/MockMarketDataProvider.cs` | Offline fallback (`Supports` all). | +| `Data/InstrumentCatalog.cs` | Static `DomainInstrument` list of what to price. | +| `Data/Finnhub/ApiFinnhubQuoteDto.cs` | Raw `/quote` DTO + `FinnhubJsonContext` (source-gen JSON). | +| `Models/{AssetCategory,DomainInstrument,DomainQuote,UiQuote}.cs` | the model layers | + +**To add a provider** (e.g. forex): implement `IMarketDataProvider`, declare `Supports`, map its +`Api*` DTO → `DomainQuote`, and register it in `MarketExtensionCommandsProvider`: +`new MarketRepository(new FinnhubMarketDataProvider(), new YourProvider())`. **Zero UI changes.** + +**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 omitted from `InstrumentCatalog`; + re-add via a paid plan or a keyless FX provider (e.g. Frankfurter) behind the same seam. +- 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`. + +**AOT/trim:** project is AOT/trim-enabled and treats trim warnings as errors **on publish**, so all +JSON must go through a source-gen `JsonSerializerContext` (never reflection-based `JsonSerializer`). +⚠️ `Settings/FavoritesStore.cs` still uses reflection JSON (IL2026/IL3050) — fine in Debug, will +ERROR on a trimmed Release publish; convert it before shipping a trimmed build. + +## API Key (secrets.props — the .NET "local.properties") + +- The Finnhub key lives in **gitignored** `MarketExtension/secrets.props`. Fresh clone: copy + `MarketExtension/secrets.props.template` → `secrets.props` and paste the key. +- The csproj `GenerateSecrets` target bakes it into a generated `Secrets.FinnhubApiKey` const (the + BuildConfig analog); `FinnhubMarketDataProvider` reads that. Missing file → empty key, build still succeeds. +- Caveat: the key is compiled into the shipped MSIX (extractable). True per-user secrecy = the + deferred bring-your-own-key in Command Palette settings. + +## Logging + +- `Log.Info/Warn/Error(tag, message)` (`Helpers/Log.cs`) — tagged by component (`Finnhub`, + `Repository`, `ComServer`, `Startup`). +- `Info`/`Warn` are `[Conditional("DEBUG")]` → **compiled out of Release** (the MSIX ships silent). + `Error` also reports to **Sentry**, which survives into Release (no-op until a DSN is set in `Program.cs`). +- 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:** layered data architecture, live Finnhub provider, `MarketRepository` coordinator, + `secrets.props` key handling, tagged logging. ⚠️ Working tree is **uncommitted** (on `master`). +- **Deferred / next:** FX provider (keyless Frankfurter); Finnhub `/search` add flow + `PinnedItem` + persistence; settings UI for bring-your-own-key; dock live-polling timer; rate-limit (429) + error + UX; convert `FavoritesStore` to source-gen JSON. + ## CommandPalette Toolkit — Quick Reference ### Navigation @@ -77,7 +151,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/MarketsPage.cs, reference/pages/AdbExtensionPage.cs): internal sealed partial class MyPage : DynamicListPage, INotifyItemsChanged { private event TypedEventHandler? _itemsChanged; @@ -248,4 +322,5 @@ This was scaffolded from AdbExtension. Already changed: COM GUID (`6b38c9aa-bbee - **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. +- **API key**: create `MarketExtension/secrets.props` from `secrets.props.template` and paste your Finnhub key (gitignored; see the "API Key" section above). - **Description**: update the placeholder description in `Package.appxmanifest`. From 0ebeb23751cb30d6ab9eb63fb520eb499c7176b1 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 20 Jun 2026 21:10:30 +0300 Subject: [PATCH 04/69] wip --- .../Commands/ToggleFavoriteCommand.cs | 12 +- .../Data/Finnhub/ApiFinnhubQuoteDto.cs | 1 + .../Data/Finnhub/ApiFinnhubSearchDto.cs | 24 +++ .../Data/Finnhub/FinnhubMarketDataProvider.cs | 61 ++++++++ MarketExtension/Data/IMarketDataProvider.cs | 5 + MarketExtension/Data/MarketRepository.cs | 19 +++ .../Data/MockMarketDataProvider.cs | 11 ++ MarketExtension/Data/Watchlist.cs | 18 +++ MarketExtension/Pages/FavoritesDockPage.cs | 2 +- MarketExtension/Pages/MarketsPage.cs | 142 +++++++++++++++--- MarketExtension/Settings/FavoritesStore.cs | 71 +++++++-- 11 files changed, 329 insertions(+), 37 deletions(-) create mode 100644 MarketExtension/Data/Finnhub/ApiFinnhubSearchDto.cs create mode 100644 MarketExtension/Data/Watchlist.cs diff --git a/MarketExtension/Commands/ToggleFavoriteCommand.cs b/MarketExtension/Commands/ToggleFavoriteCommand.cs index 7d58f00..f2abdc6 100644 --- a/MarketExtension/Commands/ToggleFavoriteCommand.cs +++ b/MarketExtension/Commands/ToggleFavoriteCommand.cs @@ -8,20 +8,20 @@ namespace MarketExtension; // in/out of the Favorites section. Adapted from reference/commands/ToggleFavoriteCommand.cs. internal sealed partial class ToggleFavoriteCommand : InvokableCommand { - private readonly string _symbol; + private readonly DomainInstrument _instrument; private readonly Action _refresh; - public ToggleFavoriteCommand(string symbol, Action refresh) + public ToggleFavoriteCommand(DomainInstrument instrument, Action refresh) { - _symbol = symbol; + _instrument = instrument; _refresh = refresh; - Name = FavoritesStore.Instance.IsFavorite(symbol) ? "Remove from Favorites" : "Add to Favorites"; - Icon = new IconInfo(FavoritesStore.Instance.IsFavorite(symbol) ? "" : ""); // FavoriteStarFill / FavoriteStar + Name = FavoritesStore.Instance.IsFavorite(instrument.Symbol) ? "Remove from Favorites" : "Add to Favorites"; + Icon = new IconInfo(FavoritesStore.Instance.IsFavorite(instrument.Symbol) ? "" : ""); // FavoriteStarFill / FavoriteStar } public override ICommandResult Invoke() { - FavoritesStore.Instance.Toggle(_symbol); + FavoritesStore.Instance.Toggle(_instrument); _refresh(); return CommandResult.KeepOpen(); } diff --git a/MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs b/MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs index e7f042d..bd8dc53 100644 --- a/MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs +++ b/MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs @@ -15,4 +15,5 @@ internal sealed record ApiFinnhubQuoteDto( [property: JsonPropertyName("pc")] decimal? PreviousClose); // previous close [JsonSerializable(typeof(ApiFinnhubQuoteDto))] +[JsonSerializable(typeof(ApiFinnhubSearchDto))] // /search response — see ApiFinnhubSearchDto.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 index 14d5047..72a4207 100644 --- a/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs +++ b/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs @@ -32,6 +32,67 @@ public async Task> GetQuotesAsync( 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 []; + + 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 Http.GetAsync( + $"search?q={Uri.EscapeDataString(query)}&exchange=US&token={ApiKey}", 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); diff --git a/MarketExtension/Data/IMarketDataProvider.cs b/MarketExtension/Data/IMarketDataProvider.cs index b315082..21029c8 100644 --- a/MarketExtension/Data/IMarketDataProvider.cs +++ b/MarketExtension/Data/IMarketDataProvider.cs @@ -15,4 +15,9 @@ internal interface IMarketDataProvider 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); } diff --git a/MarketExtension/Data/MarketRepository.cs b/MarketExtension/Data/MarketRepository.cs index 567b0b7..6237434 100644 --- a/MarketExtension/Data/MarketRepository.cs +++ b/MarketExtension/Data/MarketRepository.cs @@ -51,4 +51,23 @@ public async Task> GetQuotesAsync( $"{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( + providers.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; + } } diff --git a/MarketExtension/Data/MockMarketDataProvider.cs b/MarketExtension/Data/MockMarketDataProvider.cs index 15a3568..da9a391 100644 --- a/MarketExtension/Data/MockMarketDataProvider.cs +++ b/MarketExtension/Data/MockMarketDataProvider.cs @@ -26,6 +26,17 @@ internal sealed class MockMarketDataProvider : IMarketDataProvider public bool Supports(AssetCategory category) => true; + public Task> SearchAsync(string query, CancellationToken ct = default) + { + query = query?.Trim() ?? string.Empty; + IReadOnlyList matches = query.Length == 0 + ? [] + : [.. Seed.Keys + .Where(s => s.Contains(query, System.StringComparison.OrdinalIgnoreCase)) + .Select(s => new DomainInstrument(s, s, AssetCategory.Stock))]; + return Task.FromResult(matches); + } + public Task> GetQuotesAsync( IReadOnlyList instruments, CancellationToken ct = default) { diff --git a/MarketExtension/Data/Watchlist.cs b/MarketExtension/Data/Watchlist.cs new file mode 100644 index 0000000..d4eef75 --- /dev/null +++ b/MarketExtension/Data/Watchlist.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Linq; + +namespace MarketExtension; + +// The live set of instruments the extension prices: the built-in InstrumentCatalog defaults plus +// whatever the user has pinned via search, deduped by symbol (catalog wins on collision). This is +// the single source of truth shared by MarketsPage and FavoritesDockPage so a searched-and-pinned +// instrument shows in both. It supersedes InstrumentCatalog.All as the "what to price" list. +internal static class Watchlist +{ + public static IReadOnlyList Instruments() => + InstrumentCatalog.All + .Concat(FavoritesStore.Instance.Pinned) + .GroupBy(i => i.Symbol, System.StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .ToList(); +} diff --git a/MarketExtension/Pages/FavoritesDockPage.cs b/MarketExtension/Pages/FavoritesDockPage.cs index 1266106..eac63c6 100644 --- a/MarketExtension/Pages/FavoritesDockPage.cs +++ b/MarketExtension/Pages/FavoritesDockPage.cs @@ -83,7 +83,7 @@ internal void RefreshQuotes() private async Task LoadQuotes() { - _quotes = [.. (await _repository.GetQuotesAsync(InstrumentCatalog.All)).Select(UiQuote.From)]; + _quotes = [.. (await _repository.GetQuotesAsync(Watchlist.Instruments())).Select(UiQuote.From)]; IsLoading = false; RaiseItemsChanged(0); } diff --git a/MarketExtension/Pages/MarketsPage.cs b/MarketExtension/Pages/MarketsPage.cs index 5462747..347af8d 100644 --- a/MarketExtension/Pages/MarketsPage.cs +++ b/MarketExtension/Pages/MarketsPage.cs @@ -8,19 +8,32 @@ namespace MarketExtension; -// Top-level page: browse market instruments and star favorites. +// Top-level page: browse the watchlist, search Finnhub for new 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. +// trailing Refresh item. The priced list is Watchlist.Instruments() (catalog defaults ∪ pinned). +// +// Search is Enter-only — UpdateSearchText never hits the network (free-tier rate limits). Typing +// only re-filters the already-loaded watchlist locally; the one /search call fires solely when the +// user activates the synthetic "Search Finnhub for …" item (RunSearchCommand). Results are +// price-less identities; adding one pins it and re-prices the watchlist so it shows with a price. internal sealed partial class MarketsPage : DynamicListPage, INotifyItemsChanged { + // Segoe MDL2 "Search" glyph for the online-search action item. + private const string SearchGlyph = ""; + // Injected so the palette page and the dock band share one repository — the coordinator over // market-data providers (see Data/MarketRepository.cs). private readonly MarketRepository _repository; private UiQuote[]? _quotes; + // Last executed online search: the results and the exact query they belong 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; event TypedEventHandler INotifyItemsChanged.ItemsChanged @@ -38,9 +51,10 @@ public MarketsPage(MarketRepository repository) Icon = new IconInfo("https://github.com/favicon.ico"); Title = "Markets"; Name = "Open"; - PlaceholderText = "Search symbol or name..."; + PlaceholderText = "Filter watchlist, or type a symbol/name and press Enter to search..."; } + // Typing only re-lists — never calls the API. The online search is Enter-only. public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(0); @@ -49,36 +63,78 @@ 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 😔" }]; + return string.IsNullOrEmpty(SearchText) ? BrowseItems() : SearchItems(); + } + // Empty search box: the full watchlist — favorites first, then per-category sections. + private IListItem[] BrowseItems() + { 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(); + var favorites = _quotes!.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 inCategory = source.Where(q => q.Category == category && !FavoritesStore.Instance.IsFavorite(q.Symbol)); + var inCategory = _quotes!.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(); + } + + // Non-empty search box: the Enter-to-search action, then instant local matches, then the last + // online search's results (if they 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 = $"Search Finnhub for \"{SearchText}\"", + Subtitle = "Press Enter to look up symbols online", + Icon = new IconInfo(SearchGlyph), + }, + }; + + // Instant, no-API filter over the already-loaded watchlist. + var localMatches = _quotes!.Where(q => + q.Symbol.Contains(SearchText, StringComparison.OrdinalIgnoreCase) || + q.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase)).ToArray(); + foreach (var q in localMatches) + items.Add(BuildItem(q, "Watchlist")); + + // Online results, only while they still match what's typed. + if (_searchResults is not null && + string.Equals(_searchedQuery, SearchText, StringComparison.OrdinalIgnoreCase)) + { + // Hide results already in the watchlist — they show in the Watchlist section above. + var inWatchlist = _quotes!.Select(q => q.Symbol).ToHashSet(StringComparer.OrdinalIgnoreCase); + var fresh = _searchResults.Where(r => !inWatchlist.Contains(r.Symbol)).ToArray(); + + if (fresh.Length == 0 && localMatches.Length == 0) + { + items.Add(new ListItem(new NoOpCommand()) + { + Title = $"No matches for \"{SearchText}\"", + Section = "Search results", + }); + } + + foreach (var instrument in fresh) + items.Add(BuildResultItem(instrument)); + } return items.ToArray(); } + // A priced watchlist row. Stars/unstars in place (the row is already priced, so no refetch). private ListItem BuildItem(UiQuote q, string section) => - new(new ToggleFavoriteCommand(q.Symbol, () => RaiseItemsChanged(0))) + new(new ToggleFavoriteCommand(new DomainInstrument(q.Symbol, q.Name, q.Category), () => RaiseItemsChanged(0))) { Title = $"{q.Symbol} · {q.Name}", Subtitle = $"{q.FormatPrice()} {q.FormatChange()}", @@ -89,6 +145,19 @@ private ListItem BuildItem(UiQuote q, string section) => ], }; + // A price-less online search result. Adding it pins the instrument and re-prices the watchlist + // (RefreshQuotes) so it appears under ★ Favorites with a live price. + private ListItem BuildResultItem(DomainInstrument instrument) + { + var pinned = FavoritesStore.Instance.IsFavorite(instrument.Symbol); + return new ListItem(new ToggleFavoriteCommand(instrument, () => RefreshQuotes())) + { + Title = $"{instrument.Symbol} · {instrument.Name}", + Subtitle = pinned ? "★ In watchlist" : "Press Enter to add to watchlist", + Section = "Search results", + }; + } + private static string SectionLabel(AssetCategory category) => category switch { AssetCategory.Stock => "Stocks", @@ -107,7 +176,28 @@ internal void RefreshQuotes() private async Task LoadQuotes() { - _quotes = [.. (await _repository.GetQuotesAsync(InstrumentCatalog.All)).Select(UiQuote.From)]; + _quotes = [.. (await _repository.GetQuotesAsync(Watchlist.Instruments())).Select(UiQuote.From)]; + IsLoading = false; + RaiseItemsChanged(0); + } + + // 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); } @@ -128,4 +218,22 @@ public override ICommandResult Invoke() return CommandResult.KeepOpen(); } } + + private sealed partial class RunSearchCommand : InvokableCommand + { + private readonly MarketsPage _page; + + public RunSearchCommand(MarketsPage page) + { + _page = page; + Name = "Search"; + Icon = new IconInfo(SearchGlyph); + } + + public override ICommandResult Invoke() + { + _page.RunSearch(); + return CommandResult.KeepOpen(); + } + } } diff --git a/MarketExtension/Settings/FavoritesStore.cs b/MarketExtension/Settings/FavoritesStore.cs index 635e165..7344cd9 100644 --- a/MarketExtension/Settings/FavoritesStore.cs +++ b/MarketExtension/Settings/FavoritesStore.cs @@ -1,19 +1,26 @@ 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 favorited instrument symbols, persisted under the CmdPal settings folder -// so favorites survive reloads. Adapted from reference/settings/FavoritesStore.cs. +// JSON-backed set of pinned instruments, persisted under the CmdPal settings folder so the +// user's watchlist survives reloads. Adapted from reference/settings/FavoritesStore.cs. +// +// 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. Composed with +// the catalog into the live watchlist by Watchlist.Instruments(). internal sealed class FavoritesStore { public static readonly FavoritesStore Instance = new(); private readonly Lock _lock = new(); - private HashSet _favorites = []; + // Keyed by normalized symbol; value carries the identity needed to re-fetch a quote. + private Dictionary _favorites = []; private static string JsonPath { @@ -29,16 +36,24 @@ private static string JsonPath public bool IsFavorite(string symbol) { - lock (_lock) return _favorites.Contains(Normalize(symbol)); + lock (_lock) return _favorites.ContainsKey(Normalize(symbol)); } - public void Toggle(string symbol) + // The pinned instruments, in insertion order, as provider-agnostic identities. + public IReadOnlyList Pinned { - var key = Normalize(symbol); + get { lock (_lock) return [.. _favorites.Values]; } + } + + // Pin if absent, unpin if present. Keyed on symbol; the full identity is stored on add so the + // instrument can be re-priced later. + public void Toggle(DomainInstrument instrument) + { + var key = Normalize(instrument.Symbol); lock (_lock) { if (!_favorites.Remove(key)) - _favorites.Add(key); + _favorites[key] = instrument; } Save(); @@ -54,9 +69,22 @@ private void Load() return; var json = File.ReadAllText(JsonPath); - var ids = JsonSerializer.Deserialize>(json); - if (ids != null) - _favorites = [.. ids]; + + // Current format: array of PinnedItem objects. + var items = JsonSerializer.Deserialize(json, FavoritesJsonContext.Default.ListPinnedItem); + if (items is { Count: > 0 }) + { + _favorites = items.ToDictionary( + i => Normalize(i.Symbol), + i => new DomainInstrument(i.Symbol, i.Name, i.Category)); + return; + } + + // Legacy format: array of bare symbol strings. Recover name/category from the catalog + // where possible (so e.g. crypto favorites keep their category), defaulting to Stock. + var symbols = JsonSerializer.Deserialize(json, FavoritesJsonContext.Default.ListString); + if (symbols is { Count: > 0 }) + _favorites = symbols.ToDictionary(Normalize, ToInstrument); } catch { @@ -64,13 +92,21 @@ private void Load() } } + private static DomainInstrument ToInstrument(string symbol) + { + var key = Normalize(symbol); + var known = InstrumentCatalog.All.FirstOrDefault(i => Normalize(i.Symbol) == key); + return known ?? new DomainInstrument(symbol, symbol, AssetCategory.Stock); + } + private void Save() { try { - List snapshot; - lock (_lock) snapshot = [.. _favorites]; - File.WriteAllText(JsonPath, JsonSerializer.Serialize(snapshot)); + List snapshot; + lock (_lock) + snapshot = [.. _favorites.Values.Select(i => new PinnedItem(i.Symbol, i.Name, i.Category))]; + File.WriteAllText(JsonPath, JsonSerializer.Serialize(snapshot, FavoritesJsonContext.Default.ListPinnedItem)); } catch { @@ -78,3 +114,12 @@ private void Save() } } } + +// Persistence DTO for a pinned instrument. Source-gen (de)serialized so the trimmed Release build +// stays clean (reflection JSON here was the IL2026/IL3050 debt flagged in CLAUDE.md). +internal sealed record PinnedItem(string Symbol, string Name, AssetCategory Category); + +[JsonSourceGenerationOptions(UseStringEnumConverter = true)] // category as readable, reorder-safe text +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] // legacy format migration +internal sealed partial class FavoritesJsonContext : JsonSerializerContext; From c21af8e9c3d08f7218d87f5a46511b38f5890259 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 20 Jun 2026 21:12:49 +0300 Subject: [PATCH 05/69] wip --- CLAUDE.md | 71 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 08df6dc..88190bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,16 +66,20 @@ ApiFinnhubQuoteDto ─(provider maps)→ DomainQuote ─(repository routes+merge | 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. No provider for a category → `IsValid:false` placeholder. | -| `Data/IMarketDataProvider.cs` | ONE data source: `bool Supports(AssetCategory)` + `Task> GetQuotesAsync(IReadOnlyList, ct)`. | -| `Data/Finnhub/FinnhubMarketDataProvider.cs` | Active provider (`Supports` Stock+Crypto). Maps `ApiFinnhubQuoteDto` → `DomainQuote`. | -| `Data/MockMarketDataProvider.cs` | Offline fallback (`Supports` all). | -| `Data/InstrumentCatalog.cs` | Static `DomainInstrument` list of what to price. | -| `Data/Finnhub/ApiFinnhubQuoteDto.cs` | Raw `/quote` DTO + `FinnhubJsonContext` (source-gen JSON). | +| `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. No provider for a category → `IsValid:false` placeholder. | +| `Data/IMarketDataProvider.cs` | ONE data source: `bool Supports(AssetCategory)` + `GetQuotesAsync(instruments, ct)` + `SearchAsync(query, ct)` (free-text symbol lookup → `DomainInstrument`s, **identity only, no prices**; a provider that can't search returns `[]`). | +| `Data/Finnhub/FinnhubMarketDataProvider.cs` | Active provider (`Supports` Stock+Crypto). Maps `ApiFinnhubQuoteDto` → `DomainQuote`; `SearchAsync` calls `/search` (US equities only — see Finnhub specifics). | +| `Data/MockMarketDataProvider.cs` | Offline fallback (`Supports` all). `SearchAsync` filters its seed keys. | +| `Data/InstrumentCatalog.cs` | Static `DomainInstrument` defaults to price (the always-shown seeds). | +| `Data/Watchlist.cs` | `Instruments()` = catalog defaults ∪ user-pinned (`FavoritesStore.Pinned`), deduped. The single "what to price" list both `MarketsPage` and `FavoritesDockPage` use. | +| `Settings/FavoritesStore.cs` | JSON-persisted pinned instruments — stores **full `DomainInstrument` identity** (symbol+name+category, so searched non-catalog symbols re-price) via source-gen `PinnedItem`/`FavoritesJsonContext`; migrates the legacy symbol-only array. | +| `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. | | `Models/{AssetCategory,DomainInstrument,DomainQuote,UiQuote}.cs` | the model layers | -**To add a provider** (e.g. forex): implement `IMarketDataProvider`, declare `Supports`, map its -`Api*` DTO → `DomainQuote`, and register it in `MarketExtensionCommandsProvider`: +**To add a provider** (e.g. forex): implement `IMarketDataProvider` (`Supports`, `GetQuotesAsync`, +and `SearchAsync` — return `[]` if it can't search), map its `Api*` DTO → `DomainQuote`, and register +it in `MarketExtensionCommandsProvider`: `new MarketRepository(new FinnhubMarketDataProvider(), new YourProvider())`. **Zero UI changes.** **Finnhub specifics:** @@ -84,11 +88,22 @@ ApiFinnhubQuoteDto ─(provider maps)→ DomainQuote ─(repository routes+merge re-add via a paid plan or a keyless FX provider (e.g. Frankfurter) behind the same seam. - 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`. - -**AOT/trim:** project is AOT/trim-enabled and treats trim warnings as errors **on publish**, so all -JSON must go through a source-gen `JsonSerializerContext` (never reflection-based `JsonSerializer`). -⚠️ `Settings/FavoritesStore.cs` still uses reflection JSON (IL2026/IL3050) — fine in Debug, will -ERROR on a trimmed Release publish; convert it before shipping a trimmed build. +- **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. + +**AOT/trim:** project is AOT/trim-enabled; `EnableTrimAnalyzer` is on **in Debug** (so IL2026/IL3050 +surface every build) and `ILLinkTreatWarningsAsErrors` is set, so all JSON must go through a source-gen +`JsonSerializerContext` (never reflection-based `JsonSerializer`). Both contexts are source-gen: +`FinnhubJsonContext` (quotes + search) and `FavoritesJsonContext` (pinned items). +⚠️ **Gotcha:** 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 (secrets.props — the .NET "local.properties") @@ -110,11 +125,31 @@ ERROR on a trimmed Release publish; convert it before shipping a trimmed build. ## Current Status / Next Steps -- **Done:** layered data architecture, live Finnhub provider, `MarketRepository` coordinator, - `secrets.props` key handling, tagged logging. ⚠️ Working tree is **uncommitted** (on `master`). -- **Deferred / next:** FX provider (keyless Frankfurter); Finnhub `/search` add flow + `PinnedItem` - persistence; settings UI for bring-your-own-key; dock live-polling timer; rate-limit (429) + error - UX; convert `FavoritesStore` to source-gen JSON. +- **Done:** layered data architecture; live Finnhub provider; `MarketRepository` coordinator; + `secrets.props` key handling; tagged logging; **Enter-only Finnhub `/search`** + **persistent + watchlist** (`PinnedItem` identity persistence, source-gen `FavoritesStore`, + `Watchlist.Instruments()` catalog ∪ pinned union, searched stocks add to watchlist + dock). + ⚠️ Working tree is **uncommitted** (on `repo`). +- **Deferred / next:** the **three-screen UX** (below — the next major piece); FX provider (keyless + Frankfurter); settings UI for bring-your-own-key; dock live-polling timer; rate-limit (429) + + richer error UX; crypto/FX in symbol search. + +### Planned three-screen UX (next major piece) + +Split the single `MarketsPage` into **three top-level commands**, and separate **watchlist** +(everything the user tracks) from **favorites** (a curated subset — the only thing the dock shows): + +1. **Markets Search** (default / entry screen) — just the Enter-only `/search` flow; results can be + added to the watchlist. +2. **Watchlist** — the user's tracked instruments, added from search and persisted across sessions + (today's `FavoritesStore` + `Watchlist.Instruments()` is the seed for this). +3. **Favorites** — a curated subset of the watchlist; **only favorites render in the dock band**. + +Implication: today `FavoritesStore` conflates "pinned/watchlist" with "favorites" and the dock shows +up to 3 of them. The split needs a **watchlist store** (tracked) distinct from a **favorites** mark +(dock subset) — e.g. add an `IsFavorite` bit to `PinnedItem`, or a second store. Register the three +pages in `MarketExtensionCommandsProvider.TopLevelCommands()`; keep `GetDockBands()` pointed at the +favorites subset only. ## CommandPalette Toolkit — Quick Reference From ed2721543c25aed06bea35633e544c48089df8bc Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 20 Jun 2026 21:42:49 +0300 Subject: [PATCH 06/69] wip --- CLAUDE.md | 62 ++--- .../Commands/MembershipCommands.cs | 100 ++++++++ .../Commands/ToggleFavoriteCommand.cs | 28 -- MarketExtension/Data/Watchlist.cs | 18 -- .../MarketExtensionCommandsProvider.cs | 8 +- MarketExtension/Pages/FavoritesDockPage.cs | 10 +- MarketExtension/Pages/FavoritesPage.cs | 45 ++++ MarketExtension/Pages/MarketsPage.cs | 239 ------------------ MarketExtension/Pages/PricedListPage.cs | 102 ++++++++ MarketExtension/Pages/SearchPage.cs | 182 +++++++++++++ MarketExtension/Pages/WatchlistPage.cs | 60 +++++ MarketExtension/Settings/FavoritesStore.cs | 125 --------- MarketExtension/Settings/WatchlistStore.cs | 188 ++++++++++++++ 13 files changed, 720 insertions(+), 447 deletions(-) create mode 100644 MarketExtension/Commands/MembershipCommands.cs delete mode 100644 MarketExtension/Commands/ToggleFavoriteCommand.cs delete mode 100644 MarketExtension/Data/Watchlist.cs create mode 100644 MarketExtension/Pages/FavoritesPage.cs delete mode 100644 MarketExtension/Pages/MarketsPage.cs create mode 100644 MarketExtension/Pages/PricedListPage.cs create mode 100644 MarketExtension/Pages/SearchPage.cs create mode 100644 MarketExtension/Pages/WatchlistPage.cs delete mode 100644 MarketExtension/Settings/FavoritesStore.cs create mode 100644 MarketExtension/Settings/WatchlistStore.cs diff --git a/CLAUDE.md b/CLAUDE.md index 88190bc..9b2b7e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,8 +26,8 @@ Deploy the MSIX package, then reload Command Palette to pick up changes. 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 live code -(`Pages/MarketsPage.cs`, `Pages/FavoritesDockPage.cs`, `Helpers/ProcessHelper.cs`) shows these -patterns in use. See `reference/README.md` for the full index; +(`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 | @@ -70,9 +70,8 @@ ApiFinnhubQuoteDto ─(provider maps)→ DomainQuote ─(repository routes+merge | `Data/IMarketDataProvider.cs` | ONE data source: `bool Supports(AssetCategory)` + `GetQuotesAsync(instruments, ct)` + `SearchAsync(query, ct)` (free-text symbol lookup → `DomainInstrument`s, **identity only, no prices**; a provider that can't search returns `[]`). | | `Data/Finnhub/FinnhubMarketDataProvider.cs` | Active provider (`Supports` Stock+Crypto). Maps `ApiFinnhubQuoteDto` → `DomainQuote`; `SearchAsync` calls `/search` (US equities only — see Finnhub specifics). | | `Data/MockMarketDataProvider.cs` | Offline fallback (`Supports` all). `SearchAsync` filters its seed keys. | -| `Data/InstrumentCatalog.cs` | Static `DomainInstrument` defaults to price (the always-shown seeds). | -| `Data/Watchlist.cs` | `Instruments()` = catalog defaults ∪ user-pinned (`FavoritesStore.Pinned`), deduped. The single "what to price" list both `MarketsPage` and `FavoritesDockPage` use. | -| `Settings/FavoritesStore.cs` | JSON-persisted pinned instruments — stores **full `DomainInstrument` identity** (symbol+name+category, so searched non-catalog symbols re-price) via source-gen `PinnedItem`/`FavoritesJsonContext`; migrates the legacy symbol-only array. | +| `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). Pages read its `Watchlist`/`Favorites` subsets directly. Replaces the old `FavoritesStore` + `Watchlist.cs`. | | `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. | | `Models/{AssetCategory,DomainInstrument,DomainQuote,UiQuote}.cs` | the model layers | @@ -98,7 +97,7 @@ it in `MarketExtensionCommandsProvider`: **AOT/trim:** project is AOT/trim-enabled; `EnableTrimAnalyzer` is on **in Debug** (so IL2026/IL3050 surface every build) and `ILLinkTreatWarningsAsErrors` is set, so all JSON must go through a source-gen `JsonSerializerContext` (never reflection-based `JsonSerializer`). Both contexts are source-gen: -`FinnhubJsonContext` (quotes + search) and `FavoritesJsonContext` (pinned items). +`FinnhubJsonContext` (quotes + search) and `WatchlistJsonContext` (watchlist items + legacy migration). ⚠️ **Gotcha:** 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 @@ -126,30 +125,33 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). ## Current Status / Next Steps - **Done:** layered data architecture; live Finnhub provider; `MarketRepository` coordinator; - `secrets.props` key handling; tagged logging; **Enter-only Finnhub `/search`** + **persistent - watchlist** (`PinnedItem` identity persistence, source-gen `FavoritesStore`, - `Watchlist.Instruments()` catalog ∪ pinned union, searched stocks add to watchlist + dock). + `secrets.props` key handling; tagged logging; **Enter-only Finnhub `/search`**; **persistent + watchlist + favorites** as two independent flags (`WatchlistStore`); and the **three-screen UX** + (Markets Search / Watchlist / Favorites — see below). ⚠️ Working tree is **uncommitted** (on `repo`). -- **Deferred / next:** the **three-screen UX** (below — the next major piece); FX provider (keyless - Frankfurter); settings UI for bring-your-own-key; dock live-polling timer; rate-limit (429) + - richer error UX; crypto/FX in symbol search. - -### Planned three-screen UX (next major piece) - -Split the single `MarketsPage` into **three top-level commands**, and separate **watchlist** -(everything the user tracks) from **favorites** (a curated subset — the only thing the dock shows): - -1. **Markets Search** (default / entry screen) — just the Enter-only `/search` flow; results can be - added to the watchlist. -2. **Watchlist** — the user's tracked instruments, added from search and persisted across sessions - (today's `FavoritesStore` + `Watchlist.Instruments()` is the seed for this). -3. **Favorites** — a curated subset of the watchlist; **only favorites render in the dock band**. - -Implication: today `FavoritesStore` conflates "pinned/watchlist" with "favorites" and the dock shows -up to 3 of them. The split needs a **watchlist store** (tracked) distinct from a **favorites** mark -(dock subset) — e.g. add an `IsFavorite` bit to `PinnedItem`, or a second store. Register the three -pages in `MarketExtensionCommandsProvider.TopLevelCommands()`; keep `GetDockBands()` pointed at the -favorites subset only. +- **Deferred / next:** FX provider (keyless Frankfurter); settings UI for bring-your-own-key; dock + live-polling timer; rate-limit (429) + richer error UX; crypto/FX in symbol search. + +### 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: + +1. **Markets Search** (`Pages/SearchPage.cs`, default entry) — the Enter-only `/search` flow. On a + result: **Enter** → add to watchlist; **Ctrl+Enter** (the first `MoreCommands` context item, the + toolkit's secondary-activation slot) → add to favorites. 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** → remove from watchlist; **Ctrl+Enter** → toggle favorite. +3. **Markets Favorites** (`Pages/FavoritesPage.cs`) — the curated subset; **only favorites render in + the dock band** (`FavoritesDockPage` prices `WatchlistStore.Favorites`). **Enter** → remove from favorites. + +Watchlist + Favorites share `Pages/PricedListPage.cs` (async price + the on-load `INotifyItemsChanged` +refresh + local filter). 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`). ## CommandPalette Toolkit — Quick Reference @@ -186,7 +188,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/MarketsPage.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; diff --git a/MarketExtension/Commands/MembershipCommands.cs b/MarketExtension/Commands/MembershipCommands.cs new file mode 100644 index 0000000..4861fc8 --- /dev/null +++ b/MarketExtension/Commands/MembershipCommands.cs @@ -0,0 +1,100 @@ +using System; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace MarketExtension; + +// The watchlist/favorites membership actions surfaced on list rows. Each one mutates WatchlistStore +// for one instrument, then asks the host page to re-list so the row reflects its new membership (or +// drops out of the list). KeepOpen keeps the user on the page so they can act on several rows in a +// row — matching the project's star-in-place UX. 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; + private readonly Action _refresh; + + protected MembershipCommand(DomainInstrument instrument, Action refresh) + { + Instrument = instrument; + _refresh = refresh; + } + + protected abstract void Apply(); + + public override ICommandResult Invoke() + { + Apply(); + _refresh(); + return CommandResult.KeepOpen(); + } +} + +internal sealed partial class AddToWatchlistCommand : MembershipCommand +{ + public AddToWatchlistCommand(DomainInstrument instrument, Action refresh) : base(instrument, refresh) + { + Name = "Add to Watchlist"; + Icon = new IconInfo(AddGlyph); + } + + protected override void Apply() => WatchlistStore.Instance.AddToWatchlist(Instrument); +} + +internal sealed partial class RemoveFromWatchlistCommand : MembershipCommand +{ + public RemoveFromWatchlistCommand(DomainInstrument instrument, Action refresh) : base(instrument, refresh) + { + Name = "Remove from Watchlist"; + Icon = new IconInfo(DeleteGlyph); + } + + protected override void Apply() => WatchlistStore.Instance.RemoveFromWatchlist(Instrument.Symbol); +} + +internal sealed partial class AddToFavoritesCommand : MembershipCommand +{ + public AddToFavoritesCommand(DomainInstrument instrument, Action refresh) : base(instrument, refresh) + { + Name = "Add to Favorites"; + Icon = new IconInfo(StarGlyph); + } + + protected override void Apply() => WatchlistStore.Instance.AddToFavorites(Instrument); +} + +internal sealed partial class RemoveFromFavoritesCommand : MembershipCommand +{ + public RemoveFromFavoritesCommand(DomainInstrument instrument, Action refresh) : base(instrument, refresh) + { + Name = "Remove from Favorites"; + Icon = new IconInfo(StarFillGlyph); + } + + protected override void Apply() => WatchlistStore.Instance.RemoveFromFavorites(Instrument.Symbol); +} + +// Star/unstar in place — used as the Ctrl+Enter secondary on the Watchlist screen so the user can +// favorite a tracked instrument without re-searching. Name/icon reflect the current state. +internal sealed partial class ToggleFavoriteCommand : MembershipCommand +{ + public ToggleFavoriteCommand(DomainInstrument instrument, Action refresh) : base(instrument, refresh) + { + var isFavorite = WatchlistStore.Instance.IsFavorite(instrument.Symbol); + Name = isFavorite ? "Remove from Favorites" : "Add to Favorites"; + Icon = new IconInfo(isFavorite ? StarFillGlyph : StarGlyph); + } + + protected override void Apply() + { + if (WatchlistStore.Instance.IsFavorite(Instrument.Symbol)) + WatchlistStore.Instance.RemoveFromFavorites(Instrument.Symbol); + else + WatchlistStore.Instance.AddToFavorites(Instrument); + } +} diff --git a/MarketExtension/Commands/ToggleFavoriteCommand.cs b/MarketExtension/Commands/ToggleFavoriteCommand.cs deleted file mode 100644 index f2abdc6..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 DomainInstrument _instrument; - private readonly Action _refresh; - - public ToggleFavoriteCommand(DomainInstrument instrument, Action refresh) - { - _instrument = instrument; - _refresh = refresh; - Name = FavoritesStore.Instance.IsFavorite(instrument.Symbol) ? "Remove from Favorites" : "Add to Favorites"; - Icon = new IconInfo(FavoritesStore.Instance.IsFavorite(instrument.Symbol) ? "" : ""); // FavoriteStarFill / FavoriteStar - } - - public override ICommandResult Invoke() - { - FavoritesStore.Instance.Toggle(_instrument); - _refresh(); - return CommandResult.KeepOpen(); - } -} diff --git a/MarketExtension/Data/Watchlist.cs b/MarketExtension/Data/Watchlist.cs deleted file mode 100644 index d4eef75..0000000 --- a/MarketExtension/Data/Watchlist.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace MarketExtension; - -// The live set of instruments the extension prices: the built-in InstrumentCatalog defaults plus -// whatever the user has pinned via search, deduped by symbol (catalog wins on collision). This is -// the single source of truth shared by MarketsPage and FavoritesDockPage so a searched-and-pinned -// instrument shows in both. It supersedes InstrumentCatalog.All as the "what to price" list. -internal static class Watchlist -{ - public static IReadOnlyList Instruments() => - InstrumentCatalog.All - .Concat(FavoritesStore.Instance.Pinned) - .GroupBy(i => i.Symbol, System.StringComparer.OrdinalIgnoreCase) - .Select(g => g.First()) - .ToList(); -} diff --git a/MarketExtension/MarketExtensionCommandsProvider.cs b/MarketExtension/MarketExtensionCommandsProvider.cs index abe2ef1..b7d12bc 100644 --- a/MarketExtension/MarketExtensionCommandsProvider.cs +++ b/MarketExtension/MarketExtensionCommandsProvider.cs @@ -23,8 +23,14 @@ public MarketExtensionCommandsProvider() // Settings = MySettingsManager.Instance.Settings; // See reference/settings/AdbSettingsManager.cs for an example. + // Three top-level screens: search (the default entry), the tracked watchlist, and the + // curated favorites. All share the one repository; favorites also feed the dock band below. + // All three titles share the "Markets " prefix so they group together (and don't pollute the + // global namespace) when the user searches the Command Palette root. _commands = [ - new CommandItem(new MarketsPage(_repository)) { Title = "Markets" }, + new CommandItem(new SearchPage(_repository)) { Title = "Markets Search" }, + new CommandItem(new WatchlistPage(_repository)) { Title = "Markets Watchlist" }, + new CommandItem(new FavoritesPage(_repository)) { Title = "Markets Favorites" }, ]; // Dock band: a ticker strip of up to 3 favorited instruments. Pinned from the Dock. diff --git a/MarketExtension/Pages/FavoritesDockPage.cs b/MarketExtension/Pages/FavoritesDockPage.cs index eac63c6..0128a00 100644 --- a/MarketExtension/Pages/FavoritesDockPage.cs +++ b/MarketExtension/Pages/FavoritesDockPage.cs @@ -46,17 +46,14 @@ 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.Take(MaxDockFavorites).ToArray(); 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", + Subtitle = "Star instruments from Markets Search or your Watchlist", }]; } @@ -83,7 +80,8 @@ internal void RefreshQuotes() private async Task LoadQuotes() { - _quotes = [.. (await _repository.GetQuotesAsync(Watchlist.Instruments())).Select(UiQuote.From)]; + // The dock shows only favorites — price exactly that subset. + _quotes = [.. (await _repository.GetQuotesAsync(WatchlistStore.Instance.Favorites)).Select(UiQuote.From)]; IsLoading = false; RaiseItemsChanged(0); } diff --git a/MarketExtension/Pages/FavoritesPage.cs b/MarketExtension/Pages/FavoritesPage.cs new file mode 100644 index 0000000..e7d6738 --- /dev/null +++ b/MarketExtension/Pages/FavoritesPage.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace MarketExtension; + +// Top-level screen: the curated favorites — the same set the dock band shows — priced live. Enter +// removes the row from favorites (it stays on the watchlist if it's tracked there). This is the +// only place, besides the Watchlist screen's Ctrl+Enter, that unstars an instrument. +internal sealed partial class FavoritesPage : PricedListPage +{ + public FavoritesPage(MarketRepository repository) : base(repository) + { + Title = "Markets Favorites"; + Name = "Open"; + PlaceholderText = "Filter your favorites..."; + } + + protected override IReadOnlyList InstrumentsToPrice() + => WatchlistStore.Instance.Favorites; + + protected override IListItem BuildRow(UiQuote q) + { + var instrument = new DomainInstrument(q.Symbol, q.Name, q.Category); + + return new ListItem(new RemoveFromFavoritesCommand(instrument, () => RaiseItemsChanged(0))) + { + Title = $"★ {q.Symbol} · {q.Name}", + Subtitle = $"{q.FormatPrice()} {q.FormatChange()}", + MoreCommands = + [ + new CommandContextItem(new CopyTextCommand(q.FormatPrice())) { Title = "Copy price" }, + ], + }; + } + + protected override IListItem[] EmptyState() => + [ + new ListItem(new NoOpCommand()) + { + Title = "No favorites yet", + Subtitle = "Star instruments from Markets Search, or from your Watchlist with Ctrl+Enter", + }, + ]; +} diff --git a/MarketExtension/Pages/MarketsPage.cs b/MarketExtension/Pages/MarketsPage.cs deleted file mode 100644 index 347af8d..0000000 --- a/MarketExtension/Pages/MarketsPage.cs +++ /dev/null @@ -1,239 +0,0 @@ -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; - -namespace MarketExtension; - -// Top-level page: browse the watchlist, search Finnhub for new 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. The priced list is Watchlist.Instruments() (catalog defaults ∪ pinned). -// -// Search is Enter-only — UpdateSearchText never hits the network (free-tier rate limits). Typing -// only re-filters the already-loaded watchlist locally; the one /search call fires solely when the -// user activates the synthetic "Search Finnhub for …" item (RunSearchCommand). Results are -// price-less identities; adding one pins it and re-prices the watchlist so it shows with a price. -internal sealed partial class MarketsPage : DynamicListPage, INotifyItemsChanged -{ - // Segoe MDL2 "Search" glyph for the online-search action item. - private const string SearchGlyph = ""; - - // Injected so the palette page and the dock band share one repository — the coordinator over - // market-data providers (see Data/MarketRepository.cs). - private readonly MarketRepository _repository; - private UiQuote[]? _quotes; - - // Last executed online search: the results and the exact query they belong 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; - - event TypedEventHandler INotifyItemsChanged.ItemsChanged - { - add { _itemsChanged += value; RefreshQuotes(); } // fires every time the user navigates here - remove => _itemsChanged -= value; - } - - protected new void RaiseItemsChanged(int totalItems = -1) - => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); - - public MarketsPage(MarketRepository repository) - { - _repository = repository; - Icon = new IconInfo("https://github.com/favicon.ico"); - Title = "Markets"; - Name = "Open"; - PlaceholderText = "Filter watchlist, or type a symbol/name and press Enter to search..."; - } - - // 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() - { - if (_quotes is null) - return []; - - return string.IsNullOrEmpty(SearchText) ? BrowseItems() : SearchItems(); - } - - // Empty search box: the full watchlist — favorites first, then per-category sections. - private IListItem[] BrowseItems() - { - var items = new List(); - - var favorites = _quotes!.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 inCategory = _quotes!.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(); - } - - // Non-empty search box: the Enter-to-search action, then instant local matches, then the last - // online search's results (if they 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 = $"Search Finnhub for \"{SearchText}\"", - Subtitle = "Press Enter to look up symbols online", - Icon = new IconInfo(SearchGlyph), - }, - }; - - // Instant, no-API filter over the already-loaded watchlist. - var localMatches = _quotes!.Where(q => - q.Symbol.Contains(SearchText, StringComparison.OrdinalIgnoreCase) || - q.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase)).ToArray(); - foreach (var q in localMatches) - items.Add(BuildItem(q, "Watchlist")); - - // Online results, only while they still match what's typed. - if (_searchResults is not null && - string.Equals(_searchedQuery, SearchText, StringComparison.OrdinalIgnoreCase)) - { - // Hide results already in the watchlist — they show in the Watchlist section above. - var inWatchlist = _quotes!.Select(q => q.Symbol).ToHashSet(StringComparer.OrdinalIgnoreCase); - var fresh = _searchResults.Where(r => !inWatchlist.Contains(r.Symbol)).ToArray(); - - if (fresh.Length == 0 && localMatches.Length == 0) - { - items.Add(new ListItem(new NoOpCommand()) - { - Title = $"No matches for \"{SearchText}\"", - Section = "Search results", - }); - } - - foreach (var instrument in fresh) - items.Add(BuildResultItem(instrument)); - } - - return items.ToArray(); - } - - // A priced watchlist row. Stars/unstars in place (the row is already priced, so no refetch). - private ListItem BuildItem(UiQuote q, string section) => - new(new ToggleFavoriteCommand(new DomainInstrument(q.Symbol, q.Name, q.Category), () => RaiseItemsChanged(0))) - { - Title = $"{q.Symbol} · {q.Name}", - Subtitle = $"{q.FormatPrice()} {q.FormatChange()}", - Section = section, - MoreCommands = - [ - new CommandContextItem(new CopyTextCommand(q.FormatPrice())) { Title = "Copy price" }, - ], - }; - - // A price-less online search result. Adding it pins the instrument and re-prices the watchlist - // (RefreshQuotes) so it appears under ★ Favorites with a live price. - private ListItem BuildResultItem(DomainInstrument instrument) - { - var pinned = FavoritesStore.Instance.IsFavorite(instrument.Symbol); - return new ListItem(new ToggleFavoriteCommand(instrument, () => RefreshQuotes())) - { - Title = $"{instrument.Symbol} · {instrument.Name}", - Subtitle = pinned ? "★ In watchlist" : "Press Enter to add to watchlist", - Section = "Search results", - }; - } - - 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 async Task LoadQuotes() - { - _quotes = [.. (await _repository.GetQuotesAsync(Watchlist.Instruments())).Select(UiQuote.From)]; - IsLoading = false; - RaiseItemsChanged(0); - } - - // 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 RefreshCommand : InvokableCommand - { - private readonly MarketsPage _page; - - public RefreshCommand(MarketsPage page) - { - _page = page; - Name = "Refresh"; - } - - public override ICommandResult Invoke() - { - _page.RefreshQuotes(); - return CommandResult.KeepOpen(); - } - } - - private sealed partial class RunSearchCommand : InvokableCommand - { - private readonly MarketsPage _page; - - public RunSearchCommand(MarketsPage page) - { - _page = page; - Name = "Search"; - Icon = new IconInfo(SearchGlyph); - } - - public override ICommandResult Invoke() - { - _page.RunSearch(); - return CommandResult.KeepOpen(); - } - } -} diff --git a/MarketExtension/Pages/PricedListPage.cs b/MarketExtension/Pages/PricedListPage.cs new file mode 100644 index 0000000..f89c537 --- /dev/null +++ b/MarketExtension/Pages/PricedListPage.cs @@ -0,0 +1,102 @@ +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; + +namespace MarketExtension; + +// Shared base for the two priced list screens (Watchlist, Favorites). Each prices a different +// WatchlistStore subset but otherwise behaves identically: async-load the quotes, render rows, +// re-list on load and on every mutation. Houses the project's INotifyItemsChanged on-load refresh +// hook (see CLAUDE.md) so the list re-prices every time the user navigates to the screen. +// +// 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; + protected UiQuote[]? Quotes; + + private event TypedEventHandler? _itemsChanged; + + event TypedEventHandler INotifyItemsChanged.ItemsChanged + { + add { _itemsChanged += value; RefreshQuotes(); } // fires every time the user navigates here + remove => _itemsChanged -= value; + } + + protected new void RaiseItemsChanged(int totalItems = -1) + => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); + + protected PricedListPage(MarketRepository repository) + { + Repository = repository; + Icon = new IconInfo("https://github.com/favicon.ico"); + } + + // The store subset this screen prices. + protected abstract IReadOnlyList InstrumentsToPrice(); + + // Render one priced row. + protected abstract IListItem BuildRow(UiQuote quote); + + // Shown when the underlying set is empty. + protected abstract IListItem[] EmptyState(); + + public override void UpdateSearchText(string oldSearch, string newSearch) + => RaiseItemsChanged(0); + + public override IListItem[] GetItems() + { + if (Quotes is null) + return []; + + if (Quotes.Length == 0) + return EmptyState(); + + var rows = Quotes.Where(Matches).Select(BuildRow).ToList(); + rows.Add(new ListItem(new RefreshCommand(this)) { Title = "Refresh 🔄" }); + 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); + + internal void RefreshQuotes() + { + Quotes = null; + IsLoading = true; + RaiseItemsChanged(0); + Task.Run(LoadQuotes); + } + + private async Task LoadQuotes() + { + Quotes = [.. (await Repository.GetQuotesAsync(InstrumentsToPrice())).Select(UiQuote.From)]; + IsLoading = false; + RaiseItemsChanged(0); + } + + private sealed partial class RefreshCommand : InvokableCommand + { + private readonly PricedListPage _page; + + public RefreshCommand(PricedListPage page) + { + _page = page; + Name = "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..eb491c2 --- /dev/null +++ b/MarketExtension/Pages/SearchPage.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; + +namespace MarketExtension; + +// Default entry screen: the Enter-only Finnhub /search flow. Typing never hits the network +// (free-tier rate limits) — it only changes what the synthetic "Search Finnhub for ..." item will +// look up. Results are price-less identities; Enter adds the instrument to the watchlist, Ctrl+Enter +// (the first MoreCommands context item) adds it to favorites. The two lists are independent — a +// result can go on either, both, or neither. Lifted from the search half of the old MarketsPage. +// +// Uses the project's INotifyItemsChanged fire-on-subscribe hook (see CLAUDE.md) so the async +// /search completion and the post-add re-lists reach the framework's listener. +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; + + event TypedEventHandler INotifyItemsChanged.ItemsChanged + { + add { _itemsChanged += value; _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); } + remove => _itemsChanged -= value; + } + + protected 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 = new IconInfo("https://github.com/favicon.ico"); + Title = "Markets Search"; + Name = "Search"; + PlaceholderText = "Type a symbol or name and press Enter to search..."; + } + + // 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() => + [ + new ListItem(_watchlistPage) + { + Title = "Watchlist", + Subtitle = "The instruments you track", + Icon = new IconInfo(ListGlyph), + }, + new ListItem(_favoritesPage) + { + Title = "Favorites", + Subtitle = "Your starred instruments — shown on the dock", + Icon = new IconInfo(StarFillGlyph), + }, + ]; + + // 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 = $"Search Finnhub for \"{SearchText}\"", + Subtitle = "Press Enter to look up symbols online", + 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 = $"No matches for \"{SearchText}\"", + Section = "Search results", + }); + } + + foreach (var instrument in _searchResults) + items.Add(BuildResultItem(instrument)); + } + + return [.. items]; + } + + // A price-less search result. Enter adds to the watchlist; Ctrl+Enter adds to favorites. The + // subtitle reflects current membership so the available actions are obvious. + private ListItem BuildResultItem(DomainInstrument instrument) + { + var inWatchlist = WatchlistStore.Instance.IsInWatchlist(instrument.Symbol); + var isFavorite = WatchlistStore.Instance.IsFavorite(instrument.Symbol); + + return new ListItem(new AddToWatchlistCommand(instrument, () => RaiseItemsChanged(0))) + { + Title = (isFavorite ? "★ " : "") + $"{instrument.Symbol} · {instrument.Name}", + Subtitle = MembershipSubtitle(inWatchlist, isFavorite), + Section = "Search results", + MoreCommands = + [ + new CommandContextItem(new AddToFavoritesCommand(instrument, () => RaiseItemsChanged(0))) + { + Title = "Add to Favorites", + }, + ], + }; + } + + private static string MembershipSubtitle(bool inWatchlist, bool isFavorite) => (inWatchlist, isFavorite) switch + { + (true, true) => "On watchlist · ★ Favorite", + (true, false) => "On watchlist · Ctrl+Enter to favorite", + (false, true) => "★ Favorite · Enter to add to watchlist", + (false, false) => "Enter to add to watchlist · Ctrl+Enter to favorite", + }; + + // 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 = "Search"; + Icon = new IconInfo(SearchGlyph); + } + + public override ICommandResult Invoke() + { + _page.RunSearch(); + return CommandResult.KeepOpen(); + } + } +} diff --git a/MarketExtension/Pages/WatchlistPage.cs b/MarketExtension/Pages/WatchlistPage.cs new file mode 100644 index 0000000..c7821e8 --- /dev/null +++ b/MarketExtension/Pages/WatchlistPage.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +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 removes the row from the watchlist; Ctrl+Enter +// (the first MoreCommands context item) toggles its favorite mark so the user can curate the dock +// without going back to search. +internal sealed partial class WatchlistPage : PricedListPage +{ + public WatchlistPage(MarketRepository repository) : base(repository) + { + Title = "Markets Watchlist"; + Name = "Open"; + PlaceholderText = "Filter your watchlist..."; + } + + protected override IReadOnlyList InstrumentsToPrice() + => WatchlistStore.Instance.Watchlist; + + 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 RemoveFromWatchlistCommand(instrument, () => RaiseItemsChanged(0))) + { + Title = (isFavorite ? "★ " : "") + $"{q.Symbol} · {q.Name}", + Subtitle = $"{q.FormatPrice()} {q.FormatChange()}", + Section = SectionLabel(q.Category), + MoreCommands = + [ + new CommandContextItem(new ToggleFavoriteCommand(instrument, () => RaiseItemsChanged(0))) + { + Title = isFavorite ? "Remove from Favorites" : "Add to Favorites", + }, + new CommandContextItem(new CopyTextCommand(q.FormatPrice())) { Title = "Copy price" }, + ], + }; + } + + protected override IListItem[] EmptyState() => + [ + new ListItem(new NoOpCommand()) + { + Title = "Your watchlist is empty", + Subtitle = "Use Markets Search to look up instruments, then press Enter to add them", + }, + ]; + + private static string SectionLabel(AssetCategory category) => category switch + { + AssetCategory.Stock => "Stocks", + AssetCategory.Crypto => "Crypto", + AssetCategory.Currency => "Currency", + _ => "Other", + }; +} diff --git a/MarketExtension/Settings/FavoritesStore.cs b/MarketExtension/Settings/FavoritesStore.cs deleted file mode 100644 index 7344cd9..0000000 --- a/MarketExtension/Settings/FavoritesStore.cs +++ /dev/null @@ -1,125 +0,0 @@ -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 pinned instruments, persisted under the CmdPal settings folder so the -// user's watchlist survives reloads. Adapted from reference/settings/FavoritesStore.cs. -// -// 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. Composed with -// the catalog into the live watchlist by Watchlist.Instruments(). -internal sealed class FavoritesStore -{ - public static readonly FavoritesStore Instance = new(); - - private readonly Lock _lock = new(); - // Keyed by normalized symbol; value carries the identity needed to re-fetch a quote. - private Dictionary _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.ContainsKey(Normalize(symbol)); - } - - // The pinned instruments, in insertion order, as provider-agnostic identities. - public IReadOnlyList Pinned - { - get { lock (_lock) return [.. _favorites.Values]; } - } - - // Pin if absent, unpin if present. Keyed on symbol; the full identity is stored on add so the - // instrument can be re-priced later. - public void Toggle(DomainInstrument instrument) - { - var key = Normalize(instrument.Symbol); - lock (_lock) - { - if (!_favorites.Remove(key)) - _favorites[key] = instrument; - } - - Save(); - } - - private static string Normalize(string symbol) => symbol.Trim().ToUpperInvariant(); - - private void Load() - { - try - { - if (!File.Exists(JsonPath)) - return; - - var json = File.ReadAllText(JsonPath); - - // Current format: array of PinnedItem objects. - var items = JsonSerializer.Deserialize(json, FavoritesJsonContext.Default.ListPinnedItem); - if (items is { Count: > 0 }) - { - _favorites = items.ToDictionary( - i => Normalize(i.Symbol), - i => new DomainInstrument(i.Symbol, i.Name, i.Category)); - return; - } - - // Legacy format: array of bare symbol strings. Recover name/category from the catalog - // where possible (so e.g. crypto favorites keep their category), defaulting to Stock. - var symbols = JsonSerializer.Deserialize(json, FavoritesJsonContext.Default.ListString); - if (symbols is { Count: > 0 }) - _favorites = symbols.ToDictionary(Normalize, ToInstrument); - } - catch - { - _favorites = []; - } - } - - private static DomainInstrument ToInstrument(string symbol) - { - var key = Normalize(symbol); - var known = InstrumentCatalog.All.FirstOrDefault(i => Normalize(i.Symbol) == key); - return known ?? new DomainInstrument(symbol, symbol, AssetCategory.Stock); - } - - private void Save() - { - try - { - List snapshot; - lock (_lock) - snapshot = [.. _favorites.Values.Select(i => new PinnedItem(i.Symbol, i.Name, i.Category))]; - File.WriteAllText(JsonPath, JsonSerializer.Serialize(snapshot, FavoritesJsonContext.Default.ListPinnedItem)); - } - catch - { - // best-effort - } - } -} - -// Persistence DTO for a pinned instrument. Source-gen (de)serialized so the trimmed Release build -// stays clean (reflection JSON here was the IL2026/IL3050 debt flagged in CLAUDE.md). -internal sealed record PinnedItem(string Symbol, string Name, AssetCategory Category); - -[JsonSourceGenerationOptions(UseStringEnumConverter = true)] // category as readable, reorder-safe text -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(List))] // legacy format migration -internal sealed partial class FavoritesJsonContext : JsonSerializerContext; diff --git a/MarketExtension/Settings/WatchlistStore.cs b/MarketExtension/Settings/WatchlistStore.cs new file mode 100644 index 0000000..8bec644 --- /dev/null +++ b/MarketExtension/Settings/WatchlistStore.cs @@ -0,0 +1,188 @@ +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 = []; + + 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(); + + 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; + } + + // The watchlisted instruments, in insertion order, as provider-agnostic identities. + public IReadOnlyList Watchlist + { + get { lock (_lock) return [.. _entries.Values.Where(e => e.InWatchlist).Select(e => e.Instrument)]; } + } + + // The favorited instruments (the dock subset), in insertion order. + public IReadOnlyList Favorites + { + get { lock (_lock) return [.. _entries.Values.Where(e => e.IsFavorite).Select(e => e.Instrument)]; } + } + + 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(); + } + + 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(); + } + + // 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)); + } + + private 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. + _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; From a83a8509a457d38db10f205d47ba8aaf4ac74f16 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 20 Jun 2026 21:49:05 +0300 Subject: [PATCH 07/69] wip --- CLAUDE.md | 67 +++++++++++++++++-- .../Commands/MembershipCommands.cs | 47 +++++++++---- 2 files changed, 97 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9b2b7e4..27b2d52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,9 @@ 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()` +- 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: `new IconInfo("https://github.com/favicon.ico")` per project preference ## Market Data Architecture (the core of this app) @@ -129,8 +131,11 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). watchlist + favorites** as two independent flags (`WatchlistStore`); and the **three-screen UX** (Markets Search / Watchlist / Favorites — see below). ⚠️ Working tree is **uncommitted** (on `repo`). -- **Deferred / next:** FX provider (keyless Frankfurter); settings UI for bring-your-own-key; dock - live-polling timer; rate-limit (429) + richer error UX; crypto/FX in symbol search. +- **Next up:** **live price polling** — auto-refresh prices on a timer while a surface is visible, + **default 60 s, configurable in settings**; and **pushing dock refreshes when favorites change** so a + pinned band updates immediately instead of going stale (both designed below). +- **Deferred:** FX provider (keyless Frankfurter); settings UI for bring-your-own-key; rate-limit (429) + + richer error UX; crypto/FX in symbol search. ### Three-screen UX (done) @@ -146,12 +151,64 @@ namespace) when searching the Command Palette root: 2. **Markets Watchlist** (`Pages/WatchlistPage.cs`) — the tracked instruments, priced and grouped by class; a ★ marks favorites. **Enter** → remove from watchlist; **Ctrl+Enter** → toggle favorite. 3. **Markets Favorites** (`Pages/FavoritesPage.cs`) — the curated subset; **only favorites render in - the dock band** (`FavoritesDockPage` prices `WatchlistStore.Favorites`). **Enter** → remove from favorites. + the dock band** (`FavoritesDockPage` prices `WatchlistStore.Favorites`). **Enter** → remove from + favorites. (Caveat: a pinned band only re-reads when it reopens — pushing live refreshes on change + is a next step, see below.) Watchlist + Favorites share `Pages/PricedListPage.cs` (async price + the on-load `INotifyItemsChanged` refresh + local filter). 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`). +(`AddToWatchlist`/`RemoveFromWatchlist`/`AddToFavorites`/`RemoveFromFavorites`/`ToggleFavorite`) — each +**shows a confirmation toast** (e.g. "Added AAPL to watchlist") and **keeps the palette open** so the +user gets explicit feedback and can keep editing (the silent re-list alone was too ambiguous). + +### Live price polling (next — designed, not built) + +Today every priced surface fetches **once** when it becomes visible (the on-load `INotifyItemsChanged` +refresh). The next iteration auto-refreshes on a timer while a surface is visible: **default 60 s, +configurable in settings** (including an "Off" choice). + +- **Where:** `Pages/PricedListPage.cs` (covers Markets Watchlist + Markets Favorites) and + `Pages/FavoritesDockPage.cs`. `SearchPage` is exempt — its results are identity-only, no prices. +- **Lifecycle = the event we already have.** The `INotifyItemsChanged.ItemsChanged` `add`/`remove` + accessors are de-facto `Loaded()`/`Unloaded()` hooks (CmdPal subscribes when a surface is visible, + unsubscribes when hidden — see `reference/dock-support.md`). Today `add` does the one-shot fetch; + extend it to also start a poll loop and stop that loop in `remove`. This is the + `PerformanceWidgetsPage` pattern: Loaded → start timer; tick → re-price + `RaiseItemsChanged()`; + Unloaded → cancel. Guard a shared source with a refcount if a page and the dock can be live at once. +- **Mechanism:** a `PeriodicTimer` in an async loop guarded by a `CancellationTokenSource` created in + `add` and cancelled/disposed in `remove`. Re-read the interval from settings each tick (so changes + apply without a reload) and **skip a tick if the previous fetch is still in flight** (no overlapping + calls). The on-load fetch stays as the immediate first paint; the timer drives the rest. +- **Settings:** add `Settings/MarketSettingsManager.cs` — a `JsonSettingsManager` singleton modeled on + `reference/settings/AdbSettingsManager.cs` (`FilePath` = `…/market.settings.json`, `Settings.Add(...)`, + `LoadSettings()`, `Settings.SettingsChanged += SaveSettings`). Expose a refresh-interval setting (a + `ChoiceSetSetting` of presets — `Off / 30 s / 60 s / 5 m` — or a numeric `TextSetting` defaulting to + `60`). Wire it in `MarketExtensionCommandsProvider` via `Settings = MarketSettingsManager.Instance.Settings;` + (the commented-out hook is already there). The poll loop reads `MarketSettingsManager.Instance.RefreshIntervalSeconds`. +- ⚠️ **Rate-limit tension (design around this):** Finnhub free tier is **~60 calls/min AND ~300/day**, + and `GetQuotesAsync` issues **one `/quote` call per instrument**. Polling N instruments every 60 s = N + calls/min — e.g. 6 favorites = 360 calls/hour, which **exhausts the ~300/day budget in under an hour**. + 60 s is fine for short sessions but not sustained use. Mitigations to ship alongside: the configurable + interval + an **Off** escape, polling **only while visible** (the lifecycle guarantees this), and real + **429 handling** (back off; surface a "rate-limited" state rather than blanking prices). A + batched-quote provider or cheaper data source would relax this later. + +### Dock refresh on favorites change (next — designed, not built) + +`FavoritesDockPage` only re-reads favorites + prices when it becomes **visible** (its on-load +`INotifyItemsChanged` hook). So adding/removing a favorite from a palette page does **not** update a +band that's already pinned and showing — it stays stale until the dock re-opens (or, once polling lands, +until the next tick). To update it the instant a favorite changes: + +- Make `WatchlistStore` **observable**: raise a `FavoritesChanged` event whenever a favorite flag flips + (`AddToFavorites` / `RemoveFromFavorites`, and the first-run migration). Keep it lightweight (no args — + listeners just re-read `Favorites`). +- `FavoritesDockPage` **subscribes in its `add` accessor and unsubscribes in `remove`** (the same + Loaded/Unloaded lifecycle as polling), calling `RefreshQuotes()` on the event. Subscribing only while + visible keeps a hidden band from doing work and avoids leaks. +- Complementary to polling, not redundant: polling catches *price* drift on a timer; this pushes + *membership* changes immediately, and still works when polling is **Off**. ## CommandPalette Toolkit — Quick Reference diff --git a/MarketExtension/Commands/MembershipCommands.cs b/MarketExtension/Commands/MembershipCommands.cs index 4861fc8..6bf69c2 100644 --- a/MarketExtension/Commands/MembershipCommands.cs +++ b/MarketExtension/Commands/MembershipCommands.cs @@ -6,8 +6,9 @@ namespace MarketExtension; // The watchlist/favorites membership actions surfaced on list rows. Each one mutates WatchlistStore // for one instrument, then asks the host page to re-list so the row reflects its new membership (or -// drops out of the list). KeepOpen keeps the user on the page so they can act on several rows in a -// row — matching the project's star-in-place UX. Adapted from reference/commands/ToggleFavoriteCommand.cs. +// drops out of the list). It also pops a confirmation toast ("Added AAPL to watchlist") and keeps the +// palette open: the silent re-list alone left users unsure the action took, and KeepOpen lets them act +// on several rows in a row. Adapted from reference/commands/ToggleFavoriteCommand.cs. internal abstract partial class MembershipCommand : InvokableCommand { // Segoe MDL2 glyphs: Add, Delete, FavoriteStar (outline), FavoriteStarFill. @@ -25,13 +26,15 @@ protected MembershipCommand(DomainInstrument instrument, Action refresh) _refresh = refresh; } - protected abstract void Apply(); + // 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() { - Apply(); + var message = Apply(); _refresh(); - return CommandResult.KeepOpen(); + return CommandResult.ShowToast(new ToastArgs { Message = message, Result = CommandResult.KeepOpen() }); } } @@ -43,7 +46,11 @@ public AddToWatchlistCommand(DomainInstrument instrument, Action refresh) : base Icon = new IconInfo(AddGlyph); } - protected override void Apply() => WatchlistStore.Instance.AddToWatchlist(Instrument); + protected override string Apply() + { + WatchlistStore.Instance.AddToWatchlist(Instrument); + return $"Added {Instrument.Symbol} to watchlist"; + } } internal sealed partial class RemoveFromWatchlistCommand : MembershipCommand @@ -54,7 +61,11 @@ public RemoveFromWatchlistCommand(DomainInstrument instrument, Action refresh) : Icon = new IconInfo(DeleteGlyph); } - protected override void Apply() => WatchlistStore.Instance.RemoveFromWatchlist(Instrument.Symbol); + protected override string Apply() + { + WatchlistStore.Instance.RemoveFromWatchlist(Instrument.Symbol); + return $"Removed {Instrument.Symbol} from watchlist"; + } } internal sealed partial class AddToFavoritesCommand : MembershipCommand @@ -65,7 +76,11 @@ public AddToFavoritesCommand(DomainInstrument instrument, Action refresh) : base Icon = new IconInfo(StarGlyph); } - protected override void Apply() => WatchlistStore.Instance.AddToFavorites(Instrument); + protected override string Apply() + { + WatchlistStore.Instance.AddToFavorites(Instrument); + return $"Added {Instrument.Symbol} to favorites"; + } } internal sealed partial class RemoveFromFavoritesCommand : MembershipCommand @@ -76,7 +91,11 @@ public RemoveFromFavoritesCommand(DomainInstrument instrument, Action refresh) : Icon = new IconInfo(StarFillGlyph); } - protected override void Apply() => WatchlistStore.Instance.RemoveFromFavorites(Instrument.Symbol); + protected override string Apply() + { + WatchlistStore.Instance.RemoveFromFavorites(Instrument.Symbol); + return $"Removed {Instrument.Symbol} from favorites"; + } } // Star/unstar in place — used as the Ctrl+Enter secondary on the Watchlist screen so the user can @@ -90,11 +109,15 @@ public ToggleFavoriteCommand(DomainInstrument instrument, Action refresh) : base Icon = new IconInfo(isFavorite ? StarFillGlyph : StarGlyph); } - protected override void Apply() + protected override string Apply() { if (WatchlistStore.Instance.IsFavorite(Instrument.Symbol)) + { WatchlistStore.Instance.RemoveFromFavorites(Instrument.Symbol); - else - WatchlistStore.Instance.AddToFavorites(Instrument); + return $"Removed {Instrument.Symbol} from favorites"; + } + + WatchlistStore.Instance.AddToFavorites(Instrument); + return $"Added {Instrument.Symbol} to favorites"; } } From b434c152428d12c22aba6db9ff31076d9557ee9f Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 20 Jun 2026 21:56:06 +0300 Subject: [PATCH 08/69] wip --- CLAUDE.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 27b2d52..669ae13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,6 +136,8 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). pinned band updates immediately instead of going stale (both designed below). - **Deferred:** FX provider (keyless Frankfurter); settings UI for bring-your-own-key; rate-limit (429) + richer error UX; crypto/FX in symbol search. +- **Wishlist:** a **portfolio** screen + its own dock band — holdings with total market value and daily + P&L (design below). ### Three-screen UX (done) @@ -210,6 +212,43 @@ until the next tick). To update it the instant a favorite changes: - Complementary to polling, not redundant: polling catches *price* drift on a timer; this pushes *membership* changes immediately, and still works when polling is **Off**. +### Portfolio screen + dock band (future wishlist) + +A fourth top-level screen (**Markets Portfolio**) plus its own dock band, tracking actual *holdings* +(not just symbols): total market value and **daily P&L**. Reuses the existing data seam with **no +provider changes** — `MarketRepository.GetQuotesAsync` already returns per-instrument daily change. + +**Data model** (keep the `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** (+ optional **cost basis** for total/unrealized P&L later); quantity is `decimal` so + crypto fractions work. +- `Models/DomainPosition.cs` — a holding's provider-agnostic identity (`DomainInstrument` + quantity + + optional cost basis), **no prices**. +- `Models/UiPosition.cs` + `UiPortfolio.cs` — presentation projection: combine a `DomainQuote` with the + quantity to expose `MarketValue` (qty × price), `DailyPnL` (qty × `DomainQuote.Change`), + `DailyPnLPercent`, and the `Format*()` helpers (the only place formatting lives, like `UiQuote`); + `UiPortfolio` rolls up the totals. + +**Daily P&L:** per position = `Quantity × DomainQuote.Change` (Finnhub `/quote` `d` = today's per-unit +change, `dp` = its percent). Portfolio total value = Σ market value; total daily P&L = Σ daily P&L; the +aggregate **percent is `totalDailyPnL / (totalValue − totalDailyPnL)`** (vs. yesterday's close) — not an +average of the per-position percents. + +**Screen** (`Pages/PortfolioPage.cs`): reuse `PricedListPage`'s async price/refresh plumbing (price the +holdings via the repository, zip with quantities). Render a **totals summary** as the first item +("Portfolio $12,345.67 ▲ +$120.50 (+0.98%)") then one row per holding ("AAPL · 10 sh" → value + daily +P&L). Editing needs a quantity-input affordance the palette doesn't give for free — add via search → a +small "set quantity" form/content page; Enter on a row to edit/remove. + +**Dock band** (`Pages/PortfolioDockPage.cs`): a second band registered in `GetDockBands()` next to the +favorites band, showing the one-line portfolio summary (total value + daily P&L, green/red). Needs its +own **non-empty `Command.Id`** (`com.costafotiadis.market.dock.portfolio` — see `reference/dock-support.md`) +and inherits the same live-refresh story as the favorites band (the polling + push-on-change work above). + +**Caveats:** assumes a single quote currency (USD); mixed-currency holdings need FX conversion (deferred — +ties to the FX-provider item). Exclude `IsValid:false` quotes from totals (or show them as unpriced). + ## CommandPalette Toolkit — Quick Reference ### Navigation From 38f99193dca3b481f27f6c73196c6b26f3e12fef Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 20 Jun 2026 22:02:46 +0300 Subject: [PATCH 09/69] wip --- CLAUDE.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 669ae13..36e91f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,8 +136,9 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). pinned band updates immediately instead of going stale (both designed below). - **Deferred:** FX provider (keyless Frankfurter); settings UI for bring-your-own-key; rate-limit (429) + richer error UX; crypto/FX in symbol search. -- **Wishlist:** a **portfolio** screen + its own dock band — holdings with total market value and daily - P&L (design below). +- **Wishlist:** a **portfolio** screen + its own dock band (holdings, total value, daily P&L); and a + **per-symbol detail screen + live chart for any ticker** — drill-in from any list row and clickable + from any dock item (Performance-Monitor-style). Both designed below. ### Three-screen UX (done) @@ -239,7 +240,8 @@ average of the per-position percents. holdings via the repository, zip with quantities). Render a **totals summary** as the first item ("Portfolio $12,345.67 ▲ +$120.50 (+0.98%)") then one row per holding ("AAPL · 10 sh" → value + daily P&L). Editing needs a quantity-input affordance the palette doesn't give for free — add via search → a -small "set quantity" form/content page; Enter on a row to edit/remove. +small "set quantity" form/content page; Enter on a row to edit/remove. Like every other list, a row also +opens the **shared symbol detail + chart** (below) — here via a context item, since Enter is taken by edit. **Dock band** (`Pages/PortfolioDockPage.cs`): a second band registered in `GetDockBands()` next to the favorites band, showing the one-line portfolio summary (total value + daily P&L, green/red). Needs its @@ -249,6 +251,58 @@ and inherits the same live-refresh story as the favorites band (the polling + pu **Caveats:** assumes a single quote currency (USD); mixed-currency holdings need FX conversion (deferred — ties to the FX-provider item). Exclude `IsValid:false` quotes from totals (or show them as unpriced). +### Symbol detail + live chart — for ANY instrument (future wishlist) + +**App-wide, not portfolio-specific.** Every instrument — from search results, the watchlist, favorites, +**and** portfolio rows, plus **every dock band** — should open a per-symbol **detail screen with a small +live line chart** of its price (the way Performance Monitor's bands open a CPU/RAM graph). **One** shared +`SymbolDetailPage` backs both the **drill-in (Enter, or a context item, on any row)** and the +**dock-item click**. Investigated against its on-disk source +(`C:\Users\jarla\code\PowerToys\src\modules\cmdpal\ext\Microsoft.CmdPal.Ext.PerformanceMonitor\`) — the +mechanism is simpler than it looks: + +**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** → AOT/trim-safe (this project requires that — see the AOT section). 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. + +**For market data specifically:** +- Data source = **sample the price into a rolling per-symbol buffer each poll tick** (exactly Perf + Monitor's model — it samples a live metric, it does not fetch history). So this **depends on the live + polling work above**; a paid `/stock/candle` (premium-gated on the free tier) could backfill real history + later. Keep the buffer where the cache/sampler lives (repository-side). +- Add **one** `Pages/SymbolDetailPage.cs` (`IContentPage`) that renders the SVG sparkline (plus + open/high/low/prev-close, already in `/quote`) for any `DomainInstrument`, recolored green/red by day + direction. Wire **every** surface to it: **Enter or a context item on rows** in Search, Watchlist, + Favorites, and Portfolio, **and the click target of every dock button** (favorites dock + portfolio + dock). It is the single symbol-detail screen, shared everywhere — explicitly not a portfolio feature. +- Port `ChartHelper` mostly as-is (already pure-string SVG); feed it our `UiQuote`/price series. + ## CommandPalette Toolkit — Quick Reference ### Navigation From 8fea6b660d93f162910189775ad2fb1ee922955e Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 20 Jun 2026 22:07:15 +0300 Subject: [PATCH 10/69] wip --- CLAUDE.md | 45 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 36e91f3..02ed402 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,9 +136,10 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). pinned band updates immediately instead of going stale (both designed below). - **Deferred:** FX provider (keyless Frankfurter); settings UI for bring-your-own-key; rate-limit (429) + richer error UX; crypto/FX in symbol search. -- **Wishlist:** a **portfolio** screen + its own dock band (holdings, total value, daily P&L); and a - **per-symbol detail screen + live chart for any ticker** — drill-in from any list row and clickable - from any dock item (Performance-Monitor-style). Both designed below. +- **Wishlist:** a **portfolio** screen + its own dock band (holdings, total value, daily P&L); a + **per-symbol detail screen + live chart for any ticker** (drill-in from any list row, clickable from any + dock item); and **official asset logos as icons app-wide** (replacing today's generic copy/glyph icons). + All designed below. ### Three-screen UX (done) @@ -303,6 +304,44 @@ mechanism is simpler than it looks: dock). It is the single symbol-detail screen, shared everywhere — explicitly not a portfolio feature. - Port `ChartHelper` mostly as-is (already pure-string SVG); feed it our `UiQuote`/price series. +### Asset logos as icons, app-wide (future wishlist) + +Every row and dock button should show the instrument's **official logo** (AAPL's apple, BABA's logo, +BTC's coin) instead of today's generic icon. Right now the dock items use `CopyTextCommand`, so they +render its **copy glyph** — wrong on both counts: the icon should be the asset logo, and a **left-click +should open the chart** (the symbol-detail page above), not copy. + +**Where to get logos:** +- **Stocks / ETFs — Finnhub `/stock/profile2`** (free tier; reuses our existing key). Returns a hosted + `logo` URL (plus `weburl`, `name`, …) — docs: . One call + per symbol, and **logos are immutable, so cache the URL to disk forever and fetch each symbol at most + once** (profile2 counts against the ~60/min, ~300/day budget). Fallback: Clearbit + `https://logo.clearbit.com/{weburl-domain}` (caveat: Clearbit is now HubSpot — verify it still serves). +- **Crypto — CoinGecko** (keyless): `image` URLs from `/coins/markets?vs_currency=usd&symbols=btc`, or + **bundle a static set** (e.g. `spothq/cryptocurrency-icons` SVG/PNG by symbol) under `Assets/` for an + offline, no-network, AOT-friendly path. +- **Forex / currency:** deferred (country flags or a currency glyph when FX lands). +- **Fallback (never the copy icon):** a per-`AssetCategory` Segoe glyph or a first-letter monogram when + no logo resolves. + +**How to load it** (the toolkit already supports all three): +- Remote URL → `new IconInfo(logoUrl)` (same as today's github favicon). +- Bundled asset → `IconHelpers.FromRelativePath("Assets\\crypto\\btc.png")`. +- Inline → a `data:` URI (same trick as the chart SVG). + +**Architecture:** the logo source is provider-specific, so it fits the existing seam — add an optional +`Task GetLogoUrlAsync(DomainInstrument)` to `IMarketDataProvider` (default `null`), routed by +`MarketRepository` exactly like quotes (Finnhub serves stocks; a crypto provider serves coins). Wrap it in +a shared, **disk-persisted** `Helpers/AssetIconResolver.cs` (symbol → `IconInfo`, cached forever). Since +`GetItems()` is synchronous, resolve logo URLs **as part of the async price load** (set `ListItem.Icon` +once known), or render a fallback glyph first and `RaiseItemsChanged()` when the logo arrives; after first +run it's served from cache instantly. + +**Wire it everywhere:** set `ListItem.Icon` to the resolved logo in every row builder — `SearchPage`, +`WatchlistPage`, `FavoritesPage`, the future `PortfolioPage` — and on the dock items. For the dock, **also +swap the command from `CopyTextCommand` to the `SymbolDetailPage`** so left-click opens the chart (keep +"Copy price" as a `MoreCommands` context item). Dock bands still require a non-empty `Command.Id`. + ## CommandPalette Toolkit — Quick Reference ### Navigation From bad5dc2b7559635c2aac04be1e1db960bcf37f15 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Mon, 22 Jun 2026 01:56:23 +0300 Subject: [PATCH 11/69] wip --- MarketExtension/Pages/FavoritesDockPage.cs | 18 ++++++++++++++++-- MarketExtension/Settings/WatchlistStore.cs | 9 +++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/MarketExtension/Pages/FavoritesDockPage.cs b/MarketExtension/Pages/FavoritesDockPage.cs index 0128a00..5169822 100644 --- a/MarketExtension/Pages/FavoritesDockPage.cs +++ b/MarketExtension/Pages/FavoritesDockPage.cs @@ -26,10 +26,24 @@ internal sealed partial class FavoritesDockPage : ListPage, INotifyItemsChanged event TypedEventHandler INotifyItemsChanged.ItemsChanged { - add { _itemsChanged += value; RefreshQuotes(); } // fires when the band becomes visible - remove => _itemsChanged -= value; + add + { + _itemsChanged += value; + // Subscribe idempotently so starring/unstarring from a palette page refreshes this + // pinned band at once, instead of waiting for it to be reopened. + WatchlistStore.Instance.FavoritesChanged -= OnFavoritesChanged; + WatchlistStore.Instance.FavoritesChanged += OnFavoritesChanged; + RefreshQuotes(); // fires when the band becomes visible + } + remove + { + _itemsChanged -= value; + WatchlistStore.Instance.FavoritesChanged -= OnFavoritesChanged; + } } + private void OnFavoritesChanged() => RefreshQuotes(); + protected new void RaiseItemsChanged(int totalItems = -1) => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); diff --git a/MarketExtension/Settings/WatchlistStore.cs b/MarketExtension/Settings/WatchlistStore.cs index 8bec644..9f043eb 100644 --- a/MarketExtension/Settings/WatchlistStore.cs +++ b/MarketExtension/Settings/WatchlistStore.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -20,6 +21,10 @@ internal sealed class WatchlistStore { public static readonly WatchlistStore Instance = new(); + // Raised after a favorite flag flips (and is persisted). Args-less — listeners just re-read Favorites. + // Lets the pinned dock band refresh immediately instead of going stale until reopened. + public event Action? FavoritesChanged; + private readonly Lock _lock = new(); // Keyed by normalized symbol; value carries the identity plus the two membership flags. private Dictionary _entries = []; @@ -82,6 +87,8 @@ private void SetFlag(string symbol, bool? watchlist = null, bool? favorite = nul } Save(); + // Fire outside the lock: the dock's handler re-reads Favorites, which re-acquires it. + if (favorite is not null) FavoritesChanged?.Invoke(); } private void SetFlag(DomainInstrument instrument, bool? watchlist = null, bool? favorite = null) @@ -95,6 +102,7 @@ private void SetFlag(DomainInstrument instrument, bool? watchlist = null, bool? } Save(); + if (favorite is not null) FavoritesChanged?.Invoke(); } // Apply a flag change, then drop the entry entirely once it's on neither list. @@ -131,6 +139,7 @@ private void Load() // 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. + // No FavoritesChanged fires here: this runs in the singleton's ctor, before any subscriber. _entries = SeedEntries(); Save(); } From 987e835a370d21e9cecbcb1aa743a667f48744de Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Mon, 22 Jun 2026 02:47:23 +0300 Subject: [PATCH 12/69] wip --- CLAUDE.md | 86 ++++++----- .../Commands/MembershipCommands.cs | 30 ++-- MarketExtension/Helpers/StateFlow.cs | 143 +++++++++++++++++ MarketExtension/Pages/FavoritesDockPage.cs | 20 +-- MarketExtension/Pages/FavoritesPage.cs | 7 +- MarketExtension/Pages/PricedListPage.cs | 144 +++++++++++++++--- MarketExtension/Pages/SearchPage.cs | 31 +++- MarketExtension/Pages/WatchlistPage.cs | 12 +- MarketExtension/Settings/WatchlistStore.cs | 64 +++++--- 9 files changed, 414 insertions(+), 123 deletions(-) create mode 100644 MarketExtension/Helpers/StateFlow.cs diff --git a/CLAUDE.md b/CLAUDE.md index 02ed402..c890cbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,8 @@ ApiFinnhubQuoteDto ─(provider maps)→ DomainQuote ─(repository routes+merge | `Data/Finnhub/FinnhubMarketDataProvider.cs` | Active provider (`Supports` Stock+Crypto). Maps `ApiFinnhubQuoteDto` → `DomainQuote`; `SearchAsync` calls `/search` (US equities only — see Finnhub specifics). | | `Data/MockMarketDataProvider.cs` | Offline fallback (`Supports` all). `SearchAsync` filters its seed keys. | | `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). Pages read its `Watchlist`/`Favorites` subsets directly. Replaces the old `FavoritesStore` + `Watchlist.cs`. | +| `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** (hand-rolled, no System.Reactive — keeps the AOT/trim build clean): `StateFlow` (read-only `Value` + `Subscribe` with **replay-on-subscribe** + distinct-until-changed) and writable `MutableStateFlow` (`Update`). Has **subscriber-count hooks** `OnActive`/`OnInactive` (0↔1 transitions = the WhileSubscribed / Rx `RefCount()` seam) for the future ticker poll loop. Plus `InstrumentListComparer` (symbol-sequence dedup for the store's two flows). | | `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. | | `Models/{AssetCategory,DomainInstrument,DomainQuote,UiQuote}.cs` | the model layers | @@ -128,12 +129,17 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). - **Done:** layered data architecture; live Finnhub provider; `MarketRepository` coordinator; `secrets.props` key handling; tagged logging; **Enter-only Finnhub `/search`**; **persistent - watchlist + favorites** as two independent flags (`WatchlistStore`); and the **three-screen UX** - (Markets Search / Watchlist / Favorites — see below). + 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`. ⚠️ Working tree is **uncommitted** (on `repo`). - **Next up:** **live price polling** — auto-refresh prices on a timer while a surface is visible, - **default 60 s, configurable in settings**; and **pushing dock refreshes when favorites change** so a - pinned band updates immediately instead of going stale (both designed below). + **default 60 s, configurable in settings**. The `StateFlow` subscriber-count hooks (`OnActive`/ + `OnInactive`) are already in place as the WhileSubscribed/`RefCount()` seam for a `PolledStateFlow` + (designed below). - **Deferred:** FX provider (keyless Frankfurter); settings UI for bring-your-own-key; rate-limit (429) + richer error UX; crypto/FX in symbol search. - **Wishlist:** a **portfolio** screen + its own dock band (holdings, total value, daily P&L); a @@ -155,35 +161,44 @@ namespace) when searching the Command Palette root: 2. **Markets Watchlist** (`Pages/WatchlistPage.cs`) — the tracked instruments, priced and grouped by class; a ★ marks favorites. **Enter** → remove from watchlist; **Ctrl+Enter** → toggle favorite. 3. **Markets Favorites** (`Pages/FavoritesPage.cs`) — the curated subset; **only favorites render in - the dock band** (`FavoritesDockPage` prices `WatchlistStore.Favorites`). **Enter** → remove from - favorites. (Caveat: a pinned band only re-reads when it reopens — pushing live refreshes on change - is a next step, see below.) - -Watchlist + Favorites share `Pages/PricedListPage.cs` (async price + the on-load `INotifyItemsChanged` -refresh + local filter). The catalog seeds the watchlist **once** on first run; thereafter every row -is user-removable. The membership actions live in `Commands/MembershipCommands.cs` + the dock band** (`FavoritesDockPage` subscribes to `WatchlistStore.Favorites`). **Enter** → remove + from favorites. 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 -**shows a confirmation toast** (e.g. "Added AAPL to watchlist") and **keeps the palette open** so the -user gets explicit feedback and can keep editing (the silent re-list alone was too ambiguous). +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 (next — designed, not built) -Today every priced surface fetches **once** when it becomes visible (the on-load `INotifyItemsChanged` -refresh). The next iteration auto-refreshes on a timer while a surface is visible: **default 60 s, -configurable in settings** (including an "Off" choice). +Today every priced surface fetches **once** when it becomes visible (the StateFlow replay-on-subscribe +that drives the first price load). The next iteration auto-refreshes on a timer while a surface is +visible: **default 60 s, configurable in settings** (including an "Off" choice). - **Where:** `Pages/PricedListPage.cs` (covers Markets Watchlist + Markets Favorites) and `Pages/FavoritesDockPage.cs`. `SearchPage` is exempt — its results are identity-only, no prices. -- **Lifecycle = the event we already have.** The `INotifyItemsChanged.ItemsChanged` `add`/`remove` - accessors are de-facto `Loaded()`/`Unloaded()` hooks (CmdPal subscribes when a surface is visible, - unsubscribes when hidden — see `reference/dock-support.md`). Today `add` does the one-shot fetch; - extend it to also start a poll loop and stop that loop in `remove`. This is the - `PerformanceWidgetsPage` pattern: Loaded → start timer; tick → re-price + `RaiseItemsChanged()`; - Unloaded → cancel. Guard a shared source with a refcount if a page and the dock can be live at once. +- **Lifecycle = the StateFlow subscriber-count seam (already built).** `Helpers/StateFlow.cs` already + fires `OnActive()` on the 0→1 subscriber transition and `OnInactive()` on 1→0 — Kotlin's + WhileSubscribed / Rx `RefCount()`. Subclass it as `PolledStateFlow : StateFlow` that **starts a + poll loop in `OnActive` and cancels it in `OnInactive`**, so polling runs only while something is + watching. (Surfaces already Subscribe/Dispose on the `INotifyItemsChanged` add/remove lifecycle, so + the refcount tracks visibility for free.) - **Mechanism:** a `PeriodicTimer` in an async loop guarded by a `CancellationTokenSource` created in - `add` and cancelled/disposed in `remove`. Re-read the interval from settings each tick (so changes - apply without a reload) and **skip a tick if the previous fetch is still in flight** (no overlapping - calls). The on-load fetch stays as the immediate first paint; the timer drives the rest. + `OnActive` and cancelled/disposed in `OnInactive`. Re-read the interval from settings each tick (so + changes apply without a reload); the loop is naturally non-overlapping (linear `await` per tick). An + immediate first fetch covers the initial paint; the timer drives the rest. +- **Shared-source refcount comes free.** Point both `FavoritesPage` and `FavoritesDockPage` at **one** + favorites `PolledStateFlow`: the refcount keeps it polling while *either* is visible and stops only + when *both* are gone — no manual coordination. (Refinements to add then: a small stop-timeout so + bouncing between pages doesn't thrash the loop, and a generation token guarding rapid resubscribe.) - **Settings:** add `Settings/MarketSettingsManager.cs` — a `JsonSettingsManager` singleton modeled on `reference/settings/AdbSettingsManager.cs` (`FilePath` = `…/market.settings.json`, `Settings.Add(...)`, `LoadSettings()`, `Settings.SettingsChanged += SaveSettings`). Expose a refresh-interval setting (a @@ -198,20 +213,17 @@ configurable in settings** (including an "Off" choice). **429 handling** (back off; surface a "rate-limited" state rather than blanking prices). A batched-quote provider or cheaper data source would relax this later. -### Dock refresh on favorites change (next — designed, not built) +### Dock refresh on favorites change (done) -`FavoritesDockPage` only re-reads favorites + prices when it becomes **visible** (its on-load -`INotifyItemsChanged` hook). So adding/removing a favorite from a palette page does **not** update a -band that's already pinned and showing — it stays stale until the dock re-opens (or, once polling lands, -until the next tick). To update it the instant a favorite changes: +A pinned `FavoritesDockPage` updates the instant a favorite changes anywhere, instead of going stale +until reopened. Implemented via the observable layer: -- Make `WatchlistStore` **observable**: raise a `FavoritesChanged` event whenever a favorite flag flips - (`AddToFavorites` / `RemoveFromFavorites`, and the first-run migration). Keep it lightweight (no args — - listeners just re-read `Favorites`). -- `FavoritesDockPage` **subscribes in its `add` accessor and unsubscribes in `remove`** (the same - Loaded/Unloaded lifecycle as polling), calling `RefreshQuotes()` on the event. Subscribing only while +- `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 polling, not redundant: polling catches *price* drift on a timer; this pushes +- 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 (future wishlist) diff --git a/MarketExtension/Commands/MembershipCommands.cs b/MarketExtension/Commands/MembershipCommands.cs index 6bf69c2..d2af6df 100644 --- a/MarketExtension/Commands/MembershipCommands.cs +++ b/MarketExtension/Commands/MembershipCommands.cs @@ -1,14 +1,14 @@ -using System; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; namespace MarketExtension; -// The watchlist/favorites membership actions surfaced on list rows. Each one mutates WatchlistStore -// for one instrument, then asks the host page to re-list so the row reflects its new membership (or -// drops out of the list). It also pops a confirmation toast ("Added AAPL to watchlist") and keeps the -// palette open: the silent re-list alone left users unsure the action took, and KeepOpen lets them act -// on several rows in a row. Adapted from reference/commands/ToggleFavoriteCommand.cs. +// The watchlist/favorites membership actions surfaced on list rows. Each one just mutates WatchlistStore +// for one instrument; the store re-publishes its observable subsets, so every live page and the dock +// re-list 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-list alone +// left users unsure the action took, and KeepOpen lets them act on several rows in a row. Adapted from +// reference/commands/ToggleFavoriteCommand.cs. internal abstract partial class MembershipCommand : InvokableCommand { // Segoe MDL2 glyphs: Add, Delete, FavoriteStar (outline), FavoriteStarFill. @@ -18,13 +18,8 @@ internal abstract partial class MembershipCommand : InvokableCommand protected const string StarFillGlyph = "\uE735"; protected readonly DomainInstrument Instrument; - private readonly Action _refresh; - protected MembershipCommand(DomainInstrument instrument, Action refresh) - { - Instrument = instrument; - _refresh = refresh; - } + protected MembershipCommand(DomainInstrument instrument) => Instrument = instrument; // Performs the mutation and returns the confirmation message to toast (so toggles can report // exactly what they did). @@ -33,14 +28,13 @@ protected MembershipCommand(DomainInstrument instrument, Action refresh) public override ICommandResult Invoke() { var message = Apply(); - _refresh(); return CommandResult.ShowToast(new ToastArgs { Message = message, Result = CommandResult.KeepOpen() }); } } internal sealed partial class AddToWatchlistCommand : MembershipCommand { - public AddToWatchlistCommand(DomainInstrument instrument, Action refresh) : base(instrument, refresh) + public AddToWatchlistCommand(DomainInstrument instrument) : base(instrument) { Name = "Add to Watchlist"; Icon = new IconInfo(AddGlyph); @@ -55,7 +49,7 @@ protected override string Apply() internal sealed partial class RemoveFromWatchlistCommand : MembershipCommand { - public RemoveFromWatchlistCommand(DomainInstrument instrument, Action refresh) : base(instrument, refresh) + public RemoveFromWatchlistCommand(DomainInstrument instrument) : base(instrument) { Name = "Remove from Watchlist"; Icon = new IconInfo(DeleteGlyph); @@ -70,7 +64,7 @@ protected override string Apply() internal sealed partial class AddToFavoritesCommand : MembershipCommand { - public AddToFavoritesCommand(DomainInstrument instrument, Action refresh) : base(instrument, refresh) + public AddToFavoritesCommand(DomainInstrument instrument) : base(instrument) { Name = "Add to Favorites"; Icon = new IconInfo(StarGlyph); @@ -85,7 +79,7 @@ protected override string Apply() internal sealed partial class RemoveFromFavoritesCommand : MembershipCommand { - public RemoveFromFavoritesCommand(DomainInstrument instrument, Action refresh) : base(instrument, refresh) + public RemoveFromFavoritesCommand(DomainInstrument instrument) : base(instrument) { Name = "Remove from Favorites"; Icon = new IconInfo(StarFillGlyph); @@ -102,7 +96,7 @@ protected override string Apply() // favorite a tracked instrument without re-searching. Name/icon reflect the current state. internal sealed partial class ToggleFavoriteCommand : MembershipCommand { - public ToggleFavoriteCommand(DomainInstrument instrument, Action refresh) : base(instrument, refresh) + public ToggleFavoriteCommand(DomainInstrument instrument) : base(instrument) { var isFavorite = WatchlistStore.Instance.IsFavorite(instrument.Symbol); Name = isFavorite ? "Remove from Favorites" : "Add to Favorites"; diff --git a/MarketExtension/Helpers/StateFlow.cs b/MarketExtension/Helpers/StateFlow.cs new file mode 100644 index 0000000..8069f9b --- /dev/null +++ b/MarketExtension/Helpers/StateFlow.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +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). Hand-rolled rather than System.Reactive so the AOT/trim +// build stays warning-clean (reflection-free; see CLAUDE.md AOT section). +// +// 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. +// +// Subscriber-count awareness: OnActive() fires when the subscriber count goes 0 -> 1 and OnInactive() +// when it returns 1 -> 0. That is the WhileSubscribed / Rx RefCount() seam — a future PolledStateFlow +// (the ticker) starts its poll loop on OnActive and stops it on OnInactive, so it only does work while +// something is actually watching. Plain flows leave both hooks no-op. +internal partial class StateFlow +{ + private readonly Lock _lock = new(); + private readonly IEqualityComparer _comparer; + private T _value; + private Action? _observers; + private int _count; + + protected StateFlow(T initial, IEqualityComparer? comparer = null) + { + _value = initial; + _comparer = comparer ?? EqualityComparer.Default; + } + + public T Value + { + get { lock (_lock) return _value; } + } + + // Subscribe and immediately receive the current value (StateFlow replay). Dispose to unsubscribe; + // disposal is idempotent. Handlers run on whatever thread mutates the value — the toolkit marshals + // the RaiseItemsChanged that handlers ultimately call. + public IDisposable Subscribe(Action onNext) + { + ArgumentNullException.ThrowIfNull(onNext); + + bool becameActive; + T current; + lock (_lock) + { + _observers += onNext; + becameActive = ++_count == 1; + current = _value; + } + + if (becameActive) OnActive(); // wake any producer before the first replay + onNext(current); // replay the current value + return new Subscription(this, onNext); + } + + // Writable entry point for subclasses. Returns true if the value actually changed (i.e. listeners + // were notified). Fires handlers OUTSIDE the lock — they re-read the store, which re-takes it. + protected bool SetValue(T value) + { + Action? observers; + lock (_lock) + { + if (_comparer.Equals(_value, value)) return false; // distinct-until-changed + _value = value; + observers = _observers; + } + + observers?.Invoke(value); + return true; + } + + // Called on the 0 -> 1 subscriber transition (the flow "becomes active"). No-op by default. + protected virtual void OnActive() { } + + // Called on the 1 -> 0 subscriber transition (the flow "goes idle"). No-op by default. + protected virtual void OnInactive() { } + + private void Unsubscribe(Action onNext) + { + bool becameIdle; + lock (_lock) + { + _observers -= onNext; + becameIdle = --_count == 0; + } + + if (becameIdle) OnInactive(); + } + + private sealed partial class Subscription(StateFlow flow, Action onNext) : IDisposable + { + private StateFlow? _flow = flow; + + public void Dispose() + { + // Null out atomically so a double-dispose can't decrement the count twice. + var owner = Interlocked.Exchange(ref _flow, null); + owner?.Unsubscribe(onNext); + } + } +} + +// 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/Pages/FavoritesDockPage.cs b/MarketExtension/Pages/FavoritesDockPage.cs index 5169822..98a0d14 100644 --- a/MarketExtension/Pages/FavoritesDockPage.cs +++ b/MarketExtension/Pages/FavoritesDockPage.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using System.Threading.Tasks; using Microsoft.CommandPalette.Extensions; @@ -23,27 +24,26 @@ internal sealed partial class FavoritesDockPage : ListPage, INotifyItemsChanged private UiQuote[]? _quotes; private event TypedEventHandler? _itemsChanged; + private IDisposable? _subscription; event TypedEventHandler INotifyItemsChanged.ItemsChanged { add { _itemsChanged += value; - // Subscribe idempotently so starring/unstarring from a palette page refreshes this - // pinned band at once, instead of waiting for it to be reopened. - WatchlistStore.Instance.FavoritesChanged -= OnFavoritesChanged; - WatchlistStore.Instance.FavoritesChanged += OnFavoritesChanged; - RefreshQuotes(); // fires when the band becomes visible + // 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()); } remove { _itemsChanged -= value; - WatchlistStore.Instance.FavoritesChanged -= OnFavoritesChanged; + _subscription?.Dispose(); + _subscription = null; } } - private void OnFavoritesChanged() => RefreshQuotes(); - protected new void RaiseItemsChanged(int totalItems = -1) => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); @@ -94,8 +94,8 @@ internal void RefreshQuotes() private async Task LoadQuotes() { - // The dock shows only favorites — price exactly that subset. - _quotes = [.. (await _repository.GetQuotesAsync(WatchlistStore.Instance.Favorites)).Select(UiQuote.From)]; + // The dock shows only favorites — price exactly that subset (a snapshot of the flow's value). + _quotes = [.. (await _repository.GetQuotesAsync(WatchlistStore.Instance.Favorites.Value)).Select(UiQuote.From)]; IsLoading = false; RaiseItemsChanged(0); } diff --git a/MarketExtension/Pages/FavoritesPage.cs b/MarketExtension/Pages/FavoritesPage.cs index e7d6738..f3c7132 100644 --- a/MarketExtension/Pages/FavoritesPage.cs +++ b/MarketExtension/Pages/FavoritesPage.cs @@ -9,21 +9,18 @@ namespace MarketExtension; // only place, besides the Watchlist screen's Ctrl+Enter, that unstars an instrument. internal sealed partial class FavoritesPage : PricedListPage { - public FavoritesPage(MarketRepository repository) : base(repository) + public FavoritesPage(MarketRepository repository) : base(repository, WatchlistStore.Instance.Favorites) { Title = "Markets Favorites"; Name = "Open"; PlaceholderText = "Filter your favorites..."; } - protected override IReadOnlyList InstrumentsToPrice() - => WatchlistStore.Instance.Favorites; - protected override IListItem BuildRow(UiQuote q) { var instrument = new DomainInstrument(q.Symbol, q.Name, q.Category); - return new ListItem(new RemoveFromFavoritesCommand(instrument, () => RaiseItemsChanged(0))) + return new ListItem(new RemoveFromFavoritesCommand(instrument)) { Title = $"★ {q.Symbol} · {q.Name}", Subtitle = $"{q.FormatPrice()} {q.FormatChange()}", diff --git a/MarketExtension/Pages/PricedListPage.cs b/MarketExtension/Pages/PricedListPage.cs index f89c537..f73bdfe 100644 --- a/MarketExtension/Pages/PricedListPage.cs +++ b/MarketExtension/Pages/PricedListPage.cs @@ -1,6 +1,7 @@ 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; @@ -8,37 +9,71 @@ namespace MarketExtension; -// Shared base for the two priced list screens (Watchlist, Favorites). Each prices a different -// WatchlistStore subset but otherwise behaves identically: async-load the quotes, render rows, -// re-list on load and on every mutation. Houses the project's INotifyItemsChanged on-load refresh -// hook (see CLAUDE.md) so the list re-prices every time the user navigates to the screen. +// 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; - protected UiQuote[]? Quotes; + + 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 event TypedEventHandler? _itemsChanged; event TypedEventHandler INotifyItemsChanged.ItemsChanged { - add { _itemsChanged += value; RefreshQuotes(); } // fires every time the user navigates here - remove => _itemsChanged -= value; + 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))); + } + remove + { + _itemsChanged -= value; + foreach (var subscription in _subscriptions) + subscription.Dispose(); + _subscriptions.Clear(); + } } protected new void RaiseItemsChanged(int totalItems = -1) => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); - protected PricedListPage(MarketRepository repository) + protected PricedListPage(MarketRepository repository, StateFlow> instruments) { Repository = repository; + _instruments = instruments; Icon = new IconInfo("https://github.com/favicon.ico"); } - // The store subset this screen prices. - protected abstract IReadOnlyList InstrumentsToPrice(); + // 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); @@ -51,13 +86,22 @@ public override void UpdateSearchText(string oldSearch, string newSearch) public override IListItem[] GetItems() { - if (Quotes is null) - return []; + if (!_received) + return []; // framework's pre-subscribe fetch — nothing to show yet - if (Quotes.Length == 0) + if (_snapshot.Count == 0) return EmptyState(); - var rows = Quotes.Where(Matches).Select(BuildRow).ToList(); + 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 = cached.Where(Matches).Select(BuildRow).ToList(); rows.Add(new ListItem(new RefreshCommand(this)) { Title = "Refresh 🔄" }); return [.. rows]; } @@ -68,17 +112,81 @@ private bool Matches(UiQuote q) => 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; + RaiseItemsChanged(0); // removals reflected immediately, no network + 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 Refresh row and (later) the poll timer. Clears the cache so + // every symbol is re-fetched. internal void RefreshQuotes() { - Quotes = null; + lock (_cacheLock) _priceCache.Clear(); + Fetch(_snapshot); + } + + // 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; + } + + int generation; + lock (_cacheLock) + generation = ++_fetchGeneration; + IsLoading = true; RaiseItemsChanged(0); - Task.Run(LoadQuotes); + Task.Run(() => LoadQuotes(instruments, generation)); } - private async Task LoadQuotes() + private async Task LoadQuotes(IReadOnlyList instruments, int generation) { - Quotes = [.. (await Repository.GetQuotesAsync(InstrumentsToPrice())).Select(UiQuote.From)]; + 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)) + _priceCache[key] = UiQuote.From(quote); + } + } + IsLoading = false; RaiseItemsChanged(0); } diff --git a/MarketExtension/Pages/SearchPage.cs b/MarketExtension/Pages/SearchPage.cs index eb491c2..3d90d53 100644 --- a/MarketExtension/Pages/SearchPage.cs +++ b/MarketExtension/Pages/SearchPage.cs @@ -13,8 +13,9 @@ namespace MarketExtension; // (the first MoreCommands context item) adds it to favorites. The two lists are independent — a // result can go on either, both, or neither. Lifted from the search half of the old MarketsPage. // -// Uses the project's INotifyItemsChanged fire-on-subscribe hook (see CLAUDE.md) so the async -// /search completion and the post-add re-lists reach the framework's listener. +// 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 @@ -32,11 +33,27 @@ internal sealed partial class SearchPage : DynamicListPage, INotifyItemsChanged private string _searchedQuery = string.Empty; private event TypedEventHandler? _itemsChanged; + private readonly List _subscriptions = []; event TypedEventHandler INotifyItemsChanged.ItemsChanged { - add { _itemsChanged += value; _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); } - remove => _itemsChanged -= value; + 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))); + } + remove + { + _itemsChanged -= value; + foreach (var subscription in _subscriptions) + subscription.Dispose(); + _subscriptions.Clear(); + } } protected new void RaiseItemsChanged(int totalItems = -1) @@ -113,19 +130,19 @@ private IListItem[] SearchItems() // A price-less search result. Enter adds to the watchlist; Ctrl+Enter adds to favorites. The // subtitle reflects current membership so the available actions are obvious. - private ListItem BuildResultItem(DomainInstrument instrument) + private static ListItem BuildResultItem(DomainInstrument instrument) { var inWatchlist = WatchlistStore.Instance.IsInWatchlist(instrument.Symbol); var isFavorite = WatchlistStore.Instance.IsFavorite(instrument.Symbol); - return new ListItem(new AddToWatchlistCommand(instrument, () => RaiseItemsChanged(0))) + return new ListItem(new AddToWatchlistCommand(instrument)) { Title = (isFavorite ? "★ " : "") + $"{instrument.Symbol} · {instrument.Name}", Subtitle = MembershipSubtitle(inWatchlist, isFavorite), Section = "Search results", MoreCommands = [ - new CommandContextItem(new AddToFavoritesCommand(instrument, () => RaiseItemsChanged(0))) + new CommandContextItem(new AddToFavoritesCommand(instrument)) { Title = "Add to Favorites", }, diff --git a/MarketExtension/Pages/WatchlistPage.cs b/MarketExtension/Pages/WatchlistPage.cs index c7821e8..07d3177 100644 --- a/MarketExtension/Pages/WatchlistPage.cs +++ b/MarketExtension/Pages/WatchlistPage.cs @@ -10,29 +10,31 @@ namespace MarketExtension; // without going back to search. internal sealed partial class WatchlistPage : PricedListPage { - public WatchlistPage(MarketRepository repository) : base(repository) + public WatchlistPage(MarketRepository repository) : base(repository, WatchlistStore.Instance.Watchlist) { Title = "Markets Watchlist"; Name = "Open"; PlaceholderText = "Filter your watchlist..."; } - protected override IReadOnlyList InstrumentsToPrice() - => WatchlistStore.Instance.Watchlist; + // 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 RemoveFromWatchlistCommand(instrument, () => RaiseItemsChanged(0))) + return new ListItem(new RemoveFromWatchlistCommand(instrument)) { Title = (isFavorite ? "★ " : "") + $"{q.Symbol} · {q.Name}", Subtitle = $"{q.FormatPrice()} {q.FormatChange()}", Section = SectionLabel(q.Category), MoreCommands = [ - new CommandContextItem(new ToggleFavoriteCommand(instrument, () => RaiseItemsChanged(0))) + new CommandContextItem(new ToggleFavoriteCommand(instrument)) { Title = isFavorite ? "Remove from Favorites" : "Add to Favorites", }, diff --git a/MarketExtension/Settings/WatchlistStore.cs b/MarketExtension/Settings/WatchlistStore.cs index 9f043eb..d1e0739 100644 --- a/MarketExtension/Settings/WatchlistStore.cs +++ b/MarketExtension/Settings/WatchlistStore.cs @@ -21,14 +21,23 @@ internal sealed class WatchlistStore { public static readonly WatchlistStore Instance = new(); - // Raised after a favorite flag flips (and is persisted). Args-less — listeners just re-read Favorites. - // Lets the pinned dock band refresh immediately instead of going stale until reopened. - public event Action? FavoritesChanged; - 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; @@ -49,7 +58,11 @@ private static string SettingsDirectory } } - private WatchlistStore() => Load(); + private WatchlistStore() + { + Load(); + PublishState(); // seed the flows with the loaded subsets, before any subscriber exists + } public bool IsInWatchlist(string symbol) { @@ -61,18 +74,6 @@ public bool IsFavorite(string symbol) lock (_lock) return _entries.TryGetValue(Normalize(symbol), out var e) && e.IsFavorite; } - // The watchlisted instruments, in insertion order, as provider-agnostic identities. - public IReadOnlyList Watchlist - { - get { lock (_lock) return [.. _entries.Values.Where(e => e.InWatchlist).Select(e => e.Instrument)]; } - } - - // The favorited instruments (the dock subset), in insertion order. - public IReadOnlyList Favorites - { - get { lock (_lock) return [.. _entries.Values.Where(e => e.IsFavorite).Select(e => e.Instrument)]; } - } - 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); @@ -87,8 +88,7 @@ private void SetFlag(string symbol, bool? watchlist = null, bool? favorite = nul } Save(); - // Fire outside the lock: the dock's handler re-reads Favorites, which re-acquires it. - if (favorite is not null) FavoritesChanged?.Invoke(); + PublishState(); } private void SetFlag(DomainInstrument instrument, bool? watchlist = null, bool? favorite = null) @@ -102,7 +102,23 @@ private void SetFlag(DomainInstrument instrument, bool? watchlist = null, bool? } Save(); - if (favorite is not null) FavoritesChanged?.Invoke(); + 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. @@ -114,7 +130,9 @@ private void Apply(Entry entry, bool? watchlist, bool? favorite) _entries.Remove(Normalize(entry.Instrument.Symbol)); } - private static string Normalize(string symbol) => symbol.Trim().ToUpperInvariant(); + // 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() { @@ -138,8 +156,8 @@ private void Load() // 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. - // No FavoritesChanged fires here: this runs in the singleton's ctor, before any subscriber. + // 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(); } From 657950d95095409a47df3cfa294fb3c1d2530202 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Mon, 22 Jun 2026 02:58:11 +0300 Subject: [PATCH 13/69] wip --- CLAUDE.md | 19 +++++--- .../Commands/MembershipCommands.cs | 4 +- MarketExtension/Pages/FavoritesPage.cs | 10 +++-- MarketExtension/Pages/SearchPage.cs | 28 ++++++------ MarketExtension/Pages/SymbolDetailPage.cs | 44 +++++++++++++++++++ MarketExtension/Pages/WatchlistPage.cs | 11 ++--- 6 files changed, 85 insertions(+), 31 deletions(-) create mode 100644 MarketExtension/Pages/SymbolDetailPage.cs diff --git a/CLAUDE.md b/CLAUDE.md index c890cbe..28d6d0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,15 +155,22 @@ list, both, or neither (so "watchlist" and "favorites" are genuinely separate se 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 (currently placeholder) +per-symbol detail screen. The list-management actions are demoted to **context items** (`MoreCommands`; +**Ctrl+Enter** activates the first one) until the detail page takes them over (per the "Symbol detail + +live chart" wishlist). The screens: + 1. **Markets Search** (`Pages/SearchPage.cs`, default entry) — the Enter-only `/search` flow. On a - result: **Enter** → add to watchlist; **Ctrl+Enter** (the first `MoreCommands` context item, the - toolkit's secondary-activation slot) → add to favorites. Empty box links to the other two screens. + result: **Enter** → open detail page; **More menu** → Add to Watchlist (**Ctrl+Enter**) / Add to + Favorites. 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** → remove from watchlist; **Ctrl+Enter** → toggle favorite. + class; a ★ marks favorites. **Enter** → open detail page; **More menu** → Remove from Watchlist + (**Ctrl+Enter**) / toggle favorite / copy price. 3. **Markets Favorites** (`Pages/FavoritesPage.cs`) — the curated subset; **only favorites render in - the dock band** (`FavoritesDockPage` subscribes to `WatchlistStore.Favorites`). **Enter** → remove - from favorites. A pinned band now updates **immediately** when favorites change anywhere (it - subscribes to the flow while visible), not just on reopen. + the dock band** (`FavoritesDockPage` subscribes to `WatchlistStore.Favorites`). **Enter** → open + detail page; **More menu** → Remove from Favorites (**Ctrl+Enter**) / copy price. 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; diff --git a/MarketExtension/Commands/MembershipCommands.cs b/MarketExtension/Commands/MembershipCommands.cs index d2af6df..87d4367 100644 --- a/MarketExtension/Commands/MembershipCommands.cs +++ b/MarketExtension/Commands/MembershipCommands.cs @@ -92,8 +92,8 @@ protected override string Apply() } } -// Star/unstar in place — used as the Ctrl+Enter secondary on the Watchlist screen so the user can -// favorite a tracked instrument without re-searching. Name/icon reflect the current state. +// Star/unstar in place — a More-menu action on the Watchlist screen so the user can favorite a +// tracked instrument without re-searching. Name/icon reflect the current state. internal sealed partial class ToggleFavoriteCommand : MembershipCommand { public ToggleFavoriteCommand(DomainInstrument instrument) : base(instrument) diff --git a/MarketExtension/Pages/FavoritesPage.cs b/MarketExtension/Pages/FavoritesPage.cs index f3c7132..c66e0e4 100644 --- a/MarketExtension/Pages/FavoritesPage.cs +++ b/MarketExtension/Pages/FavoritesPage.cs @@ -5,8 +5,9 @@ namespace MarketExtension; // Top-level screen: the curated favorites — the same set the dock band shows — priced live. Enter -// removes the row from favorites (it stays on the watchlist if it's tracked there). This is the -// only place, besides the Watchlist screen's Ctrl+Enter, that unstars an instrument. +// opens the row's SymbolDetailPage; removing from favorites (Ctrl+Enter — the first MoreCommands +// context item) lives in the More menu. Removing here leaves the instrument on the watchlist if +// it's tracked there. This and the Watchlist screen's toggle are the only places that unstar. internal sealed partial class FavoritesPage : PricedListPage { public FavoritesPage(MarketRepository repository) : base(repository, WatchlistStore.Instance.Favorites) @@ -20,12 +21,13 @@ protected override IListItem BuildRow(UiQuote q) { var instrument = new DomainInstrument(q.Symbol, q.Name, q.Category); - return new ListItem(new RemoveFromFavoritesCommand(instrument)) + return new ListItem(new SymbolDetailPage(instrument)) { Title = $"★ {q.Symbol} · {q.Name}", Subtitle = $"{q.FormatPrice()} {q.FormatChange()}", MoreCommands = [ + new CommandContextItem(new RemoveFromFavoritesCommand(instrument)), new CommandContextItem(new CopyTextCommand(q.FormatPrice())) { Title = "Copy price" }, ], }; @@ -36,7 +38,7 @@ protected override IListItem[] EmptyState() => new ListItem(new NoOpCommand()) { Title = "No favorites yet", - Subtitle = "Star instruments from Markets Search, or from your Watchlist with Ctrl+Enter", + Subtitle = "Star instruments from the More menu in Markets Search or your Watchlist", }, ]; } diff --git a/MarketExtension/Pages/SearchPage.cs b/MarketExtension/Pages/SearchPage.cs index 3d90d53..abb8d2d 100644 --- a/MarketExtension/Pages/SearchPage.cs +++ b/MarketExtension/Pages/SearchPage.cs @@ -9,9 +9,10 @@ namespace MarketExtension; // Default entry screen: the Enter-only Finnhub /search flow. Typing never hits the network // (free-tier rate limits) — it only changes what the synthetic "Search Finnhub for ..." item will -// look up. Results are price-less identities; Enter adds the instrument to the watchlist, Ctrl+Enter -// (the first MoreCommands context item) adds it to favorites. The two lists are independent — a -// result can go on either, both, or neither. Lifted from the search half of the old MarketsPage. +// look up. Results are price-less identities; Enter on a result opens its SymbolDetailPage, while +// adding to the watchlist (Ctrl+Enter — the first MoreCommands context item) or favorites lives in +// the More menu. The two lists are independent — a result can go on either, both, or neither. +// 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. @@ -128,34 +129,33 @@ private IListItem[] SearchItems() return [.. items]; } - // A price-less search result. Enter adds to the watchlist; Ctrl+Enter adds to favorites. The - // subtitle reflects current membership so the available actions are obvious. + // A price-less search result. Enter opens its detail page; adding to the watchlist (Ctrl+Enter) + // or favorites lives in the More menu. The subtitle reflects current membership so the user can + // see at a glance what the row is already on. private static ListItem BuildResultItem(DomainInstrument instrument) { var inWatchlist = WatchlistStore.Instance.IsInWatchlist(instrument.Symbol); var isFavorite = WatchlistStore.Instance.IsFavorite(instrument.Symbol); - return new ListItem(new AddToWatchlistCommand(instrument)) + return new ListItem(new SymbolDetailPage(instrument)) { Title = (isFavorite ? "★ " : "") + $"{instrument.Symbol} · {instrument.Name}", Subtitle = MembershipSubtitle(inWatchlist, isFavorite), Section = "Search results", MoreCommands = [ - new CommandContextItem(new AddToFavoritesCommand(instrument)) - { - Title = "Add to Favorites", - }, + new CommandContextItem(new AddToWatchlistCommand(instrument)), + new CommandContextItem(new AddToFavoritesCommand(instrument)), ], }; } private static string MembershipSubtitle(bool inWatchlist, bool isFavorite) => (inWatchlist, isFavorite) switch { - (true, true) => "On watchlist · ★ Favorite", - (true, false) => "On watchlist · Ctrl+Enter to favorite", - (false, true) => "★ Favorite · Enter to add to watchlist", - (false, false) => "Enter to add to watchlist · Ctrl+Enter to favorite", + (true, true) => "On watchlist · ★ Favorite · Enter for details", + (true, false) => "On watchlist · Enter for details", + (false, true) => "★ Favorite · Enter for details", + (false, false) => "Enter for details · add to watchlist or favorites from the More menu", }; // Runs the single online /search call for the current SearchText, then re-lists with results. diff --git a/MarketExtension/Pages/SymbolDetailPage.cs b/MarketExtension/Pages/SymbolDetailPage.cs new file mode 100644 index 0000000..a08bc7d --- /dev/null +++ b/MarketExtension/Pages/SymbolDetailPage.cs @@ -0,0 +1,44 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace MarketExtension; + +// The shared per-symbol detail screen, reached by pressing Enter on any instrument row (Search, +// Watchlist, Favorites). For now it's a PLACEHOLDER: it shows the instrument's identity and a +// "chart coming soon" note. It will grow into the real detail screen — a live price chart plus the +// list-management actions (add / remove / favorite) that today live as the rows' context items — +// per the "Symbol detail + live chart" roadmap in CLAUDE.md. +// +// Built on the toolkit's ContentPage so the eventual adaptive-card / SVG-sparkline version is a +// straight in-place upgrade (swap MarkdownContent for a FormContent). No async, no JSON, no +// reflection → AOT/trim-safe and nothing to register. +internal sealed partial class SymbolDetailPage : ContentPage +{ + private readonly DomainInstrument _instrument; + + public SymbolDetailPage(DomainInstrument instrument) + { + _instrument = instrument; + Icon = new IconInfo("https://github.com/favicon.ico"); + Title = $"{instrument.Symbol} · {instrument.Name}"; + Name = "View details"; + } + + public override IContent[] GetContent() => + [ + new MarkdownContent( + $"# {_instrument.Symbol}\n\n" + + $"**{_instrument.Name}** · {CategoryLabel(_instrument.Category)}\n\n" + + "---\n\n" + + "📈 **Live price chart coming soon.**\n\n" + + "List management (add / remove / favorite) will move here too."), + ]; + + private static string CategoryLabel(AssetCategory category) => category switch + { + AssetCategory.Stock => "Stock", + AssetCategory.Crypto => "Crypto", + AssetCategory.Currency => "Currency", + _ => "Other", + }; +} diff --git a/MarketExtension/Pages/WatchlistPage.cs b/MarketExtension/Pages/WatchlistPage.cs index 07d3177..5fd6280 100644 --- a/MarketExtension/Pages/WatchlistPage.cs +++ b/MarketExtension/Pages/WatchlistPage.cs @@ -5,9 +5,9 @@ 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 removes the row from the watchlist; Ctrl+Enter -// (the first MoreCommands context item) toggles its favorite mark so the user can curate the dock -// without going back to search. +// prefix marks rows that are also favorited. Enter opens the row's SymbolDetailPage; removing from +// the watchlist (Ctrl+Enter — the first MoreCommands context item) and toggling its favorite mark +// live in the More menu, so the user can curate the dock without going back to search. internal sealed partial class WatchlistPage : PricedListPage { public WatchlistPage(MarketRepository repository) : base(repository, WatchlistStore.Instance.Watchlist) @@ -27,13 +27,14 @@ 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 RemoveFromWatchlistCommand(instrument)) + return new ListItem(new SymbolDetailPage(instrument)) { Title = (isFavorite ? "★ " : "") + $"{q.Symbol} · {q.Name}", Subtitle = $"{q.FormatPrice()} {q.FormatChange()}", Section = SectionLabel(q.Category), MoreCommands = [ + new CommandContextItem(new RemoveFromWatchlistCommand(instrument)), new CommandContextItem(new ToggleFavoriteCommand(instrument)) { Title = isFavorite ? "Remove from Favorites" : "Add to Favorites", @@ -48,7 +49,7 @@ protected override IListItem[] EmptyState() => new ListItem(new NoOpCommand()) { Title = "Your watchlist is empty", - Subtitle = "Use Markets Search to look up instruments, then press Enter to add them", + Subtitle = "Use Markets Search to look up instruments, then add them from the More menu", }, ]; From 4d62129c258fab43cc66b51cdc50461b0e7ac394 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Mon, 22 Jun 2026 03:13:29 +0300 Subject: [PATCH 14/69] wip --- CLAUDE.md | 33 +++--- .../Commands/MembershipCommands.cs | 37 ++----- MarketExtension/Pages/FavoritesDockPage.cs | 9 +- MarketExtension/Pages/FavoritesPage.cs | 10 +- MarketExtension/Pages/SearchPage.cs | 19 ++-- MarketExtension/Pages/SymbolDetailPage.cs | 101 +++++++++++++++--- MarketExtension/Pages/WatchlistPage.cs | 14 +-- 7 files changed, 124 insertions(+), 99 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 28d6d0f..19aabc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,20 +155,22 @@ list, both, or neither (so "watchlist" and "favorites" are genuinely separate se 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 (currently placeholder) -per-symbol detail screen. The list-management actions are demoted to **context items** (`MoreCommands`; -**Ctrl+Enter** activates the first one) until the detail page takes them over (per the "Symbol detail + -live chart" wishlist). The screens: +**Enter on any row opens the shared `Pages/SymbolDetailPage.cs`** — the per-symbol detail screen +(`ContentPage`; chart still a placeholder), 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; **More menu** → Add to Watchlist (**Ctrl+Enter**) / Add to - Favorites. Empty box links to the other two screens. + 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; **More menu** → Remove from Watchlist - (**Ctrl+Enter**) / toggle favorite / copy price. + 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; **More menu** → Remove from Favorites (**Ctrl+Enter**) / copy price. A pinned band now + 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. @@ -326,9 +328,8 @@ mechanism is simpler than it looks: ### Asset logos as icons, app-wide (future wishlist) Every row and dock button should show the instrument's **official logo** (AAPL's apple, BABA's logo, -BTC's coin) instead of today's generic icon. Right now the dock items use `CopyTextCommand`, so they -render its **copy glyph** — wrong on both counts: the icon should be the asset logo, and a **left-click -should open the chart** (the symbol-detail page above), not copy. +BTC's coin) instead of today's generic github-favicon icon. (Dock buttons already left-click into the +`SymbolDetailPage`; the remaining gap is purely the icon.) **Where to get logos:** - **Stocks / ETFs — Finnhub `/stock/profile2`** (free tier; reuses our existing key). Returns a hosted @@ -340,8 +341,7 @@ should open the chart** (the symbol-detail page above), not copy. **bundle a static set** (e.g. `spothq/cryptocurrency-icons` SVG/PNG by symbol) under `Assets/` for an offline, no-network, AOT-friendly path. - **Forex / currency:** deferred (country flags or a currency glyph when FX lands). -- **Fallback (never the copy icon):** a per-`AssetCategory` Segoe glyph or a first-letter monogram when - no logo resolves. +- **Fallback:** a per-`AssetCategory` Segoe glyph or a first-letter monogram when no logo resolves. **How to load it** (the toolkit already supports all three): - Remote URL → `new IconInfo(logoUrl)` (same as today's github favicon). @@ -357,9 +357,8 @@ once known), or render a fallback glyph first and `RaiseItemsChanged()` when the run it's served from cache instantly. **Wire it everywhere:** set `ListItem.Icon` to the resolved logo in every row builder — `SearchPage`, -`WatchlistPage`, `FavoritesPage`, the future `PortfolioPage` — and on the dock items. For the dock, **also -swap the command from `CopyTextCommand` to the `SymbolDetailPage`** so left-click opens the chart (keep -"Copy price" as a `MoreCommands` context item). Dock bands still require a non-empty `Command.Id`. +`WatchlistPage`, `FavoritesPage`, the future `PortfolioPage` — and on the dock items. (The dock command +is already the `SymbolDetailPage`; dock bands still require a non-empty `Command.Id`, which the page sets.) ## CommandPalette Toolkit — Quick Reference diff --git a/MarketExtension/Commands/MembershipCommands.cs b/MarketExtension/Commands/MembershipCommands.cs index 87d4367..cf9e843 100644 --- a/MarketExtension/Commands/MembershipCommands.cs +++ b/MarketExtension/Commands/MembershipCommands.cs @@ -3,12 +3,13 @@ namespace MarketExtension; -// The watchlist/favorites membership actions surfaced on list rows. Each one just mutates WatchlistStore -// for one instrument; the store re-publishes its observable subsets, so every live page and the dock -// re-list 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-list alone -// left users unsure the action took, and KeepOpen lets them act on several rows in a row. Adapted from -// reference/commands/ToggleFavoriteCommand.cs. +// 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. @@ -91,27 +92,3 @@ protected override string Apply() return $"Removed {Instrument.Symbol} from favorites"; } } - -// Star/unstar in place — a More-menu action on the Watchlist screen so the user can favorite a -// tracked instrument without re-searching. Name/icon reflect the current state. -internal sealed partial class ToggleFavoriteCommand : MembershipCommand -{ - public ToggleFavoriteCommand(DomainInstrument instrument) : base(instrument) - { - var isFavorite = WatchlistStore.Instance.IsFavorite(instrument.Symbol); - Name = isFavorite ? "Remove from Favorites" : "Add to Favorites"; - Icon = new IconInfo(isFavorite ? StarFillGlyph : StarGlyph); - } - - protected override string Apply() - { - if (WatchlistStore.Instance.IsFavorite(Instrument.Symbol)) - { - WatchlistStore.Instance.RemoveFromFavorites(Instrument.Symbol); - return $"Removed {Instrument.Symbol} from favorites"; - } - - WatchlistStore.Instance.AddToFavorites(Instrument); - return $"Added {Instrument.Symbol} to favorites"; - } -} diff --git a/MarketExtension/Pages/FavoritesDockPage.cs b/MarketExtension/Pages/FavoritesDockPage.cs index 98a0d14..6192975 100644 --- a/MarketExtension/Pages/FavoritesDockPage.cs +++ b/MarketExtension/Pages/FavoritesDockPage.cs @@ -8,8 +8,8 @@ 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. +// instruments 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 @@ -73,10 +73,7 @@ public override IListItem[] GetItems() 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))) { Title = $"{q.Symbol} {q.FormatChange()}", Subtitle = q.FormatPrice(), diff --git a/MarketExtension/Pages/FavoritesPage.cs b/MarketExtension/Pages/FavoritesPage.cs index c66e0e4..7f057e2 100644 --- a/MarketExtension/Pages/FavoritesPage.cs +++ b/MarketExtension/Pages/FavoritesPage.cs @@ -5,9 +5,8 @@ namespace MarketExtension; // Top-level screen: the curated favorites — the same set the dock band shows — priced live. Enter -// opens the row's SymbolDetailPage; removing from favorites (Ctrl+Enter — the first MoreCommands -// context item) lives in the More menu. Removing here leaves the instrument on the watchlist if -// it's tracked there. This and the Watchlist screen's toggle are the only places that unstar. +// 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) @@ -25,11 +24,6 @@ protected override IListItem BuildRow(UiQuote q) { Title = $"★ {q.Symbol} · {q.Name}", Subtitle = $"{q.FormatPrice()} {q.FormatChange()}", - MoreCommands = - [ - new CommandContextItem(new RemoveFromFavoritesCommand(instrument)), - new CommandContextItem(new CopyTextCommand(q.FormatPrice())) { Title = "Copy price" }, - ], }; } diff --git a/MarketExtension/Pages/SearchPage.cs b/MarketExtension/Pages/SearchPage.cs index abb8d2d..1d757a9 100644 --- a/MarketExtension/Pages/SearchPage.cs +++ b/MarketExtension/Pages/SearchPage.cs @@ -9,9 +9,9 @@ namespace MarketExtension; // Default entry screen: the Enter-only Finnhub /search flow. Typing never hits the network // (free-tier rate limits) — it only changes what the synthetic "Search Finnhub for ..." item will -// look up. Results are price-less identities; Enter on a result opens its SymbolDetailPage, while -// adding to the watchlist (Ctrl+Enter — the first MoreCommands context item) or favorites lives in -// the More menu. The two lists are independent — a result can go on either, both, or neither. +// look up. 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 @@ -129,9 +129,9 @@ private IListItem[] SearchItems() return [.. items]; } - // A price-less search result. Enter opens its detail page; adding to the watchlist (Ctrl+Enter) - // or favorites lives in the More menu. The subtitle reflects current membership so the user can - // see at a glance what the row is already on. + // 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 static ListItem BuildResultItem(DomainInstrument instrument) { var inWatchlist = WatchlistStore.Instance.IsInWatchlist(instrument.Symbol); @@ -142,11 +142,6 @@ private static ListItem BuildResultItem(DomainInstrument instrument) Title = (isFavorite ? "★ " : "") + $"{instrument.Symbol} · {instrument.Name}", Subtitle = MembershipSubtitle(inWatchlist, isFavorite), Section = "Search results", - MoreCommands = - [ - new CommandContextItem(new AddToWatchlistCommand(instrument)), - new CommandContextItem(new AddToFavoritesCommand(instrument)), - ], }; } @@ -155,7 +150,7 @@ private static ListItem BuildResultItem(DomainInstrument instrument) (true, true) => "On watchlist · ★ Favorite · Enter for details", (true, false) => "On watchlist · Enter for details", (false, true) => "★ Favorite · Enter for details", - (false, false) => "Enter for details · add to watchlist or favorites from the More menu", + (false, false) => "Enter for details", }; // Runs the single online /search call for the current SearchText, then re-lists with results. diff --git a/MarketExtension/Pages/SymbolDetailPage.cs b/MarketExtension/Pages/SymbolDetailPage.cs index a08bc7d..add9d6c 100644 --- a/MarketExtension/Pages/SymbolDetailPage.cs +++ b/MarketExtension/Pages/SymbolDetailPage.cs @@ -1,38 +1,111 @@ +using System; +using System.Collections.Generic; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; namespace MarketExtension; // The shared per-symbol detail screen, reached by pressing Enter on any instrument row (Search, -// Watchlist, Favorites). For now it's a PLACEHOLDER: it shows the instrument's identity and a -// "chart coming soon" note. It will grow into the real detail screen — a live price chart plus the -// list-management actions (add / remove / favorite) that today live as the rows' context items — -// per the "Symbol detail + live chart" roadmap in CLAUDE.md. +// Watchlist, Favorites). The chart is still a PLACEHOLDER, but list management lives here now: the +// page's command bar carries the add/remove-watchlist and add/remove-favorite actions (Enter and +// Ctrl+Enter respectively), with the body showing current membership. This is the screen that owns +// list management going forward — the rows' context-menu copies are a transitional convenience. +// Built on the toolkit's ContentPage so the eventual SVG-sparkline version is a straight in-place +// upgrade (swap MarkdownContent for a FormContent). // -// Built on the toolkit's ContentPage so the eventual adaptive-card / SVG-sparkline version is a -// straight in-place upgrade (swap MarkdownContent for a FormContent). No async, no JSON, no -// reflection → AOT/trim-safe and nothing to register. -internal sealed partial class SymbolDetailPage : ContentPage +// 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 + body on every change. So toggling watchlist/favorite here (the commands KeepOpen) flips +// the buttons in place, and a change made elsewhere is reflected too. No manual refresh callback. +internal sealed partial class SymbolDetailPage : ContentPage, INotifyItemsChanged { private readonly DomainInstrument _instrument; + private readonly List _subscriptions = []; + + private event TypedEventHandler? _itemsChanged; + + event TypedEventHandler INotifyItemsChanged.ItemsChanged + { + add + { + _itemsChanged += value; + // Replay-on-subscribe paints the initial command bar/body; later pushes flip them in place. + _subscriptions.Add(WatchlistStore.Instance.Watchlist.Subscribe(_ => RefreshState())); + _subscriptions.Add(WatchlistStore.Instance.Favorites.Subscribe(_ => RefreshState())); + } + remove + { + _itemsChanged -= value; + foreach (var subscription in _subscriptions) + subscription.Dispose(); + _subscriptions.Clear(); + } + } + + protected new void RaiseItemsChanged(int totalItems = -1) + => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); public SymbolDetailPage(DomainInstrument instrument) { _instrument = instrument; + // 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 = new IconInfo("https://github.com/favicon.ico"); Title = $"{instrument.Symbol} · {instrument.Name}"; Name = "View details"; + Commands = BuildCommands(); // initial bar; refreshed again on subscribe and on every change + } + + public override IContent[] GetContent() => [new MarkdownContent(BuildBody())]; + + // Push the current membership into both the command bar (set Commands → host re-reads it) and the + // body (RaiseItemsChanged → host re-calls GetContent). + private void RefreshState() + { + Commands = BuildCommands(); + RaiseItemsChanged(); } - public override IContent[] GetContent() => - [ - new MarkdownContent( + // The two list-management actions, labelled add/remove for the instrument's current state. The + // first is the page's primary command (Enter), the second the secondary (Ctrl+Enter). + private IContextItem[] BuildCommands() + { + var inWatchlist = WatchlistStore.Instance.IsInWatchlist(_instrument.Symbol); + var isFavorite = WatchlistStore.Instance.IsFavorite(_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)); + + return [watchlist, favorite]; + } + + private string BuildBody() + { + var inWatchlist = WatchlistStore.Instance.IsInWatchlist(_instrument.Symbol); + var isFavorite = WatchlistStore.Instance.IsFavorite(_instrument.Symbol); + var status = (inWatchlist, isFavorite) switch + { + (true, true) => "On your watchlist · ★ Favorite", + (true, false) => "On your watchlist", + (false, true) => "★ Favorite", + (false, false) => "Not tracked yet", + }; + + return $"# {_instrument.Symbol}\n\n" + $"**{_instrument.Name}** · {CategoryLabel(_instrument.Category)}\n\n" + + $"_{status}_\n\n" + "---\n\n" + - "📈 **Live price chart coming soon.**\n\n" + - "List management (add / remove / favorite) will move here too."), - ]; + "📈 **Live price chart coming soon.**"; + } private static string CategoryLabel(AssetCategory category) => category switch { diff --git a/MarketExtension/Pages/WatchlistPage.cs b/MarketExtension/Pages/WatchlistPage.cs index 5fd6280..0646952 100644 --- a/MarketExtension/Pages/WatchlistPage.cs +++ b/MarketExtension/Pages/WatchlistPage.cs @@ -5,9 +5,8 @@ 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; removing from -// the watchlist (Ctrl+Enter — the first MoreCommands context item) and toggling its favorite mark -// live in the More menu, so the user can curate the dock without going back to search. +// 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) @@ -32,15 +31,6 @@ protected override IListItem BuildRow(UiQuote q) Title = (isFavorite ? "★ " : "") + $"{q.Symbol} · {q.Name}", Subtitle = $"{q.FormatPrice()} {q.FormatChange()}", Section = SectionLabel(q.Category), - MoreCommands = - [ - new CommandContextItem(new RemoveFromWatchlistCommand(instrument)), - new CommandContextItem(new ToggleFavoriteCommand(instrument)) - { - Title = isFavorite ? "Remove from Favorites" : "Add to Favorites", - }, - new CommandContextItem(new CopyTextCommand(q.FormatPrice())) { Title = "Copy price" }, - ], }; } From 07b02590443f4614286c703f3d6599ac049fe4f8 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Mon, 22 Jun 2026 03:49:58 +0300 Subject: [PATCH 15/69] wip --- .../Data/Finnhub/ApiFinnhubCandleDto.cs | 21 ++ .../Data/Finnhub/ApiFinnhubQuoteDto.cs | 1 + .../Data/Finnhub/FinnhubMarketDataProvider.cs | 67 ++++++ MarketExtension/Data/IMarketDataProvider.cs | 9 + MarketExtension/Data/MarketRepository.cs | 19 ++ .../Data/MockMarketDataProvider.cs | 33 +++ MarketExtension/Helpers/ChartHelper.cs | 119 +++++++++ MarketExtension/Models/ChartRange.cs | 85 +++++++ MarketExtension/Models/DomainCandleSeries.cs | 29 +++ MarketExtension/Models/UiCandleSeries.cs | 46 ++++ MarketExtension/Pages/FavoritesDockPage.cs | 2 +- MarketExtension/Pages/FavoritesPage.cs | 2 +- MarketExtension/Pages/SearchPage.cs | 4 +- MarketExtension/Pages/SymbolDetailPage.cs | 227 ++++++++++++++---- MarketExtension/Pages/WatchlistPage.cs | 2 +- 15 files changed, 617 insertions(+), 49 deletions(-) create mode 100644 MarketExtension/Data/Finnhub/ApiFinnhubCandleDto.cs create mode 100644 MarketExtension/Helpers/ChartHelper.cs create mode 100644 MarketExtension/Models/ChartRange.cs create mode 100644 MarketExtension/Models/DomainCandleSeries.cs create mode 100644 MarketExtension/Models/UiCandleSeries.cs 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/ApiFinnhubQuoteDto.cs b/MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs index bd8dc53..e1d347f 100644 --- a/MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs +++ b/MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs @@ -16,4 +16,5 @@ internal sealed record ApiFinnhubQuoteDto( [JsonSerializable(typeof(ApiFinnhubQuoteDto))] [JsonSerializable(typeof(ApiFinnhubSearchDto))] // /search response — see ApiFinnhubSearchDto.cs +[JsonSerializable(typeof(ApiFinnhubCandleDto))] // /stock/candle + /crypto/candle — see ApiFinnhubCandleDto.cs internal sealed partial class FinnhubJsonContext : JsonSerializerContext; diff --git a/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs b/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs index 72a4207..280f98c 100644 --- a/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs +++ b/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs @@ -138,9 +138,76 @@ private static async Task FetchQuoteAsync(DomainInstrument instrume } } + public async Task GetCandlesAsync( + DomainInstrument instrument, ChartRange range, CancellationToken ct = default) + { + 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 Http.GetAsync( + $"{endpoint}?symbol={Uri.EscapeDataString(symbol)}&resolution={resolution}" + + $"&from={fromUnix}&to={toUnix}&token={ApiKey}", 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); + // 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 diff --git a/MarketExtension/Data/IMarketDataProvider.cs b/MarketExtension/Data/IMarketDataProvider.cs index 21029c8..eba2532 100644 --- a/MarketExtension/Data/IMarketDataProvider.cs +++ b/MarketExtension/Data/IMarketDataProvider.cs @@ -20,4 +20,13 @@ Task> GetQuotesAsync( // 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/MarketRepository.cs b/MarketExtension/Data/MarketRepository.cs index 6237434..df6fa2a 100644 --- a/MarketExtension/Data/MarketRepository.cs +++ b/MarketExtension/Data/MarketRepository.cs @@ -70,4 +70,23 @@ public async Task> SearchAsync( 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 = providers.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 da9a391..21e76fe 100644 --- a/MarketExtension/Data/MockMarketDataProvider.cs +++ b/MarketExtension/Data/MockMarketDataProvider.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -48,4 +49,36 @@ .. instruments.Select(i => Seed.TryGetValue(i.Symbol, out var s) ]; return Task.FromResult(quotes); } + + public Task GetCandlesAsync( + DomainInstrument instrument, ChartRange range, CancellationToken ct = default) + { + // A deterministic random walk anchored on the seed price (or a default) 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 : 100m; + 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)); + } } diff --git a/MarketExtension/Helpers/ChartHelper.cs b/MarketExtension/Helpers/ChartHelper.cs new file mode 100644 index 0000000..cba8128 --- /dev/null +++ b/MarketExtension/Helpers/ChartHelper.cs @@ -0,0 +1,119 @@ +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. 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 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"; + + public static string CreateImageUrl(IReadOnlyList closes, bool up) => + "data:image/svg+xml;utf8," + CreateChart(closes, up); + + public static string CreateChart(IReadOnlyList closes, bool up) + { + // + // + // (gradient under the line) + // (the price line) + // + var svg = new XElement( + "svg", + new XAttribute("height", ChartHeight), + new XAttribute("width", ChartWidth)); + + 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); + } + + // 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/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/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/Pages/FavoritesDockPage.cs b/MarketExtension/Pages/FavoritesDockPage.cs index 6192975..0510b46 100644 --- a/MarketExtension/Pages/FavoritesDockPage.cs +++ b/MarketExtension/Pages/FavoritesDockPage.cs @@ -73,7 +73,7 @@ public override IListItem[] GetItems() return favorites .Select(q => (IListItem)new ListItem( - new SymbolDetailPage(new DomainInstrument(q.Symbol, q.Name, q.Category))) + new SymbolDetailPage(new DomainInstrument(q.Symbol, q.Name, q.Category), _repository)) { Title = $"{q.Symbol} {q.FormatChange()}", Subtitle = q.FormatPrice(), diff --git a/MarketExtension/Pages/FavoritesPage.cs b/MarketExtension/Pages/FavoritesPage.cs index 7f057e2..5a96cdc 100644 --- a/MarketExtension/Pages/FavoritesPage.cs +++ b/MarketExtension/Pages/FavoritesPage.cs @@ -20,7 +20,7 @@ protected override IListItem BuildRow(UiQuote q) { var instrument = new DomainInstrument(q.Symbol, q.Name, q.Category); - return new ListItem(new SymbolDetailPage(instrument)) + return new ListItem(new SymbolDetailPage(instrument, Repository)) { Title = $"★ {q.Symbol} · {q.Name}", Subtitle = $"{q.FormatPrice()} {q.FormatChange()}", diff --git a/MarketExtension/Pages/SearchPage.cs b/MarketExtension/Pages/SearchPage.cs index 1d757a9..606c1af 100644 --- a/MarketExtension/Pages/SearchPage.cs +++ b/MarketExtension/Pages/SearchPage.cs @@ -132,12 +132,12 @@ private IListItem[] SearchItems() // 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 static ListItem BuildResultItem(DomainInstrument instrument) + 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)) + return new ListItem(new SymbolDetailPage(instrument, _repository)) { Title = (isFavorite ? "★ " : "") + $"{instrument.Symbol} · {instrument.Name}", Subtitle = MembershipSubtitle(inWatchlist, isFavorite), diff --git a/MarketExtension/Pages/SymbolDetailPage.cs b/MarketExtension/Pages/SymbolDetailPage.cs index add9d6c..9836eaf 100644 --- a/MarketExtension/Pages/SymbolDetailPage.cs +++ b/MarketExtension/Pages/SymbolDetailPage.cs @@ -1,5 +1,9 @@ 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; @@ -7,21 +11,21 @@ namespace MarketExtension; // The shared per-symbol detail screen, reached by pressing Enter on any instrument row (Search, -// Watchlist, Favorites). The chart is still a PLACEHOLDER, but list management lives here now: the -// page's command bar carries the add/remove-watchlist and add/remove-favorite actions (Enter and -// Ctrl+Enter respectively), with the body showing current membership. This is the screen that owns -// list management going forward — the rows' context-menu copies are a transitional convenience. -// Built on the toolkit's ContentPage so the eventual SVG-sparkline version is a straight in-place -// upgrade (swap MarkdownContent for a FormContent). +// 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 still lives here too: the page's command bar carries the add/remove-watchlist (Enter) +// and add/remove-favorite (Ctrl+Enter) actions, labelled for the instrument's current state. // // 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 + body on every change. So toggling watchlist/favorite here (the commands KeepOpen) flips -// the buttons in place, and a change made elsewhere is reflected too. No manual refresh callback. +// 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). internal sealed partial class SymbolDetailPage : ContentPage, INotifyItemsChanged { private readonly DomainInstrument _instrument; + private readonly SymbolChartForm _chartForm; private readonly List _subscriptions = []; private event TypedEventHandler? _itemsChanged; @@ -31,9 +35,13 @@ event TypedEventHandler INotifyItemsChanged.Item add { _itemsChanged += value; - // Replay-on-subscribe paints the initial command bar/body; later pushes flip them in place. - _subscriptions.Add(WatchlistStore.Instance.Watchlist.Subscribe(_ => RefreshState())); - _subscriptions.Add(WatchlistStore.Instance.Favorites.Subscribe(_ => RefreshState())); + // 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())); + // 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(); } remove { @@ -47,9 +55,10 @@ event TypedEventHandler INotifyItemsChanged.Item protected new void RaiseItemsChanged(int totalItems = -1) => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); - public SymbolDetailPage(DomainInstrument instrument) + 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}"; @@ -59,15 +68,11 @@ public SymbolDetailPage(DomainInstrument instrument) Commands = BuildCommands(); // initial bar; refreshed again on subscribe and on every change } - public override IContent[] GetContent() => [new MarkdownContent(BuildBody())]; + public override IContent[] GetContent() => [_chartForm]; - // Push the current membership into both the command bar (set Commands → host re-reads it) and the - // body (RaiseItemsChanged → host re-calls GetContent). - private void RefreshState() - { - Commands = BuildCommands(); - RaiseItemsChanged(); - } + // 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 two list-management actions, labelled add/remove for the instrument's current state. The // first is the page's primary command (Enter), the second the secondary (Ctrl+Enter). @@ -87,31 +92,165 @@ private IContextItem[] BuildCommands() return [watchlist, favorite]; } - private string BuildBody() + // 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). + private sealed partial class SymbolChartForm : FormContent { - var inWatchlist = WatchlistStore.Instance.IsInWatchlist(_instrument.Symbol); - var isFavorite = WatchlistStore.Instance.IsFavorite(_instrument.Symbol); - var status = (inWatchlist, isFavorite) switch + 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; + + public SymbolChartForm(DomainInstrument instrument, MarketRepository repository) { - (true, true) => "On your watchlist · ★ Favorite", - (true, false) => "On your watchlist", - (false, true) => "★ Favorite", - (false, false) => "Not tracked yet", - }; - - return - $"# {_instrument.Symbol}\n\n" + - $"**{_instrument.Name}** · {CategoryLabel(_instrument.Category)}\n\n" + - $"_{status}_\n\n" + - "---\n\n" + - "📈 **Live price chart coming soon.**"; - } + _instrument = instrument; + _repository = repository; + TemplateJson = Template; + DataJson = LoadingData(_range); // placeholder until the first fetch lands + } - private static string CategoryLabel(AssetCategory category) => category switch - { - AssetCategory.Stock => "Stock", - AssetCategory.Crypto => "Crypto", - AssetCategory.Currency => "Currency", - _ => "Other", - }; + // 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. + 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; + lock (_gate) + _cache.TryGetValue(range, out cached); + + if (cached is not null) + { + Render(cached); + return; + } + + int generation; + lock (_gate) + generation = ++_generation; + + DataJson = LoadingData(range); // show a loading state for the newly selected 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) => DataJson = BuildData(UiCandleSeries.From(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; + } + + private string LoadingData(ChartRange range) => new JsonObject + { + ["symbol"] = _instrument.Symbol, + ["name"] = _instrument.Name, + ["price"] = "—", + ["change"] = string.Empty, + ["changeColor"] = "Default", + ["chartUrl"] = string.Empty, + ["hasChart"] = false, + ["showStatus"] = true, + ["statusText"] = $"Loading {range.Label()} chart…", + }.ToJsonString(); + + private string BuildData(UiCandleSeries ui) + { + var hasChart = ui.HasData; + return new JsonObject + { + ["symbol"] = _instrument.Symbol, + ["name"] = _instrument.Name, + ["price"] = ui.FormatPrice(), + ["change"] = ui.FormatRangeChange(), + ["changeColor"] = !hasChart ? "Default" : ui.IsUp ? "Good" : "Attention", + ["chartUrl"] = ui.ChartImageUrl(), + ["hasChart"] = hasChart, + ["showStatus"] = !hasChart, + ["statusText"] = hasChart + ? string.Empty + : "No chart data — historical candles require a paid Finnhub plan.", + }.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" } } + ] + } + ] + } + """; + } } diff --git a/MarketExtension/Pages/WatchlistPage.cs b/MarketExtension/Pages/WatchlistPage.cs index 0646952..e775039 100644 --- a/MarketExtension/Pages/WatchlistPage.cs +++ b/MarketExtension/Pages/WatchlistPage.cs @@ -26,7 +26,7 @@ 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)) + return new ListItem(new SymbolDetailPage(instrument, Repository)) { Title = (isFavorite ? "★ " : "") + $"{q.Symbol} · {q.Name}", Subtitle = $"{q.FormatPrice()} {q.FormatChange()}", From c48569c14f9b6006cae0cabbc0d7efe1172db50f Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Mon, 22 Jun 2026 04:07:52 +0300 Subject: [PATCH 16/69] wip --- CLAUDE.md | 131 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 101 insertions(+), 30 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 19aabc7..bca2819 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,20 +56,26 @@ 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`). -- `Ui*` — presentation; the ONLY place `FormatPrice()`/`FormatChange()` live (`UiQuote`). -- `AssetCategory` (enum) stays **unprefixed** — shared vocabulary across all layers. +- `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. No provider for a category → `IsValid:false` placeholder. | -| `Data/IMarketDataProvider.cs` | ONE data source: `bool Supports(AssetCategory)` + `GetQuotesAsync(instruments, ct)` + `SearchAsync(query, ct)` (free-text symbol lookup → `DomainInstrument`s, **identity only, no prices**; a provider that can't search returns `[]`). | +| `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. | +| `Data/IMarketDataProvider.cs` | ONE data source: `bool Supports(AssetCategory)` + `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/Finnhub/FinnhubMarketDataProvider.cs` | Active provider (`Supports` Stock+Crypto). Maps `ApiFinnhubQuoteDto` → `DomainQuote`; `SearchAsync` calls `/search` (US equities only — see Finnhub specifics). | | `Data/MockMarketDataProvider.cs` | Offline fallback (`Supports` all). `SearchAsync` filters its seed keys. | | `Data/InstrumentCatalog.cs` | Static `DomainInstrument` defaults — the **first-run seed** for `WatchlistStore` (no longer always-shown; removable once seeded). | @@ -77,12 +83,22 @@ ApiFinnhubQuoteDto ─(provider maps)→ DomainQuote ─(repository routes+merge | `Helpers/StateFlow.cs` | A tiny **Kotlin-StateFlow analog** (hand-rolled, no System.Reactive — keeps the AOT/trim build clean): `StateFlow` (read-only `Value` + `Subscribe` with **replay-on-subscribe** + distinct-until-changed) and writable `MutableStateFlow` (`Update`). Has **subscriber-count hooks** `OnActive`/`OnInactive` (0↔1 transitions = the WhileSubscribed / Rx `RefCount()` seam) for the future ticker poll loop. Plus `InstrumentListComparer` (symbol-sequence dedup for the store's two flows). | | `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. | -| `Models/{AssetCategory,DomainInstrument,DomainQuote,UiQuote}.cs` | the model layers | +| `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). | +| `Models/{AssetCategory,DomainInstrument,DomainQuote,UiQuote}.cs` | the quote model layers | +| `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. Only caller: `UiCandleSeries`. | +| `Pages/SymbolDetailPage.cs` | Shared per-symbol screen: nested `SymbolChartForm : FormContent` (adaptive-card chart + range tabs) + the list-management command bar. ⚠️ See the open focus issue in the chart section. | **To add a provider** (e.g. forex): implement `IMarketDataProvider` (`Supports`, `GetQuotesAsync`, -and `SearchAsync` — return `[]` if it can't search), map its `Api*` DTO → `DomainQuote`, and register -it in `MarketExtensionCommandsProvider`: -`new MarketRepository(new FinnhubMarketDataProvider(), new YourProvider())`. **Zero UI changes.** +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; a Twelve Data provider would +map the same `CandleInterval` to `5min/30min/1h/1day/1week`. **Finnhub specifics:** - Base `https://finnhub.io/api/v1`, endpoint `/quote`. Free tier ~60 calls/min, ~300/day. @@ -90,6 +106,13 @@ it in `MarketExtensionCommandsProvider`: re-add via a paid plan or a keyless FX provider (e.g. Frankfurter) behind the same seam. - 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 @@ -135,6 +158,13 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). 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 + endpoint is **premium-gated**, so real charts need a paid key (free key → "requires paid plan"; the mock + provider draws synthetic candles for offline preview). ⚠️ **Two known UI issues remain** — Enter steals + focus to the "1D" tab, and the chart flickers on range switch — both diagnosed with fixes sketched in the + "Symbol detail + live chart" section. The user reverted the attempted Enter fix to rethink it. ⚠️ Working tree is **uncommitted** (on `repo`). - **Next up:** **live price polling** — auto-refresh prices on a timer while a surface is visible, **default 60 s, configurable in settings**. The `StateFlow` subscriber-count hooks (`OnActive`/ @@ -156,7 +186,8 @@ All three command titles share the **`Markets ` prefix** so they group together namespace) when searching the Command Palette root: **Enter on any row opens the shared `Pages/SymbolDetailPage.cs`** — the per-symbol detail screen -(`ContentPage`; chart still a placeholder), which is **the single place for list management**. Its +(`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. @@ -273,15 +304,48 @@ and inherits the same live-refresh story as the favorites band (the polling + pu **Caveats:** assumes a single quote currency (USD); mixed-currency holdings need FX conversion (deferred — ties to the FX-provider item). Exclude `IsValid:false` quotes from totals (or show them as unpriced). -### Symbol detail + live chart — for ANY instrument (future wishlist) - -**App-wide, not portfolio-specific.** Every instrument — from search results, the watchlist, favorites, -**and** portfolio rows, plus **every dock band** — should open a per-symbol **detail screen with a small -live line chart** of its price (the way Performance Monitor's bands open a CPU/RAM graph). **One** shared -`SymbolDetailPage` backs both the **drill-in (Enter, or a context item, on any row)** and the -**dock-item click**. Investigated against its on-disk source -(`C:\Users\jarla\code\PowerToys\src\modules\cmdpal\ext\Microsoft.CmdPal.Ext.PerformanceMonitor\`) — the -mechanism is simpler than it looks: +### Symbol detail + live chart — for ANY instrument (CHART DONE; 2 open UI issues) + +**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:** candles are premium → on the current free key every chart 403s → the card shows + "requires a paid Finnhub plan". No code change is needed when a paid key lands (candles read the same + `Secrets.FinnhubApiKey`). To preview rendering now, temporarily point the repo at + `new MarketRepository(new MockMarketDataProvider())` (don't ship — mock also feeds search). + +**⚠️ Two open UI issues (why we paused — `SymbolDetailPage` is currently the single-content version):** +1. **Enter activates "1D" instead of add-to-watchlist.** With a single `FormContent`, CmdPal sets + `OnlyControlOnPage = true` and **programmatically focuses the card's first focusable element** on + load — the 1D `Action.Submit` — so Enter hits it. (See `ContentPageViewModel` setting + `OnlyControlOnPage = (content.Count == 1)`, and `ContentFormControl.OnFrameworkElementLoaded` → + `FindFirstFocusableElement().Focus()`.) **Attempted fix (reverted as "a bit glitchy"):** return a + *second* content item (a membership/hint `MarkdownContent`) so `OnlyControlOnPage` is false → the + host skips the auto-focus → focus stays in the search box → Enter = page primary command. +2. **Flicker when switching ranges.** Every `DataJson` write rebuilds the whole card + (`ContentFormViewModel.RenderCard` → `AdaptiveCard.FromJsonString`; `ContentFormControl.DisplayCard` + clears + re-adds children). `Load()` writes `DataJson` **twice** per tap (a "Loading…" state, then + the result), so you get a double teardown/rebuild — a visible flash, worst with the near-instant mock + provider. **Fix idea:** drop the intermediate loading write on tab switches (only show "Loading" on + the very first load when no chart exists yet) and keep the prior chart visible during the swap. + +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 @@ -313,17 +377,18 @@ mechanism is simpler than it looks: `${…graphUrl}` image; copy the shape. - MediaControls `…/Pages/DockHeadItem.cs` — the event-driven content-band variant. -**For market data specifically:** -- Data source = **sample the price into a rolling per-symbol buffer each poll tick** (exactly Perf - Monitor's model — it samples a live metric, it does not fetch history). So this **depends on the live - polling work above**; a paid `/stock/candle` (premium-gated on the free tier) could backfill real history - later. Keep the buffer where the cache/sampler lives (repository-side). -- Add **one** `Pages/SymbolDetailPage.cs` (`IContentPage`) that renders the SVG sparkline (plus - open/high/low/prev-close, already in `/quote`) for any `DomainInstrument`, recolored green/red by day - direction. Wire **every** surface to it: **Enter or a context item on rows** in Search, Watchlist, - Favorites, and Portfolio, **and the click target of every dock button** (favorites dock + portfolio - dock). It is the single symbol-detail screen, shared everywhere — explicitly not a portfolio feature. -- Port `ChartHelper` mostly as-is (already pure-string SVG); feed it our `UiQuote`/price series. +**Refinements still open (beyond the 2 UI issues above):** +- **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. (This is the flip side of fixing issue #1 by keeping focus in the search box.) +- **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 1D auto-refresh** ties into the pending price-polling work; v1 fetches once per tab tap. +- Not yet wired to a future `PortfolioPage` (doesn't exist yet). ### Asset logos as icons, app-wide (future wishlist) @@ -428,6 +493,12 @@ 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. Return **≥2 content items** to suppress the auto-focus. (Hit by the symbol-detail chart — full +> trace in the "Symbol detail + live chart" section.) + ### DynamicListPage Pattern ```csharp From e74e9784d9db0e292b192e17dd58e3cdf5c1e8a5 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Tue, 23 Jun 2026 00:43:47 +0300 Subject: [PATCH 17/69] wip --- CLAUDE.md | 69 +++++++++----- MarketExtension/Pages/SymbolDetailPage.cs | 105 ++++++++++++++-------- 2 files changed, 114 insertions(+), 60 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bca2819..39bd1f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ the same three layers and the same provider seam — see the "Symbol detail + li | `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. Only caller: `UiCandleSeries`. | -| `Pages/SymbolDetailPage.cs` | Shared per-symbol screen: nested `SymbolChartForm : FormContent` (adaptive-card chart + range tabs) + the list-management command bar. ⚠️ See the open focus issue in the chart section. | +| `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 @@ -162,10 +162,9 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). Finnhub candle history behind a provider-agnostic seam (`GetCandlesAsync`, `ChartRange` + `CandleInterval`, `DomainCandleSeries` / `UiCandleSeries`, ported `Helpers/ChartHelper.cs`, `ApiFinnhubCandleDto`). The endpoint is **premium-gated**, so real charts need a paid key (free key → "requires paid plan"; the mock - provider draws synthetic candles for offline preview). ⚠️ **Two known UI issues remain** — Enter steals - focus to the "1D" tab, and the chart flickers on range switch — both diagnosed with fixes sketched in the - "Symbol detail + live chart" section. The user reverted the attempted Enter fix to rethink it. - ⚠️ Working tree is **uncommitted** (on `repo`). + provider draws synthetic candles for offline preview). 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). ⚠️ Working tree is **uncommitted** (on `repo`). - **Next up:** **live price polling** — auto-refresh prices on a timer while a surface is visible, **default 60 s, configurable in settings**. The `StateFlow` subscriber-count hooks (`OnActive`/ `OnInactive`) are already in place as the WhileSubscribed/`RefCount()` seam for a `PolledStateFlow` @@ -304,7 +303,7 @@ and inherits the same live-refresh story as the favorites band (the polling + pu **Caveats:** assumes a single quote currency (USD); mixed-currency holdings need FX conversion (deferred — ties to the FX-provider item). Exclude `IsValid:false` quotes from totals (or show them as unpriced). -### Symbol detail + live chart — for ANY instrument (CHART DONE; 2 open UI issues) +### 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 @@ -329,20 +328,37 @@ through a new provider seam — see the Finnhub candle spec + the candle layer f `Secrets.FinnhubApiKey`). To preview rendering now, temporarily point the repo at `new MarketRepository(new MockMarketDataProvider())` (don't ship — mock also feeds search). -**⚠️ Two open UI issues (why we paused — `SymbolDetailPage` is currently the single-content version):** -1. **Enter activates "1D" instead of add-to-watchlist.** With a single `FormContent`, CmdPal sets - `OnlyControlOnPage = true` and **programmatically focuses the card's first focusable element** on - load — the 1D `Action.Submit` — so Enter hits it. (See `ContentPageViewModel` setting - `OnlyControlOnPage = (content.Count == 1)`, and `ContentFormControl.OnFrameworkElementLoaded` → - `FindFirstFocusableElement().Focus()`.) **Attempted fix (reverted as "a bit glitchy"):** return a - *second* content item (a membership/hint `MarkdownContent`) so `OnlyControlOnPage` is false → the - host skips the auto-focus → focus stays in the search box → Enter = page primary command. -2. **Flicker when switching ranges.** Every `DataJson` write rebuilds the whole card +**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). `Load()` writes `DataJson` **twice** per tap (a "Loading…" state, then - the result), so you get a double teardown/rebuild — a visible flash, worst with the near-instant mock - provider. **Fix idea:** drop the intermediate loading write on tab switches (only show "Loading" on - the very first load when no chart exists yet) and keep the prior chart visible during the swap. + 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\`): @@ -377,11 +393,12 @@ For reference, the Perf Monitor source this chart was ported from `${…graphUrl}` image; copy the shape. - MediaControls `…/Pages/DockHeadItem.cs` — the event-driven content-band variant. -**Refinements still open (beyond the 2 UI issues above):** +**Refinements still open (flicker is fixed; the Enter/focus bug above is still open):** - **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. (This is the flip side of fixing issue #1 by keeping focus in the search box.) + 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 @@ -496,8 +513,14 @@ Do **not** call `IsLoading = true` + `Task.Run(Load)` from the constructor — t > **`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. Return **≥2 content items** to suppress the auto-focus. (Hit by the symbol-detail chart — full -> trace in the "Symbol detail + live chart" section.) +> 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 diff --git a/MarketExtension/Pages/SymbolDetailPage.cs b/MarketExtension/Pages/SymbolDetailPage.cs index 9836eaf..25f3cb3 100644 --- a/MarketExtension/Pages/SymbolDetailPage.cs +++ b/MarketExtension/Pages/SymbolDetailPage.cs @@ -14,14 +14,22 @@ namespace MarketExtension; // 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 still lives here too: the page's command bar carries the add/remove-watchlist (Enter) -// and add/remove-favorite (Ctrl+Enter) actions, labelled for the instrument's current state. +// +// 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). +// 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). internal sealed partial class SymbolDetailPage : ContentPage, INotifyItemsChanged { private readonly DomainInstrument _instrument; @@ -74,8 +82,8 @@ public SymbolDetailPage(DomainInstrument instrument, MarketRepository repository // Commands raises the property change the host watches, so the add/remove buttons flip in place. private void RefreshCommands() => Commands = BuildCommands(); - // The two list-management actions, labelled add/remove for the instrument's current state. The - // first is the page's primary command (Enter), the second the secondary (Ctrl+Enter). + // The two list-management actions, labelled add/remove for the instrument's current state. The first + // is the page's primary command (Enter), the second the secondary (Ctrl+Enter). private IContextItem[] BuildCommands() { var inWatchlist = WatchlistStore.Instance.IsInWatchlist(_instrument.Symbol); @@ -104,13 +112,14 @@ private sealed partial class SymbolChartForm : FormContent 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 = LoadingData(_range); // placeholder until the first fetch lands + 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. @@ -127,7 +136,8 @@ public void Start() } // 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. + // 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; @@ -140,8 +150,12 @@ 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) { @@ -153,7 +167,12 @@ private void Load(ChartRange range) lock (_gate) generation = ++_generation; - DataJson = LoadingData(range); // show a loading state for the newly selected range + // 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); @@ -169,7 +188,15 @@ private void Load(ChartRange range) }); } - private void Render(DomainCandleSeries series) => DataJson = BuildData(UiCandleSeries.From(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); + } private static ChartRange? ParseRange(string? json) { @@ -192,36 +219,40 @@ private void Load(ChartRange range) return null; } - private string LoadingData(ChartRange range) => new JsonObject - { - ["symbol"] = _instrument.Symbol, - ["name"] = _instrument.Name, - ["price"] = "—", - ["change"] = string.Empty, - ["changeColor"] = "Default", - ["chartUrl"] = string.Empty, - ["hasChart"] = false, - ["showStatus"] = true, - ["statusText"] = $"Loading {range.Label()} chart…", - }.ToJsonString(); - - private string BuildData(UiCandleSeries ui) + // 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 hasChart = ui.HasData; - return new JsonObject + var data = new JsonObject { ["symbol"] = _instrument.Symbol, ["name"] = _instrument.Name, - ["price"] = ui.FormatPrice(), - ["change"] = ui.FormatRangeChange(), - ["changeColor"] = !hasChart ? "Default" : ui.IsUp ? "Good" : "Attention", - ["chartUrl"] = ui.ChartImageUrl(), - ["hasChart"] = hasChart, - ["showStatus"] = !hasChart, - ["statusText"] = hasChart - ? string.Empty - : "No chart data — historical candles require a paid Finnhub plan.", - }.ToJsonString(); + }; + + 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"] = $"Loading {range.Label()} chart…"; + 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 + : "No chart data — historical candles require a paid Finnhub plan."; + return data.ToJsonString(); } // Static card structure; values bind from DataJson. The five Action.Submit buttons each carry From ef108bb9bb26df3941dd86a00693bd095ddc5c1b Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Tue, 23 Jun 2026 00:59:59 +0300 Subject: [PATCH 18/69] wip --- .gitignore | 3 - CLAUDE.md | 29 ++++--- .../Data/Finnhub/FinnhubMarketDataProvider.cs | 28 ++++++- MarketExtension/MarketExtension.csproj | 18 +---- .../MarketExtensionCommandsProvider.cs | 6 +- .../Settings/MarketSettingsManager.cs | 76 +++++++++++++++++++ MarketExtension/secrets.props.template | 10 --- 7 files changed, 124 insertions(+), 46 deletions(-) create mode 100644 MarketExtension/Settings/MarketSettingsManager.cs delete mode 100644 MarketExtension/secrets.props.template diff --git a/.gitignore b/.gitignore index 52bccc2..83632d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,6 @@ # Signing certificate — never commit *.pfx -# Local API keys — the .NET "local.properties" (build bakes it into a generated const) -MarketExtension/secrets.props - # Build output bin/ obj/ diff --git a/CLAUDE.md b/CLAUDE.md index 39bd1f1..67f29b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,14 +130,18 @@ across multiple `partial` declarations of one context — it emits a colliding h ("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 (secrets.props — the .NET "local.properties") - -- The Finnhub key lives in **gitignored** `MarketExtension/secrets.props`. Fresh clone: copy - `MarketExtension/secrets.props.template` → `secrets.props` and paste the key. -- The csproj `GenerateSecrets` target bakes it into a generated `Secrets.FinnhubApiKey` const (the - BuildConfig analog); `FinnhubMarketDataProvider` reads that. Missing file → empty key, build still succeeds. -- Caveat: the key is compiled into the shipped MSIX (extractable). True per-user secrecy = the - deferred bring-your-own-key in Command Palette settings. +## API Key (runtime setting — no build-time key) + +- 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 @@ -151,7 +155,8 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). ## Current Status / Next Steps - **Done:** layered data architecture; live Finnhub provider; `MarketRepository` coordinator; - `secrets.props` key handling; tagged logging; **Enter-only Finnhub `/search`**; **persistent + **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 @@ -323,9 +328,9 @@ through a new provider seam — see the Finnhub candle spec + the candle layer f 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:** candles are premium → on the current free key every chart 403s → the card shows +- **Gating:** candles are premium → on a free key every chart 403s → the card shows "requires a paid Finnhub plan". No code change is needed when a paid key lands (candles read the same - `Secrets.FinnhubApiKey`). To preview rendering now, temporarily point the repo at + settings `FinnhubApiKey`). To preview rendering now, temporarily point the repo at `new MarketRepository(new MockMarketDataProvider())` (don't ship — mock also feeds search). **State of the two UI issues: flicker FIXED; the Enter/focus bug is UNSOLVED (left as-is, documented).** @@ -660,5 +665,5 @@ This was scaffolded from AdbExtension. Already changed: COM GUID (`6b38c9aa-bbee - **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. -- **API key**: create `MarketExtension/secrets.props` from `secrets.props.template` and paste your Finnhub key (gitignored; see the "API Key" section above). +- **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/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs b/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs index 280f98c..663a263 100644 --- a/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs +++ b/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs @@ -14,9 +14,11 @@ namespace MarketExtension; // different provider can replace it without touching the repository or the UI. internal sealed class FinnhubMarketDataProvider : IMarketDataProvider { - // Loaded from the gitignored secrets.props at build time (see MarketExtension.csproj's - // GenerateSecrets target + secrets.props.template). TODO: move to per-user settings / BYO-key. - private const string ApiKey = Secrets.FinnhubApiKey; + // 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/") }; @@ -27,6 +29,14 @@ internal sealed class FinnhubMarketDataProvider : IMarketDataProvider 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); @@ -42,6 +52,12 @@ public async Task> SearchAsync(string query, Can 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 @@ -141,6 +157,12 @@ private static async Task FetchQuoteAsync(DomainInstrument instrume 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"; diff --git a/MarketExtension/MarketExtension.csproj b/MarketExtension/MarketExtension.csproj index 5fb4beb..5118dfb 100644 --- a/MarketExtension/MarketExtension.csproj +++ b/MarketExtension/MarketExtension.csproj @@ -105,20 +105,8 @@ true - - - - - - - <_SecretsCs>$(IntermediateOutputPath)Secrets.g.cs - - - - - - + diff --git a/MarketExtension/MarketExtensionCommandsProvider.cs b/MarketExtension/MarketExtensionCommandsProvider.cs index b7d12bc..8dac372 100644 --- a/MarketExtension/MarketExtensionCommandsProvider.cs +++ b/MarketExtension/MarketExtensionCommandsProvider.cs @@ -19,9 +19,9 @@ public MarketExtensionCommandsProvider() DisplayName = "Market Extension"; Icon = new IconInfo("https://github.com/favicon.ico"); - // 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 (Finnhub API key + price refresh interval) in the + // Command Palette Settings UI. See Settings/MarketSettingsManager.cs. + Settings = MarketSettingsManager.Instance.Settings; // Three top-level screens: search (the default entry), the tracked watchlist, and the // curated favorites. All share the one repository; favorites also feed the dock band below. diff --git a/MarketExtension/Settings/MarketSettingsManager.cs b/MarketExtension/Settings/MarketSettingsManager.cs new file mode 100644 index 0000000..bd60d57 --- /dev/null +++ b/MarketExtension/Settings/MarketSettingsManager.cs @@ -0,0 +1,76 @@ +using System; +using System.Globalization; +using System.IO; +using Microsoft.CommandPalette.Extensions.Toolkit; + +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 today: +// - Finnhub API key. This is the ONLY source of the key — there is no built-in/baked key; an +// empty value means the Finnhub provider returns no prices until the user supplies their own. +// - Price refresh interval in minutes (the seam for the upcoming live-price polling; 0 = off). +// +// Naming note: provider keys are named per-provider (FinnhubApiKey, not a generic ApiKey) because +// additional providers (e.g. a forex source) will each get their own key setting here later. +// +// Note: the API key is NOT masked — the toolkit's TextSetting has no password mode. That's +// acceptable here: settings live in a local plaintext JSON file anyway. +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 = 5; + + private readonly TextSetting _finnhubApiKey = new("finnhubApiKey", string.Empty) + { + Label = "Finnhub API key", + Description = "Your Finnhub API key. Required — prices won't load until this is set. " + + "Get a free key at https://finnhub.io.", + Placeholder = "Paste your Finnhub API key", + }; + + private readonly TextSetting _refreshMinutes = new( + "refreshMinutes", DefaultRefreshMinutes.ToString(CultureInfo.InvariantCulture)) + { + Label = "Price refresh interval (minutes)", + Description = "How often to refresh live prices while a Markets screen is open. " + + "Enter 0 to turn auto-refresh off. Lower values use more of the Finnhub rate limit.", + Placeholder = DefaultRefreshMinutes.ToString(CultureInfo.InvariantCulture), + }; + + // 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; + + // 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); + + private MarketSettingsManager() + { + FilePath = Path.Combine(Utilities.BaseSettingsPath("Microsoft.CmdPal"), "market.settings.json"); + Settings.Add(_finnhubApiKey); + Settings.Add(_refreshMinutes); + LoadSettings(); + Settings.SettingsChanged += (_, _) => SaveSettings(); + } +} diff --git a/MarketExtension/secrets.props.template b/MarketExtension/secrets.props.template deleted file mode 100644 index 0ad20f1..0000000 --- a/MarketExtension/secrets.props.template +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - From 693150e145d505da3f8079adeb9c26aad59c36c1 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Tue, 23 Jun 2026 01:02:13 +0300 Subject: [PATCH 19/69] wip --- CLAUDE.md | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 67f29b5..7a620c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,12 +170,12 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). provider draws synthetic candles for offline preview). 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). ⚠️ Working tree is **uncommitted** (on `repo`). -- **Next up:** **live price polling** — auto-refresh prices on a timer while a surface is visible, - **default 60 s, configurable in settings**. The `StateFlow` subscriber-count hooks (`OnActive`/ - `OnInactive`) are already in place as the WhileSubscribed/`RefCount()` seam for a `PolledStateFlow` - (designed below). -- **Deferred:** FX provider (keyless Frankfurter); settings UI for bring-your-own-key; rate-limit (429) - + richer error UX; crypto/FX in symbol search. +- **Next up:** **live price polling** — auto-refresh prices on a timer while a surface is visible. The + **refresh-interval setting is already built** (`MarketSettingsManager`, in minutes, default 5, 0 = off); + what remains is the poll loop itself. The `StateFlow` subscriber-count hooks (`OnActive`/`OnInactive`) + are already in place as the WhileSubscribed/`RefCount()` seam for a `PolledStateFlow` (designed below). +- **Deferred:** FX provider (keyless Frankfurter); rate-limit (429) + richer error UX; crypto/FX in + symbol search. - **Wishlist:** a **portfolio** screen + its own dock band (holdings, total value, daily P&L); a **per-symbol detail screen + live chart for any ticker** (drill-in from any list row, clickable from any dock item); and **official asset logos as icons app-wide** (replacing today's generic copy/glyph icons). @@ -225,7 +225,8 @@ the user gets explicit feedback and can keep editing. Today every priced surface fetches **once** when it becomes visible (the StateFlow replay-on-subscribe that drives the first price load). The next iteration auto-refreshes on a timer while a surface is -visible: **default 60 s, configurable in settings** (including an "Off" choice). +visible: **default 5 min, configurable in settings** (0 = off). The setting itself is already built (see +the Settings bullet below); only the poll loop remains. - **Where:** `Pages/PricedListPage.cs` (covers Markets Watchlist + Markets Favorites) and `Pages/FavoritesDockPage.cs`. `SearchPage` is exempt — its results are identity-only, no prices. @@ -243,17 +244,18 @@ visible: **default 60 s, configurable in settings** (including an "Off" choice). favorites `PolledStateFlow`: the refcount keeps it polling while *either* is visible and stops only when *both* are gone — no manual coordination. (Refinements to add then: a small stop-timeout so bouncing between pages doesn't thrash the loop, and a generation token guarding rapid resubscribe.) -- **Settings:** add `Settings/MarketSettingsManager.cs` — a `JsonSettingsManager` singleton modeled on - `reference/settings/AdbSettingsManager.cs` (`FilePath` = `…/market.settings.json`, `Settings.Add(...)`, - `LoadSettings()`, `Settings.SettingsChanged += SaveSettings`). Expose a refresh-interval setting (a - `ChoiceSetSetting` of presets — `Off / 30 s / 60 s / 5 m` — or a numeric `TextSetting` defaulting to - `60`). Wire it in `MarketExtensionCommandsProvider` via `Settings = MarketSettingsManager.Instance.Settings;` - (the commented-out hook is already there). The poll loop reads `MarketSettingsManager.Instance.RefreshIntervalSeconds`. +- **Settings (BUILT).** `Settings/MarketSettingsManager.cs` is a `JsonSettingsManager` singleton + (`FilePath` = `…/market.settings.json`), wired in `MarketExtensionCommandsProvider` via + `Settings = MarketSettingsManager.Instance.Settings;`. The refresh interval is a numeric `TextSetting` + **in minutes, default 5** (0 = off), surfaced as `RefreshMinutes` / `RefreshInterval` (TimeSpan) / + `AutoRefreshEnabled`. The poll loop should read `MarketSettingsManager.Instance.RefreshInterval` each + tick. (The same manager also holds the Finnhub API key — see the "API Key" section.) - ⚠️ **Rate-limit tension (design around this):** Finnhub free tier is **~60 calls/min AND ~300/day**, - and `GetQuotesAsync` issues **one `/quote` call per instrument**. Polling N instruments every 60 s = N - calls/min — e.g. 6 favorites = 360 calls/hour, which **exhausts the ~300/day budget in under an hour**. - 60 s is fine for short sessions but not sustained use. Mitigations to ship alongside: the configurable - interval + an **Off** escape, polling **only while visible** (the lifecycle guarantees this), and real + and `GetQuotesAsync` issues **one `/quote` call per instrument**. Polling N instruments at interval T: + at a low T this burns the budget fast — e.g. 6 favorites every 60 s = 360 calls/hour, which + **exhausts the ~300/day budget in under an hour**. The **5-min default is much gentler** (6 favorites = + ~72 calls/hour), but a user can still set a low interval. Mitigations: the configurable interval + an + **Off** (0) escape, polling **only while visible** (the lifecycle guarantees this), and real **429 handling** (back off; surface a "rate-limited" state rather than blanking prices). A batched-quote provider or cheaper data source would relax this later. From ef8faea419c0c30c4adfe5c4be15d4cc3e57c4fb Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Tue, 23 Jun 2026 01:34:51 +0300 Subject: [PATCH 20/69] wip --- CLAUDE.md | 113 +++++++++++++-------- MarketExtension/Helpers/PollTicker.cs | 93 +++++++++++++++++ MarketExtension/Helpers/StateFlow.cs | 12 ++- MarketExtension/Pages/FavoritesDockPage.cs | 19 ++++ MarketExtension/Pages/PricedListPage.cs | 42 +++++++- 5 files changed, 226 insertions(+), 53 deletions(-) create mode 100644 MarketExtension/Helpers/PollTicker.cs diff --git a/CLAUDE.md b/CLAUDE.md index 7a620c3..20b0bc0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,8 @@ the same three layers and the same provider seam — see the "Symbol detail + li | `Data/MockMarketDataProvider.cs` | Offline fallback (`Supports` all). `SearchAsync` filters its seed keys. | | `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** (hand-rolled, no System.Reactive — keeps the AOT/trim build clean): `StateFlow` (read-only `Value` + `Subscribe` with **replay-on-subscribe** + distinct-until-changed) and writable `MutableStateFlow` (`Update`). Has **subscriber-count hooks** `OnActive`/`OnInactive` (0↔1 transitions = the WhileSubscribed / Rx `RefCount()` seam) for the future ticker poll loop. Plus `InstrumentListComparer` (symbol-sequence dedup for the store's two flows). | +| `Helpers/StateFlow.cs` | A tiny **Kotlin-StateFlow analog** (hand-rolled, no System.Reactive — keeps the AOT/trim build clean): `StateFlow` (read-only `Value` + `Subscribe` with **replay-on-subscribe** + distinct-until-changed; a `Subscribe(onNext, replayOnSubscribe:false)` overload opts out of the replay — used by the poll ticker) and writable `MutableStateFlow` (`Update`). Has **subscriber-count hooks** `OnActive`/`OnInactive` (0↔1 transitions = the WhileSubscribed / Rx `RefCount()` seam) driving the ticker poll loop. Plus `InstrumentListComparer` (symbol-sequence dedup for the store's two flows). | +| `Helpers/PollTicker.cs` | The **live-price poll ticker** — the concrete `PolledStateFlow` the `OnActive`/`OnInactive` seam was built for. A process-wide singleton `StateFlow` that emits an incrementing tick on a `Task.Delay` loop **while any priced surface is subscribed** (timer starts in `OnActive`, cancels via CTS in `OnInactive`). Re-reads `MarketSettingsManager` each iteration (interval change applies without reload; `0` = off idles on a 30 s re-check). Priced surfaces subscribe with `replayOnSubscribe:false` and silently re-price on each tick. | | `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). | @@ -170,12 +171,19 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). provider draws synthetic candles for offline preview). 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). ⚠️ Working tree is **uncommitted** (on `repo`). -- **Next up:** **live price polling** — auto-refresh prices on a timer while a surface is visible. The - **refresh-interval setting is already built** (`MarketSettingsManager`, in minutes, default 5, 0 = off); - what remains is the poll loop itself. The `StateFlow` subscriber-count hooks (`OnActive`/`OnInactive`) - are already in place as the WhileSubscribed/`RefCount()` seam for a `PolledStateFlow` (designed below). -- **Deferred:** FX provider (keyless Frankfurter); rate-limit (429) + richer error UX; crypto/FX in - symbol search. +- **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 `StateFlow` ticker using the `OnActive`/`OnInactive` + subscriber-count seam), the new `Subscribe(replayOnSubscribe:false)` overload, 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 5 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's live 1D refresh stays deferred (see below). See "Live price polling (done)". +- **Deferred:** FX provider (keyless Frankfurter); rate-limit (429) **back-off** + richer error UX + (the keep-last-good guard is a first step, not the full story); crypto/FX in symbol search; the + **symbol-detail chart's live 1D auto-refresh** (still one fetch per tab tap); a poll-timer **debounce** + so navigating directly between two priced pages doesn't reset the interval (a pinned dock masks it). - **Wishlist:** a **portfolio** screen + its own dock band (holdings, total value, daily P&L); a **per-symbol detail screen + live chart for any ticker** (drill-in from any list row, clickable from any dock item); and **official asset logos as icons app-wide** (replacing today's generic copy/glyph icons). @@ -221,43 +229,55 @@ just **mutates the store** (no manual UI callback: the flows re-render every liv **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 (next — designed, not built) - -Today every priced surface fetches **once** when it becomes visible (the StateFlow replay-on-subscribe -that drives the first price load). The next iteration auto-refreshes on a timer while a surface is -visible: **default 5 min, configurable in settings** (0 = off). The setting itself is already built (see -the Settings bullet below); only the poll loop remains. - -- **Where:** `Pages/PricedListPage.cs` (covers Markets Watchlist + Markets Favorites) and - `Pages/FavoritesDockPage.cs`. `SearchPage` is exempt — its results are identity-only, no prices. -- **Lifecycle = the StateFlow subscriber-count seam (already built).** `Helpers/StateFlow.cs` already - fires `OnActive()` on the 0→1 subscriber transition and `OnInactive()` on 1→0 — Kotlin's - WhileSubscribed / Rx `RefCount()`. Subclass it as `PolledStateFlow : StateFlow` that **starts a - poll loop in `OnActive` and cancels it in `OnInactive`**, so polling runs only while something is - watching. (Surfaces already Subscribe/Dispose on the `INotifyItemsChanged` add/remove lifecycle, so - the refcount tracks visibility for free.) -- **Mechanism:** a `PeriodicTimer` in an async loop guarded by a `CancellationTokenSource` created in - `OnActive` and cancelled/disposed in `OnInactive`. Re-read the interval from settings each tick (so - changes apply without a reload); the loop is naturally non-overlapping (linear `await` per tick). An - immediate first fetch covers the initial paint; the timer drives the rest. -- **Shared-source refcount comes free.** Point both `FavoritesPage` and `FavoritesDockPage` at **one** - favorites `PolledStateFlow`: the refcount keeps it polling while *either* is visible and stops only - when *both* are gone — no manual coordination. (Refinements to add then: a small stop-timeout so - bouncing between pages doesn't thrash the loop, and a generation token guarding rapid resubscribe.) -- **Settings (BUILT).** `Settings/MarketSettingsManager.cs` is a `JsonSettingsManager` singleton - (`FilePath` = `…/market.settings.json`), wired in `MarketExtensionCommandsProvider` via - `Settings = MarketSettingsManager.Instance.Settings;`. The refresh interval is a numeric `TextSetting` - **in minutes, default 5** (0 = off), surfaced as `RefreshMinutes` / `RefreshInterval` (TimeSpan) / - `AutoRefreshEnabled`. The poll loop should read `MarketSettingsManager.Instance.RefreshInterval` each - tick. (The same manager also holds the Finnhub API key — see the "API Key" section.) -- ⚠️ **Rate-limit tension (design around this):** Finnhub free tier is **~60 calls/min AND ~300/day**, - and `GetQuotesAsync` issues **one `/quote` call per instrument**. Polling N instruments at interval T: - at a low T this burns the budget fast — e.g. 6 favorites every 60 s = 360 calls/hour, which - **exhausts the ~300/day budget in under an hour**. The **5-min default is much gentler** (6 favorites = - ~72 calls/hour), but a user can still set a low interval. Mitigations: the configurable interval + an - **Off** (0) escape, polling **only while visible** (the lifecycle guarantees this), and real - **429 handling** (back off; surface a "rate-limited" state rather than blanking prices). A - batched-quote provider or cheaper data source would relax this later. +### 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 5 min, settings- +configurable** (0 = off). Built as designed, on the StateFlow subscriber-count seam. + +- **Where (in scope):** `Pages/PricedListPage.cs` (Markets Watchlist + Markets Favorites) and + `Pages/FavoritesDockPage.cs`. `SearchPage` is exempt (identity-only results, no prices). The + **symbol-detail chart is NOT polled** — it still fetches once per tab tap (deferred). +- **The ticker = `Helpers/PollTicker.cs`.** A process-wide singleton `PollTicker : StateFlow` that + emits an incrementing tick on a `Task.Delay` loop. The loop **starts in `OnActive`** (the 0→1 + subscriber transition) and is **cancelled via a `CancellationTokenSource` in `OnInactive`** (1→0), so + polling runs only while something is watching. (We used `StateFlow` + a tick counter rather than + a generic `PolledStateFlow` carrying data, because re-emitting the membership list would be + swallowed by the flows' distinct-until-changed; an incrementing `long` always gets through.) The loop + re-reads `MarketSettingsManager` each iteration (interval/on-off applies without a reload); when off it + idles on a 30 s re-check so toggling it back on resumes. The CTS is swapped under a lock so a rapid + inactive→active bounce can't leave two loops running. +- **No double-fetch on open.** Surfaces subscribe with `Subscribe(_ => PollRefresh(), replayOnSubscribe: + false)` — the new overload skips the replay, so 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. (Still + open: a small stop-timeout so navigating directly between two priced pages doesn't reset the timer.) +- **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 5** (0 = off), surfaced as `RefreshMinutes` / + `RefreshInterval` (TimeSpan) / `AutoRefreshEnabled`; the ticker reads these each tick. (Same manager + also holds the Finnhub API key — 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 **5-min default is gentler** (6 favorites = ~72 calls/hour). In place today: the + configurable interval + **Off** (0) escape, polling **only while visible**, and the keep-last-good + guard. Still deferred: real **429 back-off** + a "rate-limited" UI state; a batched-quote provider + would relax this further. ⚠️ 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 — that needs + the larger "flow owns the quotes" refactor). ### Dock refresh on favorites change (done) @@ -411,7 +431,10 @@ For reference, the Perf Monitor source this chart was ported from 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 1D auto-refresh** ties into the pending price-polling work; v1 fetches once per tab tap. +- **Live 1D auto-refresh** is deferred: live price polling now exists (`Helpers/PollTicker.cs`, see "Live + price polling (done)") but the chart was kept **out of scope** — it still fetches once per tab tap. + Wiring it in would mean re-fetching the selected range's candles on each tick and repainting the + `FormContent` (reusing the chart's generation guard). - Not yet wired to a future `PortfolioPage` (doesn't exist yet). ### Asset logos as icons, app-wide (future wishlist) diff --git a/MarketExtension/Helpers/PollTicker.cs b/MarketExtension/Helpers/PollTicker.cs new file mode 100644 index 0000000..ba8fbc1 --- /dev/null +++ b/MarketExtension/Helpers/PollTicker.cs @@ -0,0 +1,93 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace MarketExtension; + +// The live-price poll ticker — the concrete realization of the "PolledStateFlow" seam StateFlow was +// built for (its OnActive/OnInactive subscriber-count hooks = Kotlin's WhileSubscribed / Rx RefCount()). +// +// It is a StateFlow that emits a monotonically increasing tick on a timer WHILE at least one +// surface is subscribed, and stops the timer when the last one unsubscribes. Priced surfaces +// (PricedListPage, FavoritesDockPage) subscribe with replayOnSubscribe:false and re-price their current +// set on each tick. A plain counter (not the instrument list) is used because the membership flows dedup +// identical symbol sequences (distinct-until-changed) — an incrementing long always gets through. +// +// One process-wide instance is 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). The loop re-reads +// the interval from MarketSettingsManager each iteration, so a settings change applies without a reload; +// when auto-refresh is off (interval 0) it idles on a short re-check so toggling it on later resumes. +// +// AOT/trim-safe: only Task.Delay / CancellationTokenSource / System.Threading.Lock — no reflection, no +// JSON, no new serializer context (see CLAUDE.md AOT section). +[SuppressMessage("Reliability", "CA1001:Types that own disposable fields should be disposable", + Justification = "Process-lifetime singleton; the CTS is created/disposed per active-poll cycle in " + + "OnActive/OnInactive and the instance itself is never disposed.")] +internal sealed class PollTicker : StateFlow +{ + public static readonly PollTicker Instance = new(); + + // 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); + + private readonly Lock _gate = new(); + private CancellationTokenSource? _cts; + + private PollTicker() : base(0) { } + + // First subscriber arrived: (re)start the poll loop. Cancelling any prior CTS first guards a rapid + // inactive -> active bounce from leaving two loops running. + protected override void OnActive() + { + lock (_gate) + { + _cts?.Cancel(); + _cts?.Dispose(); + _cts = new CancellationTokenSource(); + _ = PollLoop(_cts.Token); + } + + Log.Info("Poll", "Poll loop started — a priced surface became visible"); + } + + // Last subscriber left: stop the loop. The in-flight Task.Delay throws OperationCanceledException, + // which PollLoop swallows. + protected override void OnInactive() + { + lock (_gate) + { + _cts?.Cancel(); + _cts?.Dispose(); + _cts = null; + } + + Log.Info("Poll", "Poll loop stopped — all priced surfaces hidden"); + } + + private async Task PollLoop(CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested) + { + // Re-read live each iteration so an interval/on-off change applies without a reload. + var settings = MarketSettingsManager.Instance; + var delay = settings.AutoRefreshEnabled ? settings.RefreshInterval : OffRecheckInterval; + + await Task.Delay(delay, ct).ConfigureAwait(false); + + if (settings.AutoRefreshEnabled) + { + SetValue(Value + 1); // emit a tick -> every visible priced surface re-prices in place + Log.Info("Poll", $"Tick #{Value} — signalling visible surfaces to refresh"); + } + } + } + catch (OperationCanceledException) + { + // Cancelled in OnInactive (or on a rapid restart) — just stop. + } + } +} diff --git a/MarketExtension/Helpers/StateFlow.cs b/MarketExtension/Helpers/StateFlow.cs index 8069f9b..d5ca948 100644 --- a/MarketExtension/Helpers/StateFlow.cs +++ b/MarketExtension/Helpers/StateFlow.cs @@ -39,7 +39,13 @@ public T Value // Subscribe and immediately receive the current value (StateFlow replay). Dispose to unsubscribe; // disposal is idempotent. Handlers run on whatever thread mutates the value — the toolkit marshals // the RaiseItemsChanged that handlers ultimately call. - public IDisposable Subscribe(Action onNext) + 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. The poll ticker uses this: a page's first price load is already driven by its + // membership flow's replay, so a replayed tick would just double-fetch on every open. OnActive still + // fires on the 0 -> 1 transition regardless (so the producer starts), only the replay is skipped. + public IDisposable Subscribe(Action onNext, bool replayOnSubscribe) { ArgumentNullException.ThrowIfNull(onNext); @@ -52,8 +58,8 @@ public IDisposable Subscribe(Action onNext) current = _value; } - if (becameActive) OnActive(); // wake any producer before the first replay - onNext(current); // replay the current value + if (becameActive) OnActive(); // wake any producer before the first replay + if (replayOnSubscribe) onNext(current); // replay the current value return new Subscription(this, onNext); } diff --git a/MarketExtension/Pages/FavoritesDockPage.cs b/MarketExtension/Pages/FavoritesDockPage.cs index 0510b46..3c73345 100644 --- a/MarketExtension/Pages/FavoritesDockPage.cs +++ b/MarketExtension/Pages/FavoritesDockPage.cs @@ -25,6 +25,7 @@ internal sealed partial class FavoritesDockPage : ListPage, INotifyItemsChanged private event TypedEventHandler? _itemsChanged; private IDisposable? _subscription; + private IDisposable? _pollSubscription; event TypedEventHandler INotifyItemsChanged.ItemsChanged { @@ -35,12 +36,19 @@ event TypedEventHandler INotifyItemsChanged.Item // 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). replay:false so + // becoming visible doesn't double-fetch — the favorites subscription above already paints. + _pollSubscription = PollTicker.Instance.Subscribe(_ => PollRefresh(), 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; + Log.Info("Poll", "Dock: stopped polling"); } } @@ -89,6 +97,17 @@ internal void RefreshQuotes() Task.Run(LoadQuotes); } + // 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. + 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); + } + private async Task LoadQuotes() { // The dock shows only favorites — price exactly that subset (a snapshot of the flow's value). diff --git a/MarketExtension/Pages/PricedListPage.cs b/MarketExtension/Pages/PricedListPage.cs index f73bdfe..3025839 100644 --- a/MarketExtension/Pages/PricedListPage.cs +++ b/MarketExtension/Pages/PricedListPage.cs @@ -51,6 +51,12 @@ event TypedEventHandler INotifyItemsChanged.Item // 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. replay:false 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.Instance.Subscribe(_ => PollRefresh(), replayOnSubscribe: false)); + Log.Info("Poll", $"{Title}: started polling [{string.Join(", ", _snapshot.Select(i => i.Symbol))}] " + + $"(every {MarketSettingsManager.Instance.RefreshMinutes} min, 0=off)"); } remove { @@ -58,6 +64,7 @@ event TypedEventHandler INotifyItemsChanged.Item foreach (var subscription in _subscriptions) subscription.Dispose(); _subscriptions.Clear(); + Log.Info("Poll", $"{Title}: stopped polling [{string.Join(", ", _snapshot.Select(i => i.Symbol))}]"); } } @@ -139,14 +146,32 @@ private void OnInstrumentsChanged(IReadOnlyList instruments) Fetch(missing); } - // Full re-price of the current set — the Refresh row and (later) the poll timer. Clears the cache so - // every symbol is re-fetched. + // 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)); + } + // 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. @@ -168,7 +193,9 @@ private void Fetch(IReadOnlyList instruments) Task.Run(() => LoadQuotes(instruments, generation)); } - private async Task LoadQuotes(IReadOnlyList instruments, int generation) + // `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. + private async Task LoadQuotes(IReadOnlyList instruments, int generation, bool silent = false) { var quotes = await Repository.GetQuotesAsync(instruments); @@ -182,8 +209,13 @@ private async Task LoadQuotes(IReadOnlyList instruments, int g foreach (var quote in quotes) { var key = WatchlistStore.Normalize(quote.Symbol); - if (keep.Contains(key)) - _priceCache[key] = UiQuote.From(quote); + 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; } } From a2f9cafd361b0d499187ae5f801cb9fd173b3806 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Tue, 23 Jun 2026 01:54:05 +0300 Subject: [PATCH 21/69] wip --- CLAUDE.md | 26 ++++++++++++++--- MarketExtension/Pages/PricedListPage.cs | 38 +++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 20b0bc0..b9e654d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -182,8 +182,16 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). fine. The chart's live 1D refresh stays deferred (see below). See "Live price polling (done)". - **Deferred:** FX provider (keyless Frankfurter); rate-limit (429) **back-off** + richer error UX (the keep-last-good guard is a first step, not the full story); crypto/FX in symbol search; the - **symbol-detail chart's live 1D auto-refresh** (still one fetch per tab tap); a poll-timer **debounce** - so navigating directly between two priced pages doesn't reset the interval (a pinned dock masks it). + **symbol-detail chart's live 1D auto-refresh** (still one fetch per tab tap). +- **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. - **Wishlist:** a **portfolio** screen + its own dock band (holdings, total value, daily P&L); a **per-symbol detail screen + live chart for any ticker** (drill-in from any list row, clickable from any dock item); and **official asset logos as icons app-wide** (replacing today's generic copy/glyph icons). @@ -262,8 +270,18 @@ configurable** (0 = off). Built as designed, on the StateFlow subscriber-count s 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. (Still - open: a small stop-timeout so navigating directly between two priced pages doesn't reset the timer.) + 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 5** (0 = off), surfaced as `RefreshMinutes` / diff --git a/MarketExtension/Pages/PricedListPage.cs b/MarketExtension/Pages/PricedListPage.cs index 3025839..ba7b06f 100644 --- a/MarketExtension/Pages/PricedListPage.cs +++ b/MarketExtension/Pages/PricedListPage.cs @@ -38,6 +38,7 @@ internal abstract partial class PricedListPage : DynamicListPage, INotifyItemsCh 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; @@ -139,6 +140,7 @@ private void OnInstrumentsChanged(IReadOnlyList instruments) { IsLoading = false; RaiseItemsChanged(0); // removals reflected immediately, no network + RefreshStaleQuotes(); // ...but if the cached prices have aged past the interval, quietly re-price return; } @@ -172,6 +174,28 @@ internal void PollRefresh() 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. @@ -184,18 +208,25 @@ private void Fetch(IReadOnlyList instruments) 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)); + 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. - private async Task LoadQuotes(IReadOnlyList instruments, int generation, bool silent = false) + // `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); @@ -219,6 +250,9 @@ private async Task LoadQuotes(IReadOnlyList instruments, int g } } + if (markFresh) + _lastFullPriceTicks = Environment.TickCount64; // whole set re-priced — reset the staleness clock + IsLoading = false; RaiseItemsChanged(0); } From 622ecfd404c879262f0dbd787e80800468b1ff4a Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Tue, 23 Jun 2026 01:57:43 +0300 Subject: [PATCH 22/69] wip --- MarketExtension/Pages/FavoritesDockPage.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/MarketExtension/Pages/FavoritesDockPage.cs b/MarketExtension/Pages/FavoritesDockPage.cs index 3c73345..9caee00 100644 --- a/MarketExtension/Pages/FavoritesDockPage.cs +++ b/MarketExtension/Pages/FavoritesDockPage.cs @@ -7,8 +7,8 @@ namespace MarketExtension; -// Backs a Command Palette Dock band: a strip showing up to MaxDockFavorites favorited -// instruments as ticker buttons (e.g. "AAPL ▲ +1.20%"); clicking one opens its SymbolDetailPage. +// 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 @@ -18,8 +18,6 @@ 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 MarketRepository _repository; private UiQuote[]? _quotes; @@ -68,7 +66,7 @@ public override IListItem[] GetItems() if (_quotes is null) return []; - var favorites = _quotes.Take(MaxDockFavorites).ToArray(); + var favorites = _quotes; if (favorites.Length == 0) { From 2d13b4316813a166b1f90a12e94c6aec0ff945c4 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Tue, 23 Jun 2026 02:34:07 +0300 Subject: [PATCH 23/69] wip --- CLAUDE.md | 106 ++++++++++++------- MarketExtension/Commands/OpenUrlCommand.cs | 36 +++++++ MarketExtension/Helpers/AssetIconResolver.cs | 49 +++++++++ MarketExtension/Helpers/ProcessHelper.cs | 5 + MarketExtension/Pages/FavoritesDockPage.cs | 1 + MarketExtension/Pages/FavoritesPage.cs | 1 + MarketExtension/Pages/PricedListPage.cs | 1 + MarketExtension/Pages/SearchPage.cs | 4 + MarketExtension/Pages/SymbolDetailPage.cs | 5 +- MarketExtension/Pages/WatchlistPage.cs | 1 + 10 files changed, 168 insertions(+), 41 deletions(-) create mode 100644 MarketExtension/Commands/OpenUrlCommand.cs create mode 100644 MarketExtension/Helpers/AssetIconResolver.cs diff --git a/CLAUDE.md b/CLAUDE.md index b9e654d..652aca8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,11 +42,14 @@ 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 +- 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: `new IconInfo("https://github.com/favicon.ico")` per project preference +- 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; page **chrome** icons (list/search headers) still use + `new IconInfo("https://github.com/favicon.ico")` per project preference ## Market Data Architecture (the core of this app) @@ -90,6 +93,7 @@ the same three layers and the same provider seam — see the "Symbol detail + li | `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. Only caller: `UiCandleSeries`. | +| `Helpers/AssetIconResolver.cs` | Instrument identity → row/dock `IconInfo`. Builds **Elbstream** logo URLs by category (`/logos/symbol/{t}`, `/logos/crypto/{c}`, `?format=png`); Segoe-glyph fallback for Currency/unknown. **Zero API calls** (the host fetches the URL). Also the shared `AttributionRow()` factory (the required Elbstream credit). See "Asset logos (done)". | | `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`, @@ -192,10 +196,17 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). 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. -- **Wishlist:** a **portfolio** screen + its own dock band (holdings, total value, daily P&L); a +- **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:** a **portfolio** screen + its own dock band (holdings, total value, daily P&L); and a **per-symbol detail screen + live chart for any ticker** (drill-in from any list row, clickable from any - dock item); and **official asset logos as icons app-wide** (replacing today's generic copy/glyph icons). - All designed below. + dock item). All designed below. ### Three-screen UX (done) @@ -455,40 +466,53 @@ For reference, the Perf Monitor source this chart was ported from `FormContent` (reusing the chart's generation guard). - Not yet wired to a future `PortfolioPage` (doesn't exist yet). -### Asset logos as icons, app-wide (future wishlist) - -Every row and dock button should show the instrument's **official logo** (AAPL's apple, BABA's logo, -BTC's coin) instead of today's generic github-favicon icon. (Dock buttons already left-click into the -`SymbolDetailPage`; the remaining gap is purely the icon.) - -**Where to get logos:** -- **Stocks / ETFs — Finnhub `/stock/profile2`** (free tier; reuses our existing key). Returns a hosted - `logo` URL (plus `weburl`, `name`, …) — docs: . One call - per symbol, and **logos are immutable, so cache the URL to disk forever and fetch each symbol at most - once** (profile2 counts against the ~60/min, ~300/day budget). Fallback: Clearbit - `https://logo.clearbit.com/{weburl-domain}` (caveat: Clearbit is now HubSpot — verify it still serves). -- **Crypto — CoinGecko** (keyless): `image` URLs from `/coins/markets?vs_currency=usd&symbols=btc`, or - **bundle a static set** (e.g. `spothq/cryptocurrency-icons` SVG/PNG by symbol) under `Assets/` for an - offline, no-network, AOT-friendly path. -- **Forex / currency:** deferred (country flags or a currency glyph when FX lands). -- **Fallback:** a per-`AssetCategory` Segoe glyph or a first-letter monogram when no logo resolves. - -**How to load it** (the toolkit already supports all three): -- Remote URL → `new IconInfo(logoUrl)` (same as today's github favicon). -- Bundled asset → `IconHelpers.FromRelativePath("Assets\\crypto\\btc.png")`. -- Inline → a `data:` URI (same trick as the chart SVG). - -**Architecture:** the logo source is provider-specific, so it fits the existing seam — add an optional -`Task GetLogoUrlAsync(DomainInstrument)` to `IMarketDataProvider` (default `null`), routed by -`MarketRepository` exactly like quotes (Finnhub serves stocks; a crypto provider serves coins). Wrap it in -a shared, **disk-persisted** `Helpers/AssetIconResolver.cs` (symbol → `IconInfo`, cached forever). Since -`GetItems()` is synchronous, resolve logo URLs **as part of the async price load** (set `ListItem.Icon` -once known), or render a fallback glyph first and `RaiseItemsChanged()` when the logo arrives; after first -run it's served from cache instantly. - -**Wire it everywhere:** set `ListItem.Icon` to the resolved logo in every row builder — `SearchPage`, -`WatchlistPage`, `FavoritesPage`, the future `PortfolioPage` — and on the dock items. (The dock command -is already the `SymbolDetailPage`; dock bands still require a non-empty `Command.Id`, which the page sets.) +### 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`/unknown → a per-category Segoe MDL2 glyph (no FX logo source wired yet). + +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:** logos on a `PortfolioPage` when it lands (same `Resolve(...)` call); an FX/currency logo +source (Elbstream `/logos/country/{CC}` flags) once forex is wired; a glyph-on-miss probe if blank slots on +obscure symbols become annoying. ## CommandPalette Toolkit — Quick Reference @@ -664,6 +688,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`. diff --git a/MarketExtension/Commands/OpenUrlCommand.cs b/MarketExtension/Commands/OpenUrlCommand.cs new file mode 100644 index 0000000..0779f59 --- /dev/null +++ b/MarketExtension/Commands/OpenUrlCommand.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +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 = $"Couldn't open {_url}: {ex.Message}", + Result = CommandResult.KeepOpen(), + }); + } + } +} diff --git a/MarketExtension/Helpers/AssetIconResolver.cs b/MarketExtension/Helpers/AssetIconResolver.cs new file mode 100644 index 0000000..500dc3d --- /dev/null +++ b/MarketExtension/Helpers/AssetIconResolver.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +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. +// +// Categories without a logo source (Currency today) 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 => new IconInfo(CurrencyGlyph), + _ => new IconInfo(FallbackGlyph), + }; + + 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", "Open Elbstream")) + { + Title = "Logos provided by Elbstream", + Subtitle = "Logo images via elbstream.com", + Icon = new IconInfo(LinkGlyph), + }; +} 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/Pages/FavoritesDockPage.cs b/MarketExtension/Pages/FavoritesDockPage.cs index 9caee00..5086b04 100644 --- a/MarketExtension/Pages/FavoritesDockPage.cs +++ b/MarketExtension/Pages/FavoritesDockPage.cs @@ -83,6 +83,7 @@ public override IListItem[] GetItems() { Title = $"{q.Symbol} {q.FormatChange()}", Subtitle = q.FormatPrice(), + Icon = AssetIconResolver.Resolve(q), }) .ToArray(); } diff --git a/MarketExtension/Pages/FavoritesPage.cs b/MarketExtension/Pages/FavoritesPage.cs index 5a96cdc..5727363 100644 --- a/MarketExtension/Pages/FavoritesPage.cs +++ b/MarketExtension/Pages/FavoritesPage.cs @@ -24,6 +24,7 @@ protected override IListItem BuildRow(UiQuote q) { Title = $"★ {q.Symbol} · {q.Name}", Subtitle = $"{q.FormatPrice()} {q.FormatChange()}", + Icon = AssetIconResolver.Resolve(q), }; } diff --git a/MarketExtension/Pages/PricedListPage.cs b/MarketExtension/Pages/PricedListPage.cs index ba7b06f..ea306e4 100644 --- a/MarketExtension/Pages/PricedListPage.cs +++ b/MarketExtension/Pages/PricedListPage.cs @@ -111,6 +111,7 @@ public override IListItem[] GetItems() var rows = cached.Where(Matches).Select(BuildRow).ToList(); rows.Add(new ListItem(new RefreshCommand(this)) { Title = "Refresh 🔄" }); + rows.Add(AssetIconResolver.AttributionRow()); // Elbstream logo credit (rows above show logos) return [.. rows]; } diff --git a/MarketExtension/Pages/SearchPage.cs b/MarketExtension/Pages/SearchPage.cs index 606c1af..fc06741 100644 --- a/MarketExtension/Pages/SearchPage.cs +++ b/MarketExtension/Pages/SearchPage.cs @@ -124,6 +124,9 @@ private IListItem[] SearchItems() foreach (var instrument in _searchResults) items.Add(BuildResultItem(instrument)); + + if (_searchResults.Count > 0) + items.Add(AssetIconResolver.AttributionRow()); // Elbstream logo credit (results show logos) } return [.. items]; @@ -142,6 +145,7 @@ private ListItem BuildResultItem(DomainInstrument instrument) Title = (isFavorite ? "★ " : "") + $"{instrument.Symbol} · {instrument.Name}", Subtitle = MembershipSubtitle(inWatchlist, isFavorite), Section = "Search results", + Icon = AssetIconResolver.Resolve(instrument), }; } diff --git a/MarketExtension/Pages/SymbolDetailPage.cs b/MarketExtension/Pages/SymbolDetailPage.cs index 25f3cb3..2efb1de 100644 --- a/MarketExtension/Pages/SymbolDetailPage.cs +++ b/MarketExtension/Pages/SymbolDetailPage.cs @@ -70,7 +70,7 @@ public SymbolDetailPage(DomainInstrument instrument, MarketRepository 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 = new IconInfo("https://github.com/favicon.ico"); + Icon = AssetIconResolver.Resolve(instrument); Title = $"{instrument.Symbol} · {instrument.Name}"; Name = "View details"; Commands = BuildCommands(); // initial bar; refreshed again on subscribe and on every change @@ -279,7 +279,8 @@ private string BuildData(DomainCandleSeries? series, ChartRange range) { "type": "Action.Submit", "title": "1Y", "data": { "range": "1Y" } }, { "type": "Action.Submit", "title": "5Y", "data": { "range": "5Y" } } ] - } + }, + { "type": "TextBlock", "text": "Logos provided by Elbstream", "isSubtle": true, "wrap": true, "spacing": "Medium" } ] } """; diff --git a/MarketExtension/Pages/WatchlistPage.cs b/MarketExtension/Pages/WatchlistPage.cs index e775039..e252b39 100644 --- a/MarketExtension/Pages/WatchlistPage.cs +++ b/MarketExtension/Pages/WatchlistPage.cs @@ -31,6 +31,7 @@ protected override IListItem BuildRow(UiQuote q) Title = (isFavorite ? "★ " : "") + $"{q.Symbol} · {q.Name}", Subtitle = $"{q.FormatPrice()} {q.FormatChange()}", Section = SectionLabel(q.Category), + Icon = AssetIconResolver.Resolve(q), }; } From 3bd33ba78f4933fe4a8fc499a042629b89cd5a02 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Tue, 23 Jun 2026 02:52:09 +0300 Subject: [PATCH 24/69] wip --- CLAUDE.md | 55 ++++-- .../Data/Frankfurter/ApiFrankfurterDto.cs | 24 +++ .../FrankfurterMarketDataProvider.cs | 158 ++++++++++++++++++ MarketExtension/Data/InstrumentCatalog.cs | 14 +- MarketExtension/Helpers/AssetIconResolver.cs | 28 +++- .../MarketExtensionCommandsProvider.cs | 6 +- 6 files changed, 262 insertions(+), 23 deletions(-) create mode 100644 MarketExtension/Data/Frankfurter/ApiFrankfurterDto.cs create mode 100644 MarketExtension/Data/Frankfurter/FrankfurterMarketDataProvider.cs diff --git a/CLAUDE.md b/CLAUDE.md index 652aca8..e9dc70f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,8 @@ the same three layers and the same provider seam — see the "Symbol detail + li | `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. | | `Data/IMarketDataProvider.cs` | ONE data source: `bool Supports(AssetCategory)` + `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/Finnhub/FinnhubMarketDataProvider.cs` | Active provider (`Supports` Stock+Crypto). 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}) + its own source-gen `FrankfurterJsonContext`. | | `Data/MockMarketDataProvider.cs` | Offline fallback (`Supports` all). `SearchAsync` filters its seed keys. | | `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`. | @@ -93,7 +95,7 @@ the same three layers and the same provider seam — see the "Symbol detail + li | `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. Only caller: `UiCandleSeries`. | -| `Helpers/AssetIconResolver.cs` | Instrument identity → row/dock `IconInfo`. Builds **Elbstream** logo URLs by category (`/logos/symbol/{t}`, `/logos/crypto/{c}`, `?format=png`); Segoe-glyph fallback for Currency/unknown. **Zero API calls** (the host fetches the URL). Also the shared `AttributionRow()` factory (the required Elbstream credit). See "Asset logos (done)". | +| `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)". | | `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`, @@ -103,12 +105,14 @@ chart history, else the default invalid-series opt-out applies), map its `Api*` `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; a Twelve Data provider would -map the same `CandleInterval` to `5min/30min/1h/1day/1week`. +map the same `CandleInterval` to `5min/30min/1h/1day/1week`. **Worked example:** +`FrankfurterMarketDataProvider` is exactly this — a keyless FX source added behind the seam with zero +UI/repository changes (`Supports(Currency)` + a time-series fetch for both quotes and candles). **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 omitted from `InstrumentCatalog`; - re-add via a paid plan or a keyless FX provider (e.g. Frankfurter) behind the same seam. +- **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=` @@ -125,6 +129,23 @@ map the same `CandleInterval` to `5min/30min/1h/1day/1week`. 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:** project is AOT/trim-enabled; `EnableTrimAnalyzer` is on **in Debug** (so IL2026/IL3050 surface every build) and `ILLinkTreatWarningsAsErrors` is set, so all JSON must go through a source-gen `JsonSerializerContext` (never reflection-based `JsonSerializer`). Both contexts are source-gen: @@ -184,9 +205,19 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). `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's live 1D refresh stays deferred (see below). See "Live price polling (done)". -- **Deferred:** FX provider (keyless Frankfurter); rate-limit (429) **back-off** + richer error UX - (the keep-last-good guard is a first step, not the full story); crypto/FX in symbol search; the - **symbol-detail chart's live 1D auto-refresh** (still one fetch per tab tap). +- **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.) +- **Deferred:** rate-limit (429) **back-off** + richer error UX (the keep-last-good guard is a first + step, not the full story); crypto/Finnhub-side FX in symbol search; the **symbol-detail chart's live 1D + auto-refresh** (still one fetch per tab tap). - **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 @@ -478,7 +509,9 @@ makes the **CmdPal host** fetch the image, the logo URL is built *directly from convention lives: - `Stock → https://api.elbstream.com/logos/symbol/{ticker}?format=png` - `Crypto → https://api.elbstream.com/logos/crypto/{sym}?format=png` -- `Currency`/unknown → a per-category Segoe MDL2 glyph (no FX logo source wired yet). +- `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 @@ -510,9 +543,9 @@ you ship. ⚠️ **Clearbit's free logo API was permanently sunset Dec 2025 — the only keyless, symbol-addressable source covering stocks + crypto + forex from one place; its attribution requirement was the accepted trade-off. -**Future polish:** logos on a `PortfolioPage` when it lands (same `Resolve(...)` call); an FX/currency logo -source (Elbstream `/logos/country/{CC}` flags) once forex is wired; a glyph-on-miss probe if blank slots on -obscure symbols become annoying. +**Future polish:** logos on a `PortfolioPage` when it lands (same `Resolve(...)` call); 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.) ## CommandPalette Toolkit — Quick Reference diff --git a/MarketExtension/Data/Frankfurter/ApiFrankfurterDto.cs b/MarketExtension/Data/Frankfurter/ApiFrankfurterDto.cs new file mode 100644 index 0000000..5ee0ac1 --- /dev/null +++ b/MarketExtension/Data/Frankfurter/ApiFrankfurterDto.cs @@ -0,0 +1,24 @@ +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); + +[JsonSerializable(typeof(ApiFrankfurterSeriesDto))] +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..551c5a4 --- /dev/null +++ b/MarketExtension/Data/Frankfurter/FrankfurterMarketDataProvider.cs @@ -0,0 +1,158 @@ +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; + + var quote = new DomainQuote( + instrument.Symbol, instrument.Name, instrument.Category, price, change, pct, IsValid: true); + Log.Info("Frankfurter", $"{instrument.Symbol} -> rate={price} change%={pct}"); + 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/InstrumentCatalog.cs b/MarketExtension/Data/InstrumentCatalog.cs index 8717967..3526613 100644 --- a/MarketExtension/Data/InstrumentCatalog.cs +++ b/MarketExtension/Data/InstrumentCatalog.cs @@ -6,11 +6,10 @@ namespace MarketExtension; // 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. // -// NOTE: Forex pairs are intentionally omitted for now — Finnhub's free tier gates /quote for -// OANDA:* behind a paid plan ("You don't have access to this resource."). AssetCategory.Currency -// and the providers' symbol formatting stay in place, so FX can be re-added in one spot once we -// either pay for Finnhub forex or slot in a free keyless FX provider (e.g. Frankfurter) behind -// the same IMarketDataProvider seam. +// 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 = @@ -25,6 +24,9 @@ internal static class InstrumentCatalog new("ETH", "Ethereum", AssetCategory.Crypto), new("SOL", "Solana", AssetCategory.Crypto), - // Currency (Forex) — omitted until a forex-capable provider is wired; see note above. + // 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/Helpers/AssetIconResolver.cs b/MarketExtension/Helpers/AssetIconResolver.cs index 500dc3d..5d355ef 100644 --- a/MarketExtension/Helpers/AssetIconResolver.cs +++ b/MarketExtension/Helpers/AssetIconResolver.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -9,9 +10,11 @@ namespace MarketExtension; // 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. // -// Categories without a logo source (Currency today) 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). +// 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"; @@ -28,10 +31,27 @@ internal static class AssetIconResolver { AssetCategory.Stock => new IconInfo($"{Base}/symbol/{Uri.EscapeDataString(symbol)}?format=png"), AssetCategory.Crypto => new IconInfo($"{Base}/crypto/{Uri.EscapeDataString(symbol)}?format=png"), - AssetCategory.Currency => new IconInfo(CurrencyGlyph), + 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); diff --git a/MarketExtension/MarketExtensionCommandsProvider.cs b/MarketExtension/MarketExtensionCommandsProvider.cs index 8dac372..e38ba70 100644 --- a/MarketExtension/MarketExtensionCommandsProvider.cs +++ b/MarketExtension/MarketExtensionCommandsProvider.cs @@ -6,9 +6,11 @@ namespace MarketExtension; public partial class MarketExtensionCommandsProvider : CommandProvider { // The repository coordinates all market-data providers; both the palette page and the dock - // band share this one instance. Add a provider here to extend coverage (e.g. a forex source); + // band share this one instance. Routing is by asset class: Finnhub serves stocks + crypto, + // Frankfurter serves forex (keyless ECB rates). Add a provider here to extend coverage; // MockMarketDataProvider is the offline fallback. - private readonly MarketRepository _repository = new(new FinnhubMarketDataProvider()); + private readonly MarketRepository _repository = + new(new FinnhubMarketDataProvider(), new FrankfurterMarketDataProvider()); private readonly ICommandItem[] _commands; private readonly ICommandItem[] _dockBands; From 3e7540701bd5f814701d160a2120668245904d88 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Wed, 24 Jun 2026 19:20:52 +0300 Subject: [PATCH 25/69] wip --- MarketExtension/Helpers/PollTicker.cs | 14 +++++++++- MarketExtension/Pages/FavoritesDockPage.cs | 31 ++++++++++++++++++---- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/MarketExtension/Helpers/PollTicker.cs b/MarketExtension/Helpers/PollTicker.cs index ba8fbc1..043ff28 100644 --- a/MarketExtension/Helpers/PollTicker.cs +++ b/MarketExtension/Helpers/PollTicker.cs @@ -78,11 +78,23 @@ private async Task PollLoop(CancellationToken ct) await Task.Delay(delay, ct).ConfigureAwait(false); - if (settings.AutoRefreshEnabled) + if (!settings.AutoRefreshEnabled) + continue; + + try { SetValue(Value + 1); // emit a tick -> every visible priced surface re-prices in place Log.Info("Poll", $"Tick #{Value} — signalling visible surfaces to refresh"); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // A subscriber's tick handler threw on this (the poll-loop) thread. Swallow it so one bad + // tick can't kill the whole loop: SetValue notifies subscribers synchronously, and a + // pinned dock keeps the subscriber count >= 1, so OnActive (which restarts the loop on a + // 0 -> 1 transition) would never fire again — polling would stay dead until an extension + // reload. Report to Sentry (Log.Error survives Release) and keep ticking. + Log.Error("Poll", "tick handler threw — continuing poll loop", ex); + } } } catch (OperationCanceledException) diff --git a/MarketExtension/Pages/FavoritesDockPage.cs b/MarketExtension/Pages/FavoritesDockPage.cs index 5086b04..2db9e19 100644 --- a/MarketExtension/Pages/FavoritesDockPage.cs +++ b/MarketExtension/Pages/FavoritesDockPage.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.CommandPalette.Extensions; @@ -93,24 +94,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. + // 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); + Task.Run(() => LoadQuotes(silent: true)); } - private async Task 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) { // The dock shows only favorites — price exactly that subset (a snapshot of the flow's value). - _quotes = [.. (await _repository.GetQuotesAsync(WatchlistStore.Instance.Favorites.Value)).Select(UiQuote.From)]; + 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); } From 2ead3c7e093db2bfea3cbd473b6b5d960befbd03 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Wed, 24 Jun 2026 23:42:49 +0300 Subject: [PATCH 26/69] wip --- CLAUDE.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e9dc70f..3489865 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -336,8 +336,11 @@ configurable** (0 = off). Built as designed, on the StateFlow subscriber-count s configurable interval + **Off** (0) escape, polling **only while visible**, and the keep-last-good guard. Still deferred: real **429 back-off** + a "rate-limited" UI state; a batched-quote provider would relax this further. ⚠️ 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 — that needs - the larger "flow owns the quotes" refactor). + 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) From 2d1ae7761f3a15a51846b4ed92f63ad9eff32477 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Wed, 24 Jun 2026 23:49:57 +0300 Subject: [PATCH 27/69] add chat auto-update --- CLAUDE.md | 34 +++++++++++----- MarketExtension/Pages/SymbolDetailPage.cs | 49 ++++++++++++++++++++++- 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3489865..f3e8a53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,7 +204,7 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). cleared, prices swap when the fetch lands). Honors the existing interval setting (default 5 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's live 1D refresh stays deferred (see below). See "Live price polling (done)". + 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 @@ -215,9 +215,18 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). 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 + `OnActive`/ + `OnInactive` refcount 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: candles are premium-gated, so on a **free key** each + tick just re-fetches a 403 (correct but invisible until a paid key lands). - **Deferred:** rate-limit (429) **back-off** + richer error UX (the keep-last-good guard is a first - step, not the full story); crypto/Finnhub-side FX in symbol search; the **symbol-detail chart's live 1D - auto-refresh** (still one fetch per tab tap). + step, not the full story); crypto/Finnhub-side FX in symbol search. - **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 @@ -285,9 +294,10 @@ Priced surfaces used to fetch **once** when they became visible (the StateFlow r drives the first load). They now also auto-refresh on a timer while visible: **default 5 min, settings- configurable** (0 = off). Built as designed, on the StateFlow subscriber-count seam. -- **Where (in scope):** `Pages/PricedListPage.cs` (Markets Watchlist + Markets Favorites) and - `Pages/FavoritesDockPage.cs`. `SearchPage` is exempt (identity-only results, no prices). The - **symbol-detail chart is NOT polled** — it still fetches once per tab tap (deferred). +- **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`.** A process-wide singleton `PollTicker : StateFlow` that emits an incrementing tick on a `Task.Delay` loop. The loop **starts in `OnActive`** (the 0→1 subscriber transition) and is **cancelled via a `CancellationTokenSource` in `OnInactive`** (1→0), so @@ -494,10 +504,14 @@ For reference, the Perf Monitor source this chart was ported from 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 1D auto-refresh** is deferred: live price polling now exists (`Helpers/PollTicker.cs`, see "Live - price polling (done)") but the chart was kept **out of scope** — it still fetches once per tab tap. - Wiring it in would mean re-fetching the selected range's candles on each tick and repainting the - `FormContent` (reusing the chart's generation guard). +- **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 + `OnActive`/ + `OnInactive` refcount 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. Premium-gated, so a free key just re-fetches a 403 each tick (invisible until a + paid key). 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) diff --git a/MarketExtension/Pages/SymbolDetailPage.cs b/MarketExtension/Pages/SymbolDetailPage.cs index 2efb1de..a78be12 100644 --- a/MarketExtension/Pages/SymbolDetailPage.cs +++ b/MarketExtension/Pages/SymbolDetailPage.cs @@ -29,7 +29,8 @@ namespace MarketExtension; // 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). +// 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; @@ -50,6 +51,11 @@ event TypedEventHandler INotifyItemsChanged.Item // 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 OnActive/OnInactive refcount) with the priced list pages + dock. + // replayOnSubscribe:false 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.Instance.Subscribe(_ => _chartForm.PollRefresh(), replayOnSubscribe: false)); } remove { @@ -102,7 +108,8 @@ private IContextItem[] BuildCommands() // 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). + // 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; @@ -198,6 +205,44 @@ private void Render(DomainCandleSeries 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)) From 8bebb44246fa4a9b13f2a9054ad196c2716f8d70 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Wed, 24 Jun 2026 23:58:53 +0300 Subject: [PATCH 28/69] convert to hub --- .../MarketExtensionCommandsProvider.cs | 12 ++--- MarketExtension/Pages/MarketsPage.cs | 54 +++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 MarketExtension/Pages/MarketsPage.cs diff --git a/MarketExtension/MarketExtensionCommandsProvider.cs b/MarketExtension/MarketExtensionCommandsProvider.cs index e38ba70..58db5a0 100644 --- a/MarketExtension/MarketExtensionCommandsProvider.cs +++ b/MarketExtension/MarketExtensionCommandsProvider.cs @@ -25,14 +25,12 @@ public MarketExtensionCommandsProvider() // Command Palette Settings UI. See Settings/MarketSettingsManager.cs. Settings = MarketSettingsManager.Instance.Settings; - // Three top-level screens: search (the default entry), the tracked watchlist, and the - // curated favorites. All share the one repository; favorites also feed the dock band below. - // All three titles share the "Markets " prefix so they group together (and don't pollute the - // global namespace) when the user searches the Command Palette root. + // 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 SearchPage(_repository)) { Title = "Markets Search" }, - new CommandItem(new WatchlistPage(_repository)) { Title = "Markets Watchlist" }, - new CommandItem(new FavoritesPage(_repository)) { Title = "Markets Favorites" }, + new CommandItem(new MarketsPage(_repository)) { Title = "Markets" }, ]; // Dock band: a ticker strip of up to 3 favorited instruments. Pinned from the Dock. diff --git a/MarketExtension/Pages/MarketsPage.cs b/MarketExtension/Pages/MarketsPage.cs new file mode 100644 index 0000000..e4fd1bf --- /dev/null +++ b/MarketExtension/Pages/MarketsPage.cs @@ -0,0 +1,54 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace MarketExtension; + +// 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 +{ + 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 SearchPage _searchPage; + private readonly WatchlistPage _watchlistPage; + private readonly FavoritesPage _favoritesPage; + + public MarketsPage(MarketRepository repository) + { + Icon = new IconInfo("https://github.com/favicon.ico"); + Title = "Markets"; + Name = "Open"; + PlaceholderText = "Search, Watchlist, Favorites..."; + + _searchPage = new SearchPage(repository); + _watchlistPage = new WatchlistPage(repository); + _favoritesPage = new FavoritesPage(repository); + } + + public override IListItem[] GetItems() => + [ + new ListItem(_searchPage) + { + Title = "Search", + Subtitle = "Look up any stock, crypto, or currency", + Icon = new IconInfo(SearchGlyph), + }, + new ListItem(_watchlistPage) + { + Title = "Watchlist", + Subtitle = "The instruments you track, priced live", + Icon = new IconInfo(ListGlyph), + }, + new ListItem(_favoritesPage) + { + Title = "Favorites", + Subtitle = "Your starred instruments — shown on the dock", + Icon = new IconInfo(StarFillGlyph), + }, + ]; +} From 9e07f1b195e85ae2f44898323869f342a7e71fb9 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 00:02:40 +0300 Subject: [PATCH 29/69] add settings page --- MarketExtension/Pages/MarketsPage.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/MarketExtension/Pages/MarketsPage.cs b/MarketExtension/Pages/MarketsPage.cs index e4fd1bf..ef25e74 100644 --- a/MarketExtension/Pages/MarketsPage.cs +++ b/MarketExtension/Pages/MarketsPage.cs @@ -13,10 +13,12 @@ internal sealed partial class MarketsPage : ListPage 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 SettingsGlyph = "\uE713"; // Segoe MDL2 Setting private readonly SearchPage _searchPage; private readonly WatchlistPage _watchlistPage; private readonly FavoritesPage _favoritesPage; + private readonly IContentPage _settingsPage; public MarketsPage(MarketRepository repository) { @@ -28,6 +30,10 @@ public MarketsPage(MarketRepository repository) _searchPage = new SearchPage(repository); _watchlistPage = new WatchlistPage(repository); _favoritesPage = new FavoritesPage(repository); + + // 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 IListItem[] GetItems() => @@ -50,5 +56,11 @@ public override IListItem[] GetItems() => Subtitle = "Your starred instruments — shown on the dock", Icon = new IconInfo(StarFillGlyph), }, + new ListItem(_settingsPage) + { + Title = "Settings", + Subtitle = "Finnhub API key and price refresh interval", + Icon = new IconInfo(SettingsGlyph), + }, ]; } From 700c8a412f799a1f3505349264ab9cc49699ba81 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 00:42:14 +0300 Subject: [PATCH 30/69] add 12data --- CLAUDE.md | 86 ++++- .../Data/TwelveData/ApiTwelveDataCandleDto.cs | 29 ++ .../Data/TwelveData/ApiTwelveDataQuoteDto.cs | 34 ++ .../Data/TwelveData/ApiTwelveDataSearchDto.cs | 23 ++ .../TwelveDataMarketDataProvider.cs | 345 ++++++++++++++++++ .../MarketExtensionCommandsProvider.cs | 14 +- MarketExtension/Pages/MarketsPage.cs | 2 +- MarketExtension/Pages/SearchPage.cs | 9 +- MarketExtension/Pages/SymbolDetailPage.cs | 2 +- .../Settings/MarketSettingsManager.cs | 24 +- 10 files changed, 538 insertions(+), 30 deletions(-) create mode 100644 MarketExtension/Data/TwelveData/ApiTwelveDataCandleDto.cs create mode 100644 MarketExtension/Data/TwelveData/ApiTwelveDataQuoteDto.cs create mode 100644 MarketExtension/Data/TwelveData/ApiTwelveDataSearchDto.cs create mode 100644 MarketExtension/Data/TwelveData/TwelveDataMarketDataProvider.cs diff --git a/CLAUDE.md b/CLAUDE.md index f3e8a53..00aaf09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,8 @@ the same three layers and the same provider seam — see the "Symbol detail + li |---|---| | `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. | | `Data/IMarketDataProvider.cs` | ONE data source: `bool Supports(AssetCategory)` + `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/Finnhub/FinnhubMarketDataProvider.cs` | Active provider (`Supports` Stock+Crypto). Maps `ApiFinnhubQuoteDto` → `DomainQuote`; `SearchAsync` calls `/search` (US equities only — see Finnhub specifics). | +| `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}) + its own source-gen `FrankfurterJsonContext`. | | `Data/MockMarketDataProvider.cs` | Offline fallback (`Supports` all). `SearchAsync` filters its seed keys. | @@ -90,6 +91,9 @@ the same three layers and the same provider seam — see the "Symbol detail + li | `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 | | `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. | @@ -104,10 +108,31 @@ chart history, else the default invalid-series opt-out applies), map its `Api*` (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; a Twelve Data provider would -map the same `CandleInterval` to `5min/30min/1h/1day/1week`. **Worked example:** -`FrankfurterMarketDataProvider` is exactly this — a keyless FX source added behind the seam with zero -UI/repository changes (`Supports(Currency)` + a time-series fetch for both quotes and candles). +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 Sentry 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. @@ -148,8 +173,11 @@ UI/repository changes (`Supports(Currency)` + a time-series fetch for both quote **AOT/trim:** project is AOT/trim-enabled; `EnableTrimAnalyzer` is on **in Debug** (so IL2026/IL3050 surface every build) and `ILLinkTreatWarningsAsErrors` is set, so all JSON must go through a source-gen -`JsonSerializerContext` (never reflection-based `JsonSerializer`). Both contexts are source-gen: -`FinnhubJsonContext` (quotes + search) and `WatchlistJsonContext` (watchlist items + legacy migration). +`JsonSerializerContext` (never reflection-based `JsonSerializer`). The source-gen contexts: +`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). ⚠️ **Gotcha:** 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 @@ -158,6 +186,13 @@ 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 @@ -180,6 +215,17 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). ## Current Status / Next Steps +- **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 to **compile**; live end-to-end verification with a real key is pending. + See "Twelve Data specifics". ⚠️ Working tree is **uncommitted** (on `repo`). - **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 @@ -192,8 +238,11 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.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 - endpoint is **premium-gated**, so real charts need a paid key (free key → "requires paid plan"; the mock - provider draws synthetic candles for offline preview). The **range-switch flicker is fixed**; 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). ⚠️ Working tree is **uncommitted** (on `repo`). - **Done (this round):** **live price polling** — prices now auto-refresh on a timer while a priced @@ -223,8 +272,9 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). 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: candles are premium-gated, so on a **free key** each - tick just re-fetches a 403 (correct but invisible until a paid key lands). + 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. - **Deferred:** rate-limit (429) **back-off** + richer error UX (the keep-last-good guard is a first step, not the full story); crypto/Finnhub-side FX in symbol search. - **Done (this round): stale-revisit catch-up** — closes the "short visit" gap in live polling. The @@ -423,10 +473,11 @@ through a new provider seam — see the Finnhub candle spec + the candle layer f 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:** candles are premium → on a free key every chart 403s → the card shows - "requires a paid Finnhub plan". No code change is needed when a paid key lands (candles read the same - settings `FinnhubApiKey`). To preview rendering now, temporarily point the repo at - `new MarketRepository(new MockMarketDataProvider())` (don't ship — mock also feeds search). +- **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, temporarily point the repo at `new MarketRepository(new MockMarketDataProvider())` (don't ship — + mock also feeds search). **State of the two UI issues: flicker FIXED; the Enter/focus bug is UNSOLVED (left as-is, documented).** @@ -510,8 +561,9 @@ For reference, the Perf Monitor source this chart was ported from 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. Premium-gated, so a free key just re-fetches a 403 each tick (invisible until a - paid key). See the "Done (this round): symbol-detail chart live refresh" bullet in Current Status. + 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) 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..5fd3197 --- /dev/null +++ b/MarketExtension/Data/TwelveData/ApiTwelveDataQuoteDto.cs @@ -0,0 +1,34 @@ +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("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..0916a72 --- /dev/null +++ b/MarketExtension/Data/TwelveData/TwelveDataMarketDataProvider.cs @@ -0,0 +1,345 @@ +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 Http.GetAsync($"quote?symbol={joined}&apikey={ApiKey}", 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 quote = new DomainQuote( + i.Symbol, i.Name, i.Category, + dto.Close.Value, dto.Change ?? 0m, dto.PercentChange ?? 0m, IsValid: true); + Log.Info("TwelveData", $"{tdSymbol} -> price={quote.Price} change%={quote.ChangePercent}"); + 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) + 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. Degrade to "no + // quotes" (all invalid) rather than throwing — keeps it out of the error/Sentry path. + Log.Warn("TwelveData", $"quote: could not parse response ({ex.Message}) — treating as unavailable"); + } + return result; + } + + 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 Http.GetAsync( + $"symbol_search?symbol={Uri.EscapeDataString(query)}&outputsize={MaxSearchResults}&apikey={ApiKey}", + 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 Http.GetAsync( + $"time_series?symbol={Uri.EscapeDataString(symbol)}&interval={interval}" + + $"&outputsize={outputsize}&apikey={ApiKey}", 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); + + // 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/MarketExtensionCommandsProvider.cs b/MarketExtension/MarketExtensionCommandsProvider.cs index 58db5a0..a0ef23b 100644 --- a/MarketExtension/MarketExtensionCommandsProvider.cs +++ b/MarketExtension/MarketExtensionCommandsProvider.cs @@ -6,11 +6,15 @@ namespace MarketExtension; public partial class MarketExtensionCommandsProvider : CommandProvider { // The repository coordinates all market-data providers; both the palette page and the dock - // band share this one instance. Routing is by asset class: Finnhub serves stocks + crypto, - // Frankfurter serves forex (keyless ECB rates). Add a provider here to extend coverage; + // band share this one instance. Routing is by asset class, first-match in this order: Twelve Data + // (stocks + crypto + forex, plus free price charts) is primary WHEN its key is set — its Supports() + // is gated on the 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; // MockMarketDataProvider is the offline fallback. private readonly MarketRepository _repository = - new(new FinnhubMarketDataProvider(), new FrankfurterMarketDataProvider()); + new(new TwelveDataMarketDataProvider(), + new FinnhubMarketDataProvider(), + new FrankfurterMarketDataProvider()); private readonly ICommandItem[] _commands; private readonly ICommandItem[] _dockBands; @@ -21,8 +25,8 @@ public MarketExtensionCommandsProvider() DisplayName = "Market Extension"; Icon = new IconInfo("https://github.com/favicon.ico"); - // Surface the extension's settings (Finnhub API key + price refresh interval) in the - // Command Palette Settings UI. See Settings/MarketSettingsManager.cs. + // 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 diff --git a/MarketExtension/Pages/MarketsPage.cs b/MarketExtension/Pages/MarketsPage.cs index ef25e74..1dc94d1 100644 --- a/MarketExtension/Pages/MarketsPage.cs +++ b/MarketExtension/Pages/MarketsPage.cs @@ -59,7 +59,7 @@ public override IListItem[] GetItems() => new ListItem(_settingsPage) { Title = "Settings", - Subtitle = "Finnhub API key and price refresh interval", + Subtitle = "API keys and price refresh interval", Icon = new IconInfo(SettingsGlyph), }, ]; diff --git a/MarketExtension/Pages/SearchPage.cs b/MarketExtension/Pages/SearchPage.cs index fc06741..472238f 100644 --- a/MarketExtension/Pages/SearchPage.cs +++ b/MarketExtension/Pages/SearchPage.cs @@ -7,9 +7,10 @@ namespace MarketExtension; -// Default entry screen: the Enter-only Finnhub /search flow. Typing never hits the network -// (free-tier rate limits) — it only changes what the synthetic "Search Finnhub for ..." item will -// look up. Results are price-less identities; Enter on a result opens its SymbolDetailPage, which is +// 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. @@ -104,7 +105,7 @@ private IListItem[] SearchItems() // First item → pressing Enter runs the one /search call (see RunSearchCommand). new ListItem(new RunSearchCommand(this)) { - Title = $"Search Finnhub for \"{SearchText}\"", + Title = $"Search markets for \"{SearchText}\"", Subtitle = "Press Enter to look up symbols online", Icon = new IconInfo(SearchGlyph), }, diff --git a/MarketExtension/Pages/SymbolDetailPage.cs b/MarketExtension/Pages/SymbolDetailPage.cs index a78be12..f5f36ea 100644 --- a/MarketExtension/Pages/SymbolDetailPage.cs +++ b/MarketExtension/Pages/SymbolDetailPage.cs @@ -296,7 +296,7 @@ private string BuildData(DomainCandleSeries? series, ChartRange range) data["showStatus"] = !hasChart; data["statusText"] = hasChart ? string.Empty - : "No chart data — historical candles require a paid Finnhub plan."; + : "No chart data available for this range."; return data.ToJsonString(); } diff --git a/MarketExtension/Settings/MarketSettingsManager.cs b/MarketExtension/Settings/MarketSettingsManager.cs index bd60d57..0aaa7be 100644 --- a/MarketExtension/Settings/MarketSettingsManager.cs +++ b/MarketExtension/Settings/MarketSettingsManager.cs @@ -11,9 +11,11 @@ namespace MarketExtension; // MarketExtensionCommandsProvider (Settings = MarketSettingsManager.Instance.Settings). // // Settings today: +// - Twelve Data API key. The primary provider when set (stocks, crypto, FX + free charts); empty +// means the extension falls back to Finnhub/Frankfurter. // - Finnhub API key. This is the ONLY source of the key — there is no built-in/baked key; an // empty value means the Finnhub provider returns no prices until the user supplies their own. -// - Price refresh interval in minutes (the seam for the upcoming live-price polling; 0 = off). +// - Price refresh interval in minutes (drives live-price polling; 0 = off). // // Naming note: provider keys are named per-provider (FinnhubApiKey, not a generic ApiKey) because // additional providers (e.g. a forex source) will each get their own key setting here later. @@ -27,10 +29,19 @@ internal sealed class MarketSettingsManager : JsonSettingsManager // Default auto-refresh cadence when the field is blank/unparseable. private const int DefaultRefreshMinutes = 5; + private readonly TextSetting _twelveDataApiKey = new("twelveDataApiKey", string.Empty) + { + Label = "Twelve Data API key", + Description = "Your Twelve Data API key. When set, Twelve Data becomes the primary source for " + + "stocks, crypto and forex — and renders real price charts on the free tier. Leave " + + "blank to fall back to Finnhub/Frankfurter. Get a free key at https://twelvedata.com.", + Placeholder = "Paste your Twelve Data API key", + }; + private readonly TextSetting _finnhubApiKey = new("finnhubApiKey", string.Empty) { Label = "Finnhub API key", - Description = "Your Finnhub API key. Required — prices won't load until this is set. " + + Description = "Your Finnhub API key. Used for stocks/crypto when no Twelve Data key is set. " + "Get a free key at https://finnhub.io.", Placeholder = "Paste your Finnhub API key", }; @@ -44,6 +55,14 @@ internal sealed class MarketSettingsManager : JsonSettingsManager Placeholder = DefaultRefreshMinutes.ToString(CultureInfo.InvariantCulture), }; + // 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. @@ -68,6 +87,7 @@ internal sealed class MarketSettingsManager : JsonSettingsManager private MarketSettingsManager() { FilePath = Path.Combine(Utilities.BaseSettingsPath("Microsoft.CmdPal"), "market.settings.json"); + Settings.Add(_twelveDataApiKey); Settings.Add(_finnhubApiKey); Settings.Add(_refreshMinutes); LoadSettings(); From 5297164bcd6ff57580c2d27071af0b3de81bb8fb Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 00:58:25 +0300 Subject: [PATCH 31/69] add 12data --- .../Settings/MarketSettingsManager.cs | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/MarketExtension/Settings/MarketSettingsManager.cs b/MarketExtension/Settings/MarketSettingsManager.cs index 0aaa7be..e0ca5e9 100644 --- a/MarketExtension/Settings/MarketSettingsManager.cs +++ b/MarketExtension/Settings/MarketSettingsManager.cs @@ -10,18 +10,16 @@ namespace MarketExtension; // reference/settings/AdbSettingsManager.cs. Wired into the host via // MarketExtensionCommandsProvider (Settings = MarketSettingsManager.Instance.Settings). // -// Settings today: -// - Twelve Data API key. The primary provider when set (stocks, crypto, FX + free charts); empty -// means the extension falls back to Finnhub/Frankfurter. -// - Finnhub API key. This is the ONLY source of the key — there is no built-in/baked key; an -// empty value means the Finnhub provider returns no prices until the user supplies their own. -// - Price refresh interval in minutes (drives live-price polling; 0 = off). +// 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. // -// Naming note: provider keys are named per-provider (FinnhubApiKey, not a generic ApiKey) because -// additional providers (e.g. a forex source) will each get their own key setting here later. +// Note: keys are NOT masked — the toolkit's TextSetting has no password mode; settings live in a +// local plaintext JSON file anyway. // -// Note: the API key is NOT masked — the toolkit's TextSetting has no password mode. That's -// acceptable here: 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(); @@ -32,26 +30,22 @@ internal sealed class MarketSettingsManager : JsonSettingsManager private readonly TextSetting _twelveDataApiKey = new("twelveDataApiKey", string.Empty) { Label = "Twelve Data API key", - Description = "Your Twelve Data API key. When set, Twelve Data becomes the primary source for " + - "stocks, crypto and forex — and renders real price charts on the free tier. Leave " + - "blank to fall back to Finnhub/Frankfurter. Get a free key at https://twelvedata.com.", - Placeholder = "Paste your Twelve Data API key", + Description = "Twelve Data API key. Used first when set; other providers serve only as fallback.", + Placeholder = "Paste your API key", }; private readonly TextSetting _finnhubApiKey = new("finnhubApiKey", string.Empty) { Label = "Finnhub API key", - Description = "Your Finnhub API key. Used for stocks/crypto when no Twelve Data key is set. " + - "Get a free key at https://finnhub.io.", - Placeholder = "Paste your Finnhub API key", + Description = "Finnhub API key.", + Placeholder = "Paste your API key", }; private readonly TextSetting _refreshMinutes = new( "refreshMinutes", DefaultRefreshMinutes.ToString(CultureInfo.InvariantCulture)) { Label = "Price refresh interval (minutes)", - Description = "How often to refresh live prices while a Markets screen is open. " + - "Enter 0 to turn auto-refresh off. Lower values use more of the Finnhub rate limit.", + Description = "Price refresh interval in minutes. Enter 0 to turn it off.", Placeholder = DefaultRefreshMinutes.ToString(CultureInfo.InvariantCulture), }; From ebeb497e3597f24d433fffdba31c7718001e5fbd Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 01:15:03 +0300 Subject: [PATCH 32/69] add legal stuff --- MarketExtension/Pages/DataSourcesPage.cs | 61 ++++++++++++++++++++++++ MarketExtension/Pages/MarketsPage.cs | 9 ++++ 2 files changed, 70 insertions(+) create mode 100644 MarketExtension/Pages/DataSourcesPage.cs diff --git a/MarketExtension/Pages/DataSourcesPage.cs b/MarketExtension/Pages/DataSourcesPage.cs new file mode 100644 index 0000000..439646e --- /dev/null +++ b/MarketExtension/Pages/DataSourcesPage.cs @@ -0,0 +1,61 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +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 = new IconInfo("https://github.com/favicon.ico"); + Title = "Data Sources"; + Name = "Open"; + } + + public override IContent[] GetContent() => [_content]; +} diff --git a/MarketExtension/Pages/MarketsPage.cs b/MarketExtension/Pages/MarketsPage.cs index 1dc94d1..ea8b80a 100644 --- a/MarketExtension/Pages/MarketsPage.cs +++ b/MarketExtension/Pages/MarketsPage.cs @@ -13,11 +13,13 @@ internal sealed partial class MarketsPage : ListPage 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 InfoGlyph = "\uE946"; // Segoe MDL2 Info private const string SettingsGlyph = "\uE713"; // Segoe MDL2 Setting private readonly SearchPage _searchPage; private readonly WatchlistPage _watchlistPage; private readonly FavoritesPage _favoritesPage; + private readonly DataSourcesPage _dataSourcesPage; private readonly IContentPage _settingsPage; public MarketsPage(MarketRepository repository) @@ -30,6 +32,7 @@ public MarketsPage(MarketRepository repository) _searchPage = new SearchPage(repository); _watchlistPage = new WatchlistPage(repository); _favoritesPage = new FavoritesPage(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. @@ -56,6 +59,12 @@ public override IListItem[] GetItems() => Subtitle = "Your starred instruments — shown on the dock", Icon = new IconInfo(StarFillGlyph), }, + new ListItem(_dataSourcesPage) + { + Title = "Data Sources", + Subtitle = "Where quotes come from and how your keys are used", + Icon = new IconInfo(InfoGlyph), + }, new ListItem(_settingsPage) { Title = "Settings", From d17ccb479e2a6d0f8747e278bf7c1b56d9f40d0f Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 01:35:37 +0300 Subject: [PATCH 33/69] add hint for keys --- MarketExtension/Helpers/ApiKeyHint.cs | 40 +++++++ MarketExtension/Pages/MarketsPage.cs | 106 ++++++++++++------ MarketExtension/Pages/PricedListPage.cs | 8 +- MarketExtension/Pages/SearchPage.cs | 43 ++++--- .../Settings/MarketSettingsManager.cs | 20 +++- 5 files changed, 168 insertions(+), 49 deletions(-) create mode 100644 MarketExtension/Helpers/ApiKeyHint.cs diff --git a/MarketExtension/Helpers/ApiKeyHint.cs b/MarketExtension/Helpers/ApiKeyHint.cs new file mode 100644 index 0000000..147e8e6 --- /dev/null +++ b/MarketExtension/Helpers/ApiKeyHint.cs @@ -0,0 +1,40 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace MarketExtension; + +// A "you haven't set an API key" nudge for the list screens. With NEITHER a Twelve Data nor a Finnhub +// key set, stocks and crypto can't be priced or searched (only keyless Frankfurter FX still works), so +// the app looks broken/empty with no explanation. Rather than leave the user guessing, the hub, Search, +// Watchlist and Favorites append this row at the bottom when no key is configured; Enter navigates +// straight into the extension's Settings page (the same form the Markets hub links to). +internal static class ApiKeyHint +{ + private const string WarningGlyph = "\uE7BA"; // Segoe MDL2 Warning + + // Built once and reused: the toolkit's navigable settings form over our settings singleton. The + // missing-key row points its Enter command at this, so the user lands directly on the key fields. + 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 hint row, or null when a key is already configured. Callers append it only when non-null: + // + // if (ApiKeyHint.MissingKeyRow() is { } hint) items.Add(hint); + public static IListItem? MissingKeyRow() => + HasAnyKey + ? null + : new ListItem(SettingsPage) + { + Title = "No API key set — functionality is limited", + Subtitle = "Add an API key in Settings (press Enter)", + Icon = new IconInfo(WarningGlyph), + }; +} diff --git a/MarketExtension/Pages/MarketsPage.cs b/MarketExtension/Pages/MarketsPage.cs index ea8b80a..6d1b307 100644 --- a/MarketExtension/Pages/MarketsPage.cs +++ b/MarketExtension/Pages/MarketsPage.cs @@ -1,5 +1,8 @@ +using System; +using System.Collections.Generic; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; namespace MarketExtension; @@ -8,7 +11,7 @@ namespace MarketExtension; // 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 +internal sealed partial class MarketsPage : ListPage, INotifyItemsChanged { private const string SearchGlyph = "\uE721"; // Segoe MDL2 Search private const string ListGlyph = "\uE8FD"; // Segoe MDL2 List @@ -22,6 +25,34 @@ internal sealed partial class MarketsPage : ListPage 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; + // 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)); + } + remove + { + _itemsChanged -= value; + foreach (var subscription in _subscriptions) + subscription.Dispose(); + _subscriptions.Clear(); + } + } + + protected new void RaiseItemsChanged(int totalItems = -1) + => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); + public MarketsPage(MarketRepository repository) { Icon = new IconInfo("https://github.com/favicon.ico"); @@ -39,37 +70,46 @@ public MarketsPage(MarketRepository repository) _settingsPage = MarketSettingsManager.Instance.Settings.SettingsPage; } - public override IListItem[] GetItems() => - [ - new ListItem(_searchPage) - { - Title = "Search", - Subtitle = "Look up any stock, crypto, or currency", - Icon = new IconInfo(SearchGlyph), - }, - new ListItem(_watchlistPage) - { - Title = "Watchlist", - Subtitle = "The instruments you track, priced live", - Icon = new IconInfo(ListGlyph), - }, - new ListItem(_favoritesPage) - { - Title = "Favorites", - Subtitle = "Your starred instruments — shown on the dock", - Icon = new IconInfo(StarFillGlyph), - }, - new ListItem(_dataSourcesPage) - { - Title = "Data Sources", - Subtitle = "Where quotes come from and how your keys are used", - Icon = new IconInfo(InfoGlyph), - }, - new ListItem(_settingsPage) + public override IListItem[] GetItems() + { + var items = new List { - Title = "Settings", - Subtitle = "API keys and price refresh interval", - Icon = new IconInfo(SettingsGlyph), - }, - ]; + new ListItem(_searchPage) + { + Title = "Search", + Subtitle = "Look up any stock, crypto, or currency", + Icon = new IconInfo(SearchGlyph), + }, + new ListItem(_watchlistPage) + { + Title = "Watchlist", + Subtitle = "The instruments you track, priced live", + Icon = new IconInfo(ListGlyph), + }, + new ListItem(_favoritesPage) + { + Title = "Favorites", + Subtitle = "Your starred instruments — shown on the dock", + Icon = new IconInfo(StarFillGlyph), + }, + new ListItem(_dataSourcesPage) + { + Title = "Data Sources", + Subtitle = "Where quotes come from and how your keys are used", + Icon = new IconInfo(InfoGlyph), + }, + new ListItem(_settingsPage) + { + Title = "Settings", + Subtitle = "API keys and price refresh interval", + Icon = new IconInfo(SettingsGlyph), + }, + }; + + // With no API key configured, most of the app shows no data — surface that up front on the hub. + if (ApiKeyHint.MissingKeyRow() is { } hint) + items.Add(hint); + + return [.. items]; + } } diff --git a/MarketExtension/Pages/PricedListPage.cs b/MarketExtension/Pages/PricedListPage.cs index ea306e4..668046f 100644 --- a/MarketExtension/Pages/PricedListPage.cs +++ b/MarketExtension/Pages/PricedListPage.cs @@ -56,6 +56,10 @@ event TypedEventHandler INotifyItemsChanged.Item // 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.Instance.Subscribe(_ => PollRefresh(), replayOnSubscribe: false)); + // 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)); Log.Info("Poll", $"{Title}: started polling [{string.Join(", ", _snapshot.Select(i => i.Symbol))}] " + $"(every {MarketSettingsManager.Instance.RefreshMinutes} min, 0=off)"); } @@ -98,7 +102,7 @@ public override IListItem[] GetItems() return []; // framework's pre-subscribe fetch — nothing to show yet if (_snapshot.Count == 0) - return EmptyState(); + return ApiKeyHint.MissingKeyRow() is { } emptyHint ? [.. EmptyState(), emptyHint] : EmptyState(); UiQuote[] cached; lock (_cacheLock) @@ -112,6 +116,8 @@ public override IListItem[] GetItems() var rows = cached.Where(Matches).Select(BuildRow).ToList(); rows.Add(new ListItem(new RefreshCommand(this)) { Title = "Refresh 🔄" }); rows.Add(AssetIconResolver.AttributionRow()); // Elbstream logo credit (rows above show logos) + if (ApiKeyHint.MissingKeyRow() is { } hint) // no key → explain why prices are blank + rows.Add(hint); return [.. rows]; } diff --git a/MarketExtension/Pages/SearchPage.cs b/MarketExtension/Pages/SearchPage.cs index 472238f..22fd95a 100644 --- a/MarketExtension/Pages/SearchPage.cs +++ b/MarketExtension/Pages/SearchPage.cs @@ -48,6 +48,10 @@ event TypedEventHandler INotifyItemsChanged.Item // 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)); } remove { @@ -80,21 +84,29 @@ 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() => - [ - new ListItem(_watchlistPage) - { - Title = "Watchlist", - Subtitle = "The instruments you track", - Icon = new IconInfo(ListGlyph), - }, - new ListItem(_favoritesPage) + private IListItem[] HomeItems() + { + var items = new List { - Title = "Favorites", - Subtitle = "Your starred instruments — shown on the dock", - Icon = new IconInfo(StarFillGlyph), - }, - ]; + new ListItem(_watchlistPage) + { + Title = "Watchlist", + Subtitle = "The instruments you track", + Icon = new IconInfo(ListGlyph), + }, + new ListItem(_favoritesPage) + { + Title = "Favorites", + Subtitle = "Your starred instruments — shown on the dock", + Icon = new IconInfo(StarFillGlyph), + }, + }; + + if (ApiKeyHint.MissingKeyRow() is { } hint) // no key → nudge the user to add one + 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). @@ -130,6 +142,9 @@ private IListItem[] SearchItems() items.Add(AssetIconResolver.AttributionRow()); // Elbstream logo credit (results show logos) } + if (ApiKeyHint.MissingKeyRow() is { } hint) // no key → explain why stock/crypto search is empty + items.Add(hint); + return [.. items]; } diff --git a/MarketExtension/Settings/MarketSettingsManager.cs b/MarketExtension/Settings/MarketSettingsManager.cs index e0ca5e9..dbb4f8d 100644 --- a/MarketExtension/Settings/MarketSettingsManager.cs +++ b/MarketExtension/Settings/MarketSettingsManager.cs @@ -49,6 +49,12 @@ internal sealed class MarketSettingsManager : JsonSettingsManager Placeholder = DefaultRefreshMinutes.ToString(CultureInfo.InvariantCulture), }; + // 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); + // 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; @@ -66,6 +72,11 @@ internal sealed class MarketSettingsManager : JsonSettingsManager // 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 @@ -85,6 +96,13 @@ private MarketSettingsManager() Settings.Add(_finnhubApiKey); Settings.Add(_refreshMinutes); LoadSettings(); - Settings.SettingsChanged += (_, _) => SaveSettings(); + _hasAnyApiKey.Update(HasTwelveDataApiKey || HasFinnhubApiKey); // seed from persisted keys (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); + }; } } From d6d1edf1b51fe775b851b8e7882fb749fd4c9753 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 02:02:08 +0300 Subject: [PATCH 34/69] add hint for keys --- CLAUDE.md | 44 ++++++++++++++++------ MarketExtension/Helpers/ApiKeyHint.cs | 9 +++++ MarketExtension/MarketExtension.csproj | 30 +++++---------- MarketExtension/Pages/FavoritesDockPage.cs | 2 +- MarketExtension/Pages/MarketsPage.cs | 2 +- MarketExtension/Pages/SearchPage.cs | 2 +- MarketExtension/Pages/SymbolDetailPage.cs | 2 +- MarketExtension/Program.cs | 4 +- 8 files changed, 57 insertions(+), 38 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 00aaf09..6102d76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -171,18 +171,23 @@ UI/repository changes (only a registration line + a settings key). - 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:** project is AOT/trim-enabled; `EnableTrimAnalyzer` is on **in Debug** (so IL2026/IL3050 -surface every build) and `ILLinkTreatWarningsAsErrors` is set, so all JSON must go through a source-gen -`JsonSerializerContext` (never reflection-based `JsonSerializer`). The source-gen contexts: +**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). -⚠️ **Gotcha:** 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`). +`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) @@ -276,7 +281,8 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). 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. - **Deferred:** rate-limit (429) **back-off** + richer error UX (the keep-last-good guard is a first - step, not the full story); crypto/Finnhub-side FX in symbol search. + step, not the full story — `Retry`/`RetryWhen` is a natural fit if the Rx migration below happens); + crypto/Finnhub-side FX in symbol search. - **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 @@ -297,6 +303,21 @@ for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). - **Wishlist:** a **portfolio** screen + its own dock band (holdings, total value, daily P&L); and a **per-symbol detail screen + live chart for any ticker** (drill-in from any list row, clickable from any dock item). All designed below. +- **Wishlist — migrate the hand-rolled StateFlow to Rx.NET (`System.Reactive`):** the original blocker is + **gone** now that AOT/trim is **off** (the trim/AOT analyzers were removed — see the AOT/trim note), so + `System.Reactive` can be taken as a dependency **warning-free**. `Helpers/StateFlow.cs` is effectively a + hand-rolled `BehaviorSubject` (current value + replay-on-subscribe + distinct-until-changed), and the + `OnActive`/`OnInactive` refcount in `Helpers/PollTicker.cs` is Rx's `Publish().RefCount()` / + `WhileSubscribed` — so the swap is largely 1:1. **Not worth doing for the swap alone** (StateFlow works + today); the payoff is Rx's **operator library** for the **deferred** work: 429 **back-off** + (`Retry`/`RetryWhen` + exponential delay), the poll loop (`Observable.Timer`/`Interval`), **debounced** + search-as-you-type (`Throttle` — would let `SearchPage` drop its Enter-only rate-limit guard), and + multi-provider **merge** in `MarketRepository` (`CombineLatest`/`Merge`). **Plan:** pull + `System.Reactive`; reimplement `StateFlow`/`MutableStateFlow` as thin wrappers over `BehaviorSubject` + keeping the **same public API** (so the pages, dock, and `PollTicker` don't change); then adopt operators + where the deferred features land. **Caveat:** Rx schedulers are mostly moot here (the CmdPal host already + marshals `RaiseItemsChanged`), so this buys **operators, not threading** — do it when the + polling/back-off/search work makes the operators pay for themselves, not before. ### Three-screen UX (done) @@ -523,7 +544,8 @@ For reference, the Perf Monitor source this chart was ported from - **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** → AOT/trim-safe (this project requires that — see the AOT section). The card + 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 diff --git a/MarketExtension/Helpers/ApiKeyHint.cs b/MarketExtension/Helpers/ApiKeyHint.cs index 147e8e6..ddde79f 100644 --- a/MarketExtension/Helpers/ApiKeyHint.cs +++ b/MarketExtension/Helpers/ApiKeyHint.cs @@ -12,6 +12,10 @@ internal static class ApiKeyHint { private const string WarningGlyph = "\uE7BA"; // Segoe MDL2 Warning + // Windows "attention" red \u2014 used for the warning 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); + // Built once and reused: the toolkit's navigable settings form over our settings singleton. The // missing-key row points its Enter command at this, so the user lands directly on the key fields. private static IContentPage? _settingsPage; @@ -36,5 +40,10 @@ internal static class ApiKeyHint Title = "No API key set — functionality is limited", Subtitle = "Add an API key in Settings (press Enter)", Icon = new IconInfo(WarningGlyph), + Tags = [new Tag("Action required") + { + Foreground = WarningRed, + ToolTip = "No API key set — functionality is limited", + }], }; } diff --git a/MarketExtension/MarketExtension.csproj b/MarketExtension/MarketExtension.csproj index 5118dfb..daabb58 100644 --- a/MarketExtension/MarketExtension.csproj +++ b/MarketExtension/MarketExtension.csproj @@ -75,33 +75,21 @@ true + true - true - - true - 2 - - IL2081;$(WarningsNotAsErrors) - - - true - - - - - false - - true - true - true - false true diff --git a/MarketExtension/Pages/FavoritesDockPage.cs b/MarketExtension/Pages/FavoritesDockPage.cs index 2db9e19..dd34bc6 100644 --- a/MarketExtension/Pages/FavoritesDockPage.cs +++ b/MarketExtension/Pages/FavoritesDockPage.cs @@ -51,7 +51,7 @@ event TypedEventHandler INotifyItemsChanged.Item } } - protected new void RaiseItemsChanged(int totalItems = -1) + private new void RaiseItemsChanged(int totalItems = -1) => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); public FavoritesDockPage(MarketRepository repository) diff --git a/MarketExtension/Pages/MarketsPage.cs b/MarketExtension/Pages/MarketsPage.cs index 6d1b307..88862a2 100644 --- a/MarketExtension/Pages/MarketsPage.cs +++ b/MarketExtension/Pages/MarketsPage.cs @@ -50,7 +50,7 @@ event TypedEventHandler INotifyItemsChanged.Item } } - protected new void RaiseItemsChanged(int totalItems = -1) + private new void RaiseItemsChanged(int totalItems = -1) => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); public MarketsPage(MarketRepository repository) diff --git a/MarketExtension/Pages/SearchPage.cs b/MarketExtension/Pages/SearchPage.cs index 22fd95a..b98a1ba 100644 --- a/MarketExtension/Pages/SearchPage.cs +++ b/MarketExtension/Pages/SearchPage.cs @@ -62,7 +62,7 @@ event TypedEventHandler INotifyItemsChanged.Item } } - protected new void RaiseItemsChanged(int totalItems = -1) + private new void RaiseItemsChanged(int totalItems = -1) => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); public SearchPage(MarketRepository repository) diff --git a/MarketExtension/Pages/SymbolDetailPage.cs b/MarketExtension/Pages/SymbolDetailPage.cs index f5f36ea..6998176 100644 --- a/MarketExtension/Pages/SymbolDetailPage.cs +++ b/MarketExtension/Pages/SymbolDetailPage.cs @@ -66,7 +66,7 @@ event TypedEventHandler INotifyItemsChanged.Item } } - protected new void RaiseItemsChanged(int totalItems = -1) + private new void RaiseItemsChanged(int totalItems = -1) => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); public SymbolDetailPage(DomainInstrument instrument, MarketRepository repository) diff --git a/MarketExtension/Program.cs b/MarketExtension/Program.cs index e230bbe..dc19bbe 100644 --- a/MarketExtension/Program.cs +++ b/MarketExtension/Program.cs @@ -19,7 +19,7 @@ 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 => + using var sentry = SentrySdk.Init(options => { options.Dsn = ""; // <-- your Sentry DSN here (empty = disabled) options.AutoSessionTracking = true; @@ -51,7 +51,7 @@ public static void Main(string[] args) } 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", From 0bfb4b4a94f677b6d0c19bbefcd2099477702d71 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 02:27:17 +0300 Subject: [PATCH 35/69] gridlines on chart --- CLAUDE.md | 10 ++++- MarketExtension/Helpers/ChartHelper.cs | 46 +++++++++++++++++++-- reference/Screenshot 2026-06-25 021841.png | Bin 0 -> 168937 bytes 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 reference/Screenshot 2026-06-25 021841.png diff --git a/CLAUDE.md b/CLAUDE.md index 6102d76..6bb0d9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,7 @@ the same three layers and the same provider seam — see the "Symbol detail + li | `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. Only caller: `UiCandleSeries`. | +| `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)". | | `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. | @@ -567,6 +567,14 @@ For reference, the Perf Monitor source this chart was ported from - 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 diff --git a/MarketExtension/Helpers/ChartHelper.cs b/MarketExtension/Helpers/ChartHelper.cs index cba8128..b78f209 100644 --- a/MarketExtension/Helpers/ChartHelper.cs +++ b/MarketExtension/Helpers/ChartHelper.cs @@ -10,15 +10,20 @@ namespace MarketExtension; // 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. The only caller is the Ui layer -// (UiCandleSeries) — this is a low-level string builder, like string.Format. +// 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 off the edges + 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"; @@ -29,12 +34,19 @@ internal static class ChartHelper 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) @@ -44,6 +56,9 @@ public static string CreateChart(IReadOnlyList closes, bool up) 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) { @@ -66,6 +81,31 @@ public static string CreateChart(IReadOnlyList closes, bool up) 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) diff --git a/reference/Screenshot 2026-06-25 021841.png b/reference/Screenshot 2026-06-25 021841.png new file mode 100644 index 0000000000000000000000000000000000000000..3d48b14aa9a9c777596e119048665346d21a99a3 GIT binary patch literal 168937 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 literal 0 HcmV?d00001 From f4e2297575e8799ff2fef3a1fd9789e240d60692 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 02:27:25 +0300 Subject: [PATCH 36/69] gridlines on chart --- reference/Screenshot 2026-06-25 021841.png | Bin 168937 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 reference/Screenshot 2026-06-25 021841.png diff --git a/reference/Screenshot 2026-06-25 021841.png b/reference/Screenshot 2026-06-25 021841.png deleted file mode 100644 index 3d48b14aa9a9c777596e119048665346d21a99a3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 168937 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 From d0d3c62320554a565e04c39b6734ba7a3434046e Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 02:30:54 +0300 Subject: [PATCH 37/69] updates --- CLAUDE.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6bb0d9c..387a319 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -229,8 +229,8 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context 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 to **compile**; live end-to-end verification with a real key is pending. - See "Twelve Data specifics". ⚠️ Working tree is **uncommitted** (on `repo`). + 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 @@ -249,7 +249,7 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context 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). ⚠️ Working tree is **uncommitted** (on `repo`). + 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 `StateFlow` ticker using the `OnActive`/`OnInactive` @@ -281,8 +281,12 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context 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. - **Deferred:** rate-limit (429) **back-off** + richer error UX (the keep-last-good guard is a first - step, not the full story — `Retry`/`RetryWhen` is a natural fit if the Rx migration below happens); - crypto/Finnhub-side FX in symbol search. + step, not the full story — `Retry`/`RetryWhen` is a natural fit if the Rx migration below happens). + **(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 85c581a776d22fca7b239d1670e6c0c759991fc1 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 02:40:03 +0300 Subject: [PATCH 38/69] convert to RX --- CLAUDE.md | 32 +++++---- Directory.Packages.props | 1 + MarketExtension/Helpers/StateFlow.cs | 93 +++++++++++++------------- MarketExtension/MarketExtension.csproj | 1 + 4 files changed, 67 insertions(+), 60 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 387a319..4322149 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ the same three layers and the same provider seam — see the "Symbol detail + li | `Data/MockMarketDataProvider.cs` | Offline fallback (`Supports` all). `SearchAsync` filters its seed keys. | | `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** (hand-rolled, no System.Reactive — keeps the AOT/trim build clean): `StateFlow` (read-only `Value` + `Subscribe` with **replay-on-subscribe** + distinct-until-changed; a `Subscribe(onNext, replayOnSubscribe:false)` overload opts out of the replay — used by the poll ticker) and writable `MutableStateFlow` (`Update`). Has **subscriber-count hooks** `OnActive`/`OnInactive` (0↔1 transitions = the WhileSubscribed / Rx `RefCount()` seam) driving the ticker poll loop. Plus `InstrumentListComparer` (symbol-sequence dedup for the store's two flows). | +| `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 wishlist bullet). 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 poll ticker) 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 **subscriber-count hooks** `OnActive`/`OnInactive` (0↔1 transitions = the WhileSubscribed / Rx `RefCount()` seam) are still **hand-counted here** (BehaviorSubject has no refcount hook) and drive the ticker poll loop. Plus `InstrumentListComparer` (symbol-sequence dedup for the store's two flows). | | `Helpers/PollTicker.cs` | The **live-price poll ticker** — the concrete `PolledStateFlow` the `OnActive`/`OnInactive` seam was built for. A process-wide singleton `StateFlow` that emits an incrementing tick on a `Task.Delay` loop **while any priced surface is subscribed** (timer starts in `OnActive`, cancels via CTS in `OnInactive`). Re-reads `MarketSettingsManager` each iteration (interval change applies without reload; `0` = off idles on a 30 s re-check). Priced surfaces subscribe with `replayOnSubscribe:false` and silently re-price on each tick. | | `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. | @@ -307,21 +307,25 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context - **Wishlist:** a **portfolio** screen + its own dock band (holdings, total value, daily P&L); and a **per-symbol detail screen + live chart for any ticker** (drill-in from any list row, clickable from any dock item). All designed below. -- **Wishlist — migrate the hand-rolled StateFlow to Rx.NET (`System.Reactive`):** the original blocker is - **gone** now that AOT/trim is **off** (the trim/AOT analyzers were removed — see the AOT/trim note), so - `System.Reactive` can be taken as a dependency **warning-free**. `Helpers/StateFlow.cs` is effectively a - hand-rolled `BehaviorSubject` (current value + replay-on-subscribe + distinct-until-changed), and the - `OnActive`/`OnInactive` refcount in `Helpers/PollTicker.cs` is Rx's `Publish().RefCount()` / - `WhileSubscribed` — so the swap is largely 1:1. **Not worth doing for the swap alone** (StateFlow works - today); the payoff is Rx's **operator library** for the **deferred** work: 429 **back-off** +- **Done (this round): StateFlow migrated to Rx.NET (`System.Reactive`).** `Helpers/StateFlow.cs` is now a + **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 identical** (`Value`, both `Subscribe` overloads, `Update`, + the `OnActive`/`OnInactive` hooks, `InstrumentListComparer`), so **pages, the dock, and `PollTicker` 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); the + `OnActive`/`OnInactive` **refcount is still hand-counted** (BehaviorSubject has no refcount hook — this is + the explicit `Publish().RefCount()`/`WhileSubscribed` seam). 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), the poll loop (`Observable.Timer`/`Interval`), **debounced** search-as-you-type (`Throttle` — would let `SearchPage` drop its Enter-only rate-limit guard), and - multi-provider **merge** in `MarketRepository` (`CombineLatest`/`Merge`). **Plan:** pull - `System.Reactive`; reimplement `StateFlow`/`MutableStateFlow` as thin wrappers over `BehaviorSubject` - keeping the **same public API** (so the pages, dock, and `PollTicker` don't change); then adopt operators - where the deferred features land. **Caveat:** Rx schedulers are mostly moot here (the CmdPal host already - marshals `RaiseItemsChanged`), so this buys **operators, not threading** — do it when the - polling/back-off/search work makes the operators pay for themselves, not before. + 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** — adopt them as the back-off/search work makes them pay for themselves. ⚠️ Verified to + **compile** clean; live runtime re-verification of polling/membership push is worth a quick smoke test. ### Three-screen UX (done) diff --git a/Directory.Packages.props b/Directory.Packages.props index 3bf9eb5..4a01c41 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ + diff --git a/MarketExtension/Helpers/StateFlow.cs b/MarketExtension/Helpers/StateFlow.cs index d5ca948..7db0616 100644 --- a/MarketExtension/Helpers/StateFlow.cs +++ b/MarketExtension/Helpers/StateFlow.cs @@ -1,81 +1,85 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reactive.Linq; +using System.Reactive.Subjects; using System.Threading; 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). Hand-rolled rather than System.Reactive so the AOT/trim -// build stays warning-clean (reflection-free; see CLAUDE.md AOT section). +// equality comparer (distinct-until-changed). +// +// Implemented as a thin wrapper over System.Reactive's BehaviorSubject (which already gives us the +// current value, replay-on-subscribe, and thread-safe observer fan-out). 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, Observable.Timer polling). The PUBLIC API is unchanged, so pages, the dock, and PollTicker +// 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. // // Subscriber-count awareness: OnActive() fires when the subscriber count goes 0 -> 1 and OnInactive() -// when it returns 1 -> 0. That is the WhileSubscribed / Rx RefCount() seam — a future PolledStateFlow -// (the ticker) starts its poll loop on OnActive and stops it on OnInactive, so it only does work while -// something is actually watching. Plain flows leave both hooks no-op. +// when it returns 1 -> 0. That is the WhileSubscribed / Rx RefCount() seam — PollTicker starts its poll +// loop on OnActive and stops it on OnInactive, so it only does work while something is actually watching. +// (BehaviorSubject has no refcount hook, so we keep counting subscribers by hand here — this IS the +// Publish().RefCount() seam, kept explicit so the hooks stay a clean override point.) Plain flows leave +// both hooks no-op. +[SuppressMessage("Reliability", "CA1001:Types that own disposable fields should be disposable", + Justification = "The BehaviorSubject is owned by process-lifetime singletons (WatchlistStore, " + + "MarketSettingsManager, PollTicker) whose flows are observed for the life of the " + + "process; it is intentionally never completed or disposed.")] internal partial class StateFlow { - private readonly Lock _lock = new(); + private readonly BehaviorSubject _subject; private readonly IEqualityComparer _comparer; - private T _value; - private Action? _observers; + private readonly Lock _countGate = new(); private int _count; protected StateFlow(T initial, IEqualityComparer? comparer = null) { - _value = initial; + _subject = new BehaviorSubject(initial); _comparer = comparer ?? EqualityComparer.Default; } - public T Value - { - get { lock (_lock) return _value; } - } + public T Value => _subject.Value; - // Subscribe and immediately receive the current value (StateFlow replay). Dispose to unsubscribe; - // disposal is idempotent. Handlers run on whatever thread mutates the value — the toolkit marshals - // the RaiseItemsChanged that handlers ultimately call. + // Subscribe and immediately receive the current value (StateFlow replay — BehaviorSubject does this). + // Dispose to unsubscribe; disposal is idempotent. 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. The poll ticker uses this: a page's first price load is already driven by its // membership flow's replay, so a replayed tick would just double-fetch on every open. OnActive still - // fires on the 0 -> 1 transition regardless (so the producer starts), only the replay is skipped. + // fires on the 0 -> 1 transition regardless (so the producer starts), only the replay is skipped — + // Skip(1) drops the value BehaviorSubject replays synchronously on subscribe. public IDisposable Subscribe(Action onNext, bool replayOnSubscribe) { ArgumentNullException.ThrowIfNull(onNext); bool becameActive; - T current; - lock (_lock) - { - _observers += onNext; - becameActive = ++_count == 1; - current = _value; - } + lock (_countGate) becameActive = ++_count == 1; + if (becameActive) OnActive(); // wake any producer before the replay-on-subscribe below - if (becameActive) OnActive(); // wake any producer before the first replay - if (replayOnSubscribe) onNext(current); // replay the current value - return new Subscription(this, onNext); + IObservable source = replayOnSubscribe ? _subject : _subject.Skip(1); + var inner = source.Subscribe(onNext); // BehaviorSubject replays the current value here (unless skipped) + return new Subscription(this, inner); } // Writable entry point for subclasses. Returns true if the value actually changed (i.e. listeners - // were notified). Fires handlers OUTSIDE the lock — they re-read the store, which re-takes it. + // 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) { - Action? observers; - lock (_lock) - { - if (_comparer.Equals(_value, value)) return false; // distinct-until-changed - _value = value; - observers = _observers; - } - - observers?.Invoke(value); + if (_comparer.Equals(_subject.Value, value)) return false; // distinct-until-changed + _subject.OnNext(value); return true; } @@ -85,19 +89,14 @@ protected virtual void OnActive() { } // Called on the 1 -> 0 subscriber transition (the flow "goes idle"). No-op by default. protected virtual void OnInactive() { } - private void Unsubscribe(Action onNext) + private void Unsubscribe() { bool becameIdle; - lock (_lock) - { - _observers -= onNext; - becameIdle = --_count == 0; - } - + lock (_countGate) becameIdle = --_count == 0; if (becameIdle) OnInactive(); } - private sealed partial class Subscription(StateFlow flow, Action onNext) : IDisposable + private sealed partial class Subscription(StateFlow flow, IDisposable inner) : IDisposable { private StateFlow? _flow = flow; @@ -105,7 +104,9 @@ public void Dispose() { // Null out atomically so a double-dispose can't decrement the count twice. var owner = Interlocked.Exchange(ref _flow, null); - owner?.Unsubscribe(onNext); + if (owner is null) return; + inner.Dispose(); // detach from the BehaviorSubject + owner.Unsubscribe(); } } } diff --git a/MarketExtension/MarketExtension.csproj b/MarketExtension/MarketExtension.csproj index daabb58..76d8ae6 100644 --- a/MarketExtension/MarketExtension.csproj +++ b/MarketExtension/MarketExtension.csproj @@ -58,6 +58,7 @@ + From 1f0d8baf5161afc67aff2893333e7a72ff5bf2fe Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 03:07:24 +0300 Subject: [PATCH 39/69] convert to RX --- CLAUDE.md | 93 +++++++------ MarketExtension/Helpers/PollTicker.cs | 152 +++++++++------------ MarketExtension/Helpers/StateFlow.cs | 93 +++++-------- MarketExtension/Pages/FavoritesDockPage.cs | 7 +- MarketExtension/Pages/PricedListPage.cs | 8 +- MarketExtension/Pages/SymbolDetailPage.cs | 9 +- 6 files changed, 162 insertions(+), 200 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4322149..388432a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,8 +86,8 @@ the same three layers and the same provider seam — see the "Symbol detail + li | `Data/MockMarketDataProvider.cs` | Offline fallback (`Supports` all). `SearchAsync` filters its seed keys. | | `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 wishlist bullet). 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 poll ticker) 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 **subscriber-count hooks** `OnActive`/`OnInactive` (0↔1 transitions = the WhileSubscribed / Rx `RefCount()` seam) are still **hand-counted here** (BehaviorSubject has no refcount hook) and drive the ticker poll loop. Plus `InstrumentListComparer` (symbol-sequence dedup for the store's two flows). | -| `Helpers/PollTicker.cs` | The **live-price poll ticker** — the concrete `PolledStateFlow` the `OnActive`/`OnInactive` seam was built for. A process-wide singleton `StateFlow` that emits an incrementing tick on a `Task.Delay` loop **while any priced surface is subscribed** (timer starts in `OnActive`, cancels via CTS in `OnInactive`). Re-reads `MarketSettingsManager` each iteration (interval change applies without reload; `0` = off idles on a 30 s re-check). Priced surfaces subscribe with `replayOnSubscribe:false` and silently re-price on each tick. | +| `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`→Sentry) 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`. | | `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). | @@ -252,8 +252,8 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context 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 `StateFlow` ticker using the `OnActive`/`OnInactive` - subscriber-count seam), the new `Subscribe(replayOnSubscribe:false)` overload, and per-surface + `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 5 min, `0` = off, applied without reload). A **keep-last-good guard** in `PricedListPage.LoadQuotes(silent)` @@ -270,8 +270,8 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context 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 + `OnActive`/ - `OnInactive` refcount as the priced list pages and dock. `SymbolDetailPage` subscribes the chart in its + 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 @@ -307,25 +307,32 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context - **Wishlist:** a **portfolio** screen + its own dock band (holdings, total value, daily P&L); and a **per-symbol detail screen + live chart for any ticker** (drill-in from any list row, clickable from any dock item). All designed below. -- **Done (this round): StateFlow migrated to Rx.NET (`System.Reactive`).** `Helpers/StateFlow.cs` is now a - **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 identical** (`Value`, both `Subscribe` overloads, `Update`, - the `OnActive`/`OnInactive` hooks, `InstrumentListComparer`), so **pages, the dock, and `PollTicker` 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); the - `OnActive`/`OnInactive` **refcount is still hand-counted** (BehaviorSubject has no refcount hook — this is - the explicit `Publish().RefCount()`/`WhileSubscribed` seam). 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), the poll loop (`Observable.Timer`/`Interval`), **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** — adopt them as the back-off/search work makes them pay for themselves. ⚠️ Verified to - **compile** clean; live runtime re-verification of polling/membership push is worth a quick smoke test. +- **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**. ⚠️ + Verified to **compile** clean; a live smoke test of polling (open a priced page, watch prices refresh on + the interval; hide/reshow to confirm the loop restarts) is worth doing. ### Three-screen UX (done) @@ -371,25 +378,29 @@ the user gets explicit feedback and can keep editing. 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 5 min, settings- -configurable** (0 = off). Built as designed, on the StateFlow subscriber-count seam. +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`.** A process-wide singleton `PollTicker : StateFlow` that - emits an incrementing tick on a `Task.Delay` loop. The loop **starts in `OnActive`** (the 0→1 - subscriber transition) and is **cancelled via a `CancellationTokenSource` in `OnInactive`** (1→0), so - polling runs only while something is watching. (We used `StateFlow` + a tick counter rather than - a generic `PolledStateFlow` carrying data, because re-emitting the membership list would be - swallowed by the flows' distinct-until-changed; an incrementing `long` always gets through.) The loop - re-reads `MarketSettingsManager` each iteration (interval/on-off applies without a reload); when off it - idles on a 30 s re-check so toggling it back on resumes. The CTS is swapped under a lock so a rapid - inactive→active bounce can't leave two loops running. -- **No double-fetch on open.** Surfaces subscribe with `Subscribe(_ => PollRefresh(), replayOnSubscribe: - false)` — the new overload skips the replay, so 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. +- **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` @@ -594,8 +605,8 @@ For reference, the Perf Monitor source this chart was ported from 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 + `OnActive`/ - `OnInactive` refcount with the priced list pages. `SymbolDetailPage` subscribes the chart in its + 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 diff --git a/MarketExtension/Helpers/PollTicker.cs b/MarketExtension/Helpers/PollTicker.cs index 043ff28..021cf97 100644 --- a/MarketExtension/Helpers/PollTicker.cs +++ b/MarketExtension/Helpers/PollTicker.cs @@ -1,105 +1,81 @@ using System; -using System.Diagnostics.CodeAnalysis; -using System.Threading; -using System.Threading.Tasks; +using System.Reactive.Linq; namespace MarketExtension; -// The live-price poll ticker — the concrete realization of the "PolledStateFlow" seam StateFlow was -// built for (its OnActive/OnInactive subscriber-count hooks = Kotlin's WhileSubscribed / Rx RefCount()). +// 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. // -// It is a StateFlow that emits a monotonically increasing tick on a timer WHILE at least one -// surface is subscribed, and stops the timer when the last one unsubscribes. Priced surfaces -// (PricedListPage, FavoritesDockPage) subscribe with replayOnSubscribe:false and re-price their current -// set on each tick. A plain counter (not the instrument list) is used because the membership flows dedup -// identical symbol sequences (distinct-until-changed) — an incrementing long always gets through. +// 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. // -// One process-wide instance is 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). The loop re-reads -// the interval from MarketSettingsManager each iteration, so a settings change applies without a reload; -// when auto-refresh is off (interval 0) it idles on a short re-check so toggling it on later resumes. +// 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.) // -// AOT/trim-safe: only Task.Delay / CancellationTokenSource / System.Threading.Lock — no reflection, no -// JSON, no new serializer context (see CLAUDE.md AOT section). -[SuppressMessage("Reliability", "CA1001:Types that own disposable fields should be disposable", - Justification = "Process-lifetime singleton; the CTS is created/disposed per active-poll cycle in " + - "OnActive/OnInactive and the instance itself is never disposed.")] -internal sealed class PollTicker : StateFlow +// 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 + Sentry-logs 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 { - public static readonly PollTicker Instance = new(); - // 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); - private readonly Lock _gate = new(); - private CancellationTokenSource? _cts; - - private PollTicker() : base(0) { } - - // First subscriber arrived: (re)start the poll loop. Cancelling any prior CTS first guards a rapid - // inactive -> active bounce from leaving two loops running. - protected override void OnActive() - { - lock (_gate) - { - _cts?.Cancel(); - _cts?.Dispose(); - _cts = new CancellationTokenSource(); - _ = PollLoop(_cts.Token); - } - - Log.Info("Poll", "Poll loop started — a priced surface became visible"); - } - - // Last subscriber left: stop the loop. The in-flight Task.Delay throws OperationCanceledException, - // which PollLoop swallows. - protected override void OnInactive() - { - lock (_gate) - { - _cts?.Cancel(); - _cts?.Dispose(); - _cts = null; - } - - Log.Info("Poll", "Poll loop stopped — all priced surfaces hidden"); - } - - private async Task PollLoop(CancellationToken ct) - { - try - { - while (!ct.IsCancellationRequested) + // 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(() => { - // Re-read live each iteration so an interval/on-off change applies without a reload. - var settings = MarketSettingsManager.Instance; - var delay = settings.AutoRefreshEnabled ? settings.RefreshInterval : OffRecheckInterval; - - await Task.Delay(delay, ct).ConfigureAwait(false); + 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(); - if (!settings.AutoRefreshEnabled) - continue; - - try - { - SetValue(Value + 1); // emit a tick -> every visible priced surface re-prices in place - Log.Info("Poll", $"Tick #{Value} — signalling visible surfaces to refresh"); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - // A subscriber's tick handler threw on this (the poll-loop) thread. Swallow it so one bad - // tick can't kill the whole loop: SetValue notifies subscribers synchronously, and a - // pinned dock keeps the subscriber count >= 1, so OnActive (which restarts the loop on a - // 0 -> 1 transition) would never fire again — polling would stay dead until an extension - // reload. Report to Sentry (Log.Error survives Release) and keep ticking. - Log.Error("Poll", "tick handler threw — continuing poll loop", ex); - } - } - } - catch (OperationCanceledException) + // Run onTick on every poll tick while subscribed. The handler is wrapped so a throw is contained + // (swallowed + Sentry-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(_ => { - // Cancelled in OnInactive (or on a rapid restart) — just stop. - } + try { onTick(); } + catch (Exception ex) { Log.Error("Poll", "tick handler threw — continuing poll loop", ex); } + }); } } diff --git a/MarketExtension/Helpers/StateFlow.cs b/MarketExtension/Helpers/StateFlow.cs index 7db0616..957306b 100644 --- a/MarketExtension/Helpers/StateFlow.cs +++ b/MarketExtension/Helpers/StateFlow.cs @@ -3,7 +3,6 @@ using System.Diagnostics.CodeAnalysis; using System.Reactive.Linq; using System.Reactive.Subjects; -using System.Threading; namespace MarketExtension; @@ -11,34 +10,27 @@ namespace MarketExtension; // subscriber the moment they subscribe, then pushes the new value on each change. Deduplicates with an // equality comparer (distinct-until-changed). // -// Implemented as a thin wrapper over System.Reactive's BehaviorSubject (which already gives us the -// current value, replay-on-subscribe, and thread-safe observer fan-out). 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 +// 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, Observable.Timer polling). The PUBLIC API is unchanged, so pages, the dock, and PollTicker -// don't change. +// 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. -// -// Subscriber-count awareness: OnActive() fires when the subscriber count goes 0 -> 1 and OnInactive() -// when it returns 1 -> 0. That is the WhileSubscribed / Rx RefCount() seam — PollTicker starts its poll -// loop on OnActive and stops it on OnInactive, so it only does work while something is actually watching. -// (BehaviorSubject has no refcount hook, so we keep counting subscribers by hand here — this IS the -// Publish().RefCount() seam, kept explicit so the hooks stay a clean override point.) Plain flows leave -// both hooks no-op. +// 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, PollTicker) whose flows are observed for the life of the " + - "process; it is intentionally never completed or disposed.")] -internal partial class StateFlow + "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; - private readonly Lock _countGate = new(); - private int _count; protected StateFlow(T initial, IEqualityComparer? comparer = null) { @@ -49,27 +41,35 @@ protected StateFlow(T initial, IEqualityComparer? comparer = null) public T Value => _subject.Value; // Subscribe and immediately receive the current value (StateFlow replay — BehaviorSubject does this). - // Dispose to unsubscribe; disposal is idempotent. Handlers run on whatever thread mutates the value - // (BehaviorSubject fans out OUTSIDE its internal lock) — the toolkit marshals the RaiseItemsChanged - // that handlers ultimately call. + // 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. The poll ticker uses this: a page's first price load is already driven by its - // membership flow's replay, so a replayed tick would just double-fetch on every open. OnActive still - // fires on the 0 -> 1 transition regardless (so the producer starts), only the replay is skipped — - // Skip(1) drops the value BehaviorSubject replays synchronously on subscribe. + // 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); - bool becameActive; - lock (_countGate) becameActive = ++_count == 1; - if (becameActive) OnActive(); // wake any producer before the replay-on-subscribe below + // 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 still reach Sentry via Log.Error (survives Release), 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); - var inner = source.Subscribe(onNext); // BehaviorSubject replays the current value here (unless skipped) - return new Subscription(this, inner); + return source.Subscribe(Guarded); } // Writable entry point for subclasses. Returns true if the value actually changed (i.e. listeners @@ -82,33 +82,6 @@ protected bool SetValue(T value) _subject.OnNext(value); return true; } - - // Called on the 0 -> 1 subscriber transition (the flow "becomes active"). No-op by default. - protected virtual void OnActive() { } - - // Called on the 1 -> 0 subscriber transition (the flow "goes idle"). No-op by default. - protected virtual void OnInactive() { } - - private void Unsubscribe() - { - bool becameIdle; - lock (_countGate) becameIdle = --_count == 0; - if (becameIdle) OnInactive(); - } - - private sealed partial class Subscription(StateFlow flow, IDisposable inner) : IDisposable - { - private StateFlow? _flow = flow; - - public void Dispose() - { - // Null out atomically so a double-dispose can't decrement the count twice. - var owner = Interlocked.Exchange(ref _flow, null); - if (owner is null) return; - inner.Dispose(); // detach from the BehaviorSubject - owner.Unsubscribe(); - } - } } // The writable side of a StateFlow — only the data owner (e.g. WatchlistStore) holds one of these and diff --git a/MarketExtension/Pages/FavoritesDockPage.cs b/MarketExtension/Pages/FavoritesDockPage.cs index dd34bc6..2723068 100644 --- a/MarketExtension/Pages/FavoritesDockPage.cs +++ b/MarketExtension/Pages/FavoritesDockPage.cs @@ -35,9 +35,10 @@ event TypedEventHandler INotifyItemsChanged.Item // 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). replay:false so - // becoming visible doesn't double-fetch — the favorites subscription above already paints. - _pollSubscription = PollTicker.Instance.Subscribe(_ => PollRefresh(), replayOnSubscribe: false); + // 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); Log.Info("Poll", $"Dock: started polling [{string.Join(", ", WatchlistStore.Instance.Favorites.Value.Select(i => i.Symbol))}]"); } remove diff --git a/MarketExtension/Pages/PricedListPage.cs b/MarketExtension/Pages/PricedListPage.cs index 668046f..9dc364b 100644 --- a/MarketExtension/Pages/PricedListPage.cs +++ b/MarketExtension/Pages/PricedListPage.cs @@ -52,10 +52,10 @@ event TypedEventHandler INotifyItemsChanged.Item // 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. replay:false 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.Instance.Subscribe(_ => PollRefresh(), replayOnSubscribe: false)); + // 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 diff --git a/MarketExtension/Pages/SymbolDetailPage.cs b/MarketExtension/Pages/SymbolDetailPage.cs index 6998176..a3865df 100644 --- a/MarketExtension/Pages/SymbolDetailPage.cs +++ b/MarketExtension/Pages/SymbolDetailPage.cs @@ -52,10 +52,11 @@ event TypedEventHandler INotifyItemsChanged.Item // 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 OnActive/OnInactive refcount) with the priced list pages + dock. - // replayOnSubscribe:false 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.Instance.Subscribe(_ => _chartForm.PollRefresh(), replayOnSubscribe: false)); + // 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 { From a6401f503ddffd9d70e66fe37a1c0254e1fc85e1 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 03:43:32 +0300 Subject: [PATCH 40/69] add rate limit handling --- CLAUDE.md | 49 ++++++++++-- .../Data/Finnhub/FinnhubMarketDataProvider.cs | 18 +++-- .../TwelveDataMarketDataProvider.cs | 48 ++++++++--- MarketExtension/Helpers/HttpRetry.cs | 79 +++++++++++++++++++ MarketExtension/Helpers/RateLimitHint.cs | 39 +++++++++ MarketExtension/Helpers/RateLimitSignal.cs | 41 ++++++++++ MarketExtension/Pages/PricedListPage.cs | 6 ++ MarketExtension/Pages/SearchPage.cs | 5 ++ .../Settings/MarketSettingsManager.cs | 13 +++ 9 files changed, 275 insertions(+), 23 deletions(-) create mode 100644 MarketExtension/Helpers/HttpRetry.cs create mode 100644 MarketExtension/Helpers/RateLimitHint.cs create mode 100644 MarketExtension/Helpers/RateLimitSignal.cs diff --git a/CLAUDE.md b/CLAUDE.md index 388432a..2db597c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,6 +88,9 @@ the same three layers and the same provider seam — see the "Symbol detail + li | `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`→Sentry) 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.MissingKeyRow()`): `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). | @@ -220,6 +223,30 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context ## Current Status / Next Steps +- **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). ⚠️ Verified to **compile**; + a live smoke test (exhaust a free key, confirm the banner appears then clears on recovery) is worth doing. - **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 @@ -280,8 +307,12 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context 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. -- **Deferred:** rate-limit (429) **back-off** + richer error UX (the keep-last-good guard is a first - step, not the full story — `Retry`/`RetryWhen` is a natural fit if the Rx migration below happens). +- **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 @@ -427,15 +458,19 @@ configurable** (0 = off). Built on the ticker's subscriber-count lifecycle (now - **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 5** (0 = off), surfaced as `RefreshMinutes` / - `RefreshInterval` (TimeSpan) / `AutoRefreshEnabled`; the ticker reads these each tick. (Same manager - also holds the Finnhub API key — see the "API Key" section.) + `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. (Same manager also holds the Finnhub API key — 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 **5-min default is gentler** (6 favorites = ~72 calls/hour). In place today: the - configurable interval + **Off** (0) escape, polling **only while visible**, and the keep-last-good - guard. Still deferred: real **429 back-off** + a "rate-limited" UI state; a batched-quote provider - would relax this further. ⚠️ Note: each visible surface polls independently, so a pinned favorites + 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 diff --git a/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs b/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs index 663a263..e5031f9 100644 --- a/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs +++ b/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs @@ -66,8 +66,9 @@ public async Task> SearchAsync(string query, Can // 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 Http.GetAsync( - $"search?q={Uri.EscapeDataString(query)}&exchange=US&token={ApiKey}", ct).ConfigureAwait(false); + 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}"); @@ -116,8 +117,9 @@ private static async Task FetchQuoteAsync(DomainInstrument instrume { // 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 Http.GetAsync( - $"quote?symbol={Uri.EscapeDataString(symbol)}&token={ApiKey}", ct).ConfigureAwait(false); + 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}"); @@ -175,9 +177,11 @@ public async Task GetCandlesAsync( { // 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 Http.GetAsync( - $"{endpoint}?symbol={Uri.EscapeDataString(symbol)}&resolution={resolution}" + - $"&from={fromUnix}&to={toUnix}&token={ApiKey}", ct).ConfigureAwait(false); + 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}"); diff --git a/MarketExtension/Data/TwelveData/TwelveDataMarketDataProvider.cs b/MarketExtension/Data/TwelveData/TwelveDataMarketDataProvider.cs index 0916a72..14edf8d 100644 --- a/MarketExtension/Data/TwelveData/TwelveDataMarketDataProvider.cs +++ b/MarketExtension/Data/TwelveData/TwelveDataMarketDataProvider.cs @@ -70,7 +70,8 @@ public async Task> GetQuotesAsync( 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 Http.GetAsync($"quote?symbol={joined}&apikey={ApiKey}", ct).ConfigureAwait(false); + 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}"); @@ -121,7 +122,10 @@ private static Dictionary ParseQuotes( { 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 { @@ -135,13 +139,36 @@ private static Dictionary ParseQuotes( 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. Degrade to "no - // quotes" (all invalid) rather than throwing — keeps it out of the error/Sentry path. + // 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/Sentry 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; @@ -158,9 +185,10 @@ public async Task> SearchAsync(string query, Can { // 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 Http.GetAsync( - $"symbol_search?symbol={Uri.EscapeDataString(query)}&outputsize={MaxSearchResults}&apikey={ApiKey}", - ct).ConfigureAwait(false); + 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}"); @@ -226,9 +254,11 @@ public async Task GetCandlesAsync( { // 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 Http.GetAsync( - $"time_series?symbol={Uri.EscapeDataString(symbol)}&interval={interval}" + - $"&outputsize={outputsize}&apikey={ApiKey}", ct).ConfigureAwait(false); + 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}"); 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/RateLimitHint.cs b/MarketExtension/Helpers/RateLimitHint.cs new file mode 100644 index 0000000..f50c57d --- /dev/null +++ b/MarketExtension/Helpers/RateLimitHint.cs @@ -0,0 +1,39 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +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.MissingKeyRow(). +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). Callers append it only when non-null: + // + // if (RateLimitHint.Row() is { } banner) items.Add(banner); + public static IListItem? Row() => + RateLimitSignal.Instance.IsRateLimited.Value && MarketSettingsManager.Instance.ShowRateLimitErrors + ? new ListItem(new NoOpCommand()) // informational only — Enter must not navigate (it's the first row) + { + Title = "Rate-limited — showing last known prices", + Subtitle = "The data provider is throttling requests; prices will refresh automatically once the limit clears.", + Icon = new IconInfo(WarningGlyph), + Tags = [new Tag("Rate-limited") + { + Foreground = CautionAmber, + ToolTip = "Requests are being throttled — prices may be stale", + }], + } + : null; +} diff --git a/MarketExtension/Helpers/RateLimitSignal.cs b/MarketExtension/Helpers/RateLimitSignal.cs new file mode 100644 index 0000000..ffb1388 --- /dev/null +++ b/MarketExtension/Helpers/RateLimitSignal.cs @@ -0,0 +1,41 @@ +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() { } + + // 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/Pages/PricedListPage.cs b/MarketExtension/Pages/PricedListPage.cs index 9dc364b..815fec6 100644 --- a/MarketExtension/Pages/PricedListPage.cs +++ b/MarketExtension/Pages/PricedListPage.cs @@ -60,6 +60,10 @@ event TypedEventHandler INotifyItemsChanged.Item // 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)"); } @@ -118,6 +122,8 @@ public override IListItem[] GetItems() rows.Add(AssetIconResolver.AttributionRow()); // Elbstream logo credit (rows above show logos) if (ApiKeyHint.MissingKeyRow() is { } hint) // no key → explain why prices are blank 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]; } diff --git a/MarketExtension/Pages/SearchPage.cs b/MarketExtension/Pages/SearchPage.cs index b98a1ba..a5d39b6 100644 --- a/MarketExtension/Pages/SearchPage.cs +++ b/MarketExtension/Pages/SearchPage.cs @@ -52,6 +52,9 @@ event TypedEventHandler INotifyItemsChanged.Item // replay:false — the membership replay above already paints the initial list. _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. + _subscriptions.Add(RateLimitSignal.Instance.IsRateLimited + .Subscribe(_ => RaiseItemsChanged(0), replayOnSubscribe: false)); } remove { @@ -144,6 +147,8 @@ private IListItem[] SearchItems() if (ApiKeyHint.MissingKeyRow() is { } hint) // no key → explain why stock/crypto search is empty items.Add(hint); + if (RateLimitHint.Row() is { } banner) // throttled → just under the search action, seen without scrolling + items.Insert(1, banner); return [.. items]; } diff --git a/MarketExtension/Settings/MarketSettingsManager.cs b/MarketExtension/Settings/MarketSettingsManager.cs index dbb4f8d..de2b4b8 100644 --- a/MarketExtension/Settings/MarketSettingsManager.cs +++ b/MarketExtension/Settings/MarketSettingsManager.cs @@ -49,6 +49,13 @@ internal sealed class MarketSettingsManager : JsonSettingsManager Placeholder = DefaultRefreshMinutes.ToString(CultureInfo.InvariantCulture), }; + private readonly ToggleSetting _showRateLimitErrors = new("showRateLimitErrors", true) + { + Label = "Show rate-limit warnings", + Description = "When on, a banner appears on the price lists while the data provider is throttling " + + "requests (HTTP 429) so you know why prices may be stale. Turn off to hide it.", + }; + // 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 @@ -89,12 +96,18 @@ internal sealed class MarketSettingsManager : JsonSettingsManager // 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; + private MarketSettingsManager() { FilePath = Path.Combine(Utilities.BaseSettingsPath("Microsoft.CmdPal"), "market.settings.json"); Settings.Add(_twelveDataApiKey); Settings.Add(_finnhubApiKey); Settings.Add(_refreshMinutes); + Settings.Add(_showRateLimitErrors); LoadSettings(); _hasAnyApiKey.Update(HasTwelveDataApiKey || HasFinnhubApiKey); // seed from persisted keys (no subscribers yet) Settings.SettingsChanged += (_, _) => From 9181cc38082f4f9f72b7028c735bb0c25100051d Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 03:52:05 +0300 Subject: [PATCH 41/69] placeholder portfolio setup --- MarketExtension/Pages/MarketsPage.cs | 19 ++++++--- MarketExtension/Pages/PortfolioPage.cs | 42 +++++++++++++++++++ .../Settings/MarketSettingsManager.cs | 42 +++++++++++++++++++ 3 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 MarketExtension/Pages/PortfolioPage.cs diff --git a/MarketExtension/Pages/MarketsPage.cs b/MarketExtension/Pages/MarketsPage.cs index 88862a2..8a12ce5 100644 --- a/MarketExtension/Pages/MarketsPage.cs +++ b/MarketExtension/Pages/MarketsPage.cs @@ -13,15 +13,17 @@ namespace MarketExtension; // 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 { - 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 InfoGlyph = "\uE946"; // Segoe MDL2 Info - private const string SettingsGlyph = "\uE713"; // Segoe MDL2 Setting + 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 = "\uE946"; // Segoe MDL2 Info + 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; @@ -63,6 +65,7 @@ public MarketsPage(MarketRepository repository) _searchPage = new SearchPage(repository); _watchlistPage = new WatchlistPage(repository); _favoritesPage = new FavoritesPage(repository); + _portfolioPage = new PortfolioPage(); _dataSourcesPage = new DataSourcesPage(); // The toolkit builds a navigable settings page straight from our settings (Finnhub API key + @@ -92,6 +95,12 @@ public override IListItem[] GetItems() Subtitle = "Your starred instruments — shown on the dock", Icon = new IconInfo(StarFillGlyph), }, + new ListItem(_portfolioPage) + { + Title = "Portfolio", + Subtitle = "Your holdings, total value, and daily P&L (coming soon)", + Icon = new IconInfo(PortfolioGlyph), + }, new ListItem(_dataSourcesPage) { Title = "Data Sources", diff --git a/MarketExtension/Pages/PortfolioPage.cs b/MarketExtension/Pages/PortfolioPage.cs new file mode 100644 index 0000000..bf09fcb --- /dev/null +++ b/MarketExtension/Pages/PortfolioPage.cs @@ -0,0 +1,42 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace MarketExtension; + +// PLACEHOLDER. Reached from the Markets hub. The real screen — actual holdings (quantity + cost +// basis) with total market value and daily P&L, plus its own dock band — is designed in CLAUDE.md +// under "Portfolio screen + dock band (future wishlist)" but not built yet. For now this is a plain +// static Markdown "coming soon" card (modeled on DataSourcesPage): no data fetch, no providers, no +// lifecycle. When it's built, this becomes a PricedListPage subclass; until then it just explains +// what's coming so the hub row isn't a dead end. +internal sealed partial class PortfolioPage : ContentPage +{ + private readonly MarkdownContent _content = new( + """ + # Portfolio + + **Coming soon.** + + This screen will track your actual holdings — not just symbols you watch, + but how much of each you hold — and roll them up into: + + - **Total market value** across everything you own + - **Daily profit & loss**, in both dollars and percent + - A per-holding breakdown, each priced live + + It'll share the same live price refresh as your Watchlist and Favorites, + and get its own dock band showing the one-line summary at a glance. + + For now this is a placeholder. Use **Watchlist** and **Favorites** to + track instruments in the meantime. + """); + + public PortfolioPage() + { + Icon = new IconInfo("https://github.com/favicon.ico"); + Title = "Portfolio"; + Name = "Open"; + } + + public override IContent[] GetContent() => [_content]; +} diff --git a/MarketExtension/Settings/MarketSettingsManager.cs b/MarketExtension/Settings/MarketSettingsManager.cs index de2b4b8..bb3aacd 100644 --- a/MarketExtension/Settings/MarketSettingsManager.cs +++ b/MarketExtension/Settings/MarketSettingsManager.cs @@ -56,6 +56,41 @@ internal sealed class MarketSettingsManager : JsonSettingsManager "requests (HTTP 429) so you know why prices may be stale. Turn off to hide it.", }; + // 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", + [ + new ChoiceSetSetting.Choice("US Dollar (USD)", "USD"), + new ChoiceSetSetting.Choice("Euro (EUR)", "EUR"), + new ChoiceSetSetting.Choice("British Pound (GBP)", "GBP"), + new ChoiceSetSetting.Choice("Japanese Yen (JPY)", "JPY"), + new ChoiceSetSetting.Choice("Swiss Franc (CHF)", "CHF"), + new ChoiceSetSetting.Choice("Australian Dollar (AUD)", "AUD"), + new ChoiceSetSetting.Choice("Canadian Dollar (CAD)", "CAD"), + new ChoiceSetSetting.Choice("New Zealand Dollar (NZD)", "NZD"), + new ChoiceSetSetting.Choice("Chinese Yuan (CNY)", "CNY"), + new ChoiceSetSetting.Choice("Hong Kong Dollar (HKD)", "HKD"), + new ChoiceSetSetting.Choice("Singapore Dollar (SGD)", "SGD"), + new ChoiceSetSetting.Choice("Swedish Krona (SEK)", "SEK"), + new ChoiceSetSetting.Choice("Norwegian Krone (NOK)", "NOK"), + new ChoiceSetSetting.Choice("Danish Krone (DKK)", "DKK"), + new ChoiceSetSetting.Choice("Polish Złoty (PLN)", "PLN"), + new ChoiceSetSetting.Choice("South African Rand (ZAR)", "ZAR"), + new ChoiceSetSetting.Choice("Mexican Peso (MXN)", "MXN"), + new ChoiceSetSetting.Choice("Indian Rupee (INR)", "INR"), + new ChoiceSetSetting.Choice("Brazilian Real (BRL)", "BRL"), + new ChoiceSetSetting.Choice("South Korean Won (KRW)", "KRW"), + ]) + { + Label = "Portfolio currency", + Description = "The currency your portfolio totals are shown in. Holdings priced in other " + + "currencies will be converted into it.", + }; + // 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 @@ -101,6 +136,12 @@ internal sealed class MarketSettingsManager : JsonSettingsManager // so a toggle applies the next time a priced page re-renders (e.g. on navigating back to it). public bool ShowRateLimitErrors => _showRateLimitErrors.Value; + // 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"); @@ -108,6 +149,7 @@ private MarketSettingsManager() Settings.Add(_finnhubApiKey); Settings.Add(_refreshMinutes); Settings.Add(_showRateLimitErrors); + Settings.Add(_portfolioCurrency); LoadSettings(); _hasAnyApiKey.Update(HasTwelveDataApiKey || HasFinnhubApiKey); // seed from persisted keys (no subscribers yet) Settings.SettingsChanged += (_, _) => From 8b00a410df1031b88809a370a81594ae615a196e Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 04:26:21 +0300 Subject: [PATCH 42/69] wip portfolio --- CLAUDE.md | 125 +++++++++---- MarketExtension/Commands/PortfolioCommands.cs | 33 ++++ MarketExtension/Models/DomainPosition.cs | 11 ++ MarketExtension/Models/UiPortfolio.cs | 46 +++++ MarketExtension/Models/UiPosition.cs | 46 +++++ MarketExtension/Pages/MarketsPage.cs | 4 +- MarketExtension/Pages/PortfolioPage.cs | 87 ++++++--- MarketExtension/Pages/PricedListPage.cs | 9 +- MarketExtension/Pages/SetQuantityPage.cs | 129 +++++++++++++ MarketExtension/Pages/SymbolDetailPage.cs | 18 +- MarketExtension/Settings/PortfolioStore.cs | 171 ++++++++++++++++++ 11 files changed, 610 insertions(+), 69 deletions(-) create mode 100644 MarketExtension/Commands/PortfolioCommands.cs create mode 100644 MarketExtension/Models/DomainPosition.cs create mode 100644 MarketExtension/Models/UiPortfolio.cs create mode 100644 MarketExtension/Models/UiPosition.cs create mode 100644 MarketExtension/Pages/SetQuantityPage.cs create mode 100644 MarketExtension/Settings/PortfolioStore.cs diff --git a/CLAUDE.md b/CLAUDE.md index 2db597c..f9c9f52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -307,6 +307,18 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context 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 yet**, and **all add/edit/remove on the symbol detail page** (rows just open it, like every + other list). Build clean (0 warnings). ⚠️ Verified to **compile**; a live smoke test (add a holding, see + the total roll up, edit/remove, confirm persistence) is worth doing. 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, @@ -335,9 +347,13 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context 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:** a **portfolio** screen + its own dock band (holdings, total value, daily P&L); and a - **per-symbol detail screen + live chart for any ticker** (drill-in from any list row, clickable from any - dock item). All designed below. +- **Wishlist:** **(1) multi-currency portfolio conversion** — stop assuming USD: capture each instrument's + reporting currency and convert into the user's `PortfolioCurrency` via the keyless Frankfurter FX + provider, showing each holding in **both** its native and converted currency and the **total in the + preferred currency** (the current hardcoded `$` is a known gap — full design under "Portfolio screen"); + **(2)** the **Portfolio dock band** (a one-line total-value + daily-P&L strip next to the favorites band — + the Portfolio *screen* itself is now done); and **(3)** **cost-basis / total-return** reporting (the + `CostBasis` field is already stored, just not surfaced). All designed below. - **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 @@ -490,43 +506,80 @@ until reopened. Implemented via the observable layer: - 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 (future wishlist) +### Portfolio screen (DONE — screen built; dock band still deferred) -A fourth top-level screen (**Markets Portfolio**) plus its own dock band, tracking actual *holdings* -(not just symbols): total market value and **daily P&L**. Reuses the existing data seam with **no -provider changes** — `MarketRepository.GetQuotesAsync` already returns per-instrument daily change. +**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. -**Data model** (keep the `Api`/`Domain`/`Ui` layering; formatting only in `Ui*`): +**Scope shipped:** quantity + **daily P&L only**. `DomainPosition`/`PortfolioItem` carry an optional +`CostBasis` (persisted, **no UI yet**), so unrealized/total return is a cheap follow-up. + +**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** (+ optional **cost basis** for total/unrealized P&L later); quantity is `decimal` so - crypto fractions work. -- `Models/DomainPosition.cs` — a holding's provider-agnostic identity (`DomainInstrument` + quantity + - optional cost basis), **no prices**. -- `Models/UiPosition.cs` + `UiPortfolio.cs` — presentation projection: combine a `DomainQuote` with the - quantity to expose `MarketValue` (qty × price), `DailyPnL` (qty × `DomainQuote.Change`), - `DailyPnLPercent`, and the `Format*()` helpers (the only place formatting lives, like `UiQuote`); - `UiPortfolio` rolls up the totals. - -**Daily P&L:** per position = `Quantity × DomainQuote.Change` (Finnhub `/quote` `d` = today's per-unit -change, `dp` = its percent). Portfolio total value = Σ market value; total daily P&L = Σ daily P&L; the -aggregate **percent is `totalDailyPnL / (totalValue − totalDailyPnL)`** (vs. yesterday's close) — not an -average of the per-position percents. - -**Screen** (`Pages/PortfolioPage.cs`): reuse `PricedListPage`'s async price/refresh plumbing (price the -holdings via the repository, zip with quantities). Render a **totals summary** as the first item -("Portfolio $12,345.67 ▲ +$120.50 (+0.98%)") then one row per holding ("AAPL · 10 sh" → value + daily -P&L). Editing needs a quantity-input affordance the palette doesn't give for free — add via search → a -small "set quantity" form/content page; Enter on a row to edit/remove. Like every other list, a row also -opens the **shared symbol detail + chart** (below) — here via a context item, since Enter is taken by edit. - -**Dock band** (`Pages/PortfolioDockPage.cs`): a second band registered in `GetDockBands()` next to the -favorites band, showing the one-line portfolio summary (total value + daily P&L, green/red). Needs its -own **non-empty `Command.Id`** (`com.costafotiadis.market.dock.portfolio` — see `reference/dock-support.md`) -and inherits the same live-refresh story as the favorites band (the polling + push-on-change work above). - -**Caveats:** assumes a single quote currency (USD); mixed-currency holdings need FX conversion (deferred — -ties to the FX-provider item). Exclude `IsValid:false` quotes from totals (or show them as unpriced). + **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 → `MarketValue` (qty × price), `DailyPnL` (qty × `DomainQuote.Change`), `Format*()` helpers (the + only place position formatting lives, like `UiQuote`); `UiPortfolio.From(...)` rolls up the totals. + +**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). + +**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 quantity editor: a `ContentPage` whose single `FormContent` is an +adaptive card with one `Input.Number` (prefilled with the current holding, 0 when adding) + a Save action. +`SubmitForm` parses + validates (> 0), calls `PortfolioStore.SetPosition`, 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 number field. + +**Still deferred — the dock band** (`Pages/PortfolioDockPage.cs`): a second band in `GetDockBands()` next +to favorites, one line of total value + daily P&L. **Not built this pass.** Would need its own non-empty +`Command.Id` (`com.costafotiadis.market.dock.portfolio` — see `reference/dock-support.md`) and would inherit +the favorites band's live-refresh story (the polling + push-on-change work above). + +**⚠️ Deferred — multi-currency conversion (PLANNED; the goal is to STOP assuming USD).** Today every value +is summed and labeled `$` on the assumption each instrument is USD-quoted. That's correct for US stocks, +USD-quoted crypto, and `XXXUSD` FX pairs, but **wrong for anything quoted in another currency** — e.g. +**USDJPY**, whose price is in JPY, currently gets mislabeled `$`; a non-US stock would too. The planned +design (per the user): +- **Capture each instrument's reporting/native currency** in the data layer — an ISO-4217 `Currency` field + on `DomainQuote` (and/or `DomainInstrument`), populated by each provider. *Open piece (the real work):* + Twelve Data's `/quote` returns a `currency`; **Finnhub `/quote` does not** (would need `/stock/profile2`, + which carries `currency`); Frankfurter is inherently per-pair. So sourcing the native currency needs + provider changes. +- **Convert** each holding's market value + daily P&L into the user's **`PortfolioCurrency`** (the existing + setting, currently inert) using the **keyless Frankfurter FX provider** (it already cross-converts via ECB + rates), with a small rate cache keyed by `nativeCurrency → preferredCurrency`. +- **Display:** each holding row shows **both** its **native/local-currency** value **and** the **converted** + value; the **portfolio TOTAL is in the preferred currency** (using that currency's symbol — not a + hardcoded `$`). Requires threading a currency/format through `UiPosition`/`UiPortfolio` (replacing the + hardcoded `$#,##0.00`). +- Until this lands, `IsValid:false` holdings stay excluded from the totals, and the USD/`$` assumption above + is the known gap. ### Symbol detail + live chart — for ANY instrument (CHART DONE; flicker fixed, Enter/focus bug open) diff --git a/MarketExtension/Commands/PortfolioCommands.cs b/MarketExtension/Commands/PortfolioCommands.cs new file mode 100644 index 0000000..615960e --- /dev/null +++ b/MarketExtension/Commands/PortfolioCommands.cs @@ -0,0 +1,33 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +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 = "Remove from Portfolio"; + Icon = new IconInfo(DeleteGlyph); + } + + public override ICommandResult Invoke() + { + PortfolioStore.Instance.Remove(_instrument.Symbol); + return CommandResult.ShowToast(new ToastArgs + { + Message = $"Removed {_instrument.Symbol} from portfolio", + Result = CommandResult.KeepOpen(), + }); + } +} diff --git a/MarketExtension/Models/DomainPosition.cs b/MarketExtension/Models/DomainPosition.cs new file mode 100644 index 0000000..1e88fe1 --- /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 (what was paid per unit) is carried and persisted for forward-compat — it enables +// unrealized/total-return reporting later — but is NOT surfaced in the UI yet (this pass shows daily +// P&L only). It stays null until that feature lands. +internal sealed record DomainPosition(DomainInstrument Instrument, decimal Quantity, decimal? CostBasis = null); diff --git a/MarketExtension/Models/UiPortfolio.cs b/MarketExtension/Models/UiPortfolio.cs new file mode 100644 index 0000000..47a6e5a --- /dev/null +++ b/MarketExtension/Models/UiPortfolio.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +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. Rendered as the summary row pinned to the top of the Portfolio screen. Like the +// other Ui* types, the only place its formatting lives. +// +// Only VALID (priced) positions count toward the totals; an unpriced holding (unknown symbol, fetch +// failure) is shown as its own unpriced row but left out of the rollup, so a transient bad quote can't +// distort the total. Single-currency assumption (USD) holds for this pass — values are summed as-is and +// shown with "$"; FX conversion via the PortfolioCurrency setting is future work. +internal sealed record UiPortfolio(decimal TotalValue, decimal TotalDailyPnL, bool HasHoldings) +{ + public static UiPortfolio From(IEnumerable positions) + { + var all = positions.ToList(); + var priced = all.Where(p => p.IsValid).ToList(); + var totalValue = priced.Sum(p => p.MarketValue); + var totalPnL = priced.Sum(p => p.DailyPnL); + return new UiPortfolio(totalValue, totalPnL, all.Count > 0); + } + + public bool IsUp => TotalDailyPnL >= 0; + + // 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; + } + } + + public string FormatTotalValue() => TotalValue.ToString("$#,##0.00", CultureInfo.InvariantCulture); + + // e.g. "▲ +$120.50 (+0.98%) today" / "▼ -$84.10 (-0.71%) today". + public string FormatTotalChange() => + $"{(IsUp ? "▲" : "▼")} " + + $"{TotalDailyPnL.ToString("+$#,##0.00;-$#,##0.00", CultureInfo.InvariantCulture)} " + + $"({TotalDailyPnLPercent.ToString("+0.00;-0.00", CultureInfo.InvariantCulture)}%) today"; +} diff --git a/MarketExtension/Models/UiPosition.cs b/MarketExtension/Models/UiPosition.cs new file mode 100644 index 0000000..6833653 --- /dev/null +++ b/MarketExtension/Models/UiPosition.cs @@ -0,0 +1,46 @@ +using System.Globalization; + +namespace MarketExtension; + +// UI layer: the presentation projection of a priced holding — a DomainQuote combined with the quantity +// held. 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. +// +// This pass shows DAILY P&L only: market value (qty × price) and today's gain/loss in $ and %. The +// daily-change inputs (DomainQuote.Change / ChangePercent) are exactly what MarketRepository already +// returns, so no new data is fetched. Cost-basis / total-return reporting is deferred (see DomainPosition). +internal sealed record UiPosition(DomainQuote Source, decimal Quantity) +{ + public static UiPosition From(DomainQuote quote, decimal quantity) => new(quote, quantity); + + 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; + + // What the holding is worth right now (qty × live price) and today's gain/loss on it (qty × the + // per-unit daily change). Both are 0 on an invalid quote and excluded from the rolled-up totals. + public decimal MarketValue => IsValid ? Quantity * Source.Price : 0m; + public decimal DailyPnL => IsValid ? Quantity * Source.Change : 0m; + + // The quantity without trailing zeros (10, not 10.00; 0.5 stays 0.5). + public string FormatQuantity() => Quantity.ToString("0.########", 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 ? "sh" : "units"; + + public string FormatMarketValue() => !IsValid + ? "—" + : MarketValue.ToString("$#,##0.00", CultureInfo.InvariantCulture); + + // e.g. "▲ +$12.05 (+1.20%)" / "▼ -$8.40 (-0.80%)"; empty for an invalid quote. + public string FormatDailyPnL() => !IsValid + ? string.Empty + : $"{(IsUp ? "▲" : "▼")} " + + $"{DailyPnL.ToString("+$#,##0.00;-$#,##0.00", CultureInfo.InvariantCulture)} " + + $"({Source.ChangePercent.ToString("+0.00;-0.00", CultureInfo.InvariantCulture)}%)"; +} diff --git a/MarketExtension/Pages/MarketsPage.cs b/MarketExtension/Pages/MarketsPage.cs index 8a12ce5..1db4fdc 100644 --- a/MarketExtension/Pages/MarketsPage.cs +++ b/MarketExtension/Pages/MarketsPage.cs @@ -65,7 +65,7 @@ public MarketsPage(MarketRepository repository) _searchPage = new SearchPage(repository); _watchlistPage = new WatchlistPage(repository); _favoritesPage = new FavoritesPage(repository); - _portfolioPage = new PortfolioPage(); + _portfolioPage = new PortfolioPage(repository); _dataSourcesPage = new DataSourcesPage(); // The toolkit builds a navigable settings page straight from our settings (Finnhub API key + @@ -98,7 +98,7 @@ public override IListItem[] GetItems() new ListItem(_portfolioPage) { Title = "Portfolio", - Subtitle = "Your holdings, total value, and daily P&L (coming soon)", + Subtitle = "Your holdings, total value, and daily P&L", Icon = new IconInfo(PortfolioGlyph), }, new ListItem(_dataSourcesPage) diff --git a/MarketExtension/Pages/PortfolioPage.cs b/MarketExtension/Pages/PortfolioPage.cs index bf09fcb..ef12676 100644 --- a/MarketExtension/Pages/PortfolioPage.cs +++ b/MarketExtension/Pages/PortfolioPage.cs @@ -1,42 +1,75 @@ +using System.Collections.Generic; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; namespace MarketExtension; -// PLACEHOLDER. Reached from the Markets hub. The real screen — actual holdings (quantity + cost -// basis) with total market value and daily P&L, plus its own dock band — is designed in CLAUDE.md -// under "Portfolio screen + dock band (future wishlist)" but not built yet. For now this is a plain -// static Markdown "coming soon" card (modeled on DataSourcesPage): no data fetch, no providers, no -// lifecycle. When it's built, this becomes a PricedListPage subclass; until then it just explains -// what's coming so the hub row isn't a dead end. -internal sealed partial class PortfolioPage : ContentPage +// 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. +internal sealed partial class PortfolioPage : PricedListPage { - private readonly MarkdownContent _content = new( - """ - # Portfolio + private const string PortfolioGlyph = "\uE825"; // Segoe MDL2 Bank - **Coming soon.** - - This screen will track your actual holdings — not just symbols you watch, - but how much of each you hold — and roll them up into: + public PortfolioPage(MarketRepository repository) : base(repository, PortfolioStore.Instance.Instruments) + { + Title = "Markets Portfolio"; + Name = "Open"; + PlaceholderText = "Filter your holdings..."; + } - - **Total market value** across everything you own - - **Daily profit & loss**, in both dollars and percent - - A per-holding breakdown, each priced live + // 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 positions = new List(); + foreach (var q in pricedQuotes) + if (PortfolioStore.Instance.TryGetQuantity(q.Symbol, out var qty)) + positions.Add(UiPosition.From(q.Source, qty)); - It'll share the same live price refresh as your Watchlist and Favorites, - and get its own dock band showing the one-line summary at a glance. + if (positions.Count == 0) + return []; - For now this is a placeholder. Use **Watchlist** and **Favorites** to - track instruments in the meantime. - """); + var portfolio = UiPortfolio.From(positions); + return + [ + new ListItem(new NoOpCommand()) + { + Title = $"Portfolio {portfolio.FormatTotalValue()}", + Subtitle = portfolio.FormatTotalChange(), + Icon = new IconInfo(PortfolioGlyph), + }, + ]; + } - public PortfolioPage() + protected override IListItem BuildRow(UiQuote q) { - Icon = new IconInfo("https://github.com/favicon.ico"); - Title = "Portfolio"; - Name = "Open"; + var instrument = new DomainInstrument(q.Symbol, q.Name, q.Category); + PortfolioStore.Instance.TryGetQuantity(q.Symbol, out var qty); + var position = UiPosition.From(q.Source, qty); + + return new ListItem(new SymbolDetailPage(instrument, Repository)) + { + Title = position.FormatHolding(), + Subtitle = $"{position.FormatMarketValue()} {position.FormatDailyPnL()}", + Icon = AssetIconResolver.Resolve(q), + }; } - public override IContent[] GetContent() => [_content]; + protected override IListItem[] EmptyState() => + [ + new ListItem(new NoOpCommand()) + { + Title = "No holdings yet", + Subtitle = "Open any instrument's detail page and choose Add to Portfolio to start tracking holdings", + }, + ]; } diff --git a/MarketExtension/Pages/PricedListPage.cs b/MarketExtension/Pages/PricedListPage.cs index 815fec6..117bcb2 100644 --- a/MarketExtension/Pages/PricedListPage.cs +++ b/MarketExtension/Pages/PricedListPage.cs @@ -97,6 +97,11 @@ protected PricedListPage(MarketRepository repository, StateFlow LeadingRows(IReadOnlyList pricedQuotes) => []; + public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(0); @@ -117,7 +122,9 @@ public override IListItem[] GetItems() if (cached.Length == 0) return []; // first prices still loading — let the spinner show - var rows = cached.Where(Matches).Select(BuildRow).ToList(); + 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 = "Refresh 🔄" }); rows.Add(AssetIconResolver.AttributionRow()); // Elbstream logo credit (rows above show logos) if (ApiKeyHint.MissingKeyRow() is { } hint) // no key → explain why prices are blank diff --git a/MarketExtension/Pages/SetQuantityPage.cs b/MarketExtension/Pages/SetQuantityPage.cs new file mode 100644 index 0000000..98cc8e6 --- /dev/null +++ b/MarketExtension/Pages/SetQuantityPage.cs @@ -0,0 +1,129 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace MarketExtension; + +// The quantity editor for one instrument — the single place to add a holding to the portfolio or change +// how much of it is held. 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 one number input; 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 ? $"Edit {instrument.Symbol} holding" : $"Add {instrument.Symbol} to portfolio"; + Name = editing ? "Edit holding" : "Add to Portfolio"; + } + + 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() + { + PortfolioStore.Instance.TryGetQuantity(_instrument.Symbol, out var qty); + 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"] = qty, + }; + return data.ToJsonString(); + } + + public override ICommandResult SubmitForm(string inputs, string data) + { + if (!TryReadQuantity(inputs, out var quantity)) + return Error("Enter a number greater than 0."); + if (quantity <= 0m) + return Error("Quantity must be greater than 0. To remove a holding, use Remove from Portfolio."); + + PortfolioStore.Instance.SetPosition(_instrument, quantity); + return CommandResult.ShowToast(new ToastArgs + { + Message = $"Set {_instrument.Symbol} to {quantity.ToString("0.########", CultureInfo.InvariantCulture)}", + 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 the "quantity" 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). + private static bool TryReadQuantity(string inputs, out decimal quantity) + { + quantity = 0m; + if (string.IsNullOrWhiteSpace(inputs)) + return false; + + try + { + using var doc = JsonDocument.Parse(inputs); + if (doc.RootElement.ValueKind != JsonValueKind.Object || + !doc.RootElement.TryGetProperty("quantity", 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 quantity) + || decimal.TryParse(raw, NumberStyles.Number, CultureInfo.CurrentCulture, out quantity); + } + 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": "Quantity held", "value": ${quantity}, "min": 0, "isRequired": true, "errorMessage": "Enter a quantity greater than 0" } + ], + "actions": [ + { "type": "Action.Submit", "title": "Save", "data": { "action": "save" } } + ] + } + """; + } +} diff --git a/MarketExtension/Pages/SymbolDetailPage.cs b/MarketExtension/Pages/SymbolDetailPage.cs index a3865df..ed667e0 100644 --- a/MarketExtension/Pages/SymbolDetailPage.cs +++ b/MarketExtension/Pages/SymbolDetailPage.cs @@ -47,6 +47,9 @@ event TypedEventHandler INotifyItemsChanged.Item // 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. @@ -89,12 +92,15 @@ public SymbolDetailPage(DomainInstrument instrument, MarketRepository repository // Commands raises the property change the host watches, so the add/remove buttons flip in place. private void RefreshCommands() => Commands = BuildCommands(); - // The two list-management actions, labelled add/remove for the instrument's current state. The first - // is the page's primary command (Enter), the second the secondary (Ctrl+Enter). + // 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)) @@ -104,7 +110,13 @@ private IContextItem[] BuildCommands() ? new CommandContextItem(new RemoveFromFavoritesCommand(_instrument)) : new CommandContextItem(new AddToFavoritesCommand(_instrument)); - return [watchlist, favorite]; + // 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 diff --git a/MarketExtension/Settings/PortfolioStore.cs b/MarketExtension/Settings/PortfolioStore.cs new file mode 100644 index 0000000..5df4682 --- /dev/null +++ b/MarketExtension/Settings/PortfolioStore.cs @@ -0,0 +1,171 @@ +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; + } + + // Add or update a holding (a SetPosition with the same symbol overwrites its quantity). Preserves any + // existing cost basis unless a new one is given, so a quantity-only edit doesn't wipe it. + public void SetPosition(DomainInstrument instrument, decimal quantity, decimal? costBasis = null) + { + var key = WatchlistStore.Normalize(instrument.Symbol); + lock (_lock) + { + var basis = costBasis ?? (_positions.TryGetValue(key, out var existing) ? existing.CostBasis : null); + _positions[key] = new DomainPosition(instrument, quantity, basis); + } + + 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; From 8aef44b94520b4196f644bbbfbb54cde8e5866a2 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 04:32:06 +0300 Subject: [PATCH 43/69] wip portfolio --- CLAUDE.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f9c9f52..e4fcfed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -567,10 +567,24 @@ USD-quoted crypto, and `XXXUSD` FX pairs, but **wrong for anything quoted in ano **USDJPY**, whose price is in JPY, currently gets mislabeled `$`; a non-US stock would too. The planned design (per the user): - **Capture each instrument's reporting/native currency** in the data layer — an ISO-4217 `Currency` field - on `DomainQuote` (and/or `DomainInstrument`), populated by each provider. *Open piece (the real work):* - Twelve Data's `/quote` returns a `currency`; **Finnhub `/quote` does not** (would need `/stock/profile2`, - which carries `currency`); Frankfurter is inherently per-pair. So sourcing the native currency needs - provider changes. + on `DomainQuote`. **Key insight: currency is STATIC metadata, not live data** (a stock's reporting currency + essentially never changes), so resolve it **once per symbol and cache/persist it** (a `symbol → currency` + map, ideally stored with the instrument in the watchlist/portfolio) — NOT on every poll. That reframes the + cost entirely. + - **Twelve Data (primary):** its `/quote` response **already includes a `currency` field** — just add it + to `ApiTwelveDataQuoteDto` (`[JsonPropertyName("currency")] string? Currency`) and map it through. It + rides in the batched `/quote` we already fetch, so **zero extra calls**; accurate even for non-US stocks. + - **Finnhub:** its `/quote` (`c/d/dp/pc`) has **no** currency field. ⚠️ Do **NOT** blanket-assume USD — a + **paid Finnhub plan serves global equities** (LSE/TSE/Euronext/…) quoted in their **local** currencies, + not USD. Resolve via **`/stock/profile2?symbol=` (returns `currency`) ONCE per symbol, cached forever** — + because currency is static this is one call per *new* symbol, not per poll, so it's trivial against the + ~300/day budget (the earlier "too expensive" framing was wrong). Cheap short-circuits skip even that call: + crypto (`…USDT`) → USD, and a **free** (US-equities-only) key → USD. Only a paid key bringing in non-US + tickers actually needs the profile2 lookup. + - **Frankfurter (FX):** inherently per-pair / currency-aware already. + - ⚠️ **GBX/pence trap:** London-listed stocks are quoted in **pence** while the reported currency is GBP + (often `GBp`/`GBX`) — price 250 means £2.50, a 100× factor. Conversion must special-case this or totals + will be 100× off for UK holdings. - **Convert** each holding's market value + daily P&L into the user's **`PortfolioCurrency`** (the existing setting, currently inert) using the **keyless Frankfurter FX provider** (it already cross-converts via ECB rates), with a small rate cache keyed by `nativeCurrency → preferredCurrency`. From 01a18dd5cda9218f5dc05a97dad60fdecf30680f Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 04:58:50 +0300 Subject: [PATCH 44/69] portfolio add currency handling and totals --- CLAUDE.md | 116 ++++++++++------ .../Data/Finnhub/ApiFinnhubProfileDto.cs | 14 ++ .../Data/Finnhub/ApiFinnhubQuoteDto.cs | 5 +- .../Data/Finnhub/FinnhubMarketDataProvider.cs | 73 +++++++++- .../Data/Frankfurter/ApiFrankfurterDto.cs | 14 ++ .../FrankfurterMarketDataProvider.cs | 7 +- .../Data/MockMarketDataProvider.cs | 7 +- .../Data/TwelveData/ApiTwelveDataQuoteDto.cs | 1 + .../TwelveDataMarketDataProvider.cs | 18 ++- MarketExtension/Helpers/CurrencyConverter.cs | 128 ++++++++++++++++++ MarketExtension/Helpers/CurrencyFormat.cs | 50 +++++++ MarketExtension/Helpers/CurrencyHelper.cs | 60 ++++++++ MarketExtension/Models/DomainQuote.cs | 12 +- MarketExtension/Models/UiPortfolio.cs | 45 +++--- MarketExtension/Models/UiPosition.cs | 79 ++++++++--- MarketExtension/Models/UiQuote.cs | 7 +- MarketExtension/Pages/PortfolioPage.cs | 53 +++++++- MarketExtension/Pages/PricedListPage.cs | 18 +++ 18 files changed, 610 insertions(+), 97 deletions(-) create mode 100644 MarketExtension/Data/Finnhub/ApiFinnhubProfileDto.cs create mode 100644 MarketExtension/Helpers/CurrencyConverter.cs create mode 100644 MarketExtension/Helpers/CurrencyFormat.cs create mode 100644 MarketExtension/Helpers/CurrencyHelper.cs diff --git a/CLAUDE.md b/CLAUDE.md index e4fcfed..e7c0a10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,7 @@ the same three layers and the same provider seam — see the "Symbol detail + li | `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}) + its own source-gen `FrankfurterJsonContext`. | +| `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` | Offline fallback (`Supports` all). `SearchAsync` filters its seed keys. | | `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`. | @@ -97,12 +97,15 @@ the same three layers and the same provider seam — see the "Symbol detail + li | `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 | +| `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`, @@ -223,6 +226,20 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context ## Current Status / Next Steps +- **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 @@ -347,13 +364,11 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context 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:** **(1) multi-currency portfolio conversion** — stop assuming USD: capture each instrument's - reporting currency and convert into the user's `PortfolioCurrency` via the keyless Frankfurter FX - provider, showing each holding in **both** its native and converted currency and the **total in the - preferred currency** (the current hardcoded `$` is a known gap — full design under "Portfolio screen"); +- **Wishlist:** ~~**(1) multi-currency portfolio conversion**~~ — **DONE this round** (see "Done (this + round): multi-currency portfolio conversion" + "Multi-currency portfolio conversion (done)"). Remaining: **(2)** the **Portfolio dock band** (a one-line total-value + daily-P&L strip next to the favorites band — the Portfolio *screen* itself is now done); and **(3)** **cost-basis / total-return** reporting (the - `CostBasis` field is already stored, just not surfaced). All designed below. + `CostBasis` field is already stored, just not surfaced). Both designed below. - **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 @@ -561,39 +576,60 @@ to favorites, one line of total value + daily P&L. **Not built this pass.** Woul `Command.Id` (`com.costafotiadis.market.dock.portfolio` — see `reference/dock-support.md`) and would inherit the favorites band's live-refresh story (the polling + push-on-change work above). -**⚠️ Deferred — multi-currency conversion (PLANNED; the goal is to STOP assuming USD).** Today every value -is summed and labeled `$` on the assumption each instrument is USD-quoted. That's correct for US stocks, -USD-quoted crypto, and `XXXUSD` FX pairs, but **wrong for anything quoted in another currency** — e.g. -**USDJPY**, whose price is in JPY, currently gets mislabeled `$`; a non-US stock would too. The planned -design (per the user): -- **Capture each instrument's reporting/native currency** in the data layer — an ISO-4217 `Currency` field - on `DomainQuote`. **Key insight: currency is STATIC metadata, not live data** (a stock's reporting currency - essentially never changes), so resolve it **once per symbol and cache/persist it** (a `symbol → currency` - map, ideally stored with the instrument in the watchlist/portfolio) — NOT on every poll. That reframes the - cost entirely. - - **Twelve Data (primary):** its `/quote` response **already includes a `currency` field** — just add it - to `ApiTwelveDataQuoteDto` (`[JsonPropertyName("currency")] string? Currency`) and map it through. It - rides in the batched `/quote` we already fetch, so **zero extra calls**; accurate even for non-US stocks. - - **Finnhub:** its `/quote` (`c/d/dp/pc`) has **no** currency field. ⚠️ Do **NOT** blanket-assume USD — a - **paid Finnhub plan serves global equities** (LSE/TSE/Euronext/…) quoted in their **local** currencies, - not USD. Resolve via **`/stock/profile2?symbol=` (returns `currency`) ONCE per symbol, cached forever** — - because currency is static this is one call per *new* symbol, not per poll, so it's trivial against the - ~300/day budget (the earlier "too expensive" framing was wrong). Cheap short-circuits skip even that call: - crypto (`…USDT`) → USD, and a **free** (US-equities-only) key → USD. Only a paid key bringing in non-US - tickers actually needs the profile2 lookup. - - **Frankfurter (FX):** inherently per-pair / currency-aware already. - - ⚠️ **GBX/pence trap:** London-listed stocks are quoted in **pence** while the reported currency is GBP - (often `GBp`/`GBX`) — price 250 means £2.50, a 100× factor. Conversion must special-case this or totals - will be 100× off for UK holdings. -- **Convert** each holding's market value + daily P&L into the user's **`PortfolioCurrency`** (the existing - setting, currently inert) using the **keyless Frankfurter FX provider** (it already cross-converts via ECB - rates), with a small rate cache keyed by `nativeCurrency → preferredCurrency`. -- **Display:** each holding row shows **both** its **native/local-currency** value **and** the **converted** - value; the **portfolio TOTAL is in the preferred currency** (using that currency's symbol — not a - hardcoded `$`). Requires threading a currency/format through `UiPosition`/`UiPortfolio` (replacing the - hardcoded `$#,##0.00`). -- Until this lands, `IsValid:false` holdings stay excluded from the totals, and the USD/`$` assumption above - is the known gap. +### 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). ### Symbol detail + live chart — for ANY instrument (CHART DONE; flicker fixed, Enter/focus bug open) 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 index e1d347f..b78a1fb 100644 --- a/MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs +++ b/MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs @@ -15,6 +15,7 @@ internal sealed record ApiFinnhubQuoteDto( [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(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/FinnhubMarketDataProvider.cs b/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs index e5031f9..1bc2387 100644 --- a/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs +++ b/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -23,6 +24,13 @@ internal sealed class FinnhubMarketDataProvider : IMarketDataProvider // 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; @@ -141,10 +149,15 @@ private static async Task FetchQuoteAsync(DomainInstrument instrume 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, - price, dto!.Change ?? 0m, dto.ChangePercent ?? 0m, IsValid: true); - Log.Info("Finnhub", $"{symbol} -> price={quote.Price} change%={quote.ChangePercent}"); + 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) @@ -221,6 +234,62 @@ public async Task GetCandlesAsync( 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). diff --git a/MarketExtension/Data/Frankfurter/ApiFrankfurterDto.cs b/MarketExtension/Data/Frankfurter/ApiFrankfurterDto.cs index 5ee0ac1..a79712d 100644 --- a/MarketExtension/Data/Frankfurter/ApiFrankfurterDto.cs +++ b/MarketExtension/Data/Frankfurter/ApiFrankfurterDto.cs @@ -20,5 +20,19 @@ internal sealed record ApiFrankfurterSeriesDto( [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 index 551c5a4..5d82ab2 100644 --- a/MarketExtension/Data/Frankfurter/FrankfurterMarketDataProvider.cs +++ b/MarketExtension/Data/Frankfurter/FrankfurterMarketDataProvider.cs @@ -90,9 +90,12 @@ private static async Task FetchQuoteAsync(DomainInstrument instrume 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); - Log.Info("Frankfurter", $"{instrument.Symbol} -> rate={price} change%={pct}"); + 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; } diff --git a/MarketExtension/Data/MockMarketDataProvider.cs b/MarketExtension/Data/MockMarketDataProvider.cs index 21e76fe..e4ceb9d 100644 --- a/MarketExtension/Data/MockMarketDataProvider.cs +++ b/MarketExtension/Data/MockMarketDataProvider.cs @@ -44,7 +44,12 @@ public Task> GetQuotesAsync( IReadOnlyList quotes = [ .. 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) + ? new DomainQuote(i.Symbol, i.Name, i.Category, s.Price, s.Change, s.Pct, IsValid: true, + // Mirror the real providers' native currency so offline preview is coherent: an FX pair + // in its quote currency, everything else USD. + Currency: i.Category == AssetCategory.Currency + ? CurrencyHelper.QuoteCurrencyOfPair(i.Symbol) + : "USD") : new DomainQuote(i.Symbol, i.Name, i.Category, 0m, 0m, 0m, IsValid: false)), ]; return Task.FromResult(quotes); diff --git a/MarketExtension/Data/TwelveData/ApiTwelveDataQuoteDto.cs b/MarketExtension/Data/TwelveData/ApiTwelveDataQuoteDto.cs index 5fd3197..f1cdea7 100644 --- a/MarketExtension/Data/TwelveData/ApiTwelveDataQuoteDto.cs +++ b/MarketExtension/Data/TwelveData/ApiTwelveDataQuoteDto.cs @@ -22,6 +22,7 @@ internal sealed record ApiTwelveDataQuoteDto( [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); diff --git a/MarketExtension/Data/TwelveData/TwelveDataMarketDataProvider.cs b/MarketExtension/Data/TwelveData/TwelveDataMarketDataProvider.cs index 14edf8d..d29c54c 100644 --- a/MarketExtension/Data/TwelveData/TwelveDataMarketDataProvider.cs +++ b/MarketExtension/Data/TwelveData/TwelveDataMarketDataProvider.cs @@ -91,10 +91,11 @@ public async Task> GetQuotesAsync( 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, - dto.Close.Value, dto.Change ?? 0m, dto.PercentChange ?? 0m, IsValid: true); - Log.Info("TwelveData", $"{tdSymbol} -> price={quote.Price} change%={quote.ChangePercent}"); + price, change, dto.PercentChange ?? 0m, IsValid: true, Currency: currency); + Log.Info("TwelveData", $"{tdSymbol} -> price={quote.Price} change%={quote.ChangePercent} ccy={currency}"); return quote; } @@ -307,6 +308,19 @@ public async Task GetCandlesAsync( 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 diff --git a/MarketExtension/Helpers/CurrencyConverter.cs b/MarketExtension/Helpers/CurrencyConverter.cs new file mode 100644 index 0000000..33aed70 --- /dev/null +++ b/MarketExtension/Helpers/CurrencyConverter.cs @@ -0,0 +1,128 @@ +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); + + private CurrencyConverter() { } + + 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; + + 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/Models/DomainQuote.cs b/MarketExtension/Models/DomainQuote.cs index e369fc7..996a619 100644 --- a/MarketExtension/Models/DomainQuote.cs +++ b/MarketExtension/Models/DomainQuote.cs @@ -3,6 +3,15 @@ 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, @@ -10,4 +19,5 @@ internal sealed record DomainQuote( decimal Price, decimal Change, decimal ChangePercent, - bool IsValid = true); + bool IsValid = true, + string Currency = "USD"); diff --git a/MarketExtension/Models/UiPortfolio.cs b/MarketExtension/Models/UiPortfolio.cs index 47a6e5a..a312334 100644 --- a/MarketExtension/Models/UiPortfolio.cs +++ b/MarketExtension/Models/UiPortfolio.cs @@ -4,23 +4,26 @@ 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. Rendered as the summary row pinned to the top of the Portfolio screen. Like the -// other Ui* types, the only place its formatting lives. +// 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. // -// Only VALID (priced) positions count toward the totals; an unpriced holding (unknown symbol, fetch -// failure) is shown as its own unpriced row but left out of the rollup, so a transient bad quote can't -// distort the total. Single-currency assumption (USD) holds for this pass — values are summed as-is and -// shown with "$"; FX conversion via the PortfolioCurrency setting is future work. -internal sealed record UiPortfolio(decimal TotalValue, decimal TotalDailyPnL, bool HasHoldings) +// 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) { - public static UiPortfolio From(IEnumerable positions) + public static UiPortfolio From(IEnumerable positions, string preferredCurrency) { var all = positions.ToList(); - var priced = all.Where(p => p.IsValid).ToList(); - var totalValue = priced.Sum(p => p.MarketValue); - var totalPnL = priced.Sum(p => p.DailyPnL); - return new UiPortfolio(totalValue, totalPnL, all.Count > 0); + 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); + return new UiPortfolio(totalValue, totalPnL, preferredCurrency, all.Count > 0, unconverted); } public bool IsUp => TotalDailyPnL >= 0; @@ -36,11 +39,19 @@ public decimal TotalDailyPnLPercent } } - public string FormatTotalValue() => TotalValue.ToString("$#,##0.00", CultureInfo.InvariantCulture); + public string FormatTotalValue() => CurrencyFormat.Format(TotalValue, Currency); - // e.g. "▲ +$120.50 (+0.98%) today" / "▼ -$84.10 (-0.71%) today". + // e.g. "▲ +$120.50 (+0.98%) today" / "▼ -$84.10 (-0.71%) today", in the preferred currency. public string FormatTotalChange() => - $"{(IsUp ? "▲" : "▼")} " + - $"{TotalDailyPnL.ToString("+$#,##0.00;-$#,##0.00", CultureInfo.InvariantCulture)} " + + $"{(IsUp ? "▲" : "▼")} {CurrencyFormat.FormatSigned(TotalDailyPnL, Currency)} " + $"({TotalDailyPnLPercent.ToString("+0.00;-0.00", CultureInfo.InvariantCulture)}%) today"; + + // 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 => " · 1 holding not converted", + _ => $" · {UnconvertedCount} holdings not converted", + }; } diff --git a/MarketExtension/Models/UiPosition.cs b/MarketExtension/Models/UiPosition.cs index 6833653..92a00be 100644 --- a/MarketExtension/Models/UiPosition.cs +++ b/MarketExtension/Models/UiPosition.cs @@ -1,18 +1,20 @@ -using System.Globalization; - namespace MarketExtension; // UI layer: the presentation projection of a priced holding — a DomainQuote combined with the quantity -// held. 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. +// 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. // -// This pass shows DAILY P&L only: market value (qty × price) and today's gain/loss in $ and %. The -// daily-change inputs (DomainQuote.Change / ChangePercent) are exactly what MarketRepository already -// returns, so no new data is fetched. Cost-basis / total-return reporting is deferred (see DomainPosition). -internal sealed record UiPosition(DomainQuote Source, decimal Quantity) +// This pass shows DAILY P&L only (cost-basis / total-return remains deferred). Multi-currency: +// * The native value/P&L come straight from the quote (qty × price/change 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, MarketValue/DailyPnL +// convert; 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) { - public static UiPosition From(DomainQuote quote, decimal quantity) => new(quote, quantity); + public static UiPosition From(DomainQuote quote, decimal quantity, string preferredCurrency, decimal? rateToPreferred) + => new(quote, quantity, preferredCurrency, rateToPreferred); public string Symbol => Source.Symbol; public string Name => Source.Name; @@ -20,27 +22,62 @@ internal sealed record UiPosition(DomainQuote Source, decimal Quantity) 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). Both are 0 on an invalid quote and excluded from the rolled-up totals. + // 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; + // The quantity without trailing zeros (10, not 10.00; 0.5 stays 0.5). - public string FormatQuantity() => Quantity.ToString("0.########", CultureInfo.InvariantCulture); + 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 ? "sh" : "units"; - public string FormatMarketValue() => !IsValid - ? "—" - : MarketValue.ToString("$#,##0.00", CultureInfo.InvariantCulture); + // 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); - // e.g. "▲ +$12.05 (+1.20%)" / "▼ -$8.40 (-0.80%)"; empty for an invalid quote. - public string FormatDailyPnL() => !IsValid - ? string.Empty - : $"{(IsUp ? "▲" : "▼")} " + - $"{DailyPnL.ToString("+$#,##0.00;-$#,##0.00", CultureInfo.InvariantCulture)} " + - $"({Source.ChangePercent.ToString("+0.00;-0.00", CultureInfo.InvariantCulture)}%)"; + return $"{(IsUp ? "▲" : "▼")} {CurrencyFormat.FormatSigned(amount, currency)} " + + $"({Source.ChangePercent.ToString("+0.00;-0.00", System.Globalization.CultureInfo.InvariantCulture)}%)"; + } } diff --git a/MarketExtension/Models/UiQuote.cs b/MarketExtension/Models/UiQuote.cs index 0321ee1..885e7f2 100644 --- a/MarketExtension/Models/UiQuote.cs +++ b/MarketExtension/Models/UiQuote.cs @@ -15,13 +15,14 @@ internal sealed record UiQuote(DomainQuote Source) public bool IsValid => Source.IsValid; public bool IsUp => Source.Change >= 0; - // Currency pairs are quoted to 4 decimals (e.g. 1.0832); stocks/crypto as money. Invalid - // quotes (unknown symbol, unavailable, or a fetch failure) render as a dash. + // 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) - : Source.Price.ToString("$#,##0.00", CultureInfo.InvariantCulture); + : CurrencyFormat.Format(Source.Price, Source.Currency); // e.g. "▲ +1.20%" / "▼ -0.80%"; empty for invalid quotes. public string FormatChange() => !IsValid diff --git a/MarketExtension/Pages/PortfolioPage.cs b/MarketExtension/Pages/PortfolioPage.cs index ef12676..d9edc14 100644 --- a/MarketExtension/Pages/PortfolioPage.cs +++ b/MarketExtension/Pages/PortfolioPage.cs @@ -1,4 +1,7 @@ +using System; using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -15,9 +18,16 @@ namespace MarketExtension; // 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 = "\uE825"; // Segoe MDL2 Bank + private const string PortfolioGlyph = ""; // Segoe MDL2 Bank public PortfolioPage(MarketRepository repository) : base(repository, PortfolioStore.Instance.Instruments) { @@ -30,21 +40,22 @@ public PortfolioPage(MarketRepository repository) : base(repository, PortfolioSt // 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.TryGetQuantity(q.Symbol, out var qty)) - positions.Add(UiPosition.From(q.Source, qty)); + positions.Add(MakePosition(q.Source, qty, preferred)); if (positions.Count == 0) return []; - var portfolio = UiPortfolio.From(positions); + var portfolio = UiPortfolio.From(positions, preferred); return [ new ListItem(new NoOpCommand()) { Title = $"Portfolio {portfolio.FormatTotalValue()}", - Subtitle = portfolio.FormatTotalChange(), + Subtitle = portfolio.FormatTotalChange() + portfolio.FormatUnconvertedNote(), Icon = new IconInfo(PortfolioGlyph), }, ]; @@ -54,16 +65,46 @@ protected override IListItem BuildRow(UiQuote q) { var instrument = new DomainInstrument(q.Symbol, q.Name, q.Category); PortfolioStore.Instance.TryGetQuantity(q.Symbol, out var qty); - var position = UiPosition.From(q.Source, qty); + var position = MakePosition(q.Source, qty, MarketSettingsManager.Instance.PortfolioCurrency); return new ListItem(new SymbolDetailPage(instrument, Repository)) { Title = position.FormatHolding(), - Subtitle = $"{position.FormatMarketValue()} {position.FormatDailyPnL()}", + Subtitle = $"{position.FormatValue()} {position.FormatDailyPnL()}", 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. + private static UiPosition MakePosition(DomainQuote quote, decimal qty, string preferred) + { + var rate = CurrencyConverter.Instance.TryGetRate(quote.Currency, preferred); + return UiPosition.From(quote, qty, preferred, rate); + } + + // 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()) diff --git a/MarketExtension/Pages/PricedListPage.cs b/MarketExtension/Pages/PricedListPage.cs index 117bcb2..73127de 100644 --- a/MarketExtension/Pages/PricedListPage.cs +++ b/MarketExtension/Pages/PricedListPage.cs @@ -102,6 +102,22 @@ protected PricedListPage(MarketRepository repository, StateFlow 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); @@ -159,6 +175,7 @@ private void OnInstrumentsChanged(IReadOnlyList instruments) 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; @@ -274,6 +291,7 @@ private async Task LoadQuotes( _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); } From 977bc0f08ce3264152b61ef09b68895b0adc11d8 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 05:08:15 +0300 Subject: [PATCH 45/69] portfolio dockband --- .../MarketExtensionCommandsProvider.cs | 4 +- MarketExtension/Pages/PortfolioDockPage.cs | 188 ++++++++++++++++++ 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 MarketExtension/Pages/PortfolioDockPage.cs diff --git a/MarketExtension/MarketExtensionCommandsProvider.cs b/MarketExtension/MarketExtensionCommandsProvider.cs index a0ef23b..219ca53 100644 --- a/MarketExtension/MarketExtensionCommandsProvider.cs +++ b/MarketExtension/MarketExtensionCommandsProvider.cs @@ -37,9 +37,11 @@ public MarketExtensionCommandsProvider() new CommandItem(new MarketsPage(_repository)) { Title = "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(_repository)) { Title = "Markets" }, + new CommandItem(new PortfolioDockPage(_repository)) { Title = "Markets Portfolio" }, ]; } diff --git a/MarketExtension/Pages/PortfolioDockPage.cs b/MarketExtension/Pages/PortfolioDockPage.cs new file mode 100644 index 0000000..635c34b --- /dev/null +++ b/MarketExtension/Pages/PortfolioDockPage.cs @@ -0,0 +1,188 @@ +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; + +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; + + 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); + 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; + 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 = "Markets Portfolio"; + Icon = new IconInfo("https://github.com/favicon.ico"); + } + + 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 = "No holdings yet", + Subtitle = "Add holdings from an instrument's detail page (Add to Portfolio)", + }]; + } + + // 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 = $"Portfolio {_portfolio.FormatTotalValue()}", + Subtitle = _portfolio.FormatTotalChange() + _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)); + } + + return UiPortfolio.From(uiPositions, preferred); + } +} From 4af29bd4e0e869ccf91f6669eb2707d04dd71dbe Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 05:13:01 +0300 Subject: [PATCH 46/69] portfolio dockband --- CLAUDE.md | 54 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e7c0a10..759c482 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -226,6 +226,21 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context ## Current Status / Next Steps +- **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 @@ -333,7 +348,8 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context `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 yet**, and **all add/edit/remove on the symbol detail page** (rows just open it, like every + **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). ⚠️ Verified to **compile**; a live smoke test (add a holding, see the total roll up, edit/remove, confirm persistence) is worth doing. See "Portfolio screen (DONE…)". - **Deferred:** richer **per-symbol** error UX — a typed `QuoteStatus` (Ok/Invalid/RateLimited/NoKey) on @@ -364,11 +380,11 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context 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:** ~~**(1) multi-currency portfolio conversion**~~ — **DONE this round** (see "Done (this - round): multi-currency portfolio conversion" + "Multi-currency portfolio conversion (done)"). Remaining: - **(2)** the **Portfolio dock band** (a one-line total-value + daily-P&L strip next to the favorites band — - the Portfolio *screen* itself is now done); and **(3)** **cost-basis / total-return** reporting (the - `CostBasis` field is already stored, just not surfaced). Both designed below. +- **Wishlist:** ~~**(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)"). Remaining: **(3)** **cost-basis / total-return** reporting (the + `CostBasis` field is already stored, just not surfaced) — designed below. - **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 @@ -521,7 +537,7 @@ until reopened. Implemented via the observable layer: - 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 (DONE — screen built; dock band still deferred) +### 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 @@ -571,10 +587,26 @@ adaptive card with one `Input.Number` (prefilled with the current holding, 0 whe detail page. The single-`FormContent` auto-focus quirk (that plagues the chart) is **harmless/helpful here** — it drops the cursor straight into the number field. -**Still deferred — the dock band** (`Pages/PortfolioDockPage.cs`): a second band in `GetDockBands()` next -to favorites, one line of total value + daily P&L. **Not built this pass.** Would need its own non-empty -`Command.Id` (`com.costafotiadis.market.dock.portfolio` — see `reference/dock-support.md`) and would inherit -the favorites band's live-refresh story (the polling + push-on-change work above). +**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. + +**Still deferred — cost-basis / total-return** reporting: `DomainPosition`/`PortfolioItem` already persist an +optional `CostBasis` (no UI yet), so unrealized + total return on each holding and the portfolio is a cheap +follow-up — surface it in `UiPosition`/`UiPortfolio` and the detail-page editor. ### Multi-currency portfolio conversion (done) From aa37bf97dca051947be98ca8de52b6416ea1c5d7 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 05:18:16 +0300 Subject: [PATCH 47/69] updates --- CLAUDE.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 759c482..4576573 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -277,8 +277,8 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context 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). ⚠️ Verified to **compile**; - a live smoke test (exhaust a free key, confirm the banner appears then clears on recovery) is worth doing. + 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 @@ -350,8 +350,8 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context 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). ⚠️ Verified to **compile**; a live smoke test (add a holding, see - the total roll up, edit/remove, confirm persistence) is worth doing. See "Portfolio screen (DONE…)". + 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, @@ -408,9 +408,9 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context 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**. ⚠️ - Verified to **compile** clean; a live smoke test of polling (open a priced page, watch prices refresh on - the interval; hide/reshow to confirm the loop restarts) is worth doing. + 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) From 8e01e3957f454922aa94150c47250de1d007f797 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 14:32:07 +0300 Subject: [PATCH 48/69] add demo mode --- CLAUDE.md | 147 +++++++++++++++--- MarketExtension/Data/IMarketDataProvider.cs | 8 + MarketExtension/Data/MarketRepository.cs | 19 ++- .../Data/MockMarketDataProvider.cs | 72 ++++++--- MarketExtension/Helpers/ApiKeyHint.cs | 81 +++++++--- MarketExtension/Helpers/CurrencyConverter.cs | 29 ++++ MarketExtension/Helpers/RateLimitHint.cs | 2 +- .../MarketExtensionCommandsProvider.cs | 16 +- MarketExtension/Models/DomainPosition.cs | 6 +- MarketExtension/Models/UiPortfolio.cs | 33 +++- MarketExtension/Models/UiPosition.cs | 61 +++++++- MarketExtension/Pages/MarketsPage.cs | 5 +- MarketExtension/Pages/PortfolioDockPage.cs | 4 +- MarketExtension/Pages/PortfolioPage.cs | 25 +-- MarketExtension/Pages/PricedListPage.cs | 4 +- MarketExtension/Pages/SearchPage.cs | 4 +- MarketExtension/Pages/SetQuantityPage.cs | 51 +++--- .../Settings/MarketSettingsManager.cs | 16 ++ MarketExtension/Settings/PortfolioStore.cs | 19 ++- 19 files changed, 464 insertions(+), 138 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4576573..287fe9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,20 +77,20 @@ the same three layers and the same provider seam — see the "Symbol detail + li | 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. | -| `Data/IMarketDataProvider.cs` | ONE data source: `bool Supports(AssetCategory)` + `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/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` | Offline fallback (`Supports` all). `SearchAsync` filters its seed keys. | +| `Data/MockMarketDataProvider.cs` | The **Demo-mode** data source — seeded 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). Each seed entry carries real name/category/**native currency** (incl. a GBP London stock `HSBA.L`) so search is correctly typed and conversion/total-return have a non-USD holding to exercise. 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`→Sentry) 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.MissingKeyRow()`): `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). | +| `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). | @@ -226,6 +226,44 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context ## Current Status / Next Steps +- **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). Reads pull-style → applies + on the next refresh/reopen, no reload. Build clean (0 warnings). ⏳ Not yet live-verified. 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). ⏳ **Not yet live-verified** (next: add a holding with a cost basis and confirm the per-row + + summary + dock totals, incl. a non-native-currency holding's converted return). 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 @@ -380,11 +418,13 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context 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:** ~~**(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)"). Remaining: **(3)** **cost-basis / total-return** reporting (the - `CostBasis` field is already stored, just not surfaced) — designed below. +- **Wishlist — all three items now 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): 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 @@ -508,7 +548,9 @@ configurable** (0 = off). Built on the ticker's subscriber-count lifecycle (now `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. (Same manager also holds the Finnhub API key — see the "API Key" section.) + 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 @@ -544,8 +586,9 @@ per symbol), priced live, with a **totals summary** pinned on top and **daily P& the existing data seam with **no provider/repository changes** — `MarketRepository.GetQuotesAsync` already returns per-instrument daily change. -**Scope shipped:** quantity + **daily P&L only**. `DomainPosition`/`PortfolioItem` carry an optional -`CostBasis` (persisted, **no UI yet**), so unrealized/total return is a cheap follow-up. +**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, @@ -558,14 +601,23 @@ returns per-instrument daily change. 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 → `MarketValue` (qty × price), `DailyPnL` (qty × `DomainQuote.Change`), `Format*()` helpers (the - only place position formatting lives, like `UiQuote`); `UiPortfolio.From(...)` rolls up the totals. + 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` @@ -581,11 +633,13 @@ 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 quantity editor: a `ContentPage` whose single `FormContent` is an -adaptive card with one `Input.Number` (prefilled with the current holding, 0 when adding) + a Save action. -`SubmitForm` parses + validates (> 0), calls `PortfolioStore.SetPosition`, 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 number field. +**`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 @@ -604,9 +658,15 @@ rate-limit signal but renders no banner** (too cramped). No provider/repository/ 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. -**Still deferred — cost-basis / total-return** reporting: `DomainPosition`/`PortfolioItem` already persist an -optional `CostBasis` (no UI yet), so unrealized + total return on each holding and the portfolio is a cheap -follow-up — surface it in `UiPosition`/`UiPortfolio` and the detail-page editor. +**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. ⏳ +Not yet live-verified. ### Multi-currency portfolio conversion (done) @@ -663,6 +723,45 @@ rolled up to ~£5,571 — i.e. the USD value × ~0.76, a genuine conversion, not 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. + +All checks are **pull-style**, so flipping the toggle applies on the **next price refresh / when a list is +reopened** — no reload (consistent with `PortfolioCurrency` / `ShowRateLimitErrors`). + +**Seed coverage** (`MockMarketDataProvider`): AAPL/MSFT/NVDA (USD), **HSBA.L (GBP, a London stock)**, BTC/ETH/ +SOL (USD), EURUSD/GBPUSD/USDJPY. Each entry carries real **name + category + native currency**, so search is +correctly typed (not all-`Stock` as before) and the GBP stock gives multi-currency conversion + total return a +non-USD holding to convert. Candles are a deterministic per-`(symbol,range)` random walk. + +**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.) ⏳ Not yet live-verified. + ### 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) @@ -686,8 +785,8 @@ through a new provider seam — see the Finnhub candle spec + the candle layer f - **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, temporarily point the repo at `new MarketRepository(new MockMarketDataProvider())` (don't ship — - mock also feeds search). + 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).** diff --git a/MarketExtension/Data/IMarketDataProvider.cs b/MarketExtension/Data/IMarketDataProvider.cs index eba2532..f1f0fe6 100644 --- a/MarketExtension/Data/IMarketDataProvider.cs +++ b/MarketExtension/Data/IMarketDataProvider.cs @@ -13,6 +13,14 @@ internal interface IMarketDataProvider // 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); diff --git a/MarketExtension/Data/MarketRepository.cs b/MarketExtension/Data/MarketRepository.cs index df6fa2a..1ae253a 100644 --- a/MarketExtension/Data/MarketRepository.cs +++ b/MarketExtension/Data/MarketRepository.cs @@ -13,15 +13,26 @@ namespace MarketExtension; // 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 provider that can serve its asset class. + // 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 = providers.FirstOrDefault(p => p.Supports(instrument.Category)); + var provider = active.FirstOrDefault(p => p.Supports(instrument.Category)); if (provider is null) { unserviceable.Add(instrument); @@ -59,7 +70,7 @@ public async Task> SearchAsync( string query, CancellationToken ct = default) { var results = await Task.WhenAll( - providers.Select(p => p.SearchAsync(query, ct))).ConfigureAwait(false); + ActiveProviders().Select(p => p.SearchAsync(query, ct))).ConfigureAwait(false); var merged = results .SelectMany(r => r) @@ -77,7 +88,7 @@ public async Task> SearchAsync( public async Task GetCandlesAsync( DomainInstrument instrument, ChartRange range, CancellationToken ct = default) { - var provider = providers.FirstOrDefault(p => p.Supports(instrument.Category)); + 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}"); diff --git a/MarketExtension/Data/MockMarketDataProvider.cs b/MarketExtension/Data/MockMarketDataProvider.cs index e4ceb9d..dadca6c 100644 --- a/MarketExtension/Data/MockMarketDataProvider.cs +++ b/MarketExtension/Data/MockMarketDataProvider.cs @@ -6,35 +6,61 @@ namespace MarketExtension; -// Offline/dev fallback source. Returns deterministic seed prices for the requested instruments so -// the UI works without network access. No longer the active provider (FinnhubMarketDataProvider -// is) — kept to prove the seam stays swappable and as a no-network fallback. Supports every asset -// class. +// The "Demo mode" data source. Returns deterministic seed 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. Each seed entry +// carries its real name, category, and NATIVE currency so search returns correctly-typed results and quotes +// mirror the live providers' currency stamping (incl. a non-USD stock, to exercise multi-currency conversion +// + total return offline). internal sealed class MockMarketDataProvider : IMarketDataProvider { - private static readonly Dictionary Seed = new() + private readonly record struct SeedQuote( + string Name, AssetCategory Category, decimal Price, decimal Change, decimal Pct, string Currency); + + private static readonly Dictionary Seed = new(System.StringComparer.OrdinalIgnoreCase) { - ["AAPL"] = (189.20m, 2.24m, 1.20m), - ["MSFT"] = (421.10m, 1.68m, 0.40m), - ["NVDA"] = (118.45m, -3.10m, -2.55m), - ["BTC"] = (64210.00m, -512.00m, -0.80m), - ["ETH"] = (3420.50m, 45.20m, 1.34m), - ["SOL"] = (148.30m, -2.10m, -1.40m), - ["EURUSD"] = (1.0832m, -0.0011m, -0.10m), - ["GBPUSD"] = (1.2715m, 0.0024m, 0.19m), - ["USDJPY"] = (157.420m, 0.310m, 0.20m), + ["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"), }; - public bool Supports(AssetCategory category) => true; + // 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) { - query = query?.Trim() ?? string.Empty; + // 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 seed from polluting live results. + query = MarketSettingsManager.Instance.DemoMode ? query?.Trim() ?? string.Empty : string.Empty; IReadOnlyList matches = query.Length == 0 ? [] - : [.. Seed.Keys - .Where(s => s.Contains(query, System.StringComparison.OrdinalIgnoreCase)) - .Select(s => new DomainInstrument(s, s, AssetCategory.Stock))]; + // Match on symbol OR name, return each result with its REAL category (not all-Stock), ordered + // for deterministic test output. + : [.. Seed + .Where(kv => kv.Key.Contains(query, System.StringComparison.OrdinalIgnoreCase) + || kv.Value.Name.Contains(query, System.StringComparison.OrdinalIgnoreCase)) + .OrderBy(kv => kv.Key, System.StringComparer.Ordinal) + .Select(kv => new DomainInstrument(kv.Key, kv.Value.Name, kv.Value.Category))]; return Task.FromResult(matches); } @@ -43,13 +69,11 @@ public Task> GetQuotesAsync( { IReadOnlyList quotes = [ + // Use the incoming instrument's identity (name/category) but the seed's price + native currency, + // so e.g. HSBA.L prices in GBP exactly like a real London quote would. .. 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, - // Mirror the real providers' native currency so offline preview is coherent: an FX pair - // in its quote currency, everything else USD. - Currency: i.Category == AssetCategory.Currency - ? CurrencyHelper.QuoteCurrencyOfPair(i.Symbol) - : "USD") + Currency: s.Currency) : new DomainQuote(i.Symbol, i.Name, i.Category, 0m, 0m, 0m, IsValid: false)), ]; return Task.FromResult(quotes); diff --git a/MarketExtension/Helpers/ApiKeyHint.cs b/MarketExtension/Helpers/ApiKeyHint.cs index ddde79f..010af43 100644 --- a/MarketExtension/Helpers/ApiKeyHint.cs +++ b/MarketExtension/Helpers/ApiKeyHint.cs @@ -3,21 +3,31 @@ namespace MarketExtension; -// A "you haven't set an API key" nudge for the list screens. With NEITHER a Twelve Data nor a Finnhub -// key set, stocks and crypto can't be priced or searched (only keyless Frankfurter FX still works), so -// the app looks broken/empty with no explanation. Rather than leave the user guessing, the hub, Search, -// Watchlist and Favorites append this row at the bottom when no key is configured; Enter navigates -// straight into the extension's Settings page (the same form the Markets hub links to). +// 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 = "\uE7BA"; // Segoe MDL2 Warning + private const string WarningGlyph = ""; // Segoe MDL2 Warning + private const string DemoGlyph = ""; // Segoe MDL2 Info - // Windows "attention" red \u2014 used for the warning tag so the row reads as a problem, not a normal item. + // 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); - // Built once and reused: the toolkit's navigable settings form over our settings singleton. The - // missing-key row points its Enter command at this, so the user lands directly on the key fields. + // 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 => @@ -29,21 +39,44 @@ internal static class ApiKeyHint // hint appears/disappears reactively the instant a key is added/cleared (MarketSettingsManager.HasAnyApiKey). private static bool HasAnyKey => MarketSettingsManager.Instance.HasAnyApiKey.Value; - // The hint row, or null when a key is already configured. Callers append it only when non-null: + // 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.MissingKeyRow() is { } hint) items.Add(hint); - public static IListItem? MissingKeyRow() => - HasAnyKey - ? null - : new ListItem(SettingsPage) + // 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 = "Demo mode — showing sample data", + Subtitle = "Prices are simulated, not live. Turn off Demo mode in Settings (press Enter).", + Icon = new IconInfo(DemoGlyph), + Tags = [new Tag("Demo mode") + { + Foreground = DemoBlue, + ToolTip = "Demo mode is on — showing built-in sample data, not live prices", + }], + }; + + // "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 = "No API key set — functionality is limited", + Subtitle = "Add an API key in Settings (press Enter)", + Icon = new IconInfo(WarningGlyph), + Tags = [new Tag("Action required") { - Title = "No API key set — functionality is limited", - Subtitle = "Add an API key in Settings (press Enter)", - Icon = new IconInfo(WarningGlyph), - Tags = [new Tag("Action required") - { - Foreground = WarningRed, - ToolTip = "No API key set — functionality is limited", - }], - }; + Foreground = WarningRed, + ToolTip = "No API key set — functionality is limited", + }], + }; } diff --git a/MarketExtension/Helpers/CurrencyConverter.cs b/MarketExtension/Helpers/CurrencyConverter.cs index 33aed70..37f5257 100644 --- a/MarketExtension/Helpers/CurrencyConverter.cs +++ b/MarketExtension/Helpers/CurrencyConverter.cs @@ -38,6 +38,25 @@ internal sealed class CurrencyConverter // 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() { } private readonly record struct CachedRate(decimal? Rate, long Tick); @@ -89,6 +108,16 @@ public async Task PrimeAsync(string preferred, IEnumerable natives, Canc 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. diff --git a/MarketExtension/Helpers/RateLimitHint.cs b/MarketExtension/Helpers/RateLimitHint.cs index f50c57d..c0ca504 100644 --- a/MarketExtension/Helpers/RateLimitHint.cs +++ b/MarketExtension/Helpers/RateLimitHint.cs @@ -9,7 +9,7 @@ namespace MarketExtension; // 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.MissingKeyRow(). +// Parallels ApiKeyHint.StatusRow(). internal static class RateLimitHint { private const string WarningGlyph = ""; // Segoe MDL2 Warning diff --git a/MarketExtension/MarketExtensionCommandsProvider.cs b/MarketExtension/MarketExtensionCommandsProvider.cs index 219ca53..0bfb6f9 100644 --- a/MarketExtension/MarketExtensionCommandsProvider.cs +++ b/MarketExtension/MarketExtensionCommandsProvider.cs @@ -5,14 +5,16 @@ namespace MarketExtension; public partial class MarketExtensionCommandsProvider : CommandProvider { - // 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 this order: Twelve Data - // (stocks + crypto + forex, plus free price charts) is primary WHEN its key is set — its Supports() - // is gated on the 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; - // MockMarketDataProvider is the offline fallback. + // 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 TwelveDataMarketDataProvider(), + new(new MockMarketDataProvider(), + new TwelveDataMarketDataProvider(), new FinnhubMarketDataProvider(), new FrankfurterMarketDataProvider()); diff --git a/MarketExtension/Models/DomainPosition.cs b/MarketExtension/Models/DomainPosition.cs index 1e88fe1..afab7aa 100644 --- a/MarketExtension/Models/DomainPosition.cs +++ b/MarketExtension/Models/DomainPosition.cs @@ -5,7 +5,7 @@ namespace MarketExtension; // 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 (what was paid per unit) is carried and persisted for forward-compat — it enables -// unrealized/total-return reporting later — but is NOT surfaced in the UI yet (this pass shows daily -// P&L only). It stays null until that feature lands. +// 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/UiPortfolio.cs b/MarketExtension/Models/UiPortfolio.cs index a312334..87099f4 100644 --- a/MarketExtension/Models/UiPortfolio.cs +++ b/MarketExtension/Models/UiPortfolio.cs @@ -13,7 +13,15 @@ namespace MarketExtension; // 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) +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) { @@ -23,10 +31,19 @@ public static UiPortfolio From(IEnumerable positions, string preferr 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); - return new UiPortfolio(totalValue, totalPnL, preferredCurrency, all.Count > 0, unconverted); + // 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. @@ -39,6 +56,10 @@ public decimal TotalDailyPnLPercent } } + // 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. @@ -46,6 +67,14 @@ public string FormatTotalChange() => $"{(IsUp ? "▲" : "▼")} {CurrencyFormat.FormatSigned(TotalDailyPnL, Currency)} " + $"({TotalDailyPnLPercent.ToString("+0.00;-0.00", CultureInfo.InvariantCulture)}%) today"; + // 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() => + HasCostBasis + ? $" · Total {(IsTotalReturnUp ? "▲" : "▼")} {CurrencyFormat.FormatSigned(TotalReturn, Currency)} " + + $"({TotalReturnPercent.ToString("+0.00;-0.00", CultureInfo.InvariantCulture)}%)" + : string.Empty; + // 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 diff --git a/MarketExtension/Models/UiPosition.cs b/MarketExtension/Models/UiPosition.cs index 92a00be..5418e41 100644 --- a/MarketExtension/Models/UiPosition.cs +++ b/MarketExtension/Models/UiPosition.cs @@ -5,16 +5,21 @@ namespace MarketExtension; // 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. // -// This pass shows DAILY P&L only (cost-basis / total-return remains deferred). Multi-currency: -// * The native value/P&L come straight from the quote (qty × price/change in DomainQuote.Currency). +// 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, MarketValue/DailyPnL -// convert; 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) +// 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) - => new(quote, quantity, preferredCurrency, rateToPreferred); + 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; @@ -40,6 +45,26 @@ public static UiPosition From(DomainQuote quote, decimal quantity, string prefer // 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); @@ -80,4 +105,24 @@ public string FormatDailyPnL() 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/Pages/MarketsPage.cs b/MarketExtension/Pages/MarketsPage.cs index 1db4fdc..be9ca4f 100644 --- a/MarketExtension/Pages/MarketsPage.cs +++ b/MarketExtension/Pages/MarketsPage.cs @@ -115,8 +115,9 @@ public override IListItem[] GetItems() }, }; - // With no API key configured, most of the app shows no data — surface that up front on the hub. - if (ApiKeyHint.MissingKeyRow() is { } hint) + // 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); return [.. items]; diff --git a/MarketExtension/Pages/PortfolioDockPage.cs b/MarketExtension/Pages/PortfolioDockPage.cs index 635c34b..0b1b21d 100644 --- a/MarketExtension/Pages/PortfolioDockPage.cs +++ b/MarketExtension/Pages/PortfolioDockPage.cs @@ -92,7 +92,7 @@ public override IListItem[] GetItems() new ListItem(new PortfolioPage(_repository)) { Title = $"Portfolio {_portfolio.FormatTotalValue()}", - Subtitle = _portfolio.FormatTotalChange() + _portfolio.FormatUnconvertedNote(), + Subtitle = _portfolio.FormatTotalChange() + _portfolio.FormatTotalReturnNote() + _portfolio.FormatUnconvertedNote(), Icon = new IconInfo(PortfolioGlyph), }, ]; @@ -180,7 +180,7 @@ private static UiPortfolio BuildPortfolio(IReadOnlyList position 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)); + 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 index d9edc14..8021b9c 100644 --- a/MarketExtension/Pages/PortfolioPage.cs +++ b/MarketExtension/Pages/PortfolioPage.cs @@ -43,8 +43,8 @@ protected override IEnumerable LeadingRows(IReadOnlyList pri var preferred = MarketSettingsManager.Instance.PortfolioCurrency; var positions = new List(); foreach (var q in pricedQuotes) - if (PortfolioStore.Instance.TryGetQuantity(q.Symbol, out var qty)) - positions.Add(MakePosition(q.Source, qty, preferred)); + if (PortfolioStore.Instance.GetPosition(q.Symbol) is { } held) + positions.Add(MakePosition(q.Source, held.Quantity, held.CostBasis, preferred)); if (positions.Count == 0) return []; @@ -55,7 +55,7 @@ protected override IEnumerable LeadingRows(IReadOnlyList pri new ListItem(new NoOpCommand()) { Title = $"Portfolio {portfolio.FormatTotalValue()}", - Subtitle = portfolio.FormatTotalChange() + portfolio.FormatUnconvertedNote(), + Subtitle = portfolio.FormatTotalChange() + portfolio.FormatTotalReturnNote() + portfolio.FormatUnconvertedNote(), Icon = new IconInfo(PortfolioGlyph), }, ]; @@ -64,23 +64,30 @@ protected override IEnumerable LeadingRows(IReadOnlyList pri protected override IListItem BuildRow(UiQuote q) { var instrument = new DomainInstrument(q.Symbol, q.Name, q.Category); - PortfolioStore.Instance.TryGetQuantity(q.Symbol, out var qty); - var position = MakePosition(q.Source, qty, MarketSettingsManager.Instance.PortfolioCurrency); + 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 += $" Total {totalReturn}"; return new ListItem(new SymbolDetailPage(instrument, Repository)) { Title = position.FormatHolding(), - Subtitle = $"{position.FormatValue()} {position.FormatDailyPnL()}", + 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. - private static UiPosition MakePosition(DomainQuote quote, decimal qty, string preferred) + // 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); + 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 diff --git a/MarketExtension/Pages/PricedListPage.cs b/MarketExtension/Pages/PricedListPage.cs index 73127de..4bc9e99 100644 --- a/MarketExtension/Pages/PricedListPage.cs +++ b/MarketExtension/Pages/PricedListPage.cs @@ -127,7 +127,7 @@ public override IListItem[] GetItems() return []; // framework's pre-subscribe fetch — nothing to show yet if (_snapshot.Count == 0) - return ApiKeyHint.MissingKeyRow() is { } emptyHint ? [.. EmptyState(), emptyHint] : EmptyState(); + return ApiKeyHint.StatusRow() is { } emptyHint ? [.. EmptyState(), emptyHint] : EmptyState(); UiQuote[] cached; lock (_cacheLock) @@ -143,7 +143,7 @@ public override IListItem[] GetItems() rows.AddRange(cached.Where(Matches).Select(BuildRow)); rows.Add(new ListItem(new RefreshCommand(this)) { Title = "Refresh 🔄" }); rows.Add(AssetIconResolver.AttributionRow()); // Elbstream logo credit (rows above show logos) - if (ApiKeyHint.MissingKeyRow() is { } hint) // no key → explain why prices are blank + 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); diff --git a/MarketExtension/Pages/SearchPage.cs b/MarketExtension/Pages/SearchPage.cs index a5d39b6..c52a544 100644 --- a/MarketExtension/Pages/SearchPage.cs +++ b/MarketExtension/Pages/SearchPage.cs @@ -105,7 +105,7 @@ private IListItem[] HomeItems() }, }; - if (ApiKeyHint.MissingKeyRow() is { } hint) // no key → nudge the user to add one + if (ApiKeyHint.StatusRow() is { } hint) // no key → nudge to add one; demo mode → flag sample data items.Add(hint); return [.. items]; @@ -145,7 +145,7 @@ private IListItem[] SearchItems() items.Add(AssetIconResolver.AttributionRow()); // Elbstream logo credit (results show logos) } - if (ApiKeyHint.MissingKeyRow() is { } hint) // no key → explain why stock/crypto search is empty + 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); diff --git a/MarketExtension/Pages/SetQuantityPage.cs b/MarketExtension/Pages/SetQuantityPage.cs index 98cc8e6..06d900b 100644 --- a/MarketExtension/Pages/SetQuantityPage.cs +++ b/MarketExtension/Pages/SetQuantityPage.cs @@ -6,11 +6,12 @@ namespace MarketExtension; -// The quantity editor for one instrument — the single place to add a holding to the portfolio or change -// how much of it is held. 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 one number input; 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 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. @@ -50,29 +51,40 @@ public QuantityForm(DomainInstrument instrument) private string BuildData() { - PortfolioStore.Instance.TryGetQuantity(_instrument.Symbol, out var qty); + 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"] = qty, + ["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, }; return data.ToJsonString(); } public override ICommandResult SubmitForm(string inputs, string data) { - if (!TryReadQuantity(inputs, out var quantity)) + if (!TryReadDecimal(inputs, "quantity", out var quantity)) return Error("Enter a number greater than 0."); if (quantity <= 0m) return Error("Quantity must be greater than 0. To remove a holding, use Remove from Portfolio."); - PortfolioStore.Instance.SetPosition(_instrument, quantity); + // 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 + ? $" at {b.ToString("0.########", CultureInfo.InvariantCulture)}/unit" + : string.Empty; return CommandResult.ShowToast(new ToastArgs { - Message = $"Set {_instrument.Symbol} to {quantity.ToString("0.########", CultureInfo.InvariantCulture)}", + Message = $"Set {_instrument.Symbol} to {quantity.ToString("0.########", CultureInfo.InvariantCulture)}{basisNote}", Result = CommandResult.GoBack(), // back to the detail page; its subscription refreshes the bar }); } @@ -80,12 +92,13 @@ public override ICommandResult SubmitForm(string inputs, string data) private static CommandResult Error(string message) => CommandResult.ShowToast(new ToastArgs { Message = message, Result = CommandResult.KeepOpen() }); - // Pull the "quantity" field out of the host's inputs JSON (e.g. {"quantity":"10.5"} — the value may + // 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). - private static bool TryReadQuantity(string inputs, out decimal quantity) + // 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) { - quantity = 0m; + value = 0m; if (string.IsNullOrWhiteSpace(inputs)) return false; @@ -93,15 +106,15 @@ private static bool TryReadQuantity(string inputs, out decimal quantity) { using var doc = JsonDocument.Parse(inputs); if (doc.RootElement.ValueKind != JsonValueKind.Object || - !doc.RootElement.TryGetProperty("quantity", out var field)) + !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 quantity) - || decimal.TryParse(raw, NumberStyles.Number, CultureInfo.CurrentCulture, out quantity); + return decimal.TryParse(raw, NumberStyles.Number, CultureInfo.InvariantCulture, out value) + || decimal.TryParse(raw, NumberStyles.Number, CultureInfo.CurrentCulture, out value); } catch (JsonException) { @@ -118,7 +131,9 @@ private static bool TryReadQuantity(string inputs, out decimal quantity) "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": "Quantity held", "value": ${quantity}, "min": 0, "isRequired": true, "errorMessage": "Enter a quantity greater than 0" } + { "type": "Input.Number", "id": "quantity", "label": "Quantity held", "value": ${quantity}, "min": 0, "isRequired": true, "errorMessage": "Enter a quantity greater than 0" }, + { "type": "Input.Number", "id": "costBasis", "label": "Average cost per unit (optional)", "value": ${costBasis}, "min": 0 }, + { "type": "TextBlock", "text": "Price you paid per unit, in the instrument's trading currency. Used to show your total return — leave 0 if unknown.", "isSubtle": true, "size": "Small", "spacing": "None", "wrap": true } ], "actions": [ { "type": "Action.Submit", "title": "Save", "data": { "action": "save" } } diff --git a/MarketExtension/Settings/MarketSettingsManager.cs b/MarketExtension/Settings/MarketSettingsManager.cs index bb3aacd..fbe69c7 100644 --- a/MarketExtension/Settings/MarketSettingsManager.cs +++ b/MarketExtension/Settings/MarketSettingsManager.cs @@ -56,6 +56,14 @@ internal sealed class MarketSettingsManager : JsonSettingsManager "requests (HTTP 429) so you know why prices may be stale. Turn off to hide it.", }; + private readonly ToggleSetting _demoMode = new("demoMode", false) + { + Label = "Demo mode", + Description = "Show built-in sample market data instead of live prices — no API key or internet " + + "connection needed, ideal for trying out the extension. Applies on the next price " + + "refresh (reopen a list to see it right away).", + }; + // 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 @@ -136,6 +144,13 @@ internal sealed class MarketSettingsManager : JsonSettingsManager // 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. + // Read pull-style: MockMarketDataProvider.Supports/SearchAsync and CurrencyConverter check it per + // request, so toggling applies on the next price refresh without a reload (the price cache repaints + // immediately when a list is reopened). Default off → ships live. + public bool DemoMode => _demoMode.Value; + // 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. @@ -149,6 +164,7 @@ private MarketSettingsManager() 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) diff --git a/MarketExtension/Settings/PortfolioStore.cs b/MarketExtension/Settings/PortfolioStore.cs index 5df4682..41e4cfb 100644 --- a/MarketExtension/Settings/PortfolioStore.cs +++ b/MarketExtension/Settings/PortfolioStore.cs @@ -79,16 +79,23 @@ public bool TryGetQuantity(string symbol, out decimal quantity) return false; } - // Add or update a holding (a SetPosition with the same symbol overwrites its quantity). Preserves any - // existing cost basis unless a new one is given, so a quantity-only edit doesn't wipe it. + // 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) - { - var basis = costBasis ?? (_positions.TryGetValue(key, out var existing) ? existing.CostBasis : null); - _positions[key] = new DomainPosition(instrument, quantity, basis); - } + _positions[key] = new DomainPosition(instrument, quantity, costBasis); Save(); PublishState(); From 09a291b4071393ed349ddd524188c916ba1820ff Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 14:38:29 +0300 Subject: [PATCH 49/69] add demo mode --- CLAUDE.md | 32 ++++++++++++++----- .../Data/MockMarketDataProvider.cs | 4 +++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 287fe9a..67196a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -240,8 +240,9 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context 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). Reads pull-style → applies - on the next refresh/reopen, no reload. Build clean (0 warnings). ⏳ Not yet live-verified. See "Demo mode - (offline testing)". + on the next refresh/reopen, no reload. 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 @@ -261,9 +262,9 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context - **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). ⏳ **Not yet live-verified** (next: add a holding with a cost basis and confirm the per-row + - summary + dock totals, incl. a non-native-currency holding's converted return). See "Portfolio screen + - dock band (done)". + warnings). ⏳ **Total-return display not yet explicitly spot-checked** — easiest via Demo mode now: add the + GBP `HSBA.L` holding with a cost basis and confirm the per-row + summary + dock converted return. 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 @@ -418,13 +419,25 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context 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 — all three items now DONE.** ~~**(1) multi-currency portfolio conversion**~~ — **DONE** (see +- **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). +- **Wishlist (new): Demo mode should fabricate data for ANY symbol, not just a seeded handful.** Today + `MockMarketDataProvider` only knows the ~10 symbols in its static `Seed` dictionary — any other ticker + returns an `IsValid:false` quote (blank row) and is absent from search, so a watchlist/portfolio of + off-seed symbols looks broken in demo mode. Make demo mode **synthesize plausible data for any requested + symbol** instead: derive a deterministic price/daily-change from a hash of the symbol (stable across + refreshes), **infer category + native currency from the symbol shape** (6 letters → FX with the quote + currency; a known/short coin ticker → Crypto in USD; a `.L`/`.DE`/… suffix → that exchange's currency; + else Stock in USD), and synthesize a name. Candles already work for any symbol (the random walk doesn't + need a seed entry), so this is mostly `GetQuotesAsync` + a smarter `SearchAsync` (which would also need a + small corpus or generator to return matches for free-text). Keep the curated `Seed` as nice-looking + "known" overrides on top of the generator. Goal: demo mode is a complete stand-in for the live providers, + not a fixed demo set. - **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 @@ -666,7 +679,8 @@ per-row subtitle ("Total ▲ +$… (+…%)"), the totals summary row, and the Po 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. ⏳ -Not yet live-verified. +Total-return display not yet explicitly spot-checked — easiest via **Demo mode** (add the GBP `HSBA.L` holding +with a cost basis and confirm the per-row + summary + dock converted return). ### Multi-currency portfolio conversion (done) @@ -760,7 +774,9 @@ non-USD holding to convert. Candles are a deterministic per-`(symbol,range)` ran **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.) ⏳ Not yet live-verified. +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) diff --git a/MarketExtension/Data/MockMarketDataProvider.cs b/MarketExtension/Data/MockMarketDataProvider.cs index dadca6c..14e743e 100644 --- a/MarketExtension/Data/MockMarketDataProvider.cs +++ b/MarketExtension/Data/MockMarketDataProvider.cs @@ -71,6 +71,10 @@ public Task> GetQuotesAsync( [ // Use the incoming instrument's identity (name/category) but the seed's price + native currency, // so e.g. HSBA.L prices in GBP exactly like a real London quote would. + // ⚠️ LIMITATION (wishlisted in CLAUDE.md): only the ~10 seeded symbols price; any other ticker + // falls through to an invalid (blank) quote, so an off-seed watchlist/portfolio looks empty in + // demo mode. The wishlist item is to SYNTHESIZE plausible data for any symbol (hash-derived + // price, category/currency inferred from the symbol shape) so demo mode fully stands in for live. .. 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) From 1071a444167fb86d2490f0097dfa0b047f3be1cf Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Thu, 25 Jun 2026 15:01:19 +0300 Subject: [PATCH 50/69] add demo mode --- MarketExtension/Helpers/CurrencyConverter.cs | 10 +++++++- MarketExtension/Helpers/RateLimitHint.cs | 8 ++++++- MarketExtension/Helpers/RateLimitSignal.cs | 11 ++++++++- MarketExtension/Pages/FavoritesDockPage.cs | 9 ++++++++ MarketExtension/Pages/MarketsPage.cs | 3 +++ MarketExtension/Pages/PortfolioDockPage.cs | 9 ++++++++ MarketExtension/Pages/PricedListPage.cs | 23 +++++++++++++++++++ MarketExtension/Pages/SearchPage.cs | 4 ++++ .../Settings/MarketSettingsManager.cs | 21 +++++++++++++++-- 9 files changed, 93 insertions(+), 5 deletions(-) diff --git a/MarketExtension/Helpers/CurrencyConverter.cs b/MarketExtension/Helpers/CurrencyConverter.cs index 37f5257..5c1a5e6 100644 --- a/MarketExtension/Helpers/CurrencyConverter.cs +++ b/MarketExtension/Helpers/CurrencyConverter.cs @@ -57,7 +57,15 @@ internal sealed class CurrencyConverter ? f / t : null; - private CurrencyConverter() { } + 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); diff --git a/MarketExtension/Helpers/RateLimitHint.cs b/MarketExtension/Helpers/RateLimitHint.cs index c0ca504..d4de9ca 100644 --- a/MarketExtension/Helpers/RateLimitHint.cs +++ b/MarketExtension/Helpers/RateLimitHint.cs @@ -19,10 +19,16 @@ internal static class RateLimitHint 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). Callers append it only when non-null: + // 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) { diff --git a/MarketExtension/Helpers/RateLimitSignal.cs b/MarketExtension/Helpers/RateLimitSignal.cs index ffb1388..b523b09 100644 --- a/MarketExtension/Helpers/RateLimitSignal.cs +++ b/MarketExtension/Helpers/RateLimitSignal.cs @@ -17,7 +17,16 @@ internal sealed class RateLimitSignal private readonly MutableStateFlow _isRateLimited = new(false); - private RateLimitSignal() { } + 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). diff --git a/MarketExtension/Pages/FavoritesDockPage.cs b/MarketExtension/Pages/FavoritesDockPage.cs index 2723068..652faee 100644 --- a/MarketExtension/Pages/FavoritesDockPage.cs +++ b/MarketExtension/Pages/FavoritesDockPage.cs @@ -25,6 +25,7 @@ internal sealed partial class FavoritesDockPage : ListPage, INotifyItemsChanged private event TypedEventHandler? _itemsChanged; private IDisposable? _subscription; private IDisposable? _pollSubscription; + private IDisposable? _demoModeSubscription; event TypedEventHandler INotifyItemsChanged.ItemsChanged { @@ -39,6 +40,12 @@ event TypedEventHandler INotifyItemsChanged.Item // 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 @@ -48,6 +55,8 @@ event TypedEventHandler INotifyItemsChanged.Item _subscription = null; _pollSubscription?.Dispose(); _pollSubscription = null; + _demoModeSubscription?.Dispose(); + _demoModeSubscription = null; Log.Info("Poll", "Dock: stopped polling"); } } diff --git a/MarketExtension/Pages/MarketsPage.cs b/MarketExtension/Pages/MarketsPage.cs index be9ca4f..94a43cb 100644 --- a/MarketExtension/Pages/MarketsPage.cs +++ b/MarketExtension/Pages/MarketsPage.cs @@ -42,6 +42,9 @@ event TypedEventHandler INotifyItemsChanged.Item // 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 { diff --git a/MarketExtension/Pages/PortfolioDockPage.cs b/MarketExtension/Pages/PortfolioDockPage.cs index 0b1b21d..202590e 100644 --- a/MarketExtension/Pages/PortfolioDockPage.cs +++ b/MarketExtension/Pages/PortfolioDockPage.cs @@ -33,6 +33,7 @@ internal sealed partial class PortfolioDockPage : ListPage, INotifyItemsChanged private event TypedEventHandler? _itemsChanged; private IDisposable? _subscription; private IDisposable? _pollSubscription; + private IDisposable? _demoModeSubscription; event TypedEventHandler INotifyItemsChanged.ItemsChanged { @@ -47,6 +48,12 @@ event TypedEventHandler INotifyItemsChanged.Item // 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 @@ -56,6 +63,8 @@ event TypedEventHandler INotifyItemsChanged.Item _subscription = null; _pollSubscription?.Dispose(); _pollSubscription = null; + _demoModeSubscription?.Dispose(); + _demoModeSubscription = null; Log.Info("Poll", "PortfolioDock: stopped polling"); } } diff --git a/MarketExtension/Pages/PricedListPage.cs b/MarketExtension/Pages/PricedListPage.cs index 4bc9e99..e9f88ce 100644 --- a/MarketExtension/Pages/PricedListPage.cs +++ b/MarketExtension/Pages/PricedListPage.cs @@ -85,6 +85,29 @@ protected PricedListPage(MarketRepository repository, StateFlow