From a18cac6ff740b4baf6764c55bb50f5fd40b28859 Mon Sep 17 00:00:00 2001 From: hazeone <709547807@qq.com> Date: Tue, 16 Sep 2025 17:39:31 +0800 Subject: [PATCH 1/8] refactor: fix asset error and db symbal --- python/valuecell/adapters/assets/i18n_integration.py | 3 ++- python/valuecell/examples/asset_adapter_example.py | 4 ++-- python/valuecell/server/db/init_db.py | 10 +++++----- .../valuecell/server/services/assets/asset_service.py | 6 +++--- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/python/valuecell/adapters/assets/i18n_integration.py b/python/valuecell/adapters/assets/i18n_integration.py index ab24e608d..1e813f058 100644 --- a/python/valuecell/adapters/assets/i18n_integration.py +++ b/python/valuecell/adapters/assets/i18n_integration.py @@ -7,7 +7,8 @@ import logging from typing import Dict, List, Optional -from ...server.api.i18n_api import get_i18n_service, t, get_i18n_config +from ...server.services.i18n_service import get_i18n_service, t +from ...server.config.i18n import get_i18n_config from ...server.config.i18n import I18nConfig from .types import Asset, AssetSearchResult, AssetType, MarketStatus from .manager import AdapterManager diff --git a/python/valuecell/examples/asset_adapter_example.py b/python/valuecell/examples/asset_adapter_example.py index 3b82c0c5d..09bd010cb 100644 --- a/python/valuecell/examples/asset_adapter_example.py +++ b/python/valuecell/examples/asset_adapter_example.py @@ -7,7 +7,7 @@ import logging from valuecell.adapters.assets import get_adapter_manager -from valuecell.services.assets import ( +from valuecell.server.services.assets.asset_service import ( get_asset_service, search_assets, get_asset_info, @@ -15,7 +15,7 @@ add_to_watchlist, get_watchlist, ) -from valuecell.i18n import set_i18n_config, I18nConfig +from valuecell.server.config.i18n import set_i18n_config, I18nConfig # Configure logging logging.basicConfig(level=logging.INFO) diff --git a/python/valuecell/server/db/init_db.py b/python/valuecell/server/db/init_db.py index 9861ac077..ea20a2f40 100644 --- a/python/valuecell/server/db/init_db.py +++ b/python/valuecell/server/db/init_db.py @@ -261,7 +261,7 @@ def initialize_basic_data(self) -> bool: # Define default assets default_assets = [ { - "symbol": "AAPL", + "symbol": "NASDAQ:AAPL", "name": "Apple Inc.", "asset_type": "stock", "sector": "Technology", @@ -274,7 +274,7 @@ def initialize_basic_data(self) -> bool: }, }, { - "symbol": "GOOGL", + "symbol": "NASDAQ:GOOGL", "name": "Alphabet Inc. Class A", "asset_type": "stock", "sector": "Technology", @@ -287,7 +287,7 @@ def initialize_basic_data(self) -> bool: }, }, { - "symbol": "MSFT", + "symbol": "NASDAQ:MSFT", "name": "Microsoft Corporation", "asset_type": "stock", "sector": "Technology", @@ -300,7 +300,7 @@ def initialize_basic_data(self) -> bool: }, }, { - "symbol": "SPY", + "symbol": "NASDAQ:SPY", "name": "SPDR S&P 500 ETF Trust", "asset_type": "etf", "sector": "Diversified", @@ -312,7 +312,7 @@ def initialize_basic_data(self) -> bool: }, }, { - "symbol": "BTC-USD", + "symbol": "CRYPTO:BTC-USD", "name": "Bitcoin", "asset_type": "cryptocurrency", "sector": "Cryptocurrency", diff --git a/python/valuecell/server/services/assets/asset_service.py b/python/valuecell/server/services/assets/asset_service.py index 1e3093f1c..104a60579 100644 --- a/python/valuecell/server/services/assets/asset_service.py +++ b/python/valuecell/server/services/assets/asset_service.py @@ -8,9 +8,9 @@ from typing import Dict, List, Optional, Any from datetime import datetime -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 ....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__) From 3bcedbd01a0a4af78a656dd8183eb16f816e0bc8 Mon Sep 17 00:00:00 2001 From: hazeone <709547807@qq.com> Date: Wed, 17 Sep 2025 09:51:00 +0800 Subject: [PATCH 2/8] feat: add SQLAlchemy and optmize asset in init_db.py --- python/pyproject.toml | 1 + python/uv.lock | 64 +++++ python/valuecell/server/db/init_db.py | 386 +++++++++++++++++++------- 3 files changed, 349 insertions(+), 102 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 1e44692d3..4de3c55b7 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "akshare>=1.17.44", "agno[openai]>=1.8.2,<2.0", "edgartools>=4.12.2", + "sqlalchemy>=2.0.43", ] [project.optional-dependencies] diff --git a/python/uv.lock b/python/uv.lock index f5651aa98..a8428f102 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -659,6 +659,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8", size = 294530, upload-time = "2025-04-14T10:17:01.271Z" }, ] +[[package]] +name = "greenlet" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, + { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, + { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, + { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, + { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, + { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, + { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, + { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, + { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, + { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, + { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1826,6 +1859,35 @@ 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 = "sqlalchemy" +version = "2.0.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/bc/d59b5d97d27229b0e009bd9098cd81af71c2fa5549c580a0a67b9bed0496/sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417", size = 9762949, upload-time = "2025-08-11T14:24:58.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/db/20c78f1081446095450bdc6ee6cc10045fce67a8e003a5876b6eaafc5cc4/sqlalchemy-2.0.43-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24", size = 2134891, upload-time = "2025-08-11T15:51:13.019Z" }, + { url = "https://files.pythonhosted.org/packages/45/0a/3d89034ae62b200b4396f0f95319f7d86e9945ee64d2343dcad857150fa2/sqlalchemy-2.0.43-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83", size = 2123061, upload-time = "2025-08-11T15:51:14.319Z" }, + { url = "https://files.pythonhosted.org/packages/cb/10/2711f7ff1805919221ad5bee205971254845c069ee2e7036847103ca1e4c/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9", size = 3320384, upload-time = "2025-08-11T15:52:35.088Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0e/3d155e264d2ed2778484006ef04647bc63f55b3e2d12e6a4f787747b5900/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48", size = 3329648, upload-time = "2025-08-11T15:56:34.153Z" }, + { url = "https://files.pythonhosted.org/packages/5b/81/635100fb19725c931622c673900da5efb1595c96ff5b441e07e3dd61f2be/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687", size = 3258030, upload-time = "2025-08-11T15:52:36.933Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ed/a99302716d62b4965fded12520c1cbb189f99b17a6d8cf77611d21442e47/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe", size = 3294469, upload-time = "2025-08-11T15:56:35.553Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a2/3a11b06715149bf3310b55a98b5c1e84a42cfb949a7b800bc75cb4e33abc/sqlalchemy-2.0.43-cp312-cp312-win32.whl", hash = "sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d", size = 2098906, upload-time = "2025-08-11T15:55:00.645Z" }, + { url = "https://files.pythonhosted.org/packages/bc/09/405c915a974814b90aa591280623adc6ad6b322f61fd5cff80aeaef216c9/sqlalchemy-2.0.43-cp312-cp312-win_amd64.whl", hash = "sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a", size = 2126260, upload-time = "2025-08-11T15:55:02.965Z" }, + { url = "https://files.pythonhosted.org/packages/41/1c/a7260bd47a6fae7e03768bf66451437b36451143f36b285522b865987ced/sqlalchemy-2.0.43-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3", size = 2130598, upload-time = "2025-08-11T15:51:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/8e/84/8a337454e82388283830b3586ad7847aa9c76fdd4f1df09cdd1f94591873/sqlalchemy-2.0.43-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa", size = 2118415, upload-time = "2025-08-11T15:51:17.256Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ff/22ab2328148492c4d71899d62a0e65370ea66c877aea017a244a35733685/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9", size = 3248707, upload-time = "2025-08-11T15:52:38.444Z" }, + { url = "https://files.pythonhosted.org/packages/dc/29/11ae2c2b981de60187f7cbc84277d9d21f101093d1b2e945c63774477aba/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f", size = 3253602, upload-time = "2025-08-11T15:56:37.348Z" }, + { url = "https://files.pythonhosted.org/packages/b8/61/987b6c23b12c56d2be451bc70900f67dd7d989d52b1ee64f239cf19aec69/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738", size = 3183248, upload-time = "2025-08-11T15:52:39.865Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/29d216002d4593c2ce1c0ec2cec46dda77bfbcd221e24caa6e85eff53d89/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164", size = 3219363, upload-time = "2025-08-11T15:56:39.11Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e4/bd78b01919c524f190b4905d47e7630bf4130b9f48fd971ae1c6225b6f6a/sqlalchemy-2.0.43-cp313-cp313-win32.whl", hash = "sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d", size = 2096718, upload-time = "2025-08-11T15:55:05.349Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a5/ca2f07a2a201f9497de1928f787926613db6307992fe5cda97624eb07c2f/sqlalchemy-2.0.43-cp313-cp313-win_amd64.whl", hash = "sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197", size = 2123200, upload-time = "2025-08-11T15:55:07.932Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d9/13bdde6521f322861fab67473cec4b1cc8999f3871953531cf61945fad92/sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc", size = 1924759, upload-time = "2025-08-11T15:39:53.024Z" }, +] + [[package]] name = "sse-starlette" version = "3.0.2" @@ -2039,6 +2101,7 @@ dependencies = [ { name = "python-dateutil" }, { name = "pytz" }, { name = "requests" }, + { name = "sqlalchemy" }, { name = "tushare" }, { name = "uvicorn" }, { name = "yfinance" }, @@ -2067,6 +2130,7 @@ requires-dist = [ { name = "pytz", specifier = ">=2023.3" }, { name = "requests", specifier = ">=2.32.5" }, { name = "ruff", marker = "extra == 'dev'" }, + { name = "sqlalchemy", specifier = ">=2.0.43" }, { name = "tushare", specifier = ">=1.4.24" }, { name = "uvicorn", specifier = ">=0.24.0" }, { name = "yfinance", specifier = ">=0.2.65" }, diff --git a/python/valuecell/server/db/init_db.py b/python/valuecell/server/db/init_db.py index ea20a2f40..de6128e18 100644 --- a/python/valuecell/server/db/init_db.py +++ b/python/valuecell/server/db/init_db.py @@ -3,7 +3,10 @@ import logging import sys from pathlib import Path -from typing import Optional +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from .models.asset import Asset from sqlalchemy import text, inspect from sqlalchemy.exc import SQLAlchemyError @@ -11,6 +14,7 @@ from .connection import get_database_manager, DatabaseManager from .models.base import Base from ..config.settings import get_settings +from ..services.assets import get_asset_service # Configure logging logging.basicConfig( @@ -33,7 +37,7 @@ def check_database_exists(self) -> bool: database_url = self.settings.DATABASE_URL if database_url.startswith("sqlite:///"): - # Extract file path from SQLite URL + # Extract file path from SQLite URLß db_path = database_url.replace("sqlite:///", "") if db_path.startswith("./"): # Relative path @@ -118,8 +122,278 @@ def create_tables(self) -> bool: logger.error(f"Error creating tables: {e}") return False + def initialize_assets_with_service(self) -> bool: + """Initialize default assets using AssetService pattern.""" + try: + logger.info("Initializing assets using AssetService...") + + # Get asset service instance + asset_service = get_asset_service() + + # Define default tickers to search and initialize + # Using proper EXCHANGE:SYMBOL format for better adapter matching + default_tickers = [ + "NASDAQ:AAPL", # Apple Inc. + "NASDAQ:GOOGL", # Alphabet Inc. + "NASDAQ:MSFT", # Microsoft Corporation + "NYSE:SPY", # SPDR S&P 500 ETF + "CRYPTO:BTC-USD", # Bitcoin + # Additional diverse assets + "NYSE:TSLA", # Tesla Inc. + "NASDAQ:NVDA", # NVIDIA Corporation + "NYSE:JPM", # JPMorgan Chase & Co. + "CRYPTO:ETH-USD", # Ethereum + "NASDAQ:QQQ", # Invesco QQQ Trust ETF + ] + + # Get database session for manual asset creation if needed + session = self.db_manager.get_session() + + try: + from .models.asset import Asset + + initialized_count = 0 + + for ticker in default_tickers: + try: + logger.info(f"Initializing asset: {ticker}") + + # Extract symbol for search - try both full ticker and symbol only + symbol_only = ticker.split(":")[-1] if ":" in ticker else ticker + + # Try searching with both formats to maximize chances of finding the asset + search_queries = [ticker, symbol_only] + search_result = None + + for query in search_queries: + search_result = asset_service.search_assets( + query=query, limit=1, language="en-US" + ) + if search_result["success"] and search_result["results"]: + logger.info(f"Found asset data for {ticker} using query '{query}'") + break + + if not search_result: + search_result = {"success": False, "results": []} + + if search_result["success"] and search_result["results"]: + # Asset found via adapter, create database record + asset_data = search_result["results"][0] + + # Use the standardized ticker format (ensure EXCHANGE:SYMBOL format) + asset_ticker = asset_data.get("ticker", ticker) + if ":" not in asset_ticker: + # If adapter doesn't return proper format, use our expected format + asset_ticker = ticker + + # Check if asset already exists in database + existing_asset = ( + session.query(Asset) + .filter_by(symbol=asset_ticker) + .first() + ) + + if not existing_asset: + # Create new asset from adapter data + new_asset = Asset( + symbol=asset_ticker, + name=asset_data["display_name"], + asset_type=asset_data["asset_type"], + is_active=True, + asset_metadata={ + "exchange": asset_data.get("exchange") or ticker.split(":")[0], + "country": asset_data.get("country"), + "currency": asset_data.get("currency"), + "market_status": asset_data.get("market_status"), + "source": "adapter_search", + "relevance_score": asset_data.get("relevance_score", 0.0), + "original_search_query": query, + "standardized_ticker": asset_ticker, + }, + ) + session.add(new_asset) + logger.info(f"Added asset from adapter: {asset_ticker} (searched as '{query}')") + initialized_count += 1 + else: + # Update existing asset with adapter data + existing_asset.name = asset_data["display_name"] + existing_asset.asset_type = asset_data["asset_type"] + existing_asset.is_active = True + # Update existing asset metadata + existing_metadata = existing_asset.asset_metadata or {} + existing_metadata.update( + { + "exchange": asset_data.get("exchange") or ticker.split(":")[0], + "country": asset_data.get("country"), + "currency": asset_data.get("currency"), + "market_status": asset_data.get("market_status"), + "last_updated_from_adapter": True, + "last_search_query": query, + } + ) + existing_asset.asset_metadata = existing_metadata + logger.info(f"Updated asset from adapter: {asset_ticker} (searched as '{query}')") + + else: + # Fallback: create basic asset record for common tickers + logger.warning( + f"Could not find {ticker} via adapters, creating basic record" + ) + + existing_asset = ( + session.query(Asset).filter_by(symbol=ticker).first() + ) + if not existing_asset: + fallback_asset = self._create_fallback_asset(ticker) + if fallback_asset: + session.add(fallback_asset) + logger.info(f"Added fallback asset: {ticker}") + initialized_count += 1 + + except Exception as e: + logger.error(f"Error initializing asset {ticker}: {e}") + continue + + session.commit() + logger.info( + f"Asset initialization completed successfully. " + f"Initialized/updated {initialized_count} out of {len(default_tickers)} assets." + ) + + # Log summary of initialized assets + if initialized_count > 0: + logger.info("Initialized assets summary:") + for ticker in default_tickers[:initialized_count]: + logger.info(f" - {ticker}") + + return True + + except Exception as e: + session.rollback() + logger.error(f"Error during asset initialization: {e}") + return False + finally: + session.close() + + except Exception as e: + logger.error(f"Error getting asset service or database session: {e}") + return False + + def _create_fallback_asset(self, ticker: str) -> Optional["Asset"]: + """Create fallback asset data when adapter search fails.""" + from .models.asset import Asset + + # Basic fallback data for common tickers (using proper EXCHANGE:SYMBOL format) + fallback_data = { + "NASDAQ:AAPL": { + "name": "Apple Inc.", + "asset_type": "stock", + "sector": "Technology", + "exchange": "NASDAQ", + "metadata": { + "market_cap": "large", + "tags": ["blue-chip", "technology"], + }, + }, + "NASDAQ:GOOGL": { + "name": "Alphabet Inc. Class A", + "asset_type": "stock", + "sector": "Technology", + "exchange": "NASDAQ", + "metadata": { + "market_cap": "large", + "tags": ["growth", "tech-giant", "ai"], + }, + }, + "NASDAQ:MSFT": { + "name": "Microsoft Corporation", + "asset_type": "stock", + "sector": "Technology", + "exchange": "NASDAQ", + "metadata": { + "market_cap": "large", + "tags": ["blue-chip", "cloud", "ai"], + }, + }, + "NYSE:SPY": { + "name": "SPDR S&P 500 ETF Trust", + "asset_type": "etf", + "sector": "Diversified", + "exchange": "NYSE", + "metadata": {"tags": ["index", "diversified", "low-cost"]}, + }, + "CRYPTO:BTC-USD": { + "name": "Bitcoin", + "asset_type": "crypto", + "sector": "Cryptocurrency", + "exchange": "CRYPTO", + "metadata": {"tags": ["crypto", "store-of-value", "digital-gold"]}, + }, + "NYSE:TSLA": { + "name": "Tesla Inc.", + "asset_type": "stock", + "sector": "Automotive", + "exchange": "NYSE", + "metadata": { + "market_cap": "large", + "tags": ["electric-vehicles", "innovation", "growth"], + }, + }, + "NASDAQ:NVDA": { + "name": "NVIDIA Corporation", + "asset_type": "stock", + "sector": "Technology", + "exchange": "NASDAQ", + "metadata": { + "market_cap": "large", + "tags": ["semiconductors", "ai", "gaming"], + }, + }, + "NYSE:JPM": { + "name": "JPMorgan Chase & Co.", + "asset_type": "stock", + "sector": "Financial Services", + "exchange": "NYSE", + "metadata": { + "market_cap": "large", + "tags": ["banking", "blue-chip", "finance"], + }, + }, + "CRYPTO:ETH-USD": { + "name": "Ethereum", + "asset_type": "crypto", + "sector": "Cryptocurrency", + "exchange": "CRYPTO", + "metadata": {"tags": ["crypto", "smart-contracts", "defi"]}, + }, + "NASDAQ:QQQ": { + "name": "Invesco QQQ Trust ETF", + "asset_type": "etf", + "sector": "Technology", + "exchange": "NASDAQ", + "metadata": {"tags": ["tech-etf", "index", "growth"]}, + }, + } + + if ticker in fallback_data: + data = fallback_data[ticker] + return Asset( + symbol=ticker, + name=data["name"], + asset_type=data["asset_type"], + sector=data.get("sector"), + is_active=True, + asset_metadata={ + **data.get("metadata", {}), + "exchange": data.get("exchange"), + "source": "fallback_data", + "initialized_at": "database_init", + }, + ) + return None + def initialize_basic_data(self) -> bool: - """Initialize default agent data.""" + """Initialize default agent and asset data.""" try: logger.info("Initializing default agent data...") @@ -129,7 +403,6 @@ def initialize_basic_data(self) -> bool: try: # Import models here to avoid circular imports from .models.agent import Agent - from .models.asset import Asset # Define default agents default_agents = [ @@ -258,107 +531,16 @@ def initialize_basic_data(self) -> bool: ) logger.info(f"Updated default agent: {agent_name}") - # Define default assets - default_assets = [ - { - "symbol": "NASDAQ:AAPL", - "name": "Apple Inc.", - "asset_type": "stock", - "sector": "Technology", - "is_active": True, - "metadata": { - "market_cap": "large", - "dividend_yield": 0.5, - "beta": 1.2, - "tags": ["blue-chip", "dividend", "growth"], - }, - }, - { - "symbol": "NASDAQ:GOOGL", - "name": "Alphabet Inc. Class A", - "asset_type": "stock", - "sector": "Technology", - "is_active": True, - "metadata": { - "market_cap": "large", - "dividend_yield": 0.0, - "beta": 1.1, - "tags": ["growth", "tech-giant", "ai"], - }, - }, - { - "symbol": "NASDAQ:MSFT", - "name": "Microsoft Corporation", - "asset_type": "stock", - "sector": "Technology", - "is_active": True, - "metadata": { - "market_cap": "large", - "dividend_yield": 0.7, - "beta": 0.9, - "tags": ["blue-chip", "dividend", "cloud", "ai"], - }, - }, - { - "symbol": "NASDAQ:SPY", - "name": "SPDR S&P 500 ETF Trust", - "asset_type": "etf", - "sector": "Diversified", - "is_active": True, - "metadata": { - "expense_ratio": 0.0945, - "aum": "400B+", - "tags": ["index", "diversified", "low-cost"], - }, - }, - { - "symbol": "CRYPTO:BTC-USD", - "name": "Bitcoin", - "asset_type": "cryptocurrency", - "sector": "Cryptocurrency", - "is_active": True, - "metadata": { - "market_cap": "large", - "volatility": "high", - "tags": ["crypto", "store-of-value", "digital-gold"], - }, - }, - ] - - # Insert default assets - for asset_data in default_assets: - asset_symbol = asset_data["symbol"] + session.commit() + logger.info("Default agent data initialization completed") - # Check if asset already exists - existing_asset = ( - session.query(Asset).filter_by(symbol=asset_symbol).first() + # Initialize assets using AssetService + assets_initialized = self.initialize_assets_with_service() + if not assets_initialized: + logger.warning( + "Asset initialization via AssetService failed, but continuing..." ) - if not existing_asset: - # Create new asset - asset = Asset.from_config(asset_data) - session.add(asset) - logger.info(f"Added default asset: {asset_symbol}") - else: - # Update existing asset with default data - existing_asset.name = asset_data.get( - "name", existing_asset.name - ) - existing_asset.asset_type = asset_data.get( - "asset_type", existing_asset.asset_type - ) - existing_asset.sector = asset_data.get( - "sector", existing_asset.sector - ) - existing_asset.is_active = asset_data.get( - "is_active", existing_asset.is_active - ) - existing_asset.asset_metadata = asset_data.get( - "metadata", existing_asset.asset_metadata - ) - logger.info(f"Updated default asset: {asset_symbol}") - - session.commit() logger.info("Default agent and asset data initialization completed") return True From b44634441f42d0ae7f69e1c315e669da8539bc6e Mon Sep 17 00:00:00 2001 From: hazeone <709547807@qq.com> Date: Wed, 17 Sep 2025 09:55:04 +0800 Subject: [PATCH 3/8] lint --- python/valuecell/server/db/init_db.py | 44 ++++++++++++++++++--------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/python/valuecell/server/db/init_db.py b/python/valuecell/server/db/init_db.py index de6128e18..da8b6050c 100644 --- a/python/valuecell/server/db/init_db.py +++ b/python/valuecell/server/db/init_db.py @@ -157,29 +157,31 @@ def initialize_assets_with_service(self) -> bool: for ticker in default_tickers: try: logger.info(f"Initializing asset: {ticker}") - + # Extract symbol for search - try both full ticker and symbol only symbol_only = ticker.split(":")[-1] if ":" in ticker else ticker - + # Try searching with both formats to maximize chances of finding the asset search_queries = [ticker, symbol_only] search_result = None - + for query in search_queries: search_result = asset_service.search_assets( query=query, limit=1, language="en-US" ) if search_result["success"] and search_result["results"]: - logger.info(f"Found asset data for {ticker} using query '{query}'") + logger.info( + f"Found asset data for {ticker} using query '{query}'" + ) break - + if not search_result: search_result = {"success": False, "results": []} if search_result["success"] and search_result["results"]: # Asset found via adapter, create database record asset_data = search_result["results"][0] - + # Use the standardized ticker format (ensure EXCHANGE:SYMBOL format) asset_ticker = asset_data.get("ticker", ticker) if ":" not in asset_ticker: @@ -201,18 +203,25 @@ def initialize_assets_with_service(self) -> bool: asset_type=asset_data["asset_type"], is_active=True, asset_metadata={ - "exchange": asset_data.get("exchange") or ticker.split(":")[0], + "exchange": asset_data.get("exchange") + or ticker.split(":")[0], "country": asset_data.get("country"), "currency": asset_data.get("currency"), - "market_status": asset_data.get("market_status"), + "market_status": asset_data.get( + "market_status" + ), "source": "adapter_search", - "relevance_score": asset_data.get("relevance_score", 0.0), + "relevance_score": asset_data.get( + "relevance_score", 0.0 + ), "original_search_query": query, "standardized_ticker": asset_ticker, }, ) session.add(new_asset) - logger.info(f"Added asset from adapter: {asset_ticker} (searched as '{query}')") + logger.info( + f"Added asset from adapter: {asset_ticker} (searched as '{query}')" + ) initialized_count += 1 else: # Update existing asset with adapter data @@ -223,16 +232,21 @@ def initialize_assets_with_service(self) -> bool: existing_metadata = existing_asset.asset_metadata or {} existing_metadata.update( { - "exchange": asset_data.get("exchange") or ticker.split(":")[0], + "exchange": asset_data.get("exchange") + or ticker.split(":")[0], "country": asset_data.get("country"), "currency": asset_data.get("currency"), - "market_status": asset_data.get("market_status"), + "market_status": asset_data.get( + "market_status" + ), "last_updated_from_adapter": True, "last_search_query": query, } ) existing_asset.asset_metadata = existing_metadata - logger.info(f"Updated asset from adapter: {asset_ticker} (searched as '{query}')") + logger.info( + f"Updated asset from adapter: {asset_ticker} (searched as '{query}')" + ) else: # Fallback: create basic asset record for common tickers @@ -259,13 +273,13 @@ def initialize_assets_with_service(self) -> bool: f"Asset initialization completed successfully. " f"Initialized/updated {initialized_count} out of {len(default_tickers)} assets." ) - + # Log summary of initialized assets if initialized_count > 0: logger.info("Initialized assets summary:") for ticker in default_tickers[:initialized_count]: logger.info(f" - {ticker}") - + return True except Exception as e: From 10ee85d3b978fe38a4103e56922db35057475872 Mon Sep 17 00:00:00 2001 From: hazeone <709547807@qq.com> Date: Wed, 17 Sep 2025 13:40:28 +0800 Subject: [PATCH 4/8] feat: add search api --- .../adapters/assets/yfinance_adapter.py | 192 +++++++- .../valuecell/tests/test_yfinance_search.py | 455 ++++++++++++++++++ 2 files changed, 622 insertions(+), 25 deletions(-) create mode 100644 python/valuecell/tests/test_yfinance_search.py diff --git a/python/valuecell/adapters/assets/yfinance_adapter.py b/python/valuecell/adapters/assets/yfinance_adapter.py index 02ab6157b..b1c712d7c 100644 --- a/python/valuecell/adapters/assets/yfinance_adapter.py +++ b/python/valuecell/adapters/assets/yfinance_adapter.py @@ -53,27 +53,154 @@ def _initialize(self) -> None: "ETF": AssetType.ETF, "INDEX": AssetType.INDEX, "CRYPTOCURRENCY": AssetType.CRYPTO, + # Additional mappings for search results + "STOCK": AssetType.STOCK, + "FUND": AssetType.ETF, } logger.info("Yahoo Finance adapter initialized") def search_assets(self, query: AssetSearchQuery) -> List[AssetSearchResult]: - """Search for assets using Yahoo Finance. + """Search for assets using Yahoo Finance Search API. - 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. + Uses yfinance.Search for better search results across stocks, ETFs, and other assets. + Falls back to direct ticker lookup for specific symbols. """ results = [] - search_term = query.query.upper().strip() + search_term = query.query.strip() + try: + # Use yfinance Search API for comprehensive search + search_obj = yf.Search(search_term) + + # Get search results from different categories + search_quotes = getattr(search_obj, 'quotes', []) + + # Process search results + for quote in search_quotes[:query.limit * 2]: # Get more results to filter later + try: + result = self._create_search_result_from_quote(quote, query.language) + if result: + results.append(result) + except Exception as e: + logger.debug(f"Error processing search quote: {e}") + continue + + except Exception as e: + logger.debug(f"yfinance Search API failed for '{search_term}': {e}") + + # Fallback to direct ticker lookup + results.extend(self._fallback_ticker_search(search_term, query)) + + # If no results from search, try direct ticker lookup as final fallback + if not results: + results.extend(self._fallback_ticker_search(search_term.upper(), query)) + + # 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] + + # Sort by relevance score (highest first) + results.sort(key=lambda x: x.relevance_score, reverse=True) + + return results[:query.limit] + + def _create_search_result_from_quote( + self, quote: Dict, language: str + ) -> Optional[AssetSearchResult]: + """Create search result from Yahoo Finance search quote.""" + try: + symbol = quote.get("symbol", "") + if not symbol: + return None + + # Get exchange information first + exchange = quote.get("exchange", "UNKNOWN") + + # Map yfinance exchange codes to our internal format + exchange_mapping = { + "NMS": "NASDAQ", + "NYQ": "NYSE", + "ASE": "AMEX", + "SHH": "SSE", + "SHZ": "SZSE", + "HKG": "HKEX", + "TYO": "TSE", + "LSE": "LSE", + "PAR": "EURONEXT", + "FRA": "XETRA", + "PCX": "NYSE", # Pacific Exchange (for ETFs like SPY) + "CCC": "CRYPTO", # Crypto + } + mapped_exchange = exchange_mapping.get(exchange, exchange) + + # Create internal ticker with correct exchange + internal_ticker = f"{mapped_exchange}:{symbol}" + + # Get asset type from quote type + quote_type = quote.get("quoteType", "").upper() + asset_type = self.asset_type_mapping.get(quote_type, AssetType.STOCK) + + # Get country information + country = "US" # Default + if mapped_exchange in ["SSE", "SZSE"]: + country = "CN" + elif mapped_exchange == "HKEX": + country = "HK" + elif mapped_exchange == "TSE": + country = "JP" + elif mapped_exchange in ["LSE", "EURONEXT", "XETRA"]: + country = "GB" if mapped_exchange == "LSE" else "DE" + + # Get names in different languages + long_name = quote.get("longname", quote.get("shortname", symbol)) + short_name = quote.get("shortname", symbol) + + names = { + "en-US": long_name or short_name, + "en-GB": long_name or short_name, + } + + # Calculate relevance score based on match quality + relevance_score = self._calculate_search_relevance( + quote, symbol, long_name or short_name + ) + + return AssetSearchResult( + ticker=internal_ticker, + asset_type=asset_type, + names=names, + exchange=mapped_exchange, + country=country, + currency=quote.get("currency", "USD"), + market_status=MarketStatus.UNKNOWN, + relevance_score=relevance_score, + ) + + except Exception as e: + logger.error(f"Error creating search result from quote: {e}") + return None + + def _fallback_ticker_search( + self, search_term: str, query: AssetSearchQuery + ) -> List[AssetSearchResult]: + """Fallback search using direct ticker lookup with common suffixes.""" + results = [] + # Try direct ticker lookup first try: ticker_obj = yf.Ticker(search_term) info = ticker_obj.info - if info and "symbol" in info: + if info and "symbol" in info and info.get("symbol"): result = self._create_search_result_from_info(info, query.language) if result: results.append(result) @@ -82,36 +209,51 @@ def search_assets(self, query: AssetSearchQuery) -> List[AssetSearchResult]: # Try with common suffixes for international markets if not results: - suffixes = [".SS", ".SZ", ".HK", ".T", ".L", ".PA", ".DE"] + suffixes = [".SS", ".SZ", ".HK", ".T", ".L", ".PA", ".DE", ".TO", ".AX"] 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 info and "symbol" in info and info.get("symbol"): + 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] + return results + + def _calculate_search_relevance( + self, quote: Dict, symbol: str, name: str + ) -> float: + """Calculate relevance score for search results.""" + score = 0.0 + + # Base score for having a result + score += 0.5 + + # Higher score for exact symbol matches + if quote.get("symbol", "").upper() == symbol.upper(): + score += 0.3 + + # Score based on market cap (larger companies get higher scores) + market_cap = quote.get("marketCap") + if market_cap and isinstance(market_cap, (int, float)) and market_cap > 0: + # Normalize market cap to 0-0.2 range + score += min(0.2, market_cap / 1e12) # Trillion dollar companies get max score + + # Bonus for having complete information + if quote.get("longname"): + score += 0.1 + if quote.get("currency"): + score += 0.05 + if quote.get("exchange"): + score += 0.05 + + return min(1.0, score) # Cap at 1.0 def _create_search_result_from_info( self, info: Dict, language: str diff --git a/python/valuecell/tests/test_yfinance_search.py b/python/valuecell/tests/test_yfinance_search.py new file mode 100644 index 000000000..7d3bbfa70 --- /dev/null +++ b/python/valuecell/tests/test_yfinance_search.py @@ -0,0 +1,455 @@ +"""Comprehensive tests for YFinanceAdapter search functionality. + +This module tests the updated search_assets function that uses yfinance.Search +for improved search results across stocks, ETFs, and other financial assets. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +from decimal import Decimal +from datetime import datetime + +from valuecell.adapters.assets.yfinance_adapter import YFinanceAdapter +from valuecell.adapters.assets.types import ( + AssetSearchQuery, + AssetSearchResult, + AssetType, + MarketStatus, + DataSource, +) + + +class TestYFinanceAdapterSearch: + """Test suite for YFinanceAdapter search functionality.""" + + @pytest.fixture + def adapter(self): + """Create a YFinanceAdapter instance for testing.""" + with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + mock_yf.Ticker.return_value.info = {"symbol": "AAPL"} + adapter = YFinanceAdapter() + return adapter + + @pytest.fixture + def sample_search_query(self): + """Create a sample search query for testing.""" + return AssetSearchQuery( + query="Apple", + asset_types=[AssetType.STOCK], + limit=10, + language="en-US" + ) + + @pytest.fixture + def mock_search_quotes(self): + """Mock search results from yfinance.Search.""" + return [ + { + "symbol": "AAPL", + "shortname": "Apple Inc.", + "longname": "Apple Inc.", + "quoteType": "EQUITY", + "exchange": "NMS", + "currency": "USD", + "marketCap": 3000000000000, # 3T market cap + }, + { + "symbol": "APPL", + "shortname": "Appleton Papers Inc", + "longname": "Appleton Papers Inc.", + "quoteType": "EQUITY", + "exchange": "NYQ", + "currency": "USD", + "marketCap": 1000000000, # 1B market cap + }, + { + "symbol": "MSFT", + "shortname": "Microsoft Corporation", + "longname": "Microsoft Corporation", + "quoteType": "EQUITY", + "exchange": "NMS", + "currency": "USD", + "marketCap": 2800000000000, # 2.8T market cap + } + ] + + def test_search_assets_with_yfinance_search_success(self, adapter, sample_search_query, mock_search_quotes): + """Test successful search using yfinance.Search API.""" + with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + # Mock the Search object + mock_search = Mock() + mock_search.quotes = mock_search_quotes + mock_yf.Search.return_value = mock_search + + # Perform search + results = adapter.search_assets(sample_search_query) + + # Verify results + assert len(results) > 0 + assert isinstance(results[0], AssetSearchResult) + + # Check that yfinance.Search was called + mock_yf.Search.assert_called_once_with("Apple") + + # Verify first result (should be AAPL with highest relevance) + first_result = results[0] + assert first_result.ticker == "NASDAQ:AAPL" + assert first_result.asset_type == AssetType.STOCK + assert "Apple Inc." in first_result.names["en-US"] + assert first_result.exchange == "NASDAQ" + assert first_result.currency == "USD" + assert first_result.relevance_score > 0.5 + + def test_search_assets_fallback_to_direct_lookup(self, adapter, sample_search_query): + """Test fallback to direct ticker lookup when Search API fails.""" + with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + # Mock Search to raise an exception + mock_yf.Search.side_effect = Exception("Search API failed") + + # Mock direct ticker lookup + mock_ticker = Mock() + mock_ticker.info = { + "symbol": "AAPL", + "longName": "Apple Inc.", + "shortName": "Apple Inc.", + "quoteType": "EQUITY", + "exchange": "NASDAQ", + "currency": "USD", + "country": "US" + } + mock_yf.Ticker.return_value = mock_ticker + + # Update query to search for specific ticker + sample_search_query.query = "AAPL" + + # Perform search + results = adapter.search_assets(sample_search_query) + + # Verify fallback was used + assert len(results) > 0 + mock_yf.Ticker.assert_called() + + def test_search_assets_with_filters(self, adapter, mock_search_quotes): + """Test search with various filters applied.""" + with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + mock_search = Mock() + mock_search.quotes = mock_search_quotes + mock_yf.Search.return_value = mock_search + + # Test with asset type filter + query = AssetSearchQuery( + query="Apple", + asset_types=[AssetType.ETF], # Filter for ETF only + limit=10, + language="en-US" + ) + + results = adapter.search_assets(query) + + # Should return no results since all mock results are stocks + assert len(results) == 0 + + def test_search_assets_with_exchange_filter(self, adapter, mock_search_quotes): + """Test search with exchange filter.""" + with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + mock_search = Mock() + mock_search.quotes = mock_search_quotes + mock_yf.Search.return_value = mock_search + + # Test with exchange filter + query = AssetSearchQuery( + query="Apple", + exchanges=["NYSE"], # Filter for NYSE only + limit=10, + language="en-US" + ) + + results = adapter.search_assets(query) + + # Should return only NYSE results + for result in results: + assert result.exchange == "NYSE" + + def test_search_assets_with_country_filter(self, adapter, mock_search_quotes): + """Test search with country filter.""" + with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + mock_search = Mock() + # Add international stock to mock data + international_quotes = mock_search_quotes + [{ + "symbol": "0700.HK", + "shortname": "Tencent Holdings Ltd", + "longname": "Tencent Holdings Limited", + "quoteType": "EQUITY", + "exchange": "HKG", + "currency": "HKD", + "marketCap": 500000000000, + }] + mock_search.quotes = international_quotes + mock_yf.Search.return_value = mock_search + + # Test with country filter + query = AssetSearchQuery( + query="Tencent", + countries=["HK"], # Filter for Hong Kong only + limit=10, + language="en-US" + ) + + results = adapter.search_assets(query) + + # Should return only HK results + for result in results: + assert result.country == "HK" + + def test_search_assets_limit_results(self, adapter, mock_search_quotes): + """Test that search respects the limit parameter.""" + with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + mock_search = Mock() + mock_search.quotes = mock_search_quotes + mock_yf.Search.return_value = mock_search + + # Test with small limit + query = AssetSearchQuery( + query="Apple", + limit=2, + language="en-US" + ) + + results = adapter.search_assets(query) + + # Should return at most 2 results + assert len(results) <= 2 + + def test_search_assets_relevance_scoring(self, adapter, mock_search_quotes): + """Test that results are sorted by relevance score.""" + with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + mock_search = Mock() + mock_search.quotes = mock_search_quotes + mock_yf.Search.return_value = mock_search + + query = AssetSearchQuery( + query="Apple", + limit=10, + language="en-US" + ) + + results = adapter.search_assets(query) + + # Results should be sorted by relevance (highest first) + if len(results) > 1: + for i in range(len(results) - 1): + assert results[i].relevance_score >= results[i + 1].relevance_score + + def test_create_search_result_from_quote(self, adapter): + """Test creation of search result from quote data.""" + quote = { + "symbol": "AAPL", + "shortname": "Apple Inc.", + "longname": "Apple Inc.", + "quoteType": "EQUITY", + "exchange": "NMS", + "currency": "USD", + "marketCap": 3000000000000, + } + + result = adapter._create_search_result_from_quote(quote, "en-US") + + assert result is not None + assert result.ticker == "NASDAQ:AAPL" + assert result.asset_type == AssetType.STOCK + assert result.names["en-US"] == "Apple Inc." + assert result.exchange == "NASDAQ" + assert result.currency == "USD" + assert result.relevance_score > 0 + + def test_create_search_result_from_quote_invalid(self, adapter): + """Test handling of invalid quote data.""" + invalid_quote = {} # Empty quote + + result = adapter._create_search_result_from_quote(invalid_quote, "en-US") + + assert result is None + + def test_calculate_search_relevance(self, adapter): + """Test relevance score calculation.""" + # High relevance quote (exact match, large market cap) + high_relevance_quote = { + "symbol": "AAPL", + "longname": "Apple Inc.", + "currency": "USD", + "exchange": "NMS", + "marketCap": 3000000000000, + } + + score = adapter._calculate_search_relevance(high_relevance_quote, "AAPL", "Apple Inc.") + assert score > 0.8 # Should be high relevance + + # Low relevance quote (no match, small market cap) + low_relevance_quote = { + "symbol": "UNKNOWN", + "marketCap": 1000000, + } + + score = adapter._calculate_search_relevance(low_relevance_quote, "AAPL", "Apple Inc.") + assert score < 0.7 # Should be lower relevance + + def test_fallback_ticker_search(self, adapter): + """Test fallback ticker search functionality.""" + with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + # Mock ticker info + mock_ticker = Mock() + mock_ticker.info = { + "symbol": "AAPL", + "longName": "Apple Inc.", + "shortName": "Apple Inc.", + "quoteType": "EQUITY", + "exchange": "NASDAQ", + "currency": "USD", + "country": "US" + } + mock_yf.Ticker.return_value = mock_ticker + + query = AssetSearchQuery( + query="AAPL", + limit=10, + language="en-US" + ) + + results = adapter._fallback_ticker_search("AAPL", query) + + assert len(results) > 0 + assert results[0].ticker.endswith("AAPL") + + def test_fallback_ticker_search_with_suffixes(self, adapter): + """Test fallback search with international market suffixes.""" + with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + # Mock first call to fail, second call with suffix to succeed + def mock_ticker_side_effect(symbol): + mock_ticker = Mock() + if symbol == "0700": + mock_ticker.info = {} # Empty info (failure) + elif symbol == "0700.HK": + mock_ticker.info = { + "symbol": "0700.HK", + "longName": "Tencent Holdings Limited", + "shortName": "Tencent", + "quoteType": "EQUITY", + "exchange": "HKG", + "currency": "HKD", + "country": "HK" + } + else: + mock_ticker.info = {} + return mock_ticker + + mock_yf.Ticker.side_effect = mock_ticker_side_effect + + query = AssetSearchQuery( + query="0700", + limit=10, + language="en-US" + ) + + results = adapter._fallback_ticker_search("0700", query) + + assert len(results) > 0 + # Should find the HK version + found_hk = any("HK" in result.ticker for result in results) + assert found_hk + + def test_search_assets_empty_query(self, adapter): + """Test search with empty query.""" + query = AssetSearchQuery( + query="", + limit=10, + language="en-US" + ) + + with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + mock_search = Mock() + mock_search.quotes = [] + mock_yf.Search.return_value = mock_search + + results = adapter.search_assets(query) + + # Should return empty results for empty query + assert len(results) == 0 + + def test_search_assets_no_results(self, adapter): + """Test search when no results are found.""" + query = AssetSearchQuery( + query="NONEXISTENTSTOCK", + limit=10, + language="en-US" + ) + + with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + # Mock Search to return empty results + mock_search = Mock() + mock_search.quotes = [] + mock_yf.Search.return_value = mock_search + + # Mock direct ticker lookup to also fail + mock_ticker = Mock() + mock_ticker.info = {} + mock_yf.Ticker.return_value = mock_ticker + + results = adapter.search_assets(query) + + assert len(results) == 0 + + @pytest.mark.parametrize("exchange_code,expected_exchange", [ + ("NMS", "NASDAQ"), + ("NYQ", "NYSE"), + ("ASE", "AMEX"), + ("HKG", "HKEX"), + ("TYO", "TSE"), + ("LSE", "LSE"), + ]) + def test_exchange_mapping(self, adapter, exchange_code, expected_exchange): + """Test exchange code mapping from yfinance to internal format.""" + quote = { + "symbol": "TEST", + "shortname": "Test Company", + "longname": "Test Company Inc.", + "quoteType": "EQUITY", + "exchange": exchange_code, + "currency": "USD", + } + + result = adapter._create_search_result_from_quote(quote, "en-US") + + assert result is not None + assert result.exchange == expected_exchange + + def test_search_with_crypto_assets(self, adapter): + """Test search functionality with cryptocurrency assets.""" + crypto_quotes = [ + { + "symbol": "BTC-USD", + "shortname": "Bitcoin", + "longname": "Bitcoin USD", + "quoteType": "CRYPTOCURRENCY", + "exchange": "CCC", + "currency": "USD", + "marketCap": 800000000000, + } + ] + + with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + mock_search = Mock() + mock_search.quotes = crypto_quotes + mock_yf.Search.return_value = mock_search + + query = AssetSearchQuery( + query="Bitcoin", + asset_types=[AssetType.CRYPTO], + limit=10, + language="en-US" + ) + + results = adapter.search_assets(query) + + assert len(results) > 0 + assert results[0].asset_type == AssetType.CRYPTO + assert "Bitcoin" in results[0].names["en-US"] From c1dcbbd372ee94e8020016ef3743ad9bd3abf1f1 Mon Sep 17 00:00:00 2001 From: hazeone <709547807@qq.com> Date: Wed, 17 Sep 2025 14:45:30 +0800 Subject: [PATCH 5/8] add watchlist api --- .gitignore | 8 + .../adapters/assets/yfinance_adapter.py | 48 +- python/valuecell/server/api/app.py | 4 + .../valuecell/server/api/routers/watchlist.py | 487 ++++++++++++++++++ .../valuecell/server/api/schemas/__init__.py | 25 + .../valuecell/server/api/schemas/watchlist.py | 163 ++++++ python/valuecell/server/db/models/__init__.py | 3 + .../valuecell/server/db/models/watchlist.py | 186 +++++++ .../db/repositories/watchlist_repository.py | 461 +++++++++++++++++ .../valuecell/tests/test_yfinance_search.py | 244 ++++----- 10 files changed, 1477 insertions(+), 152 deletions(-) create mode 100644 python/valuecell/server/api/routers/watchlist.py create mode 100644 python/valuecell/server/api/schemas/watchlist.py create mode 100644 python/valuecell/server/db/models/watchlist.py create mode 100644 python/valuecell/server/db/repositories/watchlist_repository.py diff --git a/.gitignore b/.gitignore index b7faf403d..c5c10538d 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,11 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# DB +*.db +*.db-journal +*.db-wal +*.db-shm +*.sqlite +*.sqlite3 \ No newline at end of file diff --git a/python/valuecell/adapters/assets/yfinance_adapter.py b/python/valuecell/adapters/assets/yfinance_adapter.py index b1c712d7c..c5575bacc 100644 --- a/python/valuecell/adapters/assets/yfinance_adapter.py +++ b/python/valuecell/adapters/assets/yfinance_adapter.py @@ -72,14 +72,18 @@ def search_assets(self, query: AssetSearchQuery) -> List[AssetSearchResult]: try: # Use yfinance Search API for comprehensive search search_obj = yf.Search(search_term) - + # Get search results from different categories - search_quotes = getattr(search_obj, 'quotes', []) - + search_quotes = getattr(search_obj, "quotes", []) + # Process search results - for quote in search_quotes[:query.limit * 2]: # Get more results to filter later + for quote in search_quotes[ + : query.limit * 2 + ]: # Get more results to filter later try: - result = self._create_search_result_from_quote(quote, query.language) + result = self._create_search_result_from_quote( + quote, query.language + ) if result: results.append(result) except Exception as e: @@ -88,7 +92,7 @@ def search_assets(self, query: AssetSearchQuery) -> List[AssetSearchResult]: except Exception as e: logger.debug(f"yfinance Search API failed for '{search_term}': {e}") - + # Fallback to direct ticker lookup results.extend(self._fallback_ticker_search(search_term, query)) @@ -111,7 +115,7 @@ def search_assets(self, query: AssetSearchQuery) -> List[AssetSearchResult]: # Sort by relevance score (highest first) results.sort(key=lambda x: x.relevance_score, reverse=True) - return results[:query.limit] + return results[: query.limit] def _create_search_result_from_quote( self, quote: Dict, language: str @@ -124,11 +128,11 @@ def _create_search_result_from_quote( # Get exchange information first exchange = quote.get("exchange", "UNKNOWN") - + # Map yfinance exchange codes to our internal format exchange_mapping = { "NMS": "NASDAQ", - "NYQ": "NYSE", + "NYQ": "NYSE", "ASE": "AMEX", "SHH": "SSE", "SHZ": "SZSE", @@ -163,7 +167,7 @@ def _create_search_result_from_quote( # Get names in different languages long_name = quote.get("longname", quote.get("shortname", symbol)) short_name = quote.get("shortname", symbol) - + names = { "en-US": long_name or short_name, "en-GB": long_name or short_name, @@ -194,7 +198,7 @@ def _fallback_ticker_search( ) -> List[AssetSearchResult]: """Fallback search using direct ticker lookup with common suffixes.""" results = [] - + # Try direct ticker lookup first try: ticker_obj = yf.Ticker(search_term) @@ -217,7 +221,9 @@ def _fallback_ticker_search( info = ticker_obj.info if info and "symbol" in info and info.get("symbol"): - result = self._create_search_result_from_info(info, query.language) + result = self._create_search_result_from_info( + info, query.language + ) if result: results.append(result) break # Found one, stop searching @@ -226,25 +232,25 @@ def _fallback_ticker_search( return results - def _calculate_search_relevance( - self, quote: Dict, symbol: str, name: str - ) -> float: + def _calculate_search_relevance(self, quote: Dict, symbol: str, name: str) -> float: """Calculate relevance score for search results.""" score = 0.0 - + # Base score for having a result score += 0.5 - + # Higher score for exact symbol matches if quote.get("symbol", "").upper() == symbol.upper(): score += 0.3 - + # Score based on market cap (larger companies get higher scores) market_cap = quote.get("marketCap") if market_cap and isinstance(market_cap, (int, float)) and market_cap > 0: # Normalize market cap to 0-0.2 range - score += min(0.2, market_cap / 1e12) # Trillion dollar companies get max score - + score += min( + 0.2, market_cap / 1e12 + ) # Trillion dollar companies get max score + # Bonus for having complete information if quote.get("longname"): score += 0.1 @@ -252,7 +258,7 @@ def _calculate_search_relevance( score += 0.05 if quote.get("exchange"): score += 0.05 - + return min(1.0, score) # Cap at 1.0 def _create_search_result_from_info( diff --git a/python/valuecell/server/api/app.py b/python/valuecell/server/api/app.py index e879f5cf1..d2dda381d 100644 --- a/python/valuecell/server/api/app.py +++ b/python/valuecell/server/api/app.py @@ -14,6 +14,7 @@ from ..config.settings import get_settings from .routers.i18n import create_i18n_router from .routers.system import create_system_router +from .routers.watchlist import create_watchlist_router from .schemas import SuccessResponse, AppInfoData @@ -98,6 +99,9 @@ async def root(): # Include system router app.include_router(create_system_router()) + # Include watchlist router + app.include_router(create_watchlist_router()) + # For uvicorn app = create_app() diff --git a/python/valuecell/server/api/routers/watchlist.py b/python/valuecell/server/api/routers/watchlist.py new file mode 100644 index 000000000..4b9b8e769 --- /dev/null +++ b/python/valuecell/server/api/routers/watchlist.py @@ -0,0 +1,487 @@ +"""Watchlist related API routes.""" + +from typing import Optional, List +from fastapi import APIRouter, HTTPException, Query, Path + +from ..schemas import ( + SuccessResponse, + WatchlistData, + WatchlistItemData, + CreateWatchlistRequest, + AddStockRequest, + UpdateStockNotesRequest, + AssetSearchResultData, + AssetInfoData, + AssetDetailData, + AssetPriceData, +) +from ...services.assets.asset_service import get_asset_service +from ...db.repositories.watchlist_repository import get_watchlist_repository + + +def create_watchlist_router() -> APIRouter: + """Create watchlist related routes.""" + router = APIRouter(prefix="/api/v1/watchlist", tags=["Watchlist"]) + + # Get dependencies + asset_service = get_asset_service() + watchlist_repo = get_watchlist_repository() + + @router.get( + "/search", + response_model=SuccessResponse[AssetSearchResultData], + summary="Search assets", + description="Search for financial assets (stocks, etc.) with filtering options", + ) + async def search_assets( + q: str = Query(..., description="Search query", min_length=1), + asset_types: Optional[str] = Query( + None, description="Comma-separated asset types" + ), + exchanges: Optional[str] = Query(None, description="Comma-separated exchanges"), + countries: Optional[str] = Query(None, description="Comma-separated countries"), + limit: int = Query(50, description="Maximum results", ge=1, le=200), + language: Optional[str] = Query( + None, description="Language for localized results" + ), + ): + """Search for financial assets.""" + try: + # Parse comma-separated filters + asset_types_list = asset_types.split(",") if asset_types else None + exchanges_list = exchanges.split(",") if exchanges else None + countries_list = countries.split(",") if countries else None + + # Perform search using asset service + result = asset_service.search_assets( + query=q, + asset_types=asset_types_list, + exchanges=exchanges_list, + countries=countries_list, + limit=limit, + language=language, + ) + + if not result.get("success", False): + raise HTTPException( + status_code=500, detail=result.get("error", "Search failed") + ) + + # Convert to response format + search_result = AssetSearchResultData( + results=[AssetInfoData(**asset) for asset in result["results"]], + count=result["count"], + query=result["query"], + filters=result["filters"], + language=result["language"], + ) + + return SuccessResponse.create( + data=search_result, msg="Asset search completed successfully" + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Search error: {str(e)}") + + @router.get( + "/asset/{ticker}", + response_model=SuccessResponse[AssetDetailData], + summary="Get asset details", + description="Get detailed information about a specific asset", + ) + async def get_asset_detail( + ticker: str = Path(..., description="Asset ticker"), + language: Optional[str] = Query( + None, description="Language for localized content" + ), + ): + """Get detailed asset information.""" + try: + result = asset_service.get_asset_info(ticker, language=language) + + if not result.get("success", False): + if "not found" in result.get("error", "").lower(): + raise HTTPException( + status_code=404, detail=f"Asset '{ticker}' not found" + ) + raise HTTPException( + status_code=500, + detail=result.get("error", "Failed to get asset info"), + ) + + # Remove success field from result for AssetDetailData + asset_data = {k: v for k, v in result.items() if k != "success"} + asset_detail = AssetDetailData(**asset_data) + + return SuccessResponse.create( + data=asset_detail, msg="Asset details retrieved successfully" + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Asset detail error: {str(e)}") + + @router.get( + "/asset/{ticker}/price", + response_model=SuccessResponse[AssetPriceData], + summary="Get asset price", + description="Get current price information for an asset", + ) + async def get_asset_price( + ticker: str = Path(..., description="Asset ticker"), + language: Optional[str] = Query( + None, description="Language for localized formatting" + ), + ): + """Get current asset price.""" + try: + result = asset_service.get_asset_price(ticker, language=language) + + if not result.get("success", False): + if "not available" in result.get("error", "").lower(): + raise HTTPException( + status_code=404, + detail=f"Price data not available for '{ticker}'", + ) + raise HTTPException( + status_code=500, + detail=result.get("error", "Failed to get price data"), + ) + + # Remove success field from result for AssetPriceData + price_data = {k: v for k, v in result.items() if k != "success"} + asset_price = AssetPriceData(**price_data) + + return SuccessResponse.create( + data=asset_price, msg="Asset price retrieved successfully" + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Price data error: {str(e)}") + + @router.get( + "/{user_id}", + response_model=SuccessResponse[List[WatchlistData]], + summary="Get user watchlists", + description="Get all watchlists for a user", + ) + async def get_user_watchlists(user_id: str = Path(..., description="User ID")): + """Get all watchlists for a user.""" + try: + watchlists = watchlist_repo.get_user_watchlists(user_id) + + watchlist_data = [] + for watchlist in watchlists: + # Convert items to data format + items_data = [] + for item in watchlist.items: + item_dict = item.to_dict() + item_dict["exchange"] = item.exchange + item_dict["symbol"] = item.symbol + items_data.append(WatchlistItemData(**item_dict)) + + # Convert watchlist to data format + watchlist_dict = watchlist.to_dict() + watchlist_dict["items"] = items_data + watchlist_data.append(WatchlistData(**watchlist_dict)) + + return SuccessResponse.create( + data=watchlist_data, msg=f"Retrieved {len(watchlist_data)} watchlists" + ) + + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Failed to get watchlists: {str(e)}" + ) + + @router.get( + "/{user_id}/{watchlist_name}", + response_model=SuccessResponse[WatchlistData], + summary="Get specific watchlist", + description="Get a specific watchlist by name with optional price data", + ) + async def get_watchlist( + user_id: str = Path(..., description="User ID"), + watchlist_name: str = Path(..., description="Watchlist name"), + include_prices: bool = Query(True, description="Include current prices"), + language: Optional[str] = Query( + None, description="Language for localized content" + ), + ): + """Get a specific watchlist.""" + try: + # Use asset service to get watchlist with prices + result = asset_service.get_watchlist( + user_id=user_id, + watchlist_name=watchlist_name, + include_prices=include_prices, + language=language, + ) + + if not result.get("success", False): + if "not found" in result.get("error", "").lower(): + raise HTTPException( + status_code=404, + detail=f"Watchlist '{watchlist_name}' not found for user '{user_id}'", + ) + raise HTTPException( + status_code=500, + detail=result.get("error", "Failed to get watchlist"), + ) + + # Convert watchlist data + watchlist_info = result["watchlist"] + + # Convert assets to WatchlistItemData format + items_data = [] + for asset in watchlist_info.get("assets", []): + item_data = { + "id": 0, # This would be set from database + "ticker": asset["ticker"], + "notes": asset.get("notes", ""), + "order_index": asset.get("order", 0), + "added_at": asset["added_at"], + "updated_at": asset["added_at"], # Fallback + "exchange": asset["ticker"].split(":")[0] + if ":" in asset["ticker"] + else "", + "symbol": asset["ticker"].split(":")[1] + if ":" in asset["ticker"] + else asset["ticker"], + } + items_data.append(WatchlistItemData(**item_data)) + + watchlist_data = WatchlistData( + id=0, # This would be set from database + user_id=watchlist_info["user_id"], + name=watchlist_info["name"], + description=watchlist_info.get("description", ""), + is_default=watchlist_info.get("is_default", False), + is_public=watchlist_info.get("is_public", False), + created_at=watchlist_info["created_at"], + updated_at=watchlist_info["updated_at"], + items_count=watchlist_info["items_count"], + items=items_data, + ) + + return SuccessResponse.create( + data=watchlist_data, msg="Watchlist retrieved successfully" + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Failed to get watchlist: {str(e)}" + ) + + @router.post( + "/{user_id}", + response_model=SuccessResponse[WatchlistData], + summary="Create watchlist", + description="Create a new watchlist for a user", + ) + async def create_watchlist( + user_id: str = Path(..., description="User ID"), + request: CreateWatchlistRequest = None, + ): + """Create a new watchlist.""" + try: + watchlist = watchlist_repo.create_watchlist( + user_id=user_id, + name=request.name, + description=request.description or "", + is_default=request.is_default, + is_public=request.is_public, + ) + + if not watchlist: + raise HTTPException( + status_code=400, + detail=f"Failed to create watchlist. Watchlist '{request.name}' may already exist.", + ) + + # Convert to response format + watchlist_dict = watchlist.to_dict() + watchlist_dict["items"] = [] + watchlist_data = WatchlistData(**watchlist_dict) + + return SuccessResponse.create( + data=watchlist_data, msg="Watchlist created successfully" + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Failed to create watchlist: {str(e)}" + ) + + @router.post( + "/{user_id}/stocks", + response_model=SuccessResponse[dict], + summary="Add stock to watchlist", + description="Add a stock to a user's watchlist", + ) + async def add_stock_to_watchlist( + user_id: str = Path(..., description="User ID"), request: AddStockRequest = None + ): + """Add a stock to a watchlist.""" + try: + success = watchlist_repo.add_stock_to_watchlist( + user_id=user_id, + ticker=request.ticker, + watchlist_name=request.watchlist_name, + notes=request.notes or "", + ) + + if not success: + raise HTTPException( + status_code=400, + detail=f"Failed to add stock '{request.ticker}' to watchlist. Stock may already exist or watchlist not found.", + ) + + return SuccessResponse.create( + data={ + "ticker": request.ticker, + "user_id": user_id, + "watchlist_name": request.watchlist_name, + "notes": request.notes, + }, + msg="Stock added to watchlist successfully", + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Failed to add stock: {str(e)}" + ) + + @router.delete( + "/{user_id}/stocks/{ticker}", + response_model=SuccessResponse[dict], + summary="Remove stock from watchlist", + description="Remove a stock from a user's watchlist", + ) + async def remove_stock_from_watchlist( + user_id: str = Path(..., description="User ID"), + ticker: str = Path(..., description="Stock ticker to remove"), + watchlist_name: Optional[str] = Query( + None, description="Watchlist name (uses default if not provided)" + ), + ): + """Remove a stock from a watchlist.""" + try: + success = watchlist_repo.remove_stock_from_watchlist( + user_id=user_id, ticker=ticker, watchlist_name=watchlist_name + ) + + if not success: + raise HTTPException( + status_code=404, + detail=f"Stock '{ticker}' not found in watchlist or watchlist not found", + ) + + return SuccessResponse.create( + data={ + "ticker": ticker, + "user_id": user_id, + "watchlist_name": watchlist_name, + }, + msg="Stock removed from watchlist successfully", + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Failed to remove stock: {str(e)}" + ) + + @router.delete( + "/{user_id}/{watchlist_name}", + response_model=SuccessResponse[dict], + summary="Delete watchlist", + description="Delete a user's watchlist", + ) + async def delete_watchlist( + user_id: str = Path(..., description="User ID"), + watchlist_name: str = Path(..., description="Watchlist name to delete"), + ): + """Delete a watchlist.""" + try: + success = watchlist_repo.delete_watchlist( + user_id=user_id, watchlist_name=watchlist_name + ) + + if not success: + raise HTTPException( + status_code=404, + detail=f"Watchlist '{watchlist_name}' not found for user '{user_id}'", + ) + + return SuccessResponse.create( + data={"user_id": user_id, "watchlist_name": watchlist_name}, + msg="Watchlist deleted successfully", + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Failed to delete watchlist: {str(e)}" + ) + + @router.put( + "/{user_id}/stocks/{ticker}/notes", + response_model=SuccessResponse[dict], + summary="Update stock notes", + description="Update notes for a stock in a watchlist", + ) + async def update_stock_notes( + user_id: str = Path(..., description="User ID"), + ticker: str = Path(..., description="Stock ticker"), + request: UpdateStockNotesRequest = None, + watchlist_name: Optional[str] = Query( + None, description="Watchlist name (uses default if not provided)" + ), + ): + """Update notes for a stock in a watchlist.""" + try: + success = watchlist_repo.update_stock_notes( + user_id=user_id, + ticker=ticker, + notes=request.notes, + watchlist_name=watchlist_name, + ) + + if not success: + raise HTTPException( + status_code=404, + detail=f"Stock '{ticker}' not found in watchlist or watchlist not found", + ) + + return SuccessResponse.create( + data={ + "ticker": ticker, + "user_id": user_id, + "notes": request.notes, + "watchlist_name": watchlist_name, + }, + msg="Stock notes updated successfully", + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Failed to update notes: {str(e)}" + ) + + return router diff --git a/python/valuecell/server/api/schemas/__init__.py b/python/valuecell/server/api/schemas/__init__.py index fbb867382..e637075e0 100644 --- a/python/valuecell/server/api/schemas/__init__.py +++ b/python/valuecell/server/api/schemas/__init__.py @@ -30,6 +30,19 @@ NumberFormatData, CurrencyFormatData, ) +from .watchlist import ( + WatchlistItemData, + WatchlistData, + CreateWatchlistRequest, + AddStockRequest, + UpdateStockNotesRequest, + AssetSearchQuery, + AssetInfoData, + AssetSearchResultData, + AssetDetailData, + AssetPriceData, + WatchlistWithPricesData, +) __all__ = [ # Base schemas @@ -60,4 +73,16 @@ "DateTimeFormatData", "NumberFormatData", "CurrencyFormatData", + # Watchlist schemas + "WatchlistItemData", + "WatchlistData", + "CreateWatchlistRequest", + "AddStockRequest", + "UpdateStockNotesRequest", + "AssetSearchQuery", + "AssetInfoData", + "AssetSearchResultData", + "AssetDetailData", + "AssetPriceData", + "WatchlistWithPricesData", ] diff --git a/python/valuecell/server/api/schemas/watchlist.py b/python/valuecell/server/api/schemas/watchlist.py new file mode 100644 index 000000000..e52ac6f1c --- /dev/null +++ b/python/valuecell/server/api/schemas/watchlist.py @@ -0,0 +1,163 @@ +"""API schemas for watchlist operations.""" + +from typing import Optional, List +from datetime import datetime +from pydantic import BaseModel, Field + + +class WatchlistItemData(BaseModel): + """Watchlist item data schema.""" + + id: int = Field(..., description="Item ID") + ticker: str = Field(..., description="Stock ticker in format 'EXCHANGE:SYMBOL'") + notes: Optional[str] = Field(None, description="User notes about the stock") + order_index: int = Field(..., description="Display order in the watchlist") + added_at: datetime = Field(..., description="When the stock was added") + updated_at: datetime = Field(..., description="When the item was last updated") + + # Derived properties + exchange: str = Field(..., description="Exchange extracted from ticker") + symbol: str = Field(..., description="Symbol extracted from ticker") + + +class WatchlistData(BaseModel): + """Watchlist data schema.""" + + id: int = Field(..., description="Watchlist ID") + user_id: str = Field(..., description="User ID who owns the watchlist") + name: str = Field(..., description="Watchlist name") + description: Optional[str] = Field(None, description="Watchlist description") + is_default: bool = Field(..., description="Whether this is the default watchlist") + is_public: bool = Field(..., description="Whether this watchlist is public") + created_at: datetime = Field(..., description="Creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + items_count: int = Field(..., description="Number of items in the watchlist") + items: Optional[List[WatchlistItemData]] = Field( + None, description="Watchlist items" + ) + + +class CreateWatchlistRequest(BaseModel): + """Request schema for creating a watchlist.""" + + name: str = Field(..., description="Watchlist name", min_length=1, max_length=200) + description: Optional[str] = Field( + None, description="Watchlist description", max_length=1000 + ) + is_default: bool = Field(False, description="Whether this is the default watchlist") + is_public: bool = Field(False, description="Whether this watchlist is public") + + +class AddStockRequest(BaseModel): + """Request schema for adding a stock to watchlist.""" + + ticker: str = Field( + ..., + description="Stock ticker in format 'EXCHANGE:SYMBOL'", + min_length=1, + max_length=50, + ) + watchlist_name: Optional[str] = Field( + None, description="Watchlist name (uses default if not provided)" + ) + notes: Optional[str] = Field( + "", description="User notes about the stock", max_length=1000 + ) + + +class UpdateStockNotesRequest(BaseModel): + """Request schema for updating stock notes.""" + + notes: str = Field(..., description="Updated notes", max_length=1000) + + +class AssetSearchQuery(BaseModel): + """Request schema for asset search.""" + + query: str = Field(..., description="Search query", min_length=1) + asset_types: Optional[List[str]] = 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(50, description="Maximum number of results", ge=1, le=200) + language: Optional[str] = Field(None, description="Language for localized results") + + +class AssetInfoData(BaseModel): + """Asset information data schema.""" + + ticker: str = Field(..., description="Asset ticker") + asset_type: str = Field(..., description="Asset type") + asset_type_display: str = Field( + ..., description="Localized asset type display name" + ) + names: dict = Field(..., description="Asset names in different languages") + display_name: str = Field(..., description="Display name in requested language") + exchange: Optional[str] = Field(None, description="Exchange") + country: Optional[str] = Field(None, description="Country") + currency: Optional[str] = Field(None, description="Currency") + market_status: Optional[str] = Field(None, description="Market status") + market_status_display: Optional[str] = Field( + None, description="Localized market status display" + ) + relevance_score: Optional[float] = Field(None, description="Search relevance score") + + +class AssetSearchResultData(BaseModel): + """Asset search result data schema.""" + + results: List[AssetInfoData] = Field(..., description="Search results") + count: int = Field(..., description="Number of results") + query: str = Field(..., description="Original search query") + filters: dict = Field(..., description="Applied filters") + language: str = Field(..., description="Language used for results") + + +class AssetDetailData(BaseModel): + """Asset detail data schema.""" + + ticker: str = Field(..., description="Asset ticker") + asset_type: str = Field(..., description="Asset type") + asset_type_display: str = Field( + ..., description="Localized asset type display name" + ) + names: dict = Field(..., description="Asset names in different languages") + display_name: str = Field(..., description="Display name in requested language") + descriptions: Optional[dict] = Field(None, description="Asset descriptions") + market_info: dict = Field(..., description="Market information") + source_mappings: dict = Field(..., description="Source mappings") + properties: Optional[dict] = Field(None, description="Additional properties") + created_at: str = Field(..., description="Creation timestamp") + updated_at: str = Field(..., description="Update timestamp") + is_active: bool = Field(..., description="Whether the asset is active") + + +class AssetPriceData(BaseModel): + """Asset price data schema.""" + + ticker: str = Field(..., description="Asset ticker") + price: float = Field(..., description="Current price") + price_formatted: str = Field(..., description="Formatted price with currency") + currency: str = Field(..., description="Currency") + timestamp: str = Field(..., description="Price timestamp") + volume: Optional[float] = Field(None, description="Trading volume") + open_price: Optional[float] = Field(None, description="Opening price") + high_price: Optional[float] = Field(None, description="High price") + low_price: Optional[float] = Field(None, description="Low price") + close_price: Optional[float] = Field(None, description="Closing price") + change: Optional[float] = Field(None, description="Price change") + change_percent: Optional[float] = Field(None, description="Percentage change") + change_percent_formatted: Optional[str] = Field( + None, description="Formatted percentage change" + ) + market_cap: Optional[float] = Field(None, description="Market capitalization") + market_cap_formatted: Optional[str] = Field( + None, description="Formatted market cap" + ) + source: Optional[str] = Field(None, description="Data source") + + +class WatchlistWithPricesData(BaseModel): + """Watchlist data with price information.""" + + watchlist: WatchlistData = Field(..., description="Watchlist information") + prices: Optional[dict] = Field(None, description="Price data for watchlist items") diff --git a/python/valuecell/server/db/models/__init__.py b/python/valuecell/server/db/models/__init__.py index 57f157ba8..0f534b566 100644 --- a/python/valuecell/server/db/models/__init__.py +++ b/python/valuecell/server/db/models/__init__.py @@ -11,10 +11,13 @@ # Import all models to ensure they are registered with SQLAlchemy from .agent import Agent from .asset import Asset +from .watchlist import Watchlist, WatchlistItem # Export all models __all__ = [ "Base", "Agent", "Asset", + "Watchlist", + "WatchlistItem", ] diff --git a/python/valuecell/server/db/models/watchlist.py b/python/valuecell/server/db/models/watchlist.py new file mode 100644 index 000000000..422967cb5 --- /dev/null +++ b/python/valuecell/server/db/models/watchlist.py @@ -0,0 +1,186 @@ +""" +ValueCell Server - Watchlist Models + +This module defines the database models for user watchlists in the ValueCell system. +""" + +from typing import Dict, Any +from sqlalchemy import ( + Column, + Integer, + String, + Text, + Boolean, + DateTime, + ForeignKey, + UniqueConstraint, +) +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship + +from .base import Base + + +class Watchlist(Base): + """ + Watchlist model representing user's stock watchlists. + + This table stores information about user watchlists including name, description, + and metadata. + """ + + __tablename__ = "watchlists" + + # Primary key + id = Column(Integer, primary_key=True, index=True) + + # User identification (using string for flexibility) + user_id = Column( + String(100), + nullable=False, + index=True, + comment="User identifier who owns this watchlist", + ) + + # Watchlist information + name = Column(String(200), nullable=False, comment="Name of the watchlist") + description = Column(Text, nullable=True, comment="Description of the watchlist") + + # Status + is_default = Column( + Boolean, + default=False, + nullable=False, + comment="Whether this is the user's default watchlist", + ) + is_public = Column( + Boolean, + default=False, + nullable=False, + comment="Whether this watchlist is public", + ) + + # Timestamps + created_at = Column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Relationships + items = relationship( + "WatchlistItem", back_populates="watchlist", cascade="all, delete-orphan" + ) + + # Unique constraint for user_id + name + __table_args__ = ( + UniqueConstraint("user_id", "name", name="uq_user_watchlist_name"), + ) + + def __repr__(self): + return ( + f"" + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert watchlist to dictionary representation.""" + return { + "id": self.id, + "user_id": self.user_id, + "name": self.name, + "description": self.description, + "is_default": self.is_default, + "is_public": self.is_public, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "items_count": len(self.items) if self.items else 0, + } + + +class WatchlistItem(Base): + """ + WatchlistItem model representing individual stocks in a watchlist. + + This table stores the relationship between watchlists and assets, + using the format "EXCHANGE:SYMBOL" for stock identification. + """ + + __tablename__ = "watchlist_items" + + # Primary key + id = Column(Integer, primary_key=True, index=True) + + # Foreign key to watchlist + watchlist_id = Column( + Integer, + ForeignKey("watchlists.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + # Stock information in format "EXCHANGE:SYMBOL" (e.g., "NASDAQ:AAPL", "NYSE:TSLA") + ticker = Column( + String(50), + nullable=False, + index=True, + comment="Stock ticker in format 'EXCHANGE:SYMBOL' (e.g., NASDAQ:AAPL)", + ) + + # User notes about this stock + notes = Column(Text, nullable=True, comment="User notes about this stock") + + # Display order in the watchlist + order_index = Column( + Integer, default=0, nullable=False, comment="Display order in the watchlist" + ) + + # Timestamps + added_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment="When the stock was added to the watchlist", + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Relationships + watchlist = relationship("Watchlist", back_populates="items") + + # Unique constraint for watchlist_id + ticker + __table_args__ = ( + UniqueConstraint("watchlist_id", "ticker", name="uq_watchlist_ticker"), + ) + + def __repr__(self): + return f"" + + def to_dict(self) -> Dict[str, Any]: + """Convert watchlist item to dictionary representation.""" + return { + "id": self.id, + "watchlist_id": self.watchlist_id, + "ticker": self.ticker, + "notes": self.notes, + "order_index": self.order_index, + "added_at": self.added_at.isoformat() if self.added_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + @property + def exchange(self) -> str: + """Extract exchange from ticker format 'EXCHANGE:SYMBOL'.""" + return self.ticker.split(":")[0] if ":" in self.ticker else "" + + @property + def symbol(self) -> str: + """Extract symbol from ticker format 'EXCHANGE:SYMBOL'.""" + return self.ticker.split(":")[1] if ":" in self.ticker else self.ticker diff --git a/python/valuecell/server/db/repositories/watchlist_repository.py b/python/valuecell/server/db/repositories/watchlist_repository.py new file mode 100644 index 000000000..255d317a3 --- /dev/null +++ b/python/valuecell/server/db/repositories/watchlist_repository.py @@ -0,0 +1,461 @@ +""" +ValueCell Server - Watchlist Repository + +This module provides database operations for watchlist management. +""" + +from typing import List, Optional +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from sqlalchemy import desc, asc + +from ..models.watchlist import Watchlist, WatchlistItem +from ..connection import get_database_manager + + +class WatchlistRepository: + """Repository class for watchlist database operations.""" + + def __init__(self, db_session: Optional[Session] = None): + """Initialize repository with optional database session.""" + self.db_session = db_session + + def _get_session(self) -> Session: + """Get database session.""" + if self.db_session: + return self.db_session + return get_database_manager().get_session() + + def create_watchlist( + self, + user_id: str, + name: str, + description: str = "", + is_default: bool = False, + is_public: bool = False, + ) -> Optional[Watchlist]: + """Create a new watchlist for a user.""" + session = self._get_session() + + try: + # If this is set as default, unset other default watchlists for this user + if is_default: + session.query(Watchlist).filter( + Watchlist.user_id == user_id, Watchlist.is_default + ).update({"is_default": False}) + + watchlist = Watchlist( + user_id=user_id, + name=name, + description=description, + is_default=is_default, + is_public=is_public, + ) + + session.add(watchlist) + session.commit() + session.refresh(watchlist) + + # Expunge to avoid session issues + session.expunge(watchlist) + + return watchlist + + except IntegrityError: + session.rollback() + return None + except Exception: + session.rollback() + return None + finally: + if not self.db_session: + session.close() + + def get_watchlist(self, user_id: str, watchlist_name: str) -> Optional[Watchlist]: + """Get a specific watchlist by user ID and name.""" + session = self._get_session() + + try: + watchlist = ( + session.query(Watchlist) + .filter(Watchlist.user_id == user_id, Watchlist.name == watchlist_name) + .first() + ) + + if watchlist: + # Eagerly load items to avoid lazy loading issues + session.expunge(watchlist) + for item in watchlist.items: + session.expunge(item) + + return watchlist + + finally: + if not self.db_session: + session.close() + + def get_watchlist_by_id(self, watchlist_id: int) -> Optional[Watchlist]: + """Get a watchlist by ID.""" + session = self._get_session() + + try: + watchlist = ( + session.query(Watchlist).filter(Watchlist.id == watchlist_id).first() + ) + + if watchlist: + # Eagerly load items to avoid lazy loading issues + session.expunge(watchlist) + for item in watchlist.items: + session.expunge(item) + + return watchlist + + finally: + if not self.db_session: + session.close() + + def get_default_watchlist(self, user_id: str) -> Optional[Watchlist]: + """Get user's default watchlist.""" + session = self._get_session() + + try: + watchlist = ( + session.query(Watchlist) + .filter(Watchlist.user_id == user_id, Watchlist.is_default) + .first() + ) + + if watchlist: + # Eagerly load items to avoid lazy loading issues + session.expunge(watchlist) + for item in watchlist.items: + session.expunge(item) + + return watchlist + + finally: + if not self.db_session: + session.close() + + def get_user_watchlists(self, user_id: str) -> List[Watchlist]: + """Get all watchlists for a user.""" + session = self._get_session() + + try: + watchlists = ( + session.query(Watchlist) + .filter(Watchlist.user_id == user_id) + .order_by(desc(Watchlist.is_default), asc(Watchlist.name)) + .all() + ) + + # Eagerly load items for all watchlists to avoid lazy loading issues + for watchlist in watchlists: + session.expunge(watchlist) + for item in watchlist.items: + session.expunge(item) + + return watchlists + + finally: + if not self.db_session: + session.close() + + def delete_watchlist(self, user_id: str, watchlist_name: str) -> bool: + """Delete a watchlist.""" + session = self._get_session() + + try: + watchlist = ( + session.query(Watchlist) + .filter(Watchlist.user_id == user_id, Watchlist.name == watchlist_name) + .first() + ) + + if not watchlist: + return False + + session.delete(watchlist) + session.commit() + + return True + + except Exception: + session.rollback() + return False + finally: + if not self.db_session: + session.close() + + def add_stock_to_watchlist( + self, + user_id: str, + ticker: str, + watchlist_name: Optional[str] = None, + notes: str = "", + order_index: Optional[int] = None, + ) -> bool: + """Add a stock to a watchlist.""" + session = self._get_session() + + try: + # Get watchlist within the same session to avoid detached objects + if watchlist_name: + watchlist = ( + session.query(Watchlist) + .filter( + Watchlist.user_id == user_id, Watchlist.name == watchlist_name + ) + .first() + ) + else: + watchlist = ( + session.query(Watchlist) + .filter(Watchlist.user_id == user_id, Watchlist.is_default) + .first() + ) + + if not watchlist: + # Create default watchlist if it doesn't exist + watchlist = Watchlist( + user_id=user_id, + name="My Watchlist", + description="Default watchlist", + is_default=True, + ) + session.add(watchlist) + session.flush() # Get the ID without committing + + if not watchlist: + return False + + # Set order_index if not provided + if order_index is None: + max_order = ( + session.query(WatchlistItem) + .filter(WatchlistItem.watchlist_id == watchlist.id) + .count() + ) + order_index = max_order + + # Create watchlist item + item = WatchlistItem( + watchlist_id=watchlist.id, + ticker=ticker, + notes=notes, + order_index=order_index, + ) + + session.add(item) + session.commit() + + return True + + except IntegrityError: + session.rollback() + return False + except Exception: + session.rollback() + return False + finally: + if not self.db_session: + session.close() + + def remove_stock_from_watchlist( + self, user_id: str, ticker: str, watchlist_name: Optional[str] = None + ) -> bool: + """Remove a stock from a watchlist.""" + session = self._get_session() + + try: + # Get watchlist within the same session + if watchlist_name: + watchlist = ( + session.query(Watchlist) + .filter( + Watchlist.user_id == user_id, Watchlist.name == watchlist_name + ) + .first() + ) + else: + watchlist = ( + session.query(Watchlist) + .filter(Watchlist.user_id == user_id, Watchlist.is_default) + .first() + ) + + if not watchlist: + return False + + # Find and delete the item + item = ( + session.query(WatchlistItem) + .filter( + WatchlistItem.watchlist_id == watchlist.id, + WatchlistItem.ticker == ticker, + ) + .first() + ) + + if not item: + return False + + session.delete(item) + session.commit() + + return True + + except Exception: + session.rollback() + return False + finally: + if not self.db_session: + session.close() + + def get_watchlist_stocks( + self, user_id: str, watchlist_name: Optional[str] = None + ) -> List[WatchlistItem]: + """Get all stocks in a watchlist.""" + session = self._get_session() + + try: + # Get watchlist within the same session + if watchlist_name: + watchlist = ( + session.query(Watchlist) + .filter( + Watchlist.user_id == user_id, Watchlist.name == watchlist_name + ) + .first() + ) + else: + watchlist = ( + session.query(Watchlist) + .filter(Watchlist.user_id == user_id, Watchlist.is_default) + .first() + ) + + if not watchlist: + return [] + + items = ( + session.query(WatchlistItem) + .filter(WatchlistItem.watchlist_id == watchlist.id) + .order_by(asc(WatchlistItem.order_index)) + .all() + ) + + # Expunge items to avoid session issues + for item in items: + session.expunge(item) + + return items + + finally: + if not self.db_session: + session.close() + + def is_stock_in_watchlist( + self, user_id: str, ticker: str, watchlist_name: Optional[str] = None + ) -> bool: + """Check if a stock is in a watchlist.""" + session = self._get_session() + + try: + # 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 + + item = ( + session.query(WatchlistItem) + .filter( + WatchlistItem.watchlist_id == watchlist.id, + WatchlistItem.ticker == ticker, + ) + .first() + ) + + return item is not None + + finally: + if not self.db_session: + session.close() + + def update_stock_notes( + self, + user_id: str, + ticker: str, + notes: str, + watchlist_name: Optional[str] = None, + ) -> bool: + """Update notes for a stock in a watchlist.""" + session = self._get_session() + + try: + # Get watchlist within the same session + if watchlist_name: + watchlist = ( + session.query(Watchlist) + .filter( + Watchlist.user_id == user_id, Watchlist.name == watchlist_name + ) + .first() + ) + else: + watchlist = ( + session.query(Watchlist) + .filter(Watchlist.user_id == user_id, Watchlist.is_default) + .first() + ) + + if not watchlist: + return False + + # Update the item + item = ( + session.query(WatchlistItem) + .filter( + WatchlistItem.watchlist_id == watchlist.id, + WatchlistItem.ticker == ticker, + ) + .first() + ) + + if not item: + return False + + item.notes = notes + session.commit() + + return True + + except Exception: + session.rollback() + return False + finally: + if not self.db_session: + session.close() + + +# Global repository instance +_watchlist_repository: Optional[WatchlistRepository] = None + + +def get_watchlist_repository() -> WatchlistRepository: + """Get global watchlist repository instance.""" + global _watchlist_repository + if _watchlist_repository is None: + _watchlist_repository = WatchlistRepository() + return _watchlist_repository + + +def reset_watchlist_repository() -> None: + """Reset global watchlist repository instance (mainly for testing).""" + global _watchlist_repository + _watchlist_repository = None diff --git a/python/valuecell/tests/test_yfinance_search.py b/python/valuecell/tests/test_yfinance_search.py index 7d3bbfa70..d57854ced 100644 --- a/python/valuecell/tests/test_yfinance_search.py +++ b/python/valuecell/tests/test_yfinance_search.py @@ -5,17 +5,13 @@ """ import pytest -from unittest.mock import Mock, patch, MagicMock -from decimal import Decimal -from datetime import datetime +from unittest.mock import Mock, patch from valuecell.adapters.assets.yfinance_adapter import YFinanceAdapter from valuecell.adapters.assets.types import ( AssetSearchQuery, AssetSearchResult, AssetType, - MarketStatus, - DataSource, ) @@ -25,7 +21,7 @@ class TestYFinanceAdapterSearch: @pytest.fixture def adapter(self): """Create a YFinanceAdapter instance for testing.""" - with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: mock_yf.Ticker.return_value.info = {"symbol": "AAPL"} adapter = YFinanceAdapter() return adapter @@ -34,10 +30,7 @@ def adapter(self): def sample_search_query(self): """Create a sample search query for testing.""" return AssetSearchQuery( - query="Apple", - asset_types=[AssetType.STOCK], - limit=10, - language="en-US" + query="Apple", asset_types=[AssetType.STOCK], limit=10, language="en-US" ) @pytest.fixture @@ -70,27 +63,29 @@ def mock_search_quotes(self): "exchange": "NMS", "currency": "USD", "marketCap": 2800000000000, # 2.8T market cap - } + }, ] - def test_search_assets_with_yfinance_search_success(self, adapter, sample_search_query, mock_search_quotes): + def test_search_assets_with_yfinance_search_success( + self, adapter, sample_search_query, mock_search_quotes + ): """Test successful search using yfinance.Search API.""" - with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: # Mock the Search object mock_search = Mock() mock_search.quotes = mock_search_quotes mock_yf.Search.return_value = mock_search - + # Perform search results = adapter.search_assets(sample_search_query) - + # Verify results assert len(results) > 0 assert isinstance(results[0], AssetSearchResult) - + # Check that yfinance.Search was called mock_yf.Search.assert_called_once_with("Apple") - + # Verify first result (should be AAPL with highest relevance) first_result = results[0] assert first_result.ticker == "NASDAQ:AAPL" @@ -100,12 +95,14 @@ def test_search_assets_with_yfinance_search_success(self, adapter, sample_search assert first_result.currency == "USD" assert first_result.relevance_score > 0.5 - def test_search_assets_fallback_to_direct_lookup(self, adapter, sample_search_query): + def test_search_assets_fallback_to_direct_lookup( + self, adapter, sample_search_query + ): """Test fallback to direct ticker lookup when Search API fails.""" - with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: # Mock Search to raise an exception mock_yf.Search.side_effect = Exception("Search API failed") - + # Mock direct ticker lookup mock_ticker = Mock() mock_ticker.info = { @@ -115,126 +112,120 @@ def test_search_assets_fallback_to_direct_lookup(self, adapter, sample_search_qu "quoteType": "EQUITY", "exchange": "NASDAQ", "currency": "USD", - "country": "US" + "country": "US", } mock_yf.Ticker.return_value = mock_ticker - + # Update query to search for specific ticker sample_search_query.query = "AAPL" - + # Perform search results = adapter.search_assets(sample_search_query) - + # Verify fallback was used assert len(results) > 0 mock_yf.Ticker.assert_called() def test_search_assets_with_filters(self, adapter, mock_search_quotes): """Test search with various filters applied.""" - with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: mock_search = Mock() mock_search.quotes = mock_search_quotes mock_yf.Search.return_value = mock_search - + # Test with asset type filter query = AssetSearchQuery( query="Apple", asset_types=[AssetType.ETF], # Filter for ETF only limit=10, - language="en-US" + language="en-US", ) - + results = adapter.search_assets(query) - + # Should return no results since all mock results are stocks assert len(results) == 0 def test_search_assets_with_exchange_filter(self, adapter, mock_search_quotes): """Test search with exchange filter.""" - with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: mock_search = Mock() mock_search.quotes = mock_search_quotes mock_yf.Search.return_value = mock_search - + # Test with exchange filter query = AssetSearchQuery( query="Apple", exchanges=["NYSE"], # Filter for NYSE only limit=10, - language="en-US" + language="en-US", ) - + results = adapter.search_assets(query) - + # Should return only NYSE results for result in results: assert result.exchange == "NYSE" def test_search_assets_with_country_filter(self, adapter, mock_search_quotes): """Test search with country filter.""" - with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: mock_search = Mock() # Add international stock to mock data - international_quotes = mock_search_quotes + [{ - "symbol": "0700.HK", - "shortname": "Tencent Holdings Ltd", - "longname": "Tencent Holdings Limited", - "quoteType": "EQUITY", - "exchange": "HKG", - "currency": "HKD", - "marketCap": 500000000000, - }] + international_quotes = mock_search_quotes + [ + { + "symbol": "0700.HK", + "shortname": "Tencent Holdings Ltd", + "longname": "Tencent Holdings Limited", + "quoteType": "EQUITY", + "exchange": "HKG", + "currency": "HKD", + "marketCap": 500000000000, + } + ] mock_search.quotes = international_quotes mock_yf.Search.return_value = mock_search - + # Test with country filter query = AssetSearchQuery( query="Tencent", countries=["HK"], # Filter for Hong Kong only limit=10, - language="en-US" + language="en-US", ) - + results = adapter.search_assets(query) - + # Should return only HK results for result in results: assert result.country == "HK" def test_search_assets_limit_results(self, adapter, mock_search_quotes): """Test that search respects the limit parameter.""" - with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: mock_search = Mock() mock_search.quotes = mock_search_quotes mock_yf.Search.return_value = mock_search - + # Test with small limit - query = AssetSearchQuery( - query="Apple", - limit=2, - language="en-US" - ) - + query = AssetSearchQuery(query="Apple", limit=2, language="en-US") + results = adapter.search_assets(query) - + # Should return at most 2 results assert len(results) <= 2 def test_search_assets_relevance_scoring(self, adapter, mock_search_quotes): """Test that results are sorted by relevance score.""" - with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: mock_search = Mock() mock_search.quotes = mock_search_quotes mock_yf.Search.return_value = mock_search - - query = AssetSearchQuery( - query="Apple", - limit=10, - language="en-US" - ) - + + query = AssetSearchQuery(query="Apple", limit=10, language="en-US") + results = adapter.search_assets(query) - + # Results should be sorted by relevance (highest first) if len(results) > 1: for i in range(len(results) - 1): @@ -251,9 +242,9 @@ def test_create_search_result_from_quote(self, adapter): "currency": "USD", "marketCap": 3000000000000, } - + result = adapter._create_search_result_from_quote(quote, "en-US") - + assert result is not None assert result.ticker == "NASDAQ:AAPL" assert result.asset_type == AssetType.STOCK @@ -265,9 +256,9 @@ def test_create_search_result_from_quote(self, adapter): def test_create_search_result_from_quote_invalid(self, adapter): """Test handling of invalid quote data.""" invalid_quote = {} # Empty quote - + result = adapter._create_search_result_from_quote(invalid_quote, "en-US") - + assert result is None def test_calculate_search_relevance(self, adapter): @@ -280,22 +271,26 @@ def test_calculate_search_relevance(self, adapter): "exchange": "NMS", "marketCap": 3000000000000, } - - score = adapter._calculate_search_relevance(high_relevance_quote, "AAPL", "Apple Inc.") + + score = adapter._calculate_search_relevance( + high_relevance_quote, "AAPL", "Apple Inc." + ) assert score > 0.8 # Should be high relevance - + # Low relevance quote (no match, small market cap) low_relevance_quote = { "symbol": "UNKNOWN", "marketCap": 1000000, } - - score = adapter._calculate_search_relevance(low_relevance_quote, "AAPL", "Apple Inc.") + + score = adapter._calculate_search_relevance( + low_relevance_quote, "AAPL", "Apple Inc." + ) assert score < 0.7 # Should be lower relevance def test_fallback_ticker_search(self, adapter): """Test fallback ticker search functionality.""" - with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: # Mock ticker info mock_ticker = Mock() mock_ticker.info = { @@ -305,24 +300,20 @@ def test_fallback_ticker_search(self, adapter): "quoteType": "EQUITY", "exchange": "NASDAQ", "currency": "USD", - "country": "US" + "country": "US", } mock_yf.Ticker.return_value = mock_ticker - - query = AssetSearchQuery( - query="AAPL", - limit=10, - language="en-US" - ) - + + query = AssetSearchQuery(query="AAPL", limit=10, language="en-US") + results = adapter._fallback_ticker_search("AAPL", query) - + assert len(results) > 0 assert results[0].ticker.endswith("AAPL") def test_fallback_ticker_search_with_suffixes(self, adapter): """Test fallback search with international market suffixes.""" - with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: # Mock first call to fail, second call with suffix to succeed def mock_ticker_side_effect(symbol): mock_ticker = Mock() @@ -336,22 +327,18 @@ def mock_ticker_side_effect(symbol): "quoteType": "EQUITY", "exchange": "HKG", "currency": "HKD", - "country": "HK" + "country": "HK", } else: mock_ticker.info = {} return mock_ticker - + mock_yf.Ticker.side_effect = mock_ticker_side_effect - - query = AssetSearchQuery( - query="0700", - limit=10, - language="en-US" - ) - + + query = AssetSearchQuery(query="0700", limit=10, language="en-US") + results = adapter._fallback_ticker_search("0700", query) - + assert len(results) > 0 # Should find the HK version found_hk = any("HK" in result.ticker for result in results) @@ -359,53 +346,48 @@ def mock_ticker_side_effect(symbol): def test_search_assets_empty_query(self, adapter): """Test search with empty query.""" - query = AssetSearchQuery( - query="", - limit=10, - language="en-US" - ) - - with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + query = AssetSearchQuery(query="", limit=10, language="en-US") + + with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: mock_search = Mock() mock_search.quotes = [] mock_yf.Search.return_value = mock_search - + results = adapter.search_assets(query) - + # Should return empty results for empty query assert len(results) == 0 def test_search_assets_no_results(self, adapter): """Test search when no results are found.""" - query = AssetSearchQuery( - query="NONEXISTENTSTOCK", - limit=10, - language="en-US" - ) - - with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + query = AssetSearchQuery(query="NONEXISTENTSTOCK", limit=10, language="en-US") + + with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: # Mock Search to return empty results mock_search = Mock() mock_search.quotes = [] mock_yf.Search.return_value = mock_search - + # Mock direct ticker lookup to also fail mock_ticker = Mock() mock_ticker.info = {} mock_yf.Ticker.return_value = mock_ticker - + results = adapter.search_assets(query) - + assert len(results) == 0 - @pytest.mark.parametrize("exchange_code,expected_exchange", [ - ("NMS", "NASDAQ"), - ("NYQ", "NYSE"), - ("ASE", "AMEX"), - ("HKG", "HKEX"), - ("TYO", "TSE"), - ("LSE", "LSE"), - ]) + @pytest.mark.parametrize( + "exchange_code,expected_exchange", + [ + ("NMS", "NASDAQ"), + ("NYQ", "NYSE"), + ("ASE", "AMEX"), + ("HKG", "HKEX"), + ("TYO", "TSE"), + ("LSE", "LSE"), + ], + ) def test_exchange_mapping(self, adapter, exchange_code, expected_exchange): """Test exchange code mapping from yfinance to internal format.""" quote = { @@ -416,9 +398,9 @@ def test_exchange_mapping(self, adapter, exchange_code, expected_exchange): "exchange": exchange_code, "currency": "USD", } - + result = adapter._create_search_result_from_quote(quote, "en-US") - + assert result is not None assert result.exchange == expected_exchange @@ -435,21 +417,21 @@ def test_search_with_crypto_assets(self, adapter): "marketCap": 800000000000, } ] - - with patch('valuecell.adapters.assets.yfinance_adapter.yf') as mock_yf: + + with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: mock_search = Mock() mock_search.quotes = crypto_quotes mock_yf.Search.return_value = mock_search - + query = AssetSearchQuery( query="Bitcoin", asset_types=[AssetType.CRYPTO], limit=10, - language="en-US" + language="en-US", ) - + results = adapter.search_assets(query) - + assert len(results) > 0 assert results[0].asset_type == AssetType.CRYPTO assert "Bitcoin" in results[0].names["en-US"] From 310aa65ad226e29e85c65f07a29ccdecd36d8d20 Mon Sep 17 00:00:00 2001 From: hazeone <709547807@qq.com> Date: Wed, 17 Sep 2025 16:37:38 +0800 Subject: [PATCH 6/8] fix: akshare adapter --- .../adapters/assets/akshare_adapter.py | 236 +++++----- python/valuecell/server/api/app.py | 26 ++ .../db/repositories/watchlist_repository.py | 45 +- .../server/services/assets/asset_service.py | 28 +- .../valuecell/tests/test_yfinance_search.py | 437 ------------------ 5 files changed, 198 insertions(+), 574 deletions(-) delete mode 100644 python/valuecell/tests/test_yfinance_search.py diff --git a/python/valuecell/adapters/assets/akshare_adapter.py b/python/valuecell/adapters/assets/akshare_adapter.py index d92a7029e..a8e0aa9b7 100644 --- a/python/valuecell/adapters/assets/akshare_adapter.py +++ b/python/valuecell/adapters/assets/akshare_adapter.py @@ -1234,41 +1234,52 @@ def _safe_decimal_convert(self, value) -> Optional[Decimal]: def _get_hk_stock_price( self, ticker: str, exchange: str, symbol: str ) -> Optional[AssetPrice]: - """Get Hong Kong stock real-time price.""" + """Get Hong Kong stock real-time price using individual stock query.""" try: - df_hk_realtime = ak.stock_hk_spot() - - if df_hk_realtime is None or df_hk_realtime.empty: - return None - - # Find the specific stock - stock_data = df_hk_realtime[df_hk_realtime["symbol"] == symbol] - - if stock_data.empty: - return None - - stock_info = stock_data.iloc[0] + # Use individual stock query instead of downloading all HK stocks + # Try to get individual stock info first + try: + # For HK stocks, try to get historical data as a proxy for current price + df_hk_hist = ak.stock_hk_daily(symbol=symbol, adjust="qfq") + if df_hk_hist is not None and not df_hk_hist.empty: + latest = df_hk_hist.iloc[-1] + current_price = Decimal( + str(latest.get("close", latest.get("收盘", 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=Decimal( + str(latest.get("volume", latest.get("成交量", 0))) + ) + if latest.get("volume", latest.get("成交量", 0)) + else None, + open_price=Decimal( + str(latest.get("open", latest.get("开盘", 0))) + ), + high_price=Decimal( + str(latest.get("high", latest.get("最高", 0))) + ), + low_price=Decimal( + str(latest.get("low", latest.get("最低", 0))) + ), + close_price=current_price, + change=None, + change_percent=None, + market_cap=None, + source=self.source, + ) + except Exception as e: + logger.debug(f"Individual HK stock query failed for {symbol}: {e}") - 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, + # Fallback: return None instead of downloading all HK stocks + logger.warning( + f"Unable to get HK stock price for {symbol} without full market data download" ) + return None except Exception as e: logger.error(f"Error fetching HK stock price for {symbol}: {e}") @@ -1277,41 +1288,51 @@ def _get_hk_stock_price( def _get_us_stock_price( self, ticker: str, exchange: str, symbol: str ) -> Optional[AssetPrice]: - """Get US stock real-time price.""" + """Get US stock real-time price using individual stock query.""" 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] + # Use individual stock query instead of downloading all US stocks + try: + # For US stocks, try to get historical data as a proxy for current price + df_us_hist = ak.stock_us_daily(symbol=symbol, adjust="qfq") + if df_us_hist is not None and not df_us_hist.empty: + latest = df_us_hist.iloc[-1] + current_price = Decimal( + str(latest.get("close", latest.get("收盘", 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=Decimal( + str(latest.get("volume", latest.get("成交量", 0))) + ) + if latest.get("volume", latest.get("成交量", 0)) + else None, + open_price=Decimal( + str(latest.get("open", latest.get("开盘", 0))) + ), + high_price=Decimal( + str(latest.get("high", latest.get("最高", 0))) + ), + low_price=Decimal( + str(latest.get("low", latest.get("最低", 0))) + ), + close_price=current_price, + change=None, + change_percent=None, + market_cap=None, + source=self.source, + ) + except Exception as e: + logger.debug(f"Individual US stock query failed for {symbol}: {e}") - 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, + # Fallback: return None instead of downloading all US stocks + logger.warning( + f"Unable to get US stock price for {symbol} without full market data download" ) + return None except Exception as e: logger.error(f"Error fetching US stock price for {symbol}: {e}") @@ -1320,41 +1341,14 @@ def _get_us_stock_price( def _get_crypto_price( self, ticker: str, exchange: str, symbol: str ) -> Optional[AssetPrice]: - """Get cryptocurrency real-time price.""" + """Get cryptocurrency real-time price without downloading full market data.""" 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, + # Skip downloading all crypto data - this is too expensive + # Return None for now, crypto prices should be handled by other adapters like yfinance + logger.warning( + f"Crypto price fetching disabled for AKShare to avoid full market data download for {symbol}" ) + return None except Exception as e: logger.error(f"Error fetching crypto price for {symbol}: {e}") @@ -1637,35 +1631,37 @@ def get_supported_asset_types(self) -> List[AssetType]: ] def _perform_health_check(self) -> Any: - """Perform health check by testing multiple market endpoints.""" + """Perform health check by testing a simple stock info call instead of full data download.""" try: - # 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, + # Test with a simple individual stock info call instead of downloading all market data + # This avoids the expensive full market data download during health checks + try: + # Test A-share with a known stock (Ping An Bank) + df_test = ak.stock_individual_info_em(symbol="000001") + if df_test is not None and not df_test.empty: + return { + "status": "ok", + "test_method": "individual_stock_info", + "test_symbol": "000001", + "response_received": True, } - except Exception as e: - results[market_name] = {"status": "error", "message": str(e)} + except Exception as e: + logger.debug(f"A-share test failed: {e}") - # Overall status - overall_status = ( - "ok" - if any(r.get("status") == "ok" for r in results.values()) - else "error" - ) + # Fallback: just check if akshare module is available and importable + import akshare as ak_test + + if ak_test: + return { + "status": "ok", + "test_method": "module_import", + "message": "AKShare module available", + } + else: + return {"status": "error", "message": "AKShare module not available"} - return {"status": overall_status, "markets": results} + except Exception as e: + return {"status": "error", "message": str(e)} except Exception as e: return {"status": "error", "message": str(e)} diff --git a/python/valuecell/server/api/app.py b/python/valuecell/server/api/app.py index d2dda381d..c043b3fd2 100644 --- a/python/valuecell/server/api/app.py +++ b/python/valuecell/server/api/app.py @@ -16,6 +16,7 @@ from .routers.system import create_system_router from .routers.watchlist import create_watchlist_router from .schemas import SuccessResponse, AppInfoData +from ...adapters.assets import get_adapter_manager def create_app() -> FastAPI: @@ -28,6 +29,31 @@ async def lifespan(app: FastAPI): print( f"ValueCell Server starting up on {settings.API_HOST}:{settings.API_PORT}..." ) + + # Initialize and configure adapters + try: + print("Configuring data adapters...") + manager = get_adapter_manager() + + # Configure Yahoo Finance (free, no API key required) + try: + manager.configure_yfinance() + print("✓ Yahoo Finance adapter configured") + except Exception as e: + print(f"✗ Yahoo Finance adapter failed: {e}") + + # Configure AKShare (free, no API key required, optimized) + try: + manager.configure_akshare() + print("✓ AKShare adapter configured (optimized)") + except Exception as e: + print(f"✗ AKShare adapter failed: {e}") + + print("Data adapters configuration completed") + + except Exception as e: + print(f"Error configuring adapters: {e}") + yield # Shutdown print("ValueCell Server shutting down...") diff --git a/python/valuecell/server/db/repositories/watchlist_repository.py b/python/valuecell/server/db/repositories/watchlist_repository.py index 255d317a3..5c6c5873a 100644 --- a/python/valuecell/server/db/repositories/watchlist_repository.py +++ b/python/valuecell/server/db/repositories/watchlist_repository.py @@ -84,9 +84,16 @@ def get_watchlist(self, user_id: str, watchlist_name: str) -> Optional[Watchlist if watchlist: # Eagerly load items to avoid lazy loading issues - session.expunge(watchlist) + _ = len(watchlist.items) # This triggers the lazy load for item in watchlist.items: - session.expunge(item) + # Access all needed properties while session is active + _ = ( + item.ticker, + item.notes, + item.order_index, + item.added_at, + item.updated_at, + ) return watchlist @@ -105,9 +112,16 @@ def get_watchlist_by_id(self, watchlist_id: int) -> Optional[Watchlist]: if watchlist: # Eagerly load items to avoid lazy loading issues - session.expunge(watchlist) + _ = len(watchlist.items) # This triggers the lazy load for item in watchlist.items: - session.expunge(item) + # Access all needed properties while session is active + _ = ( + item.ticker, + item.notes, + item.order_index, + item.added_at, + item.updated_at, + ) return watchlist @@ -128,9 +142,16 @@ def get_default_watchlist(self, user_id: str) -> Optional[Watchlist]: if watchlist: # Eagerly load items to avoid lazy loading issues - session.expunge(watchlist) + _ = len(watchlist.items) # This triggers the lazy load for item in watchlist.items: - session.expunge(item) + # Access all needed properties while session is active + _ = ( + item.ticker, + item.notes, + item.order_index, + item.added_at, + item.updated_at, + ) return watchlist @@ -152,9 +173,17 @@ def get_user_watchlists(self, user_id: str) -> List[Watchlist]: # Eagerly load items for all watchlists to avoid lazy loading issues for watchlist in watchlists: - session.expunge(watchlist) + # Force loading of items while session is still active + _ = len(watchlist.items) # This triggers the lazy load for item in watchlist.items: - session.expunge(item) + # Access all needed properties while session is active + _ = ( + item.ticker, + item.notes, + item.order_index, + item.added_at, + item.updated_at, + ) return watchlists diff --git a/python/valuecell/server/services/assets/asset_service.py b/python/valuecell/server/services/assets/asset_service.py index 104a60579..e5dcd21c1 100644 --- a/python/valuecell/server/services/assets/asset_service.py +++ b/python/valuecell/server/services/assets/asset_service.py @@ -24,6 +24,16 @@ def __init__(self): self.adapter_manager = get_adapter_manager() self.watchlist_manager = get_watchlist_manager() self.i18n_service = get_asset_i18n_service() + self._watchlist_repository = None + + @property + def watchlist_repository(self): + """Lazy load watchlist repository to avoid circular imports.""" + if self._watchlist_repository is None: + from ...db.repositories.watchlist_repository import get_watchlist_repository + + self._watchlist_repository = get_watchlist_repository() + return self._watchlist_repository def search_assets( self, @@ -445,13 +455,13 @@ def get_watchlist( Dictionary containing watchlist data """ try: - # Get watchlist + # Get watchlist from database if watchlist_name: - watchlist = self.watchlist_manager.get_watchlist( + watchlist = self.watchlist_repository.get_watchlist( user_id, watchlist_name ) else: - watchlist = self.watchlist_manager.get_default_watchlist(user_id) + watchlist = self.watchlist_repository.get_default_watchlist(user_id) if not watchlist: return { @@ -463,7 +473,7 @@ def get_watchlist( # Get asset information and prices assets_data = [] - tickers = watchlist.get_tickers() + tickers = [item.ticker for item in watchlist.items] # Get prices if requested prices_data = {} @@ -473,16 +483,16 @@ def get_watchlist( prices_data = prices_result["prices"] # Build asset data - for item in sorted(watchlist.items, key=lambda x: x.order): + for item in sorted(watchlist.items, key=lambda x: x.order_index): 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, + "order": item.order_index, + "notes": item.notes or "", + "alerts": [], # Database model doesn't have alerts field } # Add price data if available @@ -496,7 +506,7 @@ def get_watchlist( "watchlist": { "user_id": watchlist.user_id, "name": watchlist.name, - "description": watchlist.description, + "description": watchlist.description or "", "created_at": watchlist.created_at.isoformat(), "updated_at": watchlist.updated_at.isoformat(), "is_default": watchlist.is_default, diff --git a/python/valuecell/tests/test_yfinance_search.py b/python/valuecell/tests/test_yfinance_search.py deleted file mode 100644 index d57854ced..000000000 --- a/python/valuecell/tests/test_yfinance_search.py +++ /dev/null @@ -1,437 +0,0 @@ -"""Comprehensive tests for YFinanceAdapter search functionality. - -This module tests the updated search_assets function that uses yfinance.Search -for improved search results across stocks, ETFs, and other financial assets. -""" - -import pytest -from unittest.mock import Mock, patch - -from valuecell.adapters.assets.yfinance_adapter import YFinanceAdapter -from valuecell.adapters.assets.types import ( - AssetSearchQuery, - AssetSearchResult, - AssetType, -) - - -class TestYFinanceAdapterSearch: - """Test suite for YFinanceAdapter search functionality.""" - - @pytest.fixture - def adapter(self): - """Create a YFinanceAdapter instance for testing.""" - with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: - mock_yf.Ticker.return_value.info = {"symbol": "AAPL"} - adapter = YFinanceAdapter() - return adapter - - @pytest.fixture - def sample_search_query(self): - """Create a sample search query for testing.""" - return AssetSearchQuery( - query="Apple", asset_types=[AssetType.STOCK], limit=10, language="en-US" - ) - - @pytest.fixture - def mock_search_quotes(self): - """Mock search results from yfinance.Search.""" - return [ - { - "symbol": "AAPL", - "shortname": "Apple Inc.", - "longname": "Apple Inc.", - "quoteType": "EQUITY", - "exchange": "NMS", - "currency": "USD", - "marketCap": 3000000000000, # 3T market cap - }, - { - "symbol": "APPL", - "shortname": "Appleton Papers Inc", - "longname": "Appleton Papers Inc.", - "quoteType": "EQUITY", - "exchange": "NYQ", - "currency": "USD", - "marketCap": 1000000000, # 1B market cap - }, - { - "symbol": "MSFT", - "shortname": "Microsoft Corporation", - "longname": "Microsoft Corporation", - "quoteType": "EQUITY", - "exchange": "NMS", - "currency": "USD", - "marketCap": 2800000000000, # 2.8T market cap - }, - ] - - def test_search_assets_with_yfinance_search_success( - self, adapter, sample_search_query, mock_search_quotes - ): - """Test successful search using yfinance.Search API.""" - with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: - # Mock the Search object - mock_search = Mock() - mock_search.quotes = mock_search_quotes - mock_yf.Search.return_value = mock_search - - # Perform search - results = adapter.search_assets(sample_search_query) - - # Verify results - assert len(results) > 0 - assert isinstance(results[0], AssetSearchResult) - - # Check that yfinance.Search was called - mock_yf.Search.assert_called_once_with("Apple") - - # Verify first result (should be AAPL with highest relevance) - first_result = results[0] - assert first_result.ticker == "NASDAQ:AAPL" - assert first_result.asset_type == AssetType.STOCK - assert "Apple Inc." in first_result.names["en-US"] - assert first_result.exchange == "NASDAQ" - assert first_result.currency == "USD" - assert first_result.relevance_score > 0.5 - - def test_search_assets_fallback_to_direct_lookup( - self, adapter, sample_search_query - ): - """Test fallback to direct ticker lookup when Search API fails.""" - with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: - # Mock Search to raise an exception - mock_yf.Search.side_effect = Exception("Search API failed") - - # Mock direct ticker lookup - mock_ticker = Mock() - mock_ticker.info = { - "symbol": "AAPL", - "longName": "Apple Inc.", - "shortName": "Apple Inc.", - "quoteType": "EQUITY", - "exchange": "NASDAQ", - "currency": "USD", - "country": "US", - } - mock_yf.Ticker.return_value = mock_ticker - - # Update query to search for specific ticker - sample_search_query.query = "AAPL" - - # Perform search - results = adapter.search_assets(sample_search_query) - - # Verify fallback was used - assert len(results) > 0 - mock_yf.Ticker.assert_called() - - def test_search_assets_with_filters(self, adapter, mock_search_quotes): - """Test search with various filters applied.""" - with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: - mock_search = Mock() - mock_search.quotes = mock_search_quotes - mock_yf.Search.return_value = mock_search - - # Test with asset type filter - query = AssetSearchQuery( - query="Apple", - asset_types=[AssetType.ETF], # Filter for ETF only - limit=10, - language="en-US", - ) - - results = adapter.search_assets(query) - - # Should return no results since all mock results are stocks - assert len(results) == 0 - - def test_search_assets_with_exchange_filter(self, adapter, mock_search_quotes): - """Test search with exchange filter.""" - with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: - mock_search = Mock() - mock_search.quotes = mock_search_quotes - mock_yf.Search.return_value = mock_search - - # Test with exchange filter - query = AssetSearchQuery( - query="Apple", - exchanges=["NYSE"], # Filter for NYSE only - limit=10, - language="en-US", - ) - - results = adapter.search_assets(query) - - # Should return only NYSE results - for result in results: - assert result.exchange == "NYSE" - - def test_search_assets_with_country_filter(self, adapter, mock_search_quotes): - """Test search with country filter.""" - with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: - mock_search = Mock() - # Add international stock to mock data - international_quotes = mock_search_quotes + [ - { - "symbol": "0700.HK", - "shortname": "Tencent Holdings Ltd", - "longname": "Tencent Holdings Limited", - "quoteType": "EQUITY", - "exchange": "HKG", - "currency": "HKD", - "marketCap": 500000000000, - } - ] - mock_search.quotes = international_quotes - mock_yf.Search.return_value = mock_search - - # Test with country filter - query = AssetSearchQuery( - query="Tencent", - countries=["HK"], # Filter for Hong Kong only - limit=10, - language="en-US", - ) - - results = adapter.search_assets(query) - - # Should return only HK results - for result in results: - assert result.country == "HK" - - def test_search_assets_limit_results(self, adapter, mock_search_quotes): - """Test that search respects the limit parameter.""" - with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: - mock_search = Mock() - mock_search.quotes = mock_search_quotes - mock_yf.Search.return_value = mock_search - - # Test with small limit - query = AssetSearchQuery(query="Apple", limit=2, language="en-US") - - results = adapter.search_assets(query) - - # Should return at most 2 results - assert len(results) <= 2 - - def test_search_assets_relevance_scoring(self, adapter, mock_search_quotes): - """Test that results are sorted by relevance score.""" - with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: - mock_search = Mock() - mock_search.quotes = mock_search_quotes - mock_yf.Search.return_value = mock_search - - query = AssetSearchQuery(query="Apple", limit=10, language="en-US") - - results = adapter.search_assets(query) - - # Results should be sorted by relevance (highest first) - if len(results) > 1: - for i in range(len(results) - 1): - assert results[i].relevance_score >= results[i + 1].relevance_score - - def test_create_search_result_from_quote(self, adapter): - """Test creation of search result from quote data.""" - quote = { - "symbol": "AAPL", - "shortname": "Apple Inc.", - "longname": "Apple Inc.", - "quoteType": "EQUITY", - "exchange": "NMS", - "currency": "USD", - "marketCap": 3000000000000, - } - - result = adapter._create_search_result_from_quote(quote, "en-US") - - assert result is not None - assert result.ticker == "NASDAQ:AAPL" - assert result.asset_type == AssetType.STOCK - assert result.names["en-US"] == "Apple Inc." - assert result.exchange == "NASDAQ" - assert result.currency == "USD" - assert result.relevance_score > 0 - - def test_create_search_result_from_quote_invalid(self, adapter): - """Test handling of invalid quote data.""" - invalid_quote = {} # Empty quote - - result = adapter._create_search_result_from_quote(invalid_quote, "en-US") - - assert result is None - - def test_calculate_search_relevance(self, adapter): - """Test relevance score calculation.""" - # High relevance quote (exact match, large market cap) - high_relevance_quote = { - "symbol": "AAPL", - "longname": "Apple Inc.", - "currency": "USD", - "exchange": "NMS", - "marketCap": 3000000000000, - } - - score = adapter._calculate_search_relevance( - high_relevance_quote, "AAPL", "Apple Inc." - ) - assert score > 0.8 # Should be high relevance - - # Low relevance quote (no match, small market cap) - low_relevance_quote = { - "symbol": "UNKNOWN", - "marketCap": 1000000, - } - - score = adapter._calculate_search_relevance( - low_relevance_quote, "AAPL", "Apple Inc." - ) - assert score < 0.7 # Should be lower relevance - - def test_fallback_ticker_search(self, adapter): - """Test fallback ticker search functionality.""" - with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: - # Mock ticker info - mock_ticker = Mock() - mock_ticker.info = { - "symbol": "AAPL", - "longName": "Apple Inc.", - "shortName": "Apple Inc.", - "quoteType": "EQUITY", - "exchange": "NASDAQ", - "currency": "USD", - "country": "US", - } - mock_yf.Ticker.return_value = mock_ticker - - query = AssetSearchQuery(query="AAPL", limit=10, language="en-US") - - results = adapter._fallback_ticker_search("AAPL", query) - - assert len(results) > 0 - assert results[0].ticker.endswith("AAPL") - - def test_fallback_ticker_search_with_suffixes(self, adapter): - """Test fallback search with international market suffixes.""" - with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: - # Mock first call to fail, second call with suffix to succeed - def mock_ticker_side_effect(symbol): - mock_ticker = Mock() - if symbol == "0700": - mock_ticker.info = {} # Empty info (failure) - elif symbol == "0700.HK": - mock_ticker.info = { - "symbol": "0700.HK", - "longName": "Tencent Holdings Limited", - "shortName": "Tencent", - "quoteType": "EQUITY", - "exchange": "HKG", - "currency": "HKD", - "country": "HK", - } - else: - mock_ticker.info = {} - return mock_ticker - - mock_yf.Ticker.side_effect = mock_ticker_side_effect - - query = AssetSearchQuery(query="0700", limit=10, language="en-US") - - results = adapter._fallback_ticker_search("0700", query) - - assert len(results) > 0 - # Should find the HK version - found_hk = any("HK" in result.ticker for result in results) - assert found_hk - - def test_search_assets_empty_query(self, adapter): - """Test search with empty query.""" - query = AssetSearchQuery(query="", limit=10, language="en-US") - - with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: - mock_search = Mock() - mock_search.quotes = [] - mock_yf.Search.return_value = mock_search - - results = adapter.search_assets(query) - - # Should return empty results for empty query - assert len(results) == 0 - - def test_search_assets_no_results(self, adapter): - """Test search when no results are found.""" - query = AssetSearchQuery(query="NONEXISTENTSTOCK", limit=10, language="en-US") - - with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: - # Mock Search to return empty results - mock_search = Mock() - mock_search.quotes = [] - mock_yf.Search.return_value = mock_search - - # Mock direct ticker lookup to also fail - mock_ticker = Mock() - mock_ticker.info = {} - mock_yf.Ticker.return_value = mock_ticker - - results = adapter.search_assets(query) - - assert len(results) == 0 - - @pytest.mark.parametrize( - "exchange_code,expected_exchange", - [ - ("NMS", "NASDAQ"), - ("NYQ", "NYSE"), - ("ASE", "AMEX"), - ("HKG", "HKEX"), - ("TYO", "TSE"), - ("LSE", "LSE"), - ], - ) - def test_exchange_mapping(self, adapter, exchange_code, expected_exchange): - """Test exchange code mapping from yfinance to internal format.""" - quote = { - "symbol": "TEST", - "shortname": "Test Company", - "longname": "Test Company Inc.", - "quoteType": "EQUITY", - "exchange": exchange_code, - "currency": "USD", - } - - result = adapter._create_search_result_from_quote(quote, "en-US") - - assert result is not None - assert result.exchange == expected_exchange - - def test_search_with_crypto_assets(self, adapter): - """Test search functionality with cryptocurrency assets.""" - crypto_quotes = [ - { - "symbol": "BTC-USD", - "shortname": "Bitcoin", - "longname": "Bitcoin USD", - "quoteType": "CRYPTOCURRENCY", - "exchange": "CCC", - "currency": "USD", - "marketCap": 800000000000, - } - ] - - with patch("valuecell.adapters.assets.yfinance_adapter.yf") as mock_yf: - mock_search = Mock() - mock_search.quotes = crypto_quotes - mock_yf.Search.return_value = mock_search - - query = AssetSearchQuery( - query="Bitcoin", - asset_types=[AssetType.CRYPTO], - limit=10, - language="en-US", - ) - - results = adapter.search_assets(query) - - assert len(results) > 0 - assert results[0].asset_type == AssetType.CRYPTO - assert "Bitcoin" in results[0].names["en-US"] From 0513fde799dc9ed6511bcc5c2ddc78f74ff0b50b Mon Sep 17 00:00:00 2001 From: hazeone <709547807@qq.com> Date: Wed, 17 Sep 2025 16:50:41 +0800 Subject: [PATCH 7/8] feat: add readme for watch list --- WATCHLIST_API_README.md | 799 ++++++++++++++++++++++++++ python/valuecell/server/db/init_db.py | 9 +- 2 files changed, 804 insertions(+), 4 deletions(-) create mode 100644 WATCHLIST_API_README.md diff --git a/WATCHLIST_API_README.md b/WATCHLIST_API_README.md new file mode 100644 index 000000000..dd1a0906e --- /dev/null +++ b/WATCHLIST_API_README.md @@ -0,0 +1,799 @@ +# ValueCell Watchlist API Documentation + +A comprehensive API for managing financial asset watchlists with multi-market support, real-time price data, and internationalization. + +## Table of Contents + +- [Overview](#overview) +- [Ticker Naming Conventions](#ticker-naming-conventions) +- [API Endpoints](#api-endpoints) +- [Authentication](#authentication) +- [Request/Response Format](#requestresponse-format) +- [Error Handling](#error-handling) +- [Examples](#examples) + +## Overview + +The ValueCell Watchlist API provides a complete solution for managing financial asset watchlists across multiple markets including US stocks, Hong Kong stocks, A-shares (Chinese stocks), and cryptocurrencies. The API supports real-time price data, multi-language localization, and comprehensive asset search capabilities. + +### Key Features + +- ✅ **Multi-Market Support**: US stocks (NASDAQ, NYSE), Hong Kong stocks (HKEX), Chinese A-shares (SSE, SZSE), and cryptocurrencies +- ✅ **Real-time Price Data**: Current prices, market data, and price history +- ✅ **Internationalization**: Multi-language support for asset names and descriptions +- ✅ **User Watchlists**: Create, manage, and organize multiple watchlists per user +- ✅ **Asset Search**: Powerful search with filtering by market, asset type, and country +- ✅ **RESTful API**: Standard HTTP methods with JSON responses + +## Ticker Naming Conventions + +The ValueCell system uses a standardized ticker format: `[EXCHANGE]:[SYMBOL]` + +### US Stocks +``` +NASDAQ:AAPL # Apple Inc. +NYSE:JPM # JPMorgan Chase & Co. +NYSE:JNJ # Johnson & Johnson +NASDAQ:MSFT # Microsoft Corporation +NASDAQ:GOOGL # Alphabet Inc. +``` + +### Hong Kong Stocks (HKEX) +``` +HKEX:00700 # Tencent Holdings Ltd (padded to 4 digits) +HKEX:09988 # Alibaba Group Holding Ltd +HKEX:03690 # Meituan +HKEX:01299 # AIA Group Ltd +HKEX:00005 # HSBC Holdings plc +``` + +### Chinese A-Shares +#### Shanghai Stock Exchange (SSE) +``` +SSE:600519 # Kweichow Moutai Co Ltd +SSE:600036 # China Merchants Bank Co Ltd +SSE:600000 # Pudong Development Bank Co Ltd +SSE:601318 # Ping An Insurance Group Co of China Ltd +``` + +#### Shenzhen Stock Exchange (SZSE) +``` +SZSE:000858 # Wuliangye Yibin Co Ltd +SZSE:000001 # Ping An Bank Co Ltd +SZSE:000002 # China Vanke Co Ltd +SZSE:300059 # East Money Information Co Ltd +``` + +#### Beijing Stock Exchange (BSE) +``` +BSE:430047 # Jinguan Co Ltd +BSE:832885 # Jilin Carbon Co Ltd +``` + +### Cryptocurrencies +``` +CRYPTO:BTC # Bitcoin +CRYPTO:ETH # Ethereum +CRYPTO:USDT # Tether +CRYPTO:BNB # Binance Coin +CRYPTO:ADA # Cardano +CRYPTO:SOL # Solana +``` + +### Exchange Code Reference +| Exchange Code | Full Name | Market | +|---------------|-----------|---------| +| `NASDAQ` | NASDAQ Stock Market | US | +| `NYSE` | New York Stock Exchange | US | +| `HKEX` | Hong Kong Stock Exchange | Hong Kong | +| `SSE` | Shanghai Stock Exchange | China | +| `SZSE` | Shenzhen Stock Exchange | China | +| `BSE` | Beijing Stock Exchange | China | +| `CRYPTO` | Cryptocurrency | Global | + +## API Endpoints + +Base URL: `http://localhost:8000/api/v1/watchlist` + +### 1. Search Assets + +Search for financial assets with filtering options. + +**Endpoint:** `GET /search` + +**Parameters:** +- `q` (required): Search query string +- `asset_types` (optional): Comma-separated asset types +- `exchanges` (optional): Comma-separated exchange codes +- `countries` (optional): Comma-separated country codes +- `limit` (optional): Maximum results (1-200, default: 50) +- `language` (optional): Language code for localized results + +**Example Request:** +```bash +curl -X GET "http://localhost:8000/api/v1/watchlist/search?q=Apple&limit=10&language=en-US" +``` + +**Example Response:** +```json +{ + "code": 0, + "msg": "Asset search completed successfully", + "data": { + "results": [ + { + "ticker": "NASDAQ:AAPL", + "asset_type": "stock", + "asset_type_display": "Stock", + "names": { + "en-US": "Apple Inc.", + "zh-Hans": "苹果公司", + "zh-Hant": "蘋果公司" + }, + "display_name": "Apple Inc.", + "exchange": "NASDAQ", + "country": "US", + "currency": "USD", + "market_status": "open", + "market_status_display": "Market Open", + "relevance_score": 0.95 + } + ], + "count": 1, + "query": "Apple", + "filters": {}, + "language": "en-US" + } +} +``` + +### 2. Get Asset Details + +Get detailed information about a specific asset. + +**Endpoint:** `GET /asset/{ticker}` + +**Parameters:** +- `ticker` (path): Asset ticker in standardized format +- `language` (optional): Language code for localized content + +**Example Request:** +```bash +curl -X GET "http://localhost:8000/api/v1/watchlist/asset/NASDAQ:AAPL?language=en-US" +``` + +**Example Response:** +```json +{ + "code": 0, + "msg": "Asset details retrieved successfully", + "data": { + "ticker": "NASDAQ:AAPL", + "asset_type": "stock", + "asset_type_display": "Stock", + "names": { + "en-US": "Apple Inc.", + "zh-Hans": "苹果公司", + "zh-Hant": "蘋果公司" + }, + "display_name": "Apple Inc.", + "descriptions": { + "en-US": "Apple Inc. designs, manufactures, and markets smartphones, personal computers, tablets, wearables, and accessories worldwide." + }, + "market_info": { + "exchange": "NASDAQ", + "country": "US", + "currency": "USD", + "timezone": "America/New_York", + "market_hours": { + "open": "09:30", + "close": "16:00" + } + }, + "source_mappings": { + "yfinance": "AAPL", + "finnhub": "AAPL" + }, + "properties": { + "sector": "Technology", + "industry": "Consumer Electronics" + }, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-15T12:00:00Z", + "is_active": true + } +} +``` + +### 3. Get Asset Price + +Get current price information for an asset. + +**Endpoint:** `GET /asset/{ticker}/price` + +**Parameters:** +- `ticker` (path): Asset ticker in standardized format +- `language` (optional): Language code for localized formatting + +**Example Request:** +```bash +curl -X GET "http://localhost:8000/api/v1/watchlist/asset/NASDAQ:AAPL/price?language=en-US" +``` + +**Example Response:** +```json +{ + "code": 0, + "msg": "Asset price retrieved successfully", + "data": { + "ticker": "NASDAQ:AAPL", + "price": 185.25, + "price_formatted": "$185.25", + "currency": "USD", + "timestamp": "2024-01-15T21:00:00Z", + "volume": 45678900, + "open_price": 184.50, + "high_price": 186.75, + "low_price": 183.80, + "close_price": 185.25, + "change": 0.75, + "change_percent": 0.41, + "change_percent_formatted": "+0.41%", + "market_cap": 2890000000000, + "market_cap_formatted": "$2.89T", + "source": "yfinance" + } +} +``` + +### 4. Get User Watchlists + +Get all watchlists for a specific user. + +**Endpoint:** `GET /{user_id}` + +**Parameters:** +- `user_id` (path): User identifier + +**Example Request:** +```bash +curl -X GET "http://localhost:8000/api/v1/watchlist/user123" +``` + +**Example Response:** +```json +{ + "code": 0, + "msg": "Retrieved 2 watchlists", + "data": [ + { + "id": 1, + "user_id": "user123", + "name": "My Stocks", + "description": "My favorite tech stocks", + "is_default": true, + "is_public": false, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-15T12:00:00Z", + "items_count": 3, + "items": [ + { + "id": 1, + "ticker": "NASDAQ:AAPL", + "notes": "Strong quarterly results", + "order_index": 1, + "added_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-10T08:00:00Z", + "exchange": "NASDAQ", + "symbol": "AAPL" + }, + { + "id": 2, + "ticker": "HKEX:00700", + "notes": "Gaming revenue growth", + "order_index": 2, + "added_at": "2024-01-02T00:00:00Z", + "updated_at": "2024-01-02T00:00:00Z", + "exchange": "HKEX", + "symbol": "00700" + } + ] + }, + { + "id": 2, + "user_id": "user123", + "name": "Crypto Portfolio", + "description": "Cryptocurrency investments", + "is_default": false, + "is_public": false, + "created_at": "2024-01-05T00:00:00Z", + "updated_at": "2024-01-12T15:00:00Z", + "items_count": 2, + "items": [ + { + "id": 3, + "ticker": "CRYPTO:BTC", + "notes": "Long-term hold", + "order_index": 1, + "added_at": "2024-01-05T00:00:00Z", + "updated_at": "2024-01-05T00:00:00Z", + "exchange": "CRYPTO", + "symbol": "BTC" + } + ] + } + ] +} +``` + +### 5. Get Specific Watchlist + +Get a specific watchlist by name with optional price data. + +**Endpoint:** `GET /{user_id}/{watchlist_name}` + +**Parameters:** +- `user_id` (path): User identifier +- `watchlist_name` (path): Watchlist name +- `include_prices` (optional): Include current prices (default: true) +- `language` (optional): Language code for localized content + +**Example Request:** +```bash +curl -X GET "http://localhost:8000/api/v1/watchlist/user123/My%20Stocks?include_prices=true&language=en-US" +``` + +**Example Response:** +```json +{ + "code": 0, + "msg": "Watchlist retrieved successfully", + "data": { + "id": 1, + "user_id": "user123", + "name": "My Stocks", + "description": "My favorite tech stocks", + "is_default": true, + "is_public": false, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-15T12:00:00Z", + "items_count": 2, + "items": [ + { + "id": 1, + "ticker": "NASDAQ:AAPL", + "notes": "Strong quarterly results", + "order_index": 1, + "added_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-10T08:00:00Z", + "exchange": "NASDAQ", + "symbol": "AAPL" + }, + { + "id": 2, + "ticker": "HKEX:00700", + "notes": "Gaming revenue growth", + "order_index": 2, + "added_at": "2024-01-02T00:00:00Z", + "updated_at": "2024-01-02T00:00:00Z", + "exchange": "HKEX", + "symbol": "00700" + } + ] + } +} +``` + +### 6. Create Watchlist + +Create a new watchlist for a user. + +**Endpoint:** `POST /{user_id}` + +**Parameters:** +- `user_id` (path): User identifier + +**Request Body:** +```json +{ + "name": "Tech Stocks", + "description": "Technology sector investments", + "is_default": false, + "is_public": false +} +``` + +**Example Request:** +```bash +curl -X POST "http://localhost:8000/api/v1/watchlist/user123" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Tech Stocks", + "description": "Technology sector investments", + "is_default": false, + "is_public": false + }' +``` + +**Example Response:** +```json +{ + "code": 0, + "msg": "Watchlist created successfully", + "data": { + "id": 3, + "user_id": "user123", + "name": "Tech Stocks", + "description": "Technology sector investments", + "is_default": false, + "is_public": false, + "created_at": "2024-01-15T12:30:00Z", + "updated_at": "2024-01-15T12:30:00Z", + "items_count": 0, + "items": [] + } +} +``` + +### 7. Add Stock to Watchlist + +Add a stock to a user's watchlist. + +**Endpoint:** `POST /{user_id}/stocks` + +**Parameters:** +- `user_id` (path): User identifier + +**Request Body:** +```json +{ + "ticker": "SSE:600519", + "watchlist_name": "My Stocks", + "notes": "Chinese liquor company with strong brand" +} +``` + +**Example Request:** +```bash +curl -X POST "http://localhost:8000/api/v1/watchlist/user123/stocks" \ + -H "Content-Type: application/json" \ + -d '{ + "ticker": "SSE:600519", + "watchlist_name": "My Stocks", + "notes": "Chinese liquor company with strong brand" + }' +``` + +**Example Response:** +```json +{ + "code": 0, + "msg": "Stock added to watchlist successfully", + "data": { + "ticker": "SSE:600519", + "user_id": "user123", + "watchlist_name": "My Stocks", + "notes": "Chinese liquor company with strong brand" + } +} +``` + +### 8. Remove Stock from Watchlist + +Remove a stock from a user's watchlist. + +**Endpoint:** `DELETE /{user_id}/stocks/{ticker}` + +**Parameters:** +- `user_id` (path): User identifier +- `ticker` (path): Stock ticker to remove +- `watchlist_name` (optional): Watchlist name (uses default if not provided) + +**Example Request:** +```bash +curl -X DELETE "http://localhost:8000/api/v1/watchlist/user123/stocks/SSE:600519?watchlist_name=My%20Stocks" +``` + +**Example Response:** +```json +{ + "code": 0, + "msg": "Stock removed from watchlist successfully", + "data": { + "ticker": "SSE:600519", + "user_id": "user123", + "watchlist_name": "My Stocks" + } +} +``` + +### 9. Update Stock Notes + +Update notes for a stock in a watchlist. + +**Endpoint:** `PUT /{user_id}/stocks/{ticker}/notes` + +**Parameters:** +- `user_id` (path): User identifier +- `ticker` (path): Stock ticker +- `watchlist_name` (optional): Watchlist name (uses default if not provided) + +**Request Body:** +```json +{ + "notes": "Updated analysis: Strong growth potential in AI sector" +} +``` + +**Example Request:** +```bash +curl -X PUT "http://localhost:8000/api/v1/watchlist/user123/stocks/NASDAQ:AAPL/notes?watchlist_name=My%20Stocks" \ + -H "Content-Type: application/json" \ + -d '{ + "notes": "Updated analysis: Strong growth potential in AI sector" + }' +``` + +**Example Response:** +```json +{ + "code": 0, + "msg": "Stock notes updated successfully", + "data": { + "ticker": "NASDAQ:AAPL", + "user_id": "user123", + "notes": "Updated analysis: Strong growth potential in AI sector", + "watchlist_name": "My Stocks" + } +} +``` + +### 10. Delete Watchlist + +Delete a user's watchlist. + +**Endpoint:** `DELETE /{user_id}/{watchlist_name}` + +**Parameters:** +- `user_id` (path): User identifier +- `watchlist_name` (path): Watchlist name to delete + +**Example Request:** +```bash +curl -X DELETE "http://localhost:8000/api/v1/watchlist/user123/Tech%20Stocks" +``` + +**Example Response:** +```json +{ + "code": 0, + "msg": "Watchlist deleted successfully", + "data": { + "user_id": "user123", + "watchlist_name": "Tech Stocks" + } +} +``` + +## Authentication + +Currently, the API does not require authentication. User identification is handled through the `user_id` parameter in the URL path. In production environments, you should implement proper authentication and authorization mechanisms. + +## Request/Response Format + +### Standard Response Format + +All API responses follow a consistent format: + +```json +{ + "code": 0, // Status code (0 = success, others = error) + "msg": "success", // Response message + "data": {...} // Response data (varies by endpoint) +} +``` + +### Status Codes + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `400` | Bad Request | +| `401` | Unauthorized | +| `403` | Forbidden | +| `404` | Not Found | +| `500` | Internal Server Error | + +### Content Type + +All requests and responses use `application/json` content type. + +## Error Handling + +### Error Response Format + +```json +{ + "code": 404, + "msg": "Asset 'INVALID:TICKER' not found", + "data": null +} +``` + +### Common Error Scenarios + +1. **Invalid Ticker Format** + ```json + { + "code": 400, + "msg": "Invalid ticker format. Expected 'EXCHANGE:SYMBOL'", + "data": null + } + ``` + +2. **Asset Not Found** + ```json + { + "code": 404, + "msg": "Asset 'NASDAQ:INVALID' not found", + "data": null + } + ``` + +3. **Watchlist Not Found** + ```json + { + "code": 404, + "msg": "Watchlist 'NonExistent' not found for user 'user123'", + "data": null + } + ``` + +4. **Validation Error** + ```json + { + "code": 400, + "msg": "Validation error: name field is required", + "data": null + } + ``` + +## Examples + +### Complete Workflow Example + +Here's a complete example showing how to create a watchlist and manage assets: + +```bash +# 1. Search for assets +curl -X GET "http://localhost:8000/api/v1/watchlist/search?q=Apple&limit=5" + +# 2. Create a new watchlist +curl -X POST "http://localhost:8000/api/v1/watchlist/user123" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Tech Portfolio", + "description": "My technology stock investments", + "is_default": false, + "is_public": false + }' + +# 3. Add stocks to the watchlist +curl -X POST "http://localhost:8000/api/v1/watchlist/user123/stocks" \ + -H "Content-Type: application/json" \ + -d '{ + "ticker": "NASDAQ:AAPL", + "watchlist_name": "Tech Portfolio", + "notes": "Strong iPhone sales" + }' + +curl -X POST "http://localhost:8000/api/v1/watchlist/user123/stocks" \ + -H "Content-Type: application/json" \ + -d '{ + "ticker": "HKEX:00700", + "watchlist_name": "Tech Portfolio", + "notes": "Leading Chinese tech company" + }' + +# 4. Get watchlist with current prices +curl -X GET "http://localhost:8000/api/v1/watchlist/user123/Tech%20Portfolio?include_prices=true" + +# 5. Update stock notes +curl -X PUT "http://localhost:8000/api/v1/watchlist/user123/stocks/NASDAQ:AAPL/notes" \ + -H "Content-Type: application/json" \ + -d '{ + "notes": "Strong iPhone sales + AI integration potential" + }' + +# 6. Get asset price details +curl -X GET "http://localhost:8000/api/v1/watchlist/asset/NASDAQ:AAPL/price" + +# 7. Remove a stock from watchlist +curl -X DELETE "http://localhost:8000/api/v1/watchlist/user123/stocks/HKEX:00700?watchlist_name=Tech%20Portfolio" +``` + +### Multi-Language Example + +```bash +# Search with Chinese localization +curl -X GET "http://localhost:8000/api/v1/watchlist/search?q=苹果&language=zh-Hans" + +# Get asset details in Traditional Chinese +curl -X GET "http://localhost:8000/api/v1/watchlist/asset/NASDAQ:AAPL?language=zh-Hant" + +# Get price data with Chinese formatting +curl -X GET "http://localhost:8000/api/v1/watchlist/asset/SSE:600519/price?language=zh-Hans" +``` + +### Cryptocurrency Example + +```bash +# Search for cryptocurrencies +curl -X GET "http://localhost:8000/api/v1/watchlist/search?q=Bitcoin&asset_types=crypto" + +# Add Bitcoin to watchlist +curl -X POST "http://localhost:8000/api/v1/watchlist/user123/stocks" \ + -H "Content-Type: application/json" \ + -d '{ + "ticker": "CRYPTO:BTC", + "watchlist_name": "Crypto Portfolio", + "notes": "Digital gold hedge against inflation" + }' + +# Get Bitcoin price +curl -X GET "http://localhost:8000/api/v1/watchlist/asset/CRYPTO:BTC/price" +``` + +## Development and Testing + +### Running the API Server + +```bash +# Navigate to the project directory +cd /Users/guoyuliang/Project/valuecell + +# Activate virtual environment +source python/.venv/bin/activate + +# Install dependencies +uv sync + +# Start the API server +uvicorn python.valuecell.server.main:app --host 0.0.0.0 --port 8000 --reload +``` + +### API Documentation + +Once the server is running, you can access: +- **Swagger UI**: http://localhost:8000/docs +- **ReDoc**: http://localhost:8000/redoc + +### Testing with Different Markets + +```bash +# US Stock +curl -X GET "http://localhost:8000/api/v1/watchlist/asset/NASDAQ:MSFT/price" + +# Hong Kong Stock +curl -X GET "http://localhost:8000/api/v1/watchlist/asset/HKEX:00700/price" + +# Chinese A-Share +curl -X GET "http://localhost:8000/api/v1/watchlist/asset/SSE:600519/price" + +# Cryptocurrency +curl -X GET "http://localhost:8000/api/v1/watchlist/asset/CRYPTO:ETH/price" +``` + +## Data Sources + +The API integrates with multiple data sources: + +- **Yahoo Finance**: Free global stock data +- **AKShare**: Free Chinese financial data +- **TuShare**: Professional Chinese stock data (API key required) +- **Finnhub**: Professional global stock data (API key required) +- **CoinMarketCap**: Cryptocurrency data (API key required) + +## Support + +For questions, issues, or feature requests, please refer to the ValueCell project documentation or contact the development team. diff --git a/python/valuecell/server/db/init_db.py b/python/valuecell/server/db/init_db.py index da8b6050c..590760f6c 100644 --- a/python/valuecell/server/db/init_db.py +++ b/python/valuecell/server/db/init_db.py @@ -137,12 +137,12 @@ def initialize_assets_with_service(self) -> bool: "NASDAQ:GOOGL", # Alphabet Inc. "NASDAQ:MSFT", # Microsoft Corporation "NYSE:SPY", # SPDR S&P 500 ETF - "CRYPTO:BTC-USD", # Bitcoin + "CRYPTO:BTC", # Bitcoin # Additional diverse assets "NYSE:TSLA", # Tesla Inc. "NASDAQ:NVDA", # NVIDIA Corporation "NYSE:JPM", # JPMorgan Chase & Co. - "CRYPTO:ETH-USD", # Ethereum + "CRYPTO:ETH", # Ethereum "NASDAQ:QQQ", # Invesco QQQ Trust ETF ] @@ -336,7 +336,7 @@ def _create_fallback_asset(self, ticker: str) -> Optional["Asset"]: "exchange": "NYSE", "metadata": {"tags": ["index", "diversified", "low-cost"]}, }, - "CRYPTO:BTC-USD": { + "CRYPTO:BTC": { "name": "Bitcoin", "asset_type": "crypto", "sector": "Cryptocurrency", @@ -373,7 +373,7 @@ def _create_fallback_asset(self, ticker: str) -> Optional["Asset"]: "tags": ["banking", "blue-chip", "finance"], }, }, - "CRYPTO:ETH-USD": { + "CRYPTO:ETH": { "name": "Ethereum", "asset_type": "crypto", "sector": "Cryptocurrency", @@ -664,3 +664,4 @@ def main(): if __name__ == "__main__": main() +ƒƒ \ No newline at end of file From 38882735699088c5085049a62dc8f9e7d73b4ddb Mon Sep 17 00:00:00 2001 From: hazeone <709547807@qq.com> Date: Wed, 17 Sep 2025 16:52:20 +0800 Subject: [PATCH 8/8] lint --- python/valuecell/server/db/init_db.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/valuecell/server/db/init_db.py b/python/valuecell/server/db/init_db.py index 590760f6c..351b3bcf0 100644 --- a/python/valuecell/server/db/init_db.py +++ b/python/valuecell/server/db/init_db.py @@ -664,4 +664,3 @@ def main(): if __name__ == "__main__": main() -ƒƒ \ No newline at end of file