diff --git a/.github/workflows/fee-schedule-check.yml b/.github/workflows/fee-schedule-check.yml new file mode 100644 index 0000000..d57f81a --- /dev/null +++ b/.github/workflows/fee-schedule-check.yml @@ -0,0 +1,242 @@ +name: Fee schedule check + +# Monthly review of the Firstrade fee schedule (tracking issue #497). +# +# check: compares the SEC Section 31 rate and the FINRA TAF rates on the official sites with +# application/firstrade_fee_schedule.py. Any fetch/parse failure is an error, never a pass. +# bump: only when every value is unchanged. Pushes a branch that moves `verified_through` to the +# end of next month (plus the test pin and docs) and opens a PR. It never merges. +# notify: when rates changed, the check failed, or the PR could not be opened. Opens or updates one +# issue. Rates are never edited automatically. +# +# Only GITHUB_TOKEN is used. Pull requests opened with it do not start other workflows, so the +# bump job runs the relevant tests itself and a reviewer closes and reopens the PR to run CI. +# Optional repository variable FEE_CHECK_CONTACT: a monitored address that is sent to sec.gov in +# the User-Agent (SEC fair-access policy). + +on: + schedule: + - cron: '17 2 22 * *' + workflow_dispatch: + pull_request: + paths: + - '.github/workflows/fee-schedule-check.yml' + - 'scripts/check_fee_schedule.py' + +permissions: {} + +concurrency: + group: fee-schedule-check-${{ github.event_name == 'pull_request' && github.head_ref || 'main' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + ISSUE_TITLE: 'Firstrade fee schedule check needs attention' + +jobs: + check: + name: Check official sources + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + status: ${{ steps.run.outputs.status }} + report: ${{ steps.run.outputs.report }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Ensure pdftotext + run: | + set -euo pipefail + command -v pdftotext >/dev/null || { + sudo apt-get update -qq + sudo apt-get install -y -qq --no-install-recommends poppler-utils + } + + - name: Compare with sec.gov and finra.org + id: run + env: + FEE_CHECK_CONTACT: ${{ vars.FEE_CHECK_CONTACT }} + run: | + set -uo pipefail + rc=0 + python3 -m scripts.check_fee_schedule check \ + --report "$RUNNER_TEMP/report.md" --json "$RUNNER_TEMP/result.json" || rc=$? + status=error + case "$rc" in + 0) status=unchanged ;; + 2) status=changed ;; + esac + [ -s "$RUNNER_TEMP/report.md" ] || printf 'The check produced no report.\n' > "$RUNNER_TEMP/report.md" + cat "$RUNNER_TEMP/report.md" >> "$GITHUB_STEP_SUMMARY" + delimiter="EOF_$(openssl rand -hex 8)" + { + echo "status=$status" + echo "report<<$delimiter" + cat "$RUNNER_TEMP/report.md" + echo "$delimiter" + } >> "$GITHUB_OUTPUT" + + - name: Fail pull request runs unless the sources match + if: github.event_name == 'pull_request' && steps.run.outputs.status != 'unchanged' + run: | + echo "The check status is ${{ steps.run.outputs.status }}; see the job summary." >&2 + exit 1 + + bump: + name: Open verified_through bump + needs: check + if: >- + github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + && needs.check.outputs.status == 'unchanged' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + outputs: + result: ${{ steps.bump.outputs.result }} + branch: ${{ steps.bump.outputs.branch }} + pr_url: ${{ steps.bump.outputs.pr_url }} + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + REPORT: ${{ needs.check.outputs.report }} + # The bump rewrites a module that it imported; a stale .pyc would hide the change from pytest. + PYTHONDONTWRITEBYTECODE: '1' + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Bump verified_through and open a pull request + id: bump + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + gh auth setup-git + + python3 -m scripts.check_fee_schedule bump --output "$RUNNER_TEMP/new_date" + new_date="$(cat "$RUNNER_TEMP/new_date")" + if [ -z "$new_date" ]; then + echo "result=noop" >> "$GITHUB_OUTPUT" + exit 0 + fi + branch="chore/fee-schedule-verified-through-$new_date" + echo "branch=$branch" >> "$GITHUB_OUTPUT" + + # The bump rewrites only the date pins; run the tests that cover them. + python3 -m venv "$RUNNER_TEMP/venv" + "$RUNNER_TEMP/venv/bin/pip" install --quiet pytest==9.1.1 + "$RUNNER_TEMP/venv/bin/python" -m pytest -q \ + tests/test_statement_reconciliation.py tests/test_check_fee_schedule.py + git diff --stat + + # Never overwrite an existing branch. + if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then + if [ -n "$(gh pr list --head "$branch" --state open --json number --jq '.[].number')" ]; then + echo "result=exists" >> "$GITHUB_OUTPUT" + else + echo "result=branch-only" >> "$GITHUB_OUTPUT" + fi + exit 0 + fi + + git switch -c "$branch" + git add -u + git commit -q -m "chore(fees): extend fee schedule verified_through to $new_date" \ + -m "Scheduled fee-schedule-check found the SEC Section 31 and FINRA TAF rates unchanged." \ + -m "No rate row was changed. Refs #497." + git push origin "$branch" + + { + echo "## AI assistance" + echo + echo "None. Opened by the scheduled \`Fee schedule check\` workflow, which runs" + echo "\`scripts/check_fee_schedule.py\`. A human must review before merge." + echo + echo "## What changed" + echo + echo "Moves \`verified_through\` in \`application/firstrade_fee_schedule.py\` to \`$new_date\`" + echo "so sells dated up to then keep reconciling instead of returning \`unsupported\`. No" + echo "rate row was changed. The test review pin and the doc mentions move with it." + echo + echo "Refs #497 (recurring review; intentionally not closed)." + echo + echo "## How verified" + echo + echo "Workflow run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID." + echo "\`pytest tests/test_statement_reconciliation.py tests/test_check_fee_schedule.py\`" + echo "passed in that run. CI did not start on this pull request because it was opened" + echo "with \`GITHUB_TOKEN\`: close and reopen it to run the full checks." + echo + printf '%s\n' "$REPORT" | tail -n +2 | sed 's/^## /### /' + } > "$RUNNER_TEMP/pr_body.md" + + if pr_url="$(gh pr create --base main --head "$branch" \ + --title "chore(fees): extend fee schedule verified_through to $new_date" \ + --body-file "$RUNNER_TEMP/pr_body.md")"; then + echo "result=pr-opened" >> "$GITHUB_OUTPUT" + echo "pr_url=$pr_url" >> "$GITHUB_OUTPUT" + else + echo "::warning::The branch was pushed but the pull request could not be created." + echo "result=branch-only" >> "$GITHUB_OUTPUT" + fi + + notify: + name: Open or update the attention issue + needs: [check, bump] + if: >- + always() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + && (needs.check.result != 'success' || needs.check.outputs.status != 'unchanged' + || needs.bump.result == 'failure' || needs.bump.outputs.result == 'branch-only') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + issues: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + CHECK_RESULT: ${{ needs.check.result }} + CHECK_STATUS: ${{ needs.check.outputs.status }} + BUMP_RESULT: ${{ needs.bump.result }} + BUMP_OUTCOME: ${{ needs.bump.outputs.result }} + BUMP_BRANCH: ${{ needs.bump.outputs.branch }} + REPORT: ${{ needs.check.outputs.report }} + steps: + - name: Open or update the issue + run: | + set -euo pipefail + run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + { + echo "Fee schedule check, workflow run: $run_url" + echo + if [ "$CHECK_RESULT" != "success" ]; then + echo "The check job did not finish (result: $CHECK_RESULT). Nothing was changed." + elif [ "$CHECK_STATUS" != "unchanged" ]; then + echo "Status: **$CHECK_STATUS**. Nothing was changed and no bump pull request was opened." + else + echo "The rates are unchanged, but the \`verified_through\` bump could not be completed" + echo "(bump job: $BUMP_RESULT, outcome: ${BUMP_OUTCOME:-none})." + if [ -n "$BUMP_BRANCH" ]; then + echo + echo "Branch \`$BUMP_BRANCH\` is on the remote. Open a pull request from it:" + echo "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/main...$BUMP_BRANCH?expand=1" + fi + fi + echo + printf '%s\n' "$REPORT" | tail -n +2 | sed 's/^## /### /' + echo + echo "Tracking issue: #497. Rates are never changed automatically." + } > "$RUNNER_TEMP/issue_body.md" + + number="$(gh issue list --state open --limit 100 --json number,title \ + | jq -r --arg title "$ISSUE_TITLE" '[.[] | select(.title == $title)][0].number // empty')" + if [ -n "$number" ]; then + gh issue comment "$number" --body-file "$RUNNER_TEMP/issue_body.md" + else + gh issue create --title "$ISSUE_TITLE" --body-file "$RUNNER_TEMP/issue_body.md" + fi diff --git a/docs/statement-reconciliation.md b/docs/statement-reconciliation.md index d3b8ea1..0a05829 100644 --- a/docs/statement-reconciliation.md +++ b/docs/statement-reconciliation.md @@ -52,6 +52,31 @@ until 60 days after the FY2027 appropriation is enacted) and the FINRA filings above are immediately effective but still open to SEC suspension. Re-verify the sources and move `verified_through` forward in the same change. +The `Fee schedule check` workflow (`.github/workflows/fee-schedule-check.yml`) does +this review every month, on the 22nd at 02:17 UTC, and on demand through +`workflow_dispatch`. `scripts/check_fee_schedule.py check` reads the SEC Section 31 +advisories and SEC filing pages, and the FINRA fee adjustment schedule, rule filing +listing and filing PDFs, then compares them with the schedule. A fetch or parse +failure is an error, never a pass. + +- Everything matches: the `bump` job moves `verified_through` to the end of the next + month (plus the "checked" date in the module docstring, the test review pin + `VERIFIED_THROUGH` and the two doc mentions), runs the tests and opens a pull + request. It never merges. +- A rate changed, a new FINRA fee filing appeared, an SEC page mentions a + suspension, or the check failed: the `notify` job opens or updates one issue + titled "Firstrade fee schedule check needs attention". Rates are never edited + automatically; update the segments in a normal pull request. + +The workflow uses `GITHUB_TOKEN` only. Pull requests it opens do not start CI, so +close and reopen the pull request to run the checks before merging. If the +repository setting that lets Actions create pull requests is off, the job leaves +the branch on the remote and the issue carries a compare link instead. Set the +repository variable `FEE_CHECK_CONTACT` to a monitored address: sec.gov rejects +clients that do not identify themselves. The script is tied to the current page +layouts and to the filings named in `REVIEWED_FINRA_FILINGS`; when a layout +changes the check fails loudly and the parser needs an update. + ## Valuation basis difference The balances endpoint and the positions endpoint price the same shares from diff --git a/docs/statement-reconciliation.zh-CN.md b/docs/statement-reconciliation.zh-CN.md index 1edb604..5324d90 100644 --- a/docs/statement-reconciliation.zh-CN.md +++ b/docs/statement-reconciliation.zh-CN.md @@ -23,6 +23,8 @@ `verified_through`(当前 `2026-12-01`)是费率最后一次对照公开来源核实的交易日。之后的交易日为 `unsupported`(`..._not_verified_after_<日期>`):SEC 2027 财年 Section 31 费率尚未公布(FY2026 费率在 2027 财年拨款法案生效后 60 天内继续有效),上述 FINRA 备案虽即时生效但仍可能被 SEC 暂停。更新来源核实时需在同一变更中推进 `verified_through`。 +`Fee schedule check` 工作流(`.github/workflows/fee-schedule-check.yml`)每月 22 日 UTC 02:17 自动执行这项复核,也可手动触发(`workflow_dispatch`):`scripts/check_fee_schedule.py check` 读取 SEC Section 31 公告和 FINRA 费率表、备案列表与备案 PDF,与费率表比对;抓取或解析失败一律视为错误,不会当作通过。全部一致时 `bump` 任务把 `verified_through` 推到下月末(并同步测试里的复核日期和文档),跑完测试后开 PR,不会自动合并。费率有变化、出现新的 FINRA 费用备案、SEC 页面提到暂停或检查失败时,`notify` 任务开或更新一个 issue,费率不会被自动修改。仅使用 `GITHUB_TOKEN`,它开的 PR 不会触发 CI,合并前请关闭再重新打开 PR 以运行检查。建议把仓库变量 `FEE_CHECK_CONTACT` 设为可联系的邮箱,sec.gov 会拒绝不表明身份的请求。 + ## 估值口径差异 余额接口与持仓接口对同一批股票使用不同报价口径,因此在数量和现金一致时,`long_stock_value`(余额接口)与持仓 `market_value` 之和可能相差几分钱。`valuation_basis_difference` 把它单独报告: diff --git a/scripts/check_fee_schedule.py b/scripts/check_fee_schedule.py new file mode 100644 index 0000000..94f2887 --- /dev/null +++ b/scripts/check_fee_schedule.py @@ -0,0 +1,723 @@ +#!/usr/bin/env python3 +"""Monthly check of the Firstrade fee schedule against official sources. + +Two subcommands, both standard library only (``pdftotext`` is needed for the +FINRA filing PDFs): + +``check`` + Fetch the SEC Section 31 advisory and the FINRA Trading Activity Fee (TAF) + sources, compare them with ``application/firstrade_fee_schedule.py`` and + write a Markdown report plus a JSON result. Any fetch or parse failure is an + ``error``; any difference is ``changed``. Only a complete match is + ``unchanged``. Rates are never edited. + +``bump`` + Move ``verified_through`` to the last day of the next month, together with + the date pin in the test and the two doc mentions, as in PR #498. Used only + after ``check`` reported ``unchanged``. + +Exit codes of ``check``: 0 unchanged, 2 changed, 1 error. +Usage: python -m scripts.check_fee_schedule check --report report.md --json result.json +""" +from __future__ import annotations + +import argparse +import calendar +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from datetime import date, timedelta +from decimal import Decimal, InvalidOperation +from html.parser import HTMLParser +from pathlib import Path +from typing import Any + +from application.firstrade_fee_schedule import DEFAULT_FEE_SCHEDULE_CONFIG + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCHEDULE_FILE = REPO_ROOT / "application" / "firstrade_fee_schedule.py" +TEST_FILE = REPO_ROOT / "tests" / "test_statement_reconciliation.py" +DOC_FILES = ( + REPO_ROOT / "docs" / "statement-reconciliation.md", + REPO_ROOT / "docs" / "statement-reconciliation.zh-CN.md", +) + +SEC_INDEX_URL = "https://www.sec.gov/rules-regulations/fee-rate-advisories" +SEC_SRO_URL = "https://www.sec.gov/rules-regulations/self-regulatory-organization-rulemaking/{slug}" +FINRA_SCHEDULE_URL = ( + "https://www.finra.org/rules-guidance/rule-filings/sr-finra-2024-019/fee-adjustment-schedule") +FINRA_LISTING_URL = "https://www.finra.org/rules-guidance/rule-filings" +FINRA_FILING_URL = "https://www.finra.org/rules-guidance/rule-filings/{slug}" + +# FINRA filings whose effect is already encoded in the schedule. A listed filing +# that touches the TAF and is not in this set is reported as a change. +REVIEWED_FINRA_FILINGS = frozenset({ + "SR-FINRA-2024-019", "SR-FINRA-2025-007", "SR-FINRA-2026-020", "SR-FINRA-2026-021"}) +TAF_TITLE_RE = re.compile(r"Trading Activity Fee|Schedule A to the FINRA By-Laws|\bTAF\b", re.I) +# Filings still inside the SEC's 60-day suspension window when this was written. +WATCHED_SEC_FILINGS = ("SR-FINRA-2026-020", "SR-FINRA-2026-021") + +ALLOWED_HOSTS = ("www.sec.gov", "sec.gov", "www.finra.org", "finra.org") +MAX_BYTES = 25 * 1024 * 1024 +DEFAULT_CONTACT = "fee-check@quantstrategylab.invalid" # override with FEE_CHECK_CONTACT +STATUS_UNCHANGED, STATUS_CHANGED, STATUS_ERROR = "unchanged", "changed", "error" + + +class CheckError(Exception): + """A source could not be fetched or parsed. Never treated as a pass.""" + + +# --------------------------------------------------------------------------- # +# Fetching +# --------------------------------------------------------------------------- # + +def user_agent(contact: str | None = None) -> str: + """Identify the client. sec.gov answers 403 unless the agent carries a contact address + and, in practice, no URL or host name such as github.com, so keep it plain.""" + contact = (contact or os.environ.get("FEE_CHECK_CONTACT") or DEFAULT_CONTACT).strip() + if not re.fullmatch(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+", contact): + contact = DEFAULT_CONTACT + return f"QuantStrategyLab-fee-check/1.0 {contact}" + + +def _check_url(url: str) -> None: + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https" or parsed.hostname not in ALLOWED_HOSTS: + raise CheckError(f"refusing to fetch outside the official hosts: {url}") + + +def fetch_bytes(url: str, *, attempts: int = 3, delay: float = 2.0, timeout: int = 30) -> bytes: + """GET ``url`` over https from an official host, with a few polite retries.""" + _check_url(url) + request = urllib.request.Request(url, headers={"User-Agent": user_agent()}) + last: Exception | None = None + for attempt in range(attempts): + if attempt: + time.sleep(delay * attempt) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + _check_url(response.geturl()) + body = response.read(MAX_BYTES + 1) + if len(body) > MAX_BYTES: + raise CheckError(f"response too large: {url}") + if not body: + raise CheckError(f"empty response: {url}") + return body + except CheckError: + raise + except (urllib.error.URLError, TimeoutError, OSError) as exc: + last = exc + raise CheckError(f"fetch failed for {url}: {last}") + + +def fetch_text(url: str) -> str: + return fetch_bytes(url).decode("utf-8", errors="replace") + + +def pdf_to_text(data: bytes) -> str: + """Convert a PDF to layout text with poppler's ``pdftotext``.""" + if not data.startswith(b"%PDF-"): + raise CheckError("response is not a PDF") + exe = shutil.which("pdftotext") + if exe is None: + raise CheckError("pdftotext (poppler-utils) is not installed") + with tempfile.TemporaryDirectory() as tmp: + src = Path(tmp) / "doc.pdf" + src.write_bytes(data) + try: + proc = subprocess.run( + [exe, "-layout", str(src), "-"], capture_output=True, timeout=120, check=False) + except subprocess.TimeoutExpired: + raise CheckError("pdftotext timed out") from None + if proc.returncode != 0 or not proc.stdout.strip(): + raise CheckError(f"pdftotext failed with exit code {proc.returncode}") + return proc.stdout.decode("utf-8", errors="replace") + + +# --------------------------------------------------------------------------- # +# HTML helpers +# --------------------------------------------------------------------------- # + +class _TextParser(HTMLParser): + """Collect visible text, links, tables and headings.""" + + SKIP = {"script", "style", "noscript", "template"} + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.text: list[str] = [] + self.links: list[tuple[str, str]] = [] + self.tables: list[list[list[str]]] = [] + self.headings: list[tuple[str, str]] = [] # (tag, text) in document order + self._skip = 0 + self._link: list[str] | None = None + self._href = "" + self._table_depth = 0 + self._row: list[str] | None = None + self._cell: list[str] | None = None + self._heading: tuple[str, list[str]] | None = None + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag in self.SKIP: + self._skip += 1 + elif tag == "a": + self._link = [] + self._href = dict(attrs).get("href") or "" + elif tag == "table": + self._table_depth += 1 + self.tables.append([]) + elif tag == "tr" and self._table_depth: + self._row = [] + elif tag in ("td", "th") and self._row is not None: + self._cell = [] + elif tag in ("h1", "h2", "h3", "h4"): + self._heading = (tag, []) + elif tag in ("br", "p", "li", "div"): + self.text.append(" ") + + def handle_endtag(self, tag: str) -> None: + if tag in self.SKIP: + self._skip = max(0, self._skip - 1) + elif tag == "a" and self._link is not None: + self.links.append((self._href, _norm(" ".join(self._link)))) + self._link = None + elif tag in ("td", "th") and self._cell is not None and self._row is not None: + self._row.append(_norm(" ".join(self._cell))) + self._cell = None + elif tag == "tr" and self._row is not None and self.tables: + self.tables[-1].append(self._row) + self._row = None + elif tag == "table" and self._table_depth: + self._table_depth -= 1 + elif tag in ("h1", "h2", "h3", "h4") and self._heading is not None: + self.headings.append((self._heading[0], _norm(" ".join(self._heading[1])))) + self._heading = None + + def handle_data(self, data: str) -> None: + if self._skip: + return + self.text.append(data) + if self._link is not None: + self._link.append(data) + if self._cell is not None: + self._cell.append(data) + if self._heading is not None: + self._heading[1].append(data) + + +def _norm(text: str) -> str: + return re.sub(r"\s+", " ", text.replace("\xa0", " ")).strip() + + +def parse_html(html: str) -> _TextParser: + parser = _TextParser() + try: + parser.feed(html) + parser.close() + except Exception as exc: # HTMLParser is lenient; anything else is a failure + raise CheckError(f"HTML parse error: {exc}") from None + return parser + + +def html_text(html: str) -> str: + return _norm("".join(parse_html(html).text)) + + +# --------------------------------------------------------------------------- # +# SEC Section 31 +# --------------------------------------------------------------------------- # + +_MONTHS = {m.lower(): i for i, m in enumerate(calendar.month_abbr) if m} + + +def parse_long_date(text: str) -> date: + match = re.fullmatch(r"([A-Za-z]+)\.?\s+(\d{1,2}),\s+(\d{4})", _norm(text)) + month = _MONTHS.get(match.group(1)[:3].lower()) if match else None + if not match or not month: + raise CheckError(f"unrecognised date: {text!r}") + try: + return date(int(match.group(3)), month, int(match.group(2))) + except ValueError: + raise CheckError(f"invalid date: {text!r}") from None + + +def _money(text: str, what: str) -> Decimal: + try: + value = Decimal(text) + except InvalidOperation: + raise CheckError(f"{what}: not a number: {text!r}") from None + if not value.is_finite() or value < 0: + raise CheckError(f"{what}: out of range: {text!r}") + return value + + +@dataclass(frozen=True) +class SecAdvisory: + url: str + title: str + effective_from: date + rate_per_million: Decimal + + +def parse_sec_latest_section31(html: str, base_url: str = SEC_INDEX_URL) -> tuple[str, str]: + """Return (url, title) of the advisory listed under the latest Section 31 heading.""" + page = parse_html(html) + heading = "Latest Section 31 Transaction Fee Rate Advisory" + if not any(tag == "h2" and text == heading for tag, text in page.headings): + raise CheckError("SEC index: latest Section 31 heading not found") + # Walk the raw HTML after the heading to the first link before the next heading. + start = re.search(re.escape(heading) + r"\s*", html) + if not start: + raise CheckError("SEC index: latest Section 31 heading markup not recognised") + tail = html[start.end():] + end = re.search(r"]", tail) + block = tail[:end.start()] if end else tail + link = re.search(r']*href="([^"]+)"[^>]*>(.*?)', block, re.S) + if not link: + raise CheckError("SEC index: no advisory link under the latest Section 31 heading") + title = html_text(link.group(2)) + if "Section 31" not in title: + raise CheckError(f"SEC index: unexpected advisory title: {title!r}") + url = urllib.parse.urljoin(base_url, link.group(1)) + _check_url(url) + return url, title + + +def parse_sec_advisory(html: str, url: str, title: str) -> SecAdvisory: + """Parse the effective date and rate from a Section 31 advisory page.""" + text = html_text(html) + if "Section 31" not in text: + raise CheckError("SEC advisory: not a Section 31 page") + headline = re.search( + r"starting on ([A-Z][a-z]+\.? \d{1,2}, \d{4}), the fee rates applicable to most " + r"securities transactions will be set at \$(\d+(?:\.\d+)?) per million", text) + after = re.search( + r"a rate of \$(\d+(?:\.\d+)?) per million for covered sales occurring on charge " + r"dates on or after ([A-Z][a-z]+\.? \d{1,2}, \d{4})", text) + if not headline or not after: + raise CheckError("SEC advisory: rate sentences not found") + d1, r1 = parse_long_date(headline.group(1)), _money(headline.group(2), "SEC rate") + d2, r2 = parse_long_date(after.group(2)), _money(after.group(1), "SEC rate") + if (d1, r1) != (d2, r2): + raise CheckError("SEC advisory: the two rate sentences disagree") + return SecAdvisory(url=url, title=title, effective_from=d1, rate_per_million=r1) + + +def parse_sec_filing_page(html: str, filing: str) -> str: + """Return the text of the
region of an SEC SRO rulemaking page.""" + main = _main_text(html) + if filing not in main: + raise CheckError(f"SEC {filing}: file number not found on the page") + return main + + +def _main_text(html: str) -> str: + match = re.search(r"]*>(.*)
", html, re.S | re.I) + if not match: + raise CheckError("page has no main region") + return html_text(match.group(1)) + + +# --------------------------------------------------------------------------- # +# FINRA +# --------------------------------------------------------------------------- # + +Taf = tuple[Decimal, Decimal] # (rate per share, max per trade) + + +def parse_finra_schedule_table(html: str) -> dict[int, Taf]: + """Covered-equity TAF by year from the SR-FINRA-2024-019 fee adjustment schedule.""" + tables = [t for t in parse_html(html).tables + if any(row and row[0] == "Covered Equity Security" for row in t)] + if len(tables) != 1: + raise CheckError("FINRA schedule: expected exactly one covered-equity table") + table = tables[0] + header = next((row for row in table if row and row[0] == "Security Type"), None) + row = next(row for row in table if row and row[0] == "Covered Equity Security") + if header is None or len(header) != len(row): + raise CheckError("FINRA schedule: header and row do not line up") + result: dict[int, Taf] = {} + for head, cell in zip(header[1:], row[1:]): + year = re.match(r"(\d{4})\b", head) + values = re.fullmatch( + r"\$(\d\.\d+) per share \(up to \$(\d+\.\d{2}) max per trade\)", cell) + if not year or not values: + raise CheckError(f"FINRA schedule: unrecognised cell {head!r}: {cell!r}") + result[int(year.group(1))] = (_money(values.group(1), "TAF"), _money(values.group(2), "TAF cap")) + if len(result) < 3: + raise CheckError("FINRA schedule: too few years parsed") + return result + + +def parse_finra_listing(html: str) -> list[tuple[str, str]]: + """(filing number, title) pairs from the FINRA rule filings listing.""" + out: list[tuple[str, str]] = [] + for table in parse_html(html).tables: + for row in table: + if len(row) >= 2 and re.fullmatch(r"SR-FINRA-\d{4}-\d{3}", row[0].strip()): + out.append((row[0].strip(), row[1].strip())) + if len(out) < 10: + raise CheckError(f"FINRA listing: only {len(out)} filings parsed") + return out + + +def unreviewed_taf_filings(filings: list[tuple[str, str]]) -> list[tuple[str, str]]: + return [(num, title) for num, title in filings + if TAF_TITLE_RE.search(title) and num not in REVIEWED_FINRA_FILINGS] + + +def find_finra_pdf_url(html: str, slug: str, base_url: str) -> str: + target = f"{slug.upper()}.pdf" + for href, _ in parse_html(html).links: + if href.rsplit("/", 1)[-1] == target: + url = urllib.parse.urljoin(base_url, href) + _check_url(url) + return url + raise CheckError(f"FINRA {slug}: PDF link not found") + + +def parse_finra_pdf_taf_table(text: str) -> dict[int, Taf]: + """Covered-equity TAF by year from the 'Trading Activity Fee*' table in a filing PDF.""" + blocks = re.findall( + r"Trading Activity Fee\*(.*?)\n\s*Options\b", text, re.S) + if not blocks: + raise CheckError("FINRA PDF: TAF table not found") + parsed = [] + for block in blocks: + years = [int(y) for y in re.findall(r"\b(20\d{2})\b", block.split("Covered", 1)[0])] + rates = re.findall(r"\$(0\.\d{6})\b", block) + caps = re.findall(r"to \$(\d+\.\d{2})\b", block) + if not years or len(rates) != len(years) or len(caps) != len(years): + raise CheckError("FINRA PDF: TAF table does not have one rate and cap per year") + parsed.append({y: (_money(r, "TAF"), _money(c, "TAF cap")) + for y, r, c in zip(years, rates, caps)}) + if any(p != parsed[0] for p in parsed): + raise CheckError("FINRA PDF: TAF tables disagree with each other") + return parsed[0] + + +@dataclass(frozen=True) +class TafPause: + previous: Taf + paused_from: date + paused_through: date + + +def parse_finra_pause(text: str, start: date, through: date) -> TafPause: + """Check the pause filing text for the covered-equity rate edit and the pause window.""" + flat = _norm(text) + window = (f"from {start:%B} {start.day}, {start.year} through " + f"{through:%B} {through.day}, {through.year}") + if window not in flat: + raise CheckError(f"FINRA pause: window {window!r} not found") + edit = re.search( + r"\$\[(0\.\d{6})\]0\.00 per share for each sale of a covered equity security, " + r"with a maximum charge of \$\[(\d+\.\d{2})\]0\.00 per trade", flat) + if not edit: + raise CheckError("FINRA pause: covered-equity rate edit not found") + return TafPause((_money(edit.group(1), "TAF"), _money(edit.group(2), "TAF cap")), + start, through) + + +# --------------------------------------------------------------------------- # +# Repo encoding and comparison +# --------------------------------------------------------------------------- # + +def _rows(table_name: str, config: Mapping[str, Any]) -> list[dict[str, Any]]: + raw = config.get(table_name) + if not isinstance(raw, list) or not raw: + raise CheckError(f"schedule has no {table_name}") + return sorted(raw, key=lambda r: r["effective_from"]) + + +def _in_effect(rows: list[dict[str, Any]], day: date) -> dict[str, Any] | None: + chosen = None + for row in rows: + if date.fromisoformat(row["effective_from"]) <= day: + chosen = row + return chosen + + +def repo_taf_on(config: Mapping[str, Any], day: date) -> Taf | None: + row = _in_effect(_rows("taf_per_share", config), day) + return None if row is None else (Decimal(row["rate"]), Decimal(row["max_per_trade"])) + + +def repo_pause(config: Mapping[str, Any]) -> tuple[date, date] | None: + """(first day, last day) of a zero-rate TAF segment, if the schedule has one.""" + rows = _rows("taf_per_share", config) + for i, row in enumerate(rows): + if Decimal(row["rate"]) == 0 and i + 1 < len(rows): + return (date.fromisoformat(row["effective_from"]), + date.fromisoformat(rows[i + 1]["effective_from"]) - timedelta(days=1)) + return None + + +@dataclass +class Report: + findings: list[str] = field(default_factory=list) # differences => changed + notes: list[str] = field(default_factory=list) # what was verified + sources: list[str] = field(default_factory=list) + + def source(self, url: str) -> None: + if url not in self.sources: + self.sources.append(url) + + +def compare_sec(advisory: SecAdvisory, config: Mapping[str, Any], report: Report) -> None: + report.source(advisory.url) + last = _rows("sec_rate_per_million", config)[-1] + repo = (date.fromisoformat(last["effective_from"]), Decimal(last["rate"])) + official = (advisory.effective_from, advisory.rate_per_million) + if repo == official: + report.notes.append( + f"SEC Section 31: latest advisory ({advisory.title}) sets ${official[1]} per " + f"million from {official[0]}; matches the last schedule row.") + else: + report.findings.append( + f"SEC Section 31: latest advisory ({advisory.title}) sets ${official[1]} per " + f"million from {official[0]}, but the last schedule row is ${repo[1]} from {repo[0]}.") + + +def compare_taf_years(label: str, official: Mapping[int, Taf], config: Mapping[str, Any], + years: range, report: Report) -> None: + mismatches = [] + for year in years: + if year not in official: + mismatches.append(f"{year}: missing in {label}") + continue + repo = repo_taf_on(config, date(year, 1, 1)) + if repo != official[year]: + mismatches.append(f"{year}: {label} {official[year][0]} / {official[year][1]}, " + f"schedule {repo[0] if repo else None} / {repo[1] if repo else None}") + if mismatches: + report.findings.append(f"FINRA TAF ({label}) differs from the schedule: " + "; ".join(mismatches)) + else: + report.notes.append( + f"FINRA TAF ({label}): covered-equity rate and cap for {years.start}-{years.stop - 1} " + "match the schedule.") + + +def compare_pause(pause: TafPause, config: Mapping[str, Any], report: Report) -> None: + expected = repo_pause(config) + if expected is None: + report.findings.append("FINRA TAF pause filing is published but the schedule has no zero-rate segment.") + return + if (pause.paused_from, pause.paused_through) != expected: + report.findings.append( + f"FINRA TAF pause window {pause.paused_from}..{pause.paused_through} differs from " + f"the schedule's {expected[0]}..{expected[1]}.") + return + before = repo_taf_on(config, expected[0] - timedelta(days=1)) + if before != pause.previous: + report.findings.append( + f"FINRA TAF pause filing replaces {pause.previous}, schedule has {before} before the pause.") + return + report.notes.append( + f"FINRA TAF pause (SR-FINRA-2026-021): $0.00 from {expected[0]} through {expected[1]}; " + "matches the schedule.") + + +# --------------------------------------------------------------------------- # +# Orchestration +# --------------------------------------------------------------------------- # + +def run_check(config: Mapping[str, Any], fetch_html: Callable[[str], str], + fetch_pdf: Callable[[str], str], pause_between: float = 0.0) -> Report: + """Run every source check. Raises CheckError on any fetch or parse failure.""" + report = Report() + + def sec(url: str) -> str: + time.sleep(pause_between) # stay well under the SEC request-rate limit + return fetch_html(url) + + # SEC Section 31 + index_html = sec(SEC_INDEX_URL) + report.source(SEC_INDEX_URL) + adv_url, adv_title = parse_sec_latest_section31(index_html) + advisory = parse_sec_advisory(sec(adv_url), adv_url, adv_title) + compare_sec(advisory, config, report) + + # SEC suspension tripwire for the FINRA filings (60-day window) + for filing in WATCHED_SEC_FILINGS: + url = SEC_SRO_URL.format(slug=filing.lower()) + main = parse_sec_filing_page(sec(url), filing) + report.source(url) + if re.search(r"suspen", main, re.I): + report.findings.append(f"SEC page for {filing} mentions a suspension; review it: {url}") + else: + report.notes.append(f"SEC page for {filing}: no suspension mentioned.") + + # FINRA: original schedule table, years up to 2026 (later years were deferred) + report.source(FINRA_SCHEDULE_URL) + compare_taf_years("fee adjustment schedule SR-FINRA-2024-019", + parse_finra_schedule_table(fetch_html(FINRA_SCHEDULE_URL)), + config, range(2024, 2027), report) + + # FINRA: new TAF filings that have not been reviewed + report.source(FINRA_LISTING_URL) + new = unreviewed_taf_filings(parse_finra_listing(fetch_html(FINRA_LISTING_URL))) + if new: + report.findings.append("Unreviewed FINRA fee filings: " + "; ".join(f"{n} {t}" for n, t in new)) + else: + report.notes.append("FINRA rule filings listing: no unreviewed TAF filing.") + + # FINRA: SR-FINRA-2026-020 current schedule (2026-2031) + f20 = FINRA_FILING_URL.format(slug="sr-finra-2026-020") + report.source(f20) + pdf20 = find_finra_pdf_url(fetch_html(f20), "SR-FINRA-2026-020", f20) + report.source(pdf20) + table20 = parse_finra_pdf_taf_table(fetch_pdf(pdf20)) + compare_taf_years("SR-FINRA-2026-020", table20, config, range(2026, 2032), report) + + # FINRA: SR-FINRA-2026-021 pause + pause_window = repo_pause(config) + if pause_window is None: + report.notes.append("The schedule has no zero-rate TAF segment; the pause filing was not checked.") + else: + f21 = FINRA_FILING_URL.format(slug="sr-finra-2026-021") + report.source(f21) + pdf21 = find_finra_pdf_url(fetch_html(f21), "SR-FINRA-2026-021", f21) + report.source(pdf21) + compare_pause(parse_finra_pause(fetch_pdf(pdf21), *pause_window), config, report) + return report + + +def render_report(status: str, report: Report | None, error: str | None, today: date, + verified_through: str) -> str: + lines = [f"# Fee schedule check, {today.isoformat()}", "", + f"Status: **{status}**. Current `verified_through`: `{verified_through}`.", ""] + if error: + lines += ["## Check failed", "", f"`{error}`", "", + "A failed check is never a pass. Nothing was changed and no bump PR was opened.", ""] + if report: + if report.findings: + lines += ["## Differences", ""] + [f"- {f}" for f in report.findings] + [""] + if report.notes: + lines += ["## Verified", ""] + [f"- {n}" for n in report.notes] + [""] + if report.sources: + lines += ["## Sources", ""] + [f"- {s}" for s in report.sources] + [""] + if status != STATUS_UNCHANGED: + lines += ["Rates are never changed automatically. Review the sources, update " + "`application/firstrade_fee_schedule.py`, its tests and docs in a normal " + "PR, then move `verified_through`. Tracking issue: #497.", ""] + return "\n".join(lines) + + +def cmd_check(args: argparse.Namespace) -> int: + today = date.today() + config = DEFAULT_FEE_SCHEDULE_CONFIG + report: Report | None = None + error: str | None = None + try: + report = run_check(config, fetch_text, lambda url: pdf_to_text(fetch_bytes(url)), + pause_between=1.0) + status = STATUS_CHANGED if report.findings else STATUS_UNCHANGED + except CheckError as exc: + status, error = STATUS_ERROR, str(exc) + except Exception as exc: # never let an unexpected bug look like a pass + status, error = STATUS_ERROR, f"unexpected {type(exc).__name__}: {exc}" + markdown = render_report(status, report, error, today, str(config["verified_through"])) + Path(args.report).write_text(markdown, encoding="utf-8") + Path(args.json).write_text(json.dumps({ + "status": status, "date": today.isoformat(), "error": error, + "findings": report.findings if report else [], + "sources": report.sources if report else [], + }, indent=2), encoding="utf-8") + print(markdown) + return {STATUS_UNCHANGED: 0, STATUS_CHANGED: 2}.get(status, 1) + + +# --------------------------------------------------------------------------- # +# Bump +# --------------------------------------------------------------------------- # + +def next_month_end(today: date) -> date: + year, month = (today.year + 1, 1) if today.month == 12 else (today.year, today.month + 1) + return date(year, month, calendar.monthrange(year, month)[1]) + + +def _replace_exact(text: str, old: str, new: str, minimum: int, label: str) -> str: + count = text.count(old) + if count < minimum: + raise CheckError(f"{label}: expected at least {minimum} occurrence(s) of {old!r}, found {count}") + return text.replace(old, new) + + +def apply_bump(old: date, new: date, today: date, schedule_src: str, test_src: str, + docs: Mapping[str, str]) -> tuple[str, str, dict[str, str]]: + """Return updated file contents. Raises CheckError if any pin is not where expected.""" + if new <= old: + raise CheckError("new verified_through must be later than the current one") + old_s, new_s = old.isoformat(), new.isoformat() + schedule_src = _replace_exact( + schedule_src, f'"verified_through": "{old_s}"', f'"verified_through": "{new_s}"', 1, + "schedule verified_through") + schedule_src, n = re.subn(r"Sources \(checked \d{4}-\d{2}-\d{2}\)", + f"Sources (checked {today.isoformat()})", schedule_src) + if n != 1: + raise CheckError("schedule docstring 'Sources (checked ...)' line not found once") + # The test keeps the review pin on one line; the day-after value is derived from it. + pin_line = f"VERIFIED_THROUGH = date({old.year}, {old.month}, {old.day})" + test_src = _replace_exact( + test_src, pin_line, f"VERIFIED_THROUGH = date({new.year}, {new.month}, {new.day})", 1, + "test review pin") + if test_src.count("VERIFIED_THROUGH = date(") != 1: + raise CheckError("test review pin must appear exactly once") + new_docs = {name: _replace_exact(body, f"`{old_s}`", f"`{new_s}`", 1, f"doc {name}") + for name, body in docs.items()} + return schedule_src, test_src, new_docs + + +def cmd_bump(args: argparse.Namespace) -> int: + today = date.fromisoformat(args.today) if args.today else date.today() + old = date.fromisoformat(str(DEFAULT_FEE_SCHEDULE_CONFIG["verified_through"])) + new = next_month_end(today) + if old >= new: + print(f"verified_through {old} already covers {new}; nothing to bump.") + Path(args.output).write_text("", encoding="utf-8") + return 0 + docs = {str(p): p.read_text(encoding="utf-8") for p in DOC_FILES} + schedule_src, test_src, new_docs = apply_bump( + old, new, today, SCHEDULE_FILE.read_text(encoding="utf-8"), + TEST_FILE.read_text(encoding="utf-8"), docs) + SCHEDULE_FILE.write_text(schedule_src, encoding="utf-8") + TEST_FILE.write_text(test_src, encoding="utf-8") + for name, body in new_docs.items(): + Path(name).write_text(body, encoding="utf-8") + Path(args.output).write_text(new.isoformat(), encoding="utf-8") + print(f"verified_through {old} -> {new}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = parser.add_subparsers(dest="command", required=True) + check = sub.add_parser("check", help="compare official sources with the schedule") + check.add_argument("--report", required=True) + check.add_argument("--json", required=True) + check.set_defaults(func=cmd_check) + bump = sub.add_parser("bump", help="move verified_through to the end of next month") + bump.add_argument("--today", help="override today's date (YYYY-MM-DD)") + bump.add_argument("--output", required=True, help="file that receives the new date") + bump.set_defaults(func=cmd_bump) + args = parser.parse_args(argv) + try: + return args.func(args) + except CheckError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/fixtures/fee_schedule_check/finra_2026_020_taf.txt b/tests/fixtures/fee_schedule_check/finra_2026_020_taf.txt new file mode 100644 index 0000000..ce732a3 --- /dev/null +++ b/tests/fixtures/fee_schedule_check/finra_2026_020_taf.txt @@ -0,0 +1,17 @@ +$25 billion + *Section 1(c) of Schedule A to the FINRA By-Laws sets forth the GIA. + + Trading Activity Fee* + +Security 2026 2027 (no 2028 (no 2029 2030 2031 +Type (current) change) change) + +Covered $0.000195 $0.000195 $0.000195 $0.000232 $0.000240 $0.000249 +Equity per share (up per share (up per share (up per share (up per share (up per share (up +Security to $9.79 max to $9.79 max to $9.79 max to $11.61 to $12.05 to $12.50 + per trade) per trade) per trade) max per max per max per + trade) trade) trade) + +Options $0.00329 per $0.00329 per $0.00329 per $0.00390 per $0.00404 per $0.00420 per + contract contract contract contract contract contract + Page 10 of 65 diff --git a/tests/fixtures/fee_schedule_check/finra_2026_021_pause.txt b/tests/fixtures/fee_schedule_check/finra_2026_021_pause.txt new file mode 100644 index 0000000..dcfef8e --- /dev/null +++ b/tests/fixtures/fee_schedule_check/finra_2026_021_pause.txt @@ -0,0 +1,3 @@ +The proposed rule change would temporarily pause assessment of the TAF for three months, from October 1, 2026 through December 31, 2026, with TAF assessment resuming on January 1, 2027. 3. Self-Regulatory Organization’s Statement of the Purpose of, and Statuto +... +Section 1 — Member Regulatory Fees (a) No Change. (b) Each member shall be assessed a Trading Activity Fee for the sale of covered securities. (1) through (2) No Change. (3) Fee Rates* (A) through (E) No Change. (4) No Change. (c) through (e) No Change. ____________________________ * Trading Activity Fee rates from October 1, 2026 through December 31, 2026 are as follows: Each member shall pay to FINRA: (1) $[0.000195]0.00 per share for each sale of a covered equity security, with a maximum charge of $[9.79]0.00 per trade; (2) $[0.00329]0.00 per contract for each sale of an option; (3) $[0.000135]0.00 per contract Page 29 of 31 for each round turn transaction of a security future, provided there is a minimum charge of $[0.016]0.00 per round turn transaction; (4) $[0.00124]0.00 per bond for each sal diff --git a/tests/fixtures/fee_schedule_check/finra_filing_020.html b/tests/fixtures/fee_schedule_check/finra_filing_020.html new file mode 100644 index 0000000..fbd2a56 --- /dev/null +++ b/tests/fixtures/fee_schedule_check/finra_filing_020.html @@ -0,0 +1,4 @@ +

SR-FINRA-2026-020

+Federal Register +SR-FINRA-2026-020 +
diff --git a/tests/fixtures/fee_schedule_check/finra_filing_021.html b/tests/fixtures/fee_schedule_check/finra_filing_021.html new file mode 100644 index 0000000..cb11fcc --- /dev/null +++ b/tests/fixtures/fee_schedule_check/finra_filing_021.html @@ -0,0 +1,4 @@ +

SR-FINRA-2026-021

+Federal Register +SR-FINRA-2026-021 +
diff --git a/tests/fixtures/fee_schedule_check/finra_listing.html b/tests/fixtures/fee_schedule_check/finra_listing.html new file mode 100644 index 0000000..475c4d2 --- /dev/null +++ b/tests/fixtures/fee_schedule_check/finra_listing.html @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SR-FINRA-2026-023 Proposed Rule Change to Adopt FINRA Rule 1240.03 (Senior Leader Regulatory Element Interim Program) to Establish an Interim Program of Regulatory Element Continuing Education
SR-FINRA-2026-022 Proposed Rule Change to Amend FINRA Rules 2220, 2351, 2357, 2360, and 4210 to Address Binary Options, Options Whose Payout is Based on Specified Metrics, and Options Cleared by a Registered Clearing Agency Other than The Options Clearing Corporation
SR-FINRA-2026-021 Proposed Rule Change to Temporarily Pause Assessment of the Trading Activity Fee
SR-FINRA-2026-020 Proposed Rule Change to Modify the Implementation Schedule of Amendments to Schedule A to the FINRA By-Laws Adopted in SR-FINRA-2024-019 as Modified in SR-FINRA-2025-007
SR-FINRA-2026-019 Proposed Rule Change to Amend FINRA Rule 4522 (Periodic Security Counts, Verifications and Comparisons) to Simplify Position Statement and Reconciliation Requirements for Certain Alternative Investments
SR-FINRA-2026-018 Proposed Rule Change to Amend FINRA Rules 0150 (Application of Rules to Exempted Securities Except Municipal Securities), 2165 (Financial Exploitation of Specified Adults) and 4512 (Customer Account Information) and to Adopt New FINRA Rule 2166 (Temporary Delays for Suspected Fraud)
SR-FINRA-2026-017 Proposed Rule Change to Amend the Codes of Arbitration Procedure to Make Changes to Certain Provisions Relating to Arbitrator List Selection
SR-FINRA-2026-016 Proposed Rule Change to Amend FINRA Rule 4515.01 (Allocations of Orders Made by Investment Advisers)
SR-FINRA-2026-015 Proposed Rule Change to Amend the FINRA Rule 6300 Series (Trade Reporting Facilities) to Extend the Trade Reporting Facilities Operating Hours to 23 Hours Per Day, Five Days Per Week
SR-FINRA-2026-014 Proposed Rule Change to Amend FINRA Rule 1210 (Registration Requirements)
SR-FINRA-2026-013 Proposed Rule Change to Except Accounts Pursuant to Section 530A of the Internal Revenue Code from the Requirements of FINRA Rule 3210 (Accounts At Other Broker-Dealers and Financial Institutions)
SR-FINRA-2026-012 Proposed Rule Change to Adopt FINRA Rule 4321 (Allocations of Fail to Deliver Positions) and Amend FINRA Rule 4560 (Short-Interest Reporting)
diff --git a/tests/fixtures/fee_schedule_check/finra_schedule.html b/tests/fixtures/fee_schedule_check/finra_schedule.html new file mode 100644 index 0000000..36c25e5 --- /dev/null +++ b/tests/fixtures/fee_schedule_check/finra_schedule.html @@ -0,0 +1 @@ +

Trading Activity Fee

Security Type20242025 (no change)2026202720282029
Covered Equity Security$0.000166 per share (up to $8.30 max per trade)$0.000166 per share (up to $8.30 max per trade)$0.000195 per share (up to $9.79 max per trade)$0.000232 per share (up to $11.61 max per trade)$0.000240 per share (up to $12.05 max per trade)$0.000249 per share (up to $12.50 max per trade)
Options$0.00279 per contract$0.00279 per contract$0.00329 per contract$0.00390 per contract$0.00404 per contract$0.00420 per contract
Security Future$0.00011 per contract (with $0.014 minimum per round turn transaction)$0.00011 per contract (with $0.014 minimum per round turn transaction)$0.000135 per contract (with $0.016 minimum per round turn transaction)$0.00016 per contract (with $0.019 minimum per round turn transaction)$0.000166 per contract (with $0.020 minimum per round turn transaction)$0.000172 per contract (with $0.021 minimum per round turn transaction)
TRACE-Eligible Security (Other than Asset-Backed Security) or municipal security$0.00105 per bond (up to $1.05 max per trade)$0.00105 per bond (up to $1.05 max per trade)$0.00124 per bond (up to $1.24 max per trade)$0.00147 per bond (up to $1.47 max per trade)$0.00153 per bond (up to $1.53 max per trade)$0.00158 per bond (up to $1.58 max per trade)
TRACE-Eligible Asset-Backed Security$0.00000105 times reported value (up to $1.05 max per trade)$0.00000105 times reported value (up to $1.05 max per trade)$0.00000124 times reported value (up to $1.24 max per trade)$0.00000147 times reported value (up to $1.47 max per trade)$0.00000153 times reported value (up to $1.53 max per trade)$0.00000158 times reported value (up to $1.58 max per trade)
diff --git a/tests/fixtures/fee_schedule_check/sec_advisory.html b/tests/fixtures/fee_schedule_check/sec_advisory.html new file mode 100644 index 0000000..aa3ea54 --- /dev/null +++ b/tests/fixtures/fee_schedule_check/sec_advisory.html @@ -0,0 +1 @@ +

Section 31 Transaction Fee Rate Advisory for Fiscal Year 2026

Feb. 27, 2026 — The Securities and Exchange Commission today announced that starting on April 4, 2026, the fee rates applicable to most securities transactions will be set at $20.60 per million dollars.

Consequently, each SRO will continue to pay the Commission a rate of $0.00 per million for covered sales occurring on charge dates through April 3, 2026, and a rate of $20.60 per million for covered sales occurring on charge dates on or after April 4, 2026.

This fee rate will remain in effect until 60 calendar days after legislation is enacted that sets the amount of the Commission’s fiscal year 2027 appropriation.

For more information on the term “charge date,” please refer to Rule 31(a)(3) and Exchange Act Release No. 49928 at http://www.sec.gov/rules/final/34-49928.htm.

The assessment on security futures transactions will remain unchanged at $0.0042 for each round turn transaction.

The Commission determined these new rates in accordance with Section 31 of the Securities Exchange Act of 1934. These adjustments do not directly affect the amount of funding available to the SEC.

The Office of Interpretation and Guidance in the Commission’s Division of Trading and Markets is available for questions on Section 31 at (202) 551-5777 or tradingandmarkets@sec.gov.

The Commission will issue further notices as appropriate to keep the public informed of developments relating to fees under Section 31. These notices will be posted on the Fee Rate Advisories page of the SEC website.

diff --git a/tests/fixtures/fee_schedule_check/sec_filing_020.html b/tests/fixtures/fee_schedule_check/sec_filing_020.html new file mode 100644 index 0000000..13b5151 --- /dev/null +++ b/tests/fixtures/fee_schedule_check/sec_filing_020.html @@ -0,0 +1,6 @@ +
+

Notice of Filing and Immediate Effectiveness of a Proposed Rule Change to Modify the Implementation Schedule of Amendments to Schedule A to the FINRA By-Laws Adopted in SR-FINRA-2024-019 as Modified in SR-FINRA-2025-007

+

Registered Securities Associations, Financial Industry Regulatory Authority (FINRA)

+

Last Reviewed or Updated: Sept. 23, 2026

+

Details

Public Comments Due
Oct. 14, 2026
File Number
SR-FINRA-2026-020
Rule Type
Notice
+
diff --git a/tests/fixtures/fee_schedule_check/sec_filing_021.html b/tests/fixtures/fee_schedule_check/sec_filing_021.html new file mode 100644 index 0000000..795105e --- /dev/null +++ b/tests/fixtures/fee_schedule_check/sec_filing_021.html @@ -0,0 +1,6 @@ +
+

Notice of Filing and Immediate Effectiveness of a Proposed Rule Change to Temporarily Pause Assessment of the Trading Activity Fee

+

Registered Securities Associations, Financial Industry Regulatory Authority (FINRA)

+

Last Reviewed or Updated: Sept. 23, 2026

+

Details

Public Comments Due
Oct. 14, 2026
File Number
SR-FINRA-2026-021
Rule Type
Notice
+
diff --git a/tests/fixtures/fee_schedule_check/sec_index.html b/tests/fixtures/fee_schedule_check/sec_index.html new file mode 100644 index 0000000..bcf569b --- /dev/null +++ b/tests/fixtures/fee_schedule_check/sec_index.html @@ -0,0 +1 @@ +

Latest Section 6(b) Filing Fee Rate Advisory

Latest Section 31 Transaction Fee Rate Advisory


diff --git a/tests/test_check_fee_schedule.py b/tests/test_check_fee_schedule.py new file mode 100644 index 0000000..69e776c --- /dev/null +++ b/tests/test_check_fee_schedule.py @@ -0,0 +1,373 @@ +"""Offline tests for scripts/check_fee_schedule.py. Fixtures are trimmed saved pages.""" + +from __future__ import annotations + +import copy +from datetime import date, timedelta +from decimal import Decimal +from pathlib import Path + +import pytest + +from application.firstrade_fee_schedule import DEFAULT_FEE_SCHEDULE_CONFIG +from scripts import check_fee_schedule as cfs + +FIXTURES = Path(__file__).parent / "fixtures" / "fee_schedule_check" +TAF_2026 = (Decimal("0.000195"), Decimal("9.79")) + + +def fixture(name: str) -> str: + return (FIXTURES / name).read_text(encoding="utf-8") + + +# Frozen copy of the rate tables the saved fixtures were taken against, so that a later, +# legitimate rate edit in the schedule does not break the parser tests. +FROZEN_CONFIG = { + "verified_through": "2026-12-01", + "sec_rate_per_million": [ + {"effective_from": "2024-05-22", "rate": "27.80"}, + {"effective_from": "2025-05-14", "rate": "0.00"}, + {"effective_from": "2026-04-04", "rate": "20.60"}, + ], + "taf_per_share": [ + {"effective_from": "2024-01-01", "rate": "0.000166", "max_per_trade": "8.30"}, + {"effective_from": "2026-01-01", "rate": "0.000195", "max_per_trade": "9.79"}, + {"effective_from": "2026-10-01", "rate": "0", "max_per_trade": "0"}, + {"effective_from": "2027-01-01", "rate": "0.000195", "max_per_trade": "9.79"}, + {"effective_from": "2029-01-01", "rate": "0.000232", "max_per_trade": "11.61"}, + {"effective_from": "2030-01-01", "rate": "0.000240", "max_per_trade": "12.05"}, + {"effective_from": "2031-01-01", "rate": "0.000249", "max_per_trade": "12.50"}, + ], +} + + +def config() -> dict: + return copy.deepcopy(FROZEN_CONFIG) + + +# --- SEC --------------------------------------------------------------------------------- + +def test_sec_index_finds_latest_section_31_advisory(): + url, title = cfs.parse_sec_latest_section31(fixture("sec_index.html")) + assert url == "https://www.sec.gov/rules-regulations/fee-rate-advisories/2026-2" + assert title == "Section 31 Transaction Fee Rate Advisory for Fiscal Year 2026" + + +def test_sec_index_without_the_heading_fails(): + with pytest.raises(cfs.CheckError): + cfs.parse_sec_latest_section31("

Something else

") + + +def test_sec_index_rejects_links_off_official_hosts(): + html = fixture("sec_index.html").replace("https://www.sec.gov/rules-regulations/fee-rate-advisories/2026-2", + "https://example.com/advisory") + with pytest.raises(cfs.CheckError): + cfs.parse_sec_latest_section31(html) + + +def test_sec_advisory_parses_effective_date_and_rate(): + advisory = cfs.parse_sec_advisory(fixture("sec_advisory.html"), cfs.SEC_INDEX_URL, "Section 31 test") + assert advisory.effective_from == date(2026, 4, 4) + assert advisory.rate_per_million == Decimal("20.60") + + +def test_sec_advisory_with_disagreeing_sentences_fails(): + html = fixture("sec_advisory.html").replace("a rate of $20.60 per million", "a rate of $21.60 per million") + with pytest.raises(cfs.CheckError, match="disagree"): + cfs.parse_sec_advisory(html, cfs.SEC_INDEX_URL, "Section 31 test") + + +def test_sec_advisory_with_unrecognised_wording_fails(): + with pytest.raises(cfs.CheckError): + cfs.parse_sec_advisory("
Section 31 fees changed, details soon.
", cfs.SEC_INDEX_URL, "t") + with pytest.raises(cfs.CheckError): + cfs.parse_sec_advisory("
Unrelated page.
", cfs.SEC_INDEX_URL, "t") + + +def test_sec_filing_page_ignores_navigation_when_looking_for_suspension(): + # The fixture's nav links to "Trading Suspensions"; only
counts. + main = cfs.parse_sec_filing_page(fixture("sec_filing_020.html"), "SR-FINRA-2026-020") + assert "suspen" not in main.lower() + suspended = fixture("sec_filing_020.html").replace( + "

Details

", "

Details

The Commission temporarily suspended the rule change.

") + assert "suspen" in cfs.parse_sec_filing_page(suspended, "SR-FINRA-2026-020").lower() + + +def test_sec_filing_page_for_the_wrong_filing_fails(): + with pytest.raises(cfs.CheckError): + cfs.parse_sec_filing_page(fixture("sec_filing_021.html"), "SR-FINRA-2026-020") + with pytest.raises(cfs.CheckError, match="no main"): + cfs.parse_sec_filing_page("SR-FINRA-2026-020", "SR-FINRA-2026-020") + + +# --- FINRA ------------------------------------------------------------------------------- + +def test_finra_schedule_table_gives_covered_equity_rates_by_year(): + table = cfs.parse_finra_schedule_table(fixture("finra_schedule.html")) + assert table[2024] == (Decimal("0.000166"), Decimal("8.30")) + assert table[2026] == (Decimal("0.000195"), Decimal("9.79")) + + +def test_finra_schedule_table_missing_fails(): + with pytest.raises(cfs.CheckError): + cfs.parse_finra_schedule_table("

No tables

") + + +def test_finra_listing_parses_numbers_and_titles(): + filings = cfs.parse_finra_listing(fixture("finra_listing.html")) + assert len(filings) >= 10 + assert all(num.startswith("SR-FINRA-") and title for num, title in filings) + + +def test_finra_listing_with_too_few_rows_fails(): + with pytest.raises(cfs.CheckError): + cfs.parse_finra_listing("
SR-FINRA-2026-001One
") + + +def test_unreviewed_taf_filings_flags_only_new_taf_filings(): + filings = [ + ("SR-FINRA-2026-020", "Modify the Implementation Schedule of Amendments to Schedule A to the FINRA By-Laws"), + ("SR-FINRA-2026-021", "Temporarily Pause Assessment of the Trading Activity Fee"), + ("SR-FINRA-2026-030", "Adjust the Trading Activity Fee"), + ("SR-FINRA-2026-031", "Amend Rule 1240 on continuing education"), + ] + assert cfs.unreviewed_taf_filings(filings) == [("SR-FINRA-2026-030", "Adjust the Trading Activity Fee")] + + +def test_find_finra_pdf_url_picks_the_filing_itself(): + url = cfs.find_finra_pdf_url( + fixture("finra_filing_020.html"), "sr-finra-2026-020", + "https://www.finra.org/rules-guidance/rule-filings/sr-finra-2026-020") + assert url == "https://www.finra.org/sites/default/files/2026-09/SR-FINRA-2026-020.pdf" + + +def test_find_finra_pdf_url_missing_or_off_host_fails(): + base = "https://www.finra.org/rules-guidance/rule-filings/x" + with pytest.raises(cfs.CheckError): + cfs.find_finra_pdf_url("x", "sr-finra-2026-020", base) + with pytest.raises(cfs.CheckError): + cfs.find_finra_pdf_url("x", + "sr-finra-2026-020", base) + + +def test_finra_pdf_taf_table_by_year(): + table = cfs.parse_finra_pdf_taf_table(fixture("finra_2026_020_taf.txt")) + assert sorted(table) == [2026, 2027, 2028, 2029, 2030, 2031] + assert table[2026] == TAF_2026 + assert table[2029] == (Decimal("0.000232"), Decimal("11.61")) + assert table[2031] == (Decimal("0.000249"), Decimal("12.50")) + + +def test_finra_pdf_taf_table_with_a_missing_cap_fails(): + text = fixture("finra_2026_020_taf.txt").replace("to $12.50", "to 12.50") + with pytest.raises(cfs.CheckError): + cfs.parse_finra_pdf_taf_table(text) + with pytest.raises(cfs.CheckError, match="not found"): + cfs.parse_finra_pdf_taf_table("nothing here") + + +def test_finra_pause_window_and_previous_rate(): + pause = cfs.parse_finra_pause(fixture("finra_2026_021_pause.txt"), date(2026, 10, 1), date(2026, 12, 31)) + assert pause.previous == TAF_2026 + assert (pause.paused_from, pause.paused_through) == (date(2026, 10, 1), date(2026, 12, 31)) + + +def test_finra_pause_with_a_different_window_fails(): + with pytest.raises(cfs.CheckError, match="window"): + cfs.parse_finra_pause(fixture("finra_2026_021_pause.txt"), date(2026, 10, 1), date(2027, 1, 31)) + with pytest.raises(cfs.CheckError, match="edit"): + cfs.parse_finra_pause("from October 1, 2026 through December 31, 2026", date(2026, 10, 1), + date(2026, 12, 31)) + + +# --- Comparison -------------------------------------------------------------------------- + +def sec_advisory(rate: str = "20.60", effective: date = date(2026, 4, 4)) -> cfs.SecAdvisory: + return cfs.SecAdvisory("https://www.sec.gov/x", "advisory", effective, Decimal(rate)) + + +def test_compare_sec_matches_and_differs(): + ok = cfs.Report() + cfs.compare_sec(sec_advisory(), config(), ok) + assert not ok.findings and ok.notes + + changed = cfs.Report() + cfs.compare_sec(sec_advisory("21.00", date(2026, 11, 1)), config(), changed) + assert len(changed.findings) == 1 + assert "21.00" in changed.findings[0] + + +def test_compare_taf_years_reports_each_difference_and_missing_years(): + official = {2026: TAF_2026, 2027: (Decimal("0.000300"), Decimal("9.79"))} + report = cfs.Report() + cfs.compare_taf_years("test", official, config(), range(2026, 2029), report) + assert len(report.findings) == 1 + assert "2027" in report.findings[0] and "2028: missing" in report.findings[0] + + +def test_compare_pause_checks_window_and_previous_rate(): + cfg = config() + window = cfs.repo_pause(cfg) + assert window == (date(2026, 10, 1), date(2026, 12, 31)) + + ok = cfs.Report() + cfs.compare_pause(cfs.TafPause(TAF_2026, *window), cfg, ok) + assert not ok.findings + + shifted = cfs.Report() + cfs.compare_pause(cfs.TafPause(TAF_2026, window[0], date(2027, 1, 31)), cfg, shifted) + assert shifted.findings + + other_rate = cfs.Report() + cfs.compare_pause(cfs.TafPause((Decimal("0.0003"), Decimal("9.79")), *window), cfg, other_rate) + assert other_rate.findings + + +# --- Whole run, with the saved pages instead of the network ------------------------------- + +SEC = "https://www.sec.gov/rules-regulations/" +FINRA = "https://www.finra.org/rules-guidance/rule-filings" +PAGES = { + cfs.SEC_INDEX_URL: "sec_index.html", + SEC + "fee-rate-advisories/2026-2": "sec_advisory.html", + SEC + "self-regulatory-organization-rulemaking/sr-finra-2026-020": "sec_filing_020.html", + SEC + "self-regulatory-organization-rulemaking/sr-finra-2026-021": "sec_filing_021.html", + cfs.FINRA_SCHEDULE_URL: "finra_schedule.html", + cfs.FINRA_LISTING_URL: "finra_listing.html", + FINRA + "/sr-finra-2026-020": "finra_filing_020.html", + FINRA + "/sr-finra-2026-021": "finra_filing_021.html", +} +PDFS = { + "https://www.finra.org/sites/default/files/2026-09/SR-FINRA-2026-020.pdf": "finra_2026_020_taf.txt", + "https://www.finra.org/sites/default/files/2026-09/SR-FINRA-2026-021.pdf": "finra_2026_021_pause.txt", +} + + +def offline_run(cfg: dict, overrides: dict[str, str] | None = None) -> cfs.Report: + pages = {**{u: fixture(n) for u, n in PAGES.items()}, **(overrides or {})} + + def fetch_html(url: str) -> str: + if url not in pages: + raise cfs.CheckError(f"unexpected fetch {url}") + return pages[url] + + def fetch_pdf(url: str) -> str: + if url not in PDFS: + raise cfs.CheckError(f"unexpected fetch {url}") + return fixture(PDFS[url]) + + return cfs.run_check(cfg, fetch_html, fetch_pdf) + + +def test_full_run_is_unchanged_for_the_frozen_schedule(): + report = offline_run(config()) + assert report.findings == [] + assert len(report.notes) == 7 + assert all(url.startswith(("https://www.sec.gov/", "https://www.finra.org/")) for url in report.sources) + + +def test_full_run_reports_a_sec_rate_change(): + cfg = config() + cfg["sec_rate_per_million"][-1]["rate"] = "19.00" + assert any("SEC Section 31" in f for f in offline_run(cfg).findings) + + +def test_full_run_reports_a_taf_change(): + cfg = config() + cfg["taf_per_share"][-1]["rate"] = "0.000200" + assert offline_run(cfg).findings + + +def test_full_run_reports_an_sec_suspension(): + page = fixture("sec_filing_021.html").replace( + "

Details

", "

Details

Order temporarily suspending the proposed rule change.

") + key = SEC + "self-regulatory-organization-rulemaking/sr-finra-2026-021" + assert any("suspension" in f for f in offline_run(config(), {key: page}).findings) + + +def test_full_run_reports_a_new_taf_filing(): + listing = fixture("finra_listing.html").replace( + "Senior Leader Regulatory Element", "Trading Activity Fee Increase", 1) + findings = offline_run(config(), {cfs.FINRA_LISTING_URL: listing}).findings + assert any("Unreviewed FINRA fee filings" in f for f in findings) + + +def test_full_run_fails_when_a_source_cannot_be_parsed(): + with pytest.raises(cfs.CheckError): + offline_run(config(), {cfs.SEC_INDEX_URL: "
redesigned page
"}) + with pytest.raises(cfs.CheckError): + offline_run(config(), {cfs.FINRA_SCHEDULE_URL: "
"}) + + +# --- Rendering, fetching, exit codes ----------------------------------------------------- + +def test_render_report_never_calls_an_error_a_pass(): + text = cfs.render_report(cfs.STATUS_ERROR, None, "fetch failed", date(2026, 11, 22), "2026-12-01") + assert "Status: **error**" in text and "never a pass" in text + assert "#497" in text + + +def test_fetch_rejects_non_https_and_foreign_hosts(): + for url in ("http://www.sec.gov/x", "https://example.com/x", "https://sec.gov.evil.example/x", + "file:///etc/passwd"): + with pytest.raises(cfs.CheckError): + cfs.fetch_bytes(url, attempts=1) + + +def test_user_agent_carries_a_contact_and_no_url(): + assert "@" in cfs.user_agent() + assert "ops@example.org" in cfs.user_agent("ops@example.org") + assert "github" not in cfs.user_agent("not an address").lower() + + +def test_pdf_to_text_rejects_non_pdf_bytes(): + with pytest.raises(cfs.CheckError): + cfs.pdf_to_text(b"not a pdf") + + +# --- Bump -------------------------------------------------------------------------------- + +def test_next_month_end(): + assert cfs.next_month_end(date(2026, 11, 22)) == date(2026, 12, 31) + assert cfs.next_month_end(date(2026, 12, 22)) == date(2027, 1, 31) + assert cfs.next_month_end(date(2027, 1, 22)) == date(2027, 2, 28) + + +def read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def current_pin() -> date: + return date.fromisoformat(str(DEFAULT_FEE_SCHEDULE_CONFIG["verified_through"])) + + +def test_apply_bump_moves_every_pin_and_nothing_else(): + old = current_pin() + new = old + timedelta(days=30) + today = old + docs = {str(p): read(p) for p in cfs.DOC_FILES} + schedule, test, new_docs = cfs.apply_bump( + old, new, today, read(cfs.SCHEDULE_FILE), read(cfs.TEST_FILE), docs) + assert f'"verified_through": "{new}"' in schedule + assert f"Sources (checked {today})" in schedule + assert f"VERIFIED_THROUGH = date({new.year}, {new.month}, {new.day})" in test + assert all(f"`{new}`" in body and f"`{old}`" not in body for body in new_docs.values()) + before = read(cfs.SCHEDULE_FILE).splitlines() + changed = [a for a, b in zip(before, schedule.splitlines(), strict=True) if a != b] + assert len(changed) <= 2 # the pin and the "checked" date; no rate row moves + assert all("rate" not in line and "max_per_trade" not in line for line in changed) + + +def test_apply_bump_fails_closed_when_a_pin_is_missing(): + old = current_pin() + new = old + timedelta(days=30) + docs = {str(p): read(p) for p in cfs.DOC_FILES} + schedule, test = read(cfs.SCHEDULE_FILE), read(cfs.TEST_FILE) + with pytest.raises(cfs.CheckError): # the schedule does not carry this date + cfs.apply_bump(old - timedelta(days=1), new, old, schedule, test, docs) + with pytest.raises(cfs.CheckError): + cfs.apply_bump(old, new, old, schedule, "no pin here", docs) + with pytest.raises(cfs.CheckError): + cfs.apply_bump(old, new, old, schedule, test, {"doc.md": "no date"}) + with pytest.raises(cfs.CheckError): + cfs.apply_bump(old, old, old, schedule, test, docs) diff --git a/tests/test_statement_reconciliation.py b/tests/test_statement_reconciliation.py index 53e6240..0d7d0e0 100644 --- a/tests/test_statement_reconciliation.py +++ b/tests/test_statement_reconciliation.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import date +from datetime import date, timedelta from decimal import Decimal import pytest @@ -16,6 +16,11 @@ valuation_basis_report, ) +# Review pin: the date the fee schedule is expected to be verified through. The monthly +# fee-schedule check workflow moves this line together with ``verified_through``. +VERIFIED_THROUGH = date(2026, 12, 1) +DAY_AFTER_VERIFIED = VERIFIED_THROUGH + timedelta(days=1) + # Shape of the Firstrade history CSV row for the 2026-08-28 margin sellout. # ``Date`` is the trade date (matches EXEC TIME); S/D is the T+1 settlement date. AUG_28_IBIT_SELL = { @@ -112,16 +117,17 @@ def test_taf_increase_is_deferred_to_2029_by_sr_finra_2026_020(): def test_dates_after_verified_through_are_unsupported(): schedule = load_fee_schedule() - assert schedule.verified_through == date(2026, 12, 1) - assert schedule.sec_rate_on(date(2026, 12, 1)) == Decimal("20.60") + pin = VERIFIED_THROUGH.isoformat() + assert schedule.verified_through == VERIFIED_THROUGH + assert schedule.sec_rate_on(VERIFIED_THROUGH) == Decimal("20.60") for lookup in (schedule.sec_rate_on, schedule.taf_on, schedule.broker_assisted_fee_on): - with pytest.raises(FeeScheduleError, match="not_verified_after_2026-12-01"): - lookup(date(2026, 12, 2)) + with pytest.raises(FeeScheduleError, match=f"not_verified_after_{pin}"): + lookup(DAY_AFTER_VERIFIED) # SEC FY2027 Section 31 rate is not published: a later sale must not borrow FY2026's. - late = decompose_sell_fees(sell_on("2026-12-02")) + late = decompose_sell_fees(sell_on(DAY_AFTER_VERIFIED.isoformat())) assert late["status"] == "unsupported" - assert "not_verified_after_2026-12-01" in late["reason"] - assert decompose_sell_fees(sell_on("2026-12-01"))["status"] != "unsupported" + assert f"not_verified_after_{pin}" in late["reason"] + assert decompose_sell_fees(sell_on(pin))["status"] != "unsupported" def test_verified_through_is_required_and_must_be_a_date():