From d5eb12c2621051faaf35ec35ebbac2b67d8f9118 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 27 Jun 2026 18:54:43 +0300 Subject: [PATCH 1/8] News work in progress --- .../Data/Finnhub/ApiFinnhubNewsDto.cs | 26 +++ .../Data/Finnhub/ApiFinnhubQuoteDto.cs | 1 + .../Data/Finnhub/FinnhubMarketDataProvider.cs | 86 ++++++++++ MarketExtension/Data/IMarketDataProvider.cs | 19 +++ .../Data/MockMarketDataProvider.cs | 152 ++++++++++++++++++ MarketExtension/Models/DomainNews.cs | 23 +++ MarketExtension/Models/NewsCategory.cs | 35 ++++ 7 files changed, 342 insertions(+) create mode 100644 MarketExtension/Data/Finnhub/ApiFinnhubNewsDto.cs create mode 100644 MarketExtension/Models/DomainNews.cs create mode 100644 MarketExtension/Models/NewsCategory.cs diff --git a/MarketExtension/Data/Finnhub/ApiFinnhubNewsDto.cs b/MarketExtension/Data/Finnhub/ApiFinnhubNewsDto.cs new file mode 100644 index 0000000..56760f3 --- /dev/null +++ b/MarketExtension/Data/Finnhub/ApiFinnhubNewsDto.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace MarketExtension; + +// Api layer: the raw shape of one item in Finnhub's GET /news (market news) response. Internal to the +// Finnhub provider, which will map it into a provider-agnostic domain model. The /news endpoint returns +// a BARE top-level JSON array of these objects (unlike /search, which wraps its results in an object), +// so the serializable root registered on the context is ApiFinnhubNewsDto[] — see below. All fields are +// nullable because Finnhub omits them for sparse articles (e.g. an empty `related`, a missing image); +// the provider coalesces as needed. +// +// 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 ApiFinnhubNewsDto( + [property: JsonPropertyName("category")] string? Category, // news category, e.g. "technology" + [property: JsonPropertyName("datetime")] long? Datetime, // published time, UNIX seconds + [property: JsonPropertyName("headline")] string? Headline, // article headline + [property: JsonPropertyName("id")] long? Id, // news id; reusable as minId for the next fetch + [property: JsonPropertyName("image")] string? Image, // thumbnail image URL + [property: JsonPropertyName("related")] string? Related, // related stocks/companies mentioned + [property: JsonPropertyName("source")] string? Source, // news source, e.g. "CNBC" + [property: JsonPropertyName("summary")] string? Summary, // article summary + [property: JsonPropertyName("url")] string? Url); // URL of the original article diff --git a/MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs b/MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs index b78a1fb..8297468 100644 --- a/MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs +++ b/MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs @@ -18,4 +18,5 @@ internal sealed record ApiFinnhubQuoteDto( [JsonSerializable(typeof(ApiFinnhubSearchDto))] // /search response — see ApiFinnhubSearchDto.cs [JsonSerializable(typeof(ApiFinnhubCandleDto))] // /stock/candle + /crypto/candle — see ApiFinnhubCandleDto.cs [JsonSerializable(typeof(ApiFinnhubProfileDto))] // /stock/profile2 (native currency) — see ApiFinnhubProfileDto.cs +[JsonSerializable(typeof(ApiFinnhubNewsDto[]))] // /news response (bare top-level array) — see ApiFinnhubNewsDto.cs internal sealed partial class FinnhubJsonContext : JsonSerializerContext; diff --git a/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs b/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs index 1bc2387..ac16b71 100644 --- a/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs +++ b/MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs @@ -34,6 +34,10 @@ internal sealed class FinnhubMarketDataProvider : IMarketDataProvider // 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; + // Finnhub is the (only) news source — it serves the /news market-news feed (GetNewsAsync). This opts + // the provider in past the interface's SupportsNews gate, which defaults false for every other source. + public bool SupportsNews => true; + public async Task> GetQuotesAsync( IReadOnlyList instruments, CancellationToken ct = default) { @@ -231,6 +235,76 @@ public async Task GetCandlesAsync( } } + // Latest market news for a category (Finnhub GET /news), the IMarketDataProvider.GetNewsAsync impl + // (gated by SupportsNews => true above). minId returns only items AFTER that news id (0 = the latest + // batch); pass the largest Id from a prior call to page forward without re-fetching. Returns + // DomainNews items in Finnhub's order (newest first). A no-key install or any failure (network / + // non-2xx / parse) degrades to an empty list rather than throwing. + public async Task> GetNewsAsync( + NewsCategory category, long minId = 0, CancellationToken ct = default) + { + if (!HasApiKey) + { + Log.Warn("Finnhub", "no API key set — skipping news (set one in extension settings)"); + return []; + } + + var categoryToken = ToFinnhubNewsCategory(category); + try + { + // NEVER log the full URL — it carries the API token. Log the category/minId only. + Log.Info("Finnhub", $"GET /news category={categoryToken} minId={minId}"); + using var response = await HttpRetry.SendAsync( + c => Http.GetAsync($"news?category={Uri.EscapeDataString(categoryToken)}&minId={minId}&token={ApiKey}", c), + "Finnhub", ct).ConfigureAwait(false); + + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + Log.Info("Finnhub", $"news {categoryToken} <- {(int)response.StatusCode} {response.StatusCode}"); + + if (!response.IsSuccessStatusCode) + { + // e.g. 429 rate-limited. Degrade to no news rather than throwing. + Log.Warn("Finnhub", $"news {categoryToken}: non-success status {(int)response.StatusCode} — no news"); + return []; + } + + // /news returns a bare top-level JSON array — deserialize to ApiFinnhubNewsDto[]. + var dtos = JsonSerializer.Deserialize(json, FinnhubJsonContext.Default.ApiFinnhubNewsDtoArray); + if (dtos is not { Length: > 0 }) + return []; + + // Drop items missing the essentials (id / headline / url): without them a row can't render or + // open. The survivors map directly (see ToDomainNews). + var news = dtos + .Where(d => d.Id is not null + && !string.IsNullOrWhiteSpace(d.Headline) + && !string.IsNullOrWhiteSpace(d.Url)) + .Select(ToDomainNews) + .ToList(); + + Log.Info("Finnhub", $"news {categoryToken} -> {news.Count} item(s)"); + return news; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Log.Error("Finnhub", $"news {categoryToken}: request failed", ex); + return []; + } + } + + // ApiFinnhubNewsDto -> DomainNews. Only called for items already filtered to carry id/headline/url, so + // those three map directly (non-null asserted); the rest coalesce to empty/null. datetime is UNIX seconds. + private static DomainNews ToDomainNews(ApiFinnhubNewsDto dto) => new( + Id: dto.Id!.Value, + Headline: dto.Headline!, + Source: dto.Source ?? string.Empty, + Summary: dto.Summary ?? string.Empty, + Category: dto.Category ?? string.Empty, + ArticleUrl: dto.Url!, + Published: DateTimeOffset.FromUnixTimeSeconds(dto.Datetime ?? 0), + ImageUrl: string.IsNullOrWhiteSpace(dto.Image) ? null : dto.Image, + Related: string.IsNullOrWhiteSpace(dto.Related) ? null : dto.Related); + private static DomainQuote Invalid(DomainInstrument i) => new(i.Symbol, i.Name, i.Category, 0m, 0m, 0m, IsValid: false); @@ -303,6 +377,18 @@ private static async Task GetStockCurrencyCodeAsync(string symbol, Cance _ => "D", }; + // Neutral NewsCategory -> Finnhub's /news category token. The news analog of ToFinnhubSymbol — the + // ONLY place this provider-specific token lives. A different news source maps the same NewsCategory + // to its own vocabulary. + private static string ToFinnhubNewsCategory(NewsCategory category) => category switch + { + NewsCategory.General => "general", + NewsCategory.Forex => "forex", + NewsCategory.Crypto => "crypto", + NewsCategory.Merger => "merger", + _ => "general", + }; + // 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 f1f0fe6..1d9089a 100644 --- a/MarketExtension/Data/IMarketDataProvider.cs +++ b/MarketExtension/Data/IMarketDataProvider.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -37,4 +38,22 @@ Task> GetQuotesAsync( Task GetCandlesAsync( DomainInstrument instrument, ChartRange range, CancellationToken ct = default) => Task.FromResult(DomainCandleSeries.Invalid(instrument.Symbol, range)); + + // Whether this provider serves market news (GetNewsAsync). News is a market-wide feed, not + // per-instrument data routed by Supports(AssetCategory), so it's a SEPARATE capability gate: a + // caller checks this and only asks a provider that returns true. Default false; a provider opts in + // by overriding BOTH this and GetNewsAsync (today only Finnhub). Mirrors the IsExclusive + // default-member pattern, so non-news providers (Twelve Data, Frankfurter, the mock) opt out for free. + bool SupportsNews => false; + + // Latest market news for a category (the most recent batch; pass a prior item's Id as minId to page + // forward — see DomainNews). Unlike GetCandlesAsync, the default does NOT soft-degrade to an empty + // list: news has the SupportsNews gate above, so reaching this default means a caller invoked it on a + // provider that doesn't serve news — a contract violation surfaced loudly (fail fast) rather than + // masked as "no news". Check SupportsNews first; a non-news provider throws here. + Task> GetNewsAsync( + NewsCategory category, long minId = 0, CancellationToken ct = default) + => throw new NotSupportedException( + $"{GetType().Name} does not serve market news (SupportsNews is false); " + + "check SupportsNews before calling GetNewsAsync."); } diff --git a/MarketExtension/Data/MockMarketDataProvider.cs b/MarketExtension/Data/MockMarketDataProvider.cs index 4671589..f40a9ad 100644 --- a/MarketExtension/Data/MockMarketDataProvider.cs +++ b/MarketExtension/Data/MockMarketDataProvider.cs @@ -188,6 +188,118 @@ private readonly record struct SeedQuote( ["USDMXN"] = new("US Dollar / Mexican Peso", AssetCategory.Currency), }; + private readonly record struct NewsTemplate(string Headline, string Summary, string? Related = null); + + // Synthetic market-news headlines backing Demo mode (one set per NewsCategory). Curated and evergreen + // — no dated specifics or hard numbers — so they read like a plausible feed on any day with no key or + // network. Identity-only like Catalog: the Id, Source, publish time and URL are synthesized per item at + // fetch time (see GetNewsAsync). Real, well-known outlets, but the article URLs point at example.com + // (the IANA-reserved demo domain) so a click opens something harmless and obviously not a real story. + private static readonly Dictionary NewsTemplates = new() + { + [NewsCategory.General] = + [ + new("Stocks edge higher as investors weigh latest economic data", + "Major indexes ticked up in light trading as traders parsed mixed signals on growth and inflation."), + new("Federal Reserve officials signal patience on interest-rate path", + "Policymakers reiterated a data-dependent stance, tempering bets on the timing of the next move."), + new("Tech shares lead gains as earnings season kicks off", + "Megacap technology names paced the advance ahead of a busy week of corporate results."), + new("Treasury yields slip as markets reassess rate-cut expectations", + "Bond prices firmed as softer data revived hopes for an earlier easing cycle."), + new("Oil prices steady amid supply concerns and demand outlook", + "Crude held in a narrow range as traders balanced production headlines against the global demand picture."), + new("Consumer spending holds firm despite a cooling labor market", + "Retail activity stayed resilient even as hiring showed signs of slowing."), + new("Wall Street closes mixed as the megacap rally loses steam", + "Breadth narrowed late in the session as investors rotated out of the year's biggest winners."), + new("Gold hovers near a record as investors seek safe havens", + "Bullion remained well bid amid geopolitical uncertainty and steady central-bank buying."), + new("Manufacturing activity rebounds, easing recession fears", + "A closely watched factory gauge returned to expansion, pointing to firmer industrial demand."), + new("Small-cap stocks outperform as rate-cut bets build", + "Smaller companies, more sensitive to borrowing costs, led the broader market higher."), + new("Volatility ticks up ahead of a key inflation report", + "Options markets priced in bigger swings as traders positioned for the next price reading."), + new("Dividend stocks draw inflows as yields stay elevated", + "Income-focused funds saw renewed demand with payouts competitive against cash."), + ], + [NewsCategory.Forex] = + [ + new("Dollar steadies as traders await central-bank guidance", + "The greenback consolidated recent gains ahead of a run of policy commentary."), + new("Euro firms against the dollar on upbeat eurozone data", + "The single currency gained ground as regional activity surveys beat expectations.", "EURUSD"), + new("Yen weakens past a key level, stoking intervention talk", + "Renewed pressure on the currency revived speculation about official action.", "USDJPY"), + new("Pound climbs as UK inflation cools more than expected", + "Sterling rallied as softer price data reshaped the rate outlook.", "GBPUSD"), + new("Swiss franc gains as risk sentiment sours", + "Haven demand lifted the franc during a bout of broader market caution.", "USDCHF"), + new("Emerging-market currencies under pressure from a strong dollar", + "A firmer greenback weighed on higher-yielding currencies across the complex."), + new("Aussie dollar rallies on firmer commodity prices", + "The currency tracked gains in key export markets higher.", "AUDUSD"), + new("Dollar index slips as rate-cut expectations grow", + "The benchmark gauge eased as traders leaned toward a softer policy path."), + new("Euro/dollar stays range-bound as traders eye ECB signals", + "The pair drifted in a tight band awaiting fresh policy cues.", "EURUSD"), + new("Loonie firms as oil prices steady", + "The Canadian dollar found support from a stable crude market.", "USDCAD"), + ], + [NewsCategory.Crypto] = + [ + new("Bitcoin holds above key support as traders eye the next move", + "The largest cryptocurrency steadied after a stretch of choppy trading.", "BTC"), + new("Ethereum gains as network activity picks up", + "Rising on-chain usage underpinned a modest advance in the second-largest token.", "ETH"), + new("Crypto market steadies after a week of heavy volatility", + "Digital assets found their footing as forced selling appeared to ease."), + new("Spot ETF inflows fuel renewed interest in digital assets", + "Steady fund demand drew fresh attention to the asset class.", "BTC"), + new("Solana climbs as developer activity accelerates", + "Growth in applications and users supported the token's gains.", "SOL"), + new("Stablecoin supply hits a new high amid market rotation", + "Sidelined capital parked in stablecoins as traders awaited clearer signals."), + new("Regulators weigh fresh guidance for crypto exchanges", + "Policymakers signaled a closer look at trading-venue oversight."), + new("Altcoins rally as risk appetite returns to crypto", + "Smaller tokens outpaced the majors during a broad risk-on session."), + new("Bitcoin mining difficulty rises to a record level", + "Growing competition among miners pushed network difficulty higher.", "BTC"), + new("DeFi tokens rebound as total value locked recovers", + "Decentralized-finance names gained as capital flowed back into protocols."), + ], + [NewsCategory.Merger] = + [ + new("Tech giant explores an acquisition to bolster AI ambitions", + "A leading platform is said to be weighing a deal to deepen its artificial-intelligence stack."), + new("Healthcare merger talks advance amid sector consolidation", + "Two players are reportedly in late-stage discussions as the industry scales up."), + new("Private-equity firm weighs a take-private bid for a software maker", + "Buyout interest has revived speculation about a deal for the enterprise vendor."), + new("Banking sector eyes consolidation as deal activity heats up", + "Lenders are exploring tie-ups to gain scale in a tougher operating environment."), + new("Chipmaker reportedly in talks to acquire a smaller rival", + "A deal would expand the buyer's product line in a fast-growing segment.", "NVDA,AMD"), + new("Energy companies discuss a multibillion-dollar tie-up", + "Talks point to further consolidation across the upstream sector."), + new("Media merger clears a key regulatory hurdle", + "Approval moves the long-pending combination closer to completion."), + new("Retailer agrees to buy a logistics startup to speed delivery", + "The bolt-on deal aims to shorten fulfillment times for online orders."), + new("Antitrust review delays a high-profile acquisition", + "Regulators extended their probe, pushing back the expected close."), + new("Activist investor pushes for a breakup of a conglomerate", + "The campaign argues a split would unlock value across the group's units."), + ], + }; + + // Outlets cycled deterministically per headline (a stable hash of the headline picks one), so the same + // synthetic story always shows the same source across refreshes. + private static readonly string[] NewsSources = + ["Reuters", "Bloomberg", "CNBC", "MarketWatch", "Financial Times", "The Wall Street Journal", "Barron's", "Yahoo Finance"]; + // 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. @@ -198,6 +310,12 @@ private readonly record struct SeedQuote( // exclusive, so the real providers serve as normal. public bool IsExclusive => MarketSettingsManager.Instance.DemoMode; + // Serve synthetic market news ONLY in Demo mode. Like Supports, this MUST be gated on DemoMode: the mock + // is registered first, so if it claimed news capability while live, a news caller picking the first + // SupportsNews provider would get fake headlines instead of Finnhub's real feed. Off → false, so the + // real news provider (Finnhub) is chosen; on → the mock is exclusive AND supports news, so it serves. + public bool SupportsNews => MarketSettingsManager.Instance.DemoMode; + public Task> SearchAsync(string query, CancellationToken ct = default) { // Self-gate on Demo mode: when ON the mock is exclusive, so the repository sends search to it alone; @@ -270,6 +388,40 @@ public Task GetCandlesAsync( return Task.FromResult(new DomainCandleSeries(instrument.Symbol, range, pts, IsValid: true)); } + // The IMarketDataProvider.GetNewsAsync impl (gated by SupportsNews above): synthesize the category's + // curated headline set into a plausible feed. Like the mock's quotes/candles it doesn't self-gate on + // DemoMode — the SupportsNews gate routes here only when demo is on. Newest first (item 0 most recent, + // largest Id); the Id is stable per (category, headline-index) so minId paging works, while publish + // times are relative to now so the feed always looks fresh. minId returns only items strictly newer + // (Id greater), matching Finnhub's cursor semantics; the default 0 returns the whole set. + public Task> GetNewsAsync( + NewsCategory category, long minId = 0, CancellationToken ct = default) + { + var templates = NewsTemplates[category]; + var now = DateTimeOffset.UtcNow; + // Per-category Id band (offset so categories never collide), newest item highest within the band. + var baseId = 7_000_000L + ((long)category * 1000L); + + IReadOnlyList news = + [ + .. templates.Select((t, i) => + { + var id = baseId + templates.Length - i; // i=0 (newest) → largest Id in the band + return new DomainNews( + Id: id, + Headline: t.Headline, + Source: NewsSources[(int)(Fnv1a(t.Headline) % (uint)NewsSources.Length)], + Summary: t.Summary, + Category: category.Label(), + ArticleUrl: $"https://example.com/markets/news/{id}", + Published: now - TimeSpan.FromMinutes((i * 43) + 5), + Related: t.Related); + }) + .Where(n => n.Id > minId), + ]; + return Task.FromResult(news); + } + // Build a stable, plausible quote for a non-seeded instrument. Native currency is inferred from the // symbol shape; price/change are derived from a stable hash of the symbol so they're identical across // refreshes AND process restarts (unlike string.GetHashCode, which is per-process randomized). diff --git a/MarketExtension/Models/DomainNews.cs b/MarketExtension/Models/DomainNews.cs new file mode 100644 index 0000000..49e52a1 --- /dev/null +++ b/MarketExtension/Models/DomainNews.cs @@ -0,0 +1,23 @@ +using System; + +namespace MarketExtension; + +// Domain layer: a provider-agnostic, presentation-free market-news article. This is what a news +// provider produces (mapped from its Api* DTO, e.g. ApiFinnhubNewsDto). No formatting lives here — a +// Ui* projection would own "2h ago" relative times, truncated summaries, etc. +// +// Like DomainInstrument (and unlike DomainQuote), there is no IsValid flag: a news feed is a list, so a +// provider simply drops items missing the essentials (id / headline / url) at its mapping boundary and +// returns the survivors. Id is Finnhub's news id, reusable as the `minId` cursor to fetch only newer +// items. Published is the article time as a proper instant (the Api datetime is UNIX seconds). ImageUrl +// (thumbnail) and Related (comma-separated tickers) are optional — many articles omit them. +internal sealed record DomainNews( + long Id, + string Headline, + string Source, + string Summary, + string Category, + string ArticleUrl, + DateTimeOffset Published, + string? ImageUrl = null, + string? Related = null); diff --git a/MarketExtension/Models/NewsCategory.cs b/MarketExtension/Models/NewsCategory.cs new file mode 100644 index 0000000..320bf64 --- /dev/null +++ b/MarketExtension/Models/NewsCategory.cs @@ -0,0 +1,35 @@ +namespace MarketExtension; + +// Provider-agnostic shared vocabulary (like AssetCategory / ChartRange): the topic of a market-news +// feed. It carries only neutral intent; the translation to a specific data source's category token +// lives in that provider (e.g. FinnhubMarketDataProvider.ToFinnhubNewsCategory), so a new news source +// plugs in without touching this. +internal enum NewsCategory +{ + General, + Forex, + Crypto, + Merger, +} + +internal static class NewsCategoryExtensions +{ + // The categories, in tab order. + public static readonly NewsCategory[] All = + [ + NewsCategory.General, + NewsCategory.Forex, + NewsCategory.Crypto, + NewsCategory.Merger, + ]; + + // Short, human-readable tab label. + public static string Label(this NewsCategory category) => category switch + { + NewsCategory.General => "General", + NewsCategory.Forex => "Forex", + NewsCategory.Crypto => "Crypto", + NewsCategory.Merger => "Mergers", + _ => "General", + }; +} From 1066203d2b3faa1a301d4a1b16c4620a05d7133c Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 27 Jun 2026 19:06:48 +0300 Subject: [PATCH 2/8] News work in progress --- MarketExtension/Data/INewsCacheDataSource.cs | 45 ++++++++++ .../Data/InMemoryNewsCacheDataSource.cs | 89 +++++++++++++++++++ MarketExtension/Data/NewsEntity.cs | 35 ++++++++ .../Properties/Resources.Designer.cs | 18 ++++ MarketExtension/Properties/Resources.de.resx | 71 +++++++++++++-- MarketExtension/Properties/Resources.es.resx | 6 ++ MarketExtension/Properties/Resources.hi.resx | 6 ++ MarketExtension/Properties/Resources.ja.resx | 6 ++ MarketExtension/Properties/Resources.ko.resx | 6 ++ .../Properties/Resources.pt-BR.resx | 6 ++ MarketExtension/Properties/Resources.resx | 6 ++ .../Properties/Resources.zh-Hans.resx | 6 ++ .../Settings/MarketSettingsManager.cs | 30 +++++++ 13 files changed, 325 insertions(+), 5 deletions(-) create mode 100644 MarketExtension/Data/INewsCacheDataSource.cs create mode 100644 MarketExtension/Data/InMemoryNewsCacheDataSource.cs create mode 100644 MarketExtension/Data/NewsEntity.cs diff --git a/MarketExtension/Data/INewsCacheDataSource.cs b/MarketExtension/Data/INewsCacheDataSource.cs new file mode 100644 index 0000000..be7c1f4 --- /dev/null +++ b/MarketExtension/Data/INewsCacheDataSource.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace MarketExtension; + +// A process-wide store of the latest market-news feed per NewsCategory, exposed as OBSERVABLE state every +// surface can subscribe to — so a news page (and any other news surface) reads the SAME cached feed and they +// can't drift apart. The news analog of IQuoteCacheDataSource: where the quote cache holds one QuoteEntity +// per symbol, this holds one LIST of NewsEntity per category (news is inherently a list). Keyed by +// NewsCategory. +// +// This is the news CACHE DATA SOURCE — one of the sources MarketRepository orchestrates over (alongside the +// IMarketDataProviders). It's an interface so the storage mechanism stays an implementation detail: the +// in-memory implementation can be swapped for a database-backed one later without touching MarketRepository +// or any UI surface. MarketRepository owns one instance, writes through to it on every news fetch, and news +// surfaces OBSERVE it instead of fetching independently. +// +// Data layer: holds NewsEntity (the storage model, no formatting), NOT DomainNews. MarketRepository maps +// NewsEntity <-> DomainNews at its boundary, so the entity never escapes the data source; surfaces still +// observe DomainNews off the repository and project it to a Ui* model as the quote surfaces do. +internal interface INewsCacheDataSource +{ + // Current cached feed for a category, or an empty list if it was never fetched / has been cleared. + // Synchronous snapshot read. + IReadOnlyList Get(NewsCategory category); + + // Per-category observable feed: replays the current value (empty until the first fetch lands) on + // subscribe, then pushes each change. Distinct-until-changed (an identical re-fetch does NOT re-emit — + // Upsert skips a value-equal write at the source). This is the seam a surface waits on for the first + // fetch: subscribe → empty (render a spinner/placeholder) → the feed when Upsert lands. A Clear() + // re-emits empty while keeping the subscription live. + IObservable> Observe(NewsCategory category); + + // Write a freshly fetched feed through to the cache for a category. keepLastGood:true (the default) + // drops a transient EMPTY result (e.g. a network error or rate-limit mapped to no items) when a + // non-empty feed is already cached — the news analog of the quote cache's keep-last-good (which guards a + // valid quote against a transient invalid one). keepLastGood:false overwrites unconditionally (a hard + // refresh / data-source flip). + void Upsert(NewsCategory category, IReadOnlyList news, bool keepLastGood = true); + + // Reset every category's feed to empty, KEEPING observer subscriptions live (so observers re-emit the + // empty "loading" state and a re-fetch refills). Used when the data SOURCE flips (demo mode) so news + // from the old source can neither linger nor be preserved by keep-last-good. + void Clear(); +} diff --git a/MarketExtension/Data/InMemoryNewsCacheDataSource.cs b/MarketExtension/Data/InMemoryNewsCacheDataSource.cs new file mode 100644 index 0000000..7939a0c --- /dev/null +++ b/MarketExtension/Data/InMemoryNewsCacheDataSource.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reactive.Linq; +using DynamicData; + +namespace MarketExtension; + +// In-memory INewsCacheDataSource backed by DynamicData's SourceCache — a +// reactive keyed cache (the .NET analog of Android Room's "@Query → Flow"). Each key is a NewsCategory; each +// value is that category's feed, a NewsFeedEntity wrapping the NewsEntity list. The wrapper exists ONLY so +// the list can be keyed by category inside the SourceCache — it never crosses the interface, which speaks +// NewsEntity lists. The single shared store of cached news; MarketRepository owns one instance for the whole +// process. +// +// Mirrors InMemoryQuoteCacheDataSource, and for the same reason: Upsert's keep-last-good (read current → +// decide → write) MUST be atomic, else two concurrent write-throughs (poll loop, observe-subscribe fetch, +// demo flip) could both read the same pre-write value and let a transient empty result land last, defeating +// the guard. SourceCache.Edit runs its lambda under the cache's lock, so the read-decide-write is atomic by +// construction — no app-level lock — and DynamicData fans the change out AFTER Edit returns. Absence is +// modelled as a removed key: Clear() removes all keys, and an Observe stream maps a Remove back to an empty +// list so subscribers see the "loading" state while staying subscribed. +[SuppressMessage("Reliability", "CA1001:Types that own disposable fields should be disposable", + Justification = "The SourceCache is owned by the single process-lifetime MarketRepository and is " + + "observed for the life of the process; it is intentionally never disposed (mirrors the " + + "InMemoryQuoteCacheDataSource / StateFlow singleton convention).")] +internal sealed class InMemoryNewsCacheDataSource : INewsCacheDataSource +{ + // One entry per category; only ever the four NewsCategory values, so no eviction is needed. + private readonly SourceCache _cache = new(f => f.Category); + + // A single shared empty instance returned for an absent/cleared category, so the "no feed yet" value has + // a stable reference — Get(), the Remove branch and StartWith all hand back the same object, which lets + // DistinctUntilChanged dedupe the empty case by reference. + private static readonly IReadOnlyList Empty = []; + + public IReadOnlyList Get(NewsCategory category) + { + var found = _cache.Lookup(category); + return found.HasValue ? found.Value.Items : Empty; + } + + public IObservable> Observe(NewsCategory category) + { + // Defer so the StartWith snapshot is read at SUBSCRIBE time, not when Observe is called. Connect() + // replays the cache's current state to each new subscriber, so Watch(category) emits the current feed + // on subscribe when the key is present; map a Remove (Clear) back to the empty feed; StartWith seeds + // the empty/absent case (and is deduped against Watch's replay by DistinctUntilChanged — they hand + // back the same Items reference). Net contract: empty until the first Upsert, then each distinct + // feed, empty again on Clear — subscription stays live. + return Observable.Defer(() => _cache.Connect() + .Watch(category) + .Select(change => change.Reason == ChangeReason.Remove ? Empty : change.Current.Items) + .StartWith(Get(category)) + .DistinctUntilChanged()); + } + + public void Upsert(NewsCategory category, IReadOnlyList news, bool keepLastGood = true) + { + // Edit runs ATOMICALLY under the cache's lock — the read (Lookup), the keep-last-good decision, and + // the write (AddOrUpdate) are one indivisible operation, so concurrent write-throughs can't clobber + // each other (this is the whole reason for the cache to "own" keep-last-good). + _cache.Edit(updater => + { + var current = updater.Lookup(category); + + // Keep-last-good: a transient empty result must not wipe a feed that was fine. + if (keepLastGood && news.Count == 0 && current is { HasValue: true, Value.Items.Count: > 0 }) + return; + + // Distinct-until-changed at the source: an identical re-fetch is a no-op (no change emitted), so + // an unchanged poll doesn't wake subscribers. NewsEntity is a value-equal record, so SequenceEqual + // compares element-by-element. + if (current.HasValue && current.Value.Items.SequenceEqual(news)) + return; + + updater.AddOrUpdate(new NewsFeedEntity(category, news)); + }); + } + + // Reset every category to empty while keeping observers subscribed: removing the keys makes each live + // Observe stream emit a Remove -> empty, and a later Upsert re-adds -> the stream emits the new feed. + public void Clear() => _cache.Clear(); + + // The keyed cache item: a category and its feed. Exists only so the NewsEntity list can be keyed by + // category inside the SourceCache; never crosses the interface boundary. + private sealed record NewsFeedEntity(NewsCategory Category, IReadOnlyList Items); +} diff --git a/MarketExtension/Data/NewsEntity.cs b/MarketExtension/Data/NewsEntity.cs new file mode 100644 index 0000000..47f2d79 --- /dev/null +++ b/MarketExtension/Data/NewsEntity.cs @@ -0,0 +1,35 @@ +using System; + +namespace MarketExtension; + +// Data layer (the news cache data source's storage model): a structural mirror of DomainNews that lives +// BELOW the repository. INewsCacheDataSource stores and emits NewsEntity, never DomainNews, so the storage +// model never escapes the data source — MarketRepository maps NewsEntity <-> DomainNews at its boundary +// (From on write-through, ToDomainNews on read). It's deliberately identical to DomainNews today; keeping it +// a separate type means the cache's stored shape can diverge from the domain model later (storage-only +// fields, a different on-disk representation for a DB-backed source) without touching the domain layer or +// any surface. See DomainNews for the field semantics. +// +// Data → domain is the allowed dependency direction, so this type may reference DomainNews for mapping; +// DomainNews must never reference NewsEntity. The mappers live here (like QuoteEntity.From) but are called +// only by the repository — "do the mapping at the boundary". +internal sealed record NewsEntity( + long Id, + string Headline, + string Source, + string Summary, + string Category, + string ArticleUrl, + DateTimeOffset Published, + string? ImageUrl = null, + string? Related = null) +{ + // Domain → storage. The single map-IN seam; the repository calls this on write-through. + public static NewsEntity From(DomainNews n) => + new(n.Id, n.Headline, n.Source, n.Summary, n.Category, n.ArticleUrl, n.Published, n.ImageUrl, n.Related); + + // Storage → domain. The single map-OUT seam; the repository calls this on read so the entity never + // escapes the data source. + public DomainNews ToDomainNews() => + new(Id, Headline, Source, Summary, Category, ArticleUrl, Published, ImageUrl, Related); +} diff --git a/MarketExtension/Properties/Resources.Designer.cs b/MarketExtension/Properties/Resources.Designer.cs index 2cb03b8..7c230c9 100644 --- a/MarketExtension/Properties/Resources.Designer.cs +++ b/MarketExtension/Properties/Resources.Designer.cs @@ -951,6 +951,24 @@ public static string Settings_Finnhub_Label { } } + /// + /// Looks up a localized string similar to News refresh interval in minutes (0 = off).. + /// + public static string Settings_NewsRefresh_Desc { + get { + return ResourceManager.GetString("Settings_NewsRefresh_Desc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to News refresh interval (minutes). + /// + public static string Settings_NewsRefresh_Label { + get { + return ResourceManager.GetString("Settings_NewsRefresh_Label", resourceCulture); + } + } + /// /// Looks up a localized string similar to Currency for portfolio totals. Other holdings are converted into it.. /// diff --git a/MarketExtension/Properties/Resources.de.resx b/MarketExtension/Properties/Resources.de.resx index 26ce202..f036c76 100644 --- a/MarketExtension/Properties/Resources.de.resx +++ b/MarketExtension/Properties/Resources.de.resx @@ -1,9 +1,64 @@ - + @@ -452,4 +507,10 @@ Südkoreanischer Won (KRW) - + + Nachrichtenaktualisierungsintervall (Minuten) + + + Nachrichtenaktualisierungsintervall in Minuten (0 = aus). + + \ No newline at end of file diff --git a/MarketExtension/Properties/Resources.es.resx b/MarketExtension/Properties/Resources.es.resx index 8d759e2..9ea329f 100644 --- a/MarketExtension/Properties/Resources.es.resx +++ b/MarketExtension/Properties/Resources.es.resx @@ -420,6 +420,12 @@ Intervalo de actualización de precios en minutos (0 = desactivado). + + Intervalo de actualización de noticias (minutos) + + + Intervalo de actualización de noticias en minutos (0 = desactivado). + Mostrar avisos de límite de solicitudes diff --git a/MarketExtension/Properties/Resources.hi.resx b/MarketExtension/Properties/Resources.hi.resx index 856b1ad..df1c8fb 100644 --- a/MarketExtension/Properties/Resources.hi.resx +++ b/MarketExtension/Properties/Resources.hi.resx @@ -374,6 +374,12 @@ कीमत रिफ़्रेश अंतराल मिनटों में (0 = बंद)। + + समाचार रिफ़्रेश अंतराल (मिनट) + + + समाचार रिफ़्रेश अंतराल मिनटों में (0 = बंद)। + दर-सीमा चेतावनियाँ दिखाएँ diff --git a/MarketExtension/Properties/Resources.ja.resx b/MarketExtension/Properties/Resources.ja.resx index 4ff89cb..b27a208 100644 --- a/MarketExtension/Properties/Resources.ja.resx +++ b/MarketExtension/Properties/Resources.ja.resx @@ -376,6 +376,12 @@ 価格の更新間隔(分単位、0 = オフ)。 + + ニュースの更新間隔(分) + + + ニュースの更新間隔(分単位、0 = オフ)。 + レート制限の警告を表示 diff --git a/MarketExtension/Properties/Resources.ko.resx b/MarketExtension/Properties/Resources.ko.resx index 212d6ac..e5bdffd 100644 --- a/MarketExtension/Properties/Resources.ko.resx +++ b/MarketExtension/Properties/Resources.ko.resx @@ -375,6 +375,12 @@ 가격 새로 고침 간격(분 단위, 0 = 끔). + + 뉴스 새로 고침 간격 (분) + + + 뉴스 새로 고침 간격(분 단위, 0 = 끔). + 요청 제한 경고 표시 diff --git a/MarketExtension/Properties/Resources.pt-BR.resx b/MarketExtension/Properties/Resources.pt-BR.resx index ad3d1a9..455ea31 100644 --- a/MarketExtension/Properties/Resources.pt-BR.resx +++ b/MarketExtension/Properties/Resources.pt-BR.resx @@ -374,6 +374,12 @@ Intervalo de atualização de preços em minutos (0 = desativado). + + Intervalo de atualização de notícias (minutos) + + + Intervalo de atualização de notícias em minutos (0 = desativado). + Mostrar avisos de limite de requisições diff --git a/MarketExtension/Properties/Resources.resx b/MarketExtension/Properties/Resources.resx index dd02f53..76c42f6 100644 --- a/MarketExtension/Properties/Resources.resx +++ b/MarketExtension/Properties/Resources.resx @@ -429,6 +429,12 @@ Price refresh interval in minutes (0 = off). + + News refresh interval (minutes) + + + News refresh interval in minutes (0 = off). + Show rate-limit warnings diff --git a/MarketExtension/Properties/Resources.zh-Hans.resx b/MarketExtension/Properties/Resources.zh-Hans.resx index 682ff00..d8aa0a9 100644 --- a/MarketExtension/Properties/Resources.zh-Hans.resx +++ b/MarketExtension/Properties/Resources.zh-Hans.resx @@ -429,6 +429,12 @@ 价格刷新间隔(分钟,0 = 关闭)。 + + 新闻刷新间隔(分钟) + + + 新闻刷新间隔(分钟,0 = 关闭)。 + 显示速率限制警告 diff --git a/MarketExtension/Settings/MarketSettingsManager.cs b/MarketExtension/Settings/MarketSettingsManager.cs index 4c1fd50..3ebac23 100644 --- a/MarketExtension/Settings/MarketSettingsManager.cs +++ b/MarketExtension/Settings/MarketSettingsManager.cs @@ -29,6 +29,11 @@ internal sealed class MarketSettingsManager : JsonSettingsManager // Default auto-refresh cadence when the field is blank/unparseable. private const int DefaultRefreshMinutes = 10; + // Default NEWS auto-refresh cadence when its field is blank/unparseable. Separate from (and gentler + // than) the price cadence above — headlines change far less often than quotes, and the news poll runs on + // its own clock so it isn't tied to how aggressively a user refreshes prices. + private const int DefaultNewsRefreshMinutes = 30; + private readonly TextSetting _twelveDataApiKey = new("twelveDataApiKey", string.Empty) { Label = Resources.Settings_TwelveData_Label, @@ -51,6 +56,16 @@ internal sealed class MarketSettingsManager : JsonSettingsManager Placeholder = DefaultRefreshMinutes.ToString(CultureInfo.InvariantCulture), }; + // The NEWS refresh interval — its own field, NOT reused from the price interval above (news polls on a + // separate, gentler clock; see DefaultNewsRefreshMinutes). + private readonly TextSetting _newsRefreshMinutes = new( + "newsRefreshMinutes", DefaultNewsRefreshMinutes.ToString(CultureInfo.InvariantCulture)) + { + Label = Resources.Settings_NewsRefresh_Label, + Description = Resources.Settings_NewsRefresh_Desc, + Placeholder = DefaultNewsRefreshMinutes.ToString(CultureInfo.InvariantCulture), + }; + private readonly ToggleSetting _showRateLimitErrors = new("showRateLimitErrors", true) { Label = Resources.Settings_RateLimit_Label, @@ -141,6 +156,20 @@ internal sealed class MarketSettingsManager : JsonSettingsManager // The cadence as a TimeSpan for the (upcoming) PeriodicTimer-driven poll loop. public TimeSpan RefreshInterval => TimeSpan.FromMinutes(RefreshMinutes); + // News auto-refresh cadence in minutes; 0 means off. Bad/negative input falls back to the default. + // Deliberately independent of RefreshMinutes — the news poll loop reads these, exactly as the quote poll + // loop reads the price ones, so the two refresh on their own clocks. + public int NewsRefreshMinutes => + int.TryParse(_newsRefreshMinutes.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) && v >= 0 + ? v + : DefaultNewsRefreshMinutes; + + // Whether the news poll loop should run at all (false when the user picked 0 = off). + public bool NewsAutoRefreshEnabled => NewsRefreshMinutes > 0; + + // The news cadence as a TimeSpan for the news poll loop. + public TimeSpan NewsRefreshInterval => TimeSpan.FromMinutes(NewsRefreshMinutes); + // 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). @@ -172,6 +201,7 @@ private MarketSettingsManager() Settings.Add(_twelveDataApiKey); Settings.Add(_finnhubApiKey); Settings.Add(_refreshMinutes); + Settings.Add(_newsRefreshMinutes); Settings.Add(_showRateLimitErrors); Settings.Add(_demoMode); Settings.Add(_portfolioCurrency); From 0fe68ff674a4f237b2889feb32633c22556ca685 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 27 Jun 2026 19:20:09 +0300 Subject: [PATCH 3/8] News work in progress --- MarketExtension/Data/MarketRepository.cs | 229 +++++++++++++++++++++-- MarketExtension/Helpers/PollTicker.cs | 91 +++++---- 2 files changed, 269 insertions(+), 51 deletions(-) diff --git a/MarketExtension/Data/MarketRepository.cs b/MarketExtension/Data/MarketRepository.cs index 80e0a07..47a6c4b 100644 --- a/MarketExtension/Data/MarketRepository.cs +++ b/MarketExtension/Data/MarketRepository.cs @@ -29,10 +29,18 @@ namespace MarketExtension; // observer through here — none fetch or poll themselves. The lone exception is the symbol-detail CHART, // a separate candle path (GetCandlesAsync) with no cache/observe layer: it still subscribes PollTicker // directly, which is fine since only one detail page is ever open (no cross-surface drift to fix). +// +// NEWS is orchestrated the same way (see the "News orchestration" section): the repository owns one shared +// INewsCacheDataSource, every news fetch writes through, and surfaces ObserveNews(category) instead of +// fetching — so a future news screen and news dock band read the SAME cached feed and stay in sync, exactly +// as the priced surfaces do for quotes. A SECOND poll loop (the news PollTicker, on the separate news +// cadence) refreshes the observed categories each tick. News isn't routed by asset class: it goes to the +// first active provider whose SupportsNews is true (Finnhub live; the mock in Demo mode). internal sealed class MarketRepository { private readonly IMarketDataProvider[] _providers; private readonly IQuoteCacheDataSource _cacheSource; + private readonly INewsCacheDataSource _newsCacheSource; // Refcounted registry of the instruments currently being observed via ObserveQuotes — keyed by // Normalize(symbol), each carrying the latest DomainInstrument (needed for provider routing) and a @@ -49,29 +57,47 @@ internal sealed class MarketRepository // so no eviction is needed. private readonly ConcurrentDictionary _lastFetchTicks = new(); - // Default: an in-memory cache. The call site in MarketExtensionCommandsProvider uses this. + // Refcounted registry of the news categories currently being observed via ObserveNews (the news analog of + // _observed). The news poll loop refreshes each registered category every tick; a category observed by two + // surfaces has count 2 and is fetched once per tick. + private readonly Lock _observedNewsLock = new(); + private readonly Dictionary _observedNews = []; + + // Per-category last news fetch-ATTEMPT time (Environment.TickCount64). The news analog of _lastFetchTicks; + // a subscribe reads it (NeedsNewsFetchOnSubscribe) to refresh only a stale-or-missing category's feed. + private readonly ConcurrentDictionary _lastNewsFetchTicks = new(); + + // Default: in-memory quote + news caches. The call site in MarketExtensionCommandsProvider uses this. public MarketRepository(params IMarketDataProvider[] providers) - : this(new InMemoryQuoteCacheDataSource(), providers) { } + : this(new InMemoryQuoteCacheDataSource(), new InMemoryNewsCacheDataSource(), providers) { } - // Injectable cache — for a future database-backed IQuoteCacheDataSource (and tests). No other code changes. - public MarketRepository(IQuoteCacheDataSource cacheSource, IMarketDataProvider[] providers) + // Injectable caches — for future database-backed IQuoteCacheDataSource / INewsCacheDataSource (and tests). + // No other code changes. + public MarketRepository( + IQuoteCacheDataSource cacheSource, INewsCacheDataSource newsCacheSource, IMarketDataProvider[] providers) { _cacheSource = cacheSource; + _newsCacheSource = newsCacheSource; _providers = providers; - // A demo-mode flip swaps the data SOURCE, so every cached price is now wrong (it came from the other - // source). Clear it, then refresh whatever is currently observed so visible surfaces repaint from the - // new source at once (a hidden surface refetches on its next open via ObserveQuotes' fetch-missing). - // replay:false — construction is a no-op (the cache is empty then). Subscribed before any surface; - // never disposed (process-lifetime). + // A demo-mode flip swaps the data SOURCE, so every cached price (and news feed) is now wrong (it came + // from the other source). Clear them, then refresh whatever is currently observed so visible surfaces + // repaint from the new source at once (a hidden surface refetches on its next open via the + // observe-subscribe fetch). replay:false — construction is a no-op (the caches are empty then). + // Subscribed before any surface; never disposed (process-lifetime). _ = MarketSettingsManager.Instance.DemoModeChanged.Subscribe(_ => OnDemoModeFlip(), replayOnSubscribe: false); - // The single poll loop. While the repository is alive (the process lifetime), each PollTicker tick - // refreshes the union of currently-observed instruments (RefreshObserved) in one batched fetch and - // writes the results back through the cache, so every observing surface repaints in sync. PollTicker - // honors the refresh-interval setting and idles when it's off (and an empty observed set is a no-op), - // so a permanent subscription is safe. Never disposed — process-lifetime, like the cache itself. + // The single price poll loop. While the repository is alive (the process lifetime), each PollTicker + // tick refreshes the union of currently-observed instruments (RefreshObserved) in one batched fetch + // and writes the results back through the cache, so every observing surface repaints in sync. + // PollTicker honors the refresh-interval setting and idles when it's off (and an empty observed set is + // a no-op), so a permanent subscription is safe. Never disposed — process-lifetime, like the cache. _ = PollTicker.Subscribe(RefreshObserved); + + // The NEWS poll loop — its own PollTicker subscription on the SEPARATE news cadence. Each tick + // refreshes the currently-observed news categories (RefreshObservedNews). Same lifetime/no-dispose + // rationale as the price loop above. + _ = PollTicker.SubscribeNews(RefreshObservedNews); } // The providers that should serve right now. When any provider declares itself exclusive (e.g. the @@ -264,9 +290,17 @@ private void OnDemoModeFlip() _cacheSource.Clear(); var observed = ObservedInstruments(); Log.Info("Repository", - $"demo mode flipped — cache cleared; refreshing {observed.Count} observed instrument(s) from the new source"); + $"demo mode flipped — quote cache cleared; refreshing {observed.Count} observed instrument(s) from the new source"); if (observed.Count > 0) _ = RefreshSafe(observed); + + // The news feed came from the other source too — clear it and refill the observed categories. + _newsCacheSource.Clear(); + var observedNews = ObservedNewsCategories(); + Log.Info("Repository", + $"demo mode flipped — news cache cleared; refreshing {observedNews.Count} observed categor(ies) from the new source"); + foreach (var category in observedNews) + _ = RefreshNewsSafe(category); } // Called when a surface subscribes to ObserveQuotes: ref up each instrument so the poll loop fetches it. @@ -351,4 +385,169 @@ public async Task GetCandlesAsync( $"candles {instrument.Symbol} {range} -> {(series.HasData ? $"{series.Points.Count} pts" : "no data")}"); return series; } + + // --- News orchestration (mirrors the quote orchestration above) ------------------------------------ + // + // News is a market-wide feed, not per-instrument data, so it isn't routed by Supports(AssetCategory): it + // goes to the first ACTIVE provider whose SupportsNews is true (Finnhub live; the mock in Demo mode). Each + // NewsCategory is cached + observed independently, so surfaces ObserveNews(category) instead of fetching — + // the future news screen and the news dock band then read the SAME cached feed and stay in sync, exactly + // as the priced surfaces do for quotes. The repository owns the single news poll loop (the SubscribeNews + // ticker, on the separate news cadence) refreshing the observed categories each tick. + + // The provider that serves news right now: the first ACTIVE one that supports it (Finnhub normally; the + // mock when Demo mode makes it exclusive). null only if no active provider serves news. + private IMarketDataProvider? NewsProvider() => ActiveProviders().FirstOrDefault(p => p.SupportsNews); + + // Fetch the latest news for a category from the news provider. No provider → empty. The raw data path + // with no cache side effects, used by RefreshNewsAsync. (minId 0 = the latest batch; the cache replaces + // the category's feed on write-through, so each refresh re-reads the head of the feed.) + private async Task> FetchNewsAsync(NewsCategory category, CancellationToken ct) + { + var provider = NewsProvider(); + if (provider is null) + { + Log.Info("Repository", $"news {category}: no active provider serves news"); + return []; + } + + var news = await provider.GetNewsAsync(category, minId: 0, ct).ConfigureAwait(false); + Log.Info("Repository", $"news {category} -> {news.Count} item(s)"); + return news; + } + + // Force a news fetch now; results reach observers through the news cache (the caller does not read the + // return). The single news fetch seam — the demo flip, the observe-subscribe fetch, the poll loop, and a + // surface's manual refresh all call it (via RefreshNewsSafe for the fire-and-forget paths). + // keepLastGood:false overwrites even with an empty result (e.g. after a source flip); the default keeps + // the last good feed through a transient empty fetch. + public async Task RefreshNewsAsync( + NewsCategory category, bool keepLastGood = true, CancellationToken ct = default) + { + var news = await FetchNewsAsync(category, ct).ConfigureAwait(false); + WriteThroughNews(category, news, keepLastGood); + } + + // Write a freshly fetched feed through to the news cache AND stamp the category's last fetch-attempt time + // (so a later subscribe can tell a stale feed from a fresh one — see NeedsNewsFetchOnSubscribe). Maps + // domain → storage at the boundary. The single news write-through path; called by RefreshNewsAsync. + private void WriteThroughNews(NewsCategory category, IReadOnlyList news, bool keepLastGood) + { + _newsCacheSource.Upsert(category, [.. news.Select(NewsEntity.From)], keepLastGood); + _lastNewsFetchTicks[category] = Environment.TickCount64; + } + + // Observe one category's news feed as a cache-backed list observable. Like ObserveQuotes it appends + // ObserveOn(TaskPoolScheduler) — the same DEADLOCK FIX: a cache write-through fans out synchronously, and + // without the hop a surface's RaiseItemsChanged (a blocking COM call into Command Palette's STA) could run + // while Rx still holds a producer-side gate, cycling the gate/STA lock order → hang. ObserveOn hands each + // emission to the scheduler, so surfaces are notified only AFTER the gate locks release. (Surfaces already + // expect off-host-thread notifications — the toolkit marshals RaiseItemsChanged.) + public IObservable> ObserveNews(NewsCategory category) + => ObserveNewsCore(category).ObserveOn(TaskPoolScheduler.Default); + + // Selection-aware: re-projects (Switch) whenever the chosen category changes — fetching the newly-selected + // category and dropping the previous one's observation for free. For a news screen with category tabs + // backed by a StateFlow. ObserveOn AFTER Switch so the surface is notified off the Switch gate too (Core + // is used here, not the public overload, so there's a single ObserveOn hop, after Switch). + public IObservable> ObserveNews(StateFlow category) + => category.AsObservable().Select(c => ObserveNewsCore(c)).Switch() + .ObserveOn(TaskPoolScheduler.Default); + + // The cache-backed feed observable WITHOUT the ObserveOn hop — the raw graph. On subscribe: register the + // category as observed (so the news poll loop keeps it fresh) and fetch if the cached feed is missing or + // gone stale (write-through, fire-and-forget; see NeedsNewsFetchOnSubscribe). Then map the cache's + // NewsEntity feed → DomainNews on each emission. On dispose: unregister. Observable.Create (not Defer) so + // the dispose hook can unregister. The public overloads above add ObserveOn so the host is never called + // under a producer-side gate (see that note). + private IObservable> ObserveNewsCore(NewsCategory category) + => Observable.Create>(observer => + { + RegisterNews(category); + if (NeedsNewsFetchOnSubscribe(category)) + { + Log.Info("Repository", $"observe news subscribe: refreshing {category}"); + _ = RefreshNewsSafe(category); // fire-and-forget; writes through → observers see it land + } + else + { + Log.Info("Repository", $"observe news subscribe: {category} fresh in cache — no fetch"); + } + + // The cache's per-category Observe replays the current feed (empty until the first fetch) then + // pushes each change; storage → domain at the boundary. + var inner = _newsCacheSource.Observe(category) + .Select(entities => (IReadOnlyList)entities.Select(e => e.ToDomainNews()).ToList()); + + return new CompositeDisposable( + inner.Subscribe(observer), + Disposable.Create(() => UnregisterNews(category))); + }); + + // RefreshNewsAsync with its exceptions swallowed + logged — for the fire-and-forget fetch + poll paths, + // where a provider throw would otherwise become an unobserved task exception. + private async Task RefreshNewsSafe(NewsCategory category) + { + try { await RefreshNewsAsync(category).ConfigureAwait(false); } + catch (Exception ex) { Log.Error("Repository", $"background news refresh failed for {category}", ex); } + } + + // Should this category be (re)fetched at subscribe time? Never-cached / empty → always (we must render + // something). Otherwise only when news auto-refresh is on AND the cached feed aged past one news interval + // while unobserved — the news analog of NeedsFetchOnSubscribe. With news auto-refresh off (interval 0) an + // already-cached feed is left as-is. + private bool NeedsNewsFetchOnSubscribe(NewsCategory category) + { + if (_newsCacheSource.Get(category).Count == 0) + return true; + + var settings = MarketSettingsManager.Instance; + if (!settings.NewsAutoRefreshEnabled) + return false; + + if (!_lastNewsFetchTicks.TryGetValue(category, out var ticks)) + return true; + + return TimeSpan.FromMilliseconds(Environment.TickCount64 - ticks) >= settings.NewsRefreshInterval; + } + + // One news poll tick: refresh every currently-observed category (one /news call each — news isn't + // batchable across categories like quotes are across symbols). Nothing observed → nothing to do. + private void RefreshObservedNews() + { + var categories = ObservedNewsCategories(); + if (categories.Count == 0) + return; + Log.Info("Repository", $"news poll tick: refreshing {categories.Count} observed categor(ies)"); + foreach (var category in categories) + _ = RefreshNewsSafe(category); + } + + // Ref up a category when a surface subscribes to ObserveNews, so the news poll loop keeps it fresh. + private void RegisterNews(NewsCategory category) + { + lock (_observedNewsLock) + _observedNews[category] = _observedNews.GetValueOrDefault(category) + 1; + } + + // Ref down a category when an ObserveNews subscription is disposed, dropping it once no surface observes it. + private void UnregisterNews(NewsCategory category) + { + lock (_observedNewsLock) + { + if (!_observedNews.TryGetValue(category, out var count)) + return; + if (--count <= 0) + _observedNews.Remove(category); + else + _observedNews[category] = count; + } + } + + // Snapshot of the distinct categories with at least one observer — what the news poll loop refreshes. + private IReadOnlyList ObservedNewsCategories() + { + lock (_observedNewsLock) + return [.. _observedNews.Keys]; + } } diff --git a/MarketExtension/Helpers/PollTicker.cs b/MarketExtension/Helpers/PollTicker.cs index 23b167b..eee874a 100644 --- a/MarketExtension/Helpers/PollTicker.cs +++ b/MarketExtension/Helpers/PollTicker.cs @@ -3,9 +3,16 @@ namespace MarketExtension; -// The live-price poll ticker — emits a tick WHILE at least one priced surface is subscribed and stops -// when the last one unsubscribes. Priced surfaces (PricedListPage, FavoritesDockPage, the SymbolDetailPage -// chart) subscribe and re-price their current set on each tick. +// The market-data poll tickers — each emits a tick WHILE at least one subscriber is active and stops when +// the last one unsubscribes. There are TWO, on independent cadences read from MarketSettingsManager: +// * the PRICE ticker (Subscribe) — driven by RefreshInterval / AutoRefreshEnabled. The repository's quote +// poll loop and the SymbolDetailPage chart subscribe to it. +// * the NEWS ticker (SubscribeNews) — driven by NewsRefreshInterval / NewsAutoRefreshEnabled, a SEPARATE +// (gentler) clock so news refreshes don't piggyback on how aggressively prices refresh. The repository's +// news poll loop subscribes to it. +// Both are built by the same BuildTicker, so the careful Rx lifecycle below is shared, parameterized only by +// which settings drive the cadence; each is refcounted independently (news polls only while a news surface +// is active, prices only while a priced surface is). // // Pure Rx (no longer a StateFlow): // * Observable.Generate drives a self-rescheduling timer whose per-step delay is re-read from @@ -18,36 +25,48 @@ namespace MarketExtension; // is therefore silent (no OnCompleted/OnError reaches the shared subject), so a later resubscribe // cleanly restarts the loop. Defer logs the start; Finally logs the stop, both at the refcount edges. // -// This replaces the hand-rolled StateFlow + CancellationTokenSource version: the tick-counter/dedup -// workaround, the manual subscriber-count refcount, the CTS lifecycle + lock, and the CA1001 suppression -// are all now Rx's job. (The tick value restarts from 0 on each active cycle — only ever used for a Debug -// log line, never read by subscribers.) +// Subscribe via Subscribe(onTick) / SubscribeNews(onTick): the handler is guarded so a synchronous throw +// can't escape into the shared stream. This matters because each 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 subscriber on that ticker (its polling dead +// until reload). The guard swallows + logs (Log.Error) instead. Handlers should still offload heavy work +// (all PollRefresh()s Task.Run), but they no longer have to be throw-proof. // -// Subscribe via PollTicker.Subscribe(onTick): the handler is guarded so a synchronous throw can't escape -// into the shared stream. This matters because the published stream is multicast — an unguarded throw -// would be caught by Rx's SafeObserver, which DISPOSES that subscription AND rethrows back through the -// Publish subject, terminating the stream for EVERY surface (polling dead until reload). The guard -// swallows + logs (Log.Error) instead. Handlers should still offload heavy work (all PollRefresh()s Task.Run), -// but they no longer have to be throw-proof. -// -// ⚠️ That guard covers the SUBSCRIBER side only. The SOURCE operators below (timeSelector, Where, iterate, -// Do) are NOT guarded: a throw in any of them OnErrors the Publish subject, poisoning it for every surface -// (polling dead until reload — the exact failure mode above). They are safe today because they only do -// non-throwing MarketSettingsManager property reads; keep them that way. Any new logic here that could -// throw (e.g. parsing a malformed setting) must guard itself or use .Catch/.Retry. +// ⚠️ That guard covers the SUBSCRIBER side only. The SOURCE operators in BuildTicker (timeSelector, Where, +// iterate, Do) are NOT guarded: a throw in any of them OnErrors the Publish subject, poisoning it for every +// subscriber (polling dead until reload — the exact failure mode above). They are safe today because they +// only do non-throwing MarketSettingsManager property reads; keep them that way. Any new logic here that +// could throw (e.g. parsing a malformed setting) must guard itself or use .Catch/.Retry. internal static class PollTicker { - // How long to idle before re-checking settings while auto-refresh is off, so the user can turn it on - // mid-view and have polling resume without reloading the extension. + // How long to idle before re-checking settings while a ticker's auto-refresh is off, so the user can + // turn it on mid-view and have polling resume without reloading the extension. private static readonly TimeSpan OffRecheckInterval = TimeSpan.FromSeconds(30); - // One process-wide ticker shared by every priced surface, so the timer lives while ANY of them is - // visible and goes quiet only when they all hide (a pinned dock band keeps it warm). Private so every - // subscription goes through the guarded Subscribe below — no surface can attach an unguarded handler. - private static readonly IObservable Ticks = - Observable.Defer(() => + // The price ticker — its cadence is the price RefreshInterval. Shared by every priced surface, so the + // timer lives while ANY of them is active and goes quiet only when they all stop (a pinned dock keeps it + // warm). Private so every subscription goes through the guarded Subscribe below. + private static readonly IObservable PriceTicks = + BuildTicker("Poll", s => s.AutoRefreshEnabled, s => s.RefreshInterval); + + // The news ticker — same machinery, on the SEPARATE news cadence (NewsRefreshInterval), refcounted + // independently, so news polls only while a news surface is active. + private static readonly IObservable NewsTicks = + BuildTicker("NewsPoll", s => s.NewsAutoRefreshEnabled, s => s.NewsRefreshInterval); + + // Run onTick on every PRICE poll tick while subscribed. The handler is wrapped so a throw is contained + // (swallowed + logged) and can't tear down the shared stream — see the type comment above. + public static IDisposable Subscribe(Action onTick) => SubscribeGuarded(PriceTicks, onTick, "Poll"); + + // Run onTick on every NEWS poll tick while subscribed (same guard as Subscribe, separate cadence). + public static IDisposable SubscribeNews(Action onTick) => SubscribeGuarded(NewsTicks, onTick, "NewsPoll"); + + // Build a guarded, refcounted ticker whose cadence is re-read live from settings via the two selectors. + private static IObservable BuildTicker( + string tag, Func enabled, Func interval) + => Observable.Defer(() => { - Log.Info("Poll", "Poll loop started — a priced surface became visible"); + Log.Info(tag, "Poll loop started — a subscriber became active"); return Observable .Generate( initialState: 0L, @@ -58,24 +77,24 @@ internal static class PollTicker { // 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; + return enabled(settings) ? interval(settings) : OffRecheckInterval; }) - .Where(_ => MarketSettingsManager.Instance.AutoRefreshEnabled) - .Do(tick => Log.Info("Poll", $"Tick #{tick} — signalling visible surfaces to refresh")); + .Where(_ => enabled(MarketSettingsManager.Instance)) + .Do(tick => Log.Info(tag, $"Tick #{tick} — signalling active subscribers to refresh")); }) - .Finally(() => Log.Info("Poll", "Poll loop stopped — all priced surfaces hidden")) + .Finally(() => Log.Info(tag, "Poll loop stopped — all subscribers inactive")) .Publish() .RefCount(); - // Run onTick on every poll tick while subscribed. The handler is wrapped so a throw is contained - // (swallowed + logged) and can't tear down the shared stream — see the type comment above. - public static IDisposable Subscribe(Action onTick) + // Subscribe with the throw-containing guard (see the type comment): a throwing handler is swallowed + + // logged so it can't tear down the shared multicast stream. + private static IDisposable SubscribeGuarded(IObservable ticks, Action onTick, string tag) { ArgumentNullException.ThrowIfNull(onTick); - return Ticks.Subscribe(_ => + return ticks.Subscribe(_ => { try { onTick(); } - catch (Exception ex) { Log.Error("Poll", "tick handler threw — continuing poll loop", ex); } + catch (Exception ex) { Log.Error(tag, "tick handler threw — continuing poll loop", ex); } }); } } From 98d4c4e7d9699294cf18c853910d864cd43d9882 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 27 Jun 2026 20:09:26 +0300 Subject: [PATCH 4/8] News work in progress --- CLAUDE.md | 124 ++++++++++++- MarketExtension/Data/MarketRepository.cs | 31 +++- .../MarketExtensionCommandsProvider.cs | 7 +- MarketExtension/Models/UiNews.cs | 49 +++++ MarketExtension/Pages/MarketsPage.cs | 9 + MarketExtension/Pages/NewsPage.cs | 175 ++++++++++++++++++ .../Properties/Resources.Designer.cs | 81 ++++++++ MarketExtension/Properties/Resources.de.resx | 39 +++- MarketExtension/Properties/Resources.es.resx | 27 +++ MarketExtension/Properties/Resources.hi.resx | 27 +++ MarketExtension/Properties/Resources.ja.resx | 27 +++ MarketExtension/Properties/Resources.ko.resx | 27 +++ .../Properties/Resources.pt-BR.resx | 27 +++ MarketExtension/Properties/Resources.resx | 27 +++ .../Properties/Resources.zh-Hans.resx | 27 +++ 15 files changed, 687 insertions(+), 17 deletions(-) create mode 100644 MarketExtension/Models/UiNews.cs create mode 100644 MarketExtension/Pages/NewsPage.cs diff --git a/CLAUDE.md b/CLAUDE.md index 20ee39b..8f2686a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,6 +116,14 @@ the same three layers and the same provider seam — see the "Symbol detail + li | `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. | +| `Data/Finnhub/ApiFinnhubNewsDto.cs` | **News Api layer**: the raw `/news` item DTO (one article). The `/news` response is a **bare top-level array**, so the registered root on `FinnhubJsonContext` is `ApiFinnhubNewsDto[]` (deserialize via `…Default.ApiFinnhubNewsDtoArray`). See "Market News (feed)". | +| `Models/DomainNews.cs` | **Domain news article** (provider-agnostic, no formatting): `Id` (reusable as the `minId` cursor), `Headline`, `Source`, `Summary`, `Category` (the article's OWN free-text category, e.g. "technology" — **NOT** the `NewsCategory` enum), `ArticleUrl`, `Published` (`DateTimeOffset`), optional `ImageUrl`/`Related`. Like `DomainInstrument`, **no `IsValid`** — the provider drops bad items (missing id/headline/url) at its boundary. | +| `Models/NewsCategory.cs` | The **feed-selector enum** (General/Forex/Crypto/Merger) + `NewsCategoryExtensions` (`All`, `Label()`). Unprefixed **shared vocabulary** like `AssetCategory`/`ChartRange` (NOT a `Domain*` model — it classifies/parameterizes operations, it isn't data carried through the layers). | +| `Models/UiNews.cs` | **Ui projection** of `DomainNews` (the ONLY place news formatting lives): `FormatSubtitle()` ("CNBC · 2h ago"), `FormatRelativeTime()`, `Matches(query)`. Toolkit-free — the page builds `IconInfo`/`Details` from these strings (like `UiQuote`). | +| `Data/NewsEntity.cs` | The news cache's **`*Entity` storage model** — a structural mirror of `DomainNews` BELOW the repository; the repo maps `DomainNews ⇄ NewsEntity` at its boundary (`From`/`ToDomainNews`), so the entity never escapes the data source. | +| `Data/INewsCacheDataSource.cs` | The **shared observable news cache** abstraction — the news analog of `IQuoteCacheDataSource`, but keyed by **`NewsCategory`** with a **LIST** of `NewsEntity` per category (news is inherently a list, vs one quote per symbol). `Get`/`Observe`/`Upsert(keepLastGood)`/`Clear`. **keep-last-good here = a transient EMPTY result won't wipe a non-empty cached feed.** | +| `Data/InMemoryNewsCacheDataSource.cs` | The live in-memory impl — DynamicData `SourceCache` (a **private** per-category list wrapper that never crosses the interface). Atomic `Edit` for keep-last-good; `Observe` maps Remove→empty; an identical refetch is deduped via `SequenceEqual`. | +| `Pages/NewsPage.cs` | The **Markets → News** screen (a hub row): a `DynamicListPage` **pure observer** of `MarketRepository.ObserveNews(selection flow)`. Category dropdown (toolkit `Filters`): **"All"** (merged, default) + General/Forex/Crypto/Mergers; Enter opens the article (`OpenUrlCommand`); summary in the `Details` pane; typed search filters headlines client-side. See "Market News (feed)". | **To add a provider** (e.g. forex): implement `IMarketDataProvider` (`Supports`, `GetQuotesAsync`, and `SearchAsync` — return `[]` if it can't search; optionally **override `GetCandlesAsync`** to serve @@ -235,7 +243,24 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context ## Current Status / Next Steps -- **Done (latest): re-backed the quote cache on DynamicData's `SourceCache` — fixes the keep-last-good race for +- **Done (latest): Market News feature (MVP — built end-to-end, NOT yet live-verified).** A **Markets → News** + hub screen lists market-news headlines (each row opens the source article in the browser), built behind the + SAME layered seams as quotes with a parallel cache + repository-orchestration story. The pieces: a Finnhub + `/news` provider method (`GetNewsAsync`) behind a new **`SupportsNews`** capability on `IMarketDataProvider` + whose default body **THROWS** (so non-news providers fail loud — unlike candles' soft opt-out); a + `DomainNews`/`NewsCategory`/`UiNews` model trio + `ApiFinnhubNewsDto`; a **shared observable news cache** + (`INewsCacheDataSource`/`InMemoryNewsCacheDataSource`, keyed by **`NewsCategory`** with a **list per + category**, DynamicData-backed, keep-last-good = **no-empty-overwrite**); **`MarketRepository` news + orchestration** — `ObserveNews(NewsCategory)` and `ObserveNews(StateFlow)` with **`null` = the + merged "All" view** (`CombineLatest` the 4 categories → dedupe by id → newest-first), a **second poll loop** + on a **separate `News refresh interval` setting** (default 30 min; `PollTicker` was generalized into two + refcounted tickers), stale-on-subscribe, and demo-flip refill. `NewsPage` is a `DynamicListPage` pure cache + observer with a category **`Filters` dropdown** ("All" default). **Demo mode** serves curated synthetic + headlines per category; the mock's `SupportsNews` is gated on `DemoMode`. Reached from the **Markets hub** (a + "News" row), not a separate top-level command. Build clean (0 warnings); resx strings translated to all 7 + locales. ⚠️ **NOT yet live-verified on-device.** See **"Market News (feed)"** for the full design + the + **"Left to do"** list to finish it. +- **Done (previous): re-backed the quote cache on DynamicData's `SourceCache` — fixes the keep-last-good race for free.** `InMemoryQuoteCacheDataSource` is now a thin wrapper over `DynamicData`'s `SourceCache` (a reactive keyed cache, the .NET analog of Room's `@Query → Flow`) instead of the hand-rolled `Lock` + per-symbol `MutableStateFlow` dictionary. **Why:** the hand-rolled `Upsert` read the current value @@ -1272,6 +1297,103 @@ now wired — Elbstream `/logos/country/{iso2}` keyed off the base currency. **P and it inherits the Elbstream attribution row from `PricedListPage.GetItems`; the totals summary row and the Portfolio dock band keep the Bank glyph on purpose, since those are rolled-up summaries, not instruments.) +### Market News (feed) — MVP DONE (not yet live-verified); see "Left to do" + +A **Markets → News** screen (a row in the Markets hub, **not** a separate top-level command) lists market-news +headlines, each opening the source article in the browser. News flows through the SAME layered seams as quotes, +with a parallel cache + repository-orchestration story, so a future news **dock band** can observe the same +cache and stay in sync with the screen (exactly how the quote surfaces share the quote cache). + +**The layered flow** (mirrors the quote flow): +``` +ApiFinnhubNewsDto[] ─(provider maps)→ DomainNews ─(repo routes+caches)→ IReadOnlyList + └─(page maps via UiNews.From)→ UiNews (rendered) +``` + +**The provider seam.** `IMarketDataProvider` gained **`bool SupportsNews => false`** (a capability gate, NOT +asset-class routing — news is a market-wide feed) + **`GetNewsAsync(NewsCategory, minId, ct)`** whose default +body **THROWS `NotSupportedException`** (unlike `GetCandlesAsync`'s soft invalid-series default): news has the +`SupportsNews` gate, so reaching the default means a caller skipped the check — fail loud rather than silently +return nothing. `FinnhubMarketDataProvider` opts in (`SupportsNews => true`; `GetNewsAsync` calls `/news` via +`HttpRetry`, maps `ApiFinnhubNewsDto[]` → `DomainNews`, dropping items missing id/headline/url). `Finnhub +specifics`: `GET /news?category=&minId=&token=` returns a **bare top-level array**; `category` ∈ +`general/forex/crypto/merger` (the only place that token lives is `ToFinnhubNewsCategory`); `minId` returns only +items newer than that news id (0 = latest batch). `MockMarketDataProvider` opts in **only in Demo mode** +(`SupportsNews => DemoMode`, mirroring `Supports`) and serves a curated, evergreen synthetic headline set per +category (`NewsTemplates`, stable ids + relative timestamps + a deterministic source per headline). Twelve Data +/ Frankfurter inherit the throwing default (they don't serve news). + +**The news cache** (`INewsCacheDataSource` / `InMemoryNewsCacheDataSource`). The news analog of the quote cache, +but keyed by **`NewsCategory`** with a **LIST** of `NewsEntity` per category (news is inherently a list, vs one +quote per symbol). DynamicData `SourceCache` (the wrapper record never crosses the +interface) with atomic `Edit`; **keep-last-good = a transient EMPTY fetch won't wipe a non-empty cached feed** +(the news analog of "an invalid quote won't wipe a valid one"). Stores `NewsEntity` (the `*Entity` storage +model); the repo maps `DomainNews ⇄ NewsEntity` at its boundary. + +**Repository orchestration** (`MarketRepository`, "News orchestration" section): +- **Routing:** news goes to the first ACTIVE provider whose `SupportsNews` is true (`NewsProvider()`) — Finnhub + live, the mock in Demo mode. NOT asset-class routed (single provider, unlike the quote per-category routing / + the search fan-out). +- **`ObserveNews(NewsCategory)`** (fixed — for a future dock band) and **`ObserveNews(StateFlow)`** + (selection-aware, `Switch` on change). Both append **`.ObserveOn(TaskPoolScheduler)`** — the SAME deadlock seam + as `ObserveQuotes` (a cache write-through fans out synchronously; without the hop a surface's + `RaiseItemsChanged` COM call could run under the `CombineLatest`/`Switch` gate and cycle the gate/STA lock). +- **The merged "All" view** is modeled as the **`NewsCategory?` selection where `null` = All** — + `ObserveAllNewsCore` `CombineLatest`s the 4 per-category cores (so each registers + fetches), then flattens, + **dedupes by news `Id`** (the same article can surface in >1 feed), and **sorts newest-first**. Modeling All as + `null` keeps the enum + cache keyed by REAL categories only; "All" is a pure read-time merge. +- **Second poll loop:** the repo holds a SECOND `PollTicker.SubscribeNews(RefreshObservedNews)` subscription on + the **separate news cadence** (each tick refreshes the currently-observed categories, one `/news` call each — + so "All" fans out to 4 calls/tick while it's the selected view). +- **Stale-on-subscribe** (`NeedsNewsFetchOnSubscribe`) + **demo-flip** (`_newsCacheSource.Clear()` then refill + observed categories) — the news analogs of the quote versions. + +**The poll ticker** (`PollTicker`) was generalized from one price ticker into a parameterized `BuildTicker`, +exposing **two** independent, refcounted tickers: `Subscribe` (price, `RefreshInterval`) and **`SubscribeNews`** +(news, `NewsRefreshInterval`) — so news polls on its own gentler clock and only while a news surface is active. +The guard, off-recheck idle, and `Publish().RefCount()` lifecycle are shared. + +**The setting.** A **separate** `News refresh interval (minutes)` setting +(`MarketSettingsManager.NewsRefreshMinutes` / `NewsRefreshInterval` / `NewsAutoRefreshEnabled`, default **30**, +0 = off) — deliberately NOT reusing the price `RefreshInterval` (headlines change less often). Read pull-style by +the news ticker each iteration. + +**The screen** (`NewsPage`). A `DynamicListPage` **pure observer** (like the dock bands): while visible it +subscribes to `ObserveNews(_category)` and renders what it emits — no fetch/poll itself. A toolkit **`Filters` +dropdown** switches the view: **"All"** (merged, the default — a sentinel filter id) + General/Forex/Crypto/ +Mergers; the selection drives a `MutableStateFlow` (null = All) that the `ObserveNews` overload +`Switch`es on. Each row: headline title, "source · 2h ago" subtitle, thumbnail icon (or a glyph), summary in the +`Details` pane; **Enter opens the article** (`OpenUrlCommand`). Typed search filters the current feed's headlines +client-side. New UI strings are in the resx (all 7 locales). + +**Left to do (to complete the feature):** +1. **Live-verify on-device** — the whole path compiles but is unverified. Check via **Demo mode** (curated + synthetic headlines per category; "All" merges + dedupes them) and a **real Finnhub key** (live `/news`): + category switching, article open, the separate news refresh interval, demo-flip. +2. **No-key / empty-state UX** — with no Finnhub key (live), `/news` returns `[]` and the page treats an empty + feed as "still loading" → a **perpetual spinner**. Surface a status row instead (reuse + `ApiKeyHint.StatusRow()` and/or an explicit empty-state row), and disambiguate "loading" from + "loaded-but-empty" (the page currently can't, unlike the priced pages which use the membership count). +3. **News dock band** — the repo already exposes the fixed **`ObserveNews(NewsCategory)`** for exactly this: a + dock band showing one category's latest headline(s), the "screen + dock in sync" payoff. Model it on + `FavoritesDockPage` (pure observer, `INotifyItemsChanged` visible-lifecycle, its own non-empty `Command.Id`). +4. **Rate-limit banner on the news page** — the "All" view fans out 4 `/news` calls per tick; the page doesn't + render `RateLimitHint` (the priced pages + Search do). keep-last-good already protects the feed, but the user + isn't told *why* it looks stale. +5. **Glyph check** — verify `NewsGlyph` (`MarketsPage`, the hub row) and `ArticleGlyph` (`NewsPage`, the + thumbnail-less row icon) render the intended Segoe MDL2 icons. (⚠️ writing `\uXXXX` through the edit tooling + kept dropping the glyph — set/verify these by hand; see "Write tool mangles glyph icons".) +6. **minId incremental paging (optimization)** — every refresh fetches `minId:0` (the full latest batch) and + replaces the cached feed; the `DomainNews.Id` / `minId` cursor exists but isn't used to fetch only newer items + and merge. Cheap follow-up if `/news` volume matters. +7. **Multi-provider news (deferred design)** — single-source today (`NewsProvider()` = first `SupportsNews`). + Swap/primary works as-is; FALLBACK needs gating Finnhub's `SupportsNews` on its key (it's unconditionally + `true` today); MERGE needs a fan-out in `FetchNewsAsync` (reuse `ObserveAllNewsCore`'s dedupe + `SearchAsync`'s + fan-out shape); PER-CATEGORY routing needs the seam to gain per-category granularity (`SupportsNews(category)` + or a supported-set). The cache/observe/UI layers stay provider-agnostic regardless. +8. **Locale translations** were added for all 7 locales but are machine-quality — worth a native review (esp. the + compact relative-time forms). + ## CommandPalette Toolkit — Quick Reference ### Navigation diff --git a/MarketExtension/Data/MarketRepository.cs b/MarketExtension/Data/MarketRepository.cs index 47a6c4b..9904c96 100644 --- a/MarketExtension/Data/MarketRepository.cs +++ b/MarketExtension/Data/MarketRepository.cs @@ -446,12 +446,16 @@ private void WriteThroughNews(NewsCategory category, IReadOnlyList n public IObservable> ObserveNews(NewsCategory category) => ObserveNewsCore(category).ObserveOn(TaskPoolScheduler.Default); - // Selection-aware: re-projects (Switch) whenever the chosen category changes — fetching the newly-selected - // category and dropping the previous one's observation for free. For a news screen with category tabs - // backed by a StateFlow. ObserveOn AFTER Switch so the surface is notified off the Switch gate too (Core - // is used here, not the public overload, so there's a single ObserveOn hop, after Switch). - public IObservable> ObserveNews(StateFlow category) - => category.AsObservable().Select(c => ObserveNewsCore(c)).Switch() + // Selection-aware: re-projects (Switch) whenever the chosen view changes. The selection is a NewsCategory? + // where null means "All" — the merged feed across every category (see ObserveAllNewsCore) — and a concrete + // value observes just that category. For a news screen whose category dropdown is backed by a StateFlow: + // switching fetches the newly-selected view and drops the previous one's observation for free. ObserveOn + // AFTER Switch so the surface is notified off the Switch gate too (Core is used here, not the public + // overloads, so there's a single ObserveOn hop, after Switch). + public IObservable> ObserveNews(StateFlow selection) + => selection.AsObservable() + .Select(sel => sel is { } category ? ObserveNewsCore(category) : ObserveAllNewsCore()) + .Switch() .ObserveOn(TaskPoolScheduler.Default); // The cache-backed feed observable WITHOUT the ObserveOn hop — the raw graph. On subscribe: register the @@ -484,6 +488,21 @@ private IObservable> ObserveNewsCore(NewsCategory cate Disposable.Create(() => UnregisterNews(category))); }); + // The "All" view: the raw merged feed across EVERY category, WITHOUT the ObserveOn hop. CombineLatest the + // per-category cores (so each one registers its category as observed AND fetches it if missing/stale, + // exactly as a single-category observe does — "All" therefore keeps all four categories fresh on the poll + // loop while it's the selected view), then flatten, dedupe by news Id (the same article can surface in more + // than one category feed), and sort newest-first. Re-emits whenever any category's cached feed changes; the + // selection overload adds ObserveOn so the host is never called under the CombineLatest gate. + private IObservable> ObserveAllNewsCore() + => Observable.CombineLatest(NewsCategoryExtensions.All.Select(ObserveNewsCore)) + .Select(feeds => (IReadOnlyList)feeds + .SelectMany(feed => feed) + .GroupBy(item => item.Id) + .Select(group => group.First()) + .OrderByDescending(item => item.Published) + .ToList()); + // RefreshNewsAsync with its exceptions swallowed + logged — for the fire-and-forget fetch + poll paths, // where a provider throw would otherwise become an unobserved task exception. private async Task RefreshNewsSafe(NewsCategory category) diff --git a/MarketExtension/MarketExtensionCommandsProvider.cs b/MarketExtension/MarketExtensionCommandsProvider.cs index d39dff6..08e4df6 100644 --- a/MarketExtension/MarketExtensionCommandsProvider.cs +++ b/MarketExtension/MarketExtensionCommandsProvider.cs @@ -32,10 +32,9 @@ public MarketExtensionCommandsProvider() // in the Command Palette Settings UI. See Settings/MarketSettingsManager.cs. Settings = MarketSettingsManager.Instance.Settings; - // A single top-level "Markets" command that opens the MarketsPage hub. From there the user - // funnels into the three screens — Search, Watchlist, Favorites — which are otherwise - // unchanged. This keeps the Command Palette root to one entry instead of three. Favorites - // also feed the dock band below. + // A single top-level "Markets" command that opens the MarketsPage hub. From there the user funnels into + // the screens — Search / Watchlist / Favorites / Portfolio / News — keeping the Command Palette root to + // one entry. Favorites also feed the dock band below. _commands = [ new CommandItem(new MarketsPage(_repository)) { Title = Resources.Command_Markets }, ]; diff --git a/MarketExtension/Models/UiNews.cs b/MarketExtension/Models/UiNews.cs new file mode 100644 index 0000000..47b6fb3 --- /dev/null +++ b/MarketExtension/Models/UiNews.cs @@ -0,0 +1,49 @@ +using System; +using MarketExtension.Properties; + +namespace MarketExtension; + +// UI layer: the presentation projection of a DomainNews. This is the ONLY place news formatting lives +// (relative "2h ago" times, the source · time subtitle), so the Domain and Api layers stay free of UI +// concerns. Deliberately holds NO toolkit types (IconInfo/Details) — the page builds those from these +// strings, exactly as the priced surfaces build IconInfo from UiQuote. Pages render UiNews and filter on the +// pass-through members. +internal sealed record UiNews(DomainNews Source) +{ + public static UiNews From(DomainNews n) => new(n); + + public string Headline => Source.Headline; + public string SourceName => Source.Source; + public string Summary => Source.Summary; + public string ArticleUrl => Source.ArticleUrl; + public string? ImageUrl => Source.ImageUrl; + + // The row subtitle: "CNBC · 2h ago" (the source is dropped if unknown). "·" is punctuation, not + // localizable text, so it stays inline like UiQuote's ▲/▼ glyphs. + public string FormatSubtitle() => string.IsNullOrWhiteSpace(SourceName) + ? FormatRelativeTime() + : $"{SourceName} · {FormatRelativeTime()}"; + + // Coarse relative age — "just now" / "5m ago" / "2h ago" / "3d ago". Compared against UTC now; a future + // timestamp (clock skew) clamps to "just now" rather than rendering a negative age. + public string FormatRelativeTime() + { + var delta = DateTimeOffset.UtcNow - Source.Published; + if (delta < TimeSpan.Zero) + delta = TimeSpan.Zero; + + if (delta.TotalMinutes < 1) + return Resources.News_Time_JustNow; + if (delta.TotalHours < 1) + return Strings.Format(Resources.News_Time_MinutesAgo, (int)delta.TotalMinutes); + if (delta.TotalDays < 1) + return Strings.Format(Resources.News_Time_HoursAgo, (int)delta.TotalHours); + return Strings.Format(Resources.News_Time_DaysAgo, (int)delta.TotalDays); + } + + // Client-side filter for the page's typed search box: match the query against headline / source / summary. + public bool Matches(string query) => + Headline.Contains(query, StringComparison.OrdinalIgnoreCase) + || SourceName.Contains(query, StringComparison.OrdinalIgnoreCase) + || Summary.Contains(query, StringComparison.OrdinalIgnoreCase); +} diff --git a/MarketExtension/Pages/MarketsPage.cs b/MarketExtension/Pages/MarketsPage.cs index 3d51556..30eed75 100644 --- a/MarketExtension/Pages/MarketsPage.cs +++ b/MarketExtension/Pages/MarketsPage.cs @@ -21,10 +21,13 @@ internal sealed partial class MarketsPage : ListPage, INotifyItemsChanged private const string InfoGlyph = "\uE9F9"; // Segoe MDL2 ReportDocument private const string SettingsGlyph = "\uE713"; // Segoe MDL2 Setting + private const string NewsGlyph = "\uE789"; // Segoe MDL2 News (verify/adjust glyph) + private readonly SearchPage _searchPage; private readonly WatchlistPage _watchlistPage; private readonly FavoritesPage _favoritesPage; private readonly PortfolioPage _portfolioPage; + private readonly NewsPage _newsPage; private readonly DataSourcesPage _dataSourcesPage; private readonly IContentPage _settingsPage; @@ -70,6 +73,7 @@ public MarketsPage(MarketRepository repository) _watchlistPage = new WatchlistPage(repository); _favoritesPage = new FavoritesPage(repository); _portfolioPage = new PortfolioPage(repository); + _newsPage = new NewsPage(repository); _dataSourcesPage = new DataSourcesPage(); // The toolkit builds a navigable settings page straight from our settings (Finnhub API key + @@ -103,6 +107,11 @@ public override IListItem[] GetItems() Title = Resources.Nav_Portfolio_Title, Icon = new IconInfo(PortfolioGlyph), }, + new ListItem(_newsPage) + { + Title = Resources.Nav_News_Title, + Icon = new IconInfo(NewsGlyph), + }, new ListItem(_dataSourcesPage) { Title = Resources.Nav_DataSources_Title, diff --git a/MarketExtension/Pages/NewsPage.cs b/MarketExtension/Pages/NewsPage.cs new file mode 100644 index 0000000..9a0386a --- /dev/null +++ b/MarketExtension/Pages/NewsPage.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; +using MarketExtension.Properties; + +namespace MarketExtension; + +// Top-level "Markets - News" command: a list of market-news headlines for the selected view, each row opening +// the original article in the browser (OpenUrlCommand). A dropdown (the toolkit Filters) switches between +// "All" (the merged feed across every category, the default) and the individual feeds General / Forex / +// Crypto / Mergers; typing in the search box filters the current feed's headlines client-side (no network). +// +// A PURE OBSERVER of the shared news cache, like the dock bands are of the quote cache: while visible it +// subscribes to MarketRepository.ObserveNews(selected-category flow) and renders whatever it emits. It does +// NOT fetch or poll itself — the repository owns that (one news poll loop on the separate news cadence, plus +// demo-flip refills) — so this page and a future news dock band reading the same category can never drift +// apart. Switching the category filter pushes the new category into _category, and the ObserveNews StateFlow +// overload's Switch re-projects to that category's cache stream. +// +// Threading: ObserveNews delivers via ObserveOn (see MarketRepository), so OnNewsChanged — and therefore +// RaiseItemsChanged — runs on a pool thread with NO Rx gate lock held. That is the deadlock-safe pattern the +// dock bands use; do not wrap the subscribe in Task.Run or add ObserveOn here. +internal sealed partial class NewsPage : DynamicListPage, INotifyItemsChanged +{ + // Segoe MDL2 ReportDocument — the row icon when an article carries no thumbnail image. A C# \u escape + // (not a literal glyph char) so the Write tool can't drop it. + private const string ArticleGlyph = "\uE9F9"; + + private readonly MarketRepository _repository; + private readonly NewsCategoryFilters _filters = new(); + + // The selected view, driven by the Filters dropdown: null = "All" (the merged feed across every category, + // the default), else a single category. ObserveNews(StateFlow) re-projects (Switch) when this changes, so + // flipping the filter swaps the observed feed without re-subscribing the page. + private readonly MutableStateFlow _category = new(null); + + private UiNews[]? _news; // latest cache emission, projected for rendering; null before the first emission + + private event TypedEventHandler? _itemsChanged; + // Held in a list (not a single field) so a double-`add` without an intervening `remove` can't orphan a + // subscription — which would pin the category to the repository's news poll loop forever. Matches the docks. + private readonly List _subscriptions = []; + + event TypedEventHandler INotifyItemsChanged.ItemsChanged + { + add + { + _itemsChanged += value; + // Observe the selected category's cached feed while the page is visible. The StateFlow overload + // replays the current category's cache on subscribe (fetching it if missing/stale), re-projects on + // a category change, and re-emits when the repository's poll/demo refresh updates that feed. + // Delivery is off-thread (ObserveOn in the repo), so the first emission lands after this accessor + // returns. Disposed in `remove` so a hidden page does no work and unregisters its category. + _subscriptions.Add(_repository.ObserveNews(_category).Subscribe(OnNewsChanged)); + } + remove + { + _itemsChanged -= value; + foreach (var subscription in _subscriptions) + subscription.Dispose(); + _subscriptions.Clear(); + } + } + + private new void RaiseItemsChanged(int totalItems = -1) + => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); + + public NewsPage(MarketRepository repository) + { + _repository = repository; + Id = "com.costafotiadis.market.news"; + Title = Resources.Command_MarketsNews; + Name = Resources.Action_Open; + Icon = IconHelpers.FromRelativePath("Assets\\markets_logo_base_square.png"); + PlaceholderText = Resources.News_Placeholder; + ShowDetails = true; // show the side preview pane (summary + thumbnail) + IsLoading = true; // spinner until the first feed lands + + _filters.PropChanged += OnFilterChanged; + Filters = _filters; + } + + // Typing filters the current category's headlines client-side; it never hits the network. The category + // dropdown, not typing, switches feeds. + public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(0); + + public override IListItem[] GetItems() + { + var news = _news; + if (news is null || news.Length == 0) + return []; // before the first feed / still loading — the IsLoading spinner covers it + + var query = SearchText?.Trim() ?? string.Empty; + var rows = query.Length == 0 ? news : [.. news.Where(n => n.Matches(query))]; + + if (rows.Length == 0) // a typed query matched nothing + { + return [new ListItem(new NoOpCommand()) + { + Title = Strings.Format(Resources.Search_NoMatches, query), + }]; + } + + return [.. rows.Select(BuildRow)]; + } + + // One headline row: Enter opens the article; the thumbnail (or a document glyph) is the icon; the summary + // and a larger thumbnail fill the Details preview pane. + private static ListItem BuildRow(UiNews n) + { + var image = string.IsNullOrWhiteSpace(n.ImageUrl) ? null : new IconInfo(n.ImageUrl); + var details = new Details + { + Title = n.Headline, + Body = n.Summary, + }; + if (image is not null) + details.HeroImage = image; // only set when present — IconInfo has no parameterless ctor to default to + + return new ListItem(new OpenUrlCommand(n.ArticleUrl, Resources.Action_ReadArticle)) + { + Title = n.Headline, + Subtitle = n.FormatSubtitle(), + Icon = image ?? new IconInfo(ArticleGlyph), + Details = details, + }; + } + + // A new cache emission for the selected category: project to UiNews and repaint. Runs on a pool thread + // (ObserveOn) — no Rx gate lock is held, so RaiseItemsChanged's host call is safe. + private void OnNewsChanged(IReadOnlyList news) + { + _news = [.. news.Select(UiNews.From)]; + IsLoading = _news.Length == 0; // an empty feed means "still loading" (market news is never truly empty) + RaiseItemsChanged(0); + } + + // The category dropdown changed: push the new selection into the flow (ObserveNews re-projects via Switch) + // and show the spinner immediately while the new feed loads. + private void OnFilterChanged(object sender, IPropChangedEventArgs args) + { + _news = null; + IsLoading = true; + _category.Update(NewsCategoryFilters.ToSelection(_filters.CurrentFilterId)); + RaiseItemsChanged(0); + } + + // The category dropdown shown above the list: an "All" entry (the merged view — default-selected) followed + // by one entry per NewsCategory. The selected id is "All" or the NewsCategory name (round-tripped via + // ToSelection); the visible name is NewsCategory.Label() (or the localized "All"). + private sealed partial class NewsCategoryFilters : Filters + { + // The "All" entry's id — a sentinel distinct from any NewsCategory name. Selected by default. + private const string AllId = "All"; + + public NewsCategoryFilters() => CurrentFilterId = AllId; + + public override IFilterItem[] GetFilters() => + [ + new Filter { Id = AllId, Name = Resources.News_Category_All }, + .. NewsCategoryExtensions.All.Select(c => (IFilterItem)new Filter + { + Id = c.ToString(), + Name = c.Label(), + }), + ]; + + // The selected view: null for "All" (the AllId sentinel, or any unrecognized id), else the category. + public static NewsCategory? ToSelection(string? id) => + Enum.TryParse(id, out var category) ? category : null; + } +} diff --git a/MarketExtension/Properties/Resources.Designer.cs b/MarketExtension/Properties/Resources.Designer.cs index 7c230c9..6a7c54f 100644 --- a/MarketExtension/Properties/Resources.Designer.cs +++ b/MarketExtension/Properties/Resources.Designer.cs @@ -69,6 +69,15 @@ public static string Action_Open { } } + /// + /// Looks up a localized string similar to Read article. + /// + public static string Action_ReadArticle { + get { + return ResourceManager.GetString("Action_ReadArticle", resourceCulture); + } + } + /// /// Looks up a localized string similar to Refresh. /// @@ -195,6 +204,15 @@ public static string Command_Markets { } } + /// + /// Looks up a localized string similar to Markets - News. + /// + public static string Command_MarketsNews { + get { + return ResourceManager.GetString("Command_MarketsNews", resourceCulture); + } + } + /// /// Looks up a localized string similar to Markets Portfolio. /// @@ -519,6 +537,15 @@ public static string Nav_Favorites_Title { } } + /// + /// Looks up a localized string similar to News. + /// + public static string Nav_News_Title { + get { + return ResourceManager.GetString("Nav_News_Title", resourceCulture); + } + } + /// /// Looks up a localized string similar to Portfolio. /// @@ -573,6 +600,60 @@ public static string Nav_Watchlist_Title { } } + /// + /// Looks up a localized string similar to All. + /// + public static string News_Category_All { + get { + return ResourceManager.GetString("News_Category_All", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Filter headlines. + /// + public static string News_Placeholder { + get { + return ResourceManager.GetString("News_Placeholder", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0}d ago. + /// + public static string News_Time_DaysAgo { + get { + return ResourceManager.GetString("News_Time_DaysAgo", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0}h ago. + /// + public static string News_Time_HoursAgo { + get { + return ResourceManager.GetString("News_Time_HoursAgo", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to just now. + /// + public static string News_Time_JustNow { + get { + return ResourceManager.GetString("News_Time_JustNow", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0}m ago. + /// + public static string News_Time_MinutesAgo { + get { + return ResourceManager.GetString("News_Time_MinutesAgo", resourceCulture); + } + } + /// /// Looks up a localized string similar to Data Sources. /// diff --git a/MarketExtension/Properties/Resources.de.resx b/MarketExtension/Properties/Resources.de.resx index f036c76..74c2719 100644 --- a/MarketExtension/Properties/Resources.de.resx +++ b/MarketExtension/Properties/Resources.de.resx @@ -429,6 +429,39 @@ Preisaktualisierungsintervall in Minuten (0 = aus). + + Nachrichtenaktualisierungsintervall (Minuten) + + + Nachrichtenaktualisierungsintervall in Minuten (0 = aus). + + + Märkte - Nachrichten + + + Schlagzeilen filtern + + + gerade eben + + + vor {0} Min. + + + vor {0} Std. + + + vor {0} T. + + + Artikel lesen + + + Alle + + + Nachrichten + Ratenbegrenzungswarnungen anzeigen @@ -507,10 +540,4 @@ Südkoreanischer Won (KRW) - - Nachrichtenaktualisierungsintervall (Minuten) - - - Nachrichtenaktualisierungsintervall in Minuten (0 = aus). - \ No newline at end of file diff --git a/MarketExtension/Properties/Resources.es.resx b/MarketExtension/Properties/Resources.es.resx index 9ea329f..1c9bcb6 100644 --- a/MarketExtension/Properties/Resources.es.resx +++ b/MarketExtension/Properties/Resources.es.resx @@ -426,6 +426,33 @@ Intervalo de actualización de noticias en minutos (0 = desactivado). + + Mercados - Noticias + + + Noticias + + + Filtrar titulares + + + Todas + + + ahora mismo + + + hace {0} min + + + hace {0} h + + + hace {0} d + + + Leer artículo + Mostrar avisos de límite de solicitudes diff --git a/MarketExtension/Properties/Resources.hi.resx b/MarketExtension/Properties/Resources.hi.resx index df1c8fb..a2114c8 100644 --- a/MarketExtension/Properties/Resources.hi.resx +++ b/MarketExtension/Properties/Resources.hi.resx @@ -380,6 +380,33 @@ समाचार रिफ़्रेश अंतराल मिनटों में (0 = बंद)। + + मार्केट - समाचार + + + समाचार + + + सुर्खियाँ फ़िल्टर करें + + + सभी + + + अभी-अभी + + + {0} मिनट पहले + + + {0} घंटे पहले + + + {0} दिन पहले + + + लेख पढ़ें + दर-सीमा चेतावनियाँ दिखाएँ diff --git a/MarketExtension/Properties/Resources.ja.resx b/MarketExtension/Properties/Resources.ja.resx index b27a208..16f1326 100644 --- a/MarketExtension/Properties/Resources.ja.resx +++ b/MarketExtension/Properties/Resources.ja.resx @@ -382,6 +382,33 @@ ニュースの更新間隔(分単位、0 = オフ)。 + + マーケット - ニュース + + + ニュース + + + 見出しをフィルター + + + すべて + + + たった今 + + + {0}分前 + + + {0}時間前 + + + {0}日前 + + + 記事を読む + レート制限の警告を表示 diff --git a/MarketExtension/Properties/Resources.ko.resx b/MarketExtension/Properties/Resources.ko.resx index e5bdffd..1f10b6a 100644 --- a/MarketExtension/Properties/Resources.ko.resx +++ b/MarketExtension/Properties/Resources.ko.resx @@ -381,6 +381,33 @@ 뉴스 새로 고침 간격(분 단위, 0 = 끔). + + 마켓 - 뉴스 + + + 뉴스 + + + 헤드라인 필터링 + + + 전체 + + + 방금 전 + + + {0}분 전 + + + {0}시간 전 + + + {0}일 전 + + + 기사 읽기 + 요청 제한 경고 표시 diff --git a/MarketExtension/Properties/Resources.pt-BR.resx b/MarketExtension/Properties/Resources.pt-BR.resx index 455ea31..7356c49 100644 --- a/MarketExtension/Properties/Resources.pt-BR.resx +++ b/MarketExtension/Properties/Resources.pt-BR.resx @@ -380,6 +380,33 @@ Intervalo de atualização de notícias em minutos (0 = desativado). + + Mercados - Notícias + + + Notícias + + + Filtrar manchetes + + + Todas + + + agora mesmo + + + há {0} min + + + há {0} h + + + há {0} d + + + Ler artigo + Mostrar avisos de limite de requisições diff --git a/MarketExtension/Properties/Resources.resx b/MarketExtension/Properties/Resources.resx index 76c42f6..7b80529 100644 --- a/MarketExtension/Properties/Resources.resx +++ b/MarketExtension/Properties/Resources.resx @@ -435,6 +435,33 @@ News refresh interval in minutes (0 = off). + + Markets - News + + + Filter headlines + + + just now + + + {0}m ago + + + {0}h ago + + + {0}d ago + + + Read article + + + All + + + News + Show rate-limit warnings diff --git a/MarketExtension/Properties/Resources.zh-Hans.resx b/MarketExtension/Properties/Resources.zh-Hans.resx index d8aa0a9..7b199a1 100644 --- a/MarketExtension/Properties/Resources.zh-Hans.resx +++ b/MarketExtension/Properties/Resources.zh-Hans.resx @@ -435,6 +435,33 @@ 新闻刷新间隔(分钟,0 = 关闭)。 + + 市场 - 新闻 + + + 新闻 + + + 筛选头条 + + + 全部 + + + 刚刚 + + + {0}分钟前 + + + {0}小时前 + + + {0}天前 + + + 阅读文章 + 显示速率限制警告 From 939486eeb2ed4030abc7cafc704143e364550d10 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 27 Jun 2026 20:34:31 +0300 Subject: [PATCH 5/8] News work in progress --- CLAUDE.md | 39 ++++++--- MarketExtension/Data/MarketRepository.cs | 11 +++ MarketExtension/Helpers/ApiKeyHint.cs | 27 +++++++ MarketExtension/Pages/MarketsPage.cs | 2 +- MarketExtension/Pages/NewsPage.cs | 80 +++++++++++++++++-- .../Properties/Resources.Designer.cs | 45 +++++++++++ MarketExtension/Properties/Resources.de.resx | 15 ++++ MarketExtension/Properties/Resources.es.resx | 15 ++++ MarketExtension/Properties/Resources.hi.resx | 15 ++++ MarketExtension/Properties/Resources.ja.resx | 15 ++++ MarketExtension/Properties/Resources.ko.resx | 15 ++++ .../Properties/Resources.pt-BR.resx | 15 ++++ MarketExtension/Properties/Resources.resx | 15 ++++ .../Properties/Resources.zh-Hans.resx | 15 ++++ 14 files changed, 304 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8f2686a..f3812d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -243,7 +243,22 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context ## Current Status / Next Steps -- **Done (latest): Market News feature (MVP — built end-to-end, NOT yet live-verified).** A **Markets → News** +- **Done (latest): News feature polish — empty-state / no-key UX + rate-limit banner (Left-to-do #2, #4, #5).** + Closed three of the news "Left to do" items. **#2 (the perpetual-spinner bug):** Finnhub's `SupportsNews` is + unconditionally `true`, so with no Finnhub key `/news` is still attempted and just returns `[]` → the cold news + cache stays empty → `NewsPage` treated empty as "loading" forever. Fixed with (a) a **Finnhub-gated + `ApiKeyHint.NewsStatusRow()`** — gates on `HasFinnhubApiKey`, NOT the broader `HasAnyApiKey` (a Twelve-Data-only + key can't serve news) — that renders a "News needs a Finnhub key" row instead of spinning, and (b) + **`MarketRepository.HasAttemptedNewsFetch(NewsCategory?)`** (reads the existing `_lastNewsFetchTicks` stamps; + `null` = the merged "All" view = all four categories attempted) so `NewsPage.RecomputeLoading` disambiguates + "loading" (no fetch yet) from "loaded-but-empty" (fetch done → an explicit `News_Empty_*` row). **#4:** + `NewsPage.GetItems` now inserts `RateLimitHint.Row()` at the top and subscribes to + `RateLimitSignal.Instance.IsRateLimited` (replay:false) to re-render on throttle start/stop, like the priced + pages + Search. **#5:** glyph check done (verified; the stale "verify/adjust glyph" comment was removed). New + resx strings (`Status_NewsNoKey_*`, `News_Empty_*`) across all 8 locales + the Designer. Build clean (0 + warnings). ⚠️ **Still NOT live-verified on-device** (#1). Remaining open: #1 live-verify, #3 news dock band, + #6 minId paging, #7 multi-provider, #8 native translation review. +- **Done (previous): Market News feature (MVP — built end-to-end, NOT yet live-verified).** A **Markets → News** hub screen lists market-news headlines (each row opens the source article in the browser), built behind the SAME layered seams as quotes with a parallel cache + repository-orchestration story. The pieces: a Finnhub `/news` provider method (`GetNewsAsync`) behind a new **`SupportsNews`** capability on `IMarketDataProvider` @@ -1370,19 +1385,21 @@ client-side. New UI strings are in the resx (all 7 locales). 1. **Live-verify on-device** — the whole path compiles but is unverified. Check via **Demo mode** (curated synthetic headlines per category; "All" merges + dedupes them) and a **real Finnhub key** (live `/news`): category switching, article open, the separate news refresh interval, demo-flip. -2. **No-key / empty-state UX** — with no Finnhub key (live), `/news` returns `[]` and the page treats an empty - feed as "still loading" → a **perpetual spinner**. Surface a status row instead (reuse - `ApiKeyHint.StatusRow()` and/or an explicit empty-state row), and disambiguate "loading" from - "loaded-but-empty" (the page currently can't, unlike the priced pages which use the membership count). +2. **No-key / empty-state UX — ✅ DONE.** With no Finnhub key, `/news` returns `[]` (Finnhub's `SupportsNews` + is unconditionally true, so it's still attempted) → the cold cache stayed empty and the page span forever. + Fixed: a **Finnhub-gated `ApiKeyHint.NewsStatusRow()`** (gates on `HasFinnhubApiKey`, not `HasAnyApiKey`) + shows a "News needs a Finnhub key" row instead of spinning, and **`MarketRepository.HasAttemptedNewsFetch( + NewsCategory?)`** lets `NewsPage.RecomputeLoading` tell "loading" (no fetch attempt) from "loaded-but-empty" + (fetch done → an explicit `News_Empty_*` row). Demo mode still shows the blue "sample data" row. 3. **News dock band** — the repo already exposes the fixed **`ObserveNews(NewsCategory)`** for exactly this: a dock band showing one category's latest headline(s), the "screen + dock in sync" payoff. Model it on `FavoritesDockPage` (pure observer, `INotifyItemsChanged` visible-lifecycle, its own non-empty `Command.Id`). -4. **Rate-limit banner on the news page** — the "All" view fans out 4 `/news` calls per tick; the page doesn't - render `RateLimitHint` (the priced pages + Search do). keep-last-good already protects the feed, but the user - isn't told *why* it looks stale. -5. **Glyph check** — verify `NewsGlyph` (`MarketsPage`, the hub row) and `ArticleGlyph` (`NewsPage`, the - thumbnail-less row icon) render the intended Segoe MDL2 icons. (⚠️ writing `\uXXXX` through the edit tooling - kept dropping the glyph — set/verify these by hand; see "Write tool mangles glyph icons".) +4. **Rate-limit banner on the news page — ✅ DONE.** `NewsPage.GetItems` inserts `RateLimitHint.Row()` at the + top (index 0) and subscribes to `RateLimitSignal.Instance.IsRateLimited` (replay:false) to re-render when + throttling starts/stops, exactly like the priced pages + Search. +5. **Glyph check — ✅ DONE** — `NewsGlyph` / `ArticleGlyph` verified to render; the stale "verify/adjust glyph" + comment at `MarketsPage.cs` was removed (the glyph values are `\uXXXX` escapes, so the Write-tool drop hazard + never applied here). 6. **minId incremental paging (optimization)** — every refresh fetches `minId:0` (the full latest batch) and replaces the cached feed; the `DomainNews.Id` / `minId` cursor exists but isn't used to fetch only newer items and merge. Cheap follow-up if `/news` volume matters. diff --git a/MarketExtension/Data/MarketRepository.cs b/MarketExtension/Data/MarketRepository.cs index 9904c96..6015434 100644 --- a/MarketExtension/Data/MarketRepository.cs +++ b/MarketExtension/Data/MarketRepository.cs @@ -530,6 +530,17 @@ private bool NeedsNewsFetchOnSubscribe(NewsCategory category) return TimeSpan.FromMilliseconds(Environment.TickCount64 - ticks) >= settings.NewsRefreshInterval; } + // Whether a news fetch has COMPLETED for the selection (a concrete category, or ALL categories for the + // "All" view, selection == null). Lets a surface tell "still loading" (no attempt yet) from + // "loaded-but-empty" (fetched, nothing came back) — the news cache emits an empty feed in BOTH cases, so + // the emission alone can't disambiguate. Reads the same per-category last-fetch stamps + // NeedsNewsFetchOnSubscribe uses (WriteThroughNews stamps them after every fetch, including an empty / + // kept-last-good one); _lastNewsFetchTicks is a ConcurrentDictionary, so ContainsKey is thread-safe. + public bool HasAttemptedNewsFetch(NewsCategory? selection) => + selection is { } category + ? _lastNewsFetchTicks.ContainsKey(category) + : NewsCategoryExtensions.All.All(_lastNewsFetchTicks.ContainsKey); + // One news poll tick: refresh every currently-observed category (one /news call each — news isn't // batchable across categories like quotes are across symbols). Nothing observed → nothing to do. private void RefreshObservedNews() diff --git a/MarketExtension/Helpers/ApiKeyHint.cs b/MarketExtension/Helpers/ApiKeyHint.cs index 5794deb..1de60ce 100644 --- a/MarketExtension/Helpers/ApiKeyHint.cs +++ b/MarketExtension/Helpers/ApiKeyHint.cs @@ -80,4 +80,31 @@ private static ListItem MissingKeyRow() => ToolTip = Resources.Status_NoKey_Tooltip, }], }; + + // The status row for the NEWS screen. News is served only by Finnhub (+ the mock in Demo mode), so its + // key gate is Finnhub-SPECIFIC — unlike StatusRow()'s HasAnyApiKey (which is satisfied by a Twelve Data + // key that can't serve news). Demo on → the blue "sample data" row (news works via the mock); else no + // Finnhub key → the news no-key row (the page's whole body in that state, not just a footnote — without + // it the empty feed would spin forever); else null (a Finnhub key is set → live news, no nudge). + public static IListItem? NewsStatusRow() + { + if (MarketSettingsManager.Instance.DemoMode) + return DemoRow(); + return MarketSettingsManager.Instance.HasFinnhubApiKey ? null : NewsMissingKeyRow(); + } + + // "News needs a Finnhub key": surfaced on the News screen when no Finnhub key is set (and demo mode is + // off). Reuses the missing-key red visual but with news-specific copy; Enter → Settings (to add a key). + private static ListItem NewsMissingKeyRow() => + new ListItem(SettingsPage) + { + Title = Resources.Status_NewsNoKey_Title, + Subtitle = Resources.Status_NewsNoKey_Subtitle, + Icon = new IconInfo(WarningGlyph), + Tags = [new Tag(Resources.Status_NoKey_Tag) + { + Foreground = WarningRed, + ToolTip = Resources.Status_NewsNoKey_Tooltip, + }], + }; } diff --git a/MarketExtension/Pages/MarketsPage.cs b/MarketExtension/Pages/MarketsPage.cs index 30eed75..57d873a 100644 --- a/MarketExtension/Pages/MarketsPage.cs +++ b/MarketExtension/Pages/MarketsPage.cs @@ -21,7 +21,7 @@ internal sealed partial class MarketsPage : ListPage, INotifyItemsChanged private const string InfoGlyph = "\uE9F9"; // Segoe MDL2 ReportDocument private const string SettingsGlyph = "\uE713"; // Segoe MDL2 Setting - private const string NewsGlyph = "\uE789"; // Segoe MDL2 News (verify/adjust glyph) + private const string NewsGlyph = "\uE789"; // Segoe MDL2 News private readonly SearchPage _searchPage; private readonly WatchlistPage _watchlistPage; diff --git a/MarketExtension/Pages/NewsPage.cs b/MarketExtension/Pages/NewsPage.cs index 9a0386a..850e1d4 100644 --- a/MarketExtension/Pages/NewsPage.cs +++ b/MarketExtension/Pages/NewsPage.cs @@ -55,6 +55,14 @@ event TypedEventHandler INotifyItemsChanged.Item // Delivery is off-thread (ObserveOn in the repo), so the first emission lands after this accessor // returns. Disposed in `remove` so a hidden page does no work and unregisters its category. _subscriptions.Add(_repository.ObserveNews(_category).Subscribe(OnNewsChanged)); + // Re-render when throttling starts/stops (the rate-limit banner) and when a key is added/cleared + // (the no-key status row), so each appears/disappears at once. replay:false — the observe + // subscription above already drives the initial paint. Mirrors PricedListPage. + _subscriptions.Add(RateLimitSignal.Instance.IsRateLimited + .Subscribe(_ => RaiseItemsChanged(0), replayOnSubscribe: false)); + _subscriptions.Add(MarketSettingsManager.Instance.HasAnyApiKey + .Subscribe(_ => RaiseItemsChanged(0), replayOnSubscribe: false)); + RecomputeLoading(); } remove { @@ -87,26 +95,82 @@ public NewsPage(MarketRepository repository) // dropdown, not typing, switches feeds. public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(0); + // True when news can't be served right now: demo mode is off AND no Finnhub key is set (Finnhub is the + // only live news provider; an unset key makes /news return nothing forever). In that state we show the + // no-key status row instead of an endless spinner — gated on HasFinnhubApiKey, NOT the broader + // HasAnyApiKey, since a Twelve-Data-only key can't serve news. + private static bool NewsUnavailable => + !MarketSettingsManager.Instance.DemoMode && !MarketSettingsManager.Instance.HasFinnhubApiKey; + + // The single source of truth for the spinner. Loading only when news IS serviceable AND we're genuinely + // still waiting: no emission yet, or an empty feed whose fetch for the current selection hasn't completed. + // No-key (covered by the status row) and loaded-but-empty (fetch done, nothing came back) are NOT loading. + private void RecomputeLoading() => + IsLoading = !NewsUnavailable + && (_news is null + || (_news.Length == 0 && !_repository.HasAttemptedNewsFetch(_category.Value))); + public override IListItem[] GetItems() { + var items = new List(); + + // Rate-limited banner pinned at the TOP so it's seen without scrolling — the "All" view fans out four + // /news calls per tick, so the feed can get throttled. Parallels the priced pages / Search. #4. + if (RateLimitHint.Row() is { } banner) + items.Add(banner); + + // No news provider available (demo off + no Finnhub key): explain it instead of spinning forever. This + // is the page's whole body in that state, so return right after it. #2. + if (NewsUnavailable) + { + if (ApiKeyHint.NewsStatusRow() is { } noKey) + items.Add(noKey); + return [.. items]; + } + var news = _news; - if (news is null || news.Length == 0) - return []; // before the first feed / still loading — the IsLoading spinner covers it + if (news is null) + return [.. items]; // before the first emission — the IsLoading spinner covers it var query = SearchText?.Trim() ?? string.Empty; var rows = query.Length == 0 ? news : [.. news.Where(n => n.Matches(query))]; - if (rows.Length == 0) // a typed query matched nothing + if (news.Length == 0) + { + // Empty feed: "still loading" vs "loaded-but-empty" (the cache emits empty for both). Only show the + // empty-state row once a fetch for this selection has actually completed; else leave the spinner. + if (_repository.HasAttemptedNewsFetch(_category.Value)) + items.Add(EmptyStateRow()); + } + else if (rows.Length == 0) // a typed query matched nothing { - return [new ListItem(new NoOpCommand()) + items.Add(new ListItem(new NoOpCommand()) { Title = Strings.Format(Resources.Search_NoMatches, query), - }]; + }); + } + else + { + items.AddRange(rows.Select(BuildRow)); } - return [.. rows.Select(BuildRow)]; + // Demo "showing sample data" row at the bottom (informational), consistent with the other screens — + // NewsStatusRow returns the demo row when demo mode is on, null when a live Finnhub key is set. + if (ApiKeyHint.NewsStatusRow() is { } status) + items.Add(status); + + return [.. items]; } + // Shown when a fetch completed but returned no headlines (rare for market-wide news, but it keeps the page + // honest rather than spinning forever). + private static ListItem EmptyStateRow() => + new(new NoOpCommand()) + { + Title = Resources.News_Empty_Title, + Subtitle = Resources.News_Empty_Subtitle, + }; + // One headline row: Enter opens the article; the thumbnail (or a document glyph) is the icon; the summary // and a larger thumbnail fill the Details preview pane. private static ListItem BuildRow(UiNews n) @@ -134,7 +198,7 @@ private static ListItem BuildRow(UiNews n) private void OnNewsChanged(IReadOnlyList news) { _news = [.. news.Select(UiNews.From)]; - IsLoading = _news.Length == 0; // an empty feed means "still loading" (market news is never truly empty) + RecomputeLoading(); // empty == loading only until the fetch completes; no-key never loads (see RecomputeLoading) RaiseItemsChanged(0); } @@ -143,8 +207,8 @@ private void OnNewsChanged(IReadOnlyList news) private void OnFilterChanged(object sender, IPropChangedEventArgs args) { _news = null; - IsLoading = true; _category.Update(NewsCategoryFilters.ToSelection(_filters.CurrentFilterId)); + RecomputeLoading(); // reads the just-updated _category; spinner unless news is unavailable RaiseItemsChanged(0); } diff --git a/MarketExtension/Properties/Resources.Designer.cs b/MarketExtension/Properties/Resources.Designer.cs index 6a7c54f..82029f3 100644 --- a/MarketExtension/Properties/Resources.Designer.cs +++ b/MarketExtension/Properties/Resources.Designer.cs @@ -609,6 +609,24 @@ public static string News_Category_All { } } + /// + /// Looks up a localized string similar to There's no market news to show right now.. + /// + public static string News_Empty_Subtitle { + get { + return ResourceManager.GetString("News_Empty_Subtitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No headlines. + /// + public static string News_Empty_Title { + get { + return ResourceManager.GetString("News_Empty_Title", resourceCulture); + } + } + /// /// Looks up a localized string similar to Filter headlines. /// @@ -1158,6 +1176,33 @@ public static string Status_Demo_Tooltip { } } + /// + /// Looks up a localized string similar to Add a Finnhub API key in Settings to load market news.. + /// + public static string Status_NewsNoKey_Subtitle { + get { + return ResourceManager.GetString("Status_NewsNoKey_Subtitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to News needs a Finnhub key. + /// + public static string Status_NewsNoKey_Title { + get { + return ResourceManager.GetString("Status_NewsNoKey_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Market news is provided by Finnhub.. + /// + public static string Status_NewsNoKey_Tooltip { + get { + return ResourceManager.GetString("Status_NewsNoKey_Tooltip", resourceCulture); + } + } + /// /// Looks up a localized string similar to Enter to add one in Settings.. /// diff --git a/MarketExtension/Properties/Resources.de.resx b/MarketExtension/Properties/Resources.de.resx index 74c2719..0ea53f6 100644 --- a/MarketExtension/Properties/Resources.de.resx +++ b/MarketExtension/Properties/Resources.de.resx @@ -540,4 +540,19 @@ Südkoreanischer Won (KRW) + + Nachrichten benötigen einen Finnhub-Schlüssel + + + Fügen Sie in den Einstellungen einen Finnhub-API-Schlüssel hinzu, um Marktnachrichten zu laden. + + + Marktnachrichten werden von Finnhub bereitgestellt. + + + Keine Schlagzeilen + + + Derzeit sind keine Marktnachrichten verfügbar. + \ No newline at end of file diff --git a/MarketExtension/Properties/Resources.es.resx b/MarketExtension/Properties/Resources.es.resx index 1c9bcb6..91cc5ae 100644 --- a/MarketExtension/Properties/Resources.es.resx +++ b/MarketExtension/Properties/Resources.es.resx @@ -429,6 +429,21 @@ Mercados - Noticias + + Las noticias necesitan una clave de Finnhub + + + Agrega una clave de API de Finnhub en Configuración para cargar noticias del mercado. + + + Las noticias del mercado las proporciona Finnhub. + + + Sin titulares + + + No hay noticias del mercado para mostrar en este momento. + Noticias diff --git a/MarketExtension/Properties/Resources.hi.resx b/MarketExtension/Properties/Resources.hi.resx index a2114c8..a3ab258 100644 --- a/MarketExtension/Properties/Resources.hi.resx +++ b/MarketExtension/Properties/Resources.hi.resx @@ -383,6 +383,21 @@ मार्केट - समाचार + + समाचार के लिए Finnhub कुंजी आवश्यक है + + + मार्केट समाचार लोड करने के लिए सेटिंग्स में Finnhub API कुंजी जोड़ें। + + + मार्केट समाचार Finnhub द्वारा प्रदान किए जाते हैं। + + + कोई सुर्खियाँ नहीं + + + अभी दिखाने के लिए कोई मार्केट समाचार नहीं है। + समाचार diff --git a/MarketExtension/Properties/Resources.ja.resx b/MarketExtension/Properties/Resources.ja.resx index 16f1326..cccc4b9 100644 --- a/MarketExtension/Properties/Resources.ja.resx +++ b/MarketExtension/Properties/Resources.ja.resx @@ -385,6 +385,21 @@ マーケット - ニュース + + ニュースには Finnhub キーが必要です + + + マーケットニュースを読み込むには、設定で Finnhub API キーを追加してください。 + + + マーケットニュースは Finnhub から提供されています。 + + + 見出しはありません + + + 現在表示できるマーケットニュースはありません。 + ニュース diff --git a/MarketExtension/Properties/Resources.ko.resx b/MarketExtension/Properties/Resources.ko.resx index 1f10b6a..a73e63f 100644 --- a/MarketExtension/Properties/Resources.ko.resx +++ b/MarketExtension/Properties/Resources.ko.resx @@ -384,6 +384,21 @@ 마켓 - 뉴스 + + 뉴스에는 Finnhub 키가 필요합니다 + + + 시장 뉴스를 불러오려면 설정에서 Finnhub API 키를 추가하세요. + + + 시장 뉴스는 Finnhub에서 제공합니다. + + + 헤드라인 없음 + + + 지금 표시할 시장 뉴스가 없습니다. + 뉴스 diff --git a/MarketExtension/Properties/Resources.pt-BR.resx b/MarketExtension/Properties/Resources.pt-BR.resx index 7356c49..13abb22 100644 --- a/MarketExtension/Properties/Resources.pt-BR.resx +++ b/MarketExtension/Properties/Resources.pt-BR.resx @@ -383,6 +383,21 @@ Mercados - Notícias + + As notícias precisam de uma chave do Finnhub + + + Adicione uma chave de API do Finnhub nas Configurações para carregar notícias do mercado. + + + As notícias do mercado são fornecidas pelo Finnhub. + + + Nenhuma manchete + + + Não há notícias do mercado para mostrar no momento. + Notícias diff --git a/MarketExtension/Properties/Resources.resx b/MarketExtension/Properties/Resources.resx index 7b80529..d1046ad 100644 --- a/MarketExtension/Properties/Resources.resx +++ b/MarketExtension/Properties/Resources.resx @@ -459,6 +459,21 @@ All + + News needs a Finnhub key + + + Add a Finnhub API key in Settings to load market news. + + + Market news is provided by Finnhub. + + + No headlines + + + There's no market news to show right now. + News diff --git a/MarketExtension/Properties/Resources.zh-Hans.resx b/MarketExtension/Properties/Resources.zh-Hans.resx index 7b199a1..0a45b7a 100644 --- a/MarketExtension/Properties/Resources.zh-Hans.resx +++ b/MarketExtension/Properties/Resources.zh-Hans.resx @@ -438,6 +438,21 @@ 市场 - 新闻 + + 新闻需要 Finnhub 密钥 + + + 在设置中添加 Finnhub API 密钥以加载市场新闻。 + + + 市场新闻由 Finnhub 提供。 + + + 暂无头条 + + + 当前没有可显示的市场新闻。 + 新闻 From e9a959fc0073b978ae5f0b9a294fd022e5455426 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 27 Jun 2026 20:40:49 +0300 Subject: [PATCH 6/8] News work in progress --- CLAUDE.md | 20 +++++--- MarketExtension/Data/INewsCacheDataSource.cs | 14 +++--- .../Data/InMemoryNewsCacheDataSource.cs | 29 +++++++---- MarketExtension/Data/MarketRepository.cs | 49 +++++++++---------- MarketExtension/Pages/NewsPage.cs | 24 ++++----- 5 files changed, 73 insertions(+), 63 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f3812d9..1e121c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,7 @@ the same three layers and the same provider seam — see the "Symbol detail + li | `Models/UiNews.cs` | **Ui projection** of `DomainNews` (the ONLY place news formatting lives): `FormatSubtitle()` ("CNBC · 2h ago"), `FormatRelativeTime()`, `Matches(query)`. Toolkit-free — the page builds `IconInfo`/`Details` from these strings (like `UiQuote`). | | `Data/NewsEntity.cs` | The news cache's **`*Entity` storage model** — a structural mirror of `DomainNews` BELOW the repository; the repo maps `DomainNews ⇄ NewsEntity` at its boundary (`From`/`ToDomainNews`), so the entity never escapes the data source. | | `Data/INewsCacheDataSource.cs` | The **shared observable news cache** abstraction — the news analog of `IQuoteCacheDataSource`, but keyed by **`NewsCategory`** with a **LIST** of `NewsEntity` per category (news is inherently a list, vs one quote per symbol). `Get`/`Observe`/`Upsert(keepLastGood)`/`Clear`. **keep-last-good here = a transient EMPTY result won't wipe a non-empty cached feed.** | -| `Data/InMemoryNewsCacheDataSource.cs` | The live in-memory impl — DynamicData `SourceCache` (a **private** per-category list wrapper that never crosses the interface). Atomic `Edit` for keep-last-good; `Observe` maps Remove→empty; an identical refetch is deduped via `SequenceEqual`. | +| `Data/InMemoryNewsCacheDataSource.cs` | The live in-memory impl — DynamicData `SourceCache` (a **private** per-category list wrapper that never crosses the interface). Atomic `Edit` for keep-last-good; `Observe` emits **`null` until the first fetch** (and maps Remove/Clear→`null`) — mirroring the quote cache's `QuoteEntity?` so a non-null EMPTY feed reads as "loaded-but-empty", distinct from the null "loading"; an identical refetch is deduped via `SequenceEqual`. | | `Pages/NewsPage.cs` | The **Markets → News** screen (a hub row): a `DynamicListPage` **pure observer** of `MarketRepository.ObserveNews(selection flow)`. Category dropdown (toolkit `Filters`): **"All"** (merged, default) + General/Forex/Crypto/Mergers; Enter opens the article (`OpenUrlCommand`); summary in the `Details` pane; typed search filters headlines client-side. See "Market News (feed)". | **To add a provider** (e.g. forex): implement `IMarketDataProvider` (`Supports`, `GetQuotesAsync`, @@ -248,10 +248,13 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context unconditionally `true`, so with no Finnhub key `/news` is still attempted and just returns `[]` → the cold news cache stays empty → `NewsPage` treated empty as "loading" forever. Fixed with (a) a **Finnhub-gated `ApiKeyHint.NewsStatusRow()`** — gates on `HasFinnhubApiKey`, NOT the broader `HasAnyApiKey` (a Twelve-Data-only - key can't serve news) — that renders a "News needs a Finnhub key" row instead of spinning, and (b) - **`MarketRepository.HasAttemptedNewsFetch(NewsCategory?)`** (reads the existing `_lastNewsFetchTicks` stamps; - `null` = the merged "All" view = all four categories attempted) so `NewsPage.RecomputeLoading` disambiguates - "loading" (no fetch yet) from "loaded-but-empty" (fetch done → an explicit `News_Empty_*` row). **#4:** + key can't serve news) — that renders a "News needs a Finnhub key" row instead of spinning, and (b) making the + news cache/observe emit **`null` until the first fetch lands** (mirroring the quote cache's `QuoteEntity?`), so a + non-null-but-EMPTY feed reads as "loaded-but-empty" and `NewsPage` derives loading (`null`) / empty + (`News_Empty_*` row) / items purely from the stream — no side query into the repository (an earlier draft added + a `HasAttemptedNewsFetch` repo method for this; pushing the distinction into the stream is cleaner and matches + how the quote cache already models not-yet-loaded). For "All", the merge emits `null` until every category has + been fetched, so an empty merge is genuinely "no headlines". **#4:** `NewsPage.GetItems` now inserts `RateLimitHint.Row()` at the top and subscribes to `RateLimitSignal.Instance.IsRateLimited` (replay:false) to re-render on throttle start/stop, like the priced pages + Search. **#5:** glyph check done (verified; the stale "verify/adjust glyph" comment was removed). New @@ -1388,9 +1391,10 @@ client-side. New UI strings are in the resx (all 7 locales). 2. **No-key / empty-state UX — ✅ DONE.** With no Finnhub key, `/news` returns `[]` (Finnhub's `SupportsNews` is unconditionally true, so it's still attempted) → the cold cache stayed empty and the page span forever. Fixed: a **Finnhub-gated `ApiKeyHint.NewsStatusRow()`** (gates on `HasFinnhubApiKey`, not `HasAnyApiKey`) - shows a "News needs a Finnhub key" row instead of spinning, and **`MarketRepository.HasAttemptedNewsFetch( - NewsCategory?)`** lets `NewsPage.RecomputeLoading` tell "loading" (no fetch attempt) from "loaded-but-empty" - (fetch done → an explicit `News_Empty_*` row). Demo mode still shows the blue "sample data" row. + shows a "News needs a Finnhub key" row instead of spinning; and the news cache/observe now emits **`null` + until the first fetch** (like the quote cache's `QuoteEntity?`), so `NewsPage` tells "loading" (`null`) from + "loaded-but-empty" (a non-null empty feed → an explicit `News_Empty_*` row) straight from the stream — no + side query into the repository. Demo mode still shows the blue "sample data" row. 3. **News dock band** — the repo already exposes the fixed **`ObserveNews(NewsCategory)`** for exactly this: a dock band showing one category's latest headline(s), the "screen + dock in sync" payoff. Model it on `FavoritesDockPage` (pure observer, `INotifyItemsChanged` visible-lifecycle, its own non-empty `Command.Id`). diff --git a/MarketExtension/Data/INewsCacheDataSource.cs b/MarketExtension/Data/INewsCacheDataSource.cs index be7c1f4..5ba0fc3 100644 --- a/MarketExtension/Data/INewsCacheDataSource.cs +++ b/MarketExtension/Data/INewsCacheDataSource.cs @@ -24,12 +24,14 @@ internal interface INewsCacheDataSource // Synchronous snapshot read. IReadOnlyList Get(NewsCategory category); - // Per-category observable feed: replays the current value (empty until the first fetch lands) on - // subscribe, then pushes each change. Distinct-until-changed (an identical re-fetch does NOT re-emit — - // Upsert skips a value-equal write at the source). This is the seam a surface waits on for the first - // fetch: subscribe → empty (render a spinner/placeholder) → the feed when Upsert lands. A Clear() - // re-emits empty while keeping the subscription live. - IObservable> Observe(NewsCategory category); + // Per-category observable feed, mirroring the quote cache's QuoteEntity? convention: emits NULL until the + // first fetch lands, then the feed — where a NON-NULL but EMPTY list means "a fetch completed and returned + // nothing", distinct from the null "not fetched yet". Then each subsequent change; distinct-until-changed + // (an identical re-fetch does NOT re-emit — Upsert skips a value-equal write at the source). A Clear() + // re-emits null while keeping the subscription live. This null-vs-empty distinction is the whole point: + // it lets a surface tell "loading" (null) from "loaded-but-empty" (empty list) PURELY from the stream, so + // no surface has to ask the repository a separate "has it fetched?" question. + IObservable?> Observe(NewsCategory category); // Write a freshly fetched feed through to the cache for a category. keepLastGood:true (the default) // drops a transient EMPTY result (e.g. a network error or rate-limit mapped to no items) when a diff --git a/MarketExtension/Data/InMemoryNewsCacheDataSource.cs b/MarketExtension/Data/InMemoryNewsCacheDataSource.cs index 7939a0c..e21ece6 100644 --- a/MarketExtension/Data/InMemoryNewsCacheDataSource.cs +++ b/MarketExtension/Data/InMemoryNewsCacheDataSource.cs @@ -30,9 +30,9 @@ internal sealed class InMemoryNewsCacheDataSource : INewsCacheDataSource // One entry per category; only ever the four NewsCategory values, so no eviction is needed. private readonly SourceCache _cache = new(f => f.Category); - // A single shared empty instance returned for an absent/cleared category, so the "no feed yet" value has - // a stable reference — Get(), the Remove branch and StartWith all hand back the same object, which lets - // DistinctUntilChanged dedupe the empty case by reference. + // A shared empty instance returned by Get() for an absent/cleared category — a stable "no feed" snapshot + // for the count-based staleness checks (NeedsNewsFetchOnSubscribe). Observe() uses NULL for that case + // instead (see GetOrNull), so it can distinguish "not fetched" from "fetched-but-empty". private static readonly IReadOnlyList Empty = []; public IReadOnlyList Get(NewsCategory category) @@ -41,21 +41,30 @@ public IReadOnlyList Get(NewsCategory category) return found.HasValue ? found.Value.Items : Empty; } - public IObservable> Observe(NewsCategory category) + public IObservable?> Observe(NewsCategory category) { // Defer so the StartWith snapshot is read at SUBSCRIBE time, not when Observe is called. Connect() // replays the cache's current state to each new subscriber, so Watch(category) emits the current feed - // on subscribe when the key is present; map a Remove (Clear) back to the empty feed; StartWith seeds - // the empty/absent case (and is deduped against Watch's replay by DistinctUntilChanged — they hand - // back the same Items reference). Net contract: empty until the first Upsert, then each distinct - // feed, empty again on Clear — subscription stays live. + // on subscribe when the key is present; a Remove (Clear) or an ABSENT key maps to NULL (= not fetched + // yet / cleared — the "loading" state), which a present-but-EMPTY feed does not. StartWith seeds the + // absent case with null (deduped against Watch's replay by DistinctUntilChanged — present feeds hand + // back the same Items reference). Net contract: null until the first Upsert, then each distinct feed + // (empty included), null again on Clear — subscription stays live. return Observable.Defer(() => _cache.Connect() .Watch(category) - .Select(change => change.Reason == ChangeReason.Remove ? Empty : change.Current.Items) - .StartWith(Get(category)) + .Select(change => change.Reason == ChangeReason.Remove ? null : (IReadOnlyList?)change.Current.Items) + .StartWith(GetOrNull(category)) .DistinctUntilChanged()); } + // The current cached feed, or NULL when the category was never fetched / has been cleared (absent key) — + // the snapshot Observe seeds with. Get() returns an empty list for that case instead, for count checks. + private IReadOnlyList? GetOrNull(NewsCategory category) + { + var found = _cache.Lookup(category); + return found.HasValue ? found.Value.Items : null; + } + public void Upsert(NewsCategory category, IReadOnlyList news, bool keepLastGood = true) { // Edit runs ATOMICALLY under the cache's lock — the read (Lookup), the keep-last-good decision, and diff --git a/MarketExtension/Data/MarketRepository.cs b/MarketExtension/Data/MarketRepository.cs index 6015434..d38b119 100644 --- a/MarketExtension/Data/MarketRepository.cs +++ b/MarketExtension/Data/MarketRepository.cs @@ -443,7 +443,10 @@ private void WriteThroughNews(NewsCategory category, IReadOnlyList n // while Rx still holds a producer-side gate, cycling the gate/STA lock order → hang. ObserveOn hands each // emission to the scheduler, so surfaces are notified only AFTER the gate locks release. (Surfaces already // expect off-host-thread notifications — the toolkit marshals RaiseItemsChanged.) - public IObservable> ObserveNews(NewsCategory category) + // Emits null = "loading" (no fetch has landed yet) and a non-null (possibly empty) feed once one has — the + // null-vs-empty distinction comes straight from the cache (INewsCacheDataSource.Observe), so a surface + // renders loading/empty/items purely from the stream without asking the repository anything extra. + public IObservable?> ObserveNews(NewsCategory category) => ObserveNewsCore(category).ObserveOn(TaskPoolScheduler.Default); // Selection-aware: re-projects (Switch) whenever the chosen view changes. The selection is a NewsCategory? @@ -452,7 +455,7 @@ public IObservable> ObserveNews(NewsCategory category) // switching fetches the newly-selected view and drops the previous one's observation for free. ObserveOn // AFTER Switch so the surface is notified off the Switch gate too (Core is used here, not the public // overloads, so there's a single ObserveOn hop, after Switch). - public IObservable> ObserveNews(StateFlow selection) + public IObservable?> ObserveNews(StateFlow selection) => selection.AsObservable() .Select(sel => sel is { } category ? ObserveNewsCore(category) : ObserveAllNewsCore()) .Switch() @@ -464,8 +467,8 @@ public IObservable> ObserveNews(StateFlow> ObserveNewsCore(NewsCategory category) - => Observable.Create>(observer => + private IObservable?> ObserveNewsCore(NewsCategory category) + => Observable.Create?>(observer => { RegisterNews(category); if (NeedsNewsFetchOnSubscribe(category)) @@ -478,10 +481,12 @@ private IObservable> ObserveNewsCore(NewsCategory cate Log.Info("Repository", $"observe news subscribe: {category} fresh in cache — no fetch"); } - // The cache's per-category Observe replays the current feed (empty until the first fetch) then - // pushes each change; storage → domain at the boundary. + // The cache's per-category Observe emits null until the first fetch (loading), then the feed + // (empty included); storage → domain at the boundary, the null "loading" sentinel passed through. var inner = _newsCacheSource.Observe(category) - .Select(entities => (IReadOnlyList)entities.Select(e => e.ToDomainNews()).ToList()); + .Select(entities => entities is null + ? null + : (IReadOnlyList)entities.Select(e => e.ToDomainNews()).ToList()); return new CompositeDisposable( inner.Subscribe(observer), @@ -494,14 +499,19 @@ private IObservable> ObserveNewsCore(NewsCategory cate // loop while it's the selected view), then flatten, dedupe by news Id (the same article can surface in more // than one category feed), and sort newest-first. Re-emits whenever any category's cached feed changes; the // selection overload adds ObserveOn so the host is never called under the CombineLatest gate. - private IObservable> ObserveAllNewsCore() + private IObservable?> ObserveAllNewsCore() => Observable.CombineLatest(NewsCategoryExtensions.All.Select(ObserveNewsCore)) - .Select(feeds => (IReadOnlyList)feeds - .SelectMany(feed => feed) - .GroupBy(item => item.Id) - .Select(group => group.First()) - .OrderByDescending(item => item.Published) - .ToList()); + // "All" is still loading until EVERY category has been fetched (any null feed) — only then is an + // empty merge truly "no headlines" rather than "some categories haven't landed". Once all are + // non-null, flatten, dedupe by id (an article can appear in >1 feed), and sort newest-first. + .Select(feeds => feeds.Any(feed => feed is null) + ? null + : (IReadOnlyList)feeds + .SelectMany(feed => feed!) + .GroupBy(item => item.Id) + .Select(group => group.First()) + .OrderByDescending(item => item.Published) + .ToList()); // RefreshNewsAsync with its exceptions swallowed + logged — for the fire-and-forget fetch + poll paths, // where a provider throw would otherwise become an unobserved task exception. @@ -530,17 +540,6 @@ private bool NeedsNewsFetchOnSubscribe(NewsCategory category) return TimeSpan.FromMilliseconds(Environment.TickCount64 - ticks) >= settings.NewsRefreshInterval; } - // Whether a news fetch has COMPLETED for the selection (a concrete category, or ALL categories for the - // "All" view, selection == null). Lets a surface tell "still loading" (no attempt yet) from - // "loaded-but-empty" (fetched, nothing came back) — the news cache emits an empty feed in BOTH cases, so - // the emission alone can't disambiguate. Reads the same per-category last-fetch stamps - // NeedsNewsFetchOnSubscribe uses (WriteThroughNews stamps them after every fetch, including an empty / - // kept-last-good one); _lastNewsFetchTicks is a ConcurrentDictionary, so ContainsKey is thread-safe. - public bool HasAttemptedNewsFetch(NewsCategory? selection) => - selection is { } category - ? _lastNewsFetchTicks.ContainsKey(category) - : NewsCategoryExtensions.All.All(_lastNewsFetchTicks.ContainsKey); - // One news poll tick: refresh every currently-observed category (one /news call each — news isn't // batchable across categories like quotes are across symbols). Nothing observed → nothing to do. private void RefreshObservedNews() diff --git a/MarketExtension/Pages/NewsPage.cs b/MarketExtension/Pages/NewsPage.cs index 850e1d4..ef5a0f8 100644 --- a/MarketExtension/Pages/NewsPage.cs +++ b/MarketExtension/Pages/NewsPage.cs @@ -102,13 +102,10 @@ public NewsPage(MarketRepository repository) private static bool NewsUnavailable => !MarketSettingsManager.Instance.DemoMode && !MarketSettingsManager.Instance.HasFinnhubApiKey; - // The single source of truth for the spinner. Loading only when news IS serviceable AND we're genuinely - // still waiting: no emission yet, or an empty feed whose fetch for the current selection hasn't completed. - // No-key (covered by the status row) and loaded-but-empty (fetch done, nothing came back) are NOT loading. - private void RecomputeLoading() => - IsLoading = !NewsUnavailable - && (_news is null - || (_news.Length == 0 && !_repository.HasAttemptedNewsFetch(_category.Value))); + // The single source of truth for the spinner. _news is null ONLY while loading: the cache-backed stream + // emits null until a fetch lands, then a (possibly empty) feed — so null = loading, empty = loaded-but- + // empty (see ObserveNews). No-key (covered by the status row) is also not loading. + private void RecomputeLoading() => IsLoading = !NewsUnavailable && _news is null; public override IListItem[] GetItems() { @@ -137,10 +134,9 @@ public override IListItem[] GetItems() if (news.Length == 0) { - // Empty feed: "still loading" vs "loaded-but-empty" (the cache emits empty for both). Only show the - // empty-state row once a fetch for this selection has actually completed; else leave the spinner. - if (_repository.HasAttemptedNewsFetch(_category.Value)) - items.Add(EmptyStateRow()); + // A non-null but empty feed is unambiguously "loaded-but-empty" — the loading case is null and was + // handled above (it returned with just the spinner). So show the empty-state row. + items.Add(EmptyStateRow()); } else if (rows.Length == 0) // a typed query matched nothing { @@ -195,10 +191,10 @@ private static ListItem BuildRow(UiNews n) // A new cache emission for the selected category: project to UiNews and repaint. Runs on a pool thread // (ObserveOn) — no Rx gate lock is held, so RaiseItemsChanged's host call is safe. - private void OnNewsChanged(IReadOnlyList news) + private void OnNewsChanged(IReadOnlyList? news) { - _news = [.. news.Select(UiNews.From)]; - RecomputeLoading(); // empty == loading only until the fetch completes; no-key never loads (see RecomputeLoading) + _news = news is null ? null : [.. news.Select(UiNews.From)]; // null = loading; empty = loaded-but-empty + RecomputeLoading(); RaiseItemsChanged(0); } From 4271d2d8cebad0c99dfcb1bd70b27145fa5eccc3 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 27 Jun 2026 21:52:56 +0300 Subject: [PATCH 7/8] News work in progress --- CLAUDE.md | 49 ++++- .../MarketExtensionCommandsProvider.cs | 1 + MarketExtension/Pages/NewsDockPage.cs | 180 ++++++++++++++++++ .../Properties/Resources.Designer.cs | 18 ++ MarketExtension/Properties/Resources.de.resx | 36 ++-- MarketExtension/Properties/Resources.es.resx | 6 + MarketExtension/Properties/Resources.hi.resx | 6 + MarketExtension/Properties/Resources.ja.resx | 6 + MarketExtension/Properties/Resources.ko.resx | 6 + .../Properties/Resources.pt-BR.resx | 6 + MarketExtension/Properties/Resources.resx | 6 + .../Properties/Resources.zh-Hans.resx | 6 + .../Settings/MarketSettingsManager.cs | 29 +++ 13 files changed, 332 insertions(+), 23 deletions(-) create mode 100644 MarketExtension/Pages/NewsDockPage.cs diff --git a/CLAUDE.md b/CLAUDE.md index 1e121c9..34518fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,7 @@ the same three layers and the same provider seam — see the "Symbol detail + li | `Data/INewsCacheDataSource.cs` | The **shared observable news cache** abstraction — the news analog of `IQuoteCacheDataSource`, but keyed by **`NewsCategory`** with a **LIST** of `NewsEntity` per category (news is inherently a list, vs one quote per symbol). `Get`/`Observe`/`Upsert(keepLastGood)`/`Clear`. **keep-last-good here = a transient EMPTY result won't wipe a non-empty cached feed.** | | `Data/InMemoryNewsCacheDataSource.cs` | The live in-memory impl — DynamicData `SourceCache` (a **private** per-category list wrapper that never crosses the interface). Atomic `Edit` for keep-last-good; `Observe` emits **`null` until the first fetch** (and maps Remove/Clear→`null`) — mirroring the quote cache's `QuoteEntity?` so a non-null EMPTY feed reads as "loaded-but-empty", distinct from the null "loading"; an identical refetch is deduped via `SequenceEqual`. | | `Pages/NewsPage.cs` | The **Markets → News** screen (a hub row): a `DynamicListPage` **pure observer** of `MarketRepository.ObserveNews(selection flow)`. Category dropdown (toolkit `Filters`): **"All"** (merged, default) + General/Forex/Crypto/Mergers; Enter opens the article (`OpenUrlCommand`); summary in the `Details` pane; typed search filters headlines client-side. See "Market News (feed)". | +| `Pages/NewsDockPage.cs` | The **news dock band** — a CNBC-style scrolling-marquee news ticker. A `ListPage` **pure observer** of `MarketRepository.ObserveNews(null = "All")`, rendering a **row of 4 headline buttons** (each opens its own article via `OpenUrlCommand`) that **cycles** the visible window through the feed one headline per tick. A single button can't smoothly crawl — the host caps each dock button's Title at `MaxWidth=100`≈15 chars (`CharacterEllipsis`/`NoWrap`; see PowerToys `DockItemControl.xaml`) and won't pixel-scroll a Title — so it's a row of buttons, not one scrolling line. Cycle cadence = the `NewsTickerCycleInterval` setting (default 60 s), re-read **live each tick** via a dedicated per-band `Observable.Generate` (NOT `PollTicker`). Third `CommandItem` in `GetDockBands()` (`Id = com.costafotiadis.market.dock.news`). See "Market News (feed)" + the "Done (latest)" status bullet. | **To add a provider** (e.g. forex): implement `IMarketDataProvider` (`Supports`, `GetQuotesAsync`, and `SearchAsync` — return `[]` if it can't search; optionally **override `GetCandlesAsync`** to serve @@ -243,7 +244,29 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context ## Current Status / Next Steps -- **Done (latest): News feature polish — empty-state / no-key UX + rate-limit banner (Left-to-do #2, #4, #5).** +- **Done (latest): News dock band — a CNBC-style scrolling news ticker (closes News Left-to-do #3). ✅ Live-verified on-device.** + A third Dock band (`Pages/NewsDockPage.cs`) next to the favorites + portfolio bands: a market-news ticker that + scrolls headlines like a TV news crawl. A **pure observer** of the shared news cache like the other dock bands — + it subscribes to `MarketRepository.ObserveNews(MutableStateFlow(null))` (the merged **"All"** feed) + while visible and renders what it emits; no fetch/poll itself. **Rendering: a ROW of headline buttons, NOT one + scrolling Title** — the host caps each dock button's Title at **`MaxWidth=100` DIPs** (Segoe UI 12 → ~15 chars, + `CharacterEllipsis`, `NoWrap`; see PowerToys `DockItemControl.xaml`), and a Title is static left-aligned text the + host does **not** pixel-scroll (a char-step crawl just "pops" jerkily — confirmed the wobble is unfixable on this + surface; two earlier single-button approaches — a 60-char then a 200-char overflow window — both wobbled). A + dock-band `ListPage` renders **each `GetItems()` item as its own ≤100px button** (how the favorites band shows + several tickers), so a row of **4** headline buttons is a wider, stable, jitter-free strip; each button opens + **its own** article (`OpenUrlCommand`, stable per-article `Id`), and we "go through the feed" by advancing the + visible window one headline each cycle (`offset+1`, wrapping). **Cycle speed is a new setting** — + `News ticker speed (seconds)` (`newsTickerSeconds`, surfaced as `NewsTickerCycleSeconds`/`NewsTickerCycleInterval`, + **default 60 s = 1 min**; in seconds, not minutes, for finer ticker control) — read **live each tick** by a + dedicated per-band self-rescheduling `Observable.Generate` on `TaskPoolScheduler` (PollTicker's live-settings + trick; NOT PollTicker itself — that's data-refresh, this is UI animation), so a speed change applies on the next + advance with no reload. Both the news subscription and the cycle timer are held in `_subscriptions` and disposed + on hide (no orphaned repaint loop). Registered as a third `CommandItem` in `GetDockBands()` + (`Id = com.costafotiadis.market.dock.news`). `Settings_NewsTicker_*` resx strings across all 8 locales + the + Designer; the `ArticleGlyph` fallback icon is built from `((char)0xE9F9)` (the code point — no literal glyph or + `\u` escape for the Write tool to mangle). Build clean (0 warnings). ✅ **Live-verified on-device.** +- **Done (previous): News feature polish — empty-state / no-key UX + rate-limit banner (Left-to-do #2, #4, #5).** Closed three of the news "Left to do" items. **#2 (the perpetual-spinner bug):** Finnhub's `SupportsNews` is unconditionally `true`, so with no Finnhub key `/news` is still attempted and just returns `[]` → the cold news cache stays empty → `NewsPage` treated empty as "loading" forever. Fixed with (a) a **Finnhub-gated @@ -259,8 +282,8 @@ cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context `RateLimitSignal.Instance.IsRateLimited` (replay:false) to re-render on throttle start/stop, like the priced pages + Search. **#5:** glyph check done (verified; the stale "verify/adjust glyph" comment was removed). New resx strings (`Status_NewsNoKey_*`, `News_Empty_*`) across all 8 locales + the Designer. Build clean (0 - warnings). ⚠️ **Still NOT live-verified on-device** (#1). Remaining open: #1 live-verify, #3 news dock band, - #6 minId paging, #7 multi-provider, #8 native translation review. + warnings). ✅ The news path is now **live-verified on-device** (#1 — confirmed via the news dock band rendering + live headlines). Remaining open: #6 minId paging, #7 multi-provider, #8 native translation review. - **Done (previous): Market News feature (MVP — built end-to-end, NOT yet live-verified).** A **Markets → News** hub screen lists market-news headlines (each row opens the source article in the browser), built behind the SAME layered seams as quotes with a parallel cache + repository-orchestration story. The pieces: a Finnhub @@ -1371,10 +1394,14 @@ exposing **two** independent, refcounted tickers: `Subscribe` (price, `RefreshIn (news, `NewsRefreshInterval`) — so news polls on its own gentler clock and only while a news surface is active. The guard, off-recheck idle, and `Publish().RefCount()` lifecycle are shared. -**The setting.** A **separate** `News refresh interval (minutes)` setting +**The settings.** A **separate** `News refresh interval (minutes)` setting (`MarketSettingsManager.NewsRefreshMinutes` / `NewsRefreshInterval` / `NewsAutoRefreshEnabled`, default **30**, 0 = off) — deliberately NOT reusing the price `RefreshInterval` (headlines change less often). Read pull-style by -the news ticker each iteration. +the news ticker each iteration. **Plus** the news dock band's **`News ticker speed (seconds)`** setting +(`NewsTickerCycleSeconds` / `NewsTickerCycleInterval`, default **60 s**) — the marquee CYCLE cadence (how often the +ticker advances to the next headlines), distinct from the network refresh interval above and in **seconds** (not +minutes) for finer ticker control; re-read live each tick by `NewsDockPage`'s `Observable.Generate`. See the +"Done (latest)" dock-band status bullet. **The screen** (`NewsPage`). A `DynamicListPage` **pure observer** (like the dock bands): while visible it subscribes to `ObserveNews(_category)` and renders what it emits — no fetch/poll itself. A toolkit **`Filters` @@ -1395,9 +1422,15 @@ client-side. New UI strings are in the resx (all 7 locales). until the first fetch** (like the quote cache's `QuoteEntity?`), so `NewsPage` tells "loading" (`null`) from "loaded-but-empty" (a non-null empty feed → an explicit `News_Empty_*` row) straight from the stream — no side query into the repository. Demo mode still shows the blue "sample data" row. -3. **News dock band** — the repo already exposes the fixed **`ObserveNews(NewsCategory)`** for exactly this: a - dock band showing one category's latest headline(s), the "screen + dock in sync" payoff. Model it on - `FavoritesDockPage` (pure observer, `INotifyItemsChanged` visible-lifecycle, its own non-empty `Command.Id`). +3. **News dock band — ✅ DONE (live-verified).** `Pages/NewsDockPage.cs`: a CNBC-style scrolling news ticker, a + pure observer of `ObserveNews(null = the merged "All" feed)` (modeled on the dock-band pure-observer pattern, + `INotifyItemsChanged` visible-lifecycle, `Command.Id = com.costafotiadis.market.dock.news`). It renders a + **row of 4 headline buttons** (each opens its own article) and **cycles** the visible window through the feed + one headline per tick — NOT a single scrolling Title, because the host caps each dock button at + `MaxWidth=100`≈15 chars (`DockItemControl.xaml`) and won't pixel-scroll a Title (a char-step crawl just "pops"). + Cycle speed is the configurable `News ticker speed (seconds)` setting (default 60 s), re-read live each tick by + a dedicated per-band `Observable.Generate` (not `PollTicker`). Used the StateFlow `null`-=-All overload rather + than the fixed single-category `ObserveNews(NewsCategory)` — a ticker shows everything, not one category. 4. **Rate-limit banner on the news page — ✅ DONE.** `NewsPage.GetItems` inserts `RateLimitHint.Row()` at the top (index 0) and subscribes to `RateLimitSignal.Instance.IsRateLimited` (replay:false) to re-render when throttling starts/stops, exactly like the priced pages + Search. diff --git a/MarketExtension/MarketExtensionCommandsProvider.cs b/MarketExtension/MarketExtensionCommandsProvider.cs index 08e4df6..5cd0459 100644 --- a/MarketExtension/MarketExtensionCommandsProvider.cs +++ b/MarketExtension/MarketExtensionCommandsProvider.cs @@ -51,6 +51,7 @@ public MarketExtensionCommandsProvider() _dockBands = [ new CommandItem(new FavoritesDockPage(_repository)) { Title = Resources.Command_Markets }, new CommandItem(new PortfolioDockPage(_repository)) { Title = Resources.Command_MarketsPortfolio }, + new CommandItem(new NewsDockPage(_repository)) { Title = Resources.Command_MarketsNews }, ]; } diff --git a/MarketExtension/Pages/NewsDockPage.cs b/MarketExtension/Pages/NewsDockPage.cs new file mode 100644 index 0000000..5522f0b --- /dev/null +++ b/MarketExtension/Pages/NewsDockPage.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; +using MarketExtension.Properties; + +namespace MarketExtension; + +// Backs a third Command Palette Dock band (next to favorites + portfolio): a market-news ticker. It renders a +// ROW of headline buttons — each one a real headline that opens its own article — and cycles the row through +// the feed one headline at a time on a slow timer, like a TV news crawl. Returned from +// MarketExtensionCommandsProvider.GetDockBands() wrapped in a CommandItem. +// +// Why a row of buttons (not a single scrolling Title): the host caps each dock button's Title at MaxWidth=100 +// DIPs (Segoe UI 12 → ~15 chars, CharacterEllipsis) — see PowerToys DockItemControl.xaml. A single button can +// therefore only ever show ~15 chars, and a Title is left-aligned static text the host does NOT pixel-scroll, +// so a smooth one-button crawl is impossible (advancing by a character just repaints a different ~15-char +// snapshot — it pops, it doesn't slide). A dock-band ListPage instead renders EACH GetItems() item as its own +// ≤100px button (that's how the favorites band shows several tickers), so a row of N buttons is a wider, +// readable, jitter-free strip. We "go through the headlines" by advancing which N of the feed are shown on a +// configurable cycle (NewsTickerCycleInterval, default 60s; offset += 1), so the row scrolls headline-by-headline. +// +// A PURE OBSERVER of the shared news cache, like NewsPage and the priced dock bands: while visible it +// subscribes to MarketRepository.ObserveNews(null selection = the merged "All" feed) and renders what it +// emits. It does NOT fetch or poll itself — the repository owns the single news poll loop (gentle ~30-min +// news cadence) and the demo-flip refill — so the band and the News screen can never drift apart. Subscribing +// registers the band as an observer so the repo keeps the feed fresh; disposing on hide unregisters. "All" +// fans out (ObserveAllNewsCore CombineLatest's all four categories), so a pinned band keeps all four news +// categories warm — expected, identical to NewsPage with "All" selected, and cheap at the news cadence. +// +// Threading: ObserveNews delivers via ObserveOn(TaskPoolScheduler) (see MarketRepository), and the cycle +// Observable.Interval also fires on TaskPoolScheduler — so BOTH OnNewsChanged and OnCycle (and therefore +// RaiseItemsChanged's blocking COM call into the host) run on a pool thread with NO Rx gate lock held. That is +// the deadlock-safe pattern the other bands use; do not add Task.Run / SubscribeOn here. +internal sealed partial class NewsDockPage : ListPage, INotifyItemsChanged +{ + private const int VisibleCount = 4; // headline buttons shown at once (each host-capped at ~15 chars) + // Segoe MDL2 ReportDocument (U+E9F9) - fallback icon when an article has no thumbnail. Built from its + // code point (plain ASCII hex), so there is no literal glyph or escape for the Write tool to mangle. + private static readonly string ArticleGlyph = ((char)0xE9F9).ToString(); + + private readonly MarketRepository _repository; + // Always the merged "All" feed: a null selection. Held as a field so it outlives the subscription; never + // Update()d (the band has no category dropdown — it shows everything, like a real news crawl). + private readonly MutableStateFlow _category = new(null); + // One cached NewsPage instance, reused as the placeholder button's command (loading / no-key / empty), so a + // repaint doesn't churn a new page object. + private readonly NewsPage _newsPage; + + // Latest feed, projected for rendering. Written by the news pool thread (OnNewsChanged), read by the cycle + // thread (OnCycle) and the host thread (GetItems). null = loading; empty = loaded-but-empty / no-key. + // A whole-reference assignment, so cross-thread reads are atomic (no lock) — same as the other dock pages. + private volatile UiNews[]? _news; + // The index of the first visible headline; advanced by OnCycle (the sole writer). volatile for visibility + // to the host thread's GetItems read. Always taken mod the feed length, so it can't index out of range. + private volatile int _offset; + + private event TypedEventHandler? _itemsChanged; + // Subscriptions in a list (not a single field) so a double-`add` without an intervening `remove` can't + // orphan one — which here would leave the cycle Interval repainting a hidden band forever AND pin the news + // categories to the repo's poll loop. Dispose-all-and-clear in `remove` keeps every subscribe balanced. + private readonly List _subscriptions = []; + + event TypedEventHandler INotifyItemsChanged.ItemsChanged + { + add + { + _itemsChanged += value; + // (1) The data feed: observe the merged "All" news feed while the band is visible. Replays the + // current cache on subscribe (fetching if missing/stale), re-emits on the repo's poll/demo + // refresh. Delivery is off-thread (ObserveOn in the repo). + _subscriptions.Add(_repository.ObserveNews(_category).Subscribe(OnNewsChanged)); + // (2) The cycle clock: a self-rescheduling timer that advances which headlines are shown. The delay + // is re-read from settings each tick (NewsTickerCycleInterval, default 60s), so a speed change + // applies on the next advance with no reload — the live-settings trick PollTicker uses. Fires on + // TaskPoolScheduler (same off-thread rule as ObserveNews → RaiseItemsChanged is deadlock-safe). + // MUST be disposed in `remove` (it's in _subscriptions) or it keeps repainting a hidden band. + _subscriptions.Add(Observable + .Generate( + initialState: 0L, + condition: _ => true, + iterate: tick => tick + 1, + resultSelector: tick => tick, + timeSelector: _ => MarketSettingsManager.Instance.NewsTickerCycleInterval, + scheduler: TaskPoolScheduler.Default) + .Subscribe(OnCycle)); + Log.Info("Dock", "observing news ticker"); + } + remove + { + _itemsChanged -= value; + foreach (var subscription in _subscriptions) + subscription.Dispose(); + _subscriptions.Clear(); + Log.Info("Dock", "stopped observing news ticker"); + } + } + + private new void RaiseItemsChanged(int totalItems = -1) + => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); + + public NewsDockPage(MarketRepository repository) + { + _repository = repository; + _newsPage = new NewsPage(_repository); + Id = "com.costafotiadis.market.dock.news"; // dock bands require a non-empty command Id + Title = Resources.Command_MarketsNews; + Icon = IconHelpers.FromRelativePath("Assets\\markets_logo_base_square.png"); + } + + public override IListItem[] GetItems() + { + var news = _news; + if (news is null) + return []; // before the first emission — loading; nothing to show yet + + if (news.Length == 0) + // Loaded-but-empty / no-key: keep the band present with one placeholder button that opens the full + // News screen (which surfaces the no-key / empty state properly). + return [new ListItem(_newsPage) { Title = Resources.Command_MarketsNews }]; + + // Show VisibleCount consecutive headlines starting at _offset, wrapping around the feed. Snapshot + // _offset once so a concurrent OnCycle advance can't shift the window mid-build. + var count = news.Length; + var take = Math.Min(VisibleCount, count); + var offset = _offset; + var items = new IListItem[take]; + for (var i = 0; i < take; i++) + items[i] = BuildButton(news[(offset + i) % count]); + return items; + } + + // One headline button: Enter opens THAT article in the browser; the thumbnail (or a document glyph) is the + // icon; the source · age is the subtitle. A stable per-article Id (like the favorites band sets on its dock + // items) keeps item identity steady across repaints. + private static ListItem BuildButton(UiNews n) => + new(new OpenUrlCommand(n.ArticleUrl, Resources.Action_ReadArticle) + { + Id = $"com.costafotiadis.market.dock.news.{n.Source.Id}", + }) + { + Title = n.Headline, + Subtitle = n.FormatSubtitle(), + Icon = string.IsNullOrWhiteSpace(n.ImageUrl) ? new IconInfo(ArticleGlyph) : new IconInfo(n.ImageUrl), + }; + + // A new feed emission: project to UiNews and reset the window to the freshest headlines. Runs on a pool + // thread (ObserveOn) with no Rx gate lock held, so RaiseItemsChanged's host call is safe. + private void OnNewsChanged(IReadOnlyList? news) + { + _news = news is null ? null : [.. news.Select(UiNews.From)]; + _offset = 0; // lead with the newest headlines again after each ~30-min refresh + Log.Info("Dock", news is null ? "news ticker: loading" : $"news ticker: {news.Count} headline(s)"); + RaiseItemsChanged(0); + } + + // The cycle step: advance the visible window by one headline so the row scrolls headline-by-headline. Skips + // when there's nothing to cycle (loading, empty, or the whole feed already fits) — no needless repaint. + // Guarded so a transient host hiccup can't tear down the Interval and freeze the cycle (PollTicker's guard + // precedent). + private void OnCycle(long _) + { + try + { + var news = _news; + if (news is null || news.Length <= VisibleCount) + return; + _offset = (_offset + 1) % news.Length; + RaiseItemsChanged(0); + } + catch (Exception ex) + { + Log.Error("Dock", "news ticker cycle failed — continuing", ex); + } + } +} diff --git a/MarketExtension/Properties/Resources.Designer.cs b/MarketExtension/Properties/Resources.Designer.cs index 82029f3..4586890 100644 --- a/MarketExtension/Properties/Resources.Designer.cs +++ b/MarketExtension/Properties/Resources.Designer.cs @@ -1068,6 +1068,24 @@ public static string Settings_NewsRefresh_Label { } } + /// + /// Looks up a localized string similar to Seconds each set of headlines shows before the dock news ticker advances (default 60).. + /// + public static string Settings_NewsTicker_Desc { + get { + return ResourceManager.GetString("Settings_NewsTicker_Desc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to News ticker speed (seconds). + /// + public static string Settings_NewsTicker_Label { + get { + return ResourceManager.GetString("Settings_NewsTicker_Label", resourceCulture); + } + } + /// /// Looks up a localized string similar to Currency for portfolio totals. Other holdings are converted into it.. /// diff --git a/MarketExtension/Properties/Resources.de.resx b/MarketExtension/Properties/Resources.de.resx index 0ea53f6..0ab8893 100644 --- a/MarketExtension/Properties/Resources.de.resx +++ b/MarketExtension/Properties/Resources.de.resx @@ -435,6 +435,12 @@ Nachrichtenaktualisierungsintervall in Minuten (0 = aus). + + Geschwindigkeit des Nachrichtentickers (Sekunden) + + + Sekunden, die jede Schlagzeilengruppe angezeigt wird, bevor der Nachrichtenticker weiterschaltet (Standard 60). + Märkte - Nachrichten @@ -459,6 +465,21 @@ Alle + + Nachrichten benötigen einen Finnhub-Schlüssel + + + Fügen Sie in den Einstellungen einen Finnhub-API-Schlüssel hinzu, um Marktnachrichten zu laden. + + + Marktnachrichten werden von Finnhub bereitgestellt. + + + Keine Schlagzeilen + + + Derzeit sind keine Marktnachrichten verfügbar. + Nachrichten @@ -540,19 +561,4 @@ Südkoreanischer Won (KRW) - - Nachrichten benötigen einen Finnhub-Schlüssel - - - Fügen Sie in den Einstellungen einen Finnhub-API-Schlüssel hinzu, um Marktnachrichten zu laden. - - - Marktnachrichten werden von Finnhub bereitgestellt. - - - Keine Schlagzeilen - - - Derzeit sind keine Marktnachrichten verfügbar. - \ No newline at end of file diff --git a/MarketExtension/Properties/Resources.es.resx b/MarketExtension/Properties/Resources.es.resx index 91cc5ae..881084a 100644 --- a/MarketExtension/Properties/Resources.es.resx +++ b/MarketExtension/Properties/Resources.es.resx @@ -426,6 +426,12 @@ Intervalo de actualización de noticias en minutos (0 = desactivado). + + Velocidad del tícker de noticias (segundos) + + + Segundos que se muestra cada grupo de titulares antes de que el tícker de noticias avance (predeterminado 60). + Mercados - Noticias diff --git a/MarketExtension/Properties/Resources.hi.resx b/MarketExtension/Properties/Resources.hi.resx index a3ab258..3eca97a 100644 --- a/MarketExtension/Properties/Resources.hi.resx +++ b/MarketExtension/Properties/Resources.hi.resx @@ -380,6 +380,12 @@ समाचार रिफ़्रेश अंतराल मिनटों में (0 = बंद)। + + समाचार टिकर गति (सेकंड) + + + डॉक समाचार टिकर के आगे बढ़ने से पहले हर हेडलाइन समूह कितने सेकंड दिखता है (डिफ़ॉल्ट 60)। + मार्केट - समाचार diff --git a/MarketExtension/Properties/Resources.ja.resx b/MarketExtension/Properties/Resources.ja.resx index cccc4b9..800e577 100644 --- a/MarketExtension/Properties/Resources.ja.resx +++ b/MarketExtension/Properties/Resources.ja.resx @@ -382,6 +382,12 @@ ニュースの更新間隔(分単位、0 = オフ)。 + + ニュースティッカーの速度(秒) + + + ドックのニュースティッカーが次に進むまでに各見出しグループが表示される秒数(既定値 60)。 + マーケット - ニュース diff --git a/MarketExtension/Properties/Resources.ko.resx b/MarketExtension/Properties/Resources.ko.resx index a73e63f..d700cf1 100644 --- a/MarketExtension/Properties/Resources.ko.resx +++ b/MarketExtension/Properties/Resources.ko.resx @@ -381,6 +381,12 @@ 뉴스 새로 고침 간격(분 단위, 0 = 끔). + + 뉴스 티커 속도(초) + + + 독 뉴스 티커가 다음으로 넘어가기 전에 각 헤드라인 묶음이 표시되는 시간(초, 기본값 60). + 마켓 - 뉴스 diff --git a/MarketExtension/Properties/Resources.pt-BR.resx b/MarketExtension/Properties/Resources.pt-BR.resx index 13abb22..a4b471a 100644 --- a/MarketExtension/Properties/Resources.pt-BR.resx +++ b/MarketExtension/Properties/Resources.pt-BR.resx @@ -380,6 +380,12 @@ Intervalo de atualização de notícias em minutos (0 = desativado). + + Velocidade do ticker de notícias (segundos) + + + Segundos que cada grupo de manchetes é exibido antes de o ticker de notícias avançar (padrão 60). + Mercados - Notícias diff --git a/MarketExtension/Properties/Resources.resx b/MarketExtension/Properties/Resources.resx index d1046ad..d6617b5 100644 --- a/MarketExtension/Properties/Resources.resx +++ b/MarketExtension/Properties/Resources.resx @@ -435,6 +435,12 @@ News refresh interval in minutes (0 = off). + + News ticker speed (seconds) + + + Seconds each set of headlines shows before the dock news ticker advances (default 60). + Markets - News diff --git a/MarketExtension/Properties/Resources.zh-Hans.resx b/MarketExtension/Properties/Resources.zh-Hans.resx index 0a45b7a..472d43b 100644 --- a/MarketExtension/Properties/Resources.zh-Hans.resx +++ b/MarketExtension/Properties/Resources.zh-Hans.resx @@ -435,6 +435,12 @@ 新闻刷新间隔(分钟,0 = 关闭)。 + + 新闻滚动条速度(秒) + + + 停靠栏新闻滚动条切换到下一组标题前每组显示的秒数(默认 60)。 + 市场 - 新闻 diff --git a/MarketExtension/Settings/MarketSettingsManager.cs b/MarketExtension/Settings/MarketSettingsManager.cs index 3ebac23..7865f2b 100644 --- a/MarketExtension/Settings/MarketSettingsManager.cs +++ b/MarketExtension/Settings/MarketSettingsManager.cs @@ -34,6 +34,11 @@ internal sealed class MarketSettingsManager : JsonSettingsManager // its own clock so it isn't tied to how aggressively a user refreshes prices. private const int DefaultNewsRefreshMinutes = 30; + // Default dock news-ticker cycle: how long each set of headlines shows before the ticker advances to the + // next. In SECONDS (not minutes like the refresh intervals above) since a ticker wants finer control than + // whole minutes; 60 = advance once a minute. Used when the field is blank/unparseable. + private const int DefaultNewsTickerSeconds = 60; + private readonly TextSetting _twelveDataApiKey = new("twelveDataApiKey", string.Empty) { Label = Resources.Settings_TwelveData_Label, @@ -66,6 +71,17 @@ internal sealed class MarketSettingsManager : JsonSettingsManager Placeholder = DefaultNewsRefreshMinutes.ToString(CultureInfo.InvariantCulture), }; + // The dock news-ticker cycle speed (seconds) — a local UI-animation cadence, independent of the network + // refresh intervals. NewsDockPage's cycle timer re-reads it each tick, so a change applies on the next + // advance without a reload. + private readonly TextSetting _newsTickerSeconds = new( + "newsTickerSeconds", DefaultNewsTickerSeconds.ToString(CultureInfo.InvariantCulture)) + { + Label = Resources.Settings_NewsTicker_Label, + Description = Resources.Settings_NewsTicker_Desc, + Placeholder = DefaultNewsTickerSeconds.ToString(CultureInfo.InvariantCulture), + }; + private readonly ToggleSetting _showRateLimitErrors = new("showRateLimitErrors", true) { Label = Resources.Settings_RateLimit_Label, @@ -170,6 +186,18 @@ internal sealed class MarketSettingsManager : JsonSettingsManager // The news cadence as a TimeSpan for the news poll loop. public TimeSpan NewsRefreshInterval => TimeSpan.FromMinutes(NewsRefreshMinutes); + // The dock news-ticker cycle in seconds — how long each set of headlines shows before the row advances. In + // seconds (a ticker wants finer granularity than the minute-based refresh intervals); bad/zero/negative + // input falls back to the default, and the minimum of 1 keeps the cycle timer off a zero/negative interval. + public int NewsTickerCycleSeconds => + int.TryParse(_newsTickerSeconds.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) && v >= 1 + ? v + : DefaultNewsTickerSeconds; + + // The ticker cycle as a TimeSpan, read live by NewsDockPage's self-rescheduling cycle timer each tick (so a + // speed change applies on the next advance, no reload). + public TimeSpan NewsTickerCycleInterval => TimeSpan.FromSeconds(NewsTickerCycleSeconds); + // 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). @@ -202,6 +230,7 @@ private MarketSettingsManager() Settings.Add(_finnhubApiKey); Settings.Add(_refreshMinutes); Settings.Add(_newsRefreshMinutes); + Settings.Add(_newsTickerSeconds); Settings.Add(_showRateLimitErrors); Settings.Add(_demoMode); Settings.Add(_portfolioCurrency); From 21d8623c582069502e74e29432d828a12b3543e1 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 27 Jun 2026 22:49:54 +0300 Subject: [PATCH 8/8] News work in progress --- CLAUDE.md | 1756 +++------------------------------------ notes/architecture.md | 144 ++++ notes/changelog.md | 57 ++ notes/cmdpal-toolkit.md | 127 +++ notes/features.md | 178 ++++ notes/providers.md | 100 +++ notes/releasing.md | 82 ++ 7 files changed, 781 insertions(+), 1663 deletions(-) create mode 100644 notes/architecture.md create mode 100644 notes/changelog.md create mode 100644 notes/cmdpal-toolkit.md create mode 100644 notes/features.md create mode 100644 notes/providers.md create mode 100644 notes/releasing.md diff --git a/CLAUDE.md b/CLAUDE.md index 34518fc..ddafd35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,1677 +1,107 @@ # Markets Extension for Command Palette — Claude Guide -## Documentation +A PowerToys **Command Palette** extension showing live market data — stocks, crypto, forex: search, +watchlist, favorites, portfolio, news, per-symbol detail charts, and pinnable Dock bands. .NET 9 / C# / +MSIX. Hobby project: ships **self-contained single-file JIT** (no trim/AOT — see `notes/providers.md`). -- [Extension overview & concepts](https://learn.microsoft.com/en-us/windows/powertoys/command-palette/extensions-overview) -- [Creating an extension (getting started guide)](https://learn.microsoft.com/en-us/windows/powertoys/command-palette/creating-an-extension) +## Where things are documented + +This file is the quick orientation — keep it short. Deep/historical detail lives in `notes/`: + +| File | Contents | +|---|---| +| `notes/architecture.md` | The market-data architecture: layering + naming, the full file map, the shared quote/news caches, repository orchestration, the `ObserveOn` deadlock fix, polling. | +| `notes/features.md` | Feature deep-dives: navigation/UX, Portfolio, multi-currency, Demo mode, the symbol-detail chart, asset logos, the News feed, Settings. | +| `notes/providers.md` | Twelve Data / Finnhub / Frankfurter API specifics, how to add a provider, the AOT-off rationale + the JSON source-gen gotcha. | +| `notes/cmdpal-toolkit.md` | CmdPal toolkit patterns: the page-activation hook, the ContentPage focus gotcha, CommandResult / ListItem / Icon / ProcessHelper reference. | +| `notes/releasing.md` | Version bump → GitHub Actions → Partner Center, local build/deploy, one-time setup TODOs, localization. | +| `notes/changelog.md` | Condensed "what shipped each round" log + open/deferred items. | +| `notes/CLAUDE_copy.md` | The full prior (very long) version of this guide — verbatim archive. | +| `reference/` | Real implementations from the AdbExtension this was scaffolded from — **not compiled**. Copy/adapt rather than reinventing; see `reference/README.md`. | + +## Documentation links + +- [Extension overview & concepts](https://learn.microsoft.com/en-us/windows/powertoys/command-palette/extension-development) +- [Creating an extension](https://learn.microsoft.com/en-us/windows/powertoys/command-palette/creating-an-extension) - [Toolkit namespace — full class list](https://learn.microsoft.com/en-us/windows/powertoys/command-palette/microsoft-commandpalette-extensions-toolkit/microsoft-commandpalette-extensions-toolkit) - [Command results](https://learn.microsoft.com/en-us/windows/powertoys/command-palette/command-results) -- [Sample extensions](https://learn.microsoft.com/en-us/windows/powertoys/command-palette/samples) -- [Adding Dock support](https://learn.microsoft.com/en-us/windows/powertoys/command-palette/adding-dock-support) — for the future ticker phase; full writeup + worked examples in [`reference/dock-support.md`](reference/dock-support.md) +- [Adding Dock support](https://learn.microsoft.com/en-us/windows/powertoys/command-palette/adding-dock-support) — full writeup in `reference/dock-support.md` ## Build & Deploy -Build via Visual Studio or: -``` -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/`) +`dotnet build MarketExtension.sln` (or Visual Studio). Deploy the MSIX package, then reload Command Palette +to pick up changes. Full release flow + caveats (ARM64 RID pin, `-p:Platform=x64`) → `notes/releasing.md`. -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/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: +## Project conventions -| Example | Demonstrates | -|---|---| -| `reference/pages/AdbExtensionPage.cs` | `DynamicListPage` + async load + the `INotifyItemsChanged` on-load refresh, sections, nested command | -| `reference/pages/PackageActionsPage.cs` | `ListPage` (sync `GetItems`) + `INotifyItemsChanged` fire-on-subscribe variant, per-item action menu | -| `reference/commands/TakeScreenshotCommand.cs` | external process exec, pull a file, success/error toasts, `Win32` errno-2 handling | -| `reference/settings/AdbSettingsManager.cs` | `JsonSettingsManager` singleton + a "keep open" success-toast helper | -| `reference/dock-support.md` | **Dock band** how-to for the future ticker: `GetDockBands()`, live-update lifecycle, and pointers to the Performance Monitor + MediaControls extensions on disk | - -## Project Conventions - -- New commands → `MarketExtension/Commands/`, extend `InvokableCommand` -- New pages → `MarketExtension/Pages/`, extend `ListPage` or `DynamicListPage` -- 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 +- New commands → `MarketExtension/Commands/`, extend `InvokableCommand`. +- New pages → `MarketExtension/Pages/`, extend `ListPage`, `DynamicListPage`, or `ContentPage`. +- All external process execution goes through `ProcessHelper` (`Run()` for captured CLI; `OpenUrl()` to + launch a URL/file) — **never** use `Process` directly in command files. +- **Error toasts** use `CommandResult.KeepOpen()` so the user can read them; one-shot success toasts use the default `Dismiss()`. Exception: in-place list mutations (watchlist/favorites add/remove — see - `Commands/MembershipCommands.cs`) toast **and** `KeepOpen()`, so the user gets explicit confirmation and can keep editing -- Icons: **instrument** rows / dock buttons / the detail header show the real asset logo via - `AssetIconResolver.Resolve(...)` (Elbstream CDN — see "Asset logos (done)"), with a per-category Segoe - glyph fallback; the **extension entry, Settings, and all page chrome** (list/search/dock headers) use the - bundled app logo via `IconHelpers.FromRelativePath("Assets\\markets_logo_base_square.png")` — a full-bleed - charcoal squircle with its own background, so one file covers both light/dark themes (replaced the old - `github.com/favicon.ico` placeholder across all 7 spots). The MSIX **tile/Store** assets in `Assets\` - (`StoreLogo`/`Square*Logo`/`SmallTile`/`LargeTile`/`Wide*`/`SplashScreen`, generated by VS's Manifest - Designer → Visual Assets from a square source — turn **off** "Apply recommended padding" or already-padded - art double-insets and renders small) are a **separate** system from this in-CmdPal icon - -## Market Data Architecture (the core of this app) - -Live quotes flow through three explicitly-named layers + a coordinator. **Keep this layering and -naming for new code.** - -``` -ApiFinnhubQuoteDto ─(provider maps)→ DomainQuote ─(repository routes+merges)→ IReadOnlyList - └─(page maps via UiQuote.From)→ UiQuote (rendered) - -ApiFinnhubCandleDto ─(provider maps)→ DomainCandleSeries ─(repository routes)→ DomainCandleSeries - └─(page maps via UiCandleSeries.From)→ UiCandleSeries (SVG chart) -``` - -The **candle/chart history** flow (added for the symbol-detail chart) mirrors the quote flow through -the same three layers and the same provider seam — see the "Symbol detail + live chart" section. - -**Naming convention:** -- `Api*` — raw provider DTOs (provider-specific), e.g. `ApiFinnhubQuoteDto`. -- `Domain*` — provider-agnostic, **no formatting**; what every provider AND the repository return (`DomainQuote`, `DomainInstrument`, `DomainCandleSeries`). -- `*Entity` — data-layer **storage models** that live BELOW the repository, INSIDE a data source (today just `QuoteEntity` in the cache data source). A structural mirror of the matching `Domain*` that **never escapes its data source**: the repository maps `Domain* ⇄ *Entity` at its boundary (`QuoteEntity.From(domain)` on write, `entity.ToDomainQuote()` on read). Lets the stored shape diverge later (storage-only fields, a DB representation for a DB-backed source) without touching domain or surfaces. (Data → domain is the allowed dependency direction, so an `*Entity` may reference its `Domain*` for mapping — never the reverse.) -- `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**, and now the **orchestration layer** that owns the shared quote cache + polling. Routes each `DomainInstrument` to the first `IMarketDataProvider` whose `Supports(AssetCategory)` matches, fans out concurrently, merges into one order-preserving list. Also `SearchAsync(query)` and `GetCandlesAsync(instrument, range)`. No provider for a category → `IsValid:false` quote / invalid candle series. **`ActiveProviders()`**: any `IsExclusive` provider (the Demo mock) → ALL operations route to it alone. **Owns one `IQuoteCacheDataSource`** (in-memory by default; an injectable ctor overload `MarketRepository(IQuoteCacheDataSource, providers[])` for a future DB-backed cache): `RefreshAsync` **writes through** every fetch (so the cache fills with NO surface changes), and **`ObserveQuotes(...)`** returns a cache-backed `IObservable>` — for a fixed instrument list OR a membership `StateFlow` (Rx `CombineLatest` + `Switch`), **delivered via `ObserveOn(TaskPoolScheduler)`** so a surface's `RaiseItemsChanged` is never invoked while Rx holds the combiner gate lock (**the deadlock fix** — see the orchestration section) — so surfaces OBSERVE instead of fetching, and two surfaces on the same symbol can't drift. **Owns the single poll loop:** one process-lifetime `PollTicker` subscription whose `RefreshObserved` re-fetches the union of currently-OBSERVED instruments each tick — "observed" tracked by a refcounted registry that `ObserveQuotes` Register/Unregisters on subscribe/dispose (so a hidden surface stops being polled). The demo-flip handler `Clear()`s the cache then refills the observed set from the new source. **All priced quote surfaces (the `PricedListPage` trio + both dock bands) now OBSERVE through here**; only the candle chart stays off the cache. See "Shared quote cache + repository orchestration". | -| `Data/IQuoteCacheDataSource.cs` | The **shared observable quote cache** abstraction (an interface, so the in-memory impl now can be swapped for a DB-backed one later without touching the repository or surfaces). **Stores `QuoteEntity` (the data-layer storage model), NOT `DomainQuote`** — `MarketRepository` maps `QuoteEntity ⇄ DomainQuote` at its boundary, so the storage model never escapes the data source. `Get(symbol)` (sync snapshot, null if uncached), `Observe(symbol)` → a per-symbol `IObservable` (replays current value, **null until the first fetch**, then pushes changes; `Get()` is the sync read), `Upsert(quote, keepLastGood=true)` (write-through; **the SINGLE home for keep-last-good** — a transient invalid quote won't overwrite a cached valid one; pass `false` to overwrite unconditionally), `Clear()` (reset every entry to null **keeping observers subscribed** — for a demo source flip). Keyed by `WatchlistStore.Normalize`. | -| `Data/InMemoryQuoteCacheDataSource.cs` | The **real, live** in-memory `IQuoteCacheDataSource`, backed by **DynamicData's `SourceCache`** (a reactive keyed cache — the .NET analog of Android Room's `@Query → Flow`; keyed by `WatchlistStore.Normalize(q.Symbol)`). `Get` = `_cache.Lookup(key)`; `Observe(symbol)` = `Observable.Defer(() => _cache.Connect().Watch(key).Select(c => c.Reason==Remove ? null : c.Current).StartWith(Get(symbol)).DistinctUntilChanged())` (the `StartWith`+`DistinctUntilChanged` make it robust whether or not `Watch` replays on subscribe; **absence = a removed key**, mapped back to null); `Clear()` = `_cache.Clear()` (removes keys → each live `Observe` re-emits null while staying subscribed). **`Upsert` is `_cache.Edit(...)`: the read (`Lookup`), the keep-last-good decision, and the write (`AddOrUpdate`) run ATOMICALLY under the cache's lock** — this is why the cache can own keep-last-good correctly under concurrent write-throughs (poll loop, observe-subscribe fetch, demo flip — no repo-side generation guard), which the old hand-rolled "decide under the lock, `Update` outside it" split could *not* guarantee (two concurrent Upserts could both read the pre-write value and let a transient invalid quote land last). DynamicData fans the change out **after `Edit` returns**, and the **deadlock fix still lives at the `ObserveQuotes` seam (`ObserveOn`)** — the cache's lock is always acquired *upstream* of the Rx gates (nothing downstream re-enters the cache under a gate), so there's no cycle. Swap for a DB-backed `IQuoteCacheDataSource` later via the repo's injectable ctor — no repository/UI change. (Needs the `DynamicData` package; it floors `System.Reactive` at 6.1.0.) | -| `Data/QuoteEntity.cs` | The cache data source's **storage model** (`*Entity` layer): a structural mirror of `DomainQuote` that lives BELOW the repository and **never escapes the data source**. `IQuoteCacheDataSource` stores/emits `QuoteEntity`; `MarketRepository` maps at its boundary — `QuoteEntity.From(domain)` on write-through, `entity.ToDomainQuote()` on read. Identical to `DomainQuote` today, kept separate so the stored shape can diverge later (storage-only fields, a DB representation) without touching domain or surfaces. | -| `Data/IMarketDataProvider.cs` | ONE data source: `bool Supports(AssetCategory)` + **`bool IsExclusive`** (default `false`; true → MarketRepository routes every operation to this provider alone — how the Demo-mode mock takes precedence everywhere) + `GetQuotesAsync(instruments, ct)` + `SearchAsync(query, ct)` (free-text symbol lookup → `DomainInstrument`s, **identity only, no prices**; a provider that can't search returns `[]`) + `GetCandlesAsync(instrument, ChartRange, ct)` (price history for the detail chart; **default interface method** returns an invalid series, so non-candle providers opt out for free). | -| `Data/TwelveData/TwelveDataMarketDataProvider.cs` | **Primary provider when its key is set** (`Supports` Stock+Crypto+Currency, **gated on `HasTwelveDataApiKey`** → first-match routing falls back to Finnhub/Frankfurter when unset). One API for all three classes; **`/time_series` candles are free-tier**, so charts render on a free key. `GetQuotesAsync` **batches all symbols into one `/quote`** call (tight 8/min limit); `SearchAsync` = `/symbol_search` (results normalized back to neutral symbols). See Twelve Data specifics. | -| `Data/Finnhub/FinnhubMarketDataProvider.cs` | Stock+Crypto provider (`Supports` Stock+Crypto), used when no Twelve Data key is set. Maps `ApiFinnhubQuoteDto` → `DomainQuote`; `SearchAsync` calls `/search` (US equities only — see Finnhub specifics). | -| `Data/Frankfurter/FrankfurterMarketDataProvider.cs` | FX provider (`Supports` Currency). **Keyless** ECB daily rates via Frankfurter (`https://api.frankfurter.dev/v1/`). Splits a 6-letter pair (`EURUSD` → base `EUR`/quote `USD`), calls the time-series endpoint for both quotes (latest vs previous fixing = **day-over-day** change) and candles (daily closes). `SearchAsync` = local filter over the catalog's FX pairs (no Frankfurter free-text endpoint exists). See Frankfurter specifics. | -| `Data/Frankfurter/ApiFrankfurterDto.cs` | Raw time-series DTO (`ApiFrankfurterSeriesDto`: `rates` = date→{currency→rate}) **+ the flat `/latest` DTO (`ApiFrankfurterLatestDto`: `rates` = currency→rate)** used by `CurrencyConverter` + the source-gen `FrankfurterJsonContext`. | -| `Data/MockMarketDataProvider.cs` | The **Demo-mode** data source — quotes/candles for every asset class so the whole UI works with no key/network. Registered **first**; `Supports()` is **gated on the `DemoMode` setting** and **`IsExclusive`** also returns it, so in demo mode the repository routes **everything (quotes, candles, search)** to the mock alone — it wins everywhere and no live provider is hit. Off → serves nothing, live providers take over (`SearchAsync` self-gates to `[]` so it stays out of the live search fan-out). **Serves ANY symbol:** a small hand-tuned `Seed` (headline symbols, incl. GBP `HSBA.L`) overrides on top of `SynthesizeQuote` — a stable-hash, category-/currency-inferred quote for any other ticker, so an off-seed real holding still prices. **Search** matches a curated **`Catalog`** of ~120 *real* instruments (never fabricated symbols — a faked match could be persisted and break when demo is off). See "Demo mode (offline testing)". | -| `Data/InstrumentCatalog.cs` | Static `DomainInstrument` defaults — the **first-run seed** for `WatchlistStore` (no longer always-shown; removable once seeded). | -| `Settings/WatchlistStore.cs` | JSON-persisted tracked instruments, each carrying **two independent flags** `InWatchlist`/`IsFavorite` (favorites = the dock subset). Stores **full `DomainInstrument` identity** so searched non-catalog symbols re-price; an entry with both flags false is dropped. Source-gen `WatchlistItem`/`WatchlistJsonContext` → `market_watchlist.json`; seeds `InstrumentCatalog` on first run, else migrates the legacy `market_favorites.json` (old pins → watchlisted **and** favorited). Exposes its `Watchlist`/`Favorites` subsets as **observable `StateFlow`s** (each mutation calls `PublishState()` → re-publishes both); pages and the dock **subscribe** and re-render themselves. Replaces the old `FavoritesStore` + `Watchlist.cs`. | -| `Helpers/StateFlow.cs` | A tiny **Kotlin-StateFlow analog**, now a **thin wrapper over `System.Reactive`'s `BehaviorSubject`** (the migration off the old hand-rolled version is **done** — AOT/trim is off, so the Rx dependency is taken warning-free; see the Rx done bullet). Used by the **state holders** (`WatchlistStore`, `MarketSettingsManager`) — observable *state with a current value*, which `IObservable` (no `Value`) and a bare `BehaviorSubject` (no read-only face) can't express alone. Public API unchanged: `StateFlow` (read-only `Value` + `Subscribe` with **replay-on-subscribe** + distinct-until-changed; a `Subscribe(onNext, replayOnSubscribe:false)` overload opts out of the replay via `Skip(1)` — used by the priced pages' secondary flows, e.g. `HasAnyApiKey`) and writable `MutableStateFlow` (`Update`). `SetValue` does **source-side** distinct-until-changed (an equal value isn't pushed, so re-publishing an unchanged subset doesn't wake its subscribers); `BehaviorSubject.OnNext` fans handlers out **outside** its lock (handlers re-read the store safely). The old hand-rolled **subscriber-count seam** (`OnActive`/`OnInactive` + the refcount + a custom `Subscription` wrapper) was **removed** once its only user, `PollTicker`, went pure Rx — Rx's `Publish().RefCount()` is the proper home for that, and Rx subscriptions are already idempotent on dispose. Also exposes **`AsObservable()`** (the raw `BehaviorSubject` stream, replays on subscribe) so the quote cache's per-symbol flows compose with Rx `CombineLatest`/`Switch` in `MarketRepository.ObserveQuotes`. Plus `InstrumentListComparer` (symbol-sequence dedup for the store's two flows). | -| `Helpers/PollTicker.cs` | The **live-price poll ticker** — now **pure Rx** (no longer a `StateFlow`). A process-wide singleton `IObservable` = `Observable.Generate(...)` (self-rescheduling timer; per-step delay re-read from `MarketSettingsManager` each iteration so interval/on-off applies without reload, `0` = off idles on a 30 s re-check and the tick is filtered out) wrapped in **`Publish().RefCount()`** — the WhileSubscribed seam that starts the loop on the first subscriber and tears it down on the last (Generate never completes, so disposal is silent and resubscribe restarts cleanly). `Defer`/`Finally` log the start/stop at the refcount edges. Surfaces attach via the **guarded** `PollTicker.Subscribe(onTick)` (the raw stream is private): the handler is wrapped in try/catch (swallow + `Log.Error`) so a throwing tick handler can't trip Rx's `SafeObserver` into tearing down the shared multicast stream (which would kill polling for every surface). Handlers still offload heavy work via `Task.Run`. **Two subscriber kinds now:** `MarketRepository` holds ONE process-lifetime subscription that polls the *observed* set centrally, so every priced quote surface (the `PricedListPage` trio + both dock bands) observes the cache and no longer subscribes here. The **only** direct subscriber left is the symbol-detail **chart** (a separate candle path with no cache layer — it re-fetches its visible range each tick). | -| `Helpers/HttpRetry.cs` | **Shared 429 back-off** at the HTTP seam. `SendAsync(send, tag, ct)` takes a request **thunk** (each retry must re-issue a fresh `HttpResponseMessage`) and, on HTTP `429`, honors a short `Retry-After` else backs off `1s`→`2s` (max 3 attempts, **bails** if the wait would exceed an `8s` cap — per-minute windows don't clear in seconds, so hammering only burns quota). Returns the final response (success / non-429 error / surviving 429) so callers inspect status exactly as before — a **drop-in** at each `GetAsync`. Also the single choke point that feeds `RateLimitSignal` (2xx → off, surviving 429 → on). Used by Finnhub + Twelve Data (not keyless Frankfurter). | -| `Helpers/RateLimitSignal.cs` | Process-wide **"are we throttled" flag** — a `MutableStateFlow` behind a read-only `StateFlow` (same state-holder idiom as `WatchlistStore`/`MarketSettingsManager`). `ReportRateLimited()`/`ReportSuccess()` flip it (distinct-until-changed); priced surfaces **subscribe** and re-render. Intentionally **global, not per-symbol** (a free-tier limit is key-wide). | -| `Helpers/RateLimitHint.cs` | The **rate-limited banner row** (parallels `ApiKeyHint.StatusRow()`): `Row()` returns an amber "Rate-limited — showing last known prices" `ListItem` (Enter = **`NoOpCommand`**, purely informational — it's the default-selected first row, so it must not navigate) when `RateLimitSignal` is set **and** the `ShowRateLimitErrors` setting is on, else `null`. Pinned to the **top** so it's seen without scrolling: `PricedListPage.GetItems` inserts it at index 0; `SearchPage.SearchItems` inserts it at index 1 (just under the Enter-to-search action, which must stay first). | -| `Data/Finnhub/ApiFinnhubQuoteDto.cs` | Raw `/quote` DTO + the **single** `FinnhubJsonContext` (all `[JsonSerializable]` live here — see AOT/trim gotcha). | -| `Data/Finnhub/ApiFinnhubSearchDto.cs` | Raw `/search` DTOs (`ApiFinnhubSearchDto` / `...ResultDto`); registered on `FinnhubJsonContext` in the quote file. | -| `Data/Finnhub/ApiFinnhubCandleDto.cs` | Raw `/stock/candle` + `/crypto/candle` DTO (parallel `c/h/l/o/t/v` arrays + `s` status); registered on `FinnhubJsonContext` in the quote file. **Premium** (free key → 403). | -| `Data/TwelveData/ApiTwelveDataQuoteDto.cs` | Raw `/quote` DTO + the **single** `TwelveDataJsonContext` (all `[JsonSerializable]` here, incl. the keyed `Dictionary` batch response; `NumberHandling=AllowReadingFromString` since TD encodes numbers as JSON strings). | -| `Data/TwelveData/ApiTwelveDataCandleDto.cs` | Raw `/time_series` DTOs (`ApiTwelveDataTimeSeriesDto` + per-bar `ApiTwelveDataValueDto`, **newest-first** — provider reverses to oldest-first); registered on `TwelveDataJsonContext`. **Free tier** (unlike Finnhub candles). | -| `Data/TwelveData/ApiTwelveDataSearchDto.cs` | Raw `/symbol_search` DTOs (`data[]` of symbol/instrument_name/instrument_type); registered on `TwelveDataJsonContext`. | -| `Models/{AssetCategory,DomainInstrument,DomainQuote,UiQuote}.cs` | the quote model layers. `DomainQuote` now carries a **`Currency`** (ISO-4217 native currency of the price, default `"USD"`); `UiQuote.FormatPrice` renders stock/crypto in that native currency's symbol (FX-rate prices stay raw 4-decimal). See "Multi-currency portfolio conversion (done)". | -| `Models/ChartRange.cs` | `ChartRange` (1D/1W/1M/1Y/5Y) **+ `CandleInterval`** enums + neutral helpers (`Label`/`Lookback`/`Interval`/`FromLabel`). Provider-agnostic — **no resolution tokens here** (those live in the provider). | -| `Models/DomainCandleSeries.cs` | Domain history: `Symbol`, `Range`, ordered `CandlePoint`s (`Time`+`Close`), `IsValid`; `Invalid(...)` factory + `First/LastClose`. No formatting. | -| `Models/UiCandleSeries.cs` | Ui projection of a `DomainCandleSeries`: `IsUp`, `FormatPrice`, `FormatRangeChange` (Robinhood-style net change over the selected range), `ChartImageUrl()`. The ONLY place chart formatting/SVG live. | -| `Helpers/ChartHelper.cs` | Ports Perf Monitor's **SVG-sparkline-as-`data:`-URI** (pure `System.Xml.Linq`, AOT-safe), generalized to plot N points + normalize Y to the series min/max, recolored green/red. Draws a **faint quarter gridline box** (0/¼/½/¾/1 on both axes) so the scale reads at a glance. **No numeric tick labels:** the host rasterizes the data-URI through Direct2D's SVG engine (`ID2D1SvgDocument`), which renders lines/polylines/rects/gradients but **silently drops ``** — so axis numbers aren't possible on this surface (the grid is decorative only). Grid uses a theme-neutral mid-gray (static image can't read the host theme). Only caller: `UiCandleSeries`. | -| `Helpers/AssetIconResolver.cs` | Instrument identity → row/dock `IconInfo`. Builds **Elbstream** logo URLs by category (`/logos/symbol/{t}`, `/logos/crypto/{c}`, FX pair → base currency's flag `/logos/country/{iso2}` via a currency→country map, `?format=png`); Segoe-glyph fallback for unmapped currencies/unknown. **Zero API calls** (the host fetches the URL). Also the shared `AttributionRow()` factory (the required Elbstream credit). See "Asset logos (done)". | -| `Helpers/CurrencyConverter.cs` | Process-wide singleton that converts money between currencies via Frankfurter's keyless **`/latest`** spot rates (same ECB source as the FX provider). Two-phase for the sync-render constraint: **`PrimeAsync(preferred, natives)`** fetches every not-yet-fresh `native→preferred` rate in **one batched call** (base=preferred → invert each) and caches per ordered pair with a **~1 h TTL** (negative results cached too); **`TryGetRate(from, to)`** is the synchronous cache read used while rendering (`from==to → 1`, unknown/unsupported → `null`). Used only by `PortfolioPage` (off the price-load path via `OnPriceCacheUpdated`). | -| `Helpers/CurrencyFormat.cs` | UI helper: render a `decimal` as money in an ISO-4217 currency — the home for the per-currency **symbol** (`$`/`€`/`£`/`¥`/…) + decimal-place rules that replaced the hardcoded `$`. `Format`/`FormatSigned` (sign-before-symbol for P&L), culture-invariant; unknown code → trailing code (`1,234.56 SGD`); JPY/KRW render 0-decimal. Used by `UiQuote`, `UiPosition`, `UiPortfolio`. | -| `Helpers/CurrencyHelper.cs` | Currency conventions the **providers** apply when stamping `DomainQuote.Currency`: `NormalizeStockQuote` folds London's **GBX/GBp pence → GBP** (price & change ÷100; the 100× trap) and upper-cases the code (null → `USD`); `QuoteCurrencyOfPair` returns an FX pair's quote (2nd) currency (`EURUSD → USD`). One home for the pence rule across Twelve Data + Finnhub. | -| `Pages/SymbolDetailPage.cs` | Shared per-symbol screen: nested `SymbolChartForm : FormContent` (adaptive-card chart + range tabs) + the list-management command bar. Flicker on range switch is fixed; ⚠️ the Enter-steals-focus bug is an **open known limitation** — see the chart section. | -| `Data/Finnhub/ApiFinnhubNewsDto.cs` | **News Api layer**: the raw `/news` item DTO (one article). The `/news` response is a **bare top-level array**, so the registered root on `FinnhubJsonContext` is `ApiFinnhubNewsDto[]` (deserialize via `…Default.ApiFinnhubNewsDtoArray`). See "Market News (feed)". | -| `Models/DomainNews.cs` | **Domain news article** (provider-agnostic, no formatting): `Id` (reusable as the `minId` cursor), `Headline`, `Source`, `Summary`, `Category` (the article's OWN free-text category, e.g. "technology" — **NOT** the `NewsCategory` enum), `ArticleUrl`, `Published` (`DateTimeOffset`), optional `ImageUrl`/`Related`. Like `DomainInstrument`, **no `IsValid`** — the provider drops bad items (missing id/headline/url) at its boundary. | -| `Models/NewsCategory.cs` | The **feed-selector enum** (General/Forex/Crypto/Merger) + `NewsCategoryExtensions` (`All`, `Label()`). Unprefixed **shared vocabulary** like `AssetCategory`/`ChartRange` (NOT a `Domain*` model — it classifies/parameterizes operations, it isn't data carried through the layers). | -| `Models/UiNews.cs` | **Ui projection** of `DomainNews` (the ONLY place news formatting lives): `FormatSubtitle()` ("CNBC · 2h ago"), `FormatRelativeTime()`, `Matches(query)`. Toolkit-free — the page builds `IconInfo`/`Details` from these strings (like `UiQuote`). | -| `Data/NewsEntity.cs` | The news cache's **`*Entity` storage model** — a structural mirror of `DomainNews` BELOW the repository; the repo maps `DomainNews ⇄ NewsEntity` at its boundary (`From`/`ToDomainNews`), so the entity never escapes the data source. | -| `Data/INewsCacheDataSource.cs` | The **shared observable news cache** abstraction — the news analog of `IQuoteCacheDataSource`, but keyed by **`NewsCategory`** with a **LIST** of `NewsEntity` per category (news is inherently a list, vs one quote per symbol). `Get`/`Observe`/`Upsert(keepLastGood)`/`Clear`. **keep-last-good here = a transient EMPTY result won't wipe a non-empty cached feed.** | -| `Data/InMemoryNewsCacheDataSource.cs` | The live in-memory impl — DynamicData `SourceCache` (a **private** per-category list wrapper that never crosses the interface). Atomic `Edit` for keep-last-good; `Observe` emits **`null` until the first fetch** (and maps Remove/Clear→`null`) — mirroring the quote cache's `QuoteEntity?` so a non-null EMPTY feed reads as "loaded-but-empty", distinct from the null "loading"; an identical refetch is deduped via `SequenceEqual`. | -| `Pages/NewsPage.cs` | The **Markets → News** screen (a hub row): a `DynamicListPage` **pure observer** of `MarketRepository.ObserveNews(selection flow)`. Category dropdown (toolkit `Filters`): **"All"** (merged, default) + General/Forex/Crypto/Mergers; Enter opens the article (`OpenUrlCommand`); summary in the `Details` pane; typed search filters headlines client-side. See "Market News (feed)". | -| `Pages/NewsDockPage.cs` | The **news dock band** — a CNBC-style scrolling-marquee news ticker. A `ListPage` **pure observer** of `MarketRepository.ObserveNews(null = "All")`, rendering a **row of 4 headline buttons** (each opens its own article via `OpenUrlCommand`) that **cycles** the visible window through the feed one headline per tick. A single button can't smoothly crawl — the host caps each dock button's Title at `MaxWidth=100`≈15 chars (`CharacterEllipsis`/`NoWrap`; see PowerToys `DockItemControl.xaml`) and won't pixel-scroll a Title — so it's a row of buttons, not one scrolling line. Cycle cadence = the `NewsTickerCycleInterval` setting (default 60 s), re-read **live each tick** via a dedicated per-band `Observable.Generate` (NOT `PollTicker`). Third `CommandItem` in `GetDockBands()` (`Id = com.costafotiadis.market.dock.news`). See "Market News (feed)" + the "Done (latest)" status bullet. | - -**To add a provider** (e.g. forex): implement `IMarketDataProvider` (`Supports`, `GetQuotesAsync`, -and `SearchAsync` — return `[]` if it can't search; optionally **override `GetCandlesAsync`** to serve -chart history, else the default invalid-series opt-out applies), map its `Api*` DTO → `DomainQuote` -(and → `DomainCandleSeries` for candles), and register it in `MarketExtensionCommandsProvider`: -`new MarketRepository(new FinnhubMarketDataProvider(), new YourProvider())`. **Zero UI changes.** For -candles, the provider also translates the neutral `ChartRange.Interval`/`Lookback` into its own API -tokens — Finnhub's `ToFinnhubResolution` (`5/30/60/D/W`) is the model; `TwelveDataMarketDataProvider` -maps the same `CandleInterval` to `5min/30min/1h/1day/1week`. **Worked examples:** -`FrankfurterMarketDataProvider` (keyless FX) and `TwelveDataMarketDataProvider` (an all-three-classes -source, primary when its key is set) are both exactly this — added behind the seam with zero -UI/repository changes (only a registration line + a settings key). - -**Twelve Data specifics (primary provider):** -- Base `https://api.twelvedata.com/`, key via `apikey=` query param (per-provider setting - `TwelveDataApiKey` — see API Key). **One source for stocks + crypto + forex.** Registered FIRST so it's - primary, but `Supports()` is **gated on the key** → a no-key install falls through to Finnhub/Frankfurter. -- Free tier **~8 credits/min, ~800/day** (1 credit/symbol) — tighter per-minute than Finnhub. So - `GetQuotesAsync` **batches every instrument into ONE `/quote` call** (comma-joined). Response shape - differs by count: **>1 symbol** → object keyed by symbol (`Dictionary`); - **1 symbol** → a bare quote object. A global error (bad key/429) can come back as a bare - `{"status":"error"}` even on HTTP 200 → the parse degrades to all-invalid (kept off the error path). -- Symbol formats: stock `AAPL`, crypto `BTC/USD`, FX `EUR/USD`. `ToTwelveDataSymbol` maps our neutral - symbols (bare ticker / bare coin `BTC`→`BTC/USD` / 6-letter `EURUSD`→`EUR/USD`); `SearchAsync` - normalizes results **back** (crypto → base coin, FX → 6-letter) so they round-trip and match the icon - resolver + watchlist storage. -- **Candles are FREE-tier** — the headline win: `GET /time_series?symbol=&interval=&outputsize=` renders - the symbol-detail chart on a free key (Finnhub's candles are premium → 403). `interval` via - `ToTwelveDataInterval`; `outputsize` sized per range. Values arrive **newest-first** → the provider - reverses to oldest-first. Numbers are JSON **strings** → `NumberHandling=AllowReadingFromString`. -- **Search** `/symbol_search?symbol=` returns identity only across all three classes; `instrument_type` - → `AssetCategory` (equity-ish → Stock, "Digital Currency" → Crypto, "Physical Currency" → Currency). - -**Finnhub specifics:** -- Base `https://finnhub.io/api/v1`, endpoint `/quote`. Free tier ~60 calls/min, ~300/day. -- **No forex on the free tier** — `OANDA:*` returns 403. FX is therefore routed to the keyless - `FrankfurterMarketDataProvider` (ECB rates) rather than Finnhub; see Frankfurter specifics below. -- Symbol formats: stock = bare (`AAPL`), crypto = `BINANCE:{SYM}USDT`, FX = `OANDA:{BASE}_{QUOTE}`. -- An all-zero `/quote` response = invalid/unknown symbol → map to `IsValid:false`. -- **Candles (OHLCV) — PREMIUM** (free key → 403): `GET /stock/candle?symbol=&resolution=&from=&to=` - (crypto: `/crypto/candle`, same shape). `resolution` ∈ `1,5,15,30,60,D,W,M`; `from`/`to` are **UNIX - seconds**. Response = parallel arrays `c/h/l/o/t/v` + `s` (`ok`|`no_data`). Daily is split-adjusted; - intraday is unadjusted and **only ~1 month per call**. `FinnhubMarketDataProvider.GetCandlesAsync` - maps each `ChartRange`: 1D→`5`/1d, 1W→`30`/7d, 1M→`60`/31d, 1Y→`D`/365d, 5Y→`W`/5y (keeping - 1D/1W/1M inside the intraday cap). 403/429/`no_data` → invalid series → the chart shows an - "unavailable" message rather than blanking. -- **Symbol search** `/search?q=&exchange=US` (free tier): returns identity only (`symbol`/`description`/ - `type`), **no prices**. `symbol` is the canonical id for `/quote`; `displaySymbol` is for UI. Scoped to - **US equities** and mapped to `AssetCategory.Stock` — Finnhub's canonical `symbol` only round-trips - through `ToFinnhubSymbol(Stock)` for plain US tickers; crypto/FX search needs symbol-format - reconciliation (deferred). UI is **Enter-only** (the synthetic "Search Finnhub for …" item), never - per-keystroke, to protect the rate limit. A name can list across exchanges → dedupe by symbol. - -**Frankfurter specifics (FX):** -- Base `https://api.frankfurter.dev/v1/` (the old `api.frankfurter.app` 301-redirects here). **Keyless, - no documented rate limit** → no key short-circuit, no `Has…Key` gate. -- Wraps the **ECB daily reference rates**, so data is **daily only** (one fixing per business day, ~16:00 - CET). Implications baked into the provider: a quote's **change is day-over-day** (latest vs previous - fixing — the FX analog of Finnhub's `d`), and charts plot **daily closes** (the 1D/1W tabs show recent - daily fixings, not intraday bars — the candle lookback is floored at 7 days so short ranges still draw). -- **One endpoint for both flows** — the time series `GET /{from}..{to}?base={BASE}&symbols={QUOTE}` → - `{ "rates": { "2024-06-03": { "USD": 1.0842 }, … } }`. Quotes fetch a ~10-day window (≥2 fixings across - weekends) and take the last two points; candles fetch the range's lookback. Any base works (`base=USD` - for USDJPY is fine — Frankfurter cross-converts via EUR). -- Pairs are the neutral **6-letter `BASE+QUOTE`** (`EURUSD`); the provider splits 3+3. `SearchAsync` is a - **local filter over `InstrumentCatalog`'s FX pairs** (Frankfurter has no symbol-search endpoint), so the - catalog is the single source of truth for which pairs are seeded *and* searchable. -- Icons: an FX pair shows its **base currency's country flag** (Elbstream `/logos/country/{iso2}`, EUR→`eu`) - via the `AssetIconResolver` currency→country map; unmapped currencies fall back to the bank glyph. - -**AOT/trim (intentionally OFF):** this is a hobby project that does **not** trim or Native-AOT compile — -it ships **self-contained single-file JIT** (see `MarketExtension.csproj`; same shape AdbExtension -publishes to the Store), and enabling AOT/trim is a **deliberate non-goal**. The trim/AOT analyzers + the -CsWinRT AOT optimizer were **removed**, so reflection-based code (e.g. `System.Reactive`, reflection-based -`JsonSerializer`) now builds **warning-free** — there is no `IL2026`/`IL3050` enforcement anymore. (To -revive AOT readiness, restore `true` + the CsWinRT optimizer lines from git history.) -The existing source-gen `JsonSerializerContext`s are kept as a **convention, not a build requirement**: -`FinnhubJsonContext` (quotes + search + candles), `FrankfurterJsonContext` (FX time-series), -`TwelveDataJsonContext` (quotes + candles + search; `NumberHandling=AllowReadingFromString` for TD's -string-encoded numbers, and the keyed `Dictionary` batch response is registered too), and -`WatchlistJsonContext` (watchlist items + legacy migration) all still work and **don't need changing** — -new JSON may use either source-gen or plain reflection. -⚠️ **Gotcha (only while a context stays source-gen):** the JSON source generator does **not** support -`[JsonSerializable]` attributes split across multiple `partial` declarations of one context — it emits a -colliding hintName (e.g. `FinnhubJsonContext.Decimal.g.cs`) and the *whole* generator silently fails, -cascading CS0534 ("does not implement … `GetTypeInfo`") onto **every** context in the build. Keep all -`[JsonSerializable]` for a given context on a **single** declaration (see `ApiFinnhubQuoteDto.cs`). - -## API Key (runtime setting — no build-time key) - -- **Twelve Data key (primary provider)** — `TwelveDataApiKey`/`HasTwelveDataApiKey`, same - `market.settings.json`. When set, Twelve Data serves stocks/crypto/FX **and free charts**; its - `Supports()` is gated on the key, so an unset key **falls back** to Finnhub/Frankfurter (no - regression). Read per request (a key change applies without a reload); `SearchAsync` returns `[]` and - quote/candle fetches short-circuit to "no data" when unset. Having **both** TD and Finnhub keys set is - harmless but redundant: TD (registered first) wins all pricing/candles; Finnhub then only contributes - extra `/search` hits (search fans out to every provider and dedupes by symbol). -- The Finnhub key is provided **exclusively at runtime** via the extension's settings - (`Settings/MarketSettingsManager.cs`, exposed as `FinnhubApiKey`/`HasFinnhubApiKey`, persisted to - `…/Microsoft.CmdPal/market.settings.json`). There is **no built-in/baked key** — the old - `secrets.props` + csproj `GenerateSecrets` + `Secrets.FinnhubApiKey` machinery was removed. -- `FinnhubMarketDataProvider` reads `MarketSettingsManager.Instance.FinnhubApiKey` on each request - (so a key change applies without a reload) and **short-circuits to "no data"** (invalid quotes / - empty search / invalid candle series, with a `Log.Warn`) when no key is set, rather than firing - keyless requests that would just 401. -- **Per-provider naming on purpose:** keys are named per provider (`FinnhubApiKey`, not a generic - `ApiKey`) because future providers (e.g. a forex source) each get their own key setting here. + `Commands/MembershipCommands.cs`) toast **and** `KeepOpen()`. +- **Don't hardcode user-facing strings** — add a key to `Properties/Resources.resx` and use + `Strings.Get`/`Strings.Format`. Regenerate `Resources.Designer.cs` from VS after editing the resx. +- **Icons:** instrument rows / dock buttons / detail header use `AssetIconResolver.Resolve(...)` (real asset + logo, Segoe-glyph fallback); page chrome + the extension entry use + `IconHelpers.FromRelativePath("Assets\\markets_logo_base_square.png")`. + +## Architecture in one screen + +Layered, provider-agnostic. **Keep the naming for new code:** + +- `Api*` — raw provider DTOs. `Domain*` — provider-agnostic, no formatting. `*Entity` — data-layer storage + models that never escape a data source. `Ui*` — the ONLY place formatting/SVG lives. Enums + (`AssetCategory`, `ChartRange`, `NewsCategory`, …) stay unprefixed. +- `Data/MarketRepository.cs` is **the coordinator the UI depends on**: routes each instrument to the first + `IMarketDataProvider` that `Supports` its category, owns the shared observable quote cache + the single + poll loop, and exposes `ObserveQuotes(...)` / `GetCandlesAsync(...)` / `ObserveNews(...)`. +- **All priced surfaces OBSERVE the cache** (they don't fetch) so the same symbol can't drift between two + surfaces. The lone exception is the symbol-detail chart (a separate candle path). +- **Entry point** (`MarketExtensionCommandsProvider`): one top-level **Markets** command → + `MarketsPage` hub (Search / Watchlist / Favorites / Portfolio / News / Data Sources / Settings). Three + **dock bands**: Favorites, Portfolio, News. Providers registered Mock → TwelveData → Finnhub → Frankfurter. + +Full file map, the cache/orchestration design, and the load-bearing `ObserveOn` deadlock fix are in +`notes/architecture.md`. To add a data provider, see `notes/providers.md`. + +## API keys (runtime only — no build-time key) + +Keys are provided **exclusively at runtime** via the extension's settings +(`Settings/MarketSettingsManager.cs` → `…/Microsoft.CmdPal/market.settings.json`); there is **no baked-in +key**. Keys are **per provider** (`TwelveDataApiKey`, `FinnhubApiKey`) because future providers each get +their own. Twelve Data, when its key is set, is primary (stocks/crypto/FX + free charts) and falls back to +Finnhub/Frankfurter when unset. Providers read the key per request (a change applies without a reload) and +short-circuit to "no data" when unset. Frankfurter (FX) is keyless. ## Logging -- `Log.Info/Warn/Error(tag, message)` (`Helpers/Log.cs`) — tagged by component (`Finnhub`, - `Repository`, `ComServer`, `Startup`). -- `Info`/`Warn`/`Error` are all `[Conditional("DEBUG")]` → **compiled out of Release** (the MSIX ships - silent, with **no telemetry channel** — nothing leaves the machine). Sentry was removed entirely. -- Watch live (Debug builds): VS **Debug → Attach to Process → `MarketExtension.exe`** (Managed) → - Output window; or Sysinternals **DebugView** ("Capture Global Win32"). **NEVER log the API token.** - -## Current Status / Next Steps - -- **Done (latest): News dock band — a CNBC-style scrolling news ticker (closes News Left-to-do #3). ✅ Live-verified on-device.** - A third Dock band (`Pages/NewsDockPage.cs`) next to the favorites + portfolio bands: a market-news ticker that - scrolls headlines like a TV news crawl. A **pure observer** of the shared news cache like the other dock bands — - it subscribes to `MarketRepository.ObserveNews(MutableStateFlow(null))` (the merged **"All"** feed) - while visible and renders what it emits; no fetch/poll itself. **Rendering: a ROW of headline buttons, NOT one - scrolling Title** — the host caps each dock button's Title at **`MaxWidth=100` DIPs** (Segoe UI 12 → ~15 chars, - `CharacterEllipsis`, `NoWrap`; see PowerToys `DockItemControl.xaml`), and a Title is static left-aligned text the - host does **not** pixel-scroll (a char-step crawl just "pops" jerkily — confirmed the wobble is unfixable on this - surface; two earlier single-button approaches — a 60-char then a 200-char overflow window — both wobbled). A - dock-band `ListPage` renders **each `GetItems()` item as its own ≤100px button** (how the favorites band shows - several tickers), so a row of **4** headline buttons is a wider, stable, jitter-free strip; each button opens - **its own** article (`OpenUrlCommand`, stable per-article `Id`), and we "go through the feed" by advancing the - visible window one headline each cycle (`offset+1`, wrapping). **Cycle speed is a new setting** — - `News ticker speed (seconds)` (`newsTickerSeconds`, surfaced as `NewsTickerCycleSeconds`/`NewsTickerCycleInterval`, - **default 60 s = 1 min**; in seconds, not minutes, for finer ticker control) — read **live each tick** by a - dedicated per-band self-rescheduling `Observable.Generate` on `TaskPoolScheduler` (PollTicker's live-settings - trick; NOT PollTicker itself — that's data-refresh, this is UI animation), so a speed change applies on the next - advance with no reload. Both the news subscription and the cycle timer are held in `_subscriptions` and disposed - on hide (no orphaned repaint loop). Registered as a third `CommandItem` in `GetDockBands()` - (`Id = com.costafotiadis.market.dock.news`). `Settings_NewsTicker_*` resx strings across all 8 locales + the - Designer; the `ArticleGlyph` fallback icon is built from `((char)0xE9F9)` (the code point — no literal glyph or - `\u` escape for the Write tool to mangle). Build clean (0 warnings). ✅ **Live-verified on-device.** -- **Done (previous): News feature polish — empty-state / no-key UX + rate-limit banner (Left-to-do #2, #4, #5).** - Closed three of the news "Left to do" items. **#2 (the perpetual-spinner bug):** Finnhub's `SupportsNews` is - unconditionally `true`, so with no Finnhub key `/news` is still attempted and just returns `[]` → the cold news - cache stays empty → `NewsPage` treated empty as "loading" forever. Fixed with (a) a **Finnhub-gated - `ApiKeyHint.NewsStatusRow()`** — gates on `HasFinnhubApiKey`, NOT the broader `HasAnyApiKey` (a Twelve-Data-only - key can't serve news) — that renders a "News needs a Finnhub key" row instead of spinning, and (b) making the - news cache/observe emit **`null` until the first fetch lands** (mirroring the quote cache's `QuoteEntity?`), so a - non-null-but-EMPTY feed reads as "loaded-but-empty" and `NewsPage` derives loading (`null`) / empty - (`News_Empty_*` row) / items purely from the stream — no side query into the repository (an earlier draft added - a `HasAttemptedNewsFetch` repo method for this; pushing the distinction into the stream is cleaner and matches - how the quote cache already models not-yet-loaded). For "All", the merge emits `null` until every category has - been fetched, so an empty merge is genuinely "no headlines". **#4:** - `NewsPage.GetItems` now inserts `RateLimitHint.Row()` at the top and subscribes to - `RateLimitSignal.Instance.IsRateLimited` (replay:false) to re-render on throttle start/stop, like the priced - pages + Search. **#5:** glyph check done (verified; the stale "verify/adjust glyph" comment was removed). New - resx strings (`Status_NewsNoKey_*`, `News_Empty_*`) across all 8 locales + the Designer. Build clean (0 - warnings). ✅ The news path is now **live-verified on-device** (#1 — confirmed via the news dock band rendering - live headlines). Remaining open: #6 minId paging, #7 multi-provider, #8 native translation review. -- **Done (previous): Market News feature (MVP — built end-to-end, NOT yet live-verified).** A **Markets → News** - hub screen lists market-news headlines (each row opens the source article in the browser), built behind the - SAME layered seams as quotes with a parallel cache + repository-orchestration story. The pieces: a Finnhub - `/news` provider method (`GetNewsAsync`) behind a new **`SupportsNews`** capability on `IMarketDataProvider` - whose default body **THROWS** (so non-news providers fail loud — unlike candles' soft opt-out); a - `DomainNews`/`NewsCategory`/`UiNews` model trio + `ApiFinnhubNewsDto`; a **shared observable news cache** - (`INewsCacheDataSource`/`InMemoryNewsCacheDataSource`, keyed by **`NewsCategory`** with a **list per - category**, DynamicData-backed, keep-last-good = **no-empty-overwrite**); **`MarketRepository` news - orchestration** — `ObserveNews(NewsCategory)` and `ObserveNews(StateFlow)` with **`null` = the - merged "All" view** (`CombineLatest` the 4 categories → dedupe by id → newest-first), a **second poll loop** - on a **separate `News refresh interval` setting** (default 30 min; `PollTicker` was generalized into two - refcounted tickers), stale-on-subscribe, and demo-flip refill. `NewsPage` is a `DynamicListPage` pure cache - observer with a category **`Filters` dropdown** ("All" default). **Demo mode** serves curated synthetic - headlines per category; the mock's `SupportsNews` is gated on `DemoMode`. Reached from the **Markets hub** (a - "News" row), not a separate top-level command. Build clean (0 warnings); resx strings translated to all 7 - locales. ⚠️ **NOT yet live-verified on-device.** See **"Market News (feed)"** for the full design + the - **"Left to do"** list to finish it. -- **Done (previous): re-backed the quote cache on DynamicData's `SourceCache` — fixes the keep-last-good race for - free.** `InMemoryQuoteCacheDataSource` is now a thin wrapper over `DynamicData`'s `SourceCache` - (a reactive keyed cache, the .NET analog of Room's `@Query → Flow`) instead of the hand-rolled `Lock` + - per-symbol `MutableStateFlow` dictionary. **Why:** the hand-rolled `Upsert` read the current value - under the lock but called `Update` *outside* it, so two concurrent write-throughs (poll loop, observe-subscribe - fetch, demo flip — no repo-side generation guard) could both read the same pre-write value and let a transient - invalid quote (e.g. a 429 mapped to `IsValid:false`) land last, defeating keep-last-good and blanking a price for - up to one poll interval. Low-severity + self-healing, but it's literally this file's one advertised invariant. - **Fix:** `Upsert` is now `_cache.Edit(updater => { Lookup → keep-last-good decision → AddOrUpdate })`, which - DynamicData runs ATOMICALLY under the cache's lock — the read-decide-write is indivisible, no app-level lock, and - the change is fanned out *after* `Edit` returns. Chose a battle-tested library primitive over a hand-rolled - `_writeGate` lock deliberately (concurrency code is where library-over-DIY pays off). `Observe(symbol)` now - returns `IObservable` (was `StateFlow`) via `_cache.Connect().Watch(key)` (Remove → - null) + `StartWith(Get())` + `DistinctUntilChanged`; `Get` = `Lookup`, `Clear` = `_cache.Clear()`. The interface - changed return type accordingly; the one consumer (`MarketRepository.ObserveQuotesCore`) just dropped its - `.AsObservable()`. The **`ObserveOn` deadlock seam is untouched and still load-bearing** — the cache's lock sits - *upstream* of the Rx gates (nothing downstream re-enters it under a gate), so no new cycle. Adds the `DynamicData` - package (floors `System.Reactive` at 6.1.0, bumped from 6.0.1). Build clean (0 warnings). ✅ **Live-verified - on-device** (prices observe + refresh correctly). See "Shared quote cache + repository orchestration" + the - `InMemoryQuoteCacheDataSource.cs` file-map row. -- **Done (previous): fixed an out-of-order race in the async-projection observe path (Portfolio screen + Portfolio - dock band).** On a cold cache `ObserveQuotes` emits progressive *partial* snapshots (CombineLatest fills the - set symbol-by-symbol, e.g. `[BABA]` before `[BABA, SPY]` lands). The observe handler was launched - **fire-and-forget** (`.Subscribe(q => _ = OnQuotesChangedAsync(q))`), and for the surfaces that `await` an - async projection (the Portfolio screen + dock band both `await CurrencyConverter.PrimeAsync`) that lambda - returns at the first `await` — so **two handlers run concurrently** and a stale partial could finish LAST and - overwrite the full total. Caught it in logs: the dock rolled up `$7,429.79` (SPY+BABA, correct) and - `$139.49` (BABA-only) at the same millisecond, the partial able to win and stick until the next poll tick. - `ObserveOn`'s serialization did **not** protect this — it serializes the *synchronous* part of `OnNext`, but - the fire-and-forget escapes it past the first `await`. **Fix:** project each emission through the handler with - **`Select(q => Observable.FromAsync(ct => OnQuotesChangedAsync(q, ct))).Switch()`** instead of fire-and-forget - — `Switch` cancels the prior projection's `CancellationToken` the instant a newer emission arrives, and the - handler **checks `ct` before painting** (`if (ct.IsCancellationRequested) return;` after the projection) and - swallows `OperationCanceledException` as the expected supersede path; `ct` is forwarded into `PrimeAsync` so a - superseded FX fetch stops early. Applied in the **base `PricedListPage`** (covers all three priced screens at - once) and in **`PortfolioDockPage`**; `OnQuotesProjectingAsync` gained a `ct` param (only `PortfolioPage` - overrides it). The base `GetItems` already snapshots `_quotes` into a local (no torn read); the dock's - `GetItems` was given the same snapshot. **Watchlist/Favorites are behaviorally unchanged**: their projection - is the default `Task.CompletedTask`, which never yields, so their handler still runs fully synchronously and - in order under `Switch` (the `ct` guard never skips a paint for them). **Decision (recorded as the escape - hatch, NOT built):** the *principled* de-drift fix is a shared **`PortfolioRollup`** observable (a single - resolved-portfolio value both dock + page observe, mirroring the per-symbol quote cache one level up) — but - the `Switch` fix already removes the wrong-value bug, residual dock↔page divergence is transient-only and - self-correcting (they share the quote cache + `CurrencyConverter`), and a shared rollup would pull - `PortfolioPage` off its `PricedListPage` base (re-adding chrome). Build clean (0 warnings). ✅ - **Live-verified on-device** (Portfolio screen + dock band roll up correctly; Watchlist/Favorites unaffected). -- **Done (previous): migrated the `PricedListPage` trio (`WatchlistPage`/`FavoritesPage`/`PortfolioPage`) to - pure cache observers — the QUOTE-cache migration is now COMPLETE (every priced quote surface observes).** - Migrating the shared base moved all three at once. The visible-lifecycle hook is now a single - `Repository.ObserveQuotes(membership StateFlow)` subscription, and the per-surface fetch/poll machinery is - **deleted**: the fields (`_priceCache`/`_cacheLock`/`_snapshot`/`_received`/`_fetchGeneration`/ - `_lastFullPriceTicks`) and the methods (`OnInstrumentsChanged`/`Fetch`/`LoadQuotes`/`PollRefresh`/ - `RefreshStaleQuotes`/`OnDataSourceChanged`), plus the page's own `PollTicker` and `DemoModeChanged` - subscriptions and the keep-last-good guard — the repo owns the poll loop, the demo flip, and - stale-on-subscribe, and the cache owns keep-last-good. Membership changes (incl. a quantity-only Portfolio - edit) re-project for free via the `Switch` overload; empty-vs-loading is read from the membership - `.Value.Count`. The **async-projection** hook generalizes from `PortfolioDockPage`: a new - `protected virtual Task OnQuotesProjectingAsync(quotes)` (default no-op) that `PortfolioPage` overrides to - `await CurrencyConverter.PrimeAsync` before the paint — **replacing** the old `OnPriceCacheUpdated` + - `SnapshotPricedQuotes` hooks (both deleted from the base). The manual **Refresh 🔄** row reroutes onto - `RefreshAsync(keepLastGood:false)` with its own spinner (distinct-until-changed means an unchanged refetch - won't re-emit, so the row owns its IsLoading lifecycle). `WatchlistPage`/`FavoritesPage` needed no changes - (Watchlist keeps its `RelistTriggers => [Favorites]` for the ★). The **only** quote surface left un-migrated - is intentionally none — the lone exception is the symbol-detail **chart**, a separate candle path - (`GetCandlesAsync`, no cache/observe layer) that stays on its own `PollTicker` subscription by design (one - detail page open at a time → no cross-surface drift). **Follow-up:** with no surface calling it, the now-dead - public `MarketRepository.GetQuotesAsync` was **removed** — the observe/poll paths fetch via `RefreshAsync` - (same `FetchMergeAsync` + `WriteThrough`, void return). Build clean (0 warnings); **not yet live-verified - on-device.** See "Shared quote cache + repository orchestration". -- **Done (previous round): migrated `PortfolioDockPage` to a pure cache observer + added the stale-on-subscribe - freshness primitive.** The **second dock band is now a pure observer** like the favorites band, and it proves - the **async-projection** pattern the favorites band didn't need: its observe handler `await`s - `CurrencyConverter.PrimeAsync` before rolling up, so the converted total lands in **one paint** (safe because - `ObserveQuotes` delivers off the Rx gate via `ObserveOn`). It dropped its own `PollTicker` + `DemoModeChanged` - subscriptions, its keep-last-good merge, and its `GetQuotesAsync` call; it observes `PortfolioStore.Instruments` - (which re-emits on **every** mutation, so a quantity-only edit re-rolls via `Switch`) and reads - `PortfolioStore.Positions` in the handler. Both dock bands now hold subscriptions in a `List` (not - a single overwritable field) so a double-`add` can't orphan a subscription — which would otherwise pin its - symbols to the repo poll loop forever. **Stale-on-subscribe primitive (`MarketRepository`):** every - write-through now stamps a per-symbol last-fetch time (`_lastFetchTicks` + a shared `WriteThrough` helper), and - a subscribe refreshes **null OR stale** symbols (cached but aged ≥ one `RefreshInterval` while unobserved) via - `NeedsFetchOnSubscribe`, not just missing ones — the central home for the freshness clock the priced pages keep - per-surface (`_lastFullPriceTicks`/`RefreshStaleQuotes`), so that machinery **deletes** when the trio migrates, - and it closes the "reopen shows a stale price until the next poll tick" gap on the two migrated dock bands. The - dead Portfolio-dock `SubscribeOn` comment in `MarketExtensionCommandsProvider` was corrected to the real - `ObserveOn`-in-the-repo fix. Build clean (0 warnings); **not yet live-verified on-device.** See "Shared quote - cache + repository orchestration". -- **Done (this round): shared quote cache + repository orchestration — the de-drift refactor (cache LIVE, favorites dock migrated + verified).** - A new **`IQuoteCacheDataSource`** (in-memory `InMemoryQuoteCacheDataSource` now — **real, no longer stubbed** — swappable for a - DB-backed one later) holds the latest `DomainQuote` per symbol as **observable** state. `MarketRepository` is - the orchestration layer: every fetch **writes through** to the cache, surfaces can **`ObserveQuotes(...)`** - (cache-backed `IObservable`, fixed set or membership `StateFlow`) instead of fetching, and the repository - runs the **single poll loop** (one lifetime `PollTicker` subscription refreshing the *observed* set each - tick) + the demo-flip Clear/refill. ⚠️ **The deadlock that briefly forced the stub is FIXED:** - `ObserveQuotes` now delivers via **`ObserveOn(TaskPoolScheduler)`**, so a surface's `RaiseItemsChanged` (a - blocking COM call into Command Palette's STA) is never invoked while Rx still holds the `CombineLatest`/`Switch` - gate lock — that gate/STA lock-order cycle was hanging CmdPal whenever the favorites band activated. (Two - dead ends, recorded so nobody re-tries them: pulling the band proved it was the trigger; **`SubscribeOn` did - NOT help** — it moves *subscription*, not *delivery*, and the host call happens during delivery.) **Migrated: - `FavoritesDockPage`** — now a **pure observer** (no `PollTicker`/demo subscription, no - `GetQuotesAsync`/`RefreshAsync`, no keep-last-good; it renders what the cache emits, and membership changes - come free via the `Switch` overload). Build clean (0 warnings). ✅ **Live-verified on-device**: the band - activates without hanging CmdPal and shows live prices. (`PortfolioDockPage` was migrated the following round — - see the "Done (previous round)" bullet above.) **NOT yet migrated** (still on per-surface fetch+poll, filling - the cache only via write-through): the `PricedListPage` trio (`WatchlistPage`/`FavoritesPage`/`PortfolioPage`), - plus the `SymbolDetailPage` chart (candles, separate path). **The dock bands are the template for the rest.** - See "Shared quote cache + repository orchestration" for the full design + the migration checklist. - **(Update: the `PricedListPage` trio is now migrated too — see the top "Done (latest)" bullet; only the - candle chart remains off the cache, deliberately.)** -- **Done (this round): removed Sentry entirely — the app ships with NO telemetry.** The optional crash - reporter (SDK init in `Program.cs`, the `CaptureException`/`CaptureMessage` calls in `Log.Error`, the - `Sentry` package in the csproj + `Directory.Packages.props`) is **gone**. Rationale: it was a one-off in - this extension — **no** other CmdPal extension uses Sentry (0 references across the whole PowerToys repo; - none in the AdbExtension this was scaffolded from), and a market app that holds the user's API key - shouldn't carry anything that *looks* like it could exfiltrate it (even though the audit found it never - did — keys live only in request query strings handed to plain, un-instrumented `HttpClient`s, and no - `Log.Error` ever interpolated a URL/key). `Log.Error` is now `[Conditional("DEBUG")]` like `Info`/`Warn`, - so all logging is Debug-only and a Release MSIX emits nothing off-machine. To re-add telemetry later, - restore the package + a `SentrySdk.Init` and add a `BeforeSend` scrubber for `token=`/`apikey=`. Build - clean (0 warnings). -- **Done (this round): Demo mode now applies INSTANTLY (no waiting for the next refresh) + no rate-limit - banner while demoing.** Flipping the `Demo mode` toggle used to be **pull-style** — surfaces only picked up - the new data source on their next price refresh / reopen, so a hidden priced page (a long-lived singleton - whose `_priceCache` survives navigation) would repaint **stale, old-source** prices until the interval - elapsed. Now `MarketSettingsManager` exposes an observable **`DemoModeChanged`** (`StateFlow`, - published from the existing `SettingsChanged` handler, distinct-until-changed so it fires only on a real - flip) and every surface subscribes and resets the moment it flips: - - `PricedListPage` — a **lifetime** subscription (NOT the visibility-scoped block), so even a hidden - singleton drops its price cache + freshness clock: re-fetches from the new source if visible, else the - cleared cache forces a full fetch on next open. - - `FavoritesDockPage` / `PortfolioDockPage` — visible-scoped re-price (hidden bands already refetch on open). - - `CurrencyConverter` — clears its rate cache so the next `PrimeAsync` repopulates from the correct source - (live ECB ↔ the static `DemoUsdPerUnit` table) instead of serving its <1h-TTL stale entries. - - `SearchPage` / `MarketsPage` — re-list so the status row swaps (demo ↔ missing-key) at once. - - No reload / no process restart (an out-of-proc extension can't drive CmdPal's own Reload anyway; this hits - the same goal reactively). **Also fixed:** the rate-limited banner no longer shows in demo mode — - `RateLimitHint.Row()` now guards on `DemoMode` (the authoritative gate), and `RateLimitSignal` **clears its - flag when demo mode turns on** (offline nothing calls `ReportSuccess()`, so a 429 set earlier would - otherwise linger and reappear when demo is turned back off). Build clean (0 warnings). See "Demo mode - (offline testing)". -- **Done (this round): Demo mode (offline testing).** A **`Demo mode` toggle setting** (default off) makes the - whole app serve built-in sample data with **no API key and no network** — for trying out the extension or - testing UI work (like the cost-basis/total-return + multi-currency paths) without burning real-API budget. - `MockMarketDataProvider` is registered **first** and, in demo mode, declares itself **`IsExclusive`** — a new - default member on `IMarketDataProvider` that makes `MarketRepository` route **every** operation (quotes, - candles, **and the search fan-out**) to it alone, so the mock takes precedence **everywhere** and **zero** - network is hit even if live API keys are set. Off → it's not exclusive and `Supports()` is false, so the - live providers take over unchanged. `CurrencyConverter` checks the same setting and fills its cache from a - **static FX table** instead of calling Frankfurter `/latest`, so even cross-currency portfolio conversion is - fully offline. The mock seed now carries each instrument's real name/category/**native - currency** (incl. a GBP London stock `HSBA.L`) so search is correctly typed and conversion has a non-USD - holding to exercise. The list screens show an explicit blue **"Demo mode — showing sample data"** status row - while it's on (replacing the red missing-key nudge), so it's obvious the prices are simulated, not live — - `ApiKeyHint` became a unified `StatusRow()` (demo row → missing-key row → null). **(Update: flipping the - toggle now applies immediately across the app — see the "applies INSTANTLY" bullet above; this entry's - original "next refresh/reopen" timing is superseded.)** Build clean (0 warnings). ✅ **Live-verified**: flipping the toggle - swaps to sample data across the app, the blue status row shows, and the mock takes precedence everywhere - (even with live keys set). See "Demo mode (offline testing)". -- **Done (this round): cost-basis / total-return reporting** — the last open portfolio wishlist item. The - optional `CostBasis` (average price paid per unit, in the holding's **native** currency) was already stored - but unsurfaced; it's now **captured and displayed** end-to-end with **no provider/repository/model-shape - changes** (only behavior + UI): - - **Capture** — `Pages/SetQuantityPage.cs` gained a second optional `Input.Number` ("Average cost per unit"); - the editor prefills it from the current holding and maps **0/blank → null** (not recorded → total return - hidden). `PortfolioStore.SetPosition` now applies the cost basis **verbatim** (a value sets it, null - clears) — the old preserve-on-null rule is gone, because the editor is the sole caller and round-trips the - existing basis itself (so a quantity-only edit still preserves it). New `PortfolioStore.GetPosition(symbol)` - returns the full holding (quantity + basis) for rendering. - - **Compute** (only place the math/formatting lives) — `UiPosition` exposes `TotalCost`/`TotalReturn`/ - `TotalReturnPercent` (native **and** converted via the same `RateToPreferred` as daily P&L; percent = - `(price − basis)/basis`, currency-independent) + `FormatTotalReturn()`, shown only when a positive basis - exists. `UiPortfolio.From` rolls **converted** cost + return up across the counted holdings that **have a - basis** (percent = `return/cost` over exactly that subset), gated by `HasCostBasis` + a - `FormatTotalReturnNote()`. - - **Display** — Portfolio rows append "Total ▲ +$… (+…%)" after daily P&L when a basis is set; the totals - summary row **and** the Portfolio dock band append the total-return note to their subtitle. Holdings with - no basis (or unpriced/unconvertible) simply contribute nothing to the return rollup. Build clean (0 - warnings). ✅ **Live-verified** (via Demo mode: the GBP `HSBA.L` holding with a cost basis shows the - converted total return on the per-row subtitle, the totals summary, and the dock band). See - "Portfolio screen + dock band (done)". -- **Done (this round): the Portfolio dock band.** A second Dock band next to the favorites band, showing the - portfolio's **total value + daily P&L rolled up into the `PortfolioCurrency`** as a single summary button - (clicking it opens the full `PortfolioPage`). New `Pages/PortfolioDockPage.cs` (a `ListPage`, modeled on - `FavoritesDockPage`): same `INotifyItemsChanged` visible-lifecycle hook, subscribes to - `PortfolioStore.Positions` (replay paints on open; any add/edit/remove repaints — reference-equality flow - so even a quantity-only edit re-emits) **and** `PollTicker` (silent live re-price), disposing both on hide. - It prices the holdings, **`await`s `CurrencyConverter.PrimeAsync`** for the native→preferred FX rates - before rolling up (so the converted total lands in one paint — no progressive native-only frame needed on a - one-row band), then composes `UiPortfolio` exactly like `PortfolioPage.LeadingRows`. Same keep-last-good - guard as the favorites band. Registered as a second `CommandItem` in `GetDockBands()` with its own non-empty - `Id` (`com.costafotiadis.market.dock.portfolio`). No provider/repository/model changes. Build clean (0 - warnings). ✅ **Live-verified** (band renders the total + P&L; icon shows). ⚠️ **Glyph-icon gotcha hit:** - the Bank summary-icon glyph (U+E825) is a Segoe MDL2 private-use char — the Write tool **silently dropped** - it (saved as `""` → blank icon); fixed by re-adding it and byte-checking (real glyph = `EE A0 A5`). Prefer a - C# Unicode escape for new glyph icons. See "Portfolio screen + dock band (done)". -- **Done (this round): multi-currency portfolio conversion** — the headline portfolio wishlist item. The - app no longer assumes USD: `DomainQuote` carries an ISO-4217 **`Currency`** (providers stamp it — Twelve - Data from the batched `/quote` field, Finnhub via a cached `/stock/profile2` lookup, Frankfurter from the - pair's quote currency; crypto→USD, **GBX/pence→GBP ÷100**), and the **Portfolio screen** values each - holding in its native currency **and** converts into the user's `PortfolioCurrency` via the keyless - Frankfurter `/latest` rates (new `Helpers/CurrencyConverter.cs`, primed off the price-load path through a - new `PricedListPage.OnPriceCacheUpdated` hook; synchronous `TryGetRate` at render time). Totals roll up in - the preferred currency with proper symbols (`Helpers/CurrencyFormat.cs` replaces the hardcoded `$`); - unconvertible holdings are excluded and surfaced. `UiQuote.FormatPrice` is currency-aware app-wide too - (a London stock shows `£2.50`). New files: `Helpers/CurrencyConverter.cs`, `CurrencyFormat.cs`, - `CurrencyHelper.cs`, `ApiFinnhubProfileDto.cs`, `ApiFrankfurterLatestDto`. Build clean (0 warnings). - ✅ **Live-verified**: 10 sh SPY (USD) with GBP preferred → ~£5,571 (USD value × ~0.76, a real conversion). - See "Multi-currency portfolio conversion (done)". - -- **Done (this round): rate-limit (429) back-off + a "rate-limited" banner.** Two pieces behind the - existing provider seam — **no model-layer change**: - - **Back-off** — a shared `Helpers/HttpRetry.cs` wraps every `Http.GetAsync` in the two **keyed** HTTP - providers (Finnhub + Twelve Data; Frankfurter is keyless / no documented limit, so it's left out). On - an HTTP `429` it honors a short `Retry-After`, else backs off `1s`→`2s` (max 3 attempts), bailing - immediately if the wait would exceed an `8s` cap — because the free tiers are **per-minute** windows, a - few-second retry rarely clears an exhausted minute, so hammering would just burn more quota. Plain - `async`/`Task.Delay`, **not** Rx — wrapping these Task-based calls in observables just to use - `RetryWhen` would add ceremony for no gain (the CLAUDE.md Rx framing oversold this one). - - **Banner** — a process-wide `Helpers/RateLimitSignal.cs` (`StateFlow`, the same state-holder - idiom as `WatchlistStore`): `HttpRetry` flips it **on** when a 429 survives back-off and **off** on any - 2xx, at that single choke point. The Twelve Data provider additionally reports it when TD encodes a - `429` in a **200-OK body** (`{"code":429,…}`), which an HTTP-status check alone misses. The priced - pages + Search **subscribe** (replay:false) and render `Helpers/RateLimitHint.cs` — an amber - "Rate-limited — showing last known prices" row (Enter is a **no-op** — `NoOpCommand`, purely - informational; it's the default-selected first row so it must not steal Enter), pinned to the **top** - (priced pages index 0; Search index 1, just under the Enter-to-search action) so it's seen without - scrolling — so the user knows *why* prices look stale instead of just seeing the keep-last-good values. - A new **`Show rate-limit warnings`** toggle setting (`ShowRateLimitErrors`, default on) hides the banner - entirely for users who'd rather not see it. It's deliberately - **global, not per-symbol** (a free-tier limit is key-wide). The **dock band contributes to the signal** - (its fetches go through the same providers) but doesn't render the banner — too cramped, and a - pseudo-button warning would look out of place. Build clean (0 warnings). ✅ **Live-verified**: exhausted - a free key → the amber banner appeared, then cleared on recovery. -- **Done (this round): Twelve Data provider — now the primary source.** Added - `Data/TwelveData/TwelveDataMarketDataProvider.cs` behind the existing `IMarketDataProvider` seam with - **zero UI/repository changes** — one API covering **stocks + crypto + forex**, registered FIRST and - **gated on its key** so it's primary when set and **falls back** to Finnhub/Frankfurter when not. The - headline win: **`/time_series` candles are free-tier**, so the symbol-detail chart now renders real - history on a **free** key (Finnhub's candles are premium → 403). `GetQuotesAsync` **batches** all - symbols into one `/quote` call (tight ~8/min free limit); `GetCandlesAsync` reverses TD's newest-first - values; `SearchAsync` via `/symbol_search`, normalizing results back to neutral symbols. New - `TwelveDataApiKey` setting + source-gen `TwelveDataJsonContext` (`AllowReadingFromString`). Build is - clean (AOT/trim). ✅ Verified live end-to-end against a real Twelve Data key (quotes, batched `/quote`, - and free-tier `/time_series` charts). See "Twelve Data specifics". -- **Done:** layered data architecture; live Finnhub provider; `MarketRepository` coordinator; - **runtime API key + refresh-interval settings** (`MarketSettingsManager`; key is settings-only, - no baked-in key); tagged logging; **Enter-only Finnhub `/search`**; **persistent - watchlist + favorites** as two independent flags (`WatchlistStore`); the **three-screen UX** - (Markets Search / Watchlist / Favorites — see below); and the **observable (StateFlow) data layer** — - the store exposes `Watchlist`/`Favorites` as `StateFlow`s, all surfaces subscribe and re-render - themselves, the membership commands no longer thread a manual refresh callback, and **dock refresh on - favorites change is now live** (push, not poll). Priced pages **reconcile locally** on membership - change (drop removed rows free, fetch only new symbols). See `Helpers/StateFlow.cs`. -- **Done (this round):** the **symbol-detail price chart with 1D / 1W / 1M / 1Y / 5Y range tabs** — real - Finnhub candle history behind a provider-agnostic seam (`GetCandlesAsync`, `ChartRange` + `CandleInterval`, - `DomainCandleSeries` / `UiCandleSeries`, ported `Helpers/ChartHelper.cs`, `ApiFinnhubCandleDto`). The - Finnhub candle endpoint is **premium-gated**, so on the Finnhub path real charts need a paid key (free - key → "requires paid plan"; the mock provider draws synthetic candles for offline preview). **Update: - the Twelve Data provider (now primary) serves candles on the FREE tier**, so a free Twelve Data key - renders real charts — the premium gate now applies only to the Finnhub fallback path. The - **range-switch flicker is fixed**; the - **Enter-steals-focus bug is UNSOLVED** and left as a documented known limitation (two fixes tried and - abandoned — see the "Symbol detail + live chart" section). -- **Done (this round):** **live price polling** — prices now auto-refresh on a timer while a priced - surface is visible (Markets Watchlist / Favorites + the Favorites dock band). Realized via - `Helpers/PollTicker.cs` (a singleton ticker — originally a `StateFlow` on the `OnActive`/`OnInactive` - subscriber-count seam, **now pure Rx** `Observable.Generate(...).Publish().RefCount()`), and per-surface - **silent** `PollRefresh()` methods that re-price in place with **no spinner/flicker** (cache not - cleared, prices swap when the fetch lands). Honors the existing interval setting (default 10 min, - `0` = off, applied without reload). A **keep-last-good guard** in `PricedListPage.LoadQuotes(silent)` - stops a transient bad poll (e.g. a 429 mapped to an invalid quote) from blanking a price that was - fine. The chart now live-refreshes its visible range on the same ticker (see below). See "Live price polling (done)". -- **Done (this round): FX provider (keyless Frankfurter)** — forex is now live behind the existing - `IMarketDataProvider` seam with **zero UI/repository changes**. `Data/Frankfurter/FrankfurterMarketDataProvider.cs` - (`Supports(Currency)`) wraps the ECB daily reference rates via Frankfurter (`api.frankfurter.dev`, **no - key**): it splits a 6-letter pair, uses one time-series endpoint for both quotes (latest vs previous - fixing → **day-over-day** change) and candles (daily closes). `InstrumentCatalog` re-adds `EURUSD` / - `GBPUSD` / `USDJPY`; the provider is registered alongside Finnhub in `MarketExtensionCommandsProvider`; - `AssetIconResolver` now shows the base currency's **country flag** (EUR→`eu`). FX "search" is a local - filter over the catalog's pairs (Frankfurter has no symbol endpoint). Verified end-to-end against the - live API. See "Frankfurter specifics (FX)". (Caveats: ECB is **daily-only** — no intraday FX bars; FX - pairs reach existing installs via the catalog seed / local search, since Finnhub `/search` is US-equity.) -- **Done (this round): symbol-detail chart live refresh** — the open chart now re-fetches its **visible - range** on each `PollTicker` tick (not just one fetch per tab tap), sharing the same ticker + - `Publish().RefCount()` lifecycle as the priced list pages and dock. `SymbolDetailPage` subscribes the chart in its - visible-lifecycle hook (`replayOnSubscribe:false`, so opening the page doesn't double-fetch — `Start()` - already did the first load) and disposes it on hide with the rest. `SymbolChartForm.PollRefresh()` - re-prices in place: **silent** (no "Loading…" write, so no flicker), **bypasses the per-range cache** so - the on-screen range actually refreshes (then refreshes that cache entry), **reuses the generation guard** - so a concurrent tab tap still wins, and **keeps the last good chart** if a poll returns empty (the chart's - analog of `PricedListPage`'s keep-last-good). Caveat: on the **Finnhub fallback** path candles are - premium-gated, so a free Finnhub key just re-fetches a 403 each tick; with **Twelve Data** primary its - free-tier candles refresh for real. -- **Done (this round): the Portfolio screen.** **Markets Portfolio** now tracks real holdings (a quantity - per symbol), priced live, with a totals summary on top and **daily P&L** per holding — behind the existing - data seam with **no provider/repository changes**. New: `Settings/PortfolioStore.cs` (modeled on - `WatchlistStore`, `market_portfolio.json`, two flows `Positions`/`Instruments` — the latter - **reference-equality so quantity-only edits re-emit**), `Models/DomainPosition`/`UiPosition`/`UiPortfolio`, - `Pages/SetQuantityPage.cs` (an adaptive-card `Input.Number` editor), and `Commands/PortfolioCommands.cs`. - `PortfolioPage` is a **`PricedListPage` subclass** that reuses all the caching/polling plumbing; a new - `PricedListPage.LeadingRows(...)` hook (default empty, others unaffected) renders the totals row from the - full priced set. Per the user's choices this pass: **daily P&L only** (cost basis stored but no UI), - **no dock band that pass** (the Portfolio dock band shipped later — see its bullet above), and **all - add/edit/remove on the symbol detail page** (rows just open it, like every - other list). Build clean (0 warnings). ✅ **Live-verified**: added a holding, the total rolled up; - edit/remove and persistence work. See "Portfolio screen (DONE…)". -- **Deferred:** richer **per-symbol** error UX — a typed `QuoteStatus` (Ok/Invalid/RateLimited/NoKey) on - `DomainQuote` so each row could render its own state. Deliberately **not** built: the rate-limit signal - shipped this round is global (one throttle flag for the whole key) because a free-tier limit is key-wide, - not per-symbol, and a typed status would ripple through all four providers + `UiQuote` + the keep-last-good - guard for marginal gain. **(Done this round:** 429 **back-off + a "rate-limited" banner** — see the - dedicated bullet above; the keep-last-good guard is no longer the whole story.) - **(Resolved by the Twelve Data provider:** crypto + FX symbol search now works across all three classes - — TD's `/symbol_search` maps `instrument_type` → category and normalizes symbols back to neutral form, - and the repository fans search out to every provider. The original gap was Finnhub-only; it now persists - **only on the keyless fallback path** — without a TD key, Finnhub search is US-equities-only, Frankfurter - covers FX via a local catalog filter, and crypto search is unavailable.) -- **Done (this round): stale-revisit catch-up** — closes the "short visit" gap in live polling. The - priced pages are long-lived singletons whose `_priceCache` survives navigation, so revisiting an - unchanged set repainted cached prices with **no fetch**, while every revisit restarted the poll timer - from a full interval — so visits shorter than the interval never saw a refreshed price (a pinned dock - masked it). Now `PricedListPage` stamps a freshness clock (`_lastFullPriceTicks`, set on every - whole-set re-price; a partial add doesn't advance it) and, when a fully-cached set becomes visible, - fires **one silent catch-up re-price if the prices have aged ≥ one interval** (`RefreshStaleQuotes`). - Bounded to one fetch per revisit, only when stale, gated on `AutoRefreshEnabled` — liveness without - burning the Finnhub budget. This supersedes the old "poll-timer debounce" deferred item. -- **Done (this round): asset logos as row/dock icons** — every instrument surface (Markets Search results, - Watchlist, Favorites, the Favorites dock band, and the `SymbolDetailPage` header) now shows the - instrument's **real logo** instead of the generic favicon. Logos come from **Elbstream's keyless CDN** - addressed directly by symbol (`Helpers/AssetIconResolver.cs`) — **zero API calls / no cache / no DTO** - (the host fetches the URL). Elbstream is free **with attribution**, so a "Logos provided by Elbstream" - credit (clickable via the new `Commands/OpenUrlCommand.cs` + `ProcessHelper.OpenUrl`) appears on every - logo-bearing page; the dock band is an accepted gray area. Per-category Segoe glyph fallback for - Currency/unknown. See "Asset logos (done)". -- **Wishlist — original three DONE.** ~~**(1) multi-currency portfolio conversion**~~ — **DONE** (see - "Multi-currency portfolio conversion (done)"). ~~**(2) the Portfolio dock band**~~ — **DONE** (a one-line - total-value + daily-P&L strip next to the favorites band; see the "Done (this round): the Portfolio dock - band" bullet + "Portfolio screen + dock band (done)"). ~~**(3) cost-basis / total-return** reporting~~ — - **DONE** this round (the stored `CostBasis` is now captured in the editor and surfaced as total return on - the rows, totals summary, and dock band; see the "Done (this round): cost-basis / total-return reporting" - bullet above). -- **Done (this round): Demo mode now serves data for ANY symbol (not just the seeded handful).** Previously - `MockMarketDataProvider` only knew the ~10 symbols in its static `Seed` dictionary — any other ticker - returned an `IsValid:false` quote (blank row) and was absent from search, so a watchlist/portfolio of - off-seed symbols looked broken in demo mode. Fixed as **two deliberately-separate halves** (the split - matters — see the design note): - - **Quotes: synthesize for any requested symbol.** `GetQuotesAsync` now falls back to `SynthesizeQuote` - for any non-seeded instrument: a **stable FNV-1a hash** of the symbol (deterministic across refreshes - AND process restarts — unlike per-process-randomized `string.GetHashCode`) drives a category-appropriate - price (stock tens-to-hundreds, crypto a $1–$10k log spread, FX a realistic rate band) and a ±3% daily - move. **Native currency is inferred from the symbol shape** (crypto → USD; FX pair → quote currency via - `CurrencyHelper.QuoteCurrencyOfPair`; stock → exchange suffix, `.L`→GBP/`.DE`→EUR/`.T`→JPY/…, else USD), - with a per-currency magnitude scale so a yen stock reads in the thousands. `Seed` stays as nice-looking - hand-tuned overrides on top. **Candles** now anchor on the same synthesized price (was hardcoded 100) so - the chart agrees with the quote. This is the half that fixes off-seed real holdings looking broken. - - **Search: a curated REAL-ticker corpus, NOT free-text fabrication.** `SearchAsync` now matches over a - new `Catalog` dictionary (~120 genuine instruments — US + international stocks, crypto, FX; prefix-on- - symbol ranked, capped at 25) instead of just the seed. **Design note (why not fabricate search hits):** - a watchlist/portfolio persists the full `DomainInstrument` to JSON, so if demo search invented matches - for arbitrary free-text the user could add a **non-existent ticker** that becomes a permanent blank row - once demo mode is off (no clean migration path). The curated corpus keeps search feeling complete while - guaranteeing it can only ever surface real symbols. Build clean (0 warnings). ✅ **Live-verified** — - in Demo mode an off-seed real ticker (e.g. `GOOGL`, `7203.T`, `DOGE`) is found in search, prices, and - charts after adding. See "Demo mode (offline testing)". -- **Done (this round): migrated the observable layer to Rx.NET (`System.Reactive`).** Two parts: - - **`StateFlow` → thin wrapper over `BehaviorSubject`** (the blocker was gone once AOT/trim went off — - the trim/AOT analyzers were removed, so `System.Reactive` 6.0.1 is taken **warning-free**, CA1001 - suppressed on the process-lifetime singleton). The **public API is preserved** (`Value`, both `Subscribe` - overloads, `Update`, `InstrumentListComparer`), so the **state-holder consumers (`WatchlistStore`, - `MarketSettingsManager`) and every page/dock subscriber did not change** — - verified by a clean build (0 warnings). Mechanics preserved 1:1: `BehaviorSubject` gives value + - replay-on-subscribe + thread-safe fan-out; `replayOnSubscribe:false` → `Skip(1)`; `SetValue` keeps - **source-side** distinct-until-changed (equal value not pushed → unchanged subset doesn't wake - subscribers); handlers fan out **outside** the subject's lock (so the store re-read is deadlock-free). - - **`PollTicker` → pure Rx** (`Observable.Generate(...).Publish().RefCount()`), since it was the one - consumer that was a *poor* `StateFlow` fit (an event stream abusing `StateFlow` + a counter, only - to borrow the refcount seam). It no longer inherits `StateFlow`; `RefCount()` provides the - WhileSubscribed lifecycle, deleting the `CancellationTokenSource`, the lock, the `OnActive`/`OnInactive` - overrides, and the CA1001 suppression. The 3 subscribers dropped `, replayOnSubscribe: false` (a pure - event stream has no replay to suppress). Net effect: with no consumer left, `StateFlow`'s entire - subscriber-count seam (`OnActive`/`OnInactive` + the hand-rolled refcount + custom `Subscription` - wrapper) was **deleted** — `StateFlow` is now a genuinely thin `BehaviorSubject` wrapper. - - **Wiring:** `System.Reactive` added to `Directory.Packages.props` (CPM) + a `PackageReference` in the - csproj. **This was the swap only** — the payoff (Rx's **operator library**) lands with the deferred work - it unlocks: 429 **back-off** (`Retry`/`RetryWhen` + exponential delay), **debounced** search-as-you-type - (`Throttle` — would let `SearchPage` drop its Enter-only rate-limit guard), and multi-provider **merge** - in `MarketRepository` (`CombineLatest`/`Merge`). **Caveat:** Rx schedulers are mostly moot here (the - CmdPal host already marshals `RaiseItemsChanged`), so this bought **operators, not threading**. ✅ - **Live-verified**: opened a priced page, prices refreshed on the interval; hide/reshow confirmed the - `Publish().RefCount()` loop tears down and restarts. - -### Three-screen UX (done) - -Three top-level commands in `MarketExtensionCommandsProvider.TopLevelCommands()`, backed by **two -independent membership flags** per instrument in `WatchlistStore` — an instrument can be on either -list, both, or neither (so "watchlist" and "favorites" are genuinely separate sets, not subset/superset). -All three command titles share the **`Markets ` prefix** so they group together (and don't pollute the -namespace) when searching the Command Palette root: - -**Enter on any row opens the shared `Pages/SymbolDetailPage.cs`** — the per-symbol detail screen -(`ContentPage`; **now renders a live price chart with range tabs** — see the chart section), which is -**the single place for list management**. Its -command bar carries the add/remove-watchlist (**Enter**) and add/remove-favorite (**Ctrl+Enter**) -actions, labelled for the instrument's current state, and the page **subscribes to the two -`WatchlistStore` flows** so toggling there (the commands `KeepOpen`) flips the buttons + body in place. -List rows therefore carry **no context actions at all** — they only navigate into the detail page. The -screens: - -1. **Markets Search** (`Pages/SearchPage.cs`, default entry) — the Enter-only `/search` flow. On a - result: **Enter** → open detail page (where it's added to the watchlist/favorites). The subtitle - still reflects current membership. Empty box links to the other two screens. -2. **Markets Watchlist** (`Pages/WatchlistPage.cs`) — the tracked instruments, priced and grouped by - class; a ★ marks favorites. **Enter** → open detail page. -3. **Markets Favorites** (`Pages/FavoritesPage.cs`) — the curated subset; **only favorites render in - the dock band** (`FavoritesDockPage` subscribes to `WatchlistStore.Favorites`). **Enter** → open - detail page. Each **dock button also opens the detail page** when clicked. A pinned band now - updates **immediately** when favorites change anywhere (it subscribes to the flow while visible), not - just on reopen. - -Watchlist + Favorites share `Pages/PricedListPage.cs`, which **subscribes to its `StateFlow`** in the -`INotifyItemsChanged` add/remove lifecycle (StateFlow's replay-on-subscribe drives the first price load; -later membership pushes trigger a **local reconcile** — drop departed rows with no network call, fetch -only newly-added symbols) + local filter. A page can also observe secondary flows for re-render only via -`RelistTriggers` (Watchlist uses it to keep its ★ current when favorites change). The catalog seeds the -watchlist **once** on first run; thereafter every row is user-removable. The membership actions live in -`Commands/MembershipCommands.cs` -(`AddToWatchlist`/`RemoveFromWatchlist`/`AddToFavorites`/`RemoveFromFavorites`/`ToggleFavorite`) — each -just **mutates the store** (no manual UI callback: the flows re-render every live surface) and -**shows a confirmation toast** (e.g. "Added AAPL to watchlist") while **keeping the palette open** so -the user gets explicit feedback and can keep editing. - -### Shared quote cache + repository orchestration (DONE — all quote surfaces migrated; only the candle chart stays off the cache by design) - -**Why.** Every priced surface used to fetch the **same** quotes independently and keep its **own** price -cache + its **own** `PollTicker` subscription. Because their fetches landed at different times, two surfaces -showing the same symbol (e.g. the favorites dock vs. another band) displayed **different** prices — they -drifted out of sync. The fix: one shared observable cache that every surface observes, with the repository -orchestrating all fetching/polling, so the same symbol is one cache entry everyone reads. - -**The pieces:** -- **`IQuoteCacheDataSource`** (`Data/`) — the shared store abstraction: per-symbol `IObservable`, keyed by - `WatchlistStore.Normalize`. **Stores `QuoteEntity` (the `*Entity` storage model in `Data/QuoteEntity.cs`), not - `DomainQuote`** — the repository maps `QuoteEntity.From` on write-through and `entity.ToDomainQuote()` on read, so - the storage model never escapes the data source (the public `ObserveQuotes` still hands surfaces `DomainQuote`). `Observe(symbol)` replays the current value (null until first fetch); - `Upsert(quote, keepLastGood=true)` is the SINGLE home for keep-last-good; `Clear()` resets entries to null - while keeping observers subscribed. Interface, so a DB-backed impl swaps in later via the repo's injectable - ctor. ✅ **`InMemoryQuoteCacheDataSource` is the real, live impl now, backed by DynamicData's - `SourceCache`** (a reactive keyed cache — the .NET analog of Room's `@Query → Flow`). - **`Upsert` = `_cache.Edit(...)`, so read→keep-last-good-decision→write is ATOMIC under the cache's lock** — the - reason this layer can own keep-last-good correctly under concurrent write-throughs, which the earlier hand-rolled - "decide under the lock, `Update` outside" split could not (two racing Upserts could both read the pre-write value - and let a transient invalid quote land last; this was a real, if low-severity, race — picked a library primitive - over a hand-rolled lock to close it). `Observe(symbol)` = `_cache.Connect().Watch(key)` mapped to `QuoteEntity?` - (Remove → null), with `StartWith(Get())` + `DistinctUntilChanged()`; an unchanged re-fetch is also deduped in - `Upsert` (skip `AddOrUpdate` when value-equal) → no churn/flicker. (Adds the `DynamicData` package; it floors - `System.Reactive` at 6.1.0.) -- **`MarketRepository` orchestration** — owns one `IQuoteCacheDataSource`. `RefreshAsync` writes through (the single - cache-fill path, driven by the poll loop + the observe-subscribe fetch via `RefreshSafe`). The old public - `GetQuotesAsync` was **removed** — it had no callers once every surface moved to observe. - `ObserveQuotes(IReadOnlyList)` and - `ObserveQuotes(StateFlow<…>)` return a cache-backed list observable (`CombineLatest`; the membership overload - adds `Switch` so add/unstar re-projects — fetching only new symbols, dropping departed). `RefreshSafe` - wraps fire-and-forget refreshes (logged). -- **The `ObserveOn` delivery seam (the deadlock fix — do NOT remove).** Both `ObserveQuotes` overloads append - **`.ObserveOn(TaskPoolScheduler.Default)`** (the raw graph lives in a private `ObserveQuotesCore`; the - `StateFlow` overload `ObserveOn`s *after* `Switch` so the gate of `Switch` is covered too). Why it's load-bearing: - a cache write-through fans out synchronously, and `CombineLatest`/`Switch` call the subscriber's handler — - ultimately a surface's `RaiseItemsChanged`, a **blocking COM call into Command Palette's STA** — *while Rx - still holds the combiner gate lock*. The host then re-enters the extension and the gate↔STA lock order cycles - → **CmdPal hangs** (this is exactly what made the favorites band crash the host). `ObserveOn` hands each - emission to the scheduler, so surfaces are notified only **after** the gate locks are released — no host call - ever runs under a producer-side lock. The cache itself was a red herring and stays one with the DynamicData - `SourceCache`: its lock is always acquired *upstream* of the Rx gates (the write-through notifies after - `Edit` returns, and nothing downstream re-enters the cache under a gate), so it can never be the deadlock - site — the fix is here at the delivery seam. ⚠️ **`SubscribeOn` is NOT a substitute** — it moves *where you subscribe*, not *where - notifications fire*; the deadlock is in delivery. Consequence for migrators: **`OnQuotesChanged`-style - handlers run on a pool thread** (the toolkit marshals `RaiseItemsChanged`, same as the priced pages' `Task.Run`). -- **The single poll loop (the ticker moved into the repo)** — the repo holds ONE process-lifetime - `PollTicker.Subscribe(RefreshObserved)`. Each tick refreshes the **distinct union of currently-observed - instruments** in one batched fetch. "Observed" = a refcounted registry (`_observed`, a `Lock`-guarded - `Dictionary`) that `ObserveQuotes` **Register**s on subscribe and **Unregister**s - on dispose (via `Observable.Create` + `CompositeDisposable`/`Disposable.Create`) — so a hidden surface's - symbols stop being polled, and a symbol observed by two surfaces is fetched once. Carrying the full - `DomainInstrument` (not just the symbol) is deliberate: routing needs `Category`, which a bare - `BehaviorSubject.HasObservers` boolean couldn't give for a not-yet-loaded symbol. -- **Stale-on-subscribe (the central freshness clock)** — every write-through stamps a per-symbol last-fetch time - (`_lastFetchTicks`, via the `WriteThrough` helper shared by `GetQuotesAsync`/`RefreshAsync`). On subscribe, - `ObserveQuotesCore` refreshes the symbols `NeedsFetchOnSubscribe` flags: **never-cached** ones always (must - render something), plus — only when `AutoRefreshEnabled` — ones whose cached price **aged ≥ one - `RefreshInterval`** while unobserved. The poll loop keeps *observed* symbols stamped within the interval, so - only symbols that went quiet while their surfaces were hidden trip the stale check — that's what makes a hidden - surface refresh on reopen instead of showing a stale price until the next tick. Replaces the priced pages' - per-surface `_lastFullPriceTicks`/`RefreshStaleQuotes` clock. -- **Demo-mode flip** is centralized: the repo's `DemoModeChanged` handler `Clear()`s the cache then - `RefreshSafe`es the observed set from the new source (visible surfaces repaint at once; a hidden one - refetches on next open via `ObserveQuotes`' fetch-missing). - -**Migrated: both dock bands.** `FavoritesDockPage` (the simple template) is a **pure observer**: its whole -lifecycle is `_repository.ObserveQuotes(WatchlistStore.Instance.Favorites).Subscribe(OnQuotesChanged)` and -nothing else — no `PollTicker`/demo subscriptions, no `GetQuotesAsync`/`RefreshAsync`, no keep-last-good code. -`GetItems` disambiguates "no favorites" from "still loading" via `Favorites.Value.Count == 0` (an empty cache -emission with favorites present = spinner, not the empty-state row). Membership changes are free (the `Switch` -overload). ✅ Build clean; ✅ **live-verified on-device** — the band activates without hanging CmdPal and shows -live prices (this is the build that confirmed the `ObserveOn` deadlock fix). **`PortfolioDockPage`** is the -**async-projection template**: same pure-observer shape, but it observes `PortfolioStore.Instruments` and its -handler `await`s `CurrencyConverter.PrimeAsync` before rolling up `UiPortfolio` (one paint), reading -`PortfolioStore.Positions` for the quantities; it disambiguates empty-vs-loading from `Positions.Value.Count`. -Both bands hold subscriptions in a `List` (double-`add`-safe). Build clean; not yet live-verified. - -**Migrated: the `PricedListPage` trio (`WatchlistPage`/`FavoritesPage`/`PortfolioPage`) — the migration is now -COMPLETE for quotes.** Migrating the shared base moved all three: the visible-lifecycle hook is a single -`Repository.ObserveQuotes(_instruments).Subscribe(q => _ = OnQuotesChangedAsync(q))`, and the old per-surface -machinery is **deleted** (the `_priceCache`/`_cacheLock`/`_snapshot`/`_received`/`_fetchGeneration`/ -`_lastFullPriceTicks` fields and the `OnInstrumentsChanged`/`Fetch`/`LoadQuotes`/`PollRefresh`/ -`RefreshStaleQuotes`/`OnDataSourceChanged` methods, plus the page's `PollTicker` + `DemoModeChanged` -subscriptions and the keep-last-good guard). The base now stores the latest emission as `_quotes` -(`UiQuote[]?`, null pre-first-emission, dock-style) and disambiguates empty-vs-loading from -`_instruments.Value.Count`. The async-projection seam generalizes `PortfolioDockPage`'s shape into a base hook -`protected virtual Task OnQuotesProjectingAsync(quotes)` (default no-op) that `PortfolioPage` overrides to -`await CurrencyConverter.PrimeAsync` before the paint — this **replaced** the old `OnPriceCacheUpdated` + -`SnapshotPricedQuotes` hooks (deleted). The manual **Refresh 🔄** row reroutes onto -`RefreshAsync(keepLastGood:false)` and owns its own `IsLoading` spinner (since an unchanged refetch won't -re-emit). `WatchlistPage`/`FavoritesPage` were untouched (Watchlist keeps `RelistTriggers => [Favorites]` for -the ★). Build clean (0 warnings); **not yet live-verified on-device.** The symbol-detail **chart** is the only -surface still on its own `PollTicker` — a separate candle path with no cache (see "Transitional truth" below). - -**The migration checklist (now fully applied — kept as the record of how it was done):** -1. Replace its `MarketRepository.GetQuotesAsync` call + private price cache with a subscription to - `_repository.ObserveQuotes()`; render from the emitted `IReadOnlyList`. -2. **Delete** its `PollTicker.Subscribe(...)` and its `DemoModeChanged` subscription — the repo does both now. -3. **Delete** its keep-last-good guard (the cache owns it) and its manual reconcile (the `Switch` overload - handles add/remove). -4. Disambiguate empty-vs-loading from the membership count (as the dock does). -5. **Threading is already handled — don't add your own.** `ObserveQuotes` delivers via `ObserveOn` (see the - delivery-seam bullet above), so your emission handler runs on a **pool thread** with no Rx lock held — do - NOT wrap the subscribe in `Task.Run` and do NOT add `SubscribeOn`/`ObserveOn` yourself. Just `Subscribe` - and call `RaiseItemsChanged` from the handler (the toolkit marshals it). Re-introducing a synchronous - delivery path (or doing heavy work inside the handler under an Rx gate) is what re-creates the CmdPal hang. -6. ⚠️ **`PricedListPage` is shared by `WatchlistPage`/`FavoritesPage`/`PortfolioPage` — migrating the base - moves all three at once.** Two things to handle: the **freshness clock** (`_lastFullPriceTicks` / - `RefreshStaleQuotes`) is now **handled centrally** by the repo's stale-on-subscribe primitive - (`_lastFetchTicks` + `NeedsFetchOnSubscribe` refreshing null-OR-stale on subscribe), so the base can just - **DROP** its per-surface clock rather than re-wire it. **`OnPriceCacheUpdated`** (Portfolio's FX `PrimeAsync`) - should fold **into the observe handler** — `await` the prime before rendering, exactly as `PortfolioDockPage` - now does — not a separate fetch-completion hook (an unchanged, value-equality-deduped re-fetch won't re-emit, - but the prime keys off the emission's currencies, which only change when the set does anyway). `LeadingRows` - must recompute on each emission. -7. `PortfolioDockPage` is the **worked example** of the async-projection handler (`await PrimeAsync` then roll - up, one paint) — copy its shape for the Portfolio screen rather than reinventing it. - -**Transitional truth (now essentially resolved):** every priced QUOTE surface observes the cache, so there is -no longer a quote surface that double-fetches a shared symbol. The only thing still on its own `PollTicker` -subscription is the symbol-detail **chart**, and that's a separate **candle** path (`GetCandlesAsync`) with no -cache/observe layer — left that way deliberately (only one detail page is ever open, so no cross-surface drift -to fix). Migrating candles onto an `ObserveCandles`-style layer is possible but unplanned. - -### Live price polling (done) - -> **Superseded for all quote surfaces** by "Shared quote cache + repository orchestration" above — the priced -> list pages and both dock bands no longer poll themselves; the repository's single loop polls the observed set. -> The per-surface model below now describes **only the symbol-detail chart** (a separate candle path that still -> subscribes `PollTicker` directly and keeps its own per-range cache + keep-last-good). - -Priced surfaces used to fetch **once** when they became visible (the StateFlow replay-on-subscribe that -drives the first load). They now also auto-refresh on a timer while visible: **default 10 min, settings- -configurable** (0 = off). Built on the ticker's subscriber-count lifecycle (now Rx `Publish().RefCount()`). - -- **Where (in scope):** `Pages/PricedListPage.cs` (Markets Watchlist + Markets Favorites), - `Pages/FavoritesDockPage.cs`, and **the `SymbolDetailPage` chart** (its visible range re-fetches on each - tick via `SymbolChartForm.PollRefresh()` — see the chart section). `SearchPage` is exempt (identity-only - results, no prices). -- **The ticker = `Helpers/PollTicker.cs`** — **pure Rx** (originally `PollTicker : StateFlow`; the Rx - migration converted it). A process-wide singleton `IObservable` = `Observable.Generate(...)` - (a self-rescheduling timer that emits a tick after each per-step delay) wrapped in **`Publish().RefCount()`**. - `RefCount()` IS the OnActive/OnInactive seam: the Generate loop **starts on the first subscriber** (0→1) - and is **torn down on the last unsubscribe** (1→0), so polling runs only while something is watching. - Generate's `condition` is always true so the source **never completes** — teardown is therefore a silent - disposal (no OnCompleted/OnError reaches the shared subject), and a later resubscribe restarts cleanly - (so a rapid inactive→active bounce just reconnects; no two-loops hazard, no manual CTS/lock). The - `timeSelector` re-reads `MarketSettingsManager` each iteration (interval/on-off applies without a reload); - when off it idles on a 30 s re-check and a `.Where` drops the tick, so toggling it back on resumes. (The - old design used `StateFlow` + a tick counter because re-emitting the membership list would be - swallowed by distinct-until-changed; the Rx version sidesteps that entirely — a tick is a proper event, - not deduped state.) -- **No double-fetch on open.** The ticker is a **pure event stream** (not a `BehaviorSubject`), so there is - no replay to suppress: surfaces just `Subscribe(_ => PollRefresh())` and opening a page doesn't fire an - extra fetch (the membership flow's replay already paints the initial prices); the ticker only drives - *subsequent* refreshes. -- **Silent, flicker-free refresh.** Each surface's `PollRefresh()` re-prices **without** clearing its - cache or setting `IsLoading`, so the on-screen prices stay put and are swapped in place once the new - quotes land (contrast the manual "Refresh 🔄" row, which still clears + spins). `PricedListPage` - reuses its existing `LoadQuotes` + `_fetchGeneration` guard (so a membership change still wins); - `FavoritesDockPage` just re-runs `LoadQuotes` (which assigns `_quotes` without a pre-clear). -- **Keep-last-good guard.** `PricedListPage.LoadQuotes(…, silent: true)` won't overwrite a **valid** - cached `UiQuote` with an **invalid** poll result, so a transient bad poll (e.g. a 429 mapped to an - invalid quote) degrades gracefully instead of blanking a price that was fine. This is a first step, not - the full 429 story (back-off + an explicit "rate-limited" state remain deferred). -- **Shared-source refcount comes free.** `FavoritesPage` (via `PricedListPage`) and `FavoritesDockPage` - both subscribe to the **one** `PollTicker.Instance`: its refcount keeps the timer alive while *any* - priced surface is visible and stops it only when *all* are gone — a pinned dock keeps it warm. -- **Stale-revisit catch-up (the timer-reset fix).** Because the priced pages are long-lived singletons - whose `_priceCache` survives navigation, revisiting an unchanged set repaints cached prices with **no - fetch**, and each revisit restarts the `PollTicker` countdown from a full interval (it resets on the - 0→1 subscriber transition) — so visits shorter than the interval never refreshed (a pinned dock masked - it, since it holds the subscriber count above 0). `PricedListPage` now stamps `_lastFullPriceTicks` on - every whole-set re-price (a partial add of new symbols does **not** advance it, so the older rows keep - aging) and, when a fully-cached set becomes visible, `RefreshStaleQuotes()` fires **one silent catch-up - re-price if those prices have aged ≥ one `RefreshInterval`**. One fetch per revisit, only when stale, - gated on `AutoRefreshEnabled` (so 0 = off stays off) — maximal liveness without extra API calls. The - `FavoritesDockPage` doesn't need this: it refetches on every show (its favorites-flow replay calls - `RefreshQuotes`, which clears `_quotes`). -- **Settings.** `Settings/MarketSettingsManager.cs` (`JsonSettingsManager` singleton, - `…/market.settings.json`, wired via `Settings = MarketSettingsManager.Instance.Settings;`). The refresh - interval is a numeric `TextSetting` **in minutes, default 10** (0 = off), surfaced as `RefreshMinutes` / - `RefreshInterval` (TimeSpan) / `AutoRefreshEnabled`; the ticker reads these each tick. A - **`ToggleSetting` `Show rate-limit warnings`** (default on, surfaced as `ShowRateLimitErrors`) gates the - rate-limited banner — read pull-style by `RateLimitHint.Row()`, so toggling it applies on the next - re-render. A **`ToggleSetting` `Demo mode`** (default off, surfaced as `DemoMode`) switches the app to - built-in sample data with no key/network — see "Demo mode (offline testing)". (Same manager also holds the - API keys + the `PortfolioCurrency` choice — see the "API Key" section.) -- ⚠️ **Rate-limit tension (still real):** Finnhub free tier is **~60 calls/min AND ~300/day**, and - `GetQuotesAsync` issues **one `/quote` call per instrument**. Polling N instruments at interval T burns - the budget fast at a low T — e.g. 6 favorites every 60 s = 360 calls/hour, **exhausting ~300/day in - under an hour**. The **10-min default is gentler** (6 favorites = ~36 calls/hour). In place today: the - configurable interval + **Off** (0) escape, polling **only while visible**, the keep-last-good guard, - and now **429 back-off + a "rate-limited" banner** (`HttpRetry` / `RateLimitSignal` / `RateLimitHint` — - see the "Done (this round)" bullet). Note the back-off intentionally **bails fast** rather than retrying - hard, since these are per-minute windows; a batched-quote provider (Twelve Data already does this) is the - real relief. ⚠️ Note: each visible surface polls independently, so a pinned favorites - dock **and** an open favorites page both fetch favorites each tick (no shared-fetch dedup). This is an - **accepted trade-off, not a todo** — at most three priced surfaces exist (Watchlist / Favorites page / - Favorites dock) and the only real overlap is dock + Favorites page on one small set, so the worst case - is ~2–3× on a handful of symbols. A repository-level in-flight coalescing cache would close it cheaply - if it ever matters, but it's deliberately **not** planned. - -### Dock refresh on favorites change (done) - -> **Implementation superseded by the cache migration** (see "Shared quote cache + repository orchestration"): -> `FavoritesDockPage` no longer calls `RefreshQuotes()` on a `Favorites` subscription — it observes -> `MarketRepository.ObserveQuotes(WatchlistStore.Instance.Favorites)`, and the `Switch` overload re-projects -> on membership change. The user-facing behavior below (a pinned band updates instantly on a favorite change) -> is unchanged; the mechanism is now the cache observe stream. - -A pinned `FavoritesDockPage` updates the instant a favorite changes anywhere, instead of going stale -until reopened. Implemented via the observable layer: - -- `WatchlistStore.Favorites` is a `StateFlow`; every favorite flip re-publishes it (`PublishState()`), - deduped by `InstrumentListComparer` so a watchlist-only change doesn't wake favorites subscribers. -- `FavoritesDockPage` **subscribes in its `add` accessor and disposes the `IDisposable` in `remove`** - (the Loaded/Unloaded lifecycle), calling `RefreshQuotes()` on each emission. Subscribing only while - visible keeps a hidden band from doing work and avoids leaks. -- Complementary to the (still-pending) polling: polling catches *price* drift on a timer; this pushes - *membership* changes immediately, and still works when polling is **Off**. - -### Portfolio screen + dock band (done) - -**Markets Portfolio** is live: a fourth screen off the Markets hub tracking actual *holdings* (a quantity -per symbol), priced live, with a **totals summary** pinned on top and **daily P&L** per holding. Reuses -the existing data seam with **no provider/repository changes** — `MarketRepository.GetQuotesAsync` already -returns per-instrument daily change. - -**Scope shipped:** quantity + **daily P&L** + **total return** (unrealized P&L vs. cost basis). The optional -per-unit `CostBasis` on `DomainPosition`/`PortfolioItem` is now captured in the editor and surfaced — see -"cost-basis / total-return" below; a holding with no basis simply omits the total-return figure. - -**Data model** (the usual `Api`/`Domain`/`Ui` layering; formatting only in `Ui*`): -- `Settings/PortfolioStore.cs` — JSON-persisted holdings, modeled on `WatchlistStore` (singleton, lock, - source-gen `PortfolioJsonContext` → `market_portfolio.json`). Each entry = symbol + name + category + - **quantity** (`decimal`, so crypto fractions work) + optional cost basis. **No first-run seed** (empty - by default). Exposes two `StateFlow`s: `Positions` (instrument + quantity — for rendering, the totals, - and the detail-page command bar) and `Instruments` (symbols to price — for the `PricedListPage`). Both - use **default reference equality, NOT `InstrumentListComparer`**, so EVERY mutation re-emits — including - a quantity-only edit that leaves the symbol set unchanged. That's what makes an edited quantity repaint: - the base reconcile finds nothing missing → no fetch → the re-render just reads the new quantity. -- `Models/DomainPosition.cs` — `DomainInstrument` + quantity + optional cost basis, **no prices**. -- `Models/UiPosition.cs` + `UiPortfolio.cs` — presentation: `UiPosition` combines a `DomainQuote` with the - quantity (and optional cost basis) → `MarketValue` (qty × price), `DailyPnL` (qty × `DomainQuote.Change`), - `TotalCost`/`TotalReturn`/`TotalReturnPercent` (qty × basis; unrealized P&L; `(price−basis)/basis`), - `Format*()` helpers (the only place position formatting lives, like `UiQuote`); `UiPortfolio.From(...)` - rolls up the value/daily-P&L totals plus the cost + total-return rollup over holdings that have a basis. - -**Daily P&L:** per position = `Quantity × DomainQuote.Change`. Total value = Σ market value; total daily -P&L = Σ daily P&L; aggregate **percent = `totalDailyPnL / (totalValue − totalDailyPnL)`** (vs. yesterday's -close) — not an average of the per-position percents (guards ÷0). `IsValid:false` holdings are excluded -from the totals (shown as unpriced rows). - -**Total return (cost basis):** per position = `MarketValue − Quantity × CostBasis` (the converted form when -the holding's currency differs from `PortfolioCurrency`); **percent = `(price − basis)/basis`**. Surfaced -only when a **positive** basis was recorded. The portfolio rollup sums **converted** cost + return across the -counted holdings that have a basis (aggregate **percent = `totalReturn / totalCost`**, ÷0-guarded); a holding -without a basis contributes to value/daily-P&L but not to the return rollup. Cost basis is stored **per unit -in the holding's native currency**, so it converts with the same `RateToPreferred` as the value. - -**Screen** (`Pages/PortfolioPage.cs`): a **`PricedListPage` subclass** (like Watchlist/Favorites), so it -inherits all the caching/polling/reconcile/keep-last-good plumbing — it just observes -`PortfolioStore.Instruments`. Per-row quantity is read from the store in `BuildRow` (the way `WatchlistPage` -reads `IsFavorite`). The **totals summary** is the first row, rendered via a new -`PricedListPage.LeadingRows(pricedQuotes)` **hook** (default empty → Watchlist/Favorites unaffected), fed -the **full unfiltered** priced set so the total reflects the whole portfolio even while search filters the -rows. Rows render "AAPL · 10 sh" → market value + daily P&L. - -**Management lives on the symbol detail page** (consistent with every other list — Portfolio rows just open -`SymbolDetailPage` on Enter, **no context actions**). `SymbolDetailPage.BuildCommands` gained portfolio -actions in the overflow: **Add to Portfolio** / **Edit holding** (both open `Pages/SetQuantityPage.cs`, -which auto-labels itself by current membership) plus **Remove from Portfolio** -(`Commands/PortfolioCommands.cs`) once held. The page also subscribes to `PortfolioStore.Positions`, so the -bar flips Add→Edit/Remove the instant a quantity is saved. - -**`Pages/SetQuantityPage.cs`** — the holding editor: a `ContentPage` whose single `FormContent` is an -adaptive card with a **quantity** `Input.Number` (required, > 0) and an optional **average-cost-per-unit** -`Input.Number` (the cost basis), both prefilled from the current holding (0 when adding). `SubmitForm` -validates quantity > 0 and maps a **0/blank cost basis → null** (cleared → total return hidden), then calls -`PortfolioStore.SetPosition(instrument, quantity, costBasis)` (which now applies the basis verbatim), toasts, -and `GoBack()`s to the detail page. The single-`FormContent` auto-focus quirk (that plagues the chart) is -**harmless/helpful here** — it drops the cursor straight into the quantity field. - -**The dock band — DONE** (`Pages/PortfolioDockPage.cs`): a second band in `GetDockBands()` next to favorites, -rendering **one summary button** — total value + daily P&L rolled up into the `PortfolioCurrency` (the same -totals `UiPortfolio` the screen pins on top); clicking it opens the full `PortfolioPage`. A `ListPage` modeled -on `FavoritesDockPage` with its own non-empty `Command.Id` (`com.costafotiadis.market.dock.portfolio` — see -`reference/dock-support.md`), registered as a second `CommandItem` in `MarketExtensionCommandsProvider.GetDockBands()`. -It inherits the favorites band's live-refresh story: the `INotifyItemsChanged` visible-lifecycle hook -subscribes to `PortfolioStore.Positions` (replay paints on open; add/edit/remove — incl. quantity-only edits, -since that flow is reference-equality — repaint at once) **and** `PollTicker` (silent live re-price), disposing -both on hide. Unlike the screen (which primes FX off the price-load path and paints native-only first), the -band **`await`s `CurrencyConverter.PrimeAsync`** inside its async load so the converted total is ready in a -single paint — a one-row band has no progressive-paint benefit. Same keep-last-good guard so a transient bad -poll doesn't drop a holding and make the total jump. Like the favorites band it **contributes to the -rate-limit signal but renders no banner** (too cramped). No provider/repository/model changes. -⚠️ **Icon gotcha:** the summary row uses the Segoe MDL2 Bank glyph (U+E825); the Write tool drops literal -private-use glyph chars (blank icon), so byte-check after editing (real glyph = `EE A0 A5`) or use a C# -Unicode escape. ✅ Live-verified: band shows the total + P&L + icon. - -**Cost-basis / total-return — DONE (this round).** The optional per-unit `CostBasis` is now captured in the -holding editor (`SetQuantityPage`) and surfaced as **total return** (unrealized P&L since purchase) on the -per-row subtitle ("Total ▲ +$… (+…%)"), the totals summary row, and the Portfolio dock band — via -`UiPosition.TotalCost/TotalReturn/TotalReturnPercent` and the `UiPortfolio` rollup (`HasCostBasis` + -`FormatTotalReturnNote()`). Multi-currency aware: the native basis converts into `PortfolioCurrency` with the -same FX rate as the value, so per-row returns sum to the total. `PortfolioStore.SetPosition` applies the -basis verbatim (null clears) and `GetPosition` returns the full holding for rendering. Holdings without a -recorded basis (or unpriced/unconvertible ones) contribute to value/daily-P&L but not the return rollup. ✅ -**Live-verified** via **Demo mode** (the GBP `HSBA.L` holding with a cost basis shows the converted total -return on the per-row subtitle, the totals summary, and the dock band). - -### Multi-currency portfolio conversion (done) - -**The `$`-everywhere assumption is gone.** Holdings priced in any currency are now valued in **both** their -native currency **and** the user's `PortfolioCurrency`, and the portfolio total is rolled up in the preferred -currency. Built behind the existing data seam with **no repository changes** — only `DomainQuote` gained a -field and `PortfolioPage` gained an FX-priming step. - -**1 — Capture native currency (static metadata, resolved once per provider seam).** `DomainQuote` carries an -ISO-4217 **`Currency`** (default `"USD"`, so every existing construction stays correct). Providers stamp it -when mapping their `Api*` DTO; the **`Helpers/CurrencyHelper.cs`** rules are shared so each provider does it -identically: -- **Twelve Data (primary):** added `currency` to `ApiTwelveDataQuoteDto` — it rides in the batched `/quote` - we already fetch (**zero extra calls**). Mapped per category: stock → the field (normalized), crypto → USD, - FX → the pair's quote currency. -- **Finnhub (fallback):** `/quote` has no currency, so stocks resolve via **`/stock/profile2?symbol=`**, - cached **per-symbol forever** (`StockCurrencyCache`) — one extra call per *new* stock symbol per session - (US tickers just confirm USD); crypto short-circuits to USD with no call; FX isn't served here. The USD - fallback on a failed profile2 is **cached too**, so a flaky profile2 can't double Finnhub's poll volume. -- **Frankfurter (FX):** stamps the pair's **quote currency** (`EURUSD → USD`, `USDJPY → JPY`). -- ⚠️ **GBX/pence trap handled:** `CurrencyHelper.NormalizeStockQuote` folds London's `GBp`/`GBX` (pence) to - **GBP**, dividing price *and* change by 100, so UK holdings aren't 100× too large. The Domain layer only - ever sees major-unit prices in a major-unit code. - -**2 — Convert via `Helpers/CurrencyConverter.cs`** (keyless Frankfurter `/latest`, the same ECB source as the -FX provider). Because the screen renders synchronously but FX is a fetch, it's two-phase: `PrimeAsync` fetches -every not-yet-fresh `native→preferred` rate in **one batched call** (cached per pair, ~1 h TTL, negatives -cached too); `TryGetRate` is the synchronous cache read used while building rows (`from==to → 1`, unknown → -`null`). An all-USD portfolio with USD preferred does **zero** network (rate 1 short-circuit). - -**3 — Display (`UiPosition`/`UiPortfolio`, the only place the formatting lives).** `UiPosition` takes the -preferred currency + the `native→preferred` rate and exposes native value/P&L **and** their converted forms; -a row shows e.g. `£75.00 (≈$98.70)` and its P&L in the converted currency (so per-row P&L sums to the total). -`UiPortfolio.From(positions, preferred)` sums the **converted** values and formats the total via -`CurrencyFormat` with the preferred currency's symbol. `Helpers/CurrencyFormat.cs` owns the per-currency -symbol/decimals (replacing the hardcoded `$#,##0.00`). - -**Wiring (`PortfolioPage` + one base hook).** Rates are primed off the price-load path: `PricedListPage` gained -a `protected virtual void OnPriceCacheUpdated()` hook (fired whenever the price cache changes; default no-op, -so Watchlist/Favorites are unaffected) + a `SnapshotPricedQuotes()` accessor. `PortfolioPage` overrides the -hook to `PrimeAsync` the current currencies then re-render, and reads `TryGetRate` synchronously in -`LeadingRows`/`BuildRow`. First paint shows native-only, then swaps in converted values when the rates land -(progressive, no flicker). Beyond the portfolio, `UiQuote.FormatPrice` is now currency-aware too, so a London -stock shows `£2.50` on the Watchlist/dock instead of `$2.50`. - -**Excluded from the total** (and surfaced as "*N* not converted" on the summary): a holding whose native -currency the ECB set can't convert. `IsValid:false` (unpriced) holdings remain excluded as before. - -**Caveats / not done.** Reporting currency is cached **in-memory per session** (Finnhub `StockCurrencyCache`), -not persisted with the instrument — a reload re-resolves it (one profile2 call per stock). Changing the -`PortfolioCurrency` setting applies on the **next price refresh/revisit** (pull-style, like the other -settings), not instantly. ✅ **Live-verified** end-to-end: a USD holding (10 sh SPY) with **GBP** preferred -rolled up to ~£5,571 — i.e. the USD value × ~0.76, a genuine conversion, not a relabel. Still worth a spot -check: the **GBX/pence** path (a London `.L` stock → ÷100 → GBP) and a **non-USD native** holding priced by -Finnhub via profile2. Build clean (0 warnings). - -### Demo mode (offline testing) (done) - -A **`Demo mode` toggle** in Settings (`MarketSettingsManager`, key `demoMode`, default **off**, exposed as -`DemoMode`) puts the whole app on **built-in sample data with no API key and no network** — for demoing the -extension or testing UI changes without spending real-API budget. It exercises every path: quotes, candles, -search, multi-currency conversion, and total return. - -**How it routes (no reload needed).** The mock takes precedence **everywhere** via a general "exclusive -provider" seam, not a separate build path: -- `IMarketDataProvider` gained a default member **`bool IsExclusive => false`**. `MockMarketDataProvider` - returns `MarketSettingsManager.Instance.DemoMode`. `MarketRepository.ActiveProviders()` returns **only the - exclusive providers when any exist**, else the full set — and quotes, candles, **and the search fan-out** - all route through that active set. So in demo mode the mock is the *sole* source for **every** operation; no - live provider is consulted at all (this closes the old search-leak that `Supports()` couldn't gate, since - search fans out to all providers). -- `MockMarketDataProvider` is also registered **first** with `Supports(category)` gated on `DemoMode` (so when - demo is off it's neither exclusive nor selectable → the real providers serve unchanged), and `SearchAsync` - still self-gates to `[]` when off (off → it's back in the normal search fan-out, so it must stay quiet). -- `CurrencyConverter.PrimeAsync` checks the same setting and, when on, fills its rate cache from a **static - `DemoUsdPerUnit` table** (USD value per unit → native/preferred ratio) instead of calling Frankfurter - `/latest`. So even cross-currency portfolio totals/returns are fully offline; `TryGetRate` reads the cache - exactly as for live rates. -- `ApiKeyHint` is now a unified **`StatusRow()`** (demo on → a blue "Demo mode — showing sample data" row so - it's obvious prices are simulated; else no key → the red "No API key set" nudge; else null). Both rows' - Enter opens Settings (to turn demo off, or add a key). The hub, Search, Watchlist and Favorites append it. - -Flipping the toggle applies **immediately** across the app — unlike `PortfolioCurrency` / -`ShowRateLimitErrors`, which stay pull-style. `MarketSettingsManager` publishes an observable -**`DemoModeChanged`** (`StateFlow`) from its `SettingsChanged` handler, and every surface subscribes -and resets at once: the priced pages drop their price cache (a **lifetime** subscription, so even a hidden -singleton page invalidates — re-fetching if visible, else on next open), the dock bands re-price, -`CurrencyConverter` clears its rate cache (next prime refills from the static demo table vs live ECB), and -the hub/Search re-list so the status row swaps. No reload, no process restart. The **rate-limited banner is -also suppressed in demo mode**: `RateLimitHint.Row()` guards on `DemoMode`, and `RateLimitSignal` clears its -flag when demo turns on (offline, nothing would otherwise clear a 429 set before demo mode). The -quote/candle/search **routing** itself is still read pull-style per request (`MockMarketDataProvider.Supports` -/ `IsExclusive` and `CurrencyConverter` check `DemoMode` each call) — the `DemoModeChanged` broadcast is what -makes the already-rendered surfaces re-fetch through that routing the instant the toggle flips. - -**Coverage — any symbol** (`MockMarketDataProvider`). Two sources back demo data: -- **`Seed`** — hand-tuned, nice-looking prices for the headline symbols: AAPL/MSFT/NVDA (USD), **HSBA.L (GBP, - a London stock)**, BTC/ETH/SOL (USD), EURUSD/GBPUSD/USDJPY. Used as quote *overrides*; the GBP stock gives - multi-currency conversion + total return a non-USD holding to exercise. -- **`SynthesizeQuote`** — for any **non-seeded** symbol, a stable-hash quote (deterministic across refreshes - and process restarts) with native **currency inferred from the symbol shape** (crypto→USD; FX pair→quote - currency; stock→exchange suffix `.L`→GBP/`.DE`→EUR/`.T`→JPY/… else USD) and a category-appropriate price - magnitude. So an off-seed *real* holding added while live still prices in demo mode instead of showing a - blank row. Candles anchor on the same price (seed or synthesized) — a deterministic per-`(symbol,range)` - random walk. - -**Search** matches a curated **`Catalog`** of ~120 *real* instruments (US + international stocks, crypto, FX; -prefix-on-symbol ranked, capped at 25) — each typed with its real category. It deliberately does **NOT** -fabricate matches for free-text: a watchlist/portfolio persists the full `DomainInstrument` to JSON, so a -faked symbol would become a permanent blank row once demo mode is off. Curated corpus = search feels complete -but can only ever surface genuine tickers. - -**Network in demo mode: none.** Quotes, candles, search, and FX conversion are *all* served from the mock / -the static rate table — the `IsExclusive` routing means even a user with live API keys set hits **zero** -network for market data while demo mode is on. (Logos are still fetched by the host from Elbstream's CDN — -that's the host rendering an `IconInfo` URL, not an app data call, and is unaffected.) ✅ **Live-verified**: -toggling demo mode swaps the whole app to sample data, the mock wins everywhere even with live keys set, and -the blue "Demo mode" status row makes it obvious. - -### Symbol detail + live chart — for ANY instrument (CHART DONE; flicker fixed, Enter/focus bug open) - -**App-wide, not portfolio-specific.** The shared `Pages/SymbolDetailPage.cs` opens from any row (Enter) -and every dock-button click, and **now renders a real price chart with Robinhood-style range tabs -(1D / 1W / 1M / 1Y / 5Y)**. - -**What was built (and how it differs from the original plan):** the original sketch sampled the live -price into a rolling buffer (Perf Monitor's model). That can't produce *retrospective* 1W/1Y/5Y history, -so we instead fetch **real candle history** from Finnhub's premium `/stock/candle` (+ `/crypto/candle`) -through a new provider seam — see the Finnhub candle spec + the candle layer files above. Concretely: -- `ChartRange` + `CandleInterval` (neutral), `DomainCandleSeries`/`CandlePoint`, `UiCandleSeries`, - `Helpers/ChartHelper.cs` (ported SVG sparkline), `ApiFinnhubCandleDto`, and - `IMarketDataProvider.GetCandlesAsync` → `MarketRepository.GetCandlesAsync` → - `FinnhubMarketDataProvider.GetCandlesAsync` (+ `MockMarketDataProvider` synthetic series). -- The page body is a nested `SymbolChartForm : FormContent`: an adaptive card binding the SVG chart as - an `Image`, with the 5 range tabs as `Action.Submit` buttons → `SubmitForm` switches range, fetches - (per-range in-memory cache + a generation guard against fast taps), and updates `DataJson` to repaint. - The first fetch fires from the page's "became visible" hook (the `INotifyItemsChanged.add`), so list - rows building a `SymbolDetailPage` per item never trigger a fetch. Header price/%change reflect the - **selected range** (last vs. first close), Robinhood-style — derived from the series, no extra `/quote`. -- **Gating:** this premium limit is **Finnhub-only**. With **Twelve Data** primary (its `/time_series` - candles are free-tier), a free Twelve Data key renders real charts; only the **Finnhub fallback** path - 403s on a free key → the card shows "requires a paid Finnhub plan". To preview rendering with no key at - all, turn on **Demo mode** in Settings (the `DemoMode` toggle → `MockMarketDataProvider` draws synthetic - candles; see "Demo mode (offline testing)"). - -**State of the two UI issues: flicker FIXED; the Enter/focus bug is UNSOLVED (left as-is, documented).** - -1. **Flicker on range switch — ✅ FIXED.** Each `DataJson` write rebuilds the whole card - (`ContentFormViewModel.RenderCard` → `AdaptiveCard.FromJsonString`; `ContentFormControl.DisplayCard` - clears + re-adds children), and `Load()` used to write `DataJson` **twice** per tap (a "Loading…" - state, then the result) → a double teardown/rebuild flash. **Fix:** `Load()` now only paints the - "Loading…" card when **no chart is on screen yet** (the very first load); on later range switches it - leaves the prior chart up and swaps it in place once the fetch lands. Tracked via `_displaySeries` - (the currently-painted series; a real chart present ⇒ skip the loading write). Cached ranges already - single-write. - -2. **Enter activates "1D" instead of the primary command — ❌ UNSOLVED.** Root cause: with a single - `FormContent`, CmdPal sets `OnlyControlOnPage = true` (`ContentPageViewModel`, `= (content.Count == 1)`) - and **programmatically focuses the card's first focusable element** on load (`ContentFormControl. - OnFrameworkElementLoaded` → `FindFirstFocusableElement().Focus()`) — the 1D `Action.Submit`. So Enter - hits that tab, and with focus trapped in the card **Ctrl+Enter can't reach the secondary command - either**. There is **no host API to suppress the auto-focus** — content count is the only documented - lever. **Two fixes were tried and abandoned:** - - **Option A — return a 2nd content item** (a hint-caption `MarkdownContent`) so `content.Count == 2` - → `OnlyControlOnPage` should be false → focus should stay in the search box. The CmdPal **source - reads this way**, but **in practice it did NOT help**: focus still landed on the card's first action - (Enter still activated a tab) **and Ctrl+Enter stopped working too** — strictly worse. Why the - observed behavior diverges from the source reading is unexplained (a lead for future investigation; - not chased now). Reverted. - - **Option C — lead the card with membership `Action.Submit` buttons** so the auto-focused first - element is the watchlist toggle (Enter then add/removes). It worked but was rejected as too hacky and - it splits list management away from the command bar (inconsistent with every other surface). - **Current state:** back to the post-flicker-fix single-content card; membership stays on the command - bar; the focus bug is **accepted as a known limitation**. Possible real fixes if revisited: a host API - to set/suppress the focused element (doesn't exist today — would need an upstream CmdPal change), or - moving range switching onto the command bar so the card has no focusable element at all. - -For reference, the Perf Monitor source this chart was ported from -(`C:\Users\jarla\code\PowerToys\src\modules\cmdpal\ext\Microsoft.CmdPal.Ext.PerformanceMonitor\`): - -**How Performance Monitor does it (the pattern to copy):** -- A band button's `Command` is itself an **`IContentPage`** (`WidgetPage : OnLoadContentPage`), so - clicking the button **navigates into** the page. Same trick works for a list row — point the row's - command at the content page. -- The page renders an **Adaptive Card** via a `FormContent` (`TemplateJson` = an adaptive-card template + - `DataJson` = bound values); `GetContent()` returns `[formContent]`. -- **The chart is an inline SVG embedded as a data URI** — `ChartHelper.CreateImageUrl(values, type)` - returns `"data:image/svg+xml;utf8," + …`, where the `` is just two ``s (the - line + a gradient-fill loop) and a border ``, built with `System.Xml.Linq`. **No - System.Drawing/SkiaSharp** → a dependency-free, reflection-free SVG (now just good hygiene, not a hard - requirement since AOT/trim is off — see the AOT/trim note). The card - template binds that data-URI as an `Image`. `MaxChartValues = 34` is a rolling window of the last 34 samples. -- Live update reuses the same Loaded/Unloaded lifecycle as our pages: `OnLoadBasePage` turns the - `ItemsChanged` add/remove into `Loaded()`/`Unloaded()`; each data tick re-renders `DataJson` and calls - `RaiseItemsChanged()` so the card repaints. A `PushActivate`/`PopActivate` refcount keeps the source - alive while either the band button or the open chart needs it. - -**Where to look (exact files):** -- `…/PerformanceMonitor/PerformanceWidgetsPage.cs` — `WidgetPage : OnLoadContentPage`, `GetContent()` → - `FormContent`; `LoadContentData()` sets `cpuGraphUrl = CreateCPUImageUrl()`; the `Updated → - RaiseItemsChanged` repaint; the `PushActivate`/`PopActivate` refcount. -- `…/PerformanceMonitor/OnLoadStaticPage.cs` — `OnLoadContentPage` / `OnLoadBasePage`: the `ItemsChanged` - add/remove ⇒ `Loaded()`/`Unloaded()` (the generalized form of our on-load hook). -- `…/PerformanceMonitor/DevHome/Helpers/ChartHelper.cs` — **the file to port**: `CreateImageUrl` → - `CreateChart(List, type)` building the SVG polyline/rect; `ChartHeight`/`ChartWidth`, `MaxChartValues`. -- `…/PerformanceMonitor/DevHome/Helpers/CPUStats.cs` — `CreateCPUImageUrl()` + the `CpuChartValues` - rolling `List` buffer (the sampling model). -- `…/PerformanceMonitor/DevHome/Templates/*.json` — the adaptive-card templates that place the - `${…graphUrl}` image; copy the shape. -- MediaControls `…/Pages/DockHeadItem.cs` — the event-driven content-band variant. - -**Refinements still open (flicker is fixed; the Enter/focus bug above is still open):** -- **Gridlines — ✅ DONE; numeric axis labels NOT POSSIBLE.** The chart draws a faint quarter-gridline box - (the "normal graph" look) so the scale reads at a glance. **Numeric axis ticks were attempted and - abandoned:** the SVG is rasterized by Direct2D's `ID2D1SvgDocument`, which **does not render ``** - (it silently drops it — lines/polylines/rects/gradients are fine), so `` price/time labels never - appeared (verified: gridlines showed, numbers didn't). The standard workaround — moving labels into the - adaptive card around the image (a left price column + a bottom time `ColumnSet`) — was deliberately **not** - pursued (alignment with the stretched image is fragile; deemed not worth the hack). Grid is decorative - only, theme-neutral gray. (This is the same ceiling Perf Monitor's chart hits — it has no labels either.) -- **Active-tab highlight:** the tabs don't visually mark the selected range (adaptive cards can't easily - restyle a button by bound data); the header caption names it instead. -- **Keyboard range switching:** you must Tab into the card to reach the tabs — no arrow-key switch from - the search box. Entangled with the open Enter/focus bug: any real fix there (e.g. moving range - switching onto the command bar, or host support for the focused element) would address this too. -- **Hover/scrub readout is NOT possible** on this surface: CmdPal extensions are out-of-process and the - chart is a static image — no pointer/hover events cross the boundary (Perf Monitor's graph has the same - ceiling; the content types are all declarative: Markdown / PlainText / Image / Form / Tree). Closest - approximation = bake High/Low/Open/Close text + on-chart min/max labels (the `o/h/l/v` arrays are - already parsed in `ApiFinnhubCandleDto`, just not yet promoted past `Close` into `CandlePoint`). -- **Live auto-refresh — ✅ DONE.** The open chart now re-fetches its **visible range** (not just 1D) on - each `PollTicker` tick and repaints the `FormContent` in place, sharing the one ticker + - `Publish().RefCount()` lifecycle with the priced list pages. `SymbolDetailPage` subscribes the chart in its - visible-lifecycle hook (`replayOnSubscribe:false` — no double-fetch on open) and `SymbolChartForm. - PollRefresh()` does the silent re-price: bypasses the per-range cache so the on-screen range actually - refreshes, reuses the generation guard so a tab tap still wins, and keeps the last good chart on a - transient empty poll. On the Finnhub fallback path candles are premium-gated (a free key re-fetches a - 403 each tick); with Twelve Data primary its free-tier candles refresh for real. See the "Done (this - round): symbol-detail chart live refresh" bullet in Current Status. -- Not yet wired to a future `PortfolioPage` (doesn't exist yet). - -### Asset logos as icons, app-wide (done) - -Every instrument row, dock button, and the `SymbolDetailPage` header shows the instrument's **real logo** -(AAPL's apple, BTC's coin) instead of the generic github-favicon. Page **chrome** icons (list/search page -headers) still use the favicon. - -**Shipped approach — Elbstream CDN, addressed by symbol (zero API calls).** Because `new IconInfo(url)` -makes the **CmdPal host** fetch the image, the logo URL is built *directly from the symbol* — so there is -**no API call, no caching layer, and no DTO**. `Helpers/AssetIconResolver.cs` is the single place the -convention lives: -- `Stock → https://api.elbstream.com/logos/symbol/{ticker}?format=png` -- `Crypto → https://api.elbstream.com/logos/crypto/{sym}?format=png` -- `Currency → https://api.elbstream.com/logos/country/{iso2}?format=png` — an FX pair shows its **base - currency's country flag** (a small currency→ISO-3166 map, `EUR→eu`/`USD→us`/`JPY→jp`/…; the `eu` flag - covers the Euro). Unmapped currencies and unknown categories fall back to a Segoe MDL2 glyph. - -Our neutral `DomainInstrument.Symbol` matches Elbstream's identifiers directly (`AAPL`, `BTC`), so no -provider-specific symbol translation is needed. `format` ∈ svg/png/webp/jpg (default svg, PNG auto-fallback -when no SVG); `size` defaults to 100px. A **miss returns HTTP 404** (verified — not a placeholder image), so -an off-catalog/obscure symbol shows the host's empty-icon slot; the glyph fallback only covers whole -categories, **not** per-symbol misses (a true glyph-on-miss would need a per-symbol HEAD probe + -swap-on-arrival — deferred to keep the zero-call simplicity). - -**Attribution (required).** Elbstream is free **with attribution**: a clearly-visible link back, min 12pt, -on any page where a logo shows — `Logos provided by Elbstream`. Placed as: -- a trailing clickable "Logos provided by Elbstream" row on the priced list pages (`PricedListPage.GetItems`, - beside Refresh) and on Search results (`SearchPage.SearchItems`, only when results are present); -- a subtle `TextBlock` line **inside** the `SymbolDetailPage` adaptive-card template (deliberately NOT a 2nd - `IContent` item — that would re-trigger the `OnlyControlOnPage` auto-focus bug); -- the **dock band is an accepted gray area** (no room for a link; arguably a "band," not a "page"). Strict- - compliance escape if ever needed: keep the dock's generic icon so no un-attributed logo surface exists. - -The credit row is clickable via `Commands/OpenUrlCommand.cs` → `ProcessHelper.OpenUrl(url)` (a shell launch, -not a captured `Run`). The shared row factory is `AssetIconResolver.AttributionRow()`. - -**Wired in:** `SearchPage.BuildResultItem`, `WatchlistPage.BuildRow`, `FavoritesPage.BuildRow`, -`FavoritesDockPage.GetItems`, and the `SymbolDetailPage` ctor all set `Icon = AssetIconResolver.Resolve(...)`. - -**Alternatives considered (why not):** Finnhub `/stock/profile2` (official `logo` URL, reuses our key, no -attribution) — but stocks-only and spends the tight ~300/day budget (one call per symbol, even with a -forever cache). Logo.dev (Clearbit's official successor, 500K/mo free) — needs a token → a new settings -field. Bundled static assets (e.g. `spothq/cryptocurrency-icons`) — offline/AOT-safe but only covers what -you ship. ⚠️ **Clearbit's free logo API was permanently sunset Dec 2025 — do not use it.** Elbstream won as -the only keyless, symbol-addressable source covering stocks + crypto + forex from one place; its attribution -requirement was the accepted trade-off. - -**Future polish:** a glyph-on-miss probe if blank slots on obscure symbols become annoying. (FX flags are -now wired — Elbstream `/logos/country/{iso2}` keyed off the base currency. **Portfolio logos are DONE** — -`PortfolioPage.BuildRow` resolves the per-holding logo via the same `AssetIconResolver.Resolve(...)` call, -and it inherits the Elbstream attribution row from `PricedListPage.GetItems`; the totals summary row and the -Portfolio dock band keep the Bank glyph on purpose, since those are rolled-up summaries, not instruments.) - -### Market News (feed) — MVP DONE (not yet live-verified); see "Left to do" - -A **Markets → News** screen (a row in the Markets hub, **not** a separate top-level command) lists market-news -headlines, each opening the source article in the browser. News flows through the SAME layered seams as quotes, -with a parallel cache + repository-orchestration story, so a future news **dock band** can observe the same -cache and stay in sync with the screen (exactly how the quote surfaces share the quote cache). - -**The layered flow** (mirrors the quote flow): -``` -ApiFinnhubNewsDto[] ─(provider maps)→ DomainNews ─(repo routes+caches)→ IReadOnlyList - └─(page maps via UiNews.From)→ UiNews (rendered) -``` - -**The provider seam.** `IMarketDataProvider` gained **`bool SupportsNews => false`** (a capability gate, NOT -asset-class routing — news is a market-wide feed) + **`GetNewsAsync(NewsCategory, minId, ct)`** whose default -body **THROWS `NotSupportedException`** (unlike `GetCandlesAsync`'s soft invalid-series default): news has the -`SupportsNews` gate, so reaching the default means a caller skipped the check — fail loud rather than silently -return nothing. `FinnhubMarketDataProvider` opts in (`SupportsNews => true`; `GetNewsAsync` calls `/news` via -`HttpRetry`, maps `ApiFinnhubNewsDto[]` → `DomainNews`, dropping items missing id/headline/url). `Finnhub -specifics`: `GET /news?category=&minId=&token=` returns a **bare top-level array**; `category` ∈ -`general/forex/crypto/merger` (the only place that token lives is `ToFinnhubNewsCategory`); `minId` returns only -items newer than that news id (0 = latest batch). `MockMarketDataProvider` opts in **only in Demo mode** -(`SupportsNews => DemoMode`, mirroring `Supports`) and serves a curated, evergreen synthetic headline set per -category (`NewsTemplates`, stable ids + relative timestamps + a deterministic source per headline). Twelve Data -/ Frankfurter inherit the throwing default (they don't serve news). - -**The news cache** (`INewsCacheDataSource` / `InMemoryNewsCacheDataSource`). The news analog of the quote cache, -but keyed by **`NewsCategory`** with a **LIST** of `NewsEntity` per category (news is inherently a list, vs one -quote per symbol). DynamicData `SourceCache` (the wrapper record never crosses the -interface) with atomic `Edit`; **keep-last-good = a transient EMPTY fetch won't wipe a non-empty cached feed** -(the news analog of "an invalid quote won't wipe a valid one"). Stores `NewsEntity` (the `*Entity` storage -model); the repo maps `DomainNews ⇄ NewsEntity` at its boundary. - -**Repository orchestration** (`MarketRepository`, "News orchestration" section): -- **Routing:** news goes to the first ACTIVE provider whose `SupportsNews` is true (`NewsProvider()`) — Finnhub - live, the mock in Demo mode. NOT asset-class routed (single provider, unlike the quote per-category routing / - the search fan-out). -- **`ObserveNews(NewsCategory)`** (fixed — for a future dock band) and **`ObserveNews(StateFlow)`** - (selection-aware, `Switch` on change). Both append **`.ObserveOn(TaskPoolScheduler)`** — the SAME deadlock seam - as `ObserveQuotes` (a cache write-through fans out synchronously; without the hop a surface's - `RaiseItemsChanged` COM call could run under the `CombineLatest`/`Switch` gate and cycle the gate/STA lock). -- **The merged "All" view** is modeled as the **`NewsCategory?` selection where `null` = All** — - `ObserveAllNewsCore` `CombineLatest`s the 4 per-category cores (so each registers + fetches), then flattens, - **dedupes by news `Id`** (the same article can surface in >1 feed), and **sorts newest-first**. Modeling All as - `null` keeps the enum + cache keyed by REAL categories only; "All" is a pure read-time merge. -- **Second poll loop:** the repo holds a SECOND `PollTicker.SubscribeNews(RefreshObservedNews)` subscription on - the **separate news cadence** (each tick refreshes the currently-observed categories, one `/news` call each — - so "All" fans out to 4 calls/tick while it's the selected view). -- **Stale-on-subscribe** (`NeedsNewsFetchOnSubscribe`) + **demo-flip** (`_newsCacheSource.Clear()` then refill - observed categories) — the news analogs of the quote versions. - -**The poll ticker** (`PollTicker`) was generalized from one price ticker into a parameterized `BuildTicker`, -exposing **two** independent, refcounted tickers: `Subscribe` (price, `RefreshInterval`) and **`SubscribeNews`** -(news, `NewsRefreshInterval`) — so news polls on its own gentler clock and only while a news surface is active. -The guard, off-recheck idle, and `Publish().RefCount()` lifecycle are shared. - -**The settings.** A **separate** `News refresh interval (minutes)` setting -(`MarketSettingsManager.NewsRefreshMinutes` / `NewsRefreshInterval` / `NewsAutoRefreshEnabled`, default **30**, -0 = off) — deliberately NOT reusing the price `RefreshInterval` (headlines change less often). Read pull-style by -the news ticker each iteration. **Plus** the news dock band's **`News ticker speed (seconds)`** setting -(`NewsTickerCycleSeconds` / `NewsTickerCycleInterval`, default **60 s**) — the marquee CYCLE cadence (how often the -ticker advances to the next headlines), distinct from the network refresh interval above and in **seconds** (not -minutes) for finer ticker control; re-read live each tick by `NewsDockPage`'s `Observable.Generate`. See the -"Done (latest)" dock-band status bullet. - -**The screen** (`NewsPage`). A `DynamicListPage` **pure observer** (like the dock bands): while visible it -subscribes to `ObserveNews(_category)` and renders what it emits — no fetch/poll itself. A toolkit **`Filters` -dropdown** switches the view: **"All"** (merged, the default — a sentinel filter id) + General/Forex/Crypto/ -Mergers; the selection drives a `MutableStateFlow` (null = All) that the `ObserveNews` overload -`Switch`es on. Each row: headline title, "source · 2h ago" subtitle, thumbnail icon (or a glyph), summary in the -`Details` pane; **Enter opens the article** (`OpenUrlCommand`). Typed search filters the current feed's headlines -client-side. New UI strings are in the resx (all 7 locales). - -**Left to do (to complete the feature):** -1. **Live-verify on-device** — the whole path compiles but is unverified. Check via **Demo mode** (curated - synthetic headlines per category; "All" merges + dedupes them) and a **real Finnhub key** (live `/news`): - category switching, article open, the separate news refresh interval, demo-flip. -2. **No-key / empty-state UX — ✅ DONE.** With no Finnhub key, `/news` returns `[]` (Finnhub's `SupportsNews` - is unconditionally true, so it's still attempted) → the cold cache stayed empty and the page span forever. - Fixed: a **Finnhub-gated `ApiKeyHint.NewsStatusRow()`** (gates on `HasFinnhubApiKey`, not `HasAnyApiKey`) - shows a "News needs a Finnhub key" row instead of spinning; and the news cache/observe now emits **`null` - until the first fetch** (like the quote cache's `QuoteEntity?`), so `NewsPage` tells "loading" (`null`) from - "loaded-but-empty" (a non-null empty feed → an explicit `News_Empty_*` row) straight from the stream — no - side query into the repository. Demo mode still shows the blue "sample data" row. -3. **News dock band — ✅ DONE (live-verified).** `Pages/NewsDockPage.cs`: a CNBC-style scrolling news ticker, a - pure observer of `ObserveNews(null = the merged "All" feed)` (modeled on the dock-band pure-observer pattern, - `INotifyItemsChanged` visible-lifecycle, `Command.Id = com.costafotiadis.market.dock.news`). It renders a - **row of 4 headline buttons** (each opens its own article) and **cycles** the visible window through the feed - one headline per tick — NOT a single scrolling Title, because the host caps each dock button at - `MaxWidth=100`≈15 chars (`DockItemControl.xaml`) and won't pixel-scroll a Title (a char-step crawl just "pops"). - Cycle speed is the configurable `News ticker speed (seconds)` setting (default 60 s), re-read live each tick by - a dedicated per-band `Observable.Generate` (not `PollTicker`). Used the StateFlow `null`-=-All overload rather - than the fixed single-category `ObserveNews(NewsCategory)` — a ticker shows everything, not one category. -4. **Rate-limit banner on the news page — ✅ DONE.** `NewsPage.GetItems` inserts `RateLimitHint.Row()` at the - top (index 0) and subscribes to `RateLimitSignal.Instance.IsRateLimited` (replay:false) to re-render when - throttling starts/stops, exactly like the priced pages + Search. -5. **Glyph check — ✅ DONE** — `NewsGlyph` / `ArticleGlyph` verified to render; the stale "verify/adjust glyph" - comment at `MarketsPage.cs` was removed (the glyph values are `\uXXXX` escapes, so the Write-tool drop hazard - never applied here). -6. **minId incremental paging (optimization)** — every refresh fetches `minId:0` (the full latest batch) and - replaces the cached feed; the `DomainNews.Id` / `minId` cursor exists but isn't used to fetch only newer items - and merge. Cheap follow-up if `/news` volume matters. -7. **Multi-provider news (deferred design)** — single-source today (`NewsProvider()` = first `SupportsNews`). - Swap/primary works as-is; FALLBACK needs gating Finnhub's `SupportsNews` on its key (it's unconditionally - `true` today); MERGE needs a fan-out in `FetchNewsAsync` (reuse `ObserveAllNewsCore`'s dedupe + `SearchAsync`'s - fan-out shape); PER-CATEGORY routing needs the seam to gain per-category granularity (`SupportsNews(category)` - or a supported-set). The cache/observe/UI layers stay provider-agnostic regardless. -8. **Locale translations** were added for all 7 locales but are machine-quality — worth a native review (esp. the - compact relative-time forms). - -## CommandPalette Toolkit — Quick Reference - -### Navigation - -`Page` extends `Command` (implements `ICommand`). Pass a page anywhere a command is accepted — the palette navigates into it automatically. No string-based registration needed. - -```csharp -// Top-level entry point -new CommandItem(new MyPage()) { Title = "My Command" } - -// List item that navigates into a sub-page -new ListItem(new MySubPage(arg)) { Title = "Go deeper" } - -// List item that runs a command -new ListItem(new MyInvokableCommand()) { Title = "Do thing" } -``` - -### Base Classes - -| Class | Use when | -|---|---| -| `InvokableCommand` | Action with no UI — runs and returns a `CommandResult` | -| `ListPage` | Static list of items | -| `DynamicListPage` | List that reacts to search input — override `UpdateSearchText()` | -| `CommandProvider` | Extension entry point — returns top-level `ICommandItem[]` | - -### Page Activation Hook (On-Load Refresh) - -The framework calls `FetchItems` → `GetItems()` **before** subscribing to `ItemsChanged`. This means any `RaiseItemsChanged` fired from the constructor is lost and the page shows empty on first open. - -The fix: re-implement `INotifyItemsChanged` on the page class and intercept the `add` accessor. The framework subscribes via `INotifyItemsChanged.ItemsChanged +=`, so our accessor fires right after subscription — triggering a second `FetchItems` while the framework is already listening. - -**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/PricedListPage.cs, reference/pages/AdbExtensionPage.cs): -internal sealed partial class MyPage : DynamicListPage, INotifyItemsChanged -{ - private event TypedEventHandler? _itemsChanged; - - event TypedEventHandler INotifyItemsChanged.ItemsChanged - { - add { _itemsChanged += value; RefreshData(); } // called every time user navigates to this page - remove => _itemsChanged -= value; - } - - protected new void RaiseItemsChanged(int totalItems = -1) - => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); -} - -// For pages with synchronous GetItems() (see reference/pages/PackageActionsPage.cs): -internal sealed partial class MyPage : ListPage, INotifyItemsChanged -{ - private event TypedEventHandler? _itemsChanged; - - event TypedEventHandler INotifyItemsChanged.ItemsChanged - { - add { _itemsChanged += value; _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); } // fire immediately after subscribe - remove => _itemsChanged -= value; - } - - protected new void RaiseItemsChanged(int totalItems = -1) - => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); -} -``` - -Do **not** call `IsLoading = true` + `Task.Run(Load)` from the constructor — the event fires before the framework subscribes and the signal is lost. - -> **`ContentPage` focus gotcha:** if `GetContent()` returns a **single** `FormContent`, the host marks it -> `OnlyControlOnPage = true` and **auto-focuses the card's first focusable element** (e.g. the first -> `Action.Submit`) on load — so Enter activates that card button instead of the page's primary (Enter) -> command — and with focus trapped in the card, Ctrl+Enter can't reach the secondary command either. -> Content count is the **only** documented lever (no host API suppresses the auto-focus). In theory: -> (a) return **≥2 content items** so `OnlyControlOnPage` is false and focus stays in the search box; or -> (b) **make the first focusable element the action you WANT Enter to fire** by ordering the card so that -> button comes first. ⚠️ The symbol-detail chart tried **both and neither shipped** — (a) did **not** -> actually keep focus in the search box in practice (Enter still hit a tab, Ctrl+Enter broke), and (b) -> was too hacky. It currently ships with the bug **unfixed**; full trace in the "Symbol detail + live -> chart" section of this file. Treat (a) as suspect until someone reproduces it working. - -### DynamicListPage Pattern - -```csharp -internal sealed partial class MyPage : DynamicListPage -{ - public MyPage() - { - PlaceholderText = "Type to filter..."; - } - - public override void UpdateSearchText(string oldSearch, string newSearch) - => RaiseItemsChanged(0); // triggers GetItems() re-call - - public override IListItem[] GetItems() - { - // Filter using this.SearchText - } -} -``` - -### CommandResult Options - -```csharp -CommandResult.ShowToast("message") // show toast, dismiss palette -CommandResult.ShowToast(new ToastArgs { - Message = "message", - Result = CommandResult.KeepOpen() // show toast, keep palette open -}) -CommandResult.KeepOpen() // do nothing, stay on current page -CommandResult.Dismiss() // close the palette -CommandResult.GoBack() // navigate to previous page -CommandResult.GoHome() // navigate to root -CommandResult.Confirm(new ConfirmationArgs()) // show confirmation dialog -``` - -### ListItem Properties - -```csharp -new ListItem(command) -{ - Title = "Required", - Subtitle = "Optional secondary text", - Icon = new IconInfo("https://...") , // URL icon - Section = "Section header", // groups items under a header -} -``` - -### Icon Options - -```csharp -new IconInfo("https://example.com/icon.png") // URL (light+dark same) -new IconInfo(lightIconData, darkIconData) // separate light/dark -IconHelpers.FromRelativePath("Assets\\foo.png") // bundled asset -``` - -### InvokableCommand Pattern - -```csharp -internal sealed partial class MyCommand : InvokableCommand -{ - public MyCommand() - { - Name = "Do Thing"; - Icon = new IconInfo("https://..."); - } - - public override ICommandResult Invoke() - { - try - { - // do work - return CommandResult.ShowToast("Done"); - } - catch (Exception ex) when (ex is Win32Exception w && w.NativeErrorCode == 2) - { - return ErrorToast("Required tool not found. Make sure it is on your PATH."); - } - catch (Exception ex) - { - return ErrorToast($"Unexpected error: {ex.Message}"); - } - } - - private static ICommandResult ErrorToast(string message) => - CommandResult.ShowToast(new ToastArgs { Message = message, Result = CommandResult.KeepOpen() }); -} -``` - -## ProcessHelper API - -```csharp -// 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`. - -## Releasing a New Version - -### Step 1 — Bump version in all 5 files - -| File | Field | -|---|---| -| `MarketExtension/MarketExtension.csproj` | `` | -| `MarketExtension/Package.appxmanifest` | `Identity Version=` | -| `MarketExtension/app.manifest` | `assemblyIdentity version=` | -| `MarketExtension/build-exe.ps1` | `$Version` default param | -| `MarketExtension/setup-template.iss` | `#define AppVersion` | - -One-liner (replace `OLD` and `NEW`): -```powershell -$files = @("MarketExtension/MarketExtension.csproj","MarketExtension/Package.appxmanifest","MarketExtension/app.manifest","MarketExtension/build-exe.ps1","MarketExtension/setup-template.iss") -$files | ForEach-Object { (Get-Content $_) -replace 'OLD','NEW' | Set-Content $_ } -``` - -### Step 2 — Trigger the GitHub Actions build - -``` -gh workflow run release-msix.yml --ref master -f release_notes="Your release notes here" -``` - -This reads the version from the csproj automatically, builds x64 + ARM64, bundles into a `.msixbundle`, signs it, and creates a GitHub Release. (`release-extension.yml` is the alternative self-signed `.exe` installer path.) - -### Step 3 — Submit to Partner Center - -1. Download the `.msixbundle` from the GitHub Release -2. Partner Center → app → new submission → Packages → upload the `.msixbundle` -3. Update description/notes, submit - -## One-time setup (this extension's own identity) - -This was scaffolded from AdbExtension. Already changed: COM GUID (`6b38c9aa-bbee-45e9-81e9-cf25707910e7`), namespace/assembly (`MarketExtension`), package identity (`CostaFotiadis.MarketsExtensionforCommandPalette`), version (`0.0.1.0`). Still TODO before shipping: - -- **Partner Center**: reserve the app name; confirm `Identity Name` + `Publisher` in `Package.appxmanifest`/csproj match what it assigns. -- **GitHub secrets**: add `SIGNING_CERT_PFX` + `SIGNING_CERT_PASSWORD` (see `MarketExtension/create-signing-cert.ps1`). Template clones do not carry secrets. -- ~~**Assets**: replace the art in `MarketExtension/Assets/`~~ — **DONE.** New `$ >_` charcoal-squircle - logo: in-CmdPal icon wired in code (`markets_logo_base_square.png` via `IconHelpers.FromRelativePath`, - all 7 chrome spots), and the MSIX tile/Store PNGs regenerated from the square source via VS's Visual - Assets generator. No ``/`.ico` (matches AdbExtension — the deployed app icon comes from - the manifest assets; the bare exe keeps the default apphost icon, which is fine). -- **API key**: none needed at build time — each user pastes their own Finnhub key into the extension's settings at runtime (see the "API Key" section above). -- **Description**: update the placeholder description in `Package.appxmanifest`. +`Log.Info/Warn/Error(tag, message)` (`Helpers/Log.cs`), tagged by component. **All `[Conditional("DEBUG")]`** +→ compiled out of Release; the MSIX ships silent with no telemetry. Watch Debug builds via VS Debug → Attach +to `MarketExtension.exe` (Managed) or Sysinternals DebugView. **NEVER log the API token.** + +## Gotchas (read before editing) + +- ⚠️ **The Write tool drops Segoe MDL2 glyph chars** (private-use code points save as blank → blank icon). + Use a C# Unicode escape (`""`) or build from the code point (`((char)0xE9F9)`), and byte-check after + editing. (Glyphs already written as `\uXXXX` escapes are safe.) +- ⚠️ **ContentPage with a single `FormContent`** → the host auto-focuses the card's first action, stealing + Enter (and trapping Ctrl+Enter). No host API suppresses it. Details + the symbol-detail chart's open bug: + `notes/cmdpal-toolkit.md`. +- ⚠️ **Page-activation hook:** `GetItems()` is called before `ItemsChanged` is subscribed, so a constructor + `RaiseItemsChanged` is lost. Re-implement `INotifyItemsChanged` and refresh from the `add` accessor — the + recurring pattern is in `notes/cmdpal-toolkit.md`. +- ⚠️ **The `ObserveOn(TaskPoolScheduler)` seam in `MarketRepository.ObserveQuotes`/`ObserveNews` is + load-bearing** — removing it (or "fixing" it with `SubscribeOn`) re-creates a gate↔STA deadlock that hangs + CmdPal. Observe handlers run on a pool thread; don't add your own `Task.Run`/`SubscribeOn`. See + `notes/architecture.md`. +- ⚠️ **JSON source-gen:** keep all `[JsonSerializable]` for one context on a single `partial` declaration — + splitting them silently breaks the generator for the whole build. See `notes/providers.md`. +- **AOT/trim is intentionally OFF** — reflection-based code is fine; no IL2026/IL3050 enforcement. +- **Fail loud:** crash on genuinely-wrong states; don't swallow errors and silently degrade. + +## Git + +- Never amend commits (`git commit --amend`) — always create a new commit, unless explicitly asked to amend + in the moment. diff --git a/notes/architecture.md b/notes/architecture.md new file mode 100644 index 0000000..9036b3e --- /dev/null +++ b/notes/architecture.md @@ -0,0 +1,144 @@ +# Market Data Architecture (deep reference) + +The core of the app. Live quotes flow through three explicitly-named layers + a coordinator. +**Keep this layering and naming for new code.** + +``` +ApiFinnhubQuoteDto ─(provider maps)→ DomainQuote ─(repository routes+merges)→ IReadOnlyList + └─(page maps via UiQuote.From)→ UiQuote (rendered) + +ApiFinnhubCandleDto ─(provider maps)→ DomainCandleSeries ─(repository routes)→ DomainCandleSeries + └─(page maps via UiCandleSeries.From)→ UiCandleSeries (SVG chart) +``` + +News mirrors the same shape: `ApiFinnhubNewsDto[] → DomainNews → (cache) → UiNews`. + +## Naming convention + +- `Api*` — raw provider DTOs (provider-specific), e.g. `ApiFinnhubQuoteDto`. +- `Domain*` — provider-agnostic, **no formatting**; what every provider AND the repository return + (`DomainQuote`, `DomainInstrument`, `DomainCandleSeries`, `DomainNews`, `DomainPosition`). +- `*Entity` — data-layer **storage models** that live BELOW the repository, INSIDE a data source + (`QuoteEntity`, `NewsEntity`). A structural mirror of the matching `Domain*` that **never escapes its + data source**: the repository maps `Domain* ⇄ *Entity` at its boundary (`QuoteEntity.From(domain)` on + write, `entity.ToDomainQuote()` on read). Lets the stored shape diverge later (storage-only fields, a + DB representation) without touching domain or surfaces. (Data → domain is the allowed dependency + direction, so an `*Entity` may reference its `Domain*` for mapping — never the reverse.) +- `Ui*` — presentation; the ONLY place `FormatPrice()`/`FormatChange()`/SVG/relative-time live + (`UiQuote`, `UiCandleSeries`, `UiNews`, `UiPosition`, `UiPortfolio`). +- `AssetCategory`, `ChartRange`, `CandleInterval`, `NewsCategory` (enums) stay **unprefixed** — shared + vocabulary across all layers (they classify/parameterize operations; they aren't data carried through). + +## File map + +| File | Role | +|---|---| +| `Data/MarketRepository.cs` | **The coordinator the UI depends on**, and the **orchestration layer** that owns the shared quote cache + polling. Routes each `DomainInstrument` to the first `IMarketDataProvider` whose `Supports(AssetCategory)` matches, fans out concurrently, merges into one order-preserving list. Also `SearchAsync(query)`, `GetCandlesAsync(instrument, range)`, and the News orchestration (`ObserveNews`). No provider for a category → `IsValid:false` quote / invalid candle series. **`ActiveProviders()`**: any `IsExclusive` provider (the Demo mock) → ALL operations route to it alone. **Owns one `IQuoteCacheDataSource`** (in-memory by default; injectable ctor for a future DB-backed cache): `RefreshAsync` **writes through** every fetch, and **`ObserveQuotes(...)`** returns a cache-backed `IObservable>` — for a fixed instrument list OR a membership `StateFlow` (Rx `CombineLatest` + `Switch`), **delivered via `ObserveOn(TaskPoolScheduler)`** (the deadlock fix — see below). **Owns the single poll loop:** one process-lifetime `PollTicker` subscription whose `RefreshObserved` re-fetches the union of currently-OBSERVED instruments each tick ("observed" = a refcounted registry that `ObserveQuotes` Register/Unregisters on subscribe/dispose). A second `SubscribeNews` loop polls observed news categories on the news cadence. The demo-flip handler `Clear()`s the cache then refills from the new source. | +| `Data/IQuoteCacheDataSource.cs` | The **shared observable quote cache** abstraction (an interface, swappable for a DB-backed impl later). **Stores `QuoteEntity`, NOT `DomainQuote`** — the repo maps at its boundary. `Get(symbol)` (sync snapshot, null if uncached), `Observe(symbol)` → per-symbol `IObservable` (replays current value, **null until first fetch**), `Upsert(quote, keepLastGood=true)` (write-through; **the SINGLE home for keep-last-good**), `Clear()`. Keyed by `WatchlistStore.Normalize`. | +| `Data/InMemoryQuoteCacheDataSource.cs` | The **real, live** in-memory impl, backed by **DynamicData's `SourceCache`** (a reactive keyed cache — the .NET analog of Room's `@Query → Flow`). `Get` = `_cache.Lookup(key)`; `Observe` = `Connect().Watch(key)` mapped to `QuoteEntity?` (Remove → null) + `StartWith(Get())` + `DistinctUntilChanged`; `Clear` = `_cache.Clear()`. **`Upsert` is `_cache.Edit(...)`: read + keep-last-good decision + write run ATOMICALLY under the cache's lock** — this is why the cache owns keep-last-good correctly under concurrent write-throughs (poll loop, observe-subscribe fetch, demo flip), which the old "decide under the lock, `Update` outside it" split could not. (Needs `DynamicData`; it floors `System.Reactive` at 6.1.0.) | +| `Data/QuoteEntity.cs` | The cache's **storage model** (`*Entity` layer): a structural mirror of `DomainQuote` that never escapes the data source. `QuoteEntity.From(domain)` / `entity.ToDomainQuote()`. | +| `Data/IMarketDataProvider.cs` | ONE data source: `bool Supports(AssetCategory)` + **`bool IsExclusive`** (default `false`; true → route every op to this provider alone — how the Demo mock takes precedence) + `GetQuotesAsync` + `SearchAsync` (free-text → `DomainInstrument`s, identity only, no prices; can't-search → `[]`) + `GetCandlesAsync(instrument, ChartRange, ct)` (**default interface method** returns an invalid series → non-candle providers opt out for free) + **`bool SupportsNews => false`** + **`GetNewsAsync(NewsCategory, minId, ct)`** (default body **THROWS** `NotSupportedException` — news has the `SupportsNews` gate, so reaching the default means a caller skipped the check; fail loud). | +| `Data/TwelveData/TwelveDataMarketDataProvider.cs` | **Primary provider when its key is set** (Stock+Crypto+Currency, gated on `HasTwelveDataApiKey`). One API for all three; **`/time_series` candles are free-tier**. `GetQuotesAsync` batches all symbols into one `/quote`. See `notes/providers.md`. | +| `Data/Finnhub/FinnhubMarketDataProvider.cs` | Stock+Crypto provider, used when no Twelve Data key is set. Also the **news** provider (`SupportsNews => true`, `/news`). Maps `ApiFinnhubQuoteDto` → `DomainQuote`; `/search` is US equities only. | +| `Data/Frankfurter/FrankfurterMarketDataProvider.cs` | **Keyless** FX provider (Currency) via ECB daily rates (`api.frankfurter.dev`). Day-over-day change; daily-close candles; `SearchAsync` = local filter over the catalog's FX pairs. | +| `Data/Frankfurter/ApiFrankfurterDto.cs` | Time-series DTO + flat `/latest` DTO (used by `CurrencyConverter`) + `FrankfurterJsonContext`. | +| `Data/MockMarketDataProvider.cs` | The **Demo-mode** data source — quotes/candles/news for every asset class, no key/network. Registered **first**; `Supports()`/`IsExclusive`/`SupportsNews` gated on the `DemoMode` setting → in demo mode the repo routes everything to the mock alone. **Serves ANY symbol** via a hand-tuned `Seed` over a stable-hash `SynthesizeQuote`. **Search** matches a curated `Catalog` of ~120 *real* instruments (never fabricated). See `notes/features.md` § Demo mode. | +| `Data/InstrumentCatalog.cs` | Static `DomainInstrument` defaults — the **first-run seed** for `WatchlistStore`. | +| `Settings/WatchlistStore.cs` | JSON-persisted tracked instruments, each carrying **two flags** `InWatchlist`/`IsFavorite` (favorites = the dock subset). Stores **full `DomainInstrument` identity** so searched non-catalog symbols re-price. Source-gen `WatchlistJsonContext` → `market_watchlist.json`; seeds `InstrumentCatalog` on first run. Exposes `Watchlist`/`Favorites` subsets as observable `StateFlow`s; pages and the dock subscribe and re-render. | +| `Settings/PortfolioStore.cs` | JSON-persisted holdings (`market_portfolio.json`), modeled on `WatchlistStore`. Each entry = instrument + quantity (`decimal`) + optional cost basis. **No first-run seed.** Two `StateFlow`s: `Positions` and `Instruments` — both **reference equality (NOT `InstrumentListComparer`)** so EVERY mutation (incl. a quantity-only edit) re-emits. | +| `Helpers/StateFlow.cs` | A tiny **Kotlin-StateFlow analog**, a **thin wrapper over `System.Reactive`'s `BehaviorSubject`**. Used by the state holders (`WatchlistStore`, `PortfolioStore`, `MarketSettingsManager`). `StateFlow` (read-only `Value` + `Subscribe` with replay-on-subscribe + distinct-until-changed; a `replayOnSubscribe:false` overload). `MutableStateFlow` (`Update`). `SetValue` does source-side distinct-until-changed. `AsObservable()` exposes the raw stream for Rx `CombineLatest`/`Switch`. Plus `InstrumentListComparer`. | +| `Helpers/PollTicker.cs` | The **poll ticker** — **pure Rx** (`Observable.Generate(...).Publish().RefCount()`). Generalized into a parameterized `BuildTicker` exposing TWO independent, refcounted tickers: **`Subscribe`** (price, `RefreshInterval`) and **`SubscribeNews`** (news, `NewsRefreshInterval`). Per-step delay re-read from settings each iteration (interval/on-off applies without reload; `0` = off idles on a 30 s re-check, tick filtered out). `RefCount()` starts on first subscriber, tears down on last. `Subscribe(...)` wraps the handler in try/catch so a throwing tick can't tear down the shared multicast. **`MarketRepository` holds the only quote subscriber** (the central poll loop); the **only direct subscriber left is the symbol-detail chart** (separate candle path, no cache). | +| `Helpers/HttpRetry.cs` | **Shared 429 back-off** at the HTTP seam. `SendAsync(send, tag, ct)` takes a request thunk and, on `429`, honors a short `Retry-After` else backs off `1s`→`2s` (max 3 attempts, **bails** if the wait would exceed `8s` — per-minute windows don't clear in seconds). Single choke point feeding `RateLimitSignal` (2xx → off, surviving 429 → on). Used by Finnhub + Twelve Data (not keyless Frankfurter). | +| `Helpers/RateLimitSignal.cs` | Process-wide "are we throttled" flag — `StateFlow`. Priced surfaces subscribe and re-render. Intentionally **global, not per-symbol** (a free-tier limit is key-wide). | +| `Helpers/RateLimitHint.cs` | The amber "Rate-limited — showing last known prices" banner row (Enter = `NoOpCommand`), shown when `RateLimitSignal` is set AND `ShowRateLimitErrors` is on AND not in demo mode. Pinned to the top of the priced pages / Search / News. | +| `Helpers/ApiKeyHint.cs` | Unified **`StatusRow()`** (demo on → blue "Demo mode — showing sample data"; else no key → red "No API key set"; else null) + a Finnhub-gated **`NewsStatusRow()`** ("News needs a Finnhub key"). | +| `Helpers/DataSourceAttribution.cs` | The **active live provider's** attribution row/text (Twelve Data required; Finnhub as good practice). Reflects the ACTIVE source: demo → null, TD key → Twelve Data, else Finnhub key → Finnhub, else null. Clickable `OpenUrlCommand`. ECB (Frankfurter) and Elbstream are credited separately. | +| `Helpers/AssetIconResolver.cs` | Instrument identity → row/dock `IconInfo` via **Elbstream** logo URLs by category (`/logos/symbol/{t}`, `/logos/crypto/{c}`, FX → base currency flag `/logos/country/{iso2}`); Segoe-glyph fallback. **Zero API calls** (the host fetches the URL). Also `AttributionRow()` (the Elbstream credit). | +| `Helpers/CurrencyConverter.cs` | Process-wide singleton converting money via Frankfurter's keyless `/latest` spot rates. Two-phase: **`PrimeAsync(preferred, natives)`** batches every `native→preferred` rate (~1 h TTL); **`TryGetRate(from, to)`** is the synchronous cache read used while rendering. Used only by the Portfolio surfaces. In demo mode, fills from a static `DemoUsdPerUnit` table. | +| `Helpers/CurrencyFormat.cs` | Render a `decimal` as money in an ISO-4217 currency — the home for the per-currency symbol + decimal-place rules. `Format`/`FormatSigned`. JPY/KRW 0-decimal; unknown → trailing code. | +| `Helpers/CurrencyHelper.cs` | Currency conventions the **providers** apply when stamping `DomainQuote.Currency`: `NormalizeStockQuote` folds London **GBX/GBp pence → GBP** (÷100; the 100× trap); `QuoteCurrencyOfPair` returns an FX pair's quote currency. | +| `Helpers/ChartHelper.cs` | Perf-Monitor's **SVG-sparkline-as-`data:`-URI** (pure `System.Xml.Linq`), generalized to N points + min/max Y-normalize, green/red. Faint quarter-gridline box. **No numeric tick labels** — the host rasterizes via Direct2D's `ID2D1SvgDocument`, which silently drops ``. | +| `Helpers/Strings.cs` | Localization helper — `Strings.Get`/`Strings.Format` over `Properties/Resources.resx`. **Don't hardcode user-facing literals**; add a resx key. The neutral resx is English; ship a language by adding `Resources..resx`. | +| `Helpers/Log.cs` | `Log.Info/Warn/Error(tag, message)`, all `[Conditional("DEBUG")]` → compiled out of Release (the MSIX ships silent, no telemetry). | +| `Data/IQuoteCacheDataSource` + news equivalents | `Data/INewsCacheDataSource.cs` / `InMemoryNewsCacheDataSource.cs` — the news analog, keyed by **`NewsCategory`** with a **LIST** of `NewsEntity` per category. keep-last-good here = a transient EMPTY result won't wipe a non-empty cached feed. `Observe` emits **null until first fetch**. `Data/NewsEntity.cs` is the storage model. | +| `Models/*` | `AssetCategory`, `DomainInstrument`, `DomainQuote` (carries `Currency`, default `USD`), `UiQuote`; `ChartRange`+`CandleInterval`; `DomainCandleSeries`/`UiCandleSeries`; `DomainNews`/`NewsCategory`/`UiNews`; `DomainPosition`/`UiPosition`/`UiPortfolio`. | +| `Pages/*` | `MarketsPage` (the hub), `SearchPage`, `WatchlistPage`/`FavoritesPage`/`PortfolioPage` (the `PricedListPage` trio), `NewsPage`, `DataSourcesPage` (static info), `SymbolDetailPage` (detail + chart), `SetQuantityPage` (holding editor), `FavoritesDockPage`/`PortfolioDockPage`/`NewsDockPage` (the three dock bands). | +| `Commands/*` | `MembershipCommands` (watchlist/favorites add/remove + toast + KeepOpen), `PortfolioCommands`, `OpenUrlCommand`. | + +## Shared quote cache + repository orchestration (DONE — all quote surfaces migrated) + +**Why.** Every priced surface used to fetch the **same** quotes independently and keep its **own** cache + +its **own** `PollTicker` subscription. Their fetches landed at different times, so two surfaces showing the +same symbol drifted out of sync. The fix: one shared observable cache that every surface observes, with the +repository orchestrating all fetching/polling. + +**The `ObserveOn` delivery seam (the deadlock fix — do NOT remove).** Both `ObserveQuotes` overloads append +**`.ObserveOn(TaskPoolScheduler.Default)`** (the raw graph lives in a private `ObserveQuotesCore`; the +`StateFlow` overload `ObserveOn`s *after* `Switch`). Why load-bearing: a cache write-through fans out +synchronously, and `CombineLatest`/`Switch` call the subscriber's handler — ultimately a surface's +`RaiseItemsChanged`, a **blocking COM call into Command Palette's STA** — *while Rx still holds the combiner +gate lock*. The host re-enters the extension and the gate↔STA lock order cycles → **CmdPal hangs** (exactly +what made the favorites band crash the host). `ObserveOn` hands each emission to the scheduler, so surfaces +are notified only **after** the gate locks release. ⚠️ **`SubscribeOn` is NOT a substitute** — it moves +*where you subscribe*, not *where notifications fire*. Consequence: **`OnQuotesChanged`-style handlers run on +a pool thread** — don't wrap the subscribe in `Task.Run` or add your own `SubscribeOn`/`ObserveOn`. + +**The single poll loop.** The repo holds ONE process-lifetime `PollTicker.Subscribe(RefreshObserved)`. Each +tick refreshes the **distinct union of currently-observed instruments** in one batched fetch. "Observed" = a +refcounted registry (`_observed`) that `ObserveQuotes` Registers on subscribe and Unregisters on dispose, so +a hidden surface's symbols stop being polled, and a symbol observed by two surfaces is fetched once. Carrying +the full `DomainInstrument` (not just the symbol) is deliberate: routing needs `Category`. + +**Stale-on-subscribe (the central freshness clock).** Every write-through stamps a per-symbol last-fetch +time (`_lastFetchTicks`). On subscribe, `ObserveQuotesCore` refreshes symbols `NeedsFetchOnSubscribe` flags: +**never-cached** ones always, plus — only when `AutoRefreshEnabled` — ones whose cached price **aged ≥ one +`RefreshInterval`** while unobserved. That's what makes a hidden surface refresh on reopen instead of showing +a stale price until the next tick. + +**Demo-mode flip** is centralized: the repo's `DemoModeChanged` handler `Clear()`s the cache then +`RefreshSafe`es the observed set from the new source. + +**Migrated:** both dock bands AND the `PricedListPage` trio are **pure observers** now. +`FavoritesDockPage` is the simple template (its whole lifecycle is one `ObserveQuotes(Favorites).Subscribe`). +`PortfolioDockPage` is the **async-projection template**: it `await`s `CurrencyConverter.PrimeAsync` before +rolling up `UiPortfolio` (one paint). The trio migrated via the shared base `PricedListPage`: a single +`Repository.ObserveQuotes(_instruments).Subscribe(...)`, with the old per-surface fetch/poll machinery +deleted. The async-projection seam is `protected virtual Task OnQuotesProjectingAsync(quotes)` (default +no-op; `PortfolioPage` overrides it to `await PrimeAsync`). The manual **Refresh 🔄** row reroutes onto +`RefreshAsync(keepLastGood:false)` and owns its own spinner. + +⚠️ **Out-of-order race in the async-projection observe path (fixed).** On a cold cache `ObserveQuotes` emits +progressive *partial* snapshots. The handler used to be fire-and-forget (`.Subscribe(q => _ = OnAsync(q))`), +so for surfaces that `await` an async projection the lambda returned at the first `await` → two handlers ran +concurrently and a stale partial could finish LAST and overwrite the full total. **Fix:** project each +emission through `Select(q => Observable.FromAsync(ct => OnQuotesChangedAsync(q, ct))).Switch()` — `Switch` +cancels the prior projection's `ct` the instant a newer emission arrives; the handler checks `ct` before +painting. Applied in `PricedListPage` + `PortfolioDockPage`. Watchlist/Favorites are unaffected (their +projection is `Task.CompletedTask`, never yields). + +**Transitional truth.** Every priced QUOTE surface observes the cache. The only surface still on its own +`PollTicker` subscription is the symbol-detail **chart** — a separate **candle** path (`GetCandlesAsync`) +with no cache/observe layer, left that way deliberately (only one detail page is ever open → no drift). + +## Live price polling + +> Superseded for all quote surfaces by the orchestration above — the priced pages + dock bands no longer +> poll themselves. The per-surface model now describes **only the symbol-detail chart**. + +Default **10 min**, settings-configurable (0 = off). Built on `PollTicker`'s `Publish().RefCount()` +lifecycle. The chart's `SymbolChartForm.PollRefresh()` re-fetches its **visible range** each tick: silent +(no flicker), bypasses the per-range cache, reuses the generation guard so a tab tap still wins, keeps the +last good chart on a transient empty poll. + +⚠️ **Rate-limit tension.** Finnhub free tier ~60 calls/min AND ~300/day, and `GetQuotesAsync` issues one +`/quote` per instrument. Polling N instruments at interval T burns the budget fast at low T. Mitigations in +place: the configurable interval + **Off** (0), polling only while visible, keep-last-good, 429 back-off + +banner, and Twelve Data's batched `/quote`. + +## Dock refresh on favorites change + +A pinned `FavoritesDockPage` updates the instant a favorite changes anywhere. `WatchlistStore.Favorites` is +a `StateFlow`; every favorite flip re-publishes it (deduped by `InstrumentListComparer`). The band observes +`ObserveQuotes(Favorites)` and the `Switch` overload re-projects on membership change. Complementary to +polling (which catches *price* drift on a timer); this pushes *membership* changes immediately and still +works when polling is Off. diff --git a/notes/changelog.md b/notes/changelog.md new file mode 100644 index 0000000..75da1b9 --- /dev/null +++ b/notes/changelog.md @@ -0,0 +1,57 @@ +# Status log / changelog + +Historical "what shipped each round" notes, most-recent first. Full verbatim detail (with every decision +trace) is in `notes/CLAUDE_copy.md`; this is the condensed index. Design details for each feature live in +`notes/architecture.md`, `notes/features.md`, and `notes/providers.md`. + +## Shipped & live-verified + +- **News dock band** — a CNBC-style ticker (row of 4 cycling headline buttons; the host won't pixel-scroll a + Title). Cycle cadence = the `News ticker speed (seconds)` setting. ✅ Live-verified on-device. +- **News feature polish** — no-key/empty-state UX (Finnhub-gated `NewsStatusRow`; cache emits null until + first fetch) + rate-limit banner on the news page. ✅ News path live-verified via the dock band. +- **Quote cache on DynamicData `SourceCache`** — `InMemoryQuoteCacheDataSource` re-backed on DynamicData; + `Upsert` = atomic `_cache.Edit`, fixing the keep-last-good race for free. ✅ Live-verified. +- **Out-of-order race fix** in the async-projection observe path (Portfolio screen + dock band) — replaced + fire-and-forget with `Select(FromAsync).Switch()` + a `ct` guard before painting. ✅ Live-verified. +- **`PricedListPage` trio → pure cache observers** — Watchlist/Favorites/Portfolio migrated; per-surface + fetch/poll machinery deleted; the quote-cache migration is COMPLETE (only the candle chart stays off-cache). +- **`PortfolioDockPage` → pure observer** + the stale-on-subscribe freshness primitive in the repo. +- **Shared quote cache + repository orchestration** — the de-drift refactor; `FavoritesDockPage` migrated + + verified (the build that confirmed the `ObserveOn` deadlock fix). ✅ Live-verified. +- **Removed Sentry entirely** — the app ships with NO telemetry; `Log.Error` is now `[Conditional("DEBUG")]`. +- **Demo mode** — offline sample data with no key/network via the `IsExclusive` provider seam; applies + instantly across the app (`DemoModeChanged`); serves ANY symbol (`SynthesizeQuote`) + curated real-ticker + search `Catalog`; no rate-limit banner while demoing. ✅ Live-verified. +- **Cost-basis / total-return reporting** — captured in `SetQuantityPage`, surfaced on rows/totals/dock band. + ✅ Live-verified (GBP `HSBA.L` with a basis). +- **Portfolio dock band** — total value + daily P&L rolled into `PortfolioCurrency`. ✅ Live-verified. + (⚠️ glyph-icon gotcha hit — see CLAUDE.md.) +- **Multi-currency portfolio conversion** — `DomainQuote.Currency` + `CurrencyConverter` (keyless ECB); + GBX/pence ÷100. ✅ Live-verified (10 sh SPY in GBP → ~£5,571, a real conversion). +- **Rate-limit (429) back-off + banner** — `HttpRetry` / `RateLimitSignal` / `RateLimitHint`. ✅ + Live-verified. +- **Twelve Data provider — now primary** — one API for stocks+crypto+forex, **free-tier candles**. ✅ + Verified live. +- **Migrated the observable layer to Rx.NET** — `StateFlow` → `BehaviorSubject` wrapper; `PollTicker` → + pure Rx `Observable.Generate(...).Publish().RefCount()`. ✅ Live-verified. +- **Asset logos as icons app-wide** — Elbstream CDN, zero API calls; Elbstream attribution rows. +- **Demo mode serves ANY symbol** — `SynthesizeQuote` (stable FNV-1a hash) + curated `Catalog` search. + ✅ Live-verified. +- **Symbol-detail chart** — real candle history with 1D/1W/1M/1Y/5Y tabs; live refresh on the ticker; + flicker fixed. ⚠️ Enter/focus bug is an open known limitation. +- **Live price polling**, **FX provider (keyless Frankfurter)**, **Portfolio screen**, the **three-screen + UX** + observable (StateFlow) data layer, **stale-revisit catch-up**, runtime API key + refresh-interval + settings, Enter-only Finnhub `/search`, persistent watchlist + favorites. ✅ Verified. + +## Open / deferred + +- **News:** ① minId incremental paging (every refresh fetches `minId:0` and replaces the feed); ② multi- + provider news (single-source today); ③ native review of machine-quality locale translations. +- **Symbol-detail chart Enter/focus bug** — UNSOLVED (host auto-focuses the card's first action; no API to + suppress it). Two fixes tried and abandoned. See `notes/cmdpal-toolkit.md`. +- **Per-symbol error UX** (a typed `QuoteStatus` on `DomainQuote`) — deliberately NOT built; the rate-limit + signal is global (a free-tier limit is key-wide). +- **Crypto/FX symbol search on the keyless fallback path** — Finnhub `/search` is US-equities only; FX via a + local catalog filter; crypto search needs a TD key (TD's `/symbol_search` covers all three classes). +- **Candle cache layer** — possible but unplanned (only one detail page is open at a time → no drift). diff --git a/notes/cmdpal-toolkit.md b/notes/cmdpal-toolkit.md new file mode 100644 index 0000000..64b95c7 --- /dev/null +++ b/notes/cmdpal-toolkit.md @@ -0,0 +1,127 @@ +# CommandPalette Toolkit — reference + +## Navigation + +`Page` extends `Command` (implements `ICommand`). Pass a page anywhere a command is accepted — the palette +navigates into it automatically. No string-based registration. + +```csharp +new CommandItem(new MyPage()) { Title = "My Command" } // top-level entry point +new ListItem(new MySubPage(arg)) { Title = "Go deeper" } // navigate into a sub-page +new ListItem(new MyInvokableCommand()) { Title = "Do thing" } // run a command +``` + +## Base classes + +| Class | Use when | +|---|---| +| `InvokableCommand` | Action with no UI — runs and returns a `CommandResult` | +| `ListPage` | Static list of items | +| `DynamicListPage` | List that reacts to search input — override `UpdateSearchText()` | +| `CommandProvider` | Extension entry point — returns top-level `ICommandItem[]` | + +## Page activation hook (on-load refresh) — RECURRING NEED + +The framework calls `FetchItems` → `GetItems()` **before** subscribing to `ItemsChanged`. So any +`RaiseItemsChanged` fired from the constructor is lost and the page shows empty on first open. + +The fix: re-implement `INotifyItemsChanged` on the page and intercept the `add` accessor (the framework +subscribes via `INotifyItemsChanged.ItemsChanged +=`, so our accessor fires right after subscription). The +`add`/`remove` accessors are CmdPal's de-facto Loaded/Unloaded hooks — subscribe to your flows in `add`, +dispose in `remove`. + +**Critical:** use `INotifyItemsChanged.ItemsChanged`, not `IListPage.ItemsChanged`, and re-list the +interface to override base-class dispatch. + +```csharp +// Async-loading page (see Pages/PricedListPage.cs, Pages/MarketsPage.cs): +internal sealed partial class MyPage : DynamicListPage, INotifyItemsChanged +{ + private event TypedEventHandler? _itemsChanged; + + event TypedEventHandler INotifyItemsChanged.ItemsChanged + { + add { _itemsChanged += value; RefreshData(); } // called every time the user navigates here + remove => _itemsChanged -= value; + } + + private new void RaiseItemsChanged(int totalItems = -1) + => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); +} + +// Synchronous GetItems() (see reference/pages/PackageActionsPage.cs): +// add { _itemsChanged += value; _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); } // fire immediately +``` + +Do **not** call `IsLoading = true` + `Task.Run(Load)` from the constructor — the event fires before the +framework subscribes and the signal is lost. + +## ⚠️ ContentPage single-`FormContent` focus gotcha + +If `GetContent()` returns a **single** `FormContent`, the host marks it `OnlyControlOnPage = true` and +**auto-focuses the card's first focusable element** (e.g. the first `Action.Submit`) on load — so Enter +activates that card button instead of the page's primary (Enter) command, and with focus trapped in the card +Ctrl+Enter can't reach the secondary command either. + +Content count is the **only** documented lever (no host API suppresses the auto-focus). In theory: (a) +return **≥2 content items** so `OnlyControlOnPage` is false; or (b) order the card so the action you WANT +Enter to fire is the first focusable element. ⚠️ The symbol-detail chart tried **both and neither shipped** — +(a) did NOT keep focus in the search box in practice (Enter still hit a tab, Ctrl+Enter broke), (b) was too +hacky. It ships with the bug **unfixed**. Treat (a) as suspect until someone reproduces it working. + +(`SetQuantityPage` is the benign case: a single `FormContent` whose first field is the quantity input, so +the auto-focus drops the cursor exactly where you want it.) + +## CommandResult options + +```csharp +CommandResult.ShowToast("message") // toast, dismiss palette +CommandResult.ShowToast(new ToastArgs { Message = "message", Result = CommandResult.KeepOpen() }) // toast, keep open +CommandResult.KeepOpen() // stay on current page +CommandResult.Dismiss() / .GoBack() / .GoHome() +CommandResult.Confirm(new ConfirmationArgs()) // confirmation dialog +``` + +## ListItem / Icon + +```csharp +new ListItem(command) { + Title = "Required", Subtitle = "Optional", Section = "Section header", + Icon = new IconInfo("https://..."), // URL icon (host fetches it) +} +new IconInfo("") // Segoe MDL2 glyph (C# escape — see CLAUDE.md gotcha) +new IconInfo(lightIconData, darkIconData) // separate light/dark +IconHelpers.FromRelativePath("Assets\\foo.png") // bundled asset +``` + +## ProcessHelper + +```csharp +// Run any external process. Reads BOTH stdout and stderr before WaitForExit (prevents pipe deadlocks). +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. +ProcessHelper.OpenUrl(string url); +``` + +For richer process handling (output parsing into records), see `reference/helpers/AdbHelper.cs`. + +## InvokableCommand pattern (with error-toast convention) + +```csharp +internal sealed partial class MyCommand : InvokableCommand +{ + public MyCommand() { Name = "Do Thing"; Icon = new IconInfo("https://..."); } + + public override ICommandResult Invoke() + { + try { /* work */ return CommandResult.ShowToast("Done"); } + catch (Exception ex) when (ex is Win32Exception w && w.NativeErrorCode == 2) + { return ErrorToast("Required tool not found. Make sure it is on your PATH."); } + catch (Exception ex) { return ErrorToast($"Unexpected error: {ex.Message}"); } + } + + private static ICommandResult ErrorToast(string message) => + CommandResult.ShowToast(new ToastArgs { Message = message, Result = CommandResult.KeepOpen() }); +} +``` diff --git a/notes/features.md b/notes/features.md new file mode 100644 index 0000000..f573653 --- /dev/null +++ b/notes/features.md @@ -0,0 +1,178 @@ +# Feature deep-dives + +## Navigation / UX shape + +One top-level **Markets** command (`MarketsPage`) — a hub that funnels into the screens: +**Search / Watchlist / Favorites / Portfolio / News / Data Sources / Settings**. (This replaced the old +three separate top-level commands, keeping the Command Palette root to one entry.) Sub-pages are built once +and reused, so their per-page caches survive navigating in and out of the hub. + +**Enter on any instrument row opens the shared `SymbolDetailPage`** — the per-symbol detail screen +(detail + live chart), which is **the single place for list management**. Its command bar carries the +add/remove-watchlist (**Enter**) and add/remove-favorite (**Ctrl+Enter**) actions, plus portfolio +add/edit/remove in the overflow, labelled for the instrument's current state. The page subscribes to the +store flows so toggling there (the commands `KeepOpen`) flips the buttons in place. **List rows carry no +context actions** — they only navigate. + +- **Search** (`SearchPage`) — the Enter-only `/search` flow (never per-keystroke, to protect rate limits). +- **Watchlist** (`WatchlistPage`) — tracked instruments, priced, grouped by class; a ★ marks favorites. +- **Favorites** (`FavoritesPage`) — the curated subset; **only favorites render in the dock band**. +- Watchlist + Favorites share `Pages/PricedListPage.cs`. Membership comes from `WatchlistStore`'s two + flags `InWatchlist`/`IsFavorite`. Membership commands live in `Commands/MembershipCommands.cs` — each + just mutates the store (the flows re-render every live surface) and shows a confirmation toast while + keeping the palette open. +- **Data Sources** (`DataSourcesPage`) — a static informational screen (where data comes from, + not-financial-advice, keys-stay-on-your-machine). Credited per-source; ECB credited here. + +## Portfolio screen + dock band + +**Markets Portfolio** tracks actual *holdings* (a quantity per symbol), priced live, with a totals summary +pinned on top and **daily P&L** + **total return** per holding. Reuses the data seam with no provider/ +repository changes. + +- `Settings/PortfolioStore.cs` — JSON holdings (`market_portfolio.json`); two `StateFlow`s + `Positions`/`Instruments`, both **reference equality** so EVERY mutation re-emits (incl. quantity-only). +- `Models/DomainPosition` / `UiPosition` / `UiPortfolio` — `UiPosition` combines a `DomainQuote` + quantity + (+ optional cost basis) → `MarketValue`, `DailyPnL` (qty × `DomainQuote.Change`), `TotalCost`/ + `TotalReturn`/`TotalReturnPercent`; `UiPortfolio.From` rolls up the totals. Formatting lives only here. +- **Daily P&L:** per position = qty × `DomainQuote.Change`. Aggregate **percent = totalDailyPnL / + (totalValue − totalDailyPnL)** (vs yesterday's close), ÷0-guarded. `IsValid:false` holdings excluded. +- **Total return (cost basis):** per position = `MarketValue − qty × CostBasis`; **percent = + (price − basis)/basis**. Shown only when a positive basis was recorded. Cost basis is stored per unit in + the holding's **native currency**, so it converts with the same FX rate as the value. +- **Screen** (`PortfolioPage`) — a `PricedListPage` subclass, so it inherits all caching/polling/reconcile. + The totals summary is the first row via `PricedListPage.LeadingRows(pricedQuotes)` (default empty → other + pages unaffected), fed the full unfiltered priced set. +- **Editor** (`SetQuantityPage`) — a `ContentPage` with a quantity `Input.Number` (required, > 0) and an + optional average-cost-per-unit `Input.Number`, both prefilled from the current holding. `SubmitForm` maps + 0/blank cost → null. The single-`FormContent` auto-focus quirk is helpful here (cursor lands in quantity). +- **Dock band** (`PortfolioDockPage`) — one summary button: total value + daily P&L rolled into the + `PortfolioCurrency`; clicking opens `PortfolioPage`. Pure cache observer; `await`s `PrimeAsync` for the + converted total in one paint. + +## Multi-currency portfolio conversion + +The `$`-everywhere assumption is gone. `DomainQuote` carries an ISO-4217 **`Currency`** (default `"USD"`); +providers stamp it (see `notes/providers.md`). The **Portfolio screen** values each holding in its native +currency **and** converts into the user's `PortfolioCurrency` via the keyless Frankfurter `/latest` rates +(`Helpers/CurrencyConverter.cs`). + +- Two-phase because the screen renders synchronously: `PrimeAsync` batches every not-yet-fresh + `native→preferred` rate in one call (~1 h TTL, negatives cached); `TryGetRate` is the synchronous cache + read at render time. An all-USD portfolio with USD preferred does **zero** network (rate-1 short-circuit). +- ⚠️ **GBX/pence trap handled:** `CurrencyHelper.NormalizeStockQuote` folds London `GBp`/`GBX` to GBP + (price & change ÷100). The Domain layer only ever sees major-unit prices in a major-unit code. +- A holding whose currency the ECB set can't convert is **excluded from the total** (surfaced as "*N* not + converted"). `IsValid:false` holdings excluded as before. +- Beyond the portfolio, `UiQuote.FormatPrice` is currency-aware app-wide (a London stock shows `£2.50`). +- Caveat: the reporting currency is cached in-memory per session (not persisted); changing + `PortfolioCurrency` applies on the next refresh/revisit (pull-style). + +## Demo mode (offline testing) + +A **`Demo mode` toggle** (`MarketSettingsManager`, key `demoMode`, default off) puts the whole app on +built-in sample data with **no API key and no network**. + +- **Routing via an "exclusive provider" seam:** `IMarketDataProvider.IsExclusive` (default false); + `MockMarketDataProvider` returns `DemoMode`. `MarketRepository.ActiveProviders()` returns **only the + exclusive providers when any exist** — so quotes, candles, news, AND the search fan-out all route through + the mock alone (zero network even with live keys set). `CurrencyConverter` fills its cache from a static + `DemoUsdPerUnit` table. +- **Any symbol:** `Seed` (hand-tuned headline symbols incl. GBP `HSBA.L`) overrides on top of + `SynthesizeQuote` — a stable FNV-1a hash (deterministic across refreshes AND restarts), category-/ + currency-inferred quote for any other ticker. Candles anchor on the same price. +- **Search matches a curated `Catalog` of ~120 REAL instruments, never fabricated.** Why: a watchlist/ + portfolio persists the full `DomainInstrument` to JSON, so a faked symbol would become a permanent blank + row once demo mode is off. +- **Applies instantly:** `MarketSettingsManager.DemoModeChanged` (`StateFlow`) — every surface + subscribes and resets at once (priced pages drop their cache, dock bands re-price, `CurrencyConverter` + clears, hub/Search re-list the status row). The rate-limited banner is suppressed in demo mode. +- A blue **"Demo mode — showing sample data"** status row shows while it's on (`ApiKeyHint.StatusRow()`). + +## Symbol detail + live chart (chart DONE; flicker fixed, Enter/focus bug open) + +The shared `SymbolDetailPage` opens from any row (Enter) and every dock-button click, and renders a real +price chart with Robinhood-style range tabs (1D / 1W / 1M / 1Y / 5Y). + +- Fetches **real candle history** through the provider seam (`GetCandlesAsync` → `DomainCandleSeries` → + `UiCandleSeries`, SVG via `ChartHelper`). The page body is a nested `SymbolChartForm : FormContent`: an + adaptive card binding the SVG `Image` with the 5 range tabs as `Action.Submit` buttons. Header price/% + reflect the **selected range** (last vs first close), Robinhood-style. +- **Gating:** Finnhub candles are premium (free key → 403 → "requires a paid Finnhub plan"). **Twelve Data + candles are free-tier** → a free TD key renders real charts. Demo mode draws synthetic candles. +- **Live refresh:** the open chart re-fetches its visible range each `PollTicker` tick (the only direct + `PollTicker` subscriber left). Silent, bypasses the per-range cache, keeps last good on empty. + +**Two UI issues:** +1. **Flicker on range switch — ✅ FIXED.** `Load()` only paints the "Loading…" card when no chart is on + screen yet (first load); later switches leave the prior chart up and swap in place (tracked via + `_displaySeries`). +2. **Enter activates "1D" instead of the primary command — ❌ UNSOLVED.** With a single `FormContent`, + CmdPal sets `OnlyControlOnPage = true` and programmatically focuses the card's first focusable element + (the 1D tab); focus trapped in the card also blocks Ctrl+Enter. No host API suppresses the auto-focus. + Two fixes tried and abandoned (a 2nd content item — did NOT help in practice; leading the card with + membership buttons — too hacky). Accepted as a known limitation. See `notes/cmdpal-toolkit.md` § focus. + +**Known ceilings:** numeric axis labels NOT possible (Direct2D drops SVG ``); no active-tab highlight +(adaptive cards can't restyle a button by bound data); no hover/scrub readout (out-of-proc, static image). + +## Asset logos as icons (app-wide) + +Every instrument row, dock button, and the `SymbolDetailPage` header shows the instrument's **real logo** via +the **Elbstream CDN, addressed by symbol** (`Helpers/AssetIconResolver.cs`) — because `new IconInfo(url)` +makes the **host** fetch the image, there is **zero API call, no caching, no DTO**: + +- `Stock → /logos/symbol/{ticker}`, `Crypto → /logos/crypto/{sym}`, `Currency → /logos/country/{iso2}` (FX + pair → base currency's flag, `EUR→eu`). Unmapped currencies/unknown categories → a Segoe MDL2 glyph. +- A miss returns HTTP 404 (host shows its empty-icon slot). Glyph fallback covers whole *categories*, not + per-symbol misses. +- **Attribution required** (Elbstream is free with attribution): an `AssetIconResolver.AttributionRow()` + "Logos provided by Elbstream" row on the priced pages + Search results, and a line inside the + `SymbolDetailPage` card. The dock band is an accepted gray area. Credit row is clickable via + `Commands/OpenUrlCommand.cs` → `ProcessHelper.OpenUrl`. +- ⚠️ 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. + +## Market News (feed) + +A **Markets → News** screen (a hub row) lists market-news headlines, each opening the source article. News +flows through the SAME layered seams as quotes, with a parallel cache + orchestration. + +- **Provider seam:** `SupportsNews => false` (a capability gate, NOT asset-class routing) + + `GetNewsAsync(NewsCategory, minId, ct)` whose default body **THROWS** (unlike candles' soft opt-out — + reaching it means a caller skipped the `SupportsNews` check; fail loud). Finnhub opts in; + `MockMarketDataProvider` opts in only in Demo mode. Twelve Data / Frankfurter inherit the throwing default. +- **News cache** (`INewsCacheDataSource` / `InMemoryNewsCacheDataSource`): keyed by **`NewsCategory`** with + a **LIST** of `NewsEntity` per category (news is inherently a list). DynamicData-backed, atomic `Edit`; + keep-last-good = a transient EMPTY fetch won't wipe a non-empty cached feed. `Observe` emits **null until + first fetch** (so a non-null EMPTY feed reads as "loaded-but-empty", distinct from "loading"). +- **Repository:** `ObserveNews(NewsCategory)` (fixed) and `ObserveNews(StateFlow)` + (selection-aware, `Switch`), both `.ObserveOn(TaskPoolScheduler)` (the same deadlock seam). **The merged + "All" view = the `NewsCategory?` selection where `null` = All** (`CombineLatest` the 4 categories → + dedupe by id → newest-first). A **second poll loop** (`PollTicker.SubscribeNews`) on a separate + `NewsRefreshInterval` (default 30 min). Stale-on-subscribe + demo-flip refill, as for quotes. +- **Screen** (`NewsPage`): a `DynamicListPage` **pure observer** with a `Filters` dropdown ("All" default + + General/Forex/Crypto/Mergers); Enter opens the article (`OpenUrlCommand`); summary in the `Details` pane; + typed search filters headlines client-side. No-key UX: a Finnhub-gated `ApiKeyHint.NewsStatusRow()` shows + "News needs a Finnhub key" instead of spinning. Rate-limit banner inserted at the top. +- **News dock band** (`NewsDockPage`): a CNBC-style ticker, pure observer of the merged "All" feed. + Renders a **row of 4 headline buttons** (each opens its own article) and **cycles** the window through the + feed one headline per tick — NOT one scrolling Title (the host caps each dock button at `MaxWidth=100`≈15 + chars and won't pixel-scroll a Title). Cycle cadence = the **`News ticker speed (seconds)`** setting + (`NewsTickerCycleSeconds`, default 60 s), re-read live each tick by a dedicated per-band + `Observable.Generate` (NOT `PollTicker` — that's data-refresh; this is UI animation). Both subscriptions + disposed on hide. Third `CommandItem` in `GetDockBands()` (`Id = com.costafotiadis.market.dock.news`). + +**Left to do:** ① minId incremental paging (every refresh fetches `minId:0` and replaces the cached feed); +② multi-provider news (single-source today — `NewsProvider()` = first `SupportsNews`); ③ native review of +the machine-quality locale translations. + +## Settings (`Settings/MarketSettingsManager.cs`) + +`JsonSettingsManager` singleton → `…/Microsoft.CmdPal/market.settings.json`. Holds: the API keys +(`FinnhubApiKey`/`TwelveDataApiKey`), the price **refresh interval** (`RefreshMinutes`, default 10, 0=off → +`RefreshInterval`/`AutoRefreshEnabled`), the **`News refresh interval (minutes)`** (`NewsRefreshMinutes`, +default 30 — deliberately separate from the price interval), the news ticker **`News ticker speed +(seconds)`** (`NewsTickerCycleSeconds`, default 60), `PortfolioCurrency`, the **`Demo mode`** toggle +(→ `DemoMode` + the observable `DemoModeChanged`), and **`Show rate-limit warnings`** (`ShowRateLimitErrors`, +default on). The same form is shown both in CmdPal's Settings UI and as a hub row. diff --git a/notes/providers.md b/notes/providers.md new file mode 100644 index 0000000..31e78ac --- /dev/null +++ b/notes/providers.md @@ -0,0 +1,100 @@ +# Providers (data sources) + +Providers are registered in `MarketExtensionCommandsProvider` in this order — first-match by asset class: + +```csharp +new MarketRepository( + new MockMarketDataProvider(), // gated on Demo mode → wins everything when demoing + new TwelveDataMarketDataProvider(), // primary WHEN its key is set (gated on the key) + new FinnhubMarketDataProvider(), // stocks + crypto fallback; also the news provider + new FrankfurterMarketDataProvider() // keyless ECB forex +) +``` + +## To add a provider + +Implement `IMarketDataProvider`: +- `Supports(AssetCategory)`, `GetQuotesAsync`, `SearchAsync` (return `[]` if it can't search). +- Optionally **override `GetCandlesAsync`** to serve chart history, else the default invalid-series opt-out + applies. The provider translates the neutral `ChartRange.Interval`/`Lookback` into its own API tokens + (Finnhub's `ToFinnhubResolution` is the model; TD maps `CandleInterval` to `5min/30min/1h/1day/1week`). +- For news, set `SupportsNews => true` and implement `GetNewsAsync`. + +Map its `Api*` DTO → `DomainQuote` (and → `DomainCandleSeries` for candles), register it in +`MarketExtensionCommandsProvider`, gate its `Supports()` on whatever should make it active. **Zero UI/ +repository changes.** Worked examples: `FrankfurterMarketDataProvider` (keyless FX) and +`TwelveDataMarketDataProvider` (an all-three-classes source) were both added exactly this way. + +## Twelve Data (primary provider) + +- Base `https://api.twelvedata.com/`, key via `apikey=` query param (setting `TwelveDataApiKey`). **One + source for stocks + crypto + forex.** Registered FIRST among live providers; `Supports()` 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. +- Symbol formats: stock `AAPL`, crypto `BTC/USD`, FX `EUR/USD`. `ToTwelveDataSymbol` maps our neutral + symbols; `SearchAsync` normalizes results **back** (crypto → base coin, FX → 6-letter) so they round-trip. +- **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). 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`. +- **Currency:** `currency` rides in the batched `/quote` (zero extra calls). Stamped per category: stock → + the field (normalized), crypto → USD, FX → the pair's quote currency. + +## Finnhub + +- Base `https://finnhub.io/api/v1`, endpoint `/quote`. Free tier ~60 calls/min, ~300/day. Setting + `FinnhubApiKey`; `SearchAsync`/quote/candle short-circuit to "no data" (with a `Log.Warn`) when unset. +- **No forex on the free tier** — `OANDA:*` returns 403. FX is routed to keyless Frankfurter. +- Symbol formats: stock = bare (`AAPL`), crypto = `BINANCE:{SYM}USDT`, FX = `OANDA:{BASE}_{QUOTE}`. +- An all-zero `/quote` response = invalid/unknown symbol → `IsValid:false`. +- **Candles — PREMIUM** (free key → 403): `GET /stock/candle?symbol=&resolution=&from=&to=` (crypto: + `/crypto/candle`). `resolution` ∈ `1,5,15,30,60,D,W,M`; `from`/`to` are UNIX seconds. Response = parallel + arrays `c/h/l/o/t/v` + `s`. `GetCandlesAsync` maps each `ChartRange`: 1D→`5`/1d, 1W→`30`/7d, 1M→`60`/31d, + 1Y→`D`/365d, 5Y→`W`/5y. 403/429/`no_data` → invalid series → the chart shows "unavailable". +- **Symbol search** `/search?q=&exchange=US` (free tier): identity only, **no prices**, scoped to **US + equities** (mapped to `Stock`). UI is **Enter-only** (never per-keystroke) to protect the rate limit. +- **Currency:** `/quote` has no currency, so stocks resolve via `/stock/profile2?symbol=`, cached + **per-symbol forever** (one extra call per *new* stock per session; the USD fallback on failure is cached + too). Crypto short-circuits to USD; FX isn't served here. `ApiFinnhubProfileDto.cs`. +- **News:** `GET /news?category=&minId=&token=` returns a **bare top-level array** (`ApiFinnhubNewsDto[]` + registered as the root on `FinnhubJsonContext`). `category` ∈ `general/forex/crypto/merger` (the only + place that token lives is `ToFinnhubNewsCategory`); `minId` returns only items newer than that news id + (0 = latest batch). Drops items missing id/headline/url at the boundary. + +## Frankfurter (FX) + +- Base `https://api.frankfurter.dev/v1/`. **Keyless, no documented rate limit** → no key gate. +- Wraps the **ECB daily reference rates** → **daily only** (one fixing per business day, ~16:00 CET). + Implications: a quote's **change is day-over-day** (latest vs previous fixing); charts plot **daily + closes** (1D/1W tabs show recent fixings, not intraday; the candle lookback is floored at 7 days). +- **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 and take the last + two points; candles fetch the range's lookback. Any base works (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). +- Icons: an FX pair shows its **base currency's country flag** (Elbstream `/logos/country/{iso2}`, EUR→`eu`). + +## 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`). 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`) builds **warning-free** — no `IL2026`/`IL3050` +enforcement. (To revive AOT readiness, restore `true` + the CsWinRT optimizer lines from +git history.) + +The source-gen `JsonSerializerContext`s (`FinnhubJsonContext`, `FrankfurterJsonContext`, +`TwelveDataJsonContext`, `WatchlistJsonContext`) are kept as a **convention, not a build requirement** — new +JSON may use either source-gen or plain reflection. + +⚠️ **Gotcha (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 and the *whole* generator silently fails, cascading CS0534 onto **every** context in the +build. Keep all `[JsonSerializable]` for a given context on a **single** declaration (see +`ApiFinnhubQuoteDto.cs`). diff --git a/notes/releasing.md b/notes/releasing.md new file mode 100644 index 0000000..1bad980 --- /dev/null +++ b/notes/releasing.md @@ -0,0 +1,82 @@ +# Releasing a new version + +Current version: **1.1.0.0** (in the csproj). Bump all 5 files together. + +## Step 1 — Bump version in all 5 files + +| File | Field | +|---|---| +| `MarketExtension/MarketExtension.csproj` | `` | +| `MarketExtension/Package.appxmanifest` | `Identity Version=` | +| `MarketExtension/app.manifest` | `assemblyIdentity version=` | +| `MarketExtension/build-exe.ps1` | `$Version` default param | +| `MarketExtension/setup-template.iss` | `#define AppVersion` | + +One-liner (replace `OLD` and `NEW`): +```powershell +$files = @("MarketExtension/MarketExtension.csproj","MarketExtension/Package.appxmanifest","MarketExtension/app.manifest","MarketExtension/build-exe.ps1","MarketExtension/setup-template.iss") +$files | ForEach-Object { (Get-Content $_) -replace 'OLD','NEW' | Set-Content $_ } +``` + +## Step 2 — Trigger the GitHub Actions build + +``` +gh workflow run release-msix.yml --ref master -f release_notes="Your release notes here" +``` + +Reads the version from the csproj automatically, builds x64 + ARM64, bundles into a `.msixbundle`, signs it, +and creates a GitHub Release. (`release-extension.yml` is the alternative self-signed `.exe` installer path.) + +## Step 3 — Submit to Partner Center + +1. Download the `.msixbundle` from the GitHub Release. +2. Partner Center → app → new submission → Packages → upload the `.msixbundle`. +3. Update description/notes, submit. + +## Build & deploy locally + +Build via Visual Studio or `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). +- ⚠️ The csproj build can fail on AnyCPU — pin `-p:Platform=x64` if you hit that. + +## One-time setup (this extension's own identity) + +Scaffolded from AdbExtension. Already changed: COM GUID (`6b38c9aa-bbee-45e9-81e9-cf25707910e7`), +namespace/assembly (`MarketExtension`), package identity +(`CostaFotiadis.MarketsExtensionforCommandPalette`). Still TODO before shipping: + +- **Partner Center:** reserve the app name; confirm `Identity Name` + `Publisher` in + `Package.appxmanifest`/csproj match what it assigns. +- **GitHub secrets:** add `SIGNING_CERT_PFX` + `SIGNING_CERT_PASSWORD` (see + `MarketExtension/create-signing-cert.ps1`). Template clones do not carry secrets. +- **Description:** update the placeholder description in `Package.appxmanifest`. +- ✅ **Assets — DONE.** The `$ >_` charcoal-squircle logo is wired in code + (`markets_logo_base_square.png` via `IconHelpers.FromRelativePath`) and the MSIX tile/Store PNGs were + regenerated from the square source via VS's Visual Assets generator (turn **off** "Apply recommended + padding" — already-padded art double-insets). No ``/`.ico` (matches AdbExtension). +- **API key:** none needed at build time — each user pastes their own key into the extension's settings at + runtime. + +### Icons: two separate systems + +- **In-CmdPal icons:** instrument rows / dock buttons / detail header use the real asset logo via + `AssetIconResolver.Resolve(...)` (Elbstream CDN) with a Segoe-glyph fallback; the extension entry, + Settings, and all page chrome use the bundled app logo via + `IconHelpers.FromRelativePath("Assets\\markets_logo_base_square.png")`. +- **MSIX tile/Store assets** in `Assets\` (`StoreLogo`/`Square*Logo`/`SmallTile`/`LargeTile`/`Wide*`/ + `SplashScreen`, generated by VS's Manifest Designer → Visual Assets from a square source) are a + **separate** system from the in-CmdPal icon. + +## Localization + +UI strings live in `Properties/Resources.resx` (neutral = English), accessed via +`Strings.Get`/`Strings.Format` (`Helpers/Strings.cs`). The Designer (`Resources.Designer.cs`, a public +`Resources` class, generated by VS's `PublicResXFileCodeGenerator`) is checked in so `dotnet build` +compiles — regenerate it from VS after editing the resx. Ship another language by adding a +`Resources..resx` satellite. Locales currently carried are machine-quality and worth a native +review (esp. compact relative-time forms). **Don't hardcode user-facing literals** — add a resx key.