fix(typing): scope mypy strict to the brokers, and check keel.* for real - #266
Merged
Conversation
…or real `strict = true` is not a per-module setting. Written inside a `[[tool.mypy.overrides]]` block it turns strict on GLOBALLY, whatever `module` pattern the section carries -- a section naming a module that does not exist does it just the same. The broker block therefore had `keel.*`, `keel_core.*` and `tests.*` under full strict mode as well, invisibly, because all three carry `ignore_errors` and so never printed what it found. The bug only shows up when a module is ungated, which is what this commit does: dropping `keel.*` from the exempt list surfaced 131 errors where default mode finds 45. Spelling the bundle out flag by flag scopes it to the four broker packages for real, and `keel.*` is now CHECKED (not yet `strict` -- that stays the next tightening step, one package at a time). The 45 were almost all annotation debt guarded by real invariants rather than live defects. Fixed at the root instead of silenced: * `sum()` over `Decimal` with no start value picks the `int` overload and widens to `Decimal | float` (`stats.py`, `levels.py`). * `Trade.pnl`/`exit_ts` are optional because an OPEN trade has none; the aggregates run only over closed trades. Stated via `_closed_pnl` and an explicit filter, so a violation names the trade instead of raising from inside a generator. * `TurtleBreakout` tracked entry and stop as two independently-optional locals though they are set and cleared together; they are one optional pair now, so "stopped without an entry" is unrepresentable rather than merely unreachable. * `_effective_mode` returns exactly `"confirm"`/`"autonomous"` but was typed `str`, so both `executor.execute` call sites passed an unchecked value. * `product_id` was a de-facto part of the `Rule` interface (every concrete rule takes and stores it; `sim` reads it off the base type) -- declared. * `rules seed` built rules via `RULE_REGISTRY[kind](...)`, reaching around `build_rule_from_params`, the documented `(kind, params)` -> `Rule` boundary. * Two `_human_dt(None)` paths in the TUI. These do not raise -- `time.localtime(None)` means "now" -- so a missing timestamp rendered as a lapse that happened this instant. Both guarded. One real defect: `_process_rule_signals` declared `held: dict[str, _Held]` while every other declaration, its only caller, and its own body use `(asset, rule_name)` tuple keys. `test_strictly_typed_packages_ship_a_py_typed_marker` discovers its packages by reading `strict = true` from the config, so the expanded flag list silently emptied its parametrization -- four passing cases became one skipped `[NOTSET]` and the PEP 561 rule stopped being enforced without going red. Discovery now keys off the flag block, plus two guards: one that the strict-module list is never empty, and one that the expanded list still equals what the installed mypy's `--strict` actually turns on, since a hard-coded copy of someone else's bundle drifts. No runtime behaviour changes. 2721 baseline tests still pass (+2 new). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim
added a commit
that referenced
this pull request
Aug 13, 2026
Version bump across all six distributions (`tests/test_packaging.py` fails the build if a sibling pin is left behind), plus the findings from reviewing #266 after it merged. Shipping a release with those still open would have baked a known-weak guard into a live-trading build. Follow-ups to #266: * `test_broker_strict_flags_match_mypy_strict` died with a bare `StopIteration` in exactly the case it exists to catch -- someone re-collapsing the broker block to `strict = true`. It now asserts, and the message points at the pyproject comment explaining why the block is expanded. * That test's `warn_redundant_casts` exclusion was implicit: the flag is absent from the default-vs-strict diff only because it is currently mypy's default. Were that default to flip, the test would demand the flag in a per-module section where mypy refuses to accept it -- unsatisfiable. Excluded by name. * `TradeOutcome` was spelled with the PEP 695 `type` statement. `get_type_hints` leaves such an alias as a `TypeAliasType` whose `get_origin()` is `None`, while the assignment form resolves to `Literal` -- and `commands.rules._declared_choices` validates operator-supplied `rules add --params` by testing precisely `get_origin(hint) is Literal`. No effect today (`TradeOutcome` is in no rule constructor), but it planted the spelling that silently disables that validation beside the modules whose `--params` safety depends on it. Reverted to the assignment form used by `StopMethod`/ `TargetMethod`. Three new user-visible behaviours from #266 shipped untested; all three mutations survived the whole suite. Now covered, and each verified to fail when its guard is removed: * `summarize()` rejecting a closed trade with no P&L, asserting the outcome appears in the message -- the named diagnostic is the entire point of raising. * Both `_human_dt(None)` guards in the TUI. These never raised: `time.localtime(None)` means "now", so a missing timestamp rendered the current instant as fact. Each test asserts the text says "unknown" AND does not contain the rendered current time. Not added: a test pinning `cli.avg_hold_hours`'s `exit_ts` filter. Both `SimTrade` producers set `outcome`/`exit_ts` together, so `outcome != "open"` implies `exit_ts is not None` and the filtered denominator provably cannot differ. Covering it means fabricating a state the code cannot reach, which would pin an arbitrary choice rather than a behaviour. 2723 -> 2726 tests. mypy, ruff clean. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim
added a commit
that referenced
this pull request
Aug 13, 2026
`mypy` ran in NO workflow before this -- it appeared only in a passing comment in `code-quality.yml`. #266 brought `keel.*` under the checker, but nothing enforced that: a type error there was a clean CI run. Added as a STEP in the existing `test` job, not a job of its own. The `main` ruleset requires the status context `test`, which comes from that job's id, so a separate `typecheck:` job would report a context nothing requires and a red mypy would not block a merge -- the same failure shape `ci.yml`'s header already warns about for renamed jobs. `release.yml` gets it too, and re-runs it rather than trusting CI's: that workflow is dispatched against whatever `main` is at the time, which need not be a commit any CI run went green on. Neither workflow repeats the paths; they come from `[tool.mypy]`'s `files`. CI closes only half the hole, though. It catches a type ERROR in `keel/`. It cannot catch `keel.*` being re-added to an `ignore_errors` override, which silences the package wholesale -- mypy then exits 0 while checking nothing there, and the ungating is reverted with a green build to show for it. Verified both halves: with a type error injected, mypy exits 1; with the exemption restored, mypy exits 0 and only the new `test_keel_is_not_exempt_from_type_checking` fails. `tests.*` and `keel_core.*` stay legitimately exempt; the guard pins only the module that was deliberately brought under the checker. 2726 -> 2727 tests. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
An assessment of the codebase turned up no active bugs — the tree is healthy (2721 tests green, ruff clean). The one real gap was the mypy config itself, plus the 45 default-mode errors it was hiding on
keel.*.The config bug
strict = trueis not a per-module setting. Written inside a[[tool.mypy.overrides]]block it enables strict globally, whatevermodulepattern the section carries. Verified directly — a section naming a module that does not exist produces the identical error count:strict = truenonexistent_module_xyz.*So
pyproject.tomlread as "brokers strict, everything else default" but did not do that. It was invisible becausekeel.*,keel_core.*andtests.*all carryignore_errors, so strict had nothing left to shout at. Ungatingkeel.*is exactly the action that exposes it.The bundle is now spelled out flag by flag, scoping it to the four broker packages for real. Broker strictness is preserved — confirmed by injecting an untyped def and a bare
dictinto a broker module and watchingno-untyped-def/type-argstill fire.keel.*is now checked, not yetstrict— that stays the next tightening step, per the existing "one package at a time, never all at once" policy.keel_core.*andtests.*remain gated.The 45 errors
Almost all annotation debt guarded by real invariants rather than live defects. Fixed at the root instead of silenced:
sum()overDecimalwith no start value picks theintoverload and widens toDecimal | float(stats.py,levels.py).Trade.pnl/exit_tsare optional because an OPEN trade has none; the aggregates run only over closed trades. Stated via_closed_pnland an explicit filter, so a violation names the offending trade instead of raising from inside a generator.TurtleBreakouttracked entry and stop as two independently-optional locals though they are set and cleared together — now one optional pair, making "stopped without an entry" unrepresentable rather than merely unreachable._effective_modereturns exactly"confirm"/"autonomous"but was typedstr, so bothexecutor.executecall sites passed an unchecked value into aLiteralparameter.product_idwas already a de-facto part of theRuleinterface (every concrete rule takes and stores it;simreads it off the base type) — declared.rules seedbuilt rules viaRULE_REGISTRY[kind](...), reaching aroundbuild_rule_from_params, the documented(kind, params)->Ruleboundary._decline's 8 errors are a documented idiom (one-line declines so the reason cannot drift from its branch). Idiom kept; annotation corrected.Real findings
One genuine defect —
_process_rule_signalsdeclaredheld: dict[str, _Held]while every other declaration, its only caller, and its own body use(asset, rule_name)tuple keys.Two silent TUI bugs —
_human_dt(None)does not raise:time.localtime(None)means now, so a missing timestamp rendered as a lapse that happened this instant. Both guarded.A guardrail this broke, and its repair
The config change silently gutted an existing test.
test_strictly_typed_packages_ship_a_py_typed_markerdiscovers its packages by readingstrict = true, so the expanded flag list collapsed four passing cases into one skipped[NOTSET]— the PEP 561 rule stopped being enforced without anything going red. It surfaced only from diffing collected tests against baseline, not from the summary line.Discovery now keys off the flag block, plus two new guards:
--strictactually turns on, derived from mypy's own option parser rather than hard-coded — a copy of someone else's bundle drifts. Confirmed to fail correctly by deleting a flag.Verification
mypy— clean, 224 source filesruff check keel tests packages— cleankeel --help— imports fine (theci.ymlsmoke check)pytest— 2723 passed, 1 skipped; collection diffed against baseline is exactly +2, the two new testsNo runtime behaviour changes.
🤖 Generated with Claude Code