Issue #332 slice: public CSV provider contracts (trader-monty)
Selection and isolation
Issue #332 is the only open P0. Its 2026-10-10 progress audit lists “non-FMP providers have no contracts yet” as remaining work. No open PR or branch covers it. The open PRs are #587 (#495 Batch C), #585 (external) and the dependabot PRs #577 and #578.
Rejected alternatives:
- #495 Batch D would edit the same generator registry and gate lists as the open #587, so the two would conflict.
- #496, #297 and #298 each already have merged slices.
New worktree cts-wt-332-csv, branch
codex/issue332-public-csv-contracts-20261024, based on main 52c9323.
#332 stays open.
Why these providers first: the four trader-monty public CSVs feed
market-breadth-analyzer and uptrend-analyzer. Those are two of the three
required skills in the canonical market-regime-daily workflow. They need no
API key, so their live canary actually runs. That closes the “canary never
probed” gap for this provider without a repository secret.
Scope decision: this is one PR covering the framework, the four contracts, the canary, the workflow, and consumer hardening for the two market-regime-daily skills. The consumer part is kept because “consumer tests fail when a field is renamed” is a #332 acceptance criterion, and it is small (two fetcher modules). If code review finds the diff unreviewable, step 5 is split out.
Current state (verified on main 52c9323; live CSVs read 2026-10-24)
| Endpoint (proposed key) | URL | Size today | Owners (all readers) |
|---|---|---|---|
market-breadth-data |
tradermonty.github.io/market-breadth-analysis/market_breadth_data.csv |
380 KB, 2,512 rows | market-breadth-analyzer (csv_client.py:22), market-top-detector (breadth_csv_client.py:20), breadth-chart-analyst (fetch_breadth_csv.py:25) |
market-breadth-summary |
…/market_breadth_summary.csv (Metric,Value) |
small | market-breadth-analyzer (csv_client.py:24). Reads Average Peaks (200MA) and Average Troughs (8MA < 0.4) (market_breadth_analyzer.py:314, 328; historical_context_calculator.py:55-56). Value mixes int, float and date strings. |
uptrend-ratio-timeseries |
raw.githubusercontent.com/tradermonty/uptrend-dashboard/main/data/uptrend_ratio_timeseries.csv |
2.81 MB, 31,948 rows, ~14 KB/trading day growth | uptrend-analyzer (data_fetcher.py:20), sector-analyst (analyze_sector_rotation.py:38), breadth-chart-analyst (fetch_breadth_csv.py:28), theme-detector (uptrend_client.py:30) |
uptrend-sector-summary |
…/data/sector_summary.csv |
small | uptrend-analyzer (data_fetcher.py:24), sector-analyst, theme-detector, breadth-chart-analyst |
Live data facts:
- In the timeseries,
ma_10is empty in 1,431 rows andslopein 1,590. - Every
ratiovalue is populated. - No CSV contains
nan. - Booleans are written as
True/False.
Silent-drift behavior today:
- uptrend-analyzer.
_parse_timeseries_rowand_parse_sector_summary_row(data_fetcher.py:185-214) read cells withrow.getand_safe_float. A renamed column therefore becomesNoneeverywhere. Rows are still returned, andmainproceeds (uptrend_analyzer.py:78-95). - market-breadth-analyzer.
fetch_detail_csv(csv_client.py:43-80) emits one WARN per line, skipping every row. It then printsOK (0 rows), andmainexits 1 with a generic message (market_breadth_analyzer.py:94-97). The schema cause is buried. - Summary metrics. When a summary metric is missing, the code falls back silently.
Framework facts:
- Validator.
scripts/provider_contracts.pyis provider-agnostic: it has aproviderfield and a globally uniqueendpoint.validate_rowsemits one anomaly per row (:287-316). - Canary. The canary in
scripts/check_provider_contracts.pyis FMP-only:- the URL is built at
:110; - the key is required at
:183-186; - the summary titles are hard-coded at
:218-221,:269and:275; - the default report path is
fmp_canary_<date>.json(:155-156).
- the URL is built at
- Existing tests that pin the FMP set and fetch signature:
test_provider_contracts.py:56-66holds the exact contract set.- The
len(CONTRACTS)budget assertions are at:673, :700, :742, :939. - The direct
run_canarycallers use(path, query)at:977, :986, :1173. - The stubbed
_build_requests_fetchis at:650, :688, :707, :735, :926. test_provider_contracts_summary.py:118asserts the FMP title.
- Workflow tests.
scripts/tests/test_fmp_canary_workflow.pylints the FMP canary workflow.scripts/tests/test_ci_supply_chain_wiring.py:16-20enforces the locked environment per workflow. - Replay. Workflow replay never calls these fetchers. The skill
maintests mockfetch_detail_csv/fetch_timeseriesat module level.
Implementation plan
- Contract schema extension (additive; FMP contracts unchanged).
format:"json"(default) or"csv".validate_contract_filerejects any other value.url: required for csv, forbidden for json. Rules:- the scheme must be
https; - the host must be in the allowlist
{tradermonty.github.io, raw.githubusercontent.com}; - there is no query string, and
querymust be empty; pathequals the URL path for display.
- the scheme must be
max_bytes: optional for csv, a positive int, default 2 MiB. The timeseries sets 16 MiB, which leaves room for years of growth.required_values:{column: [values…]}. Each value must appear in at least one row. If not, the result is the new fatalmissing_required_value:<column>=<value>.parse_csv_rows(text) -> (rows, parse_anomalies)(stdlibcsv). On anextra_cellsrow the restkey cells are dropped, so a row keeps only the header keys and noNonekey ever reachesvalidate_rowsor the report:- strips a UTF-8 BOM and reads the header;
- an empty cell becomes
None; true/falsein any case becomesbool, the same as the breadth consumer;- an integer literal becomes
int; - a decimal or exponent literal becomes
float; - anything else stays
str, including dates,nanandinf.
nan/inftherefore givewrong_type:…:strfor numeric columns. This is intentional and documented: the breadth consumer rejects them, and live data has none.Short rows are handled: DictReader’s restval
Nonemaps toNone. An extra cell, which DictReader puts in its restkey list, is reported as the fatalextra_cells.A header-only CSV gives
[], which yieldsempty_responsewhenmin_rows >= 1.CSV fixtures are stored as already-coerced JSON rows. Fixture and live text go through the same coercion.
- Type declarations follow what consumers accept.
- Every float-parsed column declares
["float","int"], becausefloat("1")is valid. ["int"]applies only where the consumer usesint()directly:Breadth_200MA_Trend.countandtotalare["int","float"], because_safe_intisint(float(v)).- Bool columns are
["bool"], which is deliberately stricter than any one consumer. market-breadth-analyzer accepts1/0/yes/no, while market-top-detector reads every other value as False, so any other spelling is fatal drift (decided in code review round 2). - Summary
Valueis["int","float","str"].
- Every float-parsed column declares
- Anomaly dedupe.
RowValidationgainssummarized(), which returns the unique(code, severity)pairs with acount, in first-seen order.validate_rowskeeps its per-row output, so existing FMP callers and tests are unchanged. The canary uses only the summarized form.
- Four contracts under
config/provider-contracts/trader-monty/*.v1.json.- Each has
provider: "trader-monty", matching the provenance provider string atmarket_breadth_analyzer.py:102anduptrend_analyzer.py:99. - Each has the full owner lists from the table above.
- Each has a sanitized real-row fixture of 5-10 rows. The timeseries
fixture includes rows with empty
ma_10/slope. non_empty.min_rowsis set.required_fieldscovers exactly the columns the owners read:- breadth data: the ten
DETAIL_COLUMNS; - timeseries:
worksheet,date,count,total,ratio(withreject_all_null),ma_10(nullable) andslope(nullable).trendis nullable in case it is empty. - sector summary:
Sector,Ratio,10MA,Trend,SlopeandStatus; - breadth summary:
MetricandValue, plusrequired_valuesfor the two metrics.
- breadth data: the ten
- Unread columns, such as the
Breadth_50_*family, go inoptional_fields. - breadth-chart-analyst tolerates alternate column names at
fetch_breadth_csv.py:176-184:SP500_Price,Breadth_200MA,Breadth_8MA, andTrendforBreadth_200MA_Trend. They are recorded aslegacy_aliasesof the canonical names. The docs describe them as consumer-tolerated alternates, not provider history. A provider rename to one of them is fatal (canonical_absent_legacy_present), which is correct, because market-breadth-analyzer and market-top-detector would break.
- Each has
- Provider-aware canary (FMP path unchanged by default).
canary --provider {fmp,trader-monty,all}, defaultfmp. Contracts are filtered by provider before the budget is computed.--max-callsdefaults to the selected count.FMP_API_KEYis required only when FMP contracts are selected._build_requests_fetch(api_key) -> fetch(path, query)stays exactly as it is.cmd_canarystill calls it, so the existing monkeypatches at:650, :688, :707, :735, :926keep working.run_canary(contracts, fetch)dispatches on the type offetch:- A dict.
fetchis adict[str, Callable[[Contract], FetchResult]]keyed by provider. - A bare callable. It is wrapped as the legacy FMP fetch, called as
fetch(c.endpoint_path, dict(c.query)), andTypeErroris raised if any selected contract is not FMP/json. The direct callers at:977, :986, :1173keep working, and:977’s parametrization is restricted to FMP contracts.
- A dict.
FetchResult(status, rows, extra_anomalies)is a small dataclass. The legacy FMP adapter turns its(status, data)tuple intoFetchResult(status, data, []), so FMP outcomes do not change. The current test at:974-982still pins exactly[http_status:-1], which now carriescount: 1.run_canarymergesextra_anomalies. For CSV contracts it skipsvalidate_rowswheneverrows is None, which happens on a transport error, an oversized body, a decode failure or a non-200/3xx response, so no spuriousempty_responseappears. The non-200 and 3xx tests assert exactly[http_status:<code>]. The FMP path keeps callingvalidate_rowsexactly as it does today. Thehttp_status:*entry carriescount: 1.- New
_build_public_csv_fetch():- one GET to
contract.urlwith no credentials,allow_redirects=False, a 30 s timeout andstream=True; - it reads raw bytes up to
max_bytes + 1, and the cap counts decompressed bytes. It decodes the capped buffer asutf-8-sig, neverresponse.text. An undecodable buffer gives the fataldecode_error, and acsv.Errorgives the fatalparse_error; - an oversized body gives
FetchResult(status, None, [body_too_large:<max_bytes>]); - a 3xx or other non-200 response gives
FetchResult(status, None, []), which leaveshttp_status:<code>; - a transport error gives
FetchResult(-1, None, [transport_error]). For CSV only, this replaces the FMP-stylehttp_status:-1. Its message is redacted and printed to stderr only; - otherwise it returns
FetchResult(200, rows, parse_anomalies).
- one GET to
parse_csv_rows(text)returns(rows, parse_anomalies). An extra cell adds oneextra_cellsanomaly per affected row; these are summarized later. The step-1 coercion description is otherwise unchanged.- Report changes:
- There is an additive top-level
"provider"key. - Per-contract
anomaliesanddeprecationsare the summarized{code, severity, count}entries.countis additive, and FMP reports also gain it. - The default report path is
reports/<provider>_canary_<date>.json. For FMP this is stillfmp_canary_<date>.json.
- There is an additive top-level
summarizederives its titles fromreport.get("provider", "fmp"). Absent orfmpkeeps the exact current strings, sotest_provider_contracts_summary.py:118still holds. trader-monty uses “Public CSV contract anomaly/deprecation” and “Public CSV canary report missing”.--provideronsummarizechooses the missing-report title. Summary cells rendercode ×countwhen count > 1.
- Workflow
.github/workflows/public-csv-contract-canary.yml:- Triggers and access: weekly on Monday at 12:30 UTC plus
workflow_dispatch,permissions: contents: read, the same repository guard, and nosecrets.references. - Actions: SHA-pinned
usesand a lockeduv sync, mirroring the FMP workflow. - Steps:
- Run
canary --provider trader-monty --report reports/public-csv-canary-report.json. - Run
summarize --provider trader-montyunderif: always(). - Upload the report as an artifact kept for 30 days.
- Run
- Report-only:
continue-on-error: true, under the same promotion policy as the FMP canary. The docs state that the annotations and the step summary are the only drift signal, and that the run stays green on drift until promotion. - Tests: a new
scripts/tests/test_public_csv_canary_workflow.py, modeled ontest_fmp_canary_workflow.py. The workflow is also added totest_ci_supply_chain_wiring.py:16-20. fmp-contract-canary.ymlis not modified.
- Triggers and access: weekly on Monday at 12:30 UTC plus
- Consumer hardening for the two market-regime-daily skills.
- market-breadth-analyzer
csv_client.py.fetch_detail_csvchecks the header againstDETAIL_COLUMNSbefore parsing any row. If columns are missing, it prints oneERROR: market_breadth_data.csv schema drift: missing columns […]and returns[]. The existingmainexit 1 is kept, and the per-line WARN flood goes away.fetch_summary_csvwarns once for each missing required metric. Otherwise behavior is unchanged, because the percentile fallback already exists.
- uptrend-analyzer
data_fetcher.py.fetch_timeseriesandfetch_sector_summarycheck the header against their required columns. If columns are missing, they print oneERROR: <file> schema drift: missing columns […]and return[].fetch_timeseriesalso flags drift when everyworksheet == "all"row has an empty ratio. A NaN result from_safe_floatcounts as empty. Zeroallrows also counts as drift, becausemainneedslatest_all. Both cases are tested.- On drift, the fetcher caches
[]so the ERROR prints once and the file is not downloaded again. The cache stays non-None. - Existing behavior is kept: an empty timeseries still exits 1
(
uptrend_analyzer.py:80-84), and an empty sector summary still falls back to summaries derived from the timeseries.
- Other owners. market-top-detector, breadth-chart-analyst, sector-analyst and theme-detector are contract owners only. Their parser hardening is listed in the doc’s “Out of scope” section with file:line read sites as a follow-up. Their behavior does not change.
- market-breadth-analyzer
- Docs and text.
docs/dev/provider-contracts.md:- drop “(FMP)” from the title;
- add the trader-monty layout;
- document the new keys, coercion,
max_bytes,extra_cells,missing_required_value,body_too_large,transport_error, the dedupe andcount; - document the canary
--providerflag and the new workflow; - add the four ownership rows;
- replace “Non-FMP providers” in “Out of scope” (
:537) with the remaining providers (yfinance, CoinGecko, Binance, Alpaca, FXMacroData) and the four deferred owner hardenings; - update the CLI reference.
- Update the
ci.yml:268-271comment and thedocs/dev/maintenance-runbook.md:79row. skills-index.yamlis not changed.
Tests (TDD: failing tests first)
- Framework. Tests in
scripts/tests/test_provider_contracts.py.parse_csv_rowscases: empty cells, any-case bools, int, float, exponent,nan/inf→ str, str dates, header-only input, a quoted comma, a BOM header, a short row, and an extra cell →extra_cells.format/url/query/max_bytesvalidation, covering each invalid case.required_valuespass and fail.summarized()dedupe: a 30k-row body with one renamed column gives one entry with a count of 30,000.- The four trader-monty fixtures pass
check. test_expected_contracts_are_presentbecomes per-provider exact sets: the 9 FMP contracts and the 4 trader-monty contracts.- The
len(CONTRACTS)budget assertions use the FMP-filtered count. - Exact-dict assertions that gain
count::932-938(the profile report entry),:981(http_status:-1) and:990(empty_response). test_expected_legacy_alias_holders(:102-103) addsmarket-breadth-data. The parametrized rename test (:106-122) then covers the new aliases automatically.- A stubbed
_build_requests_fetchstill drives a defaultfmprun. - A bare callable combined with a non-FMP contract raises
TypeError. - An undecodable body →
decode_error. Zeroallrows → drift. - Duplicate endpoint across providers is still rejected.
- Canary.
- Provider filtering and budget.
--provider trader-montywith noFMP_API_KEYsucceeds against a fake CSV fetch.- The default
fmpprovider still requires the key. - The CSV fake is called with the URL, no credentials and no redirects.
- These inputs each map to their code:
- non-200 →
http_status; - 3xx →
http_status; - transport error →
transport_error; - a body one byte under
max_bytesis OK, and one byte over isbody_too_large; - renamed column →
missing_required_field.
- non-200 →
- The report carries
providerandcount. - The legacy
(path, query)direct callers still work.
- Summary. FMP titles are unchanged with or without
provider. A trader-monty report uses the public titles. A missing report uses the provider-specific title. - Consumer contract tests in
scripts/tests/. This follows the FMP-consumer convention intest_provider_contracts.py, where client modules are loaded by path, so skill suites never readconfig/.- Each test builds CSV text from the contract fixture and checks that the consumer parses it.
- Renaming each required column must produce exactly one schema-drift
ERROR,
[]and no WARN flood.
- Skill-suite unit tests (inline CSV) run in each skill’s own suite, for
coverage:
- a header with missing columns produces the error and
[]; - a missing summary metric produces a warning;
- all-empty
ratioin theallrows produces the error; - the cached drift result prints once and fetches once;
- main-level exit 1 for drifted detail and timeseries data.
- HTTP goes through the existing fakes or mocks. No live calls.
- a header with missing columns produces the error and
- Workflow.
test_public_csv_canary_workflow.pyand the supply-chain-wiring entry.
Verification
- Contract check:
python3 scripts/check_provider_contracts.py check. - Unit tests: the provider-contract, summary, workflow and supply-chain tests.
- Skill suites: market-breadth-analyzer and uptrend-analyzer, each run alone, with CI-equivalent coverage at or above the floor and before/after deltas reported.
- Replay: the market-regime and provenance replay tests. No golden change is expected; any change is a regression.
- Lint: ruff and codespell.
- Packaging: rebuild both
.skillarchives and passpackage_skills.py --checkfor the two skills. - Docs:
generate_skill_docs.py --check. - One manual live run,
canary --provider trader-monty --report /private/tmp/…, against the keyless, read-only public CSVs. The result is recorded in the PR. - No secrets, trading actions or merges.
Review log
Plan round 1: not approved. It found 1 blocker, 4 major and 8 minor issues; all were accepted:
- The 2 MB cap was smaller than the 2.81 MB timeseries; it is now a
per-contract
max_byteswith a named code. - Anomalies are now deduped with a count for the canary and summary.
- The FMP fetch factory is kept, and the existing tests that pin the contract set and budget are listed.
summarizetitles and the report path are now provider-aware.- Types now follow what the consumers accept, with case-insensitive bools,
mixed summary
Value, and short/extra-cell handling. - The owner list is complete, and the breadth-chart-analyst aliases are recorded.
- Workflow lint and supply-chain wiring are added.
- Redirects are disabled, and format/url/query consistency is validated.
- The uptrend drift check is scoped to the
allrows, treats NaN as empty and runs once. - Consumer contract tests now live in
scripts/tests. - Stale “FMP-only” text is updated.
- The workflow being green on drift is documented.
- The scope decision is recorded.
Plan round 2: not approved (1 major, 5 minor); all accepted.
- FetchResult.
FetchResult(status, rows, extra_anomalies)now carriesbody_too_large,transport_error,decode_error,parse_errorandextra_cells.parse_csv_rowsreturns its anomalies, and validation is skipped after a fetch failure. FMPhttp_status:-1behavior is pinned unchanged. - Tests. The exact-dict tests that change because of
countare listed, andtest_expected_legacy_alias_holdersis updated. - Alias list. The
Trendalias is added. The aliases are worded as consumer-tolerated alternates. - Dispatch.
run_canarydispatch is by dict or bare callable, not by arity. A test covers a stubbed factory. - Edge cases. CSV bytes are decoded as
utf-8-sig. Zeroallrows count as drift. Summary cells show×count.
Plan round 3 approved the plan, with 4 minor implementation notes, all applied:
- Validation is skipped for CSV whenever there are no rows, so a non-200
response yields exactly
[http_status:<code>]. - The wording of the FMP pinned-test was corrected.
- The step-1 signature for
parse_csv_rowsnow matches. - Rows with
extra_cellskeep only the header keys.
This is the final plan.
Implementation reviews
Code round 1: approved, with 7 minor findings, all fixed:
- header whitespace is normalized once;
- a canary
csv.Errornow reportsparse_error; - the uptrend short-row crash is fixed;
count/totalaccept["int","float"];- the anomaly docs are updated;
--provider allgets neutral titles;- BOMs are written as escapes, and the dead block is removed;
- in addition, sector-summary
10MA/Slopegainedreject_all_null.
Code round 2: 1 major and 3 minor findings, all fixed:
- (major) the bool columns had been loosened to
["bool","int","str"], which let any string pass. They are back to["bool"]with the multi-owner rationale, and a test now checks thatY/N/T/1/yesare fatal; _missing_columnsno longer strips a second time;- both consumers now report an unparsable CSV as drift via
_read_csvinstead of crashing, and uptrend caches that result; - the plan’s bool wording is aligned with the contract.