Skip to content

WorkWingman — Company Health (Technical)

Backward-looking summary of already-published facts — advisory only, not financial advice, and not a prediction. This feature reads a company's recent health at apply time so you can favour growing employers over ones showing recent contraction signals. Every input is a fact that has ALREADY happened — layoffs on official record, the tone of recent news, and (for public companies) how the stock has already moved over the trailing months. A read here never means a company will or won't lay you off; it summarizes what already happened. It mirrors the honesty posture of the news-and-price event study: if a claim would require predicting the future, it is omitted, not guessed, and nothing is ever fabricated — every rationale bullet carries its own source and as-of date.

It is advisory only. It surfaces a card on the job view; it never blocks or gates applying.

What it shows

For one employer, a compact card with:

  • A single signal bandGrowth / Stable / Caution / Risk, or Unknown when nothing is on record — coloured green→red.
  • A coarse 0-100 tier score (higher = healthier), offered only as a relative sort key across jobs. It is a blunt composite of already-happened facts, not a probability or a prediction — treat it like the band it maps to. Null when the signal is Unknown.
  • Cited rationale bullets, each a fact-only sentence with its own source, optional sourceUrl, and asOf date (a layoff of N employees on a date from SEC EDGAR; recent news leaning positive per GDELT; the stock's trailing ±X% move per Stooq).
  • A "limited data" marker when the read rests on fewer than all three factors (most often a private company / unresolvable ticker, so there's no price factor).
  • The disclaimer, verbatim, on every render.

Data model

CompanyHealth:

public sealed class CompanyHealth
{
    public string Company { get; set; }
    public string? Ticker { get; set; }                       // null for private/unresolved
    public CompanyHealthSignal Signal { get; set; }           // Growth|Stable|Caution|Risk|Unknown
    public int? Score { get; set; }                           // 0-100, null when Unknown
    public IReadOnlyList<CompanyHealthReason> Reasons { get; set; }
    public bool LimitedData { get; set; }
    public DateTimeOffset AsOf { get; set; }
    public string Disclaimer { get; set; }                    // repeated verbatim on every surface
}

public sealed class CompanyHealthReason
{
    public CompanyHealthFactor Factor { get; set; }           // Layoffs|News|PriceMomentum
    public CompanyHealthSignal Direction { get; set; }        // this reason's own lean
    public string Detail { get; set; }                        // fact-only sentence, no invented numbers
    public string Source { get; set; }                        // "SEC EDGAR", "WARN notice", "GDELT", "Stooq"
    public string SourceUrl { get; set; }                     // direct link when the source has one; else ""
    public DateOnly? AsOf { get; set; }                       // the date the fact is anchored to
}
Field Reliability
Signal / Score A BAND, not a measurement. Derived from a neutral 50 baseline plus bounded, signed per-factor adjustments; the composite is clamped 0-100 and mapped to a band. Unknown/null when no factor was present.
Reasons[].Detail Built only from facts on record — a real layoff count/percent, a real trailing-price percent. A layoff with no size on record says "an unspecified number", never a guessed count.
Reasons[].Source / SourceUrl / AsOf Straight from the underlying record (layoff event, briefing, or price series). Never fabricated.
LimitedData True when fewer than all three factors contributed (e.g. private company → no price).

The scoring model

CompanyHealthService is pure and deterministic given its one injected IPriceHistoryProvider and a TimeProvider clock — no caching, no side effects — so the scoring is fully testable against stubs. It starts at a neutral baseline of 50 and applies three bounded, signed factors; the composite is clamped to 0-100 and mapped to a band. A factor that has no data simply doesn't contribute (and flips LimitedData on) rather than being guessed at. If no factor is present, the result is Unknown with a null score — never a fabricated band.

  1. Layoffs (LayoffEvent[] from WARN / SEC EDGAR / layoffs.fyi — see job-signals.md). Negative pressure only. Each event's penalty is weighted by recency (linear fade from 1.0 today to 0 at an 18-month horizon; older layoffs are cited as context but exert no live pressure) and size (workforce percent preferred, else headcount, else a small honest floor for "a layoff happened but size unknown"). Up to −22 per event, aggregate capped at −40 so a long history can't drive the score arbitrarily negative.
  2. Recent-news tone (CompanyBriefing.RecentNews). Each headline is classified by the same deterministic NewsSentimentClassifier the news-and-price feature uses — a description of the article's own words, never a price call. The net of positive vs negative labels nudges ±4 each, capped at ±16 so news alone can't dominate.
  3. Trailing price momentum (public companies only). The already-realized percent change from the first available close in a trailing ~9-month window to the most recent one, fetched via the same free IPriceHistoryProvider (Stooq-backed) the news-and-price feature uses. A backward-looking FACT — never a forecast. Mapped to a bounded ±18 contribution (~±30% saturates the factor). Returns no contribution when price history doesn't cover the window (unresolvable ticker, Stooq closed behind its bot-challenge, no data) — the factor is simply absent, never a fabricated move.

Band thresholds over the 0-100 composite: Growth ≥ 65, Stable ≥ 45, Caution ≥ 30, else Risk.

Private / no-ticker companies get a read from layoffs + news alone, with LimitedData set to note the price factor was unavailable — never a faked price.

Sources & cost

No new data sources and no new paid keys. All three factors reuse data the app already fetched:

  • Layoffs and news come off the JobPosting the job already carries (populated by JobQueueService enrichment — layoffs from WARN/SEC EDGAR/layoffs.fyi, news via GDELT).
  • Price momentum uses the existing free IPriceHistoryProvider (Stooq primary; the config-gated, keyed Alpha Vantage/Barchart fallbacks stay behind their existing IsConfigured env-key gate — key absent → that source is simply skipped, free Stooq carries on). See news-price-context.md and comp-signals.md for the free-default / paid-behind-a-gate posture this follows.

API

JobsController:

GET /api/jobs/{jobId}/company-health      → CompanyHealth        [RequireLocalToken]

Loads the job (404 for an unknown id), then calls ICompanyHealthService.GetHealthAsync(job.Company, job.Ticker, job.LayoffHistory, job.Research). Token-gated (RequireLocalTokenAttribute) on the same sensitivity tier as the equity valuation: it aggregates several signals (including a stock read) into one blunt "Growth…Risk" band on the employer, so only the app itself should surface it — never a hostile local page over loopback. The Electron preload exposes the token and the Angular interceptor attaches it to every API call, so the gate needs no per-endpoint plumbing beyond the attribute.

Wiring

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

builder.Services.AddSingleton<ICompanyHealthService, CompanyHealthService>();

Frontend

The Company health card lives in the shared card set (frontend/src/app/shared/company-health-card/) and is composed onto the job-detail view's "Company & stability" section (frontend/src/app/features/job-detail/), the apply-time surface, alongside the existing company-research and layoff-history cards. It:

  • Loads getCompanyHealth(jobId) from ApiService when the job loads, in the same forkJoin as the other job-detail data.
  • Never blocks the page: the call is catchError-ed to null, so a token/API failure leaves the card simply absent while the rest of the page renders normally — advisory only, by construction.
  • Renders the signal band (coloured green→red), the tier score, each cited rationale bullet (source linked when a URL is present, with its as-of date), a "limited data" chip when applicable, and the disclaimer inline on every render.
  • Shows an honest empty state for an Unknown read ("not enough on record yet… the common, honest outcome, not an error") rather than dressing up an empty result as a band.

Tests

  • CompanyHealthServiceTests.cs — scoring against mocked IPriceHistoryProvider (NSubstitute) and faked inputs (Bogus), zero real network: growth (rising price + positive news), risk (recent large layoff + negative news + falling price), private/no-ticker (layoffs+news only, price provider never called, LimitedData set), no-data (Unknown + null score + empty reasons + disclaimer), public-but-price-unavailable graceful degradation, stale-layoff-is-context, sizeless-layoff-no-fabrication, a Bogus-fuzzed positive-only-inputs invariant, and band-threshold mapping.
  • company-health-card.spec.ts — band-to-colour mapping, cited reasons + disclaimer rendering, limited-data chip, and the honest Unknown empty state.
  • job-detail.spec.ts — the card loads into the stability section, and a failed health call never blocks the rest of the page.