settfex 0.22.0__tar.gz → 0.22.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {settfex-0.22.0 → settfex-0.22.2}/AGENTS.md +10 -1
- {settfex-0.22.0 → settfex-0.22.2}/CHANGELOG.md +103 -0
- {settfex-0.22.0 → settfex-0.22.2}/CLAUDE.md +7 -1
- {settfex-0.22.0 → settfex-0.22.2}/PKG-INFO +1 -1
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/sec/financial_report.md +24 -1
- {settfex-0.22.0 → settfex-0.22.2}/pyproject.toml +1 -1
- {settfex-0.22.0 → settfex-0.22.2}/settfex/__init__.py +1 -1
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/sec/constants.py +9 -1
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/sec/download.py +69 -3
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/sec/financial_report.py +134 -3
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/sec/utils.py +26 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/golden/api_surface.json +2 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/derive_th_fixtures.py +36 -17
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/fixtures_sec/README.md +8 -2
- settfex-0.22.2/tests/services/sec/fixtures_sec/capital_indirection_en.html +6 -1
- settfex-0.22.2/tests/services/sec/fixtures_sec/en_ptt_viewmore_56_2.html +1 -0
- settfex-0.22.2/tests/services/sec/fixtures_sec/idisc_505_error_page.html +1 -0
- settfex-0.22.2/tests/services/sec/fixtures_sec/th_cpall_viewmore_56_1.html +1 -0
- settfex-0.22.2/tests/services/sec/test_download_indirection.py +184 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/test_financial_report_accounting.py +39 -0
- settfex-0.22.2/tests/services/sec/test_listing_transport.py +305 -0
- settfex-0.22.2/tests/services/sec/test_view_more_slugs.py +166 -0
- {settfex-0.22.0 → settfex-0.22.2}/uv.lock +1 -1
- {settfex-0.22.0 → settfex-0.22.2}/.editorconfig +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.github/dependabot.yml +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.github/instructions/core-architectrual-principles.instructions.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.github/instructions/documentation-standards.instructions.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.github/instructions/file-organization.instructions.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.github/instructions/git-commit.instructions.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.github/instructions/python-dependency-management.instructions.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.github/prompts/Coding.prompt.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.github/prompts/Git-Commit-Reviewer.prompt.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.github/prompts/Prompt-Engineer.prompt.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.github/prompts/Python-Architect.prompt.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.github/workflows/ci.yml +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.github/workflows/dependency-drift.yml +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.github/workflows/pre-commit.yml +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.github/workflows/release.yml +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.github/workflows/security.yml +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.gitignore +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.pre-commit-config.yaml +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/.python-version +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/CODE_OF_CONDUCT.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/COMPREHENSIVE_AUDIT.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/CONTRIBUTING.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/LICENSE +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/README.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/RELEASING.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/SECURITY.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/guide/PYTHON_LIBRARY_BEST_PRACTICES.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/index.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/API_PROTECTION_NOTE.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/analyst_consensus.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/board_of_director.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/chart_quotation.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/corporate_action.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/dr_indicative_price.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/earnings_call.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/financial.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/highlight_data.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/holiday.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/index.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/info.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/latest_historical_trading.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/list.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/news.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/nvdr_holder.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/price_performance.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/profile_company.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/profile_dr.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/profile_stock.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/shareholder.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/set/trading_stat.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/tfex/list.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/tfex/trading_statistics.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/services/thaibma/yield_curve.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/utils/data_fetcher.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/settfex/utils/session_caching.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/solution/FINDINGS.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/solution/Performance Boost Session Caching Implementation.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/solution/SESSION_CACHE_SUMMARY.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/docs/solution/SOLUTION_100_PERCENT.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/README.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/sec/01_financial_report.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/01_stock_list.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/02_highlight_data.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/03_stock_profile.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/04_company_profile.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/05_corporate_action.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/06_shareholder.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/07_nvdr_holder.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/08_board_of_director.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/09_trading_statistics.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/10_price_performance.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/11_financial.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/12_earnings_call.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/13_chart_quotation.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/14_latest_historical_trading.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/15_market_index.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/16_news.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/17_holiday.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/18_dr_and_asset_type.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/19_analyst_consensus.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/20_stock_info.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/README.md +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/cpall_balance_sheet.csv +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/market_dashboard_20251005_173221.csv +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/market_dashboard_20251005_210737.csv +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/market_dashboard_20251006_091440.csv +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/thailand_banking_stocks.csv +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/thailand_set_main_board.csv +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/set/thailand_stock_universe.csv +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/tfex/01_series_list.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/tfex/02_trading_statistics.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/tfex/03_underlying_price.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/examples/thaibma/01_government_yield_curve.ipynb +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/exceptions.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/py.typed +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/sec/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/sec/company.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/sec/sec.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/asset_type.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/constants.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/earnings_call.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/holiday.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/index/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/index/chart_quotation.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/index/composition.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/index/index.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/index/info.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/index/list.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/index/utils.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/list.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/news.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/analyst_consensus.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/board_of_director.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/chart_quotation.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/corporate_action.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/dr_indicative_price.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/financial/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/financial/financial.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/highlight_data.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/info.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/latest_historical_trading.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/nvdr_holder.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/price_performance.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/profile_company.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/profile_dr.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/profile_stock.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/shareholder.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/stock.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/trading_stat.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/set/stock/utils.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/tfex/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/tfex/constants.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/tfex/list.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/tfex/trading_statistics.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/tfex/underlying_price.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/thaibma/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/thaibma/availability.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/thaibma/constants.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/thaibma/history.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/thaibma/thaibma.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/thaibma/utils.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/services/thaibma/yield_curve.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/utils/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/utils/data_fetcher.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/utils/http.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/utils/logging.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/utils/parsing.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/utils/session_cache.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/utils/session_manager.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/settfex/utils/youtube_transcript.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/conftest.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/golden/model_contract/analyst_consensus.json +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/golden/model_contract/dr_profile.json +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/golden/model_contract/earnings_call_item.json +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/golden/model_contract/highlight_data.json +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/golden/model_contract/holiday_calendar.json +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/golden/model_contract/index_list_response.json +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/golden/model_contract/news_search_response.json +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/golden/model_contract/sec_company_match.json +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/golden/model_contract/stock_info.json +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/golden/model_contract/stock_list_response.json +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/golden/model_contract/tfex_series_list_response.json +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/golden/model_contract/tfex_trading_statistics.json +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/golden/model_contract/tfex_underlying_price.json +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/golden/model_contract/yield_curve.json +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/integration/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/integration/test_live_endpoints.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/fixtures.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/fixtures_sec/en_mother_fs_h1_2025.html +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/fixtures_sec/en_ptt_56_1.html +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/fixtures_sec/en_ptt_56_2.html +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/fixtures_sec/en_ptt_fs_h1_2025.html +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/fixtures_sec/th_mother_fs_h1_2025.html +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/fixtures_sec/th_ptt_56_1.html +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/fixtures_sec/th_ptt_56_2.html +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/fixtures_sec/th_ptt_fs_h1_2025.html +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/test_company.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/test_download.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/test_download_result.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/test_financial_report.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/test_financial_report_th.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/test_sec.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/sec/test_utils.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/index/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/index/test_chart_quotation.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/index/test_composition.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/index/test_index.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/index/test_info.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/index/test_list.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/stock/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/stock/financial/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/stock/financial/test_financial.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/stock/test_analyst_consensus.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/stock/test_chart_quotation.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/stock/test_dr_indicative_price.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/stock/test_info.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/stock/test_latest_historical_trading.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/stock/test_profile_dr.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/test_asset_type.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/test_board_of_director.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/test_client.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/test_corporate_action.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/test_earnings_call.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/test_historical.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/test_holiday.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/test_list.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/test_news.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/test_profile_company.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/test_realtime.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/test_shareholder.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/set/test_symbol_suggestion.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/tfex/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/tfex/test_client.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/tfex/test_historical.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/tfex/test_list.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/tfex/test_realtime.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/tfex/test_trading_statistics.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/tfex/test_underlying_price.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/thaibma/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/thaibma/fixtures.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/thaibma/test_availability.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/thaibma/test_history.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/thaibma/test_thaibma.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/thaibma/test_utils.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/services/thaibma/test_yield_curve.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/test_impersonate_target.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/test_model_contract.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/test_public_api_surface.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/utils/__init__.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/utils/test_data_fetcher.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/utils/test_formatting.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/utils/test_http.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/utils/test_logging.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/utils/test_parsing.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/utils/test_session_cache.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/utils/test_session_manager.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/utils/test_validation.py +0 -0
- {settfex-0.22.0 → settfex-0.22.2}/tests/utils/test_youtube_transcript.py +0 -0
|
@@ -126,11 +126,20 @@ window. `docs.accounting` is what the *parser* did with the rows it got: `accoun
|
|
|
126
126
|
is the one flag to branch on, `accounting.no_link` counts rows whose download link was missing, and
|
|
127
127
|
`accounting.unmapped_headers` names columns the site serves that this library does not model. A
|
|
128
128
|
listing where **every** usable row was lost raises `IncompleteListingError` rather than returning
|
|
129
|
-
`[]`, so an empty list you actually receive means an empty result.
|
|
129
|
+
`[]`, so an empty list you actually receive means an empty result. The same now holds for the
|
|
130
|
+
transport: a failed request raises `FetchError`, and a body that is not a listing page raises
|
|
131
|
+
`ParseError`, instead of parsing to zero rows and looking like "this issuer filed nothing".
|
|
132
|
+
|
|
133
|
+
One thing deliberately does **not** raise: a broken "display all results" page. That section keeps
|
|
134
|
+
its truncated inline rows and the shortfall shows in `completeness()` — so check that before
|
|
135
|
+
treating a section as complete. As of 2026-09-20 SEC's Thai `fs-kf` page answers HTTP 500, which is
|
|
136
|
+
an upstream bug, not a settfex one.
|
|
130
137
|
|
|
131
138
|
`download_sec_documents(...)` returns a `DownloadResult` — a list of the files that downloaded,
|
|
132
139
|
which also carries `.failed` (each with its target and reason) and `.is_complete`. Check it before
|
|
133
140
|
reporting a batch as done; dead links on the SEC host arrive as HTML under HTTP 200, not as errors.
|
|
141
|
+
⚠️ Read `.failed` off **that** object: it is a `list` subclass, so slicing, `sorted()`, `list()` and
|
|
142
|
+
comprehensions all return a plain `list` and drop the failure report without warning.
|
|
134
143
|
|
|
135
144
|
### ThaiBMA bonds — `from settfex.services.thaibma import ...`
|
|
136
145
|
|
|
@@ -7,6 +7,109 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.22.2] - 2026-09-20
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **56-1 and 56-2 sections truncated even with `follow_view_more=True`, because their
|
|
15
|
+
"display all results" slugs were unmapped.** `_CATEGORY_FOR_VIEWMORE_SLUG` covered `fs-norm`,
|
|
16
|
+
`fs-kf` and `fs-mda` — the three sections a single `FS` search returns — and nothing else. A
|
|
17
|
+
56-1 or 56-2 section over the site's ~10-row inline cap therefore linked to its complete list
|
|
18
|
+
through a slug the map did not recognise, and the link was skipped.
|
|
19
|
+
|
|
20
|
+
0.22.0's own notes predicted this and said the shortfall WARNING would be what surfaced it. It
|
|
21
|
+
was, from the other side of the API: a consumer read `completeness()` and reported 10 of 12 on a
|
|
22
|
+
Thai 56-1. Both slugs were then **live-probed rather than guessed** (CPALL / PTT / SCB × th / en,
|
|
23
|
+
2026-09-20), which is how the second one was found at all.
|
|
24
|
+
|
|
25
|
+
The gap was larger than first reported, and not Thai-only. Over a 2000–2026 window:
|
|
26
|
+
|
|
27
|
+
| Listing | Before | After |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| CPALL 56-1 `th` | 11 of 23 | **23 of 23** |
|
|
30
|
+
| PTT 56-1 `th` | 11 of 25 | **25 of 25** |
|
|
31
|
+
| PTT 56-2 `th` | 10 of 15 | **15 of 15** |
|
|
32
|
+
| PTT 56-2 `en` | 10 of 15 | **15 of 15** |
|
|
33
|
+
|
|
34
|
+
Note the `fs-` prefix is the site's own and is **not** a category hint — `fs-r561` is served by
|
|
35
|
+
the 56-1 search, not the FS one — so a mapping keyed on the prefix would be wrong.
|
|
36
|
+
|
|
37
|
+
No API change: the mapping is module-private, and the public `SEC_VIEWMORE_SLUGS` constant (which
|
|
38
|
+
has no caller, and so would not notice itself going stale) is updated to match, with a test
|
|
39
|
+
pinning the two equal. The generated API surface is byte-identical to 0.22.1.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
## [0.22.1] - 2026-09-20
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **The SEC listing path never checked the HTTP status, so an error page came back as an empty
|
|
47
|
+
list** (issue #131). `_run_search` returned `post_resp.text` without ever reading
|
|
48
|
+
`status_code`, so a failed request parsed to zero rows and was returned as `[]` with
|
|
49
|
+
`has_losses == False` — indistinguishable from an issuer that genuinely filed nothing. Worse
|
|
50
|
+
than a parse bug, because it is *intermittent*: the same query succeeds an hour later, so a
|
|
51
|
+
backfill records the window as covered and nothing ever marks the gap. Evidenced by a real
|
|
52
|
+
**HTTP 505** captured from the listing host on 2026-09-20 (committed as
|
|
53
|
+
`idisc_505_error_page.html`).
|
|
54
|
+
|
|
55
|
+
settfex already had the answer everywhere else — `raise_for_status` at ~25 call sites across SET
|
|
56
|
+
and ThaiBMA, and an explicit check in `download.py`. `financial_report.py` was the one module
|
|
57
|
+
that did neither. Two rules now, because a status code cannot see everything:
|
|
58
|
+
|
|
59
|
+
- **non-2xx → `FetchError`**, naming the status, the report code and the URL;
|
|
60
|
+
- **a 2xx body that is no listing page at all → `ParseError`**. The rule is the weakest one that
|
|
61
|
+
separates the populations (no result table *and* no section heading), because the case that
|
|
62
|
+
must never trip it is a **genuinely empty listing**. Verified against every page this repo
|
|
63
|
+
holds, including the one whose section is genuinely empty.
|
|
64
|
+
|
|
65
|
+
**Behavior change:** these entry points now raise where they previously returned `[]`. Both are
|
|
66
|
+
`FetchError` subclasses and both were already documented outcomes of the listing call, so an
|
|
67
|
+
existing `except FetchError` already covers them.
|
|
68
|
+
|
|
69
|
+
- **A broken "display all results" page silently deleted its whole section** — found by the fix
|
|
70
|
+
above, on the live site, the same day. A ViewMore page *replaces* its section's inline rows, and
|
|
71
|
+
0.22.0 applied that replacement unconditionally: a failing page parsed to zero rows, superseded
|
|
72
|
+
the tally, and the section vanished with the inline rows included. The live Thai `fs-kf` page
|
|
73
|
+
answered **HTTP 500 on every attempt** on 2026-09-20 while the other five slug/language pairs
|
|
74
|
+
answered 200, so a Thai listing was losing its entire Key Financial Ratio section in silence.
|
|
75
|
+
|
|
76
|
+
An unusable ViewMore page is now **degraded, not raised**: the truncated inline rows are kept —
|
|
77
|
+
they are the real answer `follow_view_more=False` would have given — a WARNING names the page
|
|
78
|
+
and its status, and the shortfall shows through `completeness()`. Raising would cost the caller
|
|
79
|
+
the whole listing over one section. A CPALL Thai listing that returned 167 documents with a
|
|
80
|
+
silent hole now returns 177 with `key_financial_ratio: (10, 15)` stated in the result.
|
|
81
|
+
|
|
82
|
+
- **A fifth download-link shape was unrecognised, so the oldest filings were unreachable**
|
|
83
|
+
(issue #133). `capital.sec.or.th/…/get_zip_all_public_page.php` carries pre-2014 `.DOC`/`.XLS`
|
|
84
|
+
filings — the history that cannot be re-derived from anywhere else. 0.22.0 counted those rows as
|
|
85
|
+
`no_link` and warned (that is #127's mechanism working, and it is how the shape was reported),
|
|
86
|
+
but could not reach the data.
|
|
87
|
+
|
|
88
|
+
It is an **indirection**, so recognising the URL is necessary and not sufficient: it answers a
|
|
89
|
+
2 KB HTML page whose entire body is a JavaScript redirect to a server-minted zip.
|
|
90
|
+
`classify_download_href` now returns it as `capfin:<comp_id>-<year>-<period>-<lang>`, and
|
|
91
|
+
`download()` resolves the redirect (one extra request) and returns the real archive — 334 KB
|
|
92
|
+
across four members, verified live. Three properties the code respects: the target is **minted
|
|
93
|
+
per request** (so `file_url` stays the stable indirection URL and the `/tmp/…` one is never
|
|
94
|
+
stored), it is **per-language** (the Thai request mints a different archive), and the pattern is
|
|
95
|
+
strict with a live off-host check rather than a decorative one.
|
|
96
|
+
|
|
97
|
+
### Documentation
|
|
98
|
+
|
|
99
|
+
- **`DownloadResult` loses `.failed` when you derive a new list** (issue #134). Slicing,
|
|
100
|
+
`sorted()`, `list()`, concatenation and comprehensions all return a plain `list` without
|
|
101
|
+
`.failed`, `.requested` or `.is_complete`, because that is what the `list` methods construct;
|
|
102
|
+
`copy.copy()` is the exception. So `for f in sorted(result)` quietly gives up the failure report
|
|
103
|
+
that #128 added. Documented on the class and in the service docs for now — removing the edge is
|
|
104
|
+
a breaking change and is tracked separately.
|
|
105
|
+
|
|
106
|
+
- **The old-format archives are MEMBER-STABLE, not byte-stable.** Re-fetching the same filing
|
|
107
|
+
legitimately yields different container bytes — 12 bytes of 334,498, the Info-ZIP pack timestamp
|
|
108
|
+
— while every member payload is byte-identical. **Judge integrity per member; the container
|
|
109
|
+
sha256 is not an identity for this shape.** An archive keyed on the container hash will see a
|
|
110
|
+
re-fetch as a new or corrupted object.
|
|
111
|
+
|
|
112
|
+
|
|
10
113
|
## [0.22.0] - 2026-09-20
|
|
11
114
|
|
|
12
115
|
### Fixed
|
|
@@ -412,12 +412,18 @@ See [`CHANGELOG.md`](CHANGELOG.md) for the full, versioned release history — t
|
|
|
412
412
|
- **A stock-info 404 means "unknown symbol" — unlike the DR-profile 404:** `/api/set/stock/{sym}/info` answers an unknown ticker with HTTP 404 `{"message": "Invalid Stock Name"}` and resolves every real one, so `SymbolNotFoundError` is raised **with** the "did you mean?" suggester enabled (the opposite of `/api/set/dr/{sym}/profile`, which 404s for valid non-DRs and must pass `suggest=False`).
|
|
413
413
|
- **The session cache dir is forced to `0700`, and a caller-supplied one is deliberately NOT — do not "make it consistent":** `diskcache` reads cached values back with **pickle**, so write access to `~/.settfex/cache` is code execution in the caller's process (CVE-2025-69872 / PYSEC-2026-2447 — **no fixed release**, upstream's last release was 2023; it is the only advisory `pip-audit` still reports and the only one on a *runtime* dependency). `_secure_cache_dir()` therefore creates the directory `0700` and **chmods an existing default one in place** (a real side effect on the user's filesystem, logged at INFO). A directory the caller passed is created `0700` when new but, if it already exists group/other-writable, is **reported and left alone** — a shared cache can be deliberate, and silently breaking a multi-account layout is worse than the warning. Windows is skipped outright (POSIX bits do not express this), and every permission step is inside `try/except OSError` because a cache that cannot be chmod'd is still a usable cache. Tests pin all four behaviours in `tests/utils/test_session_cache.py`.
|
|
414
414
|
- **SEC serves FOUR download-URL shapes; two of them were silently dropping live filings:** `classify_download_href` knew `Download?FILEID=<path>` and `ipos/Common/IPOSGetFile.aspx?id=`. **Key Financial Ratio** rows use two more, and both fell through to "not a download link" and made the row vanish — live-probed 2026-09-20 on CPALL, both real downloads: (1) recent rows (2026 Q1/Q2) link through `/public/idisc/Views/FinancialStatementDownload?query=<opaque base64>`, answering `application/zip` — a **134 KB zip of the real PDFs**; (2) older rows (2018, 2019) link through `/public/idisc/views/viewdoc?…&TransId=<id>&FileSeq=<n>`, answering `application/.tif` — a **36 KB scan of the original**. Mapped now as `file_id="fsdl:<blob>"` / `"viewdoc:<id>-<n>"`, both `file_kind=None` (neither URL states a type; `Content-Disposition` names the file at download time). After the fix, CPALL 2015-2026 returns **177 documents with `completeness()` exact in all five categories** — `key_financial_ratio` was `(13, 15)`. ⚠️ **Three things this should teach the next reader.** (a) **It falsifies the reporting issue's own finding:** its corpus scan of 39 captured pages found *zero* natural instances of a dropped row and stated P1 "has not been observed in the wild"; the first two live runs of the new accounting found four. A captured corpus can only show what was captured. (b) **The window is load-bearing:** a narrow recent window passed clean while the 2018/2019 rows were being dropped — **old filings use old URL shapes**, so a probe that only checks recent data cannot see them. (c) **Do NOT generalise the allowlist** into "any link under /public/idisc/ with a query is a download": the "display all results" ViewMore link has exactly that shape, and would become a phantom filing in every truncated section. Expect the list to grow; `ListingAccounting.no_link` is the mechanism that makes the next one visible, and a test pins the ViewMore link as not-a-download. Note `_fallback_filename` keys on the **colon**, not on an `ipos:` prefix: a real FILEID path never contains one, so every synthetic id is safely excluded from being sliced like a path.
|
|
415
|
+
- **The SEC LISTING path checks its HTTP status now — and a ViewMore failure DEGRADES rather than raising (issue #131):** `_run_search` used to return `post_resp.text` without ever reading `status_code`, so an error page parsed to zero rows and came back as `[]` with `has_losses == False` — indistinguishable from an issuer that filed nothing, and intermittent, so a backfill recorded the window as covered. Evidenced by a real **HTTP 505** from the listing host (2026-09-20). Two rules: non-2xx → `FetchError`; a 2xx body with **no result table and no section heading** → `ParseError`. The content rule is deliberately the weakest one that separates the populations, because the case it must never trip is a **genuinely empty listing** — a test pins it against every page this repo holds. It does **not** key on `ctl00_CPH_pnlControl`: the real ViewMore capture has that id and the trimmed `FS_VIEWMORE_HTML` constant does not, so a panel-id rule would call a legitimate page an error. ⚠️ **The severity ladder differs by leg, on purpose.** The POST leg raises (a total loss — nothing to fall back on). A **ViewMore** page is different: it *replaces* its section's inline rows, so 0.22.0's unconditional replacement meant a failing page **deleted the whole section, inline rows included, in silence**. A broken ViewMore is a *partial* loss, so it now keeps the inline rows, warns, and lets `completeness()` show the shortfall. `_map_rows` additionally refuses a **zero-row** input: within this package that can only be a non-listing body, since every captured page carries at least the placeholder row an empty section serves.
|
|
416
|
+
- **The live Thai `fs-kf` ViewMore page has been answering HTTP 500 since at least 2026-09-20 — an UPSTREAM bug, and it is why the degrade path exists:** probed 3× each, `th/fs-kf` returned 500 every time while `th/fs-norm`, `th/fs-mda` and all three `en/*` returned 200. On 0.22.0 this silently removed CPALL's entire Thai Key Financial Ratio section; now the listing returns 177 documents with `key_financial_ratio: (10, 15)` stated in `completeness()` plus two WARNINGs. **If you see a Thai KFR shortfall, check whether SEC has fixed their page before looking in settfex.**
|
|
417
|
+
- **~~`fs-r561` is an UNMAPPED ViewMore slug~~ — CLOSED in 0.22.2, and `fs-r562` with it:** `_CATEGORY_FOR_VIEWMORE_SLUG` used to map only `fs-norm`/`fs-kf`/`fs-mda`, the three sections one `FS` search returns, so a **56-1 or 56-2** section over the site's ~10-row inline cap truncated **even with `follow_view_more=True`**. 0.22.0's notes predicted it and said the shortfall WARNING would surface it — it did, reported by a downstream consumer reading `completeness()`. Both slugs were **live-probed, never guessed** (2026-09-20, CPALL/PTT/SCB × th/en), and the scale was larger than first reported: over a 2000-2026 window CPALL th 56-1 served **11 of 23**, PTT th 56-1 **11 of 25**, PTT 56-2 **10 of 15 in BOTH languages** — so it was never a Thai-only gap. Now 23/23, 25/25, 15/15, 15/15. ⚠️ **The `fs-` prefix is the site's own and is NOT a category hint** — `fs-r561` is served by the *56-1* search, not the FS one; a mapping keyed on the prefix would be wrong. Two surfaces state this fact: `_CATEGORY_FOR_VIEWMORE_SLUG` (used) and the public `SEC_VIEWMORE_SLUGS` in `constants.py` (**used nowhere**, so nothing would notice it going stale) — a test pins them equal.
|
|
418
|
+
- **SEC's old-format filings are on a FIFTH host and behind a JavaScript indirection (issue #133):** `capital.sec.or.th/…/get_zip_all_public_page.php` carries pre-2014 `.DOC`/`.XLS` filings. The URL answers **HTTP 200 with a 2 KB HTML page whose entire body is `document.location = '/tmp/<id>.zip'`** — so recognising the link is necessary but not sufficient, and `download()` resolves the redirect at download time (one extra request). Three properties the code depends on: the target is **minted per request** (`XP`/`sD`/`V1` for the same filing across three captures), so `file_url` stays the stable indirection URL and the `/tmp/…` one is never stored; it is **per-language** (the Thai request mints a different archive, so `lang` is part of the `capfin:<comp_id>-<year>-<period>-<lang>` id); and `comp_id` is a **different id space from `set_id`** in the same query — they are not interchangeable. The redirect pattern accepts an absolute URL on purpose, so the same-host check is live rather than dead code.
|
|
419
|
+
- **These old-format zips are MEMBER-STABLE, not byte-stable — do not key an archive on the container hash:** re-minting the same filing produced a different container sha256 while every member payload stayed byte-identical. Exactly **12 bytes of 334,498** differ, in four 3-byte runs, one per member: the Info-ZIP extended-timestamp (`UT`) extra field, i.e. the pack time. **Judge integrity per member.** A consumer that hashes the container will see a legitimate re-fetch as a new — or corrupted — object. (Also observed: one sample's embedded pack time *predated* the request that minted it, suggesting the server sometimes hands back a previously generated archive. INFERRED from two samples.)
|
|
420
|
+
- **`DownloadResult` loses `.failed` the moment you derive a new list (issue #134, documented not fixed):** `.failed`, `.requested` and `.is_complete` are instance attributes on a `list` subclass, so slicing, `sorted()`, `list()`, concatenation and comprehensions all return a plain `list` without them — 6 of 7 ordinary operations, `copy.copy()` being the exception. `for f in sorted(result)` therefore quietly gives up the failure report #128 added. **Read `.failed` from the object `download_all` returned, before deriving anything from it.** `SecDocumentList` has the identical hazard for `.accounting`/`.reported_counts`. Removing the edge is a breaking change and is tracked for a later release.
|
|
415
421
|
- **`no_href` was three different things, and only one is a defect:** a dropped SEC listing row is a `ไม่พบข้อมูล`/`Data not found` **placeholder** (the site saying the section is empty), a "display all results" **ViewMore navigation row** (how a long section truncates — 24% of live sections), or a **real data row whose download link is gone** (`no_link`). They shared one counter, which is why P1 could not be escalated: any rule strict enough to catch a genuine loss also fired on every truncated section. Split (`ListingAccounting.placeholders`/`navigation`/`no_link`), `no_link` is **0 on every real page captured** and non-zero only on a page derived to exhibit the bug — so it can be escalated without a record-count marker at all. `_MapResult.no_href` is kept as the sum of the three; **branch on `accounting.no_link`, never on `no_href`** (it is non-zero on perfectly healthy pages).
|
|
416
422
|
- **Two cross-checks on a SEC listing, and they answer different questions:** `completeness()`/`reported_counts` compares what you hold against **the number the site printed** — a shortfall there is usually legitimate "view more" truncation, so it is reported and never raised. `docs.accounting` is **ours**: what the parser did with each row it received. A shortfall is a maybe; `accounting.no_link > 0` is always a loss. Do not fold one into the other.
|
|
417
423
|
- **`IncompleteListingError` fires only on a TOTAL loss:** rows classified fine and *every* usable one was dropped for want of a download link. A **partial** loss warns and lands on `accounting.no_link` instead — raising there would turn one bad row in a 200-row listing into no listing at all, and unlike a total loss a partial one is visible in the return value. Same severity ladder as the 0.21.0 `ParseError` for unrecognised headings, and it is a `ParseError` subclass so `except ParseError`/`except FetchError` keep working. The escalation is scoped to the **requested** categories (a single `FS` search returns three sections) — the same principle PR #125 applied to `completeness()`.
|
|
418
424
|
- **An unmapped COLUMN is reported, never raised — and the ignore-list is 5 entries, not 13:** the issue that reported this measured "100% of sections carry an unmapped header", but that count includes the revision-tracking sections, **whose rows are skipped before a single cell is read**. Restricted to sections actually read, the whole corpus serves exactly six unmapped headers, five of which have no model field (`Details`, `รายละเอียด`, `Link`, `Time`, `เวลา` → `_IGNORED_HEADERS`) and the sixth was the real loss (`วันที่ได้รับข้อมูล`, now mapped). So the corpus is 100% clean and the first header this check ever names will be a genuine site change. It **warns**, because an unmodelled column is "the site has more than we model", not lost data — raising would turn a cosmetic change into an outage. Do **not** add the revision-section headers (`Order Date`, `Company Name`, `ชื่อบริษัท`, …) to the ignore-list: they never reach the check, and listing them would make it look broader than it is.
|
|
419
425
|
- **`download_all` returns a `DownloadResult`, not a bare list — a partial batch used to be shaped like a complete one:** with `continue_on_error=True` (still the default) a failure became `None` and was filtered out, so the difference between "I downloaded the filings" and "I downloaded some of the filings" existed only in a log line. `DownloadResult` **is** a `list[DownloadedFile]` (same trick as `SecDocumentList`), so `len()`/iteration/indexing/pass-through are unchanged; it additionally carries `.failed` (target, source document, error, error type), `.requested` and `.is_complete`. `continue_on_error=False` still propagates the first failure.
|
|
420
|
-
- **The SEC listing summary log is derived from the accounting, and its LEVEL is load-bearing:** `Listed 0 SEC document(s)` used to be the same sentence at the same level as a complete success, with the contradicting number already in scope three lines above. Now: a lossy parse → WARNING; a shortfall **with** `follow_view_more=True` → WARNING (the ViewMore page should have returned the section in full, so nothing is left to explain the gap); a shortfall with `follow_view_more=False` → INFO (that truncation is what the caller asked for — warning about it trains people to ignore warnings).
|
|
426
|
+
- **The SEC listing summary log is derived from the accounting, and its LEVEL is load-bearing:** `Listed 0 SEC document(s)` used to be the same sentence at the same level as a complete success, with the contradicting number already in scope three lines above. Now: a lossy parse → WARNING; a shortfall **with** `follow_view_more=True` → WARNING (the ViewMore page should have returned the section in full, so nothing is left to explain the gap); a shortfall with `follow_view_more=False` → INFO (that truncation is what the caller asked for — warning about it trains people to ignore warnings). This WARNING is not theoretical: it is what surfaced the unmapped `fs-r561`/`fs-r562` slugs (closed in 0.22.2, see above) — a downstream consumer read the shortfall out of `completeness()` and reported it.
|
|
421
427
|
- **`Stock.get_latest_price()` is DR-aware by default:** for DRs it returns a `DrIndicativeQuotation` (TradingView indicative price; `volume`/`change` are `None` — it's a fair value, not a SET trade) and falls back to SET chart data on ANY TradingView failure. The switch only applies when `as_of is None` (TV can't answer historical instants); opt out per-call with `prefer_dr_indicative=False`. DR-ness is detected by one cached DR-profile probe per `Stock` instance (a 404 marks non-DR permanently for that instance; transient errors are never cached). Indicative vs SET-close divergence is EXPECTED (probe: 5.94 indicative vs 5.75 SET close after a US-session move) — not a bug.
|
|
422
428
|
|
|
423
429
|
---
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: settfex
|
|
3
|
-
Version: 0.22.
|
|
3
|
+
Version: 0.22.2
|
|
4
4
|
Summary: Async Python library for fetching real-time and historical data from the Stock Exchange of Thailand (SET) and Thailand Futures Exchange (TFEX)
|
|
5
5
|
Project-URL: Homepage, https://github.com/lumduan/settfex
|
|
6
6
|
Project-URL: Documentation, https://github.com/lumduan/settfex#readme
|
|
@@ -229,6 +229,27 @@ files.requested # unique files attempted (duplicates
|
|
|
229
229
|
Before that, a failed item was logged and dropped from the result, so a partial batch was shaped
|
|
230
230
|
exactly like a complete one.
|
|
231
231
|
|
|
232
|
+
> ⚠️ **Read `.failed` from the object `download_all` returned.** `DownloadResult` is a `list`
|
|
233
|
+
> subclass, so the failure report lives on the instance — and **every operation that derives a new
|
|
234
|
+
> list drops it**: slicing, `sorted()`, `list()`, concatenation and comprehensions all return a
|
|
235
|
+
> plain `list` with no `.failed`, `.requested` or `.is_complete`, and nothing warns.
|
|
236
|
+
> `copy.copy()` is the one exception. So `for f in sorted(files)` silently gives up the report:
|
|
237
|
+
> check `files.is_complete` first, then sort.
|
|
238
|
+
|
|
239
|
+
### Old-format filings live on another host, behind an indirection
|
|
240
|
+
|
|
241
|
+
Some pre-2014 financial statements link to `capital.sec.or.th` instead of IDISC. That URL answers a
|
|
242
|
+
2 KB HTML page whose entire body is a JavaScript redirect to a **server-minted** zip, so
|
|
243
|
+
`download()` resolves it (one extra request) and returns the real archive. Two consequences:
|
|
244
|
+
|
|
245
|
+
* **The minted target is single-use** — the same filing mints `/tmp/0653XP.zip`, `/tmp/0653sD.zip`,
|
|
246
|
+
`/tmp/0653V1.zip` on successive requests. `file_url` therefore stays the stable indirection URL;
|
|
247
|
+
never store the `/tmp/…` one.
|
|
248
|
+
* **The container sha256 is not an identity for these.** Re-fetching the same filing legitimately
|
|
249
|
+
yields different container bytes — 12 bytes of 334,498, the Info-ZIP pack timestamp — while every
|
|
250
|
+
member payload is byte-identical. **Judge integrity per member, not by hashing the zip.** An
|
|
251
|
+
archive that keys on the container hash will see a re-fetch as a new or corrupted object.
|
|
252
|
+
|
|
232
253
|
### Tuning downloads (timeout & concurrency)
|
|
233
254
|
|
|
234
255
|
Form 56‑1/56‑2 "One Reports" are large (**15–25 MB**). Downloads default to a **180 s** per-file
|
|
@@ -351,7 +372,9 @@ file = await download_sec_document(doc) # dead link -> FetchError ("soft 404")
|
|
|
351
372
|
POST /public/idisc/api/company/valuebyuniqueId {"lang","content"} -> [{"Text","Value","Flag"}]
|
|
352
373
|
GET /public/idisc/{lang}/FinancialReport/{TYPE} -> page + __VIEWSTATE tokens
|
|
353
374
|
POST /public/idisc/{lang}/FinancialReport/{TYPE} form postback -> result HTML
|
|
354
|
-
GET /public/idisc/{lang}/ViewMore/{slug}?... -> complete section
|
|
375
|
+
GET /public/idisc/{lang}/ViewMore/{slug}?... -> complete section
|
|
376
|
+
slug in fs-norm | fs-kf | fs-mda | fs-r561 | fs-r562 ('fs-' is the site's prefix,
|
|
377
|
+
not a category hint)
|
|
355
378
|
GET /public/idisc/Download?FILEID=<path> -> zip/pdf bytes
|
|
356
379
|
GET /ipos/Common/IPOSGetFile.aspx?id=<id>&sq=0&v=10 -> zip bytes
|
|
357
380
|
GET /public/idisc/Views/FinancialStatementDownload?query=<blob> -> zip bytes (recent KFR rows)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "settfex"
|
|
3
|
-
version = "0.22.
|
|
3
|
+
version = "0.22.2"
|
|
4
4
|
description = "Async Python library for fetching real-time and historical data from the Stock Exchange of Thailand (SET) and Thailand Futures Exchange (TFEX)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -44,11 +44,19 @@ SEC_REPORT_TYPE_FORM_56_1 = "R561"
|
|
|
44
44
|
SEC_REPORT_TYPE_FORM_56_2 = "R562"
|
|
45
45
|
SEC_REPORT_TYPE_KEY_FINANCIAL_RATIO = "KFR"
|
|
46
46
|
|
|
47
|
-
# ViewMore slugs (seen live) for
|
|
47
|
+
# ViewMore slugs (seen live) for every section that can truncate behind "display all results".
|
|
48
|
+
# Note the `fs-` prefix is the site's own and does not mean "financial statement": `fs-r561` and
|
|
49
|
+
# `fs-r562` are served by the 56-1 / 56-2 searches. Live-probed 2026-09-20.
|
|
50
|
+
#
|
|
51
|
+
# This is a reference constant; the mapping the listing service actually consults is
|
|
52
|
+
# `_CATEGORY_FOR_VIEWMORE_SLUG` in `financial_report.py`. Keep the two in step -- they state the
|
|
53
|
+
# same fact, and this one has no caller to notice when it goes stale.
|
|
48
54
|
SEC_VIEWMORE_SLUGS: dict[str, str] = {
|
|
49
55
|
"financial_statement": "fs-norm",
|
|
50
56
|
"key_financial_ratio": "fs-kf",
|
|
51
57
|
"mda": "fs-mda",
|
|
58
|
+
"form_56_1": "fs-r561",
|
|
59
|
+
"form_56_2": "fs-r562",
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
# The SEC search form's field names (ASP.NET control ids).
|
|
@@ -13,12 +13,12 @@ import re
|
|
|
13
13
|
from collections.abc import Iterable, Sequence
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import Any
|
|
16
|
-
from urllib.parse import unquote, urlparse
|
|
16
|
+
from urllib.parse import unquote, urljoin, urlparse
|
|
17
17
|
|
|
18
18
|
from loguru import logger
|
|
19
19
|
from pydantic import BaseModel, ConfigDict, Field
|
|
20
20
|
|
|
21
|
-
from settfex.exceptions import FetchError
|
|
21
|
+
from settfex.exceptions import FetchError, ParseError
|
|
22
22
|
from settfex.services.sec.constants import (
|
|
23
23
|
SEC_BASE_URL,
|
|
24
24
|
SEC_DOWNLOAD_ENDPOINT,
|
|
@@ -26,7 +26,7 @@ from settfex.services.sec.constants import (
|
|
|
26
26
|
SEC_REFERER,
|
|
27
27
|
)
|
|
28
28
|
from settfex.services.sec.financial_report import SecDocument
|
|
29
|
-
from settfex.services.sec.utils import build_sec_headers
|
|
29
|
+
from settfex.services.sec.utils import _CAPITAL_HOST, build_sec_headers
|
|
30
30
|
from settfex.utils.data_fetcher import AsyncDataFetcher, FetcherConfig
|
|
31
31
|
|
|
32
32
|
|
|
@@ -95,6 +95,21 @@ class DownloadResult(list[DownloadedFile]):
|
|
|
95
95
|
|
|
96
96
|
Same shape as :class:`~settfex.services.sec.financial_report.SecDocumentList`, for the same
|
|
97
97
|
reason: the extra information rides along instead of breaking every existing caller.
|
|
98
|
+
|
|
99
|
+
.. warning::
|
|
100
|
+
**Deriving a new list drops the failure report** (issue #134). ``.failed``, ``.requested``
|
|
101
|
+
and ``.is_complete`` live on the instance, so every operation that *builds a new list* —
|
|
102
|
+
slicing, ``sorted()``, ``list()``, concatenation, a comprehension — returns a plain
|
|
103
|
+
``list`` without them, and nothing warns::
|
|
104
|
+
|
|
105
|
+
for f in sorted(result, key=lambda f: f.size): # <- .failed is gone from here
|
|
106
|
+
...
|
|
107
|
+
if not result.is_complete: # <- read it from the ORIGINAL
|
|
108
|
+
print([f.target for f in result.failed])
|
|
109
|
+
|
|
110
|
+
``copy.copy(result)`` is the exception and does preserve them. Read the failure report from
|
|
111
|
+
the object ``download_all`` returned, before deriving anything from it. A redesign that
|
|
112
|
+
removes this edge is a breaking change and is tracked for a later release.
|
|
98
113
|
"""
|
|
99
114
|
|
|
100
115
|
def __init__(
|
|
@@ -165,6 +180,50 @@ def _effective_download_config(config: FetcherConfig | None, timeout: int | None
|
|
|
165
180
|
return base.model_copy(update={"use_session": False})
|
|
166
181
|
|
|
167
182
|
|
|
183
|
+
# The capital.sec.or.th shape answers HTML whose entire body is a JavaScript redirect:
|
|
184
|
+
#
|
|
185
|
+
# <script language = 'javascript' >
|
|
186
|
+
# document.location = '/tmp/0653XP.zip';
|
|
187
|
+
# </script>
|
|
188
|
+
#
|
|
189
|
+
# Strict on purpose, but in two parts rather than one. The pattern pins the assignment and the
|
|
190
|
+
# `.zip` suffix; the HOST and the path are then checked against the page's own origin after the
|
|
191
|
+
# join. Doing it that way keeps the host check LIVE: a pattern that also required the `/tmp/`
|
|
192
|
+
# prefix could never match an absolute URL, so the off-host branch below would have been dead
|
|
193
|
+
# code that looked like a safeguard. Every capture so far is a bare `/tmp/<id>.zip` path.
|
|
194
|
+
_CAPITAL_REDIRECT = re.compile(r"""document\.location\s*=\s*['"]([^'"]+\.zip)['"]""", re.IGNORECASE)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _capital_redirect_target(body: bytes, url: str) -> str:
|
|
198
|
+
"""Resolve the JavaScript indirection to its absolute target, or raise.
|
|
199
|
+
|
|
200
|
+
The target is **minted per request** — the same filing yielded ``/tmp/0653XP.zip``,
|
|
201
|
+
``/tmp/0653sD.zip`` and ``/tmp/0653V1.zip`` across three captures — so it is resolved at
|
|
202
|
+
download time and never stored on the listing.
|
|
203
|
+
|
|
204
|
+
The page is TIS-620, not UTF-8, so the body is decoded permissively; the line this reads is
|
|
205
|
+
pure ASCII either way.
|
|
206
|
+
"""
|
|
207
|
+
text = body.decode("utf-8", "replace")
|
|
208
|
+
match = _CAPITAL_REDIRECT.search(text)
|
|
209
|
+
if match is None:
|
|
210
|
+
raise ParseError(
|
|
211
|
+
f"The old-format filing page at {url} carried no JavaScript redirect to a zip. Its "
|
|
212
|
+
f"shape may have changed; raised rather than returning the 2 KB HTML page as if it "
|
|
213
|
+
f"were the document.",
|
|
214
|
+
url=url,
|
|
215
|
+
)
|
|
216
|
+
target: str = urljoin(url, match.group(1))
|
|
217
|
+
parsed = urlparse(target)
|
|
218
|
+
if parsed.hostname != urlparse(url).hostname:
|
|
219
|
+
raise FetchError(
|
|
220
|
+
f"The redirect on {url} points off-host, to {target} — refusing to follow it."
|
|
221
|
+
)
|
|
222
|
+
if not parsed.path.lower().endswith(".zip"):
|
|
223
|
+
raise ParseError(f"The redirect on {url} points at {target}, which is not a zip.", url=url)
|
|
224
|
+
return target
|
|
225
|
+
|
|
226
|
+
|
|
168
227
|
class DocumentDownloadService:
|
|
169
228
|
"""Download SEC documents to bytes (and optionally disk). Stateless host — no SessionManager."""
|
|
170
229
|
|
|
@@ -212,6 +271,13 @@ class DocumentDownloadService:
|
|
|
212
271
|
fetcher = fetcher or AsyncDataFetcher(config=self.config)
|
|
213
272
|
try:
|
|
214
273
|
resp = await fetcher.fetch(url, headers=headers, decode_text=False)
|
|
274
|
+
if resp.status_code == 200 and _CAPITAL_HOST in (urlparse(url).hostname or ""):
|
|
275
|
+
# The old-format shape is an indirection, so this costs one extra request. It is
|
|
276
|
+
# done HERE, inside the fetcher's lifetime, rather than on the listing: the target
|
|
277
|
+
# is minted per request and would be stale by the time anyone used it.
|
|
278
|
+
url = _capital_redirect_target(resp.content, url)
|
|
279
|
+
logger.debug(f"Resolved the old-format indirection to {url}")
|
|
280
|
+
resp = await fetcher.fetch(url, headers=headers, decode_text=False)
|
|
215
281
|
finally:
|
|
216
282
|
if owns_fetcher:
|
|
217
283
|
await fetcher.__aexit__(None, None, None)
|
|
@@ -20,7 +20,12 @@ from urllib.parse import urljoin
|
|
|
20
20
|
from loguru import logger
|
|
21
21
|
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
|
22
22
|
|
|
23
|
-
from settfex.exceptions import
|
|
23
|
+
from settfex.exceptions import (
|
|
24
|
+
FetchError,
|
|
25
|
+
IncompleteListingError,
|
|
26
|
+
InvalidDateError,
|
|
27
|
+
ParseError,
|
|
28
|
+
)
|
|
24
29
|
from settfex.services.sec.company import resolve_company
|
|
25
30
|
from settfex.services.sec.constants import (
|
|
26
31
|
SEC_BASE_URL,
|
|
@@ -581,6 +586,88 @@ def _drop_reason(row: ReportRow, *, reported: int | None) -> _DropReason:
|
|
|
581
586
|
return "no_link"
|
|
582
587
|
|
|
583
588
|
|
|
589
|
+
# A listing response is an ASP.NET result panel: a sequence of `card-heading` divs, each with a
|
|
590
|
+
# `<table>`. An error page has neither. Measured over every page this repo holds -- 8 committed
|
|
591
|
+
# fixtures, 3 listing test constants and a real 104 KB ViewMore capture -- every genuine listing
|
|
592
|
+
# body carries at least one of each, INCLUDING the one whose section is genuinely empty (MOTHER's
|
|
593
|
+
# Key Financial Ratio, which reports 0 records). The two error-shaped bodies in the evidence, a
|
|
594
|
+
# real HTTP 505 page and a capital.sec.or.th indirection page, carry neither.
|
|
595
|
+
#
|
|
596
|
+
# NOTE it deliberately does NOT key on `ctl00_CPH_pnlControl` alone: the real ViewMore capture has
|
|
597
|
+
# that id and the trimmed `FS_VIEWMORE_HTML` test constant does not, so a panel-id rule would call
|
|
598
|
+
# a legitimate page an error.
|
|
599
|
+
_LISTING_MARKERS = ("card-heading", "<table")
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _require_ok(status_code: int, url: str, code: str) -> None:
|
|
603
|
+
"""Raise ``FetchError`` unless the listing response is 2xx (issue #131).
|
|
604
|
+
|
|
605
|
+
The listing path used to read ``response.text`` without ever looking at the status, so an
|
|
606
|
+
error page parsed to zero rows and was returned as an empty list -- indistinguishable from an
|
|
607
|
+
issuer with no filings, and worse than a parse bug because it is intermittent.
|
|
608
|
+
|
|
609
|
+
Raised as a plain :class:`FetchError`, **not** through ``raise_for_status``: that helper maps
|
|
610
|
+
404 to ``SymbolNotFoundError`` and would consult the symbol suggester, which is wrong here.
|
|
611
|
+
The unique_id was already resolved, so a 404 on this endpoint means the route or the host is
|
|
612
|
+
wrong, not that an issuer does not exist -- the same reasoning that gives the DR-profile and
|
|
613
|
+
analyst-consensus endpoints their own handling (see CLAUDE.md's Known Gotchas).
|
|
614
|
+
"""
|
|
615
|
+
if 200 <= status_code < 300:
|
|
616
|
+
return
|
|
617
|
+
message = (
|
|
618
|
+
f"SEC listing request failed: HTTP {status_code} for report code {code!r} "
|
|
619
|
+
f"at {url}. The response was not parsed — an error page yields zero rows, which is "
|
|
620
|
+
f"indistinguishable from an issuer that filed nothing."
|
|
621
|
+
)
|
|
622
|
+
logger.error(message)
|
|
623
|
+
raise FetchError(message, status_code=status_code)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _require_listing_page(html: str, url: str, code: str) -> None:
|
|
627
|
+
"""Raise ``ParseError`` when a 2xx body is not a listing page at all (issue #131).
|
|
628
|
+
|
|
629
|
+
The status check above catches the evidenced case (a real HTTP 505). This is the companion for
|
|
630
|
+
an error or interstitial page served with HTTP 200, which no status check can see. It is
|
|
631
|
+
deliberately the weakest rule that separates the two populations, so that a genuinely empty
|
|
632
|
+
listing -- the thing that must never raise -- cannot trip it. See :data:`_LISTING_MARKERS`.
|
|
633
|
+
"""
|
|
634
|
+
lowered = html.lower()
|
|
635
|
+
if any(marker in lowered for marker in _LISTING_MARKERS):
|
|
636
|
+
return
|
|
637
|
+
message = (
|
|
638
|
+
f"SEC listing response for report code {code!r} at {url} carries no result table and no "
|
|
639
|
+
f"section heading, so it is not a listing page (HTTP 200 error or interstitial page?). "
|
|
640
|
+
f"Raised rather than returned as an empty list, which would be indistinguishable from an "
|
|
641
|
+
f"issuer that filed nothing."
|
|
642
|
+
)
|
|
643
|
+
logger.error(message)
|
|
644
|
+
raise ParseError(message, url=url, rows_parsed=0)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _view_more_is_usable(status_code: int, html: str, url: str, code: str) -> bool:
|
|
648
|
+
"""Is this "display all results" page fit to replace its section's inline rows?
|
|
649
|
+
|
|
650
|
+
Returns False (with a WARNING) instead of raising, so a broken ViewMore page costs the caller
|
|
651
|
+
the *completeness* of one section rather than the whole listing. See the call site for why.
|
|
652
|
+
"""
|
|
653
|
+
if not 200 <= status_code < 300:
|
|
654
|
+
logger.warning(
|
|
655
|
+
f"The 'display all results' page for report code {code!r} at {url} answered HTTP "
|
|
656
|
+
f"{status_code}. Keeping the truncated inline rows for that section instead of "
|
|
657
|
+
f"replacing them; `completeness()` will show the shortfall."
|
|
658
|
+
)
|
|
659
|
+
return False
|
|
660
|
+
lowered = html.lower()
|
|
661
|
+
if not any(marker in lowered for marker in _LISTING_MARKERS):
|
|
662
|
+
logger.warning(
|
|
663
|
+
f"The 'display all results' page for report code {code!r} at {url} is not a listing "
|
|
664
|
+
f"page (no result table, no section heading). Keeping the truncated inline rows for "
|
|
665
|
+
f"that section; `completeness()` will show the shortfall."
|
|
666
|
+
)
|
|
667
|
+
return False
|
|
668
|
+
return True
|
|
669
|
+
|
|
670
|
+
|
|
584
671
|
# ViewMore slug -> the category whose complete list that page holds.
|
|
585
672
|
@dataclass
|
|
586
673
|
class _MapResult:
|
|
@@ -640,6 +727,19 @@ def _map_rows(
|
|
|
640
727
|
single "FS" search returns three sections, so without it a loss in a section that was filtered
|
|
641
728
|
out on request would raise for a caller who never wanted it.
|
|
642
729
|
"""
|
|
730
|
+
if not rows:
|
|
731
|
+
# Reachable only for a body that looked like a listing and yielded no data row at all.
|
|
732
|
+
# Within this package that cannot be a real listing: every captured page — including the
|
|
733
|
+
# ones whose sections are empty — carries at least the revision-tracking placeholder row.
|
|
734
|
+
# Returning [] here is the exact silent loss of issue #131, one layer below the transport.
|
|
735
|
+
message = (
|
|
736
|
+
f"No result rows were parsed from {source}. A SEC listing always carries at least one "
|
|
737
|
+
f"row, including the placeholder an empty section serves, so this body is not a "
|
|
738
|
+
f"listing — raised rather than returned as an empty list."
|
|
739
|
+
)
|
|
740
|
+
logger.error(message)
|
|
741
|
+
raise ParseError(message, url=source, rows_parsed=0)
|
|
742
|
+
|
|
643
743
|
result = _MapResult()
|
|
644
744
|
accounting = result.accounting
|
|
645
745
|
accounting.rows = len(rows)
|
|
@@ -780,10 +880,21 @@ def _escalate_losses(
|
|
|
780
880
|
)
|
|
781
881
|
|
|
782
882
|
|
|
883
|
+
# Every slug the site serves a "display all results" page under. The `fs-` prefix is the site's,
|
|
884
|
+
# not a category hint: `fs-r561` and `fs-r562` belong to the 56-1 / 56-2 searches, not to the FS
|
|
885
|
+
# one. Live-probed 2026-09-20 across CPALL/PTT/SCB in both languages.
|
|
886
|
+
#
|
|
887
|
+
# The last two were MISSING until 0.22.2, so a 56-1 or 56-2 section over the ~10-row inline cap
|
|
888
|
+
# was truncated even with `follow_view_more=True`. 0.22.0's notes predicted exactly this and said
|
|
889
|
+
# the shortfall WARNING would surface it; it did, from a downstream consumer reading
|
|
890
|
+
# `completeness()`. Scale, with a 2000-2026 window: CPALL Thai 56-1 served 11 of 23, PTT Thai 56-1
|
|
891
|
+
# 11 of 25, PTT 56-2 10 of 15 in BOTH languages -- so this was never a Thai-only gap.
|
|
783
892
|
_CATEGORY_FOR_VIEWMORE_SLUG: dict[str, DocumentCategory] = {
|
|
784
893
|
"fs-norm": DocumentCategory.FINANCIAL_STATEMENT,
|
|
785
894
|
"fs-kf": DocumentCategory.KEY_FINANCIAL_RATIO,
|
|
786
895
|
"fs-mda": DocumentCategory.MDA,
|
|
896
|
+
"fs-r561": DocumentCategory.FORM_56_1,
|
|
897
|
+
"fs-r562": DocumentCategory.FORM_56_2,
|
|
787
898
|
}
|
|
788
899
|
|
|
789
900
|
_VIEWMORE_HREF = re.compile(r'href="([^"]*?/ViewMore/([a-z0-9-]+)[^"]*)"', re.IGNORECASE)
|
|
@@ -987,10 +1098,12 @@ class FinancialReportService:
|
|
|
987
1098
|
f"{SEC_BASE_URL}{SEC_FINANCIAL_REPORT_ENDPOINT.format(lang=lang, report_type=code)}"
|
|
988
1099
|
)
|
|
989
1100
|
get_resp = await fetcher.fetch(report_url, headers=build_sec_headers(referer=SEC_REFERER))
|
|
1101
|
+
# This leg was already guarded by accident: an error page carries no __VIEWSTATE, so the
|
|
1102
|
+
# check below fired. It reported the wrong cause, though — "the page structure may have
|
|
1103
|
+
# changed" for what was really an HTTP 500 — so the status is now read first.
|
|
1104
|
+
_require_ok(get_resp.status_code, report_url, code)
|
|
990
1105
|
tokens = extract_aspnet_tokens(get_resp.text)
|
|
991
1106
|
if not tokens.get("__VIEWSTATE"):
|
|
992
|
-
from settfex.exceptions import FetchError
|
|
993
|
-
|
|
994
1107
|
raise FetchError(
|
|
995
1108
|
"SEC search page returned no __VIEWSTATE token — the page structure may have "
|
|
996
1109
|
"changed, or the request was blocked."
|
|
@@ -1011,6 +1124,7 @@ class FinancialReportService:
|
|
|
1011
1124
|
method="POST",
|
|
1012
1125
|
data=form,
|
|
1013
1126
|
)
|
|
1127
|
+
_require_ok(post_resp.status_code, report_url, code)
|
|
1014
1128
|
return post_resp.text, report_url
|
|
1015
1129
|
|
|
1016
1130
|
async def _search_code(
|
|
@@ -1030,6 +1144,7 @@ class FinancialReportService:
|
|
|
1030
1144
|
fetcher, code, unique_id, company_name, date_from, date_to, lang
|
|
1031
1145
|
)
|
|
1032
1146
|
wanted_values = {c.value for c in wanted}
|
|
1147
|
+
_require_listing_page(html, url, code)
|
|
1033
1148
|
mapped = _map_rows(
|
|
1034
1149
|
parse_report_tables(html),
|
|
1035
1150
|
unique_id,
|
|
@@ -1068,6 +1183,22 @@ class FinancialReportService:
|
|
|
1068
1183
|
)
|
|
1069
1184
|
)
|
|
1070
1185
|
for (cat, url), page in zip(vm_targets, pages, strict=True):
|
|
1186
|
+
# A ViewMore page holds the COMPLETE list for its section and REPLACES the inline
|
|
1187
|
+
# rows, so an unusable one here does not merely add nothing -- on 0.22.0 it
|
|
1188
|
+
# silently deleted the whole section, the inline rows included.
|
|
1189
|
+
#
|
|
1190
|
+
# It is DEGRADED rather than raised, which is the same severity ladder the rest of
|
|
1191
|
+
# this module uses: a failed ViewMore is a PARTIAL loss, because the truncated
|
|
1192
|
+
# inline rows are still a real answer -- exactly the one `follow_view_more=False`
|
|
1193
|
+
# would have given. Skipping the replacement keeps them, and the shortfall then
|
|
1194
|
+
# shows up on its own through `completeness()` and the WARNING in
|
|
1195
|
+
# `_log_listing_summary`, which already treats "short even after following the
|
|
1196
|
+
# view-more pages" as the case worth warning about.
|
|
1197
|
+
#
|
|
1198
|
+
# This is not hypothetical: on 2026-09-20 the live Thai `fs-kf` page answered
|
|
1199
|
+
# HTTP 500 on every attempt while the other five slug/language pairs answered 200.
|
|
1200
|
+
if not _view_more_is_usable(page.status_code, page.text, url, code):
|
|
1201
|
+
continue
|
|
1071
1202
|
vm = _map_rows(
|
|
1072
1203
|
parse_report_tables(page.text),
|
|
1073
1204
|
unique_id,
|
|
@@ -273,6 +273,14 @@ def parse_int(value: str | None) -> int | None:
|
|
|
273
273
|
return int(match.group(0)) if match else None
|
|
274
274
|
|
|
275
275
|
|
|
276
|
+
# The fifth download shape lives on a different host and is an indirection, not a file. Kept
|
|
277
|
+
# module-private on purpose: the other four shapes have public `SEC_*_ENDPOINT` constants in
|
|
278
|
+
# `constants.py`, but adding a fifth public symbol would put this fix outside a patch release.
|
|
279
|
+
# Promoting it is a recorded 0.23.0 consistency item.
|
|
280
|
+
_CAPITAL_HOST = "capital.sec.or.th"
|
|
281
|
+
_CAPITAL_ZIP_PATH = "get_zip_all_public_page.php"
|
|
282
|
+
|
|
283
|
+
|
|
276
284
|
def classify_download_href(href: str | None) -> tuple[str, str | None, str | None]:
|
|
277
285
|
"""
|
|
278
286
|
Resolve a row href to (absolute_url, file_id, file_kind).
|
|
@@ -283,6 +291,13 @@ def classify_download_href(href: str | None) -> tuple[str, str | None, str | Non
|
|
|
283
291
|
file_id="fsdl:<blob>", kind=None. The blob is opaque, so nothing can be derived from it.
|
|
284
292
|
- Scanned originals: ``/public/idisc/views/viewdoc?…&TransId=<id>&FileSeq=<n>`` →
|
|
285
293
|
file_id="viewdoc:<id>-<n>", kind=None (it answers a TIFF, but the URL does not say so).
|
|
294
|
+
- Old-format filings on a DIFFERENT host: ``capital.sec.or.th/…/get_zip_all_public_page.php``
|
|
295
|
+
→ file_id="capfin:<comp_id>-<year>-<period>-<lang>", kind=None. This one is an
|
|
296
|
+
**indirection**: the URL answers HTML whose whole body is a JavaScript redirect to a
|
|
297
|
+
server-minted zip, so the download path resolves it (see
|
|
298
|
+
:meth:`~settfex.services.sec.download.DocumentDownloadService.download`). The returned
|
|
299
|
+
``file_url`` is the STABLE indirection URL, never the minted target — that target is
|
|
300
|
+
per-request and must not be cached.
|
|
286
301
|
Relative hrefs are resolved against the SEC base URL.
|
|
287
302
|
|
|
288
303
|
Anything else returns ``("", None, None)`` and the row produces no document — which is the
|
|
@@ -310,5 +325,16 @@ def classify_download_href(href: str | None) -> tuple[str, str | None, str | Non
|
|
|
310
325
|
return absolute, f"fsdl:{query['query'][0]}", None
|
|
311
326
|
if parsed.path.lower().endswith("viewdoc") and "TransId" in query:
|
|
312
327
|
return absolute, f"viewdoc:{query['TransId'][0]}-{query.get('FileSeq', ['1'])[0]}", None
|
|
328
|
+
if parsed.path.lower().endswith(_CAPITAL_ZIP_PATH) and "comp_id" in query:
|
|
329
|
+
# `comp_id` is a DIFFERENT id space from the IDISC `set_id` in the same query — they are
|
|
330
|
+
# not interchangeable, so the file_id is built from comp_id and never from set_id. `lang`
|
|
331
|
+
# is load-bearing: the Thai request mints a different archive for the same filing.
|
|
332
|
+
parts = (
|
|
333
|
+
query["comp_id"][0],
|
|
334
|
+
query.get("year", ["?"])[0],
|
|
335
|
+
query.get("period", ["?"])[0],
|
|
336
|
+
query.get("lang", ["?"])[0],
|
|
337
|
+
)
|
|
338
|
+
return absolute, "capfin:" + "-".join(parts), None
|
|
313
339
|
# Not a recognized download link (e.g. a "display all results" ViewMore link) — not a doc.
|
|
314
340
|
return "", None, None
|
|
@@ -1063,6 +1063,7 @@
|
|
|
1063
1063
|
"FailedDownload",
|
|
1064
1064
|
"FetchError",
|
|
1065
1065
|
"FetcherConfig",
|
|
1066
|
+
"ParseError",
|
|
1066
1067
|
"SEC_BASE_URL",
|
|
1067
1068
|
"SEC_DOWNLOAD_ENDPOINT",
|
|
1068
1069
|
"SEC_FILE_NOT_FOUND_MARKER",
|
|
@@ -1711,6 +1712,7 @@
|
|
|
1711
1712
|
"AsyncDataFetcher",
|
|
1712
1713
|
"CATEGORY_TO_REPORT_TYPE",
|
|
1713
1714
|
"DocumentCategory",
|
|
1715
|
+
"FetchError",
|
|
1714
1716
|
"FetcherConfig",
|
|
1715
1717
|
"FinancialReportService",
|
|
1716
1718
|
"IncompleteListingError",
|