Provider response contracts (FMP)
Tracking: Issue #332.
Purpose
The #328 failure
class is: the provider (Financial Modeling Prep, “FMP”) returns HTTP 200 with a
response body that silently renamed, dropped, or nulled a field a consumer reads —
marketCap → mktCap, exchangeShortName instead of exchange — and nothing in CI
catches it. A downstream screener or analyzer just goes quietly empty. pytest
against a hand-written fixture doesn’t help, because nobody notices the fixture
itself has drifted from what the provider actually returns.
This slice adds a recorded, versioned response contract per FMP endpoint: real
(sanitized) field names, types, and nullability, checked against both a fixture (in
CI, offline) and a live probe (weekly canary). It intentionally does not try to
lint arbitrary FMP responses generically — a single endpoint like
/stable/company-screener still returns exchangeShortName while /stable/profile
renamed the same concept to exchange (verified live 2026-09-05), so a repo-wide
static rule for legacy field names is not viable. Contracts are per endpoint.
File layout
config/provider-contracts/
fmp/
company-screener.v1.json
sp500-constituent.v1.json
profile.v1.json
quote.v1.json
historical-price-eod-full.v1.json
earnings-calendar.v1.json
income-statement.v1.json
ratios.v1.json
economic-calendar.v1.json
Location follows config/ci-test-policy.yaml’s convention: data lives under
config/, the code that reads it lives under scripts/ — see
scripts/provider_contracts.py (loader/validator, stdlib-only at import time) and
scripts/check_provider_contracts.py (the check / canary CLI).
Contract schema
{
"schema_version": 1,
"provider": "fmp",
"endpoint": "profile",
"path": "/stable/profile",
"query": {"symbol": "AAPL"},
"contract_version": 1,
"recorded_on": "2026-09-05",
"recording": "live /stable response; description/website/image/... removed",
"owners": ["earnings-trade-analyzer", "pead-screener", "..."],
"tier_notes": "served on free tier; comma-batched symbol returns []",
"required_fields": {
"symbol": {"types": ["str"], "nullable": false},
"marketCap": {"types": ["int", "float"], "nullable": true, "reject_all_null": true},
"exchange": {"types": ["str"], "nullable": false}
},
"optional_fields": ["beta", "lastDividend", "..."],
"legacy_aliases": {
"mktCap": {"canonical": "marketCap"},
"exchangeShortName": {"canonical": "exchange"}
},
"known_gaps": [],
"non_empty": {"min_rows": 1, "reason": "AAPL profile must never be empty"},
"fixture": [{"...sanitized real rows...": true}]
}
Field semantics:
required_fields— fields a consumer skill actually reads. Each entry declarestypes(JSON-ish type names:str,int,float,bool,list,dict) andnullable(whether the provider is known to legitimately returnnullfor this field, e.g.epsActualbefore an earnings report is filed). The optional booleanreject_all_null(defaultfalse) additionally rejects a non-empty response when every row explicitly hasnullfor that field. Individual nulls alongside a non-null value still follownullable; missing keys and malformed rows retain their existing fatal anomaly codes. Onlyprofile.marketCapopts in: the AAPL probe should have a populated market cap. Earnings fields such asepsActualmay legitimately be all-null before reporting and do not opt in. Non-boolean policy values fail both the offline fixture check and canary validation, including on empty responses.optional_fields— every other field observed live but not read by any in-scope consumer. Informational only; not validated.legacy_aliases— a field name the provider used to use (mktCap,exchangeShortName,lastDivon the v3 API) mapped to its current canonical name. This is exactly the #328 signature: if a row has the legacy key but not the canonical one, the provider silently reverted or the endpoint changed shape.known_gaps— a documented, accepted gap between what the provider used to return (v3) and what/stablereturns now, each tied to a follow-up issue number. Currently empty across all contracts; onlyearnings-calendaris allowed to carry entries here (it previously recorded #352 — thetimefield — untilincludeReportTimes=truerestored it, see below).non_empty—min_rowsis the floor below which an empty/Noneresponse is itself an anomaly (empty_response), not just a shape mismatch.fixture— real (sanitized) rows recorded live onrecorded_on. Rules enforced bycheck: at least one row; every row satisfiesrequired_fields; no row may contain alegacy_aliaseskey (a fixture records the current canonical stable shape, not the legacy one — the rename-regression unit tests construct the legacy-shaped row on the fly instead, seescripts/tests/test_provider_contracts.py).
Versioning rule: contract_version bumps only on a breaking change (a
required field removed, renamed, or its type narrowed/changed). Adding a new
optional_fields entry, widening a type set, or marking a field newly nullable is
non-breaking and does not bump the version. Adding a new required field that
consumers start reading (e.g. time after #352) is consumer-compatible and does
not bump the version; it tightens the canary only. If the addition needs a new
query parameter, record it in query so the canary requests the same shape.
Anomaly / severity table
Produced by validate_rows() in scripts/provider_contracts.py:
| Code | Severity | Meaning |
|---|---|---|
empty_response |
fatal | [] / None where non_empty.min_rows >= 1 |
not_a_list |
fatal | the response body itself is not a list |
row_not_object |
fatal | a list entry is not a JSON object |
missing_required_field:<f> |
fatal | required key absent from a row, and no legacy alias is present either |
null_required_field:<f> |
fatal | required key is null and nullable: false |
all_null_required_field:<f> |
fatal | every row explicitly has null for a field with reject_all_null: true |
invalid_contract_rule:<f>:reject_all_null |
fatal | the opt-in policy value is not a JSON boolean |
wrong_type:<f>:<got> |
fatal | required key’s value type is outside its declared types |
canonical_absent_legacy_present:<legacy>-><canonical> |
fatal | the #328 signature — the legacy field name showed up instead of the canonical one |
legacy_alias_present:<legacy> |
deprecation (non-fatal) | both the legacy and canonical keys are present; informational only |
A contract result is ok = True when it has zero fatal anomalies (a
deprecation-only result is still ok).
Economic calendar contract (#332)
economic-calendar.v1.json records three unmodified public macro-event rows
from a 2026-10-17 HTTP 200 response (568 rows) for 2026-10-01 through 2026-10-07.
The probe uses the singular /stable/economic-calendar endpoint and that known
populated historical window. It joins the existing scheduled canary automatically,
adding one request to its default budget (now nine contracts). Entitlement is not
inferred from a successful recording credential: HTTP 402 remains a failure.
The owning standalone consumer checks every row before reporting or opening an
output file. It requires date/country/event/currency/impact strings and the five
nullable numeric readings (previous/estimate/actual/change/changePercentage).
An absent key is not equivalent to null. All-null readings are allowed; unit
and unknown additional fields are preserved. Consumer validation also rejects
blank strings, unparsable YYYY-MM-DD HH:MM:SS timestamps, booleans and nonfinite
numeric values. It does not constrain the impact string to High/Medium/Low:
the recording includes the valid string "None".
| CLI reason | Exit | Meaning |
|---|---|---|
economic_calendar_schema_drift |
1 | Malformed event rows, missing/renamed/retyped fields, invalid dates/numbers, or the generated client’s explicit non-array JSON shape error |
calendar_fetch_failed |
1 | HTTP/tier/network failure, or a generic generated-client acquisition failure (including HTTP-200 null and invalid JSON after its existing bounded retry) |
empty_calendar_unverified |
1 | Clean empty array; no trusted completeness classifier proves this is a quiet calendar |
These failures preserve existing output files and do not write a successful
empty report. The low-level Python fetch still returns a validated empty list,
allowing callers with an independent completeness policy to decide what to do.
The canary’s min_rows: 1 applies to its populated historical probe, not to every
arbitrary query window. No global economic-calendar holiday rule is assumed.
This slice does not promote the existing report-only canary, configure secrets, or complete #332’s remaining provider/endpoint inventory.
Consumer zero-result reason codes
The anomaly table above is what the contract layer itself detects (fixture vs.
live, or live vs. contract in the canary). Two consumer skills go further: when
their own candidate-selection logic empties out, each prints a machine-readable
ZERO_RESULT_REASON=<code> line to stderr (one line, exactly that format) before
exiting, so a CI/scheduler consumer can tell “an ordinary quiet day” apart from
“the provider response shape drifted” without parsing prose. Each code maps to a
fixed exit code (and, for pead-screener, a log level); an unrecognized/unknown
reason falls back to exit 1 in both scripts.
earnings-trade-analyzer (analyze_earnings_trades.py, _ZERO_RESULT_MESSAGES;
falsy/non-list calendar bodies evaluated first in classify_empty_calendar;
profiles_budget_exhausted is also emitted directly from the
except ApiCallBudgetExceeded around the profile fetch in main();
the rest in explain_empty_selection in fixed order, first match wins):
| Code | Condition | Exit |
|---|---|---|
no_earnings_rows |
the calendar returned [] for an inclusive date window containing zero XNYS sessions, or a non-empty response had no row carrying a symbol |
0 |
calendar_fetch_failed |
the calendar fetch returned no usable body (None on transport/HTTP/rate-limit failure, or a non-list body) |
1 |
earnings_calendar_empty_with_market_sessions |
the calendar returned a clean [], but the identical inclusive date window sent to FMP contains at least one XNYS session; possible silent provider drop |
1 |
market_calendar_unavailable |
the shared XNYS calendar could not classify a clean []; dependency/query failure is not treated as a quiet window |
1 |
profiles_budget_exhausted |
profile fetching raised ApiCallBudgetExceeded (including after partial retrieval), or profiles came back empty and the budget is exhausted (api_stats["budget_remaining"] == 0 or rate_limit_reached) |
1 |
no_profiles_returned |
≥1 earnings symbol, but get_company_profiles returned nothing |
1 |
profiles_missing_required_field:marketCap |
≥1 profile came back, but none has a usable numeric marketCap (non-bool int/float, finite) |
1 |
profiles_missing_required_field:exchange |
≥1 usable market cap exists, but none of those profiles has a string exchange |
1 |
all_below_market_cap_floor |
every usable profile’s market cap is below --min-market-cap |
0 |
all_non_us_exchange |
every usable profile’s exchange is a non-US exchange (not in FMPClient.US_EXCHANGES) |
0 |
mixed_filters_rejected_all |
usable profiles exist and were all rejected, but by a mix of the cap-floor and exchange filters rather than one uniform cause — treated as an ordinary empty day, not schema drift | 0 |
(fallback) unknown / any other reason not in the table |
none of the above matched | 1 |
For earnings-trade-analyzer, profiles_budget_exhausted exits 1: a daily 429
limit or an aborted profile fetch leaves an incomplete profile set (possibly
none at all), so no scored report can be produced — that is not a benign empty
day.
pead-screener (screen_pead.py, _ZERO_RESULT_REASONS / _get_candidates_mode_a
_get_candidates_mode_b, both return(candidates, reason)withreason is Nonewhencandidatesis non-empty):
| Code | Condition | Exit | Level |
|---|---|---|---|
no_earnings_rows |
Mode A: the FMP earnings calendar returned no rows (or no rows carried a symbol) for the lookback window |
0 | WARNING |
calendar_fetch_failed |
Mode A: the calendar fetch returned no usable body (None on transport/HTTP/rate-limit failure, or a non-list body); non-dict rows are ignored, never dereferenced |
1 | ERROR |
profiles_budget_exhausted |
Mode A: profile fetching raised ApiCallBudgetExceeded (including after partial retrieval), or profiles came back empty and the budget is exhausted (budget_remaining == 0 or rate_limit_reached) |
1 | ERROR |
no_profiles_returned |
Mode A: ≥1 symbol, but get_company_profiles returned nothing and the budget is not exhausted |
1 | ERROR |
profiles_missing_required_field:marketCap |
Mode A: no candidate passed the cap filter and no profile has a coercible marketCap or mktCap — see the value-based nuance below |
1 | ERROR |
all_below_market_cap_floor |
Mode A: at least one profile has a usable market cap, but every candidate’s cap is below --min-market-cap |
0 | INFO |
no_input_candidates |
Mode B: no record in the input JSON met the --min-grade filter |
0 | INFO |
(fallback) unknown / any other reason not in the table |
none of the above matched | 1 | ERROR |
pead-screener’s marketCap check is value-based, not just key-presence.
_coerce_market_cap() treats a present-but-null (or non-numeric, or
non-finite) marketCap/mktCap as “no usable cap” — the same as the key being
absent entirely. So a lookback window where every returned profile has
"marketCap": null (key present, value null) is indistinguishable from one
where the key is missing outright: both set any_usable_cap = False for every
symbol and exit 1 with profiles_missing_required_field:marketCap, on the
assumption that a batch of all-null market caps is more likely provider drift
than 100% coincidental missing fundamentals data. Contrast this with
profile_market_cap() (used only once a profile has already passed that
value-based usability check), which collapses a missing/null/non-numeric cap to
0.0 so the plain < floor comparison never raises — that fallback is for a
single bad profile among otherwise-usable ones, not the “everything is null”
signal above.
Ownership table (this slice)
| Endpoint | Owners (skills that call it) |
|---|---|
profile |
earnings-trade-analyzer, pead-screener, ibd-distribution-day-monitor, parabolic-short-trade-planner, canslim-screener, us-undervalued-growth-screener |
quote |
vcp-screener, parabolic-short-trade-planner, ftd-detector, canslim-screener, market-top-detector, us-undervalued-growth-screener |
historical-price-eod-full |
all 10 generated clients: pead-screener, earnings-trade-analyzer, ibd-distribution-day-monitor, vcp-screener, parabolic-short-trade-planner, ftd-detector, canslim-screener, macro-regime-detector, market-top-detector, us-undervalued-growth-screener |
earnings-calendar |
pead-screener, earnings-trade-analyzer, ibd-distribution-day-monitor |
sp500-constituent |
vcp-screener, parabolic-short-trade-planner |
income-statement |
canslim-screener, dividend-growth-pullback-screener, value-dividend-screener — ad-hoc consumers (their own inline FMP fetch layer) |
ratios |
value-dividend-screener — ad-hoc consumer |
company-screener (slice 2a) |
dividend-growth-pullback-screener, downtrend-duration-analyzer, institutional-flow-tracker, pair-trade-screener, stockbee-20pct-study, stockbee-exhaustion-hammer-screener, stockbee-momentum-burst-screener, us-undervalued-growth-screener, value-dividend-screener — note these are ad-hoc consumers, not generated clients: eight scripts carry their own inline FMP fetch layer and only us-undervalued-growth-screener rides the generated garp client (get_company_screener) |
income-statement (this slice) |
canslim-screener, dividend-growth-pullback-screener, value-dividend-screener — ad-hoc consumers (their own inline FMP fetch layer) |
ratios (this slice) |
value-dividend-screener — ad-hoc consumer |
Owners are validated against skills-index.yaml by check — an owner naming a
skill directory that does not exist there is a validation error.
company-screener field-usage inventory (slice 2a prerequisite)
/stable/company-screener (legacy alias for callers: v3 /stock-screener,
which returns the same fields for legacy keys) is read by 9 call sites — one
per skill, no shared helper — across 9 skills. Recorded below is what each
consumer actually reads from the
response rows, per grep-verified call sites:
| Skill | Fields read from screener rows |
|---|---|
| dividend-growth-pullback-screener | symbol, price, companyName, marketCap |
| downtrend-duration-analyzer | symbol, sector, marketCap |
| institutional-flow-tracker | symbol, companyName, isEtf, isFund, isActivelyTrading, sector |
| pair-trade-screener | symbol, companyName, marketCap, sector, exchangeShortName, isActivelyTrading |
| stockbee-20pct-study | symbol, exchangeShortName, type (defaults to "stock" — a key /stable rows do not carry), price, isEtf, isFund |
| stockbee-exhaustion-hammer-screener | symbol, isEtf, isFund, marketCap (defensive fallback mktCap) |
| stockbee-momentum-burst-screener | symbol, isEtf, isFund, marketCap (defensive fallback mktCap) |
| us-undervalued-growth-screener | symbol, companyName, exchange OR exchangeShortName, sector, industry, price, marketCap, volume, isEtf, isFund, isActivelyTrading (client pins the three flags server-side; normalize_listing re-checks them client-side) |
| value-dividend-screener | symbol, price, lastAnnualDividend, marketCap, isEtf, isFund |
Two semantic notes the contract encodes:
exchangevsexchangeShortName. On/stable,exchangecarries the full venue name ("NASDAQ Global Select") andexchangeShortNamethe exchange code ("NASDAQ"). They are different concepts, not aliases — the contract marksexchangeoptional andexchangeShortNamerequired, and does NOT map one to the other.typeis absent on/stablerows. Only stockbee-20pct-study reads it, and only with a default, so it is left out ofrequired_fields(and the fixture records what the provider actually returned).
This inventory is slice 2a’s deliverable: it is what makes the company-screener
contract’s required_fields auditable, and it is what slice 2b’s consumer
zero-result reason codes must reuse.
Session-aware empty suspicion
The earnings-trade-analyzer is a generated consumer of the shared XNYS
calendar. For a clean HTTP 200 [], it counts sessions over the exact same
inclusive from / to dates sent to FMP. Zero sessions remains the benign
no_earnings_rows result. One or more sessions is a fail-closed
earnings_calendar_empty_with_market_sessions anomaly, while a calendar
dependency or query failure is market_calendar_unavailable. This avoids the
exchange-holiday false positives of a weekday approximation and keeps the
standalone .skill install complete through the generated runtime and pinned
requirement. Non-empty lists whose rows carry no symbol retain the pre-#356
no_earnings_rows behavior; session suspicion applies only to the provider’s
literal empty list.
Follow-up issue: earnings-calendar time field — resolved (2026-09-06)
Previously (verified live 2026-09-05): /stable/earnings-calendar with no query
parameters did not return a time field, unlike the legacy v3 earning_calendar
endpoint (bmo / amc — before/after market open/close), which
earnings-trade-analyzer and pead-screener used for timing logic.
Verified live 2026-09-06: the field is not gone — it is gated behind the
includeReportTimes=true query parameter (the value must be the literal string
"true" or "false"; any other value, including a JSON boolean, is HTTP 400).
With that parameter, /stable/earnings-calendar returns time as "bmo",
"amc", or null, plus periodEnding, fiscalPeriod, fiscalYear, and
confirmed. About one third of rows on a typical trading day still carry
null (the provider has not confirmed a session for that report), so
unknown remains a real, expected outcome downstream — it is now backed by an
explicit null from the provider rather than an always-missing key.
Issue #352 is
resolved: the client template requests includeReportTimes=true,
earnings-calendar.v1.json requires time (nullable) with an empty
known_gaps, and earnings-trade-analyzer / pead-screener report a
timing_unknown_count alongside their results so the residual null rate is
visible rather than silently assumed away.
Manual fixture refresh procedure
There is no record subcommand in slice 1 (kept out for coverage + scope reasons).
To refresh a fixture by hand:
# Query-param auth (consistent with the canary — the FMP key is a query
# parameter, never a header, on some plans/endpoints).
curl --get "https://financialmodelingprep.com/stable/profile" \
--data-urlencode "symbol=AAPL" \
--data-urlencode "apikey=$FMP_API_KEY" \
| jq 'if type == "array" then map(del(.description, .website, .image, .ceo, .phone, .address, .city, .state, .zip)) else . end'
For earnings-calendar, the includeReportTimes=true query parameter is
required to get the time field at all (see above):
curl --get "https://financialmodelingprep.com/stable/earnings-calendar" \
--data-urlencode "from=2026-09-04" \
--data-urlencode "to=2026-09-04" \
--data-urlencode "includeReportTimes=true" \
--data-urlencode "apikey=$FMP_API_KEY"
For company-screener, replay the contract’s recorded query verbatim:
curl --get "https://financialmodelingprep.com/stable/company-screener" \
--data-urlencode "marketCapMoreThan=200000000000" \
--data-urlencode "exchange=NASDAQ,NYSE" \
--data-urlencode "limit=5" \
--data-urlencode "apikey=$FMP_API_KEY"
On refresh, re-judge two required fields before lowering their guard rule:
sector (nullable:false rests on the US-listed universe; FMP may legitimately
null it for odd listings) and lastAnnualDividend (non-payers are observed to
emit 0, not null — a first-ever null should flip it to nullable:true with
a re-recorded fixture). See company-screener.v1.json’s tier_notes for the
same triggers.
Sanitization checklist before pasting the result into a fixture array:
- Strip free-text / PII-adjacent fields:
description,website,image,ceo,phone,address,city,state,zip. - Never include the
apikeyquery value or any header in the recordedquery. - Re-diff the new fixture’s keys against
required_fields+legacy_aliases+optional_fields— a genuinely new field goes tooptional_fields(non-breaking); a removed/renamed/retyped required field is a breaking change and must bumpcontract_version. - Update
recorded_onto the actual capture date.
Canary: schedule, report shape, promotion criteria
.github/workflows/fmp-contract-canary.yml runs
python3 scripts/check_provider_contracts.py canary weekly (Monday 12:00 UTC) plus
on workflow_dispatch. Drift is report-only (the canary job has
continue-on-error: true, mirroring the packaged-deps-nightly / #349 pattern): a
live anomaly does not fail CI on its own today. It is still visible: a summarize step
(if: always()) writes a per-contract table to the run summary and emits ::error /
::warning annotations (capped at 10 per severity, overflow aggregated as +N more).
The workflow has two jobs and three visible states, so “not configured” can never look like a pass:
| State | Condition | preflight result |
|---|---|---|
| configured | secrets.FMP_API_KEY is set |
passes; canary probes |
| disabled | secret unset and repository variable FMP_CANARY_ENABLED is false or 0 (any case) |
passes with a ::notice; canary is skipped |
| not configured | secret unset, no opt-out | fails with ::error and a step-summary how-to; the run turns red |
Setup: add a repository secret FMP_API_KEY (a dedicated low-quota key is
recommended), or opt out explicitly with the repository variable
FMP_CANARY_ENABLED=false. preflight carries a repository guard
(github.repository == 'tradermonty/claude-trading-skills') so forks with schedules
enabled are not red every Monday; update the slug if the repository is renamed or
transferred. If preflight is skipped or fails, canary does not run.
Report (reports/fmp-canary-report.json in CI, uploaded as an artifact for 30
days; default local path reports/fmp_canary_<YYYY-MM-DD>.json, override with
--report):
{
"generated_at": "2026-09-05T12:00:00+00:00",
"budget": {"max": 5, "used": 5},
"ok": true,
"contracts": {
"profile": {
"status": 200,
"rows": 1,
"anomalies": [],
"deprecations": [],
"ok": true
}
}
}
Top-level budget.max is the effective --max-calls ceiling (default: the number
of loaded contracts), budget.used is the number of live fetch calls actually made
(one per contract), and top-level ok is true only when every contract’s own ok
is true.
The key is sent only as a requests params= value, never string-formatted into a
URL. scripts.provider_contracts.redact_url() is applied to the report path printed
to stdout and to transport-level error strings (a
requests.exceptions.RequestException message can embed the full request URL,
apikey included) before anything is written to the report or printed to
stdout/stderr — so a captured exception never leaks the key either.
Promotion criteria (making the canary fail-closed on a schedule, or gating a PR on it) is explicitly deferred — see “Out of scope” below and the plan’s resolved review notes. Promote only after a period of the canary running green (or with well-understood/acceptable anomalies) with no false positives.
Generated client stderr redaction (#357)
The redaction above covers only the canary CLI. All 10 vendored fmp_client.py
files (rendered from scripts/fmp_client/core_template.py.tmpl and the four
scripts/fmp_client/specials/*.py.tmpl) mask apikey=/api_key= at every site
that can echo provider text. The nine clients rendered from
core_template.py.tmpl, canslim.py.tmpl, macro.py.tmpl, and
market_top.py.tmpl mask a non-200 HTTP response body and a
requests.exceptions.RequestException message before storing it in
self._last_error (the six core-family clients only) and before printing it
to stderr. The GARP client (us-undervalued-growth-screener) never reads
response.text on a non-200 response — its non-200 WARN lines only
interpolate the request url, which never carries the key since the client
injects apikey into the request params dict rather than the url string
— so it instead masks its request-exception {exc} interpolation and, for
future-proofing, the url interpolation in every other WARN line, even
though none of them can carry the key today. Because generated clients are
vendored standalone (packaged without scripts/), they cannot import
scripts.provider_contracts.redact_url(); each template instead carries a
verbatim, module-level mirror (_APIKEY_RE + _redact_key()) with a comment
naming that function as the source of truth. scripts/tests/test_generate_fmp_client.py
asserts the regex literal is identical across all five templates’ rendered
output, so the five copies cannot silently drift apart, and
scripts/tests/test_fmp_client_redaction.py exercises all 10 vendored clients
at runtime to confirm a fake key never reaches stdout or stderr.
FMP endpoint inventory (/stable/... paths found in skills/*/scripts)
Discovered via grep -rhoE '/stable/[A-Za-z0-9_/-]+' skills/*/scripts. /stable/x
and /stable/some-bulk-endpoint are test-fixture placeholder strings, not real
endpoints, and are excluded.
/stable/... path |
Recorded contract | Owners (grep-discovered) | Deferred to |
|---|---|---|---|
profile |
✅ profile.v1.json |
see ownership table above | — |
quote |
✅ quote.v1.json |
see ownership table above | — |
historical-price-eod/full |
✅ historical-price-eod-full.v1.json |
see ownership table above | — |
earnings-calendar (v3 alias: earning_calendar) |
✅ earnings-calendar.v1.json |
pead-screener, earnings-trade-analyzer, ibd-distribution-day-monitor call get_earnings_calendar; vcp-screener/parabolic-short-trade-planner vendor the same v3→stable compat rename but do not call it |
— |
company-screener |
✅ company-screener.v1.json |
dividend-growth-pullback-screener, downtrend-duration-analyzer, institutional-flow-tracker, pair-trade-screener, stockbee-20pct-study, stockbee-exhaustion-hammer-screener, stockbee-momentum-burst-screener, us-undervalued-growth-screener, value-dividend-screener | consumer reason codes → slice 2b |
sp500-constituent (v3 alias: sp500_constituent) |
✅ sp500-constituent.v1.json |
vcp-screener, parabolic-short-trade-planner call get_sp500_constituents; pead-screener/earnings-trade-analyzer/ibd-distribution-day-monitor vendor the compat rename but do not call it |
covered |
income-statement |
✅ income-statement.v1.json |
canslim-screener, dividend-growth-pullback-screener, value-dividend-screener — ad-hoc consumers (their own inline FMP fetch layer) | — |
ratios |
✅ ratios.v1.json |
value-dividend-screener — ad-hoc consumer | — |
profile-bulk |
❌ | parabolic-short-trade-planner | slice 2 |
aftermarket-quote |
❌ | parabolic-short-trade-planner | slice 2 |
institutional-ownership/symbol-positions-summary |
❌ | canslim-screener | slice 2 |
etf/holdings |
❌ | theme-detector | slice 2 |
economic-calendar |
✅ | economic-calendar-fetcher | recorded contract + consumer validation; #332 economic-calendar slice |
historical-price-eod/light |
❌ | news-reaction-failure-analyzer | slice 2 |
commitment-of-traders-report |
❌ | cot-contrarian-detector | slice 2 |
This table defines slice 2’s completion criteria: every row marked ❌ needs its own
contract file before slice 2 can close. company-screener (✅ as of slice 2a)
also carries the field-usage inventory across its nine ad-hoc consumers, and the
headings above now name the two remaining deferred pieces for that endpoint:
consumer zero-result reason codes (slice 2b) and the remaining ❌ rows (each its
own endpoint contract; several need dynamic-date or weekly-report recording
windows for the canary query, e.g. economic-calendar / commitment-of-traders-report).
All-null detection follow-up (#332)
The profile contract now detects all-null marketCap responses in the offline
fixture gate and the canary report, while preserving per-row nullability. This
adds a probe policy without changing the recorded provider shape, fixture capture
date, or contract version. Tests use synthetic mutations of the recorded fixture;
this follow-up does not claim a new live capture or live canary run.
income-statement + ratios contract notes (#332 slice)
Authenticated /stable/income-statement (query symbol=JNJ, limit=5) and
/stable/ratios (query symbol=JNJ, limit=3) probes recorded 2026-10-03 and
sanitized to the fields the consumers read. Both endpoints return a flat list of
period records (one dict per reporting period), so the contract fixture is a
flat list of record dicts and non_empty.min_rows=1 applies.
income-statementrequired_fields:symbol/datenon-nullable (str),revenuenon-nullable (number);netIncomeandepsnullable because a given period can legitimately report null for one, andcanslim-screenerfalls back toepsdiluted.dividendsPaidis intentionally NOT on this contract — it is a cash-flow-statement field (exposed via thenetDividendsPaidstabilize shim in the ad-hoc consumers), and listing it here would FATALmissing_required_field.ratiosrequired_fields:symbol/datenon-nullable;priceToEarningsRatioandpriceToBookRatioare nullable withreject_all_nullso a probe losing both is flagged while an individual null (negative-EPS / zero-book-value name) is not — matching the consumer’s ownif pe is None or pb is None: continueguard.returnOnEquityetc. are optional (observed null on the recorded fixture).
Issue #332 remains open: non-FMP providers, the remaining FMP endpoint contracts,
other consumer reason codes, and canary promotion remain outstanding. The
scheduled canary remains report-only. Slice 2a (company-screener contract +
its field-usage inventory) has landed; slice 2b is the consumer reason codes for
the nine company-screener consumers.
Out of scope (this slice)
- Non-FMP providers.
- Every
/stableendpoint above marked ❌ in the “Covered in slice 1” column. - Zero-result reason codes for the
company-screenerconsumers (slice 2b — the inventory above is 2b’s prerequisite and was delivered in 2a). - Zero-result reason codes for consumers other than earnings-trade-analyzer and pead-screener (outside the slice-1 scope note below).
- Fixing the
earnings-calendartimegap (#352) — only documented here. - Promoting the canary to fail-closed.
skills-index.yamlschema changes.- A separate, smaller, pre-existing gap (not touched by this PR):
us-undervalued-growth-screeneris missing from theci.yml/ pre-commit FMP package-drift skill lists.
CLI reference
# Offline CI gate — validates every contract file + fixture, checks owners
# against skills-index.yaml. Zero network. Exit 1 on any error.
python3 scripts/check_provider_contracts.py check
# Live probe — needs FMP_API_KEY. Makes one GET per contract (query-param
# auth), scores the response, writes a JSON report, exits 1 if any contract
# is not ok.
python3 scripts/check_provider_contracts.py canary [--max-calls N] [--report PATH]
# Render a canary report as markdown (stdout, or appended to --summary-file such as
# $GITHUB_STEP_SUMMARY). --github also prints ::error / ::warning annotations to
# stdout. Stdlib only. Exits 0 for missing/malformed reports (report problems never gate);
# argument or write errors exit non-zero.
python3 scripts/check_provider_contracts.py summarize --report PATH [--summary-file PATH] [--github]
S&P 500 constituent recording and consumer boundary
The 2026-10-03 authenticated /stable/sp500-constituent probe returned HTTP
200 and 504 rows. The contract retains the first five public company records,
without credentials or request URLs. The key’s subscription tier was not
established; this recording does not demonstrate free-tier availability.
Membership count is variable, so the contract requires only one or more rows.
Both owners consume symbol for universe membership. VCP also reads name and
sector for report labels. Although VCP defaults missing labels at runtime,
these three fields are required non-null strings in the raw provider contract
to detect label degradation. subSector, headQuarter, dateFirstAdded,
cik, and founded are optional; their fixture values are retained for context.
The generated clients retain their public CSV fallback when FMP is unavailable
or empty, including symbol dot-to-hyphen normalization and caching. Offline
tests cover both owners’ clients with the recorded response and simulated tier
denials; these denied statuses are test scenarios, not live tier observations.
The raw canary has no CSV fallback: it requires HTTP 200 as well as valid rows.
Other statuses produce a fatal http_status:<status> anomaly even if their
payload matches the schema; HTTP 200 with no rows retains empty_response.
The existing weekly report-only workflow discovers all loaded contracts and makes
one request per contract with its dynamically computed call budget.
This endpoint completes another part of Issue #332. Other uncovered endpoints in the inventory, cross-provider contracts, and production consumer reason codes remain separate work; this change does not close the Issue.