Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/data_quality/candles.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,10 @@ def _is_finite(value: object) -> bool:
letting the exception escape.
"""
try:
return bool(pd.notna(value) and math.isfinite(float(cast(Any, value))))
# cast(Any, ...) both times: pandas-stubs' notna overloads take concrete
# scalar/array types, but this boundary helper deliberately accepts any
# cell value and treats "cannot even check" as not-finite.
return bool(pd.notna(cast(Any, value)) and math.isfinite(float(cast(Any, value))))
except (TypeError, ValueError):
return False

Expand Down
11 changes: 8 additions & 3 deletions backend/dhan_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import threading
from collections.abc import Callable
from datetime import date, datetime
from typing import Any
from typing import Any, Literal

import pandas as pd

Expand All @@ -22,8 +22,13 @@ class DhanRateLimitError(RuntimeError):
"""Raised when Dhan asks the app to slow down history requests."""


def infer_epoch_unit(values: pd.Series) -> str:
"""Infer whether numeric timestamps are seconds, milliseconds, or microseconds."""
def infer_epoch_unit(values: pd.Series) -> Literal["s", "ms", "us"]:
"""Infer whether numeric timestamps are seconds, milliseconds, or microseconds.

The return type is the exact literal set ``pd.to_datetime``'s ``unit=``
parameter accepts (QUAL-006), so callers can pass the result straight
through without a cast.
"""
nums = pd.to_numeric(values, errors="coerce").dropna()
if nums.empty:
return "s"
Expand Down
13 changes: 10 additions & 3 deletions backend/indicators.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
from __future__ import annotations

import logging
from collections.abc import Hashable
from typing import cast

import numpy as np
import pandas as pd
Expand Down Expand Up @@ -476,7 +478,9 @@ def resample_to_weekly(frame: pd.DataFrame) -> pd.DataFrame:
work["timestamp"] = pd.to_datetime(work["timestamp"], errors="coerce")
work = work.dropna(subset=["timestamp"]).sort_values("timestamp").set_index("timestamp")

aggregation = {"open": "first", "high": "max", "low": "min", "close": "last"}
# Keyed by Hashable (not str) because pandas-stubs' ``agg`` mapping
# overload is invariant in its key type (QUAL-006).
aggregation: dict[Hashable, str] = {"open": "first", "high": "max", "low": "min", "close": "last"}
if "volume" in work.columns:
aggregation["volume"] = "sum"

Expand Down Expand Up @@ -902,7 +906,9 @@ def bullish_knoxville_divergence(
if (len(enriched) - 1 - latest_index) > int(recency):
return None

latest = enriched.loc[latest_index]
# ``.loc`` with a scalar label types as Series | DataFrame; the label here
# is always one integer row, so narrow to the Series it returns at runtime.
latest = cast(pd.Series, enriched.loc[latest_index])
if float(latest["rsi"]) > float(oversold):
return None

Expand Down Expand Up @@ -958,7 +964,8 @@ def bullish_knoxville_divergences(
bars_back = max(1, int(bars_back))
for latest_index in pivot_rows.index[1:]:
latest_index = int(latest_index)
latest = enriched.loc[latest_index]
# Scalar-label .loc narrows to a Series at runtime (see above).
latest = cast(pd.Series, enriched.loc[latest_index])
if float(latest["rsi"]) > float(oversold):
continue

Expand Down
4 changes: 3 additions & 1 deletion backend/scanning/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -750,7 +750,9 @@ def _result_rows(
# persistence records, never the DataFrame owned by the caller.
rows: list[dict[str, Any]] = []
skipped = 0
for index, row in enumerate(results.to_dict("records")):
# to_dict("records") types its keys as Hashable; screener columns are
# strings (the contract normalizer enforces exactly that downstream).
for index, row in enumerate(cast(list[dict[str, Any]], results.to_dict("records"))):
try:
rows.append(
normalize_screener_row(
Expand Down
6 changes: 4 additions & 2 deletions backend/scoring/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

import math
from collections.abc import Mapping
from typing import Any
from typing import Any, cast

import numpy as np
import pandas as pd
Expand Down Expand Up @@ -181,7 +181,9 @@ def risk_score_absolute(
if len(close) < window_size or (close <= 0).any():
return None

returns = np.log(close / close.shift(1)).dropna()
# numpy's stubs type np.log(Series) as an ndarray, but a ufunc on a Series
# returns a Series at runtime — narrow so .dropna() stays typed (QUAL-006).
returns = cast(pd.Series, np.log(close / close.shift(1))).dropna()
if returns.empty:
return None
sigma = float(returns.std(ddof=0))
Expand Down
7 changes: 5 additions & 2 deletions backend/scoring/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import math
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from typing import Any, cast

import pandas as pd

Expand Down Expand Up @@ -95,7 +95,10 @@ def score_candidates(
# parquet cache twice for a single symbol.
# Materialize the row dicts once and reuse them for every component below; a
# large shortlist would otherwise be converted to records three times.
records = ranked.to_dict("records")
# ``to_dict("records")`` types its keys as Hashable; these frames come from
# screeners whose columns are always strings, so narrow once for every
# Mapping[str, ...] consumer below (QUAL-006).
records = cast(list[dict[str, Any]], ranked.to_dict("records"))
symbol_to_security_id = _security_id_lookup(context.universe_df)
cached_candles = [
_read_cached_candles(row, symbol_to_security_id, context.data_loader)
Expand Down
11 changes: 7 additions & 4 deletions backend/technical/technical_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, Literal
from typing import Any, Literal, cast

import pandas as pd
from pydantic import Field, ValidationError, field_validator
Expand Down Expand Up @@ -115,7 +115,8 @@ def _technical_context_hash(
"""
window = candles.tail(_OHLC_WINDOW_BARS).copy() if not candles.empty else candles
candle_records: list[dict[str, Any]] = []
for row in window.to_dict("records"):
# to_dict("records") types its keys as Hashable; candle columns are strings.
for row in cast(list[dict[str, Any]], window.to_dict("records")):
candle_records.append(
{
key: (value.isoformat() if hasattr(value, "isoformat") else value)
Expand Down Expand Up @@ -143,9 +144,11 @@ def _technical_ohlc_csv(
for row in recent.itertuples(index=False):
timestamp = getattr(row, "timestamp", "")
date_str = str(timestamp)[:10] if timestamp is not None else ""
# itertuples() fields type as a broad scalar union; OHLC columns are
# numeric by the loader's boundary coercion, so narrow for float().
lines.append(
f"{date_str},{float(row.open):.2f},{float(row.high):.2f},"
f"{float(row.low):.2f},{float(row.close):.2f}"
f"{date_str},{float(cast(float, row.open)):.2f},{float(cast(float, row.high)):.2f},"
f"{float(cast(float, row.low)):.2f},{float(cast(float, row.close)):.2f}"
)
return "\n".join(lines)

Expand Down
4 changes: 4 additions & 0 deletions constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,9 @@ pip-audit==2.10.0
mypy==1.19.1
types-requests==2.33.0.20260518
types-PyYAML==6.0.12.20260518
# pandas-stubs tracks the pinned pandas 2.3.3 series (QUAL-006); types-pytz is
# its declared dependency.
pandas-stubs==2.3.3.260113
types-pytz==2026.2.0.20260518
pytest-cov==7.1.0
pre-commit==4.6.0
2 changes: 1 addition & 1 deletion docs/architecture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ testing · extension points.
- **[rank-002-handoff.md](rank-002-handoff.md)** — RANK-002 implemented build brief for the `backend/scoring/` scorer (pure components + config + the `run_scan` call + UI sort/components + tests).
- **[auth-003-role-model.md](auth-003-role-model.md)** — AUTH-003 role model: hierarchical viewer/analyst/admin, the capability→min-role map, the database-driven `user_roles` store with an `ADMIN_EMAILS` bootstrap floor, resolution precedence, defense-in-depth enforcement, and denial logging/audit.
- **[auth-003-handoff.md](auth-003-handoff.md)** — AUTH-003 build brief for the `backend/auth/roles.py` policy + `user_roles` table/migration + repository + `require_capability` enforcement + the admin Roles page + tests.
- **[audit-2026-06.md](audit-2026-06.md)** — June 2026 codebase audit & hardening register (QUAL-001/002/003, REF-001, PERF-001, DOC-001): what was found, fixed, and deferred.
- **[audit-2026-06.md](audit-2026-06.md)** — June–July 2026 codebase audit and hardening register through PR #107: what was found, fixed, rejected, and deferred across both review waves.

## Conventions

Expand Down
77 changes: 77 additions & 0 deletions docs/architecture/audit-2026-06.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,80 @@ removal of one redundant flush, and the stronger regression guard.
| Fundamentals eligibility tooltip UX | Users can't tell why "Check Fundamentals" mode differs by symbol; small UI improvement, needs product wording. |
| `_parse_company_page(session=None)` + a page with an HTMX peers URL would crash in the unmocked path | Production-unreachable (`fetch_company_data` always builds a real Session); the test seam relies on mocking `_fetch_html`. Documented at the call site; tidy when the scraper next changes. |
| Postgres-backed deployment guide with a worked example | docs/operations.md covers the switch; a full worked deploy (host, service file, reverse proxy) is its own doc when a real deployment exists. |

## July 2026 — whole-app review, two waves (appended 2026-07-11)

Two independent review passes ran this month and their outputs were reconciled
against main @ `0c87b65` before the second wave started, so nothing below was
built twice. **Wave 1** (another Claude session, PRs
[#88](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/88)–[#97](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/97))
and **wave 2** (this session, PRs
[#98](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/98)–[#107](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/107),
one ticket per PR) together close most of the June deferral list; the status of
every June item is updated in the table at the end of this section.

### Wave 1 (PRs #88–#97, other session)

DOC-002 AGENTS.md IPO map (#88) · TEST-004 golden coverage for the five
untested deterministic screeners (#89) · UI-001 chart-cache
`screener_version` key, SEC-001 role-email shape check, QUAL-005 coverage
floor raised to 87 (#90–#92) · AI-005 shared sync bridge, landed as
REFACTOR-003 with ADR (#93) · TEST-005 screener.in HTML snapshot tests (#94)
· UI-002 fundamentals freshness caption + eligibility captions (#95) ·
QUAL-004 mypy on tests/ phase 1 (#96) · REF-002 app.py extraction of
`ui/scan_view.py` + `ui/fundamentals_panel.py` (#97).

### Wave 2 (PRs #98–#107, this session)

| PR | Ticket | What landed |
|---|---|---|
| [#98](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/98) | TEST-007 | Render-path tests for the #97 extractions; locks the AUTH-003 export gate, OBS-003 export audit, symbol-stable chart selection after row reordering, secret-safe cached/agent failures, and force-refresh reruns. |
| [#99](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/99) | REF-003 | app.py 1,127→822 lines: status panel + parameter controls → `ui/`, 100% tested; `cache_summary` default no longer bound at import time. |
| [#100](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/100) | AI-006 | One shared verdict-JSON extractor in `backend/ai_runtime.py`; rejects non-finite constants and parser failures before validation/cache signing, with `allow_inf_nan=False` as model-level defense in depth. |
| [#101](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/101) | IPO-006 | One `canonical_sebi_url` behind both SSRF gates; rejects encoded traversal/separators, preserves PDF-path policy across redirects, resolves wrappers from the final URL, and normalizes malformed URL/stream failures into safe domain errors. |
| [#102](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/102) | TEST-006 | IPO pipeline end-to-end scenario (ingest→download→extract→ratios→verdict) with a provenance-digest chain across every stage hand-off. |
| [#103](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/103) | PERF-002 | Parquet footer-statistics cache decisions (see the sidecar supersession note below); benchmark-gated at ~12×. |
| [#104](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/104) | DEPLOY-004 | Password-safe env-file/prompt Postgres workflow, URL-encoding guidance, SQLite→Postgres migration recipe, Alembic encoded-password safety, and the correct `audit_logs` table; `pool_pre_ping` remains server-only. |
| [#105](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/105) | QUAL-006 | pandas-stubs adopted (see the estimate correction below). The wave's one sanctioned constraints/pyproject change. |
| [#106](https://github.com/DoRmAmMu1997/Streamlit-Scanner-App/pull/106) | QUAL-007 | mypy checks the whole test tree by default; 29 modules of pre-typing debt are enumerated in a mechanically enforced shrink-only override. |
| (this PR) | DOC-003 | This register section. |

### Findings verified FALSE in this review (do not re-flag)

1. **"`cpr_yearly` computes with floats, violating the Decimal-for-money
rule."** False as a violation: float math is the framework-wide convention
for *indicator* calculations across every screener and `backend/indicators.py`;
the Decimal rule governs money at persistence/contract boundaries. Changing
one screener would create inconsistency, not correctness.
2. **"`cpr_yearly` is untested."** False — four dedicated tests plus a golden
snapshot existed at review time.
3. **"The technical agent lacks a prompt-injection quarantine."** Not a gap:
its structured-candle-only posture (no untrusted-text tool) is the locked
TEST-003 decision above, with regression tests pinning it.

### Considered and REJECTED in wave 1 (recorded so they are not re-proposed)

- **Session-state registry abstraction** for the UI — indirection without a
demonstrated bug class.
- **Indicators decorator dedup** — the repetition is shallow; a decorator
would obscure the per-indicator math it wraps.
- **`sectors` iterrows micro-optimization** — not a measured hotspot.
- **DNS-rebinding hardening for the fixed SEBI listing URLs** — the listing
URLs are hardcoded constants, and the byte-fetching surface (the PDF
downloader) already resolves the host and rejects non-public answers.
- **`run_scan` length refactor** — long but linear and heavily commented;
splitting it would scatter one coherent lifecycle.

### June deferral list — status after July

| June item | July status |
|---|---|
| Agent SDK boilerplate dedup | **Landed in two steps**: the sync bridge (REFACTOR-003, #93) and the verdict-JSON extractor (AI-006, #100). Options-construction/retry/error-taxonomy blocks stay per-agent **by ADR decision** — that is the recorded end state, not remaining debt. |
| mypy on tests/ | **Landed in two phases**: whitelist (#96), then checked-by-default with a 29-module shrink-only debt list (#106). |
| pandas-stubs | **Landed** (#105). Correction for future estimators: June predicted "hundreds of errors"; with stubs version-matched to the pinned pandas minor (2.3.3), the real count was **22**, all mechanical. |
| Parquet metadata sidecar | **Superseded, honoring the June objection.** PERF-002 (#103) needs **no second cache format**: Parquet footers already carry row-group min/max statistics (health.py had read them since OBS-002). Benchmark-gated per the objection (~12× on the bounds question). Review hardening on the PR: footer bounds are treated as an advisory index — the prefetch still validates the actual frame before a "fresh" verdict, because a valid footer can coexist with corrupt data pages. |
| screener.in HTML snapshots | **Landed** (#94). |
| Fundamentals eligibility tooltip | **Landed** (#95). |
| Postgres worked deployment guide | **Landed** (#104), including the SQLite→Postgres data-migration recipe and pool guidance. |
| Redaction single-pass regex | Still deferred — remains a micro-optimization. |
| `_parse_company_page(session=None)` crash path | Still deferred — production-unreachable; tidy when the scraper next changes. |
61 changes: 51 additions & 10 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,17 @@ external = ["BLE"]
# CI runs 3.11; local development may run newer. Pinning the target version
# keeps local runs honest about what CI will accept.
python_version = "3.11"
# tests/ adopts mypy module-by-module (QUAL-004): the shared fixtures and the
# two policy/guard tests go first because they encode repo invariants. Extend
# this list as further test modules come clean; do not remove entries.
# QUAL-004 started tests/ adoption module-by-module (conftest + the two
# policy/guard tests); QUAL-007 finished it — the whole test tree is checked,
# so a fixture or fake that drifts from the typed application API fails here
# before it fails at runtime.
files = [
"app.py",
"backend",
"screeners",
"ui",
"Dependencies",
"tests/conftest.py",
"tests/test_supply_chain_policy.py",
"tests/test_repository_layer_boundary.py",
"tests",
]
# First-adoption strictness: check function bodies everywhere (including
# untyped ones) without yet *requiring* annotations on every def. Stricter
Expand All @@ -62,14 +61,56 @@ warn_redundant_casts = true
# import/assignment lines also suppress the environment-dependent unused-ignore.
warn_unused_ignores = true

[[tool.mypy.overrides]]
# QUAL-007's remaining debt: these test modules still carry pre-typing idioms
# (fake clients passed where the real client class is annotated, records-dict
# rows, unannotated accumulators) — ~385 mechanical errors at adoption time.
# Every OTHER test module is fully checked, and a NEW test file is checked by
# default. Shrink this list as modules come clean; NEVER add to it — a new
# entry means new untyped debt, which is exactly what this gate exists to
# prevent.
# NOTE: tests/ is not a package (no __init__.py), so mypy sees these as
# top-level modules — no "tests." prefix.
module = [
"test_app_comparison_page",
"test_app_validation_page",
"test_auth_session",
"test_daily_data_loader",
"test_daily_scan_job",
"test_dhan_client",
"test_forward_return_service",
"test_indicators",
"test_ipo_document_downloader",
"test_ipo_models",
"test_ipo_ratio_engine",
"test_ipo_repository",
"test_ipo_scorecard",
"test_notifications_channels",
"test_notifications_report",
"test_notifications_service",
"test_pdf_reader",
"test_real_screeners",
"test_result_contract",
"test_scan_run_integration",
"test_scan_service",
"test_scan_storage_repository",
"test_scanner_base",
"test_scoring_model",
"test_screener_in_client",
"test_screener_registry",
"test_sixty_seven_agent",
"test_sixty_seven_search_client",
"test_technical_analysis_agent",
]
ignore_errors = true

[[tool.mypy.overrides]]
# Third-party packages that ship no type stubs (and optional accelerators
# that are not installed on CI). pandas is deliberately untyped here:
# pandas-stubs would surface hundreds of new errors in this pandas-heavy
# codebase and is its own future migration.
# that are not installed on CI). pandas graduated OUT of this list in
# QUAL-006: pandas-stubs (pinned to the pandas 2.3.3 series) now types every
# DataFrame/Series call site in the checked application code.
module = [
"dhanhq.*",
"pandas.*",
"pandas_ta.*",
"pyarrow.*",
"pypdf.*",
Expand Down
4 changes: 4 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ pip-audit
mypy
types-requests
types-PyYAML
# pandas typing (QUAL-006). pandas-stubs tracks the pinned pandas minor
# series; types-pytz is its declared dependency and is pinned alongside it.
pandas-stubs
types-pytz
# Coverage measurement for the CI gate (QUAL-003).
pytest-cov
# Local commit-time checks mirroring CI's non-rewriting hooks.
Expand Down
Loading
Loading