Skip to content

WorkWingman — News & Price Context (Technical)

Historical context only — not investment advice. This feature is a backward-looking event study: for each recent news item about a public employer, it shows how the stock ALREADY moved around that item's date. A price move near a news date does not mean the news caused it (correlation is not causation), and past movement does not predict the future. This feature never forecasts, estimates, or targets a future price, and never frames anything as a buy/sell signal — if a number would require predicting what happens next, it is omitted, not guessed.

What it shows

For a company, a compact list of its recent news items, each paired with:

  • The headline, publication date, source, and a link to the original article.
  • A factual sentiment label (Negative / Neutral / Positive) classifying the news item's own text (e.g. "layoffs" reads negative, "product launch" reads positive) — not a price prediction. A "positive" headline paired with a realized price drop (or vice versa) is expected and is never treated as a contradiction.
  • The stock's realized percent price change from the last trading day before the news date to the close WindowTradingDays trading days on/after it — a plain fact about what already happened, computed purely from historical closes.

Data model

NewsPriceContext:

public class NewsPriceContext
{
    public IReadOnlyList<NewsPriceContextItem> Items { get; set; } = [];
    public string Disclaimer { get; set; } = "Historical context only — not investment advice. ...";
    public DateTimeOffset AsOf { get; set; }
}

public class NewsPriceContextItem
{
    public string Headline { get; set; }
    public DateOnly Date { get; set; }
    public string Url { get; set; }
    public string SourceName { get; set; }
    public NewsSentimentLabel SentimentLabel { get; set; }   // Negative | Neutral | Positive
    public decimal? RealizedMovePctAroundDate { get; set; }  // null when price data doesn't cover the date
    public int WindowTradingDays { get; set; }
}

Fields returned:

Field Type Reliability
Headline, Date, Url, SourceName string/DateOnly Straight from the recent-news provider.
SentimentLabel enum Deterministic keyword classification of the headline text — see NewsSentimentClassifier below. Always populated.
RealizedMovePctAroundDate decimal? Null when price history doesn't cover the news date (private company, no ticker, no data that far back, market holiday gaps beyond the fetch window) — never estimated or fabricated when missing.
WindowTradingDays int The trading-day window used for the realized-move calculation (currently 3).

The realized-move calculation

NewsPriceContextService.ComputeRealizedMovePct is a pure static function over a list of DailyClose (date + close), a news date, and a window size in trading days:

  1. Prior close — the last close strictly before the news date in the supplied series (this naturally skips weekends/holidays, which simply have no DailyClose entry).
  2. Window-end close — the WindowTradingDays-th close on or after the news date in the supplied series (i.e. it counts trading days actually present in the data, not calendar days).
  3. Realized move = (windowEndClose - priorClose) / priorClose * 100, rounded to 2 decimal places.

If either anchor close is missing from the supplied series (no prior close in range, or fewer than WindowTradingDays closes on/after the news date), the function returns null rather than partially computing or interpolating. A priorClose of exactly 0 also returns null (guards divide-by-zero on bad source data rather than throwing).

NewsPriceContextService.GetContextAsync calls this once per news item, having asked IPriceHistoryProvider for a ±14 calendar-day window of closes around each item's date (enough padding to find "the trading day before" and "N trading days after" across weekends/holidays). When the caller passes a null/blank ticker (private or unresolvable company), the price provider is never called at all — every item still comes back with its headline and sentiment, just with RealizedMovePctAroundDate = null.

This is arithmetic over already-happened closes, nothing else. There is no model, no regression, no correlation coefficient, no "expected move" — a single subtraction and division over two facts that are both already in the past by the time this code runs.

Sentiment classification

NewsSentimentClassifier is a fixed keyword list applied to the headline (and optional body text), case-insensitive substring match:

  • Negative keywords: layoffs, lawsuit, recall, investigation, fraud, bankruptcy, downgrade, resigns, data breach, misses estimates, job cuts, shutdown, and similar.
  • Positive keywords: launch, beats estimates, record revenue, upgrade, acquisition, partnership, wins contract, all-time high, and similar.
  • Neither matches → Neutral.
  • If both a negative and a positive keyword are present, negative wins — job-loss news is treated as the more decision-relevant fact to a reader regardless of accompanying good news.

This is text classification of the article's own words. It carries no opinion about what the stock will do next and is never combined with RealizedMovePctAroundDate into any kind of "impact score."

Seams to sibling branches

This feature depends on two small interfaces, each now backed by a real implementation wired in Program.cs. The deterministic stubs this branch shipped standalone with (StubPriceHistoryProvider, StubRecentNewsProvider) were deleted once the sibling services landed — see commit cb297f4 ("Phase 2 seam 2: wire NewsPriceContextService to real equity-comp/company-research services"). The interfaces are unchanged, so no caller changed:

public interface IPriceHistoryProvider
{
    Task<IReadOnlyList<DailyClose>> GetDailyClosesAsync(
        string ticker, DateOnly fromDate, DateOnly toDate, CancellationToken ct = default);
}

public interface IRecentNewsProvider
{
    Task<IReadOnlyList<RecentNewsItem>> GetRecentAsync(string company, CancellationToken ct = default);
}
  • IPriceHistoryProvider — backed by EquityPriceHistoryProvider, which adapts feature-equity-comp's IStockQuoteService (Stooq primary — currently expected to fail closed behind its bot-challenge — then the config-gated, keyed Alpha Vantage fallback for history; Barchart is quote-only, its GetHistoryAsync returns [], so it contributes no closes. All sources unavailable → empty history). IStockQuoteService.GetHistoryAsync returns a source's full available history with no date-range parameter, so the adapter fetches once and filters client-side to the requested fromDate/toDate window; DailyClose and DailyPricePoint are shape-identical (Date + Close), so each point maps across with a straight field copy — no reshaping. It replaced StubPriceHistoryProvider's synthetic per-ticker drift series.
  • IRecentNewsProvider — backed by GdeltRecentNewsProvider, which adapts feature-company-research's GDELT-backed INewsSourceAdapter (NewsSource) — the same free, keyless GDELT DOC 2.0 news index CompanyResearchService uses for company briefings — translating each NewsItem (Title/PublishedAt) into this seam's RecentNewsItem (Headline/Date). It replaced StubRecentNewsProvider's fixed three-item fixture.

Contract for both providers: never throw for "no data" — an unresolvable ticker or a company with no news returns an empty list, the same graceful-degradation contract used by ILayoffSourceAdapter elsewhere in this codebase (see job-signals.md). Each adapter also guards its own source call (catch-to-empty, rethrow only on cancellation). Underneath, StockQuoteService already tries every configured source in turn (Stooq, then the keyed Alpha Vantage — Barchart is quote-only and returns no history), so only a total failure of the history-capable sources — or a GDELT outage on the news side — degrades to an empty section rather than a thrown error.

API

MarketContextController:

GET /api/market-context/news-price?company={company}&ticker={ticker?}
  • company (required) — the employer name.
  • ticker (optional) — pass when known; omit (or leave blank) for a private/unresolvable company. News items still come back with every field populated except realizedMovePctAroundDate, which is left null for each rather than the whole response being suppressed.

Response body is a NewsPriceContext (see Data model above). The controller class carries the disclaimer in its own doc comment, matching every other surface of this feature.

JobPosting (src/WorkWingman.Core/Models/JobPosting.cs) carries a nullable Ticker field (string? Ticker), the same "empty means not resolved yet, not an error" pattern as LayoffHistory/PostingSignal. The frontend reads job.ticker and passes it straight through to this endpoint — nothing in this branch populates Ticker yet (that's a future company-research/ticker-resolution concern), so today every call still exercises the null-ticker path until that's wired up.

Wiring

Registered in Program.cs the same way every other service in this codebase is:

builder.Services.AddSingleton<IPriceHistoryProvider, EquityPriceHistoryProvider>();
builder.Services.AddSingleton<IRecentNewsProvider, GdeltRecentNewsProvider>();
builder.Services.AddSingleton<INewsPriceContextService, NewsPriceContextService>();

Frontend

The "News & the stock (historical)" section lives in the study feature (frontend/src/app/features/study/) — WorkWingman's interview-prep/company view. It:

  • Loads getNewsPriceContext(company, ticker) from ApiService whenever the selected job changes, passing the job's ticker field through (currently always null until a ticker resolver exists — see Wiring note above).
  • Renders as a compact list — headline (linked), date + source, a sentiment chip (Negative/Neutral/Positive colored danger/mut/acc), and the realized move with an explicit sign (e.g. +2.87%, -4.21%) and its trading-day window, or no price data when realizedMovePctAroundDate is null.
  • Shows the disclaimer inline, directly under the list, on every render that has items.
  • Hides entirely when the context has zero items (e.g. a private company the news/price providers found nothing for) — consistent with this codebase's existing "empty means nothing found, not an error" convention (see job-signals.md).
  • Guards against stale responses: each load is stamped with the job id it was issued for; if the user switches to a different job before a slow request resolves, the response is discarded rather than overwriting the now-selected job's context.

Tests

  • NewsPriceContextServiceTests.cs — exhaustive tests of ComputeRealizedMovePct (window edges, non-trading-day gaps, missing prior close, insufficient window-end data, divide-by-zero guard, sign, rounding) plus GetContextAsync end-to-end wiring (null/blank ticker, multiple items computed independently, disclaimer content, AsOf stamping via injected TimeProvider, cancellation) — all against mocked IRecentNewsProvider/IPriceHistoryProvider, zero real network.
  • NewsSentimentClassifierTests.cs — keyword classification for negative/positive/neutral, case-insensitivity, negative-wins-on-tie, null body text.
  • NewsPriceContextAdaptersTests.cs — the two real adapters against mocked IStockQuoteService/INewsSourceAdapter: date-range filtering, NewsItemRecentNewsItem translation, newest-first ordering, and graceful degradation to an empty list on a source failure — zero real network.
  • Frontend: study.spec.ts — loads context for the selected company, renders the disclaimer, hides the section when there are no items, sentiment-chip mapping, move formatting (sign, blank-on-null).
  • Stryker config (stryker-config.json) adds **/MarketContext/*.cs to the mutate list.