diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index 9f549f7..a8fb4ef 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -67,10 +67,5 @@ jobs: pip install mypy pip install nicegui fastapi uvicorn yfinance polars pandas pydantic pyyaml pip list - - name: Show working directory - run: pwd && ls -la - name: Run mypy - run: | - echo "Running mypy on core modules..." - mypy app/core app/models app/strategies app/services --ignore-missing-imports --show-error-codes --show-traceback || true - echo "Type check completed (warnings allowed during development)" \ No newline at end of file + run: mypy app/core app/models app/strategies app/services --ignore-missing-imports --show-error-codes \ No newline at end of file diff --git a/app/domain/units.py b/app/domain/units.py index ad94957..a84ed91 100644 --- a/app/domain/units.py +++ b/app/domain/units.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from decimal import Decimal from enum import StrEnum +from typing import TYPE_CHECKING class BaseCurrency(StrEnum): @@ -99,6 +100,11 @@ class Money: object.__setattr__(self, "amount", to_decimal(self.amount)) object.__setattr__(self, "currency", _coerce_currency(self.currency)) + @property + def _currency_typed(self) -> BaseCurrency: + """Type-narrowed currency accessor for internal use.""" + return self.currency # type: ignore[return-value] + @classmethod def zero(cls, currency: BaseCurrency) -> Money: return cls(amount=Decimal("0"), currency=currency) @@ -147,6 +153,15 @@ class Money: return Money(amount=-self.amount, currency=self.currency) +if TYPE_CHECKING: + # Type narrowing: after __post_init__, these are the actual types + Money_amount: Decimal + Money_currency: BaseCurrency +else: + Money_amount = Decimal + Money_currency = BaseCurrency | str + + @dataclass(frozen=True, slots=True) class Weight: amount: Decimal @@ -156,49 +171,54 @@ class Weight: object.__setattr__(self, "amount", to_decimal(self.amount)) object.__setattr__(self, "unit", _coerce_weight_unit(self.unit)) + @property + def _unit_typed(self) -> WeightUnit: + """Type-narrowed unit accessor for internal use.""" + return self.unit # type: ignore[return-value] + def to_unit(self, unit: WeightUnit) -> Weight: - return Weight(amount=convert_weight(self.amount, self.unit, unit), unit=unit) + return Weight(amount=convert_weight(self.amount, self._unit_typed, unit), unit=unit) def __add__(self, other: object) -> Weight: if not isinstance(other, Weight): return NotImplemented - other_converted = other.to_unit(self.unit) - return Weight(amount=self.amount + other_converted.amount, unit=self.unit) + other_converted = other.to_unit(self._unit_typed) + return Weight(amount=self.amount + other_converted.amount, unit=self._unit_typed) def __sub__(self, other: object) -> Weight: if not isinstance(other, Weight): return NotImplemented - other_converted = other.to_unit(self.unit) - return Weight(amount=self.amount - other_converted.amount, unit=self.unit) + other_converted = other.to_unit(self._unit_typed) + return Weight(amount=self.amount - other_converted.amount, unit=self._unit_typed) def __mul__(self, other: object) -> Weight | Money: if isinstance(other, bool): return NotImplemented if isinstance(other, Decimal): - return Weight(amount=self.amount * other, unit=self.unit) + return Weight(amount=self.amount * other, unit=self._unit_typed) if isinstance(other, int): - return Weight(amount=self.amount * Decimal(other), unit=self.unit) + return Weight(amount=self.amount * Decimal(other), unit=self._unit_typed) if isinstance(other, PricePerWeight): - adjusted_weight = self.to_unit(other.per_unit) - return Money(amount=adjusted_weight.amount * other.amount, currency=other.currency) + adjusted_weight = self.to_unit(other._per_unit_typed) + return Money(amount=adjusted_weight.amount * other.amount, currency=other._currency_typed) return NotImplemented def __rmul__(self, other: object) -> Weight: if isinstance(other, bool): return NotImplemented if isinstance(other, Decimal): - return Weight(amount=self.amount * other, unit=self.unit) + return Weight(amount=self.amount * other, unit=self._unit_typed) if isinstance(other, int): - return Weight(amount=self.amount * Decimal(other), unit=self.unit) + return Weight(amount=self.amount * Decimal(other), unit=self._unit_typed) return NotImplemented def __truediv__(self, other: object) -> Weight: if isinstance(other, bool): return NotImplemented if isinstance(other, Decimal): - return Weight(amount=self.amount / other, unit=self.unit) + return Weight(amount=self.amount / other, unit=self._unit_typed) if isinstance(other, int): - return Weight(amount=self.amount / Decimal(other), unit=self.unit) + return Weight(amount=self.amount / Decimal(other), unit=self._unit_typed) return NotImplemented @@ -216,10 +236,20 @@ class PricePerWeight: object.__setattr__(self, "currency", _coerce_currency(self.currency)) object.__setattr__(self, "per_unit", _coerce_weight_unit(self.per_unit)) + @property + def _currency_typed(self) -> BaseCurrency: + """Type-narrowed currency accessor for internal use.""" + return self.currency # type: ignore[return-value] + + @property + def _per_unit_typed(self) -> WeightUnit: + """Type-narrowed unit accessor for internal use.""" + return self.per_unit # type: ignore[return-value] + def to_unit(self, unit: WeightUnit) -> PricePerWeight: return PricePerWeight( - amount=convert_price_per_weight(self.amount, self.per_unit, unit), - currency=self.currency, + amount=convert_price_per_weight(self.amount, self._per_unit_typed, unit), + currency=self._currency_typed, per_unit=unit, ) @@ -227,19 +257,19 @@ class PricePerWeight: if isinstance(other, bool): return NotImplemented if isinstance(other, Weight): - adjusted_weight = other.to_unit(self.per_unit) - return Money(amount=adjusted_weight.amount * self.amount, currency=self.currency) + adjusted_weight = other.to_unit(self._per_unit_typed) + return Money(amount=adjusted_weight.amount * self.amount, currency=self._currency_typed) if isinstance(other, Decimal): - return PricePerWeight(amount=self.amount * other, currency=self.currency, per_unit=self.per_unit) + return PricePerWeight(amount=self.amount * other, currency=self._currency_typed, per_unit=self._per_unit_typed) if isinstance(other, int): - return PricePerWeight(amount=self.amount * Decimal(other), currency=self.currency, per_unit=self.per_unit) + return PricePerWeight(amount=self.amount * Decimal(other), currency=self._currency_typed, per_unit=self._per_unit_typed) return NotImplemented def __rmul__(self, other: object) -> PricePerWeight: if isinstance(other, bool): return NotImplemented if isinstance(other, Decimal): - return PricePerWeight(amount=self.amount * other, currency=self.currency, per_unit=self.per_unit) + return PricePerWeight(amount=self.amount * other, currency=self._currency_typed, per_unit=self._per_unit_typed) if isinstance(other, int): - return PricePerWeight(amount=self.amount * Decimal(other), currency=self.currency, per_unit=self.per_unit) + return PricePerWeight(amount=self.amount * Decimal(other), currency=self._currency_typed, per_unit=self._per_unit_typed) return NotImplemented diff --git a/docs/roadmap/ROADMAP.yaml b/docs/roadmap/ROADMAP.yaml index 3da61d8..9447c3d 100644 --- a/docs/roadmap/ROADMAP.yaml +++ b/docs/roadmap/ROADMAP.yaml @@ -14,6 +14,7 @@ notes: - Alpha migration policy: once alpha is declared, compatibility only needs to move forward; backward migrations are not required. priority_queue: - EXEC-002 + - CORE-003 - DATA-DB-005 - DATA-002A - DATA-001A diff --git a/docs/roadmap/backlog/CORE-003-mypy-type-safety.yaml b/docs/roadmap/backlog/CORE-003-mypy-type-safety.yaml new file mode 100644 index 0000000..87318e6 --- /dev/null +++ b/docs/roadmap/backlog/CORE-003-mypy-type-safety.yaml @@ -0,0 +1,112 @@ +name: CORE-003 Mypy Type Safety +description: | + Fix all mypy type errors to enable strict type checking in CI. + + Currently 42 errors in 15 files. The CI uses `|| true` to allow warnings, + but we should fix these properly with strong types and conversion functions. + +status: backlog +priority: medium +created_at: 2026-03-29 +dependencies: [] + +acceptance_criteria: + - mypy passes with 0 errors on app/core app/models app/strategies app/services + - CI type-check job passes without `|| true` + - All type narrowing uses proper patterns (properties, cast(), or isinstance checks) + - No duplicate method definitions + +scope: + in_scope: + - Fix type errors in app/domain/units.py + - Fix type errors in app/domain/portfolio_math.py + - Fix type errors in app/models/portfolio.py + - Fix type errors in app/domain/backtesting_math.py + - Fix type errors in app/domain/instruments.py + - Fix type errors in app/services/*.py + - Fix type errors in app/pages/*.py + - Remove `|| true` from type-check job in CI + + out_of_scope: + - Adding new type annotations to previously untyped code + - Refactoring business logic + +files_with_errors: + - file: app/domain/units.py + errors: 6 + pattern: "WeightUnit | str" not narrowed after __post_init__ + fix: Use _unit_typed property for type-narrowed access + + - file: app/models/portfolio.py + errors: 1 + pattern: "Duplicate _serialize_value definition" + fix: Remove duplicate method definition + + - file: app/domain/backtesting_math.py + errors: 1 + pattern: "assert_currency" argument type + fix: Use Money.assert_currency properly or add type narrowing + + - file: app/domain/instruments.py + errors: 1 + pattern: "to_unit" argument type + fix: Use _unit_typed property or explicit coercion + + - file: app/domain/portfolio_math.py + errors: 11 + pattern: "float(object), Weight | Money union, dict type mismatch" + fix: Add proper type guards and conversion functions + + - file: app/services/backtesting/ui_service.py + errors: 2 + pattern: "Provider type mismatch, YFinance vs Databento source" + fix: Use proper union types for provider interface + + - file: app/services/event_comparison_ui.py + errors: 1 + pattern: "FixtureBoundSyntheticHistoricalProvider type" + fix: Update type annotations for provider hierarchy + + - file: app/services/cache.py + errors: 1 + pattern: "str | None to Redis URL" + fix: Add None check or use assertion + + - file: app/services/price_feed.py + errors: 2 + pattern: "float(object)" + fix: Add explicit type coercion + + - file: app/pages/settings.py + errors: 1 + pattern: "Return value on ui.button scope" + fix: Proper return type annotation + +implementation_notes: | + The root cause is that frozen dataclass fields with `Field: UnionType` + are not narrowed by `__post_init__` coercion. Mypy sees the declared + type, not the runtime type. + + Solutions: + 1. Add `@property def _field_typed(self) -> NarrowType:` for internal use + 2. Use `cast(NarrowType, self.field)` at call sites + 3. Use `isinstance` checks before operations requiring narrow type + + Pattern example from units.py fix: + ```python + @property + def _unit_typed(self) -> WeightUnit: + """Type-narrowed unit accessor for internal use.""" + return self.unit # type: ignore[return-value] + + def to_unit(self, unit: WeightUnit) -> Weight: + return Weight(amount=convert_weight(self.amount, self._unit_typed, unit), unit=unit) + ``` + +estimated_effort: 4-6 hours + +tags: + - type-safety + - mypy + - technical-debt + - ci-quality \ No newline at end of file