Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,580 changes: 93 additions & 1,487 deletions CLAUDE.md

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions MarketExtension/Data/Finnhub/ApiFinnhubNewsDto.cs
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions MarketExtension/Data/Finnhub/ApiFinnhubQuoteDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
86 changes: 86 additions & 0 deletions MarketExtension/Data/Finnhub/FinnhubMarketDataProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IReadOnlyList<DomainQuote>> GetQuotesAsync(
IReadOnlyList<DomainInstrument> instruments, CancellationToken ct = default)
{
Expand Down Expand Up @@ -231,6 +235,76 @@ public async Task<DomainCandleSeries> 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<IReadOnlyList<DomainNews>> 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);

Expand Down Expand Up @@ -303,6 +377,18 @@ private static async Task<string> 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
Expand Down
19 changes: 19 additions & 0 deletions MarketExtension/Data/IMarketDataProvider.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -37,4 +38,22 @@ Task<IReadOnlyList<DomainQuote>> GetQuotesAsync(
Task<DomainCandleSeries> 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<IReadOnlyList<DomainNews>> 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.");
}
47 changes: 47 additions & 0 deletions MarketExtension/Data/INewsCacheDataSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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<NewsEntity> Get(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<IReadOnlyList<NewsEntity>?> 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<NewsEntity> 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();
}
98 changes: 98 additions & 0 deletions MarketExtension/Data/InMemoryNewsCacheDataSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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<NewsFeedEntity, NewsCategory> — 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<NewsFeedEntity, NewsCategory> _cache = new(f => f.Category);

// 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<NewsEntity> Empty = [];

public IReadOnlyList<NewsEntity> Get(NewsCategory category)
{
var found = _cache.Lookup(category);
return found.HasValue ? found.Value.Items : Empty;
}

public IObservable<IReadOnlyList<NewsEntity>?> 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; 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 ? null : (IReadOnlyList<NewsEntity>?)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<NewsEntity>? GetOrNull(NewsCategory category)
{
var found = _cache.Lookup(category);
return found.HasValue ? found.Value.Items : null;
}

public void Upsert(NewsCategory category, IReadOnlyList<NewsEntity> 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<NewsEntity> Items);
}
Loading
Loading