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 band —
Growth/Stable/Caution/Risk, orUnknownwhen 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, optionalsourceUrl, andasOfdate (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¶
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.
- 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. - Recent-news tone (
CompanyBriefing.RecentNews). Each headline is classified by the same deterministicNewsSentimentClassifierthe 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. - 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
JobPostingthe job already carries (populated byJobQueueServiceenrichment — 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 existingIsConfiguredenv-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¶
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)fromApiServicewhen the job loads, in the sameforkJoinas 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
Unknownread ("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 mockedIPriceHistoryProvider(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,LimitedDataset), 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.
Related docs¶
- Plain-language version: ../plain/company-health.md
- The event study this reuses (and whose honesty posture it mirrors): news-price-context.md
- Layoff/comp signal provenance and graceful-degradation precedent: job-signals.md, comp-signals.md
- Testing philosophy and tooling: testing.md