Skip to content

fix(typing): scope mypy strict to the brokers, and check keel.* for real - #266

Merged
eaitbrahim merged 1 commit into
mainfrom
refactor/mypy-ungate-keel
Aug 13, 2026
Merged

fix(typing): scope mypy strict to the brokers, and check keel.* for real#266
eaitbrahim merged 1 commit into
mainfrom
refactor/mypy-ungate-keel

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

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 = true is not a per-module setting. Written inside a [[tool.mypy.overrides]] block it enables strict globally, whatever module pattern the section carries. Verified directly — a section naming a module that does not exist produces the identical error count:

config errors
broker block with strict = true 131
same block targeting nonexistent_module_xyz.* 131
block removed entirely 45

So pyproject.toml read as "brokers strict, everything else default" but did not do that. It was invisible because keel.*, keel_core.* and tests.* all carry ignore_errors, so strict had nothing left to shout at. Ungating keel.* 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 dict into a broker module and watching no-untyped-def / type-arg still fire.

keel.* is now checked, not yet strict — that stays the next tightening step, per the existing "one package at a time, never all at once" policy. keel_core.* and tests.* 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() 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 offending 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 — now one optional pair, making "stopped without an entry" 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 into a Literal parameter.
  • product_id was already 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.
  • _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_signals declared held: 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_marker discovers its packages by reading strict = 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:

  • the strict-module list can never be empty;
  • the expanded list must still equal what the installed mypy's --strict actually 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 files
  • ruff check keel tests packages — clean
  • keel --help — imports fine (the ci.yml smoke check)
  • pytest2723 passed, 1 skipped; collection diffed against baseline is exactly +2, the two new tests

No runtime behaviour changes.

🤖 Generated with Claude Code

…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
eaitbrahim merged commit 3090e9b into main Aug 13, 2026
1 check passed
@eaitbrahim
eaitbrahim deleted the refactor/mypy-ungate-keel branch August 13, 2026 14:23
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant