From 935bb6a0c921691f9c845166df894df8bdb973b9 Mon Sep 17 00:00:00 2001 From: hazeone <709547807@qq.com> Date: Fri, 5 Sep 2025 10:55:33 +0800 Subject: [PATCH 1/6] init data adpter --- python/locales/en-GB.json | 60 +- python/locales/en-US.json | 56 +- python/locales/zh-Hans.json | 56 +- python/locales/zh-Hant.json | 56 +- python/pyproject.toml | 3 + python/uv.lock | 520 +++++++++++++ python/valuecell/adapters/assets/README.md | 425 +++++++++++ python/valuecell/adapters/assets/__init__.py | 143 ++++ .../adapters/assets/akshare_adapter.py | 647 ++++++++++++++++ python/valuecell/adapters/assets/api.py | 634 ++++++++++++++++ python/valuecell/adapters/assets/base.py | 387 ++++++++++ .../adapters/assets/coinmarketcap_adapter.py | 477 ++++++++++++ .../adapters/assets/finnhub_adapter.py | 696 +++++++++++++++++ .../adapters/assets/i18n_integration.py | 479 ++++++++++++ python/valuecell/adapters/assets/manager.py | 711 ++++++++++++++++++ .../adapters/assets/tushare_adapter.py | 517 +++++++++++++ python/valuecell/adapters/assets/types.py | 352 +++++++++ .../adapters/assets/yfinance_adapter.py | 483 ++++++++++++ .../examples/asset_adapter_example.py | 328 ++++++++ 19 files changed, 7024 insertions(+), 6 deletions(-) create mode 100644 python/valuecell/adapters/assets/README.md create mode 100644 python/valuecell/adapters/assets/__init__.py create mode 100644 python/valuecell/adapters/assets/akshare_adapter.py create mode 100644 python/valuecell/adapters/assets/api.py create mode 100644 python/valuecell/adapters/assets/base.py create mode 100644 python/valuecell/adapters/assets/coinmarketcap_adapter.py create mode 100644 python/valuecell/adapters/assets/finnhub_adapter.py create mode 100644 python/valuecell/adapters/assets/i18n_integration.py create mode 100644 python/valuecell/adapters/assets/manager.py create mode 100644 python/valuecell/adapters/assets/tushare_adapter.py create mode 100644 python/valuecell/adapters/assets/types.py create mode 100644 python/valuecell/adapters/assets/yfinance_adapter.py create mode 100644 python/valuecell/examples/asset_adapter_example.py diff --git a/python/locales/en-GB.json b/python/locales/en-GB.json index 659910a27..6521ebcb0 100644 --- a/python/locales/en-GB.json +++ b/python/locales/en-GB.json @@ -61,7 +61,7 @@ "data_deleted": "Data has been deleted successfully", "operation_failed": "Operation failed. Please try again.", "network_error": "Network error. Please check your connection.", - "unauthorized": "You are not authorised to perform this action", + "unauthorised": "You are not authorised to perform this action", "session_expired": "Your session has expired. Please login again.", "maintenance": "System is under maintenance. Please try again later." }, @@ -94,7 +94,11 @@ "days": "Days", "weeks": "Weeks", "months": "Months", - "years": "Years" + "years": "Years", + "thousand": "K", + "million": "M", + "billion": "B", + "trillion": "T" }, "app": { "name": "ValueCell", @@ -104,7 +108,7 @@ }, "settings": { "language": "Language", - "timezone": "Timezone", + "timezone": "Time Zone", "theme": "Theme", "notifications": "Notifications", "privacy": "Privacy", @@ -113,5 +117,55 @@ "preferences": "Preferences", "general": "General", "advanced": "Advanced" + }, + "assets": { + "types": { + "stock": "Share", + "crypto": "Cryptocurrency", + "etf": "ETF", + "bond": "Bond", + "commodity": "Commodity", + "forex": "Foreign Exchange", + "index": "Index", + "mutual_fund": "Unit Trust", + "option": "Option", + "future": "Future" + }, + "market_status": { + "open": "Open", + "closed": "Closed", + "pre_market": "Pre-market", + "after_hours": "After Hours", + "halted": "Halted", + "unknown": "Unknown" + }, + "watchlist": { + "title": "Watchlist", + "my_watchlist": "My Watchlist", + "add_asset": "Add Asset", + "remove_asset": "Remove Asset", + "search_placeholder": "Search shares, funds, or cryptocurrencies...", + "no_assets": "No assets in watchlist", + "price": "Price", + "change": "Change", + "change_percent": "Change %", + "market_cap": "Market Cap", + "volume": "Volume", + "last_updated": "Last Updated", + "add_to_watchlist": "Add to Watchlist", + "remove_from_watchlist": "Remove from Watchlist", + "notes": "Notes" + }, + "search": { + "title": "Asset Search", + "placeholder": "Enter ticker symbol or company name...", + "no_results": "No assets found", + "searching": "Searching...", + "results_count": "Found {count} results", + "filters": "Filters", + "asset_type": "Asset Type", + "exchange": "Exchange", + "country": "Country" + } } } \ No newline at end of file diff --git a/python/locales/en-US.json b/python/locales/en-US.json index 76a717a2f..2633ab579 100644 --- a/python/locales/en-US.json +++ b/python/locales/en-US.json @@ -94,7 +94,11 @@ "days": "Days", "weeks": "Weeks", "months": "Months", - "years": "Years" + "years": "Years", + "thousand": "K", + "million": "M", + "billion": "B", + "trillion": "T" }, "app": { "name": "ValueCell", @@ -113,5 +117,55 @@ "preferences": "Preferences", "general": "General", "advanced": "Advanced" + }, + "assets": { + "types": { + "stock": "Stock", + "crypto": "Cryptocurrency", + "etf": "ETF", + "bond": "Bond", + "commodity": "Commodity", + "forex": "Forex", + "index": "Index", + "mutual_fund": "Mutual Fund", + "option": "Option", + "future": "Future" + }, + "market_status": { + "open": "Open", + "closed": "Closed", + "pre_market": "Pre-market", + "after_hours": "After Hours", + "halted": "Halted", + "unknown": "Unknown" + }, + "watchlist": { + "title": "Watchlist", + "my_watchlist": "My Watchlist", + "add_asset": "Add Asset", + "remove_asset": "Remove Asset", + "search_placeholder": "Search stocks, funds, or cryptocurrencies...", + "no_assets": "No assets in watchlist", + "price": "Price", + "change": "Change", + "change_percent": "Change %", + "market_cap": "Market Cap", + "volume": "Volume", + "last_updated": "Last Updated", + "add_to_watchlist": "Add to Watchlist", + "remove_from_watchlist": "Remove from Watchlist", + "notes": "Notes" + }, + "search": { + "title": "Asset Search", + "placeholder": "Enter ticker symbol or company name...", + "no_results": "No assets found", + "searching": "Searching...", + "results_count": "Found {count} results", + "filters": "Filters", + "asset_type": "Asset Type", + "exchange": "Exchange", + "country": "Country" + } } } \ No newline at end of file diff --git a/python/locales/zh-Hans.json b/python/locales/zh-Hans.json index a10e90d98..f9b363653 100644 --- a/python/locales/zh-Hans.json +++ b/python/locales/zh-Hans.json @@ -94,7 +94,11 @@ "days": "天", "weeks": "周", "months": "月", - "years": "年" + "years": "年", + "thousand": "千", + "million": "百万", + "billion": "十亿", + "trillion": "万亿" }, "app": { "name": "ValueCell", @@ -113,5 +117,55 @@ "preferences": "偏好设置", "general": "常规", "advanced": "高级" + }, + "assets": { + "types": { + "stock": "股票", + "crypto": "加密货币", + "etf": "ETF基金", + "bond": "债券", + "commodity": "商品", + "forex": "外汇", + "index": "指数", + "mutual_fund": "共同基金", + "option": "期权", + "future": "期货" + }, + "market_status": { + "open": "开盘", + "closed": "闭市", + "pre_market": "盘前交易", + "after_hours": "盘后交易", + "halted": "暂停交易", + "unknown": "未知" + }, + "watchlist": { + "title": "自选股", + "my_watchlist": "我的自选", + "add_asset": "添加资产", + "remove_asset": "移除资产", + "search_placeholder": "搜索股票、基金或加密货币...", + "no_assets": "暂无自选资产", + "price": "价格", + "change": "涨跌", + "change_percent": "涨跌幅", + "market_cap": "市值", + "volume": "成交量", + "last_updated": "最后更新", + "add_to_watchlist": "加入自选", + "remove_from_watchlist": "移出自选", + "notes": "备注" + }, + "search": { + "title": "资产搜索", + "placeholder": "输入股票代码或公司名称...", + "no_results": "未找到相关资产", + "searching": "搜索中...", + "results_count": "找到 {count} 个结果", + "filters": "筛选条件", + "asset_type": "资产类型", + "exchange": "交易所", + "country": "国家/地区" + } } } \ No newline at end of file diff --git a/python/locales/zh-Hant.json b/python/locales/zh-Hant.json index ee7e4602e..42bb0f675 100644 --- a/python/locales/zh-Hant.json +++ b/python/locales/zh-Hant.json @@ -94,7 +94,11 @@ "days": "天", "weeks": "週", "months": "月", - "years": "年" + "years": "年", + "thousand": "千", + "million": "百萬", + "billion": "十億", + "trillion": "萬億" }, "app": { "name": "ValueCell", @@ -113,5 +117,55 @@ "preferences": "偏好設定", "general": "一般", "advanced": "高級" + }, + "assets": { + "types": { + "stock": "股票", + "crypto": "加密貨幣", + "etf": "ETF基金", + "bond": "債券", + "commodity": "商品", + "forex": "外匯", + "index": "指數", + "mutual_fund": "共同基金", + "option": "期權", + "future": "期貨" + }, + "market_status": { + "open": "開盤", + "closed": "閉市", + "pre_market": "盤前交易", + "after_hours": "盤後交易", + "halted": "暫停交易", + "unknown": "未知" + }, + "watchlist": { + "title": "自選股", + "my_watchlist": "我的自選", + "add_asset": "新增資產", + "remove_asset": "移除資產", + "search_placeholder": "搜尋股票、基金或加密貨幣...", + "no_assets": "暫無自選資產", + "price": "價格", + "change": "漲跌", + "change_percent": "漲跌幅", + "market_cap": "市值", + "volume": "成交量", + "last_updated": "最後更新", + "add_to_watchlist": "加入自選", + "remove_from_watchlist": "移出自選", + "notes": "備註" + }, + "search": { + "title": "資產搜尋", + "placeholder": "輸入股票代碼或公司名稱...", + "no_results": "未找到相關資產", + "searching": "搜尋中...", + "results_count": "找到 {count} 個結果", + "filters": "篩選條件", + "asset_type": "資產類型", + "exchange": "交易所", + "country": "國家/地區" + } } } \ No newline at end of file diff --git a/python/pyproject.toml b/python/pyproject.toml index b4c86b916..5205276aa 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -14,6 +14,9 @@ dependencies = [ "fastapi>=0.104.0", "pydantic>=2.0.0", "uvicorn>=0.24.0", + "yfinance>=0.2.65", + "tushare>=1.4.24", + "requests>=2.32.5", ] [project.optional-dependencies] diff --git a/python/uv.lock b/python/uv.lock index 357c99bd7..a5c14c49b 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -25,6 +25,115 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/2e/3e5079847e653b1f6dc647aa24549d68c6addb4c595cc0d902d1b19308ad/beautifulsoup4-4.13.5.tar.gz", hash = "sha256:5e70131382930e7c3de33450a2f54a63d5e4b19386eab43a5b34d594268f3695", size = 622954, upload-time = "2025-08-24T14:06:13.168Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/eb/f4151e0c7377a6e08a38108609ba5cede57986802757848688aeedd1b9e8/beautifulsoup4-4.13.5-py3-none-any.whl", hash = "sha256:642085eaa22233aceadff9c69651bc51e8bf3f874fb6d7104ece2beb24b47c4a", size = 105113, upload-time = "2025-08-24T14:06:14.884Z" }, +] + +[[package]] +name = "bs4" +version = "0.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/aa/4acaf814ff901145da37332e05bb510452ebed97bc9602695059dd46ef39/bs4-0.0.2.tar.gz", hash = "sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925", size = 698, upload-time = "2024-01-17T18:15:47.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/bb/bf7aab772a159614954d84aa832c129624ba6c32faa559dfb200a534e50b/bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc", size = 1189, upload-time = "2024-01-17T18:15:48.613Z" }, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + [[package]] name = "click" version = "8.2.1" @@ -110,6 +219,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/0c/50db5379b615854b5cf89146f8f5bd1d5a9693d7f3a987e269693521c404/coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3", size = 208986, upload-time = "2025-08-29T15:35:14.506Z" }, ] +[[package]] +name = "curl-cffi" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/3d/f39ca1f8fdf14408888e7c25e15eed63eac5f47926e206fb93300d28378c/curl_cffi-0.13.0.tar.gz", hash = "sha256:62ecd90a382bd5023750e3606e0aa7cb1a3a8ba41c14270b8e5e149ebf72c5ca", size = 151303, upload-time = "2025-08-06T13:05:42.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/d1/acabfd460f1de26cad882e5ef344d9adde1507034528cb6f5698a2e6a2f1/curl_cffi-0.13.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:434cadbe8df2f08b2fc2c16dff2779fb40b984af99c06aa700af898e185bb9db", size = 5686337, upload-time = "2025-08-06T13:05:28.985Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1c/cdb4fb2d16a0e9de068e0e5bc02094e105ce58a687ff30b4c6f88e25a057/curl_cffi-0.13.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:59afa877a9ae09efa04646a7d068eeea48915a95d9add0a29854e7781679fcd7", size = 2994613, upload-time = "2025-08-06T13:05:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3e/fdf617c1ec18c3038b77065d484d7517bb30f8fb8847224eb1f601a4e8bc/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d06ed389e45a7ca97b17c275dbedd3d6524560270e675c720e93a2018a766076", size = 7931353, upload-time = "2025-08-06T13:05:32.273Z" }, + { url = "https://files.pythonhosted.org/packages/3d/10/6f30c05d251cf03ddc2b9fd19880f3cab8c193255e733444a2df03b18944/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4e0de45ab3b7a835c72bd53640c2347415111b43421b5c7a1a0b18deae2e541", size = 7486378, upload-time = "2025-08-06T13:05:33.672Z" }, + { url = "https://files.pythonhosted.org/packages/77/81/5bdb7dd0d669a817397b2e92193559bf66c3807f5848a48ad10cf02bf6c7/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb4083371bbb94e9470d782de235fb5268bf43520de020c9e5e6be8f395443f", size = 8328585, upload-time = "2025-08-06T13:05:35.28Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c1/df5c6b4cfad41c08442e0f727e449f4fb5a05f8aa564d1acac29062e9e8e/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:28911b526e8cd4aa0e5e38401bfe6887e8093907272f1f67ca22e6beb2933a51", size = 8739831, upload-time = "2025-08-06T13:05:37.078Z" }, + { url = "https://files.pythonhosted.org/packages/1a/91/6dd1910a212f2e8eafe57877bcf97748eb24849e1511a266687546066b8a/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6d433ffcb455ab01dd0d7bde47109083aa38b59863aa183d29c668ae4c96bf8e", size = 8711908, upload-time = "2025-08-06T13:05:38.741Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e4/15a253f9b4bf8d008c31e176c162d2704a7e0c5e24d35942f759df107b68/curl_cffi-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:66a6b75ce971de9af64f1b6812e275f60b88880577bac47ef1fa19694fa21cd3", size = 1614510, upload-time = "2025-08-06T13:05:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0f/9c5275f17ad6ff5be70edb8e0120fdc184a658c9577ca426d4230f654beb/curl_cffi-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:d438a3b45244e874794bc4081dc1e356d2bb926dcc7021e5a8fef2e2105ef1d8", size = 1365753, upload-time = "2025-08-06T13:05:41.879Z" }, +] + [[package]] name = "fastapi" version = "0.116.1" @@ -124,6 +254,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" }, ] +[[package]] +name = "frozendict" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/59/19eb300ba28e7547538bdf603f1c6c34793240a90e1a7b61b65d8517e35e/frozendict-2.4.6.tar.gz", hash = "sha256:df7cd16470fbd26fc4969a208efadc46319334eb97def1ddf48919b351192b8e", size = 316416, upload-time = "2024-10-13T12:15:32.449Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/13/d9839089b900fa7b479cce495d62110cddc4bd5630a04d8469916c0e79c5/frozendict-2.4.6-py311-none-any.whl", hash = "sha256:d065db6a44db2e2375c23eac816f1a022feb2fa98cbb50df44a9e83700accbea", size = 16148, upload-time = "2024-10-13T12:15:26.839Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d0/d482c39cee2ab2978a892558cf130681d4574ea208e162da8958b31e9250/frozendict-2.4.6-py312-none-any.whl", hash = "sha256:49344abe90fb75f0f9fdefe6d4ef6d4894e640fadab71f11009d52ad97f370b9", size = 16146, upload-time = "2024-10-13T12:15:28.16Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8e/b6bf6a0de482d7d7d7a2aaac8fdc4a4d0bb24a809f5ddd422aa7060eb3d2/frozendict-2.4.6-py313-none-any.whl", hash = "sha256:7134a2bb95d4a16556bb5f2b9736dceb6ea848fa5b6f3f6c2d6dba93b44b4757", size = 16146, upload-time = "2024-10-13T12:15:29.495Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -151,6 +292,137 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] +[[package]] +name = "lxml" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/bd/f9d01fd4132d81c6f43ab01983caea69ec9614b913c290a26738431a015d/lxml-6.0.1.tar.gz", hash = "sha256:2b3a882ebf27dd026df3801a87cf49ff791336e0f94b0fad195db77e01240690", size = 4070214, upload-time = "2025-08-22T10:37:53.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/a9/82b244c8198fcdf709532e39a1751943a36b3e800b420adc739d751e0299/lxml-6.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c03ac546adaabbe0b8e4a15d9ad815a281afc8d36249c246aecf1aaad7d6f200", size = 8422788, upload-time = "2025-08-22T10:32:56.612Z" }, + { url = "https://files.pythonhosted.org/packages/c9/8d/1ed2bc20281b0e7ed3e6c12b0a16e64ae2065d99be075be119ba88486e6d/lxml-6.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33b862c7e3bbeb4ba2c96f3a039f925c640eeba9087a4dc7a572ec0f19d89392", size = 4593547, upload-time = "2025-08-22T10:32:59.016Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/d7fd3af95b72a3493bf7fbe842a01e339d8f41567805cecfecd5c71aa5ee/lxml-6.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7a3ec1373f7d3f519de595032d4dcafae396c29407cfd5073f42d267ba32440d", size = 4948101, upload-time = "2025-08-22T10:33:00.765Z" }, + { url = "https://files.pythonhosted.org/packages/9d/51/4e57cba4d55273c400fb63aefa2f0d08d15eac021432571a7eeefee67bed/lxml-6.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03b12214fb1608f4cffa181ec3d046c72f7e77c345d06222144744c122ded870", size = 5108090, upload-time = "2025-08-22T10:33:03.108Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/5f290bc26fcc642bc32942e903e833472271614e24d64ad28aaec09d5dae/lxml-6.0.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:207ae0d5f0f03b30f95e649a6fa22aa73f5825667fee9c7ec6854d30e19f2ed8", size = 5021791, upload-time = "2025-08-22T10:33:06.972Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/2e7551a86992ece4f9a0f6eebd4fb7e312d30f1e372760e2109e721d4ce6/lxml-6.0.1-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:32297b09ed4b17f7b3f448de87a92fb31bb8747496623483788e9f27c98c0f00", size = 5358861, upload-time = "2025-08-22T10:33:08.967Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/cb49d727fc388bf5fd37247209bab0da11697ddc5e976ccac4826599939e/lxml-6.0.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e18224ea241b657a157c85e9cac82c2b113ec90876e01e1f127312006233756", size = 5652569, upload-time = "2025-08-22T10:33:10.815Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b8/66c1ef8c87ad0f958b0a23998851e610607c74849e75e83955d5641272e6/lxml-6.0.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a07a994d3c46cd4020c1ea566345cf6815af205b1e948213a4f0f1d392182072", size = 5252262, upload-time = "2025-08-22T10:33:12.673Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ef/131d3d6b9590e64fdbb932fbc576b81fcc686289da19c7cb796257310e82/lxml-6.0.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:2287fadaa12418a813b05095485c286c47ea58155930cfbd98c590d25770e225", size = 4710309, upload-time = "2025-08-22T10:33:14.952Z" }, + { url = "https://files.pythonhosted.org/packages/bc/3f/07f48ae422dce44902309aa7ed386c35310929dc592439c403ec16ef9137/lxml-6.0.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4e597efca032ed99f418bd21314745522ab9fa95af33370dcee5533f7f70136", size = 5265786, upload-time = "2025-08-22T10:33:16.721Z" }, + { url = "https://files.pythonhosted.org/packages/11/c7/125315d7b14ab20d9155e8316f7d287a4956098f787c22d47560b74886c4/lxml-6.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9696d491f156226decdd95d9651c6786d43701e49f32bf23715c975539aa2b3b", size = 5062272, upload-time = "2025-08-22T10:33:18.478Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c3/51143c3a5fc5168a7c3ee626418468ff20d30f5a59597e7b156c1e61fba8/lxml-6.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e4e3cd3585f3c6f87cdea44cda68e692cc42a012f0131d25957ba4ce755241a7", size = 4786955, upload-time = "2025-08-22T10:33:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/11/86/73102370a420ec4529647b31c4a8ce8c740c77af3a5fae7a7643212d6f6e/lxml-6.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:45cbc92f9d22c28cd3b97f8d07fcefa42e569fbd587dfdac76852b16a4924277", size = 5673557, upload-time = "2025-08-22T10:33:22.282Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2d/aad90afaec51029aef26ef773b8fd74a9e8706e5e2f46a57acd11a421c02/lxml-6.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f8c9bcfd2e12299a442fba94459adf0b0d001dbc68f1594439bfa10ad1ecb74b", size = 5254211, upload-time = "2025-08-22T10:33:24.15Z" }, + { url = "https://files.pythonhosted.org/packages/63/01/c9e42c8c2d8b41f4bdefa42ab05448852e439045f112903dd901b8fbea4d/lxml-6.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e9dc2b9f1586e7cd77753eae81f8d76220eed9b768f337dc83a3f675f2f0cf9", size = 5275817, upload-time = "2025-08-22T10:33:26.007Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1f/962ea2696759abe331c3b0e838bb17e92224f39c638c2068bf0d8345e913/lxml-6.0.1-cp312-cp312-win32.whl", hash = "sha256:987ad5c3941c64031f59c226167f55a04d1272e76b241bfafc968bdb778e07fb", size = 3610889, upload-time = "2025-08-22T10:33:28.169Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/22c86a990b51b44442b75c43ecb2f77b8daba8c4ba63696921966eac7022/lxml-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:abb05a45394fd76bf4a60c1b7bec0e6d4e8dfc569fc0e0b1f634cd983a006ddc", size = 4010925, upload-time = "2025-08-22T10:33:29.874Z" }, + { url = "https://files.pythonhosted.org/packages/b2/21/dc0c73325e5eb94ef9c9d60dbb5dcdcb2e7114901ea9509735614a74e75a/lxml-6.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:c4be29bce35020d8579d60aa0a4e95effd66fcfce31c46ffddf7e5422f73a299", size = 3671922, upload-time = "2025-08-22T10:33:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/43/c4/cd757eeec4548e6652eff50b944079d18ce5f8182d2b2cf514e125e8fbcb/lxml-6.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:485eda5d81bb7358db96a83546949c5fe7474bec6c68ef3fa1fb61a584b00eea", size = 8405139, upload-time = "2025-08-22T10:33:34.09Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/0290bb86a7403893f5e9658490c705fcea103b9191f2039752b071b4ef07/lxml-6.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d12160adea318ce3d118f0b4fbdff7d1225c75fb7749429541b4d217b85c3f76", size = 4585954, upload-time = "2025-08-22T10:33:36.294Z" }, + { url = "https://files.pythonhosted.org/packages/88/a7/4bb54dd1e626342a0f7df6ec6ca44fdd5d0e100ace53acc00e9a689ead04/lxml-6.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48c8d335d8ab72f9265e7ba598ae5105a8272437403f4032107dbcb96d3f0b29", size = 4944052, upload-time = "2025-08-22T10:33:38.19Z" }, + { url = "https://files.pythonhosted.org/packages/71/8d/20f51cd07a7cbef6214675a8a5c62b2559a36d9303fe511645108887c458/lxml-6.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:405e7cf9dbdbb52722c231e0f1257214202dfa192327fab3de45fd62e0554082", size = 5098885, upload-time = "2025-08-22T10:33:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/63/efceeee7245d45f97d548e48132258a36244d3c13c6e3ddbd04db95ff496/lxml-6.0.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:299a790d403335a6a057ade46f92612ebab87b223e4e8c5308059f2dc36f45ed", size = 5017542, upload-time = "2025-08-22T10:33:41.896Z" }, + { url = "https://files.pythonhosted.org/packages/57/5d/92cb3d3499f5caba17f7933e6be3b6c7de767b715081863337ced42eb5f2/lxml-6.0.1-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:48da704672f6f9c461e9a73250440c647638cc6ff9567ead4c3b1f189a604ee8", size = 5347303, upload-time = "2025-08-22T10:33:43.868Z" }, + { url = "https://files.pythonhosted.org/packages/69/f8/606fa16a05d7ef5e916c6481c634f40870db605caffed9d08b1a4fb6b989/lxml-6.0.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:21e364e1bb731489e3f4d51db416f991a5d5da5d88184728d80ecfb0904b1d68", size = 5641055, upload-time = "2025-08-22T10:33:45.784Z" }, + { url = "https://files.pythonhosted.org/packages/b3/01/15d5fc74ebb49eac4e5df031fbc50713dcc081f4e0068ed963a510b7d457/lxml-6.0.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bce45a2c32032afddbd84ed8ab092130649acb935536ef7a9559636ce7ffd4a", size = 5242719, upload-time = "2025-08-22T10:33:48.089Z" }, + { url = "https://files.pythonhosted.org/packages/42/a5/1b85e2aaaf8deaa67e04c33bddb41f8e73d07a077bf9db677cec7128bfb4/lxml-6.0.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:fa164387ff20ab0e575fa909b11b92ff1481e6876835014e70280769920c4433", size = 4717310, upload-time = "2025-08-22T10:33:49.852Z" }, + { url = "https://files.pythonhosted.org/packages/42/23/f3bb1292f55a725814317172eeb296615db3becac8f1a059b53c51fc1da8/lxml-6.0.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7587ac5e000e1594e62278422c5783b34a82b22f27688b1074d71376424b73e8", size = 5254024, upload-time = "2025-08-22T10:33:52.22Z" }, + { url = "https://files.pythonhosted.org/packages/b4/be/4d768f581ccd0386d424bac615d9002d805df7cc8482ae07d529f60a3c1e/lxml-6.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:57478424ac4c9170eabf540237125e8d30fad1940648924c058e7bc9fb9cf6dd", size = 5055335, upload-time = "2025-08-22T10:33:54.041Z" }, + { url = "https://files.pythonhosted.org/packages/40/07/ed61d1a3e77d1a9f856c4fab15ee5c09a2853fb7af13b866bb469a3a6d42/lxml-6.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:09c74afc7786c10dd6afaa0be2e4805866beadc18f1d843cf517a7851151b499", size = 4784864, upload-time = "2025-08-22T10:33:56.382Z" }, + { url = "https://files.pythonhosted.org/packages/01/37/77e7971212e5c38a55431744f79dff27fd751771775165caea096d055ca4/lxml-6.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7fd70681aeed83b196482d42a9b0dc5b13bab55668d09ad75ed26dff3be5a2f5", size = 5657173, upload-time = "2025-08-22T10:33:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/32/a3/e98806d483941cd9061cc838b1169626acef7b2807261fbe5e382fcef881/lxml-6.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:10a72e456319b030b3dd900df6b1f19d89adf06ebb688821636dc406788cf6ac", size = 5245896, upload-time = "2025-08-22T10:34:00.586Z" }, + { url = "https://files.pythonhosted.org/packages/07/de/9bb5a05e42e8623bf06b4638931ea8c8f5eb5a020fe31703abdbd2e83547/lxml-6.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0fa45fb5f55111ce75b56c703843b36baaf65908f8b8d2fbbc0e249dbc127ed", size = 5267417, upload-time = "2025-08-22T10:34:02.719Z" }, + { url = "https://files.pythonhosted.org/packages/f2/43/c1cb2a7c67226266c463ef8a53b82d42607228beb763b5fbf4867e88a21f/lxml-6.0.1-cp313-cp313-win32.whl", hash = "sha256:01dab65641201e00c69338c9c2b8a0f2f484b6b3a22d10779bb417599fae32b5", size = 3610051, upload-time = "2025-08-22T10:34:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/34/96/6a6c3b8aa480639c1a0b9b6faf2a63fb73ab79ffcd2a91cf28745faa22de/lxml-6.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:bdf8f7c8502552d7bff9e4c98971910a0a59f60f88b5048f608d0a1a75e94d1c", size = 4009325, upload-time = "2025-08-22T10:34:06.24Z" }, + { url = "https://files.pythonhosted.org/packages/8c/66/622e8515121e1fd773e3738dae71b8df14b12006d9fb554ce90886689fd0/lxml-6.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a6aeca75959426b9fd8d4782c28723ba224fe07cfa9f26a141004210528dcbe2", size = 3670443, upload-time = "2025-08-22T10:34:07.974Z" }, + { url = "https://files.pythonhosted.org/packages/38/e3/b7eb612ce07abe766918a7e581ec6a0e5212352194001fd287c3ace945f0/lxml-6.0.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:29b0e849ec7030e3ecb6112564c9f7ad6881e3b2375dd4a0c486c5c1f3a33859", size = 8426160, upload-time = "2025-08-22T10:34:10.154Z" }, + { url = "https://files.pythonhosted.org/packages/35/8f/ab3639a33595cf284fe733c6526da2ca3afbc5fd7f244ae67f3303cec654/lxml-6.0.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:02a0f7e629f73cc0be598c8b0611bf28ec3b948c549578a26111b01307fd4051", size = 4589288, upload-time = "2025-08-22T10:34:12.972Z" }, + { url = "https://files.pythonhosted.org/packages/2c/65/819d54f2e94d5c4458c1db8c1ccac9d05230b27c1038937d3d788eb406f9/lxml-6.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:beab5e54de016e730875f612ba51e54c331e2fa6dc78ecf9a5415fc90d619348", size = 4964523, upload-time = "2025-08-22T10:34:15.474Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4a/d4a74ce942e60025cdaa883c5a4478921a99ce8607fc3130f1e349a83b28/lxml-6.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a08aefecd19ecc4ebf053c27789dd92c87821df2583a4337131cf181a1dffa", size = 5101108, upload-time = "2025-08-22T10:34:17.348Z" }, + { url = "https://files.pythonhosted.org/packages/cb/48/67f15461884074edd58af17b1827b983644d1fae83b3d909e9045a08b61e/lxml-6.0.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36c8fa7e177649470bc3dcf7eae6bee1e4984aaee496b9ccbf30e97ac4127fa2", size = 5053498, upload-time = "2025-08-22T10:34:19.232Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d4/ec1bf1614828a5492f4af0b6a9ee2eb3e92440aea3ac4fa158e5228b772b/lxml-6.0.1-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:5d08e0f1af6916267bb7eff21c09fa105620f07712424aaae09e8cb5dd4164d1", size = 5351057, upload-time = "2025-08-22T10:34:21.143Z" }, + { url = "https://files.pythonhosted.org/packages/65/2b/c85929dacac08821f2100cea3eb258ce5c8804a4e32b774f50ebd7592850/lxml-6.0.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9705cdfc05142f8c38c97a61bd3a29581ceceb973a014e302ee4a73cc6632476", size = 5671579, upload-time = "2025-08-22T10:34:23.528Z" }, + { url = "https://files.pythonhosted.org/packages/d0/36/cf544d75c269b9aad16752fd9f02d8e171c5a493ca225cb46bb7ba72868c/lxml-6.0.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74555e2da7c1636e30bff4e6e38d862a634cf020ffa591f1f63da96bf8b34772", size = 5250403, upload-time = "2025-08-22T10:34:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e8/83dbc946ee598fd75fdeae6151a725ddeaab39bb321354a9468d4c9f44f3/lxml-6.0.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e38b5f94c5a2a5dadaddd50084098dfd005e5a2a56cd200aaf5e0a20e8941782", size = 4696712, upload-time = "2025-08-22T10:34:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/f4/72/889c633b47c06205743ba935f4d1f5aa4eb7f0325d701ed2b0540df1b004/lxml-6.0.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5ec101a92ddacb4791977acfc86c1afd624c032974bfb6a21269d1083c9bc49", size = 5268177, upload-time = "2025-08-22T10:34:29.804Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/f42a21a1428479b66ea0da7bd13e370436aecaff0cfe93270c7e165bd2a4/lxml-6.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5c17e70c82fd777df586c12114bbe56e4e6f823a971814fd40dec9c0de518772", size = 5094648, upload-time = "2025-08-22T10:34:31.703Z" }, + { url = "https://files.pythonhosted.org/packages/51/b0/5f8c1e8890e2ee1c2053c2eadd1cb0e4b79e2304e2912385f6ca666f48b1/lxml-6.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:45fdd0415a0c3d91640b5d7a650a8f37410966a2e9afebb35979d06166fd010e", size = 4745220, upload-time = "2025-08-22T10:34:33.595Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f9/820b5125660dae489ca3a21a36d9da2e75dd6b5ffe922088f94bbff3b8a0/lxml-6.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d417eba28981e720a14fcb98f95e44e7a772fe25982e584db38e5d3b6ee02e79", size = 5692913, upload-time = "2025-08-22T10:34:35.482Z" }, + { url = "https://files.pythonhosted.org/packages/23/8e/a557fae9eec236618aecf9ff35fec18df41b6556d825f3ad6017d9f6e878/lxml-6.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8e5d116b9e59be7934febb12c41cce2038491ec8fdb743aeacaaf36d6e7597e4", size = 5259816, upload-time = "2025-08-22T10:34:37.482Z" }, + { url = "https://files.pythonhosted.org/packages/fa/fd/b266cfaab81d93a539040be699b5854dd24c84e523a1711ee5f615aa7000/lxml-6.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c238f0d0d40fdcb695c439fe5787fa69d40f45789326b3bb6ef0d61c4b588d6e", size = 5276162, upload-time = "2025-08-22T10:34:39.507Z" }, + { url = "https://files.pythonhosted.org/packages/25/6c/6f9610fbf1de002048e80585ea4719591921a0316a8565968737d9f125ca/lxml-6.0.1-cp314-cp314-win32.whl", hash = "sha256:537b6cf1c5ab88cfd159195d412edb3e434fee880f206cbe68dff9c40e17a68a", size = 3669595, upload-time = "2025-08-22T10:34:41.783Z" }, + { url = "https://files.pythonhosted.org/packages/72/a5/506775e3988677db24dc75a7b03e04038e0b3d114ccd4bccea4ce0116c15/lxml-6.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:911d0a2bb3ef3df55b3d97ab325a9ca7e438d5112c102b8495321105d25a441b", size = 4079818, upload-time = "2025-08-22T10:34:44.04Z" }, + { url = "https://files.pythonhosted.org/packages/0a/44/9613f300201b8700215856e5edd056d4e58dd23368699196b58877d4408b/lxml-6.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:2834377b0145a471a654d699bdb3a2155312de492142ef5a1d426af2c60a0a31", size = 3753901, upload-time = "2025-08-22T10:34:45.799Z" }, +] + +[[package]] +name = "multitasking" +version = "0.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/0d/74f0293dfd7dcc3837746d0138cbedd60b31701ecc75caec7d3f281feba0/multitasking-0.0.12.tar.gz", hash = "sha256:2fba2fa8ed8c4b85e227c5dd7dc41c7d658de3b6f247927316175a57349b84d1", size = 19984, upload-time = "2025-07-20T21:27:51.636Z" } + +[[package]] +name = "numpy" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/7d/3fec4199c5ffb892bed55cff901e4f39a58c81df9c44c280499e92cad264/numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48", size = 20489306, upload-time = "2025-07-24T21:32:07.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/6d/745dd1c1c5c284d17725e5c802ca4d45cfc6803519d777f087b71c9f4069/numpy-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bc3186bea41fae9d8e90c2b4fb5f0a1f5a690682da79b92574d63f56b529080b", size = 20956420, upload-time = "2025-07-24T20:28:18.002Z" }, + { url = "https://files.pythonhosted.org/packages/bc/96/e7b533ea5740641dd62b07a790af5d9d8fec36000b8e2d0472bd7574105f/numpy-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f4f0215edb189048a3c03bd5b19345bdfa7b45a7a6f72ae5945d2a28272727f", size = 14184660, upload-time = "2025-07-24T20:28:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/2b/53/102c6122db45a62aa20d1b18c9986f67e6b97e0d6fbc1ae13e3e4c84430c/numpy-2.3.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b1224a734cd509f70816455c3cffe13a4f599b1bf7130f913ba0e2c0b2006c0", size = 5113382, upload-time = "2025-07-24T20:28:48.544Z" }, + { url = "https://files.pythonhosted.org/packages/2b/21/376257efcbf63e624250717e82b4fae93d60178f09eb03ed766dbb48ec9c/numpy-2.3.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3dcf02866b977a38ba3ec10215220609ab9667378a9e2150615673f3ffd6c73b", size = 6647258, upload-time = "2025-07-24T20:28:59.104Z" }, + { url = "https://files.pythonhosted.org/packages/91/ba/f4ebf257f08affa464fe6036e13f2bf9d4642a40228781dc1235da81be9f/numpy-2.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:572d5512df5470f50ada8d1972c5f1082d9a0b7aa5944db8084077570cf98370", size = 14281409, upload-time = "2025-07-24T20:40:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/59/ef/f96536f1df42c668cbacb727a8c6da7afc9c05ece6d558927fb1722693e1/numpy-2.3.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8145dd6d10df13c559d1e4314df29695613575183fa2e2d11fac4c208c8a1f73", size = 16641317, upload-time = "2025-07-24T20:40:56.625Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a7/af813a7b4f9a42f498dde8a4c6fcbff8100eed00182cc91dbaf095645f38/numpy-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:103ea7063fa624af04a791c39f97070bf93b96d7af7eb23530cd087dc8dbe9dc", size = 16056262, upload-time = "2025-07-24T20:41:20.797Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5d/41c4ef8404caaa7f05ed1cfb06afe16a25895260eacbd29b4d84dff2920b/numpy-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc927d7f289d14f5e037be917539620603294454130b6de200091e23d27dc9be", size = 18579342, upload-time = "2025-07-24T20:41:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/9950e44c5a11636f4a3af6e825ec23003475cc9a466edb7a759ed3ea63bd/numpy-2.3.2-cp312-cp312-win32.whl", hash = "sha256:d95f59afe7f808c103be692175008bab926b59309ade3e6d25009e9a171f7036", size = 6320610, upload-time = "2025-07-24T20:42:01.551Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2f/244643a5ce54a94f0a9a2ab578189c061e4a87c002e037b0829dd77293b6/numpy-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:9e196ade2400c0c737d93465327d1ae7c06c7cb8a1756121ebf54b06ca183c7f", size = 12786292, upload-time = "2025-07-24T20:42:20.738Z" }, + { url = "https://files.pythonhosted.org/packages/54/cd/7b5f49d5d78db7badab22d8323c1b6ae458fbf86c4fdfa194ab3cd4eb39b/numpy-2.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:ee807923782faaf60d0d7331f5e86da7d5e3079e28b291973c545476c2b00d07", size = 10194071, upload-time = "2025-07-24T20:42:36.657Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c0/c6bb172c916b00700ed3bf71cb56175fd1f7dbecebf8353545d0b5519f6c/numpy-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8d9727f5316a256425892b043736d63e89ed15bbfe6556c5ff4d9d4448ff3b3", size = 20949074, upload-time = "2025-07-24T20:43:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/20/4e/c116466d22acaf4573e58421c956c6076dc526e24a6be0903219775d862e/numpy-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:efc81393f25f14d11c9d161e46e6ee348637c0a1e8a54bf9dedc472a3fae993b", size = 14177311, upload-time = "2025-07-24T20:43:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/78/45/d4698c182895af189c463fc91d70805d455a227261d950e4e0f1310c2550/numpy-2.3.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dd937f088a2df683cbb79dda9a772b62a3e5a8a7e76690612c2737f38c6ef1b6", size = 5106022, upload-time = "2025-07-24T20:43:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/9f/76/3e6880fef4420179309dba72a8c11f6166c431cf6dee54c577af8906f914/numpy-2.3.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:11e58218c0c46c80509186e460d79fbdc9ca1eb8d8aee39d8f2dc768eb781089", size = 6640135, upload-time = "2025-07-24T20:43:49.28Z" }, + { url = "https://files.pythonhosted.org/packages/34/fa/87ff7f25b3c4ce9085a62554460b7db686fef1e0207e8977795c7b7d7ba1/numpy-2.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ad4ebcb683a1f99f4f392cc522ee20a18b2bb12a2c1c42c3d48d5a1adc9d3d2", size = 14278147, upload-time = "2025-07-24T20:44:10.328Z" }, + { url = "https://files.pythonhosted.org/packages/1d/0f/571b2c7a3833ae419fe69ff7b479a78d313581785203cc70a8db90121b9a/numpy-2.3.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938065908d1d869c7d75d8ec45f735a034771c6ea07088867f713d1cd3bbbe4f", size = 16635989, upload-time = "2025-07-24T20:44:34.88Z" }, + { url = "https://files.pythonhosted.org/packages/24/5a/84ae8dca9c9a4c592fe11340b36a86ffa9fd3e40513198daf8a97839345c/numpy-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66459dccc65d8ec98cc7df61307b64bf9e08101f9598755d42d8ae65d9a7a6ee", size = 16053052, upload-time = "2025-07-24T20:44:58.872Z" }, + { url = "https://files.pythonhosted.org/packages/57/7c/e5725d99a9133b9813fcf148d3f858df98511686e853169dbaf63aec6097/numpy-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a7af9ed2aa9ec5950daf05bb11abc4076a108bd3c7db9aa7251d5f107079b6a6", size = 18577955, upload-time = "2025-07-24T20:45:26.714Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7c546fcf42145f29b71e4d6f429e96d8d68e5a7ba1830b2e68d7418f0bbd/numpy-2.3.2-cp313-cp313-win32.whl", hash = "sha256:906a30249315f9c8e17b085cc5f87d3f369b35fedd0051d4a84686967bdbbd0b", size = 6311843, upload-time = "2025-07-24T20:49:24.444Z" }, + { url = "https://files.pythonhosted.org/packages/aa/6f/a428fd1cb7ed39b4280d057720fed5121b0d7754fd2a9768640160f5517b/numpy-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:c63d95dc9d67b676e9108fe0d2182987ccb0f11933c1e8959f42fa0da8d4fa56", size = 12782876, upload-time = "2025-07-24T20:49:43.227Z" }, + { url = "https://files.pythonhosted.org/packages/65/85/4ea455c9040a12595fb6c43f2c217257c7b52dd0ba332c6a6c1d28b289fe/numpy-2.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:b05a89f2fb84d21235f93de47129dd4f11c16f64c87c33f5e284e6a3a54e43f2", size = 10192786, upload-time = "2025-07-24T20:49:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/80/23/8278f40282d10c3f258ec3ff1b103d4994bcad78b0cba9208317f6bb73da/numpy-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e6ecfeddfa83b02318f4d84acf15fbdbf9ded18e46989a15a8b6995dfbf85ab", size = 21047395, upload-time = "2025-07-24T20:45:58.821Z" }, + { url = "https://files.pythonhosted.org/packages/1f/2d/624f2ce4a5df52628b4ccd16a4f9437b37c35f4f8a50d00e962aae6efd7a/numpy-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:508b0eada3eded10a3b55725b40806a4b855961040180028f52580c4729916a2", size = 14300374, upload-time = "2025-07-24T20:46:20.207Z" }, + { url = "https://files.pythonhosted.org/packages/f6/62/ff1e512cdbb829b80a6bd08318a58698867bca0ca2499d101b4af063ee97/numpy-2.3.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:754d6755d9a7588bdc6ac47dc4ee97867271b17cee39cb87aef079574366db0a", size = 5228864, upload-time = "2025-07-24T20:46:30.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8e/74bc18078fff03192d4032cfa99d5a5ca937807136d6f5790ce07ca53515/numpy-2.3.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a9f66e7d2b2d7712410d3bc5684149040ef5f19856f20277cd17ea83e5006286", size = 6737533, upload-time = "2025-07-24T20:46:46.111Z" }, + { url = "https://files.pythonhosted.org/packages/19/ea/0731efe2c9073ccca5698ef6a8c3667c4cf4eea53fcdcd0b50140aba03bc/numpy-2.3.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6ea4e5a65d5a90c7d286ddff2b87f3f4ad61faa3db8dabe936b34c2275b6f8", size = 14352007, upload-time = "2025-07-24T20:47:07.1Z" }, + { url = "https://files.pythonhosted.org/packages/cf/90/36be0865f16dfed20f4bc7f75235b963d5939707d4b591f086777412ff7b/numpy-2.3.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3ef07ec8cbc8fc9e369c8dcd52019510c12da4de81367d8b20bc692aa07573a", size = 16701914, upload-time = "2025-07-24T20:47:32.459Z" }, + { url = "https://files.pythonhosted.org/packages/94/30/06cd055e24cb6c38e5989a9e747042b4e723535758e6153f11afea88c01b/numpy-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:27c9f90e7481275c7800dc9c24b7cc40ace3fdb970ae4d21eaff983a32f70c91", size = 16132708, upload-time = "2025-07-24T20:47:58.129Z" }, + { url = "https://files.pythonhosted.org/packages/9a/14/ecede608ea73e58267fd7cb78f42341b3b37ba576e778a1a06baffbe585c/numpy-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:07b62978075b67eee4065b166d000d457c82a1efe726cce608b9db9dd66a73a5", size = 18651678, upload-time = "2025-07-24T20:48:25.402Z" }, + { url = "https://files.pythonhosted.org/packages/40/f3/2fe6066b8d07c3685509bc24d56386534c008b462a488b7f503ba82b8923/numpy-2.3.2-cp313-cp313t-win32.whl", hash = "sha256:c771cfac34a4f2c0de8e8c97312d07d64fd8f8ed45bc9f5726a7e947270152b5", size = 6441832, upload-time = "2025-07-24T20:48:37.181Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ba/0937d66d05204d8f28630c9c60bc3eda68824abde4cf756c4d6aad03b0c6/numpy-2.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:72dbebb2dcc8305c431b2836bcc66af967df91be793d63a24e3d9b741374c450", size = 12927049, upload-time = "2025-07-24T20:48:56.24Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ed/13542dd59c104d5e654dfa2ac282c199ba64846a74c2c4bcdbc3a0f75df1/numpy-2.3.2-cp313-cp313t-win_arm64.whl", hash = "sha256:72c6df2267e926a6d5286b0a6d556ebe49eae261062059317837fda12ddf0c1a", size = 10262935, upload-time = "2025-07-24T20:49:13.136Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7c/7659048aaf498f7611b783e000c7268fcc4dcf0ce21cd10aad7b2e8f9591/numpy-2.3.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:448a66d052d0cf14ce9865d159bfc403282c9bc7bb2a31b03cc18b651eca8b1a", size = 20950906, upload-time = "2025-07-24T20:50:30.346Z" }, + { url = "https://files.pythonhosted.org/packages/80/db/984bea9d4ddf7112a04cfdfb22b1050af5757864cfffe8e09e44b7f11a10/numpy-2.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:546aaf78e81b4081b2eba1d105c3b34064783027a06b3ab20b6eba21fb64132b", size = 14185607, upload-time = "2025-07-24T20:50:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/e4/76/b3d6f414f4eca568f469ac112a3b510938d892bc5a6c190cb883af080b77/numpy-2.3.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:87c930d52f45df092f7578889711a0768094debf73cfcde105e2d66954358125", size = 5114110, upload-time = "2025-07-24T20:51:01.041Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d2/6f5e6826abd6bca52392ed88fe44a4b52aacb60567ac3bc86c67834c3a56/numpy-2.3.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:8dc082ea901a62edb8f59713c6a7e28a85daddcb67454c839de57656478f5b19", size = 6642050, upload-time = "2025-07-24T20:51:11.64Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/f12b2ade99199e39c73ad182f103f9d9791f48d885c600c8e05927865baf/numpy-2.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af58de8745f7fa9ca1c0c7c943616c6fe28e75d0c81f5c295810e3c83b5be92f", size = 14296292, upload-time = "2025-07-24T20:51:33.488Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f9/77c07d94bf110a916b17210fac38680ed8734c236bfed9982fd8524a7b47/numpy-2.3.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed5527c4cf10f16c6d0b6bee1f89958bccb0ad2522c8cadc2efd318bcd545f5", size = 16638913, upload-time = "2025-07-24T20:51:58.517Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d1/9d9f2c8ea399cc05cfff8a7437453bd4e7d894373a93cdc46361bbb49a7d/numpy-2.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:095737ed986e00393ec18ec0b21b47c22889ae4b0cd2d5e88342e08b01141f58", size = 16071180, upload-time = "2025-07-24T20:52:22.827Z" }, + { url = "https://files.pythonhosted.org/packages/4c/41/82e2c68aff2a0c9bf315e47d61951099fed65d8cb2c8d9dc388cb87e947e/numpy-2.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5e40e80299607f597e1a8a247ff8d71d79c5b52baa11cc1cce30aa92d2da6e0", size = 18576809, upload-time = "2025-07-24T20:52:51.015Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/4b4fd3efb0837ed252d0f583c5c35a75121038a8c4e065f2c259be06d2d8/numpy-2.3.2-cp314-cp314-win32.whl", hash = "sha256:7d6e390423cc1f76e1b8108c9b6889d20a7a1f59d9a60cac4a050fa734d6c1e2", size = 6366410, upload-time = "2025-07-24T20:56:44.949Z" }, + { url = "https://files.pythonhosted.org/packages/11/9e/b4c24a6b8467b61aced5c8dc7dcfce23621baa2e17f661edb2444a418040/numpy-2.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:b9d0878b21e3918d76d2209c924ebb272340da1fb51abc00f986c258cd5e957b", size = 12918821, upload-time = "2025-07-24T20:57:06.479Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0f/0dc44007c70b1007c1cef86b06986a3812dd7106d8f946c09cfa75782556/numpy-2.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:2738534837c6a1d0c39340a190177d7d66fdf432894f469728da901f8f6dc910", size = 10477303, upload-time = "2025-07-24T20:57:22.879Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3e/075752b79140b78ddfc9c0a1634d234cfdbc6f9bbbfa6b7504e445ad7d19/numpy-2.3.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:4d002ecf7c9b53240be3bb69d80f86ddbd34078bae04d87be81c1f58466f264e", size = 21047524, upload-time = "2025-07-24T20:53:22.086Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6d/60e8247564a72426570d0e0ea1151b95ce5bd2f1597bb878a18d32aec855/numpy-2.3.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:293b2192c6bcce487dbc6326de5853787f870aeb6c43f8f9c6496db5b1781e45", size = 14300519, upload-time = "2025-07-24T20:53:44.053Z" }, + { url = "https://files.pythonhosted.org/packages/4d/73/d8326c442cd428d47a067070c3ac6cc3b651a6e53613a1668342a12d4479/numpy-2.3.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0a4f2021a6da53a0d580d6ef5db29947025ae8b35b3250141805ea9a32bbe86b", size = 5228972, upload-time = "2025-07-24T20:53:53.81Z" }, + { url = "https://files.pythonhosted.org/packages/34/2e/e71b2d6dad075271e7079db776196829019b90ce3ece5c69639e4f6fdc44/numpy-2.3.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9c144440db4bf3bb6372d2c3e49834cc0ff7bb4c24975ab33e01199e645416f2", size = 6737439, upload-time = "2025-07-24T20:54:04.742Z" }, + { url = "https://files.pythonhosted.org/packages/15/b0/d004bcd56c2c5e0500ffc65385eb6d569ffd3363cb5e593ae742749b2daa/numpy-2.3.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f92d6c2a8535dc4fe4419562294ff957f83a16ebdec66df0805e473ffaad8bd0", size = 14352479, upload-time = "2025-07-24T20:54:25.819Z" }, + { url = "https://files.pythonhosted.org/packages/11/e3/285142fcff8721e0c99b51686426165059874c150ea9ab898e12a492e291/numpy-2.3.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cefc2219baa48e468e3db7e706305fcd0c095534a192a08f31e98d83a7d45fb0", size = 16702805, upload-time = "2025-07-24T20:54:50.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/c3/33b56b0e47e604af2c7cd065edca892d180f5899599b76830652875249a3/numpy-2.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76c3e9501ceb50b2ff3824c3589d5d1ab4ac857b0ee3f8f49629d0de55ecf7c2", size = 16133830, upload-time = "2025-07-24T20:55:17.306Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/7b1476a1f4d6a48bc669b8deb09939c56dd2a439db1ab03017844374fb67/numpy-2.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:122bf5ed9a0221b3419672493878ba4967121514b1d7d4656a7580cd11dddcbf", size = 18652665, upload-time = "2025-07-24T20:55:46.665Z" }, + { url = "https://files.pythonhosted.org/packages/14/ba/5b5c9978c4bb161034148ade2de9db44ec316fab89ce8c400db0e0c81f86/numpy-2.3.2-cp314-cp314t-win32.whl", hash = "sha256:6f1ae3dcb840edccc45af496f312528c15b1f79ac318169d094e85e4bb35fdf1", size = 6514777, upload-time = "2025-07-24T20:55:57.66Z" }, + { url = "https://files.pythonhosted.org/packages/eb/46/3dbaf0ae7c17cdc46b9f662c56da2054887b8d9e737c1476f335c83d33db/numpy-2.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:087ffc25890d89a43536f75c5fe8770922008758e8eeeef61733957041ed2f9b", size = 13111856, upload-time = "2025-07-24T20:56:17.318Z" }, + { url = "https://files.pythonhosted.org/packages/c1/9e/1652778bce745a67b5fe05adde60ed362d38eb17d919a540e813d30f6874/numpy-2.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:092aeb3449833ea9c0bf0089d70c29ae480685dd2377ec9cdbbb620257f84631", size = 10544226, upload-time = "2025-07-24T20:56:34.509Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -160,6 +432,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pandas" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/8e/0e90233ac205ad182bd6b422532695d2b9414944a280488105d598c70023/pandas-2.3.2.tar.gz", hash = "sha256:ab7b58f8f82706890924ccdfb5f48002b83d2b5a3845976a9fb705d36c34dcdb", size = 4488684, upload-time = "2025-08-21T10:28:29.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/db/614c20fb7a85a14828edd23f1c02db58a30abf3ce76f38806155d160313c/pandas-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fbb977f802156e7a3f829e9d1d5398f6192375a3e2d1a9ee0803e35fe70a2b9", size = 11587652, upload-time = "2025-08-21T10:27:15.888Z" }, + { url = "https://files.pythonhosted.org/packages/99/b0/756e52f6582cade5e746f19bad0517ff27ba9c73404607c0306585c201b3/pandas-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b9b52693123dd234b7c985c68b709b0b009f4521000d0525f2b95c22f15944b", size = 10717686, upload-time = "2025-08-21T10:27:18.486Z" }, + { url = "https://files.pythonhosted.org/packages/37/4c/dd5ccc1e357abfeee8353123282de17997f90ff67855f86154e5a13b81e5/pandas-2.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bd281310d4f412733f319a5bc552f86d62cddc5f51d2e392c8787335c994175", size = 11278722, upload-time = "2025-08-21T10:27:21.149Z" }, + { url = "https://files.pythonhosted.org/packages/d3/a4/f7edcfa47e0a88cda0be8b068a5bae710bf264f867edfdf7b71584ace362/pandas-2.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d31a6b4354e3b9b8a2c848af75d31da390657e3ac6f30c05c82068b9ed79b9", size = 11987803, upload-time = "2025-08-21T10:27:23.767Z" }, + { url = "https://files.pythonhosted.org/packages/f6/61/1bce4129f93ab66f1c68b7ed1c12bac6a70b1b56c5dab359c6bbcd480b52/pandas-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:df4df0b9d02bb873a106971bb85d448378ef14b86ba96f035f50bbd3688456b4", size = 12766345, upload-time = "2025-08-21T10:27:26.6Z" }, + { url = "https://files.pythonhosted.org/packages/8e/46/80d53de70fee835531da3a1dae827a1e76e77a43ad22a8cd0f8142b61587/pandas-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:213a5adf93d020b74327cb2c1b842884dbdd37f895f42dcc2f09d451d949f811", size = 13439314, upload-time = "2025-08-21T10:27:29.213Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/8114832daff7489f179971dbc1d854109b7f4365a546e3ea75b6516cea95/pandas-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c13b81a9347eb8c7548f53fd9a4f08d4dfe996836543f805c987bafa03317ae", size = 10983326, upload-time = "2025-08-21T10:27:31.901Z" }, + { url = "https://files.pythonhosted.org/packages/27/64/a2f7bf678af502e16b472527735d168b22b7824e45a4d7e96a4fbb634b59/pandas-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0c6ecbac99a354a051ef21c5307601093cb9e0f4b1855984a084bfec9302699e", size = 11531061, upload-time = "2025-08-21T10:27:34.647Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/c3d21b2b7769ef2f4c2b9299fcadd601efa6729f1357a8dbce8dd949ed70/pandas-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c6f048aa0fd080d6a06cc7e7537c09b53be6642d330ac6f54a600c3ace857ee9", size = 10668666, upload-time = "2025-08-21T10:27:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/f775ba76ecfb3424d7f5862620841cf0edb592e9abd2d2a5387d305fe7a8/pandas-2.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0064187b80a5be6f2f9c9d6bdde29372468751dfa89f4211a3c5871854cfbf7a", size = 11332835, upload-time = "2025-08-21T10:27:40.188Z" }, + { url = "https://files.pythonhosted.org/packages/8f/52/0634adaace9be2d8cac9ef78f05c47f3a675882e068438b9d7ec7ef0c13f/pandas-2.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ac8c320bded4718b298281339c1a50fb00a6ba78cb2a63521c39bec95b0209b", size = 12057211, upload-time = "2025-08-21T10:27:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/0b/9d/2df913f14b2deb9c748975fdb2491da1a78773debb25abbc7cbc67c6b549/pandas-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:114c2fe4f4328cf98ce5716d1532f3ab79c5919f95a9cfee81d9140064a2e4d6", size = 12749277, upload-time = "2025-08-21T10:27:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/da1a2417026bd14d98c236dba88e39837182459d29dcfcea510b2ac9e8a1/pandas-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:48fa91c4dfb3b2b9bfdb5c24cd3567575f4e13f9636810462ffed8925352be5a", size = 13415256, upload-time = "2025-08-21T10:27:49.885Z" }, + { url = "https://files.pythonhosted.org/packages/22/3c/f2af1ce8840ef648584a6156489636b5692c162771918aa95707c165ad2b/pandas-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:12d039facec710f7ba305786837d0225a3444af7bbd9c15c32ca2d40d157ed8b", size = 10982579, upload-time = "2025-08-21T10:28:08.435Z" }, + { url = "https://files.pythonhosted.org/packages/f3/98/8df69c4097a6719e357dc249bf437b8efbde808038268e584421696cbddf/pandas-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c624b615ce97864eb588779ed4046186f967374185c047070545253a52ab2d57", size = 12028163, upload-time = "2025-08-21T10:27:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/0e/23/f95cbcbea319f349e10ff90db488b905c6883f03cbabd34f6b03cbc3c044/pandas-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0cee69d583b9b128823d9514171cabb6861e09409af805b54459bd0c821a35c2", size = 11391860, upload-time = "2025-08-21T10:27:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1b/6a984e98c4abee22058aa75bfb8eb90dce58cf8d7296f8bc56c14bc330b0/pandas-2.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2319656ed81124982900b4c37f0e0c58c015af9a7bbc62342ba5ad07ace82ba9", size = 11309830, upload-time = "2025-08-21T10:27:56.957Z" }, + { url = "https://files.pythonhosted.org/packages/15/d5/f0486090eb18dd8710bf60afeaf638ba6817047c0c8ae5c6a25598665609/pandas-2.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b37205ad6f00d52f16b6d09f406434ba928c1a1966e2771006a9033c736d30d2", size = 11883216, upload-time = "2025-08-21T10:27:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/10/86/692050c119696da19e20245bbd650d8dfca6ceb577da027c3a73c62a047e/pandas-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:837248b4fc3a9b83b9c6214699a13f069dc13510a6a6d7f9ba33145d2841a012", size = 12699743, upload-time = "2025-08-21T10:28:02.447Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/612123674d7b17cf345aad0a10289b2a384bff404e0463a83c4a3a59d205/pandas-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d2c3554bd31b731cd6490d94a28f3abb8dd770634a9e06eb6d2911b9827db370", size = 13186141, upload-time = "2025-08-21T10:28:05.377Z" }, +] + +[[package]] +name = "peewee" +version = "3.18.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/89/76f6f1b744c8608e0d416b588b9d63c2a500ff800065ae610f7c80f532d6/peewee-3.18.2.tar.gz", hash = "sha256:77a54263eb61aff2ea72f63d2eeb91b140c25c1884148e28e4c0f7c4f64996a0", size = 949220, upload-time = "2025-07-08T12:52:03.941Z" } + +[[package]] +name = "platformdirs" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -169,6 +490,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "protobuf" +version = "6.32.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/df/fb4a8eeea482eca989b51cffd274aac2ee24e825f0bf3cbce5281fa1567b/protobuf-6.32.0.tar.gz", hash = "sha256:a81439049127067fc49ec1d36e25c6ee1d1a2b7be930675f919258d03c04e7d2", size = 440614, upload-time = "2025-08-14T21:21:25.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/18/df8c87da2e47f4f1dcc5153a81cd6bca4e429803f4069a299e236e4dd510/protobuf-6.32.0-cp310-abi3-win32.whl", hash = "sha256:84f9e3c1ff6fb0308dbacb0950d8aa90694b0d0ee68e75719cb044b7078fe741", size = 424409, upload-time = "2025-08-14T21:21:12.366Z" }, + { url = "https://files.pythonhosted.org/packages/e1/59/0a820b7310f8139bd8d5a9388e6a38e1786d179d6f33998448609296c229/protobuf-6.32.0-cp310-abi3-win_amd64.whl", hash = "sha256:a8bdbb2f009cfc22a36d031f22a625a38b615b5e19e558a7b756b3279723e68e", size = 435735, upload-time = "2025-08-14T21:21:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/cc/5b/0d421533c59c789e9c9894683efac582c06246bf24bb26b753b149bd88e4/protobuf-6.32.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d52691e5bee6c860fff9a1c86ad26a13afbeb4b168cd4445c922b7e2cf85aaf0", size = 426449, upload-time = "2025-08-14T21:21:16.687Z" }, + { url = "https://files.pythonhosted.org/packages/ec/7b/607764ebe6c7a23dcee06e054fd1de3d5841b7648a90fd6def9a3bb58c5e/protobuf-6.32.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:501fe6372fd1c8ea2a30b4d9be8f87955a64d6be9c88a973996cef5ef6f0abf1", size = 322869, upload-time = "2025-08-14T21:21:18.282Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/2e730bd1c25392fc32e3268e02446f0d77cb51a2c3a8486b1798e34d5805/protobuf-6.32.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:75a2aab2bd1aeb1f5dc7c5f33bcb11d82ea8c055c9becbb41c26a8c43fd7092c", size = 322009, upload-time = "2025-08-14T21:21:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f2/80ffc4677aac1bc3519b26bc7f7f5de7fce0ee2f7e36e59e27d8beb32dd1/protobuf-6.32.0-py3-none-any.whl", hash = "sha256:ba377e5b67b908c8f3072a57b63e2c6a4cbd18aea4ed98d2584350dbf46f2783", size = 169287, upload-time = "2025-08-14T21:21:23.515Z" }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, +] + [[package]] name = "pydantic" version = "2.11.7" @@ -298,6 +642,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + [[package]] name = "ruff" version = "0.12.11" @@ -324,6 +683,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/a8/001d4a7c2b37623a3fd7463208267fb906df40ff31db496157549cfd6e72/ruff-0.12.11-py3-none-win_arm64.whl", hash = "sha256:bae4d6e6a2676f8fb0f98b74594a048bae1b944aab17e9f5d504062303c6dbea", size = 12135290, upload-time = "2025-08-28T13:59:06.933Z" }, ] +[[package]] +name = "simplejson" +version = "3.20.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/92/51b417685abd96b31308b61b9acce7ec50d8e1de8fbc39a7fd4962c60689/simplejson-3.20.1.tar.gz", hash = "sha256:e64139b4ec4f1f24c142ff7dcafe55a22b811a74d86d66560c8815687143037d", size = 85591, upload-time = "2025-02-15T05:18:53.15Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/eb/34c16a1ac9ba265d024dc977ad84e1659d931c0a700967c3e59a98ed7514/simplejson-3.20.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f31c4a3a7ab18467ee73a27f3e59158255d1520f3aad74315edde7a940f1be23", size = 93100, upload-time = "2025-02-15T05:16:38.801Z" }, + { url = "https://files.pythonhosted.org/packages/41/fc/2c2c007d135894971e6814e7c0806936e5bade28f8db4dd7e2a58b50debd/simplejson-3.20.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:884e6183d16b725e113b83a6fc0230152ab6627d4d36cb05c89c2c5bccfa7bc6", size = 75464, upload-time = "2025-02-15T05:16:40.905Z" }, + { url = "https://files.pythonhosted.org/packages/0f/05/2b5ecb33b776c34bb5cace5de5d7669f9b60e3ca13c113037b2ca86edfbd/simplejson-3.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03d7a426e416fe0d3337115f04164cd9427eb4256e843a6b8751cacf70abc832", size = 75112, upload-time = "2025-02-15T05:16:42.246Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/1f3609a2792f06cd4b71030485f78e91eb09cfd57bebf3116bf2980a8bac/simplejson-3.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:000602141d0bddfcff60ea6a6e97d5e10c9db6b17fd2d6c66199fa481b6214bb", size = 150182, upload-time = "2025-02-15T05:16:43.557Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b0/053fbda38b8b602a77a4f7829def1b4f316cd8deb5440a6d3ee90790d2a4/simplejson-3.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af8377a8af78226e82e3a4349efdde59ffa421ae88be67e18cef915e4023a595", size = 158363, upload-time = "2025-02-15T05:16:45.748Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4b/2eb84ae867539a80822e92f9be4a7200dffba609275faf99b24141839110/simplejson-3.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15c7de4c88ab2fbcb8781a3b982ef883696736134e20b1210bca43fb42ff1acf", size = 148415, upload-time = "2025-02-15T05:16:47.861Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bd/400b0bd372a5666addf2540c7358bfc3841b9ce5cdbc5cc4ad2f61627ad8/simplejson-3.20.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:455a882ff3f97d810709f7b620007d4e0aca8da71d06fc5c18ba11daf1c4df49", size = 152213, upload-time = "2025-02-15T05:16:49.25Z" }, + { url = "https://files.pythonhosted.org/packages/50/12/143f447bf6a827ee9472693768dc1a5eb96154f8feb140a88ce6973a3cfa/simplejson-3.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fc0f523ce923e7f38eb67804bc80e0a028c76d7868500aa3f59225574b5d0453", size = 150048, upload-time = "2025-02-15T05:16:51.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ea/dd9b3e8e8ed710a66f24a22c16a907c9b539b6f5f45fd8586bd5c231444e/simplejson-3.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76461ec929282dde4a08061071a47281ad939d0202dc4e63cdd135844e162fbc", size = 151668, upload-time = "2025-02-15T05:16:53Z" }, + { url = "https://files.pythonhosted.org/packages/99/af/ee52a8045426a0c5b89d755a5a70cc821815ef3c333b56fbcad33c4435c0/simplejson-3.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ab19c2da8c043607bde4d4ef3a6b633e668a7d2e3d56f40a476a74c5ea71949f", size = 158840, upload-time = "2025-02-15T05:16:54.851Z" }, + { url = "https://files.pythonhosted.org/packages/68/db/ab32869acea6b5de7d75fa0dac07a112ded795d41eaa7e66c7813b17be95/simplejson-3.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2578bedaedf6294415197b267d4ef678fea336dd78ee2a6d2f4b028e9d07be3", size = 154212, upload-time = "2025-02-15T05:16:56.318Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/e3132d454977d75a3bf9a6d541d730f76462ebf42a96fea2621498166f41/simplejson-3.20.1-cp312-cp312-win32.whl", hash = "sha256:339f407373325a36b7fd744b688ba5bae0666b5d340ec6d98aebc3014bf3d8ea", size = 74101, upload-time = "2025-02-15T05:16:57.746Z" }, + { url = "https://files.pythonhosted.org/packages/bc/5d/4e243e937fa3560107c69f6f7c2eed8589163f5ed14324e864871daa2dd9/simplejson-3.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:627d4486a1ea7edf1f66bb044ace1ce6b4c1698acd1b05353c97ba4864ea2e17", size = 75736, upload-time = "2025-02-15T05:16:59.017Z" }, + { url = "https://files.pythonhosted.org/packages/c4/03/0f453a27877cb5a5fff16a975925f4119102cc8552f52536b9a98ef0431e/simplejson-3.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:71e849e7ceb2178344998cbe5ade101f1b329460243c79c27fbfc51c0447a7c3", size = 93109, upload-time = "2025-02-15T05:17:00.377Z" }, + { url = "https://files.pythonhosted.org/packages/74/1f/a729f4026850cabeaff23e134646c3f455e86925d2533463420635ae54de/simplejson-3.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b63fdbab29dc3868d6f009a59797cefaba315fd43cd32ddd998ee1da28e50e29", size = 75475, upload-time = "2025-02-15T05:17:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/e2/14/50a2713fee8ff1f8d655b1a14f4a0f1c0c7246768a1b3b3d12964a4ed5aa/simplejson-3.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1190f9a3ce644fd50ec277ac4a98c0517f532cfebdcc4bd975c0979a9f05e1fb", size = 75112, upload-time = "2025-02-15T05:17:03.875Z" }, + { url = "https://files.pythonhosted.org/packages/45/86/ea9835abb646755140e2d482edc9bc1e91997ed19a59fd77ae4c6a0facea/simplejson-3.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1336ba7bcb722ad487cd265701ff0583c0bb6de638364ca947bb84ecc0015d1", size = 150245, upload-time = "2025-02-15T05:17:06.899Z" }, + { url = "https://files.pythonhosted.org/packages/12/b4/53084809faede45da829fe571c65fbda8479d2a5b9c633f46b74124d56f5/simplejson-3.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e975aac6a5acd8b510eba58d5591e10a03e3d16c1cf8a8624ca177491f7230f0", size = 158465, upload-time = "2025-02-15T05:17:08.707Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7d/d56579468d1660b3841e1f21c14490d103e33cf911886b22652d6e9683ec/simplejson-3.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a6dd11ee282937ad749da6f3b8d87952ad585b26e5edfa10da3ae2536c73078", size = 148514, upload-time = "2025-02-15T05:17:11.323Z" }, + { url = "https://files.pythonhosted.org/packages/19/e3/874b1cca3d3897b486d3afdccc475eb3a09815bf1015b01cf7fcb52a55f0/simplejson-3.20.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab980fcc446ab87ea0879edad41a5c28f2d86020014eb035cf5161e8de4474c6", size = 152262, upload-time = "2025-02-15T05:17:13.543Z" }, + { url = "https://files.pythonhosted.org/packages/32/84/f0fdb3625292d945c2bd13a814584603aebdb38cfbe5fe9be6b46fe598c4/simplejson-3.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f5aee2a4cb6b146bd17333ac623610f069f34e8f31d2f4f0c1a2186e50c594f0", size = 150164, upload-time = "2025-02-15T05:17:15.021Z" }, + { url = "https://files.pythonhosted.org/packages/95/51/6d625247224f01eaaeabace9aec75ac5603a42f8ebcce02c486fbda8b428/simplejson-3.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:652d8eecbb9a3b6461b21ec7cf11fd0acbab144e45e600c817ecf18e4580b99e", size = 151795, upload-time = "2025-02-15T05:17:16.542Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d9/bb921df6b35be8412f519e58e86d1060fddf3ad401b783e4862e0a74c4c1/simplejson-3.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8c09948f1a486a89251ee3a67c9f8c969b379f6ffff1a6064b41fea3bce0a112", size = 159027, upload-time = "2025-02-15T05:17:18.083Z" }, + { url = "https://files.pythonhosted.org/packages/03/c5/5950605e4ad023a6621cf4c931b29fd3d2a9c1f36be937230bfc83d7271d/simplejson-3.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cbbd7b215ad4fc6f058b5dd4c26ee5c59f72e031dfda3ac183d7968a99e4ca3a", size = 154380, upload-time = "2025-02-15T05:17:20.334Z" }, + { url = "https://files.pythonhosted.org/packages/66/ad/b74149557c5ec1e4e4d55758bda426f5d2ec0123cd01a53ae63b8de51fa3/simplejson-3.20.1-cp313-cp313-win32.whl", hash = "sha256:ae81e482476eaa088ef9d0120ae5345de924f23962c0c1e20abbdff597631f87", size = 74102, upload-time = "2025-02-15T05:17:22.475Z" }, + { url = "https://files.pythonhosted.org/packages/db/a9/25282fdd24493e1022f30b7f5cdf804255c007218b2bfaa655bd7ad34b2d/simplejson-3.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:1b9fd15853b90aec3b1739f4471efbf1ac05066a2c7041bf8db821bb73cd2ddc", size = 75736, upload-time = "2025-02-15T05:17:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/00f02a0a921556dd5a6db1ef2926a1bc7a8bbbfb1c49cfed68a275b8ab2b/simplejson-3.20.1-py3-none-any.whl", hash = "sha256:8a6c1bbac39fa4a79f83cbf1df6ccd8ff7069582a9fd8db1e52cea073bc2c697", size = 57121, upload-time = "2025-02-15T05:18:51.243Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -342,6 +736,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, +] + [[package]] name = "starlette" version = "0.47.3" @@ -355,6 +758,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" }, ] +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "tushare" +version = "1.4.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bs4" }, + { name = "lxml" }, + { name = "pandas" }, + { name = "requests" }, + { name = "simplejson" }, + { name = "tqdm" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/09/2141aaccb90a8249edb42d6b31330606d8cf9345237773775a3aa4c71986/tushare-1.4.24.tar.gz", hash = "sha256:786acbf6ee7dfb0b152bdd570b673f74e58b86a0d9908a221c6bdc4254a4e0ea", size = 128539, upload-time = "2025-08-25T02:02:05.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/75/63810958023595b460f2a5ef6baf5a60ffd8166e5fc06a3c2f22e9ca7b34/tushare-1.4.24-py3-none-any.whl", hash = "sha256:778e3128262747cb0cdadac2e5a5e6cd1a520c239b4ffbde2776652424451b08", size = 143587, upload-time = "2025-08-25T02:02:03.554Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -376,6 +809,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, ] +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + [[package]] name = "uvicorn" version = "0.35.0" @@ -398,7 +849,10 @@ dependencies = [ { name = "pydantic" }, { name = "python-dateutil" }, { name = "pytz" }, + { name = "requests" }, + { name = "tushare" }, { name = "uvicorn" }, + { name = "yfinance" }, ] [package.optional-dependencies] @@ -418,7 +872,73 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, { name = "python-dateutil", specifier = ">=2.8.2" }, { name = "pytz", specifier = ">=2023.3" }, + { name = "requests", specifier = ">=2.32.5" }, { name = "ruff", marker = "extra == 'dev'" }, + { name = "tushare", specifier = ">=1.4.24" }, { name = "uvicorn", specifier = ">=0.24.0" }, + { name = "yfinance", specifier = ">=0.2.65" }, ] provides-extras = ["dev"] + +[[package]] +name = "websocket-client" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648, upload-time = "2024-04-23T22:16:16.976Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826, upload-time = "2024-04-23T22:16:14.422Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "yfinance" +version = "0.2.65" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "curl-cffi" }, + { name = "frozendict" }, + { name = "multitasking" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "peewee" }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "pytz" }, + { name = "requests" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/c1/2ef5acda45a71297f4be22e205359e0f93b0171f2b6ebdd681362e725686/yfinance-0.2.65.tar.gz", hash = "sha256:3d465e58c49be9d61f9862829de3e00bef6b623809f32f4efb5197b62fc60485", size = 128666, upload-time = "2025-07-06T16:20:12.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/1e/631c80e0f97aef46eb73549b9b0f60d94057294e040740f4cad0cb1f48e4/yfinance-0.2.65-py2.py3-none-any.whl", hash = "sha256:7be13abb0d80a17230bf798e9c6a324fa2bef0846684a6d4f7fa2abd21938963", size = 119438, upload-time = "2025-07-06T16:20:11.251Z" }, +] diff --git a/python/valuecell/adapters/assets/README.md b/python/valuecell/adapters/assets/README.md new file mode 100644 index 000000000..b72e89a02 --- /dev/null +++ b/python/valuecell/adapters/assets/README.md @@ -0,0 +1,425 @@ +# ValueCell Asset Data Adapter System + +A comprehensive financial asset data management system that supports multiple data sources, internationalization, and user watchlist management. + +## Features + +### 🌐 Multi-Source Data Adapters +- **Yahoo Finance**: Free stock market data for global markets +- **TuShare**: Professional Chinese stock market data (requires API key) +- **AKShare**: Free Chinese financial data library (no API key required) +- **Finnhub**: Professional global stock market data (requires API key) +- **CoinMarketCap**: Cryptocurrency market data (requires API key) +- **Extensible**: Easy to add new data sources + +### 📊 Asset Types Support +- Stocks (US, Chinese, Hong Kong, etc.) +- Cryptocurrencies +- ETFs, Mutual Funds +- Bonds, Commodities +- Forex, Indices +- Options, Futures + +### 🔄 Standardized Ticker Format +All assets use the format `[EXCHANGE]:[SYMBOL]`: +- `NASDAQ:AAPL` - Apple Inc. +- `SSE:600519` - Kweichow Moutai +- `CRYPTO:BTC` - Bitcoin +- `HKEX:00700` - Tencent Holdings + +### 🌍 Internationalization (i18n) +- Multi-language asset names +- Localized UI text and messages +- Currency and number formatting +- Support for Chinese, English, and more + +### 📝 User Watchlist Management +- Create multiple watchlists per user +- Add/remove assets with personal notes +- Real-time price updates +- Persistent storage ready + +## Quick Start + +### 1. Installation + +```bash +# Install required dependencies + +pip install yfinance tushare requests pydantic +``` + +### 2. Basic Usage + +```python +from valuecell.adapters.assets import ( + get_adapter_manager, search_assets, add_to_watchlist, get_watchlist +) + +# Configure data adapters +manager = get_adapter_manager() +manager.configure_yfinance() # Free, no API key needed + +# Search for assets +results = search_assets("AAPL", language="zh-Hans") +print(f"Found {results['count']} assets") + +# Add to watchlist +add_to_watchlist( + user_id="user123", + ticker="NASDAQ:AAPL", + notes="苹果公司股票" +) + +# Get watchlist with prices +watchlist = get_watchlist(user_id="user123", include_prices=True) +``` + +### 3. Configure Data Sources + +```python +# Yahoo Finance (Free) +manager.configure_yfinance() + +# AKShare (Free Chinese markets) +manager.configure_akshare() + +# TuShare (Chinese markets, requires API key) +manager.configure_tushare(api_key="your_tushare_token") + +# Finnhub (Global markets, requires API key) +manager.configure_finnhub(api_key="your_finnhub_token") + +# CoinMarketCap (Crypto, requires API key) +manager.configure_coinmarketcap(api_key="your_cmc_api_key") +``` + +## API Reference + +### Asset Search + +```python +from valuecell.adapters.assets import search_assets + +# Basic search +results = search_assets("Apple") + +# Advanced search with filters +results = search_assets( + query="tech", + asset_types=["stock", "etf"], + exchanges=["NASDAQ", "NYSE"], + countries=["US"], + limit=20, + language="zh-Hans" +) +``` + +### Asset Information + +```python +from valuecell.adapters.assets import get_asset_info, get_asset_price + +# Get detailed asset information +info = get_asset_info("NASDAQ:AAPL", language="zh-Hans") +print(info["display_name"]) # "苹果公司" + +# Get current price +price = get_asset_price("NASDAQ:AAPL", language="zh-Hans") +print(price["price_formatted"]) # "¥150.25" +print(price["change_percent_formatted"]) # "+2.5%" +``` + +### Watchlist Management + +```python +from valuecell.adapters.assets import get_asset_api + +api = get_asset_api() + +# Create watchlist +api.create_watchlist( + user_id="user123", + name="My Tech Stocks", + description="Technology companies" +) + +# Add assets +api.add_to_watchlist("user123", "NASDAQ:AAPL", notes="iPhone maker") +api.add_to_watchlist("user123", "NASDAQ:GOOGL", notes="Search engine") + +# Get watchlist with prices +watchlist = api.get_watchlist("user123", include_prices=True) +``` + +## Data Source Configuration + +### Yahoo Finance +- **Cost**: Free +- **Coverage**: Global stocks, ETFs, indices, crypto +- **Rate Limits**: Reasonable for personal use +- **Setup**: No API key required + +```python +manager.configure_yfinance() +``` + +### TuShare +- **Cost**: Free tier available, paid plans for more data +- **Coverage**: Chinese stocks (A-shares), indices, financials +- **Rate Limits**: Based on subscription plan +- **Setup**: Register at [tushare.pro](https://tushare.pro) + +```python +manager.configure_tushare(api_key="your_token_here") +``` + +### AKShare +- **Cost**: Free +- **Coverage**: Chinese stocks, funds, bonds, economic data +- **Rate Limits**: Reasonable for personal use +- **Setup**: No registration required + +```python +manager.configure_akshare() +``` + +### Finnhub +- **Cost**: Free tier (60 calls/minute), paid plans available +- **Coverage**: Global stocks, forex, crypto, company data +- **Rate Limits**: Based on plan (free: 60 calls/minute) +- **Setup**: Register at [finnhub.io](https://finnhub.io) + +```python +manager.configure_finnhub(api_key="your_api_key_here") +``` + +### CoinMarketCap +- **Cost**: Free tier (10,000 calls/month), paid plans available +- **Coverage**: 9,000+ cryptocurrencies +- **Rate Limits**: Based on plan (free: 333 calls/day) +- **Setup**: Register at [coinmarketcap.com](https://coinmarketcap.com/api/) + +```python +manager.configure_coinmarketcap(api_key="your_api_key_here") +``` + +## Internationalization + +### Supported Languages +- English US (`en-US`) +- English UK (`en-GB`) +- Simplified Chinese (`zh-Hans`) +- Traditional Chinese (`zh-Hant`) +- Easy to add more languages + +### Asset Name Translation +The system includes built-in translations for popular assets: + +```python +# Apple Inc. in different languages +"NASDAQ:AAPL": { + "en-US": "Apple Inc.", + "zh-Hans": "苹果公司", + "zh-Hant": "蘋果公司" +} +``` + +### Custom Translations +Add your own asset translations: + +```python +from valuecell.adapters.assets import get_asset_i18n_service + +i18n_service = get_asset_i18n_service() +i18n_service.add_asset_translation( + ticker="NASDAQ:TSLA", + language="zh-Hans", + name="特斯拉" +) +``` + +## Architecture + +### Core Components + +1. **Types** (`types.py`): Data structures and models +2. **Base Adapter** (`base.py`): Abstract interface for data sources +3. **Specific Adapters**: Implementation for each data source +4. **Manager** (`manager.py`): Coordinates multiple adapters +5. **I18n Integration** (`i18n_integration.py`): Localization support +6. **API** (`api.py`): High-level interface + +### Data Flow + +``` +User Request → API Layer → Manager → Adapter → Data Source + ↓ + I18n Service → Localized Response +``` + +### Ticker Conversion + +Internal format: `EXCHANGE:SYMBOL` +- `NASDAQ:AAPL` → `AAPL` (Yahoo Finance) +- `SSE:600519` → `600519.SH` (TuShare) +- `CRYPTO:BTC` → `BTC` (CoinMarketCap) + +## Error Handling + +The system provides comprehensive error handling: + +```python +# All API functions return structured responses +result = search_assets("invalid_query") + +if result["success"]: + # Process results + assets = result["results"] +else: + # Handle error + error_message = result["error"] + print(f"Search failed: {error_message}") +``` + +### Common Error Types +- `AdapterError`: General adapter issues +- `RateLimitError`: API rate limit exceeded +- `AuthenticationError`: Invalid API credentials +- `DataNotAvailableError`: Requested data not found +- `InvalidTickerError`: Malformed ticker format + +## Performance Considerations + +### Batch Operations +Use batch operations for better performance: + +```python +# Get multiple prices at once (more efficient) +prices = api.get_multiple_prices(["NASDAQ:AAPL", "NASDAQ:GOOGL", "NASDAQ:MSFT"]) + +# Instead of individual calls +# price1 = get_asset_price("NASDAQ:AAPL") # Slower +# price2 = get_asset_price("NASDAQ:GOOGL") # Slower +``` + +### Caching +- Asset information is cached automatically +- Price data is real-time (not cached) +- Translation cache improves i18n performance + +### Rate Limiting +- Built-in rate limiting for each data source +- Automatic retry with exponential backoff +- Respects API provider limits + +## Testing + +Run the example to test your setup: + +```python +python -m valuecell.examples.asset_adapter_example +``` + +### Health Check +Monitor adapter status: + +```python +from valuecell.adapters.assets import get_asset_api + +api = get_asset_api() +health = api.get_system_health() +print(f"System status: {health['overall_status']}") +``` + +## Extending the System + +### Adding New Data Sources + +1. Create a new adapter class inheriting from `BaseDataAdapter` +2. Implement required methods (`search_assets`, `get_asset_info`, etc.) +3. Add ticker conversion logic +4. Register with the manager + +```python +class MyDataAdapter(BaseDataAdapter): + def search_assets(self, query): + # Implementation + pass + + def get_asset_info(self, ticker): + # Implementation + pass + + # ... other methods + +# Register the adapter +manager.register_adapter(MyDataAdapter()) +``` + +### Adding New Asset Types + +1. Add to `AssetType` enum in `types.py` +2. Update adapter priority mapping +3. Add i18n translations + +## Best Practices + +### API Keys Security +- Store API keys in environment variables +- Never commit API keys to version control +- Use different keys for development/production + +### Error Handling +- Always check the `success` field in responses +- Implement proper retry logic for transient failures +- Log errors for debugging + +### Performance +- Use batch operations when possible +- Implement client-side caching for static data +- Monitor API usage to avoid rate limits + +### Internationalization +- Always specify language parameter for consistent results +- Provide fallback translations +- Test with different locales + +## Troubleshooting + +### Common Issues + +**"No suitable adapter found for ticker"** +- Check ticker format: `EXCHANGE:SYMBOL` +- Verify the exchange is supported by configured adapters +- Ensure at least one adapter is configured + +**"Rate limit exceeded"** +- Wait for the specified retry period +- Consider upgrading to paid API plan +- Implement request batching + +**"Authentication failed"** +- Verify API key is correct and active +- Check API key permissions/subscription status +- Ensure API key is properly configured + +### Debug Mode +Enable detailed logging: + +```python +import logging +logging.basicConfig(level=logging.DEBUG) +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Add tests for new functionality +4. Update documentation +5. Submit a pull request + +## License + +This project is part of the ValueCell platform and follows the project's licensing terms. diff --git a/python/valuecell/adapters/assets/__init__.py b/python/valuecell/adapters/assets/__init__.py new file mode 100644 index 000000000..a7e61e326 --- /dev/null +++ b/python/valuecell/adapters/assets/__init__.py @@ -0,0 +1,143 @@ +"""ValueCell Asset Data Adapter Module. + +This module provides a comprehensive system for managing financial asset data +across multiple data sources with support for internationalization and user +watchlists. + +Key Features: +- Multi-source data adapters (Yahoo Finance, TuShare, CoinMarketCap, etc.) +- Standardized asset representation with ticker format [EXCHANGE]:[SYMBOL] +- User watchlist management with persistent storage +- Internationalization support for asset names and UI text +- Real-time and historical price data +- Asset search across different markets and types + +Usage Example: + ```python + from valuecell.adapters.assets import ( + get_adapter_manager, get_asset_api, search_assets, add_to_watchlist + ) + + # Configure data adapters + manager = get_adapter_manager() + manager.configure_yfinance() + manager.configure_tushare(api_key="your_tushare_key") + + # Search for assets + results = search_assets("AAPL", language="zh-Hans") + + # Add to watchlist + add_to_watchlist(user_id="user123", ticker="NASDAQ:AAPL") + ``` +""" + +# Core types and data structures +from .types import ( + Asset, + AssetPrice, + AssetSearchResult, + AssetSearchQuery, + AssetType, + MarketStatus, + DataSource, + MarketInfo, + LocalizedName, + Watchlist, + WatchlistItem, +) + +# Base adapter classes +from .base import ( + BaseDataAdapter, + TickerConverter, + AdapterError, + RateLimitError, + DataNotAvailableError, + AuthenticationError, + InvalidTickerError, +) + +# Specific adapter implementations +from .yfinance_adapter import YFinanceAdapter +from .tushare_adapter import TuShareAdapter +from .coinmarketcap_adapter import CoinMarketCapAdapter +from .akshare_adapter import AKShareAdapter +from .finnhub_adapter import FinnhubAdapter + +# Management and coordination +from .manager import ( + AdapterManager, + WatchlistManager, + get_adapter_manager, + get_watchlist_manager, + reset_managers, +) + +# Internationalization support +from .i18n_integration import ( + AssetI18nService, + get_asset_i18n_service, + reset_asset_i18n_service, +) + +# High-level API +from .api import ( + AssetAPI, + get_asset_api, + reset_asset_api, + search_assets, + get_asset_info, + get_asset_price, + add_to_watchlist, + get_watchlist, +) + +__version__ = "1.0.0" + +__all__ = [ + # Types + "Asset", + "AssetPrice", + "AssetSearchResult", + "AssetSearchQuery", + "AssetType", + "MarketStatus", + "DataSource", + "MarketInfo", + "LocalizedName", + "Watchlist", + "WatchlistItem", + # Base classes + "BaseDataAdapter", + "TickerConverter", + "AdapterError", + "RateLimitError", + "DataNotAvailableError", + "AuthenticationError", + "InvalidTickerError", + # Adapters + "YFinanceAdapter", + "TuShareAdapter", + "CoinMarketCapAdapter", + "AKShareAdapter", + "FinnhubAdapter", + # Managers + "AdapterManager", + "WatchlistManager", + "get_adapter_manager", + "get_watchlist_manager", + "reset_managers", + # I18n + "AssetI18nService", + "get_asset_i18n_service", + "reset_asset_i18n_service", + # API + "AssetAPI", + "get_asset_api", + "reset_asset_api", + "search_assets", + "get_asset_info", + "get_asset_price", + "add_to_watchlist", + "get_watchlist", +] diff --git a/python/valuecell/adapters/assets/akshare_adapter.py b/python/valuecell/adapters/assets/akshare_adapter.py new file mode 100644 index 000000000..769c49222 --- /dev/null +++ b/python/valuecell/adapters/assets/akshare_adapter.py @@ -0,0 +1,647 @@ +"""AKShare adapter for Chinese financial market data. + +This adapter provides integration with AKShare library to fetch comprehensive +Chinese financial market data including stocks, funds, bonds, and economic indicators. +""" + +import logging +from typing import List, Optional, Any +from datetime import datetime, timedelta +from decimal import Decimal +import pandas as pd + +try: + import akshare as ak +except ImportError: + ak = None + +from .base import BaseDataAdapter +from .types import ( + Asset, + AssetPrice, + AssetSearchResult, + AssetSearchQuery, + DataSource, + AssetType, + MarketInfo, + LocalizedName, + MarketStatus, +) + +logger = logging.getLogger(__name__) + + +class AKShareAdapter(BaseDataAdapter): + """AKShare data adapter for Chinese financial markets.""" + + def __init__(self, **kwargs): + """Initialize AKShare adapter. + + Args: + **kwargs: Additional configuration parameters + """ + super().__init__(DataSource.AKSHARE, **kwargs) + + if ak is None: + raise ImportError( + "akshare library is required. Install with: pip install akshare" + ) + + def _initialize(self) -> None: + """Initialize AKShare adapter configuration.""" + self.timeout = self.config.get("timeout", 30) + + # Asset type mapping for AKShare + self.asset_type_mapping = { + "stock": AssetType.STOCK, + "fund": AssetType.ETF, + "bond": AssetType.BOND, + "index": AssetType.INDEX, + } + + # Exchange mapping for AKShare + self.exchange_mapping = { + "SH": "SSE", # Shanghai Stock Exchange + "SZ": "SZSE", # Shenzhen Stock Exchange + "BJ": "BSE", # Beijing Stock Exchange + } + + logger.info("AKShare adapter initialized") + + def search_assets(self, query: AssetSearchQuery) -> List[AssetSearchResult]: + """Search for assets using AKShare stock info.""" + try: + results = [] + search_term = query.query.strip() + + # Get stock basic info from AKShare + try: + # Get A-share stock list + df_stocks = ak.stock_zh_a_spot_em() + + if df_stocks is None or df_stocks.empty: + return results + + # Search by code or name + mask = df_stocks["代码"].astype(str).str.contains( + search_term, case=False, na=False + ) | df_stocks["名称"].str.contains(search_term, case=False, na=False) + + matched_stocks = df_stocks[mask].head(query.limit) + + for _, row in matched_stocks.iterrows(): + try: + # Parse stock code and exchange + stock_code = str(row["代码"]) + stock_name = row["名称"] + + # Determine exchange from code + if stock_code.startswith("6"): + exchange = "SSE" # Shanghai + internal_ticker = f"SSE:{stock_code}" + elif stock_code.startswith(("0", "3")): + exchange = "SZSE" # Shenzhen + internal_ticker = f"SZSE:{stock_code}" + elif stock_code.startswith("8"): + exchange = "BSE" # Beijing + internal_ticker = f"BSE:{stock_code}" + else: + continue # Skip unknown exchanges + + # Create localized names + names = { + "zh-Hans": stock_name, + "zh-Hant": stock_name, + "en-US": stock_name, # AKShare primarily has Chinese names + } + + result = AssetSearchResult( + ticker=internal_ticker, + asset_type=AssetType.STOCK, + names=names, + exchange=exchange, + country="CN", + currency="CNY", + market_status=MarketStatus.UNKNOWN, + relevance_score=self._calculate_relevance( + search_term, stock_code, stock_name + ), + ) + + results.append(result) + + except Exception as e: + logger.warning( + f"Error processing search result for {row.get('代码')}: {e}" + ) + continue + + except Exception as e: + logger.error(f"Error fetching stock list from AKShare: {e}") + + # Try to search funds if no stock results or if fund type is requested + if not results or ( + query.asset_types and AssetType.ETF in query.asset_types + ): + try: + df_funds = ak.fund_etf_spot_em() + + if df_funds is not None and not df_funds.empty: + # Search funds + fund_mask = df_funds["代码"].astype(str).str.contains( + search_term, case=False, na=False + ) | df_funds["名称"].str.contains( + search_term, case=False, na=False + ) + + matched_funds = df_funds[fund_mask].head( + max(5, query.limit - len(results)) + ) + + for _, row in matched_funds.iterrows(): + try: + fund_code = str(row["代码"]) + fund_name = row["名称"] + + # Determine exchange for funds + if fund_code.startswith("5"): + exchange = "SSE" + internal_ticker = f"SSE:{fund_code}" + else: + exchange = "SZSE" + internal_ticker = f"SZSE:{fund_code}" + + names = { + "zh-Hans": fund_name, + "zh-Hant": fund_name, + "en-US": fund_name, + } + + result = AssetSearchResult( + ticker=internal_ticker, + asset_type=AssetType.ETF, + names=names, + exchange=exchange, + country="CN", + currency="CNY", + market_status=MarketStatus.UNKNOWN, + relevance_score=self._calculate_relevance( + search_term, fund_code, fund_name + ), + ) + + results.append(result) + + except Exception as e: + logger.warning( + f"Error processing fund result for {row.get('代码')}: {e}" + ) + continue + + except Exception as e: + logger.warning(f"Error fetching fund list from AKShare: {e}") + + # Apply filters + if query.asset_types: + results = [r for r in results if r.asset_type in query.asset_types] + + if query.exchanges: + results = [r for r in results if r.exchange in query.exchanges] + + if query.countries: + results = [r for r in results if r.country in query.countries] + + # Sort by relevance score + results.sort(key=lambda x: x.relevance_score, reverse=True) + + return results[: query.limit] + + except Exception as e: + logger.error(f"Error searching assets: {e}") + return [] + + def _calculate_relevance(self, search_term: str, code: str, name: str) -> float: + """Calculate relevance score for search results.""" + search_term_lower = search_term.lower() + code_lower = code.lower() + name_lower = name.lower() + + # Exact matches get highest score + if search_term_lower == code_lower or search_term_lower == name_lower: + return 2.0 + + # Code starts with search term + if code_lower.startswith(search_term_lower): + return 1.8 + + # Name starts with search term + if name_lower.startswith(search_term_lower): + return 1.6 + + # Code contains search term + if search_term_lower in code_lower: + return 1.4 + + # Name contains search term + if search_term_lower in name_lower: + return 1.2 + + return 1.0 + + def get_asset_info(self, ticker: str) -> Optional[Asset]: + """Get detailed asset information from AKShare.""" + try: + exchange, symbol = ticker.split(":") + + # Get stock individual info + try: + df_info = ak.stock_individual_info_em(symbol=symbol) + + if df_info is None or df_info.empty: + return None + + # Convert DataFrame to dict for easier access + info_dict = {} + for _, row in df_info.iterrows(): + info_dict[row["item"]] = row["value"] + + # Create localized names + names = LocalizedName() + stock_name = info_dict.get("股票名称", symbol) + names.set_name("zh-Hans", stock_name) + names.set_name("zh-Hant", stock_name) + names.set_name("en-US", stock_name) + + # Create market info + market_info = MarketInfo( + exchange=exchange, + country="CN", + currency="CNY", + timezone="Asia/Shanghai", + ) + + # Create asset + asset = Asset( + ticker=ticker, + asset_type=AssetType.STOCK, + names=names, + market_info=market_info, + ) + + # Set source mapping + asset.set_source_ticker(self.source, symbol) + + # Add additional properties from AKShare + properties = { + "stock_name": info_dict.get("股票名称"), + "stock_code": info_dict.get("股票代码"), + "listing_date": info_dict.get("上市时间"), + "total_share_capital": info_dict.get("总股本"), + "circulating_share_capital": info_dict.get("流通股本"), + "industry": info_dict.get("所处行业"), + "main_business": info_dict.get("主营业务"), + "business_scope": info_dict.get("经营范围"), + "chairman": info_dict.get("董事长"), + "general_manager": info_dict.get("总经理"), + "secretary": info_dict.get("董秘"), + "registered_capital": info_dict.get("注册资本"), + "employees": info_dict.get("员工人数"), + "province": info_dict.get("所属省份"), + "city": info_dict.get("所属城市"), + "office_address": info_dict.get("办公地址"), + "company_website": info_dict.get("公司网址"), + "email": info_dict.get("电子邮箱"), + "main_business_income": info_dict.get("主营业务收入"), + "net_profit": info_dict.get("净利润"), + } + + # Filter out None values + properties = {k: v for k, v in properties.items() if v is not None} + asset.properties.update(properties) + + return asset + + except Exception as e: + logger.error(f"Error fetching individual stock info for {symbol}: {e}") + return None + + except Exception as e: + logger.error(f"Error getting asset info for {ticker}: {e}") + return None + + def get_real_time_price(self, ticker: str) -> Optional[AssetPrice]: + """Get real-time price data from AKShare.""" + try: + exchange, symbol = ticker.split(":") + + # Get real-time stock data + try: + df_realtime = ak.stock_zh_a_spot_em() + + if df_realtime is None or df_realtime.empty: + return None + + # Find the specific stock + stock_data = df_realtime[df_realtime["代码"] == symbol] + + if stock_data.empty: + return None + + stock_info = stock_data.iloc[0] + + # Extract price information + current_price = Decimal(str(stock_info["最新价"])) + open_price = Decimal(str(stock_info["今开"])) + high_price = Decimal(str(stock_info["最高"])) + low_price = Decimal(str(stock_info["最低"])) + pre_close = Decimal(str(stock_info["昨收"])) + + # Calculate change + change = current_price - pre_close + change_percent = ( + (change / pre_close) * 100 if pre_close else Decimal("0") + ) + + # Get volume and market cap + volume = ( + Decimal(str(stock_info["成交量"])) if stock_info["成交量"] else None + ) + market_cap = ( + Decimal(str(stock_info["总市值"])) if stock_info["总市值"] else None + ) + + return AssetPrice( + ticker=ticker, + price=current_price, + currency="CNY", + timestamp=datetime.now(), # AKShare doesn't provide exact timestamp + volume=volume, + open_price=open_price, + high_price=high_price, + low_price=low_price, + close_price=current_price, + change=change, + change_percent=change_percent, + market_cap=market_cap, + source=self.source, + ) + + except Exception as e: + logger.error(f"Error fetching real-time price for {symbol}: {e}") + return None + + except Exception as e: + logger.error(f"Error getting real-time price for {ticker}: {e}") + return None + + def get_historical_prices( + self, + ticker: str, + start_date: datetime, + end_date: datetime, + interval: str = "1d", + ) -> List[AssetPrice]: + """Get historical price data from AKShare.""" + try: + exchange, symbol = ticker.split(":") + + # Format dates for AKShare + start_date_str = start_date.strftime("%Y%m%d") + end_date_str = end_date.strftime("%Y%m%d") + + # Map interval to AKShare format + if interval in ["1d", "daily"]: + period = "daily" + else: + logger.warning( + f"AKShare primarily supports daily data. Requested interval: {interval}" + ) + period = "daily" + + # Get historical data + try: + df_hist = ak.stock_zh_a_hist( + symbol=symbol, + period=period, + start_date=start_date_str, + end_date=end_date_str, + adjust="", # No adjustment + ) + + if df_hist is None or df_hist.empty: + return [] + + prices = [] + for _, row in df_hist.iterrows(): + # Parse date + trade_date = pd.to_datetime(row["日期"]).to_pydatetime() + + # Extract price data + open_price = Decimal(str(row["开盘"])) + high_price = Decimal(str(row["最高"])) + low_price = Decimal(str(row["最低"])) + close_price = Decimal(str(row["收盘"])) + volume = Decimal(str(row["成交量"])) if row["成交量"] else None + + # Calculate change from previous day + change = None + change_percent = None + if len(prices) > 0: + prev_close = prices[-1].close_price + change = close_price - prev_close + change_percent = ( + (change / prev_close) * 100 if prev_close else Decimal("0") + ) + + price = AssetPrice( + ticker=ticker, + price=close_price, + currency="CNY", + timestamp=trade_date, + volume=volume, + open_price=open_price, + high_price=high_price, + low_price=low_price, + close_price=close_price, + change=change, + change_percent=change_percent, + source=self.source, + ) + prices.append(price) + + return prices + + except Exception as e: + logger.error(f"Error fetching historical data for {symbol}: {e}") + return [] + + except Exception as e: + logger.error(f"Error getting historical prices for {ticker}: {e}") + return [] + + def get_supported_asset_types(self) -> List[AssetType]: + """Get asset types supported by AKShare.""" + return [ + AssetType.STOCK, + AssetType.ETF, + AssetType.BOND, + AssetType.INDEX, + ] + + def _perform_health_check(self) -> Any: + """Perform health check by fetching stock list.""" + try: + # Test with a simple query to get stock list + df = ak.stock_zh_a_spot_em() + + if df is not None and not df.empty: + return { + "status": "ok", + "stocks_count": len(df), + "sample_stock": df.iloc[0]["代码"] if len(df) > 0 else None, + } + else: + return {"status": "error", "message": "No data received"} + + except Exception as e: + return {"status": "error", "message": str(e)} + + def validate_ticker(self, ticker: str) -> bool: + """Validate if ticker is supported by AKShare (Chinese markets only).""" + try: + exchange, symbol = ticker.split(":", 1) + + # AKShare supports Chinese exchanges + supported_exchanges = ["SSE", "SZSE", "BSE"] + + if exchange not in supported_exchanges: + return False + + # Validate symbol format (6 digits for Chinese stocks) + if not symbol.isdigit() or len(symbol) != 6: + return False + + return True + + except ValueError: + return False + + def get_market_calendar( + self, start_date: datetime, end_date: datetime + ) -> List[datetime]: + """Get trading calendar for Chinese markets.""" + try: + # Get trading calendar from AKShare + df_calendar = ak.tool_trade_date_hist_sina() + + if df_calendar is None or df_calendar.empty: + return [] + + # Convert to datetime and filter by date range + df_calendar["trade_date"] = pd.to_datetime(df_calendar["trade_date"]) + + mask = (df_calendar["trade_date"] >= start_date) & ( + df_calendar["trade_date"] <= end_date + ) + filtered_dates = df_calendar[mask]["trade_date"] + + return [date.to_pydatetime() for date in filtered_dates] + + except Exception as e: + logger.error(f"Error fetching market calendar: {e}") + return [] + + def get_sector_stocks(self, sector: str) -> List[AssetSearchResult]: + """Get stocks from a specific sector.""" + try: + # Get sector classification + df_industry = ak.stock_board_industry_name_em() + + if df_industry is None or df_industry.empty: + return [] + + # Find matching sectors + sector_matches = df_industry[ + df_industry["板块名称"].str.contains(sector, na=False) + ] + + results = [] + for _, sector_row in sector_matches.iterrows(): + try: + # Get stocks in this sector + sector_name = sector_row["板块名称"] + df_sector_stocks = ak.stock_board_industry_cons_em( + symbol=sector_name + ) + + if df_sector_stocks is not None and not df_sector_stocks.empty: + for _, stock_row in df_sector_stocks.iterrows(): + stock_code = str(stock_row["代码"]) + stock_name = stock_row["名称"] + + # Determine exchange + if stock_code.startswith("6"): + exchange = "SSE" + internal_ticker = f"SSE:{stock_code}" + elif stock_code.startswith(("0", "3")): + exchange = "SZSE" + internal_ticker = f"SZSE:{stock_code}" + else: + continue + + names = { + "zh-Hans": stock_name, + "zh-Hant": stock_name, + "en-US": stock_name, + } + + result = AssetSearchResult( + ticker=internal_ticker, + asset_type=AssetType.STOCK, + names=names, + exchange=exchange, + country="CN", + currency="CNY", + market_status=MarketStatus.UNKNOWN, + relevance_score=1.0, + ) + + results.append(result) + + except Exception as e: + logger.warning( + f"Error processing sector {sector_row.get('板块名称')}: {e}" + ) + continue + + return results + + except Exception as e: + logger.error(f"Error getting sector stocks for {sector}: {e}") + return [] + + def is_market_open(self, exchange: str) -> bool: + """Check if Chinese market is currently open.""" + if exchange not in ["SSE", "SZSE", "BSE"]: + return False + + # Chinese market hours: 9:30-11:30, 13:00-15:00 (GMT+8) + now = datetime.utcnow() + # Convert to Beijing time (UTC+8) + beijing_time = now.replace(tzinfo=None) + timedelta(hours=8) + + # Check if it's a weekday + if beijing_time.weekday() >= 5: # Saturday = 5, Sunday = 6 + return False + + # Check trading hours + current_time = beijing_time.time() + morning_open = datetime.strptime("09:30", "%H:%M").time() + morning_close = datetime.strptime("11:30", "%H:%M").time() + afternoon_open = datetime.strptime("13:00", "%H:%M").time() + afternoon_close = datetime.strptime("15:00", "%H:%M").time() + + return ( + morning_open <= current_time <= morning_close + or afternoon_open <= current_time <= afternoon_close + ) diff --git a/python/valuecell/adapters/assets/api.py b/python/valuecell/adapters/assets/api.py new file mode 100644 index 000000000..2e9e1758a --- /dev/null +++ b/python/valuecell/adapters/assets/api.py @@ -0,0 +1,634 @@ +"""API interface for asset management and watchlist operations. + +This module provides high-level API functions for asset search, watchlist management, +and price data retrieval with i18n support. +""" + +import logging +from typing import Dict, List, Optional, Any +from datetime import datetime + +from .manager import get_adapter_manager, get_watchlist_manager +from .i18n_integration import get_asset_i18n_service +from .types import AssetSearchQuery, AssetType +from ...i18n import get_i18n_config + +logger = logging.getLogger(__name__) + + +class AssetAPI: + """High-level API for asset operations with i18n support.""" + + def __init__(self): + """Initialize asset API.""" + self.adapter_manager = get_adapter_manager() + self.watchlist_manager = get_watchlist_manager() + self.i18n_service = get_asset_i18n_service() + + def search_assets( + self, + query: str, + asset_types: Optional[List[str]] = None, + exchanges: Optional[List[str]] = None, + countries: Optional[List[str]] = None, + limit: int = 50, + language: Optional[str] = None, + ) -> Dict[str, Any]: + """Search for assets with localization support. + + Args: + query: Search query string + asset_types: Filter by asset types (optional) + exchanges: Filter by exchanges (optional) + countries: Filter by countries (optional) + limit: Maximum number of results + language: Language for localized results + + Returns: + Dictionary containing search results and metadata + """ + try: + # Convert string asset types to enum + parsed_asset_types = None + if asset_types: + parsed_asset_types = [] + for asset_type_str in asset_types: + try: + parsed_asset_types.append(AssetType(asset_type_str.lower())) + except ValueError: + logger.warning(f"Invalid asset type: {asset_type_str}") + + # Create search query + search_query = AssetSearchQuery( + query=query, + asset_types=parsed_asset_types, + exchanges=exchanges, + countries=countries, + limit=limit, + language=language or get_i18n_config().language, + ) + + # Perform search + results = self.adapter_manager.search_assets(search_query) + + # Localize results + localized_results = self.i18n_service.localize_search_results( + results, language + ) + + # Convert to dictionary format + result_dicts = [] + for result in localized_results: + result_dict = { + "ticker": result.ticker, + "asset_type": result.asset_type.value, + "asset_type_display": self.i18n_service.get_asset_type_display_name( + result.asset_type, language + ), + "names": result.names, + "display_name": result.get_display_name( + language or get_i18n_config().language + ), + "exchange": result.exchange, + "country": result.country, + "currency": result.currency, + "market_status": result.market_status.value, + "market_status_display": self.i18n_service.get_market_status_display_name( + result.market_status, language + ), + "relevance_score": result.relevance_score, + } + result_dicts.append(result_dict) + + return { + "success": True, + "results": result_dicts, + "count": len(result_dicts), + "query": query, + "filters": { + "asset_types": asset_types, + "exchanges": exchanges, + "countries": countries, + "limit": limit, + }, + "language": language or get_i18n_config().language, + } + + except Exception as e: + logger.error(f"Error searching assets: {e}") + return {"success": False, "error": str(e), "results": [], "count": 0} + + def get_asset_info( + self, ticker: str, language: Optional[str] = None + ) -> Dict[str, Any]: + """Get detailed asset information with localization. + + Args: + ticker: Asset ticker in internal format + language: Language for localized content + + Returns: + Dictionary containing asset information + """ + try: + asset = self.adapter_manager.get_asset_info(ticker) + + if not asset: + return {"success": False, "error": "Asset not found", "ticker": ticker} + + # Localize asset + localized_asset = self.i18n_service.localize_asset(asset, language) + + # Convert to dictionary + asset_dict = { + "success": True, + "ticker": localized_asset.ticker, + "asset_type": localized_asset.asset_type.value, + "asset_type_display": self.i18n_service.get_asset_type_display_name( + localized_asset.asset_type, language + ), + "names": localized_asset.names.names, + "display_name": localized_asset.get_localized_name( + language or get_i18n_config().language + ), + "descriptions": localized_asset.descriptions, + "market_info": { + "exchange": localized_asset.market_info.exchange, + "country": localized_asset.market_info.country, + "currency": localized_asset.market_info.currency, + "timezone": localized_asset.market_info.timezone, + "trading_hours": localized_asset.market_info.trading_hours, + "market_status": localized_asset.market_info.market_status.value, + }, + "source_mappings": { + k.value: v for k, v in localized_asset.source_mappings.items() + }, + "properties": localized_asset.properties, + "created_at": localized_asset.created_at.isoformat(), + "updated_at": localized_asset.updated_at.isoformat(), + "is_active": localized_asset.is_active, + } + + return asset_dict + + except Exception as e: + logger.error(f"Error getting asset info for {ticker}: {e}") + return {"success": False, "error": str(e), "ticker": ticker} + + def get_asset_price( + self, ticker: str, language: Optional[str] = None + ) -> Dict[str, Any]: + """Get current price for an asset with localized formatting. + + Args: + ticker: Asset ticker in internal format + language: Language for localized formatting + + Returns: + Dictionary containing price information + """ + try: + price_data = self.adapter_manager.get_real_time_price(ticker) + + if not price_data: + return { + "success": False, + "error": "Price data not available", + "ticker": ticker, + } + + # Format price data with localization + formatted_price = { + "success": True, + "ticker": price_data.ticker, + "price": float(price_data.price), + "price_formatted": self.i18n_service.format_currency_amount( + float(price_data.price), price_data.currency, language + ), + "currency": price_data.currency, + "timestamp": price_data.timestamp.isoformat(), + "volume": float(price_data.volume) if price_data.volume else None, + "open_price": float(price_data.open_price) + if price_data.open_price + else None, + "high_price": float(price_data.high_price) + if price_data.high_price + else None, + "low_price": float(price_data.low_price) + if price_data.low_price + else None, + "close_price": float(price_data.close_price) + if price_data.close_price + else None, + "change": float(price_data.change) if price_data.change else None, + "change_percent": float(price_data.change_percent) + if price_data.change_percent + else None, + "change_percent_formatted": self.i18n_service.format_percentage_change( + float(price_data.change_percent), language + ) + if price_data.change_percent + else None, + "market_cap": float(price_data.market_cap) + if price_data.market_cap + else None, + "market_cap_formatted": self.i18n_service.format_market_cap( + float(price_data.market_cap), price_data.currency, language + ) + if price_data.market_cap + else None, + "source": price_data.source.value if price_data.source else None, + } + + return formatted_price + + except Exception as e: + logger.error(f"Error getting price for {ticker}: {e}") + return {"success": False, "error": str(e), "ticker": ticker} + + def get_multiple_prices( + self, tickers: List[str], language: Optional[str] = None + ) -> Dict[str, Any]: + """Get prices for multiple assets efficiently. + + Args: + tickers: List of asset tickers + language: Language for localized formatting + + Returns: + Dictionary containing price data for all tickers + """ + try: + price_data = self.adapter_manager.get_multiple_prices(tickers) + + formatted_prices = {} + + for ticker, price in price_data.items(): + if price: + formatted_prices[ticker] = { + "price": float(price.price), + "price_formatted": self.i18n_service.format_currency_amount( + float(price.price), price.currency, language + ), + "currency": price.currency, + "timestamp": price.timestamp.isoformat(), + "change": float(price.change) if price.change else None, + "change_percent": float(price.change_percent) + if price.change_percent + else None, + "change_percent_formatted": self.i18n_service.format_percentage_change( + float(price.change_percent), language + ) + if price.change_percent + else None, + "volume": float(price.volume) if price.volume else None, + "market_cap": float(price.market_cap) + if price.market_cap + else None, + "market_cap_formatted": self.i18n_service.format_market_cap( + float(price.market_cap), price.currency, language + ) + if price.market_cap + else None, + "source": price.source.value if price.source else None, + } + else: + formatted_prices[ticker] = None + + return { + "success": True, + "prices": formatted_prices, + "count": len([p for p in formatted_prices.values() if p is not None]), + "requested_count": len(tickers), + } + + except Exception as e: + logger.error(f"Error getting multiple prices: {e}") + return {"success": False, "error": str(e), "prices": {}} + + def create_watchlist( + self, + user_id: str, + name: str = "My Watchlist", + description: str = "", + is_default: bool = False, + ) -> Dict[str, Any]: + """Create a new watchlist for a user. + + Args: + user_id: User identifier + name: Watchlist name + description: Watchlist description + is_default: Whether this is the default watchlist + + Returns: + Dictionary containing created watchlist information + """ + try: + watchlist = self.watchlist_manager.create_watchlist( + user_id, name, description, is_default + ) + + return { + "success": True, + "watchlist": { + "user_id": watchlist.user_id, + "name": watchlist.name, + "description": watchlist.description, + "created_at": watchlist.created_at.isoformat(), + "updated_at": watchlist.updated_at.isoformat(), + "is_default": watchlist.is_default, + "is_public": watchlist.is_public, + "items_count": len(watchlist.items), + }, + } + + except Exception as e: + logger.error(f"Error creating watchlist: {e}") + return {"success": False, "error": str(e)} + + def add_to_watchlist( + self, + user_id: str, + ticker: str, + watchlist_name: Optional[str] = None, + notes: str = "", + ) -> Dict[str, Any]: + """Add an asset to a watchlist. + + Args: + user_id: User identifier + ticker: Asset ticker to add + watchlist_name: Watchlist name (uses default if None) + notes: User notes about the asset + + Returns: + Dictionary containing operation result + """ + try: + success = self.watchlist_manager.add_asset_to_watchlist( + user_id, ticker, watchlist_name, notes + ) + + if success: + return { + "success": True, + "message": "Asset added to watchlist successfully", + "ticker": ticker, + "user_id": user_id, + "watchlist_name": watchlist_name, + } + else: + return { + "success": False, + "error": "Failed to add asset to watchlist", + "ticker": ticker, + } + + except Exception as e: + logger.error(f"Error adding {ticker} to watchlist: {e}") + return {"success": False, "error": str(e), "ticker": ticker} + + def remove_from_watchlist( + self, user_id: str, ticker: str, watchlist_name: Optional[str] = None + ) -> Dict[str, Any]: + """Remove an asset from a watchlist. + + Args: + user_id: User identifier + ticker: Asset ticker to remove + watchlist_name: Watchlist name (uses default if None) + + Returns: + Dictionary containing operation result + """ + try: + success = self.watchlist_manager.remove_asset_from_watchlist( + user_id, ticker, watchlist_name + ) + + if success: + return { + "success": True, + "message": "Asset removed from watchlist successfully", + "ticker": ticker, + "user_id": user_id, + "watchlist_name": watchlist_name, + } + else: + return { + "success": False, + "error": "Asset not found in watchlist or watchlist not found", + "ticker": ticker, + } + + except Exception as e: + logger.error(f"Error removing {ticker} from watchlist: {e}") + return {"success": False, "error": str(e), "ticker": ticker} + + def get_watchlist( + self, + user_id: str, + watchlist_name: Optional[str] = None, + include_prices: bool = True, + language: Optional[str] = None, + ) -> Dict[str, Any]: + """Get watchlist with asset information and prices. + + Args: + user_id: User identifier + watchlist_name: Watchlist name (uses default if None) + include_prices: Whether to include current prices + language: Language for localized content + + Returns: + Dictionary containing watchlist data + """ + try: + # Get watchlist + if watchlist_name: + watchlist = self.watchlist_manager.get_watchlist( + user_id, watchlist_name + ) + else: + watchlist = self.watchlist_manager.get_default_watchlist(user_id) + + if not watchlist: + return { + "success": False, + "error": "Watchlist not found", + "user_id": user_id, + "watchlist_name": watchlist_name, + } + + # Get asset information and prices + assets_data = [] + tickers = watchlist.get_tickers() + + # Get prices if requested + prices_data = {} + if include_prices and tickers: + prices_result = self.get_multiple_prices(tickers, language) + if prices_result["success"]: + prices_data = prices_result["prices"] + + # Build asset data + for item in sorted(watchlist.items, key=lambda x: x.order): + asset_data = { + "ticker": item.ticker, + "display_name": self.i18n_service.get_localized_asset_name( + item.ticker, language + ), + "added_at": item.added_at.isoformat(), + "order": item.order, + "notes": item.notes, + "alerts": item.alerts, + } + + # Add price data if available + if item.ticker in prices_data and prices_data[item.ticker]: + asset_data["price_data"] = prices_data[item.ticker] + + assets_data.append(asset_data) + + return { + "success": True, + "watchlist": { + "user_id": watchlist.user_id, + "name": watchlist.name, + "description": watchlist.description, + "created_at": watchlist.created_at.isoformat(), + "updated_at": watchlist.updated_at.isoformat(), + "is_default": watchlist.is_default, + "is_public": watchlist.is_public, + "items_count": len(watchlist.items), + "assets": assets_data, + }, + } + + except Exception as e: + logger.error(f"Error getting watchlist: {e}") + return {"success": False, "error": str(e), "user_id": user_id} + + def get_user_watchlists(self, user_id: str) -> Dict[str, Any]: + """Get all watchlists for a user. + + Args: + user_id: User identifier + + Returns: + Dictionary containing all user watchlists + """ + try: + watchlists = self.watchlist_manager.get_user_watchlists(user_id) + + watchlists_data = [] + for watchlist in watchlists: + watchlist_data = { + "name": watchlist.name, + "description": watchlist.description, + "created_at": watchlist.created_at.isoformat(), + "updated_at": watchlist.updated_at.isoformat(), + "is_default": watchlist.is_default, + "is_public": watchlist.is_public, + "items_count": len(watchlist.items), + } + watchlists_data.append(watchlist_data) + + return { + "success": True, + "user_id": user_id, + "watchlists": watchlists_data, + "count": len(watchlists_data), + } + + except Exception as e: + logger.error(f"Error getting user watchlists: {e}") + return {"success": False, "error": str(e), "user_id": user_id} + + def get_system_health(self) -> Dict[str, Any]: + """Get system health status for all data adapters. + + Returns: + Dictionary containing health status for all adapters + """ + try: + health_data = self.adapter_manager.health_check() + + # Convert enum keys to strings + health_status = {} + for source, status in health_data.items(): + health_status[source.value] = status + + # Calculate overall health + healthy_count = sum( + 1 + for status in health_status.values() + if status.get("status") == "healthy" + ) + total_count = len(health_status) + + overall_status = ( + "healthy" + if healthy_count == total_count + else "degraded" + if healthy_count > 0 + else "unhealthy" + ) + + return { + "success": True, + "overall_status": overall_status, + "healthy_adapters": healthy_count, + "total_adapters": total_count, + "adapters": health_status, + "timestamp": datetime.utcnow().isoformat(), + } + + except Exception as e: + logger.error(f"Error getting system health: {e}") + return {"success": False, "error": str(e), "overall_status": "error"} + + +# Global API instance +_asset_api: Optional[AssetAPI] = None + + +def get_asset_api() -> AssetAPI: + """Get global asset API instance.""" + global _asset_api + if _asset_api is None: + _asset_api = AssetAPI() + return _asset_api + + +def reset_asset_api() -> None: + """Reset global asset API instance (mainly for testing).""" + global _asset_api + _asset_api = None + + +# Convenience functions for direct API access +def search_assets(query: str, **kwargs) -> Dict[str, Any]: + """Convenience function for asset search.""" + return get_asset_api().search_assets(query, **kwargs) + + +def get_asset_info(ticker: str, **kwargs) -> Dict[str, Any]: + """Convenience function for getting asset info.""" + return get_asset_api().get_asset_info(ticker, **kwargs) + + +def get_asset_price(ticker: str, **kwargs) -> Dict[str, Any]: + """Convenience function for getting asset price.""" + return get_asset_api().get_asset_price(ticker, **kwargs) + + +def add_to_watchlist(user_id: str, ticker: str, **kwargs) -> Dict[str, Any]: + """Convenience function for adding to watchlist.""" + return get_asset_api().add_to_watchlist(user_id, ticker, **kwargs) + + +def get_watchlist(user_id: str, **kwargs) -> Dict[str, Any]: + """Convenience function for getting watchlist.""" + return get_asset_api().get_watchlist(user_id, **kwargs) diff --git a/python/valuecell/adapters/assets/base.py b/python/valuecell/adapters/assets/base.py new file mode 100644 index 000000000..60c1a1867 --- /dev/null +++ b/python/valuecell/adapters/assets/base.py @@ -0,0 +1,387 @@ +"""Base classes and interfaces for asset data adapters. + +This module defines the abstract base classes that all data source adapters +must implement to ensure consistent behavior across different providers. +""" + +from abc import ABC, abstractmethod +from typing import Dict, List, Optional, Any +from datetime import datetime +import logging + +from .types import ( + Asset, + AssetPrice, + AssetSearchResult, + AssetSearchQuery, + DataSource, + AssetType, +) + +logger = logging.getLogger(__name__) + + +class TickerConverter: + """Utility class for converting between internal ticker format and data source formats.""" + + def __init__(self): + """Initialize ticker converter with mapping rules.""" + # Mapping from internal exchange codes to data source specific formats + self.exchange_mappings: Dict[DataSource, Dict[str, str]] = { + DataSource.YFINANCE: { + "NASDAQ": "", # NASDAQ stocks don't need suffix in yfinance + "NYSE": "", # NYSE stocks don't need suffix in yfinance + "SSE": ".SS", # Shanghai Stock Exchange + "SZSE": ".SZ", # Shenzhen Stock Exchange + "HKEX": ".HK", # Hong Kong Exchange + "TSE": ".T", # Tokyo Stock Exchange + }, + DataSource.TUSHARE: { + "SSE": ".SH", # Shanghai Stock Exchange in TuShare + "SZSE": ".SZ", # Shenzhen Stock Exchange in TuShare + }, + DataSource.AKSHARE: { + "SSE": "", # AKShare uses plain symbols for Chinese stocks + "SZSE": "", # AKShare uses plain symbols for Chinese stocks + "BSE": "", # Beijing Stock Exchange + }, + DataSource.FINNHUB: { + "NASDAQ": "", # Finnhub uses plain symbols for US stocks + "NYSE": "", # Finnhub uses plain symbols for US stocks + "AMEX": "", # American Stock Exchange + "HKEX": ".HK", # Hong Kong stocks need .HK suffix + "TSE": ".T", # Tokyo Stock Exchange + "LSE": ".L", # London Stock Exchange + "XETRA": ".DE", # German Exchange + }, + DataSource.COINMARKETCAP: { + "CRYPTO": "", # Crypto symbols are used as-is + "BINANCE": "", # Binance symbols are used as-is + }, + } + + # Reverse mappings for converting back to internal format + self.reverse_mappings: Dict[DataSource, Dict[str, str]] = {} + for source, mappings in self.exchange_mappings.items(): + self.reverse_mappings[source] = {v: k for k, v in mappings.items() if v} + + def to_source_format(self, internal_ticker: str, source: DataSource) -> str: + """Convert internal ticker format to data source specific format. + + Args: + internal_ticker: Ticker in internal format (e.g., "NASDAQ:AAPL") + source: Target data source + + Returns: + Ticker in data source specific format (e.g., "AAPL" for yfinance) + """ + try: + exchange, symbol = internal_ticker.split(":", 1) + + if source not in self.exchange_mappings: + logger.warning(f"No mapping found for data source: {source}") + return symbol + + suffix = self.exchange_mappings[source].get(exchange, "") + return f"{symbol}{suffix}" + + except ValueError: + logger.error(f"Invalid ticker format: {internal_ticker}") + return internal_ticker + + def to_internal_format( + self, + source_ticker: str, + source: DataSource, + default_exchange: Optional[str] = None, + ) -> str: + """Convert data source ticker to internal format. + + Args: + source_ticker: Ticker in data source format (e.g., "000001.SZ") + source: Source data provider + default_exchange: Default exchange if cannot be determined from ticker + + Returns: + Ticker in internal format (e.g., "SZSE:000001") + """ + try: + # Check for known suffixes + if source in self.reverse_mappings: + for suffix, exchange in self.reverse_mappings[source].items(): + if source_ticker.endswith(suffix): + symbol = ( + source_ticker[: -len(suffix)] if suffix else source_ticker + ) + return f"{exchange}:{symbol}" + + # If no suffix found and default exchange provided + if default_exchange: + return f"{default_exchange}:{source_ticker}" + + # For crypto and other assets without clear exchange mapping + if source == DataSource.COINMARKETCAP: + return f"CRYPTO:{source_ticker}" + elif source == DataSource.BINANCE: + return f"BINANCE:{source_ticker}" + + # Fallback to using the source as exchange + return f"{source.value.upper()}:{source_ticker}" + + except Exception as e: + logger.error(f"Error converting ticker {source_ticker}: {e}") + return f"UNKNOWN:{source_ticker}" + + def get_supported_exchanges(self, source: DataSource) -> List[str]: + """Get list of supported exchanges for a data source.""" + return list(self.exchange_mappings.get(source, {}).keys()) + + +class BaseDataAdapter(ABC): + """Abstract base class for all data source adapters.""" + + def __init__(self, source: DataSource, api_key: Optional[str] = None, **kwargs): + """Initialize adapter with data source and configuration. + + Args: + source: Data source identifier + api_key: API key for the data source (if required) + **kwargs: Additional configuration parameters + """ + self.source = source + self.api_key = api_key + self.config = kwargs + self.converter = TickerConverter() + self.logger = logging.getLogger(f"{__name__}.{source.value}") + + # Initialize adapter-specific configuration + self._initialize() + + @abstractmethod + def _initialize(self) -> None: + """Initialize adapter-specific configuration and connections.""" + pass + + @abstractmethod + def search_assets(self, query: AssetSearchQuery) -> List[AssetSearchResult]: + """Search for assets matching the query criteria. + + Args: + query: Search query parameters + + Returns: + List of matching assets + """ + pass + + @abstractmethod + def get_asset_info(self, ticker: str) -> Optional[Asset]: + """Get detailed information about a specific asset. + + Args: + ticker: Asset ticker in internal format + + Returns: + Asset information or None if not found + """ + pass + + @abstractmethod + def get_real_time_price(self, ticker: str) -> Optional[AssetPrice]: + """Get real-time price data for an asset. + + Args: + ticker: Asset ticker in internal format + + Returns: + Current price data or None if not available + """ + pass + + @abstractmethod + def get_historical_prices( + self, + ticker: str, + start_date: datetime, + end_date: datetime, + interval: str = "1d", + ) -> List[AssetPrice]: + """Get historical price data for an asset. + + Args: + ticker: Asset ticker in internal format + start_date: Start date for historical data + end_date: End date for historical data + interval: Data interval (e.g., "1d", "1h", "5m") + + Returns: + List of historical price data + """ + pass + + def get_multiple_prices( + self, tickers: List[str] + ) -> Dict[str, Optional[AssetPrice]]: + """Get real-time prices for multiple assets. + + Args: + tickers: List of asset tickers in internal format + + Returns: + Dictionary mapping tickers to price data + """ + results = {} + for ticker in tickers: + try: + results[ticker] = self.get_real_time_price(ticker) + except Exception as e: + self.logger.error(f"Error fetching price for {ticker}: {e}") + results[ticker] = None + return results + + def validate_ticker(self, ticker: str) -> bool: + """Validate if a ticker format is supported by this adapter. + + Args: + ticker: Ticker in internal format + + Returns: + True if ticker is valid for this adapter + """ + try: + exchange, _ = ticker.split(":", 1) + supported_exchanges = self.converter.get_supported_exchanges(self.source) + return exchange in supported_exchanges + except ValueError: + return False + + def convert_to_source_ticker(self, internal_ticker: str) -> str: + """Convert internal ticker to data source format.""" + return self.converter.to_source_format(internal_ticker, self.source) + + def convert_to_internal_ticker( + self, source_ticker: str, default_exchange: Optional[str] = None + ) -> str: + """Convert data source ticker to internal format.""" + return self.converter.to_internal_format( + source_ticker, self.source, default_exchange + ) + + def is_market_open(self, exchange: str) -> bool: + """Check if a specific market is currently open. + + Args: + exchange: Exchange identifier + + Returns: + True if market is open, False otherwise + """ + # This is a basic implementation - subclasses should override + # with more accurate market hours checking + now = datetime.utcnow() + hour = now.hour + + # Basic US market hours (9:30 AM - 4:00 PM EST = 14:30 - 21:00 UTC) + if exchange in ["NASDAQ", "NYSE"]: + return 14 <= hour < 21 + + # Basic Chinese market hours (9:30 AM - 3:00 PM CST = 1:30 - 7:00 UTC) + elif exchange in ["SSE", "SZSE"]: + return 1 <= hour < 7 + + # For crypto markets, assume always open + elif exchange in ["CRYPTO", "BINANCE"]: + return True + + return False + + def get_supported_asset_types(self) -> List[AssetType]: + """Get list of asset types supported by this adapter.""" + # Default implementation - subclasses should override + return [AssetType.STOCK] + + def health_check(self) -> Dict[str, Any]: + """Perform health check on the data adapter. + + Returns: + Dictionary containing health status information + """ + try: + # Try to make a simple API call to test connectivity + test_result = self._perform_health_check() + return { + "source": self.source.value, + "status": "healthy" if test_result else "unhealthy", + "timestamp": datetime.utcnow().isoformat(), + "details": test_result, + } + except Exception as e: + return { + "source": self.source.value, + "status": "error", + "timestamp": datetime.utcnow().isoformat(), + "error": str(e), + } + + @abstractmethod + def _perform_health_check(self) -> Any: + """Perform adapter-specific health check. + + Returns: + Health check result (implementation-specific) + """ + pass + + +class AdapterError(Exception): + """Base exception class for adapter-related errors.""" + + def __init__( + self, + message: str, + source: Optional[DataSource] = None, + ticker: Optional[str] = None, + ): + """Initialize adapter error. + + Args: + message: Error message + source: Data source where error occurred + ticker: Asset ticker related to the error + """ + self.source = source + self.ticker = ticker + super().__init__(message) + + +class RateLimitError(AdapterError): + """Exception raised when API rate limits are exceeded.""" + + def __init__(self, message: str, retry_after: Optional[int] = None, **kwargs): + """Initialize rate limit error. + + Args: + message: Error message + retry_after: Seconds to wait before retrying + **kwargs: Additional error context + """ + self.retry_after = retry_after + super().__init__(message, **kwargs) + + +class DataNotAvailableError(AdapterError): + """Exception raised when requested data is not available.""" + + pass + + +class AuthenticationError(AdapterError): + """Exception raised when API authentication fails.""" + + pass + + +class InvalidTickerError(AdapterError): + """Exception raised when ticker format is invalid or not supported.""" + + pass diff --git a/python/valuecell/adapters/assets/coinmarketcap_adapter.py b/python/valuecell/adapters/assets/coinmarketcap_adapter.py new file mode 100644 index 000000000..2d42091ee --- /dev/null +++ b/python/valuecell/adapters/assets/coinmarketcap_adapter.py @@ -0,0 +1,477 @@ +"""CoinMarketCap adapter for cryptocurrency data. + +This adapter provides integration with CoinMarketCap API to fetch cryptocurrency +market data, including prices, market caps, and metadata. +""" + +import logging +from typing import Dict, List, Optional, Any +from datetime import datetime +from decimal import Decimal +import requests +import time + +from .base import ( + BaseDataAdapter, + DataNotAvailableError, + AuthenticationError, + RateLimitError, +) +from .types import ( + Asset, + AssetPrice, + AssetSearchResult, + AssetSearchQuery, + DataSource, + AssetType, + MarketInfo, + LocalizedName, + MarketStatus, +) + +logger = logging.getLogger(__name__) + + +class CoinMarketCapAdapter(BaseDataAdapter): + """CoinMarketCap data adapter for cryptocurrency markets.""" + + def __init__(self, api_key: str, **kwargs): + """Initialize CoinMarketCap adapter. + + Args: + api_key: CoinMarketCap API key + **kwargs: Additional configuration parameters + """ + super().__init__(DataSource.COINMARKETCAP, api_key, **kwargs) + + if not api_key: + raise AuthenticationError("CoinMarketCap API key is required") + + def _initialize(self) -> None: + """Initialize CoinMarketCap adapter configuration.""" + self.base_url = "https://pro-api.coinmarketcap.com/v1" + self.headers = { + "Accepts": "application/json", + "X-CMC_PRO_API_KEY": self.api_key, + } + self.session = requests.Session() + self.session.headers.update(self.headers) + + # Rate limiting + self.last_request_time = 0 + self.min_request_interval = 1.0 # Minimum 1 second between requests + + # Test connection + try: + self._perform_health_check() + logger.info("CoinMarketCap adapter initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize CoinMarketCap adapter: {e}") + raise AuthenticationError(f"CoinMarketCap initialization failed: {e}") + + def _make_request( + self, endpoint: str, params: Optional[Dict] = None + ) -> Dict[str, Any]: + """Make rate-limited request to CoinMarketCap API.""" + # Rate limiting + current_time = time.time() + time_since_last_request = current_time - self.last_request_time + if time_since_last_request < self.min_request_interval: + time.sleep(self.min_request_interval - time_since_last_request) + + url = f"{self.base_url}{endpoint}" + + try: + response = self.session.get(url, params=params or {}) + self.last_request_time = time.time() + + if response.status_code == 429: + # Rate limit exceeded + retry_after = int(response.headers.get("Retry-After", 60)) + raise RateLimitError( + f"Rate limit exceeded. Retry after {retry_after} seconds", + retry_after=retry_after, + source=self.source, + ) + elif response.status_code == 401: + raise AuthenticationError("Invalid API key", source=self.source) + elif response.status_code != 200: + raise DataNotAvailableError( + f"API request failed with status {response.status_code}: {response.text}", + source=self.source, + ) + + data = response.json() + if data.get("status", {}).get("error_code") != 0: + error_message = data.get("status", {}).get( + "error_message", "Unknown error" + ) + raise DataNotAvailableError( + f"API error: {error_message}", source=self.source + ) + + return data + + except requests.RequestException as e: + raise DataNotAvailableError(f"Network error: {e}", source=self.source) + + def search_assets(self, query: AssetSearchQuery) -> List[AssetSearchResult]: + """Search for cryptocurrencies using CoinMarketCap.""" + try: + # CoinMarketCap doesn't have a direct search endpoint in free tier + # We'll get the top cryptocurrencies and filter by name/symbol + params = { + "start": 1, + "limit": 5000, # Get more coins to search through + "convert": "USD", + } + + data = self._make_request("/cryptocurrency/listings/latest", params) + coins = data.get("data", []) + + search_term = query.query.lower().strip() + results = [] + + for coin in coins: + # Search by symbol or name + symbol = coin.get("symbol", "").lower() + name = coin.get("name", "").lower() + + if ( + search_term in symbol + or search_term in name + or symbol.startswith(search_term) + ): + # Convert to internal ticker format + internal_ticker = f"CRYPTO:{coin['symbol']}" + + # Create localized names + names = { + "en-US": coin["name"], + "zh-Hans": coin["name"], # Could be enhanced with translations + } + + # Calculate relevance score + relevance_score = 1.0 + if symbol == search_term: + relevance_score = 2.0 # Exact symbol match + elif symbol.startswith(search_term): + relevance_score = 1.5 # Symbol starts with search term + + result = AssetSearchResult( + ticker=internal_ticker, + asset_type=AssetType.CRYPTO, + names=names, + exchange="CRYPTO", + country="GLOBAL", + currency="USD", + market_status=MarketStatus.OPEN, # Crypto markets are always open + relevance_score=relevance_score, + ) + + results.append(result) + + # Sort by relevance score and market cap + results.sort(key=lambda x: (x.relevance_score, -1), reverse=True) + + # Apply filters + if query.asset_types: + results = [r for r in results if r.asset_type in query.asset_types] + + return results[: query.limit] + + except Exception as e: + logger.error(f"Error searching cryptocurrencies: {e}") + return [] + + def get_asset_info(self, ticker: str) -> Optional[Asset]: + """Get detailed cryptocurrency information from CoinMarketCap.""" + try: + # Extract symbol from ticker + symbol = self.get_symbol() + + # Get cryptocurrency metadata + params = {"symbol": symbol} + data = self._make_request("/cryptocurrency/info", params) + + coin_data = data.get("data", {}).get(symbol) + if not coin_data: + return None + + # Create localized names + names = LocalizedName() + names.set_name("en-US", coin_data["name"]) + names.set_name("zh-Hans", coin_data["name"]) # Could be enhanced + + # Create market info + market_info = MarketInfo( + exchange="CRYPTO", country="GLOBAL", currency="USD", timezone="UTC" + ) + + # Create asset + asset = Asset( + ticker=ticker, + asset_type=AssetType.CRYPTO, + names=names, + market_info=market_info, + ) + + # Set source mapping + asset.set_source_ticker(self.source, symbol) + + # Add additional properties + properties = { + "description": coin_data.get("description"), + "category": coin_data.get("category"), + "tags": coin_data.get("tags", []), + "platform": coin_data.get("platform"), + "date_added": coin_data.get("date_added"), + "date_launched": coin_data.get("date_launched"), + "is_hidden": coin_data.get("is_hidden"), + "notice": coin_data.get("notice"), + "logo": coin_data.get("logo"), + "subreddit": coin_data.get("subreddit"), + "twitter_username": coin_data.get("twitter_username"), + "website_url": coin_data.get("urls", {}).get("website", []), + "technical_doc": coin_data.get("urls", {}).get("technical_doc", []), + "explorer": coin_data.get("urls", {}).get("explorer", []), + "source_code": coin_data.get("urls", {}).get("source_code", []), + } + + # Filter out None values + properties = {k: v for k, v in properties.items() if v is not None} + asset.properties.update(properties) + + return asset + + except Exception as e: + logger.error(f"Error fetching asset info for {ticker}: {e}") + return None + + def get_real_time_price(self, ticker: str) -> Optional[AssetPrice]: + """Get real-time cryptocurrency price from CoinMarketCap.""" + try: + symbol = self.get_symbol(ticker) + + params = {"symbol": symbol, "convert": "USD"} + + data = self._make_request("/cryptocurrency/quotes/latest", params) + coin_data = data.get("data", {}).get(symbol) + + if not coin_data: + return None + + quote = coin_data["quote"]["USD"] + + # Convert timestamp + last_updated = datetime.fromisoformat( + coin_data["last_updated"].replace("Z", "+00:00") + ).replace(tzinfo=None) + + return AssetPrice( + ticker=ticker, + price=Decimal(str(quote["price"])), + currency="USD", + timestamp=last_updated, + volume=Decimal(str(quote["volume_24h"])) + if quote.get("volume_24h") + else None, + change=None, # CoinMarketCap doesn't provide absolute change + change_percent=Decimal(str(quote["percent_change_24h"])) + if quote.get("percent_change_24h") + else None, + market_cap=Decimal(str(quote["market_cap"])) + if quote.get("market_cap") + else None, + source=self.source, + ) + + except Exception as e: + logger.error(f"Error fetching real-time price for {ticker}: {e}") + return None + + def get_historical_prices( + self, + ticker: str, + start_date: datetime, + end_date: datetime, + interval: str = "1d", + ) -> List[AssetPrice]: + """Get historical cryptocurrency prices from CoinMarketCap. + + Note: Historical data requires a paid CoinMarketCap plan. + This implementation provides a placeholder structure. + """ + try: + # CoinMarketCap historical data requires paid plan + # This is a placeholder implementation + logger.warning( + f"Historical data for {ticker} requires CoinMarketCap paid plan. " + f"Consider using alternative data sources for historical crypto data." + ) + + return [] + + except Exception as e: + logger.error(f"Error fetching historical prices for {ticker}: {e}") + return [] + + def get_multiple_prices( + self, tickers: List[str] + ) -> Dict[str, Optional[AssetPrice]]: + """Get real-time prices for multiple cryptocurrencies efficiently.""" + try: + # Extract symbols from tickers + symbols = [self.get_symbol(ticker) for ticker in tickers] + + # CoinMarketCap supports comma-separated symbols + params = {"symbol": ",".join(symbols), "convert": "USD"} + + data = self._make_request("/cryptocurrency/quotes/latest", params) + coin_data = data.get("data", {}) + + results = {} + + for ticker in tickers: + symbol = self.get_symbol(ticker) + + if symbol in coin_data: + coin_info = coin_data[symbol] + quote = coin_info["quote"]["USD"] + + last_updated = datetime.fromisoformat( + coin_info["last_updated"].replace("Z", "+00:00") + ).replace(tzinfo=None) + + results[ticker] = AssetPrice( + ticker=ticker, + price=Decimal(str(quote["price"])), + currency="USD", + timestamp=last_updated, + volume=Decimal(str(quote["volume_24h"])) + if quote.get("volume_24h") + else None, + change=None, + change_percent=Decimal(str(quote["percent_change_24h"])) + if quote.get("percent_change_24h") + else None, + market_cap=Decimal(str(quote["market_cap"])) + if quote.get("market_cap") + else None, + source=self.source, + ) + else: + results[ticker] = None + + return results + + except Exception as e: + logger.error(f"Error fetching multiple prices: {e}") + # Fallback to individual requests + return super().get_multiple_prices(tickers) + + def get_supported_asset_types(self) -> List[AssetType]: + """Get asset types supported by CoinMarketCap.""" + return [AssetType.CRYPTO] + + def _perform_health_check(self) -> Any: + """Perform health check by fetching API info.""" + try: + data = self._make_request("/key/info") + + if "data" in data: + return { + "status": "ok", + "plan": data["data"].get("plan", {}).get("name"), + "credits_left": data["data"] + .get("usage", {}) + .get("current_month", {}) + .get("credits_left"), + "credits_used": data["data"] + .get("usage", {}) + .get("current_month", {}) + .get("credits_used"), + } + else: + return {"status": "error", "message": "No data received"} + + except Exception as e: + return {"status": "error", "message": str(e)} + + def validate_ticker(self, ticker: str) -> bool: + """Validate if ticker is a cryptocurrency ticker.""" + try: + exchange, symbol = ticker.split(":", 1) + + # CoinMarketCap supports crypto tickers + supported_exchanges = ["CRYPTO", "BINANCE", "COINBASE"] + + return exchange in supported_exchanges + + except ValueError: + return False + + def get_symbol(self, ticker: str) -> str: + """Extract symbol from internal ticker format.""" + try: + return ticker.split(":", 1)[1] + except (ValueError, IndexError): + return ticker + + def get_global_metrics(self) -> Dict[str, Any]: + """Get global cryptocurrency market metrics.""" + try: + data = self._make_request("/global-metrics/quotes/latest") + return data.get("data", {}) + + except Exception as e: + logger.error(f"Error fetching global metrics: {e}") + return {} + + def get_trending_cryptocurrencies(self, limit: int = 10) -> List[AssetSearchResult]: + """Get trending cryptocurrencies by market cap.""" + try: + params = { + "start": 1, + "limit": limit, + "convert": "USD", + "sort": "market_cap", + "sort_dir": "desc", + } + + data = self._make_request("/cryptocurrency/listings/latest", params) + coins = data.get("data", []) + + results = [] + for coin in coins: + internal_ticker = f"CRYPTO:{coin['symbol']}" + + names = { + "en-US": coin["name"], + "zh-Hans": coin["name"], + } + + result = AssetSearchResult( + ticker=internal_ticker, + asset_type=AssetType.CRYPTO, + names=names, + exchange="CRYPTO", + country="GLOBAL", + currency="USD", + market_status=MarketStatus.OPEN, + relevance_score=1.0, + ) + + results.append(result) + + return results + + except Exception as e: + logger.error(f"Error fetching trending cryptocurrencies: {e}") + return [] + + def is_market_open(self, exchange: str) -> bool: + """Cryptocurrency markets are always open.""" + if exchange in ["CRYPTO", "BINANCE", "COINBASE"]: + return True + return False diff --git a/python/valuecell/adapters/assets/finnhub_adapter.py b/python/valuecell/adapters/assets/finnhub_adapter.py new file mode 100644 index 000000000..0e5b1977b --- /dev/null +++ b/python/valuecell/adapters/assets/finnhub_adapter.py @@ -0,0 +1,696 @@ +"""Finnhub adapter for global stock market data. + +This adapter provides integration with Finnhub API to fetch global stock market data, +including US stocks, international markets, company profiles, and financial metrics. +""" + +import logging +from typing import Dict, List, Optional, Any +from datetime import datetime +from decimal import Decimal +import requests +import time + +from .base import ( + BaseDataAdapter, + DataNotAvailableError, + AuthenticationError, + RateLimitError, +) +from .types import ( + Asset, + AssetPrice, + AssetSearchResult, + AssetSearchQuery, + DataSource, + AssetType, + MarketInfo, + LocalizedName, + MarketStatus, +) + +logger = logging.getLogger(__name__) + + +class FinnhubAdapter(BaseDataAdapter): + """Finnhub data adapter for global stock markets.""" + + def __init__(self, api_key: str, **kwargs): + """Initialize Finnhub adapter. + + Args: + api_key: Finnhub API key + **kwargs: Additional configuration parameters + """ + super().__init__(DataSource.FINNHUB, api_key, **kwargs) + + if not api_key: + raise AuthenticationError("Finnhub API key is required") + + def _initialize(self) -> None: + """Initialize Finnhub adapter configuration.""" + self.base_url = "https://finnhub.io/api/v1" + self.session = requests.Session() + + # Rate limiting + self.last_request_time = 0 + self.min_request_interval = ( + 1.0 # Minimum 1 second between requests for free tier + ) + + # Asset type mapping for Finnhub + self.asset_type_mapping = { + "Common Stock": AssetType.STOCK, + "ETF": AssetType.ETF, + "Mutual Fund": AssetType.MUTUAL_FUND, + "Index": AssetType.INDEX, + "Bond": AssetType.BOND, + } + + # Exchange mapping + self.exchange_mapping = { + "US": ["NASDAQ", "NYSE", "AMEX"], + "HK": ["HKEX"], + "CN": ["SSE", "SZSE"], + "JP": ["TSE"], + "GB": ["LSE"], + "DE": ["XETRA"], + } + + # Test connection + try: + self._perform_health_check() + logger.info("Finnhub adapter initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize Finnhub adapter: {e}") + raise AuthenticationError(f"Finnhub initialization failed: {e}") + + def _make_request( + self, endpoint: str, params: Optional[Dict] = None + ) -> Dict[str, Any]: + """Make rate-limited request to Finnhub API.""" + # Rate limiting + current_time = time.time() + time_since_last_request = current_time - self.last_request_time + if time_since_last_request < self.min_request_interval: + time.sleep(self.min_request_interval - time_since_last_request) + + url = f"{self.base_url}{endpoint}" + request_params = params or {} + request_params["token"] = self.api_key + + try: + response = self.session.get(url, params=request_params, timeout=30) + self.last_request_time = time.time() + + if response.status_code == 429: + # Rate limit exceeded + retry_after = int(response.headers.get("Retry-After", 60)) + raise RateLimitError( + f"Rate limit exceeded. Retry after {retry_after} seconds", + retry_after=retry_after, + source=self.source, + ) + elif response.status_code == 401: + raise AuthenticationError("Invalid API key", source=self.source) + elif response.status_code != 200: + raise DataNotAvailableError( + f"API request failed with status {response.status_code}: {response.text}", + source=self.source, + ) + + data = response.json() + + # Check for API errors + if isinstance(data, dict) and data.get("error"): + raise DataNotAvailableError( + f"API error: {data['error']}", source=self.source + ) + + return data + + except requests.RequestException as e: + raise DataNotAvailableError(f"Network error: {e}", source=self.source) + + def search_assets(self, query: AssetSearchQuery) -> List[AssetSearchResult]: + """Search for assets using Finnhub symbol lookup.""" + try: + results = [] + search_term = query.query.upper().strip() + + # Search US stocks + try: + data = self._make_request("/search", {"q": search_term}) + + if data and "result" in data: + for item in data["result"][: query.limit]: + try: + symbol = item.get("symbol", "") + description = item.get("description", "") + asset_type = item.get("type", "Common Stock") + + if not symbol or not description: + continue + + # Determine exchange and create internal ticker + exchange = self._determine_exchange(symbol) + internal_ticker = f"{exchange}:{symbol}" + + # Map asset type + mapped_asset_type = self.asset_type_mapping.get( + asset_type, AssetType.STOCK + ) + + # Create localized names + names = { + "en-US": description, + "en-GB": description, + "zh-Hans": description, # Could be enhanced with translation + "zh-Hant": description, + } + + # Calculate relevance score + relevance_score = self._calculate_relevance( + search_term, symbol, description + ) + + result = AssetSearchResult( + ticker=internal_ticker, + asset_type=mapped_asset_type, + names=names, + exchange=exchange, + country=self._get_country_for_exchange(exchange), + currency=self._get_currency_for_exchange(exchange), + market_status=MarketStatus.UNKNOWN, + relevance_score=relevance_score, + ) + + results.append(result) + + except Exception as e: + logger.warning(f"Error processing search result: {e}") + continue + + except Exception as e: + logger.error(f"Error searching symbols: {e}") + + # Apply filters + if query.asset_types: + results = [r for r in results if r.asset_type in query.asset_types] + + if query.exchanges: + results = [r for r in results if r.exchange in query.exchanges] + + if query.countries: + results = [r for r in results if r.country in query.countries] + + # Sort by relevance + results.sort(key=lambda x: x.relevance_score, reverse=True) + + return results[: query.limit] + + except Exception as e: + logger.error(f"Error searching assets: {e}") + return [] + + def _calculate_relevance( + self, search_term: str, symbol: str, description: str + ) -> float: + """Calculate relevance score for search results.""" + search_term_lower = search_term.lower() + symbol_lower = symbol.lower() + description_lower = description.lower() + + # Exact symbol match gets highest score + if search_term_lower == symbol_lower: + return 2.0 + + # Symbol starts with search term + if symbol_lower.startswith(search_term_lower): + return 1.8 + + # Description starts with search term + if description_lower.startswith(search_term_lower): + return 1.6 + + # Symbol contains search term + if search_term_lower in symbol_lower: + return 1.4 + + # Description contains search term + if search_term_lower in description_lower: + return 1.2 + + return 1.0 + + def _determine_exchange(self, symbol: str) -> str: + """Determine exchange from symbol format.""" + # Simple heuristics for exchange determination + if "." in symbol: + suffix = symbol.split(".")[-1] + if suffix == "HK": + return "HKEX" + elif suffix == "T": + return "TSE" + elif suffix == "L": + return "LSE" + elif suffix == "DE": + return "XETRA" + + # Default to NASDAQ for US symbols + return "NASDAQ" + + def _get_country_for_exchange(self, exchange: str) -> str: + """Get country code for exchange.""" + country_mapping = { + "NASDAQ": "US", + "NYSE": "US", + "AMEX": "US", + "HKEX": "HK", + "TSE": "JP", + "LSE": "GB", + "XETRA": "DE", + "SSE": "CN", + "SZSE": "CN", + } + return country_mapping.get(exchange, "US") + + def _get_currency_for_exchange(self, exchange: str) -> str: + """Get currency for exchange.""" + currency_mapping = { + "NASDAQ": "USD", + "NYSE": "USD", + "AMEX": "USD", + "HKEX": "HKD", + "TSE": "JPY", + "LSE": "GBP", + "XETRA": "EUR", + "SSE": "CNY", + "SZSE": "CNY", + } + return currency_mapping.get(exchange, "USD") + + def get_asset_info(self, ticker: str) -> Optional[Asset]: + """Get detailed asset information from Finnhub.""" + try: + exchange, symbol = ticker.split(":") + + # Get company profile + try: + profile_data = self._make_request("/stock/profile2", {"symbol": symbol}) + + if not profile_data: + return None + + # Create localized names + names = LocalizedName() + company_name = profile_data.get("name", symbol) + names.set_name("en-US", company_name) + names.set_name("en-GB", company_name) + names.set_name("zh-Hans", company_name) # Could be enhanced + names.set_name("zh-Hant", company_name) + + # Create market info + country = profile_data.get( + "country", self._get_country_for_exchange(exchange) + ) + currency = profile_data.get( + "currency", self._get_currency_for_exchange(exchange) + ) + + market_info = MarketInfo( + exchange=exchange, + country=country, + currency=currency, + timezone=self._get_timezone_for_country(country), + ) + + # Create asset + asset = Asset( + ticker=ticker, + asset_type=AssetType.STOCK, # Default to stock, could be enhanced + names=names, + market_info=market_info, + ) + + # Set source mapping + asset.set_source_ticker(self.source, symbol) + + # Add additional properties + properties = { + "country": profile_data.get("country"), + "currency": profile_data.get("currency"), + "exchange": profile_data.get("exchange"), + "ipo": profile_data.get("ipo"), + "market_capitalization": profile_data.get("marketCapitalization"), + "outstanding_shares": profile_data.get("shareOutstanding"), + "name": profile_data.get("name"), + "phone": profile_data.get("phone"), + "weburl": profile_data.get("weburl"), + "logo": profile_data.get("logo"), + "finnhub_industry": profile_data.get("finnhubIndustry"), + } + + # Filter out None values + properties = {k: v for k, v in properties.items() if v is not None} + asset.properties.update(properties) + + return asset + + except Exception as e: + logger.error(f"Error fetching company profile for {symbol}: {e}") + return None + + except Exception as e: + logger.error(f"Error getting asset info for {ticker}: {e}") + return None + + def _get_timezone_for_country(self, country: str) -> str: + """Get timezone for country.""" + timezone_mapping = { + "US": "America/New_York", + "HK": "Asia/Hong_Kong", + "JP": "Asia/Tokyo", + "GB": "Europe/London", + "DE": "Europe/Berlin", + "CN": "Asia/Shanghai", + } + return timezone_mapping.get(country, "America/New_York") + + def get_real_time_price(self, ticker: str) -> Optional[AssetPrice]: + """Get real-time price data from Finnhub.""" + try: + exchange, symbol = ticker.split(":") + + # Get real-time quote + try: + quote_data = self._make_request("/quote", {"symbol": symbol}) + + if not quote_data or "c" not in quote_data: + return None + + current_price = Decimal(str(quote_data["c"])) # Current price + open_price = Decimal(str(quote_data["o"])) # Open price + high_price = Decimal(str(quote_data["h"])) # High price + low_price = Decimal(str(quote_data["l"])) # Low price + previous_close = Decimal(str(quote_data["pc"])) # Previous close + + # Calculate change + change = current_price - previous_close + change_percent = ( + (change / previous_close) * 100 if previous_close else Decimal("0") + ) + + # Timestamp (Unix timestamp) + timestamp = ( + datetime.fromtimestamp(quote_data["t"]) + if quote_data.get("t") + else datetime.now() + ) + + return AssetPrice( + ticker=ticker, + price=current_price, + currency=self._get_currency_for_exchange(exchange), + timestamp=timestamp, + volume=None, # Volume not provided in basic quote + open_price=open_price, + high_price=high_price, + low_price=low_price, + close_price=current_price, + change=change, + change_percent=change_percent, + source=self.source, + ) + + except Exception as e: + logger.error(f"Error fetching quote for {symbol}: {e}") + return None + + except Exception as e: + logger.error(f"Error getting real-time price for {ticker}: {e}") + return None + + def get_historical_prices( + self, + ticker: str, + start_date: datetime, + end_date: datetime, + interval: str = "1d", + ) -> List[AssetPrice]: + """Get historical price data from Finnhub.""" + try: + exchange, symbol = ticker.split(":") + + # Convert dates to Unix timestamps + start_timestamp = int(start_date.timestamp()) + end_timestamp = int(end_date.timestamp()) + + # Map interval to Finnhub resolution + resolution_mapping = { + "1m": "1", + "5m": "5", + "15m": "15", + "30m": "30", + "1h": "60", + "1d": "D", + "daily": "D", + "1w": "W", + "1mo": "M", + } + + resolution = resolution_mapping.get(interval, "D") + + try: + # Get historical data (candles) + candle_data = self._make_request( + "/stock/candle", + { + "symbol": symbol, + "resolution": resolution, + "from": start_timestamp, + "to": end_timestamp, + }, + ) + + if not candle_data or candle_data.get("s") != "ok": + return [] + + # Extract data arrays + timestamps = candle_data.get("t", []) + opens = candle_data.get("o", []) + highs = candle_data.get("h", []) + lows = candle_data.get("l", []) + closes = candle_data.get("c", []) + volumes = candle_data.get("v", []) + + if not all([timestamps, opens, highs, lows, closes]): + return [] + + prices = [] + currency = self._get_currency_for_exchange(exchange) + + for i in range(len(timestamps)): + # Convert timestamp + trade_date = datetime.fromtimestamp(timestamps[i]) + + # Extract price data + open_price = Decimal(str(opens[i])) + high_price = Decimal(str(highs[i])) + low_price = Decimal(str(lows[i])) + close_price = Decimal(str(closes[i])) + volume = ( + Decimal(str(volumes[i])) + if i < len(volumes) and volumes[i] + else None + ) + + # Calculate change from previous day + change = None + change_percent = None + if i > 0: + prev_close = Decimal(str(closes[i - 1])) + change = close_price - prev_close + change_percent = ( + (change / prev_close) * 100 if prev_close else Decimal("0") + ) + + price = AssetPrice( + ticker=ticker, + price=close_price, + currency=currency, + timestamp=trade_date, + volume=volume, + open_price=open_price, + high_price=high_price, + low_price=low_price, + close_price=close_price, + change=change, + change_percent=change_percent, + source=self.source, + ) + prices.append(price) + + return prices + + except Exception as e: + logger.error(f"Error fetching historical data for {symbol}: {e}") + return [] + + except Exception as e: + logger.error(f"Error getting historical prices for {ticker}: {e}") + return [] + + def get_supported_asset_types(self) -> List[AssetType]: + """Get asset types supported by Finnhub.""" + return [ + AssetType.STOCK, + AssetType.ETF, + AssetType.MUTUAL_FUND, + AssetType.INDEX, + ] + + def _perform_health_check(self) -> Any: + """Perform health check by fetching API status.""" + try: + # Test with a simple quote request for AAPL + data = self._make_request("/quote", {"symbol": "AAPL"}) + + if data and "c" in data: + return { + "status": "ok", + "test_symbol": "AAPL", + "current_price": data["c"], + } + else: + return {"status": "error", "message": "No data received"} + + except Exception as e: + return {"status": "error", "message": str(e)} + + def validate_ticker(self, ticker: str) -> bool: + """Validate if ticker is supported by Finnhub.""" + try: + exchange, symbol = ticker.split(":", 1) + + # Finnhub supports major global exchanges + supported_exchanges = [ + "NASDAQ", + "NYSE", + "AMEX", # US + "HKEX", # Hong Kong + "TSE", # Tokyo + "LSE", # London + "XETRA", # Germany + ] + + return exchange in supported_exchanges + + except ValueError: + return False + + def get_company_news( + self, ticker: str, start_date: datetime, end_date: datetime + ) -> List[Dict[str, Any]]: + """Get company news from Finnhub.""" + try: + exchange, symbol = ticker.split(":") + + # Convert dates to YYYY-MM-DD format + start_date_str = start_date.strftime("%Y-%m-%d") + end_date_str = end_date.strftime("%Y-%m-%d") + + news_data = self._make_request( + "/company-news", + {"symbol": symbol, "from": start_date_str, "to": end_date_str}, + ) + + if not news_data: + return [] + + news_items = [] + for item in news_data: + news_item = { + "id": item.get("id"), + "category": item.get("category"), + "datetime": datetime.fromtimestamp(item.get("datetime", 0)), + "headline": item.get("headline"), + "image": item.get("image"), + "related": item.get("related"), + "source": item.get("source"), + "summary": item.get("summary"), + "url": item.get("url"), + } + news_items.append(news_item) + + return news_items + + except Exception as e: + logger.error(f"Error fetching company news for {ticker}: {e}") + return [] + + def get_basic_financials(self, ticker: str) -> Dict[str, Any]: + """Get basic financial metrics from Finnhub.""" + try: + exchange, symbol = ticker.split(":") + + financials_data = self._make_request( + "/stock/metric", {"symbol": symbol, "metric": "all"} + ) + + if not financials_data: + return {} + + # Extract key metrics + metrics = financials_data.get("metric", {}) + + return { + "market_cap": metrics.get("marketCapitalization"), + "pe_ratio": metrics.get("peBasicExclExtraTTM"), + "pb_ratio": metrics.get("pbQuarterly"), + "dividend_yield": metrics.get("dividendYieldIndicatedAnnual"), + "beta": metrics.get("beta"), + "eps_ttm": metrics.get("epsBasicExclExtraItemsTTM"), + "revenue_ttm": metrics.get("revenueTTM"), + "gross_margin": metrics.get("grossMarginTTM"), + "operating_margin": metrics.get("operatingMarginTTM"), + "net_margin": metrics.get("netProfitMarginTTM"), + "roe": metrics.get("roeTTM"), + "roa": metrics.get("roaTTM"), + "debt_to_equity": metrics.get("totalDebt/totalEquityQuarterly"), + "52_week_high": metrics.get("52WeekHigh"), + "52_week_low": metrics.get("52WeekLow"), + } + + except Exception as e: + logger.error(f"Error fetching basic financials for {ticker}: {e}") + return {} + + def is_market_open(self, exchange: str) -> bool: + """Check if a specific market is currently open.""" + now = datetime.utcnow() + hour = now.hour + weekday = now.weekday() + + # Skip weekends + if weekday >= 5: # Saturday = 5, Sunday = 6 + return False + + # Basic market hours (approximate) + if exchange in ["NASDAQ", "NYSE", "AMEX"]: + # US market hours: 9:30 AM - 4:00 PM EST = 14:30 - 21:00 UTC + return 14 <= hour < 21 + elif exchange == "HKEX": + # Hong Kong: 9:30 AM - 4:00 PM HKT = 1:30 - 8:00 UTC + return 1 <= hour < 8 + elif exchange == "TSE": + # Tokyo: 9:00 AM - 3:00 PM JST = 0:00 - 6:00 UTC + return 0 <= hour < 6 + elif exchange == "LSE": + # London: 8:00 AM - 4:30 PM GMT = 8:00 - 16:30 UTC + return 8 <= hour < 17 + elif exchange == "XETRA": + # Germany: 9:00 AM - 5:30 PM CET = 8:00 - 16:30 UTC + return 8 <= hour < 17 + + return False diff --git a/python/valuecell/adapters/assets/i18n_integration.py b/python/valuecell/adapters/assets/i18n_integration.py new file mode 100644 index 000000000..68efed8b6 --- /dev/null +++ b/python/valuecell/adapters/assets/i18n_integration.py @@ -0,0 +1,479 @@ +"""Integration with ValueCell i18n system for asset localization. + +This module provides integration with the existing i18n infrastructure to support +localized asset names, descriptions, and other text content. +""" + +import logging +from typing import Dict, List, Optional + +from ...i18n import get_i18n_service, t, get_i18n_config +from ...config.i18n import I18nConfig +from .types import Asset, AssetSearchResult, AssetType, MarketStatus +from .manager import AdapterManager + +logger = logging.getLogger(__name__) + + +class AssetI18nService: + """Service for handling asset internationalization.""" + + def __init__(self, adapter_manager: AdapterManager): + """Initialize asset i18n service. + + Args: + adapter_manager: Asset adapter manager instance + """ + self.adapter_manager = adapter_manager + self.i18n_service = get_i18n_service() + + # Cache for translated asset names + self._name_cache: Dict[str, Dict[str, str]] = {} # ticker -> language -> name + + # Known translations for common assets + self._predefined_translations = self._load_predefined_translations() + + logger.info("Asset i18n service initialized") + + def _load_predefined_translations(self) -> Dict[str, Dict[str, str]]: + """Load predefined translations for common assets. + + Returns: + Dictionary mapping tickers to language-name mappings + """ + return { + # US Tech Stocks + "NASDAQ:AAPL": { + "en-US": "Apple Inc.", + "en-GB": "Apple Inc.", + "zh-Hans": "苹果公司", + "zh-Hant": "蘋果公司", + "ja-JP": "アップル", + }, + "NASDAQ:MSFT": { + "en-US": "Microsoft Corporation", + "en-GB": "Microsoft Corporation", + "zh-Hans": "微软公司", + "zh-Hant": "微軟公司", + "ja-JP": "マイクロソフト", + }, + "NASDAQ:GOOGL": { + "en-US": "Alphabet Inc.", + "en-GB": "Alphabet Inc.", + "zh-Hans": "谷歌", + "zh-Hant": "谷歌", + "ja-JP": "アルファベット", + }, + "NASDAQ:AMZN": { + "en-US": "Amazon.com Inc.", + "en-GB": "Amazon.com Inc.", + "zh-Hans": "亚马逊", + "zh-Hant": "亞馬遜", + "ja-JP": "アマゾン", + }, + "NASDAQ:TSLA": { + "en-US": "Tesla Inc.", + "en-GB": "Tesla Inc.", + "zh-Hans": "特斯拉", + "zh-Hant": "特斯拉", + "ja-JP": "テスラ", + }, + "NASDAQ:META": { + "en-US": "Meta Platforms Inc.", + "en-GB": "Meta Platforms Inc.", + "zh-Hans": "Meta平台", + "zh-Hant": "Meta平台", + "ja-JP": "メタ・プラットフォームズ", + }, + "NASDAQ:NVDA": { + "en-US": "NVIDIA Corporation", + "en-GB": "NVIDIA Corporation", + "zh-Hans": "英伟达", + "zh-Hant": "輝達", + "ja-JP": "エヌビディア", + }, + "NYSE:JPM": { + "en-US": "JPMorgan Chase & Co", + "en-GB": "JPMorgan Chase & Co", + "zh-Hans": "摩根大通", + "zh-Hant": "摩根大通", + }, + "NYSE:JNJ": { + "en-US": "Johnson & Johnson", + "en-GB": "Johnson & Johnson", + "zh-Hans": "强生公司", + "zh-Hant": "強生公司", + }, + # Chinese Stocks + "SSE:600519": { + "en-US": "Kweichow Moutai Co Ltd", + "zh-Hans": "贵州茅台", + "zh-Hant": "貴州茅台", + }, + "SZSE:000858": { + "en-US": "Wuliangye Yibin Co Ltd", + "zh-Hans": "五粮液", + "zh-Hant": "五糧液", + }, + "SSE:600036": { + "en-US": "China Merchants Bank Co Ltd", + "zh-Hans": "招商银行", + "zh-Hant": "招商銀行", + }, + "SZSE:000001": { + "en-US": "Ping An Bank Co Ltd", + "zh-Hans": "平安银行", + "zh-Hant": "平安銀行", + }, + "HKEX:00700": { + "en-US": "Tencent Holdings Ltd", + "zh-Hans": "腾讯控股", + "zh-Hant": "騰訊控股", + "ja-JP": "テンセント", + }, + "HKEX:09988": { + "en-US": "Alibaba Group Holding Ltd", + "zh-Hans": "阿里巴巴集团", + "zh-Hant": "阿里巴巴集團", + "ja-JP": "アリババ", + }, + # Cryptocurrencies + "CRYPTO:BTC": { + "en-US": "Bitcoin", + "zh-Hans": "比特币", + "zh-Hant": "比特幣", + "ja-JP": "ビットコイン", + }, + "CRYPTO:ETH": { + "en-US": "Ethereum", + "zh-Hans": "以太坊", + "zh-Hant": "以太坊", + "ja-JP": "イーサリアム", + }, + "CRYPTO:USDT": { + "en-US": "Tether", + "zh-Hans": "泰达币", + "zh-Hant": "泰達幣", + "ja-JP": "テザー", + }, + "CRYPTO:BNB": { + "en-US": "Binance Coin", + "zh-Hans": "币安币", + "zh-Hant": "幣安幣", + "ja-JP": "バイナンスコイン", + }, + } + + def get_localized_asset_name( + self, ticker: str, language: Optional[str] = None + ) -> str: + """Get localized name for an asset. + + Args: + ticker: Asset ticker in internal format + language: Target language code (uses current i18n config if None) + + Returns: + Localized asset name or ticker if no translation available + """ + if language is None: + config = get_i18n_config() + language = config.language + + # Check cache first + if ticker in self._name_cache and language in self._name_cache[ticker]: + return self._name_cache[ticker][language] + + # Check predefined translations + if ticker in self._predefined_translations: + translations = self._predefined_translations[ticker] + if language in translations: + # Cache the result + if ticker not in self._name_cache: + self._name_cache[ticker] = {} + self._name_cache[ticker][language] = translations[language] + return translations[language] + + # Try to get from asset data + try: + asset = self.adapter_manager.get_asset_info(ticker) + if asset: + name = asset.get_localized_name(language) + if name: + # Cache the result + if ticker not in self._name_cache: + self._name_cache[ticker] = {} + self._name_cache[ticker][language] = name + return name + except Exception as e: + logger.warning(f"Could not fetch asset info for {ticker}: {e}") + + # Fallback to ticker + return ticker + + def localize_asset(self, asset: Asset, language: Optional[str] = None) -> Asset: + """Add localized names to an asset object. + + Args: + asset: Asset to localize + language: Target language (uses current i18n config if None) + + Returns: + Asset with localized names added + """ + if language is None: + config = get_i18n_config() + language = config.language + + # Check if we have predefined translations + if asset.ticker in self._predefined_translations: + translations = self._predefined_translations[asset.ticker] + for lang, name in translations.items(): + asset.set_localized_name(lang, name) + + return asset + + def localize_search_results( + self, results: List[AssetSearchResult], language: Optional[str] = None + ) -> List[AssetSearchResult]: + """Add localized names to search results. + + Args: + results: Search results to localize + language: Target language (uses current i18n config if None) + + Returns: + Search results with localized names + """ + if language is None: + config = get_i18n_config() + language = config.language + + for result in results: + localized_name = self.get_localized_asset_name(result.ticker, language) + if localized_name != result.ticker: + result.names[language] = localized_name + + return results + + def get_asset_type_display_name( + self, asset_type: AssetType, language: Optional[str] = None + ) -> str: + """Get localized display name for asset type. + + Args: + asset_type: Asset type + language: Target language (uses current i18n config if None) + + Returns: + Localized asset type name + """ + if language is None: + config = get_i18n_config() + language = config.language + + # Use i18n service to translate asset type + key = f"assets.types.{asset_type.value}" + return t(key, default=asset_type.value.replace("_", " ").title()) + + def get_market_status_display_name( + self, status: MarketStatus, language: Optional[str] = None + ) -> str: + """Get localized display name for market status. + + Args: + status: Market status + language: Target language (uses current i18n config if None) + + Returns: + Localized market status name + """ + if language is None: + config = get_i18n_config() + language = config.language + + # Use i18n service to translate market status + key = f"assets.market_status.{status.value}" + return t(key, default=status.value.replace("_", " ").title()) + + def format_currency_amount( + self, amount: float, currency: str, language: Optional[str] = None + ) -> str: + """Format currency amount according to locale. + + Args: + amount: Amount to format + currency: Currency code + language: Target language (uses current i18n config if None) + + Returns: + Formatted currency string + """ + if language is None: + config = get_i18n_config() + else: + config = I18nConfig(language=language) + + # Use the existing i18n currency formatting + if currency == "USD": + return f"${config.format_number(amount, 2)}" + elif currency == "CNY": + if language and language.startswith("zh"): + return f"¥{config.format_number(amount, 2)}" + else: + return f"CN¥{config.format_number(amount, 2)}" + elif currency == "HKD": + return f"HK${config.format_number(amount, 2)}" + elif currency == "JPY": + return f"¥{config.format_number(amount, 0)}" + elif currency == "EUR": + return f"€{config.format_number(amount, 2)}" + elif currency == "GBP": + return f"£{config.format_number(amount, 2)}" + else: + return f"{currency} {config.format_number(amount, 2)}" + + def format_percentage_change( + self, change_percent: float, language: Optional[str] = None + ) -> str: + """Format percentage change with appropriate styling. + + Args: + change_percent: Percentage change value + language: Target language (uses current i18n config if None) + + Returns: + Formatted percentage string + """ + if language is None: + config = get_i18n_config() + else: + config = I18nConfig(language=language) + + # Format with + or - sign + formatted_percent = config.format_number(abs(change_percent), 2) + + if change_percent > 0: + return f"+{formatted_percent}%" + elif change_percent < 0: + return f"-{formatted_percent}%" + else: + return f"{formatted_percent}%" + + def format_market_cap( + self, market_cap: float, currency: str = "USD", language: Optional[str] = None + ) -> str: + """Format market capitalization with appropriate units. + + Args: + market_cap: Market capitalization value + currency: Currency code + language: Target language (uses current i18n config if None) + + Returns: + Formatted market cap string + """ + if language is None: + config = get_i18n_config() + else: + config = I18nConfig(language=language) + + # Determine appropriate unit + if market_cap >= 1e12: # Trillion + value = market_cap / 1e12 + unit = t("units.trillion", default="T") + elif market_cap >= 1e9: # Billion + value = market_cap / 1e9 + unit = t("units.billion", default="B") + elif market_cap >= 1e6: # Million + value = market_cap / 1e6 + unit = t("units.million", default="M") + elif market_cap >= 1e3: # Thousand + value = market_cap / 1e3 + unit = t("units.thousand", default="K") + else: + value = market_cap + unit = "" + + formatted_value = config.format_number(value, 1 if value >= 10 else 2) + + # Format with currency + if currency == "USD": + return f"${formatted_value}{unit}" + elif currency == "CNY": + if language and language.startswith("zh"): + return f"¥{formatted_value}{unit}" + else: + return f"CN¥{formatted_value}{unit}" + else: + return f"{currency} {formatted_value}{unit}" + + def add_asset_translation(self, ticker: str, language: str, name: str) -> None: + """Add a custom translation for an asset. + + Args: + ticker: Asset ticker in internal format + language: Language code + name: Localized asset name + """ + if ticker not in self._predefined_translations: + self._predefined_translations[ticker] = {} + + self._predefined_translations[ticker][language] = name + + # Update cache + if ticker not in self._name_cache: + self._name_cache[ticker] = {} + self._name_cache[ticker][language] = name + + logger.info(f"Added translation for {ticker} in {language}: {name}") + + def clear_cache(self) -> None: + """Clear the translation cache.""" + self._name_cache.clear() + logger.info("Asset translation cache cleared") + + def get_available_languages_for_asset(self, ticker: str) -> List[str]: + """Get list of available languages for an asset. + + Args: + ticker: Asset ticker in internal format + + Returns: + List of available language codes + """ + languages = set() + + # Check predefined translations + if ticker in self._predefined_translations: + languages.update(self._predefined_translations[ticker].keys()) + + # Check asset data + try: + asset = self.adapter_manager.get_asset_info(ticker) + if asset: + languages.update(asset.names.get_available_languages()) + except Exception as e: + logger.warning(f"Could not fetch asset info for {ticker}: {e}") + + return list(languages) + + +# Global instance +_asset_i18n_service: Optional[AssetI18nService] = None + + +def get_asset_i18n_service() -> AssetI18nService: + """Get global asset i18n service instance.""" + global _asset_i18n_service + if _asset_i18n_service is None: + from .manager import get_adapter_manager + + _asset_i18n_service = AssetI18nService(get_adapter_manager()) + return _asset_i18n_service + + +def reset_asset_i18n_service() -> None: + """Reset global asset i18n service instance (mainly for testing).""" + global _asset_i18n_service + _asset_i18n_service = None diff --git a/python/valuecell/adapters/assets/manager.py b/python/valuecell/adapters/assets/manager.py new file mode 100644 index 000000000..21847266c --- /dev/null +++ b/python/valuecell/adapters/assets/manager.py @@ -0,0 +1,711 @@ +"""Asset adapter manager for coordinating multiple data sources. + +This module provides a unified interface for managing multiple data source adapters +and routing requests to the appropriate providers based on asset types and availability. +""" + +import logging +from typing import Dict, List, Optional, Any +from datetime import datetime +from concurrent.futures import ThreadPoolExecutor, as_completed +import threading + +from .base import BaseDataAdapter +from .types import ( + Asset, + AssetPrice, + AssetSearchResult, + AssetSearchQuery, + DataSource, + AssetType, + Watchlist, +) +from .yfinance_adapter import YFinanceAdapter +from .tushare_adapter import TuShareAdapter +from .coinmarketcap_adapter import CoinMarketCapAdapter +from .akshare_adapter import AKShareAdapter +from .finnhub_adapter import FinnhubAdapter + +logger = logging.getLogger(__name__) + + +class AdapterManager: + """Manager for coordinating multiple asset data adapters.""" + + def __init__(self): + """Initialize adapter manager.""" + self.adapters: Dict[DataSource, BaseDataAdapter] = {} + self.adapter_priorities: Dict[AssetType, List[DataSource]] = {} + self.lock = threading.RLock() + + # Default adapter priorities by asset type + self._set_default_priorities() + + logger.info("Asset adapter manager initialized") + + def _set_default_priorities(self) -> None: + """Set default adapter priorities for different asset types.""" + self.adapter_priorities = { + AssetType.STOCK: [ + DataSource.YFINANCE, + DataSource.FINNHUB, + DataSource.TUSHARE, + DataSource.AKSHARE, + ], + AssetType.ETF: [ + DataSource.YFINANCE, + DataSource.FINNHUB, + DataSource.AKSHARE, + ], + AssetType.CRYPTO: [DataSource.COINMARKETCAP, DataSource.YFINANCE], + AssetType.INDEX: [ + DataSource.YFINANCE, + DataSource.TUSHARE, + DataSource.AKSHARE, + ], + AssetType.FOREX: [DataSource.YFINANCE], + AssetType.BOND: [ + DataSource.TUSHARE, + DataSource.AKSHARE, + DataSource.YFINANCE, + ], + AssetType.MUTUAL_FUND: [DataSource.YFINANCE, DataSource.FINNHUB], + AssetType.COMMODITY: [DataSource.YFINANCE], + AssetType.OPTION: [DataSource.YFINANCE], + AssetType.FUTURE: [DataSource.YFINANCE], + } + + def register_adapter(self, adapter: BaseDataAdapter) -> None: + """Register a data adapter. + + Args: + adapter: Data adapter instance to register + """ + with self.lock: + self.adapters[adapter.source] = adapter + logger.info(f"Registered adapter: {adapter.source.value}") + + def unregister_adapter(self, source: DataSource) -> None: + """Unregister a data adapter. + + Args: + source: Data source to unregister + """ + with self.lock: + if source in self.adapters: + del self.adapters[source] + logger.info(f"Unregistered adapter: {source.value}") + + def configure_yfinance(self, **kwargs) -> None: + """Configure and register Yahoo Finance adapter.""" + try: + adapter = YFinanceAdapter(**kwargs) + self.register_adapter(adapter) + except Exception as e: + logger.error(f"Failed to configure Yahoo Finance adapter: {e}") + + def configure_tushare(self, api_key: str, **kwargs) -> None: + """Configure and register TuShare adapter. + + Args: + api_key: TuShare API key + **kwargs: Additional configuration + """ + try: + adapter = TuShareAdapter(api_key=api_key, **kwargs) + self.register_adapter(adapter) + except Exception as e: + logger.error(f"Failed to configure TuShare adapter: {e}") + + def configure_coinmarketcap(self, api_key: str, **kwargs) -> None: + """Configure and register CoinMarketCap adapter. + + Args: + api_key: CoinMarketCap API key + **kwargs: Additional configuration + """ + try: + adapter = CoinMarketCapAdapter(api_key=api_key, **kwargs) + self.register_adapter(adapter) + except Exception as e: + logger.error(f"Failed to configure CoinMarketCap adapter: {e}") + + def configure_akshare(self, **kwargs) -> None: + """Configure and register AKShare adapter. + + Args: + **kwargs: Additional configuration + """ + try: + adapter = AKShareAdapter(**kwargs) + self.register_adapter(adapter) + except Exception as e: + logger.error(f"Failed to configure AKShare adapter: {e}") + + def configure_finnhub(self, api_key: str, **kwargs) -> None: + """Configure and register Finnhub adapter. + + Args: + api_key: Finnhub API key + **kwargs: Additional configuration + """ + try: + adapter = FinnhubAdapter(api_key=api_key, **kwargs) + self.register_adapter(adapter) + except Exception as e: + logger.error(f"Failed to configure Finnhub adapter: {e}") + + def get_available_adapters(self) -> List[DataSource]: + """Get list of available data adapters.""" + with self.lock: + return list(self.adapters.keys()) + + def get_adapters_for_asset_type( + self, asset_type: AssetType + ) -> List[BaseDataAdapter]: + """Get prioritized list of adapters for an asset type. + + Args: + asset_type: Type of asset + + Returns: + List of adapters in priority order + """ + with self.lock: + priority_sources = self.adapter_priorities.get(asset_type, []) + adapters = [] + + for source in priority_sources: + if source in self.adapters: + adapters.append(self.adapters[source]) + + return adapters + + def get_adapter_for_ticker(self, ticker: str) -> Optional[BaseDataAdapter]: + """Get the best adapter for a specific ticker. + + Args: + ticker: Asset ticker in internal format + + Returns: + Best available adapter for the ticker + """ + with self.lock: + # Try to determine asset type from ticker + exchange = ticker.split(":")[0] if ":" in ticker else "" + + # Map exchanges to likely asset types + exchange_asset_mapping = { + "NASDAQ": AssetType.STOCK, + "NYSE": AssetType.STOCK, + "SSE": AssetType.STOCK, + "SZSE": AssetType.STOCK, + "HKEX": AssetType.STOCK, + "CRYPTO": AssetType.CRYPTO, + "BINANCE": AssetType.CRYPTO, + } + + asset_type = exchange_asset_mapping.get(exchange, AssetType.STOCK) + adapters = self.get_adapters_for_asset_type(asset_type) + + # Return first adapter that supports this ticker + for adapter in adapters: + if adapter.validate_ticker(ticker): + return adapter + + return None + + def search_assets(self, query: AssetSearchQuery) -> List[AssetSearchResult]: + """Search for assets across all available adapters. + + Args: + query: Search query parameters + + Returns: + Combined and deduplicated search results + """ + all_results = [] + + # Determine which adapters to use based on asset types + target_adapters = set() + + if query.asset_types: + for asset_type in query.asset_types: + target_adapters.update(self.get_adapters_for_asset_type(asset_type)) + else: + # Use all available adapters + with self.lock: + target_adapters.update(self.adapters.values()) + + # Search in parallel across adapters + with ThreadPoolExecutor(max_workers=len(target_adapters)) as executor: + future_to_adapter = { + executor.submit(adapter.search_assets, query): adapter + for adapter in target_adapters + } + + for future in as_completed(future_to_adapter): + adapter = future_to_adapter[future] + try: + results = future.result(timeout=30) # 30 second timeout + all_results.extend(results) + except Exception as e: + logger.warning( + f"Search failed for adapter {adapter.source.value}: {e}" + ) + + # Deduplicate results by ticker + seen_tickers = set() + unique_results = [] + + # Sort by relevance score first + all_results.sort(key=lambda x: x.relevance_score, reverse=True) + + for result in all_results: + if result.ticker not in seen_tickers: + seen_tickers.add(result.ticker) + unique_results.append(result) + + return unique_results[: query.limit] + + def get_asset_info(self, ticker: str) -> Optional[Asset]: + """Get detailed asset information. + + Args: + ticker: Asset ticker in internal format + + Returns: + Asset information or None if not found + """ + adapter = self.get_adapter_for_ticker(ticker) + if not adapter: + logger.warning(f"No suitable adapter found for ticker: {ticker}") + return None + + try: + return adapter.get_asset_info(ticker) + except Exception as e: + logger.error(f"Error fetching asset info for {ticker}: {e}") + return None + + def get_real_time_price(self, ticker: str) -> Optional[AssetPrice]: + """Get real-time price for an asset. + + Args: + ticker: Asset ticker in internal format + + Returns: + Current price data or None if not available + """ + adapter = self.get_adapter_for_ticker(ticker) + if not adapter: + logger.warning(f"No suitable adapter found for ticker: {ticker}") + return None + + try: + return adapter.get_real_time_price(ticker) + except Exception as e: + logger.error(f"Error fetching real-time price for {ticker}: {e}") + return None + + def get_multiple_prices( + self, tickers: List[str] + ) -> Dict[str, Optional[AssetPrice]]: + """Get real-time prices for multiple assets efficiently. + + Args: + tickers: List of asset tickers + + Returns: + Dictionary mapping tickers to price data + """ + # Group tickers by adapter + adapter_tickers: Dict[BaseDataAdapter, List[str]] = {} + + for ticker in tickers: + adapter = self.get_adapter_for_ticker(ticker) + if adapter: + if adapter not in adapter_tickers: + adapter_tickers[adapter] = [] + adapter_tickers[adapter].append(ticker) + + # Fetch prices in parallel from each adapter + all_results = {} + + with ThreadPoolExecutor(max_workers=len(adapter_tickers)) as executor: + future_to_adapter = { + executor.submit(adapter.get_multiple_prices, ticker_list): adapter + for adapter, ticker_list in adapter_tickers.items() + } + + for future in as_completed(future_to_adapter): + adapter = future_to_adapter[future] + try: + results = future.result(timeout=60) # 60 second timeout + all_results.update(results) + except Exception as e: + logger.warning( + f"Batch price fetch failed for adapter {adapter.source.value}: {e}" + ) + + # Ensure all requested tickers are in results + for ticker in tickers: + if ticker not in all_results: + all_results[ticker] = None + + return all_results + + def get_historical_prices( + self, + ticker: str, + start_date: datetime, + end_date: datetime, + interval: str = "1d", + ) -> List[AssetPrice]: + """Get historical price data for an asset. + + Args: + ticker: Asset ticker in internal format + start_date: Start date for historical data + end_date: End date for historical data + interval: Data interval + + Returns: + List of historical price data + """ + adapter = self.get_adapter_for_ticker(ticker) + if not adapter: + logger.warning(f"No suitable adapter found for ticker: {ticker}") + return [] + + try: + return adapter.get_historical_prices(ticker, start_date, end_date, interval) + except Exception as e: + logger.error(f"Error fetching historical prices for {ticker}: {e}") + return [] + + def health_check(self) -> Dict[DataSource, Dict[str, Any]]: + """Perform health check on all registered adapters. + + Returns: + Dictionary mapping data sources to health status + """ + health_results = {} + + with ThreadPoolExecutor(max_workers=len(self.adapters)) as executor: + future_to_source = { + executor.submit(adapter.health_check): source + for source, adapter in self.adapters.items() + } + + for future in as_completed(future_to_source): + source = future_to_source[future] + try: + result = future.result(timeout=30) + health_results[source] = result + except Exception as e: + health_results[source] = { + "status": "error", + "message": f"Health check failed: {e}", + "timestamp": datetime.utcnow().isoformat(), + } + + return health_results + + def get_supported_asset_types(self) -> Dict[DataSource, List[AssetType]]: + """Get supported asset types for each adapter. + + Returns: + Dictionary mapping data sources to supported asset types + """ + supported_types = {} + + with self.lock: + for source, adapter in self.adapters.items(): + try: + supported_types[source] = adapter.get_supported_asset_types() + except Exception as e: + logger.warning( + f"Error getting supported types for {source.value}: {e}" + ) + supported_types[source] = [] + + return supported_types + + def set_adapter_priority( + self, asset_type: AssetType, sources: List[DataSource] + ) -> None: + """Set adapter priority for an asset type. + + Args: + asset_type: Asset type to configure + sources: List of data sources in priority order + """ + with self.lock: + self.adapter_priorities[asset_type] = sources + logger.info( + f"Updated adapter priority for {asset_type.value}: {[s.value for s in sources]}" + ) + + +class WatchlistManager: + """Manager for user watchlists and portfolio tracking.""" + + def __init__(self, adapter_manager: AdapterManager): + """Initialize watchlist manager. + + Args: + adapter_manager: Asset adapter manager instance + """ + self.adapter_manager = adapter_manager + self.watchlists: Dict[ + str, Dict[str, Watchlist] + ] = {} # user_id -> watchlist_name -> Watchlist + self.lock = threading.RLock() + + logger.info("Watchlist manager initialized") + + def create_watchlist( + self, + user_id: str, + name: str = "My Watchlist", + description: str = "", + is_default: bool = False, + ) -> Watchlist: + """Create a new watchlist for a user. + + Args: + user_id: User identifier + name: Watchlist name + description: Watchlist description + is_default: Whether this is the default watchlist + + Returns: + Created watchlist + """ + with self.lock: + if user_id not in self.watchlists: + self.watchlists[user_id] = {} + + # If this is the first watchlist, make it default + if not self.watchlists[user_id]: + is_default = True + + # If setting as default, unset other defaults + if is_default: + for watchlist in self.watchlists[user_id].values(): + watchlist.is_default = False + + watchlist = Watchlist( + user_id=user_id, + name=name, + description=description, + is_default=is_default, + ) + + self.watchlists[user_id][name] = watchlist + logger.info(f"Created watchlist '{name}' for user {user_id}") + + return watchlist + + def get_watchlist(self, user_id: str, name: str) -> Optional[Watchlist]: + """Get a specific watchlist. + + Args: + user_id: User identifier + name: Watchlist name + + Returns: + Watchlist or None if not found + """ + with self.lock: + return self.watchlists.get(user_id, {}).get(name) + + def get_default_watchlist(self, user_id: str) -> Optional[Watchlist]: + """Get user's default watchlist. + + Args: + user_id: User identifier + + Returns: + Default watchlist or None if not found + """ + with self.lock: + user_watchlists = self.watchlists.get(user_id, {}) + + for watchlist in user_watchlists.values(): + if watchlist.is_default: + return watchlist + + # If no default found but user has watchlists, return first one + if user_watchlists: + return list(user_watchlists.values())[0] + + return None + + def get_user_watchlists(self, user_id: str) -> List[Watchlist]: + """Get all watchlists for a user. + + Args: + user_id: User identifier + + Returns: + List of user's watchlists + """ + with self.lock: + return list(self.watchlists.get(user_id, {}).values()) + + def add_asset_to_watchlist( + self, + user_id: str, + ticker: str, + watchlist_name: Optional[str] = None, + notes: str = "", + ) -> bool: + """Add an asset to a watchlist. + + Args: + user_id: User identifier + ticker: Asset ticker to add + watchlist_name: Watchlist name (uses default if None) + notes: User notes about the asset + + Returns: + True if added successfully, False otherwise + """ + with self.lock: + # Get watchlist + if watchlist_name: + watchlist = self.get_watchlist(user_id, watchlist_name) + else: + watchlist = self.get_default_watchlist(user_id) + + # Create default watchlist if none exists + if not watchlist: + watchlist = self.create_watchlist(user_id, is_default=True) + + if not watchlist: + logger.error(f"Could not find or create watchlist for user {user_id}") + return False + + # Validate ticker exists + asset_info = self.adapter_manager.get_asset_info(ticker) + if not asset_info: + logger.warning(f"Asset not found: {ticker}") + return False + + # Add to watchlist + watchlist.add_asset(ticker, notes) + logger.info( + f"Added {ticker} to watchlist '{watchlist.name}' for user {user_id}" + ) + + return True + + def remove_asset_from_watchlist( + self, user_id: str, ticker: str, watchlist_name: Optional[str] = None + ) -> bool: + """Remove an asset from a watchlist. + + Args: + user_id: User identifier + ticker: Asset ticker to remove + watchlist_name: Watchlist name (uses default if None) + + Returns: + True if removed successfully, False otherwise + """ + with self.lock: + # Get watchlist + if watchlist_name: + watchlist = self.get_watchlist(user_id, watchlist_name) + else: + watchlist = self.get_default_watchlist(user_id) + + if not watchlist: + return False + + success = watchlist.remove_asset(ticker) + if success: + logger.info( + f"Removed {ticker} from watchlist '{watchlist.name}' for user {user_id}" + ) + + return success + + def get_watchlist_prices( + self, user_id: str, watchlist_name: Optional[str] = None + ) -> Dict[str, Optional[AssetPrice]]: + """Get current prices for all assets in a watchlist. + + Args: + user_id: User identifier + watchlist_name: Watchlist name (uses default if None) + + Returns: + Dictionary mapping tickers to price data + """ + # Get watchlist + if watchlist_name: + watchlist = self.get_watchlist(user_id, watchlist_name) + else: + watchlist = self.get_default_watchlist(user_id) + + if not watchlist: + return {} + + tickers = watchlist.get_tickers() + if not tickers: + return {} + + return self.adapter_manager.get_multiple_prices(tickers) + + def delete_watchlist(self, user_id: str, name: str) -> bool: + """Delete a watchlist. + + Args: + user_id: User identifier + name: Watchlist name to delete + + Returns: + True if deleted successfully, False otherwise + """ + with self.lock: + if user_id not in self.watchlists: + return False + + if name not in self.watchlists[user_id]: + return False + + del self.watchlists[user_id][name] + logger.info(f"Deleted watchlist '{name}' for user {user_id}") + + return True + + +# Global instances +_adapter_manager: Optional[AdapterManager] = None +_watchlist_manager: Optional[WatchlistManager] = None + + +def get_adapter_manager() -> AdapterManager: + """Get global adapter manager instance.""" + global _adapter_manager + if _adapter_manager is None: + _adapter_manager = AdapterManager() + return _adapter_manager + + +def get_watchlist_manager() -> WatchlistManager: + """Get global watchlist manager instance.""" + global _watchlist_manager + if _watchlist_manager is None: + _watchlist_manager = WatchlistManager(get_adapter_manager()) + return _watchlist_manager + + +def reset_managers() -> None: + """Reset global manager instances (mainly for testing).""" + global _adapter_manager, _watchlist_manager + _adapter_manager = None + _watchlist_manager = None diff --git a/python/valuecell/adapters/assets/tushare_adapter.py b/python/valuecell/adapters/assets/tushare_adapter.py new file mode 100644 index 000000000..2d6d655bc --- /dev/null +++ b/python/valuecell/adapters/assets/tushare_adapter.py @@ -0,0 +1,517 @@ +"""TuShare adapter for Chinese stock market data. + +This adapter provides integration with TuShare API to fetch Chinese stock market data, +including A-shares, indices, and fundamental data. +""" + +import logging +from typing import Dict, List, Optional, Any +from datetime import datetime, timedelta +from decimal import Decimal + +try: + import tushare as ts +except ImportError: + ts = None + +from .base import BaseDataAdapter, AuthenticationError +from .types import ( + Asset, + AssetPrice, + AssetSearchResult, + AssetSearchQuery, + DataSource, + AssetType, + MarketInfo, + LocalizedName, + MarketStatus, +) + +logger = logging.getLogger(__name__) + + +class TuShareAdapter(BaseDataAdapter): + """TuShare data adapter for Chinese stock markets.""" + + def __init__(self, api_key: str, **kwargs): + """Initialize TuShare adapter. + + Args: + api_key: TuShare API token + **kwargs: Additional configuration parameters + """ + super().__init__(DataSource.TUSHARE, api_key, **kwargs) + + if ts is None: + raise ImportError( + "tushare library is required. Install with: pip install tushare" + ) + + if not api_key: + raise AuthenticationError("TuShare API key is required") + + def _initialize(self) -> None: + """Initialize TuShare adapter configuration.""" + try: + # Set TuShare token + ts.set_token(self.api_key) + self.pro = ts.pro_api() + + # Test connection + self.pro.query( + "stock_basic", + exchange="", + list_status="L", + fields="ts_code,symbol,name,area,industry,list_date", + ) + + logger.info("TuShare adapter initialized successfully") + + except Exception as e: + logger.error(f"Failed to initialize TuShare adapter: {e}") + raise AuthenticationError(f"TuShare initialization failed: {e}") + + def search_assets(self, query: AssetSearchQuery) -> List[AssetSearchResult]: + """Search for assets using TuShare stock basic info.""" + try: + results = [] + search_term = query.query.strip() + + # Get all stock basic info + df = self.pro.query( + "stock_basic", + exchange="", + list_status="L", + fields="ts_code,symbol,name,area,industry,market,exchange,list_date", + ) + + if df.empty: + return results + + # Search by symbol or name + mask = ( + df["symbol"].str.contains(search_term, case=False, na=False) + | df["name"].str.contains(search_term, case=False, na=False) + | df["ts_code"].str.contains(search_term, case=False, na=False) + ) + + matched_stocks = df[mask] + + for _, row in matched_stocks.iterrows(): + try: + # Convert TuShare code to internal format + ts_code = row["ts_code"] # Format: 000001.SZ + internal_ticker = self.convert_to_internal_ticker(ts_code) + + # Determine exchange + exchange_suffix = ts_code.split(".")[1] + exchange_mapping = {"SH": "SSE", "SZ": "SZSE"} + exchange = exchange_mapping.get(exchange_suffix, exchange_suffix) + + # Create localized names + names = { + "zh-Hans": row["name"], + "en-US": row["name"], # TuShare primarily has Chinese names + } + + result = AssetSearchResult( + ticker=internal_ticker, + asset_type=AssetType.STOCK, + names=names, + exchange=exchange, + country="CN", + currency="CNY", + market_status=MarketStatus.UNKNOWN, + relevance_score=1.0, + ) + + results.append(result) + + except Exception as e: + logger.warning( + f"Error processing search result for {row.get('ts_code')}: {e}" + ) + continue + + # Apply filters + if query.asset_types: + results = [r for r in results if r.asset_type in query.asset_types] + + if query.exchanges: + results = [r for r in results if r.exchange in query.exchanges] + + if query.countries: + results = [r for r in results if r.country in query.countries] + + return results[: query.limit] + + except Exception as e: + logger.error(f"Error searching assets: {e}") + return [] + + def get_asset_info(self, ticker: str) -> Optional[Asset]: + """Get detailed asset information from TuShare.""" + try: + source_ticker = self.convert_to_source_ticker(ticker) + + # Get basic stock info + df_basic = self.pro.query( + "stock_basic", + ts_code=source_ticker, + fields="ts_code,symbol,name,area,industry,market,exchange,curr_type,list_date,delist_date,is_hs", + ) + + if df_basic.empty: + return None + + stock_info = df_basic.iloc[0] + + # Create localized names + names = LocalizedName() + names.set_name("zh-Hans", stock_info["name"]) + names.set_name( + "en-US", stock_info["name"] + ) # Could be enhanced with translation + + # Determine exchange + exchange_suffix = source_ticker.split(".")[1] + exchange_mapping = {"SH": "SSE", "SZ": "SZSE"} + exchange = exchange_mapping.get(exchange_suffix, exchange_suffix) + + # Create market info + market_info = MarketInfo( + exchange=exchange, + country="CN", + currency=stock_info.get("curr_type", "CNY"), + timezone="Asia/Shanghai", + ) + + # Create asset + asset = Asset( + ticker=ticker, + asset_type=AssetType.STOCK, + names=names, + market_info=market_info, + ) + + # Set source mapping + asset.set_source_ticker(self.source, source_ticker) + + # Add additional properties + properties = { + "area": stock_info.get("area"), + "industry": stock_info.get("industry"), + "market": stock_info.get("market"), + "list_date": stock_info.get("list_date"), + "is_hs": stock_info.get("is_hs"), # Hong Kong-Shanghai Stock Connect + } + + # Get company info if available + try: + df_company = self.pro.query( + "stock_company", + ts_code=source_ticker, + fields="ts_code,chairman,manager,secretary,reg_capital,setup_date,province,city,introduction,website,email,office,employees,main_business,business_scope", + ) + + if not df_company.empty: + company_info = df_company.iloc[0] + properties.update( + { + "chairman": company_info.get("chairman"), + "manager": company_info.get("manager"), + "reg_capital": company_info.get("reg_capital"), + "setup_date": company_info.get("setup_date"), + "province": company_info.get("province"), + "city": company_info.get("city"), + "introduction": company_info.get("introduction"), + "website": company_info.get("website"), + "employees": company_info.get("employees"), + "main_business": company_info.get("main_business"), + } + ) + except Exception as e: + logger.warning(f"Could not fetch company info for {source_ticker}: {e}") + + # Filter out None values + properties = {k: v for k, v in properties.items() if v is not None} + asset.properties.update(properties) + + return asset + + except Exception as e: + logger.error(f"Error fetching asset info for {ticker}: {e}") + return None + + def get_real_time_price(self, ticker: str) -> Optional[AssetPrice]: + """Get real-time price data from TuShare.""" + try: + source_ticker = self.convert_to_source_ticker(ticker) + + # Get real-time quotes + df = self.pro.query( + "daily", + ts_code=source_ticker, + trade_date="", + start_date="", + end_date="", + ) + + if df.empty: + return None + + # Get the most recent trading day + latest_data = df.iloc[0] # TuShare returns data in descending order + + # Convert to AssetPrice + current_price = Decimal(str(latest_data["close"])) + open_price = Decimal(str(latest_data["open"])) + + # Calculate change + change = ( + Decimal(str(latest_data["change"])) + if latest_data["change"] + else Decimal("0") + ) + change_percent = ( + Decimal(str(latest_data["pct_chg"])) + if latest_data["pct_chg"] + else Decimal("0") + ) + + # Parse trade date + trade_date_str = str(latest_data["trade_date"]) + trade_date = datetime.strptime(trade_date_str, "%Y%m%d") + + return AssetPrice( + ticker=ticker, + price=current_price, + currency="CNY", + timestamp=trade_date, + volume=Decimal(str(latest_data["vol"])) if latest_data["vol"] else None, + open_price=open_price, + high_price=Decimal(str(latest_data["high"])), + low_price=Decimal(str(latest_data["low"])), + close_price=current_price, + change=change, + change_percent=change_percent, + source=self.source, + ) + + except Exception as e: + logger.error(f"Error fetching real-time price for {ticker}: {e}") + return None + + def get_historical_prices( + self, + ticker: str, + start_date: datetime, + end_date: datetime, + interval: str = "1d", + ) -> List[AssetPrice]: + """Get historical price data from TuShare.""" + try: + source_ticker = self.convert_to_source_ticker(ticker) + + # TuShare uses YYYYMMDD format + start_date_str = start_date.strftime("%Y%m%d") + end_date_str = end_date.strftime("%Y%m%d") + + # TuShare primarily supports daily data + if interval not in ["1d", "daily"]: + logger.warning( + f"TuShare primarily supports daily data. Requested interval: {interval}" + ) + + # Get daily price data + df = self.pro.query( + "daily", + ts_code=source_ticker, + start_date=start_date_str, + end_date=end_date_str, + ) + + if df.empty: + return [] + + # Sort by trade_date ascending + df = df.sort_values("trade_date") + + prices = [] + for _, row in df.iterrows(): + # Parse trade date + trade_date_str = str(row["trade_date"]) + trade_date = datetime.strptime(trade_date_str, "%Y%m%d") + + # Calculate change and change_percent + change = Decimal(str(row["change"])) if row["change"] else Decimal("0") + change_percent = ( + Decimal(str(row["pct_chg"])) if row["pct_chg"] else Decimal("0") + ) + + price = AssetPrice( + ticker=ticker, + price=Decimal(str(row["close"])), + currency="CNY", + timestamp=trade_date, + volume=Decimal(str(row["vol"])) if row["vol"] else None, + open_price=Decimal(str(row["open"])), + high_price=Decimal(str(row["high"])), + low_price=Decimal(str(row["low"])), + close_price=Decimal(str(row["close"])), + change=change, + change_percent=change_percent, + source=self.source, + ) + prices.append(price) + + return prices + + except Exception as e: + logger.error(f"Error fetching historical prices for {ticker}: {e}") + return [] + + def get_supported_asset_types(self) -> List[AssetType]: + """Get asset types supported by TuShare.""" + return [ + AssetType.STOCK, + AssetType.INDEX, + AssetType.ETF, + AssetType.BOND, + ] + + def _perform_health_check(self) -> Any: + """Perform health check by fetching stock basic info.""" + try: + # Test with a simple query + df = self.pro.query( + "stock_basic", + exchange="", + list_status="L", + fields="ts_code,symbol,name", + ) + + if not df.empty: + return { + "status": "ok", + "stocks_count": len(df), + "sample_stock": df.iloc[0]["ts_code"] if len(df) > 0 else None, + } + else: + return {"status": "error", "message": "No data received"} + + except Exception as e: + return {"status": "error", "message": str(e)} + + def validate_ticker(self, ticker: str) -> bool: + """Validate if ticker is supported by TuShare (Chinese markets only).""" + try: + exchange, symbol = ticker.split(":", 1) + + # TuShare supports Chinese exchanges + supported_exchanges = ["SSE", "SZSE"] + + return exchange in supported_exchanges + + except ValueError: + return False + + def get_market_calendar( + self, start_date: datetime, end_date: datetime + ) -> List[datetime]: + """Get trading calendar for Chinese markets.""" + try: + start_date_str = start_date.strftime("%Y%m%d") + end_date_str = end_date.strftime("%Y%m%d") + + df = self.pro.query( + "trade_cal", + exchange="SSE", + start_date=start_date_str, + end_date=end_date_str, + is_open="1", + ) + + if df.empty: + return [] + + trading_days = [] + for _, row in df.iterrows(): + trade_date = datetime.strptime(str(row["cal_date"]), "%Y%m%d") + trading_days.append(trade_date) + + return trading_days + + except Exception as e: + logger.error(f"Error fetching market calendar: {e}") + return [] + + def get_stock_financials( + self, ticker: str, year: Optional[int] = None + ) -> Dict[str, Any]: + """Get financial data for a stock.""" + try: + source_ticker = self.convert_to_source_ticker(ticker) + + # Get income statement + params = {"ts_code": source_ticker} + if year: + params["period"] = f"{year}1231" # Year-end + + financials = {} + + # Income statement + try: + df_income = self.pro.query("income", **params) + if not df_income.empty: + financials["income_statement"] = df_income.to_dict("records") + except Exception as e: + logger.warning(f"Could not fetch income statement: {e}") + + # Balance sheet + try: + df_balance = self.pro.query("balancesheet", **params) + if not df_balance.empty: + financials["balance_sheet"] = df_balance.to_dict("records") + except Exception as e: + logger.warning(f"Could not fetch balance sheet: {e}") + + # Cash flow + try: + df_cashflow = self.pro.query("cashflow", **params) + if not df_cashflow.empty: + financials["cash_flow"] = df_cashflow.to_dict("records") + except Exception as e: + logger.warning(f"Could not fetch cash flow: {e}") + + return financials + + except Exception as e: + logger.error(f"Error fetching financials for {ticker}: {e}") + return {} + + def is_market_open(self, exchange: str) -> bool: + """Check if Chinese market is currently open.""" + if exchange not in ["SSE", "SZSE"]: + return False + + # Chinese market hours: 9:30-11:30, 13:00-15:00 (GMT+8) + now = datetime.utcnow() + # Convert to Beijing time (UTC+8) + beijing_time = now.replace(tzinfo=None) + timedelta(hours=8) + + # Check if it's a weekday + if beijing_time.weekday() >= 5: # Saturday = 5, Sunday = 6 + return False + + # Check trading hours + current_time = beijing_time.time() + morning_open = datetime.strptime("09:30", "%H:%M").time() + morning_close = datetime.strptime("11:30", "%H:%M").time() + afternoon_open = datetime.strptime("13:00", "%H:%M").time() + afternoon_close = datetime.strptime("15:00", "%H:%M").time() + + return ( + morning_open <= current_time <= morning_close + or afternoon_open <= current_time <= afternoon_close + ) diff --git a/python/valuecell/adapters/assets/types.py b/python/valuecell/adapters/assets/types.py new file mode 100644 index 000000000..d73777355 --- /dev/null +++ b/python/valuecell/adapters/assets/types.py @@ -0,0 +1,352 @@ +"""Asset data types and structures for the ValueCell platform. + +This module defines the core data structures for representing financial assets +across different data sources and markets, with support for internationalization. +""" + +from datetime import datetime +from decimal import Decimal +from enum import Enum +from typing import Dict, List, Optional, Any +from dataclasses import dataclass, field +from pydantic import BaseModel, Field, validator + + +class AssetType(str, Enum): + """Enumeration of supported asset types.""" + + STOCK = "stock" + CRYPTO = "crypto" + ETF = "etf" + BOND = "bond" + COMMODITY = "commodity" + FOREX = "forex" + INDEX = "index" + MUTUAL_FUND = "mutual_fund" + OPTION = "option" + FUTURE = "future" + + +class MarketStatus(str, Enum): + """Market status enumeration.""" + + OPEN = "open" + CLOSED = "closed" + PRE_MARKET = "pre_market" + AFTER_HOURS = "after_hours" + HALTED = "halted" + UNKNOWN = "unknown" + + +class DataSource(str, Enum): + """Supported data source providers.""" + + TUSHARE = "tushare" + AKSHARE = "akshare" + YFINANCE = "yfinance" + FINNHUB = "finnhub" + COINMARKETCAP = "coinmarketcap" + BINANCE = "binance" + ALPHA_VANTAGE = "alpha_vantage" + + +@dataclass +class MarketInfo: + """Market information for an asset.""" + + exchange: str # Exchange identifier (e.g., "NASDAQ", "NYSE", "SSE", "BINANCE") + country: str # ISO country code (e.g., "US", "CN", "HK") + currency: str # Currency code (e.g., "USD", "CNY", "HKD", "BTC") + timezone: str # Market timezone (e.g., "America/New_York", "Asia/Shanghai") + trading_hours: Optional[Dict[str, str]] = None # Trading hours info + market_status: MarketStatus = MarketStatus.UNKNOWN + + +@dataclass +class LocalizedName: + """Localized names for an asset in different languages.""" + + names: Dict[str, str] = field(default_factory=dict) + + def get_name(self, language: str, fallback: str = "en-US") -> str: + """Get localized name for a specific language. + + Args: + language: Language code (e.g., 'zh-Hans', 'en-US') + fallback: Fallback language if requested language not available + + Returns: + Localized asset name + """ + return self.names.get(language, self.names.get(fallback, "")) + + def set_name(self, language: str, name: str) -> None: + """Set localized name for a specific language. + + Args: + language: Language code + name: Asset name in the specified language + """ + self.names[language] = name + + def get_available_languages(self) -> List[str]: + """Get list of available languages for this asset.""" + return list(self.names.keys()) + + +class Asset(BaseModel): + """Core asset data structure. + + This represents a financial asset in the ValueCell system with support + for multiple data sources and internationalization. + """ + + # Core identification + ticker: str = Field( + ..., description="Standardized ticker format: [EXCHANGE]:[SYMBOL]" + ) + asset_type: AssetType = Field(..., description="Type of financial asset") + + # Names and descriptions + names: LocalizedName = Field( + default_factory=LocalizedName, description="Localized asset names" + ) + descriptions: Dict[str, str] = Field( + default_factory=dict, description="Localized descriptions" + ) + + # Market information + market_info: MarketInfo = Field(..., description="Market and exchange information") + + # Data source mappings + source_mappings: Dict[DataSource, str] = Field( + default_factory=dict, + description="Mapping of data sources to their specific ticker formats", + ) + + # Metadata + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + is_active: bool = Field( + default=True, description="Whether asset is currently tradable" + ) + + # Additional properties + properties: Dict[str, Any] = Field( + default_factory=dict, description="Additional asset properties" + ) + + @validator("ticker") + def validate_ticker_format(cls, v): + """Validate ticker format: EXCHANGE:SYMBOL""" + if ":" not in v: + raise ValueError("Ticker must be in format 'EXCHANGE:SYMBOL'") + parts = v.split(":") + if len(parts) != 2 or not all(part.strip() for part in parts): + raise ValueError("Invalid ticker format. Expected 'EXCHANGE:SYMBOL'") + return v.upper() + + def get_exchange(self) -> str: + """Extract exchange from ticker.""" + return self.ticker.split(":")[0] + + def get_symbol(self) -> str: + """Extract symbol from ticker.""" + return self.ticker.split(":")[1] + + def get_localized_name(self, language: str = "en-US") -> str: + """Get asset name in specified language.""" + return self.names.get_name(language) + + def set_localized_name(self, language: str, name: str) -> None: + """Set asset name for specified language.""" + self.names.set_name(language, name) + self.updated_at = datetime.utcnow() + + def get_source_ticker(self, source: DataSource) -> Optional[str]: + """Get ticker format for specific data source.""" + return self.source_mappings.get(source) + + def set_source_ticker(self, source: DataSource, ticker: str) -> None: + """Set ticker format for specific data source.""" + self.source_mappings[source] = ticker + self.updated_at = datetime.utcnow() + + def add_property(self, key: str, value: Any) -> None: + """Add custom property to asset.""" + self.properties[key] = value + self.updated_at = datetime.utcnow() + + def get_property(self, key: str, default: Any = None) -> Any: + """Get custom property value.""" + return self.properties.get(key, default) + + +@dataclass +class AssetPrice: + """Real-time or historical price data for an asset.""" + + ticker: str + price: Decimal + currency: str + timestamp: datetime + volume: Optional[Decimal] = None + open_price: Optional[Decimal] = None + high_price: Optional[Decimal] = None + low_price: Optional[Decimal] = None + close_price: Optional[Decimal] = None + change: Optional[Decimal] = None + change_percent: Optional[Decimal] = None + market_cap: Optional[Decimal] = None + source: Optional[DataSource] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary representation.""" + return { + "ticker": self.ticker, + "price": float(self.price) if self.price else None, + "currency": self.currency, + "timestamp": self.timestamp.isoformat(), + "volume": float(self.volume) if self.volume else None, + "open_price": float(self.open_price) if self.open_price else None, + "high_price": float(self.high_price) if self.high_price else None, + "low_price": float(self.low_price) if self.low_price else None, + "close_price": float(self.close_price) if self.close_price else None, + "change": float(self.change) if self.change else None, + "change_percent": float(self.change_percent) + if self.change_percent + else None, + "market_cap": float(self.market_cap) if self.market_cap else None, + "source": self.source.value if self.source else None, + } + + +class WatchlistItem(BaseModel): + """Individual item in a user's watchlist.""" + + user_id: str = Field(..., description="User identifier") + ticker: str = Field(..., description="Asset ticker in standard format") + added_at: datetime = Field( + default_factory=datetime.utcnow, description="When added to watchlist" + ) + order: int = Field(default=0, description="Display order in watchlist") + notes: str = Field(default="", description="User's personal notes about this asset") + alerts: Dict[str, Any] = Field( + default_factory=dict, description="Price alerts and notifications" + ) + + class Config: + json_encoders = {datetime: lambda v: v.isoformat()} + + +class Watchlist(BaseModel): + """User's complete watchlist.""" + + user_id: str = Field(..., description="User identifier") + name: str = Field(default="My Watchlist", description="Watchlist name") + description: str = Field(default="", description="Watchlist description") + items: List[WatchlistItem] = Field( + default_factory=list, description="Assets in watchlist" + ) + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + is_default: bool = Field( + default=True, description="Whether this is the default watchlist" + ) + is_public: bool = Field( + default=False, description="Whether watchlist is publicly visible" + ) + + def add_asset( + self, ticker: str, notes: str = "", order: Optional[int] = None + ) -> None: + """Add asset to watchlist.""" + # Check if asset already exists + for item in self.items: + if item.ticker == ticker: + return # Asset already in watchlist + + # Determine order + if order is None: + order = len(self.items) + + # Create new watchlist item + item = WatchlistItem( + user_id=self.user_id, ticker=ticker, order=order, notes=notes + ) + + self.items.append(item) + self.updated_at = datetime.utcnow() + + def remove_asset(self, ticker: str) -> bool: + """Remove asset from watchlist. Returns True if removed, False if not found.""" + for i, item in enumerate(self.items): + if item.ticker == ticker: + del self.items[i] + self.updated_at = datetime.utcnow() + return True + return False + + def reorder_assets(self, ticker_order: List[str]) -> None: + """Reorder assets according to provided ticker list.""" + # Create a mapping of ticker to new order + order_map = {ticker: i for i, ticker in enumerate(ticker_order)} + + # Update order for existing items + for item in self.items: + if item.ticker in order_map: + item.order = order_map[item.ticker] + + # Sort items by order + self.items.sort(key=lambda x: x.order) + self.updated_at = datetime.utcnow() + + def get_tickers(self) -> List[str]: + """Get list of all tickers in watchlist.""" + return [item.ticker for item in sorted(self.items, key=lambda x: x.order)] + + def get_item(self, ticker: str) -> Optional[WatchlistItem]: + """Get watchlist item by ticker.""" + for item in self.items: + if item.ticker == ticker: + return item + return None + + class Config: + json_encoders = {datetime: lambda v: v.isoformat()} + + +class AssetSearchResult(BaseModel): + """Search result for asset lookup.""" + + ticker: str = Field(..., description="Standardized ticker") + asset_type: AssetType = Field(..., description="Asset type") + names: Dict[str, str] = Field(..., description="Asset names in different languages") + exchange: str = Field(..., description="Exchange name") + country: str = Field(..., description="Country code") + currency: str = Field(..., description="Currency code") + market_status: MarketStatus = Field(default=MarketStatus.UNKNOWN) + relevance_score: float = Field(default=0.0, description="Search relevance score") + + def get_display_name(self, language: str = "en-US") -> str: + """Get display name for specified language.""" + return self.names.get(language, self.names.get("en-US", self.ticker)) + + +class AssetSearchQuery(BaseModel): + """Asset search query parameters.""" + + query: str = Field(..., description="Search query string") + asset_types: Optional[List[AssetType]] = Field( + None, description="Filter by asset types" + ) + exchanges: Optional[List[str]] = Field(None, description="Filter by exchanges") + countries: Optional[List[str]] = Field(None, description="Filter by countries") + limit: int = Field(default=50, description="Maximum number of results") + language: str = Field(default="en-US", description="Preferred language for results") + + @validator("limit") + def validate_limit(cls, v): + if v <= 0 or v > 1000: + raise ValueError("Limit must be between 1 and 1000") + return v diff --git a/python/valuecell/adapters/assets/yfinance_adapter.py b/python/valuecell/adapters/assets/yfinance_adapter.py new file mode 100644 index 000000000..0500ebb77 --- /dev/null +++ b/python/valuecell/adapters/assets/yfinance_adapter.py @@ -0,0 +1,483 @@ +"""Yahoo Finance adapter for asset data. + +This adapter provides integration with Yahoo Finance API through the yfinance library +to fetch stock market data, including real-time prices and historical data. +""" + +import logging +from typing import Dict, List, Optional, Any +from datetime import datetime +from decimal import Decimal + +try: + import yfinance as yf +except ImportError: + yf = None + +from .base import BaseDataAdapter +from .types import ( + Asset, + AssetPrice, + AssetSearchResult, + AssetSearchQuery, + DataSource, + AssetType, + MarketInfo, + LocalizedName, + MarketStatus, +) + +logger = logging.getLogger(__name__) + + +class YFinanceAdapter(BaseDataAdapter): + """Yahoo Finance data adapter implementation.""" + + def __init__(self, **kwargs): + """Initialize Yahoo Finance adapter.""" + super().__init__(DataSource.YFINANCE, **kwargs) + + if yf is None: + raise ImportError( + "yfinance library is required. Install with: pip install yfinance" + ) + + def _initialize(self) -> None: + """Initialize Yahoo Finance adapter configuration.""" + self.session = None # yfinance handles sessions internally + self.timeout = self.config.get("timeout", 30) + + # Asset type mapping for Yahoo Finance + self.asset_type_mapping = { + "EQUITY": AssetType.STOCK, + "ETF": AssetType.ETF, + "MUTUALFUND": AssetType.MUTUAL_FUND, + "INDEX": AssetType.INDEX, + "CURRENCY": AssetType.FOREX, + "CRYPTOCURRENCY": AssetType.CRYPTO, + } + + logger.info("Yahoo Finance adapter initialized") + + def search_assets(self, query: AssetSearchQuery) -> List[AssetSearchResult]: + """Search for assets using Yahoo Finance. + + Note: Yahoo Finance doesn't have a direct search API, so this implementation + uses common ticker patterns and known symbols. For production use, consider + integrating with a dedicated search service. + """ + results = [] + search_term = query.query.upper().strip() + + # Try direct ticker lookup first + try: + ticker_obj = yf.Ticker(search_term) + info = ticker_obj.info + + if info and "symbol" in info: + result = self._create_search_result_from_info(info, query.language) + if result: + results.append(result) + except Exception as e: + logger.debug(f"Direct ticker lookup failed for {search_term}: {e}") + + # Try with common suffixes for international markets + if not results: + suffixes = [".SS", ".SZ", ".HK", ".T", ".L", ".PA", ".DE"] + for suffix in suffixes: + try: + test_ticker = f"{search_term}{suffix}" + ticker_obj = yf.Ticker(test_ticker) + info = ticker_obj.info + + if info and "symbol" in info: + result = self._create_search_result_from_info( + info, query.language + ) + if result: + results.append(result) + break # Found one, stop searching + except Exception: + continue + + # Filter by asset types if specified + if query.asset_types: + results = [r for r in results if r.asset_type in query.asset_types] + + # Filter by exchanges if specified + if query.exchanges: + results = [r for r in results if r.exchange in query.exchanges] + + # Filter by countries if specified + if query.countries: + results = [r for r in results if r.country in query.countries] + + return results[: query.limit] + + def _create_search_result_from_info( + self, info: Dict, language: str + ) -> Optional[AssetSearchResult]: + """Create search result from Yahoo Finance info dictionary.""" + try: + symbol = info.get("symbol", "") + if not symbol: + return None + + # Convert to internal ticker format + internal_ticker = self.convert_to_internal_ticker(symbol) + + # Get asset type + asset_type = self.asset_type_mapping.get( + info.get("quoteType", "").upper(), AssetType.STOCK + ) + + # Get exchange and country + exchange = info.get("exchange", "UNKNOWN") + country = info.get("country", "US") # Default to US + + # Get names in different languages + names = { + "en-US": info.get("longName", info.get("shortName", symbol)), + } + + # For Chinese markets, try to get Chinese name + if exchange in ["SSE", "SHE"] and language.startswith("zh"): + # This would require additional API calls or data sources + # For now, use English name as fallback + pass + + return AssetSearchResult( + ticker=internal_ticker, + asset_type=asset_type, + names=names, + exchange=exchange, + country=country, + currency=info.get("currency", "USD"), + market_status=MarketStatus.UNKNOWN, # Would need real-time data + relevance_score=1.0, # Simple relevance scoring + ) + + except Exception as e: + logger.error(f"Error creating search result: {e}") + return None + + def get_asset_info(self, ticker: str) -> Optional[Asset]: + """Get detailed asset information from Yahoo Finance.""" + try: + source_ticker = self.convert_to_source_ticker(ticker) + ticker_obj = yf.Ticker(source_ticker) + info = ticker_obj.info + + if not info or "symbol" not in info: + return None + + # Create localized names + names = LocalizedName() + long_name = info.get("longName", info.get("shortName", ticker)) + names.set_name("en-US", long_name) + + # Create market info + market_info = MarketInfo( + exchange=info.get("exchange", "UNKNOWN"), + country=info.get("country", "US"), + currency=info.get("currency", "USD"), + timezone=info.get("exchangeTimezoneName", "America/New_York"), + ) + + # Determine asset type + asset_type = self.asset_type_mapping.get( + info.get("quoteType", "").upper(), AssetType.STOCK + ) + + # Create asset object + asset = Asset( + ticker=ticker, + asset_type=asset_type, + names=names, + market_info=market_info, + ) + + # Set source mapping + asset.set_source_ticker(self.source, source_ticker) + + # Add additional properties + properties = { + "sector": info.get("sector"), + "industry": info.get("industry"), + "market_cap": info.get("marketCap"), + "pe_ratio": info.get("trailingPE"), + "dividend_yield": info.get("dividendYield"), + "beta": info.get("beta"), + "website": info.get("website"), + "business_summary": info.get("longBusinessSummary"), + } + + # Filter out None values + properties = {k: v for k, v in properties.items() if v is not None} + asset.properties.update(properties) + + return asset + + except Exception as e: + logger.error(f"Error fetching asset info for {ticker}: {e}") + return None + + def get_real_time_price(self, ticker: str) -> Optional[AssetPrice]: + """Get real-time price data from Yahoo Finance.""" + try: + source_ticker = self.convert_to_source_ticker(ticker) + ticker_obj = yf.Ticker(source_ticker) + + # Get current data + data = ticker_obj.history(period="1d", interval="1m") + if data.empty: + return None + + # Get the most recent data point + latest = data.iloc[-1] + info = ticker_obj.info + + # Calculate change + current_price = Decimal(str(latest["Close"])) + previous_close = Decimal(str(info.get("previousClose", latest["Close"]))) + change = current_price - previous_close + change_percent = ( + (change / previous_close) * 100 if previous_close else Decimal("0") + ) + + return AssetPrice( + ticker=ticker, + price=current_price, + currency=info.get("currency", "USD"), + timestamp=latest.name.to_pydatetime(), + volume=Decimal(str(latest["Volume"])) if latest["Volume"] else None, + open_price=Decimal(str(latest["Open"])), + high_price=Decimal(str(latest["High"])), + low_price=Decimal(str(latest["Low"])), + close_price=current_price, + change=change, + change_percent=change_percent, + market_cap=Decimal(str(info["marketCap"])) + if info.get("marketCap") + else None, + source=self.source, + ) + + except Exception as e: + logger.error(f"Error fetching real-time price for {ticker}: {e}") + return None + + def get_historical_prices( + self, + ticker: str, + start_date: datetime, + end_date: datetime, + interval: str = "1d", + ) -> List[AssetPrice]: + """Get historical price data from Yahoo Finance.""" + try: + source_ticker = self.convert_to_source_ticker(ticker) + ticker_obj = yf.Ticker(source_ticker) + + # Map interval to Yahoo Finance format + interval_mapping = { + "1m": "1m", + "2m": "2m", + "5m": "5m", + "15m": "15m", + "30m": "30m", + "60m": "60m", + "90m": "90m", + "1h": "1h", + "1d": "1d", + "5d": "5d", + "1w": "1wk", + "1mo": "1mo", + "3mo": "3mo", + } + yf_interval = interval_mapping.get(interval, "1d") + + # Fetch historical data + data = ticker_obj.history( + start=start_date.strftime("%Y-%m-%d"), + end=end_date.strftime("%Y-%m-%d"), + interval=yf_interval, + ) + + if data.empty: + return [] + + # Get currency from ticker info + info = ticker_obj.info + currency = info.get("currency", "USD") + + prices = [] + for timestamp, row in data.iterrows(): + # Calculate change from previous day + change = None + change_percent = None + + if len(prices) > 0: + prev_close = prices[-1].close_price + change = Decimal(str(row["Close"])) - prev_close + change_percent = ( + (change / prev_close) * 100 if prev_close else Decimal("0") + ) + + price = AssetPrice( + ticker=ticker, + price=Decimal(str(row["Close"])), + currency=currency, + timestamp=timestamp.to_pydatetime(), + volume=Decimal(str(row["Volume"])) if row["Volume"] else None, + open_price=Decimal(str(row["Open"])), + high_price=Decimal(str(row["High"])), + low_price=Decimal(str(row["Low"])), + close_price=Decimal(str(row["Close"])), + change=change, + change_percent=change_percent, + source=self.source, + ) + prices.append(price) + + return prices + + except Exception as e: + logger.error(f"Error fetching historical prices for {ticker}: {e}") + return [] + + def get_multiple_prices( + self, tickers: List[str] + ) -> Dict[str, Optional[AssetPrice]]: + """Get real-time prices for multiple assets efficiently.""" + try: + # Convert to source tickers + source_tickers = [self.convert_to_source_ticker(t) for t in tickers] + + # Use yfinance's download function for batch requests + data = yf.download( + source_tickers, period="1d", interval="1m", group_by="ticker" + ) + + results = {} + + for i, ticker in enumerate(tickers): + try: + source_ticker = source_tickers[i] + + if len(source_tickers) == 1: + # Single ticker case + ticker_data = data + else: + # Multiple tickers case + ticker_data = data[source_ticker] + + if ticker_data.empty: + results[ticker] = None + continue + + # Get the most recent data point + latest = ticker_data.iloc[-1] + + # Get additional info for currency and market cap + ticker_obj = yf.Ticker(source_ticker) + info = ticker_obj.info + + current_price = Decimal(str(latest["Close"])) + previous_close = Decimal( + str(info.get("previousClose", latest["Close"])) + ) + change = current_price - previous_close + change_percent = ( + (change / previous_close) * 100 + if previous_close + else Decimal("0") + ) + + results[ticker] = AssetPrice( + ticker=ticker, + price=current_price, + currency=info.get("currency", "USD"), + timestamp=latest.name.to_pydatetime(), + volume=Decimal(str(latest["Volume"])) + if latest["Volume"] + else None, + open_price=Decimal(str(latest["Open"])), + high_price=Decimal(str(latest["High"])), + low_price=Decimal(str(latest["Low"])), + close_price=current_price, + change=change, + change_percent=change_percent, + market_cap=Decimal(str(info["marketCap"])) + if info.get("marketCap") + else None, + source=self.source, + ) + + except Exception as e: + logger.error(f"Error processing ticker {ticker}: {e}") + results[ticker] = None + + return results + + except Exception as e: + logger.error(f"Error fetching multiple prices: {e}") + # Fallback to individual requests + return super().get_multiple_prices(tickers) + + def get_supported_asset_types(self) -> List[AssetType]: + """Get asset types supported by Yahoo Finance.""" + return [ + AssetType.STOCK, + AssetType.ETF, + AssetType.MUTUAL_FUND, + AssetType.INDEX, + AssetType.FOREX, + AssetType.CRYPTO, + AssetType.OPTION, + AssetType.FUTURE, + ] + + def _perform_health_check(self) -> Any: + """Perform health check by fetching a known ticker.""" + try: + # Test with Apple stock + ticker_obj = yf.Ticker("AAPL") + info = ticker_obj.info + + if info and "symbol" in info: + return { + "status": "ok", + "test_ticker": "AAPL", + "response_received": True, + } + else: + return {"status": "error", "message": "No data received"} + + except Exception as e: + return {"status": "error", "message": str(e)} + + def validate_ticker(self, ticker: str) -> bool: + """Validate if ticker is supported by Yahoo Finance.""" + try: + exchange, symbol = ticker.split(":", 1) + + # Yahoo Finance supports most major exchanges + supported_exchanges = [ + "NASDAQ", + "NYSE", + "AMEX", # US + "SSE", + "SZSE", # China + "HKEX", # Hong Kong + "TSE", # Tokyo + "LSE", # London + "EURONEXT", # Europe + "TSX", # Toronto + "ASX", # Australia + ] + + return exchange in supported_exchanges + + except ValueError: + return False diff --git a/python/valuecell/examples/asset_adapter_example.py b/python/valuecell/examples/asset_adapter_example.py new file mode 100644 index 000000000..9eb49fbb9 --- /dev/null +++ b/python/valuecell/examples/asset_adapter_example.py @@ -0,0 +1,328 @@ +"""Example usage of the ValueCell Asset Data Adapter system. + +This example demonstrates how to configure and use the asset data adapters +for financial data retrieval, search, and watchlist management with i18n support. +""" + +import logging + +from valuecell.adapters.assets import ( + get_adapter_manager, + get_asset_api, + search_assets, + get_asset_info, + get_asset_price, + add_to_watchlist, + get_watchlist, +) +from valuecell.i18n import set_i18n_config, I18nConfig + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def setup_adapters(): + """Configure and initialize data adapters.""" + logger.info("Setting up data adapters...") + + # Get adapter manager + manager = get_adapter_manager() + + # Configure Yahoo Finance (free, no API key required) + try: + manager.configure_yfinance() + logger.info("✓ Yahoo Finance adapter configured") + except Exception as e: + logger.warning(f"✗ Yahoo Finance adapter failed: {e}") + + # Configure TuShare (requires API key) + try: + # Replace with your actual TuShare API key + tushare_api_key = "your_tushare_api_key_here" + if tushare_api_key != "your_tushare_api_key_here": + manager.configure_tushare(api_key=tushare_api_key) + logger.info("✓ TuShare adapter configured") + else: + logger.warning("✗ TuShare API key not provided, skipping") + except Exception as e: + logger.warning(f"✗ TuShare adapter failed: {e}") + + # Configure CoinMarketCap (requires API key for crypto data) + try: + # Replace with your actual CoinMarketCap API key + cmc_api_key = "your_coinmarketcap_api_key_here" + if cmc_api_key != "your_coinmarketcap_api_key_here": + manager.configure_coinmarketcap(api_key=cmc_api_key) + logger.info("✓ CoinMarketCap adapter configured") + else: + logger.warning("✗ CoinMarketCap API key not provided, skipping") + except Exception as e: + logger.warning(f"✗ CoinMarketCap adapter failed: {e}") + + # Configure AKShare (free, no API key required) + try: + manager.configure_akshare() + logger.info("✓ AKShare adapter configured") + except Exception as e: + logger.warning(f"✗ AKShare adapter failed: {e}") + + # Configure Finnhub (requires API key) + try: + # Replace with your actual Finnhub API key + finnhub_api_key = "your_finnhub_api_key_here" + if finnhub_api_key != "your_finnhub_api_key_here": + manager.configure_finnhub(api_key=finnhub_api_key) + logger.info("✓ Finnhub adapter configured") + else: + logger.warning("✗ Finnhub API key not provided, skipping") + except Exception as e: + logger.warning(f"✗ Finnhub adapter failed: {e}") + + # Check system health + api = get_asset_api() + health = api.get_system_health() + logger.info( + f"System health: {health['overall_status']} " + f"({health['healthy_adapters']}/{health['total_adapters']} adapters)" + ) + + return manager + + +def demonstrate_asset_search(): + """Demonstrate asset search functionality with i18n.""" + logger.info("\n=== Asset Search Demo ===") + + # Search in English + logger.info("Searching for 'AAPL' in English...") + results_en = search_assets("AAPL", language="en-US", limit=5) + + if results_en["success"]: + logger.info(f"Found {results_en['count']} results:") + for result in results_en["results"]: + logger.info( + f" - {result['ticker']}: {result['display_name']} " + f"({result['asset_type_display']})" + ) + + # Search in Chinese + logger.info("\nSearching for 'Apple' in Chinese...") + results_zh = search_assets("Apple", language="zh-Hans", limit=5) + + if results_zh["success"]: + logger.info(f"找到 {results_zh['count']} 个结果:") + for result in results_zh["results"]: + logger.info( + f" - {result['ticker']}: {result['display_name']} " + f"({result['asset_type_display']})" + ) + + # Search for Chinese stocks + logger.info("\nSearching for Chinese stocks...") + results_cn = search_assets("茅台", asset_types=["stock"], limit=3) + + if results_cn["success"]: + logger.info(f"Found {results_cn['count']} Chinese stocks:") + for result in results_cn["results"]: + logger.info(f" - {result['ticker']}: {result['display_name']}") + + # Search for cryptocurrencies + logger.info("\nSearching for cryptocurrencies...") + results_crypto = search_assets("Bitcoin", asset_types=["crypto"], limit=3) + + if results_crypto["success"]: + logger.info(f"Found {results_crypto['count']} cryptocurrencies:") + for result in results_crypto["results"]: + logger.info(f" - {result['ticker']}: {result['display_name']}") + + +def demonstrate_asset_info(): + """Demonstrate getting detailed asset information.""" + logger.info("\n=== Asset Information Demo ===") + + # Get info for Apple stock + tickers = ["NASDAQ:AAPL", "SSE:600519", "CRYPTO:BTC"] + + for ticker in tickers: + logger.info(f"\nGetting info for {ticker}...") + + # Get in English + info_en = get_asset_info(ticker, language="en-US") + if info_en["success"]: + logger.info( + f" English: {info_en['display_name']} " + f"({info_en['asset_type_display']})" + ) + logger.info(f" Exchange: {info_en['market_info']['exchange']}") + logger.info(f" Country: {info_en['market_info']['country']}") + + # Get in Chinese + info_zh = get_asset_info(ticker, language="zh-Hans") + if info_zh["success"]: + logger.info( + f" 中文: {info_zh['display_name']} ({info_zh['asset_type_display']})" + ) + + +def demonstrate_price_data(): + """Demonstrate real-time price data retrieval.""" + logger.info("\n=== Price Data Demo ===") + + tickers = ["NASDAQ:AAPL", "NASDAQ:MSFT", "NASDAQ:GOOGL"] + + # Get individual price + logger.info("Getting individual price for AAPL...") + price_data = get_asset_price("NASDAQ:AAPL", language="zh-Hans") + + if price_data["success"]: + logger.info(f" 价格: {price_data['price_formatted']}") + if price_data["change_percent_formatted"]: + logger.info(f" 涨跌幅: {price_data['change_percent_formatted']}") + if price_data["market_cap_formatted"]: + logger.info(f" 市值: {price_data['market_cap_formatted']}") + + # Get multiple prices + logger.info(f"\nGetting prices for multiple assets: {tickers}") + api = get_asset_api() + prices_data = api.get_multiple_prices(tickers, language="en-US") + + if prices_data["success"]: + logger.info(f"Successfully retrieved {prices_data['count']} prices:") + for ticker, price_info in prices_data["prices"].items(): + if price_info: + logger.info( + f" {ticker}: {price_info['price_formatted']} " + f"({price_info.get('change_percent_formatted', 'N/A')})" + ) + else: + logger.info(f" {ticker}: Price not available") + + +def demonstrate_watchlist_management(): + """Demonstrate watchlist creation and management.""" + logger.info("\n=== Watchlist Management Demo ===") + + user_id = "demo_user_123" + api = get_asset_api() + + # Create a watchlist + logger.info("Creating a new watchlist...") + create_result = api.create_watchlist( + user_id=user_id, + name="My Tech Stocks", + description="Technology companies I'm watching", + is_default=True, + ) + + if create_result["success"]: + logger.info("✓ Watchlist created successfully") + + # Add assets to watchlist + assets_to_add = [ + ("NASDAQ:AAPL", "Apple - iPhone maker"), + ("NASDAQ:MSFT", "Microsoft - Cloud and software"), + ("NASDAQ:GOOGL", "Google - Search and ads"), + ("NASDAQ:TSLA", "Tesla - Electric vehicles"), + ] + + logger.info("Adding assets to watchlist...") + for ticker, notes in assets_to_add: + result = add_to_watchlist(user_id=user_id, ticker=ticker, notes=notes) + if result["success"]: + logger.info(f" ✓ Added {ticker}") + else: + logger.warning(f" ✗ Failed to add {ticker}: {result.get('error')}") + + # Get watchlist with prices + logger.info("\nRetrieving watchlist with current prices...") + watchlist_data = get_watchlist( + user_id=user_id, include_prices=True, language="zh-Hans" + ) + + if watchlist_data["success"]: + watchlist = watchlist_data["watchlist"] + logger.info(f"观察列表: {watchlist['name']}") + logger.info(f"资产数量: {watchlist['items_count']}") + + for asset in watchlist["assets"]: + display_name = asset["display_name"] + notes = asset["notes"] + + price_info = "" + if "price_data" in asset and asset["price_data"]: + price_data = asset["price_data"] + price_info = f" - {price_data['price_formatted']}" + if price_data.get("change_percent_formatted"): + price_info += f" ({price_data['change_percent_formatted']})" + + logger.info(f" • {display_name}{price_info}") + if notes: + logger.info(f" 备注: {notes}") + + # List all user watchlists + logger.info("\nListing all user watchlists...") + all_watchlists = api.get_user_watchlists(user_id) + + if all_watchlists["success"]: + logger.info(f"用户 {user_id} 有 {all_watchlists['count']} 个观察列表:") + for wl in all_watchlists["watchlists"]: + default_marker = " (默认)" if wl["is_default"] else "" + logger.info(f" • {wl['name']}{default_marker} - {wl['items_count']} 资产") + + +def demonstrate_i18n_features(): + """Demonstrate internationalization features.""" + logger.info("\n=== Internationalization Demo ===") + + # Test different languages + languages = ["en-US", "zh-Hans", "zh-Hant"] + ticker = "NASDAQ:AAPL" + + for lang in languages: + logger.info(f"\nTesting language: {lang}") + + # Set language configuration + config = I18nConfig(language=lang) + set_i18n_config(config) + + # Search for assets + results = search_assets("Apple", language=lang, limit=1) + if results["success"] and results["results"]: + result = results["results"][0] + logger.info( + f" 搜索结果: {result['display_name']} ({result['asset_type_display']})" + ) + + # Get price with localized formatting + price_data = get_asset_price(ticker, language=lang) + if price_data["success"]: + logger.info(f" 价格: {price_data['price_formatted']}") + if price_data.get("change_percent_formatted"): + logger.info(f" 涨跌: {price_data['change_percent_formatted']}") + + +def main(): + """Main demonstration function.""" + logger.info("=== ValueCell Asset Data Adapter Demo ===") + + try: + # Setup adapters + setup_adapters() + + # Run demonstrations + demonstrate_asset_search() + demonstrate_asset_info() + demonstrate_price_data() + demonstrate_watchlist_management() + demonstrate_i18n_features() + + logger.info("\n=== Demo completed successfully! ===") + + except Exception as e: + logger.error(f"Demo failed with error: {e}") + raise + + +if __name__ == "__main__": + main() From 523638508bc34fdfc62c6ecd75ca6be4aeac0ae9 Mon Sep 17 00:00:00 2001 From: hazeone <709547807@qq.com> Date: Fri, 5 Sep 2025 10:57:37 +0800 Subject: [PATCH 2/6] lint --- .../valuecell/adapters/assets/i18n_integration.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/python/valuecell/adapters/assets/i18n_integration.py b/python/valuecell/adapters/assets/i18n_integration.py index 68efed8b6..4ef3b078c 100644 --- a/python/valuecell/adapters/assets/i18n_integration.py +++ b/python/valuecell/adapters/assets/i18n_integration.py @@ -48,49 +48,42 @@ def _load_predefined_translations(self) -> Dict[str, Dict[str, str]]: "en-GB": "Apple Inc.", "zh-Hans": "苹果公司", "zh-Hant": "蘋果公司", - "ja-JP": "アップル", }, "NASDAQ:MSFT": { "en-US": "Microsoft Corporation", "en-GB": "Microsoft Corporation", "zh-Hans": "微软公司", "zh-Hant": "微軟公司", - "ja-JP": "マイクロソフト", }, "NASDAQ:GOOGL": { "en-US": "Alphabet Inc.", "en-GB": "Alphabet Inc.", "zh-Hans": "谷歌", "zh-Hant": "谷歌", - "ja-JP": "アルファベット", }, "NASDAQ:AMZN": { "en-US": "Amazon.com Inc.", "en-GB": "Amazon.com Inc.", "zh-Hans": "亚马逊", "zh-Hant": "亞馬遜", - "ja-JP": "アマゾン", }, "NASDAQ:TSLA": { "en-US": "Tesla Inc.", "en-GB": "Tesla Inc.", "zh-Hans": "特斯拉", "zh-Hant": "特斯拉", - "ja-JP": "テスラ", }, "NASDAQ:META": { "en-US": "Meta Platforms Inc.", "en-GB": "Meta Platforms Inc.", "zh-Hans": "Meta平台", "zh-Hant": "Meta平台", - "ja-JP": "メタ・プラットフォームズ", }, "NASDAQ:NVDA": { "en-US": "NVIDIA Corporation", "en-GB": "NVIDIA Corporation", "zh-Hans": "英伟达", "zh-Hant": "輝達", - "ja-JP": "エヌビディア", }, "NYSE:JPM": { "en-US": "JPMorgan Chase & Co", @@ -129,38 +122,32 @@ def _load_predefined_translations(self) -> Dict[str, Dict[str, str]]: "en-US": "Tencent Holdings Ltd", "zh-Hans": "腾讯控股", "zh-Hant": "騰訊控股", - "ja-JP": "テンセント", }, "HKEX:09988": { "en-US": "Alibaba Group Holding Ltd", "zh-Hans": "阿里巴巴集团", "zh-Hant": "阿里巴巴集團", - "ja-JP": "アリババ", }, # Cryptocurrencies "CRYPTO:BTC": { "en-US": "Bitcoin", "zh-Hans": "比特币", "zh-Hant": "比特幣", - "ja-JP": "ビットコイン", }, "CRYPTO:ETH": { "en-US": "Ethereum", "zh-Hans": "以太坊", "zh-Hant": "以太坊", - "ja-JP": "イーサリアム", }, "CRYPTO:USDT": { "en-US": "Tether", "zh-Hans": "泰达币", "zh-Hant": "泰達幣", - "ja-JP": "テザー", }, "CRYPTO:BNB": { "en-US": "Binance Coin", "zh-Hans": "币安币", "zh-Hant": "幣安幣", - "ja-JP": "バイナンスコイン", }, } From 3366c20edd2e75a16e8e8e4a3560d4e1515a144c Mon Sep 17 00:00:00 2001 From: hazeone <709547807@qq.com> Date: Mon, 8 Sep 2025 09:31:03 +0800 Subject: [PATCH 3/6] debug yfinance source --- .vscode/launch.json | 15 ++++ .vscode/settings.json | 3 + .../adapters/assets/akshare_adapter.py | 4 +- python/valuecell/adapters/assets/base.py | 48 +++++++++- .../adapters/assets/coinmarketcap_adapter.py | 4 +- python/valuecell/adapters/assets/manager.py | 15 +--- python/valuecell/adapters/assets/types.py | 19 ++-- .../adapters/assets/yfinance_adapter.py | 88 ++++++++++++++----- python/valuecell/api/router/i18n.py | 20 +++++ .../examples/asset_adapter_example.py | 48 +++++----- 10 files changed, 190 insertions(+), 74 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 python/valuecell/api/router/i18n.py diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..fa3485c60 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debug", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "python": "${workspaceFolder}/python/.venv/bin/python", + "args": "${command:pickArgs}" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..082b19437 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "makefile.configureOnOpen": false +} \ No newline at end of file diff --git a/python/valuecell/adapters/assets/akshare_adapter.py b/python/valuecell/adapters/assets/akshare_adapter.py index 769c49222..df931ceab 100644 --- a/python/valuecell/adapters/assets/akshare_adapter.py +++ b/python/valuecell/adapters/assets/akshare_adapter.py @@ -55,7 +55,7 @@ def _initialize(self) -> None: self.asset_type_mapping = { "stock": AssetType.STOCK, "fund": AssetType.ETF, - "bond": AssetType.BOND, + # "bond": AssetType.BOND, "index": AssetType.INDEX, } @@ -484,7 +484,7 @@ def get_supported_asset_types(self) -> List[AssetType]: return [ AssetType.STOCK, AssetType.ETF, - AssetType.BOND, + # AssetType.BOND, AssetType.INDEX, ] diff --git a/python/valuecell/adapters/assets/base.py b/python/valuecell/adapters/assets/base.py index 60c1a1867..b8869edcc 100644 --- a/python/valuecell/adapters/assets/base.py +++ b/python/valuecell/adapters/assets/base.py @@ -56,7 +56,6 @@ def __init__(self): }, DataSource.COINMARKETCAP: { "CRYPTO": "", # Crypto symbols are used as-is - "BINANCE": "", # Binance symbols are used as-is }, } @@ -78,6 +77,36 @@ def to_source_format(self, internal_ticker: str, source: DataSource) -> str: try: exchange, symbol = internal_ticker.split(":", 1) + # Special handling for crypto tickers in yfinance + if exchange == "CRYPTO" and source == DataSource.YFINANCE: + # Map common crypto symbols to yfinance format + crypto_mapping = { + "BTC": "BTC-USD", + "ETH": "ETH-USD", + "ADA": "ADA-USD", + "DOT": "DOT-USD", + "SOL": "SOL-USD", + "MATIC": "MATIC-USD", + "LINK": "LINK-USD", + "UNI": "UNI-USD", + "AVAX": "AVAX-USD", + "ATOM": "ATOM-USD", + } + return crypto_mapping.get(symbol, f"{symbol}-USD") + + # Special handling for Hong Kong stocks in yfinance + if exchange == "HKEX" and source == DataSource.YFINANCE: + # Hong Kong stock codes need to be padded to 4 digits + # e.g., "700" -> "0700.HK", "1234" -> "1234.HK" + if symbol.isdigit(): + padded_symbol = symbol.zfill( + 4 + ) # Pad with leading zeros to 4 digits + return f"{padded_symbol}.HK" + else: + # For non-numeric symbols, use as-is with .HK suffix + return f"{symbol}.HK" + if source not in self.exchange_mappings: logger.warning(f"No mapping found for data source: {source}") return symbol @@ -106,6 +135,19 @@ def to_internal_format( Ticker in internal format (e.g., "SZSE:000001") """ try: + # Special handling for Hong Kong stocks from yfinance + if source == DataSource.YFINANCE and source_ticker.endswith(".HK"): + symbol = source_ticker[:-3] # Remove .HK suffix + # Remove leading zeros for internal format (0700 -> 700) + if symbol.isdigit(): + symbol = str(int(symbol)) # This removes leading zeros + return f"HKEX:{symbol}" + + # Special handling for crypto from yfinance + if source == DataSource.YFINANCE and "-USD" in source_ticker: + crypto_symbol = source_ticker.replace("-USD", "") + return f"CRYPTO:{crypto_symbol}" + # Check for known suffixes if source in self.reverse_mappings: for suffix, exchange in self.reverse_mappings[source].items(): @@ -122,8 +164,6 @@ def to_internal_format( # For crypto and other assets without clear exchange mapping if source == DataSource.COINMARKETCAP: return f"CRYPTO:{source_ticker}" - elif source == DataSource.BINANCE: - return f"BINANCE:{source_ticker}" # Fallback to using the source as exchange return f"{source.value.upper()}:{source_ticker}" @@ -290,7 +330,7 @@ def is_market_open(self, exchange: str) -> bool: return 1 <= hour < 7 # For crypto markets, assume always open - elif exchange in ["CRYPTO", "BINANCE"]: + elif exchange in ["CRYPTO"]: return True return False diff --git a/python/valuecell/adapters/assets/coinmarketcap_adapter.py b/python/valuecell/adapters/assets/coinmarketcap_adapter.py index 2d42091ee..bc9711612 100644 --- a/python/valuecell/adapters/assets/coinmarketcap_adapter.py +++ b/python/valuecell/adapters/assets/coinmarketcap_adapter.py @@ -404,7 +404,7 @@ def validate_ticker(self, ticker: str) -> bool: exchange, symbol = ticker.split(":", 1) # CoinMarketCap supports crypto tickers - supported_exchanges = ["CRYPTO", "BINANCE", "COINBASE"] + supported_exchanges = ["CRYPTO"] return exchange in supported_exchanges @@ -472,6 +472,6 @@ def get_trending_cryptocurrencies(self, limit: int = 10) -> List[AssetSearchResu def is_market_open(self, exchange: str) -> bool: """Cryptocurrency markets are always open.""" - if exchange in ["CRYPTO", "BINANCE", "COINBASE"]: + if exchange in ["CRYPTO"]: return True return False diff --git a/python/valuecell/adapters/assets/manager.py b/python/valuecell/adapters/assets/manager.py index 21847266c..8ffcd1d11 100644 --- a/python/valuecell/adapters/assets/manager.py +++ b/python/valuecell/adapters/assets/manager.py @@ -57,22 +57,16 @@ def _set_default_priorities(self) -> None: DataSource.FINNHUB, DataSource.AKSHARE, ], - AssetType.CRYPTO: [DataSource.COINMARKETCAP, DataSource.YFINANCE], - AssetType.INDEX: [ + AssetType.CRYPTO: [ + DataSource.COINMARKETCAP, DataSource.YFINANCE, - DataSource.TUSHARE, DataSource.AKSHARE, ], - AssetType.FOREX: [DataSource.YFINANCE], - AssetType.BOND: [ + AssetType.INDEX: [ + DataSource.YFINANCE, DataSource.TUSHARE, DataSource.AKSHARE, - DataSource.YFINANCE, ], - AssetType.MUTUAL_FUND: [DataSource.YFINANCE, DataSource.FINNHUB], - AssetType.COMMODITY: [DataSource.YFINANCE], - AssetType.OPTION: [DataSource.YFINANCE], - AssetType.FUTURE: [DataSource.YFINANCE], } def register_adapter(self, adapter: BaseDataAdapter) -> None: @@ -202,7 +196,6 @@ def get_adapter_for_ticker(self, ticker: str) -> Optional[BaseDataAdapter]: "SZSE": AssetType.STOCK, "HKEX": AssetType.STOCK, "CRYPTO": AssetType.CRYPTO, - "BINANCE": AssetType.CRYPTO, } asset_type = exchange_asset_mapping.get(exchange, AssetType.STOCK) diff --git a/python/valuecell/adapters/assets/types.py b/python/valuecell/adapters/assets/types.py index d73777355..7fafab615 100644 --- a/python/valuecell/adapters/assets/types.py +++ b/python/valuecell/adapters/assets/types.py @@ -13,18 +13,21 @@ class AssetType(str, Enum): - """Enumeration of supported asset types.""" + """Enumeration of supported asset types. + + The other asset types are not supported yet. + """ STOCK = "stock" CRYPTO = "crypto" ETF = "etf" - BOND = "bond" - COMMODITY = "commodity" - FOREX = "forex" + # BOND = "bond" + # COMMODITY = "commodity" + # FOREX = "forex" INDEX = "index" - MUTUAL_FUND = "mutual_fund" - OPTION = "option" - FUTURE = "future" + # MUTUAL_FUND = "mutual_fund" + # OPTION = "option" + # FUTURE = "future" class MarketStatus(str, Enum): @@ -54,7 +57,7 @@ class DataSource(str, Enum): class MarketInfo: """Market information for an asset.""" - exchange: str # Exchange identifier (e.g., "NASDAQ", "NYSE", "SSE", "BINANCE") + exchange: str # Exchange identifier (e.g., "NASDAQ", "NYSE", "SSE", "CRYPTO") country: str # ISO country code (e.g., "US", "CN", "HK") currency: str # Currency code (e.g., "USD", "CNY", "HKD", "BTC") timezone: str # Market timezone (e.g., "America/New_York", "Asia/Shanghai") diff --git a/python/valuecell/adapters/assets/yfinance_adapter.py b/python/valuecell/adapters/assets/yfinance_adapter.py index 0500ebb77..02ab6157b 100644 --- a/python/valuecell/adapters/assets/yfinance_adapter.py +++ b/python/valuecell/adapters/assets/yfinance_adapter.py @@ -51,9 +51,7 @@ def _initialize(self) -> None: self.asset_type_mapping = { "EQUITY": AssetType.STOCK, "ETF": AssetType.ETF, - "MUTUALFUND": AssetType.MUTUAL_FUND, "INDEX": AssetType.INDEX, - "CURRENCY": AssetType.FOREX, "CRYPTOCURRENCY": AssetType.CRYPTO, } @@ -65,6 +63,7 @@ def search_assets(self, query: AssetSearchQuery) -> List[AssetSearchResult]: Note: Yahoo Finance doesn't have a direct search API, so this implementation uses common ticker patterns and known symbols. For production use, consider integrating with a dedicated search service. + TODO: Implement a dedicated search service. """ results = [] search_term = query.query.upper().strip() @@ -354,10 +353,28 @@ def get_multiple_prices( # Convert to source tickers source_tickers = [self.convert_to_source_ticker(t) for t in tickers] - # Use yfinance's download function for batch requests - data = yf.download( - source_tickers, period="1d", interval="1m", group_by="ticker" - ) + # Try minute data first, then fall back to daily data + data = None + for interval, period in [("1m", "1d"), ("1d", "5d")]: + try: + data = yf.download( + source_tickers, + period=period, + interval=interval, + group_by="ticker", + ) + if not data.empty: + break + logger.warning(f"No data with {interval} interval, trying next...") + except Exception as e: + logger.warning( + f"Failed to fetch data with {interval} interval: {e}" + ) + continue + + if data is None or data.empty: + logger.error("Failed to fetch data with all intervals") + return {ticker: None for ticker in tickers} results = {} @@ -379,18 +396,48 @@ def get_multiple_prices( # Get the most recent data point latest = ticker_data.iloc[-1] + # Check if we have valid price data + import pandas as pd + + if pd.isna(latest["Close"]) or latest["Close"] is None: + # Try to find the most recent valid data point + valid_data = ticker_data.dropna(subset=["Close"]) + if valid_data.empty: + logger.warning(f"No valid price data found for {ticker}") + results[ticker] = None + continue + latest = valid_data.iloc[-1] + # Get additional info for currency and market cap ticker_obj = yf.Ticker(source_ticker) info = ticker_obj.info - current_price = Decimal(str(latest["Close"])) - previous_close = Decimal( - str(info.get("previousClose", latest["Close"])) + # Safe Decimal conversion with NaN check + def safe_decimal(value, default=None): + if pd.isna(value) or value is None: + return default + try: + return Decimal(str(float(value))) + except (ValueError, TypeError, OverflowError): + return default + + current_price = safe_decimal(latest["Close"]) + if current_price is None: + logger.warning(f"Invalid price data for {ticker}") + results[ticker] = None + continue + + previous_close = safe_decimal( + info.get("previousClose"), current_price + ) + change = ( + current_price - previous_close + if previous_close + else Decimal("0") ) - change = current_price - previous_close change_percent = ( (change / previous_close) * 100 - if previous_close + if previous_close and previous_close != 0 else Decimal("0") ) @@ -399,18 +446,14 @@ def get_multiple_prices( price=current_price, currency=info.get("currency", "USD"), timestamp=latest.name.to_pydatetime(), - volume=Decimal(str(latest["Volume"])) - if latest["Volume"] - else None, - open_price=Decimal(str(latest["Open"])), - high_price=Decimal(str(latest["High"])), - low_price=Decimal(str(latest["Low"])), + volume=safe_decimal(latest["Volume"]), + open_price=safe_decimal(latest["Open"]), + high_price=safe_decimal(latest["High"]), + low_price=safe_decimal(latest["Low"]), close_price=current_price, change=change, change_percent=change_percent, - market_cap=Decimal(str(info["marketCap"])) - if info.get("marketCap") - else None, + market_cap=safe_decimal(info.get("marketCap")), source=self.source, ) @@ -430,12 +473,8 @@ def get_supported_asset_types(self) -> List[AssetType]: return [ AssetType.STOCK, AssetType.ETF, - AssetType.MUTUAL_FUND, AssetType.INDEX, - AssetType.FOREX, AssetType.CRYPTO, - AssetType.OPTION, - AssetType.FUTURE, ] def _perform_health_check(self) -> Any: @@ -475,6 +514,7 @@ def validate_ticker(self, ticker: str) -> bool: "EURONEXT", # Europe "TSX", # Toronto "ASX", # Australia + "CRYPTO", # Crypto ] return exchange in supported_exchanges diff --git a/python/valuecell/api/router/i18n.py b/python/valuecell/api/router/i18n.py new file mode 100644 index 000000000..7baaf5427 --- /dev/null +++ b/python/valuecell/api/router/i18n.py @@ -0,0 +1,20 @@ +"""I18n router module for ValueCell API.""" + +from fastapi import APIRouter +from ..i18n_api import create_i18n_router + + +def get_i18n_router() -> APIRouter: + """Get i18n router instance. + + This function creates and returns an i18n router that can be included + in the main FastAPI application. + + Returns: + APIRouter: The configured i18n router + """ + return create_i18n_router() + + +# Export the router function +__all__ = ["get_i18n_router"] diff --git a/python/valuecell/examples/asset_adapter_example.py b/python/valuecell/examples/asset_adapter_example.py index 9eb49fbb9..1945c4046 100644 --- a/python/valuecell/examples/asset_adapter_example.py +++ b/python/valuecell/examples/asset_adapter_example.py @@ -107,8 +107,8 @@ def demonstrate_asset_search(): ) # Search in Chinese - logger.info("\nSearching for 'Apple' in Chinese...") - results_zh = search_assets("Apple", language="zh-Hans", limit=5) + logger.info("\nSearching for '00700.HK' in Chinese...") + results_zh = search_assets("00700.HK", language="zh-Hans", limit=5) if results_zh["success"]: logger.info(f"找到 {results_zh['count']} 个结果:") @@ -120,7 +120,7 @@ def demonstrate_asset_search(): # Search for Chinese stocks logger.info("\nSearching for Chinese stocks...") - results_cn = search_assets("茅台", asset_types=["stock"], limit=3) + results_cn = search_assets("600519", asset_types=["stock"], limit=3) if results_cn["success"]: logger.info(f"Found {results_cn['count']} Chinese stocks:") @@ -129,7 +129,7 @@ def demonstrate_asset_search(): # Search for cryptocurrencies logger.info("\nSearching for cryptocurrencies...") - results_crypto = search_assets("Bitcoin", asset_types=["crypto"], limit=3) + results_crypto = search_assets("BTC-USD", asset_types=["crypto"], limit=3) if results_crypto["success"]: logger.info(f"Found {results_crypto['count']} cryptocurrencies:") @@ -142,7 +142,7 @@ def demonstrate_asset_info(): logger.info("\n=== Asset Information Demo ===") # Get info for Apple stock - tickers = ["NASDAQ:AAPL", "SSE:600519", "CRYPTO:BTC"] + tickers = ["NASDAQ:AAPL", "HKEX:700", "SSE:600519", "CRYPTO:BTC"] for ticker in tickers: logger.info(f"\nGetting info for {ticker}...") @@ -169,18 +169,18 @@ def demonstrate_price_data(): """Demonstrate real-time price data retrieval.""" logger.info("\n=== Price Data Demo ===") - tickers = ["NASDAQ:AAPL", "NASDAQ:MSFT", "NASDAQ:GOOGL"] + tickers = ["NASDAQ:AAPL", "HKEX:700", "SSE:600519", "CRYPTO:BTC"] # Get individual price logger.info("Getting individual price for AAPL...") - price_data = get_asset_price("NASDAQ:AAPL", language="zh-Hans") + price_data = get_asset_price("NASDAQ:AAPL", language="en-US") if price_data["success"]: - logger.info(f" 价格: {price_data['price_formatted']}") + logger.info(f" Price: {price_data['price_formatted']}") if price_data["change_percent_formatted"]: - logger.info(f" 涨跌幅: {price_data['change_percent_formatted']}") + logger.info(f" Change: {price_data['change_percent_formatted']}") if price_data["market_cap_formatted"]: - logger.info(f" 市值: {price_data['market_cap_formatted']}") + logger.info(f" Market Cap: {price_data['market_cap_formatted']}") # Get multiple prices logger.info(f"\nGetting prices for multiple assets: {tickers}") @@ -221,9 +221,9 @@ def demonstrate_watchlist_management(): # Add assets to watchlist assets_to_add = [ ("NASDAQ:AAPL", "Apple - iPhone maker"), - ("NASDAQ:MSFT", "Microsoft - Cloud and software"), - ("NASDAQ:GOOGL", "Google - Search and ads"), - ("NASDAQ:TSLA", "Tesla - Electric vehicles"), + ("HKEX:700", "Tencent - Chinese tech giant"), + ("SSE:600519", "Kweichow Moutai - Chinese liquor company"), + ("CRYPTO:BTC", "Bitcoin - First and largest cryptocurrency"), ] logger.info("Adding assets to watchlist...") @@ -242,8 +242,8 @@ def demonstrate_watchlist_management(): if watchlist_data["success"]: watchlist = watchlist_data["watchlist"] - logger.info(f"观察列表: {watchlist['name']}") - logger.info(f"资产数量: {watchlist['items_count']}") + logger.info(f"Watchlist: {watchlist['name']}") + logger.info(f"Number of assets: {watchlist['items_count']}") for asset in watchlist["assets"]: display_name = asset["display_name"] @@ -258,17 +258,19 @@ def demonstrate_watchlist_management(): logger.info(f" • {display_name}{price_info}") if notes: - logger.info(f" 备注: {notes}") + logger.info(f" Notes: {notes}") # List all user watchlists logger.info("\nListing all user watchlists...") all_watchlists = api.get_user_watchlists(user_id) if all_watchlists["success"]: - logger.info(f"用户 {user_id} 有 {all_watchlists['count']} 个观察列表:") + logger.info(f"User {user_id} has {all_watchlists['count']} watchlists:") for wl in all_watchlists["watchlists"]: - default_marker = " (默认)" if wl["is_default"] else "" - logger.info(f" • {wl['name']}{default_marker} - {wl['items_count']} 资产") + default_marker = " (Default)" if wl["is_default"] else "" + logger.info( + f" • {wl['name']}{default_marker} - {wl['items_count']} assets" + ) def demonstrate_i18n_features(): @@ -287,19 +289,19 @@ def demonstrate_i18n_features(): set_i18n_config(config) # Search for assets - results = search_assets("Apple", language=lang, limit=1) + results = search_assets("APPL", language=lang, limit=1) if results["success"] and results["results"]: result = results["results"][0] logger.info( - f" 搜索结果: {result['display_name']} ({result['asset_type_display']})" + f" Search result: {result['display_name']} ({result['asset_type_display']})" ) # Get price with localized formatting price_data = get_asset_price(ticker, language=lang) if price_data["success"]: - logger.info(f" 价格: {price_data['price_formatted']}") + logger.info(f" Price: {price_data['price_formatted']}") if price_data.get("change_percent_formatted"): - logger.info(f" 涨跌: {price_data['change_percent_formatted']}") + logger.info(f" Change: {price_data['change_percent_formatted']}") def main(): From 2fa91057ca58152d25ddbafa7948c4d0c9a25f83 Mon Sep 17 00:00:00 2001 From: hazeone <709547807@qq.com> Date: Mon, 8 Sep 2025 09:49:20 +0800 Subject: [PATCH 4/6] add akshare --- python/pyproject.toml | 1 + python/uv.lock | 471 ++++++++++++++++++ .../adapters/assets/akshare_adapter.py | 1 + python/valuecell/adapters/assets/api.py | 16 +- python/valuecell/adapters/assets/manager.py | 11 + 5 files changed, 493 insertions(+), 7 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 5205276aa..35ce0cbd5 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "yfinance>=0.2.65", "tushare>=1.4.24", "requests>=2.32.5", + "akshare>=1.17.44", ] [project.optional-dependencies] diff --git a/python/uv.lock b/python/uv.lock index a5c14c49b..d7f88bc3f 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -2,6 +2,116 @@ version = 1 revision = 2 requires-python = ">=3.12" +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.12.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/97/77cb2450d9b35f517d6cf506256bf4f5bda3f93a66b4ad64ba7fc917899c/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7", size = 702333, upload-time = "2025-07-29T05:50:46.507Z" }, + { url = "https://files.pythonhosted.org/packages/83/6d/0544e6b08b748682c30b9f65640d006e51f90763b41d7c546693bc22900d/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444", size = 476948, upload-time = "2025-07-29T05:50:48.067Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1d/c8c40e611e5094330284b1aea8a4b02ca0858f8458614fa35754cab42b9c/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d", size = 469787, upload-time = "2025-07-29T05:50:49.669Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/b76438e70319796bfff717f325d97ce2e9310f752a267bfdf5192ac6082b/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c", size = 1716590, upload-time = "2025-07-29T05:50:51.368Z" }, + { url = "https://files.pythonhosted.org/packages/79/b1/60370d70cdf8b269ee1444b390cbd72ce514f0d1cd1a715821c784d272c9/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0", size = 1699241, upload-time = "2025-07-29T05:50:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/4968a7b8792437ebc12186db31523f541943e99bda8f30335c482bea6879/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab", size = 1754335, upload-time = "2025-07-29T05:50:55.394Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/49524ed553f9a0bec1a11fac09e790f49ff669bcd14164f9fab608831c4d/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb", size = 1800491, upload-time = "2025-07-29T05:50:57.202Z" }, + { url = "https://files.pythonhosted.org/packages/de/5e/3bf5acea47a96a28c121b167f5ef659cf71208b19e52a88cdfa5c37f1fcc/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", size = 1719929, upload-time = "2025-07-29T05:50:59.192Z" }, + { url = "https://files.pythonhosted.org/packages/39/94/8ae30b806835bcd1cba799ba35347dee6961a11bd507db634516210e91d8/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c", size = 1635733, upload-time = "2025-07-29T05:51:01.394Z" }, + { url = "https://files.pythonhosted.org/packages/7a/46/06cdef71dd03acd9da7f51ab3a9107318aee12ad38d273f654e4f981583a/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd", size = 1696790, upload-time = "2025-07-29T05:51:03.657Z" }, + { url = "https://files.pythonhosted.org/packages/02/90/6b4cfaaf92ed98d0ec4d173e78b99b4b1a7551250be8937d9d67ecb356b4/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f", size = 1718245, upload-time = "2025-07-29T05:51:05.911Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e6/2593751670fa06f080a846f37f112cbe6f873ba510d070136a6ed46117c6/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d", size = 1658899, upload-time = "2025-07-29T05:51:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/8f/28/c15bacbdb8b8eb5bf39b10680d129ea7410b859e379b03190f02fa104ffd/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519", size = 1738459, upload-time = "2025-07-29T05:51:09.56Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/c269cbc4faa01fb10f143b1670633a8ddd5b2e1ffd0548f7aa49cb5c70e2/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea", size = 1766434, upload-time = "2025-07-29T05:51:11.423Z" }, + { url = "https://files.pythonhosted.org/packages/52/b0/4ff3abd81aa7d929b27d2e1403722a65fc87b763e3a97b3a2a494bfc63bc/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3", size = 1726045, upload-time = "2025-07-29T05:51:13.689Z" }, + { url = "https://files.pythonhosted.org/packages/71/16/949225a6a2dd6efcbd855fbd90cf476052e648fb011aa538e3b15b89a57a/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1", size = 423591, upload-time = "2025-07-29T05:51:15.452Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266, upload-time = "2025-07-29T05:51:17.239Z" }, + { url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741, upload-time = "2025-07-29T05:51:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407, upload-time = "2025-07-29T05:51:21.165Z" }, + { url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703, upload-time = "2025-07-29T05:51:22.948Z" }, + { url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532, upload-time = "2025-07-29T05:51:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794, upload-time = "2025-07-29T05:51:27.145Z" }, + { url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865, upload-time = "2025-07-29T05:51:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238, upload-time = "2025-07-29T05:51:31.285Z" }, + { url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566, upload-time = "2025-07-29T05:51:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270, upload-time = "2025-07-29T05:51:35.195Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294, upload-time = "2025-07-29T05:51:37.215Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958, upload-time = "2025-07-29T05:51:39.328Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553, upload-time = "2025-07-29T05:51:41.356Z" }, + { url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688, upload-time = "2025-07-29T05:51:43.452Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157, upload-time = "2025-07-29T05:51:45.643Z" }, + { url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050, upload-time = "2025-07-29T05:51:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647, upload-time = "2025-07-29T05:51:50.718Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload-time = "2025-07-29T05:51:52.549Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "akracer" +version = "0.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/76/424358add4fb060ab24c7a4d6e90c5c05cc1871cf27b6afc3f39ff5774fe/akracer-0.0.13.tar.gz", hash = "sha256:8ab67dabda34f38604d037f2cac67078d253d8c4c316ffe0d80d27ed03cdbb5e", size = 10047445, upload-time = "2023-10-25T08:02:31.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/86/6ef05f0b51a36dbec2b260da7a93ed0096dea32e708e127c5051b875af2d/akracer-0.0.13-py3-none-any.whl", hash = "sha256:55bd04c69e35130994d26795f00183e0c33d4e237f7ebfa35074a760c30209d1", size = 10078023, upload-time = "2023-10-25T08:02:27.193Z" }, +] + +[[package]] +name = "akshare" +version = "1.17.44" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "akracer", marker = "sys_platform == 'linux'" }, + { name = "beautifulsoup4" }, + { name = "decorator" }, + { name = "html5lib" }, + { name = "jsonpath" }, + { name = "lxml" }, + { name = "mini-racer", marker = "sys_platform != 'linux'" }, + { name = "nest-asyncio" }, + { name = "openpyxl" }, + { name = "pandas" }, + { name = "py-mini-racer", marker = "sys_platform == 'linux'" }, + { name = "requests" }, + { name = "tabulate" }, + { name = "tqdm" }, + { name = "urllib3" }, + { name = "xlrd" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/57/345a38110c588082ba378fb919fa6a46dcf88acb577de670d72e0f42a6bf/akshare-1.17.44.tar.gz", hash = "sha256:c1a29765f92359f62edccaa2e81d4d54f2fb1dbffd8cb3e154a21e05848a8127", size = 841522, upload-time = "2025-08-30T07:44:58.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/b7/2648d022d7055cf9342f0beb4cd7a7abeb66e8534df1fcc832a6fca505ad/akshare-1.17.44-py3-none-any.whl", hash = "sha256:1344838e1f644eee2cd54f5c54a906aa5ee805510e5a7edb7d3fd8af381efccd", size = 1056185, upload-time = "2025-08-30T07:44:57.262Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -25,6 +135,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, ] +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.13.5" @@ -240,6 +359,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/0f/9c5275f17ad6ff5be70edb8e0120fdc184a658c9577ca426d4230f654beb/curl_cffi-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:d438a3b45244e874794bc4081dc1e356d2bb926dcc7021e5a8fef2e2105ef1d8", size = 1365753, upload-time = "2025-08-06T13:05:41.879Z" }, ] +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "fastapi" version = "0.116.1" @@ -265,6 +402,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/8e/b6bf6a0de482d7d7d7a2aaac8fdc4a4d0bb24a809f5ddd422aa7060eb3d2/frozendict-2.4.6-py313-none-any.whl", hash = "sha256:7134a2bb95d4a16556bb5f2b9736dceb6ea848fa5b6f3f6c2d6dba93b44b4757", size = 16146, upload-time = "2024-10-13T12:15:29.495Z" }, ] +[[package]] +name = "frozenlist" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" }, + { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" }, + { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" }, + { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" }, + { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" }, + { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, + { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, + { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, + { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, + { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, + { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -274,6 +471,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "html5lib" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/b6/b55c3f49042f1df3dcd422b7f224f939892ee94f22abcf503a9b7339eaf2/html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f", size = 272215, upload-time = "2020-06-22T23:32:38.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173, upload-time = "2020-06-22T23:32:36.781Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -292,6 +502,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] +[[package]] +name = "jsonpath" +version = "0.82.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/a1/693351acd0a9edca4de9153372a65e75398898ea7f8a5c722ab00f464929/jsonpath-0.82.2.tar.gz", hash = "sha256:d87ef2bcbcded68ee96bc34c1809b69457ecec9b0c4dd471658a12bd391002d1", size = 10353, upload-time = "2023-08-24T18:57:55.459Z" } + [[package]] name = "lxml" version = "6.0.1" @@ -354,12 +570,95 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/44/9613f300201b8700215856e5edd056d4e58dd23368699196b58877d4408b/lxml-6.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:2834377b0145a471a654d699bdb3a2155312de492142ef5a1d426af2c60a0a31", size = 3753901, upload-time = "2025-08-22T10:34:45.799Z" }, ] +[[package]] +name = "mini-racer" +version = "0.12.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/2d/e051f58e17117b1b8b11a7d17622c1528fa9002c553943c6b677c1b412da/mini_racer-0.12.4.tar.gz", hash = "sha256:84c67553ce9f3736d4c617d8a3f882949d37a46cfb47fe11dab33dd6704e62a4", size = 447529, upload-time = "2024-06-20T14:44:39.992Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/fe/1452b6c74cae9e8cd7b6a16d8b1ef08bba4dd0ed373a95f3b401c2e712ea/mini_racer-0.12.4-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:bce8a3cee946575a352f5e65335903bc148da42c036d0c738ac67e931600e455", size = 15701219, upload-time = "2024-06-20T14:44:21.96Z" }, + { url = "https://files.pythonhosted.org/packages/99/ae/c22478eff26e6136341e6b40d34f8d285f910ca4d2e2a0ca4703ef87be79/mini_racer-0.12.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:56c832e6ac2db6a304d1e8e80030615297aafbc6940f64f3479af4ba16abccd5", size = 14566436, upload-time = "2024-06-20T14:44:24.496Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0e/a9943f90b4a8a6d3849b81a00a00d2db128d876365385af382a0e2caf191/mini_racer-0.12.4-py3-none-win_amd64.whl", hash = "sha256:9446e3bd6a4eb9fbedf1861326f7476080995a31c9b69308acef17e5b7ecaa1b", size = 13674040, upload-time = "2024-06-20T14:44:37.851Z" }, +] + +[[package]] +name = "multidict" +version = "6.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/f6/512ffd8fd8b37fb2680e5ac35d788f1d71bbaf37789d21a820bdc441e565/multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8", size = 76516, upload-time = "2025-08-11T12:06:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/58/45c3e75deb8855c36bd66cc1658007589662ba584dbf423d01df478dd1c5/multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3", size = 45394, upload-time = "2025-08-11T12:06:54.555Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/e8c4472a93a26e4507c0b8e1f0762c0d8a32de1328ef72fd704ef9cc5447/multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b", size = 43591, upload-time = "2025-08-11T12:06:55.672Z" }, + { url = "https://files.pythonhosted.org/packages/05/51/edf414f4df058574a7265034d04c935aa84a89e79ce90fcf4df211f47b16/multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287", size = 237215, upload-time = "2025-08-11T12:06:57.213Z" }, + { url = "https://files.pythonhosted.org/packages/c8/45/8b3d6dbad8cf3252553cc41abea09ad527b33ce47a5e199072620b296902/multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138", size = 258299, upload-time = "2025-08-11T12:06:58.946Z" }, + { url = "https://files.pythonhosted.org/packages/3c/e8/8ca2e9a9f5a435fc6db40438a55730a4bf4956b554e487fa1b9ae920f825/multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6", size = 242357, upload-time = "2025-08-11T12:07:00.301Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/80c77c99df05a75c28490b2af8f7cba2a12621186e0a8b0865d8e745c104/multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9", size = 268369, upload-time = "2025-08-11T12:07:01.638Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e9/920bfa46c27b05fb3e1ad85121fd49f441492dca2449c5bcfe42e4565d8a/multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c", size = 269341, upload-time = "2025-08-11T12:07:02.943Z" }, + { url = "https://files.pythonhosted.org/packages/af/65/753a2d8b05daf496f4a9c367fe844e90a1b2cac78e2be2c844200d10cc4c/multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402", size = 256100, upload-time = "2025-08-11T12:07:04.564Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/655be13ae324212bf0bc15d665a4e34844f34c206f78801be42f7a0a8aaa/multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7", size = 253584, upload-time = "2025-08-11T12:07:05.914Z" }, + { url = "https://files.pythonhosted.org/packages/5c/74/ab2039ecc05264b5cec73eb018ce417af3ebb384ae9c0e9ed42cb33f8151/multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f", size = 251018, upload-time = "2025-08-11T12:07:08.301Z" }, + { url = "https://files.pythonhosted.org/packages/af/0a/ccbb244ac848e56c6427f2392741c06302bbfba49c0042f1eb3c5b606497/multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d", size = 251477, upload-time = "2025-08-11T12:07:10.248Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b0/0ed49bba775b135937f52fe13922bc64a7eaf0a3ead84a36e8e4e446e096/multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7", size = 263575, upload-time = "2025-08-11T12:07:11.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/7fb85a85e14de2e44dfb6a24f03c41e2af8697a6df83daddb0e9b7569f73/multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802", size = 259649, upload-time = "2025-08-11T12:07:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/03/9e/b3a459bcf9b6e74fa461a5222a10ff9b544cb1cd52fd482fb1b75ecda2a2/multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24", size = 251505, upload-time = "2025-08-11T12:07:14.57Z" }, + { url = "https://files.pythonhosted.org/packages/86/a2/8022f78f041dfe6d71e364001a5cf987c30edfc83c8a5fb7a3f0974cff39/multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793", size = 41888, upload-time = "2025-08-11T12:07:15.904Z" }, + { url = "https://files.pythonhosted.org/packages/c7/eb/d88b1780d43a56db2cba24289fa744a9d216c1a8546a0dc3956563fd53ea/multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e", size = 46072, upload-time = "2025-08-11T12:07:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/b929320bf5750e2d9d4931835a4c638a19d2494a5b519caaaa7492ebe105/multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364", size = 43222, upload-time = "2025-08-11T12:07:18.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848, upload-time = "2025-08-11T12:07:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060, upload-time = "2025-08-11T12:07:21.163Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269, upload-time = "2025-08-11T12:07:22.392Z" }, + { url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158, upload-time = "2025-08-11T12:07:23.636Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076, upload-time = "2025-08-11T12:07:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694, upload-time = "2025-08-11T12:07:26.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350, upload-time = "2025-08-11T12:07:27.94Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250, upload-time = "2025-08-11T12:07:29.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900, upload-time = "2025-08-11T12:07:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355, upload-time = "2025-08-11T12:07:32.205Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061, upload-time = "2025-08-11T12:07:33.623Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675, upload-time = "2025-08-11T12:07:34.958Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247, upload-time = "2025-08-11T12:07:36.588Z" }, + { url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960, upload-time = "2025-08-11T12:07:39.735Z" }, + { url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078, upload-time = "2025-08-11T12:07:41.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708, upload-time = "2025-08-11T12:07:43.405Z" }, + { url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912, upload-time = "2025-08-11T12:07:45.082Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076, upload-time = "2025-08-11T12:07:46.746Z" }, + { url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812, upload-time = "2025-08-11T12:07:48.402Z" }, + { url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313, upload-time = "2025-08-11T12:07:49.679Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777, upload-time = "2025-08-11T12:07:51.318Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321, upload-time = "2025-08-11T12:07:52.965Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954, upload-time = "2025-08-11T12:07:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612, upload-time = "2025-08-11T12:07:55.914Z" }, + { url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528, upload-time = "2025-08-11T12:07:57.371Z" }, + { url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329, upload-time = "2025-08-11T12:07:58.844Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928, upload-time = "2025-08-11T12:08:01.037Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228, upload-time = "2025-08-11T12:08:02.96Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869, upload-time = "2025-08-11T12:08:04.746Z" }, + { url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446, upload-time = "2025-08-11T12:08:06.332Z" }, + { url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299, upload-time = "2025-08-11T12:08:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926, upload-time = "2025-08-11T12:08:09.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383, upload-time = "2025-08-11T12:08:10.981Z" }, + { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" }, + { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, +] + [[package]] name = "multitasking" version = "0.0.12" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/17/0d/74f0293dfd7dcc3837746d0138cbedd60b31701ecc75caec7d3f281feba0/multitasking-0.0.12.tar.gz", hash = "sha256:2fba2fa8ed8c4b85e227c5dd7dc41c7d658de3b6f247927316175a57349b84d1", size = 19984, upload-time = "2025-07-20T21:27:51.636Z" } +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + [[package]] name = "numpy" version = "2.3.2" @@ -423,6 +722,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/9e/1652778bce745a67b5fe05adde60ed362d38eb17d919a540e813d30f6874/numpy-2.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:092aeb3449833ea9c0bf0089d70c29ae480685dd2377ec9cdbbb620257f84631", size = 10544226, upload-time = "2025-07-24T20:56:34.509Z" }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -490,6 +801,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "propcache" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, + { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, + { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" }, + { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" }, + { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" }, + { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, + { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, + { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, + { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, + { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, +] + [[package]] name = "protobuf" version = "6.32.0" @@ -504,6 +872,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/f2/80ffc4677aac1bc3519b26bc7f7f5de7fce0ee2f7e36e59e27d8beb32dd1/protobuf-6.32.0-py3-none-any.whl", hash = "sha256:ba377e5b67b908c8f3072a57b63e2c6a4cbd18aea4ed98d2584350dbf46f2783", size = 169287, upload-time = "2025-08-14T21:21:23.515Z" }, ] +[[package]] +name = "py-mini-racer" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/97/a578b918b2e5923dd754cb60bb8b8aeffc85255ffb92566e3c65b148ff72/py_mini_racer-0.6.0.tar.gz", hash = "sha256:f71e36b643d947ba698c57cd9bd2232c83ca997b0802fc2f7f79582377040c11", size = 5994836, upload-time = "2021-04-22T07:58:35.993Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/a9/8ce0ca222ef04d602924a1e099be93f5435ca6f3294182a30574d4159ca2/py_mini_racer-0.6.0-py2.py3-none-manylinux1_x86_64.whl", hash = "sha256:42896c24968481dd953eeeb11de331f6870917811961c9b26ba09071e07180e2", size = 5416149, upload-time = "2021-04-22T07:58:25.615Z" }, +] + [[package]] name = "pycparser" version = "2.22" @@ -758,6 +1135,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" }, ] +[[package]] +name = "tabulate" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -845,6 +1231,7 @@ name = "valuecell" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "akshare" }, { name = "fastapi" }, { name = "pydantic" }, { name = "python-dateutil" }, @@ -865,6 +1252,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "akshare", specifier = ">=1.17.44" }, { name = "fastapi", specifier = ">=0.104.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, @@ -880,6 +1268,15 @@ requires-dist = [ ] provides-extras = ["dev"] +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + [[package]] name = "websocket-client" version = "1.8.0" @@ -920,6 +1317,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "xlrd" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/5a/377161c2d3538d1990d7af382c79f3b2372e880b65de21b01b1a2b78691e/xlrd-2.0.2.tar.gz", hash = "sha256:08b5e25de58f21ce71dc7db3b3b8106c1fa776f3024c54e45b45b374e89234c9", size = 100167, upload-time = "2025-06-14T08:46:39.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/62/c8d562e7766786ba6587d09c5a8ba9f718ed3fa8af7f4553e8f91c36f302/xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9", size = 96555, upload-time = "2025-06-14T08:46:37.766Z" }, +] + +[[package]] +name = "yarl" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, + { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" }, + { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" }, + { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" }, + { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, + { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, + { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, + { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, + { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, + { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, + { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, + { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, + { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, + { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, +] + [[package]] name = "yfinance" version = "0.2.65" diff --git a/python/valuecell/adapters/assets/akshare_adapter.py b/python/valuecell/adapters/assets/akshare_adapter.py index df931ceab..ebb912df4 100644 --- a/python/valuecell/adapters/assets/akshare_adapter.py +++ b/python/valuecell/adapters/assets/akshare_adapter.py @@ -57,6 +57,7 @@ def _initialize(self) -> None: "fund": AssetType.ETF, # "bond": AssetType.BOND, "index": AssetType.INDEX, + "crypto": AssetType.CRYPTO, } # Exchange mapping for AKShare diff --git a/python/valuecell/adapters/assets/api.py b/python/valuecell/adapters/assets/api.py index 2e9e1758a..2759c4040 100644 --- a/python/valuecell/adapters/assets/api.py +++ b/python/valuecell/adapters/assets/api.py @@ -568,13 +568,15 @@ def get_system_health(self) -> Dict[str, Any]: ) total_count = len(health_status) - overall_status = ( - "healthy" - if healthy_count == total_count - else "degraded" - if healthy_count > 0 - else "unhealthy" - ) + # Determine overall status + if total_count == 0: + overall_status = "no_adapters" + elif healthy_count == total_count: + overall_status = "healthy" + elif healthy_count > 0: + overall_status = "degraded" + else: + overall_status = "unhealthy" return { "success": True, diff --git a/python/valuecell/adapters/assets/manager.py b/python/valuecell/adapters/assets/manager.py index 8ffcd1d11..7d64b93af 100644 --- a/python/valuecell/adapters/assets/manager.py +++ b/python/valuecell/adapters/assets/manager.py @@ -231,6 +231,9 @@ def search_assets(self, query: AssetSearchQuery) -> List[AssetSearchResult]: target_adapters.update(self.adapters.values()) # Search in parallel across adapters + if not target_adapters: + return [] + with ThreadPoolExecutor(max_workers=len(target_adapters)) as executor: future_to_adapter = { executor.submit(adapter.search_assets, query): adapter @@ -325,6 +328,10 @@ def get_multiple_prices( # Fetch prices in parallel from each adapter all_results = {} + if not adapter_tickers: + # If no adapters found for any tickers, return None for all + return {ticker: None for ticker in tickers} + with ThreadPoolExecutor(max_workers=len(adapter_tickers)) as executor: future_to_adapter = { executor.submit(adapter.get_multiple_prices, ticker_list): adapter @@ -385,6 +392,10 @@ def health_check(self) -> Dict[DataSource, Dict[str, Any]]: """ health_results = {} + # If no adapters are registered, return empty results + if not self.adapters: + return health_results + with ThreadPoolExecutor(max_workers=len(self.adapters)) as executor: future_to_source = { executor.submit(adapter.health_check): source From 590775f506a76b0d213ea022a4a9306bfce4b7fd Mon Sep 17 00:00:00 2001 From: hazeone <709547807@qq.com> Date: Mon, 8 Sep 2025 15:02:51 +0800 Subject: [PATCH 5/6] fit akshare --- .../adapters/assets/akshare_adapter.py | 1906 ++++++++++++++--- python/valuecell/adapters/assets/manager.py | 2 +- .../examples/asset_adapter_example.py | 43 +- 3 files changed, 1614 insertions(+), 337 deletions(-) diff --git a/python/valuecell/adapters/assets/akshare_adapter.py b/python/valuecell/adapters/assets/akshare_adapter.py index ebb912df4..abd6ba91c 100644 --- a/python/valuecell/adapters/assets/akshare_adapter.py +++ b/python/valuecell/adapters/assets/akshare_adapter.py @@ -1,14 +1,17 @@ -"""AKShare adapter for Chinese financial market data. +"""AKShare adapter for Chinese user-friendly financial market data. This adapter provides integration with AKShare library to fetch comprehensive -Chinese financial market data including stocks, funds, bonds, and economic indicators. +Global financial market data including stocks, funds, bonds, and economic indicators. """ import logging from typing import List, Optional, Any from datetime import datetime, timedelta from decimal import Decimal +import decimal import pandas as pd +import time +import threading try: import akshare as ak @@ -49,7 +52,26 @@ def __init__(self, **kwargs): def _initialize(self) -> None: """Initialize AKShare adapter configuration.""" - self.timeout = self.config.get("timeout", 30) + self.timeout = self.config.get("timeout", 10) # Reduced timeout duration + + # Different cache TTLs for different data types + self.price_cache_ttl = self.config.get("price_cache_ttl", 30) # 30 seconds for real-time prices + self.info_cache_ttl = self.config.get("info_cache_ttl", 3600) # 1 hour for stock info + self.hist_cache_ttl = self.config.get("hist_cache_ttl", 1800) # 30 minutes for historical data + + self.max_retries = self.config.get("max_retries", 2) # Maximum retry attempts + + # Data caching with different TTLs + self._cache = {} + self._cache_lock = threading.Lock() + self._last_cache_clear = time.time() + + # Cache statistics for monitoring + self._cache_stats = { + "hits": 0, + "misses": 0, + "evictions": 0 + } # Asset type mapping for AKShare self.asset_type_mapping = { @@ -60,147 +82,231 @@ def _initialize(self) -> None: "crypto": AssetType.CRYPTO, } + # Field mapping - Handle AKShare API field changes + self.field_mappings = { + "a_shares": { + "code": ["代码", "symbol", "ts_code"], + "name": ["名称", "name", "short_name"], + "price": ["最新价", "close", "price"], + "open": ["今开", "open"], + "high": ["最高", "high"], + "low": ["最低", "low"], + "volume": ["成交量", "volume", "vol"], + "market_cap": ["总市值", "total_mv"], + }, + "hk_stocks": { + "code": ["symbol", "code", "代码"], + "name": ["name", "名称", "short_name"], + }, + "us_stocks": { + "code": ["代码", "symbol", "ticker"], + "name": ["名称", "name", "short_name"], + }, + "crypto": {"code": ["symbol", "代码"], "name": ["name", "名称"]}, + } + # Exchange mapping for AKShare self.exchange_mapping = { "SH": "SSE", # Shanghai Stock Exchange "SZ": "SZSE", # Shenzhen Stock Exchange "BJ": "BSE", # Beijing Stock Exchange + "HK": "HKEX", # Hong Kong Stock Exchange + "US": "NASDAQ", # US markets (generic) + "NYSE": "NYSE", # New York Stock Exchange + "NASDAQ": "NASDAQ", # NASDAQ } - logger.info("AKShare adapter initialized") + logger.info("AKShare adapter initialized with caching and field mapping") + + def _get_cached_data(self, cache_key: str, fetch_func, *args, **kwargs): + """Get cached data or fetch new data with adaptive TTL.""" + current_time = time.time() + + # Determine TTL based on cache key type + ttl = self._get_cache_ttl(cache_key) + + with self._cache_lock: + # Clean up expired cache periodically + if current_time - self._last_cache_clear > min(self.price_cache_ttl, self.info_cache_ttl): + expired_keys = [ + key + for key, (_, timestamp, key_ttl) in self._cache.items() + if current_time - timestamp > key_ttl * 2 # Keep expired data for fallback + ] + for key in expired_keys: + del self._cache[key] + self._cache_stats["evictions"] += 1 + self._last_cache_clear = current_time + + # Check for valid cache + if cache_key in self._cache: + cached_data, timestamp, key_ttl = self._cache[cache_key] + if current_time - timestamp < key_ttl: + logger.debug(f"Cache hit for {cache_key}") + self._cache_stats["hits"] += 1 + return cached_data + else: + logger.debug(f"Cache expired for {cache_key}") + + # Cache miss + self._cache_stats["misses"] += 1 + + # Fetch new data outside the lock to reduce lock time + try: + logger.debug(f"Fetching new data for {cache_key}") + data = fetch_func(*args, **kwargs) + with self._cache_lock: + self._cache[cache_key] = (data, current_time, ttl) + return data + except Exception as e: + logger.error(f"Failed to fetch data for {cache_key}: {e}") + # Try to return expired data as fallback + with self._cache_lock: + if cache_key in self._cache: + cached_data, _, _ = self._cache[cache_key] + logger.warning(f"Using expired cached data for {cache_key}") + return cached_data + raise + + def _get_cache_ttl(self, cache_key: str) -> int: + """Get appropriate TTL based on cache key type.""" + if "price" in cache_key or "spot" in cache_key: + return self.price_cache_ttl + elif "hist" in cache_key: + return self.hist_cache_ttl + else: + return self.info_cache_ttl + + def get_cache_stats(self) -> dict: + """Get cache statistics for monitoring.""" + with self._cache_lock: + total_requests = self._cache_stats["hits"] + self._cache_stats["misses"] + hit_rate = ( + self._cache_stats["hits"] / total_requests + if total_requests > 0 + else 0 + ) + return { + "cache_size": len(self._cache), + "hit_rate": hit_rate, + **self._cache_stats + } + + def clear_cache(self) -> None: + """Clear all cached data.""" + with self._cache_lock: + self._cache.clear() + self._cache_stats = {"hits": 0, "misses": 0, "evictions": 0} + logger.info("Cache cleared") + + def _safe_get_field(self, data_row, field_type: str, market_type: str = "a_shares"): + """Safely get data field value, handling field name changes.""" + possible_fields = self.field_mappings.get(market_type, {}).get(field_type, []) + + for field_name in possible_fields: + if field_name in data_row and data_row[field_name] is not None: + return data_row[field_name] + + logger.debug(f"Field {field_type} not found in {market_type} data") + return None + + def _safe_akshare_call(self, func, *args, **kwargs): + """Safely call AKShare API with retry mechanism.""" + for attempt in range(self.max_retries + 1): + try: + # Set timeout + result = func(*args, **kwargs) + if result is not None and not ( + hasattr(result, "empty") and result.empty + ): + return result + else: + logger.warning( + f"AKShare API returned empty data on attempt {attempt + 1}" + ) + if attempt < self.max_retries: + time.sleep(1) # Wait 1 second before retry + continue + return None + except Exception as e: + logger.warning(f"AKShare API call failed on attempt {attempt + 1}: {e}") + if attempt < self.max_retries: + time.sleep(2**attempt) # Exponential backoff + continue + raise e + return None + + def _get_exchange_from_a_share_code(self, stock_code: str) -> Optional[tuple]: + """Get exchange and ticker from A-share stock code.""" + if stock_code.startswith("6"): + return ("SSE", f"SSE:{stock_code}") + elif stock_code.startswith(("0", "3")): + return ("SZSE", f"SZSE:{stock_code}") + elif stock_code.startswith("8"): + return ("BSE", f"BSE:{stock_code}") + return None + + def _create_stock_search_result( + self, + ticker: str, + asset_type: AssetType, + stock_code: str, + stock_name: str, + exchange: str, + country: str, + currency: str, + search_term: str, + ) -> AssetSearchResult: + """Create a standardized stock search result.""" + names = { + "zh-Hans": stock_name, + "zh-Hant": stock_name, + "en-US": stock_name, + } + + return AssetSearchResult( + ticker=ticker, + asset_type=asset_type, + names=names, + exchange=exchange, + country=country, + currency=currency, + market_status=MarketStatus.UNKNOWN, + relevance_score=self._calculate_relevance( + search_term, stock_code, stock_name + ), + ) def search_assets(self, query: AssetSearchQuery) -> List[AssetSearchResult]: - """Search for assets using AKShare stock info.""" + """Search for assets using AKShare direct queries.""" try: results = [] search_term = query.query.strip() - # Get stock basic info from AKShare - try: - # Get A-share stock list - df_stocks = ak.stock_zh_a_spot_em() - - if df_stocks is None or df_stocks.empty: - return results - - # Search by code or name - mask = df_stocks["代码"].astype(str).str.contains( - search_term, case=False, na=False - ) | df_stocks["名称"].str.contains(search_term, case=False, na=False) - - matched_stocks = df_stocks[mask].head(query.limit) - - for _, row in matched_stocks.iterrows(): - try: - # Parse stock code and exchange - stock_code = str(row["代码"]) - stock_name = row["名称"] - - # Determine exchange from code - if stock_code.startswith("6"): - exchange = "SSE" # Shanghai - internal_ticker = f"SSE:{stock_code}" - elif stock_code.startswith(("0", "3")): - exchange = "SZSE" # Shenzhen - internal_ticker = f"SZSE:{stock_code}" - elif stock_code.startswith("8"): - exchange = "BSE" # Beijing - internal_ticker = f"BSE:{stock_code}" - else: - continue # Skip unknown exchanges - - # Create localized names - names = { - "zh-Hans": stock_name, - "zh-Hant": stock_name, - "en-US": stock_name, # AKShare primarily has Chinese names - } - - result = AssetSearchResult( - ticker=internal_ticker, - asset_type=AssetType.STOCK, - names=names, - exchange=exchange, - country="CN", - currency="CNY", - market_status=MarketStatus.UNKNOWN, - relevance_score=self._calculate_relevance( - search_term, stock_code, stock_name - ), - ) + # Direct ticker lookup strategy - try to match exact codes first + if self._looks_like_ticker(search_term): + results.extend(self._search_by_direct_ticker_lookup(search_term, query)) + if results: + return results[: query.limit] - results.append(result) + # Determine likely markets based on search term + likely_markets = self._determine_likely_markets(search_term, query) - except Exception as e: - logger.warning( - f"Error processing search result for {row.get('代码')}: {e}" - ) - continue + # Search markets by priority using direct queries + if "a_shares" in likely_markets: + results.extend(self._search_a_shares_direct(search_term, query)) - except Exception as e: - logger.error(f"Error fetching stock list from AKShare: {e}") + if "hk_stocks" in likely_markets and len(results) < query.limit: + results.extend(self._search_hk_stocks_direct(search_term, query)) - # Try to search funds if no stock results or if fund type is requested - if not results or ( - query.asset_types and AssetType.ETF in query.asset_types - ): - try: - df_funds = ak.fund_etf_spot_em() - - if df_funds is not None and not df_funds.empty: - # Search funds - fund_mask = df_funds["代码"].astype(str).str.contains( - search_term, case=False, na=False - ) | df_funds["名称"].str.contains( - search_term, case=False, na=False - ) - - matched_funds = df_funds[fund_mask].head( - max(5, query.limit - len(results)) - ) - - for _, row in matched_funds.iterrows(): - try: - fund_code = str(row["代码"]) - fund_name = row["名称"] - - # Determine exchange for funds - if fund_code.startswith("5"): - exchange = "SSE" - internal_ticker = f"SSE:{fund_code}" - else: - exchange = "SZSE" - internal_ticker = f"SZSE:{fund_code}" - - names = { - "zh-Hans": fund_name, - "zh-Hant": fund_name, - "en-US": fund_name, - } - - result = AssetSearchResult( - ticker=internal_ticker, - asset_type=AssetType.ETF, - names=names, - exchange=exchange, - country="CN", - currency="CNY", - market_status=MarketStatus.UNKNOWN, - relevance_score=self._calculate_relevance( - search_term, fund_code, fund_name - ), - ) - - results.append(result) - - except Exception as e: - logger.warning( - f"Error processing fund result for {row.get('代码')}: {e}" - ) - continue + if "us_stocks" in likely_markets and len(results) < query.limit: + results.extend(self._search_us_stocks_direct(search_term, query)) - except Exception as e: - logger.warning(f"Error fetching fund list from AKShare: {e}") + if "crypto" in likely_markets and len(results) < query.limit: + results.extend(self._search_crypto_direct(search_term, query)) + + if "etfs" in likely_markets and len(results) < query.limit: + results.extend(self._search_etfs_direct(search_term, query)) # Apply filters if query.asset_types: @@ -221,6 +327,552 @@ def search_assets(self, query: AssetSearchQuery) -> List[AssetSearchResult]: logger.error(f"Error searching assets: {e}") return [] + def _determine_likely_markets( + self, search_term: str, query: AssetSearchQuery + ) -> List[str]: + """Intelligently determine likely markets for search term, reducing unnecessary network requests.""" + likely_markets = set() # Use set to avoid duplicates + search_term_upper = search_term.upper().strip() + + # Mappings for efficient lookup + exchange_market_map = { + "SSE": "a_shares", + "SZSE": "a_shares", + "BSE": "a_shares", + "HKEX": "hk_stocks", + "NASDAQ": "us_stocks", + "NYSE": "us_stocks", + "CRYPTO": "crypto", + } + + country_market_map = { + "CN": ["a_shares", "etfs"], + "HK": ["hk_stocks"], + "US": ["us_stocks"], + "GLOBAL": ["crypto"], + } + + # Determine markets based on query filters + if query.asset_types: + type_market_map = { + AssetType.CRYPTO: "crypto", + AssetType.ETF: "etfs", + AssetType.STOCK: ["a_shares", "hk_stocks", "us_stocks"], + } + for asset_type in query.asset_types: + markets = type_market_map.get(asset_type, []) + if isinstance(markets, str): + likely_markets.add(markets) + else: + likely_markets.update(markets) + + if query.exchanges: + for exchange in query.exchanges: + market = exchange_market_map.get(exchange) + if market: + likely_markets.add(market) + + if query.countries: + for country in query.countries: + markets = country_market_map.get(country, []) + likely_markets.update(markets) + + # If no explicit filters, determine based on search term patterns + if not likely_markets: + likely_markets.update(self._analyze_search_term_pattern(search_term_upper)) + + # If still empty, search all markets + if not likely_markets: + likely_markets = {"a_shares", "us_stocks", "hk_stocks", "crypto", "etfs"} + + # Convert to list with priority order + priority_order = ["a_shares", "us_stocks", "hk_stocks", "crypto", "etfs"] + result = [market for market in priority_order if market in likely_markets] + + logger.debug(f"Determined likely markets for '{search_term}': {result}") + return result + + def _analyze_search_term_pattern(self, search_term_upper: str) -> set: + """Analyze search term pattern to determine likely markets.""" + markets = set() + + # A-share code pattern (6 digits starting with specific numbers) + if ( + search_term_upper.isdigit() + and len(search_term_upper) == 6 + and search_term_upper.startswith(("6", "0", "3", "8")) + ): + markets.add("a_shares") + + # HK stock code pattern + elif ( + search_term_upper.isdigit() and 1 <= len(search_term_upper) <= 5 + ) or search_term_upper.startswith("00"): + markets.add("hk_stocks") + + # US stock/crypto pattern (letters) + elif search_term_upper.isalpha() and len(search_term_upper) <= 5: + crypto_symbols = {"BTC", "ETH", "USDT", "BNB", "ADA", "XRP", "SOL", "DOT"} + if search_term_upper in crypto_symbols: + markets.add("crypto") + else: + markets.add("us_stocks") + + # Chinese names - prioritize A-shares + elif any("\u4e00" <= char <= "\u9fff" for char in search_term_upper): + markets.update(["a_shares", "hk_stocks"]) + + # Default case + else: + markets.update(["a_shares", "us_stocks", "hk_stocks", "crypto", "etfs"]) + + return markets + + def _search_a_shares_direct( + self, search_term: str, query: AssetSearchQuery + ) -> List[AssetSearchResult]: + """Search A-share stocks using direct queries.""" + results = [] + + # If search term looks like A-share code, try direct lookup + if self._is_a_share_code(search_term): + result = self._get_a_share_by_code(search_term) + if result: + results.append(result) + return results + + # For name searches, try fuzzy matching with common patterns + # This is a simplified approach - in production, you might want to use + # a search service or maintain a local index + if len(search_term) >= 2: # Only search if term is meaningful + # Try some common A-share codes that might match the search term + candidate_codes = self._generate_a_share_candidates(search_term) + + for code in candidate_codes[:query.limit]: + try: + result = self._get_a_share_by_code(code) + if result and self._matches_search_term(result, search_term): + results.append(result) + except Exception as e: + logger.debug(f"Failed to get A-share info for {code}: {e}") + continue + + return results + + def _is_a_share_code(self, search_term: str) -> bool: + """Check if search term looks like an A-share code.""" + return ( + search_term.isdigit() + and len(search_term) == 6 + and search_term.startswith(("6", "0", "3", "8")) + ) + + def _get_a_share_by_code(self, stock_code: str) -> Optional[AssetSearchResult]: + """Get A-share info by stock code using direct query.""" + try: + # Use individual stock info query + cache_key = f"a_share_info_{stock_code}" + df_info = self._get_cached_data( + cache_key, + self._safe_akshare_call, + ak.stock_individual_info_em, + symbol=stock_code + ) + + if df_info is None or df_info.empty: + return None + + # Extract stock name from info + info_dict = {} + for _, row in df_info.iterrows(): + info_dict[row["item"]] = row["value"] + + stock_name = info_dict.get("股票名称", stock_code) + + # Determine exchange from code + exchange_info = self._get_exchange_from_a_share_code(stock_code) + if not exchange_info: + return None + + exchange, internal_ticker = exchange_info + + return self._create_stock_search_result( + internal_ticker, + AssetType.STOCK, + stock_code, + stock_name, + exchange, + "CN", + "CNY", + stock_code, + ) + + except Exception as e: + logger.debug(f"Error getting A-share info for {stock_code}: {e}") + return None + + def _generate_a_share_candidates(self, search_term: str) -> List[str]: + """Generate candidate A-share codes based on search term.""" + candidates = [] + + # If it's a partial number, try to complete it + if search_term.isdigit() and len(search_term) < 6: + # Try common prefixes + for prefix in ["6", "0", "3"]: + if search_term.startswith(prefix) or not search_term.startswith(("6", "0", "3", "8")): + padded = search_term.ljust(6, '0') + if not search_term.startswith(("6", "0", "3", "8")): + candidates.extend([f"{prefix}{padded[1:]}" for prefix in ["6", "0", "3"]]) + else: + candidates.append(padded) + + # For Chinese names, we would need a mapping service + # For now, return some common stocks as examples + common_stocks = [ + "000001", # 平安银行 + "000002", # 万科A + "600000", # 浦发银行 + "600036", # 招商银行 + "600519", # 贵州茅台 + ] + + if not candidates and any("\u4e00" <= char <= "\u9fff" for char in search_term): + candidates.extend(common_stocks) + + return candidates[:10] # Limit candidates + + def _matches_search_term(self, result: AssetSearchResult, search_term: str) -> bool: + """Check if search result matches the search term.""" + search_lower = search_term.lower() + + # Check ticker + if search_lower in result.ticker.lower(): + return True + + # Check names + for name in result.names.values(): + if name and search_lower in name.lower(): + return True + + return False + + def _search_hk_stocks_direct( + self, search_term: str, query: AssetSearchQuery + ) -> List[AssetSearchResult]: + """Search Hong Kong stocks using direct queries.""" + results = [] + + # If search term looks like HK stock code, try direct lookup + if self._is_hk_stock_code(search_term): + result = self._get_hk_stock_by_code(search_term) + if result: + results.append(result) + return results + + # For other searches, try common HK stock codes + candidate_codes = self._generate_hk_stock_candidates(search_term) + + for code in candidate_codes[:query.limit]: + try: + result = self._get_hk_stock_by_code(code) + if result and self._matches_search_term(result, search_term): + results.append(result) + except Exception as e: + logger.debug(f"Failed to get HK stock info for {code}: {e}") + continue + + return results + + def _is_hk_stock_code(self, search_term: str) -> bool: + """Check if search term looks like a HK stock code.""" + return search_term.isdigit() and 1 <= len(search_term) <= 5 + + def _get_hk_stock_by_code(self, stock_code: str) -> Optional[AssetSearchResult]: + """Get HK stock info by stock code using direct query.""" + try: + # Format HK stock code + formatted_code = stock_code.zfill(5) if not stock_code.startswith("0") else stock_code + + # Try to get HK stock data - note: AKShare may not have direct individual HK stock query + # so we create a basic result based on code + internal_ticker = f"HKEX:{formatted_code}" + + # Create basic result - in production, you might want to query actual HK stock info + return self._create_stock_search_result( + internal_ticker, + AssetType.STOCK, + formatted_code, + f"HK{formatted_code}", # Basic name + "HKEX", + "HK", + "HKD", + stock_code, + ) + + except Exception as e: + logger.debug(f"Error getting HK stock info for {stock_code}: {e}") + return None + + def _generate_hk_stock_candidates(self, search_term: str) -> List[str]: + """Generate candidate HK stock codes based on search term.""" + candidates = [] + + # Common HK stocks + common_hk_stocks = [ + "00700", # 腾讯 + "00941", # 中国移动 + "01299", # 友邦保险 + "02318", # 中国平安 + "03988", # 中国银行 + ] + + if search_term.isdigit() and len(search_term) <= 5: + candidates.append(search_term.zfill(5)) + else: + candidates.extend(common_hk_stocks) + + return candidates[:10] + + def _search_us_stocks_direct( + self, search_term: str, query: AssetSearchQuery + ) -> List[AssetSearchResult]: + """Search US stocks using direct queries.""" + results = [] + + # If search term looks like US stock symbol, try direct lookup + if self._is_us_stock_symbol(search_term): + result = self._get_us_stock_by_symbol(search_term) + if result: + results.append(result) + return results + + # For other searches, try common US stock symbols + candidate_symbols = self._generate_us_stock_candidates(search_term) + + for symbol in candidate_symbols[:query.limit]: + try: + result = self._get_us_stock_by_symbol(symbol) + if result and self._matches_search_term(result, search_term): + results.append(result) + except Exception as e: + logger.debug(f"Failed to get US stock info for {symbol}: {e}") + continue + + return results + + def _is_us_stock_symbol(self, search_term: str) -> bool: + """Check if search term looks like a US stock symbol.""" + return search_term.isalpha() and 1 <= len(search_term) <= 5 + + def _get_us_stock_by_symbol(self, symbol: str) -> Optional[AssetSearchResult]: + """Get US stock info by symbol using direct query.""" + try: + # Create basic result - AKShare may not have direct individual US stock query + exchange = "NASDAQ" # Default to NASDAQ + internal_ticker = f"{exchange}:{symbol.upper()}" + + return self._create_stock_search_result( + internal_ticker, + AssetType.STOCK, + symbol.upper(), + symbol.upper(), # Basic name + exchange, + "US", + "USD", + symbol, + ) + + except Exception as e: + logger.debug(f"Error getting US stock info for {symbol}: {e}") + return None + + def _generate_us_stock_candidates(self, search_term: str) -> List[str]: + """Generate candidate US stock symbols based on search term.""" + candidates = [] + + # Common US stocks + common_us_stocks = [ + "AAPL", # Apple + "GOOGL", # Google + "MSFT", # Microsoft + "AMZN", # Amazon + "TSLA", # Tesla + ] + + if search_term.isalpha() and len(search_term) <= 5: + candidates.append(search_term.upper()) + else: + candidates.extend(common_us_stocks) + + return candidates[:10] + + def _search_crypto_direct( + self, search_term: str, query: AssetSearchQuery + ) -> List[AssetSearchResult]: + """Search cryptocurrencies using direct queries.""" + results = [] + + # If search term looks like crypto symbol, try direct lookup + if self._is_crypto_symbol(search_term): + result = self._get_crypto_by_symbol(search_term) + if result: + results.append(result) + return results + + # For other searches, try common crypto symbols + candidate_symbols = self._generate_crypto_candidates(search_term) + + for symbol in candidate_symbols[:query.limit]: + try: + result = self._get_crypto_by_symbol(symbol) + if result and self._matches_search_term(result, search_term): + results.append(result) + except Exception as e: + logger.debug(f"Failed to get crypto info for {symbol}: {e}") + continue + + return results + + def _is_crypto_symbol(self, search_term: str) -> bool: + """Check if search term looks like a crypto symbol.""" + common_crypto = {"BTC", "ETH", "USDT", "BNB", "ADA", "XRP", "SOL", "DOT"} + return search_term.upper() in common_crypto or ( + search_term.isalpha() and 2 <= len(search_term) <= 10 + ) + + def _get_crypto_by_symbol(self, symbol: str) -> Optional[AssetSearchResult]: + """Get crypto info by symbol using direct query.""" + try: + internal_ticker = f"CRYPTO:{symbol.upper()}" + + names = { + "zh-Hans": symbol.upper(), + "zh-Hant": symbol.upper(), + "en-US": symbol.upper(), + } + + return AssetSearchResult( + ticker=internal_ticker, + asset_type=AssetType.CRYPTO, + names=names, + exchange="CRYPTO", + country="GLOBAL", + currency="USD", + market_status=MarketStatus.UNKNOWN, + relevance_score=2.0, # High relevance for direct matches + ) + + except Exception as e: + logger.debug(f"Error getting crypto info for {symbol}: {e}") + return None + + def _generate_crypto_candidates(self, search_term: str) -> List[str]: + """Generate candidate crypto symbols based on search term.""" + candidates = [] + + # Common cryptocurrencies + common_cryptos = [ + "BTC", # Bitcoin + "ETH", # Ethereum + "USDT", # Tether + "BNB", # Binance Coin + "ADA", # Cardano + "XRP", # Ripple + "SOL", # Solana + "DOT", # Polkadot + ] + + if search_term.isalpha() and len(search_term) <= 10: + candidates.append(search_term.upper()) + + candidates.extend(common_cryptos) + + return candidates[:10] + + def _search_etfs_direct( + self, search_term: str, query: AssetSearchQuery + ) -> List[AssetSearchResult]: + """Search ETFs using direct queries.""" + results = [] + + # If search term looks like ETF code, try direct lookup + if self._is_etf_code(search_term): + result = self._get_etf_by_code(search_term) + if result: + results.append(result) + return results + + # For other searches, try common ETF codes + candidate_codes = self._generate_etf_candidates(search_term) + + for code in candidate_codes[:query.limit]: + try: + result = self._get_etf_by_code(code) + if result and self._matches_search_term(result, search_term): + results.append(result) + except Exception as e: + logger.debug(f"Failed to get ETF info for {code}: {e}") + continue + + return results + + def _is_etf_code(self, search_term: str) -> bool: + """Check if search term looks like an ETF code.""" + return ( + search_term.isdigit() + and len(search_term) == 6 + and search_term.startswith(("5", "1")) + ) + + def _get_etf_by_code(self, fund_code: str) -> Optional[AssetSearchResult]: + """Get ETF info by code using direct query.""" + try: + # Determine exchange for funds + exchange = "SSE" if fund_code.startswith("5") else "SZSE" + internal_ticker = f"{exchange}:{fund_code}" + + # Create basic result - in production, you might want to query actual ETF info + names = { + "zh-Hans": f"ETF{fund_code}", + "zh-Hant": f"ETF{fund_code}", + "en-US": f"ETF{fund_code}", + } + + return AssetSearchResult( + ticker=internal_ticker, + asset_type=AssetType.ETF, + names=names, + exchange=exchange, + country="CN", + currency="CNY", + market_status=MarketStatus.UNKNOWN, + relevance_score=2.0, # High relevance for direct matches + ) + + except Exception as e: + logger.debug(f"Error getting ETF info for {fund_code}: {e}") + return None + + def _generate_etf_candidates(self, search_term: str) -> List[str]: + """Generate candidate ETF codes based on search term.""" + candidates = [] + + # Common ETFs + common_etfs = [ + "510050", # 50ETF + "510300", # 沪深300ETF + "159919", # 沪深300ETF + "510500", # 中证500ETF + "159915", # 创业板ETF + ] + + if search_term.isdigit() and len(search_term) == 6: + candidates.append(search_term) + else: + candidates.extend(common_etfs) + + return candidates[:10] + def _calculate_relevance(self, search_term: str, code: str, name: str) -> float: """Calculate relevance score for search results.""" search_term_lower = search_term.lower() @@ -254,80 +906,191 @@ def get_asset_info(self, ticker: str) -> Optional[Asset]: try: exchange, symbol = ticker.split(":") - # Get stock individual info - try: - df_info = ak.stock_individual_info_em(symbol=symbol) + # Handle different markets + if exchange in ["SSE", "SZSE", "BSE"]: + return self._get_a_share_info(ticker, exchange, symbol) + elif exchange == "HKEX": + return self._get_hk_stock_info(ticker, exchange, symbol) + elif exchange in ["NASDAQ", "NYSE"]: + return self._get_us_stock_info(ticker, exchange, symbol) + elif exchange == "CRYPTO": + return self._get_crypto_info(ticker, exchange, symbol) + else: + logger.warning(f"Unsupported exchange: {exchange}") + return None - if df_info is None or df_info.empty: - return None + except Exception as e: + logger.error(f"Error getting asset info for {ticker}: {e}") + return None - # Convert DataFrame to dict for easier access - info_dict = {} - for _, row in df_info.iterrows(): - info_dict[row["item"]] = row["value"] - - # Create localized names - names = LocalizedName() - stock_name = info_dict.get("股票名称", symbol) - names.set_name("zh-Hans", stock_name) - names.set_name("zh-Hant", stock_name) - names.set_name("en-US", stock_name) - - # Create market info - market_info = MarketInfo( - exchange=exchange, - country="CN", - currency="CNY", - timezone="Asia/Shanghai", - ) + def _get_a_share_info( + self, ticker: str, exchange: str, symbol: str + ) -> Optional[Asset]: + """Get A-share stock information.""" + try: + df_info = ak.stock_individual_info_em(symbol=symbol) - # Create asset - asset = Asset( - ticker=ticker, - asset_type=AssetType.STOCK, - names=names, - market_info=market_info, - ) + if df_info is None or df_info.empty: + return None - # Set source mapping - asset.set_source_ticker(self.source, symbol) - - # Add additional properties from AKShare - properties = { - "stock_name": info_dict.get("股票名称"), - "stock_code": info_dict.get("股票代码"), - "listing_date": info_dict.get("上市时间"), - "total_share_capital": info_dict.get("总股本"), - "circulating_share_capital": info_dict.get("流通股本"), - "industry": info_dict.get("所处行业"), - "main_business": info_dict.get("主营业务"), - "business_scope": info_dict.get("经营范围"), - "chairman": info_dict.get("董事长"), - "general_manager": info_dict.get("总经理"), - "secretary": info_dict.get("董秘"), - "registered_capital": info_dict.get("注册资本"), - "employees": info_dict.get("员工人数"), - "province": info_dict.get("所属省份"), - "city": info_dict.get("所属城市"), - "office_address": info_dict.get("办公地址"), - "company_website": info_dict.get("公司网址"), - "email": info_dict.get("电子邮箱"), - "main_business_income": info_dict.get("主营业务收入"), - "net_profit": info_dict.get("净利润"), - } - - # Filter out None values - properties = {k: v for k, v in properties.items() if v is not None} - asset.properties.update(properties) - - return asset + # Convert DataFrame to dict for easier access + info_dict = {} + for _, row in df_info.iterrows(): + info_dict[row["item"]] = row["value"] + + # Create localized names + names = LocalizedName() + stock_name = info_dict.get("股票名称", symbol) + names.set_name("zh-Hans", stock_name) + names.set_name("zh-Hant", stock_name) + names.set_name("en-US", stock_name) + + # Create market info + market_info = MarketInfo( + exchange=exchange, + country="CN", + currency="CNY", + timezone="Asia/Shanghai", + ) - except Exception as e: - logger.error(f"Error fetching individual stock info for {symbol}: {e}") - return None + # Create asset + asset = Asset( + ticker=ticker, + asset_type=AssetType.STOCK, + names=names, + market_info=market_info, + ) + + # Set source mapping + asset.set_source_ticker(self.source, symbol) + + # Add additional properties from AKShare + properties = { + "stock_name": info_dict.get("股票名称"), + "stock_code": info_dict.get("股票代码"), + "listing_date": info_dict.get("上市时间"), + "total_share_capital": info_dict.get("总股本"), + "circulating_share_capital": info_dict.get("流通股本"), + "industry": info_dict.get("所处行业"), + "main_business": info_dict.get("主营业务"), + "business_scope": info_dict.get("经营范围"), + "chairman": info_dict.get("董事长"), + "general_manager": info_dict.get("总经理"), + "secretary": info_dict.get("董秘"), + "registered_capital": info_dict.get("注册资本"), + "employees": info_dict.get("员工人数"), + "province": info_dict.get("所属省份"), + "city": info_dict.get("所属城市"), + "office_address": info_dict.get("办公地址"), + "company_website": info_dict.get("公司网址"), + "email": info_dict.get("电子邮箱"), + "main_business_income": info_dict.get("主营业务收入"), + "net_profit": info_dict.get("净利润"), + } + + # Filter out None values + properties = {k: v for k, v in properties.items() if v is not None} + asset.properties.update(properties) + + return asset except Exception as e: - logger.error(f"Error getting asset info for {ticker}: {e}") + logger.error(f"Error fetching A-share info for {symbol}: {e}") + return None + + def _get_hk_stock_info( + self, ticker: str, exchange: str, symbol: str + ) -> Optional[Asset]: + """Get Hong Kong stock information.""" + try: + # For HK stocks, we'll create basic info since detailed info API may be limited + names = LocalizedName() + names.set_name("zh-Hans", symbol) + names.set_name("zh-Hant", symbol) + names.set_name("en-US", symbol) + + market_info = MarketInfo( + exchange=exchange, + country="HK", + currency="HKD", + timezone="Asia/Hong_Kong", + ) + + asset = Asset( + ticker=ticker, + asset_type=AssetType.STOCK, + names=names, + market_info=market_info, + ) + + asset.set_source_ticker(self.source, symbol) + return asset + + except Exception as e: + logger.error(f"Error creating HK stock info for {symbol}: {e}") + return None + + def _get_us_stock_info( + self, ticker: str, exchange: str, symbol: str + ) -> Optional[Asset]: + """Get US stock information.""" + try: + # For US stocks, we'll create basic info since detailed info API may be limited + names = LocalizedName() + names.set_name("zh-Hans", symbol) + names.set_name("zh-Hant", symbol) + names.set_name("en-US", symbol) + + market_info = MarketInfo( + exchange=exchange, + country="US", + currency="USD", + timezone="America/New_York", + ) + + asset = Asset( + ticker=ticker, + asset_type=AssetType.STOCK, + names=names, + market_info=market_info, + ) + + asset.set_source_ticker(self.source, symbol) + return asset + + except Exception as e: + logger.error(f"Error creating US stock info for {symbol}: {e}") + return None + + def _get_crypto_info( + self, ticker: str, exchange: str, symbol: str + ) -> Optional[Asset]: + """Get cryptocurrency information.""" + try: + names = LocalizedName() + names.set_name("zh-Hans", symbol) + names.set_name("zh-Hant", symbol) + names.set_name("en-US", symbol) + + market_info = MarketInfo( + exchange=exchange, + country="GLOBAL", + currency="USD", + timezone="UTC", + ) + + asset = Asset( + ticker=ticker, + asset_type=AssetType.CRYPTO, + names=names, + market_info=market_info, + ) + + asset.set_source_ticker(self.source, symbol) + return asset + + except Exception as e: + logger.error(f"Error creating crypto info for {symbol}: {e}") return None def get_real_time_price(self, ticker: str) -> Optional[AssetPrice]: @@ -335,64 +1098,258 @@ def get_real_time_price(self, ticker: str) -> Optional[AssetPrice]: try: exchange, symbol = ticker.split(":") - # Get real-time stock data - try: - df_realtime = ak.stock_zh_a_spot_em() + # Handle different markets + if exchange in ["SSE", "SZSE", "BSE"]: + return self._get_a_share_price(ticker, exchange, symbol) + elif exchange == "HKEX": + return self._get_hk_stock_price(ticker, exchange, symbol) + elif exchange in ["NASDAQ", "NYSE"]: + return self._get_us_stock_price(ticker, exchange, symbol) + elif exchange == "CRYPTO": + return self._get_crypto_price(ticker, exchange, symbol) + else: + logger.warning(f"Unsupported exchange for real-time price: {exchange}") + return None - if df_realtime is None or df_realtime.empty: - return None + except Exception as e: + logger.error(f"Error getting real-time price for {ticker}: {e}") + return None - # Find the specific stock - stock_data = df_realtime[df_realtime["代码"] == symbol] + def _get_a_share_price( + self, ticker: str, exchange: str, symbol: str + ) -> Optional[AssetPrice]: + """Get A-share real-time price using direct query.""" + try: + # Use direct real-time price query for individual stock + cache_key = f"a_share_price_{symbol}" + df_realtime = self._get_cached_data( + cache_key, + self._safe_akshare_call, + ak.stock_zh_a_spot_em, + symbol=symbol + ) + if df_realtime is None or df_realtime.empty: + # Fallback to individual stock info if spot price fails + return self._get_a_share_price_from_info(ticker, exchange, symbol) + + # If we get multiple results, find the matching one + if len(df_realtime) > 1: + stock_data = df_realtime[df_realtime["代码"] == symbol] if stock_data.empty: - return None + stock_info = df_realtime.iloc[0] # Use first result as fallback + else: + stock_info = stock_data.iloc[0] + else: + stock_info = df_realtime.iloc[0] + + # Extract price information using safe field access + current_price = self._safe_decimal_convert(stock_info.get("最新价", 0)) + open_price = self._safe_decimal_convert(stock_info.get("今开", 0)) + high_price = self._safe_decimal_convert(stock_info.get("最高", 0)) + low_price = self._safe_decimal_convert(stock_info.get("最低", 0)) + pre_close = self._safe_decimal_convert(stock_info.get("昨收", 0)) + + # Calculate change + change = current_price - pre_close if current_price and pre_close else None + change_percent = ( + (change / pre_close) * 100 + if change and pre_close and pre_close != 0 + else None + ) - stock_info = stock_data.iloc[0] + # Get volume and market cap + volume = self._safe_decimal_convert(stock_info.get("成交量")) + market_cap = self._safe_decimal_convert(stock_info.get("总市值")) + + return AssetPrice( + ticker=ticker, + price=current_price, + currency="CNY", + timestamp=datetime.now(), + volume=volume, + open_price=open_price, + high_price=high_price, + low_price=low_price, + close_price=current_price, + change=change, + change_percent=change_percent, + market_cap=market_cap, + source=self.source, + ) - # Extract price information - current_price = Decimal(str(stock_info["最新价"])) - open_price = Decimal(str(stock_info["今开"])) - high_price = Decimal(str(stock_info["最高"])) - low_price = Decimal(str(stock_info["最低"])) - pre_close = Decimal(str(stock_info["昨收"])) + except Exception as e: + logger.error(f"Error fetching A-share price for {symbol}: {e}") + return None + + def _get_a_share_price_from_info( + self, ticker: str, exchange: str, symbol: str + ) -> Optional[AssetPrice]: + """Get A-share price from individual stock info as fallback.""" + try: + # Try to get basic price info from stock individual info + df_info = self._safe_akshare_call(ak.stock_individual_info_em, symbol=symbol) + + if df_info is None or df_info.empty: + return None + + # Create basic price info + return AssetPrice( + ticker=ticker, + price=Decimal("0"), # Placeholder + currency="CNY", + timestamp=datetime.now(), + volume=None, + open_price=None, + high_price=None, + low_price=None, + close_price=Decimal("0"), + change=None, + change_percent=None, + market_cap=None, + source=self.source, + ) + + except Exception as e: + logger.error(f"Error fetching A-share info price for {symbol}: {e}") + return None + + def _safe_decimal_convert(self, value) -> Optional[Decimal]: + """Safely convert value to Decimal.""" + if value is None or value == "": + return None + try: + return Decimal(str(value)) + except (ValueError, TypeError, decimal.InvalidOperation): + return None - # Calculate change - change = current_price - pre_close - change_percent = ( - (change / pre_close) * 100 if pre_close else Decimal("0") - ) + def _get_hk_stock_price( + self, ticker: str, exchange: str, symbol: str + ) -> Optional[AssetPrice]: + """Get Hong Kong stock real-time price.""" + try: + df_hk_realtime = ak.stock_hk_spot() - # Get volume and market cap - volume = ( - Decimal(str(stock_info["成交量"])) if stock_info["成交量"] else None - ) - market_cap = ( - Decimal(str(stock_info["总市值"])) if stock_info["总市值"] else None - ) + if df_hk_realtime is None or df_hk_realtime.empty: + return None - return AssetPrice( - ticker=ticker, - price=current_price, - currency="CNY", - timestamp=datetime.now(), # AKShare doesn't provide exact timestamp - volume=volume, - open_price=open_price, - high_price=high_price, - low_price=low_price, - close_price=current_price, - change=change, - change_percent=change_percent, - market_cap=market_cap, - source=self.source, - ) + # Find the specific stock + stock_data = df_hk_realtime[df_hk_realtime["symbol"] == symbol] - except Exception as e: - logger.error(f"Error fetching real-time price for {symbol}: {e}") + if stock_data.empty: return None + stock_info = stock_data.iloc[0] + + # Extract price information (adjust field names based on actual data structure) + current_price = Decimal( + str(stock_info.get("price", stock_info.get("last", 0))) + ) + + return AssetPrice( + ticker=ticker, + price=current_price, + currency="HKD", + timestamp=datetime.now(), + volume=None, # May not be available in real-time data + open_price=None, + high_price=None, + low_price=None, + close_price=current_price, + change=None, + change_percent=None, + market_cap=None, + source=self.source, + ) + except Exception as e: - logger.error(f"Error getting real-time price for {ticker}: {e}") + logger.error(f"Error fetching HK stock price for {symbol}: {e}") + return None + + def _get_us_stock_price( + self, ticker: str, exchange: str, symbol: str + ) -> Optional[AssetPrice]: + """Get US stock real-time price.""" + try: + df_us_realtime = ak.stock_us_spot() + + if df_us_realtime is None or df_us_realtime.empty: + return None + + # Find the specific stock + stock_data = df_us_realtime[df_us_realtime["代码"] == symbol] + + if stock_data.empty: + return None + + stock_info = stock_data.iloc[0] + + # Extract price information + current_price = Decimal( + str(stock_info.get("最新价", stock_info.get("price", 0))) + ) + + return AssetPrice( + ticker=ticker, + price=current_price, + currency="USD", + timestamp=datetime.now(), + volume=None, + open_price=None, + high_price=None, + low_price=None, + close_price=current_price, + change=None, + change_percent=None, + market_cap=None, + source=self.source, + ) + + except Exception as e: + logger.error(f"Error fetching US stock price for {symbol}: {e}") + return None + + def _get_crypto_price( + self, ticker: str, exchange: str, symbol: str + ) -> Optional[AssetPrice]: + """Get cryptocurrency real-time price.""" + try: + df_crypto_realtime = ak.crypto_js_spot() + + if df_crypto_realtime is None or df_crypto_realtime.empty: + return None + + # Find the specific cryptocurrency + crypto_data = df_crypto_realtime[df_crypto_realtime["symbol"] == symbol] + + if crypto_data.empty: + return None + + crypto_info = crypto_data.iloc[0] + + # Extract price information + current_price = Decimal( + str(crypto_info.get("price", crypto_info.get("last", 0))) + ) + + return AssetPrice( + ticker=ticker, + price=current_price, + currency="USD", + timestamp=datetime.now(), + volume=None, + open_price=None, + high_price=None, + low_price=None, + close_price=current_price, + change=None, + change_percent=None, + market_cap=None, + source=self.source, + ) + + except Exception as e: + logger.error(f"Error fetching crypto price for {symbol}: {e}") return None def get_historical_prices( @@ -406,6 +1363,42 @@ def get_historical_prices( try: exchange, symbol = ticker.split(":") + # Handle different markets + if exchange in ["SSE", "SZSE", "BSE"]: + return self._get_a_share_historical( + ticker, exchange, symbol, start_date, end_date, interval + ) + elif exchange == "HKEX": + return self._get_hk_stock_historical( + ticker, exchange, symbol, start_date, end_date, interval + ) + elif exchange in ["NASDAQ", "NYSE"]: + return self._get_us_stock_historical( + ticker, exchange, symbol, start_date, end_date, interval + ) + elif exchange == "CRYPTO": + return self._get_crypto_historical( + ticker, exchange, symbol, start_date, end_date, interval + ) + else: + logger.warning(f"Unsupported exchange for historical data: {exchange}") + return [] + + except Exception as e: + logger.error(f"Error getting historical prices for {ticker}: {e}") + return [] + + def _get_a_share_historical( + self, + ticker: str, + exchange: str, + symbol: str, + start_date: datetime, + end_date: datetime, + interval: str, + ) -> List[AssetPrice]: + """Get A-share historical price data using direct query.""" + try: # Format dates for AKShare start_date_str = start_date.strftime("%Y%m%d") end_date_str = end_date.strftime("%Y%m%d") @@ -419,40 +1412,47 @@ def get_historical_prices( ) period = "daily" - # Get historical data - try: - df_hist = ak.stock_zh_a_hist( - symbol=symbol, - period=period, - start_date=start_date_str, - end_date=end_date_str, - adjust="", # No adjustment - ) + # Use cached data for historical prices + cache_key = f"a_share_hist_{symbol}_{start_date_str}_{end_date_str}_{period}" + df_hist = self._get_cached_data( + cache_key, + self._safe_akshare_call, + ak.stock_zh_a_hist, + symbol=symbol, + period=period, + start_date=start_date_str, + end_date=end_date_str, + adjust="", # No adjustment + ) - if df_hist is None or df_hist.empty: - return [] + if df_hist is None or df_hist.empty: + logger.warning(f"No historical data available for {symbol}") + return [] - prices = [] - for _, row in df_hist.iterrows(): - # Parse date + prices = [] + for _, row in df_hist.iterrows(): + try: + # Parse date safely trade_date = pd.to_datetime(row["日期"]).to_pydatetime() - # Extract price data - open_price = Decimal(str(row["开盘"])) - high_price = Decimal(str(row["最高"])) - low_price = Decimal(str(row["最低"])) - close_price = Decimal(str(row["收盘"])) - volume = Decimal(str(row["成交量"])) if row["成交量"] else None + # Extract price data safely + open_price = self._safe_decimal_convert(row.get("开盘")) + high_price = self._safe_decimal_convert(row.get("最高")) + low_price = self._safe_decimal_convert(row.get("最低")) + close_price = self._safe_decimal_convert(row.get("收盘")) + volume = self._safe_decimal_convert(row.get("成交量")) + + if not close_price: # Skip if no closing price + continue # Calculate change from previous day change = None change_percent = None if len(prices) > 0: prev_close = prices[-1].close_price - change = close_price - prev_close - change_percent = ( - (change / prev_close) * 100 if prev_close else Decimal("0") - ) + if prev_close and prev_close != 0: + change = close_price - prev_close + change_percent = (change / prev_close) * 100 price = AssetPrice( ticker=ticker, @@ -469,15 +1469,150 @@ def get_historical_prices( source=self.source, ) prices.append(price) + + except Exception as row_error: + logger.warning(f"Error processing historical data row for {symbol}: {row_error}") + continue - return prices + logger.info(f"Retrieved {len(prices)} historical price points for {symbol}") + return prices - except Exception as e: - logger.error(f"Error fetching historical data for {symbol}: {e}") + except Exception as e: + logger.error(f"Error fetching A-share historical data for {symbol}: {e}") + return [] + + def _get_hk_stock_historical( + self, + ticker: str, + exchange: str, + symbol: str, + start_date: datetime, + end_date: datetime, + interval: str, + ) -> List[AssetPrice]: + """Get Hong Kong stock historical price data.""" + try: + # Use AKShare HK stock historical data + df_hist = ak.stock_hk_daily(symbol=symbol, adjust="qfq") + + if df_hist is None or df_hist.empty: return [] + # Filter by date range + df_hist["date"] = pd.to_datetime(df_hist["date"]) + mask = (df_hist["date"] >= start_date) & (df_hist["date"] <= end_date) + df_hist = df_hist[mask] + + prices = [] + for _, row in df_hist.iterrows(): + trade_date = row["date"].to_pydatetime() + + # Extract price data (adjust field names based on actual data structure) + open_price = Decimal(str(row.get("open", 0))) + high_price = Decimal(str(row.get("high", 0))) + low_price = Decimal(str(row.get("low", 0))) + close_price = Decimal(str(row.get("close", 0))) + volume = ( + Decimal(str(row.get("volume", 0))) if row.get("volume") else None + ) + + price = AssetPrice( + ticker=ticker, + price=close_price, + currency="HKD", + timestamp=trade_date, + volume=volume, + open_price=open_price, + high_price=high_price, + low_price=low_price, + close_price=close_price, + change=None, + change_percent=None, + source=self.source, + ) + prices.append(price) + + return prices + except Exception as e: - logger.error(f"Error getting historical prices for {ticker}: {e}") + logger.error(f"Error fetching HK stock historical data for {symbol}: {e}") + return [] + + def _get_us_stock_historical( + self, + ticker: str, + exchange: str, + symbol: str, + start_date: datetime, + end_date: datetime, + interval: str, + ) -> List[AssetPrice]: + """Get US stock historical price data.""" + try: + # Use AKShare US stock historical data + df_hist = ak.stock_us_daily(symbol=symbol, adjust="qfq") + + if df_hist is None or df_hist.empty: + return [] + + # Filter by date range + df_hist["date"] = pd.to_datetime(df_hist["date"]) + mask = (df_hist["date"] >= start_date) & (df_hist["date"] <= end_date) + df_hist = df_hist[mask] + + prices = [] + for _, row in df_hist.iterrows(): + trade_date = row["date"].to_pydatetime() + + # Extract price data + open_price = Decimal(str(row.get("open", 0))) + high_price = Decimal(str(row.get("high", 0))) + low_price = Decimal(str(row.get("low", 0))) + close_price = Decimal(str(row.get("close", 0))) + volume = ( + Decimal(str(row.get("volume", 0))) if row.get("volume") else None + ) + + price = AssetPrice( + ticker=ticker, + price=close_price, + currency="USD", + timestamp=trade_date, + volume=volume, + open_price=open_price, + high_price=high_price, + low_price=low_price, + close_price=close_price, + change=None, + change_percent=None, + source=self.source, + ) + prices.append(price) + + return prices + + except Exception as e: + logger.error(f"Error fetching US stock historical data for {symbol}: {e}") + return [] + + def _get_crypto_historical( + self, + ticker: str, + exchange: str, + symbol: str, + start_date: datetime, + end_date: datetime, + interval: str, + ) -> List[AssetPrice]: + """Get cryptocurrency historical price data.""" + try: + # Note: AKShare crypto historical data may be limited + # For now, return empty list as detailed crypto historical API needs to be investigated + logger.warning(f"Crypto historical data not yet implemented for {symbol}") + return [] + + except Exception as e: + logger.error(f"Error fetching crypto historical data for {symbol}: {e}") return [] def get_supported_asset_types(self) -> List[AssetType]: @@ -485,44 +1620,149 @@ def get_supported_asset_types(self) -> List[AssetType]: return [ AssetType.STOCK, AssetType.ETF, - # AssetType.BOND, AssetType.INDEX, + AssetType.CRYPTO, ] def _perform_health_check(self) -> Any: - """Perform health check by fetching stock list.""" + """Perform health check by testing multiple market endpoints.""" try: - # Test with a simple query to get stock list - df = ak.stock_zh_a_spot_em() - - if df is not None and not df.empty: - return { - "status": "ok", - "stocks_count": len(df), - "sample_stock": df.iloc[0]["代码"] if len(df) > 0 else None, - } - else: - return {"status": "error", "message": "No data received"} + # Test endpoints with their corresponding functions + test_endpoints = [ + ("a_shares", ak.stock_zh_a_spot_em), + ("hk_stocks", ak.stock_hk_spot), + ("us_stocks", ak.stock_us_spot), + ("crypto", ak.crypto_js_spot), + ] + + results = {} + for market_name, test_func in test_endpoints: + try: + df = test_func() + results[market_name] = { + "status": "ok" if df is not None and not df.empty else "error", + "count": len(df) if df is not None else 0, + } + except Exception as e: + results[market_name] = {"status": "error", "message": str(e)} + + # Overall status + overall_status = ( + "ok" + if any(r.get("status") == "ok" for r in results.values()) + else "error" + ) + + return {"status": overall_status, "markets": results} except Exception as e: return {"status": "error", "message": str(e)} + def _looks_like_ticker(self, search_term: str) -> bool: + """Check if search term looks like a ticker symbol.""" + search_term = search_term.upper().strip() + + # Combined heuristics for ticker-like patterns + return (len(search_term) <= 6 and search_term.isalnum()) or ( + len(search_term) <= 10 and search_term.isalpha() + ) + + def _search_by_direct_ticker_lookup( + self, search_term: str, query: AssetSearchQuery + ) -> List[AssetSearchResult]: + """Search by direct ticker lookup as fallback for semantic search. + + This method provides a yfinance-like approach for cases where AKShare + doesn't have comprehensive search capabilities. + """ + search_term = search_term.upper().strip() + + # Generate ticker variations based on search term characteristics + ticker_variations = self._generate_ticker_variations(search_term) + + for ticker_format in ticker_variations: + try: + # Try to get asset info to validate the ticker + asset_info = self.get_asset_info(ticker_format) + if asset_info: + # Create search result from asset info + result = AssetSearchResult( + ticker=ticker_format, + asset_type=asset_info.asset_type, + names={ + "zh-Hans": asset_info.names.get_name("zh-Hans") + or search_term, + "zh-Hant": asset_info.names.get_name("zh-Hant") + or search_term, + "en-US": asset_info.names.get_name("en-US") or search_term, + }, + exchange=asset_info.market_info.exchange, + country=asset_info.market_info.country, + currency=asset_info.market_info.currency, + market_status=MarketStatus.UNKNOWN, + relevance_score=2.0, # High relevance for direct matches + ) + return [result] # Return immediately on first match + + except Exception as e: + logger.debug(f"Ticker lookup failed for {ticker_format}: {e}") + continue + + return [] + + def _generate_ticker_variations(self, search_term: str) -> List[str]: + """Generate ticker variations based on search term characteristics.""" + variations = [search_term] # Direct ticker first + + # A-share variations (6 digits) + if search_term.isdigit() and len(search_term) == 6: + if search_term.startswith("6"): + variations.append(f"SSE:{search_term}") + elif search_term.startswith(("0", "3")): + variations.append(f"SZSE:{search_term}") + elif search_term.startswith("8"): + variations.append(f"BSE:{search_term}") + + # HK variations (digits, potentially short) + elif search_term.isdigit() and 1 <= len(search_term) <= 5: + variations.extend( + [ + f"HKEX:{search_term}", + f"HKEX:{search_term.zfill(5)}", # Pad with zeros + ] + ) + + # US/Crypto variations (letters) + elif search_term.isalpha(): + variations.extend( + [ + f"NASDAQ:{search_term}", + f"NYSE:{search_term}", + f"CRYPTO:{search_term}", + f"CRYPTO:{search_term}USD", + ] + ) + + return variations + def validate_ticker(self, ticker: str) -> bool: - """Validate if ticker is supported by AKShare (Chinese markets only).""" + """Validate if ticker is supported by AKShare.""" try: exchange, symbol = ticker.split(":", 1) - # AKShare supports Chinese exchanges - supported_exchanges = ["SSE", "SZSE", "BSE"] + # Exchange validation rules + validation_rules = { + "SSE": lambda s: s.isdigit() and len(s) == 6, + "SZSE": lambda s: s.isdigit() and len(s) == 6, + "BSE": lambda s: s.isdigit() and len(s) == 6, + "HKEX": lambda s: s.isdigit() and 1 <= len(s) <= 5, + "NASDAQ": lambda s: 1 <= len(s) <= 5, + "NYSE": lambda s: 1 <= len(s) <= 5, + "CRYPTO": lambda s: 2 <= len(s) <= 10, + } - if exchange not in supported_exchanges: - return False - - # Validate symbol format (6 digits for Chinese stocks) - if not symbol.isdigit() or len(symbol) != 6: - return False - - return True + validator = validation_rules.get(exchange) + return validator(symbol) if validator else False except ValueError: return False @@ -581,32 +1821,25 @@ def get_sector_stocks(self, sector: str) -> List[AssetSearchResult]: stock_name = stock_row["名称"] # Determine exchange - if stock_code.startswith("6"): - exchange = "SSE" - internal_ticker = f"SSE:{stock_code}" - elif stock_code.startswith(("0", "3")): - exchange = "SZSE" - internal_ticker = f"SZSE:{stock_code}" - else: + exchange_info = self._get_exchange_from_a_share_code( + stock_code + ) + if not exchange_info: continue - names = { - "zh-Hans": stock_name, - "zh-Hant": stock_name, - "en-US": stock_name, - } - - result = AssetSearchResult( - ticker=internal_ticker, - asset_type=AssetType.STOCK, - names=names, - exchange=exchange, - country="CN", - currency="CNY", - market_status=MarketStatus.UNKNOWN, - relevance_score=1.0, + exchange, internal_ticker = exchange_info + + result = self._create_stock_search_result( + internal_ticker, + AssetType.STOCK, + stock_code, + stock_name, + exchange, + "CN", + "CNY", + "", ) - + result.relevance_score = 1.0 # Override for sector search results.append(result) except Exception as e: @@ -622,27 +1855,40 @@ def get_sector_stocks(self, sector: str) -> List[AssetSearchResult]: return [] def is_market_open(self, exchange: str) -> bool: - """Check if Chinese market is currently open.""" - if exchange not in ["SSE", "SZSE", "BSE"]: + """Check if market is currently open.""" + now = datetime.utcnow() + + # Market configurations: (timezone_offset, trading_sessions) + market_config = { + "SSE": (8, [("09:30", "11:30"), ("13:00", "15:00")]), + "SZSE": (8, [("09:30", "11:30"), ("13:00", "15:00")]), + "BSE": (8, [("09:30", "11:30"), ("13:00", "15:00")]), + "HKEX": (8, [("09:30", "12:00"), ("13:00", "16:00")]), + "NASDAQ": (-5, [("09:30", "16:00")]), + "NYSE": (-5, [("09:30", "16:00")]), + "CRYPTO": (0, [("00:00", "23:59")]), # Always open + } + + if exchange not in market_config: return False - # Chinese market hours: 9:30-11:30, 13:00-15:00 (GMT+8) - now = datetime.utcnow() - # Convert to Beijing time (UTC+8) - beijing_time = now.replace(tzinfo=None) + timedelta(hours=8) + if exchange == "CRYPTO": + return True + + timezone_offset, sessions = market_config[exchange] + local_time = now.replace(tzinfo=None) + timedelta(hours=timezone_offset) # Check if it's a weekday - if beijing_time.weekday() >= 5: # Saturday = 5, Sunday = 6 + if local_time.weekday() >= 5: return False - # Check trading hours - current_time = beijing_time.time() - morning_open = datetime.strptime("09:30", "%H:%M").time() - morning_close = datetime.strptime("11:30", "%H:%M").time() - afternoon_open = datetime.strptime("13:00", "%H:%M").time() - afternoon_close = datetime.strptime("15:00", "%H:%M").time() + current_time = local_time.time() - return ( - morning_open <= current_time <= morning_close - or afternoon_open <= current_time <= afternoon_close - ) + # Check if current time falls within any trading session + for start_str, end_str in sessions: + start_time = datetime.strptime(start_str, "%H:%M").time() + end_time = datetime.strptime(end_str, "%H:%M").time() + if start_time <= current_time <= end_time: + return True + + return False diff --git a/python/valuecell/adapters/assets/manager.py b/python/valuecell/adapters/assets/manager.py index 7d64b93af..9a6fa1ecd 100644 --- a/python/valuecell/adapters/assets/manager.py +++ b/python/valuecell/adapters/assets/manager.py @@ -233,7 +233,7 @@ def search_assets(self, query: AssetSearchQuery) -> List[AssetSearchResult]: # Search in parallel across adapters if not target_adapters: return [] - + with ThreadPoolExecutor(max_workers=len(target_adapters)) as executor: future_to_adapter = { executor.submit(adapter.search_assets, query): adapter diff --git a/python/valuecell/examples/asset_adapter_example.py b/python/valuecell/examples/asset_adapter_example.py index 1945c4046..fedef226d 100644 --- a/python/valuecell/examples/asset_adapter_example.py +++ b/python/valuecell/examples/asset_adapter_example.py @@ -30,11 +30,11 @@ def setup_adapters(): manager = get_adapter_manager() # Configure Yahoo Finance (free, no API key required) - try: - manager.configure_yfinance() - logger.info("✓ Yahoo Finance adapter configured") - except Exception as e: - logger.warning(f"✗ Yahoo Finance adapter failed: {e}") + # try: + # manager.configure_yfinance() + # logger.info("✓ Yahoo Finance adapter configured") + # except Exception as e: + # logger.warning(f"✗ Yahoo Finance adapter failed: {e}") # Configure TuShare (requires API key) try: @@ -61,9 +61,12 @@ def setup_adapters(): logger.warning(f"✗ CoinMarketCap adapter failed: {e}") # Configure AKShare (free, no API key required) + # Now supports US stocks, Hong Kong stocks, and cryptocurrencies try: manager.configure_akshare() - logger.info("✓ AKShare adapter configured") + logger.info( + "✓ AKShare adapter configured (supports A-shares, HK stocks, US stocks, and crypto)" + ) except Exception as e: logger.warning(f"✗ AKShare adapter failed: {e}") @@ -136,6 +139,34 @@ def demonstrate_asset_search(): for result in results_crypto["results"]: logger.info(f" - {result['ticker']}: {result['display_name']}") + # Demonstrate enhanced AKShare multi-market search + logger.info("\n=== Enhanced Multi-Market Search (AKShare) ===") + + # Search Hong Kong stocks + logger.info("Searching for Hong Kong stocks (Tencent)...") + hk_results = search_assets("00700", limit=3) + if hk_results["success"]: + for result in hk_results["results"]: + logger.info(f" - HK Stock: {result['ticker']}: {result['display_name']}") + + # Search US stocks through AKShare + logger.info("\nSearching for US stocks through AKShare...") + us_results = search_assets("AAPL", exchanges=["NASDAQ", "NYSE"], limit=3) + if us_results["success"]: + for result in us_results["results"]: + logger.info(f" - US Stock: {result['ticker']}: {result['display_name']}") + + # Direct ticker lookup fallback + logger.info("\nDemonstrating semantic search fallback...") + direct_results = search_assets( + "000001", limit=3 + ) # Should find through direct lookup + if direct_results["success"]: + for result in direct_results["results"]: + logger.info( + f" - Direct Match: {result['ticker']}: {result['display_name']}" + ) + def demonstrate_asset_info(): """Demonstrate getting detailed asset information.""" From b1e8ed3230e686d57c156641189da9e9cd57deeb Mon Sep 17 00:00:00 2001 From: hazeone <709547807@qq.com> Date: Mon, 8 Sep 2025 15:31:48 +0800 Subject: [PATCH 6/6] optimize code --- python/valuecell/adapters/assets/README.md | 41 +-- python/valuecell/adapters/assets/__init__.py | 31 +- .../adapters/assets/akshare_adapter.py | 282 +++++++++--------- .../examples/asset_adapter_example.py | 32 +- python/valuecell/services/__init__.py | 23 ++ python/valuecell/services/assets/__init__.py | 51 ++++ .../assets/asset_service.py} | 54 ++-- 7 files changed, 293 insertions(+), 221 deletions(-) create mode 100644 python/valuecell/services/assets/__init__.py rename python/valuecell/{adapters/assets/api.py => services/assets/asset_service.py} (94%) diff --git a/python/valuecell/adapters/assets/README.md b/python/valuecell/adapters/assets/README.md index b72e89a02..4c92e7bdd 100644 --- a/python/valuecell/adapters/assets/README.md +++ b/python/valuecell/adapters/assets/README.md @@ -52,26 +52,27 @@ pip install yfinance tushare requests pydantic ### 2. Basic Usage ```python -from valuecell.adapters.assets import ( - get_adapter_manager, search_assets, add_to_watchlist, get_watchlist +from valuecell.adapters.assets import get_adapter_manager +from valuecell.services.assets import ( + search_assets, add_to_watchlist, get_watchlist ) # Configure data adapters manager = get_adapter_manager() manager.configure_yfinance() # Free, no API key needed -# Search for assets +# Search for assets (now via service layer) results = search_assets("AAPL", language="zh-Hans") print(f"Found {results['count']} assets") -# Add to watchlist +# Add to watchlist (now via service layer) add_to_watchlist( user_id="user123", ticker="NASDAQ:AAPL", notes="苹果公司股票" ) -# Get watchlist with prices +# Get watchlist with prices (now via service layer) watchlist = get_watchlist(user_id="user123", include_prices=True) ``` @@ -99,7 +100,7 @@ manager.configure_coinmarketcap(api_key="your_cmc_api_key") ### Asset Search ```python -from valuecell.adapters.assets import search_assets +from valuecell.services.assets import search_assets # Basic search results = search_assets("Apple") @@ -118,7 +119,7 @@ results = search_assets( ### Asset Information ```python -from valuecell.adapters.assets import get_asset_info, get_asset_price +from valuecell.services.assets import get_asset_info, get_asset_price # Get detailed asset information info = get_asset_info("NASDAQ:AAPL", language="zh-Hans") @@ -133,23 +134,23 @@ print(price["change_percent_formatted"]) # "+2.5%" ### Watchlist Management ```python -from valuecell.adapters.assets import get_asset_api +from valuecell.services.assets import get_asset_service -api = get_asset_api() +service = get_asset_service() # Create watchlist -api.create_watchlist( +service.create_watchlist( user_id="user123", name="My Tech Stocks", description="Technology companies" ) # Add assets -api.add_to_watchlist("user123", "NASDAQ:AAPL", notes="iPhone maker") -api.add_to_watchlist("user123", "NASDAQ:GOOGL", notes="Search engine") +service.add_to_watchlist("user123", "NASDAQ:AAPL", notes="iPhone maker") +service.add_to_watchlist("user123", "NASDAQ:GOOGL", notes="Search engine") # Get watchlist with prices -watchlist = api.get_watchlist("user123", include_prices=True) +watchlist = service.get_watchlist("user123", include_prices=True) ``` ## Data Source Configuration @@ -248,14 +249,14 @@ i18n_service.add_asset_translation( 3. **Specific Adapters**: Implementation for each data source 4. **Manager** (`manager.py`): Coordinates multiple adapters 5. **I18n Integration** (`i18n_integration.py`): Localization support -6. **API** (`api.py`): High-level interface +6. **Service Layer** (`valuecell.services.assets`): High-level business logic interface ### Data Flow ``` -User Request → API Layer → Manager → Adapter → Data Source - ↓ - I18n Service → Localized Response +User Request → Service Layer → Manager → Adapter → Data Source + ↓ + I18n Service → Localized Response ``` ### Ticker Conversion @@ -325,10 +326,10 @@ python -m valuecell.examples.asset_adapter_example Monitor adapter status: ```python -from valuecell.adapters.assets import get_asset_api +from valuecell.services.assets import get_asset_service -api = get_asset_api() -health = api.get_system_health() +service = get_asset_service() +health = service.get_system_health() print(f"System status: {health['overall_status']}") ``` diff --git a/python/valuecell/adapters/assets/__init__.py b/python/valuecell/adapters/assets/__init__.py index a7e61e326..7867db010 100644 --- a/python/valuecell/adapters/assets/__init__.py +++ b/python/valuecell/adapters/assets/__init__.py @@ -15,7 +15,10 @@ Usage Example: ```python from valuecell.adapters.assets import ( - get_adapter_manager, get_asset_api, search_assets, add_to_watchlist + get_adapter_manager, get_watchlist_manager + ) + from valuecell.services.assets import ( + get_asset_service, search_assets, add_to_watchlist ) # Configure data adapters @@ -23,10 +26,10 @@ manager.configure_yfinance() manager.configure_tushare(api_key="your_tushare_key") - # Search for assets + # Search for assets (now via service layer) results = search_assets("AAPL", language="zh-Hans") - # Add to watchlist + # Add to watchlist (now via service layer) add_to_watchlist(user_id="user123", ticker="NASDAQ:AAPL") ``` """ @@ -80,17 +83,8 @@ reset_asset_i18n_service, ) -# High-level API -from .api import ( - AssetAPI, - get_asset_api, - reset_asset_api, - search_assets, - get_asset_info, - get_asset_price, - add_to_watchlist, - get_watchlist, -) +# Note: High-level asset service functions have been moved to valuecell.services.assets +# Import from there for asset search, price retrieval, and watchlist operations __version__ = "1.0.0" @@ -131,13 +125,4 @@ "AssetI18nService", "get_asset_i18n_service", "reset_asset_i18n_service", - # API - "AssetAPI", - "get_asset_api", - "reset_asset_api", - "search_assets", - "get_asset_info", - "get_asset_price", - "add_to_watchlist", - "get_watchlist", ] diff --git a/python/valuecell/adapters/assets/akshare_adapter.py b/python/valuecell/adapters/assets/akshare_adapter.py index abd6ba91c..d92a7029e 100644 --- a/python/valuecell/adapters/assets/akshare_adapter.py +++ b/python/valuecell/adapters/assets/akshare_adapter.py @@ -53,25 +53,27 @@ def __init__(self, **kwargs): def _initialize(self) -> None: """Initialize AKShare adapter configuration.""" self.timeout = self.config.get("timeout", 10) # Reduced timeout duration - + # Different cache TTLs for different data types - self.price_cache_ttl = self.config.get("price_cache_ttl", 30) # 30 seconds for real-time prices - self.info_cache_ttl = self.config.get("info_cache_ttl", 3600) # 1 hour for stock info - self.hist_cache_ttl = self.config.get("hist_cache_ttl", 1800) # 30 minutes for historical data - + self.price_cache_ttl = self.config.get( + "price_cache_ttl", 30 + ) # 30 seconds for real-time prices + self.info_cache_ttl = self.config.get( + "info_cache_ttl", 3600 + ) # 1 hour for stock info + self.hist_cache_ttl = self.config.get( + "hist_cache_ttl", 1800 + ) # 30 minutes for historical data + self.max_retries = self.config.get("max_retries", 2) # Maximum retry attempts # Data caching with different TTLs self._cache = {} self._cache_lock = threading.Lock() self._last_cache_clear = time.time() - + # Cache statistics for monitoring - self._cache_stats = { - "hits": 0, - "misses": 0, - "evictions": 0 - } + self._cache_stats = {"hits": 0, "misses": 0, "evictions": 0} # Asset type mapping for AKShare self.asset_type_mapping = { @@ -121,17 +123,20 @@ def _initialize(self) -> None: def _get_cached_data(self, cache_key: str, fetch_func, *args, **kwargs): """Get cached data or fetch new data with adaptive TTL.""" current_time = time.time() - + # Determine TTL based on cache key type ttl = self._get_cache_ttl(cache_key) with self._cache_lock: # Clean up expired cache periodically - if current_time - self._last_cache_clear > min(self.price_cache_ttl, self.info_cache_ttl): + if current_time - self._last_cache_clear > min( + self.price_cache_ttl, self.info_cache_ttl + ): expired_keys = [ key for key, (_, timestamp, key_ttl) in self._cache.items() - if current_time - timestamp > key_ttl * 2 # Keep expired data for fallback + if current_time - timestamp + > key_ttl * 2 # Keep expired data for fallback ] for key in expired_keys: del self._cache[key] @@ -167,7 +172,7 @@ def _get_cached_data(self, cache_key: str, fetch_func, *args, **kwargs): logger.warning(f"Using expired cached data for {cache_key}") return cached_data raise - + def _get_cache_ttl(self, cache_key: str) -> int: """Get appropriate TTL based on cache key type.""" if "price" in cache_key or "spot" in cache_key: @@ -176,22 +181,20 @@ def _get_cache_ttl(self, cache_key: str) -> int: return self.hist_cache_ttl else: return self.info_cache_ttl - + def get_cache_stats(self) -> dict: """Get cache statistics for monitoring.""" with self._cache_lock: total_requests = self._cache_stats["hits"] + self._cache_stats["misses"] hit_rate = ( - self._cache_stats["hits"] / total_requests - if total_requests > 0 - else 0 + self._cache_stats["hits"] / total_requests if total_requests > 0 else 0 ) return { "cache_size": len(self._cache), "hit_rate": hit_rate, - **self._cache_stats + **self._cache_stats, } - + def clear_cache(self) -> None: """Clear all cached data.""" with self._cache_lock: @@ -433,22 +436,22 @@ def _search_a_shares_direct( ) -> List[AssetSearchResult]: """Search A-share stocks using direct queries.""" results = [] - + # If search term looks like A-share code, try direct lookup if self._is_a_share_code(search_term): result = self._get_a_share_by_code(search_term) if result: results.append(result) return results - + # For name searches, try fuzzy matching with common patterns # This is a simplified approach - in production, you might want to use # a search service or maintain a local index if len(search_term) >= 2: # Only search if term is meaningful # Try some common A-share codes that might match the search term candidate_codes = self._generate_a_share_candidates(search_term) - - for code in candidate_codes[:query.limit]: + + for code in candidate_codes[: query.limit]: try: result = self._get_a_share_by_code(code) if result and self._matches_search_term(result, search_term): @@ -456,46 +459,46 @@ def _search_a_shares_direct( except Exception as e: logger.debug(f"Failed to get A-share info for {code}: {e}") continue - + return results - + def _is_a_share_code(self, search_term: str) -> bool: """Check if search term looks like an A-share code.""" return ( - search_term.isdigit() - and len(search_term) == 6 + search_term.isdigit() + and len(search_term) == 6 and search_term.startswith(("6", "0", "3", "8")) ) - + def _get_a_share_by_code(self, stock_code: str) -> Optional[AssetSearchResult]: """Get A-share info by stock code using direct query.""" try: # Use individual stock info query cache_key = f"a_share_info_{stock_code}" df_info = self._get_cached_data( - cache_key, - self._safe_akshare_call, - ak.stock_individual_info_em, - symbol=stock_code + cache_key, + self._safe_akshare_call, + ak.stock_individual_info_em, + symbol=stock_code, ) - + if df_info is None or df_info.empty: return None - + # Extract stock name from info info_dict = {} for _, row in df_info.iterrows(): info_dict[row["item"]] = row["value"] - + stock_name = info_dict.get("股票名称", stock_code) - + # Determine exchange from code exchange_info = self._get_exchange_from_a_share_code(stock_code) if not exchange_info: return None - + exchange, internal_ticker = exchange_info - + return self._create_stock_search_result( internal_ticker, AssetType.STOCK, @@ -506,26 +509,30 @@ def _get_a_share_by_code(self, stock_code: str) -> Optional[AssetSearchResult]: "CNY", stock_code, ) - + except Exception as e: logger.debug(f"Error getting A-share info for {stock_code}: {e}") return None - + def _generate_a_share_candidates(self, search_term: str) -> List[str]: """Generate candidate A-share codes based on search term.""" candidates = [] - + # If it's a partial number, try to complete it if search_term.isdigit() and len(search_term) < 6: # Try common prefixes for prefix in ["6", "0", "3"]: - if search_term.startswith(prefix) or not search_term.startswith(("6", "0", "3", "8")): - padded = search_term.ljust(6, '0') + if search_term.startswith(prefix) or not search_term.startswith( + ("6", "0", "3", "8") + ): + padded = search_term.ljust(6, "0") if not search_term.startswith(("6", "0", "3", "8")): - candidates.extend([f"{prefix}{padded[1:]}" for prefix in ["6", "0", "3"]]) + candidates.extend( + [f"{prefix}{padded[1:]}" for prefix in ["6", "0", "3"]] + ) else: candidates.append(padded) - + # For Chinese names, we would need a mapping service # For now, return some common stocks as examples common_stocks = [ @@ -535,25 +542,25 @@ def _generate_a_share_candidates(self, search_term: str) -> List[str]: "600036", # 招商银行 "600519", # 贵州茅台 ] - + if not candidates and any("\u4e00" <= char <= "\u9fff" for char in search_term): candidates.extend(common_stocks) - + return candidates[:10] # Limit candidates - + def _matches_search_term(self, result: AssetSearchResult, search_term: str) -> bool: """Check if search result matches the search term.""" search_lower = search_term.lower() - + # Check ticker if search_lower in result.ticker.lower(): return True - + # Check names for name in result.names.values(): if name and search_lower in name.lower(): return True - + return False def _search_hk_stocks_direct( @@ -561,18 +568,18 @@ def _search_hk_stocks_direct( ) -> List[AssetSearchResult]: """Search Hong Kong stocks using direct queries.""" results = [] - + # If search term looks like HK stock code, try direct lookup if self._is_hk_stock_code(search_term): result = self._get_hk_stock_by_code(search_term) if result: results.append(result) return results - + # For other searches, try common HK stock codes candidate_codes = self._generate_hk_stock_candidates(search_term) - - for code in candidate_codes[:query.limit]: + + for code in candidate_codes[: query.limit]: try: result = self._get_hk_stock_by_code(code) if result and self._matches_search_term(result, search_term): @@ -580,23 +587,25 @@ def _search_hk_stocks_direct( except Exception as e: logger.debug(f"Failed to get HK stock info for {code}: {e}") continue - + return results - + def _is_hk_stock_code(self, search_term: str) -> bool: """Check if search term looks like a HK stock code.""" return search_term.isdigit() and 1 <= len(search_term) <= 5 - + def _get_hk_stock_by_code(self, stock_code: str) -> Optional[AssetSearchResult]: """Get HK stock info by stock code using direct query.""" try: # Format HK stock code - formatted_code = stock_code.zfill(5) if not stock_code.startswith("0") else stock_code - + formatted_code = ( + stock_code.zfill(5) if not stock_code.startswith("0") else stock_code + ) + # Try to get HK stock data - note: AKShare may not have direct individual HK stock query # so we create a basic result based on code internal_ticker = f"HKEX:{formatted_code}" - + # Create basic result - in production, you might want to query actual HK stock info return self._create_stock_search_result( internal_ticker, @@ -608,15 +617,15 @@ def _get_hk_stock_by_code(self, stock_code: str) -> Optional[AssetSearchResult]: "HKD", stock_code, ) - + except Exception as e: logger.debug(f"Error getting HK stock info for {stock_code}: {e}") return None - + def _generate_hk_stock_candidates(self, search_term: str) -> List[str]: """Generate candidate HK stock codes based on search term.""" candidates = [] - + # Common HK stocks common_hk_stocks = [ "00700", # 腾讯 @@ -625,12 +634,12 @@ def _generate_hk_stock_candidates(self, search_term: str) -> List[str]: "02318", # 中国平安 "03988", # 中国银行 ] - + if search_term.isdigit() and len(search_term) <= 5: candidates.append(search_term.zfill(5)) else: candidates.extend(common_hk_stocks) - + return candidates[:10] def _search_us_stocks_direct( @@ -638,18 +647,18 @@ def _search_us_stocks_direct( ) -> List[AssetSearchResult]: """Search US stocks using direct queries.""" results = [] - + # If search term looks like US stock symbol, try direct lookup if self._is_us_stock_symbol(search_term): result = self._get_us_stock_by_symbol(search_term) if result: results.append(result) return results - + # For other searches, try common US stock symbols candidate_symbols = self._generate_us_stock_candidates(search_term) - - for symbol in candidate_symbols[:query.limit]: + + for symbol in candidate_symbols[: query.limit]: try: result = self._get_us_stock_by_symbol(symbol) if result and self._matches_search_term(result, search_term): @@ -657,20 +666,20 @@ def _search_us_stocks_direct( except Exception as e: logger.debug(f"Failed to get US stock info for {symbol}: {e}") continue - + return results - + def _is_us_stock_symbol(self, search_term: str) -> bool: """Check if search term looks like a US stock symbol.""" return search_term.isalpha() and 1 <= len(search_term) <= 5 - + def _get_us_stock_by_symbol(self, symbol: str) -> Optional[AssetSearchResult]: """Get US stock info by symbol using direct query.""" try: # Create basic result - AKShare may not have direct individual US stock query exchange = "NASDAQ" # Default to NASDAQ internal_ticker = f"{exchange}:{symbol.upper()}" - + return self._create_stock_search_result( internal_ticker, AssetType.STOCK, @@ -681,29 +690,29 @@ def _get_us_stock_by_symbol(self, symbol: str) -> Optional[AssetSearchResult]: "USD", symbol, ) - + except Exception as e: logger.debug(f"Error getting US stock info for {symbol}: {e}") return None - + def _generate_us_stock_candidates(self, search_term: str) -> List[str]: """Generate candidate US stock symbols based on search term.""" candidates = [] - + # Common US stocks common_us_stocks = [ "AAPL", # Apple - "GOOGL", # Google + "GOOGL", # Google "MSFT", # Microsoft "AMZN", # Amazon "TSLA", # Tesla ] - + if search_term.isalpha() and len(search_term) <= 5: candidates.append(search_term.upper()) else: candidates.extend(common_us_stocks) - + return candidates[:10] def _search_crypto_direct( @@ -711,18 +720,18 @@ def _search_crypto_direct( ) -> List[AssetSearchResult]: """Search cryptocurrencies using direct queries.""" results = [] - + # If search term looks like crypto symbol, try direct lookup if self._is_crypto_symbol(search_term): result = self._get_crypto_by_symbol(search_term) if result: results.append(result) return results - + # For other searches, try common crypto symbols candidate_symbols = self._generate_crypto_candidates(search_term) - - for symbol in candidate_symbols[:query.limit]: + + for symbol in candidate_symbols[: query.limit]: try: result = self._get_crypto_by_symbol(symbol) if result and self._matches_search_term(result, search_term): @@ -730,27 +739,27 @@ def _search_crypto_direct( except Exception as e: logger.debug(f"Failed to get crypto info for {symbol}: {e}") continue - + return results - + def _is_crypto_symbol(self, search_term: str) -> bool: """Check if search term looks like a crypto symbol.""" common_crypto = {"BTC", "ETH", "USDT", "BNB", "ADA", "XRP", "SOL", "DOT"} return search_term.upper() in common_crypto or ( search_term.isalpha() and 2 <= len(search_term) <= 10 ) - + def _get_crypto_by_symbol(self, symbol: str) -> Optional[AssetSearchResult]: """Get crypto info by symbol using direct query.""" try: internal_ticker = f"CRYPTO:{symbol.upper()}" - + names = { "zh-Hans": symbol.upper(), "zh-Hant": symbol.upper(), "en-US": symbol.upper(), } - + return AssetSearchResult( ticker=internal_ticker, asset_type=AssetType.CRYPTO, @@ -761,32 +770,32 @@ def _get_crypto_by_symbol(self, symbol: str) -> Optional[AssetSearchResult]: market_status=MarketStatus.UNKNOWN, relevance_score=2.0, # High relevance for direct matches ) - + except Exception as e: logger.debug(f"Error getting crypto info for {symbol}: {e}") return None - + def _generate_crypto_candidates(self, search_term: str) -> List[str]: """Generate candidate crypto symbols based on search term.""" candidates = [] - + # Common cryptocurrencies common_cryptos = [ - "BTC", # Bitcoin - "ETH", # Ethereum + "BTC", # Bitcoin + "ETH", # Ethereum "USDT", # Tether - "BNB", # Binance Coin - "ADA", # Cardano - "XRP", # Ripple - "SOL", # Solana - "DOT", # Polkadot + "BNB", # Binance Coin + "ADA", # Cardano + "XRP", # Ripple + "SOL", # Solana + "DOT", # Polkadot ] - + if search_term.isalpha() and len(search_term) <= 10: candidates.append(search_term.upper()) - + candidates.extend(common_cryptos) - + return candidates[:10] def _search_etfs_direct( @@ -794,18 +803,18 @@ def _search_etfs_direct( ) -> List[AssetSearchResult]: """Search ETFs using direct queries.""" results = [] - + # If search term looks like ETF code, try direct lookup if self._is_etf_code(search_term): result = self._get_etf_by_code(search_term) if result: results.append(result) return results - + # For other searches, try common ETF codes candidate_codes = self._generate_etf_candidates(search_term) - - for code in candidate_codes[:query.limit]: + + for code in candidate_codes[: query.limit]: try: result = self._get_etf_by_code(code) if result and self._matches_search_term(result, search_term): @@ -813,31 +822,31 @@ def _search_etfs_direct( except Exception as e: logger.debug(f"Failed to get ETF info for {code}: {e}") continue - + return results - + def _is_etf_code(self, search_term: str) -> bool: """Check if search term looks like an ETF code.""" return ( - search_term.isdigit() - and len(search_term) == 6 + search_term.isdigit() + and len(search_term) == 6 and search_term.startswith(("5", "1")) ) - + def _get_etf_by_code(self, fund_code: str) -> Optional[AssetSearchResult]: """Get ETF info by code using direct query.""" try: # Determine exchange for funds exchange = "SSE" if fund_code.startswith("5") else "SZSE" internal_ticker = f"{exchange}:{fund_code}" - + # Create basic result - in production, you might want to query actual ETF info names = { "zh-Hans": f"ETF{fund_code}", "zh-Hant": f"ETF{fund_code}", "en-US": f"ETF{fund_code}", } - + return AssetSearchResult( ticker=internal_ticker, asset_type=AssetType.ETF, @@ -848,15 +857,15 @@ def _get_etf_by_code(self, fund_code: str) -> Optional[AssetSearchResult]: market_status=MarketStatus.UNKNOWN, relevance_score=2.0, # High relevance for direct matches ) - + except Exception as e: logger.debug(f"Error getting ETF info for {fund_code}: {e}") return None - + def _generate_etf_candidates(self, search_term: str) -> List[str]: """Generate candidate ETF codes based on search term.""" candidates = [] - + # Common ETFs common_etfs = [ "510050", # 50ETF @@ -865,12 +874,12 @@ def _generate_etf_candidates(self, search_term: str) -> List[str]: "510500", # 中证500ETF "159915", # 创业板ETF ] - + if search_term.isdigit() and len(search_term) == 6: candidates.append(search_term) else: candidates.extend(common_etfs) - + return candidates[:10] def _calculate_relevance(self, search_term: str, code: str, name: str) -> float: @@ -1123,10 +1132,7 @@ def _get_a_share_price( # Use direct real-time price query for individual stock cache_key = f"a_share_price_{symbol}" df_realtime = self._get_cached_data( - cache_key, - self._safe_akshare_call, - ak.stock_zh_a_spot_em, - symbol=symbol + cache_key, self._safe_akshare_call, ak.stock_zh_a_spot_em, symbol=symbol ) if df_realtime is None or df_realtime.empty: @@ -1153,8 +1159,8 @@ def _get_a_share_price( # Calculate change change = current_price - pre_close if current_price and pre_close else None change_percent = ( - (change / pre_close) * 100 - if change and pre_close and pre_close != 0 + (change / pre_close) * 100 + if change and pre_close and pre_close != 0 else None ) @@ -1181,18 +1187,20 @@ def _get_a_share_price( except Exception as e: logger.error(f"Error fetching A-share price for {symbol}: {e}") return None - + def _get_a_share_price_from_info( self, ticker: str, exchange: str, symbol: str ) -> Optional[AssetPrice]: """Get A-share price from individual stock info as fallback.""" try: # Try to get basic price info from stock individual info - df_info = self._safe_akshare_call(ak.stock_individual_info_em, symbol=symbol) - + df_info = self._safe_akshare_call( + ak.stock_individual_info_em, symbol=symbol + ) + if df_info is None or df_info.empty: return None - + # Create basic price info return AssetPrice( ticker=ticker, @@ -1209,11 +1217,11 @@ def _get_a_share_price_from_info( market_cap=None, source=self.source, ) - + except Exception as e: logger.error(f"Error fetching A-share info price for {symbol}: {e}") return None - + def _safe_decimal_convert(self, value) -> Optional[Decimal]: """Safely convert value to Decimal.""" if value is None or value == "": @@ -1413,7 +1421,9 @@ def _get_a_share_historical( period = "daily" # Use cached data for historical prices - cache_key = f"a_share_hist_{symbol}_{start_date_str}_{end_date_str}_{period}" + cache_key = ( + f"a_share_hist_{symbol}_{start_date_str}_{end_date_str}_{period}" + ) df_hist = self._get_cached_data( cache_key, self._safe_akshare_call, @@ -1469,9 +1479,11 @@ def _get_a_share_historical( source=self.source, ) prices.append(price) - + except Exception as row_error: - logger.warning(f"Error processing historical data row for {symbol}: {row_error}") + logger.warning( + f"Error processing historical data row for {symbol}: {row_error}" + ) continue logger.info(f"Retrieved {len(prices)} historical price points for {symbol}") diff --git a/python/valuecell/examples/asset_adapter_example.py b/python/valuecell/examples/asset_adapter_example.py index fedef226d..3b82c0c5d 100644 --- a/python/valuecell/examples/asset_adapter_example.py +++ b/python/valuecell/examples/asset_adapter_example.py @@ -6,9 +6,9 @@ import logging -from valuecell.adapters.assets import ( - get_adapter_manager, - get_asset_api, +from valuecell.adapters.assets import get_adapter_manager +from valuecell.services.assets import ( + get_asset_service, search_assets, get_asset_info, get_asset_price, @@ -30,11 +30,11 @@ def setup_adapters(): manager = get_adapter_manager() # Configure Yahoo Finance (free, no API key required) - # try: - # manager.configure_yfinance() - # logger.info("✓ Yahoo Finance adapter configured") - # except Exception as e: - # logger.warning(f"✗ Yahoo Finance adapter failed: {e}") + try: + manager.configure_yfinance() + logger.info("✓ Yahoo Finance adapter configured") + except Exception as e: + logger.warning(f"✗ Yahoo Finance adapter failed: {e}") # Configure TuShare (requires API key) try: @@ -61,7 +61,7 @@ def setup_adapters(): logger.warning(f"✗ CoinMarketCap adapter failed: {e}") # Configure AKShare (free, no API key required) - # Now supports US stocks, Hong Kong stocks, and cryptocurrencies + # Now supports A-shares, US stocks, Hong Kong stocks, and cryptocurrencies try: manager.configure_akshare() logger.info( @@ -83,8 +83,8 @@ def setup_adapters(): logger.warning(f"✗ Finnhub adapter failed: {e}") # Check system health - api = get_asset_api() - health = api.get_system_health() + service = get_asset_service() + health = service.get_system_health() logger.info( f"System health: {health['overall_status']} " f"({health['healthy_adapters']}/{health['total_adapters']} adapters)" @@ -215,8 +215,8 @@ def demonstrate_price_data(): # Get multiple prices logger.info(f"\nGetting prices for multiple assets: {tickers}") - api = get_asset_api() - prices_data = api.get_multiple_prices(tickers, language="en-US") + service = get_asset_service() + prices_data = service.get_multiple_prices(tickers, language="en-US") if prices_data["success"]: logger.info(f"Successfully retrieved {prices_data['count']} prices:") @@ -235,11 +235,11 @@ def demonstrate_watchlist_management(): logger.info("\n=== Watchlist Management Demo ===") user_id = "demo_user_123" - api = get_asset_api() + service = get_asset_service() # Create a watchlist logger.info("Creating a new watchlist...") - create_result = api.create_watchlist( + create_result = service.create_watchlist( user_id=user_id, name="My Tech Stocks", description="Technology companies I'm watching", @@ -293,7 +293,7 @@ def demonstrate_watchlist_management(): # List all user watchlists logger.info("\nListing all user watchlists...") - all_watchlists = api.get_user_watchlists(user_id) + all_watchlists = service.get_user_watchlists(user_id) if all_watchlists["success"]: logger.info(f"User {user_id} has {all_watchlists['count']} watchlists:") diff --git a/python/valuecell/services/__init__.py b/python/valuecell/services/__init__.py index e69de29bb..b5f1f42cd 100644 --- a/python/valuecell/services/__init__.py +++ b/python/valuecell/services/__init__.py @@ -0,0 +1,23 @@ +"""ValueCell Services Module. + +This module provides high-level service layers for various business operations +including asset management, internationalization, and agent context management. +""" + +# Asset service (import directly from .assets to avoid circular imports) + +# I18n service +from .i18n_service import I18nService, get_i18n_service + +# Agent context service +from .agent_context import AgentContextManager, get_agent_context + +__all__ = [ + # I18n services + "I18nService", + "get_i18n_service", + # Agent context services + "AgentContextManager", + "get_agent_context", + # Note: For asset services, import directly from valuecell.services.assets +] diff --git a/python/valuecell/services/assets/__init__.py b/python/valuecell/services/assets/__init__.py new file mode 100644 index 000000000..18873f983 --- /dev/null +++ b/python/valuecell/services/assets/__init__.py @@ -0,0 +1,51 @@ +"""ValueCell Asset Service Module. + +This module provides high-level asset service functionality for financial asset management, +search, price retrieval, and watchlist operations with internationalization support. + +Key Features: +- Asset search with localization +- Real-time and historical price data +- Watchlist management +- Multi-language support +- Integration with multiple data adapters + +Usage Example: + ```python + from valuecell.services.assets import ( + get_asset_service, search_assets, add_to_watchlist + ) + + # Search for assets + results = search_assets("AAPL", language="zh-Hans") + + # Add to watchlist + add_to_watchlist(user_id="user123", ticker="NASDAQ:AAPL") + ``` +""" + +from .asset_service import ( + AssetService, + get_asset_service, + reset_asset_service, + search_assets, + get_asset_info, + get_asset_price, + add_to_watchlist, + get_watchlist, +) + +__version__ = "1.0.0" + +__all__ = [ + # Service class + "AssetService", + "get_asset_service", + "reset_asset_service", + # Convenience functions + "search_assets", + "get_asset_info", + "get_asset_price", + "add_to_watchlist", + "get_watchlist", +] diff --git a/python/valuecell/adapters/assets/api.py b/python/valuecell/services/assets/asset_service.py similarity index 94% rename from python/valuecell/adapters/assets/api.py rename to python/valuecell/services/assets/asset_service.py index 2759c4040..1e3093f1c 100644 --- a/python/valuecell/adapters/assets/api.py +++ b/python/valuecell/services/assets/asset_service.py @@ -1,6 +1,6 @@ -"""API interface for asset management and watchlist operations. +"""Asset service for asset management and watchlist operations. -This module provides high-level API functions for asset search, watchlist management, +This module provides high-level service functions for asset search, watchlist management, and price data retrieval with i18n support. """ @@ -8,19 +8,19 @@ from typing import Dict, List, Optional, Any from datetime import datetime -from .manager import get_adapter_manager, get_watchlist_manager -from .i18n_integration import get_asset_i18n_service -from .types import AssetSearchQuery, AssetType -from ...i18n import get_i18n_config +from ...adapters.assets.manager import get_adapter_manager, get_watchlist_manager +from ...adapters.assets.i18n_integration import get_asset_i18n_service +from ...adapters.assets.types import AssetSearchQuery, AssetType +from ...config.i18n import get_i18n_config logger = logging.getLogger(__name__) -class AssetAPI: - """High-level API for asset operations with i18n support.""" +class AssetService: + """High-level service for asset operations with i18n support.""" def __init__(self): - """Initialize asset API.""" + """Initialize asset service.""" self.adapter_manager = get_adapter_manager() self.watchlist_manager = get_watchlist_manager() self.i18n_service = get_asset_i18n_service() @@ -592,45 +592,45 @@ def get_system_health(self) -> Dict[str, Any]: return {"success": False, "error": str(e), "overall_status": "error"} -# Global API instance -_asset_api: Optional[AssetAPI] = None +# Global service instance +_asset_service: Optional[AssetService] = None -def get_asset_api() -> AssetAPI: - """Get global asset API instance.""" - global _asset_api - if _asset_api is None: - _asset_api = AssetAPI() - return _asset_api +def get_asset_service() -> AssetService: + """Get global asset service instance.""" + global _asset_service + if _asset_service is None: + _asset_service = AssetService() + return _asset_service -def reset_asset_api() -> None: - """Reset global asset API instance (mainly for testing).""" - global _asset_api - _asset_api = None +def reset_asset_service() -> None: + """Reset global asset service instance (mainly for testing).""" + global _asset_service + _asset_service = None -# Convenience functions for direct API access +# Convenience functions for direct service access def search_assets(query: str, **kwargs) -> Dict[str, Any]: """Convenience function for asset search.""" - return get_asset_api().search_assets(query, **kwargs) + return get_asset_service().search_assets(query, **kwargs) def get_asset_info(ticker: str, **kwargs) -> Dict[str, Any]: """Convenience function for getting asset info.""" - return get_asset_api().get_asset_info(ticker, **kwargs) + return get_asset_service().get_asset_info(ticker, **kwargs) def get_asset_price(ticker: str, **kwargs) -> Dict[str, Any]: """Convenience function for getting asset price.""" - return get_asset_api().get_asset_price(ticker, **kwargs) + return get_asset_service().get_asset_price(ticker, **kwargs) def add_to_watchlist(user_id: str, ticker: str, **kwargs) -> Dict[str, Any]: """Convenience function for adding to watchlist.""" - return get_asset_api().add_to_watchlist(user_id, ticker, **kwargs) + return get_asset_service().add_to_watchlist(user_id, ticker, **kwargs) def get_watchlist(user_id: str, **kwargs) -> Dict[str, Any]: """Convenience function for getting watchlist.""" - return get_asset_api().get_watchlist(user_id, **kwargs) + return get_asset_service().get_watchlist(user_id, **kwargs)