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
WindowTradingDaystrading days on/after it — a plain fact about what already happened, computed purely from historical closes.
Data model¶
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:
- Prior close — the last close strictly before the news date in the supplied series (this
naturally skips weekends/holidays, which simply have no
DailyCloseentry). - 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). - 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 byEquityPriceHistoryProvider, which adapts feature-equity-comp'sIStockQuoteService(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, itsGetHistoryAsyncreturns[], so it contributes no closes. All sources unavailable → empty history).IStockQuoteService.GetHistoryAsyncreturns a source's full available history with no date-range parameter, so the adapter fetches once and filters client-side to the requestedfromDate/toDatewindow;DailyCloseandDailyPricePointare shape-identical (Date + Close), so each point maps across with a straight field copy — no reshaping. It replacedStubPriceHistoryProvider's synthetic per-ticker drift series.IRecentNewsProvider— backed byGdeltRecentNewsProvider, which adapts feature-company-research's GDELT-backedINewsSourceAdapter(NewsSource) — the same free, keyless GDELT DOC 2.0 news indexCompanyResearchServiceuses for company briefings — translating eachNewsItem(Title/PublishedAt) into this seam'sRecentNewsItem(Headline/Date). It replacedStubRecentNewsProvider'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¶
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 exceptrealizedMovePctAroundDate, which is leftnullfor 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)fromApiServicewhenever the selected job changes, passing the job'stickerfield 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/Positivecolored danger/mut/acc), and the realized move with an explicit sign (e.g.+2.87%,-4.21%) and its trading-day window, orno price datawhenrealizedMovePctAroundDateis 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 ofComputeRealizedMovePct(window edges, non-trading-day gaps, missing prior close, insufficient window-end data, divide-by-zero guard, sign, rounding) plusGetContextAsyncend-to-end wiring (null/blank ticker, multiple items computed independently, disclaimer content,AsOfstamping via injectedTimeProvider, cancellation) — all against mockedIRecentNewsProvider/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 mockedIStockQuoteService/INewsSourceAdapter: date-range filtering,NewsItem→RecentNewsItemtranslation, 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/*.csto themutatelist.
Related docs¶
- Plain-language version: ../plain/news-price-context.md
- Job enrichment signals (the closest existing precedent for "historical fact, graceful degradation, never a guess"): job-signals.md
- Testing philosophy and tooling: testing.md