From e5196d4643660ffd3bc420ff1910cea7e5c599ee Mon Sep 17 00:00:00 2001 From: Mayue Date: Sun, 4 Jan 2026 16:25:43 +0800 Subject: [PATCH 01/44] Enhance asset management and UI components - Updated .gitignore to include .idea directory for IDE configurations. - Added a new refactoring progress report for multi-market support. - Integrated SkeletonStyles into App.axaml for improved UI consistency. - Updated project dependencies, including WebViewControl-Avalonia to version 3.120.11. - Refactored various analyst agents to use new data tools interfaces, enhancing modularity. - Introduced new investment selection executors and models for crypto and stock analysis. - Removed obsolete stock selection components to streamline the codebase. - Enhanced navigation and view models to support asset-focused features. - Updated UI elements across multiple views to reflect asset terminology and improve user experience. --- .gitignore | 1 + REFACTORING_PROGRESS.md | 189 +++++++ src/Agents/Analysts/FinancialAnalystAgent.cs | 4 +- .../Analysts/FundamentalAnalystAgent.cs | 4 +- .../Analysts/MarketSentimentAnalystAgent.cs | 6 +- src/Agents/Analysts/NewsEventAnalystAgent.cs | 4 +- src/Agents/Analysts/TechnicalAnalystAgent.cs | 4 +- .../Executors/AnalyzeCryptoExecutor.cs | 286 ++++++++++ .../Executors/AnalyzeStocksExecutor.cs | 139 ++--- .../GenerateCryptoCriteriaExecutor.cs | 256 +++++++++ .../GenerateStockCriteriaExecutor.cs} | 72 +-- .../ScreenInvestmentTargetsExecutor.cs | 74 +++ .../InvestmentSelectionWorkflow.cs | 174 ++++++ .../Models/WorkflowModels.cs | 30 +- .../Executors/ScreenStocksExecutor.cs | 63 --- .../StockSelection/StockSelectionWorkflow.cs | 150 ------ .../AShareBasicTools.cs} | 46 +- .../AShareFinancialTools.cs} | 31 +- .../AShareNewsTools.cs} | 67 +-- .../Tools/AShare/AShareSentimentTools.cs | 49 ++ .../Tools/AShare/AShareTechnicalTools.cs | 61 +++ .../Tools/Abstractions/IBasicDataTools.cs | 30 ++ .../Tools/Abstractions/IFinancialDataTools.cs | 45 ++ .../Tools/Abstractions/INewsDataTools.cs | 26 + .../Tools/Abstractions/ISentimentDataTools.cs | 26 + .../Tools/Abstractions/ITechnicalDataTools.cs | 40 ++ src/Agents/Tools/Crypto/CryptoBasicTools.cs | 404 ++++++++++++++ .../Tools/Crypto/CryptoFinancialTools.cs | 160 ++++++ src/Agents/Tools/Crypto/CryptoNewsTools.cs | 165 ++++++ .../Tools/Crypto/CryptoSentimentTools.cs | 120 +++++ .../Tools/Crypto/CryptoTechnicalTools.cs | 321 +++++++++++ .../Tools/Crypto/README_CRYPTO_TOOLS.md | 417 +++++++++++++++ src/Agents/Tools/MarketSentimentTools.cs | 141 ----- .../{StockQuoteInfo.cs => AssetQuoteInfo.cs} | 6 +- .../{StockCompanyInfo.cs => CompanyInfo.cs} | 6 +- ...StockFinancialData.cs => FinancialData.cs} | 8 +- .../Models/{StockBoll.cs => TechnicalBoll.cs} | 4 +- .../Models/{StockKDJ.cs => TechnicalKDJ.cs} | 4 +- .../Models/{StockMA.cs => TechnicalMA.cs} | 4 +- .../Models/{StockMACD.cs => TechnicalMACD.cs} | 4 +- src/Agents/Tools/StockTechnicalTools.cs | 56 -- src/App.axaml | 1 + .../AssetScreener/CryptoScreenerService.cs | 45 ++ .../AssetScreener/IAssetScreenerService.cs | 17 + .../AssetScreener/Models/AShareType.cs} | 9 +- .../AssetScreener/Models/CryptoCriteria.cs | 49 ++ .../AssetScreener}/Models/IndustryType.cs | 3 +- .../Models/ScreenerStockInfo.cs | 2 +- .../AssetScreener}/Models/StockCriteria.cs | 8 +- .../Models/StockScreeningCriteria.cs | 2 +- .../AssetScreener}/StockScreenerService.cs | 23 +- .../AShareAssetInfoService.cs} | 127 ++--- .../Assets/CryptoAssetInfoService.cs | 324 ++++++++++++ src/Applications/Assets/IAssetInfoService.cs | 30 ++ src/Applications/Assets/Models/AssetInfo.cs | 67 +++ src/Applications/Assets/Models/AssetItem.cs | 23 + .../Assets/Models/FavoriteAsset.cs | 23 + .../HotStock.cs => Assets/Models/HotAsset.cs} | 55 +- .../Cache/AShareAssetCacheService.cs | 63 +++ .../Cache/CryptoAssetCacheService.cs | 61 +++ src/Applications/Cache/IAssetCacheService.cs | 30 ++ src/Applications/Charts/AShareKLineService.cs | 255 +++++++++ src/Applications/Charts/CryptoKLineService.cs | 216 ++++++++ src/Applications/Charts/IKLineService.cs | 24 + .../Models/KLineData.cs} | 20 +- .../{Stocks => Charts}/Models/KLineType.cs | 8 +- src/Applications/Charts/README_CRYPTO.md | 200 +++++++ .../Favorites/AShareFavoriteService.cs | 156 ++++++ .../Favorites/CryptoFavoriteService.cs | 190 +++++++ .../Favorites/IFavoriteService.cs | 45 ++ .../History/AShareHistoryService.cs | 86 +++ .../History/CryptoHistoryService.cs | 113 ++++ .../History/IAssetHistoryService.cs | 30 ++ src/Applications/Home/AShareHomeService.cs | 168 ++++++ src/Applications/Home/CryptoHomeService.cs | 164 ++++++ src/Applications/Home/IHomeAssetService.cs | 40 ++ .../InvestmentSelectionService.cs} | 180 +++---- .../Models/InvestmentRecommendation.cs} | 25 +- .../InvestmentRecommendationRequest.cs} | 15 +- .../Models/InvestmentSelectionEnums.cs} | 5 +- .../Models/InvestmentSelectionResult.cs} | 18 +- .../Models/NewsBasedInvestmentRequest.cs | 25 + .../Models/QuickSelectionStrategy.cs | 26 +- src/Applications/News/NewsUpdateService.cs | 4 +- src/Applications/README_CRYPTO_SERVICES.md | 269 ++++++++++ src/Applications/Settings/UserSetting.cs | 16 + .../Models/NewsBasedSelectionRequest.cs | 18 - src/Applications/Stocks/HomeStockService.cs | 157 ------ src/Applications/Stocks/IHomeStockService.cs | 35 -- .../Stocks/Models/FavoriteStock.cs | 19 - src/Applications/Stocks/Models/StockInfo.cs | 42 -- src/Applications/Stocks/Models/StockItem.cs | 12 - .../Stocks/StockFavoriteService.cs | 185 ------- src/Applications/Stocks/StockInfoCache.cs | 96 ---- src/Applications/Stocks/StockKLineService.cs | 497 ------------------ src/Applications/Stocks/StockSearchHistory.cs | 103 ---- ...ramService.cs => AShareTelegramService.cs} | 12 +- .../Telegrams/CryptoTelegramService.cs | 271 ++++++++++ .../Telegrams/ITelegramService.cs | 13 + .../Core/AssetFavoritesChanged.cs | 14 + src/Infrastructure/Core/MarketType.cs | 23 + src/Infrastructure/Core/ViewLocator.cs | 4 +- .../Factories/AnalystAgentFactory.cs | 60 ++- src/MarketAssistant.csproj | 10 +- src/Resources/Styles/SkeletonStyles.axaml | 44 ++ src/Services/Market/MarketContext.cs | 54 ++ src/Services/Navigation/NavigationService.cs | 2 +- src/Services/ServiceCollectionExtensions.cs | 112 +++- src/ViewModels/AssetNavigationParameter.cs | 34 ++ ...PageViewModel.cs => AssetPageViewModel.cs} | 128 ++--- ...odel.cs => AssetSelectionPageViewModel.cs} | 70 ++- src/ViewModels/FavoritesPageViewModel.cs | 118 +++-- src/ViewModels/Home/HomeSearchViewModel.cs | 54 +- src/ViewModels/Home/HotAssetsViewModel.cs | 110 ++++ src/ViewModels/Home/HotStocksViewModel.cs | 94 ---- src/ViewModels/Home/RecentAssetsViewModel.cs | 126 +++++ src/ViewModels/Home/RecentStocksViewModel.cs | 106 ---- src/ViewModels/Home/TelegraphNewsViewModel.cs | 75 ++- src/ViewModels/HomePageViewModel.cs | 93 ++-- src/ViewModels/MainWindowViewModel.cs | 55 +- src/ViewModels/SettingsPageViewModel.cs | 52 +- ...StockWebChartView.cs => KLineChartView.cs} | 27 +- ...tockPageView.axaml => AssetPageView.axaml} | 68 ++- ...geView.axaml.cs => AssetPageView.axaml.cs} | 9 +- ...iew.axaml => AssetSelectionPageView.axaml} | 10 +- ...aml.cs => AssetSelectionPageView.axaml.cs} | 11 +- src/Views/Pages/FavoritesPageView.axaml | 16 +- src/Views/Pages/FavoritesPageView.axaml.cs | 8 +- src/Views/Pages/HomePageView.axaml | 49 +- src/Views/Pages/HomePageView.axaml.cs | 18 +- src/Views/Pages/SettingsPageView.axaml | 85 ++- src/Views/Windows/MainWindow.axaml | 37 +- tests/BaseAgentTest.cs | 2 +- tests/GenerateCriteriaExecutorTest.cs | 2 +- tests/StockKLineServiceTest.cs | 37 +- tests/StockScreenerServiceTest.cs | 10 +- tests/StockServiceTest.cs | 12 +- 137 files changed, 7577 insertions(+), 2679 deletions(-) create mode 100644 REFACTORING_PROGRESS.md create mode 100644 src/Agents/InvestmentSelection/Executors/AnalyzeCryptoExecutor.cs rename src/Agents/{StockSelection => InvestmentSelection}/Executors/AnalyzeStocksExecutor.cs (69%) create mode 100644 src/Agents/InvestmentSelection/Executors/GenerateCryptoCriteriaExecutor.cs rename src/Agents/{StockSelection/Executors/GenerateCriteriaExecutor.cs => InvestmentSelection/Executors/GenerateStockCriteriaExecutor.cs} (82%) create mode 100644 src/Agents/InvestmentSelection/Executors/ScreenInvestmentTargetsExecutor.cs create mode 100644 src/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs rename src/Agents/{StockSelection => InvestmentSelection}/Models/WorkflowModels.cs (56%) delete mode 100644 src/Agents/StockSelection/Executors/ScreenStocksExecutor.cs delete mode 100644 src/Agents/StockSelection/StockSelectionWorkflow.cs rename src/Agents/Tools/{StockBasicTools.cs => AShare/AShareBasicTools.cs} (78%) rename src/Agents/Tools/{StockFinancialTools.cs => AShare/AShareFinancialTools.cs} (85%) rename src/Agents/Tools/{StockNewsTools.cs => AShare/AShareNewsTools.cs} (73%) create mode 100644 src/Agents/Tools/AShare/AShareSentimentTools.cs create mode 100644 src/Agents/Tools/AShare/AShareTechnicalTools.cs create mode 100644 src/Agents/Tools/Abstractions/IBasicDataTools.cs create mode 100644 src/Agents/Tools/Abstractions/IFinancialDataTools.cs create mode 100644 src/Agents/Tools/Abstractions/INewsDataTools.cs create mode 100644 src/Agents/Tools/Abstractions/ISentimentDataTools.cs create mode 100644 src/Agents/Tools/Abstractions/ITechnicalDataTools.cs create mode 100644 src/Agents/Tools/Crypto/CryptoBasicTools.cs create mode 100644 src/Agents/Tools/Crypto/CryptoFinancialTools.cs create mode 100644 src/Agents/Tools/Crypto/CryptoNewsTools.cs create mode 100644 src/Agents/Tools/Crypto/CryptoSentimentTools.cs create mode 100644 src/Agents/Tools/Crypto/CryptoTechnicalTools.cs create mode 100644 src/Agents/Tools/Crypto/README_CRYPTO_TOOLS.md delete mode 100644 src/Agents/Tools/MarketSentimentTools.cs rename src/Agents/Tools/Models/{StockQuoteInfo.cs => AssetQuoteInfo.cs} (98%) rename src/Agents/Tools/Models/{StockCompanyInfo.cs => CompanyInfo.cs} (99%) rename src/Agents/Tools/Models/{StockFinancialData.cs => FinancialData.cs} (99%) rename src/Agents/Tools/Models/{StockBoll.cs => TechnicalBoll.cs} (96%) rename src/Agents/Tools/Models/{StockKDJ.cs => TechnicalKDJ.cs} (95%) rename src/Agents/Tools/Models/{StockMA.cs => TechnicalMA.cs} (98%) rename src/Agents/Tools/Models/{StockMACD.cs => TechnicalMACD.cs} (96%) delete mode 100644 src/Agents/Tools/StockTechnicalTools.cs create mode 100644 src/Applications/AssetScreener/CryptoScreenerService.cs create mode 100644 src/Applications/AssetScreener/IAssetScreenerService.cs rename src/{Services/StockScreener/Models/MarketType.cs => Applications/AssetScreener/Models/AShareType.cs} (76%) create mode 100644 src/Applications/AssetScreener/Models/CryptoCriteria.cs rename src/{Services/StockScreener => Applications/AssetScreener}/Models/IndustryType.cs (98%) rename src/{Services/StockScreener => Applications/AssetScreener}/Models/ScreenerStockInfo.cs (98%) rename src/{Services/StockScreener => Applications/AssetScreener}/Models/StockCriteria.cs (79%) rename src/{Services/StockScreener => Applications/AssetScreener}/Models/StockScreeningCriteria.cs (94%) rename src/{Services/StockScreener => Applications/AssetScreener}/StockScreenerService.cs (97%) rename src/Applications/{Stocks/StockService.cs => Assets/AShareAssetInfoService.cs} (65%) create mode 100644 src/Applications/Assets/CryptoAssetInfoService.cs create mode 100644 src/Applications/Assets/IAssetInfoService.cs create mode 100644 src/Applications/Assets/Models/AssetInfo.cs create mode 100644 src/Applications/Assets/Models/AssetItem.cs create mode 100644 src/Applications/Assets/Models/FavoriteAsset.cs rename src/Applications/{Stocks/Models/HotStock.cs => Assets/Models/HotAsset.cs} (54%) create mode 100644 src/Applications/Cache/AShareAssetCacheService.cs create mode 100644 src/Applications/Cache/CryptoAssetCacheService.cs create mode 100644 src/Applications/Cache/IAssetCacheService.cs create mode 100644 src/Applications/Charts/AShareKLineService.cs create mode 100644 src/Applications/Charts/CryptoKLineService.cs create mode 100644 src/Applications/Charts/IKLineService.cs rename src/Applications/{Stocks/Models/StockKLineData.cs => Charts/Models/KLineData.cs} (87%) rename src/Applications/{Stocks => Charts}/Models/KLineType.cs (87%) create mode 100644 src/Applications/Charts/README_CRYPTO.md create mode 100644 src/Applications/Favorites/AShareFavoriteService.cs create mode 100644 src/Applications/Favorites/CryptoFavoriteService.cs create mode 100644 src/Applications/Favorites/IFavoriteService.cs create mode 100644 src/Applications/History/AShareHistoryService.cs create mode 100644 src/Applications/History/CryptoHistoryService.cs create mode 100644 src/Applications/History/IAssetHistoryService.cs create mode 100644 src/Applications/Home/AShareHomeService.cs create mode 100644 src/Applications/Home/CryptoHomeService.cs create mode 100644 src/Applications/Home/IHomeAssetService.cs rename src/Applications/{StockSelection/StockSelectionService.cs => InvestmentSelection/InvestmentSelectionService.cs} (53%) rename src/Applications/{StockSelection/Models/StockRecommendation.cs => InvestmentSelection/Models/InvestmentRecommendation.cs} (76%) rename src/Applications/{StockSelection/Models/StockRecommendationRequest.cs => InvestmentSelection/Models/InvestmentRecommendationRequest.cs} (73%) rename src/Applications/{StockSelection/Models/StockSelectionEnums.cs => InvestmentSelection/Models/InvestmentSelectionEnums.cs} (91%) rename src/Applications/{StockSelection/Models/StockSelectionResult.cs => InvestmentSelection/Models/InvestmentSelectionResult.cs} (74%) create mode 100644 src/Applications/InvestmentSelection/Models/NewsBasedInvestmentRequest.cs rename src/Applications/{StockSelection => InvestmentSelection}/Models/QuickSelectionStrategy.cs (76%) create mode 100644 src/Applications/README_CRYPTO_SERVICES.md delete mode 100644 src/Applications/StockSelection/Models/NewsBasedSelectionRequest.cs delete mode 100644 src/Applications/Stocks/HomeStockService.cs delete mode 100644 src/Applications/Stocks/IHomeStockService.cs delete mode 100644 src/Applications/Stocks/Models/FavoriteStock.cs delete mode 100644 src/Applications/Stocks/Models/StockInfo.cs delete mode 100644 src/Applications/Stocks/Models/StockItem.cs delete mode 100644 src/Applications/Stocks/StockFavoriteService.cs delete mode 100644 src/Applications/Stocks/StockInfoCache.cs delete mode 100644 src/Applications/Stocks/StockKLineService.cs delete mode 100644 src/Applications/Stocks/StockSearchHistory.cs rename src/Applications/Telegrams/{TelegramService.cs => AShareTelegramService.cs} (94%) create mode 100644 src/Applications/Telegrams/CryptoTelegramService.cs create mode 100644 src/Applications/Telegrams/ITelegramService.cs create mode 100644 src/Infrastructure/Core/AssetFavoritesChanged.cs create mode 100644 src/Infrastructure/Core/MarketType.cs create mode 100644 src/Resources/Styles/SkeletonStyles.axaml create mode 100644 src/Services/Market/MarketContext.cs create mode 100644 src/ViewModels/AssetNavigationParameter.cs rename src/ViewModels/{StockPageViewModel.cs => AssetPageViewModel.cs} (57%) rename src/ViewModels/{StockSelectionPageViewModel.cs => AssetSelectionPageViewModel.cs} (82%) create mode 100644 src/ViewModels/Home/HotAssetsViewModel.cs delete mode 100644 src/ViewModels/Home/HotStocksViewModel.cs create mode 100644 src/ViewModels/Home/RecentAssetsViewModel.cs delete mode 100644 src/ViewModels/Home/RecentStocksViewModel.cs rename src/Views/Components/{StockWebChartView.cs => KLineChartView.cs} (95%) rename src/Views/Pages/{StockPageView.axaml => AssetPageView.axaml} (80%) rename src/Views/Pages/{StockPageView.axaml.cs => AssetPageView.axaml.cs} (69%) rename src/Views/Pages/{StockSelectionPageView.axaml => AssetSelectionPageView.axaml} (99%) rename src/Views/Pages/{StockSelectionPageView.axaml.cs => AssetSelectionPageView.axaml.cs} (82%) diff --git a/.gitignore b/.gitignore index a4fe18b..a22086c 100644 --- a/.gitignore +++ b/.gitignore @@ -398,3 +398,4 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml +.idea/ diff --git a/REFACTORING_PROGRESS.md b/REFACTORING_PROGRESS.md new file mode 100644 index 0000000..bd1108c --- /dev/null +++ b/REFACTORING_PROGRESS.md @@ -0,0 +1,189 @@ +# 多市场支持重构进度报告 + +## ✅ 已完成的阶段 + +### 第一阶段:核心架构重构 ✅ +- ✅ 创建了新的顶层 `MarketType` 枚举(AShare / Crypto) +- ✅ 将原有的 `MarketType` 重命名为 `AShareType`(AllAShares / ShanghaiAShares / ShenzhenAShares) +- ✅ 创建了 `MarketContext` 服务用于管理当前市场状态 +- ✅ 更新了所有引用旧 `MarketType` 的代码 + +### 第二阶段:设置页面增强 ✅ +- ✅ 扩展了 `UserSetting` 模型,添加: + - `CurrentMarketType`(当前市场类型) + - `BinanceApiKey`(币安API密钥) + - `BinanceSecretKey`(币安Secret密钥) +- ✅ 更新了设置页面UI,添加市场类型选择器(RadioButton) +- ✅ 根据市场类型动态显示/隐藏对应的API配置区域 +- ✅ 更新了 `SettingsPageViewModel`,支持市场切换逻辑 +- ✅ 注册了 `MarketContext` 到依赖注入容器 + +### 第三阶段:服务层抽象化 ✅ +创建了以下接口和实现: + +**接口:** +- ✅ `IAssetInfoService` - 资产信息服务 +- ✅ `IHomeAssetService` - 首页资产服务 +- ✅ `IFavoriteService` - 收藏服务 +- ✅ `IAssetHistoryService` - 历史记录服务 +- ✅ `IChartDataService` - 图表数据服务 + +**A股实现(完整迁移现有逻辑):** +- ✅ `AShareAssetInfoService` - 从 `StockService` 迁移 +- ✅ `AShareHomeService` - 从 `HomeStockService` 迁移 +- ✅ `AShareFavoriteService` - 从 `StockFavoriteService` 迁移,使用独立存储key `"FavoriteAssets_AShare"` +- ✅ `AShareHistoryService` - 从 `StockSearchHistory` 迁移,使用独立存储key `"RecentAssets_AShare"` +- ✅ `AShareChartService` - 封装 `StockKLineService` + +**虚拟币实现(NotImplementedException + 详细注释):** +- ✅ `CryptoAssetInfoService` - 标注需要调用币安/CoinMarketCap API +- ✅ `CryptoHomeService` - 所有方法抛出NotImplementedException +- ✅ `CryptoFavoriteService` - 使用存储key `"FavoriteAssets_Crypto"` +- ✅ `CryptoHistoryService` - 使用存储key `"RecentAssets_Crypto"` +- ✅ `CryptoChartService` - 标注需要调用币安K线API + +### 第七阶段:通用资产数据模型 ✅ +- ✅ `AssetItem` - 通用资产条目 +- ✅ `AssetInfo` - 通用资产详情(包含A股和虚拟币特有字段) +- ✅ `HotAsset` - 热门资产 +- ✅ `FavoriteAsset` - 收藏资产 + +--- + +## 🚧 剩余工作(需要继续完成) + +### 第四阶段:Agent Tools 抽象化 ⏳ +需要创建以下接口和实现: + +**接口:** +- `IBasicDataTools` - 基础数据工具 +- `IFinancialDataTools` - 财务数据工具 +- `ITechnicalDataTools` - 技术分析工具 +- `INewsDataTools` - 新闻数据工具 +- `ISentimentDataTools` - 市场情绪工具 + +**A股实现:** +- `AShareBasicTools` - 迁移自 `StockBasicTools` +- `AShareFinancialTools` - 迁移自 `StockFinancialTools` +- `AShareTechnicalTools` - 迁移自 `StockTechnicalTools` +- `AShareNewsTools` - 迁移自 `StockNewsTools` +- `AShareSentimentTools` - 迁移自 `MarketSentimentTools` + +**虚拟币实现:** +- `CryptoBasicTools` - NotImplementedException +- `CryptoFinancialTools` - NotImplementedException +- `CryptoTechnicalTools` - NotImplementedException +- `CryptoNewsTools` - NotImplementedException(标注从Twitter抓取) +- `CryptoSentimentTools` - NotImplementedException + +### 第五阶段:UI层适配 ⏳ + +**主窗口Logo切换:** +- 在 `MainWindow.axaml` 的Logo区域添加点击事件 +- 添加快捷键 `Ctrl+M` 绑定到市场切换命令 +- 在 `MainWindowViewModel` 中实现切换逻辑 + +**首页ViewModel重构:** +- 修改 `HomePageViewModel` 及其子ViewModels +- 依赖注入 `IServiceProvider` 和 `MarketContext` +- 根据 `MarketContext.CurrentMarket` 获取对应的 keyed service +- 监听 `MarketContext.PropertyChanged`,市场切换时刷新数据 + +**子ViewModels适配:** +- `HomeSearchViewModel` - 使用 `IHomeAssetService` +- `HotStocksViewModel` → `HotAssetsViewModel` - 适配通用模型 +- `RecentStocksViewModel` → `RecentAssetsViewModel` - 适配通用模型 + +**收藏页适配:** +- 修改 `FavoritesPageViewModel` 使用 keyed `IFavoriteService` +- 市场切换时自动刷新收藏列表 + +**股票详情页适配:** +- 将 `StockPageViewModel` 重命名为 `AssetPageViewModel` +- 使用 keyed 服务获取资产详情 + +### 第六阶段:依赖注入配置 ⏳ + +在 `ServiceCollectionExtensions.cs` 中注册所有 keyed services: + +```csharp +// 注册服务抽象 - A股实现 +services.AddKeyedSingleton(MarketType.AShare); +services.AddKeyedSingleton(MarketType.AShare); +services.AddKeyedSingleton(MarketType.AShare); +services.AddKeyedSingleton(MarketType.AShare); +services.AddKeyedSingleton(MarketType.AShare); + +// 注册服务抽象 - 虚拟币实现 +services.AddKeyedSingleton(MarketType.Crypto); +services.AddKeyedSingleton(MarketType.Crypto); +services.AddKeyedSingleton(MarketType.Crypto); +services.AddKeyedSingleton(MarketType.Crypto); +services.AddKeyedSingleton(MarketType.Crypto); + +// 注册 Agent Tools - A股实现 +services.AddKeyedSingleton(MarketType.AShare); +services.AddKeyedSingleton(MarketType.AShare); +services.AddKeyedSingleton(MarketType.AShare); +services.AddKeyedSingleton(MarketType.AShare); +services.AddKeyedSingleton(MarketType.AShare); + +// 注册 Agent Tools - 虚拟币实现 +services.AddKeyedSingleton(MarketType.Crypto); +services.AddKeyedSingleton(MarketType.Crypto); +services.AddKeyedSingleton(MarketType.Crypto); +services.AddKeyedSingleton(MarketType.Crypto); +services.AddKeyedSingleton(MarketType.Crypto); +``` + +更新 `AnalystAgentFactory` 根据 `MarketContext.CurrentMarket` 获取对应的 keyed tools。 + +### 第八阶段:测试与验证 ⏳ + +**构建验证:** +- 执行 `dotnet build` 确保所有代码编译通过 + +**功能验证清单:** +- [ ] 设置页面显示市场切换选项 +- [ ] A股市场下显示ZhiTu API配置 +- [ ] 虚拟币市场下显示Binance API配置 +- [ ] Logo点击可以切换市场 +- [ ] Ctrl+M快捷键可以切换市场 +- [ ] 切换市场后首页搜索功能正常(A股能搜索,虚拟币抛异常) +- [ ] 切换市场后收藏列表独立显示 +- [ ] 切换市场后历史记录独立显示 +- [ ] Agent Tools根据市场类型调用对应实现 + +--- + +## 📝 关键设计决策 + +1. **数据隔离方式**:采用完全独立存储,不同市场使用不同的存储key + - A股收藏:`"FavoriteAssets_AShare"` + - 虚拟币收藏:`"FavoriteAssets_Crypto"` + - A股历史:`"RecentAssets_AShare"` + - 虚拟币历史:`"RecentAssets_Crypto"` + +2. **市场切换行为**:保持当前页面位置,仅更新数据 + +3. **Keyed Services**:所有市场特定服务使用 `MarketType` 枚举值作为key + +4. **命名规范**:接口和抽象服务不包含"Stock"字样,使用"Asset"等通用术语 + +5. **虚拟币实现**:暂时抛出 `NotImplementedException`,但包含详细的实现思路注释 + +--- + +## 🎯 下一步行动 + +继续执行第四、五、六、八阶段的工作,完成整个重构。建议按顺序执行: +1. 先完成第四阶段(Agent Tools抽象) +2. 再完成第六阶段(依赖注入配置) +3. 然后完成第五阶段(UI层适配) +4. 最后完成第八阶段(测试验证) + + + + + + diff --git a/src/Agents/Analysts/FinancialAnalystAgent.cs b/src/Agents/Analysts/FinancialAnalystAgent.cs index a016bcc..dad4b60 100644 --- a/src/Agents/Analysts/FinancialAnalystAgent.cs +++ b/src/Agents/Analysts/FinancialAnalystAgent.cs @@ -1,5 +1,5 @@ using MarketAssistant.Agents.MarketAnalysis.Models; -using MarketAssistant.Agents.Tools; +using MarketAssistant.Agents.Tools.Abstractions; using Microsoft.Extensions.AI; using System.ComponentModel; @@ -23,7 +23,7 @@ public class FinancialAnalystAgent : AnalystAgentBase public FinancialAnalystAgent( IChatClient chatClient, - StockFinancialTools financialTools) + IFinancialDataTools financialTools) : base( chatClient, instructions: GetInstructions(), diff --git a/src/Agents/Analysts/FundamentalAnalystAgent.cs b/src/Agents/Analysts/FundamentalAnalystAgent.cs index b7b95be..df092ae 100644 --- a/src/Agents/Analysts/FundamentalAnalystAgent.cs +++ b/src/Agents/Analysts/FundamentalAnalystAgent.cs @@ -1,6 +1,6 @@ using MarketAssistant.Agents.Analysts.Attributes; using MarketAssistant.Agents.MarketAnalysis.Models; -using MarketAssistant.Agents.Tools; +using MarketAssistant.Agents.Tools.Abstractions; using Microsoft.Extensions.AI; using System.ComponentModel; @@ -25,7 +25,7 @@ public class FundamentalAnalystAgent : AnalystAgentBase public FundamentalAnalystAgent( IChatClient chatClient, - StockBasicTools basicTools) + IBasicDataTools basicTools) : base( chatClient, instructions: GetInstructions(), diff --git a/src/Agents/Analysts/MarketSentimentAnalystAgent.cs b/src/Agents/Analysts/MarketSentimentAnalystAgent.cs index bf4cfe7..6bec2a2 100644 --- a/src/Agents/Analysts/MarketSentimentAnalystAgent.cs +++ b/src/Agents/Analysts/MarketSentimentAnalystAgent.cs @@ -1,6 +1,6 @@ using MarketAssistant.Agents.Analysts.Attributes; using MarketAssistant.Agents.MarketAnalysis.Models; -using MarketAssistant.Agents.Tools; +using MarketAssistant.Agents.Tools.Abstractions; using Microsoft.Extensions.AI; using System.ComponentModel; @@ -24,8 +24,8 @@ public class MarketSentimentAnalystAgent : AnalystAgentBase public MarketSentimentAnalystAgent( IChatClient chatClient, - StockFinancialTools financialTools, - MarketSentimentTools marketSentimentTools) + IFinancialDataTools financialTools, + ISentimentDataTools marketSentimentTools) : base( chatClient, instructions: GetInstructions(), diff --git a/src/Agents/Analysts/NewsEventAnalystAgent.cs b/src/Agents/Analysts/NewsEventAnalystAgent.cs index 447bded..359cbe0 100644 --- a/src/Agents/Analysts/NewsEventAnalystAgent.cs +++ b/src/Agents/Analysts/NewsEventAnalystAgent.cs @@ -1,6 +1,6 @@ using MarketAssistant.Agents.Analysts.Attributes; using MarketAssistant.Agents.MarketAnalysis.Models; -using MarketAssistant.Agents.Tools; +using MarketAssistant.Agents.Tools.Abstractions; using Microsoft.Extensions.AI; using System.ComponentModel; @@ -24,7 +24,7 @@ public class NewsEventAnalystAgent : AnalystAgentBase public NewsEventAnalystAgent( IChatClient chatClient, - StockNewsTools newsTools) + INewsDataTools newsTools) : base( chatClient, instructions: GetInstructions(), diff --git a/src/Agents/Analysts/TechnicalAnalystAgent.cs b/src/Agents/Analysts/TechnicalAnalystAgent.cs index f5e4336..06ec6ed 100644 --- a/src/Agents/Analysts/TechnicalAnalystAgent.cs +++ b/src/Agents/Analysts/TechnicalAnalystAgent.cs @@ -1,6 +1,6 @@ using MarketAssistant.Agents.Analysts.Attributes; using MarketAssistant.Agents.MarketAnalysis.Models; -using MarketAssistant.Agents.Tools; +using MarketAssistant.Agents.Tools.Abstractions; using Microsoft.Extensions.AI; using System.ComponentModel; @@ -24,7 +24,7 @@ public class TechnicalAnalystAgent : AnalystAgentBase public TechnicalAnalystAgent( IChatClient chatClient, - StockTechnicalTools technicalTools) + ITechnicalDataTools technicalTools) : base( chatClient, instructions: GetInstructions(), diff --git a/src/Agents/InvestmentSelection/Executors/AnalyzeCryptoExecutor.cs b/src/Agents/InvestmentSelection/Executors/AnalyzeCryptoExecutor.cs new file mode 100644 index 0000000..b7fcdb1 --- /dev/null +++ b/src/Agents/InvestmentSelection/Executors/AnalyzeCryptoExecutor.cs @@ -0,0 +1,286 @@ +using MarketAssistant.Agents.InvestmentSelection.Models; +using MarketAssistant.Applications.InvestmentSelection.Models; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Infrastructure.Factories; +using MarketAssistant.Applications.AssetScreener.Models; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Agents.InvestmentSelection.Executors; + +/// +/// 步骤3: AI分析虚拟币筛选结果的 Executor +/// 对筛选出的虚拟币进行深度分析并生成推荐报告 +/// +public sealed class AnalyzeCryptoExecutor : Executor +{ + private readonly IChatClientFactory _chatClientFactory; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions(JsonSerializerOptions.Web) + { + PropertyNameCaseInsensitive = true + }; + + public AnalyzeCryptoExecutor( + IChatClientFactory chatClientFactory, + ILogger logger) : base("AnalyzeCrypto") + { + if (chatClientFactory == null) throw new ArgumentNullException(nameof(chatClientFactory)); + _chatClientFactory = chatClientFactory; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public override async ValueTask HandleAsync( + AssetScreeningResult input, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + _logger.LogInformation("[步骤3/3-虚拟币] 对筛选结果进行AI分析"); + + try + { + var originalRequest = input.OriginalRequest; + if (originalRequest == null) + { + _logger.LogError("[步骤3/3-虚拟币] 缺少原始请求信息"); + return new InvestmentSelectionResult + { + Recommendations = new List(), + ConfidenceScore = 0, + AnalysisSummary = "分析失败:缺少原始请求信息" + }; + } + + if (originalRequest.MarketType != MarketType.Crypto) + { + throw new InvalidOperationException($"AnalyzeCryptoExecutor 仅支持 Crypto 市场,当前市场类型: {originalRequest.MarketType}"); + } + + if (input.ScreenedAssets.Count == 0) + { + _logger.LogWarning("[步骤3/3-虚拟币] 未筛选到符合条件的虚拟币"); + return new InvestmentSelectionResult + { + Recommendations = new List(), + ConfidenceScore = 0, + AnalysisSummary = "未找到符合条件的虚拟币,建议放宽筛选条件。" + }; + } + + var cryptoDataText = FormatScreenedCryptoForAnalysis(input.ScreenedAssets); + + var systemPrompt = GetAnalysisInstructions(originalRequest.IsNewsAnalysis); + var userPrompt = BuildAnalysisPrompt(originalRequest, cryptoDataText); + + var options = new ChatOptions + { + ResponseFormat = ChatResponseFormat.ForJsonSchema( + schema: AIJsonUtilities.CreateJsonSchema(typeof(InvestmentSelectionResult)), + schemaName: "InvestmentSelectionResult", + schemaDescription: "投资选择分析结果,包含推荐虚拟币列表和分析报告"), + Temperature = 0.2f, + MaxOutputTokens = 8000 + }; + + var response = await _chatClientFactory.CreateClient().GetResponseAsync( + [ + new ChatMessage(ChatRole.System, systemPrompt), + new ChatMessage(ChatRole.User, userPrompt) + ], + options, + cancellationToken); + + _logger.LogDebug("[步骤3/3-虚拟币] AI原始响应: {Response}", response.Text); + + var result = JsonSerializer.Deserialize(response.Text, JsonOptions); + + if (result == null) + { + _logger.LogWarning("[步骤3/3-虚拟币] 响应反序列化失败,原始响应: {Response}", response.Text); + result = CreateDefaultResult(); + } + else + { + var validationErrors = ValidateResult(result); + if (validationErrors.Count > 0) + { + _logger.LogWarning("[步骤3/3-虚拟币] AI返回数据验证失败: {Errors}", string.Join("; ", validationErrors)); + } + + if (result.Recommendations.Count == 0) + { + _logger.LogWarning("[步骤3/3-虚拟币] AI未生成任何推荐,原始响应: {Response}", response.Text); + } + } + + _logger.LogInformation("[步骤3/3-虚拟币] 分析完成,推荐 {Count} 个虚拟币,置信度: {Score}", + result.Recommendations.Count, result.ConfidenceScore); + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "[步骤3/3-虚拟币] AI分析失败"); + return new InvestmentSelectionResult + { + Recommendations = new List(), + ConfidenceScore = 0, + AnalysisSummary = $"分析失败: {ex.Message}" + }; + } + } + + private List ValidateResult(InvestmentSelectionResult result) + { + var errors = new List(); + + if (!Enum.IsDefined(typeof(SelectionType), result.SelectionType)) + errors.Add($"SelectionType 值无效: {result.SelectionType}"); + + if (string.IsNullOrWhiteSpace(result.AnalysisSummary)) + errors.Add("AnalysisSummary 不能为空"); + + if (string.IsNullOrWhiteSpace(result.MarketEnvironmentAnalysis)) + errors.Add("MarketEnvironmentAnalysis 不能为空"); + + if (string.IsNullOrWhiteSpace(result.InvestmentAdvice)) + errors.Add("InvestmentAdvice 不能为空"); + + if (result.RiskWarnings == null || result.RiskWarnings.Count == 0) + errors.Add("RiskWarnings 不能为空"); + + for (int i = 0; i < result.Recommendations.Count; i++) + { + var rec = result.Recommendations[i]; + if (string.IsNullOrWhiteSpace(rec.Symbol)) + errors.Add($"第{i + 1}个推荐的 Symbol 不能为空"); + + if (string.IsNullOrWhiteSpace(rec.Name)) + errors.Add($"第{i + 1}个推荐的 Name 不能为空"); + + if (string.IsNullOrWhiteSpace(rec.Reason)) + errors.Add($"第{i + 1}个推荐的 Reason 不能为空"); + + if (!Enum.IsDefined(typeof(RiskLevel), rec.RiskLevel)) + errors.Add($"第{i + 1}个推荐的 RiskLevel 值无效: {rec.RiskLevel}"); + } + + return errors; + } + + private string FormatScreenedCryptoForAnalysis(List cryptos) + { + // TODO: 当虚拟币筛选服务实现后,这里需要格式化虚拟币特有的数据 + // 目前暂时使用通用格式 + var simplifiedCryptos = cryptos.Select(c => + { + var data = new Dictionary + { + ["名称"] = c.Name, + ["代码"] = c.Symbol + }; + + // 虚拟币特有字段(待实现) + // data["市值_美元"] = ... + // data["24h交易量_美元"] = ... + // data["24h涨跌幅_百分比"] = ... + // data["7天涨跌幅_百分比"] = ... + + return data; + }).ToList(); + + var jsonOptions = new JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + return JsonSerializer.Serialize(simplifiedCryptos, jsonOptions); + } + + private string BuildAnalysisPrompt(InvestmentSelectionWorkflowRequest request, string cryptoData) + { + var sb = new StringBuilder(); + + if (request.IsNewsAnalysis) + { + sb.AppendLine("## 新闻内容"); + sb.AppendLine(request.Content); + sb.AppendLine(); + } + else + { + sb.AppendLine("## 用户需求"); + sb.AppendLine($"- 需求描述: {request.Content}"); + sb.AppendLine($"- 风险偏好: {request.RiskPreference}"); + + if (request.InvestmentAmount.HasValue) + sb.AppendLine($"- 投资金额: {request.InvestmentAmount:C}"); + + if (request.InvestmentHorizon.HasValue) + sb.AppendLine($"- 投资期限: {request.InvestmentHorizon}天"); + + sb.AppendLine(); + } + + sb.AppendLine("## 筛选出的虚拟币数据(JSON格式)"); + sb.AppendLine(cryptoData); + sb.AppendLine(); + sb.AppendLine("## 分析任务"); + sb.AppendLine("请基于以上虚拟币数据和用户需求,进行综合分析并生成推荐报告。"); + sb.AppendLine("- 从中选择最优的3-8个虚拟币进行推荐"); + sb.AppendLine("- 说明推荐理由和风险提示"); + + return sb.ToString(); + } + + private string GetAnalysisInstructions(bool isNewsAnalysis) + { + return @" +你是专业的加密货币投资顾问,基于用户需求/新闻热点和虚拟币数据提供投资建议。 + +## 核心职责 +从筛选出的虚拟币中进行多维度分析,输出结构化推荐报告。 + +## 评估维度(灵活权重) +1. **项目基本面**:技术创新、团队背景、生态发展、实际应用 +2. **市场表现**:市值排名、交易量、价格走势、流动性 +3. **链上数据**:活跃地址、交易次数、持币集中度、大户动向 +4. **社区热度**:社交媒体讨论、开发者活跃度、社区支持 +5. **风险评估**:波动性、监管风险、技术风险、市场情绪" + (isNewsAnalysis ? "、新闻关联度" : "") + @" + +## 虚拟币特有分析要点 +- 优先考虑市值排名前100的主流币种 +- 关注项目的技术创新和实际应用场景 +- 评估代币经济模型的合理性 +- 注意市场情绪和恐慌贪婪指数 +- 虚拟币市场波动性大,风险提示要充分 + +## 分析要点 +- 推荐理由必须包含具体数据支撑,避免空泛描述 +- 风险提示应特别强调虚拟币的高波动性 +- 如无合适标的,可返回空推荐列表 + +## 输出格式 +严格按 JSON Schema 定义的结构输出,所有必填字段不能为空或null。 +Symbol 字段格式为交易对形式,如 BTC/USDT、ETH/USDT。 +"; + } + + private InvestmentSelectionResult CreateDefaultResult() + { + return new InvestmentSelectionResult + { + SelectionType = SelectionType.UserRequest, + Recommendations = new List(), + ConfidenceScore = 0, + AnalysisSummary = "解析分析结果失败", + MarketEnvironmentAnalysis = "无可用分析", + InvestmentAdvice = "建议重新尝试分析", + RiskWarnings = new List { "分析失败,请联系技术支持" } + }; + } +} + diff --git a/src/Agents/StockSelection/Executors/AnalyzeStocksExecutor.cs b/src/Agents/InvestmentSelection/Executors/AnalyzeStocksExecutor.cs similarity index 69% rename from src/Agents/StockSelection/Executors/AnalyzeStocksExecutor.cs rename to src/Agents/InvestmentSelection/Executors/AnalyzeStocksExecutor.cs index f203843..de3ae4f 100644 --- a/src/Agents/StockSelection/Executors/AnalyzeStocksExecutor.cs +++ b/src/Agents/InvestmentSelection/Executors/AnalyzeStocksExecutor.cs @@ -1,18 +1,19 @@ -using MarketAssistant.Agents.StockSelection.Models; -using MarketAssistant.Applications.StockSelection.Models; +using MarketAssistant.Agents.InvestmentSelection.Models; +using MarketAssistant.Applications.InvestmentSelection.Models; +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Infrastructure.Factories; -using MarketAssistant.Services.StockScreener.Models; +using MarketAssistant.Applications.AssetScreener.Models; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; -namespace MarketAssistant.Agents.StockSelection.Executors; +namespace MarketAssistant.Agents.InvestmentSelection.Executors; /// -/// 步骤3: AI分析筛选结果的 Executor(基于 Executor 模式) +/// 步骤3: AI分析股票筛选结果的 Executor /// 对筛选出的股票进行深度分析并生成推荐报告 /// -public sealed class AnalyzeStocksExecutor : Executor +public sealed class AnalyzeStocksExecutor : Executor { private readonly IChatClientFactory _chatClientFactory; private readonly ILogger _logger; @@ -31,59 +32,58 @@ public AnalyzeStocksExecutor( _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public override async ValueTask HandleAsync( - ScreeningResult input, + public override async ValueTask HandleAsync( + AssetScreeningResult input, IWorkflowContext context, CancellationToken cancellationToken = default) { - _logger.LogInformation("[步骤3/3] 对筛选结果进行AI分析"); + _logger.LogInformation("[步骤3/3-股票] 对筛选结果进行AI分析"); try { - // 从输入中获取原始请求 var originalRequest = input.OriginalRequest; if (originalRequest == null) { - _logger.LogError("[步骤3/3] 缺少原始请求信息"); - return new StockSelectionResult + _logger.LogError("[步骤3/3-股票] 缺少原始请求信息"); + return new InvestmentSelectionResult { - Recommendations = new List(), + Recommendations = new List(), ConfidenceScore = 0, AnalysisSummary = "分析失败:缺少原始请求信息" }; } - // 检查是否有筛选结果 - if (input.ScreenedStocks.Count == 0) + if (originalRequest.MarketType != MarketType.AShare) { - _logger.LogWarning("[步骤3/3] 未筛选到符合条件的股票"); - return new StockSelectionResult + throw new InvalidOperationException($"AnalyzeStocksExecutor 仅支持 AShare 市场,当前市场类型: {originalRequest.MarketType}"); + } + + if (input.ScreenedAssets.Count == 0) + { + _logger.LogWarning("[步骤3/3-股票] 未筛选到符合条件的股票"); + return new InvestmentSelectionResult { - Recommendations = new List(), + Recommendations = new List(), ConfidenceScore = 0, AnalysisSummary = "未找到符合条件的股票,建议放宽筛选条件。" }; } - // 格式化股票数据为文本 - var stocksDataText = FormatScreenedStocksForAnalysis(input.ScreenedStocks); + var stocksDataText = FormatScreenedStocksForAnalysis(input.ScreenedAssets); - // 构建分析提示词 var systemPrompt = GetAnalysisInstructions(originalRequest.IsNewsAnalysis); var userPrompt = BuildAnalysisPrompt(originalRequest, stocksDataText); - // 使用结构化输出 var options = new ChatOptions { ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema: AIJsonUtilities.CreateJsonSchema(typeof(StockSelectionResult)), - schemaName: "StockSelectionResult", - schemaDescription: "股票选择分析结果,包含推荐股票列表和分析报告"), + schema: AIJsonUtilities.CreateJsonSchema(typeof(InvestmentSelectionResult)), + schemaName: "InvestmentSelectionResult", + schemaDescription: "投资选择分析结果,包含推荐股票列表和分析报告"), Temperature = 0.2f, MaxOutputTokens = 8000 }; - // 执行 AI 分析(纯分析,无工具调用) var response = await _chatClientFactory.CreateClient().GetResponseAsync( [ new ChatMessage(ChatRole.System, systemPrompt), @@ -92,57 +92,50 @@ public override async ValueTask HandleAsync( options, cancellationToken); - // 添加调试日志:输出AI原始响应 - _logger.LogDebug("[步骤3/3] AI原始响应: {Response}", response.Text); + _logger.LogDebug("[步骤3/3-股票] AI原始响应: {Response}", response.Text); - var result = JsonSerializer.Deserialize(response.Text, JsonOptions); + var result = JsonSerializer.Deserialize(response.Text, JsonOptions); if (result == null) { - _logger.LogWarning("[步骤3/3] 响应反序列化失败,原始响应: {Response}", response.Text); + _logger.LogWarning("[步骤3/3-股票] 响应反序列化失败,原始响应: {Response}", response.Text); result = CreateDefaultResult(); } else { - // 验证必填字段 var validationErrors = ValidateResult(result); if (validationErrors.Count > 0) { - _logger.LogWarning("[步骤3/3] AI返回数据验证失败: {Errors}", string.Join("; ", validationErrors)); + _logger.LogWarning("[步骤3/3-股票] AI返回数据验证失败: {Errors}", string.Join("; ", validationErrors)); } if (result.Recommendations.Count == 0) { - _logger.LogWarning("[步骤3/3] AI未生成任何推荐股票,原始响应: {Response}", response.Text); + _logger.LogWarning("[步骤3/3-股票] AI未生成任何推荐股票,原始响应: {Response}", response.Text); } } - _logger.LogInformation("[步骤3/3] 分析完成,推荐 {Count} 只股票,置信度: {Score}", + _logger.LogInformation("[步骤3/3-股票] 分析完成,推荐 {Count} 只股票,置信度: {Score}", result.Recommendations.Count, result.ConfidenceScore); return result; } catch (Exception ex) { - _logger.LogError(ex, "[步骤3/3] AI分析失败"); - return new StockSelectionResult + _logger.LogError(ex, "[步骤3/3-股票] AI分析失败"); + return new InvestmentSelectionResult { - Recommendations = new List(), + Recommendations = new List(), ConfidenceScore = 0, AnalysisSummary = $"分析失败: {ex.Message}" }; } } - /// - /// 验证 StockSelectionResult 的必填字段 - /// - private List ValidateResult(StockSelectionResult result) + private List ValidateResult(InvestmentSelectionResult result) { var errors = new List(); - // SelectionType 是枚举,不需要验证是否为空(枚举有默认值) - // 但可以验证是否为有效的枚举值 if (!Enum.IsDefined(typeof(SelectionType), result.SelectionType)) errors.Add($"SelectionType 值无效: {result.SelectionType}"); @@ -158,42 +151,34 @@ private List ValidateResult(StockSelectionResult result) if (result.RiskWarnings == null || result.RiskWarnings.Count == 0) errors.Add("RiskWarnings 不能为空"); - // 验证每只推荐股票 for (int i = 0; i < result.Recommendations.Count; i++) { - var stock = result.Recommendations[i]; - if (string.IsNullOrWhiteSpace(stock.Symbol)) - errors.Add($"第{i + 1}只股票的 Symbol 不能为空"); + var rec = result.Recommendations[i]; + if (string.IsNullOrWhiteSpace(rec.Symbol)) + errors.Add($"第{i + 1}个推荐的 Symbol 不能为空"); - if (string.IsNullOrWhiteSpace(stock.Name)) - errors.Add($"第{i + 1}只股票的 Name 不能为空"); + if (string.IsNullOrWhiteSpace(rec.Name)) + errors.Add($"第{i + 1}个推荐的 Name 不能为空"); - if (string.IsNullOrWhiteSpace(stock.Reason)) - errors.Add($"第{i + 1}只股票的 Reason 不能为空"); + if (string.IsNullOrWhiteSpace(rec.Reason)) + errors.Add($"第{i + 1}个推荐的 Reason 不能为空"); - // RiskLevel 是枚举,验证是否为有效值 - if (!Enum.IsDefined(typeof(RiskLevel), stock.RiskLevel)) - errors.Add($"第{i + 1}只股票的 RiskLevel 值无效: {stock.RiskLevel}"); + if (!Enum.IsDefined(typeof(RiskLevel), rec.RiskLevel)) + errors.Add($"第{i + 1}个推荐的 RiskLevel 值无效: {rec.RiskLevel}"); } return errors; } - /// - /// 格式化筛选结果为分析数据(JSON格式) - /// 只序列化非零值和非空值的属性,减少数据冗余 - /// private string FormatScreenedStocksForAnalysis(List stocks) { var simplifiedStocks = stocks.Select(s => { var data = new Dictionary(); - // 基本信息(必需) data["名称"] = s.Name; data["代码"] = s.Symbol; - // 辅助方法:添加非零值 void AddIfNotZero(string key, decimal value, int decimals = 2, decimal divisor = 1) { if (value != 0) @@ -203,43 +188,28 @@ void AddIfNotZero(string key, decimal value, int decimals = 2, decimal divisor = } } - // 价格与涨跌 AddIfNotZero("当前价_元", s.Current); AddIfNotZero("涨跌幅_百分比", s.Pct); AddIfNotZero("当日振幅_百分比", s.ChgPct); - - // 市值 AddIfNotZero("总市值_亿元", s.Mc, 2, 100000000); AddIfNotZero("流通市值_亿元", s.Fmc, 2, 100000000); - - // 成交数据 AddIfNotZero("成交额_亿元", s.Amount, 2, 100000000); AddIfNotZero("成交量_万股", s.Volume); AddIfNotZero("量比", s.VolumeRatio); AddIfNotZero("换手率_百分比", s.Tr); - - // 估值指标 AddIfNotZero("市盈率TTM", s.PeTtm); AddIfNotZero("市盈率LYR", s.PeLyr); AddIfNotZero("市净率", s.Pb); AddIfNotZero("市销率", s.Psr); - - // 每股指标 AddIfNotZero("每股净资产_元", s.Bps); AddIfNotZero("每股收益_元", s.Eps); AddIfNotZero("股息收益率_百分比", s.DyL); - - // 盈利能力 AddIfNotZero("净资产收益率ROE_百分比", s.RoeDiluted); AddIfNotZero("总资产报酬率_百分比", s.Niota); AddIfNotZero("净利润_亿元", s.NetProfit, 2, 100000000); AddIfNotZero("营业收入_亿元", s.TotalRevenue, 2, 100000000); - - // 增长指标 AddIfNotZero("净利润同比增长_百分比", s.Npay); AddIfNotZero("营收同比增长_百分比", s.Oiy); - - // 历史涨跌 AddIfNotZero("近5日涨跌幅_百分比", s.Pct5); AddIfNotZero("近10日涨跌幅_百分比", s.Pct10); AddIfNotZero("近20日涨跌幅_百分比", s.Pct20); @@ -247,8 +217,6 @@ void AddIfNotZero(string key, decimal value, int decimals = 2, decimal divisor = AddIfNotZero("近120日涨跌幅_百分比", s.Pct120); AddIfNotZero("近250日涨跌幅_百分比", s.Pct250); AddIfNotZero("年初至今涨跌幅_百分比", s.PctCurrentYear); - - // 雪球社交热度 AddIfNotZero("累计关注人数", s.Follow, 0); AddIfNotZero("累计讨论次数", s.Tweet, 0); AddIfNotZero("累计交易分享数", s.Deal, 0); @@ -271,10 +239,7 @@ void AddIfNotZero(string key, decimal value, int decimals = 2, decimal divisor = return JsonSerializer.Serialize(simplifiedStocks, jsonOptions); } - /// - /// 构建分析提示词 - /// - private string BuildAnalysisPrompt(StockSelectionWorkflowRequest request, string stocksData) + private string BuildAnalysisPrompt(InvestmentSelectionWorkflowRequest request, string stocksData) { var sb = new StringBuilder(); @@ -316,9 +281,6 @@ private string BuildAnalysisPrompt(StockSelectionWorkflowRequest request, string return sb.ToString(); } - /// - /// 获取分析指令(System Prompt)- 简洁版,依赖 JSON Schema 约束 - /// private string GetAnalysisInstructions(bool isNewsAnalysis) { return @" @@ -345,12 +307,12 @@ 严格按 JSON Schema 定义的结构输出,所有必填字段不能为空或n "; } - private StockSelectionResult CreateDefaultResult() + private InvestmentSelectionResult CreateDefaultResult() { - return new StockSelectionResult + return new InvestmentSelectionResult { - SelectionType = SelectionType.UserRequest, // 设置默认枚举值 - Recommendations = new List(), + SelectionType = SelectionType.UserRequest, + Recommendations = new List(), ConfidenceScore = 0, AnalysisSummary = "解析分析结果失败", MarketEnvironmentAnalysis = "无可用分析", @@ -359,3 +321,4 @@ private StockSelectionResult CreateDefaultResult() }; } } + diff --git a/src/Agents/InvestmentSelection/Executors/GenerateCryptoCriteriaExecutor.cs b/src/Agents/InvestmentSelection/Executors/GenerateCryptoCriteriaExecutor.cs new file mode 100644 index 0000000..767aade --- /dev/null +++ b/src/Agents/InvestmentSelection/Executors/GenerateCryptoCriteriaExecutor.cs @@ -0,0 +1,256 @@ +using MarketAssistant.Agents.InvestmentSelection.Models; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Infrastructure.Factories; +using MarketAssistant.Applications.AssetScreener.Models; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Agents.InvestmentSelection.Executors; + +/// +/// 步骤1: 生成虚拟币筛选条件的 Executor +/// 将用户需求或新闻内容转换为结构化的虚拟币筛选条件 JSON +/// +public sealed class GenerateCryptoCriteriaExecutor : Executor +{ + private readonly IChatClientFactory _chatClientFactory; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions SchemaOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + private static readonly JsonSerializerOptions DeserializationOptions = new(JsonSerializerOptions.Web) + { + PropertyNameCaseInsensitive = true + }; + + public GenerateCryptoCriteriaExecutor( + IChatClientFactory chatClientFactory, + ILogger logger) : base("GenerateCryptoCriteria") + { + _chatClientFactory = chatClientFactory ?? throw new ArgumentNullException(nameof(chatClientFactory)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public override async ValueTask HandleAsync( + InvestmentSelectionWorkflowRequest input, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + if (input.MarketType != MarketType.Crypto) + { + throw new InvalidOperationException($"GenerateCryptoCriteriaExecutor 仅支持 Crypto 市场,当前市场类型: {input.MarketType}"); + } + + _logger.LogInformation("[步骤1/3-虚拟币] 将{Type}转换为虚拟币筛选条件", + input.IsNewsAnalysis ? "新闻内容" : "用户需求"); + + try + { + string systemPrompt = input.IsNewsAnalysis + ? BuildNewsAnalysisSystemPrompt() + : BuildUserRequirementSystemPrompt(); + + string userPrompt = BuildUserPrompt(input); + + var chatClient = _chatClientFactory.CreateClient(); + + var schema = AIJsonUtilities.CreateJsonSchema(typeof(CryptoCriteria), serializerOptions: SchemaOptions); + + var chatOptions = new ChatOptions + { + ResponseFormat = ChatResponseFormat.ForJsonSchema( + schema: schema, + schemaName: "CryptoCriteria", + schemaDescription: "包含筛选条件、交易所和数量限制的虚拟币筛选参数"), + Temperature = 0.1f, + MaxOutputTokens = input.IsNewsAnalysis ? 3500 : 2000 + }; + + var response = await chatClient.GetResponseAsync( + [ + new ChatMessage(ChatRole.System, systemPrompt), + new ChatMessage(ChatRole.User, userPrompt) + ], + chatOptions, + cancellationToken); + + var criteria = JsonSerializer.Deserialize(response.Text, DeserializationOptions); + if (criteria == null) + { + throw new InvalidOperationException("虚拟币筛选条件 JSON 解析失败"); + } + + _logger.LogInformation("[步骤1/3-虚拟币] 筛选条件生成完成,包含 {Count} 个条件", + criteria.Criteria?.Count ?? 0); + + return new CriteriaGenerationResult + { + Criteria = criteria, + OriginalRequest = input + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "[步骤1/3-虚拟币] 生成筛选条件失败"); + if (ex is FriendlyException) + { + throw; + } + throw new FriendlyException(ex.Message); + } + } + + private string BuildUserRequirementSystemPrompt() + { + return """ +## 主要任务 +分析用户对虚拟币投资的需求,生成合理的筛选条件。 + +## 需求转换规则 + +### 市值类别 +- 大盘币/主流币 → market_cap >= 10000000000 (100亿美元) +- 中盘币 → market_cap: 1000000000-10000000000 (10亿-100亿美元) +- 小盘币 → market_cap < 1000000000 (10亿美元以下) +- 市值X亿美元以上 → market_cap >= X*100000000 + +### 市场表现 +- 活跃币/成交活跃 → volume_24h > 100000000 (1亿美元) +- 强势币 → price_change_7d > 10 +- 近期涨幅大 → price_change_24h > 5 +- 抗跌币 → price_change_24h > -5 +- 24小时涨幅X% → price_change_24h > X +- 7天涨幅X% → price_change_7d > X +- 30天涨幅X% → price_change_30d > X + +### 价格相关 +- 低价币 → price < 1 +- 中价币 → price: 1-100 +- 高价币 → price > 100 +- 价格X美元以下 → price < X +- 价格X美元以上 → price > X + +### 市值排名 +- 前10大币 → market_cap_rank <= 10 +- 前50大币 → market_cap_rank <= 50 +- 前100大币 → market_cap_rank <= 100 + +## 支持的筛选指标 + +### 基本指标 +- market_cap: 市值(美元) +- volume_24h: 24小时交易量(美元) +- price: 当前价格(USDT) +- market_cap_rank: 市值排名 +- circulating_supply: 流通量 +- total_supply: 总供应量 +- max_supply: 最大供应量 + +### 市场表现指标 +- price_change_24h: 24小时涨跌幅(%) +- price_change_7d: 7天涨跌幅(%) +- price_change_30d: 30天涨跌幅(%) +- price_change_1y: 1年涨跌幅(%) +- volume_change_24h: 24小时交易量变化(%) + +### 交易对设置 +- 默认使用 USDT 作为基准货币 +- 可指定其他交易对如 BTC、ETH 等 + +## 注意事项 +- 虚拟币市场波动性大,筛选条件应合理 +- 建议关注市值排名前100的主流币种 +- 小盘币风险较高,需谨慎推荐 +"""; + } + + private string BuildNewsAnalysisSystemPrompt() + { + return """ +## 任务 +分析新闻内容,识别相关虚拟币类别,判断情感倾向,并生成对应的筛选条件。 + +## 新闻类型识别规则 + +**区块链技术新闻**(以太坊升级、Layer2、DeFi协议)→ 关注 ETH、相关生态币种 +**比特币新闻**(BTC ETF、减半、机构采用)→ 关注 BTC +**稳定币新闻**(USDT、USDC监管)→ 关注稳定币相关 +**NFT/元宇宙新闻**→ 关注相关概念币 +**交易所新闻**(币安、Coinbase)→ 关注平台币 BNB、交易所代币 +**监管新闻**(SEC、各国政策)→ 关注合规性强的主流币 +**技术创新新闻**(AI+区块链、新共识机制)→ 关注创新项目代币 + +## 指标说明 + +### 基本指标 +- market_cap: 市值(美元) +- volume_24h: 24小时交易量(美元) +- market_cap_rank: 市值排名 +- price: 当前价格(USDT) + +### 市场表现指标 +- price_change_24h: 24小时涨跌幅(%) +- price_change_7d: 7天涨跌幅(%) +- price_change_30d: 30天涨跌幅(%) +- volume_change_24h: 24小时交易量变化(%) + +## 情感判断与筛选策略 + +**积极新闻 → 成长币策略** +- price_change_7d > 0 +- volume_24h > 50000000 +- market_cap_rank <= 200 + +**技术突破 → 创新币策略** +- volume_change_24h > 50 +- price_change_24h > -5 +- market_cap_rank <= 100 + +**监管利好 → 主流币策略** +- market_cap_rank <= 20 +- market_cap > 1000000000 +- volume_24h > 100000000 + +**市场泡沫/消极新闻 → 防御策略** +- market_cap_rank <= 10 +- volume_24h > 500000000 +- price_change_30d > -10 + +**中性新闻 → 平衡策略** +- market_cap_rank <= 50 +- volume_24h > 10000000 +- price_change_7d > -20 +"""; + } + + private string BuildUserPrompt(InvestmentSelectionWorkflowRequest input) + { + if (input.IsNewsAnalysis) + { + return $""" + 新闻内容: + {input.Content} + + 推荐虚拟币数量限制:{input.MaxRecommendations} + + 请根据新闻内容生成虚拟币筛选条件。 + """; + } + else + { + return $""" + 用户需求: + {input.Content} + + 推荐虚拟币数量限制:{input.MaxRecommendations} + + 请根据用户需求生成虚拟币筛选条件。 + """; + } + } +} + diff --git a/src/Agents/StockSelection/Executors/GenerateCriteriaExecutor.cs b/src/Agents/InvestmentSelection/Executors/GenerateStockCriteriaExecutor.cs similarity index 82% rename from src/Agents/StockSelection/Executors/GenerateCriteriaExecutor.cs rename to src/Agents/InvestmentSelection/Executors/GenerateStockCriteriaExecutor.cs index 540e2e3..c6bb3de 100644 --- a/src/Agents/StockSelection/Executors/GenerateCriteriaExecutor.cs +++ b/src/Agents/InvestmentSelection/Executors/GenerateStockCriteriaExecutor.cs @@ -1,70 +1,65 @@ -using MarketAssistant.Agents.StockSelection.Models; +using MarketAssistant.Agents.InvestmentSelection.Models; +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Infrastructure.Factories; -using MarketAssistant.Services.StockScreener.Models; +using MarketAssistant.Applications.AssetScreener.Models; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; -namespace MarketAssistant.Agents.StockSelection.Executors; +namespace MarketAssistant.Agents.InvestmentSelection.Executors; /// -/// 步骤1: 生成股票筛选条件的 Executor(基于 Executor 模式) -/// 将用户需求或新闻内容转换为结构化的筛选条件 JSON +/// 步骤1: 生成股票筛选条件的 Executor +/// 将用户需求或新闻内容转换为结构化的股票筛选条件 JSON /// -public sealed class GenerateCriteriaExecutor : Executor +public sealed class GenerateStockCriteriaExecutor : Executor { private readonly IChatClientFactory _chatClientFactory; - private readonly ILogger _logger; + private readonly ILogger _logger; - /// - /// 用于生成 JSON Schema 的序列化选项(camelCase 属性命名) - /// private static readonly JsonSerializerOptions SchemaOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - /// - /// 用于反序列化 AI 响应的序列化选项(Web 默认配置 + 大小写不敏感) - /// private static readonly JsonSerializerOptions DeserializationOptions = new(JsonSerializerOptions.Web) { PropertyNameCaseInsensitive = true }; - public GenerateCriteriaExecutor( + public GenerateStockCriteriaExecutor( IChatClientFactory chatClientFactory, - ILogger logger) : base("GenerateCriteria") + ILogger logger) : base("GenerateStockCriteria") { _chatClientFactory = chatClientFactory ?? throw new ArgumentNullException(nameof(chatClientFactory)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public override async ValueTask HandleAsync( - StockSelectionWorkflowRequest input, + InvestmentSelectionWorkflowRequest input, IWorkflowContext context, CancellationToken cancellationToken = default) { - _logger.LogInformation("[步骤1/3] 将{Type}转换为筛选条件", + if (input.MarketType != MarketType.AShare) + { + throw new InvalidOperationException($"GenerateStockCriteriaExecutor 仅支持 AShare 市场,当前市场类型: {input.MarketType}"); + } + + _logger.LogInformation("[步骤1/3-股票] 将{Type}转换为股票筛选条件", input.IsNewsAnalysis ? "新闻内容" : "用户需求"); try { - // 构建 System Prompt string systemPrompt = input.IsNewsAnalysis ? BuildNewsAnalysisSystemPrompt() : BuildUserRequirementSystemPrompt(); - // 构建 User Prompt string userPrompt = BuildUserPrompt(input); - // 创建 ChatClient var chatClient = _chatClientFactory.CreateClient(); - // 创建结构化输出的 JSON Schema var schema = AIJsonUtilities.CreateJsonSchema(typeof(StockCriteria), serializerOptions: SchemaOptions); - // 配置聊天选项 var chatOptions = new ChatOptions { ResponseFormat = ChatResponseFormat.ForJsonSchema( @@ -75,7 +70,6 @@ public override async ValueTask HandleAsync( MaxOutputTokens = input.IsNewsAnalysis ? 3500 : 2000 }; - // 执行聊天完成 var response = await chatClient.GetResponseAsync( [ new ChatMessage(ChatRole.System, systemPrompt), @@ -84,15 +78,15 @@ public override async ValueTask HandleAsync( chatOptions, cancellationToken); - // 获取响应文本并反序列化为 StockCriteria 对象以验证格式 var criteria = JsonSerializer.Deserialize(response.Text, DeserializationOptions); if (criteria == null) { - throw new InvalidOperationException("筛选条件 JSON 解析失败"); + throw new InvalidOperationException("股票筛选条件 JSON 解析失败"); } - _logger.LogInformation("[步骤1/3] 筛选条件生成完成,包含 {Count} 个条件", + + _logger.LogInformation("[步骤1/3-股票] 筛选条件生成完成,包含 {Count} 个条件", criteria.Criteria?.Count ?? 0); - // 返回结果 + return new CriteriaGenerationResult { Criteria = criteria, @@ -101,21 +95,15 @@ public override async ValueTask HandleAsync( } catch (Exception ex) { - _logger.LogError(ex, "[步骤1/3] 生成筛选条件失败"); - // 检查是否为 FriendlyException,如果是则直接抛出 + _logger.LogError(ex, "[步骤1/3-股票] 生成筛选条件失败"); if (ex is FriendlyException) { throw; } - - // 将其他异常包装为 FriendlyException,以便在 UI 层显示友好错误 throw new FriendlyException(ex.Message); } } - /// - /// 构建用户需求分析的系统提示词 - /// private string BuildUserRequirementSystemPrompt() { return """ @@ -188,7 +176,7 @@ 2. 如果用户**明确提到**某个具体行业或领域 → 根据下表选 ## 支持的筛选指标 -### 基本指标 (basic) - 15个 +### 基本指标 (15个) - mc: 总市值 - fmc: 流通市值 - pettm: 市盈率TTM @@ -205,7 +193,7 @@ 2. 如果用户**明确提到**某个具体行业或领域 → 根据下表选 - oiy: 营业收入同比增长 - niota: 总资产报酬率 -### 行情指标 (market) - 14个 +### 行情指标 (14个) - current: 当前价 - pct: 当日涨跌幅 - pct5: 近5日涨跌幅 @@ -221,7 +209,7 @@ 2. 如果用户**明确提到**某个具体行业或领域 → 根据下表选 - tr: 当日换手率 - chgpct: 当日振幅 -### 雪球指标 (snowball) - 9个 +### 雪球指标 (9个) - follow: 累计关注人数 - tweet: 累计讨论次数 - deal: 累计交易分享数 @@ -234,9 +222,6 @@ 2. 如果用户**明确提到**某个具体行业或领域 → 根据下表选 """; } - /// - /// 构建新闻分析的系统提示词 - /// private string BuildNewsAnalysisSystemPrompt() { return """ @@ -286,7 +271,7 @@ 2. 只有在新闻是**宏观经济、多行业政策、跨行业综合报道** **积极新闻 → 成长股策略** - npay > 15 -- ro ediluted > 12 +- roediluted > 12 - pct20 > -5 **政策利好 → 龙头股策略** @@ -311,10 +296,7 @@ 2. 只有在新闻是**宏观经济、多行业政策、跨行业综合报道** """; } - /// - /// 构建用户提示词 - /// - private string BuildUserPrompt(StockSelectionWorkflowRequest input) + private string BuildUserPrompt(InvestmentSelectionWorkflowRequest input) { if (input.IsNewsAnalysis) { diff --git a/src/Agents/InvestmentSelection/Executors/ScreenInvestmentTargetsExecutor.cs b/src/Agents/InvestmentSelection/Executors/ScreenInvestmentTargetsExecutor.cs new file mode 100644 index 0000000..bd3b436 --- /dev/null +++ b/src/Agents/InvestmentSelection/Executors/ScreenInvestmentTargetsExecutor.cs @@ -0,0 +1,74 @@ +using MarketAssistant.Agents.InvestmentSelection.Models; +using MarketAssistant.Applications.AssetScreener; +using MarketAssistant.Applications.AssetScreener.Models; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Agents.InvestmentSelection.Executors; + +/// +/// 步骤2: 执行投资标的筛选的 Executor(共用,支持多市场) +/// 通过 IAssetScreenerService 接口抽象,根据市场类型动态选择筛选服务 +/// +public sealed class ScreenInvestmentTargetsExecutor : Executor +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public ScreenInvestmentTargetsExecutor( + IServiceProvider serviceProvider, + ILogger logger) : base("ScreenInvestmentTargets") + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public override async ValueTask HandleAsync( + CriteriaGenerationResult input, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + _logger.LogInformation("[步骤2/3] 执行投资标的筛选"); + + try + { + if (input?.Criteria == null) + { + throw new ArgumentNullException(nameof(input), "筛选条件不能为空"); + } + + var originalRequest = input.OriginalRequest; + if (originalRequest == null) + { + throw new InvalidOperationException("缺少原始请求信息"); + } + + // 根据市场类型获取对应的筛选服务(使用 Keyed Services) + var marketTypeKey = originalRequest.MarketType.ToString(); + var screenerService = _serviceProvider.GetRequiredKeyedService(marketTypeKey); + + _logger.LogInformation("[步骤2/3] 使用市场类型: {MarketType}, 筛选服务: {ServiceType}", + originalRequest.MarketType, screenerService.GetType().Name); + + // 调用筛选服务 + List assets = await screenerService.ScreenAsync(input.Criteria); + + _logger.LogInformation("[步骤2/3] 筛选完成,获得 {Count} 个投资标的", assets.Count); + + // 返回筛选结果 + return new AssetScreeningResult + { + ScreenedAssets = assets, + Criteria = input.Criteria, + OriginalRequest = originalRequest + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "[步骤2/3] 投资标的筛选失败"); + throw new InvalidOperationException($"投资标的筛选失败: {ex.Message}", ex); + } + } +} + diff --git a/src/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs b/src/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs new file mode 100644 index 0000000..7b29305 --- /dev/null +++ b/src/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs @@ -0,0 +1,174 @@ +using MarketAssistant.Agents.InvestmentSelection.Executors; +using MarketAssistant.Agents.InvestmentSelection.Models; +using MarketAssistant.Applications.InvestmentSelection.Models; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Agents.InvestmentSelection; + +/// +/// AI投资选择工作流,使用 Agent Framework Workflows 实现确定性三步骤流程 +/// 根据市场类型(股票/虚拟币)动态构建执行链 +/// 第1步: 生成筛选条件 → 第2步: 执行筛选 → 第3步: AI分析结果 +/// +public class InvestmentSelectionWorkflow : IDisposable +{ + private readonly GenerateStockCriteriaExecutor _generateStockCriteriaExecutor; + private readonly GenerateCryptoCriteriaExecutor _generateCryptoCriteriaExecutor; + private readonly ScreenInvestmentTargetsExecutor _screenTargetsExecutor; + private readonly AnalyzeStocksExecutor _analyzeStocksExecutor; + private readonly AnalyzeCryptoExecutor _analyzeCryptoExecutor; + private readonly ILogger _logger; + private bool _disposed = false; + + public InvestmentSelectionWorkflow( + GenerateStockCriteriaExecutor generateStockCriteriaExecutor, + GenerateCryptoCriteriaExecutor generateCryptoCriteriaExecutor, + ScreenInvestmentTargetsExecutor screenTargetsExecutor, + AnalyzeStocksExecutor analyzeStocksExecutor, + AnalyzeCryptoExecutor analyzeCryptoExecutor, + ILogger logger) + { + _generateStockCriteriaExecutor = generateStockCriteriaExecutor ?? throw new ArgumentNullException(nameof(generateStockCriteriaExecutor)); + _generateCryptoCriteriaExecutor = generateCryptoCriteriaExecutor ?? throw new ArgumentNullException(nameof(generateCryptoCriteriaExecutor)); + _screenTargetsExecutor = screenTargetsExecutor ?? throw new ArgumentNullException(nameof(screenTargetsExecutor)); + _analyzeStocksExecutor = analyzeStocksExecutor ?? throw new ArgumentNullException(nameof(analyzeStocksExecutor)); + _analyzeCryptoExecutor = analyzeCryptoExecutor ?? throw new ArgumentNullException(nameof(analyzeCryptoExecutor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// 执行基于用户需求的AI投资分析(使用工作流) + /// + public async Task AnalyzeUserRequirementAsync( + InvestmentRecommendationRequest request, + CancellationToken cancellationToken = default) + { + var workflowRequest = new InvestmentSelectionWorkflowRequest + { + MarketType = request.MarketType, + IsNewsAnalysis = false, + Content = request.UserRequirements, + RiskPreference = request.RiskPreference, + InvestmentAmount = request.InvestmentAmount, + InvestmentHorizon = request.InvestmentHorizon, + PreferredSectors = request.PreferredSectors, + ExcludedSectors = request.ExcludedSectors, + MaxRecommendations = request.MaxRecommendations + }; + + return await ExecuteWorkflowAsync(workflowRequest, cancellationToken); + } + + /// + /// 执行基于新闻内容的AI投资分析(使用工作流) + /// + public async Task AnalyzeNewsHotspotAsync( + NewsBasedInvestmentRequest request, + CancellationToken cancellationToken = default) + { + var workflowRequest = new InvestmentSelectionWorkflowRequest + { + MarketType = request.MarketType, + IsNewsAnalysis = true, + Content = request.NewsContent, + MaxRecommendations = request.MaxRecommendations + }; + + return await ExecuteWorkflowAsync(workflowRequest, cancellationToken); + } + + /// + /// 执行完整的投资选择工作流(确定性三步骤) + /// 根据市场类型动态构建执行链 + /// + private async Task ExecuteWorkflowAsync( + InvestmentSelectionWorkflowRequest request, + CancellationToken cancellationToken) + { + _logger.LogInformation("开始执行投资选择工作流,市场类型: {MarketType},分析类型: {Type}", + request.MarketType, + request.IsNewsAnalysis ? "新闻热点" : "用户需求"); + + // 根据市场类型动态构建执行链 + WorkflowBuilder builder = request.MarketType switch + { + MarketType.AShare => new WorkflowBuilder(_generateStockCriteriaExecutor) + .AddEdge(_generateStockCriteriaExecutor, _screenTargetsExecutor) + .AddEdge(_screenTargetsExecutor, _analyzeStocksExecutor) + .WithOutputFrom(_analyzeStocksExecutor), + + MarketType.Crypto => new WorkflowBuilder(_generateCryptoCriteriaExecutor) + .AddEdge(_generateCryptoCriteriaExecutor, _screenTargetsExecutor) + .AddEdge(_screenTargetsExecutor, _analyzeCryptoExecutor) + .WithOutputFrom(_analyzeCryptoExecutor), + + _ => throw new NotSupportedException($"不支持的市场类型: {request.MarketType}") + }; + + var workflow = builder.Build(); + + // 执行工作流 + await using Run run = await InProcessExecution.RunAsync(workflow, request, runId: null, cancellationToken); + + InvestmentSelectionResult? finalResult = null; + + // 处理工作流事件 + foreach (WorkflowEvent evt in run.NewEvents) + { + switch (evt) + { + case ExecutorInvokedEvent executorInvoked: + _logger.LogInformation("步骤开始: {ExecutorId}", executorInvoked.ExecutorId); + break; + case ExecutorCompletedEvent executorComplete: + _logger.LogInformation("步骤完成: {ExecutorId}", executorComplete.ExecutorId); + break; + case WorkflowOutputEvent workflowOutput: + finalResult = workflowOutput.Data as InvestmentSelectionResult; + _logger.LogInformation("工作流完成,推荐数量: {Count}", + finalResult?.Recommendations?.Count ?? 0); + break; + case ExecutorFailedEvent executorFailed: + _logger.LogError("步骤失败: {ExecutorId}, 错误: {Error}", + executorFailed.ExecutorId, + executorFailed.Data.Message); + throw new FriendlyException(executorFailed.Data.Message); + } + } + + return finalResult ?? CreateDefaultResult("工作流未返回结果"); + } + + /// + /// 创建默认结果 + /// + private InvestmentSelectionResult CreateDefaultResult(string? problem = null) + { + return new InvestmentSelectionResult + { + Recommendations = new List(), + ConfidenceScore = 0, + AnalysisSummary = problem ?? "分析过程中遇到问题,请稍后重试。", + MarketEnvironmentAnalysis = "无可用分析", + InvestmentAdvice = "建议稍后重试", + RiskWarnings = new List { "系统异常,请联系技术支持" } + }; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed && disposing) + { + _disposed = true; + } + } +} + diff --git a/src/Agents/StockSelection/Models/WorkflowModels.cs b/src/Agents/InvestmentSelection/Models/WorkflowModels.cs similarity index 56% rename from src/Agents/StockSelection/Models/WorkflowModels.cs rename to src/Agents/InvestmentSelection/Models/WorkflowModels.cs index f3cfe5e..b6e2022 100644 --- a/src/Agents/StockSelection/Models/WorkflowModels.cs +++ b/src/Agents/InvestmentSelection/Models/WorkflowModels.cs @@ -1,12 +1,18 @@ -using MarketAssistant.Services.StockScreener.Models; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Applications.AssetScreener.Models; -namespace MarketAssistant.Agents.StockSelection.Models; +namespace MarketAssistant.Agents.InvestmentSelection.Models; /// /// 工作流请求模型(统一的输入) /// -public record StockSelectionWorkflowRequest +public record InvestmentSelectionWorkflowRequest { + /// + /// 市场类型 + /// + public MarketType MarketType { get; init; } = MarketType.AShare; + /// /// 是否为新闻分析(true=新闻分析,false=用户需求分析) /// @@ -23,39 +29,39 @@ public record StockSelectionWorkflowRequest } /// -/// 步骤1的输出:筛选条件生成结果 +/// 步骤1的输出:筛选条件生成结果(泛型支持不同市场) /// public record CriteriaGenerationResult { /// - /// 生成的筛选条件 + /// 生成的筛选条件(可以是 StockCriteria 或 CryptoCriteria) /// - public StockCriteria Criteria { get; init; } = new(); + public object Criteria { get; init; } = new(); /// /// 原始请求(用于传递到后续步骤) /// - public StockSelectionWorkflowRequest OriginalRequest { get; init; } = new(); + public InvestmentSelectionWorkflowRequest OriginalRequest { get; init; } = new(); } /// /// 步骤2的输出:筛选结果 /// -public record ScreeningResult +public record AssetScreeningResult { /// - /// 筛选得到的股票列表 + /// 筛选得到的资产列表 /// - public List ScreenedStocks { get; init; } = new(); + public List ScreenedAssets { get; init; } = new(); /// /// 使用的筛选条件 /// - public StockCriteria? Criteria { get; init; } + public object? Criteria { get; init; } /// /// 原始请求信息(用于步骤3分析) /// - public StockSelectionWorkflowRequest? OriginalRequest { get; init; } + public InvestmentSelectionWorkflowRequest? OriginalRequest { get; init; } } diff --git a/src/Agents/StockSelection/Executors/ScreenStocksExecutor.cs b/src/Agents/StockSelection/Executors/ScreenStocksExecutor.cs deleted file mode 100644 index 56c1e46..0000000 --- a/src/Agents/StockSelection/Executors/ScreenStocksExecutor.cs +++ /dev/null @@ -1,63 +0,0 @@ -using MarketAssistant.Agents.StockSelection.Models; -using MarketAssistant.Services.StockScreener; -using MarketAssistant.Services.StockScreener.Models; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.Logging; - -namespace MarketAssistant.Agents.StockSelection.Executors; - -/// -/// 步骤2: 执行股票筛选的 Executor(基于 Executor 模式) -/// 直接调用 StockScreenerService 进行股票筛选 -/// -public sealed class ScreenStocksExecutor : Executor -{ - private readonly StockScreenerService _stockScreenerService; - private readonly ILogger _logger; - - public ScreenStocksExecutor( - StockScreenerService stockScreenerService, - ILogger logger) : base("ScreenStocks") - { - _stockScreenerService = stockScreenerService ?? throw new ArgumentNullException(nameof(stockScreenerService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public override async ValueTask HandleAsync( - CriteriaGenerationResult input, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - _logger.LogInformation("[步骤2/3] 执行股票筛选"); - - try - { - if (input?.Criteria == null) - { - throw new ArgumentNullException(nameof(input), "筛选条件不能为空"); - } - - _logger.LogInformation("[步骤2/3] 筛选条件: 市场={Market}, 行业={Industry}, 条件数={Count}", - input.Criteria.Market, input.Criteria.Industry, input.Criteria.Criteria.Count); - - // 调用 StockScreenerService 执行筛选 - List stocks = await _stockScreenerService.ScreenStocksAsync(input.Criteria); - - _logger.LogInformation("[步骤2/3] 筛选完成,获得 {Count} 只股票", stocks.Count); - - // 返回筛选结果 - return new ScreeningResult - { - ScreenedStocks = stocks, - Criteria = input.Criteria, - OriginalRequest = input.OriginalRequest - }; - } - catch (Exception ex) - { - _logger.LogError(ex, "[步骤2/3] 股票筛选失败"); - throw new InvalidOperationException($"股票筛选失败: {ex.Message}", ex); - } - } -} - diff --git a/src/Agents/StockSelection/StockSelectionWorkflow.cs b/src/Agents/StockSelection/StockSelectionWorkflow.cs deleted file mode 100644 index bbc6f20..0000000 --- a/src/Agents/StockSelection/StockSelectionWorkflow.cs +++ /dev/null @@ -1,150 +0,0 @@ -using MarketAssistant.Agents.StockSelection.Executors; -using MarketAssistant.Agents.StockSelection.Models; -using MarketAssistant.Applications.StockSelection.Models; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.Logging; - -namespace MarketAssistant.Agents.StockSelection; - -/// -/// AI选股工作流,使用 Agent Framework Workflows 实现确定性三步骤流程 -/// 第1步: 生成筛选条件 → 第2步: 执行筛选 → 第3步: AI分析结果 -/// -public class StockSelectionWorkflow : IDisposable -{ - private readonly GenerateCriteriaExecutor _generateCriteriaExecutor; - private readonly ScreenStocksExecutor _screenStocksExecutor; - private readonly AnalyzeStocksExecutor _analyzeStocksExecutor; - private readonly ILogger _logger; - private bool _disposed = false; - - public StockSelectionWorkflow( - GenerateCriteriaExecutor generateCriteriaExecutor, - ScreenStocksExecutor screenStocksExecutor, - AnalyzeStocksExecutor analyzeStocksExecutor, - ILogger logger) - { - _generateCriteriaExecutor = generateCriteriaExecutor ?? throw new ArgumentNullException(nameof(generateCriteriaExecutor)); - _screenStocksExecutor = screenStocksExecutor ?? throw new ArgumentNullException(nameof(screenStocksExecutor)); - _analyzeStocksExecutor = analyzeStocksExecutor ?? throw new ArgumentNullException(nameof(analyzeStocksExecutor)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - /// 执行基于用户需求的AI选股分析(使用工作流) - /// - public async Task AnalyzeUserRequirementAsync( - StockRecommendationRequest request, - CancellationToken cancellationToken = default) - { - var workflowRequest = new StockSelectionWorkflowRequest - { - IsNewsAnalysis = false, - Content = request.UserRequirements, - RiskPreference = request.RiskPreference, - InvestmentAmount = request.InvestmentAmount, - InvestmentHorizon = request.InvestmentHorizon, - PreferredSectors = request.PreferredSectors, - ExcludedSectors = request.ExcludedSectors, - MaxRecommendations = request.MaxRecommendations - }; - - return await ExecuteWorkflowAsync(workflowRequest, cancellationToken); - } - - /// - /// 执行基于新闻内容的AI选股分析(使用工作流) - /// - public async Task AnalyzeNewsHotspotAsync( - NewsBasedSelectionRequest request, - CancellationToken cancellationToken = default) - { - var workflowRequest = new StockSelectionWorkflowRequest - { - IsNewsAnalysis = true, - Content = request.NewsContent, - MaxRecommendations = request.MaxRecommendations - }; - - return await ExecuteWorkflowAsync(workflowRequest, cancellationToken); - } - - /// - /// 执行完整的选股工作流(确定性三步骤) - /// - private async Task ExecuteWorkflowAsync( - StockSelectionWorkflowRequest request, - CancellationToken cancellationToken) - { - _logger.LogInformation("开始执行选股工作流,分析类型: {Type}", - request.IsNewsAnalysis ? "新闻热点" : "用户需求"); - - // 构建顺序工作流: 步骤1 → 步骤2 → 步骤3 - var builder = new WorkflowBuilder(_generateCriteriaExecutor); - builder - .AddEdge(_generateCriteriaExecutor, _screenStocksExecutor) - .AddEdge(_screenStocksExecutor, _analyzeStocksExecutor) - .WithOutputFrom(_analyzeStocksExecutor); - - var workflow = builder.Build(); - - // 执行工作流 - await using Run run = await InProcessExecution.RunAsync(workflow, request, runId: null, cancellationToken); - - StockSelectionResult? finalResult = null; - - // 处理工作流事件 - foreach (WorkflowEvent evt in run.NewEvents) - { - switch (evt) - { - case ExecutorInvokedEvent executorInvoked: - _logger.LogInformation("步骤开始: {ExecutorId}", executorInvoked.ExecutorId); - break; - case ExecutorCompletedEvent executorComplete: - _logger.LogInformation("步骤完成: {ExecutorId}", executorComplete.ExecutorId); - break; - case WorkflowOutputEvent workflowOutput: - finalResult = workflowOutput.Data as StockSelectionResult; - _logger.LogInformation("工作流完成,推荐股票数量: {Count}", - finalResult?.Recommendations?.Count ?? 0); - break; - case ExecutorFailedEvent executorFailed: - _logger.LogError("步骤失败: {ExecutorId}, 错误: {Error}", - executorFailed.ExecutorId, - executorFailed.Data.Message); - throw new FriendlyException(executorFailed.Data.Message); - } - } - - return finalResult ?? CreateDefaultResult("工作流未返回结果"); - } - - /// - /// 创建默认结果 - /// - private StockSelectionResult CreateDefaultResult(string? problem = null) - { - return new StockSelectionResult - { - Recommendations = new List(), - ConfidenceScore = 0, - AnalysisSummary = problem ?? "分析过程中遇到问题,请稍后重试。" - }; - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!_disposed && disposing) - { - _disposed = true; - } - } -} - diff --git a/src/Agents/Tools/StockBasicTools.cs b/src/Agents/Tools/AShare/AShareBasicTools.cs similarity index 78% rename from src/Agents/Tools/StockBasicTools.cs rename to src/Agents/Tools/AShare/AShareBasicTools.cs index 68b6921..b4c44c4 100644 --- a/src/Agents/Tools/StockBasicTools.cs +++ b/src/Agents/Tools/AShare/AShareBasicTools.cs @@ -1,34 +1,38 @@ using MarketAssistant.Agents.Plugins.Models; +using MarketAssistant.Agents.Tools.Abstractions; using MarketAssistant.Services.Settings; using Microsoft.Extensions.AI; using System.ComponentModel; +using System.Text.Json; -namespace MarketAssistant.Agents.Tools; +namespace MarketAssistant.Agents.Tools.AShare; -public sealed class StockBasicTools +/// +/// A股基础数据工具实现 +/// +public sealed class AShareBasicTools : IBasicDataTools { private readonly IHttpClientFactory _httpClientFactory; private readonly IUserSettingService _userSettingService; - public StockBasicTools(IHttpClientFactory httpClientFactory, IUserSettingService userSettingService) + public AShareBasicTools(IHttpClientFactory httpClientFactory, IUserSettingService userSettingService) { _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); } [Description("根据股票代码获取股票基本数据,包括实时行情、价格变动、市值等信息")] - public async Task GetStockInfoAsync([Description("股票代码")] string stockSymbol) + public async Task GetAssetInfoAsync([Description("股票代码")] string assetSymbol) { try { - var url = $"https://x-quote.cls.cn/quote/stock/basic?secu_code={stockSymbol}&fields=open_px,av_px,high_px,low_px,change,change_px,down_price,change_3,change_5,qrr,entrust_rate,tr,amp,TotalShares,mc,NetAssetPS,NonRestrictedShares,cmc,business_amount,business_balance,pe,ttm_pe,pb,secu_name,secu_code,trade_status,secu_type,preclose_px,up_price,last_px&app=CailianpressWeb&os=web&sv=8.4.6"; + var url = $"https://x-quote.cls.cn/quote/stock/basic?secu_code={assetSymbol}&fields=open_px,av_px,high_px,low_px,change,change_px,down_price,change_3,change_5,qrr,entrust_rate,tr,amp,TotalShares,mc,NetAssetPS,NonRestrictedShares,cmc,business_amount,business_balance,pe,ttm_pe,pb,secu_name,secu_code,trade_status,secu_type,preclose_px,up_price,last_px&app=CailianpressWeb&os=web&sv=8.4.6"; using var httpClient = _httpClientFactory.CreateClient(); var response = await httpClient.GetStringAsync(url); var jsonDocument = JsonDocument.Parse(response); - var stockPriceInfo = new StockQuoteInfo(); - + var stockPriceInfo = new AssetQuoteInfo(); var data = jsonDocument.RootElement.GetProperty("data"); stockPriceInfo.CurrentPrice = data.GetProperty("last_px").GetDecimal(); @@ -36,13 +40,13 @@ public async Task GetStockInfoAsync([Description("股票代码") stockPriceInfo.PercentageChange = data.GetProperty("change").GetDecimal(); stockPriceInfo.HighPrice = data.GetProperty("high_px").GetDecimal(); stockPriceInfo.LowPrice = data.GetProperty("low_px").GetDecimal(); - stockPriceInfo.Volume = data.GetProperty("business_amount").GetDecimal() / 10000; // 转换为万手 - stockPriceInfo.Amount = data.GetProperty("business_balance").GetDecimal() / 100000000; // 转换为亿 + stockPriceInfo.Volume = data.GetProperty("business_amount").GetDecimal() / 10000; + stockPriceInfo.Amount = data.GetProperty("business_balance").GetDecimal() / 100000000; stockPriceInfo.TurnoverRate = data.GetProperty("tr").GetDecimal(); stockPriceInfo.PercentageChange3Day = data.GetProperty("change_3").GetDecimal(); stockPriceInfo.PercentageChange5Day = data.GetProperty("change_5").GetDecimal(); stockPriceInfo.TotalShares = data.GetProperty("TotalShares").GetDecimal(); - stockPriceInfo.MarketCapitalization = data.GetProperty("mc").GetDecimal() / 100000000; // 转换为亿 + stockPriceInfo.MarketCapitalization = data.GetProperty("mc").GetDecimal() / 100000000; stockPriceInfo.SecurityName = data.GetProperty("secu_name").GetString() ?? string.Empty; stockPriceInfo.SecurityCode = data.GetProperty("secu_code").GetString() ?? string.Empty; stockPriceInfo.TradeStatus = data.GetProperty("trade_status").GetString() ?? string.Empty; @@ -55,7 +59,7 @@ public async Task GetStockInfoAsync([Description("股票代码") stockPriceInfo.PERatio = data.GetProperty("pe").GetDecimal(); stockPriceInfo.TTMPERatio = data.GetProperty("ttm_pe").GetDecimal(); stockPriceInfo.PBRatio = data.GetProperty("pb").GetDecimal(); - stockPriceInfo.CirculationMarketCap = data.GetProperty("cmc").GetDecimal() / 100000000; // 转换为亿 + stockPriceInfo.CirculationMarketCap = data.GetProperty("cmc").GetDecimal() / 100000000; stockPriceInfo.NonRestrictedShares = data.GetProperty("NonRestrictedShares").GetDecimal(); stockPriceInfo.NetAssetPerShare = data.GetProperty("NetAssetPS").GetDecimal(); stockPriceInfo.AveragePrice = data.GetProperty("av_px").GetDecimal(); @@ -71,21 +75,20 @@ public async Task GetStockInfoAsync([Description("股票代码") } [Description("根据股票代码获取上市公司基本面信息,包括公司简介、主营业务、所属行业等")] - public async Task GetStockCompanyInfoAsync([Description("股票代码")] string stockSymbol) + public async Task GetCompanyInfoAsync([Description("股票代码")] string assetSymbol) { try { - // 只保留 stockSymbol 中的数字部分 - stockSymbol = new string(stockSymbol.Where(char.IsDigit).ToArray()); + assetSymbol = new string(assetSymbol.Where(char.IsDigit).ToArray()); var token = _userSettingService.CurrentSetting.ZhiTuApiToken; - var url = $"https://api.zhituapi.com/hs/gs/gsjj/{stockSymbol}?token={token}"; + var url = $"https://api.zhituapi.com/hs/gs/gsjj/{assetSymbol}?token={token}"; using var httpClient = _httpClientFactory.CreateClient(); var response = await httpClient.GetStringAsync(url); - var info = JsonSerializer.Deserialize(response); + var info = JsonSerializer.Deserialize(response); - return info ?? throw new Exception("GetStockCompanyInfoAsync返回数据为空"); + return info ?? throw new Exception("GetCompanyInfoAsync返回数据为空"); } catch (Exception ex) { @@ -95,7 +98,12 @@ public async Task GetStockCompanyInfoAsync([Description("股 public IEnumerable GetFunctions() { - yield return AIFunctionFactory.Create(GetStockInfoAsync); - yield return AIFunctionFactory.Create(GetStockCompanyInfoAsync); + yield return AIFunctionFactory.Create(GetAssetInfoAsync); + yield return AIFunctionFactory.Create(GetCompanyInfoAsync); } } + + + + + diff --git a/src/Agents/Tools/StockFinancialTools.cs b/src/Agents/Tools/AShare/AShareFinancialTools.cs similarity index 85% rename from src/Agents/Tools/StockFinancialTools.cs rename to src/Agents/Tools/AShare/AShareFinancialTools.cs index 3da1051..fb47f33 100644 --- a/src/Agents/Tools/StockFinancialTools.cs +++ b/src/Agents/Tools/AShare/AShareFinancialTools.cs @@ -1,27 +1,32 @@ using MarketAssistant.Agents.Plugins.Models; +using MarketAssistant.Agents.Tools.Abstractions; using MarketAssistant.Services.Settings; using Microsoft.Extensions.AI; using System.ComponentModel; +using System.Text.Json; -namespace MarketAssistant.Agents.Tools; +namespace MarketAssistant.Agents.Tools.AShare; -public class StockFinancialTools +/// +/// A股财务数据工具实现 +/// +public sealed class AShareFinancialTools : IFinancialDataTools { private readonly IHttpClientFactory _httpClientFactory; private readonly IUserSettingService _userSettingService; - public StockFinancialTools(IHttpClientFactory httpClientFactory, IUserSettingService userSettingService) + public AShareFinancialTools(IHttpClientFactory httpClientFactory, IUserSettingService userSettingService) { _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); } [Description("获取上市公司资产负债表,默认返回最近2年的数据")] - public async Task> GetBalanceSheetAsync([Description("股票代码")] string stockSymbol) + public async Task> GetBalanceSheetAsync([Description("股票代码")] string assetSymbol) { try { - var stockCode = StockSymbolConverter.ToZhiTuFormat(stockSymbol); + var stockCode = StockSymbolConverter.ToZhiTuFormat(assetSymbol); var token = _userSettingService.CurrentSetting.ZhiTuApiToken; // 默认查询最近2年的数据(约8个季度) @@ -43,11 +48,11 @@ public async Task> GetBalanceSheetAsync([Description("股票 } [Description("获取上市公司利润表,默认返回最近2年的数据")] - public async Task> GetIncomeStatementAsync([Description("股票代码")] string stockSymbol) + public async Task> GetIncomeStatementAsync([Description("股票代码")] string assetSymbol) { try { - var stockCode = StockSymbolConverter.ToZhiTuFormat(stockSymbol); + var stockCode = StockSymbolConverter.ToZhiTuFormat(assetSymbol); var token = _userSettingService.CurrentSetting.ZhiTuApiToken; // 默认查询最近2年的数据(约8个季度) @@ -69,11 +74,11 @@ public async Task> GetIncomeStatementAsync([Description(" } [Description("获取上市公司现金流量表,默认返回最近2年的数据")] - public async Task> GetCashFlowStatementAsync([Description("股票代码")] string stockSymbol) + public async Task> GetCashFlowStatementAsync([Description("股票代码")] string assetSymbol) { try { - var stockCode = StockSymbolConverter.ToZhiTuFormat(stockSymbol); + var stockCode = StockSymbolConverter.ToZhiTuFormat(assetSymbol); var token = _userSettingService.CurrentSetting.ZhiTuApiToken; // 默认查询最近2年的数据(约8个季度) @@ -95,11 +100,11 @@ public async Task> GetCashFlowStatementAsync([Descriptio } [Description("获取上市公司财务主要指标,默认返回最近2年的数据")] - public async Task> GetFinancialRatiosAsync([Description("股票代码")] string stockSymbol) + public async Task> GetFinancialRatiosAsync([Description("股票代码")] string assetSymbol) { try { - var stockCode = StockSymbolConverter.ToZhiTuFormat(stockSymbol); + var stockCode = StockSymbolConverter.ToZhiTuFormat(assetSymbol); var token = _userSettingService.CurrentSetting.ZhiTuApiToken; // 默认查询最近2年的数据(约8个季度) @@ -121,11 +126,11 @@ public async Task> GetFinancialRatiosAsync([Description(" } [Description("获取上市公司股本结构,默认返回最近3年的变动记录")] - public async Task> GetCapitalStructureAsync([Description("股票代码")] string stockSymbol) + public async Task> GetCapitalStructureAsync([Description("股票代码")] string assetSymbol) { try { - var stockCode = StockSymbolConverter.ToZhiTuFormat(stockSymbol); + var stockCode = StockSymbolConverter.ToZhiTuFormat(assetSymbol); var token = _userSettingService.CurrentSetting.ZhiTuApiToken; // 股本变动相对较少,查询最近3年的变动记录 diff --git a/src/Agents/Tools/StockNewsTools.cs b/src/Agents/Tools/AShare/AShareNewsTools.cs similarity index 73% rename from src/Agents/Tools/StockNewsTools.cs rename to src/Agents/Tools/AShare/AShareNewsTools.cs index 49b94cb..f184596 100644 --- a/src/Agents/Tools/StockNewsTools.cs +++ b/src/Agents/Tools/AShare/AShareNewsTools.cs @@ -1,23 +1,31 @@ using MarketAssistant.Agents.Plugins.Models; +using MarketAssistant.Agents.Tools.Abstractions; using MarketAssistant.Infrastructure.Factories; using MarketAssistant.Services.Browser; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; using Microsoft.Playwright; using System.ComponentModel; -namespace MarketAssistant.Agents.Tools; +namespace MarketAssistant.Agents.Tools.AShare; -public class StockNewsTools +/// +/// A股新闻数据工具实现 +/// +public sealed class AShareNewsTools : INewsDataTools { private readonly PlaywrightService _playwrightService; private readonly IChatClientFactory _chatClientFactory; + private readonly ILogger _logger; - public StockNewsTools( + public AShareNewsTools( PlaywrightService playwrightService, - IChatClientFactory chatClientFactory) + IChatClientFactory chatClientFactory, + ILogger logger) { _playwrightService = playwrightService ?? throw new ArgumentNullException(nameof(playwrightService)); _chatClientFactory = chatClientFactory ?? throw new ArgumentNullException(nameof(chatClientFactory)); + _logger = logger; } /// @@ -81,16 +89,13 @@ 3. 仅返回正文文本内容,不需要其他信息 /// /// 获取指定股票新闻列表(标题/来源/链接) /// - /// - /// - /// - private async Task> GetNewsListAsync(string stockSymbol) + private async Task> GetNewsListAsync(string assetSymbol) { try { - stockSymbol = StockSymbolConverter.ToClsFormat(stockSymbol).ToLower(); + assetSymbol = StockSymbolConverter.ToClsFormat(assetSymbol).ToLower(); - var url = $"https://www.cls.cn/stock?code={stockSymbol}"; + var url = $"https://www.cls.cn/stock?code={assetSymbol}"; // 使用PlaywrightService获取Browser实例 return await _playwrightService.ExecuteWithPageAsync(async page => @@ -144,54 +149,28 @@ private async Task> GetNewsListAsync(string stockSymbol) /// 获取指定股票的聚合新闻上下文(Tool Function) /// [Description("获取指定股票的聚合新闻上下文,一次返回最近且相关的新闻要点。默认返回精简要点,可通过 response_format 控制详细程度。")] - public async Task> GetStockNewsContextAsync( - [Description("股票代码")] string stockSymbol, - [Description("返回的新闻条数上限,默认 5,建议 1-10")] int topK = 5, - [Description("响应格式:concise | detailed。默认 concise 更省 token")] string responseFormat = "concise") + public async Task> GetNewsAsync( + [Description("股票代码")] string assetSymbol, + [Description("返回的新闻条数上限,默认 5,建议 1-10")] int count = 5) { try { - topK = Math.Clamp(topK, 1, 10); + count = Math.Clamp(count, 1, 10); - var list = await GetNewsListAsync(stockSymbol); - var results = list.Take(topK).ToList(); - - bool isDetailed = string.Equals(responseFormat, "detailed", StringComparison.OrdinalIgnoreCase); - - if (isDetailed) - { - foreach (var item in results) - { - try - { - var content = await GetNewsContentAsync(item.Link); - - // 简单截断作为摘要片段,避免长文本占用上下文 - if (!string.IsNullOrWhiteSpace(content)) - { - var normalized = content.Trim(); - item.Summary = normalized.Length <= 500 ? normalized : normalized.Substring(0, 500); - } - } - catch - { - // 单条失败不影响整体,保持 Summary 为空 - } - } - } + var list = await GetNewsListAsync(assetSymbol); + var results = list.Take(count).ToList(); return results; } catch (Exception ex) { // 以可读错误提示帮助代理调整调用策略 - throw new Exception($"获取聚合新闻上下文失败: {ex.Message}. 可尝试:降低 topK、将 response_format 设为 'concise' 或缩小时间范围。", ex); + throw new Exception($"获取聚合新闻上下文失败: {ex.Message}. 可尝试:降低 count 或缩小时间范围。", ex); } } public IEnumerable GetFunctions() { - yield return AIFunctionFactory.Create(GetStockNewsContextAsync); + yield return AIFunctionFactory.Create(GetNewsAsync); } } - diff --git a/src/Agents/Tools/AShare/AShareSentimentTools.cs b/src/Agents/Tools/AShare/AShareSentimentTools.cs new file mode 100644 index 0000000..b257a07 --- /dev/null +++ b/src/Agents/Tools/AShare/AShareSentimentTools.cs @@ -0,0 +1,49 @@ +using MarketAssistant.Agents.Plugins.Models; +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.AI; +using System.ComponentModel; +using System.Text.Json; + +namespace MarketAssistant.Agents.Tools.AShare; + +/// +/// A股市场情绪工具实现(从MarketSentimentTools迁移) +/// +public sealed class AShareSentimentTools : ISentimentDataTools +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IUserSettingService _userSettingService; + + public AShareSentimentTools(IHttpClientFactory httpClientFactory, IUserSettingService userSettingService) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); + } + + [Description("根据股票代码获取资金流向数据")] + public async Task GetFundFlowAsync([Description("股票代码")] string assetSymbol) + { + // 迁移自MarketSentimentTools的实现 + assetSymbol = new string(assetSymbol.Where(char.IsDigit).ToArray()); + var token = _userSettingService.CurrentSetting.ZhiTuApiToken; + var url = $"https://api.zhituapi.com/hs/zjlx/{assetSymbol}?token={token}"; + + using var httpClient = _httpClientFactory.CreateClient(); + var response = await httpClient.GetStringAsync(url); + var fundFlow = JsonSerializer.Deserialize(response); + + return fundFlow ?? throw new Exception("GetFundFlowAsync返回数据为空"); + } + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(GetFundFlowAsync); + } +} + + + + + + diff --git a/src/Agents/Tools/AShare/AShareTechnicalTools.cs b/src/Agents/Tools/AShare/AShareTechnicalTools.cs new file mode 100644 index 0000000..3813322 --- /dev/null +++ b/src/Agents/Tools/AShare/AShareTechnicalTools.cs @@ -0,0 +1,61 @@ +using MarketAssistant.Agents.Plugins.Models; +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.AI; +using System.ComponentModel; +using System.Text.Json; + +namespace MarketAssistant.Agents.Tools.AShare; + +/// +/// A股技术分析工具实现 +/// +public sealed class AShareTechnicalTools : ITechnicalDataTools +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IUserSettingService _userSettingService; + + public AShareTechnicalTools(IHttpClientFactory httpClientFactory, IUserSettingService userSettingService) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); + } + + private async Task GetIndicatorAsync(string indicator, string assetSymbol) + { + var token = _userSettingService.CurrentSetting.ZhiTuApiToken; + var url = $"https://api.zhituapi.com/hs/history/{indicator}/{StockSymbolConverter.ToZhiTuFormat(assetSymbol)}/d/n?token={token}<=30"; + using var httpClient = _httpClientFactory.CreateClient(); + var response = await httpClient.GetStringAsync(url); + var items = JsonSerializer.Deserialize>(response); + + if (items == null || !items.Any()) + throw new Exception($"获取{indicator.ToUpper()}数据失败: 返回数据为空或无有效数据"); + + return items.Last(); + } + + [Description("获取近30日最新日线KDJ")] + public Task GetKDJAsync([Description("股票代码")] string assetSymbol) + => GetIndicatorAsync("kdj", assetSymbol); + + [Description("获取近30日最新日线MACD")] + public Task GetMACDAsync([Description("股票代码")] string assetSymbol) + => GetIndicatorAsync("macd", assetSymbol); + + [Description("获取近30日最新日线BOLL")] + public Task GetBOLLAsync([Description("股票代码")] string assetSymbol) + => GetIndicatorAsync("boll", assetSymbol); + + [Description("获取近30日最新日线MA")] + public Task GetMAAsync([Description("股票代码")] string assetSymbol) + => GetIndicatorAsync("ma", assetSymbol); + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(GetKDJAsync); + yield return AIFunctionFactory.Create(GetMACDAsync); + yield return AIFunctionFactory.Create(GetBOLLAsync); + yield return AIFunctionFactory.Create(GetMAAsync); + } +} diff --git a/src/Agents/Tools/Abstractions/IBasicDataTools.cs b/src/Agents/Tools/Abstractions/IBasicDataTools.cs new file mode 100644 index 0000000..5dd820b --- /dev/null +++ b/src/Agents/Tools/Abstractions/IBasicDataTools.cs @@ -0,0 +1,30 @@ +using MarketAssistant.Agents.Plugins.Models; +using Microsoft.Extensions.AI; + +namespace MarketAssistant.Agents.Tools.Abstractions; + +/// +/// 基础数据工具接口 +/// +public interface IBasicDataTools +{ + /// + /// 根据资产代码获取基本数据,包括实时行情、价格变动、市值等信息 + /// + Task GetAssetInfoAsync(string assetSymbol); + + /// + /// 根据资产代码获取公司/项目基本面信息,包括简介、主营业务、所属行业等 + /// + Task GetCompanyInfoAsync(string assetSymbol); + + /// + /// 获取AI工具函数列表 + /// + IEnumerable GetFunctions(); +} + + + + + diff --git a/src/Agents/Tools/Abstractions/IFinancialDataTools.cs b/src/Agents/Tools/Abstractions/IFinancialDataTools.cs new file mode 100644 index 0000000..8a4f457 --- /dev/null +++ b/src/Agents/Tools/Abstractions/IFinancialDataTools.cs @@ -0,0 +1,45 @@ +using MarketAssistant.Agents.Plugins.Models; +using Microsoft.Extensions.AI; + +namespace MarketAssistant.Agents.Tools.Abstractions; + +/// +/// 财务数据工具接口 +/// +public interface IFinancialDataTools +{ + /// + /// 获取资产负债表 + /// + Task> GetBalanceSheetAsync(string assetSymbol); + + /// + /// 获取利润表 + /// + Task> GetIncomeStatementAsync(string assetSymbol); + + /// + /// 获取现金流量表 + /// + Task> GetCashFlowStatementAsync(string assetSymbol); + + /// + /// 获取财务主要指标 + /// + Task> GetFinancialRatiosAsync(string assetSymbol); + + /// + /// 获取股本结构 + /// + Task> GetCapitalStructureAsync(string assetSymbol); + + /// + /// 获取AI工具函数列表 + /// + IEnumerable GetFunctions(); +} + + + + + diff --git a/src/Agents/Tools/Abstractions/INewsDataTools.cs b/src/Agents/Tools/Abstractions/INewsDataTools.cs new file mode 100644 index 0000000..ef7f851 --- /dev/null +++ b/src/Agents/Tools/Abstractions/INewsDataTools.cs @@ -0,0 +1,26 @@ +using MarketAssistant.Agents.Plugins.Models; +using Microsoft.Extensions.AI; + +namespace MarketAssistant.Agents.Tools.Abstractions; + +/// +/// 新闻数据工具接口 +/// +public interface INewsDataTools +{ + /// + /// 获取资产相关的新闻(对于A股从财联社等获取,对于虚拟币从Twitter/X获取) + /// + Task> GetNewsAsync(string assetSymbol, int count = 10); + + /// + /// 获取AI工具函数列表 + /// + IEnumerable GetFunctions(); +} + + + + + + diff --git a/src/Agents/Tools/Abstractions/ISentimentDataTools.cs b/src/Agents/Tools/Abstractions/ISentimentDataTools.cs new file mode 100644 index 0000000..d704f24 --- /dev/null +++ b/src/Agents/Tools/Abstractions/ISentimentDataTools.cs @@ -0,0 +1,26 @@ +using MarketAssistant.Agents.Plugins.Models; +using Microsoft.Extensions.AI; + +namespace MarketAssistant.Agents.Tools.Abstractions; + +/// +/// 市场情绪数据工具接口 +/// +public interface ISentimentDataTools +{ + /// + /// 获取资金流向数据 + /// + Task GetFundFlowAsync(string assetSymbol); + + /// + /// 获取AI工具函数列表 + /// + IEnumerable GetFunctions(); +} + + + + + + diff --git a/src/Agents/Tools/Abstractions/ITechnicalDataTools.cs b/src/Agents/Tools/Abstractions/ITechnicalDataTools.cs new file mode 100644 index 0000000..57d7431 --- /dev/null +++ b/src/Agents/Tools/Abstractions/ITechnicalDataTools.cs @@ -0,0 +1,40 @@ +using MarketAssistant.Agents.Plugins.Models; +using Microsoft.Extensions.AI; + +namespace MarketAssistant.Agents.Tools.Abstractions; + +/// +/// 技术分析数据工具接口 +/// +public interface ITechnicalDataTools +{ + /// + /// 获取KDJ技术指标 + /// + Task GetKDJAsync(string assetSymbol); + + /// + /// 获取MACD技术指标 + /// + Task GetMACDAsync(string assetSymbol); + + /// + /// 获取BOLL技术指标 + /// + Task GetBOLLAsync(string assetSymbol); + + /// + /// 获取MA技术指标 + /// + Task GetMAAsync(string assetSymbol); + + /// + /// 获取AI工具函数列表 + /// + IEnumerable GetFunctions(); +} + + + + + diff --git a/src/Agents/Tools/Crypto/CryptoBasicTools.cs b/src/Agents/Tools/Crypto/CryptoBasicTools.cs new file mode 100644 index 0000000..9cfd0bf --- /dev/null +++ b/src/Agents/Tools/Crypto/CryptoBasicTools.cs @@ -0,0 +1,404 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using MarketAssistant.Agents.Plugins.Models; +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Agents.Tools.Crypto; + +/// +/// 虚拟币基础数据工具实现 +/// +public sealed class CryptoBasicTools : IBasicDataTools +{ + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private readonly IUserSettingService _userSettingService; + private const string BINANCE_API_BASE_URL = "https://api.binance.com/api/v3"; + + public CryptoBasicTools(ILogger logger, IUserSettingService userSettingService) + { + _logger = logger; + _userSettingService = userSettingService; + _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + } + + /// + /// 根据资产代码获取基本数据(使用币安 API) + /// + public async Task GetAssetInfoAsync(string assetSymbol) + { + try + { + // 格式化交易对符号(如 "BTC" -> "BTCUSDT") + var symbol = FormatSymbol(assetSymbol); + var url = $"{BINANCE_API_BASE_URL}/ticker/24hr?symbol={symbol}"; + + _logger.LogInformation("正在获取虚拟币行情数据: {Symbol}", symbol); + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + var tickerData = JsonSerializer.Deserialize(content); + + if (tickerData == null) + { + throw new InvalidOperationException($"无法解析币安API响应数据: {assetSymbol}"); + } + + // 映射到 AssetQuoteInfo 模型 + var quoteInfo = new AssetQuoteInfo + { + SecurityCode = assetSymbol.ToUpper(), + SecurityName = assetSymbol.ToUpper(), + SecurityType = "虚拟币", + TradeStatus = "交易中", + + // 价格信息 + CurrentPrice = tickerData.LastPrice, + OpenPrice = tickerData.OpenPrice, + HighPrice = tickerData.HighPrice, + LowPrice = tickerData.LowPrice, + PreviousClosePrice = tickerData.PrevClosePrice, + AveragePrice = tickerData.WeightedAvgPrice, + + // 涨跌信息 + PriceChange = tickerData.PriceChange, + PercentageChange = tickerData.PriceChangePercent, + Amplitude = CalculateAmplitude(tickerData.HighPrice, tickerData.LowPrice, tickerData.PrevClosePrice), + + // 交易量(币安单位:BTC 数量和 USDT 金额) + Volume = tickerData.Volume / 10000m, // 转换为万手(这里作为万个币) + Amount = tickerData.QuoteVolume / 100000000m, // 转换为亿 USDT + + // 虚拟币无以下字段,设为 0 + TurnoverRate = 0, + PercentageChange3Day = 0, + PercentageChange5Day = 0, + TotalShares = 0, + MarketCapitalization = 0, + UpLimitPrice = 0, + DownLimitPrice = 0, + PERatio = 0, + TTMPERatio = 0, + PBRatio = 0, + CirculationMarketCap = 0, + NonRestrictedShares = 0, + NetAssetPerShare = 0, + VolumeRatio = 0, + EntrustRatio = 0 + }; + + _logger.LogInformation("成功获取虚拟币行情: {Symbol}, 当前价: {Price}, 涨跌幅: {Change}%", + assetSymbol, quoteInfo.CurrentPrice, quoteInfo.PercentageChange); + + return quoteInfo; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "调用币安API获取行情失败: {Symbol}", assetSymbol); + throw new InvalidOperationException($"获取虚拟币行情失败: {assetSymbol},请检查网络连接或交易对是否正确", ex); + } + catch (Exception ex) + { + _logger.LogError(ex, "获取虚拟币行情时发生错误: {Symbol}", assetSymbol); + throw; + } + } + + /// + /// 格式化交易对符号(如 "BTC" -> "BTCUSDT") + /// + private string FormatSymbol(string symbol) + { + symbol = symbol.ToUpper().Trim(); + + // 如果已经包含 USDT,直接返回 + if (symbol.EndsWith("USDT")) + { + return symbol; + } + + // 否则添加 USDT 后缀 + return $"{symbol}USDT"; + } + + /// + /// 计算振幅(%) + /// + private decimal CalculateAmplitude(decimal high, decimal low, decimal prevClose) + { + if (prevClose == 0) return 0; + return ((high - low) / prevClose) * 100; + } + + #region 币安 API 响应模型 + + /// + /// 币安 24 小时行情数据模型 + /// + private class BinanceTicker24hr + { + public string Symbol { get; set; } = string.Empty; + public decimal PriceChange { get; set; } + public decimal PriceChangePercent { get; set; } + public decimal WeightedAvgPrice { get; set; } + public decimal PrevClosePrice { get; set; } + public decimal LastPrice { get; set; } + public decimal BidPrice { get; set; } + public decimal AskPrice { get; set; } + public decimal OpenPrice { get; set; } + public decimal HighPrice { get; set; } + public decimal LowPrice { get; set; } + public decimal Volume { get; set; } + public decimal QuoteVolume { get; set; } + public long OpenTime { get; set; } + public long CloseTime { get; set; } + public int Count { get; set; } + } + + #endregion + + /// + /// 根据资产代码获取公司/项目基本面信息(使用 CoinGecko API) + /// + public async Task GetCompanyInfoAsync(string assetSymbol) + { + try + { + // 将交易对符号映射到 CoinGecko ID + var coinId = MapSymbolToCoinGeckoId(assetSymbol); + var url = $"https://api.coingecko.com/api/v3/coins/{coinId}?localization=false&tickers=false&market_data=false&community_data=true&developer_data=true&sparkline=false"; + + _logger.LogInformation("正在获取虚拟币项目信息: {Symbol} ({CoinId})", assetSymbol, coinId); + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + var coinData = JsonSerializer.Deserialize(content); + + if (coinData == null) + { + throw new InvalidOperationException($"无法解析 CoinGecko API 响应数据: {assetSymbol}"); + } + + // 映射到 CompanyInfo 模型 + var companyInfo = new CompanyInfo + { + // 基础信息 + Name = coinData.Name ?? assetSymbol.ToUpper(), + EName = coinData.Symbol?.ToUpper() ?? assetSymbol.ToUpper(), + Market = "虚拟币市场", + + // 项目信息 + Description = CleanHtmlTags(coinData.Description?.En ?? "暂无描述"), + Concept = string.Join(", ", coinData.Categories ?? new List()), + BusinessScope = coinData.Description?.En != null + ? TruncateText(CleanHtmlTags(coinData.Description.En), 200) + : "暂无经营范围描述", + + // 网站信息 + Website = coinData.Links?.Homepage?.FirstOrDefault(x => !string.IsNullOrEmpty(x)) ?? "", + InfoWebsite = coinData.Links?.BlockchainSite?.FirstOrDefault(x => !string.IsNullOrEmpty(x)) ?? "", + + // 社区数据(映射到联系方式字段) + CompanyPhone = coinData.CommunityData != null + ? $"Twitter: {coinData.CommunityData.TwitterFollowers:N0} 关注者" + : "", + Email = coinData.CommunityData != null + ? $"Reddit: {coinData.CommunityData.RedditSubscribers:N0} 订阅者" + : "", + + // 开发者数据(映射到发行信息字段) + Underwriter = coinData.Links?.ReposUrl?.GitHub?.FirstOrDefault() ?? "", + RegisteredCapital = coinData.DeveloperData != null + ? $"GitHub Stars: {coinData.DeveloperData.Stars:N0}" + : "", + + // 成立日期 + EstablishmentDate = coinData.GenesisDate ?? "未知", + ListingDate = coinData.GenesisDate ?? "未知", + + // 其他字段保持默认值(虚拟币不适用) + InstitutionType = "加密货币项目", + Organization = coinData.Categories?.FirstOrDefault() ?? "数字货币" + }; + + _logger.LogInformation("成功获取虚拟币项目信息: {Name} ({Symbol})", coinData.Name, assetSymbol); + + return companyInfo; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "调用 CoinGecko API 获取项目信息失败: {Symbol}", assetSymbol); + throw new InvalidOperationException($"获取虚拟币项目信息失败: {assetSymbol},请检查网络连接或币种代码是否正确", ex); + } + catch (Exception ex) + { + _logger.LogError(ex, "获取虚拟币项目信息时发生错误: {Symbol}", assetSymbol); + throw; + } + } + + /// + /// 将交易对符号映射到 CoinGecko ID + /// + private string MapSymbolToCoinGeckoId(string symbol) + { + symbol = symbol.ToLower().Replace("usdt", "").Trim(); + + // 常见币种映射表 + var mapping = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "btc", "bitcoin" }, + { "eth", "ethereum" }, + { "bnb", "binancecoin" }, + { "xrp", "ripple" }, + { "ada", "cardano" }, + { "doge", "dogecoin" }, + { "sol", "solana" }, + { "dot", "polkadot" }, + { "matic", "matic-network" }, + { "avax", "avalanche-2" }, + { "link", "chainlink" }, + { "uni", "uniswap" }, + { "ltc", "litecoin" }, + { "etc", "ethereum-classic" }, + { "xlm", "stellar" }, + { "bch", "bitcoin-cash" }, + { "atom", "cosmos" }, + { "trx", "tron" }, + { "shib", "shiba-inu" }, + { "usdc", "usd-coin" }, + { "usdt", "tether" }, + { "dai", "dai" } + }; + + return mapping.TryGetValue(symbol, out var coinId) ? coinId : symbol; + } + + /// + /// 清理 HTML 标签 + /// + private string CleanHtmlTags(string html) + { + if (string.IsNullOrEmpty(html)) return ""; + + // 简单的 HTML 标签清理(可考虑使用 HtmlAgilityPack 库获得更好效果) + var text = System.Text.RegularExpressions.Regex.Replace(html, "<.*?>", " "); + text = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " "); + return text.Trim(); + } + + /// + /// 截断文本到指定长度 + /// + private string TruncateText(string text, int maxLength) + { + if (string.IsNullOrEmpty(text) || text.Length <= maxLength) return text; + return text.Substring(0, maxLength) + "..."; + } + + #region CoinGecko API 响应模型 + + /// + /// CoinGecko 币种详情响应模型 + /// + private class CoinGeckoResponse + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("symbol")] + public string Symbol { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public CoinDescription? Description { get; set; } + + [JsonPropertyName("links")] + public CoinLinks? Links { get; set; } + + [JsonPropertyName("categories")] + public List? Categories { get; set; } + + [JsonPropertyName("genesis_date")] + public string? GenesisDate { get; set; } + + [JsonPropertyName("community_data")] + public CommunityData? CommunityData { get; set; } + + [JsonPropertyName("developer_data")] + public DeveloperData? DeveloperData { get; set; } + } + + private class CoinDescription + { + [JsonPropertyName("en")] + public string? En { get; set; } + } + + private class CoinLinks + { + [JsonPropertyName("homepage")] + public List? Homepage { get; set; } + + [JsonPropertyName("blockchain_site")] + public List? BlockchainSite { get; set; } + + [JsonPropertyName("repos_url")] + public ReposUrl? ReposUrl { get; set; } + } + + private class ReposUrl + { + [JsonPropertyName("github")] + public List? GitHub { get; set; } + } + + private class CommunityData + { + [JsonPropertyName("twitter_followers")] + public int TwitterFollowers { get; set; } + + [JsonPropertyName("reddit_subscribers")] + public int RedditSubscribers { get; set; } + } + + private class DeveloperData + { + [JsonPropertyName("stars")] + public int Stars { get; set; } + + [JsonPropertyName("forks")] + public int Forks { get; set; } + + public List Repos + { + get + { + // 这里需要从 repos_url 中提取 + return new List(); + } + } + } + + #endregion + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(GetAssetInfoAsync); + yield return AIFunctionFactory.Create(GetCompanyInfoAsync); + } +} + + + + + diff --git a/src/Agents/Tools/Crypto/CryptoFinancialTools.cs b/src/Agents/Tools/Crypto/CryptoFinancialTools.cs new file mode 100644 index 0000000..de32f11 --- /dev/null +++ b/src/Agents/Tools/Crypto/CryptoFinancialTools.cs @@ -0,0 +1,160 @@ +using MarketAssistant.Agents.Plugins.Models; +using MarketAssistant.Agents.Tools.Abstractions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Agents.Tools.Crypto; + +/// +/// 虚拟币财务数据工具实现 +/// +public sealed class CryptoFinancialTools : IFinancialDataTools +{ + private readonly ILogger _logger; + + public CryptoFinancialTools(ILogger logger) + { + _logger = logger; + } + + /// + /// 获取资产负债表(虚拟币不适用) + /// + public Task> GetBalanceSheetAsync(string assetSymbol) + { + // 【不适用】虚拟币无传统财务报表概念 + // + // 💡 替代方案:提供链上数据(需区块链浏览器 API) + // + // 如需提供类似数据,可考虑: + // 1. 代币供应量数据(Total Supply, Circulating Supply) + // - CoinGecko API: GET /api/v3/coins/{id} + // - 返回:total_supply, circulating_supply, max_supply + // + // 2. 链上持币地址分布(Rich List) + // - Etherscan API (ETH/ERC20): GET /api?module=account&action=tokentx + // - BscScan API (BSC): 类似接口 + // - 需要各链的 API Key + // + // 3. 智能合约资金锁定量(TVL - Total Value Locked) + // - DeFi Llama API: GET /tvl/{protocol} + // - 适用于 DeFi 项目 + + _logger.LogWarning("虚拟币无资产负债表概念,此接口不适用"); + throw new NotImplementedException( + "虚拟币无传统资产负债表概念。\n" + + "替代方案:使用 CoinGecko API 获取代币供应量数据,或使用区块链浏览器 API 获取链上资金分布。" + ); + } + + /// + /// 获取利润表(虚拟币不适用) + /// + public Task> GetIncomeStatementAsync(string assetSymbol) + { + // 【不适用】虚拟币无利润表概念 + // + // 💡 替代方案:协议收入数据(仅适用于 DeFi 协议) + // + // - Token Terminal API: 提供协议收入、费用、P/S 比率等 + // https://tokenterminal.com/terminal/api + // - 示例数据: + // * Revenue(协议收入) + // * Protocol Earnings(协议净收入) + // * P/S Ratio(市销率) + // * P/E Ratio(市盈率,极少协议有) + + _logger.LogWarning("虚拟币无利润表概念,此接口不适用"); + throw new NotImplementedException( + "虚拟币无传统利润表概念。\n" + + "替代方案:对于 DeFi 协议,可使用 Token Terminal API 获取协议收入和费用数据。" + ); + } + + /// + /// 获取现金流量表(虚拟币不适用) + /// + public Task> GetCashFlowStatementAsync(string assetSymbol) + { + // 【不适用】虚拟币无现金流量表概念 + // + // 💡 替代方案:链上资金流动分析 + // + // - Glassnode API: 提供链上资金流动指标 + // https://docs.glassnode.com/ + // - 可提供数据: + // * Exchange Netflow(交易所净流入/流出) + // * Whale Transactions(大额交易) + // * Active Addresses(活跃地址数) + // * Transaction Volume(交易量) + + _logger.LogWarning("虚拟币无现金流量表概念,此接口不适用"); + throw new NotImplementedException( + "虚拟币无传统现金流量表概念。\n" + + "替代方案:使用 Glassnode API 获取链上资金流动指标(如交易所净流入、大额交易等)。" + ); + } + + /// + /// 获取财务主要指标(虚拟币不适用) + /// + public Task> GetFinancialRatiosAsync(string assetSymbol) + { + // 【不适用】虚拟币无传统财务指标 + // + // 💡 替代方案:链上指标和估值比率 + // + // 可提供的类似指标: + // 1. 市值/实现市值比(MVRV Ratio)- Glassnode + // 2. 网络价值/交易量比(NVT Ratio)- CoinMetrics + // 3. 活跃地址增长率 - Glassnode + // 4. 持币地址集中度 - Etherscan/BscScan + // 5. Staking 比率(适用于 PoS 币种)- Staking Rewards API + + _logger.LogWarning("虚拟币无传统财务指标概念,此接口不适用"); + throw new NotImplementedException( + "虚拟币无传统财务指标概念。\n" + + "替代方案:使用链上分析 API(如 Glassnode, CoinMetrics)获取 MVRV、NVT 等链上估值指标。" + ); + } + + /// + /// 获取股本结构(虚拟币不适用) + /// + public Task> GetCapitalStructureAsync(string assetSymbol) + { + // 【不适用】虚拟币无股本概念 + // + // 💡 替代方案:代币供应量和分配信息 + // + // - CoinGecko API: GET /api/v3/coins/{id} + // - 提供数据: + // * Total Supply(总供应量) + // * Circulating Supply(流通供应量) + // * Max Supply(最大供应量) + // + // - Messari API: 提供更详细的代币分配信息 + // https://messari.io/api/docs + // - 可获取: + // * Initial Distribution(初始分配) + // * Team/Investor Allocation(团队/投资者份额) + // * Vesting Schedule(解锁计划) + + _logger.LogWarning("虚拟币无股本结构概念,此接口不适用"); + throw new NotImplementedException( + "虚拟币无传统股本结构概念。\n" + + "替代方案:\n" + + "1. 使用 CoinGecko API 获取代币供应量数据(总量、流通量、最大量)\n" + + "2. 使用 Messari API 获取详细的代币分配和解锁计划信息" + ); + } + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(GetBalanceSheetAsync); + yield return AIFunctionFactory.Create(GetIncomeStatementAsync); + yield return AIFunctionFactory.Create(GetCashFlowStatementAsync); + yield return AIFunctionFactory.Create(GetFinancialRatiosAsync); + yield return AIFunctionFactory.Create(GetCapitalStructureAsync); + } +} diff --git a/src/Agents/Tools/Crypto/CryptoNewsTools.cs b/src/Agents/Tools/Crypto/CryptoNewsTools.cs new file mode 100644 index 0000000..cd9be44 --- /dev/null +++ b/src/Agents/Tools/Crypto/CryptoNewsTools.cs @@ -0,0 +1,165 @@ +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; +using MarketAssistant.Agents.Plugins.Models; +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Agents.Tools.Crypto; + +/// +/// 虚拟币新闻数据工具实现(使用 CryptoCompare API 免费模式) +/// +public sealed class CryptoNewsTools : INewsDataTools +{ + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private const string CRYPTOCOMPARE_API_BASE_URL = "https://min-api.cryptocompare.com/data/v2/news"; + + public CryptoNewsTools(ILogger logger) + { + _logger = logger; + _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + } + + /// + /// 获取虚拟币相关新闻(使用 CryptoCompare API) + /// + [Description("获取虚拟币相关的最新新闻")] + public async Task> GetNewsAsync( + [Description("虚拟币代码(如BTC、ETH)")] string assetSymbol, + int count = 10) + { + try + { + // 格式化币种代码 + var category = FormatSymbolForNews(assetSymbol); + + // 构建请求 URL(使用英文,给AI模型使用) + var url = $"{CRYPTOCOMPARE_API_BASE_URL}/?categories={category}&lang=EN&sortOrder=latest"; + + _logger.LogInformation("正在获取虚拟币新闻(AI Tools用): {Symbol} (category={Category})", assetSymbol, category); + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + var newsResponse = JsonSerializer.Deserialize(content); + + if (newsResponse?.Data == null || newsResponse.Data.Count == 0) + { + _logger.LogWarning("未找到虚拟币新闻: {Symbol}", assetSymbol); + return new List(); + } + + // 映射到 NewsItem 模型并限制数量 + var newsItems = newsResponse.Data + .Take(count) + .Select(article => new NewsItem + { + Title = article.Title ?? "无标题", + Source = article.Source ?? "未知来源", + Link = article.Url ?? "", + PublishTime = ConvertUnixTimestamp(article.PublishedOn), + Summary = TruncateText(article.Body ?? "", 300) + }) + .ToList(); + + _logger.LogInformation("成功获取虚拟币新闻: {Symbol}, 数量: {Count}", assetSymbol, newsItems.Count); + + return newsItems; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "调用 CryptoCompare API 获取新闻失败: {Symbol}", assetSymbol); + throw new InvalidOperationException($"获取虚拟币新闻失败: {assetSymbol},请检查网络连接", ex); + } + catch (Exception ex) + { + _logger.LogError(ex, "获取虚拟币新闻时发生错误: {Symbol}", assetSymbol); + throw; + } + } + + /// + /// 格式化币种代码为新闻分类 + /// + private string FormatSymbolForNews(string symbol) + { + // 移除 USDT 后缀,转大写 + return symbol.ToUpper().Replace("USDT", "").Trim(); + } + + /// + /// 转换 Unix 时间戳为本地时间字符串 + /// + private string ConvertUnixTimestamp(long unixTimestamp) + { + var dateTime = DateTimeOffset.FromUnixTimeSeconds(unixTimestamp).ToLocalTime(); + return dateTime.ToString("yyyy-MM-dd HH:mm:ss"); + } + + /// + /// 截断文本到指定长度 + /// + private string TruncateText(string text, int maxLength) + { + if (string.IsNullOrEmpty(text) || text.Length <= maxLength) return text; + return text.Substring(0, maxLength) + "..."; + } + + #region CryptoCompare API 响应模型 + + /// + /// CryptoCompare 新闻响应模型 + /// + private class CryptoCompareNewsResponse + { + [JsonPropertyName("Data")] + public List? Data { get; set; } + } + + /// + /// 新闻文章模型 + /// + private class NewsArticle + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("body")] + public string Body { get; set; } = string.Empty; + + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + [JsonPropertyName("source")] + public string Source { get; set; } = string.Empty; + + [JsonPropertyName("published_on")] + public long PublishedOn { get; set; } + + [JsonPropertyName("imageurl")] + public string? ImageUrl { get; set; } + + [JsonPropertyName("categories")] + public string? Categories { get; set; } + } + + #endregion + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(GetNewsAsync); + } +} + + + + + diff --git a/src/Agents/Tools/Crypto/CryptoSentimentTools.cs b/src/Agents/Tools/Crypto/CryptoSentimentTools.cs new file mode 100644 index 0000000..cf6bdb1 --- /dev/null +++ b/src/Agents/Tools/Crypto/CryptoSentimentTools.cs @@ -0,0 +1,120 @@ +using MarketAssistant.Agents.Plugins.Models; +using MarketAssistant.Agents.Tools.Abstractions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Agents.Tools.Crypto; + +/// +/// 虚拟币市场情绪工具实现 +/// +public sealed class CryptoSentimentTools : ISentimentDataTools +{ + private readonly ILogger _logger; + + public CryptoSentimentTools(ILogger logger) + { + _logger = logger; + } + + /// + /// 获取虚拟币市场情绪数据 + /// 注意:此功能需要币安 Futures API + 第三方情绪 API 组合实现 + /// + public Task GetFundFlowAsync(string assetSymbol) + { + // 【可部分实现】结合币安 API 和第三方情绪 API + // + // ✅ 币安 Futures API 提供的数据(可实现): + // + // 1. 资金费率(Funding Rate)- 反映多空情绪 + // - API: GET https://fapi.binance.com/fapi/v1/fundingRate?symbol={symbol} + // - 说明:正值表示多头支付空头(市场看多),负值相反 + // - 返回数据:fundingRate(资金费率), fundingTime(结算时间) + // + // 2. 多空持仓人数比 + // - API: GET https://fapi.binance.com/futures/data/globalLongShortAccountRatio + // - 参数:symbol={symbol}&period=5m (5m/15m/30m/1h/2h/4h/6h/12h/1d) + // - 返回:longAccount(多头人数比), shortAccount(空头人数比) + // + // 3. 大户多空持仓比 + // - API: GET https://fapi.binance.com/futures/data/topLongShortAccountRatio + // - 说明:大户(Top Trader)持仓情况,更有参考价值 + // + // 4. 多空持仓量比 + // - API: GET https://fapi.binance.com/futures/data/globalLongShortAccountRatio + // - 说明:实际持仓量(而非人数)的多空比 + // + // 5. 合约持仓量(Open Interest) + // - API: GET https://fapi.binance.com/fapi/v1/openInterest?symbol={symbol} + // - 说明:未平仓合约总量,反映市场活跃度 + // + // ⚠️ 第三方 API 提供的数据(需补充): + // + // 1. 恐慌贪婪指数(Fear & Greed Index) + // - API: GET https://api.alternative.me/fng/ + // - 免费,无需 API Key + // - 返回:value(0-100,0=极度恐慌,100=极度贪婪) + // - value_classification(文字描述:Extreme Fear, Fear, Neutral, Greed, Extreme Greed) + // + // 2. Twitter 情绪分析(需实现 NLP) + // - 方案 A:爬取 Twitter 推文 + 本地情感分析 + // - 方案 B:使用第三方情感分析 API(如 Google NLP, Azure Text Analytics) + // - 方案 C:使用 LunarCrush API(提供社交媒体情绪数据) + // + // 3. 爆仓数据(Liquidation Data) + // - 币安不直接提供,需要第三方平台如 CoinGlass + // - API: https://open-api.coinglass.com/public/v2/liquidation + // + // 📌 实现建议: + // + // 阶段 1(仅币安 API,可立即实现): + // - 获取资金费率(fundingRate) + // - 获取多空持仓人数比(longShortRatio) + // - 获取大户持仓比(topTraderRatio) + // - 获取合约持仓量(openInterest) + // - 映射到 FundFlow 模型(字段复用或扩展) + // + // 阶段 2(补充恐慌贪婪指数): + // - 调用 https://api.alternative.me/fng/ 获取指数 + // - 将指数值映射到情绪描述 + // + // 阶段 3(Twitter 情绪分析): + // - 复杂度高,可暂缓或使用第三方 API + // + // 🔧 FundFlow 模型适配建议: + // - FundFlow 原为 A 股设计(主力/超大单/大单/中单/小单流入流出) + // - 虚拟币无此概念,建议扩展模型或创建新模型(如 CryptoSentiment) + // - 可复用字段映射: + // * MainNetInflow -> 资金费率 + // * SuperLargeNetInflow -> 大户多头比例 + // * LargeNetInflow -> 多空持仓人数比 + // * MediumNetInflow -> 恐慌贪婪指数 + // * SmallNetInflow -> 合约持仓量变化 + + _logger.LogWarning("虚拟币市场情绪数据获取功能尚未实现,建议分阶段实现"); + throw new NotImplementedException( + "虚拟币市场情绪数据获取功能尚未实现。\n" + + "\n=== 可使用币安 Futures API 实现(优先级 P0)===\n" + + "1. 资金费率: GET /fapi/v1/fundingRate (反映多空情绪)\n" + + "2. 多空人数比: GET /futures/data/globalLongShortAccountRatio\n" + + "3. 大户持仓比: GET /futures/data/topLongShortAccountRatio\n" + + "4. 合约持仓量: GET /fapi/v1/openInterest\n" + + "\n=== 需第三方 API 补充(优先级 P1)===\n" + + "5. 恐慌贪婪指数: GET https://api.alternative.me/fng/ (免费)\n" + + "6. Twitter 情绪分析: 需 NLP 模型或第三方 API\n" + + "\n💡 建议先实现 1-4 项,使用币安 API 即可" + ); + } + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(GetFundFlowAsync); + } +} + + + + + + diff --git a/src/Agents/Tools/Crypto/CryptoTechnicalTools.cs b/src/Agents/Tools/Crypto/CryptoTechnicalTools.cs new file mode 100644 index 0000000..6ba6523 --- /dev/null +++ b/src/Agents/Tools/Crypto/CryptoTechnicalTools.cs @@ -0,0 +1,321 @@ +using System.ComponentModel; +using MarketAssistant.Agents.Plugins.Models; +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Applications.Charts; +using MarketAssistant.Applications.Charts.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Agents.Tools.Crypto; + +/// +/// 虚拟币技术分析工具实现(基于币安 K 线数据计算) +/// +public sealed class CryptoTechnicalTools : ITechnicalDataTools +{ + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + + public CryptoTechnicalTools(ILogger logger, IServiceProvider serviceProvider) + { + _logger = logger; + _serviceProvider = serviceProvider; + } + + [Description("获取近30日最新日线KDJ")] + public async Task GetKDJAsync([Description("虚拟币代码(如BTC、ETH)")] string assetSymbol) + { + try + { + var klineService = _serviceProvider.GetRequiredKeyedService(MarketType.Crypto); + var klineData = await klineService.GetKLineDataAsync(assetSymbol, KLineType.Daily, 30); + + if (klineData == null || klineData.Count < 9) + { + throw new InvalidOperationException($"K线数据不足,无法计算KDJ指标: {assetSymbol}"); + } + + var kdj = CalculateKDJ(klineData); + kdj.T = klineData.Last().Timestamp.ToString("yyyy-MM-dd"); + + _logger.LogInformation("成功计算虚拟币KDJ指标: {Symbol}, K={K}, D={D}, J={J}", + assetSymbol, kdj.K, kdj.D, kdj.J); + + return kdj; + } + catch (Exception ex) + { + _logger.LogError(ex, "计算虚拟币KDJ指标失败: {Symbol}", assetSymbol); + throw; + } + } + + [Description("获取近30日最新日线MACD")] + public async Task GetMACDAsync([Description("虚拟币代码(如BTC、ETH)")] string assetSymbol) + { + try + { + var klineService = _serviceProvider.GetRequiredKeyedService(MarketType.Crypto); + var klineData = await klineService.GetKLineDataAsync(assetSymbol, KLineType.Daily, 50); + + if (klineData == null || klineData.Count < 26) + { + throw new InvalidOperationException($"K线数据不足,无法计算MACD指标: {assetSymbol}"); + } + + var macd = CalculateMACD(klineData); + macd.T = klineData.Last().Timestamp.ToString("yyyy-MM-dd"); + + _logger.LogInformation("成功计算虚拟币MACD指标: {Symbol}, DIFF={Diff}, DEA={Dea}, MACD={Macd}", + assetSymbol, macd.Diff, macd.Dea, macd.Macd); + + return macd; + } + catch (Exception ex) + { + _logger.LogError(ex, "计算虚拟币MACD指标失败: {Symbol}", assetSymbol); + throw; + } + } + + [Description("获取近30日最新日线BOLL")] + public async Task GetBOLLAsync([Description("虚拟币代码(如BTC、ETH)")] string assetSymbol) + { + try + { + var klineService = _serviceProvider.GetRequiredKeyedService(MarketType.Crypto); + var klineData = await klineService.GetKLineDataAsync(assetSymbol, KLineType.Daily, 30); + + if (klineData == null || klineData.Count < 20) + { + throw new InvalidOperationException($"K线数据不足,无法计算BOLL指标: {assetSymbol}"); + } + + var boll = CalculateBOLL(klineData); + boll.T = klineData.Last().Timestamp.ToString("yyyy-MM-dd"); + + _logger.LogInformation("成功计算虚拟币BOLL指标: {Symbol}, 上轨={U}, 中轨={M}, 下轨={D}", + assetSymbol, boll.U, boll.M, boll.D); + + return boll; + } + catch (Exception ex) + { + _logger.LogError(ex, "计算虚拟币BOLL指标失败: {Symbol}", assetSymbol); + throw; + } + } + + [Description("获取近30日最新日线MA")] + public async Task GetMAAsync([Description("虚拟币代码(如BTC、ETH)")] string assetSymbol) + { + try + { + var klineService = _serviceProvider.GetRequiredKeyedService(MarketType.Crypto); + var klineData = await klineService.GetKLineDataAsync(assetSymbol, KLineType.Daily, 250); + + if (klineData == null || klineData.Count < 3) + { + throw new InvalidOperationException($"K线数据不足,无法计算MA指标: {assetSymbol}"); + } + + var ma = CalculateMA(klineData); + ma.T = klineData.Last().Timestamp.ToString("yyyy-MM-dd"); + + _logger.LogInformation("成功计算虚拟币MA指标: {Symbol}, MA5={MA5}, MA10={MA10}, MA20={MA20}", + assetSymbol, ma.MA5, ma.MA10, ma.MA20); + + return ma; + } + catch (Exception ex) + { + _logger.LogError(ex, "计算虚拟币MA指标失败: {Symbol}", assetSymbol); + throw; + } + } + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(GetKDJAsync); + yield return AIFunctionFactory.Create(GetMACDAsync); + yield return AIFunctionFactory.Create(GetBOLLAsync); + yield return AIFunctionFactory.Create(GetMAAsync); + } + + #region 技术指标计算方法 + + /// + /// 计算KDJ指标(RSV -> K -> D -> J) + /// + private TechnicalKDJ CalculateKDJ(List klineData, int period = 9) + { + var rsvList = new List(); + var kList = new List(); + var dList = new List(); + + for (int i = 0; i < klineData.Count; i++) + { + if (i < period - 1) + { + rsvList.Add(50); // 前N天RSV默认50 + continue; + } + + var recentData = klineData.Skip(i - period + 1).Take(period).ToList(); + var low = recentData.Min(x => x.Low); + var high = recentData.Max(x => x.High); + var close = klineData[i].Close; + + var rsv = high == low ? 50 : (close - low) / (high - low) * 100; + rsvList.Add(rsv); + } + + // 计算K值(K = 2/3 * 前一日K + 1/3 * RSV) + decimal prevK = 50; + for (int i = 0; i < rsvList.Count; i++) + { + var k = (2m / 3m) * prevK + (1m / 3m) * rsvList[i]; + kList.Add(k); + prevK = k; + } + + // 计算D值(D = 2/3 * 前一日D + 1/3 * K) + decimal prevD = 50; + for (int i = 0; i < kList.Count; i++) + { + var d = (2m / 3m) * prevD + (1m / 3m) * kList[i]; + dList.Add(d); + prevD = d; + } + + // 计算J值(J = 3K - 2D) + var lastK = kList.Last(); + var lastD = dList.Last(); + var lastJ = 3 * lastK - 2 * lastD; + + return new TechnicalKDJ + { + K = Math.Round(lastK, 2), + D = Math.Round(lastD, 2), + J = Math.Round(lastJ, 2) + }; + } + + /// + /// 计算MACD指标(EMA12, EMA26, DIFF, DEA, MACD) + /// + private TechnicalMACD CalculateMACD(List klineData) + { + var closePrices = klineData.Select(x => x.Close).ToList(); + + // 计算EMA12和EMA26 + var ema12 = CalculateEMA(closePrices, 12); + var ema26 = CalculateEMA(closePrices, 26); + + // 计算DIFF (EMA12 - EMA26) + var diffList = new List(); + for (int i = 0; i < ema12.Count; i++) + { + diffList.Add(ema12[i] - ema26[i]); + } + + // 计算DEA (DIFF的9日EMA) + var dea = CalculateEMA(diffList, 9); + + // 计算MACD柱 (DIFF - DEA) * 2 + var lastDiff = diffList.Last(); + var lastDea = dea.Last(); + var macdBar = (lastDiff - lastDea) * 2; + + return new TechnicalMACD + { + Ema12 = Math.Round(ema12.Last(), 2), + Ema26 = Math.Round(ema26.Last(), 2), + Diff = Math.Round(lastDiff, 2), + Dea = Math.Round(lastDea, 2), + Macd = Math.Round(macdBar, 2) + }; + } + + /// + /// 计算布林带指标(上轨、中轨、下轨) + /// + private TechnicalBoll CalculateBOLL(List klineData, int period = 20, decimal multiplier = 2) + { + var closePrices = klineData.TakeLast(period).Select(x => x.Close).ToList(); + + // 计算中轨(MA20) + var middle = closePrices.Average(); + + // 计算标准差 + var variance = closePrices.Sum(x => (x - middle) * (x - middle)) / period; + var stdDev = (decimal)Math.Sqrt((double)variance); + + // 计算上下轨 + var upper = middle + multiplier * stdDev; + var lower = middle - multiplier * stdDev; + + return new TechnicalBoll + { + U = Math.Round(upper, 2), + M = Math.Round(middle, 2), + D = Math.Round(lower, 2) + }; + } + + /// + /// 计算多周期MA指标 + /// + private TechnicalMA CalculateMA(List klineData) + { + var closePrices = klineData.Select(x => x.Close).ToList(); + + return new TechnicalMA + { + MA3 = CalculateSMA(closePrices, 3), + MA5 = CalculateSMA(closePrices, 5), + MA10 = CalculateSMA(closePrices, 10), + MA15 = CalculateSMA(closePrices, 15), + MA20 = CalculateSMA(closePrices, 20), + MA30 = CalculateSMA(closePrices, 30), + MA60 = CalculateSMA(closePrices, 60), + MA120 = CalculateSMA(closePrices, 120), + MA200 = CalculateSMA(closePrices, 200), + MA250 = CalculateSMA(closePrices, 250) + }; + } + + /// + /// 计算简单移动平均线(SMA) + /// + private decimal? CalculateSMA(List prices, int period) + { + if (prices.Count < period) return null; + return Math.Round(prices.TakeLast(period).Average(), 2); + } + + /// + /// 计算指数移动平均线(EMA) + /// + private List CalculateEMA(List prices, int period) + { + var emaList = new List(); + var multiplier = 2m / (period + 1); + + // 第一个EMA使用SMA + var sma = prices.Take(period).Average(); + emaList.Add(sma); + + // 后续EMA计算 + for (int i = period; i < prices.Count; i++) + { + var ema = (prices[i] - emaList.Last()) * multiplier + emaList.Last(); + emaList.Add(ema); + } + + return emaList; + } + + #endregion +} diff --git a/src/Agents/Tools/Crypto/README_CRYPTO_TOOLS.md b/src/Agents/Tools/Crypto/README_CRYPTO_TOOLS.md new file mode 100644 index 0000000..d7cc022 --- /dev/null +++ b/src/Agents/Tools/Crypto/README_CRYPTO_TOOLS.md @@ -0,0 +1,417 @@ +# 虚拟币市场 Tools 实现说明 + +本文档说明虚拟币市场在 `Tools/Abstractions` 下各抽象接口的实现情况和所需 API。 + +--- + +## 📊 实现状态总览 + +| 抽象接口 | 实现状态 | 使用的 API | 实现文件 | +|---------|---------|-----------|----------| +| **IBasicDataTools** | ✅ 部分实现 | 币安 API + 需补充 CoinGecko | `CryptoBasicTools.cs` | +| **ITechnicalDataTools** | ✅ 完全实现 | 币安 K线数据(本地计算) | `CryptoTechnicalTools.cs` | +| **INewsDataTools** | ⚠️ 未实现(已说明) | 需 CryptoCompare API | `CryptoNewsTools.cs` | +| **ISentimentDataTools** | ⚠️ 未实现(已说明) | 币安 Futures API + 第三方 | `CryptoSentimentTools.cs` | +| **IFinancialDataTools** | ❌ 不适用(已说明) | 虚拟币无财务报表概念 | `CryptoFinancialTools.cs` | + +--- + +## 1️⃣ IBasicDataTools - 基础数据工具 + +### ✅ 已实现:GetAssetInfoAsync + +**使用 API**: 币安 REST API + +``` +GET https://api.binance.com/api/v3/ticker/24hr?symbol={symbol} +``` + +**提供数据**: +- ✅ 当前价格(lastPrice) +- ✅ 24h 涨跌幅(priceChangePercent) +- ✅ 24h 最高/最低价(highPrice, lowPrice) +- ✅ 24h 交易量(volume, quoteVolume) +- ✅ 开盘价(openPrice) +- ✅ 昨收价(prevClosePrice) +- ✅ 加权平均价(weightedAvgPrice) + +**实现特点**: +- 自动格式化交易对符号(如 "BTC" → "BTCUSDT") +- 计算振幅(Amplitude) +- 映射到 `AssetQuoteInfo` 模型 +- 虚拟币不支持的字段(如市盈率、换手率)设为 0 + +**使用示例**: +```csharp +var basicTools = serviceProvider.GetRequiredKeyedService(MarketType.Crypto); +var quoteInfo = await basicTools.GetAssetInfoAsync("BTC"); +Console.WriteLine($"BTC 当前价: ${quoteInfo.CurrentPrice}, 涨跌幅: {quoteInfo.PercentageChange}%"); +``` + +--- + +### ⚠️ 未实现:GetCompanyInfoAsync + +**需要 API**: CoinGecko API(免费,无需 API Key) + +``` +GET https://api.coingecko.com/api/v3/coins/{id} +示例: https://api.coingecko.com/api/v3/coins/bitcoin +``` + +**可提供数据**: +- 项目描述(description.en) +- 官方网站(links.homepage) +- 区块链浏览器(links.blockchain_site) +- 源代码仓库(links.repos_url.github) +- 所属类别(categories: ["Layer 1", "DeFi", "NFT"]) +- 社区数据(twitter_followers, reddit_subscribers) +- 开发者数据(developer_data.stars, forks, commits) + +**实现步骤**: +1. 建立交易对符号到 CoinGecko ID 的映射表(如 BTC→bitcoin, ETH→ethereum) +2. 调用 `/api/v3/coins/{id}` 获取项目详情 +3. 映射到 `CompanyInfo` 模型: + - `Description`: 项目描述(description.en) + - `MainBusiness`: 主要应用场景(从 categories 提取) + - `Industry`: 所属类别(categories[0]) + +**替代方案**: CoinMarketCap API(需要 API Key,有免费额度) + +--- + +## 2️⃣ ITechnicalDataTools - 技术分析工具 + +### ✅ 已全部实现(基于币安 K 线数据本地计算) + +**数据源**: 复用已实现的 `CryptoKLineService` + +```csharp +// 通过 IKLineService 获取 K 线数据 +var klineService = serviceProvider.GetRequiredKeyedService(MarketType.Crypto); +var klineData = await klineService.GetKLineDataAsync("BTC", KLineType.Daily, 100); +``` + +#### ✅ GetKDJAsync - KDJ 随机指标 + +**计算方法**: +1. 计算 RSV (Raw Stochastic Value) +2. 计算 K 值: `K = 2/3 * 前一日K + 1/3 * RSV` +3. 计算 D 值: `D = 2/3 * 前一日D + 1/3 * K` +4. 计算 J 值: `J = 3K - 2D` + +**数据要求**: 至少 9 日 K 线数据 + +#### ✅ GetMACDAsync - MACD 指标 + +**计算方法**: +1. 计算 EMA12 和 EMA26 +2. 计算 DIFF: `EMA12 - EMA26` +3. 计算 DEA: DIFF 的 9 日 EMA +4. 计算 MACD 柱: `(DIFF - DEA) * 2` + +**数据要求**: 至少 26 日 K 线数据(推荐 50 日) + +#### ✅ GetBOLLAsync - 布林带指标 + +**计算方法**: +1. 计算中轨: 20 日 SMA +2. 计算标准差 +3. 计算上轨: `中轨 + 2 * 标准差` +4. 计算下轨: `中轨 - 2 * 标准差` + +**数据要求**: 至少 20 日 K 线数据 + +#### ✅ GetMAAsync - 移动平均线 + +**支持周期**: MA3, MA5, MA10, MA15, MA20, MA30, MA60, MA120, MA200, MA250 + +**计算方法**: 简单移动平均线(SMA) + +**数据要求**: 最多 250 日 K 线数据(根据需要的最大周期) + +**使用示例**: +```csharp +var technicalTools = serviceProvider.GetRequiredKeyedService(MarketType.Crypto); + +// 获取 KDJ 指标 +var kdj = await technicalTools.GetKDJAsync("BTC"); +Console.WriteLine($"KDJ: K={kdj.K}, D={kdj.D}, J={kdj.J}"); + +// 获取 MACD 指标 +var macd = await technicalTools.GetMACDAsync("ETH"); +Console.WriteLine($"MACD: DIFF={macd.Diff}, DEA={macd.Dea}, MACD={macd.Macd}"); + +// 获取布林带 +var boll = await technicalTools.GetBOLLAsync("BTC"); +Console.WriteLine($"BOLL: 上轨={boll.U}, 中轨={boll.M}, 下轨={boll.D}"); + +// 获取均线 +var ma = await technicalTools.GetMAAsync("BTC"); +Console.WriteLine($"MA: MA5={ma.MA5}, MA10={ma.MA10}, MA20={ma.MA20}"); +``` + +--- + +## 3️⃣ INewsDataTools - 新闻数据工具 + +### ⚠️ 未实现(已提供详细说明) + +**推荐 API**: CryptoCompare News API(免费,项目已集成) + +``` +GET https://min-api.cryptocompare.com/data/v2/news/?categories={symbol}&lang=EN&sortOrder=latest +示例: https://min-api.cryptocompare.com/data/v2/news/?categories=BTC&lang=EN +``` + +**提供数据**: +- ✓ 新闻标题(title) +- ✓ 新闻正文(body) +- ✓ 来源网站(source) +- ✓ 发布时间(published_on,Unix 时间戳) +- ✓ 新闻链接(url) +- ✓ 图片链接(imageurl) +- ✓ 分类标签(categories) + +**实现步骤**: +1. 从 `IUserSettingService` 获取 `CryptoCompareApiKey`(可选,无 Key 也可用但有限额) +2. 构建请求 URL: `GET /data/v2/news/?categories={symbol}&lang=EN&sortOrder=latest` +3. 解析 JSON 响应中的 Data 数组 +4. 映射到 `NewsItem` 模型: + - `Title`: 新闻标题(title) + - `Content`: 新闻正文(body,取前 300 字) + - `PublishTime`: 发布时间(从 published_on Unix 时间戳转换) + - `Source`: 来源(source) + - `Url`: 原文链接(url) +5. 按时间倒序排序,返回最新的 count 条 + +**性能建议**: 添加缓存机制(如 `IMemoryCache`),避免频繁请求 + +**替代方案**: +- CryptoPanic API(需注册免费 API Token) +- Playwright 爬取 Twitter/X(无需 API,但实现复杂度高) + +--- + +## 4️⃣ ISentimentDataTools - 市场情绪工具 + +### ⚠️ 未实现(已提供详细说明) + +**可分阶段实现**: + +### 阶段 1: 币安 Futures API(可立即实现,优先级 P0) + +#### 1. 资金费率(Funding Rate) + +``` +GET https://fapi.binance.com/fapi/v1/fundingRate?symbol={symbol} +``` + +**说明**: 反映多空情绪 +- 正值:多头支付空头(市场看多) +- 负值:空头支付多头(市场看空) + +#### 2. 多空持仓人数比 + +``` +GET https://fapi.binance.com/futures/data/globalLongShortAccountRatio?symbol={symbol}&period=5m +``` + +**参数**: period 可选 5m/15m/30m/1h/2h/4h/6h/12h/1d + +**返回**: `longAccount`(多头人数比), `shortAccount`(空头人数比) + +#### 3. 大户多空持仓比 + +``` +GET https://fapi.binance.com/futures/data/topLongShortAccountRatio?symbol={symbol}&period=5m +``` + +**说明**: Top Trader(大户)的持仓情况,更有参考价值 + +#### 4. 合约持仓量(Open Interest) + +``` +GET https://fapi.binance.com/fapi/v1/openInterest?symbol={symbol} +``` + +**说明**: 未平仓合约总量,反映市场活跃度 + +### 阶段 2: 恐慌贪婪指数(优先级 P1) + +**API**: Alternative.me Fear & Greed Index(免费,无需 API Key) + +``` +GET https://api.alternative.me/fng/ +``` + +**返回数据**: +- `value`: 0-100(0=极度恐慌,100=极度贪婪) +- `value_classification`: 文字描述(Extreme Fear, Fear, Neutral, Greed, Extreme Greed) + +### 阶段 3: Twitter 情绪分析(优先级 P2) + +**方案 A**: 爬取 Twitter 推文 + 本地情感分析模型 + +**方案 B**: 使用第三方情感分析 API(如 Google NLP, Azure Text Analytics) + +**方案 C**: 使用 LunarCrush API(提供社交媒体情绪数据) + +### 模型适配建议 + +`FundFlow` 模型原为 A 股设计(主力/超大单/大单/中单/小单流入流出),虚拟币无此概念。 + +**建议**: +- 扩展模型或创建新模型(如 `CryptoSentiment`) +- 或复用字段映射: + - `MainNetInflow` → 资金费率 + - `SuperLargeNetInflow` → 大户多头比例 + - `LargeNetInflow` → 多空持仓人数比 + - `MediumNetInflow` → 恐慌贪婪指数 + - `SmallNetInflow` → 合约持仓量变化 + +--- + +## 5️⃣ IFinancialDataTools - 财务数据工具 + +### ❌ 不适用(虚拟币无传统财务报表概念) + +虚拟币项目没有传统的资产负债表、利润表、现金流量表等财务报表。 + +### 可选替代方案 + +#### 代币供应量数据 + +**API**: CoinGecko API + +``` +GET https://api.coingecko.com/api/v3/coins/{id} +``` + +**提供数据**: +- `total_supply`: 总供应量 +- `circulating_supply`: 流通供应量 +- `max_supply`: 最大供应量 + +#### 协议收入数据(仅适用于 DeFi 协议) + +**API**: Token Terminal API + +**提供数据**: +- Revenue(协议收入) +- Protocol Earnings(协议净收入) +- P/S Ratio(市销率) +- P/E Ratio(市盈率,极少协议有) + +#### 链上资金流动分析 + +**API**: Glassnode API + +**可提供数据**: +- Exchange Netflow(交易所净流入/流出) +- Whale Transactions(大额交易) +- Active Addresses(活跃地址数) +- Transaction Volume(交易量) + +#### 链上估值指标 + +**API**: Glassnode, CoinMetrics + +**可提供指标**: +- MVRV Ratio(市值/实现市值比) +- NVT Ratio(网络价值/交易量比) +- 活跃地址增长率 +- 持币地址集中度 +- Staking 比率(适用于 PoS 币种) + +#### 代币分配和解锁信息 + +**API**: Messari API + +**可提供数据**: +- Initial Distribution(初始分配) +- Team/Investor Allocation(团队/投资者份额) +- Vesting Schedule(解锁计划) + +--- + +## 📦 依赖项总结 + +### 必需(已集成) +- ✅ 币安 API(K 线数据、24h 行情) +- ✅ .NET System.Text.Json(JSON 解析) +- ✅ Microsoft.Extensions.AI(AI Function 工厂) +- ✅ Microsoft.Extensions.Logging(日志记录) + +### 推荐补充(免费 API) +- ⚠️ CoinGecko API: 项目信息、代币供应量(免费,无需 Key) +- ⚠️ CryptoCompare API: 新闻数据(项目已集成,免费额度) +- ⚠️ Alternative.me API: 恐慌贪婪指数(免费,无需 Key) + +### 可选补充(需注册或付费) +- 🔧 币安 Futures API: 资金费率、多空持仓比(需 Futures 账户) +- 🔧 CoinMarketCap API: 项目信息(需 API Key,有免费额度) +- 🔧 Glassnode API: 链上数据(需订阅) +- 🔧 Token Terminal API: DeFi 协议收入(需订阅) +- 🔧 Messari API: 代币分配信息(需订阅) + +--- + +## 🚀 实现优先级建议 + +### P0(立即可用) +1. ✅ **IBasicDataTools.GetAssetInfoAsync** - 已实现 +2. ✅ **ITechnicalDataTools(所有方法)** - 已实现 + +### P1(推荐实现) +3. ⚠️ **IBasicDataTools.GetCompanyInfoAsync** - 需 CoinGecko API +4. ⚠️ **INewsDataTools.GetNewsAsync** - 需 CryptoCompare API + +### P2(可选实现) +5. ⚠️ **ISentimentDataTools.GetFundFlowAsync** - 需币安 Futures API + 第三方 + +### P3(暂不实现) +6. ❌ **IFinancialDataTools(所有方法)** - 虚拟币不适用 + +--- + +## 📝 使用注意事项 + +1. **API 限流**: + - 币安 API: 每 IP 每分钟 1200 次请求 + - CryptoCompare: 免费额度每秒 50 次请求 + - 建议添加缓存机制 + +2. **错误处理**: + - 网络异常处理 + - API 返回错误处理 + - 数据格式验证 + +3. **符号映射**: + - 币安使用交易对格式(如 BTCUSDT) + - CoinGecko 使用 ID 格式(如 bitcoin) + - 需要建立映射表 + +4. **数据精度**: + - 虚拟币价格精度通常为小数点后 2-8 位 + - 建议使用 `decimal` 类型避免精度损失 + +5. **时区处理**: + - 币安 API 返回 UTC 时间戳 + - 需要转换为本地时间显示 + +--- + +## 🔗 相关文档 + +- [币安 API 文档](https://developers.binance.com/docs/zh-CN/binance-spot-api-docs) +- [CoinGecko API 文档](https://www.coingecko.com/zh/api/documentation) +- [CryptoCompare API 文档](https://min-api.cryptocompare.com/) +- [Alternative.me Fear & Greed Index](https://alternative.me/crypto/fear-and-greed-index/) + +--- + +**编译状态**: ✅ 所有代码已通过编译验证(0 错误,仅项目既有警告) + +**最后更新**: 2025-01-04 + diff --git a/src/Agents/Tools/MarketSentimentTools.cs b/src/Agents/Tools/MarketSentimentTools.cs deleted file mode 100644 index aa89fc4..0000000 --- a/src/Agents/Tools/MarketSentimentTools.cs +++ /dev/null @@ -1,141 +0,0 @@ -using MarketAssistant.Agents.Plugins.Models; -using MarketAssistant.Services.Settings; -using Microsoft.Extensions.AI; -using System.ComponentModel; - -namespace MarketAssistant.Agents.Tools; - -public class MarketSentimentTools -{ - private readonly IHttpClientFactory _httpClientFactory; - private readonly IUserSettingService _userSettingService; - - public MarketSentimentTools(IHttpClientFactory httpClientFactory, IUserSettingService userSettingService) - { - _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); - _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); - } - - [Description("获取上市公司十大股东信息,默认返回最近1年的数据")] - public async Task> GetTopShareholdersAsync([Description("股票代码")] string stockSymbol) - { - try - { - var stockCode = StockSymbolConverter.ToZhiTuFormat(stockSymbol); - var token = _userSettingService.CurrentSetting.ZhiTuApiToken; - - // 十大股东数据量大(每期10个股东),只查询最近1年的数据 - var endDate = DateTime.Now.ToString("yyyyMMdd"); - var startDate = DateTime.Now.AddYears(-1).ToString("yyyyMMdd"); - - var url = $"https://api.zhituapi.com/hs/fin/topholder/{stockCode}?token={token}&st={startDate}&et={endDate}"; - - using var httpClient = _httpClientFactory.CreateClient(); - var response = await httpClient.GetStringAsync(url); - var topShareholders = JsonSerializer.Deserialize>(response); - - return topShareholders ?? new List(); - } - catch (Exception ex) - { - throw new Exception($"获取十大股东信息时发生错误: {ex.Message}", ex); - } - } - - [Description("获取上市公司十大流通股东信息,默认返回最近1年的数据")] - public async Task> GetTopCirculatingShareholdersAsync([Description("股票代码")] string stockSymbol) - { - try - { - var stockCode = StockSymbolConverter.ToZhiTuFormat(stockSymbol); - var token = _userSettingService.CurrentSetting.ZhiTuApiToken; - - // 十大流通股东数据量大(每期10个股东),只查询最近1年的数据 - var endDate = DateTime.Now.ToString("yyyyMMdd"); - var startDate = DateTime.Now.AddYears(-1).ToString("yyyyMMdd"); - - var url = $"https://api.zhituapi.com/hs/fin/flowholder/{stockCode}?token={token}&st={startDate}&et={endDate}"; - - using var httpClient = _httpClientFactory.CreateClient(); - var response = await httpClient.GetStringAsync(url); - var topCirculatingShareholders = JsonSerializer.Deserialize>(response); - - return topCirculatingShareholders ?? new List(); - } - catch (Exception ex) - { - throw new Exception($"获取十大流通股东信息时发生错误: {ex.Message}", ex); - } - } - - [Description("获取上市公司股东数变化,可用于分析筹码集中度和市场情绪,默认返回最近2年的数据")] - public async Task> GetShareholderCountAsync([Description("股票代码")] string stockSymbol) - { - try - { - var stockCode = StockSymbolConverter.ToZhiTuFormat(stockSymbol); - var token = _userSettingService.CurrentSetting.ZhiTuApiToken; - - // 查询最近2年的股东数变化,用于分析趋势 - var endDate = DateTime.Now.ToString("yyyyMMdd"); - var startDate = DateTime.Now.AddYears(-2).ToString("yyyyMMdd"); - - var url = $"https://api.zhituapi.com/hs/fin/hm/{stockCode}?token={token}&st={startDate}&et={endDate}"; - - using var httpClient = _httpClientFactory.CreateClient(); - var response = await httpClient.GetStringAsync(url); - var shareholderCounts = JsonSerializer.Deserialize>(response); - - return shareholderCounts ?? new List(); - } - catch (Exception ex) - { - throw new Exception($"获取公司股东数时发生错误: {ex.Message}", ex); - } - } - - [Description("获取股票资金流数据")] - public async Task GetFundFlowAsync([Description("股票代码")] string stockSymbol) - { - try - { - var url = $"https://x-quote.cls.cn/quote/stock/fundflow?secu_code={stockSymbol}&app=CailianpressWeb&os=web&sv=8.4.6"; - - using var httpClient = _httpClientFactory.CreateClient(); - var response = await httpClient.GetStringAsync(url); - var jsonDocument = JsonDocument.Parse(response); - - var fundFlow = new FundFlow(); - - var data = jsonDocument.RootElement.GetProperty("data"); - - fundFlow.MainFundIn = data.GetProperty("main_fund_in").GetInt64() / 10000f; // 转换为万元 - fundFlow.MainFundOut = data.GetProperty("main_fund_out").GetInt64() / 10000f; // 转换为万元 - fundFlow.MainFundDiff = data.GetProperty("main_fund_diff").GetInt64() / 10000f; // 转换为万元 - fundFlow.SuperFundDiff = data.GetProperty("super_fund_diff").GetInt64() / 10000f; // 转换为万元 - fundFlow.LargeFundDiff = data.GetProperty("large_fund_diff").GetInt64() / 10000f; // 转换为万元 - fundFlow.MediumFundDiff = data.GetProperty("medium_fund_diff").GetInt64() / 10000f; // 转换为万元 - fundFlow.LittleFundDiff = data.GetProperty("little_fund_diff").GetInt64() / 10000f; // 转换为万元 - fundFlow.MainFund3 = data.GetProperty("main_fund_3").GetInt64() / 10000f; // 转换为万元 - fundFlow.MainFund5 = data.GetProperty("main_fund_5").GetInt64() / 10000f; // 转换为万元 - fundFlow.MainFund10 = data.GetProperty("main_fund_10").GetInt64() / 10000f; // 转换为万元 - fundFlow.MainFund20 = data.GetProperty("main_fund_20").GetInt64() / 10000f; // 转换为万元 - fundFlow.Date = data.GetProperty("date").GetInt32(); // 解析日期 - - return fundFlow; - } - catch (Exception ex) - { - throw new Exception($"处理资金流数据时发生错误: {ex.Message}", ex); - } - } - - public IEnumerable GetFunctions() - { - yield return AIFunctionFactory.Create(GetTopShareholdersAsync); - yield return AIFunctionFactory.Create(GetTopCirculatingShareholdersAsync); - yield return AIFunctionFactory.Create(GetShareholderCountAsync); - yield return AIFunctionFactory.Create(GetFundFlowAsync); - } -} - diff --git a/src/Agents/Tools/Models/StockQuoteInfo.cs b/src/Agents/Tools/Models/AssetQuoteInfo.cs similarity index 98% rename from src/Agents/Tools/Models/StockQuoteInfo.cs rename to src/Agents/Tools/Models/AssetQuoteInfo.cs index 04a95ac..cd66482 100644 --- a/src/Agents/Tools/Models/StockQuoteInfo.cs +++ b/src/Agents/Tools/Models/AssetQuoteInfo.cs @@ -1,9 +1,9 @@ namespace MarketAssistant.Agents.Plugins.Models; /// -/// 股票行情数据模型 +/// 资产行情数据模型 /// -public class StockQuoteInfo +public class AssetQuoteInfo { /// /// 当前价格(元) @@ -155,3 +155,5 @@ public class StockQuoteInfo /// public decimal EntrustRatio { get; set; } } + + diff --git a/src/Agents/Tools/Models/StockCompanyInfo.cs b/src/Agents/Tools/Models/CompanyInfo.cs similarity index 99% rename from src/Agents/Tools/Models/StockCompanyInfo.cs rename to src/Agents/Tools/Models/CompanyInfo.cs index 762407b..e73e15c 100644 --- a/src/Agents/Tools/Models/StockCompanyInfo.cs +++ b/src/Agents/Tools/Models/CompanyInfo.cs @@ -3,9 +3,9 @@ namespace MarketAssistant.Agents.Plugins.Models; /// -/// 股票公司信息实体类 +/// 公司信息实体类 /// -public class StockCompanyInfo +public class CompanyInfo { // ================= 基础信息 ================= /// @@ -254,3 +254,5 @@ public class StockCompanyInfo [JsonPropertyName("pdate")] public string ProspectusDate { get; set; } = ""; } + + diff --git a/src/Agents/Tools/Models/StockFinancialData.cs b/src/Agents/Tools/Models/FinancialData.cs similarity index 99% rename from src/Agents/Tools/Models/StockFinancialData.cs rename to src/Agents/Tools/Models/FinancialData.cs index d3c2dbf..145caf5 100644 --- a/src/Agents/Tools/Models/StockFinancialData.cs +++ b/src/Agents/Tools/Models/FinancialData.cs @@ -3,9 +3,9 @@ namespace MarketAssistant.Agents.Plugins.Models; /// -/// 股票财务指标实体类 +/// 财务指标实体类 /// -public class StockFinancialData +public class FinancialData { /// /// 报告日期yyyy-MM-dd @@ -516,4 +516,6 @@ public class StockFinancialData /// [JsonPropertyName("ysk3")] public string Ysk3 { get; set; } = ""; -} \ No newline at end of file +} + + diff --git a/src/Agents/Tools/Models/StockBoll.cs b/src/Agents/Tools/Models/TechnicalBoll.cs similarity index 96% rename from src/Agents/Tools/Models/StockBoll.cs rename to src/Agents/Tools/Models/TechnicalBoll.cs index 458ac9b..ae39212 100644 --- a/src/Agents/Tools/Models/StockBoll.cs +++ b/src/Agents/Tools/Models/TechnicalBoll.cs @@ -2,7 +2,7 @@ namespace MarketAssistant.Agents.Plugins.Models; -public class StockBoll +public class TechnicalBoll { /// /// 交易时间,短分时级别格式为yyyy-MM-ddHH:mm:ss,日线级别为yyyy-MM-dd @@ -33,3 +33,5 @@ public class StockBoll /// public string Description => $"日期: {T}, 上轨(Upper): {U}, 中轨(Middle): {M}, 下轨(Lower): {D}"; } + + diff --git a/src/Agents/Tools/Models/StockKDJ.cs b/src/Agents/Tools/Models/TechnicalKDJ.cs similarity index 95% rename from src/Agents/Tools/Models/StockKDJ.cs rename to src/Agents/Tools/Models/TechnicalKDJ.cs index 3387948..f4ecf4b 100644 --- a/src/Agents/Tools/Models/StockKDJ.cs +++ b/src/Agents/Tools/Models/TechnicalKDJ.cs @@ -2,7 +2,7 @@ namespace MarketAssistant.Agents.Plugins.Models; -public class StockKDJ +public class TechnicalKDJ { /// /// 交易时间,短分时级别格式为yyyy-MM-dd HH:mm:ss,日线级别为yyyy-MM-dd @@ -28,3 +28,5 @@ public class StockKDJ [JsonPropertyName("j")] public decimal? J { get; set; } } + + diff --git a/src/Agents/Tools/Models/StockMA.cs b/src/Agents/Tools/Models/TechnicalMA.cs similarity index 98% rename from src/Agents/Tools/Models/StockMA.cs rename to src/Agents/Tools/Models/TechnicalMA.cs index 3d94abd..749362f 100644 --- a/src/Agents/Tools/Models/StockMA.cs +++ b/src/Agents/Tools/Models/TechnicalMA.cs @@ -2,7 +2,7 @@ namespace MarketAssistant.Agents.Plugins.Models; -public class StockMA +public class TechnicalMA { /// /// 交易时间,短分时级别格式为yyyy-MM-ddHH:mm:ss,日线级别为yyyy-MM-dd @@ -70,3 +70,5 @@ public class StockMA [JsonPropertyName("ma250")] public decimal? MA250 { get; set; } } + + diff --git a/src/Agents/Tools/Models/StockMACD.cs b/src/Agents/Tools/Models/TechnicalMACD.cs similarity index 96% rename from src/Agents/Tools/Models/StockMACD.cs rename to src/Agents/Tools/Models/TechnicalMACD.cs index 6926c4e..473ed80 100644 --- a/src/Agents/Tools/Models/StockMACD.cs +++ b/src/Agents/Tools/Models/TechnicalMACD.cs @@ -2,7 +2,7 @@ namespace MarketAssistant.Agents.Plugins.Models; -public class StockMACD +public class TechnicalMACD { /// /// 交易时间,短分时级别格式为yyyy-MM-ddHH:mm:ss,日线级别为yyyy-MM-dd @@ -40,3 +40,5 @@ public class StockMACD [JsonPropertyName("ema26")] public decimal Ema26 { get; set; } } + + diff --git a/src/Agents/Tools/StockTechnicalTools.cs b/src/Agents/Tools/StockTechnicalTools.cs deleted file mode 100644 index c976519..0000000 --- a/src/Agents/Tools/StockTechnicalTools.cs +++ /dev/null @@ -1,56 +0,0 @@ -using MarketAssistant.Agents.Plugins.Models; -using MarketAssistant.Services.Settings; -using Microsoft.Extensions.AI; -using System.ComponentModel; - -namespace MarketAssistant.Agents.Tools; - -public sealed class StockTechnicalTools -{ - private readonly IHttpClientFactory _httpClientFactory; - private readonly IUserSettingService _userSettingService; - - public StockTechnicalTools(IHttpClientFactory httpClientFactory, IUserSettingService userSettingService) - { - _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); - _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); - } - - private async Task GetStockIndicatorAsync(string indicator, string stockSymbol) - { - var token = _userSettingService.CurrentSetting.ZhiTuApiToken; - var url = $"https://api.zhituapi.com/hs/history/{indicator}/{StockSymbolConverter.ToZhiTuFormat(stockSymbol)}/d/n?token={token}<=30"; - using var httpClient = _httpClientFactory.CreateClient(); - var response = await httpClient.GetStringAsync(url); - var items = JsonSerializer.Deserialize>(response); - - if (items == null || !items.Any()) - throw new Exception($"获取{indicator.ToUpper()}数据失败: 返回数据为空或无有效数据"); - - return items.Last(); - } - - [Description("获取近30日最新日线KDJ")] - public Task GetStockKDJAsync([Description("股票代码")] string stockSymbol) - => GetStockIndicatorAsync("kdj", stockSymbol); - - [Description("获取近30日最新日线MACD")] - public Task GetStockMACDAsync([Description("股票代码")] string stockSymbol) - => GetStockIndicatorAsync("macd", stockSymbol); - - [Description("获取近30日最新日线BOLL")] - public Task GetStockBOLLAsync([Description("股票代码")] string stockSymbol) - => GetStockIndicatorAsync("boll", stockSymbol); - - [Description("获取近30日最新日线MA")] - public Task GetStockMAAsync([Description("股票代码")] string stockSymbol) - => GetStockIndicatorAsync("ma", stockSymbol); - - public IEnumerable GetFunctions() - { - yield return AIFunctionFactory.Create(GetStockKDJAsync); - yield return AIFunctionFactory.Create(GetStockMACDAsync); - yield return AIFunctionFactory.Create(GetStockBOLLAsync); - yield return AIFunctionFactory.Create(GetStockMAAsync); - } -} diff --git a/src/App.axaml b/src/App.axaml index d9ab7d2..8b9af51 100644 --- a/src/App.axaml +++ b/src/App.axaml @@ -25,6 +25,7 @@ + diff --git a/src/Applications/AssetScreener/CryptoScreenerService.cs b/src/Applications/AssetScreener/CryptoScreenerService.cs new file mode 100644 index 0000000..9444757 --- /dev/null +++ b/src/Applications/AssetScreener/CryptoScreenerService.cs @@ -0,0 +1,45 @@ +using MarketAssistant.Applications.AssetScreener.Models; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Applications.AssetScreener; + +/// +/// 虚拟币筛选服务实现 +/// +public sealed class CryptoScreenerService : IAssetScreenerService +{ + private readonly ILogger _logger; + + public CryptoScreenerService(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// 根据筛选条件筛选虚拟币 + /// + public Task> ScreenAsync(object criteria) + { + if (criteria is not CryptoCriteria cryptoCriteria) + { + throw new ArgumentException("筛选条件类型错误,期望 CryptoCriteria", nameof(criteria)); + } + + _logger.LogWarning("虚拟币筛选服务尚未实现,筛选条件数量: {Count}", cryptoCriteria.Criteria.Count); + + // TODO: 实现虚拟币筛选逻辑 + // 思路: + // 1. 调用币安 API 或 CoinMarketCap API 获取虚拟币列表 + // 2. 根据 CryptoCriteria 中的条件进行过滤 + // 3. 支持的筛选指标示例: + // - market_cap: 市值 + // - volume_24h: 24小时交易量 + // - price_change_24h: 24小时涨跌幅 + // - price_change_7d: 7天涨跌幅 + // - market_cap_rank: 市值排名 + // 4. 将结果映射为 ScreenerStockInfo 格式返回 + + throw new NotImplementedException("虚拟币筛选服务尚未实现。需要调用币安或CoinMarketCap API进行筛选。"); + } +} + diff --git a/src/Applications/AssetScreener/IAssetScreenerService.cs b/src/Applications/AssetScreener/IAssetScreenerService.cs new file mode 100644 index 0000000..6bc6805 --- /dev/null +++ b/src/Applications/AssetScreener/IAssetScreenerService.cs @@ -0,0 +1,17 @@ +using MarketAssistant.Applications.AssetScreener.Models; + +namespace MarketAssistant.Applications.AssetScreener; + +/// +/// 资产筛选服务接口,支持股票、虚拟币等多种资产类型的筛选 +/// +public interface IAssetScreenerService +{ + /// + /// 根据筛选条件筛选资产 + /// + /// 筛选条件对象(支持 StockCriteria、CryptoCriteria 等) + /// 筛选结果列表 + Task> ScreenAsync(object criteria); +} + diff --git a/src/Services/StockScreener/Models/MarketType.cs b/src/Applications/AssetScreener/Models/AShareType.cs similarity index 76% rename from src/Services/StockScreener/Models/MarketType.cs rename to src/Applications/AssetScreener/Models/AShareType.cs index 61dc7e1..9d28524 100644 --- a/src/Services/StockScreener/Models/MarketType.cs +++ b/src/Applications/AssetScreener/Models/AShareType.cs @@ -2,13 +2,13 @@ using System.Runtime.Serialization; using System.Text.Json.Serialization; -namespace MarketAssistant.Services.StockScreener.Models; +namespace MarketAssistant.Applications.AssetScreener.Models; /// -/// 市场类型枚举 +/// A股市场细分类型枚举 /// -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum MarketType +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AShareType { /// /// 全部A股 @@ -31,3 +31,4 @@ public enum MarketType [EnumMember(Value = "深市A股")] ShenzhenAShares } + diff --git a/src/Applications/AssetScreener/Models/CryptoCriteria.cs b/src/Applications/AssetScreener/Models/CryptoCriteria.cs new file mode 100644 index 0000000..ec91ced --- /dev/null +++ b/src/Applications/AssetScreener/Models/CryptoCriteria.cs @@ -0,0 +1,49 @@ +namespace MarketAssistant.Applications.AssetScreener.Models; + +/// +/// 虚拟币筛选条件 +/// +public class CryptoCriteria +{ + /// + /// 筛选条件列表 + /// + public List Criteria { get; set; } = new(); + + /// + /// 返回结果数量限制 + /// + public int Limit { get; set; } = 100; + + /// + /// 交易所(如 Binance、Coinbase 等) + /// + public string? Exchange { get; set; } + + /// + /// 交易对基准货币(如 USDT、BTC 等) + /// + public string? QuoteCurrency { get; set; } = "USDT"; +} + +/// +/// 虚拟币筛选条件项 +/// +public class CryptoScreeningCondition +{ + /// + /// 指标代码(如 market_cap、volume_24h、price_change_24h 等) + /// + public string Code { get; set; } = string.Empty; + + /// + /// 最小值 + /// + public decimal? MinValue { get; set; } + + /// + /// 最大值 + /// + public decimal? MaxValue { get; set; } +} + diff --git a/src/Services/StockScreener/Models/IndustryType.cs b/src/Applications/AssetScreener/Models/IndustryType.cs similarity index 98% rename from src/Services/StockScreener/Models/IndustryType.cs rename to src/Applications/AssetScreener/Models/IndustryType.cs index b522d9f..47bf509 100644 --- a/src/Services/StockScreener/Models/IndustryType.cs +++ b/src/Applications/AssetScreener/Models/IndustryType.cs @@ -2,7 +2,7 @@ using System.Runtime.Serialization; using System.Text.Json.Serialization; -namespace MarketAssistant.Services.StockScreener.Models; +namespace MarketAssistant.Applications.AssetScreener.Models; /// /// 行业分类枚举(精确匹配雪球网行业分类) @@ -211,3 +211,4 @@ public enum IndustryType [EnumMember(Value = "小家电")] SmallAppliances } + diff --git a/src/Services/StockScreener/Models/ScreenerStockInfo.cs b/src/Applications/AssetScreener/Models/ScreenerStockInfo.cs similarity index 98% rename from src/Services/StockScreener/Models/ScreenerStockInfo.cs rename to src/Applications/AssetScreener/Models/ScreenerStockInfo.cs index 8b65438..1f5550b 100644 --- a/src/Services/StockScreener/Models/ScreenerStockInfo.cs +++ b/src/Applications/AssetScreener/Models/ScreenerStockInfo.cs @@ -1,4 +1,4 @@ -namespace MarketAssistant.Services.StockScreener.Models; +namespace MarketAssistant.Applications.AssetScreener.Models; /// /// 雪球网股票筛选结果实体 diff --git a/src/Services/StockScreener/Models/StockCriteria.cs b/src/Applications/AssetScreener/Models/StockCriteria.cs similarity index 79% rename from src/Services/StockScreener/Models/StockCriteria.cs rename to src/Applications/AssetScreener/Models/StockCriteria.cs index df25559..6baafba 100644 --- a/src/Services/StockScreener/Models/StockCriteria.cs +++ b/src/Applications/AssetScreener/Models/StockCriteria.cs @@ -1,6 +1,6 @@ using System.ComponentModel; -namespace MarketAssistant.Services.StockScreener.Models; +namespace MarketAssistant.Applications.AssetScreener.Models; /// /// 股票筛选参数 @@ -15,10 +15,10 @@ public class StockCriteria public List Criteria { get; set; } = new(); /// - /// 市场类型 + /// A股市场细分类型 /// - [Description("市场类型")] - public MarketType Market { get; set; } = MarketType.AllAShares; + [Description("A股市场细分类型")] + public AShareType Market { get; set; } = AShareType.AllAShares; /// /// 行业分类 diff --git a/src/Services/StockScreener/Models/StockScreeningCriteria.cs b/src/Applications/AssetScreener/Models/StockScreeningCriteria.cs similarity index 94% rename from src/Services/StockScreener/Models/StockScreeningCriteria.cs rename to src/Applications/AssetScreener/Models/StockScreeningCriteria.cs index d61432b..b4fc6d1 100644 --- a/src/Services/StockScreener/Models/StockScreeningCriteria.cs +++ b/src/Applications/AssetScreener/Models/StockScreeningCriteria.cs @@ -1,6 +1,6 @@ using System.ComponentModel; -namespace MarketAssistant.Services.StockScreener.Models; +namespace MarketAssistant.Applications.AssetScreener.Models; /// /// 股票筛选条件 diff --git a/src/Services/StockScreener/StockScreenerService.cs b/src/Applications/AssetScreener/StockScreenerService.cs similarity index 97% rename from src/Services/StockScreener/StockScreenerService.cs rename to src/Applications/AssetScreener/StockScreenerService.cs index a6dbcd5..1025ba8 100644 --- a/src/Services/StockScreener/StockScreenerService.cs +++ b/src/Applications/AssetScreener/StockScreenerService.cs @@ -1,16 +1,16 @@ using MarketAssistant.Infrastructure.Extensions; +using MarketAssistant.Applications.AssetScreener.Models; using MarketAssistant.Services.Browser; -using MarketAssistant.Services.StockScreener.Models; using Microsoft.Extensions.Logging; using Microsoft.Playwright; using System.Text.RegularExpressions; -namespace MarketAssistant.Services.StockScreener; +namespace MarketAssistant.Applications.AssetScreener; /// /// 雪球网股票筛选服务,通过Playwright自动化操作xueqiu.com股票筛选器 /// -public sealed class StockScreenerService +public sealed class StockScreenerService : IAssetScreenerService { private readonly PlaywrightService _playwrightService; private readonly ILogger _logger; @@ -74,6 +74,19 @@ public StockScreenerService( _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } + /// + /// 根据筛选条件筛选资产(IAssetScreenerService 接口实现) + /// + public async Task> ScreenAsync(object criteria) + { + if (criteria is not StockCriteria stockCriteria) + { + throw new ArgumentException("筛选条件类型错误,期望 StockCriteria", nameof(criteria)); + } + + return await ScreenStocksAsync(stockCriteria); + } + /// /// 从雪球网筛选股票 /// @@ -140,9 +153,9 @@ public async Task> ScreenStocksAsync(StockCriteria crite #region 私有方法 /// - /// 设置市场类型 + /// 设置A股市场细分类型 /// - private async Task SetMarketType(IPage page, MarketType market) + private async Task SetMarketType(IPage page, AShareType market) { try { diff --git a/src/Applications/Stocks/StockService.cs b/src/Applications/Assets/AShareAssetInfoService.cs similarity index 65% rename from src/Applications/Stocks/StockService.cs rename to src/Applications/Assets/AShareAssetInfoService.cs index dfa8397..348feb8 100644 --- a/src/Applications/Stocks/StockService.cs +++ b/src/Applications/Assets/AShareAssetInfoService.cs @@ -1,26 +1,29 @@ -using MarketAssistant.Applications.Stocks.Models; -using MarketAssistant.Infrastructure; +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Services.Browser; using Microsoft.Extensions.Logging; using Microsoft.Playwright; using System.Text.Json; -namespace MarketAssistant.Applications.Stocks; +namespace MarketAssistant.Applications.Assets; -public class StockService +/// +/// A股资产信息服务实现 +/// +public class AShareAssetInfoService : IAssetInfoService { private readonly HttpClient _httpClient; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly PlaywrightService _playwrightService; - public StockService(ILogger logger, PlaywrightService playwrightService) + public AShareAssetInfoService(ILogger logger, PlaywrightService playwrightService) { _httpClient = new HttpClient(); _logger = logger; _playwrightService = playwrightService; } - public async Task> SearchStockAsync(string keyword, CancellationToken cancellationToken = default) + public async Task> SearchAsync(string keyword, CancellationToken cancellationToken = default) { var url = $"https://www.cls.cn/searchPage?keyword={keyword.Trim()}&type=stock"; @@ -57,25 +60,24 @@ public StockService(ILogger logger, PlaywrightService playwrightSe }, cancellationToken: cancellationToken); } - public async Task GetStockInfoAsync(string stockCode, string market = "", CancellationToken cancellationToken = default) + public async Task GetAssetInfoAsync(string code, string market = "", CancellationToken cancellationToken = default) { - // 创建股票信息对象 - var stockInfo = new StockInfo + // 创建资产信息对象 + var assetInfo = new AssetInfo { - Code = stockCode, + Code = code, Name = "未知股票", - Market = market + Market = market, + MarketType = MarketType.AShare }; - // 尝试获取股票数据 try { // 构建股票详情页URL - var fullCode = string.IsNullOrEmpty(market) ? stockCode : $"{market}{stockCode}".ToLower(); + var fullCode = string.IsNullOrEmpty(market) ? code : $"{market}{code}".ToLower(); var url = $"https://www.cls.cn/stock?code={fullCode}"; - // 使用Playwright获取股票详情页(强制使用泛型重载,返回最新的 stockInfo) - stockInfo = await _playwrightService.ExecuteWithPageAsync(async page => + assetInfo = await _playwrightService.ExecuteWithPageAsync(async page => { try { @@ -85,102 +87,90 @@ public async Task GetStockInfoAsync(string stockCode, string market = catch (Exception ex) { _logger.LogWarning(ex, "股票详情页打开失败或超时,降级返回已有字段"); - return stockInfo; + return assetInfo; } - // 获取股票名称和代码 - 根据提供的HTML结构 var stockDetailElement = await page.QuerySelectorAsync(".stock-detail"); if (stockDetailElement != null) { - // 获取股票名称 var nameElement = await stockDetailElement.QuerySelectorAsync(".f-s-25.f-w-b"); if (nameElement != null) { - stockInfo.Name = (await nameElement.InnerTextAsync()).Trim(); + assetInfo.Name = (await nameElement.InnerTextAsync()).Trim(); } - // 获取股票代码和市场 var codeElement = await stockDetailElement.QuerySelectorAsync(".f-s-20.f-w-b"); if (codeElement != null) { var fullCodeText = (await codeElement.InnerTextAsync()).Trim(); if (!string.IsNullOrEmpty(fullCodeText)) { - // 从代码中提取市场信息(如sh601138中的sh表示上海市场) if (fullCodeText.StartsWith("sh", StringComparison.OrdinalIgnoreCase)) { - stockInfo.Market = "SH"; - stockInfo.Code = fullCodeText.Substring(2); + assetInfo.Market = "SH"; + assetInfo.Code = fullCodeText.Substring(2); } else if (fullCodeText.StartsWith("sz", StringComparison.OrdinalIgnoreCase)) { - stockInfo.Market = "SZ"; - stockInfo.Code = fullCodeText.Substring(2); + assetInfo.Market = "SZ"; + assetInfo.Code = fullCodeText.Substring(2); } else { - stockInfo.Code = fullCodeText; + assetInfo.Code = fullCodeText; } } } } - // 获取价格和涨跌幅 - 根据提供的HTML结构 var quoteChangeBox = await page.QuerySelectorAsync(".quote-change-box"); if (quoteChangeBox != null) { - // 获取当前价格 var priceElement = await quoteChangeBox.QuerySelectorAsync(".quote-price"); if (priceElement != null) { - stockInfo.CurrentPrice = (await priceElement.InnerTextAsync()).Trim(); + assetInfo.CurrentPrice = (await priceElement.InnerTextAsync()).Trim(); } - // 获取涨跌幅 var changeElement = await quoteChangeBox.QuerySelectorAsync(".quote-change"); if (changeElement != null) { var changeText = (await changeElement.InnerTextAsync()).Trim(); - // 提取涨跌幅百分比 if (changeText.Contains("%")) { var startIndex = changeText.IndexOf("(") + 1; var endIndex = changeText.IndexOf("%") + 1; if (startIndex > 0 && endIndex > startIndex) { - stockInfo.ChangePercentage = changeText.Substring(startIndex, endIndex - startIndex); + assetInfo.ChangePercentage = changeText.Substring(startIndex, endIndex - startIndex); } else { - stockInfo.ChangePercentage = changeText; + assetInfo.ChangePercentage = changeText; } } else { - stockInfo.ChangePercentage = changeText; + assetInfo.ChangePercentage = changeText; } } } - // 获取所属板块 - 根据提供的HTML结构 var stockRelatedBox = await page.QuerySelectorAsync(".stock-related-box"); if (stockRelatedBox != null) { var stockPlage = await stockRelatedBox.QuerySelectorAsync(".stock-related-plate"); - if (stockPlage != null) { var sectorElement = await stockPlage.QuerySelectorAsync(".m-r-10.f-s-20.c-222.f-w-b"); if (sectorElement != null) { - stockInfo.SectorName = (await sectorElement.InnerTextAsync()).Trim(); + assetInfo.SectorName = (await sectorElement.InnerTextAsync()).Trim(); } } - // 获取所有板块元素 - //var stockRelatedPlates = await stockRelatedBox.QuerySelectorAllAsync(".stock-related-plate"); } - return stockInfo; + return assetInfo; }, cancellationToken: cancellationToken); } catch (Exception ex) @@ -188,52 +178,44 @@ public async Task GetStockInfoAsync(string stockCode, string market = _logger.LogWarning($"获取股票详细数据异常: {ex.Message}"); } - return stockInfo; + return assetInfo; } - public async Task> GetHotStocksAsync() + public async Task> GetHotAssetsAsync() { try { - // 获取当前日期 DateTime today = DateTime.Now; - // 如果是周六或周日,调整为最近的周五 if (today.DayOfWeek == DayOfWeek.Saturday) { - today = today.AddDays(-1); // 周六减一天为周五 + today = today.AddDays(-1); } else if (today.DayOfWeek == DayOfWeek.Sunday) { - today = today.AddDays(-2); // 周日减两天为周五 + today = today.AddDays(-2); } - // 格式化日期为yyyyMMdd string formattedDate = today.ToString("yyyyMMdd"); - - // 百度股市通API地址,将固定日期替换为当前日期 var url = $"https://finance.pae.baidu.com/vapi/v1/hotrank?product=stock&day={formattedDate}&pn=0&rn=8&market=ab&type=day&finClientType=pc"; var response = await _httpClient.GetStringAsync(url); var jsonDocument = JsonDocument.Parse(response); var root = jsonDocument.RootElement; - // 检查API返回状态 if (!root.TryGetProperty("Result", out var resultElement)) { - _logger.LogError("GetHotStocksAsync: API返回数据格式不正确,缺少Result字段"); - return new List(); + _logger.LogError("GetHotAssetsAsync: API返回数据格式不正确,缺少Result字段"); + return new List(); } - // 检查header和body数组 if (!resultElement.TryGetProperty("header", out var headerElement) || !resultElement.TryGetProperty("body", out var bodyElement)) { - _logger.LogError("GetHotStocksAsync: API返回数据格式不正确,缺少header或body字段"); - return new List(); + _logger.LogError("GetHotAssetsAsync: API返回数据格式不正确,缺少header或body字段"); + return new List(); } - // 解析header数组,获取字段索引 var headerIndices = new Dictionary(); int index = 0; foreach (var header in headerElement.EnumerateArray()) @@ -241,21 +223,19 @@ public async Task> GetHotStocksAsync() headerIndices[header.GetString() ?? string.Empty] = index++; } - var hotStocks = new List(); + var hotAssets = new List(); - // 遍历body数组中的每个股票数据 foreach (var stockArray in bodyElement.EnumerateArray()) { - // 确保数组长度与header长度一致 if (stockArray.GetArrayLength() != headerIndices.Count) { - _logger.LogError("GetHotStocksAsync: 股票数据数组长度与header不匹配"); + _logger.LogError("GetHotAssetsAsync: 股票数据数组长度与header不匹配"); continue; } var stockData = stockArray.EnumerateArray().ToArray(); - var hotStock = new HotStock + var hotAsset = new HotAsset { Name = stockData[headerIndices["股票名称"]].GetString() ?? string.Empty, ChangePercentage = stockData[headerIndices["涨跌幅"]].GetString() ?? string.Empty, @@ -264,29 +244,20 @@ public async Task> GetHotStocksAsync() CurrentPrice = stockData[headerIndices["现价"]].GetString() ?? string.Empty, Market = stockData[headerIndices["市场缩写"]].GetString() ?? string.Empty, RankChange = stockData[headerIndices["排名变化"]].GetString() ?? string.Empty, - MarketType = stockData[headerIndices["市场"]].GetString() ?? string.Empty, - HeatIndex = stockData[headerIndices["综合热度"]].GetString() ?? string.Empty + HeatIndex = stockData[headerIndices["综合热度"]].GetString() ?? string.Empty, + MarketType = MarketType.AShare }; - hotStocks.Add(hotStock); + hotAssets.Add(hotAsset); } - return hotStocks; - } - catch (HttpRequestException ex) - { - _logger.LogError(ex, "GetHotStocksAsync HTTP请求异常: {Message}", ex.Message); - throw new FriendlyException($"获取热门股票失败: 网络请求错误 - {ex.Message}", ex); - } - catch (JsonException ex) - { - _logger.LogError(ex, "GetHotStocksAsync JSON解析异常: {Message}", ex.Message); - throw new FriendlyException($"获取热门股票失败: 数据解析错误 - {ex.Message}", ex); + return hotAssets; } catch (Exception ex) { - _logger.LogError(ex, "GetHotStocksAsync未知异常: {Message}", ex.Message); - throw new FriendlyException($"获取热门股票失败: {ex.Message}", ex); + _logger.LogError(ex, "GetHotAssetsAsync未知异常: {Message}", ex.Message); + throw new Infrastructure.Core.FriendlyException($"获取热门股票失败: {ex.Message}", ex); } } } + diff --git a/src/Applications/Assets/CryptoAssetInfoService.cs b/src/Applications/Assets/CryptoAssetInfoService.cs new file mode 100644 index 0000000..09b1c80 --- /dev/null +++ b/src/Applications/Assets/CryptoAssetInfoService.cs @@ -0,0 +1,324 @@ +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Infrastructure; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace MarketAssistant.Applications.Assets; + +/// +/// 虚拟币资产信息服务实现(基于币安API) +/// API文档: https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/rest-api +/// +public class CryptoAssetInfoService : IAssetInfoService +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private const string BINANCE_API_BASE_URL = "https://api.binance.com"; + + // 缓存交易对信息(避免频繁请求) + private List? _symbolsCache; + private DateTime _symbolsCacheTime; + private readonly TimeSpan _cacheExpiry = TimeSpan.FromHours(1); + + public CryptoAssetInfoService(ILogger logger) + { + _logger = logger; + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(30) + }; + } + + /// + /// 搜索虚拟币(支持名称和代码) + /// + public async Task> SearchAsync(string keyword, CancellationToken cancellationToken = default) + { + try + { + if (string.IsNullOrWhiteSpace(keyword)) + { + return new List<(string Name, string Code)>(); + } + + keyword = keyword.Trim().ToUpperInvariant(); + + // 获取所有交易对信息 + var symbols = await GetSymbolsAsync(cancellationToken); + + // 搜索匹配的交易对(只返回USDT交易对,最常用) + var results = symbols + .Where(s => s.Symbol.Contains(keyword) && s.QuoteAsset == "USDT" && s.Status == "TRADING") + .Select(s => (Name: s.BaseAsset, Code: s.Symbol)) + .Take(20) + .ToList(); + + _logger.LogInformation("虚拟币搜索: {Keyword}, 返回 {Count} 条结果", keyword, results.Count); + return results; + } + catch (Exception ex) + { + _logger.LogError(ex, "虚拟币搜索失败: {Keyword}", keyword); + return new List<(string Name, string Code)>(); + } + } + + /// + /// 获取虚拟币详细信息 + /// + public async Task GetAssetInfoAsync(string code, string market = "", CancellationToken cancellationToken = default) + { + try + { + // 格式化交易对代码 + string symbol = FormatSymbol(code); + + // 调用币安24小时价格统计API + var url = $"{BINANCE_API_BASE_URL}/api/v3/ticker/24hr?symbol={symbol}"; + var response = await _httpClient.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + var jsonContent = await response.Content.ReadAsStringAsync(cancellationToken); + var ticker = JsonSerializer.Deserialize(jsonContent); + + if (ticker == null) + { + throw new FriendlyException("解析币安API响应失败"); + } + + // 构建资产信息 + var assetInfo = new AssetInfo + { + Code = symbol, + Name = ticker.Symbol.Replace("USDT", "").Replace("BTC", "").Replace("ETH", ""), // 提取基础币种 + MarketType = MarketType.Crypto, + Market = "Binance", + CurrentPrice = FormatPrice(ticker.LastPrice), + ChangePercentage = FormatPercentage(ticker.PriceChangePercent), + Volume24h = FormatVolume(ticker.Volume), + MarketCap = FormatVolume(ticker.QuoteVolume) // 使用USDT交易量作为市值参考 + }; + + _logger.LogInformation("成功获取虚拟币详情: {Symbol}", symbol); + return assetInfo; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "获取虚拟币详情失败 - 网络错误: {Code}", code); + throw new FriendlyException($"获取虚拟币详情失败: 网络连接错误", ex); + } + catch (Exception ex) + { + _logger.LogError(ex, "获取虚拟币详情失败: {Code}", code); + throw new FriendlyException($"获取虚拟币详情失败: {ex.Message}", ex); + } + } + + /// + /// 获取热门虚拟币(按24小时交易量排序) + /// + public async Task> GetHotAssetsAsync() + { + try + { + // 获取所有交易对的24小时统计 + var url = $"{BINANCE_API_BASE_URL}/api/v3/ticker/24hr"; + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var jsonContent = await response.Content.ReadAsStringAsync(); + var tickers = JsonSerializer.Deserialize>(jsonContent); + + if (tickers == null || tickers.Count == 0) + { + _logger.LogWarning("币安API返回数据为空"); + return new List(); + } + + // 筛选USDT交易对,按24小时交易量排序,取前8个 + var hotAssets = tickers + .Where(t => t.Symbol.EndsWith("USDT") && t.Symbol != "USDT") + .OrderByDescending(t => decimal.TryParse(t.QuoteVolume, out var vol) ? vol : 0) + .Take(8) + .Select(t => new HotAsset + { + Name = t.Symbol.Replace("USDT", ""), + Code = t.Symbol, + Market = "Binance", + CurrentPrice = FormatPrice(t.LastPrice), + ChangePercentage = FormatPercentage(t.PriceChangePercent), + MarketType = MarketType.Crypto, + HeatIndex = FormatVolume(t.QuoteVolume), // 使用交易量作为热度 + SectorName = "加密货币" // 虚拟币暂不区分板块 + }) + .ToList(); + + _logger.LogInformation("成功获取热门虚拟币: {Count} 个", hotAssets.Count); + return hotAssets; + } + catch (Exception ex) + { + _logger.LogError(ex, "获取热门虚拟币失败: {Message}", ex.Message); + throw new FriendlyException($"获取热门虚拟币失败: {ex.Message}", ex); + } + } + + #region 辅助方法 + + /// + /// 获取所有交易对信息(带缓存) + /// + private async Task> GetSymbolsAsync(CancellationToken cancellationToken) + { + // 检查缓存 + if (_symbolsCache != null && (DateTime.Now - _symbolsCacheTime) < _cacheExpiry) + { + return _symbolsCache; + } + + // 请求交易所信息 + var url = $"{BINANCE_API_BASE_URL}/api/v3/exchangeInfo"; + var response = await _httpClient.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + var jsonContent = await response.Content.ReadAsStringAsync(cancellationToken); + var exchangeInfo = JsonSerializer.Deserialize(jsonContent); + + _symbolsCache = exchangeInfo?.Symbols ?? new List(); + _symbolsCacheTime = DateTime.Now; + + _logger.LogInformation("已缓存 {Count} 个交易对信息", _symbolsCache.Count); + return _symbolsCache; + } + + /// + /// 格式化交易对代码 + /// + private string FormatSymbol(string code) + { + code = code.Replace("crypto.", "", StringComparison.OrdinalIgnoreCase) + .Replace(" ", "") + .ToUpperInvariant(); + + // 如果没有交易对后缀,默认添加USDT + if (!code.Contains("USDT") && !code.Contains("BTC") && !code.Contains("ETH")) + { + code += "USDT"; + } + + return code; + } + + /// + /// 格式化价格显示 + /// + private string FormatPrice(string? price) + { + if (string.IsNullOrEmpty(price) || !decimal.TryParse(price, out var value)) + { + return "0.00"; + } + + // 根据价格大小选择精度 + if (value >= 1000) + { + return value.ToString("N2"); // 1000+ 显示2位小数 + } + else if (value >= 1) + { + return value.ToString("N4"); // 1-1000 显示4位小数 + } + else + { + return value.ToString("N6"); // <1 显示6位小数 + } + } + + /// + /// 格式化百分比显示 + /// + private string FormatPercentage(string? percent) + { + if (string.IsNullOrEmpty(percent) || !decimal.TryParse(percent, out var value)) + { + return "0.00%"; + } + + return $"{value:F2}%"; + } + + /// + /// 格式化交易量显示(K, M, B) + /// + private string FormatVolume(string? volume) + { + if (string.IsNullOrEmpty(volume) || !decimal.TryParse(volume, out var value)) + { + return "0"; + } + + if (value >= 1_000_000_000) + { + return $"{(value / 1_000_000_000):N2}B"; + } + else if (value >= 1_000_000) + { + return $"{(value / 1_000_000):N2}M"; + } + else if (value >= 1_000) + { + return $"{(value / 1_000):N2}K"; + } + else + { + return $"{value:N2}"; + } + } + + #endregion + + #region 币安API响应模型 + + /// + /// 币安交易所信息响应 + /// + private class BinanceExchangeInfo + { + public List? Symbols { get; set; } + } + + /// + /// 币安交易对信息 + /// + private class BinanceSymbolInfo + { + public string Symbol { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string BaseAsset { get; set; } = string.Empty; + public string QuoteAsset { get; set; } = string.Empty; + } + + /// + /// 币安24小时价格统计 + /// + private class BinanceTicker24hr + { + public string Symbol { get; set; } = string.Empty; + public string? LastPrice { get; set; } + public string? PriceChange { get; set; } + public string? PriceChangePercent { get; set; } + public string? Volume { get; set; } + public string? QuoteVolume { get; set; } + public string? HighPrice { get; set; } + public string? LowPrice { get; set; } + } + + #endregion +} + + + + + + diff --git a/src/Applications/Assets/IAssetInfoService.cs b/src/Applications/Assets/IAssetInfoService.cs new file mode 100644 index 0000000..6d8aabc --- /dev/null +++ b/src/Applications/Assets/IAssetInfoService.cs @@ -0,0 +1,30 @@ +using MarketAssistant.Applications.Assets.Models; + +namespace MarketAssistant.Applications.Assets; + +/// +/// 资产信息服务接口 +/// +public interface IAssetInfoService +{ + /// + /// 搜索资产 + /// + Task> SearchAsync(string keyword, CancellationToken cancellationToken = default); + + /// + /// 获取资产详情 + /// + Task GetAssetInfoAsync(string code, string market = "", CancellationToken cancellationToken = default); + + /// + /// 获取热门资产 + /// + Task> GetHotAssetsAsync(); +} + + + + + + diff --git a/src/Applications/Assets/Models/AssetInfo.cs b/src/Applications/Assets/Models/AssetInfo.cs new file mode 100644 index 0000000..e528c30 --- /dev/null +++ b/src/Applications/Assets/Models/AssetInfo.cs @@ -0,0 +1,67 @@ +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Applications.Assets.Models; + +/// +/// 通用资产详情信息 +/// +public class AssetInfo +{ + /// + /// 资产代码 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 资产名称 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 市场类型 + /// + public MarketType MarketType { get; set; } + + /// + /// 当前价格 + /// + public string CurrentPrice { get; set; } = string.Empty; + + /// + /// 涨跌幅百分比 + /// + public string ChangePercentage { get; set; } = string.Empty; + + /// + /// 市场标识(如SH、SZ、BTC/USDT等) + /// + public string Market { get; set; } = string.Empty; + + // A股特有字段 + /// + /// 所属板块名称(A股特有) + /// + public string? SectorName { get; set; } + + // 虚拟币特有字段 + /// + /// 区块链网络(虚拟币特有) + /// + public string? BlockchainNetwork { get; set; } + + /// + /// 市值(虚拟币特有) + /// + public string? MarketCap { get; set; } + + /// + /// 24小时交易量(虚拟币特有) + /// + public string? Volume24h { get; set; } +} + + + + + + diff --git a/src/Applications/Assets/Models/AssetItem.cs b/src/Applications/Assets/Models/AssetItem.cs new file mode 100644 index 0000000..a2cf577 --- /dev/null +++ b/src/Applications/Assets/Models/AssetItem.cs @@ -0,0 +1,23 @@ +namespace MarketAssistant.Applications.Assets.Models; + +/// +/// 通用资产条目 +/// +public class AssetItem +{ + /// + /// 资产名称 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 资产代码 + /// + public string Code { get; set; } = string.Empty; +} + + + + + + diff --git a/src/Applications/Assets/Models/FavoriteAsset.cs b/src/Applications/Assets/Models/FavoriteAsset.cs new file mode 100644 index 0000000..3beb465 --- /dev/null +++ b/src/Applications/Assets/Models/FavoriteAsset.cs @@ -0,0 +1,23 @@ +namespace MarketAssistant.Applications.Assets.Models; + +/// +/// 收藏的资产 +/// +public class FavoriteAsset +{ + /// + /// 资产代码 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 市场标识 + /// + public string Market { get; set; } = string.Empty; +} + + + + + + diff --git a/src/Applications/Stocks/Models/HotStock.cs b/src/Applications/Assets/Models/HotAsset.cs similarity index 54% rename from src/Applications/Stocks/Models/HotStock.cs rename to src/Applications/Assets/Models/HotAsset.cs index e2bd854..3c799ef 100644 --- a/src/Applications/Stocks/Models/HotStock.cs +++ b/src/Applications/Assets/Models/HotAsset.cs @@ -1,9 +1,14 @@ -namespace MarketAssistant.Applications.Stocks.Models; +using MarketAssistant.Infrastructure.Core; -public class HotStock +namespace MarketAssistant.Applications.Assets.Models; + +/// +/// 热门资产 +/// +public class HotAsset { /// - /// 股票名称 + /// 资产名称 /// public string Name { get; set; } = string.Empty; @@ -13,37 +18,55 @@ public class HotStock public string ChangePercentage { get; set; } = string.Empty; /// - /// 所属板块名称 - /// - public string SectorName { get; set; } = string.Empty; - - /// - /// 市场代码 + /// 资产代码 /// public string Code { get; set; } = string.Empty; /// - /// 现价 + /// 当前价格 /// public string CurrentPrice { get; set; } = string.Empty; /// - /// 市场缩写 + /// 市场标识 /// public string Market { get; set; } = string.Empty; /// - /// 排名变化 + /// 市场类型 /// - public string RankChange { get; set; } = string.Empty; + public MarketType MarketType { get; set; } /// - /// 市场类型 + /// 排名变化 /// - public string MarketType { get; set; } = string.Empty; + public string RankChange { get; set; } = string.Empty; /// /// 综合热度 /// public string HeatIndex { get; set; } = string.Empty; -} \ No newline at end of file + + // A股特有字段 + /// + /// 所属板块名称(A股特有) + /// + public string? SectorName { get; set; } + + // 虚拟币特有字段 + /// + /// 市值(虚拟币特有) + /// + public string? MarketCap { get; set; } + + /// + /// 24小时交易量(虚拟币特有) + /// + public string? Volume24h { get; set; } +} + + + + + + diff --git a/src/Applications/Cache/AShareAssetCacheService.cs b/src/Applications/Cache/AShareAssetCacheService.cs new file mode 100644 index 0000000..d2115c9 --- /dev/null +++ b/src/Applications/Cache/AShareAssetCacheService.cs @@ -0,0 +1,63 @@ +using MarketAssistant.Applications.Assets.Models; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Applications.Cache; + +/// +/// A股资产缓存服务实现 +/// +public class AShareAssetCacheService : IAssetCacheService +{ + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private const int CacheExpirationMinutes = 5; // 缓存5分钟 + + public AShareAssetCacheService(IMemoryCache cache, ILogger logger) + { + _cache = cache; + _logger = logger; + } + + public Task GetCachedAssetInfoAsync(string code) + { + var cacheKey = GetCacheKey(code); + if (_cache.TryGetValue(cacheKey, out AssetInfo? assetInfo)) + { + _logger.LogDebug("从缓存获取资产信息: {Code}", code); + return Task.FromResult(assetInfo); + } + return Task.FromResult(null); + } + + public void CacheAssetInfo(string code, AssetInfo info) + { + var cacheKey = GetCacheKey(code); + var cacheOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(CacheExpirationMinutes), + Size = 1 // 用于缓存大小限制 + }; + + _cache.Set(cacheKey, info, cacheOptions); + _logger.LogDebug("缓存资产信息: {Code}", code); + } + + public void Clear() + { + // MemoryCache 不支持清除所有条目 + // 这里只是记录日志 + _logger.LogInformation("清除A股资产缓存"); + } + + private static string GetCacheKey(string code) + { + return $"AssetInfo_AShare_{code}"; + } +} + + + + + + diff --git a/src/Applications/Cache/CryptoAssetCacheService.cs b/src/Applications/Cache/CryptoAssetCacheService.cs new file mode 100644 index 0000000..ee01c22 --- /dev/null +++ b/src/Applications/Cache/CryptoAssetCacheService.cs @@ -0,0 +1,61 @@ +using MarketAssistant.Applications.Assets.Models; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Applications.Cache; + +/// +/// 虚拟币资产缓存服务实现 +/// +public class CryptoAssetCacheService : IAssetCacheService +{ + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private const int CacheExpirationMinutes = 5; // 缓存5分钟 + + public CryptoAssetCacheService(IMemoryCache cache, ILogger logger) + { + _cache = cache; + _logger = logger; + } + + public Task GetCachedAssetInfoAsync(string code) + { + var cacheKey = GetCacheKey(code); + if (_cache.TryGetValue(cacheKey, out AssetInfo? assetInfo)) + { + _logger.LogDebug("从缓存获取虚拟币资产信息: {Code}", code); + return Task.FromResult(assetInfo); + } + return Task.FromResult(null); + } + + public void CacheAssetInfo(string code, AssetInfo info) + { + var cacheKey = GetCacheKey(code); + var cacheOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(CacheExpirationMinutes), + Size = 1 + }; + + _cache.Set(cacheKey, info, cacheOptions); + _logger.LogDebug("缓存虚拟币资产信息: {Code}", code); + } + + public void Clear() + { + _logger.LogInformation("清除虚拟币资产缓存"); + } + + private static string GetCacheKey(string code) + { + return $"AssetInfo_Crypto_{code}"; + } +} + + + + + + diff --git a/src/Applications/Cache/IAssetCacheService.cs b/src/Applications/Cache/IAssetCacheService.cs new file mode 100644 index 0000000..4855ba0 --- /dev/null +++ b/src/Applications/Cache/IAssetCacheService.cs @@ -0,0 +1,30 @@ +using MarketAssistant.Applications.Assets.Models; + +namespace MarketAssistant.Applications.Cache; + +/// +/// 资产缓存服务接口 +/// +public interface IAssetCacheService +{ + /// + /// 获取缓存的资产信息 + /// + Task GetCachedAssetInfoAsync(string code); + + /// + /// 缓存资产信息 + /// + void CacheAssetInfo(string code, AssetInfo info); + + /// + /// 清除所有缓存 + /// + void Clear(); +} + + + + + + diff --git a/src/Applications/Charts/AShareKLineService.cs b/src/Applications/Charts/AShareKLineService.cs new file mode 100644 index 0000000..e689a46 --- /dev/null +++ b/src/Applications/Charts/AShareKLineService.cs @@ -0,0 +1,255 @@ +using MarketAssistant.Applications.Charts.Models; +using MarketAssistant.Infrastructure; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MarketAssistant.Applications.Charts; + +/// +/// A股K线数据服务实现 +/// +public class AShareKLineService : IKLineService +{ + private readonly HttpClient _httpClient; + private readonly IUserSettingService _userSettingService; + private readonly ILogger _logger; + private const string ZHITU_API_BASE_URL = "https://api.zhituapi.com/hs/history"; + + public AShareKLineService(ILogger logger, IUserSettingService userSettingService) + { + _httpClient = new HttpClient(); + _userSettingService = userSettingService; + _logger = logger; + } + + /// + /// 获取K线数据(统一入口,根据类型调用对应的实现) + /// + public async Task> GetKLineDataAsync(string code, KLineType kLineType, int count = 100) + { + // 根据K线类型映射到智图API的时间周期参数 + var (interval, dataTypeName) = kLineType switch + { + KLineType.Daily => ("d", "日K线"), + KLineType.Weekly => ("w", "周K线"), + KLineType.Monthly => ("m", "月K线"), + KLineType.Minute5 => ("5", "5分钟K线"), + KLineType.Minute15 => ("15", "15分钟K线"), + _ => ("d", "日K线") // 默认日K线 + }; + + var dataset = await GetKLineDataInternalAsync(code, interval, dataTypeName); + return dataset?.Data ?? new List(); + } + + #region 内部实现方法 + + private async Task GetKLineDataInternalAsync( + string symbol, + string interval, + string dataType, + DateTime? startDate = null, + DateTime? endDate = null, + string adjustType = "n") + { + try + { + // 验证参数 + if (string.IsNullOrEmpty(symbol)) + { + throw new ArgumentException("股票代码参数必须提供"); + } + + // 处理股票代码格式 + string formattedSymbol = StockSymbolConverter.ToZhiTuFormat(symbol); + + // 构建API URL + string apiUrl = BuildZhiTuApiUrl(formattedSymbol, interval, adjustType, startDate, endDate); + + // 发送请求并获取数据 + var zhiTuData = await FetchZhiTuDataAsync(apiUrl, dataType, symbol); + + // 转换为应用程序数据模型 + var klineDataSet = new KLineDataSet + { + Symbol = symbol, + Interval = interval, + Data = new List() + }; + + // 解析数据 + ParseZhiTuKLineData(zhiTuData, klineDataSet); + + return klineDataSet; + } + catch (Exception ex) + { + _logger.LogError(ex, "获取{DataType}数据时发生错误 - 股票代码: {Symbol}", dataType, symbol); + throw new FriendlyException($"获取{dataType}数据失败: {ex.Message}", ex); + } + } + + private string BuildZhiTuApiUrl(string symbol, string interval, string adjustType = "n", DateTime? startDate = null, DateTime? endDate = null) + { + var token = _userSettingService.CurrentSetting.ZhiTuApiToken; + var url = $"{ZHITU_API_BASE_URL}/{symbol}/{interval}/{adjustType}?token={token}"; + + // 如果没有指定时间范围,根据不同的interval设置合理的默认时间范围 + DateTime defaultStartDate; + DateTime defaultEndDate = DateTime.Now; + + if (!startDate.HasValue && !endDate.HasValue) + { + switch (interval.ToLower()) + { + case "d": // 日K线,默认查询最近6个月 + defaultStartDate = DateTime.Now.AddMonths(-6); + break; + case "w": // 周K线,默认查询最近1年 + defaultStartDate = DateTime.Now.AddYears(-1); + break; + case "m": // 月K线,默认查询最近3年 + defaultStartDate = DateTime.Now.AddYears(-3); + break; + case "y": // 年K线,默认查询最近10年 + defaultStartDate = DateTime.Now.AddYears(-10); + break; + case "1": + case "5": + case "15": + case "30": + case "60": // 分钟级别数据,默认查询最近30天 + defaultStartDate = DateTime.Now.AddDays(-30); + break; + default: // 其他情况,默认查询最近1年 + defaultStartDate = DateTime.Now.AddYears(-1); + break; + } + + startDate = defaultStartDate; + endDate = defaultEndDate; + } + + if (startDate.HasValue) + { + url += $"&st={startDate.Value:yyyyMMdd}"; + } + + if (endDate.HasValue) + { + url += $"&et={endDate.Value:yyyyMMdd}"; + } + + return url; + } + + private async Task> FetchZhiTuDataAsync(string url, string dataType, string symbol) + { + _logger.LogInformation("正在获取股票{DataType}数据: 股票代码: {Symbol}", dataType, symbol); + + try + { + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var jsonContent = await response.Content.ReadAsStringAsync(); + var zhiTuData = JsonSerializer.Deserialize>(jsonContent); + + if (zhiTuData == null || !zhiTuData.Any()) + { + throw new FriendlyException($"获取{dataType}数据失败: 返回数据为空"); + } + + return zhiTuData; + } + catch (HttpRequestException ex) + { + throw new FriendlyException($"获取{dataType}数据失败: 网络请求错误 - {ex.Message}", ex); + } + catch (JsonException ex) + { + throw new FriendlyException($"获取{dataType}数据失败: 数据解析错误 - {ex.Message}", ex); + } + } + + private void ParseZhiTuKLineData(List zhiTuData, KLineDataSet klineDataSet) + { + foreach (var item in zhiTuData) + { + // 解析时间戳 + if (DateTime.TryParse(item.T, out DateTime timestamp)) + { + var klineData = new KLineData + { + Timestamp = timestamp, + Open = item.O, + High = item.H, + Low = item.L, + Close = item.C, + Volume = item.V, + Amount = item.A, + PreClose = item.Pc + }; + + // 计算涨跌额和涨跌幅 + if (item.Pc > 0) + { + klineData.Change = item.C - item.Pc; + klineData.PctChg = (klineData.Change / item.Pc) * 100; + } + else + { + klineData.Change = 0; + klineData.PctChg = 0; + } + + klineDataSet.Data.Add(klineData); + } + } + + // 按日期时间排序(从旧到新) + klineDataSet.Data = klineDataSet.Data.OrderBy(x => x.Timestamp).ToList(); + } + + #endregion +} + +/// +/// ZhiTu API K线数据模型 +/// +[Serializable] +internal class ZhiTuKLineData +{ + [JsonPropertyName("t")] + public string T { get; set; } = string.Empty; + + [JsonPropertyName("o")] + public decimal O { get; set; } + + [JsonPropertyName("h")] + public decimal H { get; set; } + + [JsonPropertyName("l")] + public decimal L { get; set; } + + [JsonPropertyName("c")] + public decimal C { get; set; } + + [JsonPropertyName("v")] + public decimal V { get; set; } + + [JsonPropertyName("a")] + public decimal A { get; set; } + + [JsonPropertyName("pc")] + public decimal Pc { get; set; } +} + + + + + + diff --git a/src/Applications/Charts/CryptoKLineService.cs b/src/Applications/Charts/CryptoKLineService.cs new file mode 100644 index 0000000..38cdb66 --- /dev/null +++ b/src/Applications/Charts/CryptoKLineService.cs @@ -0,0 +1,216 @@ +using MarketAssistant.Applications.Charts.Models; +using MarketAssistant.Infrastructure; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace MarketAssistant.Applications.Charts; + +/// +/// 虚拟币K线数据服务实现(基于币安API) +/// 文档:https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/rest-api +/// +public class CryptoKLineService : IKLineService +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + // 币安公开市场数据API(无需API Key) + private const string BINANCE_API_BASE_URL = "https://api.binance.com"; + private const int DEFAULT_LIMIT = 500; // 币安API最多返回1000条,默认500条 + + public CryptoKLineService(ILogger logger) + { + _logger = logger; + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(30) + }; + } + + /// + /// 获取K线数据(统一入口,根据类型调用对应的实现) + /// + public async Task> GetKLineDataAsync(string code, KLineType kLineType, int count = 100) + { + // 根据K线类型映射到币安API的时间周期参数 + var interval = kLineType switch + { + KLineType.Daily => "1d", + KLineType.Weekly => "1w", + KLineType.Monthly => "1M", + KLineType.Minute5 => "5m", + KLineType.Minute15 => "15m", + _ => "1d" // 默认日K线 + }; + + return await GetKLineDataInternalAsync(code, interval, count); + } + + #region 内部实现 + + /// + /// 获取K线数据(内部实现) + /// + /// 交易对,如 BTCUSDT + /// 时间间隔:1m, 5m, 15m, 30m, 1h, 1d, 1w, 1M + /// 返回数据条数,最大1000 + private async Task> GetKLineDataInternalAsync(string symbol, string interval, int limit = 100) + { + try + { + // 验证参数 + if (string.IsNullOrEmpty(symbol)) + { + throw new ArgumentException("交易对代码不能为空"); + } + + // 格式化交易对代码(转换为币安格式,如 BTCUSDT) + string formattedSymbol = FormatSymbolForBinance(symbol); + + // 限制请求数量(币安限制最大1000) + int requestLimit = Math.Min(limit, 1000); + + // 构建API URL + string apiUrl = $"{BINANCE_API_BASE_URL}/api/v3/klines?symbol={formattedSymbol}&interval={interval}&limit={requestLimit}"; + + _logger.LogInformation("正在获取币安K线数据: {Symbol}, 周期: {Interval}, 数量: {Limit}", formattedSymbol, interval, requestLimit); + + // 发送HTTP请求 + var response = await _httpClient.GetAsync(apiUrl); + response.EnsureSuccessStatusCode(); + + var jsonContent = await response.Content.ReadAsStringAsync(); + + // 币安返回的是数组的数组格式 + var binanceData = JsonSerializer.Deserialize>>(jsonContent); + + if (binanceData == null || !binanceData.Any()) + { + _logger.LogWarning("币安API返回数据为空: {Symbol}", formattedSymbol); + return new List(); + } + + // 转换为应用程序数据模型 + var klineDataList = new List(); + + foreach (var item in binanceData) + { + if (item.Count < 11) + { + _logger.LogWarning("币安K线数据格式异常,跳过该条数据"); + continue; + } + + // 币安K线数据格式(数组): + // [0] 开盘时间(毫秒) + // [1] 开盘价 + // [2] 最高价 + // [3] 最低价 + // [4] 收盘价 + // [5] 成交量 + // [6] 收盘时间(毫秒) + // [7] 成交额 + // [8] 成交笔数 + // [9] 主动买入成交量 + // [10] 主动买入成交额 + // [11] 忽略 + + var klineData = new KLineData + { + Timestamp = DateTimeOffset.FromUnixTimeMilliseconds(item[0].GetInt64()).DateTime, + Open = decimal.Parse(item[1].GetString() ?? "0"), + High = decimal.Parse(item[2].GetString() ?? "0"), + Low = decimal.Parse(item[3].GetString() ?? "0"), + Close = decimal.Parse(item[4].GetString() ?? "0"), + Volume = decimal.Parse(item[5].GetString() ?? "0"), + Amount = decimal.Parse(item[7].GetString() ?? "0") + }; + + klineDataList.Add(klineData); + } + + // 计算涨跌额和涨跌幅 + CalculatePriceChanges(klineDataList); + + _logger.LogInformation("成功获取币安K线数据: {Symbol}, 返回 {Count} 条记录", formattedSymbol, klineDataList.Count); + + return klineDataList; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "获取币安K线数据失败 - 网络请求错误: {Symbol}", symbol); + throw new FriendlyException($"获取虚拟币K线数据失败: 网络连接错误 - {ex.Message}", ex); + } + catch (JsonException ex) + { + _logger.LogError(ex, "获取币安K线数据失败 - JSON解析错误: {Symbol}", symbol); + throw new FriendlyException($"获取虚拟币K线数据失败: 数据解析错误 - {ex.Message}", ex); + } + catch (Exception ex) + { + _logger.LogError(ex, "获取币安K线数据失败: {Symbol}", symbol); + throw new FriendlyException($"获取虚拟币K线数据失败: {ex.Message}", ex); + } + } + + /// + /// 格式化交易对代码为币安格式 + /// + private string FormatSymbolForBinance(string symbol) + { + // 移除可能的前缀(如 crypto.)和空格 + symbol = symbol.Replace("crypto.", "", StringComparison.OrdinalIgnoreCase) + .Replace(" ", "") + .ToUpperInvariant(); + + // 如果没有交易对后缀,默认添加USDT + if (!symbol.Contains("USDT") && !symbol.Contains("BTC") && !symbol.Contains("ETH")) + { + symbol += "USDT"; + } + + return symbol; + } + + /// + /// 计算涨跌额和涨跌幅 + /// + private void CalculatePriceChanges(List klineDataList) + { + for (int i = 0; i < klineDataList.Count; i++) + { + if (i == 0) + { + // 第一条数据,无法计算涨跌 + klineDataList[i].PreClose = klineDataList[i].Open; + klineDataList[i].Change = 0; + klineDataList[i].PctChg = 0; + } + else + { + // 使用前一条数据的收盘价作为昨收价 + var preClose = klineDataList[i - 1].Close; + klineDataList[i].PreClose = preClose; + + if (preClose > 0) + { + klineDataList[i].Change = klineDataList[i].Close - preClose; + klineDataList[i].PctChg = (klineDataList[i].Change / preClose) * 100; + } + else + { + klineDataList[i].Change = 0; + klineDataList[i].PctChg = 0; + } + } + } + } + + #endregion +} + + + + + + diff --git a/src/Applications/Charts/IKLineService.cs b/src/Applications/Charts/IKLineService.cs new file mode 100644 index 0000000..e6531a1 --- /dev/null +++ b/src/Applications/Charts/IKLineService.cs @@ -0,0 +1,24 @@ +using MarketAssistant.Applications.Charts.Models; + +namespace MarketAssistant.Applications.Charts; + +/// +/// K线数据服务接口(统一接口,支持多种时间周期) +/// +public interface IKLineService +{ + /// + /// 获取K线数据 + /// + /// 资产代码 + /// K线类型(时间周期) + /// 数据条数 + /// K线数据列表 + Task> GetKLineDataAsync(string code, KLineType kLineType, int count = 100); +} + + + + + + diff --git a/src/Applications/Stocks/Models/StockKLineData.cs b/src/Applications/Charts/Models/KLineData.cs similarity index 87% rename from src/Applications/Stocks/Models/StockKLineData.cs rename to src/Applications/Charts/Models/KLineData.cs index 9885eef..dc6e90d 100644 --- a/src/Applications/Stocks/Models/StockKLineData.cs +++ b/src/Applications/Charts/Models/KLineData.cs @@ -1,11 +1,11 @@ using System.Text.Json.Serialization; -namespace MarketAssistant.Applications.Stocks.Models; +namespace MarketAssistant.Applications.Charts.Models; /// -/// K线图数据模型 +/// K线图数据模型(通用) /// -public class StockKLineData +public class KLineData { /// /// 交易日期/时间 @@ -71,10 +71,10 @@ public class StockKLineData /// /// K线图数据集合 /// -public class StockKLineDataSet +public class KLineDataSet { /// - /// 股票代码 + /// 资产代码 /// public string Symbol { get; set; } = string.Empty; @@ -86,5 +86,11 @@ public class StockKLineDataSet /// /// K线数据列表 /// - public List Data { get; set; } = new List(); -} \ No newline at end of file + public List Data { get; set; } = new List(); +} + + + + + + diff --git a/src/Applications/Stocks/Models/KLineType.cs b/src/Applications/Charts/Models/KLineType.cs similarity index 87% rename from src/Applications/Stocks/Models/KLineType.cs rename to src/Applications/Charts/Models/KLineType.cs index 6e74690..6790bb8 100644 --- a/src/Applications/Stocks/Models/KLineType.cs +++ b/src/Applications/Charts/Models/KLineType.cs @@ -1,4 +1,4 @@ -namespace MarketAssistant.Applications.Stocks.Models; +namespace MarketAssistant.Applications.Charts.Models; /// /// K线类型枚举 @@ -30,3 +30,9 @@ public enum KLineType /// Monthly } + + + + + + diff --git a/src/Applications/Charts/README_CRYPTO.md b/src/Applications/Charts/README_CRYPTO.md new file mode 100644 index 0000000..843ea4c --- /dev/null +++ b/src/Applications/Charts/README_CRYPTO.md @@ -0,0 +1,200 @@ +# 虚拟币 K 线数据服务说明 + +## 概述 + +`CryptoKLineService` 基于[币安 REST API](https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/rest-api) 实现,用于获取虚拟货币(加密货币)的 K 线数据。 + +## 技术架构 + +### API 提供商 +- **币安(Binance)**: 全球领先的加密货币交易所 +- **API 文档**: https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/rest-api +- **Base URL**: `https://api.binance.com` + +### 认证方式 +- **公开市场数据**: 无需 API Key,直接访问 +- **限流规则**: + - 单个 IP 每分钟最多 1200 次请求 + - 权重限制:每分钟 6000 权重 + - K 线接口权重:2 + +## 支持的时间周期 + +根据币安 API 的 `interval` 参数,支持以下周期: + +| 方法 | 币安 Interval | 说明 | +|------|---------------|------| +| `GetMinuteKLineDataAsync` | `1m` | 1分钟K线 | +| `Get5MinuteKLineDataAsync` | `5m` | 5分钟K线 | +| `Get15MinuteKLineDataAsync` | `15m` | 15分钟K线 | +| `Get30MinuteKLineDataAsync` | `30m` | 30分钟K线 | +| `Get60MinuteKLineDataAsync` | `1h` | 1小时K线 | +| `GetDailyKLineDataAsync` | `1d` | 日K线 | +| `GetWeeklyKLineDataAsync` | `1w` | 周K线 | +| `GetMonthlyKLineDataAsync` | `1M` | 月K线 | + +## 交易对格式 + +### 输入格式(灵活) +服务会自动处理以下格式: + +- `BTCUSDT` ✅ 推荐 +- `BTC` ✅ 自动添加 USDT 后缀 +- `btcusdt` ✅ 自动转大写 +- `crypto.BTCUSDT` ✅ 自动移除前缀 + +### 支持的交易对 +常见交易对包括: +- **USDT 交易对**: BTCUSDT, ETHUSDT, BNBUSDT, SOLUSDT 等 +- **BTC 交易对**: ETHBTC, BNBBTC 等 +- **ETH 交易对**: BNBETH 等 + +完整列表参考: https://api.binance.com/api/v3/exchangeInfo + +## K 线数据格式 + +### 币安原始响应(JSON 数组) + +```json +[ + [ + 1499040000000, // [0] 开盘时间(毫秒) + "0.01634000", // [1] 开盘价 + "0.80000000", // [2] 最高价 + "0.01575800", // [3] 最低价 + "0.01577100", // [4] 收盘价 + "148976.11427815", // [5] 成交量 + 1499644799999, // [6] 收盘时间(毫秒) + "2434.19055334", // [7] 成交额 + 308, // [8] 成交笔数 + "1756.87402397", // [9] 主动买入成交量 + "28.46694368", // [10] 主动买入成交额 + "0" // [11] 忽略 + ] +] +``` + +### 转换后的应用模型(KLineData) + +```csharp +public class KLineData +{ + public DateTime Timestamp { get; set; } // 时间戳 + public decimal Open { get; set; } // 开盘价 + public decimal High { get; set; } // 最高价 + public decimal Low { get; set; } // 最低价 + public decimal Close { get; set; } // 收盘价 + public decimal Volume { get; set; } // 成交量 + public decimal Amount { get; set; } // 成交额 + public decimal PreClose { get; set; } // 昨收价(计算得出) + public decimal Change { get; set; } // 涨跌额(计算得出) + public decimal PctChg { get; set; } // 涨跌幅%(计算得出) +} +``` + +## 使用示例 + +### 基本用法 + +```csharp +// 通过依赖注入获取服务 +var klineService = serviceProvider.GetRequiredKeyedService(MarketType.Crypto); + +// 获取比特币日K线(最近500条) +var btcDailyData = await klineService.GetDailyKLineDataAsync("BTCUSDT", 500); + +// 获取以太坊15分钟K线 +var ethData = await klineService.Get15MinuteKLineDataAsync("ETH", 100); + +// 获取币安币周K线 +var bnbData = await klineService.GetWeeklyKLineDataAsync("BNBUSDT", 52); +``` + +### 错误处理 + +```csharp +try +{ + var data = await klineService.GetDailyKLineDataAsync("BTCUSDT"); +} +catch (FriendlyException ex) +{ + // 用户友好的错误信息 + Console.WriteLine($"获取K线数据失败: {ex.Message}"); +} +catch (HttpRequestException ex) +{ + // 网络连接错误 + Console.WriteLine($"网络错误: {ex.Message}"); +} +``` + +## 限制与注意事项 + +### 数据量限制 +- **单次请求最大**: 1000 条(币安API限制) +- **默认数量**: 500 条 +- **建议数量**: + - 分钟级: 100-500 条 + - 小时级: 500-1000 条 + - 日K/周K/月K: 根据需要调整 + +### 实时性 +- **数据延迟**: < 1 秒(币安实时数据) +- **无需缓存**: 数据有实时性要求,不推荐缓存 +- **更新频率**: 根据时间周期实时更新 + +### 网络要求 +- **连接超时**: 30 秒 +- **重试机制**: 未实现,需要调用方处理 +- **代理支持**: 可通过 HttpClient 配置 + +### 合规性 +- **公开数据**: 无需 API Key,可直接访问 +- **使用条款**: 遵守币安 API 使用条款 +- **费率限制**: 避免频繁请求,建议间隔 > 1 秒 + +## 与 A 股服务的对比 + +| 特性 | CryptoKLineService (币安) | AShareKLineService (智图) | +|------|--------------------------|--------------------------| +| 数据源 | 币安 API | 智图 API | +| 认证 | 无需 API Key | 需要 Token | +| 市场 | 全球虚拟币 | A股市场 | +| 交易时间 | 24/7 全天候 | 交易日 9:30-15:00 | +| 数据延迟 | < 1秒 | < 5秒 | +| 限流 | 1200次/分钟 | 依智图API | +| 数据格式 | JSON 数组 | JSON 对象 | + +## 故障排查 + +### 问题:返回空数据 +- **原因**: 交易对不存在或格式错误 +- **解决**: 检查交易对名称,确保在币安上市 + +### 问题:HTTP 429 错误 +- **原因**: 请求频率过高,触发限流 +- **解决**: 降低请求频率,添加延迟 + +### 问题:超时错误 +- **原因**: 网络连接问题或币安服务器响应慢 +- **解决**: 检查网络连接,考虑使用代理 + +## 参考链接 + +- [币安 API 文档(中文)](https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/rest-api) +- [K 线接口说明](https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/rest-api/market-data-endpoints) +- [枚举定义](https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/enums) +- [错误代码](https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/error-codes) +- [币安交易对列表](https://api.binance.com/api/v3/exchangeInfo) + +## 更新日志 + +### v1.0.0 (2025-01-04) +- ✅ 实现币安 K 线数据获取 +- ✅ 支持 8 种时间周期(1m, 5m, 15m, 30m, 1h, 1d, 1w, 1M) +- ✅ 自动格式化交易对代码 +- ✅ 计算涨跌额和涨跌幅 +- ✅ 友好的错误处理 +- ✅ 完整的日志记录 + diff --git a/src/Applications/Favorites/AShareFavoriteService.cs b/src/Applications/Favorites/AShareFavoriteService.cs new file mode 100644 index 0000000..df30ae7 --- /dev/null +++ b/src/Applications/Favorites/AShareFavoriteService.cs @@ -0,0 +1,156 @@ +using CommunityToolkit.Mvvm.Messaging; +using MarketAssistant.Applications.Assets; +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Infrastructure.Configuration; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Market; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace MarketAssistant.Applications.Favorites; + +/// +/// A股收藏服务实现 +/// +public class AShareFavoriteService : IFavoriteService +{ + private const string PreferenceKey = "FavoriteAssets_AShare"; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + private IAssetInfoService AssetInfoService + { + get + { + var marketContext = _serviceProvider.GetRequiredService(); + return _serviceProvider.GetRequiredKeyedService(marketContext.CurrentMarket); + } + } + + public AShareFavoriteService( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public void AddFavorite(string code, string market) + { + if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(market)) + return; + + var favoriteList = GetFavoritesCodes(); + + var existingItem = favoriteList.FirstOrDefault(x => x.Code == code && x.Market == market); + if (existingItem != null) + return; + + favoriteList.Add(new FavoriteAsset { Code = code, Market = market }); + SaveFavorites(favoriteList); + WeakReferenceMessenger.Default.Send(new AssetFavoritesChanged()); + } + + public void RemoveFavorite(string code, string market) + { + var favoriteList = GetFavoritesCodes(); + + var itemToRemove = favoriteList.FirstOrDefault(x => x.Code == code && x.Market == market); + if (itemToRemove != null) + { + favoriteList.Remove(itemToRemove); + SaveFavorites(favoriteList); + WeakReferenceMessenger.Default.Send(new AssetFavoritesChanged()); + } + } + + public bool IsFavorite(string code, string market) + { + var favoriteList = GetFavoritesCodes(); + return favoriteList.Any(x => x.Code == code && x.Market == market); + } + + public List GetFavoritesCodes() + { + try + { + var json = Preferences.Default.Get(PreferenceKey, string.Empty); + if (string.IsNullOrWhiteSpace(json)) + return new List(); + + var favoriteList = JsonSerializer.Deserialize>(json); + return favoriteList ?? new List(); + } + catch (Exception ex) + { + _logger.LogError(ex, $"获取收藏资产时出错: {ex.Message}"); + return new List(); + } + } + + public async Task> GetFavoritesWithLatestDataAsync(CancellationToken cancellationToken = default) + { + var favoritesCodes = GetFavoritesCodes(); + if (favoritesCodes.Count == 0) + return new(); + + var assetInfos = new List(); + + try + { + var tasks = new List>(); + + foreach (var favorite in favoritesCodes) + { + var task = Task.Run(async () => + { + try + { + return await AssetInfoService.GetAssetInfoAsync(favorite.Code, favorite.Market, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, $"获取资产 {favorite.Code} 最新数据时出错: {ex.Message}"); + return new AssetInfo { Code = favorite.Code, Market = favorite.Market, Name = $"{favorite.Market}.{favorite.Code}" }; + } + }); + + tasks.Add(task); + } + + var results = await Task.WhenAll(tasks); + assetInfos.AddRange(results.Where(r => r != null)); + + return assetInfos; + } + catch (Exception ex) + { + _logger.LogError(ex, $"获取收藏资产最新数据时出错: {ex.Message}"); + return assetInfos; + } + } + + public void ClearFavorites() + { + SaveFavorites(new List()); + WeakReferenceMessenger.Default.Send(new AssetFavoritesChanged()); + } + + private void SaveFavorites(List favoriteList) + { + try + { + var json = JsonSerializer.Serialize(favoriteList); + Preferences.Default.Set(PreferenceKey, json); + } + catch (Exception ex) + { + _logger.LogError(ex, $"保存收藏资产时出错: {ex.Message}"); + } + } +} + + + + diff --git a/src/Applications/Favorites/CryptoFavoriteService.cs b/src/Applications/Favorites/CryptoFavoriteService.cs new file mode 100644 index 0000000..3f43f1a --- /dev/null +++ b/src/Applications/Favorites/CryptoFavoriteService.cs @@ -0,0 +1,190 @@ +using CommunityToolkit.Mvvm.Messaging; +using MarketAssistant.Applications.Assets; +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Infrastructure.Configuration; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Market; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace MarketAssistant.Applications.Favorites; + +/// +/// 虚拟币收藏服务实现 +/// +public class CryptoFavoriteService : IFavoriteService +{ + private const string PreferenceKey = "FavoriteAssets_Crypto"; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + private IAssetInfoService AssetInfoService + { + get + { + var marketContext = _serviceProvider.GetRequiredService(); + return _serviceProvider.GetRequiredKeyedService(marketContext.CurrentMarket); + } + } + + public CryptoFavoriteService( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + /// + /// 添加虚拟币到收藏 + /// + public void AddFavorite(string code, string market) + { + if (string.IsNullOrWhiteSpace(code)) + return; + + var favoriteList = GetFavoritesCodes(); + + var existingItem = favoriteList.FirstOrDefault(x => x.Code == code && x.Market == market); + if (existingItem != null) + return; + + favoriteList.Add(new FavoriteAsset { Code = code, Market = market }); + SaveFavorites(favoriteList); + WeakReferenceMessenger.Default.Send(new AssetFavoritesChanged()); + + _logger.LogInformation("已添加虚拟币到收藏: {Code}", code); + } + + /// + /// 从收藏中移除虚拟币 + /// + public void RemoveFavorite(string code, string market) + { + var favoriteList = GetFavoritesCodes(); + + var itemToRemove = favoriteList.FirstOrDefault(x => x.Code == code && x.Market == market); + if (itemToRemove != null) + { + favoriteList.Remove(itemToRemove); + SaveFavorites(favoriteList); + WeakReferenceMessenger.Default.Send(new AssetFavoritesChanged()); + + _logger.LogInformation("已从收藏中移除虚拟币: {Code}", code); + } + } + + /// + /// 检查虚拟币是否已收藏 + /// + public bool IsFavorite(string code, string market) + { + var favoriteList = GetFavoritesCodes(); + return favoriteList.Any(x => x.Code == code && x.Market == market); + } + + /// + /// 获取收藏的虚拟币代码列表 + /// + public List GetFavoritesCodes() + { + try + { + var json = Preferences.Default.Get(PreferenceKey, string.Empty); + if (string.IsNullOrWhiteSpace(json)) + return new List(); + + var favoriteList = JsonSerializer.Deserialize>(json); + return favoriteList ?? new List(); + } + catch (Exception ex) + { + _logger.LogError(ex, "获取收藏虚拟币时出错: {Message}", ex.Message); + return new List(); + } + } + + /// + /// 获取收藏虚拟币的最新数据 + /// + public async Task> GetFavoritesWithLatestDataAsync(CancellationToken cancellationToken = default) + { + var favoritesCodes = GetFavoritesCodes(); + if (favoritesCodes.Count == 0) + return new(); + + var assetInfos = new List(); + + try + { + var tasks = new List>(); + + foreach (var favorite in favoritesCodes) + { + var task = Task.Run(async () => + { + try + { + return await AssetInfoService.GetAssetInfoAsync(favorite.Code, favorite.Market, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "获取虚拟币 {Code} 最新数据时出错: {Message}", favorite.Code, ex.Message); + return new AssetInfo + { + Code = favorite.Code, + Market = favorite.Market, + Name = favorite.Code.Replace("USDT", ""), + MarketType = MarketType.Crypto + }; + } + }); + + tasks.Add(task); + } + + var results = await Task.WhenAll(tasks); + assetInfos.AddRange(results.Where(r => r != null)); + + return assetInfos; + } + catch (Exception ex) + { + _logger.LogError(ex, "获取收藏虚拟币最新数据时出错: {Message}", ex.Message); + return assetInfos; + } + } + + /// + /// 清空所有收藏 + /// + public void ClearFavorites() + { + SaveFavorites(new List()); + WeakReferenceMessenger.Default.Send(new AssetFavoritesChanged()); + _logger.LogInformation("已清空所有收藏虚拟币"); + } + + /// + /// 保存收藏列表到本地存储 + /// + private void SaveFavorites(List favoriteList) + { + try + { + var json = JsonSerializer.Serialize(favoriteList); + Preferences.Default.Set(PreferenceKey, json); + } + catch (Exception ex) + { + _logger.LogError(ex, "保存收藏虚拟币时出错: {Message}", ex.Message); + } + } +} + + + + + + diff --git a/src/Applications/Favorites/IFavoriteService.cs b/src/Applications/Favorites/IFavoriteService.cs new file mode 100644 index 0000000..02326dc --- /dev/null +++ b/src/Applications/Favorites/IFavoriteService.cs @@ -0,0 +1,45 @@ +using MarketAssistant.Applications.Assets.Models; + +namespace MarketAssistant.Applications.Favorites; + +/// +/// 收藏服务接口 +/// +public interface IFavoriteService +{ + /// + /// 添加资产到收藏 + /// + void AddFavorite(string code, string market); + + /// + /// 从收藏中移除资产 + /// + void RemoveFavorite(string code, string market); + + /// + /// 检查资产是否已收藏 + /// + bool IsFavorite(string code, string market); + + /// + /// 获取所有收藏的资产代码 + /// + List GetFavoritesCodes(); + + /// + /// 获取所有收藏的资产(包含最新数据) + /// + Task> GetFavoritesWithLatestDataAsync(CancellationToken cancellationToken = default); + + /// + /// 清空所有收藏 + /// + void ClearFavorites(); +} + + + + + + diff --git a/src/Applications/History/AShareHistoryService.cs b/src/Applications/History/AShareHistoryService.cs new file mode 100644 index 0000000..3a69fb4 --- /dev/null +++ b/src/Applications/History/AShareHistoryService.cs @@ -0,0 +1,86 @@ +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Infrastructure.Configuration; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace MarketAssistant.Applications.History; + +/// +/// A股资产历史记录服务实现 +/// +public class AShareHistoryService : IAssetHistoryService +{ + private const string PreferenceKey = "RecentAssets_AShare"; + private const int MaxHistoryCount = 10; + private readonly ILogger _logger; + + public AShareHistoryService(ILogger logger) + { + _logger = logger; + } + + public void AddHistory(AssetItem asset) + { + if (asset == null || string.IsNullOrWhiteSpace(asset.Code)) + return; + + var historyList = GetHistory(); + + var existingItem = historyList.FirstOrDefault(x => x.Code == asset.Code); + if (existingItem != null) + { + historyList.Remove(existingItem); + } + + historyList.Insert(0, asset); + + if (historyList.Count > MaxHistoryCount) + { + historyList.RemoveAt(historyList.Count - 1); + } + + SaveHistory(historyList); + } + + public List GetHistory() + { + try + { + var json = Preferences.Default.Get(PreferenceKey, string.Empty); + if (string.IsNullOrWhiteSpace(json)) + return new List(); + + var historyList = JsonSerializer.Deserialize>(json); + return historyList ?? new List(); + } + catch (Exception ex) + { + _logger?.LogError(ex, "获取历史记录时出错: {Message}", ex.Message); + return new List(); + } + } + + public void ClearHistory() + { + SaveHistory(new List()); + } + + private void SaveHistory(List historyList) + { + try + { + var json = JsonSerializer.Serialize(historyList); + Preferences.Default.Set(PreferenceKey, json); + } + catch (Exception ex) + { + _logger?.LogError(ex, "保存历史记录时出错: {Message}", ex.Message); + } + } +} + + + + + + diff --git a/src/Applications/History/CryptoHistoryService.cs b/src/Applications/History/CryptoHistoryService.cs new file mode 100644 index 0000000..1a02f98 --- /dev/null +++ b/src/Applications/History/CryptoHistoryService.cs @@ -0,0 +1,113 @@ +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Infrastructure.Configuration; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace MarketAssistant.Applications.History; + +/// +/// 虚拟币资产历史记录服务实现 +/// +public class CryptoHistoryService : IAssetHistoryService +{ + private const string PreferenceKey = "RecentAssets_Crypto"; + private const int MaxHistoryCount = 10; + private readonly ILogger _logger; + + public CryptoHistoryService(ILogger logger) + { + _logger = logger; + } + + /// + /// 添加虚拟币到历史记录 + /// + public void AddHistory(AssetItem asset) + { + if (asset == null || string.IsNullOrWhiteSpace(asset.Code)) + return; + + var historyList = GetHistory(); + + // 如果已存在,先移除 + var existingItem = historyList.FirstOrDefault(x => x.Code == asset.Code); + if (existingItem != null) + { + historyList.Remove(existingItem); + } + + // 插入到最前面 + historyList.Insert(0, asset); + + // 保持最多10条记录 + if (historyList.Count > MaxHistoryCount) + { + historyList.RemoveAt(historyList.Count - 1); + } + + SaveHistory(historyList); + _logger.LogInformation("已添加虚拟币到历史记录: {Code}", asset.Code); + } + + /// + /// 获取历史记录 + /// + public List GetHistory() + { + try + { + var json = Preferences.Default.Get(PreferenceKey, string.Empty); + if (string.IsNullOrWhiteSpace(json)) + return new List(); + + var historyList = JsonSerializer.Deserialize>(json); + return historyList ?? new List(); + } + catch (Exception ex) + { + _logger?.LogError(ex, "获取虚拟币历史记录时出错: {Message}", ex.Message); + return new List(); + } + } + + /// + /// 清空历史记录 + /// + public void ClearHistory() + { + SaveHistory(new List()); + _logger.LogInformation("已清空虚拟币历史记录"); + } + + /// + /// 获取最近查看的虚拟币(与GetHistory功能相同) + /// + public List GetRecentAssets() + { + return GetHistory(); + } + + /// + /// 添加到最近查看(与AddHistory功能相同) + /// + public void AddToRecentAssets(AssetItem asset) + { + AddHistory(asset); + } + + /// + /// 保存历史记录到本地存储 + /// + private void SaveHistory(List historyList) + { + try + { + var json = JsonSerializer.Serialize(historyList); + Preferences.Default.Set(PreferenceKey, json); + } + catch (Exception ex) + { + _logger?.LogError(ex, "保存虚拟币历史记录时出错: {Message}", ex.Message); + } + } +} diff --git a/src/Applications/History/IAssetHistoryService.cs b/src/Applications/History/IAssetHistoryService.cs new file mode 100644 index 0000000..269398d --- /dev/null +++ b/src/Applications/History/IAssetHistoryService.cs @@ -0,0 +1,30 @@ +using MarketAssistant.Applications.Assets.Models; + +namespace MarketAssistant.Applications.History; + +/// +/// 资产历史记录服务接口 +/// +public interface IAssetHistoryService +{ + /// + /// 添加一条资产访问记录 + /// + void AddHistory(AssetItem asset); + + /// + /// 获取历史记录列表 + /// + List GetHistory(); + + /// + /// 清空历史记录 + /// + void ClearHistory(); +} + + + + + + diff --git a/src/Applications/Home/AShareHomeService.cs b/src/Applications/Home/AShareHomeService.cs new file mode 100644 index 0000000..7542d01 --- /dev/null +++ b/src/Applications/Home/AShareHomeService.cs @@ -0,0 +1,168 @@ +using MarketAssistant.Applications.Assets; +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Applications.Favorites; +using MarketAssistant.Applications.History; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Dialog; +using MarketAssistant.Services.Market; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Applications.Home; + +/// +/// A股首页服务实现 +/// +public class AShareHomeService : IHomeAssetService +{ + private readonly IServiceProvider _serviceProvider; + private readonly IDialogService _dialogService; + private readonly ILogger _logger; + + private IAssetInfoService AssetInfoService + { + get + { + var marketContext = _serviceProvider.GetRequiredService(); + return _serviceProvider.GetRequiredKeyedService(marketContext.CurrentMarket); + } + } + + private IAssetHistoryService HistoryService + { + get + { + var marketContext = _serviceProvider.GetRequiredService(); + return _serviceProvider.GetRequiredKeyedService(marketContext.CurrentMarket); + } + } + + private IFavoriteService FavoriteService + { + get + { + var marketContext = _serviceProvider.GetRequiredService(); + return _serviceProvider.GetRequiredKeyedService(marketContext.CurrentMarket); + } + } + + public AShareHomeService( + IServiceProvider serviceProvider, + IDialogService dialogService, + ILogger logger) + { + _serviceProvider = serviceProvider; + _dialogService = dialogService; + _logger = logger; + } + + public async Task> SearchAssetAsync(string query, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(query)) + return new List(); + + try + { + var results = await AssetInfoService.SearchAsync(query, cancellationToken); + return results.Select(asset => new AssetItem { Name = asset.Name, Code = asset.Code }).ToList(); + } + catch (Exception ex) + { + _logger?.LogError(ex, "搜索资产时出错,查询:{Query}", query); + return new List(); + } + } + + public async Task> GetHotAssetsAsync() + { + try + { + return await AssetInfoService.GetHotAssetsAsync(); + } + catch (Exception ex) + { + _logger?.LogError(ex, "获取热门资产时出错"); + return new List(); + } + } + + public List GetRecentAssets() + { + try + { + return HistoryService.GetHistory(); + } + catch (Exception ex) + { + _logger?.LogError(ex, "获取最近查看资产时出错"); + return new List(); + } + } + + public void AddToRecentAssets(AssetItem asset) + { + try + { + HistoryService.AddHistory(asset); + } + catch (Exception ex) + { + _logger?.LogError(ex, "添加到最近查看时出错,资产:{AssetName}", asset?.Name); + } + } + + public async Task AddToFavoriteAsync(object assetParameter) + { + try + { + string assetName = ""; + string code = ""; + string market = ""; + + if (assetParameter is HotAsset hotAsset) + { + assetName = hotAsset.Name; + code = hotAsset.Code; + market = hotAsset.Market; + } + else if (assetParameter is AssetItem assetItem) + { + assetName = assetItem.Name; + code = assetItem.Code; + + // 尝试从资产代码中提取市场代码 + if (code.StartsWith("sh") || code.StartsWith("sz")) + { + market = code.Substring(0, 2).ToUpper(); + code = code.Substring(2); + } + } + else + { + return false; + } + + bool confirmed = await _dialogService.ShowConfirmationAsync( + "添加收藏", + $"确定要将 {assetName} 添加到收藏列表吗?", + "确认", + "取消"); + + if (confirmed) + { + FavoriteService.AddFavorite(code, market); + await _dialogService.ShowMessageAsync("收藏成功", $"已将 {assetName} 添加到收藏列表"); + return true; + } + + return false; + } + catch (Exception ex) + { + _logger?.LogError(ex, "添加收藏时出错"); + await _dialogService.ShowMessageAsync("收藏失败", "添加收藏时发生错误,请稍后重试"); + return false; + } + } +} + diff --git a/src/Applications/Home/CryptoHomeService.cs b/src/Applications/Home/CryptoHomeService.cs new file mode 100644 index 0000000..e9ea53b --- /dev/null +++ b/src/Applications/Home/CryptoHomeService.cs @@ -0,0 +1,164 @@ +using MarketAssistant.Applications.Assets; +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Applications.Favorites; +using MarketAssistant.Applications.History; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Dialog; +using MarketAssistant.Services.Market; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Applications.Home; + +/// +/// 虚拟币首页服务实现 +/// +public class CryptoHomeService : IHomeAssetService +{ + private readonly IServiceProvider _serviceProvider; + private readonly IDialogService _dialogService; + private readonly ILogger _logger; + + private IAssetInfoService AssetInfoService + { + get + { + var marketContext = _serviceProvider.GetRequiredService(); + return _serviceProvider.GetRequiredKeyedService(marketContext.CurrentMarket); + } + } + + private IAssetHistoryService HistoryService + { + get + { + var marketContext = _serviceProvider.GetRequiredService(); + return _serviceProvider.GetRequiredKeyedService(marketContext.CurrentMarket); + } + } + + private IFavoriteService FavoriteService + { + get + { + var marketContext = _serviceProvider.GetRequiredService(); + return _serviceProvider.GetRequiredKeyedService(marketContext.CurrentMarket); + } + } + + public CryptoHomeService( + IServiceProvider serviceProvider, + IDialogService dialogService, + ILogger logger) + { + _serviceProvider = serviceProvider; + _dialogService = dialogService; + _logger = logger; + } + + /// + /// 搜索虚拟币 + /// + public async Task> SearchAssetAsync(string query, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(query)) + return new List(); + + try + { + var results = await AssetInfoService.SearchAsync(query, cancellationToken); + return results.Select(asset => new AssetItem { Name = asset.Name, Code = asset.Code }).ToList(); + } + catch (Exception ex) + { + _logger?.LogError(ex, "搜索虚拟币时出错,查询:{Query}", query); + return new List(); + } + } + + /// + /// 获取热门虚拟币 + /// + public async Task> GetHotAssetsAsync() + { + try + { + return await AssetInfoService.GetHotAssetsAsync(); + } + catch (Exception ex) + { + _logger?.LogError(ex, "获取热门虚拟币时出错"); + return new List(); + } + } + + /// + /// 获取最近查看的虚拟币 + /// + public List GetRecentAssets() + { + try + { + return HistoryService.GetHistory(); + } + catch (Exception ex) + { + _logger?.LogError(ex, "获取最近查看的虚拟币时出错"); + return new List(); + } + } + + /// + /// 添加到最近查看 + /// + public void AddToRecentAssets(AssetItem asset) + { + try + { + HistoryService.AddHistory(asset); + } + catch (Exception ex) + { + _logger?.LogError(ex, "添加虚拟币到最近查看时出错: {Code}", asset.Code); + } + } + + /// + /// 添加到收藏 + /// + public async Task AddToFavoriteAsync(object assetParameter) + { + try + { + if (assetParameter is not ViewModels.AssetNavigationParameter parameter) + { + _logger?.LogWarning("添加到收藏失败:参数类型不匹配"); + return false; + } + + // 检查是否已收藏(虚拟币使用空字符串作为market) + if (FavoriteService.IsFavorite(parameter.Code, "")) + { + await _dialogService.ShowMessageAsync("提示", "该虚拟币已在收藏列表中"); + return false; + } + + // 添加到收藏 + FavoriteService.AddFavorite(parameter.Code, ""); + await _dialogService.ShowMessageAsync("成功", "已添加到收藏"); + return true; + } + catch (Exception ex) + { + _logger?.LogError(ex, "添加虚拟币到收藏时出错"); + await _dialogService.ShowMessageAsync("错误", $"添加到收藏失败:{ex.Message}"); + return false; + } + } +} + + + + + + diff --git a/src/Applications/Home/IHomeAssetService.cs b/src/Applications/Home/IHomeAssetService.cs new file mode 100644 index 0000000..0824289 --- /dev/null +++ b/src/Applications/Home/IHomeAssetService.cs @@ -0,0 +1,40 @@ +using MarketAssistant.Applications.Assets.Models; + +namespace MarketAssistant.Applications.Home; + +/// +/// 首页资产服务接口 +/// +public interface IHomeAssetService +{ + /// + /// 搜索资产 + /// + Task> SearchAssetAsync(string query, CancellationToken cancellationToken = default); + + /// + /// 获取热门资产 + /// + Task> GetHotAssetsAsync(); + + /// + /// 获取最近查看的资产 + /// + List GetRecentAssets(); + + /// + /// 添加到最近查看 + /// + void AddToRecentAssets(AssetItem asset); + + /// + /// 添加到收藏 + /// + Task AddToFavoriteAsync(object assetParameter); +} + + + + + + diff --git a/src/Applications/StockSelection/StockSelectionService.cs b/src/Applications/InvestmentSelection/InvestmentSelectionService.cs similarity index 53% rename from src/Applications/StockSelection/StockSelectionService.cs rename to src/Applications/InvestmentSelection/InvestmentSelectionService.cs index 7280c42..3e82b3d 100644 --- a/src/Applications/StockSelection/StockSelectionService.cs +++ b/src/Applications/InvestmentSelection/InvestmentSelectionService.cs @@ -1,22 +1,23 @@ -using MarketAssistant.Agents.StockSelection; -using MarketAssistant.Applications.StockSelection.Models; +using MarketAssistant.Agents.InvestmentSelection; +using MarketAssistant.Applications.InvestmentSelection.Models; using Microsoft.Extensions.Logging; -namespace MarketAssistant.Applications.StockSelection; +namespace MarketAssistant.Applications.InvestmentSelection; /// -/// AI选股服务 - 业务逻辑层,负责对外API和业务规则 -/// 使用 Agent Framework Workflows 实现确定性选股流程 +/// AI投资选择服务 - 业务逻辑层,负责对外API和业务规则 +/// 使用 Agent Framework Workflows 实现确定性投资选择流程 +/// 支持股票和虚拟币市场 /// -public class StockSelectionService : IDisposable +public class InvestmentSelectionService : IDisposable { - private readonly StockSelectionWorkflow _selectionWorkflow; - private readonly ILogger _logger; + private readonly InvestmentSelectionWorkflow _selectionWorkflow; + private readonly ILogger _logger; private bool _disposed = false; - public StockSelectionService( - StockSelectionWorkflow selectionWorkflow, - ILogger logger) + public InvestmentSelectionService( + InvestmentSelectionWorkflow selectionWorkflow, + ILogger logger) { _selectionWorkflow = selectionWorkflow ?? throw new ArgumentNullException(nameof(selectionWorkflow)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -25,10 +26,10 @@ public StockSelectionService( #region 业务API接口 /// - /// 功能1: 根据用户需求推荐股票 + /// 功能1: 根据用户需求推荐投资标的 /// - public async Task RecommendStocksByUserRequirementAsync( - StockRecommendationRequest request, + public async Task RecommendByUserRequirementAsync( + InvestmentRecommendationRequest request, CancellationToken cancellationToken = default) { if (request == null || string.IsNullOrWhiteSpace(request.UserRequirements)) @@ -38,97 +39,91 @@ public async Task RecommendStocksByUserRequirementAsync( try { - _logger.LogInformation("开始基于用户需求的AI选股,需求: {Requirements}", request.UserRequirements); + _logger.LogInformation("开始基于用户需求的AI投资选择,市场: {Market},需求: {Requirements}", + request.MarketType, request.UserRequirements); - // 业务逻辑:验证和预处理请求 var validatedRequest = ValidateAndNormalizeUserRequest(request); - // 调用工作流进行分析 var result = await _selectionWorkflow.AnalyzeUserRequirementAsync(validatedRequest, cancellationToken); - // 业务逻辑:后处理和结果优化 var optimizedResult = OptimizeUserBasedResult(result, validatedRequest); - _logger.LogInformation("用户需求选股完成,推荐股票数量: {Count}, 置信度: {Confidence:F1}%", - optimizedResult.Recommendations.Count, optimizedResult.ConfidenceScore); + _logger.LogInformation("用户需求投资选择完成,市场: {Market},推荐数量: {Count},置信度: {Confidence:F1}%", + request.MarketType, optimizedResult.Recommendations.Count, optimizedResult.ConfidenceScore); return optimizedResult; } catch (Exception ex) { - _logger.LogError(ex, "基于用户需求的选股过程中发生错误"); + _logger.LogError(ex, "基于用户需求的投资选择过程中发生错误"); throw; } } /// - /// 功能2: 根据新闻推荐股票 + /// 功能2: 根据新闻推荐投资标的 /// - public async Task RecommendStocksByNewsAsync( - NewsBasedSelectionRequest request, + public async Task RecommendByNewsAsync( + NewsBasedInvestmentRequest request, CancellationToken cancellationToken = default) { if (request == null) { - request = new NewsBasedSelectionRequest(); // 使用默认设置 + request = new NewsBasedInvestmentRequest(); } try { - _logger.LogInformation("开始基于热点新闻的AI选股,推荐股票数: {Max}", request.MaxRecommendations); + _logger.LogInformation("开始基于热点新闻的AI投资选择,市场: {Market},推荐数: {Max}", + request.MarketType, request.MaxRecommendations); - // 业务逻辑:验证和预处理请求 var validatedRequest = ValidateAndNormalizeNewsRequest(request); - // 调用工作流进行分析 var result = await _selectionWorkflow.AnalyzeNewsHotspotAsync(validatedRequest, cancellationToken); - // 业务逻辑:后处理和结果优化 var optimizedResult = OptimizeNewsBasedResult(result, validatedRequest); - _logger.LogInformation("热点新闻选股完成,推荐股票数量: {Count}, 置信度: {Confidence:F1}%", - optimizedResult.Recommendations.Count, optimizedResult.ConfidenceScore); + _logger.LogInformation("热点新闻投资选择完成,市场: {Market},推荐数量: {Count},置信度: {Confidence:F1}%", + request.MarketType, optimizedResult.Recommendations.Count, optimizedResult.ConfidenceScore); return optimizedResult; } catch (Exception ex) { - _logger.LogError(ex, "基于热点新闻的选股过程中发生错误"); + _logger.LogError(ex, "基于热点新闻的投资选择过程中发生错误"); throw; } } /// - /// 功能4: 快速选股(预设策略) + /// 功能4: 快速选择(预设策略) /// - public async Task QuickSelectAsync( + public async Task QuickSelectAsync( QuickSelectionStrategy strategy, CancellationToken cancellationToken = default) { try { - _logger.LogInformation("开始执行快速选股,策略: {Strategy}", strategy); + _logger.LogInformation("开始执行快速选择,策略: {Strategy}", strategy); - // 业务逻辑:将策略转换为用户需求 var request = ConvertStrategyToUserRequest(strategy); - // 调用用户需求分析 - var result = await RecommendStocksByUserRequirementAsync(request, cancellationToken); + var result = await RecommendByUserRequirementAsync(request, cancellationToken); - _logger.LogInformation("快速选股完成,策略: {Strategy},结果长度: {Length}", + _logger.LogInformation("快速选择完成,策略: {Strategy},结果数量: {Count}", strategy, result.Recommendations.Count); return result; } catch (Exception ex) { - _logger.LogError(ex, "执行快速选股时发生错误,策略: {Strategy}", strategy); + _logger.LogError(ex, "执行快速选择时发生错误,策略: {Strategy}", strategy); throw; } } /// - /// 功能5: 获取快速选股策略列表 + /// 功能5: 获取快速选择策略列表 /// public List GetQuickSelectionStrategies() { @@ -136,55 +131,55 @@ public List GetQuickSelectionStrategies() { new QuickSelectionStrategyInfo { - Strategy = QuickSelectionStrategy.ValueStocks, - Name = "价值股筛选", + Strategy = QuickSelectionStrategy.ValueInvestment, + Name = "价值投资", Icon = "💎", - Description = "筛选PE低、PB低、ROE高的优质价值股", + Description = "筛选PE低、PB低、ROE高的优质价值标的", Scenario = "适合稳健型投资者,追求长期价值投资", RiskLevel = "低风险" }, new QuickSelectionStrategyInfo { - Strategy = QuickSelectionStrategy.GrowthStocks, - Name = "成长股筛选", + Strategy = QuickSelectionStrategy.GrowthInvestment, + Name = "成长投资", Icon = "🚀", - Description = "筛选营收和利润高增长的成长型股票", + Description = "筛选营收和利润高增长的成长型标的", Scenario = "适合积极型投资者,追求高成长收益", RiskLevel = "中高风险" }, new QuickSelectionStrategyInfo { - Strategy = QuickSelectionStrategy.ActiveStocks, - Name = "活跃股筛选", + Strategy = QuickSelectionStrategy.ActiveTrading, + Name = "活跃标的", Icon = "🔥", - Description = "筛选换手率高、成交活跃的热门股票", + Description = "筛选换手率高、成交活跃的热门标的", Scenario = "适合短线交易者,追求市场热点", RiskLevel = "高风险" }, new QuickSelectionStrategyInfo { Strategy = QuickSelectionStrategy.LargeCap, - Name = "大盘股筛选", + Name = "大盘标的", Icon = "🏢", - Description = "筛选市值大、业绩稳定的蓝筹股", + Description = "筛选市值大、业绩稳定的蓝筹标的", Scenario = "适合保守型投资者,追求稳定收益", RiskLevel = "低风险" }, new QuickSelectionStrategyInfo { Strategy = QuickSelectionStrategy.SmallCap, - Name = "小盘股筛选", + Name = "小盘标的", Icon = "🌱", - Description = "筛选市值较小、具有成长潜力的股票", + Description = "筛选市值较小、具有成长潜力的标的", Scenario = "适合风险偏好较高的投资者", RiskLevel = "高风险" }, new QuickSelectionStrategyInfo { - Strategy = QuickSelectionStrategy.Dividend, - Name = "高股息筛选", + Strategy = QuickSelectionStrategy.HighYield, + Name = "高收益", Icon = "💰", - Description = "筛选股息率高、分红稳定的股票", + Description = "筛选股息率/收益率高、分红/收益稳定的标的", Scenario = "适合追求稳定现金流的投资者", RiskLevel = "低风险" } @@ -195,20 +190,20 @@ public List GetQuickSelectionStrategies() #region 业务逻辑处理 - /// - /// 验证和规范化用户请求 - /// - private StockRecommendationRequest ValidateAndNormalizeUserRequest(StockRecommendationRequest request) + private InvestmentRecommendationRequest ValidateAndNormalizeUserRequest(InvestmentRecommendationRequest request) { - var normalized = new StockRecommendationRequest + var normalized = new InvestmentRecommendationRequest { + MarketType = request.MarketType, UserRequirements = request.UserRequirements?.Trim() ?? "", InvestmentAmount = request.InvestmentAmount, RiskPreference = NormalizeRiskPreference(request.RiskPreference), - InvestmentHorizon = request.InvestmentHorizon + InvestmentHorizon = request.InvestmentHorizon, + PreferredSectors = request.PreferredSectors, + ExcludedSectors = request.ExcludedSectors, + MaxRecommendations = request.MaxRecommendations }; - // 业务规则:设置默认值 if (string.IsNullOrWhiteSpace(normalized.RiskPreference)) { normalized.RiskPreference = "moderate"; @@ -217,36 +212,28 @@ private StockRecommendationRequest ValidateAndNormalizeUserRequest(StockRecommen return normalized; } - /// - /// 验证和规范化新闻请求 - /// - private NewsBasedSelectionRequest ValidateAndNormalizeNewsRequest(NewsBasedSelectionRequest request) + private NewsBasedInvestmentRequest ValidateAndNormalizeNewsRequest(NewsBasedInvestmentRequest request) { - var normalized = new NewsBasedSelectionRequest + var normalized = new NewsBasedInvestmentRequest { + MarketType = request.MarketType, NewsContent = request.NewsContent?.Trim() ?? "", - MaxRecommendations = Math.Max(1, Math.Min(10, request.MaxRecommendations)) // 限制在1-10只之间 + MaxRecommendations = Math.Max(1, Math.Min(10, request.MaxRecommendations)) }; return normalized; } - /// - /// 优化用户需求分析结果 - /// - private StockSelectionResult OptimizeUserBasedResult(StockSelectionResult result, StockRecommendationRequest request) + private InvestmentSelectionResult OptimizeUserBasedResult(InvestmentSelectionResult result, InvestmentRecommendationRequest request) { - // 业务逻辑:根据用户风险偏好调整推荐 if (request.RiskPreference == "conservative") { - // 保守型投资者,过滤掉高风险股票 result.Recommendations = result.Recommendations .Where(r => r.RiskLevel != RiskLevel.High) .ToList(); } else if (request.RiskPreference == "aggressive") { - // 激进型投资者,优先推荐高收益股票 result.Recommendations = result.Recommendations .OrderByDescending(r => r.ExpectedReturn ?? 0) .ToList(); @@ -255,12 +242,8 @@ private StockSelectionResult OptimizeUserBasedResult(StockSelectionResult result return result; } - /// - /// 优化新闻分析结果 - /// - private StockSelectionResult OptimizeNewsBasedResult(StockSelectionResult result, NewsBasedSelectionRequest request) + private InvestmentSelectionResult OptimizeNewsBasedResult(InvestmentSelectionResult result, NewsBasedInvestmentRequest request) { - // 业务逻辑:根据请求的最大推荐数量限制结果 if (result.Recommendations.Count > request.MaxRecommendations) { result.Recommendations = result.Recommendations @@ -268,7 +251,6 @@ private StockSelectionResult OptimizeNewsBasedResult(StockSelectionResult result .ToList(); } - // 业务逻辑:添加新闻相关性标识 foreach (var recommendation in result.Recommendations) { recommendation.Reason = $"[新闻热点] {recommendation.Reason}"; @@ -277,38 +259,32 @@ private StockSelectionResult OptimizeNewsBasedResult(StockSelectionResult result return result; } - /// - /// 将策略转换为用户请求 - /// - private StockRecommendationRequest ConvertStrategyToUserRequest(QuickSelectionStrategy strategy) + private InvestmentRecommendationRequest ConvertStrategyToUserRequest(QuickSelectionStrategy strategy) { var (requirements, riskPreference) = strategy switch { - QuickSelectionStrategy.ValueStocks => - ("请筛选价值股:PE低于20,PB低于3,ROE大于10%,负债率低于60%的优质价值股", "conservative"), - QuickSelectionStrategy.GrowthStocks => - ("请筛选成长股:营收增长率大于20%,净利润增长率大于15%,PEG小于1.5的高成长股", "aggressive"), - QuickSelectionStrategy.ActiveStocks => - ("请筛选活跃股:换手率大于2%,成交额大于5亿,量比大于1.5的活跃股票", "moderate"), + QuickSelectionStrategy.ValueInvestment => + ("请筛选价值型标的:PE低于20,PB低于3,ROE大于10%的优质价值标的", "conservative"), + QuickSelectionStrategy.GrowthInvestment => + ("请筛选成长型标的:营收增长率大于20%,净利润增长率大于15%的高成长标的", "aggressive"), + QuickSelectionStrategy.ActiveTrading => + ("请筛选活跃标的:换手率大于2%,成交额大,量比大于1.5的活跃标的", "moderate"), QuickSelectionStrategy.LargeCap => - ("请筛选大盘股:市值大于500亿,流动性好,业绩稳定的大盘蓝筹股", "conservative"), + ("请筛选大盘标的:市值大,流动性好,业绩稳定的大盘蓝筹标的", "conservative"), QuickSelectionStrategy.SmallCap => - ("请筛选小盘股:市值在50-200亿之间,具有成长潜力的优质小盘股", "aggressive"), - QuickSelectionStrategy.Dividend => - ("请筛选高股息股:股息率大于3%,连续分红3年以上,现金流稳定的高股息股票", "conservative"), - _ => throw new ArgumentException($"不支持的选股策略: {strategy}") + ("请筛选小盘标的:市值较小,具有成长潜力的优质小盘标的", "aggressive"), + QuickSelectionStrategy.HighYield => + ("请筛选高收益标的:股息率/收益率大于3%,分红/收益稳定的高收益标的", "conservative"), + _ => throw new ArgumentException($"不支持的选择策略: {strategy}") }; - return new StockRecommendationRequest + return new InvestmentRecommendationRequest { UserRequirements = requirements, RiskPreference = riskPreference }; } - /// - /// 规范化风险偏好 - /// private string NormalizeRiskPreference(string riskPreference) { return riskPreference?.ToLower() switch diff --git a/src/Applications/StockSelection/Models/StockRecommendation.cs b/src/Applications/InvestmentSelection/Models/InvestmentRecommendation.cs similarity index 76% rename from src/Applications/StockSelection/Models/StockRecommendation.cs rename to src/Applications/InvestmentSelection/Models/InvestmentRecommendation.cs index 8d873a9..e744961 100644 --- a/src/Applications/StockSelection/Models/StockRecommendation.cs +++ b/src/Applications/InvestmentSelection/Models/InvestmentRecommendation.cs @@ -1,25 +1,24 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; -namespace MarketAssistant.Applications.StockSelection.Models; +namespace MarketAssistant.Applications.InvestmentSelection.Models; /// -/// 股票推荐结果 +/// 投资标的推荐结果 /// -[Description("单只股票的推荐详情")] -public class StockRecommendation +[Description("单个投资标的的推荐详情")] +public class InvestmentRecommendation { /// - /// 股票代码 + /// 资产代码(股票如 SH600000,虚拟币如 BTC/USDT) /// - [RegularExpression(@"^(SH|SZ)\d{6}$", ErrorMessage = "股票代码格式错误,应为 SH000000 或 SZ000000")] - [Description("股票代码,格式如 SH600000、SZ000001")] + [Description("资产代码,格式:股票如 SH600000、SZ000001,虚拟币如 BTC/USDT、ETH/USDT")] public string Symbol { get; set; } = string.Empty; /// - /// 股票名称 + /// 资产名称 /// - [Description("股票名称")] + [Description("资产名称")] public string Name { get; set; } = string.Empty; /// @@ -67,15 +66,15 @@ public class StockRecommendation /// /// 目标价格 /// - [Range(0.01, 100000)] - [Description("目标价格,单位:元,范围0.01-100000,无法预测时设为null")] + [Range(0.01, 100000000)] + [Description("目标价格,单位:元或USDT,范围0.01-100000000,无法预测时设为null")] public decimal? TargetPrice { get; set; } /// /// 止损价格 /// - [Range(0.01, 100000)] - [Description("止损价格,单位:元,范围0.01-100000,无法预测时设为null")] + [Range(0.01, 100000000)] + [Description("止损价格,单位:元或USDT,范围0.01-100000000,无法预测时设为null")] public decimal? StopLoss { get; set; } } diff --git a/src/Applications/StockSelection/Models/StockRecommendationRequest.cs b/src/Applications/InvestmentSelection/Models/InvestmentRecommendationRequest.cs similarity index 73% rename from src/Applications/StockSelection/Models/StockRecommendationRequest.cs rename to src/Applications/InvestmentSelection/Models/InvestmentRecommendationRequest.cs index c7eef74..79e7a18 100644 --- a/src/Applications/StockSelection/Models/StockRecommendationRequest.cs +++ b/src/Applications/InvestmentSelection/Models/InvestmentRecommendationRequest.cs @@ -1,10 +1,17 @@ -namespace MarketAssistant.Applications.StockSelection.Models; +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Applications.InvestmentSelection.Models; /// -/// 股票推荐请求 +/// 投资推荐请求 /// -public class StockRecommendationRequest +public class InvestmentRecommendationRequest { + /// + /// 市场类型 + /// + public MarketType MarketType { get; set; } = MarketType.AShare; + /// /// 用户需求描述 /// @@ -36,7 +43,7 @@ public class StockRecommendationRequest public List ExcludedSectors { get; set; } = new(); /// - /// 最大推荐股票数量 + /// 最大推荐数量 /// public int MaxRecommendations { get; set; } = 10; } diff --git a/src/Applications/StockSelection/Models/StockSelectionEnums.cs b/src/Applications/InvestmentSelection/Models/InvestmentSelectionEnums.cs similarity index 91% rename from src/Applications/StockSelection/Models/StockSelectionEnums.cs rename to src/Applications/InvestmentSelection/Models/InvestmentSelectionEnums.cs index 192c2de..28839a3 100644 --- a/src/Applications/StockSelection/Models/StockSelectionEnums.cs +++ b/src/Applications/InvestmentSelection/Models/InvestmentSelectionEnums.cs @@ -2,7 +2,7 @@ using System.Runtime.Serialization; using System.Text.Json.Serialization; -namespace MarketAssistant.Applications.StockSelection.Models; +namespace MarketAssistant.Applications.InvestmentSelection.Models; /// /// 风险等级枚举 @@ -30,7 +30,7 @@ public enum RiskLevel } /// -/// 选股类型枚举 +/// 选择类型枚举 /// [JsonConverter(typeof(JsonStringEnumConverter))] public enum SelectionType @@ -49,3 +49,4 @@ public enum SelectionType [EnumMember(Value = "news_based")] NewsBased } + diff --git a/src/Applications/StockSelection/Models/StockSelectionResult.cs b/src/Applications/InvestmentSelection/Models/InvestmentSelectionResult.cs similarity index 74% rename from src/Applications/StockSelection/Models/StockSelectionResult.cs rename to src/Applications/InvestmentSelection/Models/InvestmentSelectionResult.cs index 7e6c6b6..bb76ee3 100644 --- a/src/Applications/StockSelection/Models/StockSelectionResult.cs +++ b/src/Applications/InvestmentSelection/Models/InvestmentSelectionResult.cs @@ -1,18 +1,18 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; -namespace MarketAssistant.Applications.StockSelection.Models; +namespace MarketAssistant.Applications.InvestmentSelection.Models; /// -/// 股票选择结果 +/// 投资选择结果 /// -[Description("股票选择分析结果,包含推荐股票列表和分析报告")] -public class StockSelectionResult +[Description("投资选择分析结果,包含推荐投资标的列表和分析报告")] +public class InvestmentSelectionResult { /// - /// 选股类型 + /// 选择类型 /// - [Description("选股类型:user_request(用户需求分析) 或 news_based(新闻分析)")] + [Description("选择类型:user_request(用户需求分析) 或 news_based(新闻分析)")] public SelectionType SelectionType { get; set; } /// @@ -32,10 +32,10 @@ public class StockSelectionResult public string MarketEnvironmentAnalysis { get; set; } = string.Empty; /// - /// 推荐股票列表 + /// 推荐投资标的列表 /// - [Description("推荐股票列表,按推荐优先级从高到低排序。若无合适股票则返回空数组[]")] - public List Recommendations { get; set; } = new(); + [Description("推荐投资标的列表,按推荐优先级从高到低排序。若无合适标的则返回空数组[]")] + public List Recommendations { get; set; } = new(); /// /// 风险提示 diff --git a/src/Applications/InvestmentSelection/Models/NewsBasedInvestmentRequest.cs b/src/Applications/InvestmentSelection/Models/NewsBasedInvestmentRequest.cs new file mode 100644 index 0000000..74efdac --- /dev/null +++ b/src/Applications/InvestmentSelection/Models/NewsBasedInvestmentRequest.cs @@ -0,0 +1,25 @@ +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Applications.InvestmentSelection.Models; + +/// +/// 热点新闻投资选择请求 +/// +public class NewsBasedInvestmentRequest +{ + /// + /// 市场类型 + /// + public MarketType MarketType { get; set; } = MarketType.AShare; + + /// + /// 用户提供的新闻内容 + /// + public string NewsContent { get; set; } = string.Empty; + + /// + /// 最大推荐数量 + /// + public int MaxRecommendations { get; set; } = 10; +} + diff --git a/src/Applications/StockSelection/Models/QuickSelectionStrategy.cs b/src/Applications/InvestmentSelection/Models/QuickSelectionStrategy.cs similarity index 76% rename from src/Applications/StockSelection/Models/QuickSelectionStrategy.cs rename to src/Applications/InvestmentSelection/Models/QuickSelectionStrategy.cs index e2e58b2..70902e1 100644 --- a/src/Applications/StockSelection/Models/QuickSelectionStrategy.cs +++ b/src/Applications/InvestmentSelection/Models/QuickSelectionStrategy.cs @@ -1,43 +1,43 @@ -namespace MarketAssistant.Applications.StockSelection.Models; +namespace MarketAssistant.Applications.InvestmentSelection.Models; /// -/// 快速选股策略枚举 +/// 快速选择策略枚举 /// public enum QuickSelectionStrategy { /// - /// 价值股 + /// 价值股/币 /// - ValueStocks, + ValueInvestment, /// - /// 成长股 + /// 成长股/币 /// - GrowthStocks, + GrowthInvestment, /// - /// 活跃股 + /// 活跃标的 /// - ActiveStocks, + ActiveTrading, /// - /// 大盘股 + /// 大盘标的 /// LargeCap, /// - /// 小盘股 + /// 小盘标的 /// SmallCap, /// - /// 高股息股 + /// 高股息/高收益 /// - Dividend + HighYield } /// -/// 快速选股策略信息 +/// 快速选择策略信息 /// public class QuickSelectionStrategyInfo { diff --git a/src/Applications/News/NewsUpdateService.cs b/src/Applications/News/NewsUpdateService.cs index e3bd647..ca95749 100644 --- a/src/Applications/News/NewsUpdateService.cs +++ b/src/Applications/News/NewsUpdateService.cs @@ -10,7 +10,7 @@ namespace MarketAssistant.Applications.News; /// public class NewsUpdateService : INewsUpdateService { - private readonly TelegramService _telegramService; + private readonly ITelegramService _telegramService; private readonly ILogger _logger; private System.Timers.Timer? _updateTimer; private bool _disposed; @@ -20,7 +20,7 @@ public class NewsUpdateService : INewsUpdateService public bool IsRunning => _updateTimer?.Enabled ?? false; - public NewsUpdateService(TelegramService telegramService, ILogger logger) + public NewsUpdateService(ITelegramService telegramService, ILogger logger) { _telegramService = telegramService; _logger = logger; diff --git a/src/Applications/README_CRYPTO_SERVICES.md b/src/Applications/README_CRYPTO_SERVICES.md new file mode 100644 index 0000000..fbbccc0 --- /dev/null +++ b/src/Applications/README_CRYPTO_SERVICES.md @@ -0,0 +1,269 @@ +# 虚拟币服务实现说明 + +## 概述 + +基于[币安 API](https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/rest-api)完整实现了虚拟币市场的所有核心服务,使应用支持加密货币交易对的搜索、行情查看、K线图表、收藏管理等功能。 + +## 已实现服务列表 + +### 1. CryptoAssetInfoService ✅ +**路径**: `src/Applications/Assets/CryptoAssetInfoService.cs` + +**功能**: +- ✅ 搜索虚拟币交易对(基于币安 exchangeInfo API) +- ✅ 获取虚拟币详细信息(基于币安 ticker/24hr API) +- ✅ 获取热门虚拟币(按24小时交易量排序) + +**特性**: +- 自动格式化交易对代码(BTC → BTCUSDT) +- 缓存交易对列表(1小时过期) +- 智能价格格式化(根据价格大小选择精度) +- 交易量格式化(K/M/B 单位) + +**API 使用**: +``` +GET /api/v3/exchangeInfo # 搜索交易对 +GET /api/v3/ticker/24hr # 获取价格统计 +``` + +### 2. CryptoHomeService ✅ +**路径**: `src/Applications/Home/CryptoHomeService.cs` + +**功能**: +- ✅ 搜索虚拟币 +- ✅ 获取热门虚拟币 +- ✅ 获取最近查看记录 +- ✅ 添加到最近查看 +- ✅ 添加到收藏 + +**依赖**: +- `IAssetInfoService` (Keyed: Crypto) +- `IAssetHistoryService` (Keyed: Crypto) +- `IFavoriteService` (Keyed: Crypto) + +### 3. CryptoChartService ✅ +**路径**: `src/Applications/Charts/CryptoChartService.cs` + +**功能**: +- ✅ 根据 KLineType 获取对应周期的K线数据 +- ✅ 支持 4 种周期:15分钟、日K、周K、月K + +**实现方式**: +包装 `CryptoKLineService`,根据 `KLineType` 枚举调用对应方法。 + +### 4. CryptoKLineService ✅ +**路径**: `src/Applications/Charts/CryptoKLineService.cs` + +**功能**: +- ✅ 支持 8 种时间周期(1m, 5m, 15m, 30m, 1h, 1d, 1w, 1M) +- ✅ 自动计算涨跌额和涨跌幅 +- ✅ 实时数据,无缓存 + +**API 使用**: +``` +GET /api/v3/klines?symbol={SYMBOL}&interval={INTERVAL}&limit={LIMIT} +``` + +**详细文档**: [README_CRYPTO.md](./Charts/README_CRYPTO.md) + +### 5. CryptoFavoriteService ✅ +**路径**: `src/Applications/Favorites/CryptoFavoriteService.cs` + +**功能**: +- ✅ 添加虚拟币到收藏 +- ✅ 从收藏中移除 +- ✅ 检查是否已收藏 +- ✅ 获取收藏列表 +- ✅ 获取收藏虚拟币的最新数据 +- ✅ 清空所有收藏 + +**存储**: +- 使用 `Preferences.Default`(MAUI 本地存储) +- 存储 Key: `FavoriteAssets_Crypto` +- 数据格式: JSON 序列化的 `List` + +### 6. CryptoHistoryService ✅ +**路径**: `src/Applications/History/CryptoHistoryService.cs` + +**功能**: +- ✅ 添加到历史记录 +- ✅ 获取历史记录 +- ✅ 清空历史记录 +- ✅ 最多保留 10 条记录(FIFO) + +**存储**: +- 使用 `Preferences.Default` +- 存储 Key: `RecentAssets_Crypto` +- 数据格式: JSON 序列化的 `List` + +### 7. CryptoTelegramService ✅ +**路径**: `src/Applications/Telegrams/CryptoTelegramService.cs` + +**功能**: +- ✅ 获取虚拟币市场快讯(基于 CryptoCompare API) +- ✅ 标注重要新闻 +- ✅ 提取相关币种符号 + +**API 使用**: +``` +GET https://min-api.cryptocompare.com/data/v2/news/?lang=EN +``` + +**注意**: 使用免费的 CryptoCompare API,无需 API Key。 + +## 数据流程图 + +``` +首页 (HomePageView) + ├── 搜索 → CryptoHomeService.SearchAssetAsync() + │ └── CryptoAssetInfoService.SearchAsync() + │ └── 币安 API: /api/v3/exchangeInfo + │ + ├── 热门 → CryptoHomeService.GetHotAssetsAsync() + │ └── CryptoAssetInfoService.GetHotAssetsAsync() + │ └── 币安 API: /api/v3/ticker/24hr + │ + └── 最近 → CryptoHomeService.GetRecentAssets() + └── CryptoHistoryService.GetHistory() + └── 本地存储 (Preferences) + +资产详情页 (AssetPageView) + ├── 基本信息 → CryptoAssetInfoService.GetAssetInfoAsync() + │ └── 币安 API: /api/v3/ticker/24hr + │ + └── K线图表 → CryptoChartService.GetKLineDataAsync() + └── CryptoKLineService.Get{Period}KLineDataAsync() + └── 币安 API: /api/v3/klines + +收藏页 (FavoritesPageView) + └── 收藏列表 → CryptoFavoriteService.GetFavoritesWithLatestDataAsync() + ├── 本地存储 (Preferences) + └── 批量获取最新数据 (并发请求) +``` + +## 技术特点 + +### 1. 无需 API Key ⭐ +所有币安公开市场数据接口无需 API Key,直接访问。 + +### 2. 数据隔离 ⭐ +- A股和虚拟币使用不同的存储 Key +- 收藏: `FavoriteAssets_AShare` vs `FavoriteAssets_Crypto` +- 历史: `RecentAssets_AShare` vs `RecentAssets_Crypto` + +### 3. 实时数据 ⭐ +- K线数据不使用缓存,确保实时性 +- 交易对列表缓存 1 小时(变化频率低) + +### 4. 友好的错误处理 ⭐ +- 使用 `FriendlyException` 提供用户友好的错误信息 +- 完整的日志记录 +- 优雅降级(网络错误返回空列表) + +### 5. 性能优化 ⭐ +- 并发获取收藏列表的最新数据 +- HTTP 超时设置(30秒) +- 智能数据格式化 + +## 使用示例 + +### 搜索虚拟币 + +```csharp +var assetInfoService = serviceProvider + .GetRequiredKeyedService(MarketType.Crypto); + +var results = await assetInfoService.SearchAsync("BTC"); +// 返回: [(BTC, BTCUSDT), (BTC, BTCBUSD), ...] +``` + +### 获取K线数据 + +```csharp +var chartService = serviceProvider + .GetRequiredKeyedService(MarketType.Crypto); + +var klineData = await chartService.GetKLineDataAsync("BTCUSDT", KLineType.Daily, 100); +// 返回: 最近100天的日K线数据 +``` + +### 添加到收藏 + +```csharp +var favoriteService = serviceProvider + .GetRequiredKeyedService(MarketType.Crypto); + +favoriteService.AddFavorite("BTCUSDT", ""); +// 虚拟币的 market 参数使用空字符串 +``` + +## 限制与注意事项 + +### 币安 API 限流 +- **请求频率**: 1200次/分钟(单IP) +- **权重限制**: 6000/分钟 +- **建议**: 避免频繁请求,间隔 > 1 秒 + +### 数据格式 +- **交易对**: 必须大写(BTCUSDT) +- **价格**: 字符串格式,需要解析为 decimal +- **时间戳**: Unix 毫秒,需要转换为 DateTime + +### 网络要求 +- **超时**: 30秒 +- **重试**: 未实现,由调用方处理 +- **代理**: 可通过 HttpClient 配置 + +## 未实现功能 + +以下服务虚拟币版本暂未实现(占位): + +- ❌ `CryptoScreenerService` - 资产筛选(需要更复杂的筛选逻辑) +- ❌ `CryptoCacheService` - 缓存服务(不需要,实时数据) +- ❌ Crypto Agent Tools - AI 工具集(需要额外开发) + +## 测试建议 + +### 手动测试清单 + +- [ ] 搜索 "BTC" 能返回相关交易对 +- [ ] 热门虚拟币能正常显示(8个) +- [ ] 点击虚拟币卡片能跳转详情页 +- [ ] 详情页 K线图能正常显示 +- [ ] 收藏功能能正常添加/移除 +- [ ] 历史记录能正常记录和显示 +- [ ] 市场切换后数据正确刷新 + +### 性能测试 + +- [ ] 搜索响应时间 < 2秒 +- [ ] 热门列表加载时间 < 3秒 +- [ ] K线数据加载时间 < 5秒 +- [ ] 收藏列表刷新时间 < 10秒(8个并发) + +## 参考文档 + +- [币安 REST API 文档](https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/rest-api) +- [K线数据详细说明](./Charts/README_CRYPTO.md) +- [枚举定义](https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/enums) +- [错误代码](https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/error-codes) + +## 更新日志 + +### v1.0.0 (2025-01-04) +- ✅ 完整实现 6 个核心服务 +- ✅ 支持搜索、热门、详情、K线、收藏、历史 +- ✅ 基于币安公开 API,无需认证 +- ✅ 完整的错误处理和日志 +- ✅ 编译通过,无错误 + +## 下一步 + +建议按以下顺序继续开发: + +1. **UI 集成测试** - 确保虚拟币市场在UI层正常工作 +2. **添加单元测试** - 为核心服务添加测试用例 +3. **性能优化** - 根据实际使用情况调整 +4. **完善 Agent Tools** - 实现虚拟币相关的 AI 工具集 +5. **添加更多交易对** - 支持 BTC、ETH 等基础交易对 + diff --git a/src/Applications/Settings/UserSetting.cs b/src/Applications/Settings/UserSetting.cs index bc536f4..aa26482 100644 --- a/src/Applications/Settings/UserSetting.cs +++ b/src/Applications/Settings/UserSetting.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using System.Text.Json.Serialization; +using MarketAssistant.Infrastructure.Core; namespace MarketAssistant.Applications.Settings; @@ -38,6 +39,21 @@ public string KnowledgeFileDirectory public string ZhiTuApiToken { get; set; } = ""; + /// + /// 当前市场类型 + /// + public MarketType CurrentMarketType { get; set; } = MarketType.AShare; + + /// + /// 币安API密钥 + /// + public string BinanceApiKey { get; set; } = ""; + + /// + /// 币安Secret密钥 + /// + public string BinanceSecretKey { get; set; } = ""; + /// /// 浏览器路径,如果为空则自动检测 /// diff --git a/src/Applications/StockSelection/Models/NewsBasedSelectionRequest.cs b/src/Applications/StockSelection/Models/NewsBasedSelectionRequest.cs deleted file mode 100644 index 090d554..0000000 --- a/src/Applications/StockSelection/Models/NewsBasedSelectionRequest.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace MarketAssistant.Applications.StockSelection.Models; - -/// -/// 热点新闻选股请求 -/// -public class NewsBasedSelectionRequest -{ - /// - /// 用户提供的新闻内容 - /// - public string NewsContent { get; set; } = string.Empty; - - /// - /// 最大推荐股票数量 - /// - public int MaxRecommendations { get; set; } = 10; -} - diff --git a/src/Applications/Stocks/HomeStockService.cs b/src/Applications/Stocks/HomeStockService.cs deleted file mode 100644 index 03004f8..0000000 --- a/src/Applications/Stocks/HomeStockService.cs +++ /dev/null @@ -1,157 +0,0 @@ -using MarketAssistant.Applications.Stocks.Models; -using MarketAssistant.Services.Dialog; -using Microsoft.Extensions.Logging; - -namespace MarketAssistant.Applications.Stocks; - -/// -/// 主页股票相关服务实现 -/// -public class HomeStockService : IHomeStockService -{ - private readonly StockService _stockService; - private readonly StockSearchHistory _searchHistory; - private readonly StockFavoriteService _favoriteService; - private readonly IDialogService _dialogService; - private readonly ILogger _logger; - - public HomeStockService( - StockService stockService, - StockSearchHistory searchHistory, - StockFavoriteService favoriteService, - IDialogService dialogService, - ILogger logger) - { - _stockService = stockService; - _searchHistory = searchHistory; - _favoriteService = favoriteService; - _dialogService = dialogService; - _logger = logger; - } - - /// - /// 搜索股票 - /// - public async Task> SearchStockAsync(string query, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(query)) - return new List(); - - try - { - var results = await _stockService.SearchStockAsync(query, cancellationToken); - return results.Select(stock => new StockItem { Name = stock.Name, Code = stock.Code }).ToList(); - } - catch (Exception ex) - { - _logger?.LogError(ex, "搜索股票时出错,查询:{Query}", query); - return new List(); - } - } - - /// - /// 获取热门股票 - /// - public async Task> GetHotStocksAsync() - { - try - { - return await _stockService.GetHotStocksAsync(); - } - catch (Exception ex) - { - _logger?.LogError(ex, "获取热门股票时出错"); - return new List(); - } - } - - /// - /// 获取最近查看的股票 - /// - public List GetRecentStocks() - { - try - { - return _searchHistory.GetSearchHistory(); - } - catch (Exception ex) - { - _logger?.LogError(ex, "获取最近查看股票时出错"); - return new List(); - } - } - - /// - /// 添加到最近查看 - /// - public void AddToRecentStocks(StockItem stock) - { - try - { - _searchHistory.AddSearchHistory(stock); - } - catch (Exception ex) - { - _logger?.LogError(ex, "添加到最近查看时出错,股票:{StockName}", stock?.Name); - } - } - - /// - /// 添加到收藏 - /// - public async Task AddToFavoriteAsync(object stockParameter) - { - try - { - string stockName = ""; - string code = ""; - string market = ""; - - if (stockParameter is HotStock hotStock) - { - stockName = hotStock.Name; - code = hotStock.Code; - market = hotStock.Market; - } - else if (stockParameter is StockItem stockItem) - { - stockName = stockItem.Name; - code = stockItem.Code; - - // 尝试从股票代码中提取市场代码 - if (code.StartsWith("sh") || code.StartsWith("sz")) - { - market = code.Substring(0, 2).ToUpper(); - code = code.Substring(2); - } - } - else - { - return false; - } - - // 显示确认对话框 - bool confirmed = await _dialogService.ShowConfirmationAsync( - "添加收藏", - $"确定要将 {stockName} 添加到收藏列表吗?", - "确认", - "取消"); - - if (confirmed) - { - _favoriteService.AddFavorite(code, market); - await _dialogService.ShowMessageAsync("收藏成功", $"已将 {stockName} 添加到收藏列表"); - return true; - } - - return false; - } - catch (Exception ex) - { - _logger?.LogError(ex, "添加收藏时出错"); - await _dialogService.ShowMessageAsync("收藏失败", "添加收藏时发生错误,请稍后重试"); - return false; - } - } -} - diff --git a/src/Applications/Stocks/IHomeStockService.cs b/src/Applications/Stocks/IHomeStockService.cs deleted file mode 100644 index 4f8ed83..0000000 --- a/src/Applications/Stocks/IHomeStockService.cs +++ /dev/null @@ -1,35 +0,0 @@ -using MarketAssistant.Applications.Stocks.Models; - -namespace MarketAssistant.Applications.Stocks; - -/// -/// 主页股票相关服务接口 -/// -public interface IHomeStockService -{ - /// - /// 搜索股票 - /// - Task> SearchStockAsync(string query, CancellationToken cancellationToken = default); - - /// - /// 获取热门股票 - /// - Task> GetHotStocksAsync(); - - /// - /// 获取最近查看的股票 - /// - List GetRecentStocks(); - - /// - /// 添加到最近查看 - /// - void AddToRecentStocks(StockItem stock); - - /// - /// 添加到收藏 - /// - Task AddToFavoriteAsync(object stockParameter); -} - diff --git a/src/Applications/Stocks/Models/FavoriteStock.cs b/src/Applications/Stocks/Models/FavoriteStock.cs deleted file mode 100644 index bfa0f0b..0000000 --- a/src/Applications/Stocks/Models/FavoriteStock.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace MarketAssistant.Applications.Stocks.Models; - -public class FavoriteStock -{ - /// - /// 股票代码 - /// - public string Code { get; set; } = string.Empty; - - /// - /// 市场代码 - /// - public string Market { get; set; } = string.Empty; - - /// - /// 获取完整股票代码(市场+代码) - /// - public string FullCode => $"{Market}{Code}".ToLower(); -} \ No newline at end of file diff --git a/src/Applications/Stocks/Models/StockInfo.cs b/src/Applications/Stocks/Models/StockInfo.cs deleted file mode 100644 index 0db4b28..0000000 --- a/src/Applications/Stocks/Models/StockInfo.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace MarketAssistant.Applications.Stocks.Models; - -/// -/// 股票基本信息类 -/// -public class StockInfo -{ - /// - /// 股票代码 - /// - public string Code { get; set; } = string.Empty; - - /// - /// 股票名称 - /// - public string Name { get; set; } = string.Empty; - - /// - /// 市场代码 - /// - public string Market { get; set; } = string.Empty; - - /// - /// 当前价格 - /// - public string CurrentPrice { get; set; } = string.Empty; - - /// - /// 涨跌幅 - /// - public string ChangePercentage { get; set; } = string.Empty; - - /// - /// 所属板块名称 - /// - public string SectorName { get; set; } = string.Empty; - - /// - /// 获取完整股票代码(市场+代码) - /// - public string FullCode => $"{Market}{Code}".ToLower(); -} \ No newline at end of file diff --git a/src/Applications/Stocks/Models/StockItem.cs b/src/Applications/Stocks/Models/StockItem.cs deleted file mode 100644 index 5fa9fef..0000000 --- a/src/Applications/Stocks/Models/StockItem.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace MarketAssistant.Applications.Stocks.Models; - -public class StockItem -{ - public string Name { get; set; } = ""; - public string Code { get; set; } = ""; - - public override string ToString() - { - return Name; - } -} diff --git a/src/Applications/Stocks/StockFavoriteService.cs b/src/Applications/Stocks/StockFavoriteService.cs deleted file mode 100644 index 40325af..0000000 --- a/src/Applications/Stocks/StockFavoriteService.cs +++ /dev/null @@ -1,185 +0,0 @@ -using CommunityToolkit.Mvvm.Messaging; -using MarketAssistant.Infrastructure.Configuration; -using MarketAssistant.Applications.Stocks.Models; -using MarketAssistant.Infrastructure.Configuration; -using MarketAssistant.Infrastructure; -using MarketAssistant.Infrastructure.Configuration; -using Microsoft.Extensions.Logging; -using MarketAssistant.Infrastructure.Configuration; -using System.Text.Json; -using MarketAssistant.Infrastructure.Configuration; - -namespace MarketAssistant.Applications.Stocks; - -public record StockFavoritesChanged(); - -public class StockFavoriteService -{ - private const string PreferenceKey = "FavoriteStocks"; - private readonly StockService _stockService; - private readonly ILogger _logger; - - public StockFavoriteService(StockService stockService, ILogger logger) - { - _stockService = stockService; - _logger = logger; - } - - /// - /// 添加股票到收藏 - /// - /// 股票代码 - /// 市场代码 - /// 股票名称 - public void AddFavorite(string code, string market) - { - if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(market)) - return; - - var favoriteList = GetFavoritesCodes(); - - // 检查是否已经收藏过 - var existingItem = favoriteList.FirstOrDefault(x => x.Code == code && x.Market == market); - if (existingItem != null) - return; // 已经收藏过,不重复添加 - - // 添加到收藏列表 - favoriteList.Add(new FavoriteStock { Code = code, Market = market }); - - // 保存到本地存储 - SaveFavorites(favoriteList); - WeakReferenceMessenger.Default.Send(new StockFavoritesChanged()); - } - - /// - /// 从收藏中移除股票 - /// - /// 要移除的股票 - public void RemoveFavorite(string code, string market) - { - var favoriteList = GetFavoritesCodes(); - - // 查找并移除匹配的股票 - var itemToRemove = favoriteList.FirstOrDefault(x => x.Code == code && x.Market == market); - if (itemToRemove != null) - { - favoriteList.Remove(itemToRemove); - SaveFavorites(favoriteList); - WeakReferenceMessenger.Default.Send(new StockFavoritesChanged()); - } - } - - /// - /// 检查股票是否已收藏 - /// - /// 股票代码 - /// 市场代码 - /// 是否已收藏 - public bool IsFavorite(string code, string market) - { - var favoriteList = GetFavoritesCodes(); - return favoriteList.Any(x => x.Code == code && x.Market == market); - } - - /// - /// 获取所有收藏的股票代码 - /// - /// 收藏的股票代码列表 - public List GetFavoritesCodes() - { - try - { - var json = Preferences.Default.Get(PreferenceKey, string.Empty); - if (string.IsNullOrWhiteSpace(json)) - return new List(); - - var favoriteList = JsonSerializer.Deserialize>(json); - return favoriteList ?? new List(); - } - catch (Exception ex) - { - _logger.LogError(ex, $"获取收藏股票时出错: {ex.Message}"); - return new List(); - } - } - - /// - /// 获取所有收藏的股票(包含最新数据) - /// - /// 收藏的股票列表(带最新数据) - public async Task> GetFavoritesWithLatestDataAsync(CancellationToken cancellationToken = default) - { - var favoritesCodes = GetFavoritesCodes(); - if (favoritesCodes.Count == 0) - return new(); - - // 创建更新后的收藏股票列表 - var stockInfos = new List(); - - try - { - // 创建任务列表,为每个收藏的股票并行获取最新数据 - var tasks = new List>(); - - // 为每个收藏的股票创建一个任务 - foreach (var favorite in favoritesCodes) - { - var task = Task.Run(async () => - { - try - { - // 使用GetStockInfoAsync获取最新股票数据 - return await _stockService.GetStockInfoAsync(favorite.Code, favorite.Market, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, $"获取股票 {favorite.Code} 最新数据时出错: {ex.Message}"); - // 发生错误时返回基本信息 - return new StockInfo { Code = favorite.Code, Market = favorite.Market, Name = $"{favorite.Market}.{favorite.Code}" }; - } - }); - - tasks.Add(task); - } - - // 等待所有任务完成 - var results = await Task.WhenAll(tasks); - - // 将结果添加到列表中 - stockInfos.AddRange(results.Where(r => r != null)); - - return stockInfos; - } - catch (Exception ex) - { - _logger.LogError(ex, $"获取收藏股票最新数据时出错: {ex.Message}"); - return stockInfos; // 返回原始数据,不带最新价格和涨跌幅 - } - } - - /// - /// 清空所有收藏 - /// - public void ClearFavorites() - { - SaveFavorites(new List()); - WeakReferenceMessenger.Default.Send(new StockFavoritesChanged()); - } - - /// - /// 保存收藏列表到本地存储 - /// - /// 要保存的收藏列表 - private void SaveFavorites(List favoriteList) - { - try - { - var json = JsonSerializer.Serialize(favoriteList); - Preferences.Default.Set(PreferenceKey, json); - } - catch (Exception ex) - { - _logger.LogError(ex, $"保存收藏股票时出错: {ex.Message}"); - } - } -} diff --git a/src/Applications/Stocks/StockInfoCache.cs b/src/Applications/Stocks/StockInfoCache.cs deleted file mode 100644 index 0988408..0000000 --- a/src/Applications/Stocks/StockInfoCache.cs +++ /dev/null @@ -1,96 +0,0 @@ -using MarketAssistant.Applications.Stocks.Models; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; - -namespace MarketAssistant.Applications.Stocks; - -/// -/// 股票信息缓存服务 -/// -public class StockInfoCache -{ - private readonly IMemoryCache _cache; - private readonly ILogger _logger; - private const int CacheExpirationMinutes = 5; // 缓存5分钟 - - public StockInfoCache(IMemoryCache cache, ILogger logger) - { - _cache = cache; - _logger = logger; - } - - /// - /// 获取缓存的股票信息 - /// - public StockInfo? Get(string code, string market) - { - var cacheKey = GetCacheKey(code, market); - if (_cache.TryGetValue(cacheKey, out StockInfo? stockInfo)) - { - _logger.LogDebug($"从缓存获取股票信息: {code} ({market})"); - return stockInfo; - } - return null; - } - - /// - /// 设置股票信息到缓存 - /// - public void Set(StockInfo stockInfo) - { - var cacheKey = GetCacheKey(stockInfo.Code, stockInfo.Market); - var cacheOptions = new MemoryCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(CacheExpirationMinutes), - Size = 1 // 用于缓存大小限制 - }; - - _cache.Set(cacheKey, stockInfo, cacheOptions); - _logger.LogDebug($"缓存股票信息: {stockInfo.Code} ({stockInfo.Market})"); - } - - /// - /// 批量设置股票信息到缓存 - /// - public void SetRange(IEnumerable stockInfos) - { - foreach (var stockInfo in stockInfos) - { - Set(stockInfo); - } - } - - /// - /// 清除指定股票的缓存 - /// - public void Remove(string code, string market) - { - var cacheKey = GetCacheKey(code, market); - _cache.Remove(cacheKey); - _logger.LogDebug($"清除股票缓存: {code} ({market})"); - } - - /// - /// 清除所有缓存 - /// - public void Clear() - { - // MemoryCache 不支持清除所有条目,只能通过重新创建 - // 这里只是记录日志 - _logger.LogInformation("清除股票缓存"); - } - - private static string GetCacheKey(string code, string market) - { - return $"StockInfo_{market}_{code}"; - } -} - - - - - - - diff --git a/src/Applications/Stocks/StockKLineService.cs b/src/Applications/Stocks/StockKLineService.cs deleted file mode 100644 index 521b565..0000000 --- a/src/Applications/Stocks/StockKLineService.cs +++ /dev/null @@ -1,497 +0,0 @@ -using MarketAssistant.Applications.Stocks.Models; -using MarketAssistant.Infrastructure; -using MarketAssistant.Infrastructure.Core; -using MarketAssistant.Services.Settings; -using Microsoft.Extensions.Logging; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace MarketAssistant.Applications.Stocks; - -public class StockKLineService -{ - private readonly HttpClient _httpClient; - private readonly IUserSettingService _userSettingService; - private readonly ILogger _logger; - private const string ZHITU_API_BASE_URL = "https://api.zhituapi.com/hs/history"; - - public StockKLineService(ILogger logger, IUserSettingService userSettingService) - { - _httpClient = new HttpClient(); - _userSettingService = userSettingService; - _logger = logger; - } - - #region 通用辅助方法 - - /// - /// 验证K线参数 - /// - /// 股票代码 - private void ValidateKLineParameters(string symbol) - { - if (string.IsNullOrEmpty(symbol)) - { - throw new ArgumentException("股票代码参数必须提供"); - } - } - - /// - /// 构建zhituapi请求URL - /// - /// 股票代码(如000001.SZ) - /// 分时级别(如d、w、m、y、1、5、15、30、60) - /// 除权方式(n、f、b、fr、br) - /// 开始时间 - /// 结束时间 - /// 完整的API URL - private string BuildZhiTuApiUrl(string symbol, string interval, string adjustType = "n", DateTime? startDate = null, DateTime? endDate = null) - { - var token = _userSettingService.CurrentSetting.ZhiTuApiToken; - var url = $"{ZHITU_API_BASE_URL}/{symbol}/{interval}/{adjustType}?token={token}"; - - // 如果没有指定时间范围,根据不同的interval设置合理的默认时间范围 - DateTime defaultStartDate; - DateTime defaultEndDate = DateTime.Now; - - if (!startDate.HasValue && !endDate.HasValue) - { - switch (interval.ToLower()) - { - case "d": // 日K线,默认查询最近1年 - defaultStartDate = DateTime.Now.AddMonths(-6); - break; - case "w": // 周K线,默认查询最近2年 - defaultStartDate = DateTime.Now.AddYears(-1); - break; - case "m": // 月K线,默认查询最近5年 - defaultStartDate = DateTime.Now.AddYears(-3); - break; - case "y": // 年K线,默认查询最近10年 - defaultStartDate = DateTime.Now.AddYears(-10); - break; - case "1": - case "5": - case "15": - case "30": - case "60": // 分钟级别数据,默认查询最近30天 - defaultStartDate = DateTime.Now.AddDays(-30); - break; - default: // 其他情况,默认查询最近1年 - defaultStartDate = DateTime.Now.AddYears(-1); - break; - } - - startDate = defaultStartDate; - endDate = defaultEndDate; - } - - if (startDate.HasValue) - { - url += $"&st={startDate.Value:yyyyMMdd}"; - } - - if (endDate.HasValue) - { - url += $"&et={endDate.Value:yyyyMMdd}"; - } - - return url; - } - - /// - /// 从zhituapi获取数据 - /// - /// API URL - /// 数据类型描述 - /// 股票代码 - /// API响应数据 - private async Task> FetchZhiTuDataAsync(string url, string dataType, string symbol) - { - _logger.LogInformation("正在获取股票{DataType}数据: 股票代码: {Symbol}", dataType, symbol); - - try - { - var response = await _httpClient.GetAsync(url); - response.EnsureSuccessStatusCode(); - - var jsonContent = await response.Content.ReadAsStringAsync(); - var zhiTuData = JsonSerializer.Deserialize>(jsonContent); - - if (zhiTuData == null || !zhiTuData.Any()) - { - throw new FriendlyException($"获取{dataType}数据失败: 返回数据为空"); - } - - return zhiTuData; - } - catch (HttpRequestException ex) - { - throw new FriendlyException($"获取{dataType}数据失败: 网络请求错误 - {ex.Message}", ex); - } - catch (JsonException ex) - { - throw new FriendlyException($"获取{dataType}数据失败: 数据解析错误 - {ex.Message}", ex); - } - } - - /// - /// 解析zhituapi K线数据 - /// - /// zhituapi响应数据 - /// K线数据集 - private void ParseZhiTuKLineData(List zhiTuData, StockKLineDataSet klineDataSet) - { - foreach (var item in zhiTuData) - { - // 解析时间戳 - if (DateTime.TryParse(item.T, out DateTime timestamp)) - { - var klineData = new StockKLineData - { - Timestamp = timestamp, - Open = item.O, - High = item.H, - Low = item.L, - Close = item.C, - Volume = item.V, - Amount = item.A, - PreClose = item.Pc - }; - - // 计算涨跌额和涨跌幅 - if (item.Pc > 0) - { - klineData.Change = item.C - item.Pc; - klineData.PctChg = (klineData.Change / item.Pc) * 100; - } - else - { - klineData.Change = 0; - klineData.PctChg = 0; - } - - klineDataSet.Data.Add(klineData); - } - } - - // 按日期时间排序(从旧到新) - klineDataSet.Data = klineDataSet.Data.OrderBy(x => x.Timestamp).ToList(); - } - - /// - /// 记录错误并抛出异常 - /// - /// 数据类型 - /// 股票代码 - /// 异常 - private void LogAndThrowException(string dataType, string symbol, Exception ex) - { - // 构建错误日志信息 - string errorInfo = !string.IsNullOrEmpty(symbol) ? $"股票代码: {symbol}" : ""; - - _logger.LogError(ex, "获取{DataType}数据时发生错误 - {ErrorInfo}", dataType, errorInfo); - throw new FriendlyException($"获取{dataType}数据失败: {ex.Message}", ex); - } - - #endregion - - /// - /// 从zhituapi获取日K线数据 - /// - /// 股票代码(如000001.SZ) - /// 开始日期,默认为null - /// 结束日期,默认为null - /// 除权方式(n=不复权,f=前复权,b=后复权,fr=等比前复权,br=等比后复权),默认为n - /// K线数据集合 - public async Task GetDailyKLineDataAsync(string symbol, DateTime? startDate = null, DateTime? endDate = null, string adjustType = "n") - { - try - { - // 验证参数 - ValidateKLineParameters(symbol); - - // 处理股票代码格式 - string formattedSymbol = StockSymbolConverter.ToZhiTuFormat(symbol); - - // 构建API URL - string apiUrl = BuildZhiTuApiUrl(formattedSymbol, "d", adjustType, startDate, endDate); - - // 发送请求并获取数据 - var zhiTuData = await FetchZhiTuDataAsync(apiUrl, "日K线", symbol); - - // 转换为应用程序数据模型 - var klineDataSet = new StockKLineDataSet - { - Symbol = symbol, - Interval = "daily", - Data = new List() - }; - - // 解析数据 - ParseZhiTuKLineData(zhiTuData, klineDataSet); - - return klineDataSet; - } - catch (Exception ex) - { - LogAndThrowException("日K线", symbol, ex); - throw; // LogAndThrowException 已经抛出异常,这里不会执行到 - } - } - - /// - /// 从zhituapi获取周K线数据 - /// - /// 股票代码(如000001.SZ) - /// 开始日期,默认为null - /// 结束日期,默认为null - /// 除权方式(n=不复权,f=前复权,b=后复权,fr=等比前复权,br=等比后复权),默认为n - /// K线数据集合 - public async Task GetWeeklyKLineDataAsync(string symbol, DateTime? startDate = null, DateTime? endDate = null, string adjustType = "n") - { - try - { - // 验证参数 - ValidateKLineParameters(symbol); - - // 处理股票代码格式 - string formattedSymbol = StockSymbolConverter.ToZhiTuFormat(symbol); - - // 构建API URL - string apiUrl = BuildZhiTuApiUrl(formattedSymbol, "w", adjustType, startDate, endDate); - - // 发送请求并获取数据 - var zhiTuData = await FetchZhiTuDataAsync(apiUrl, "周K线", symbol); - - // 转换为应用程序数据模型 - var klineDataSet = new StockKLineDataSet - { - Symbol = symbol, - Interval = "weekly", - Data = new List() - }; - - // 解析数据 - ParseZhiTuKLineData(zhiTuData, klineDataSet); - - return klineDataSet; - } - catch (Exception ex) - { - LogAndThrowException("周K线", symbol, ex); - throw; // LogAndThrowException 已经抛出异常,这里不会执行到 - } - } - - /// - /// 从zhituapi获取月K线数据 - /// - /// 股票代码(如000001.SZ) - /// 开始日期,默认为null - /// 结束日期,默认为null - /// 除权方式(n=不复权,f=前复权,b=后复权,fr=等比前复权,br=等比后复权),默认为n - /// K线数据集合 - public async Task GetMonthlyKLineDataAsync(string symbol, DateTime? startDate = null, DateTime? endDate = null, string adjustType = "n") - { - try - { - // 验证参数 - ValidateKLineParameters(symbol); - - // 处理股票代码格式 - string formattedSymbol = StockSymbolConverter.ToZhiTuFormat(symbol); - - // 构建API URL - string apiUrl = BuildZhiTuApiUrl(formattedSymbol, "m", adjustType, startDate, endDate); - - // 发送请求并获取数据 - var zhiTuData = await FetchZhiTuDataAsync(apiUrl, "月K线", symbol); - - // 转换为应用程序数据模型 - var klineDataSet = new StockKLineDataSet - { - Symbol = symbol, - Interval = "monthly", - Data = new List() - }; - - // 解析数据 - ParseZhiTuKLineData(zhiTuData, klineDataSet); - - return klineDataSet; - } - catch (Exception ex) - { - LogAndThrowException("月K线", symbol, ex); - throw; // LogAndThrowException 已经抛出异常,这里不会执行到 - } - } - - /// - /// 从zhituapi获取分钟级K线数据 - /// - /// 股票代码(如000001.SZ) - /// 分钟频度(1、5、15、30、60) - /// 开始日期 - /// 结束日期 - /// 除权方式(n=不复权,f=前复权,b=后复权,fr=等比前复权,br=等比后复权),默认为n - /// K线数据集合 - public async Task GetMinuteKLineDataAsync(string symbol, string interval, DateTime? startDate = null, DateTime? endDate = null, string adjustType = "n") - { - try - { - // 验证频率参数 - if (!IsValidInterval(interval)) - { - throw new ArgumentException("无效的分钟频度参数,有效值为:1, 5, 15, 30, 60", nameof(interval)); - } - - // 验证参数 - ValidateKLineParameters(symbol); - - // 处理股票代码格式 - string formattedSymbol = StockSymbolConverter.ToZhiTuFormat(symbol); - - // 构建API URL - string apiUrl = BuildZhiTuApiUrl(formattedSymbol, interval, adjustType, startDate, endDate); - - // 发送请求并获取数据 - var zhiTuData = await FetchZhiTuDataAsync(apiUrl, $"{interval}分钟K线", symbol); - - // 转换为应用程序数据模型 - var klineDataSet = new StockKLineDataSet - { - Symbol = symbol, - Interval = $"{interval}min", - Data = new List() - }; - - // 解析数据 - ParseZhiTuKLineData(zhiTuData, klineDataSet); - - return klineDataSet; - } - catch (Exception ex) - { - LogAndThrowException($"{interval}分钟K线", symbol, ex); - throw; // LogAndThrowException 已经抛出异常,这里不会执行到 - } - } - - /// - /// 验证分钟频度参数是否有效 - /// - /// 分钟频度参数 - /// 是否有效 - private bool IsValidInterval(string interval) - { - string[] validIntervals = { "1", "5", "15", "30", "60" }; - return validIntervals.Contains(interval); - } - - /// - /// 从zhituapi获取年K线数据 - /// - /// 股票代码(如000001.SZ) - /// 开始日期,默认为null - /// 结束日期,默认为null - /// 除权方式(n=不复权,f=前复权,b=后复权,fr=等比前复权,br=等比后复权),默认为n - /// K线数据集合 - public async Task GetYearlyKLineDataAsync(string symbol, DateTime? startDate = null, DateTime? endDate = null, string adjustType = "n") - { - try - { - // 验证参数 - ValidateKLineParameters(symbol); - - // 处理股票代码格式 - string formattedSymbol = StockSymbolConverter.ToZhiTuFormat(symbol); - - // 构建API URL - string apiUrl = BuildZhiTuApiUrl(formattedSymbol, "y", adjustType, startDate, endDate); - - // 发送请求并获取数据 - var zhiTuData = await FetchZhiTuDataAsync(apiUrl, "年K线", symbol); - - // 转换为应用程序数据模型 - var klineDataSet = new StockKLineDataSet - { - Symbol = symbol, - Interval = "yearly", - Data = new List() - }; - - // 解析数据 - ParseZhiTuKLineData(zhiTuData, klineDataSet); - - return klineDataSet; - } - catch (Exception ex) - { - LogAndThrowException("年K线", symbol, ex); - throw; // LogAndThrowException 已经抛出异常,这里不会执行到 - } - } -} - -/// -/// ZhiTu API K线数据模型 -/// -[Serializable] -public class ZhiTuKLineData -{ - /// - /// 交易时间 - /// - [JsonPropertyName("t")] - public string T { get; set; } = string.Empty; - - /// - /// 开盘价 - /// - [JsonPropertyName("o")] - public decimal O { get; set; } - - /// - /// 最高价 - /// - [JsonPropertyName("h")] - public decimal H { get; set; } - - /// - /// 最低价 - /// - [JsonPropertyName("l")] - public decimal L { get; set; } - - /// - /// 收盘价 - /// - [JsonPropertyName("c")] - public decimal C { get; set; } - - /// - /// 成交量 - /// - [JsonPropertyName("v")] - public decimal V { get; set; } - - /// - /// 成交额 - /// - [JsonPropertyName("a")] - public decimal A { get; set; } - - /// - /// 前收盘价 - /// - [JsonPropertyName("pc")] - public decimal Pc { get; set; } - - /// - /// 停牌标志(1停牌,0不停牌) - /// - ///[JsonPropertyName("sf")] - ///public int Sf { get; set; } -} \ No newline at end of file diff --git a/src/Applications/Stocks/StockSearchHistory.cs b/src/Applications/Stocks/StockSearchHistory.cs deleted file mode 100644 index eda6f76..0000000 --- a/src/Applications/Stocks/StockSearchHistory.cs +++ /dev/null @@ -1,103 +0,0 @@ -using MarketAssistant.Applications.Stocks.Models; -using MarketAssistant.Infrastructure.Configuration; -using MarketAssistant.Infrastructure; -using MarketAssistant.Infrastructure.Configuration; -using Microsoft.Extensions.Logging; -using MarketAssistant.Infrastructure.Configuration; -using System.Text.Json; -using MarketAssistant.Infrastructure.Configuration; - -namespace MarketAssistant.Applications.Stocks; - -/// -/// 股票搜索历史记录管理类 -/// -public class StockSearchHistory -{ - private const string PreferenceKey = "RecentViewedStocks"; - private const int MaxHistoryCount = 10; // 最多保存10条历史记录 - private readonly ILogger _logger; - - public StockSearchHistory(ILogger logger) - { - _logger = logger; - } - - /// - /// 添加一条股票访问记录 - /// - /// 股票信息 - public void AddSearchHistory(StockItem stock) - { - if (stock == null || string.IsNullOrWhiteSpace(stock.Code)) - return; - - var historyList = GetSearchHistory(); - - // 如果已存在相同代码的记录,先移除旧记录 - var existingItem = historyList.FirstOrDefault(x => x.Code == stock.Code); - if (existingItem != null) - { - historyList.Remove(existingItem); - } - - // 添加到列表开头(最新的记录放在最前面) - historyList.Insert(0, stock); - - // 如果超过最大数量,移除最旧的记录 - if (historyList.Count > MaxHistoryCount) - { - historyList.RemoveAt(historyList.Count - 1); - } - - // 保存到本地存储 - SaveSearchHistory(historyList); - } - - /// - /// 获取搜索历史记录列表 - /// - /// 股票历史记录列表 - public List GetSearchHistory() - { - try - { - var json = Preferences.Default.Get(PreferenceKey, string.Empty); - if (string.IsNullOrWhiteSpace(json)) - return new List(); - - var historyList = JsonSerializer.Deserialize>(json); - return historyList ?? new List(); - } - catch (Exception ex) - { - _logger?.LogError(ex, "获取搜索历史记录时出错: {Message}", ex.Message); - return new List(); - } - } - - /// - /// 清空搜索历史记录 - /// - public void ClearSearchHistory() - { - SaveSearchHistory(new List()); - } - - /// - /// 保存搜索历史记录到本地存储 - /// - /// 要保存的历史记录列表 - private void SaveSearchHistory(List historyList) - { - try - { - var json = JsonSerializer.Serialize(historyList); - Preferences.Default.Set(PreferenceKey, json); - } - catch (Exception ex) - { - _logger?.LogError(ex, "保存搜索历史记录时出错: {Message}", ex.Message); - } - } -} diff --git a/src/Applications/Telegrams/TelegramService.cs b/src/Applications/Telegrams/AShareTelegramService.cs similarity index 94% rename from src/Applications/Telegrams/TelegramService.cs rename to src/Applications/Telegrams/AShareTelegramService.cs index 3d0eb6b..d85af9b 100644 --- a/src/Applications/Telegrams/TelegramService.cs +++ b/src/Applications/Telegrams/AShareTelegramService.cs @@ -4,12 +4,15 @@ namespace MarketAssistant.Applications.Telegrams; -public class TelegramService +/// +/// A股市场快讯服务,调用同花顺快讯API +/// +public class AShareTelegramService : ITelegramService { - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; - public TelegramService(ILogger logger, IHttpClientFactory httpClientFactory) + public AShareTelegramService(ILogger logger, IHttpClientFactory httpClientFactory) { _logger = logger; _httpClientFactory = httpClientFactory; @@ -18,8 +21,6 @@ public TelegramService(ILogger logger, IHttpClientFactory httpC /// /// 通过同花顺快讯API获取实时新闻数据 /// - /// 取消令牌 - /// 实时新闻列表 public async Task> GetTelegraphsAsync(CancellationToken cancellationToken = default) { // var url = "https://news.10jqka.com.cn/realtimenews.html"; @@ -163,3 +164,4 @@ private class ThsNewsStock public string? StockMarket { get; set; } } } + diff --git a/src/Applications/Telegrams/CryptoTelegramService.cs b/src/Applications/Telegrams/CryptoTelegramService.cs new file mode 100644 index 0000000..bb6a5db --- /dev/null +++ b/src/Applications/Telegrams/CryptoTelegramService.cs @@ -0,0 +1,271 @@ +using Microsoft.Extensions.Logging; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MarketAssistant.Applications.Telegrams; + +/// +/// 虚拟币市场快讯服务,调用 PANews 快讯API(中文) +/// 数据来源:https://www.panewslab.com/zh/newsflash +/// +public class CryptoTelegramService : ITelegramService +{ + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + + // PANews 快讯API - rn=条数, lid=语言(1=中文), apppush=0 + private const string ApiUrl = "https://api.panewslab.com/webapi/flashnews?rn=20&lid=1&apppush=0"; + + public CryptoTelegramService( + ILogger logger, + IHttpClientFactory httpClientFactory) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + } + + /// + /// 通过 PANews API 获取虚拟币市场实时快讯(中文) + /// + public async Task> GetTelegraphsAsync(CancellationToken cancellationToken = default) + { + var result = new List(); + try + { + var client = _httpClientFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(10); + + using var request = new HttpRequestMessage(HttpMethod.Get, ApiUrl); + request.Headers.TryAddWithoutValidation("Accept", "*/*"); + request.Headers.TryAddWithoutValidation("Accept-Language", "zh-CN,zh;q=0.9,en-GB;q=0.8,en;q=0.7"); + request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"); + request.Headers.TryAddWithoutValidation("Origin", "https://www.panewslab.com"); + request.Headers.TryAddWithoutValidation("Referer", "https://www.panewslab.com/"); + + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("获取 PANews 快讯失败,状态码: {StatusCode}", (int)response.StatusCode); + return result; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var apiResponse = JsonSerializer.Deserialize(json, JsonOptions); + + if (apiResponse?.Data == null || apiResponse.Data.Count == 0) + { + _logger.LogWarning("PANews API 返回数据为空"); + return result; + } + + // 处理所有返回的快讯(已限制 20 条) + foreach (var item in apiResponse.Data) + { + try + { + var timeText = FormatUnixTime(item.PublishTime); + var title = item.Title ?? string.Empty; + var content = CleanHtmlContent(item.Content); + var url = !string.IsNullOrEmpty(item.Link) + ? item.Link + : $"https://www.panewslab.com/zh/articledetails/{item.Id}.html"; + var cryptos = ExtractCryptoSymbols(content, title); + var isImportant = item.ImportLevel > 0; // importlevel > 0 表示重要快讯 + + result.Add(new Telegram + { + Time = timeText, + Title = title, + Content = content, + Url = url, + Stocks = cryptos, // 存储相关币种符号 + IsImportant = isImportant + }); + } + catch (Exception mapEx) + { + _logger.LogWarning(mapEx, "映射 PANews 快讯项失败: {Message}", mapEx.Message); + } + } + + _logger.LogInformation("成功获取 {Count} 条虚拟币快讯(PANews)", result.Count); + return result; + } + catch (TaskCanceledException) + { + _logger.LogWarning("获取虚拟币快讯超时"); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "GetTelegraphsAsync 调用 PANews API 异常: {Message}", ex.Message); + return result; + } + } + + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString + }; + + /// + /// 格式化 Unix 时间戳为本地时间 + /// + private static string FormatUnixTime(long? unixSeconds) + { + if (unixSeconds == null || unixSeconds == 0) + return string.Empty; + + try + { + return DateTimeOffset.FromUnixTimeSeconds(unixSeconds.Value).ToLocalTime().ToString("HH:mm:ss"); + } + catch + { + return string.Empty; + } + } + + /// + /// 从HTML内容中提取纯文本标题(取第一句话) + /// + private static string ExtractTitle(string? htmlContent, int maxLength = 50) + { + if (string.IsNullOrWhiteSpace(htmlContent)) + return string.Empty; + + // 清除HTML标签 + var text = System.Text.RegularExpressions.Regex.Replace(htmlContent, "<.*?>", " "); + text = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim(); + + if (string.IsNullOrEmpty(text)) + return string.Empty; + + // 提取第一句话作为标题 + var sentences = text.Split(new[] { '。', '!', '?', '.', '!', '?' }, StringSplitOptions.RemoveEmptyEntries); + var title = sentences.Length > 0 ? sentences[0].Trim() : text; + + // 限制长度 + if (title.Length > maxLength) + title = title.Substring(0, maxLength) + "..."; + + return title; + } + + /// + /// 清理HTML内容为纯文本 + /// + private static string CleanHtmlContent(string? htmlContent, int maxLength = 200) + { + if (string.IsNullOrWhiteSpace(htmlContent)) + return string.Empty; + + // 清除HTML标签 + var text = System.Text.RegularExpressions.Regex.Replace(htmlContent, "<.*?>", " "); + text = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim(); + + // 限制长度 + if (text.Length > maxLength) + text = text.Substring(0, maxLength) + "..."; + + return text; + } + + /// + /// 从文本中提取加密货币符号 + /// + private static List ExtractCryptoSymbols(string content, string title) + { + var symbols = new HashSet(); + var text = $"{title} {content}".ToUpperInvariant(); + + // 常见加密货币符号及其变体 + var cryptoPatterns = new Dictionary + { + { "BTC", new[] { "BTC", "比特币", "BITCOIN" } }, + { "ETH", new[] { "ETH", "以太坊", "ETHEREUM" } }, + { "USDT", new[] { "USDT", "泰达币", "TETHER" } }, + { "BNB", new[] { "BNB", "币安币", "BINANCE" } }, + { "SOL", new[] { "SOL", "SOLANA", "索拉纳" } }, + { "XRP", new[] { "XRP", "瑞波币", "RIPPLE" } }, + { "ADA", new[] { "ADA", "艾达币", "CARDANO" } }, + { "DOGE", new[] { "DOGE", "狗狗币", "DOGECOIN" } }, + { "MATIC", new[] { "MATIC", "POLYGON", "马蹄" } }, + { "DOT", new[] { "DOT", "波卡", "POLKADOT" } }, + { "AVAX", new[] { "AVAX", "雪崩", "AVALANCHE" } }, + { "SHIB", new[] { "SHIB", "柴犬币" } }, + { "LTC", new[] { "LTC", "莱特币", "LITECOIN" } }, + { "UNI", new[] { "UNI", "UNISWAP" } }, + { "LINK", new[] { "LINK", "CHAINLINK" } } + }; + + foreach (var (symbol, patterns) in cryptoPatterns) + { + if (patterns.Any(p => text.Contains(p))) + { + symbols.Add(symbol); + } + } + + return symbols.OrderBy(s => s).ToList(); + } + + /// + /// PANews API 响应模型 + /// + private class PanewsResponse + { + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("data")] + public List Data { get; set; } = new(); + + [JsonPropertyName("code")] + public int Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + } + + /// + /// PANews 快讯条目 + /// + private class PanewsFlashItem + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("content")] + public string? Content { get; set; } + + [JsonPropertyName("link")] + public string? Link { get; set; } + + [JsonPropertyName("posttime")] + public long? PublishTime { get; set; } + + [JsonPropertyName("importlevel")] + public int ImportLevel { get; set; } // 0=普通,>0=重要 + + [JsonPropertyName("source")] + public string? Source { get; set; } + + [JsonPropertyName("author")] + public string? Author { get; set; } + + [JsonPropertyName("icon")] + public string? Icon { get; set; } + + [JsonPropertyName("readnum")] + public int? ReadNum { get; set; } + + [JsonPropertyName("isimportant")] + public bool? IsImportant { get; set; } + } +} + diff --git a/src/Applications/Telegrams/ITelegramService.cs b/src/Applications/Telegrams/ITelegramService.cs new file mode 100644 index 0000000..2d6439b --- /dev/null +++ b/src/Applications/Telegrams/ITelegramService.cs @@ -0,0 +1,13 @@ +namespace MarketAssistant.Applications.Telegrams; + +/// +/// 快讯服务接口,支持多市场实现 +/// +public interface ITelegramService +{ + /// + /// 获取实时快讯 + /// + Task> GetTelegraphsAsync(CancellationToken cancellationToken = default); +} + diff --git a/src/Infrastructure/Core/AssetFavoritesChanged.cs b/src/Infrastructure/Core/AssetFavoritesChanged.cs new file mode 100644 index 0000000..476dc2e --- /dev/null +++ b/src/Infrastructure/Core/AssetFavoritesChanged.cs @@ -0,0 +1,14 @@ +namespace MarketAssistant.Infrastructure.Core; + +/// +/// 资产收藏变更消息 +/// +public class AssetFavoritesChanged +{ +} + + + + + + diff --git a/src/Infrastructure/Core/MarketType.cs b/src/Infrastructure/Core/MarketType.cs new file mode 100644 index 0000000..e4c3cc3 --- /dev/null +++ b/src/Infrastructure/Core/MarketType.cs @@ -0,0 +1,23 @@ +namespace MarketAssistant.Infrastructure.Core; + +/// +/// 顶层市场类型枚举 +/// +public enum MarketType +{ + /// + /// A股市场 + /// + AShare, + + /// + /// 虚拟币市场 + /// + Crypto +} + + + + + + diff --git a/src/Infrastructure/Core/ViewLocator.cs b/src/Infrastructure/Core/ViewLocator.cs index 07f3dbd..b41fa1b 100644 --- a/src/Infrastructure/Core/ViewLocator.cs +++ b/src/Infrastructure/Core/ViewLocator.cs @@ -36,11 +36,11 @@ public class ViewLocator : IDataTemplate { HomePageViewModel => new HomePageView { DataContext = data }, FavoritesPageViewModel => new FavoritesPageView { DataContext = data }, - StockSelectionPageViewModel => new StockSelectionPageView { DataContext = data }, + AssetSelectionPageViewModel => new AssetSelectionPageView { DataContext = data }, SettingsPageViewModel => new SettingsPageView { DataContext = data }, AboutPageViewModel => new AboutPageView { DataContext = data }, MCPConfigPageViewModel => new MCPConfigPageView { DataContext = data }, - StockPageViewModel => new StockPageView { DataContext = data }, + AssetPageViewModel => new AssetPageView { DataContext = data }, AgentAnalysisViewModel => new AgentAnalysisPageView { DataContext = data }, ChatSidebarDemoViewModel => new ChatSidebarDemoView { DataContext = data }, _ => new TextBlock { Text = $"未找到视图: {viewModelType.Name}" } diff --git a/src/Infrastructure/Factories/AnalystAgentFactory.cs b/src/Infrastructure/Factories/AnalystAgentFactory.cs index 82505bf..92ceacd 100644 --- a/src/Infrastructure/Factories/AnalystAgentFactory.cs +++ b/src/Infrastructure/Factories/AnalystAgentFactory.cs @@ -1,5 +1,9 @@ using MarketAssistant.Agents.Analysts; +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Market; using Microsoft.Agents.AI; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace MarketAssistant.Infrastructure.Factories; @@ -29,15 +33,18 @@ public class AnalystAgentFactory : IAnalystAgentFactory { private readonly IServiceProvider _serviceProvider; private readonly IChatClientFactory _chatClientFactory; + private readonly MarketContext _marketContext; private readonly ILogger _logger; public AnalystAgentFactory( IServiceProvider serviceProvider, IChatClientFactory chatClientFactory, + MarketContext marketContext, ILogger logger) { _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); _chatClientFactory = chatClientFactory ?? throw new ArgumentNullException(nameof(chatClientFactory)); + _marketContext = marketContext ?? throw new ArgumentNullException(nameof(marketContext)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -57,13 +64,19 @@ public AIAgent CreateAnalyst(Type agentType) // 创建 ChatClient var chatClient = _chatClientFactory.CreateClient(); + // 根据当前市场类型获取对应的工具实现 + var currentMarket = _marketContext.CurrentMarket; + + // 根据 Analyst 类型获取需要的工具 + var tools = ResolveToolsForAnalyst(agentType, currentMarket); + // 使用 ActivatorUtilities.CreateInstance - // 显式传递 chatClient,其他依赖从 DI 获取 - var agent = (AIAgent)ActivatorUtilities.CreateInstance(_serviceProvider, agentType, chatClient); + // 显式传递 chatClient 和工具,其他依赖从 DI 获取 + var agent = (AIAgent)ActivatorUtilities.CreateInstance(_serviceProvider, agentType, chatClient, tools.ToArray()); _logger.LogInformation( - "成功创建分析师代理: {AgentType}", - agentType.Name); + "成功创建分析师代理: {AgentType} (市场: {Market})", + agentType.Name, currentMarket); return agent; } @@ -74,6 +87,45 @@ public AIAgent CreateAnalyst(Type agentType) } } + /// + /// 根据 Analyst 类型和市场类型解析所需的工具 + /// + private List ResolveToolsForAnalyst(Type agentType, MarketType marketType) + { + var tools = new List(); + + // 根据不同的 Analyst 类型,解析对应的工具接口 + switch (agentType.Name) + { + case nameof(FinancialAnalystAgent): + tools.Add(_serviceProvider.GetRequiredKeyedService(marketType)); + break; + + case nameof(FundamentalAnalystAgent): + tools.Add(_serviceProvider.GetRequiredKeyedService(marketType)); + break; + + case nameof(MarketSentimentAnalystAgent): + tools.Add(_serviceProvider.GetRequiredKeyedService(marketType)); + tools.Add(_serviceProvider.GetRequiredKeyedService(marketType)); + break; + + case nameof(NewsEventAnalystAgent): + tools.Add(_serviceProvider.GetRequiredKeyedService(marketType)); + break; + + case nameof(TechnicalAnalystAgent): + tools.Add(_serviceProvider.GetRequiredKeyedService(marketType)); + break; + + default: + _logger.LogWarning("未知的 Analyst 类型: {AgentType},不注入任何工具", agentType.Name); + break; + } + + return tools; + } + /// /// 创建指定类型的分析师代理(泛型版本) /// diff --git a/src/MarketAssistant.csproj b/src/MarketAssistant.csproj index a520ba8..c5c56a6 100644 --- a/src/MarketAssistant.csproj +++ b/src/MarketAssistant.csproj @@ -58,9 +58,9 @@ All - - - + + + @@ -69,7 +69,7 @@ - + @@ -79,7 +79,7 @@ - + diff --git a/src/Resources/Styles/SkeletonStyles.axaml b/src/Resources/Styles/SkeletonStyles.axaml new file mode 100644 index 0000000..edb34ee --- /dev/null +++ b/src/Resources/Styles/SkeletonStyles.axaml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Services/Market/MarketContext.cs b/src/Services/Market/MarketContext.cs new file mode 100644 index 0000000..39ffdb5 --- /dev/null +++ b/src/Services/Market/MarketContext.cs @@ -0,0 +1,54 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Services.Market; + +/// +/// 市场上下文服务,管理当前激活的市场类型 +/// +public class MarketContext : INotifyPropertyChanged +{ + private MarketType _currentMarket = MarketType.AShare; + + /// + /// 当前激活的市场类型 + /// + public MarketType CurrentMarket + { + get => _currentMarket; + private set + { + if (_currentMarket != value) + { + _currentMarket = value; + OnPropertyChanged(); + } + } + } + + /// + /// 切换市场 + /// + /// 新的市场类型 + public void SwitchMarket(MarketType newMarket) + { + if (CurrentMarket != newMarket) + { + CurrentMarket = newMarket; + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} + + + + + + diff --git a/src/Services/Navigation/NavigationService.cs b/src/Services/Navigation/NavigationService.cs index e600db5..d501b87 100644 --- a/src/Services/Navigation/NavigationService.cs +++ b/src/Services/Navigation/NavigationService.cs @@ -52,7 +52,7 @@ public NavigationService(IServiceProvider serviceProvider, ILogger("MCPConfig"); - RegisterRoute("Stock"); + RegisterRoute("Asset"); RegisterRoute("Analysis"); // 注册导航消息监听 diff --git a/src/Services/ServiceCollectionExtensions.cs b/src/Services/ServiceCollectionExtensions.cs index 60d74f0..dad2661 100644 --- a/src/Services/ServiceCollectionExtensions.cs +++ b/src/Services/ServiceCollectionExtensions.cs @@ -1,23 +1,33 @@ using MarketAssistant.Agents.MarketAnalysis; using MarketAssistant.Agents.MarketAnalysis.Executors; -using MarketAssistant.Agents.StockSelection; -using MarketAssistant.Agents.StockSelection.Executors; +using MarketAssistant.Agents.InvestmentSelection; +using MarketAssistant.Agents.InvestmentSelection.Executors; +using MarketAssistant.Applications.AssetScreener; using MarketAssistant.Agents.Tools; +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.AShare; +using MarketAssistant.Agents.Tools.Crypto; +using MarketAssistant.Applications.Assets; +using MarketAssistant.Applications.Cache; +using MarketAssistant.Applications.Charts; +using MarketAssistant.Applications.Favorites; +using MarketAssistant.Applications.History; +using MarketAssistant.Applications.Home; using MarketAssistant.Applications.News; using MarketAssistant.Applications.Settings; -using MarketAssistant.Applications.Stocks; -using MarketAssistant.Applications.StockSelection; +using MarketAssistant.Applications.InvestmentSelection; using MarketAssistant.Applications.Telegrams; +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Infrastructure.Factories; using MarketAssistant.Rag.Extensions; using MarketAssistant.Services.Browser; using MarketAssistant.Services.Cache; using MarketAssistant.Services.Dialog; +using MarketAssistant.Services.Market; using MarketAssistant.Services.Mcp; using MarketAssistant.Services.Navigation; using MarketAssistant.Services.Notification; using MarketAssistant.Services.Settings; -using MarketAssistant.Services.StockScreener; using MarketAssistant.ViewModels; using MarketAssistant.ViewModels.Home; using Microsoft.Extensions.Logging; @@ -42,13 +52,25 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection // 注册用户设置服务为单例 services.AddSingleton(); - // 注册 Agent Tool 类 - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + // 注册市场上下文服务为单例 + services.AddSingleton(); + + // 注册通用工具(不依赖市场类型) services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + + // 注册 Agent Tools - A股实现(Keyed Services) + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.AShare); + + // 注册 Agent Tools - 虚拟币实现(Keyed Services) + services.AddKeyedSingleton(MarketType.Crypto); + services.AddKeyedSingleton(MarketType.Crypto); + services.AddKeyedSingleton(MarketType.Crypto); + services.AddKeyedSingleton(MarketType.Crypto); + services.AddKeyedSingleton(MarketType.Crypto); // 注册 Kernel 和嵌入服务(保留用于 RAG 和提示词模板) services.AddSingleton(); @@ -66,31 +88,63 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection // 注册 RAG 和分析服务 services.AddRagServices(); - services.AddSingleton(); services.AddSingleton(); + // 注册快讯服务接口的实现(使用 Keyed Services) + services.AddKeyedSingleton("AShare"); + services.AddKeyedSingleton("Crypto"); + + // 注册新闻更新服务(使用 Keyed Services) + services.AddKeyedSingleton( + "AShare", + (sp, key) => new NewsUpdateService( + sp.GetRequiredKeyedService("AShare"), + sp.GetRequiredService>())); + + services.AddKeyedSingleton( + "Crypto", + (sp, key) => new NewsUpdateService( + sp.GetRequiredKeyedService("Crypto"), + sp.GetRequiredService>())); + // 注册分析缓存服务 services.AddSingleton(); - // 注册股票相关服务 - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + // 注册浏览器服务 services.AddSingleton(); services.AddSingleton(); - // 注册主页相关服务 - services.AddSingleton(); - services.AddSingleton(); + // 注册资产服务抽象 - A股实现(Keyed Services) + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.AShare); + + // 注册资产服务抽象 - 虚拟币实现(Keyed Services) + services.AddKeyedSingleton(MarketType.Crypto); + services.AddKeyedSingleton(MarketType.Crypto); + services.AddKeyedSingleton(MarketType.Crypto); + services.AddKeyedSingleton(MarketType.Crypto); + services.AddKeyedSingleton(MarketType.Crypto); + services.AddKeyedSingleton(MarketType.Crypto); // 注册AI选股相关服务(使用 Agent Framework Workflows) - services.AddSingleton(); - services.AddSingleton(); + // 注册筛选服务接口的实现(使用 Keyed Services) + services.AddKeyedSingleton("AShare"); + services.AddKeyedSingleton("Crypto"); + + // 注册投资选择工作流的 Executors + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + + // 注册投资选择工作流和服务 + services.AddSingleton(); + services.AddSingleton(); // 注册市场分析相关服务(使用 Agent Framework Workflows - 最佳实践) services.AddSingleton(); @@ -126,16 +180,16 @@ public static IServiceCollection AddViewModels(this IServiceCollection services) // 注册主要页面 ViewModels services.AddTransient(); services.AddTransient(); - services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); + services.AddTransient(); // 注册 Home 子 ViewModels services.AddTransient(); - services.AddTransient(); - services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); // 注册 AI 分析相关 ViewModels diff --git a/src/ViewModels/AssetNavigationParameter.cs b/src/ViewModels/AssetNavigationParameter.cs new file mode 100644 index 0000000..de434b3 --- /dev/null +++ b/src/ViewModels/AssetNavigationParameter.cs @@ -0,0 +1,34 @@ +namespace MarketAssistant.ViewModels; + +/// +/// 资产导航参数 +/// +public class AssetNavigationParameter +{ + public string Code { get; set; } + public string Name { get; set; } + + /// + /// 当前价格(可选)- 用于立即显示基本信息 + /// + public decimal? CurrentPrice { get; set; } + + /// + /// 涨跌幅(可选)- 用于立即显示基本信息 + /// + public decimal? ChangePercent { get; set; } + + public AssetNavigationParameter(string code, string name, decimal? currentPrice = null, decimal? changePercent = null) + { + Code = code; + Name = name; + CurrentPrice = currentPrice; + ChangePercent = changePercent; + } +} + + + + + + diff --git a/src/ViewModels/StockPageViewModel.cs b/src/ViewModels/AssetPageViewModel.cs similarity index 57% rename from src/ViewModels/StockPageViewModel.cs rename to src/ViewModels/AssetPageViewModel.cs index 8ae3a1d..28f5391 100644 --- a/src/ViewModels/StockPageViewModel.cs +++ b/src/ViewModels/AssetPageViewModel.cs @@ -1,34 +1,37 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; -using MarketAssistant.Applications.Stocks; -using MarketAssistant.Applications.Stocks.Models; +using MarketAssistant.Applications.Charts; +using MarketAssistant.Applications.Charts.Models; using MarketAssistant.Infrastructure; using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Market; using MarketAssistant.Services.Navigation; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System.Collections.ObjectModel; namespace MarketAssistant.ViewModels; /// -/// 股票详情页ViewModel +/// 资产详情页ViewModel /// -public partial class StockPageViewModel : ViewModelBase, INavigationAware +public partial class AssetPageViewModel : ViewModelBase, INavigationAware { - public override string Title => "股票详情"; + public override string Title => "资产详情"; - private readonly StockKLineService _stockKLineService; + private readonly IServiceProvider _serviceProvider; + private readonly MarketContext _marketContext; private CancellationTokenSource? _loadingCancellationTokenSource; [ObservableProperty] private KLineType _currentKLineType = KLineType.Daily; [ObservableProperty] - private string _stockCode = ""; + private string _assetCode = ""; [ObservableProperty] - private string _stockName = string.Empty; + private string _assetName = string.Empty; [ObservableProperty] private string _errorMessage = string.Empty; @@ -37,10 +40,7 @@ public partial class StockPageViewModel : ViewModelBase, INavigationAware _kLineData = new(); + private ObservableCollection _kLineData = new(); [ObservableProperty] private decimal _currentPrice; @@ -62,26 +62,27 @@ public partial class StockPageViewModel : ViewModelBase, INavigationAware ChangeKLineTypeCommand { get; private set; } public IRelayCommand NavigateToAnalysisCommand { get; private set; } - public StockPageViewModel( - ILogger logger, - StockKLineService stockKLineService) : base(logger) + public AssetPageViewModel( + ILogger logger, + IServiceProvider serviceProvider, + MarketContext marketContext) : base(logger) { - _stockKLineService = stockKLineService; + _serviceProvider = serviceProvider; + _marketContext = marketContext; ChangeKLineTypeCommand = new RelayCommand(ChangeKLineTypeAsync); NavigateToAnalysisCommand = new RelayCommand(NavigateToAnalysisAsync); } /// - /// 设置股票代码(异步加载数据,避免阻塞UI) + /// 设置资产代码(异步加载数据,避免阻塞UI) /// - private void SetStockCode(string code) + private void SetAssetCode(string code) { - StockCode = code; + AssetCode = code; if (!string.IsNullOrEmpty(code)) { - // 立即开始异步加载数据 - _ = LoadStockDataAsync(code); + _ = LoadAssetDataAsync(code); } } @@ -95,34 +96,33 @@ partial void OnCurrentKLineTypeChanged(KLineType value) OnPropertyChanged(nameof(IsWeeklySelected)); OnPropertyChanged(nameof(IsMonthlySelected)); - if (!string.IsNullOrEmpty(StockCode)) + if (!string.IsNullOrEmpty(AssetCode)) { - _ = LoadStockDataAsync(StockCode); + _ = LoadAssetDataAsync(AssetCode); } } /// - /// 刷新股票数据 + /// 刷新资产数据 /// [RelayCommand] private async Task RefreshDataAsync() { - if (!string.IsNullOrEmpty(StockCode)) + if (!string.IsNullOrEmpty(AssetCode)) { - await SafeExecuteAsync(async () => await LoadStockDataAsync(StockCode), "刷新数据"); + await SafeExecuteAsync(async () => await LoadAssetDataAsync(AssetCode), "刷新数据"); } } /// - /// 导航到股票详情页 + /// 导航到资产分析页面 /// private void NavigateToAnalysisAsync() { - if (string.IsNullOrEmpty(StockCode)) + if (string.IsNullOrEmpty(AssetCode)) return; - // 发送导航消息到分析页面 - WeakReferenceMessenger.Default.Send(new NavigationMessage("Analysis", new StockNavigationParameter(StockCode, StockName))); + WeakReferenceMessenger.Default.Send(new NavigationMessage("Analysis", new AssetNavigationParameter(AssetCode, AssetName))); } /// @@ -149,14 +149,13 @@ private void ChangeKLineTypeAsync(string? type) } /// - /// 加载股票K线数据 + /// 加载资产K线数据 /// - private async Task LoadStockDataAsync(string stockCode) + private async Task LoadAssetDataAsync(string assetCode) { - if (string.IsNullOrEmpty(stockCode)) + if (string.IsNullOrEmpty(assetCode)) return; - // 取消之前的加载操作 _loadingCancellationTokenSource?.Cancel(); _loadingCancellationTokenSource = new CancellationTokenSource(); var cancellationToken = _loadingCancellationTokenSource.Token; @@ -167,34 +166,24 @@ private async Task LoadStockDataAsync(string stockCode) try { - var kLineDataSet = CurrentKLineType switch - { - KLineType.Minute15 => await _stockKLineService.GetMinuteKLineDataAsync(stockCode, "15"), - KLineType.Weekly => await _stockKLineService.GetWeeklyKLineDataAsync(stockCode), - KLineType.Monthly => await _stockKLineService.GetMonthlyKLineDataAsync(stockCode), - _ => await _stockKLineService.GetDailyKLineDataAsync(stockCode) - }; + var klineService = _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + var kLineDataList = await klineService.GetKLineDataAsync(assetCode, CurrentKLineType); - // 检查是否已被取消 cancellationToken.ThrowIfCancellationRequested(); - KLineDataSet = kLineDataSet; - KLineData = new ObservableCollection(kLineDataSet.Data); + KLineData = new ObservableCollection(kLineDataList); - // 计算价格信息 - CalculatePriceInfo(kLineDataSet.Data); + CalculatePriceInfo(kLineDataList); } catch (OperationCanceledException) { - // 取消操作,不显示错误 - Logger?.LogInformation("股票 {StockCode} 的K线数据加载已取消", stockCode); + Logger?.LogInformation("资产 {AssetCode} 的K线数据加载已取消", assetCode); } catch (Exception ex) { - // 设置错误状态 HasError = true; ErrorMessage = ErrorMessageMapper.GetUserFriendlyMessageWithContext(ex, "加载K线数据"); - Logger?.LogError(ex, "加载股票 {StockCode} 的K线数据时发生错误", stockCode); + Logger?.LogError(ex, "加载资产 {AssetCode} 的K线数据时发生错误", assetCode); } finally { @@ -205,7 +194,7 @@ private async Task LoadStockDataAsync(string stockCode) /// /// 计算价格相关信息 /// - private void CalculatePriceInfo(List data) + private void CalculatePriceInfo(List data) { if (data.Count == 0) return; @@ -227,26 +216,47 @@ private void CalculatePriceInfo(List data) } } - public void OnNavigatedTo(StockNavigationParameter parameter) + public void OnNavigatedTo(AssetNavigationParameter parameter) { - if (!string.IsNullOrEmpty(parameter.StockCode)) + if (!string.IsNullOrEmpty(parameter.Code)) { - if (!string.IsNullOrEmpty(parameter.StockName)) + // 1. 立即设置加载状态,确保骨架屏显示 + IsBusy = true; + HasError = false; + + // 2. 设置基本信息(立即显示) + AssetName = !string.IsNullOrEmpty(parameter.Name) ? parameter.Name : parameter.Code; + AssetCode = parameter.Code; + + // 3. 如果导航参数中包含价格信息,立即显示 + if (parameter.CurrentPrice.HasValue) { - StockName = parameter.StockName; + CurrentPrice = parameter.CurrentPrice.Value; + + if (parameter.ChangePercent.HasValue) + { + PriceChangePercent = parameter.ChangePercent.Value; + PriceChange = CurrentPrice * PriceChangePercent / 100; + } } else { - // 如果没有传递名称,重置为空或保持代码 - StockName = parameter.StockCode; + // 清空旧数据 + CurrentPrice = 0; + PriceChangePercent = 0; + PriceChange = 0; } - SetStockCode(parameter.StockCode); + + // 4. 在后台线程加载完整数据(不阻塞导航) + _ = Task.Run(async () => await LoadAssetDataAsync(parameter.Code)); } } public void OnNavigatedFrom() { - // 离开页面时可以取消正在进行的加载 _loadingCancellationTokenSource?.Cancel(); } } + + + diff --git a/src/ViewModels/StockSelectionPageViewModel.cs b/src/ViewModels/AssetSelectionPageViewModel.cs similarity index 82% rename from src/ViewModels/StockSelectionPageViewModel.cs rename to src/ViewModels/AssetSelectionPageViewModel.cs index 0da0b1d..68fd556 100644 --- a/src/ViewModels/StockSelectionPageViewModel.cs +++ b/src/ViewModels/AssetSelectionPageViewModel.cs @@ -1,10 +1,13 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; -using MarketAssistant.Applications.Stocks; -using MarketAssistant.Applications.StockSelection; -using MarketAssistant.Applications.StockSelection.Models; +using MarketAssistant.Applications.Favorites; +using MarketAssistant.Applications.InvestmentSelection; +using MarketAssistant.Applications.InvestmentSelection.Models; +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Services.Dialog; +using MarketAssistant.Services.Market; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System.Collections.ObjectModel; @@ -44,10 +47,11 @@ public enum SelectionModeType /// /// AI选股功能的ViewModel /// -public partial class StockSelectionPageViewModel : ViewModelBase +public partial class AssetSelectionPageViewModel : ViewModelBase { - private readonly StockSelectionService _stockSelectionService; - private readonly StockFavoriteService _stockFavoriteService; + private readonly InvestmentSelectionService _investmentSelectionService; + private readonly IServiceProvider _serviceProvider; + private readonly MarketContext _marketContext; private readonly IDialogService _dialogService; [ObservableProperty] @@ -63,7 +67,7 @@ public partial class StockSelectionPageViewModel : ViewModelBase private SelectionModeItem? _selectedMode; [ObservableProperty] - private StockSelectionResult? _selectionResult; + private InvestmentSelectionResult? _selectionResult; [ObservableProperty] private bool _hasResult; @@ -143,12 +147,12 @@ public string CurrentInputContent }; /// - /// 推荐股票列表(用于UI绑定) + /// 推荐投资标的列表(用于UI绑定) /// - public ObservableCollection RecommendedStocks => + public ObservableCollection RecommendedStocks => SelectionResult?.Recommendations != null - ? new ObservableCollection(SelectionResult.Recommendations) - : new ObservableCollection(); + ? new ObservableCollection(SelectionResult.Recommendations) + : new ObservableCollection(); /// /// 格式化的风险提示文本(用于UI绑定) @@ -167,14 +171,16 @@ public string CurrentInputContent /// /// 构造函数(使用依赖注入) /// - public StockSelectionPageViewModel( - ILogger logger, - StockSelectionService stockSelectionService, - StockFavoriteService stockFavoriteService, + public AssetSelectionPageViewModel( + ILogger logger, + InvestmentSelectionService investmentSelectionService, + IServiceProvider serviceProvider, + MarketContext marketContext, IDialogService dialogService) : base(logger) { - _stockSelectionService = stockSelectionService; - _stockFavoriteService = stockFavoriteService; + _investmentSelectionService = investmentSelectionService; + _serviceProvider = serviceProvider; + _marketContext = marketContext; _dialogService = dialogService; _ = LoadQuickStrategiesAsync(); _ = LoadSelectionModesAsync(); @@ -192,7 +198,7 @@ partial void OnSelectedModeChanged(SelectionModeItem? value) } } - partial void OnSelectionResultChanged(StockSelectionResult? value) + partial void OnSelectionResultChanged(InvestmentSelectionResult? value) { OnPropertyChanged(nameof(RecommendedStocks)); OnPropertyChanged(nameof(FormattedRiskWarnings)); @@ -200,16 +206,16 @@ partial void OnSelectionResultChanged(StockSelectionResult? value) } [RelayCommand] - private void ViewStockDetail(StockRecommendation? stock) + private void ViewStockDetail(InvestmentRecommendation? stock) { if (stock == null) return; WeakReferenceMessenger.Default.Send( - new NavigationMessage("Stock", new StockNavigationParameter(stock.Symbol, stock.Name))); + new NavigationMessage("Asset", new AssetNavigationParameter(stock.Symbol, stock.Name))); } [RelayCommand] - private async Task AddToFavorites(StockRecommendation? stock) + private async Task AddToFavorites(InvestmentRecommendation? stock) { if (stock == null) return; @@ -223,13 +229,14 @@ private async Task AddToFavorites(StockRecommendation? stock) code = stock.Symbol.Substring(2); } - if (_stockFavoriteService.IsFavorite(code, market)) + var favoriteService = _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + if (favoriteService.IsFavorite(code, market)) { await _dialogService.ShowMessageAsync("提示", $"{stock.Name} ({stock.Symbol}) 已在自选列表中"); return; } - _stockFavoriteService.AddFavorite(code, market); + favoriteService.AddFavorite(code, market); await _dialogService.ShowMessageAsync("成功", $"已将 {stock.Name} ({stock.Symbol}) 加入自选"); } @@ -284,7 +291,7 @@ private async Task ExecuteQuickSelectionAsync(QuickSelectionStrategyInfo? strate await SafeExecuteAsync(async () => { - var result = await _stockSelectionService.QuickSelectAsync(strategy.Strategy); + var result = await _investmentSelectionService.QuickSelectAsync(strategy.Strategy); SelectionResult = result; HasResult = result != null && ( (result.Recommendations?.Count ?? 0) > 0 || @@ -312,12 +319,13 @@ private async Task ExecuteSelectionAsync() ClearValidationMessage(); await SafeExecuteAsync(async () => { - var request = new StockRecommendationRequest + var request = new InvestmentRecommendationRequest { + MarketType = _marketContext.CurrentMarket, UserRequirements = UserRequirements }; - var result = await _stockSelectionService.RecommendStocksByUserRequirementAsync(request); + var result = await _investmentSelectionService.RecommendByUserRequirementAsync(request); SelectionResult = result; HasResult = result != null && ( (result.Recommendations?.Count ?? 0) > 0 || @@ -336,13 +344,14 @@ private async Task ExecuteNewsSelectionAsync() ClearValidationMessage(); await SafeExecuteAsync(async () => { - var request = new NewsBasedSelectionRequest + var request = new NewsBasedInvestmentRequest { + MarketType = _marketContext.CurrentMarket, NewsContent = NewsContent, MaxRecommendations = 5 }; - var result = await _stockSelectionService.RecommendStocksByNewsAsync(request); + var result = await _investmentSelectionService.RecommendByNewsAsync(request); SelectionResult = result; HasResult = result != null && ( (result.Recommendations?.Count ?? 0) > 0 || @@ -375,7 +384,7 @@ private async Task LoadQuickStrategiesAsync() { await SafeExecuteAsync(() => { - var strategies = _stockSelectionService.GetQuickSelectionStrategies(); + var strategies = _investmentSelectionService.GetQuickSelectionStrategies(); Dispatcher.UIThread.InvokeAsync(() => { QuickStrategies.Clear(); @@ -412,3 +421,6 @@ await SafeExecuteAsync(() => }, "加载选股模式"); } } + + + diff --git a/src/ViewModels/FavoritesPageViewModel.cs b/src/ViewModels/FavoritesPageViewModel.cs index 84862b9..ac6eab9 100644 --- a/src/ViewModels/FavoritesPageViewModel.cs +++ b/src/ViewModels/FavoritesPageViewModel.cs @@ -1,67 +1,78 @@ using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; -using MarketAssistant.Applications.Stocks; -using MarketAssistant.Applications.Stocks.Models; +using MarketAssistant.Applications.Assets; +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Applications.Cache; +using MarketAssistant.Applications.Favorites; +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Services.Dialog; +using MarketAssistant.Services.Market; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System.Collections.ObjectModel; namespace MarketAssistant.ViewModels; /// -/// 收藏页ViewModel - 对应 FavoritesViewModel +/// 收藏页ViewModel /// -public partial class FavoritesPageViewModel : ViewModelBase, IRecipient +public partial class FavoritesPageViewModel : ViewModelBase, IRecipient { - private readonly StockFavoriteService _favoriteService; - private readonly StockService _stockService; - private readonly StockInfoCache _stockInfoCache; + private readonly IServiceProvider _serviceProvider; + private readonly MarketContext _marketContext; private readonly IDialogService _dialogService; - public ObservableCollection Stocks { get; set; } = new ObservableCollection(); + private IFavoriteService FavoriteService => + _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + + private IAssetInfoService AssetInfoService => + _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + + private IAssetCacheService CacheService => + _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + + public ObservableCollection Assets { get; set; } = new ObservableCollection(); /// /// 构造函数 /// public FavoritesPageViewModel( - StockFavoriteService favoriteService, - StockService stockService, - StockInfoCache stockInfoCache, + IServiceProvider serviceProvider, + MarketContext marketContext, IDialogService dialogService, ILogger logger) : base(logger) { - _favoriteService = favoriteService; - _stockService = stockService; - _stockInfoCache = stockInfoCache; + _serviceProvider = serviceProvider; + _marketContext = marketContext; _dialogService = dialogService; - _ = LoadFavoriteStocksAsync(); + _ = LoadFavoriteAssetsAsync(); WeakReferenceMessenger.Default.Register(this); } /// - /// 加载收藏股票列表 + /// 加载收藏资产列表 /// - private async Task LoadFavoriteStocksAsync() + private async Task LoadFavoriteAssetsAsync() { await SafeExecuteAsync(async () => { // 获取收藏列表 - var favoritesCodes = _favoriteService.GetFavoritesCodes(); + var favoritesCodes = FavoriteService.GetFavoritesCodes(); - Stocks.Clear(); + Assets.Clear(); - // 使用并发加载所有股票数据 - await UpdateStockDataProgressivelyAsync(favoritesCodes); + // 使用并发加载所有资产数据 + await UpdateAssetDataProgressivelyAsync(favoritesCodes); }, "加载收藏列表"); } /// - /// 渐进式加载股票实时数据(限制并发数,避免同时打开过多浏览器页面) + /// 渐进式加载资产实时数据(限制并发数,避免同时打开过多浏览器页面) /// - private async Task UpdateStockDataProgressivelyAsync(List favorites) + private async Task UpdateAssetDataProgressivelyAsync(List favorites) { - const int maxConcurrency = 3; // 最多同时请求3个股票数据 + const int maxConcurrency = 3; // 最多同时请求3个资产数据 var semaphore = new SemaphoreSlim(maxConcurrency); var tasks = favorites.Select(async favorite => @@ -70,24 +81,24 @@ private async Task UpdateStockDataProgressivelyAsync(List favorit try { // 先尝试从缓存获取 - var stockInfo = _stockInfoCache.Get(favorite.Code, favorite.Market); + var assetInfo = await CacheService.GetCachedAssetInfoAsync(favorite.Code); // 如果缓存中没有,则从网络获取 - if (stockInfo == null) + if (assetInfo == null) { - stockInfo = await _stockService.GetStockInfoAsync(favorite.Code, favorite.Market); + assetInfo = await AssetInfoService.GetAssetInfoAsync(favorite.Code, favorite.Market); // 缓存获取到的数据 - if (stockInfo != null) + if (assetInfo != null) { - _stockInfoCache.Set(stockInfo); + CacheService.CacheAssetInfo(favorite.Code, assetInfo); } } - return stockInfo; + return assetInfo; } catch (Exception ex) { - Logger?.LogError(ex, $"加载股票 {favorite.Code} 数据时出错"); + Logger?.LogError(ex, $"加载资产 {favorite.Code} 数据时出错"); return null; } finally @@ -99,39 +110,48 @@ private async Task UpdateStockDataProgressivelyAsync(List favorit var results = await Task.WhenAll(tasks); // 在UI线程上批量添加结果 - foreach (var stockInfo in results) + foreach (var assetInfo in results) { - if (stockInfo != null) + if (assetInfo != null) { - Stocks.Add(stockInfo); + Assets.Add(assetInfo); } } } /// - /// 选择收藏股票 + /// 选择收藏资产 /// [RelayCommand] - private void SelectFavoriteStock(StockInfo? stock) + private void SelectFavoriteAsset(AssetInfo? asset) { - if (stock == null) return; + if (asset == null) return; + + // 解析价格信息 + decimal? currentPrice = decimal.TryParse(asset.CurrentPrice, out var price) ? price : null; + decimal? changePercent = decimal.TryParse(asset.ChangePercentage?.TrimEnd('%'), out var percent) ? percent : null; + // 传递完整的基本信息,加速详情页显示 WeakReferenceMessenger.Default.Send( - new NavigationMessage("Stock", new StockNavigationParameter(stock.FullCode, stock.Name))); + new NavigationMessage("Asset", new AssetNavigationParameter( + asset.Code, + asset.Name, + currentPrice, + changePercent))); } /// - /// 移除收藏股票 + /// 移除收藏资产 /// [RelayCommand] - private async Task RemoveFavorite(StockInfo? stock) + private async Task RemoveFavorite(AssetInfo? asset) { - if (stock == null) return; + if (asset == null) return; // 显示确认对话框 var confirmed = await _dialogService.ShowConfirmationAsync( "取消收藏", - $"确定要取消收藏 {stock.Name}({stock.Code}) 吗?", + $"确定要取消收藏 {asset.Name}({asset.Code}) 吗?", "确定", "取消" ); @@ -142,16 +162,16 @@ private async Task RemoveFavorite(StockInfo? stock) await SafeExecuteAsync(async () => { // 先从UI集合中移除(避免因消息触发重新加载导致的竞态条件) - var stockToRemove = Stocks.FirstOrDefault(s => s.Code == stock.Code && s.Market == stock.Market); - if (stockToRemove != null) + var assetToRemove = Assets.FirstOrDefault(s => s.Code == asset.Code && s.Market == asset.Market); + if (assetToRemove != null) { - Stocks.Remove(stockToRemove); + Assets.Remove(assetToRemove); } // 再从持久化存储中移除 - _favoriteService.RemoveFavorite(stock.Code, stock.Market); + FavoriteService.RemoveFavorite(asset.Code, asset.Market); - Logger?.LogInformation($"已取消收藏股票: {stock.Name}({stock.Code})"); + Logger?.LogInformation($"已取消收藏资产: {asset.Name}({asset.Code})"); await Task.CompletedTask; }, "取消收藏"); } @@ -160,8 +180,8 @@ await SafeExecuteAsync(async () => /// /// 接收收藏变更消息 /// - public void Receive(StockFavoritesChanged message) + public void Receive(AssetFavoritesChanged message) { - _ = LoadFavoriteStocksAsync(); + _ = LoadFavoriteAssetsAsync(); } } diff --git a/src/ViewModels/Home/HomeSearchViewModel.cs b/src/ViewModels/Home/HomeSearchViewModel.cs index 950c8b7..32f91d3 100644 --- a/src/ViewModels/Home/HomeSearchViewModel.cs +++ b/src/ViewModels/Home/HomeSearchViewModel.cs @@ -1,7 +1,9 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using MarketAssistant.Applications.Stocks; -using MarketAssistant.Applications.Stocks.Models; +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Applications.Home; +using MarketAssistant.Services.Market; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System.Collections.ObjectModel; @@ -12,10 +14,14 @@ namespace MarketAssistant.ViewModels.Home; /// public partial class HomeSearchViewModel : ViewModelBase { - private readonly IHomeStockService _homeStockService; + private readonly IServiceProvider _serviceProvider; + private readonly MarketContext _marketContext; private CancellationTokenSource? _debounceCts; private const int DebounceDelayMs = 200; + private IHomeAssetService HomeAssetService => + _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + [ObservableProperty] private string _searchQuery = string.Empty; @@ -28,7 +34,7 @@ public partial class HomeSearchViewModel : ViewModelBase /// /// 搜索结果集合 /// - public ObservableCollection SearchResults { get; } = new(); + public ObservableCollection SearchResults { get; } = new(); /// /// 搜索命令 @@ -36,24 +42,26 @@ public partial class HomeSearchViewModel : ViewModelBase public IAsyncRelayCommand SearchCommand { get; } /// - /// 选择股票命令 + /// 选择资产命令 /// - public IRelayCommand SelectStockCommand { get; } + public IRelayCommand SelectAssetCommand { get; } /// - /// 股票选择事件 + /// 资产选择事件 /// - public event EventHandler? StockSelected; + public event EventHandler? AssetSelected; public HomeSearchViewModel( - IHomeStockService homeStockService, + IServiceProvider serviceProvider, + MarketContext marketContext, ILogger logger) : base(logger) { - _homeStockService = homeStockService; + _serviceProvider = serviceProvider; + _marketContext = marketContext; SearchCommand = new AsyncRelayCommand(OnSearchAsync); - SelectStockCommand = new RelayCommand(OnSelectStock); + SelectAssetCommand = new RelayCommand(OnSelectAsset); } /// @@ -99,7 +107,7 @@ partial void OnSearchQueryChanged(string value) } catch (Exception ex) { - Logger?.LogError(ex, "搜索股票时发生错误,查询:{Query}", value); + Logger?.LogError(ex, "搜索资产时发生错误,查询:{Query}", value); } }, cancellationToken); } @@ -118,12 +126,12 @@ private async Task OnSearchAsync(string? query) return; } - Logger?.LogInformation("开始搜索股票,查询:{Query}", query); + Logger?.LogInformation("开始搜索资产,查询:{Query}", query); IsSearching = true; await SafeExecuteAsync(async () => { - var results = await _homeStockService.SearchStockAsync(query, CancellationToken.None); + var results = await HomeAssetService.SearchAssetAsync(query, CancellationToken.None); Logger?.LogInformation("搜索完成,找到 {Count} 个结果", results.Count); @@ -131,10 +139,10 @@ await SafeExecuteAsync(async () => await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => { SearchResults.Clear(); - foreach (var stock in results) + foreach (var asset in results) { - SearchResults.Add(stock); - Logger?.LogDebug("添加搜索结果:{Name} ({Code})", stock.Name, stock.Code); + SearchResults.Add(asset); + Logger?.LogDebug("添加搜索结果:{Name} ({Code})", asset.Name, asset.Code); } IsSearchResultVisible = SearchResults.Count > 0; @@ -142,25 +150,25 @@ await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => if (results.Count == 0) { - Logger?.LogWarning("未找到匹配的股票,查询:{Query}", query); + Logger?.LogWarning("未找到匹配的资产,查询:{Query}", query); } - }, "搜索股票"); + }, "搜索资产"); IsSearching = false; } /// - /// 选择股票 + /// 选择资产 /// - private void OnSelectStock(StockItem? stock) + private void OnSelectAsset(AssetItem? asset) { - if (stock == null) return; + if (asset == null) return; // 隐藏搜索结果 IsSearchResultVisible = false; // 通知父ViewModel - StockSelected?.Invoke(this, stock); + AssetSelected?.Invoke(this, asset); } /// diff --git a/src/ViewModels/Home/HotAssetsViewModel.cs b/src/ViewModels/Home/HotAssetsViewModel.cs new file mode 100644 index 0000000..d6b5002 --- /dev/null +++ b/src/ViewModels/Home/HotAssetsViewModel.cs @@ -0,0 +1,110 @@ +using CommunityToolkit.Mvvm.Input; +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Applications.Home; +using MarketAssistant.Services.Market; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Collections.ObjectModel; + +namespace MarketAssistant.ViewModels.Home; + +/// +/// 热门资产ViewModel +/// +public partial class HotAssetsViewModel : ViewModelBase +{ + private readonly IServiceProvider _serviceProvider; + private readonly MarketContext _marketContext; + + private IHomeAssetService HomeAssetService => + _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + + /// + /// 热门资产集合 + /// + public ObservableCollection HotAssets { get; } = new(); + + /// + /// 选择热门资产命令 + /// + public IRelayCommand SelectHotAssetCommand { get; } + + /// + /// 添加到收藏命令 + /// + public IAsyncRelayCommand AddToFavoriteCommand { get; } + + /// + /// 刷新热门资产命令 + /// + public IAsyncRelayCommand RefreshCommand { get; } + + /// + /// 热门资产选择事件 + /// + public event EventHandler? HotAssetSelected; + + public HotAssetsViewModel( + IServiceProvider serviceProvider, + MarketContext marketContext, + ILogger logger) + : base(logger) + { + _serviceProvider = serviceProvider; + _marketContext = marketContext; + + SelectHotAssetCommand = new RelayCommand(OnSelectHotAsset); + AddToFavoriteCommand = new AsyncRelayCommand(OnAddToFavoriteAsync); + RefreshCommand = new AsyncRelayCommand(LoadHotAssetsAsync); + + // 自动加载热门资产 + _ = LoadHotAssetsAsync(); + } + + /// + /// 加载热门资产 + /// + public async Task LoadHotAssetsAsync() + { + await SafeExecuteAsync(async () => + { + var hotAssets = await HomeAssetService.GetHotAssetsAsync(); + + HotAssets.Clear(); + foreach (var asset in hotAssets) + { + HotAssets.Add(asset); + } + }, "加载热门资产"); + } + + /// + /// 选择热门资产 + /// + private void OnSelectHotAsset(HotAsset? asset) + { + if (asset == null) return; + + // 通知父ViewModel + HotAssetSelected?.Invoke(this, asset); + } + + /// + /// 添加到收藏 + /// + private async Task OnAddToFavoriteAsync(HotAsset? asset) + { + if (asset == null) return; + + await SafeExecuteAsync(async () => + { + await HomeAssetService.AddToFavoriteAsync(asset); + }, "添加收藏"); + } +} + + + + + + diff --git a/src/ViewModels/Home/HotStocksViewModel.cs b/src/ViewModels/Home/HotStocksViewModel.cs deleted file mode 100644 index 723305e..0000000 --- a/src/ViewModels/Home/HotStocksViewModel.cs +++ /dev/null @@ -1,94 +0,0 @@ -using CommunityToolkit.Mvvm.Input; -using MarketAssistant.Applications.Stocks; -using MarketAssistant.Applications.Stocks.Models; -using Microsoft.Extensions.Logging; -using System.Collections.ObjectModel; - -namespace MarketAssistant.ViewModels.Home; - -/// -/// 热门股票ViewModel -/// -public partial class HotStocksViewModel : ViewModelBase -{ - private readonly IHomeStockService _homeStockService; - - /// - /// 热门股票集合 - /// - public ObservableCollection HotStocks { get; } = new(); - - /// - /// 选择热门股票命令 - /// - public IRelayCommand SelectHotStockCommand { get; } - - /// - /// 添加到收藏命令 - /// - public IAsyncRelayCommand AddToFavoriteCommand { get; } - - /// - /// 刷新热门股票命令 - /// - public IAsyncRelayCommand RefreshCommand { get; } - - /// - /// 热门股票选择事件 - /// - public event EventHandler? HotStockSelected; - - public HotStocksViewModel(IHomeStockService homeStockService, ILogger logger) - : base(logger) - { - _homeStockService = homeStockService; - - SelectHotStockCommand = new RelayCommand(OnSelectHotStock); - AddToFavoriteCommand = new AsyncRelayCommand(OnAddToFavoriteAsync); - RefreshCommand = new AsyncRelayCommand(LoadHotStocksAsync); - - // 自动加载热门股票 - _ = LoadHotStocksAsync(); - } - - /// - /// 加载热门股票 - /// - public async Task LoadHotStocksAsync() - { - await SafeExecuteAsync(async () => - { - var hotStocks = await _homeStockService.GetHotStocksAsync(); - - HotStocks.Clear(); - foreach (var stock in hotStocks) - { - HotStocks.Add(stock); - } - }, "加载热门股票"); - } - - /// - /// 选择热门股票 - /// - private void OnSelectHotStock(HotStock? stock) - { - if (stock == null) return; - - // 通知父ViewModel - HotStockSelected?.Invoke(this, stock); - } - - /// - /// 添加到收藏 - /// - private async Task OnAddToFavoriteAsync(HotStock? stock) - { - if (stock == null) return; - - await SafeExecuteAsync(async () => - { - await _homeStockService.AddToFavoriteAsync(stock); - }, "添加收藏"); - } -} diff --git a/src/ViewModels/Home/RecentAssetsViewModel.cs b/src/ViewModels/Home/RecentAssetsViewModel.cs new file mode 100644 index 0000000..3237b0d --- /dev/null +++ b/src/ViewModels/Home/RecentAssetsViewModel.cs @@ -0,0 +1,126 @@ +using CommunityToolkit.Mvvm.Input; +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Applications.History; +using MarketAssistant.Applications.Home; +using MarketAssistant.Services.Market; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Collections.ObjectModel; + +namespace MarketAssistant.ViewModels.Home; + +/// +/// 最近查看资产ViewModel +/// +public partial class RecentAssetsViewModel : ViewModelBase +{ + private readonly IServiceProvider _serviceProvider; + private readonly MarketContext _marketContext; + + private IAssetHistoryService HistoryService => + _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + + private IHomeAssetService HomeAssetService => + _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + + /// + /// 最近查看资产集合 + /// + public ObservableCollection RecentAssets { get; } = new(); + + /// + /// 选择最近资产命令 + /// + public IRelayCommand SelectRecentAssetCommand { get; } + + /// + /// 添加到收藏命令 + /// + public IAsyncRelayCommand AddToFavoriteCommand { get; } + + /// + /// 刷新最近资产命令 + /// + public IRelayCommand RefreshCommand { get; } + + /// + /// 最近资产选择事件 + /// + public event EventHandler? RecentAssetSelected; + + public RecentAssetsViewModel( + IServiceProvider serviceProvider, + MarketContext marketContext, + ILogger logger) + : base(logger) + { + _serviceProvider = serviceProvider; + _marketContext = marketContext; + + SelectRecentAssetCommand = new RelayCommand(OnSelectRecentAsset); + AddToFavoriteCommand = new AsyncRelayCommand(OnAddToFavoriteAsync); + RefreshCommand = new RelayCommand(LoadRecentAssets); + + // 自动加载最近资产 + LoadRecentAssets(); + } + + /// + /// 加载最近查看资产 + /// + public void LoadRecentAssets() + { + SafeExecute(() => + { + var recentAssets = HistoryService.GetHistory(); + + RecentAssets.Clear(); + foreach (var asset in recentAssets) + { + RecentAssets.Add(asset); + } + }, "加载最近查看资产"); + } + + /// + /// 添加资产到最近查看 + /// + public void AddToRecentAssets(AssetItem asset) + { + SafeExecute(() => + { + HistoryService.AddHistory(asset); + LoadRecentAssets(); // 刷新列表 + }, "添加到最近查看"); + } + + /// + /// 选择最近资产 + /// + private void OnSelectRecentAsset(AssetItem? asset) + { + if (asset == null) return; + + // 通知父ViewModel + RecentAssetSelected?.Invoke(this, asset); + } + + /// + /// 添加到收藏 + /// + private async Task OnAddToFavoriteAsync(AssetItem? asset) + { + if (asset == null) return; + + await SafeExecuteAsync(async () => + { + await HomeAssetService.AddToFavoriteAsync(asset); + }, "添加收藏"); + } +} + + + + + + diff --git a/src/ViewModels/Home/RecentStocksViewModel.cs b/src/ViewModels/Home/RecentStocksViewModel.cs deleted file mode 100644 index b21c666..0000000 --- a/src/ViewModels/Home/RecentStocksViewModel.cs +++ /dev/null @@ -1,106 +0,0 @@ -using CommunityToolkit.Mvvm.Input; -using MarketAssistant.Applications.Stocks; -using MarketAssistant.Applications.Stocks.Models; -using Microsoft.Extensions.Logging; -using System.Collections.ObjectModel; - -namespace MarketAssistant.ViewModels.Home; - -/// -/// 最近查看股票ViewModel -/// -public partial class RecentStocksViewModel : ViewModelBase -{ - private readonly IHomeStockService _homeStockService; - - /// - /// 最近查看股票集合 - /// - public ObservableCollection RecentStocks { get; } = new(); - - /// - /// 选择最近股票命令 - /// - public IRelayCommand SelectRecentStockCommand { get; } - - /// - /// 添加到收藏命令 - /// - public IAsyncRelayCommand AddToFavoriteCommand { get; } - - /// - /// 刷新最近股票命令 - /// - public IRelayCommand RefreshCommand { get; } - - /// - /// 最近股票选择事件 - /// - public event EventHandler? RecentStockSelected; - - public RecentStocksViewModel(IHomeStockService homeStockService, ILogger logger) - : base(logger) - { - _homeStockService = homeStockService; - - SelectRecentStockCommand = new RelayCommand(OnSelectRecentStock); - AddToFavoriteCommand = new AsyncRelayCommand(OnAddToFavoriteAsync); - RefreshCommand = new RelayCommand(LoadRecentStocks); - - // 自动加载最近股票 - LoadRecentStocks(); - } - - /// - /// 加载最近查看股票 - /// - public void LoadRecentStocks() - { - SafeExecute(() => - { - var recentStocks = _homeStockService.GetRecentStocks(); - - RecentStocks.Clear(); - foreach (var stock in recentStocks) - { - RecentStocks.Add(stock); - } - }, "加载最近查看股票"); - } - - /// - /// 添加股票到最近查看 - /// - public void AddToRecentStocks(StockItem stock) - { - SafeExecute(() => - { - _homeStockService.AddToRecentStocks(stock); - LoadRecentStocks(); // 刷新列表 - }, "添加到最近查看"); - } - - /// - /// 选择最近股票 - /// - private void OnSelectRecentStock(StockItem? stock) - { - if (stock == null) return; - - // 通知父ViewModel - RecentStockSelected?.Invoke(this, stock); - } - - /// - /// 添加到收藏 - /// - private async Task OnAddToFavoriteAsync(StockItem? stock) - { - if (stock == null) return; - - await SafeExecuteAsync(async () => - { - await _homeStockService.AddToFavoriteAsync(stock); - }, "添加收藏"); - } -} diff --git a/src/ViewModels/Home/TelegraphNewsViewModel.cs b/src/ViewModels/Home/TelegraphNewsViewModel.cs index 48b41dd..69d8165 100644 --- a/src/ViewModels/Home/TelegraphNewsViewModel.cs +++ b/src/ViewModels/Home/TelegraphNewsViewModel.cs @@ -2,8 +2,11 @@ using CommunityToolkit.Mvvm.Input; using MarketAssistant.Applications.News; using MarketAssistant.Applications.Telegrams; +using MarketAssistant.Services.Market; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System.Collections.ObjectModel; +using System.ComponentModel; using System.Diagnostics; namespace MarketAssistant.ViewModels.Home; @@ -13,7 +16,9 @@ namespace MarketAssistant.ViewModels.Home; /// public partial class TelegraphNewsViewModel : ViewModelBase, IDisposable { - private readonly INewsUpdateService _newsUpdateService; + private readonly IServiceProvider _serviceProvider; + private readonly MarketContext _marketContext; + private INewsUpdateService _newsUpdateService; private bool _disposed; [ObservableProperty] @@ -44,10 +49,18 @@ public partial class TelegraphNewsViewModel : ViewModelBase, IDisposable /// public IRelayCommand StopUpdatesCommand { get; } - public TelegraphNewsViewModel(INewsUpdateService newsUpdateService, ILogger logger) + public TelegraphNewsViewModel( + IServiceProvider serviceProvider, + MarketContext marketContext, + ILogger logger) : base(logger) { - _newsUpdateService = newsUpdateService; + _serviceProvider = serviceProvider; + _marketContext = marketContext; + + // 根据当前市场类型获取对应的 NewsUpdateService + _newsUpdateService = _serviceProvider.GetRequiredKeyedService( + _marketContext.CurrentMarket.ToString()); OpenNewsCommand = new AsyncRelayCommand(OnOpenNewsAsync); RefreshCommand = new RelayCommand(() => _newsUpdateService.StartUpdates()); @@ -58,10 +71,61 @@ public TelegraphNewsViewModel(INewsUpdateService newsUpdateService, ILogger + /// 处理市场切换事件 + /// + private void OnMarketContextPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(MarketContext.CurrentMarket)) + { + OnMarketChanged(_marketContext.CurrentMarket); + } + } + + /// + /// 市场切换时更换新闻服务 + /// + private void OnMarketChanged(MarketType newMarket) + { + Dispatcher.UIThread.InvokeAsync(() => + { + // 记录旧服务状态 + var wasRunning = _newsUpdateService.IsRunning; + + // 停止旧服务并取消事件订阅 + _newsUpdateService.StopUpdates(); + _newsUpdateService.NewsUpdated -= OnNewsUpdated; + _newsUpdateService.CountdownUpdated -= OnCountdownUpdated; + + // 获取新市场的服务 + _newsUpdateService = _serviceProvider.GetRequiredKeyedService( + newMarket.ToString()); + + // 订阅新服务事件 + _newsUpdateService.NewsUpdated += OnNewsUpdated; + _newsUpdateService.CountdownUpdated += OnCountdownUpdated; + + // 清空旧新闻 + Telegraphs.Clear(); + TelegraphRefreshCountdown = ""; + + // 如果之前在运行,重新启动 + if (wasRunning) + { + _newsUpdateService.StartUpdates(); + } + + Logger?.LogInformation("已切换到 {Market} 市场新闻源", newMarket); + }); + } + /// /// 处理新闻更新事件 /// @@ -141,7 +205,10 @@ public void Dispose() { if (!_disposed) { - // 取消事件订阅 + // 取消市场切换事件订阅 + _marketContext.PropertyChanged -= OnMarketContextPropertyChanged; + + // 取消新闻服务事件订阅 _newsUpdateService.NewsUpdated -= OnNewsUpdated; _newsUpdateService.CountdownUpdated -= OnCountdownUpdated; diff --git a/src/ViewModels/HomePageViewModel.cs b/src/ViewModels/HomePageViewModel.cs index 5f867a4..1e16688 100644 --- a/src/ViewModels/HomePageViewModel.cs +++ b/src/ViewModels/HomePageViewModel.cs @@ -1,5 +1,5 @@ using CommunityToolkit.Mvvm.Messaging; -using MarketAssistant.Applications.Stocks.Models; +using MarketAssistant.Applications.Assets.Models; using MarketAssistant.ViewModels.Home; using Microsoft.Extensions.Logging; @@ -16,14 +16,14 @@ public partial class HomePageViewModel : ViewModelBase, IDisposable public HomeSearchViewModel Search { get; } /// - /// 热门股票ViewModel + /// 热门资产ViewModel /// - public HotStocksViewModel HotStocks { get; } + public HotAssetsViewModel HotAssets { get; } /// /// 最近查看ViewModel /// - public RecentStocksViewModel RecentStocks { get; } + public RecentAssetsViewModel RecentAssets { get; } /// /// 新闻快讯ViewModel @@ -35,61 +35,82 @@ public partial class HomePageViewModel : ViewModelBase, IDisposable /// public HomePageViewModel( HomeSearchViewModel searchViewModel, - HotStocksViewModel hotStocksViewModel, - RecentStocksViewModel recentStocksViewModel, + HotAssetsViewModel hotAssetsViewModel, + RecentAssetsViewModel recentAssetsViewModel, TelegraphNewsViewModel newsViewModel, ILogger logger) : base(logger) { Search = searchViewModel; - HotStocks = hotStocksViewModel; - RecentStocks = recentStocksViewModel; + HotAssets = hotAssetsViewModel; + RecentAssets = recentAssetsViewModel; News = newsViewModel; // 订阅子ViewModel事件 - Search.StockSelected += OnStockSelected; - HotStocks.HotStockSelected += OnHotStockSelected; - RecentStocks.RecentStockSelected += OnRecentStockSelected; + Search.AssetSelected += OnAssetSelected; + HotAssets.HotAssetSelected += OnHotAssetSelected; + RecentAssets.RecentAssetSelected += OnRecentAssetSelected; } /// - /// 处理搜索股票选择事件 + /// 处理搜索资产选择事件 /// - private void OnStockSelected(object? sender, StockItem stock) + private void OnAssetSelected(object? sender, AssetItem asset) { - NavigateToStock(stock); + NavigateToAsset(asset); } /// - /// 处理热门股票选择事件 + /// 处理热门资产选择事件 /// - private void OnHotStockSelected(object? sender, HotStock stock) + private void OnHotAssetSelected(object? sender, HotAsset asset) { - var stockCode = $"{stock.Market}{stock.Code}".ToLower(); - var stockItem = new StockItem { Name = stock.Name, Code = stockCode }; - NavigateToStock(stockItem); + var assetCode = $"{asset.Market}{asset.Code}".ToLower(); + + // 解析价格信息 + decimal? currentPrice = decimal.TryParse(asset.CurrentPrice, out var price) ? price : null; + decimal? changePercent = decimal.TryParse(asset.ChangePercentage?.TrimEnd('%'), out var percent) ? percent : null; + + // 传递完整的基本信息,避免详情页等待 + var parameter = new AssetNavigationParameter( + assetCode, + asset.Name, + currentPrice, + changePercent + ); + + WeakReferenceMessenger.Default.Send(new NavigationMessage("Asset", parameter)); + Logger?.LogInformation($"导航到资产详情页: {assetCode}"); + + // 异步添加到最近查看 + var assetItem = new AssetItem { Name = asset.Name, Code = assetCode }; + _ = Task.Run(() => RecentAssets.AddToRecentAssets(assetItem)); } /// - /// 处理最近股票选择事件 + /// 处理最近资产选择事件 /// - private void OnRecentStockSelected(object? sender, StockItem stock) + private void OnRecentAssetSelected(object? sender, AssetItem asset) { - NavigateToStock(stock); + NavigateToAsset(asset); } /// - /// 导航到股票详情页 + /// 导航到资产详情页 /// - private void NavigateToStock(StockItem stockItem) + private void NavigateToAsset(AssetItem assetItem, decimal? currentPrice = null, decimal? changePercent = null) { // 立即发送导航消息,不阻塞UI WeakReferenceMessenger.Default.Send( - new NavigationMessage("Stock", new StockNavigationParameter(stockItem.Code, stockItem.Name))); + new NavigationMessage("Asset", new AssetNavigationParameter( + assetItem.Code, + assetItem.Name, + currentPrice, + changePercent))); - Logger?.LogInformation($"导航到股票详情页: {stockItem.Code}"); + Logger?.LogInformation($"导航到资产详情页: {assetItem.Code}"); // 异步添加到最近查看,不阻塞导航 - _ = Task.Run(() => RecentStocks.AddToRecentStocks(stockItem)); + _ = Task.Run(() => RecentAssets.AddToRecentAssets(assetItem)); } /// @@ -111,19 +132,19 @@ public void StopTimer() } /// - /// 刷新热门股票 + /// 刷新热门资产 /// - public async Task RefreshHotStocksAsync() + public async Task RefreshHotAssetsAsync() { - await HotStocks.LoadHotStocksAsync(); + await HotAssets.LoadHotAssetsAsync(); } /// - /// 刷新最近查看股票 + /// 刷新最近查看资产 /// - public void RefreshRecentStocks() + public void RefreshRecentAssets() { - RecentStocks.LoadRecentStocks(); + RecentAssets.LoadRecentAssets(); } /// @@ -132,9 +153,9 @@ public void RefreshRecentStocks() public void Dispose() { // 取消事件订阅 - Search.StockSelected -= OnStockSelected; - HotStocks.HotStockSelected -= OnHotStockSelected; - RecentStocks.RecentStockSelected -= OnRecentStockSelected; + Search.AssetSelected -= OnAssetSelected; + HotAssets.HotAssetSelected -= OnHotAssetSelected; + RecentAssets.RecentAssetSelected -= OnRecentAssetSelected; // 释放子ViewModel资源 News.Dispose(); diff --git a/src/ViewModels/MainWindowViewModel.cs b/src/ViewModels/MainWindowViewModel.cs index 655b08a..dc97649 100644 --- a/src/ViewModels/MainWindowViewModel.cs +++ b/src/ViewModels/MainWindowViewModel.cs @@ -1,6 +1,9 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Market; using MarketAssistant.Services.Navigation; +using MarketAssistant.Services.Notification; using MarketAssistant.ViewModels.Demo; using Microsoft.Extensions.Logging; using System.Collections.ObjectModel; @@ -12,6 +15,8 @@ public partial class MainWindowViewModel : ViewModelBase { private readonly IServiceProvider _serviceProvider; private readonly NavigationService _navigationService; + private readonly MarketContext _marketContext; + private readonly INotificationService _notificationService; [ObservableProperty] private NavigationItemViewModel? _selectedNavigationItem; @@ -22,14 +27,23 @@ public partial class MainWindowViewModel : ViewModelBase public ObservableCollection NavigationItems { get; } + /// + /// 当前市场类型显示文本 + /// + public string CurrentMarketText => _marketContext.CurrentMarket == MarketType.AShare ? "A股市场" : "虚拟币市场"; + public MainWindowViewModel( IServiceProvider serviceProvider, NavigationService navigationService, + MarketContext marketContext, + INotificationService notificationService, ILogger? logger = null) : base(logger) { _serviceProvider = serviceProvider; _navigationService = navigationService; + _marketContext = marketContext; + _notificationService = notificationService; NavigationItems = new ObservableCollection { @@ -38,7 +52,7 @@ public MainWindowViewModel( #endif new NavigationItemViewModel("首页", "avares://MarketAssistant/Assets/Images/tab_home.svg", "avares://MarketAssistant/Assets/Images/tab_home_on.svg", () => _serviceProvider.GetRequiredService()), new NavigationItemViewModel("收藏", "avares://MarketAssistant/Assets/Images/tab_favorites.svg", "avares://MarketAssistant/Assets/Images/tab_favorites_on.svg", () => _serviceProvider.GetRequiredService()), - new NavigationItemViewModel("AI选股", "avares://MarketAssistant/Assets/Images/tab_analysis.svg", "avares://MarketAssistant/Assets/Images/tab_analysis_on.svg", () => _serviceProvider.GetRequiredService()), + new NavigationItemViewModel("AI选股", "avares://MarketAssistant/Assets/Images/tab_analysis.svg", "avares://MarketAssistant/Assets/Images/tab_analysis_on.svg", () => _serviceProvider.GetRequiredService()), new NavigationItemViewModel("设置", "avares://MarketAssistant/Assets/Images/tab_settings.svg", "avares://MarketAssistant/Assets/Images/tab_settings_on.svg", () => _serviceProvider.GetRequiredService()), new NavigationItemViewModel("关于", "avares://MarketAssistant/Assets/Images/tab_about.svg", "avares://MarketAssistant/Assets/Images/tab_about_on.svg", () => _serviceProvider.GetRequiredService()) }; @@ -46,12 +60,23 @@ public MainWindowViewModel( // 监听导航服务属性变更 _navigationService.PropertyChanged += OnNavigationServicePropertyChanged; + // 监听市场切换事件 + _marketContext.PropertyChanged += OnMarketContextPropertyChanged; + // 默认导航到首页 SelectedNavigationItem = NavigationItems[0]; var homeViewModel = SelectedNavigationItem.CreateViewModel(); _navigationService.NavigateToRoot(homeViewModel, SelectedNavigationItem.Title); } + private void OnMarketContextPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(MarketContext.CurrentMarket)) + { + OnPropertyChanged(nameof(CurrentMarketText)); + } + } + private void OnNavigationServicePropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(NavigationService.CurrentPage)) @@ -82,6 +107,34 @@ private void GoBack() _navigationService.GoBack(); } + /// + /// 切换市场命令(Logo点击或Ctrl+M快捷键) + /// + [RelayCommand] + private void ToggleMarket() + { + // 切换市场类型 + var newMarket = _marketContext.CurrentMarket == MarketType.AShare + ? MarketType.Crypto + : MarketType.AShare; + + _marketContext.SwitchMarket(newMarket); + + // 显示切换提示 + var marketName = newMarket == MarketType.AShare ? "A股市场" : "虚拟币市场"; + _notificationService.ShowSuccess($"已切换到{marketName}"); + + Logger?.LogInformation("市场已切换到: {Market} ({MarketName})", newMarket, marketName); + + // 刷新当前页面(重新加载数据) + if (SelectedNavigationItem != null) + { + var currentTitle = SelectedNavigationItem.Title; + var viewModel = SelectedNavigationItem.CreateViewModel(); + _navigationService.NavigateToRoot(viewModel, currentTitle); + } + } + partial void OnSelectedNavigationItemChanged(NavigationItemViewModel? value) { if (value != null) diff --git a/src/ViewModels/SettingsPageViewModel.cs b/src/ViewModels/SettingsPageViewModel.cs index c569668..0cb5293 100644 --- a/src/ViewModels/SettingsPageViewModel.cs +++ b/src/ViewModels/SettingsPageViewModel.cs @@ -5,6 +5,7 @@ using MarketAssistant.Agents.Analysts; using MarketAssistant.Agents.Analysts.Attributes; using MarketAssistant.Applications.Settings; +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Infrastructure.Factories; using MarketAssistant.Rag; using MarketAssistant.Rag.Interfaces; @@ -29,6 +30,7 @@ public partial class SettingsPageViewModel : ViewModelBase private readonly IUserSettingService _userSettingService; private readonly IEmbeddingFactory _embeddingFactory; private readonly VectorStore _vectorStore; + private readonly Services.Market.MarketContext _marketContext; private IStorageProvider? _storageProvider; // UserSetting对象,包含所有用户设置 @@ -70,6 +72,41 @@ public partial class SettingsPageViewModel : ViewModelBase // API密钥获取URL public string ModelApiUrl { get; } = "https://cloud.siliconflow.cn/i/z4lbHdBE"; public string ZhiTuApiUrl { get; } = "https://www.zhituapi.com/gettoken.html"; + public string BinanceApiUrl { get; } = "https://www.binance.com/zh-CN/support/faq/360002502072"; + + /// + /// 是否为A股市场 + /// + public bool IsAShareMarket + { + get => UserSetting.CurrentMarketType == MarketType.AShare; + set + { + if (value) + { + UserSetting.CurrentMarketType = MarketType.AShare; + OnPropertyChanged(nameof(IsAShareMarket)); + OnPropertyChanged(nameof(IsCryptoMarket)); + } + } + } + + /// + /// 是否为虚拟币市场 + /// + public bool IsCryptoMarket + { + get => UserSetting.CurrentMarketType == MarketType.Crypto; + set + { + if (value) + { + UserSetting.CurrentMarketType = MarketType.Crypto; + OnPropertyChanged(nameof(IsAShareMarket)); + OnPropertyChanged(nameof(IsCryptoMarket)); + } + } + } /// /// 构造函数(使用依赖注入) @@ -80,6 +117,7 @@ public SettingsPageViewModel( IUserSettingService userSettingService, IEmbeddingFactory embeddingFactory, VectorStore vectorStore, + Services.Market.MarketContext marketContext, ILogger logger) : base(logger) { _ragIngestionService = ragIngestionService; @@ -87,6 +125,7 @@ public SettingsPageViewModel( _userSettingService = userSettingService; _embeddingFactory = embeddingFactory; _vectorStore = vectorStore; + _marketContext = marketContext; _ = InitializeAsync(); } @@ -104,8 +143,13 @@ private async Task InitializeAsync() await LoadModelsAsync(); // 加载用户设置 UserSetting = _userSettingService.CurrentSetting; + // 同步市场类型到MarketContext + _marketContext.SwitchMarket(UserSetting.CurrentMarketType); // 加载分析师角色 LoadAnalystRoles(); + // 触发属性变更通知 + OnPropertyChanged(nameof(IsAShareMarket)); + OnPropertyChanged(nameof(IsCryptoMarket)); } private void LoadAnalystRoles() @@ -154,6 +198,9 @@ private void LoadAnalystRoles() [RelayCommand] private Task OpenZhiTuApiWebsite() => OpenUrlAsync(ZhiTuApiUrl); + [RelayCommand] + private Task OpenBinanceApiWebsite() => OpenUrlAsync(BinanceApiUrl); + /// /// 选择知识库目录 /// @@ -371,9 +418,12 @@ private void Save() UserSetting.EnabledAnalystRoles[role.Id] = role.IsEnabled; } + // 同步市场类型到MarketContext + _marketContext.SwitchMarket(UserSetting.CurrentMarketType); + _userSettingService.UpdateSettings(UserSetting); _notificationService.ShowSuccess("设置已保存"); - Logger?.LogInformation("保存设置"); + Logger?.LogInformation("保存设置,市场类型:{MarketType}", UserSetting.CurrentMarketType); }, "保存设置"); } diff --git a/src/Views/Components/StockWebChartView.cs b/src/Views/Components/KLineChartView.cs similarity index 95% rename from src/Views/Components/StockWebChartView.cs rename to src/Views/Components/KLineChartView.cs index deb1365..663e97d 100644 --- a/src/Views/Components/StockWebChartView.cs +++ b/src/Views/Components/KLineChartView.cs @@ -2,17 +2,17 @@ using Avalonia.Controls; using Avalonia.Layout; using Avalonia.Platform; -using MarketAssistant.Applications.Stocks.Models; +using MarketAssistant.Applications.Charts.Models; using System.Text.Json; using WebViewControl; namespace MarketAssistant.Views.Components; /// -/// 股票Web图表视图组件 (Avalonia版本) +/// K线图表视图组件 (Avalonia版本) /// 使用 WebView.Avalonia.Desktop 库提供 WebView 支持 /// -public class StockWebChartView : UserControl +public class KLineChartView : UserControl { private bool _isInitialized = false; private WebView? _webView; @@ -22,19 +22,19 @@ public class StockWebChartView : UserControl private Button? _retryButton; // 定义依赖属性,支持 MVVM 绑定 - public static readonly StyledProperty?> DataProperty = - AvaloniaProperty.Register?>(nameof(Data)); + public static readonly StyledProperty?> DataProperty = + AvaloniaProperty.Register?>(nameof(Data)); /// /// K线数据源 /// - public IEnumerable? Data + public IEnumerable? Data { get => GetValue(DataProperty); set => SetValue(DataProperty, value); } - public StockWebChartView() + public KLineChartView() { InitializeComponent(); } @@ -49,7 +49,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang if (change.Property == DataProperty) { // 当数据源发生变化时,自动更新图表 - _ = UpdateChartAsync(change.NewValue as IEnumerable); + _ = UpdateChartAsync(change.NewValue as IEnumerable); } } @@ -216,7 +216,7 @@ private string GetDefaultChartHtml() - 股票K线图表 + K线图表 diff --git a/src/MarketAssistant.App/Resources/Styles/Spacing.axaml b/src/MarketAssistant.App/Resources/Styles/Spacing.axaml index ccdc9ca..fb5e5c2 100644 --- a/src/MarketAssistant.App/Resources/Styles/Spacing.axaml +++ b/src/MarketAssistant.App/Resources/Styles/Spacing.axaml @@ -121,8 +121,9 @@ 120 - 600 + 420 300 + 700 320 72 400 diff --git a/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs b/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs index 0b90cf0..64e77ad 100644 --- a/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs @@ -1,12 +1,8 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using MarketAssistant.Agents; -using MarketAssistant.Agents.ContextProviders; -using MarketAssistant.Agents.Middleware; -using MarketAssistant.Agents.Tools; using MarketAssistant.Infrastructure.Factories; using MarketAssistant.Services; -using MarketAssistant.Services.Mcp; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using System.Collections.ObjectModel; @@ -44,36 +40,12 @@ public partial class ChatSidebarViewModel : ViewModelBase public ChatSidebarViewModel( ILogger logger, - IChatClientFactory chatClientFactory, - ILoggerFactory loggerFactory, - McpToolContextProvider mcpToolProvider, - GroundingSearchTools searchTools, - MemoryManagementTools memoryTools, - SessionSearchTools sessionSearchTools, - KnowledgeGraphTools knowledgeGraphTools, - TokenTrackingMiddleware tokenTracking, - ConversationCompressionMiddleware compressionMiddleware, - LayeredMemoryContextProvider layeredMemoryProvider, - RagContextProvider ragProvider, - ChatSessionPersistenceService sessionPersistence, - MemoryExtractionService memoryExtraction) + IMarketChatSessionFactory chatSessionFactory, + ChatSessionPersistenceService sessionPersistence) : base(logger) { _sessionPersistence = sessionPersistence; - - var chatClient = chatClientFactory.CreateClient(); - var sessionLogger = loggerFactory.CreateLogger(); - _chatSession = new MarketChatSession( - chatClient, sessionLogger, mcpToolProvider, searchTools, - memoryTools: memoryTools, - sessionSearchTools: sessionSearchTools, - knowledgeGraphTools: knowledgeGraphTools, - tokenTracking: tokenTracking, - compressionMiddleware: compressionMiddleware, - layeredMemoryProvider: layeredMemoryProvider, - ragProvider: ragProvider, - sessionPersistence: sessionPersistence, - memoryExtraction: memoryExtraction); + _chatSession = chatSessionFactory.Create(); SendMessageCommand = new RelayCommand(SendMessage, CanSendMessage); _ = LoadSessionHistoryAsync(); @@ -124,15 +96,15 @@ private async void SendMessage() await foreach (var chunk in _chatSession.SendMessageStreamAsync(currentInput, _currentCancellationTokenSource.Token)) { - if (!string.IsNullOrEmpty(chunk.Content)) + if (!string.IsNullOrEmpty(chunk)) { - contentBuilder.Append(chunk.Content); + contentBuilder.Append(chunk); if (!hasReceivedContent) { hasReceivedContent = true; aiMessage.Status = MessageStatus.Streaming; - aiMessage.Content = chunk.Content; + aiMessage.Content = chunk; } else { diff --git a/src/MarketAssistant.App/ViewModels/Demo/ChatSidebarDemoViewModel.cs b/src/MarketAssistant.App/ViewModels/Demo/ChatSidebarDemoViewModel.cs index c6d9ee2..93a7cbe 100644 --- a/src/MarketAssistant.App/ViewModels/Demo/ChatSidebarDemoViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Demo/ChatSidebarDemoViewModel.cs @@ -1,7 +1,8 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using MarketAssistant.Agents; using MarketAssistant.Agents.MarketAnalysis.Models; -using MarketAssistant.Services.Mcp; +using MarketAssistant.Infrastructure.Factories; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging.Abstractions; using System.Text.Json.Serialization; @@ -19,24 +20,9 @@ public partial class ChatSidebarDemoViewModel : ViewModelBase public ChatSidebarDemoViewModel() : base(NullLogger.Instance) { - // 初始化 TargetViewModel - var mcpService = new McpService(NullLogger.Instance); - var mcpToolProvider = new McpToolContextProvider(mcpService, NullLogger.Instance); - TargetViewModel = new ChatSidebarViewModel( NullLogger.Instance, - new MockChatClientFactory(), - NullLoggerFactory.Instance, - mcpToolProvider, - null!, - null!, - null!, - null!, - null!, - null!, - null!, - null!, - null!, + new MockMarketChatSessionFactory(), null! ); diff --git a/src/MarketAssistant.App/ViewModels/Demo/MockChatClientFactory.cs b/src/MarketAssistant.App/ViewModels/Demo/MockChatClientFactory.cs index 1f898d2..919ee16 100644 --- a/src/MarketAssistant.App/ViewModels/Demo/MockChatClientFactory.cs +++ b/src/MarketAssistant.App/ViewModels/Demo/MockChatClientFactory.cs @@ -1,5 +1,7 @@ +using MarketAssistant.Agents; using MarketAssistant.Infrastructure.Factories; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; using System; using System.Collections.Generic; using System.Threading; @@ -15,6 +17,20 @@ public IChatClient CreateClient() } } +/// +/// Demo 用 MarketChatSession 工厂,使用 Mock ChatClient +/// +public class MockMarketChatSessionFactory : IMarketChatSessionFactory +{ + public MarketChatSession Create(string? initialStockCode = null) + { + return new MarketChatSession( + new MockChatClient(), + NullLogger.Instance, + initialStockCode: initialStockCode); + } +} + public class MockChatClient : IChatClient { public ChatClientMetadata Metadata => new ChatClientMetadata("Mock", new Uri("http://localhost")); diff --git a/src/MarketAssistant.App/Views/Components/AdaptiveCardView.axaml.cs b/src/MarketAssistant.App/Views/Components/AdaptiveCardView.axaml.cs index 202a436..9e273c3 100644 --- a/src/MarketAssistant.App/Views/Components/AdaptiveCardView.axaml.cs +++ b/src/MarketAssistant.App/Views/Components/AdaptiveCardView.axaml.cs @@ -3,7 +3,7 @@ using Avalonia.Controls; using Avalonia.Layout; using Avalonia.Media; -using Avalonia.Controls.Shapes; + using Avalonia.Markup.Xaml.MarkupExtensions; namespace MarketAssistant.Views.Components; @@ -148,8 +148,7 @@ private Control RenderContainer(AdaptiveContainer container) } else if (container.Style == AdaptiveContainerStyle.Attention) { - // Light red background for attention - border.Background = new SolidColorBrush(Color.Parse("#20FF0000")); + border[!Border.BackgroundProperty] = new DynamicResourceExtension("DangerBackgroundBrush"); border.Padding = new Thickness(8); border.CornerRadius = new CornerRadius(4); } @@ -258,20 +257,22 @@ private Control RenderFactSet(AdaptiveFactSet factSet) private Control RenderImage(AdaptiveImage image) { - // Placeholder for image rendering + var textBlock = new TextBlock + { + Text = "Image: " + image.Url, + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center, + FontSize = 10 + }; + textBlock[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlForegroundBaseMediumBrush"); + var border = new Border { - Background = Brushes.LightGray, Height = 100, CornerRadius = new CornerRadius(4), - Child = new TextBlock - { - Text = "Image: " + image.Url, - VerticalAlignment = VerticalAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center, - FontSize = 10 - } + Child = textBlock }; + border[!Border.BackgroundProperty] = new DynamicResourceExtension("SurfaceVariantBrush"); if (image.PixelHeight > 0) border.Height = image.PixelHeight; diff --git a/src/MarketAssistant.App/Views/Components/ChatSidebarView.axaml.cs b/src/MarketAssistant.App/Views/Components/ChatSidebarView.axaml.cs index 560ec07..b1fbc34 100644 --- a/src/MarketAssistant.App/Views/Components/ChatSidebarView.axaml.cs +++ b/src/MarketAssistant.App/Views/Components/ChatSidebarView.axaml.cs @@ -24,34 +24,58 @@ public ICommand? CloseCommand set => SetValue(CloseCommandProperty, value); } + private ChatSidebarViewModel? _subscribedViewModel; + public ChatSidebarView() { InitializeComponent(); - // 监听 DataContext 变化以订阅集合变更事件 DataContextChanged += OnDataContextChanged; - - // 监听输入框按键 MessageEntry.KeyDown += MessageEntry_KeyDown; } + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + SubscribeToViewModel(DataContext as ChatSidebarViewModel); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + UnsubscribeFromViewModel(); + base.OnDetachedFromVisualTree(e); + } + private void OnDataContextChanged(object? sender, EventArgs e) { - if (DataContext is ChatSidebarViewModel vm) - { - // 订阅新 ViewModel 的事件 - vm.ChatMessages.CollectionChanged += ChatMessages_CollectionChanged; - } + UnsubscribeFromViewModel(); + SubscribeToViewModel(DataContext as ChatSidebarViewModel); + } + + private void SubscribeToViewModel(ChatSidebarViewModel? vm) + { + if (vm == null || vm == _subscribedViewModel) + return; + + _subscribedViewModel = vm; + vm.ChatMessages.CollectionChanged += ChatMessages_CollectionChanged; + } + + private void UnsubscribeFromViewModel() + { + if (_subscribedViewModel == null) + return; + + _subscribedViewModel.ChatMessages.CollectionChanged -= ChatMessages_CollectionChanged; + _subscribedViewModel = null; } private void ChatMessages_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { - // 仅当有新消息添加时滚动到底部 if (e.Action == NotifyCollectionChangedAction.Add) { Dispatcher.UIThread.Post(() => { - // 滚动到最后一项 if (ChatListBox.ItemCount > 0) { ChatListBox.ScrollIntoView(ChatListBox.ItemCount - 1); @@ -62,7 +86,6 @@ private void ChatMessages_CollectionChanged(object? sender, NotifyCollectionChan private void MessageEntry_KeyDown(object? sender, KeyEventArgs e) { - // Enter 发送,Shift+Enter 换行 if (e.Key == Key.Enter && e.KeyModifiers == KeyModifiers.None) { if (DataContext is ChatSidebarViewModel vm && !string.IsNullOrWhiteSpace(vm.UserInput)) diff --git a/src/MarketAssistant.App/Views/Pages/AgentAnalysisPageView.axaml b/src/MarketAssistant.App/Views/Pages/AgentAnalysisPageView.axaml index 8f6c6bf..8a95233 100644 --- a/src/MarketAssistant.App/Views/Pages/AgentAnalysisPageView.axaml +++ b/src/MarketAssistant.App/Views/Pages/AgentAnalysisPageView.axaml @@ -16,7 +16,7 @@ 300 - + @@ -126,10 +126,20 @@ + + + - diff --git a/tests/MarketChatSessionTest.cs b/tests/MarketChatSessionTest.cs index 344443a..d27db18 100644 --- a/tests/MarketChatSessionTest.cs +++ b/tests/MarketChatSessionTest.cs @@ -38,7 +38,7 @@ public async Task TestBasicChatAsync() await foreach (var update in _chatSession.SendMessageStreamAsync("你好,请介绍股票投资的基础知识")) { - responseBuilder.Append(update.Content); + responseBuilder.Append(update); } var responseText = responseBuilder.ToString(); @@ -58,7 +58,7 @@ public async Task TestStockContextChatAsync() var responseBuilder = new System.Text.StringBuilder(); await foreach (var update in _chatSession.SendMessageStreamAsync("这只股票的基本面如何?")) { - responseBuilder.Append(update.Content); + responseBuilder.Append(update); } var responseText = responseBuilder.ToString(); @@ -78,7 +78,7 @@ public async Task TestConversationHistoryAsync() var responseBuilder = new System.Text.StringBuilder(); await foreach (var update in _chatSession.SendMessageStreamAsync("这两个指标有什么区别")) { - responseBuilder.Append(update.Content); + responseBuilder.Append(update); } var responseText = responseBuilder.ToString(); @@ -141,7 +141,7 @@ public async Task TestTopicGuidanceAsync() var responseBuilder = new System.Text.StringBuilder(); await foreach (var update in _chatSession.SendMessageStreamAsync("今天的天气怎么样")) { - responseBuilder.Append(update.Content); + responseBuilder.Append(update); } var responseText = responseBuilder.ToString(); @@ -162,9 +162,9 @@ public async Task TestStreamingResponseAsync() var allContent = new List(); await foreach (var update in _chatSession.SendMessageStreamAsync("分析sz000001的技术指标")) { - if (!string.IsNullOrEmpty(update.Content)) + if (!string.IsNullOrEmpty(update)) { - allContent.Add(update.Content); + allContent.Add(update); } } @@ -197,21 +197,21 @@ public async Task TestIntelligentAnalysisAsync() var response1Builder = new System.Text.StringBuilder(); await foreach (var update in _chatSession.SendMessageStreamAsync("分析MACD和RSI指标")) { - response1Builder.Append(update.Content); + response1Builder.Append(update); } Assert.IsFalse(string.IsNullOrEmpty(response1Builder.ToString())); var response2Builder = new System.Text.StringBuilder(); await foreach (var update in _chatSession.SendMessageStreamAsync("这家公司的ROE和净利润如何?")) { - response2Builder.Append(update.Content); + response2Builder.Append(update); } Assert.IsFalse(string.IsNullOrEmpty(response2Builder.ToString())); var response3Builder = new System.Text.StringBuilder(); await foreach (var update in _chatSession.SendMessageStreamAsync("投资这只股票有什么风险?")) { - response3Builder.Append(update.Content); + response3Builder.Append(update); } Assert.IsFalse(string.IsNullOrEmpty(response3Builder.ToString())); diff --git a/tests/TestHttpClientServiceCollectionExtensions.cs b/tests/TestHttpClientServiceCollectionExtensions.cs index f60786f..f3dfdd4 100644 --- a/tests/TestHttpClientServiceCollectionExtensions.cs +++ b/tests/TestHttpClientServiceCollectionExtensions.cs @@ -1,37 +1,15 @@ +using MarketAssistant.Services; using Microsoft.Extensions.DependencyInjection; namespace TestMarketAssistant; +/// +/// 测试用 HttpClient 注册,直接复用生产配置(含 Resilience Handler) +/// internal static class TestHttpClientServiceCollectionExtensions { public static IServiceCollection AddTestMarketDataHttpClients(this IServiceCollection services) { - services.AddHttpClient("Binance", client => - { - client.BaseAddress = new Uri("https://api.binance.com"); - client.Timeout = TimeSpan.FromSeconds(30); - }); - - services.AddHttpClient("CoinGecko", client => - { - client.BaseAddress = new Uri("https://api.coingecko.com/api/v3"); - client.Timeout = TimeSpan.FromSeconds(25); - }); - - services.AddHttpClient("CoinDesk", client => - { - client.BaseAddress = new Uri("https://data-api.coindesk.com"); - client.Timeout = TimeSpan.FromSeconds(15); - client.DefaultRequestHeaders.Accept.Add( - new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); - }); - - services.AddHttpClient("ZhiTu", client => - { - client.BaseAddress = new Uri("https://api.zhituapi.com"); - client.Timeout = TimeSpan.FromSeconds(15); - }); - - return services; + return BusinessServiceCollectionExtensions.AddHttpClients(services); } } From f5a747617e96d9e897211b3e5b7e5095b6147fce Mon Sep 17 00:00:00 2001 From: MayueCif Date: Sat, 16 May 2026 10:58:08 +0800 Subject: [PATCH 26/44] Refactor project dependencies and improve test error handling - Updated Microsoft.Extensions.Logging.Abstractions package reference to remove version specification. - Added .lscache files for project and test projects to improve performance in C# Dev Kit. - Modified AnalystAgentFactoryTest to enhance error handling by excluding AssertFailedException from catch blocks. - Changed test HTTP client service collection extension to use named market HTTP clients. - Removed specific versioning for test project dependencies (Microsoft.NET.Test.Sdk, Moq, MSTest) for flexibility. --- Directory.Packages.props | 63 ++ docs/maf-upgrade-guide.md | 272 +++++++++ .../MarketAssistant.Agents.csproj | 12 +- .../MarketAssistant.Agents.csproj.lscache | 353 +++++++++++ .../Analysts/CoordinatorAnalystAgent.cs | 4 +- .../ContextProviders/RagContextProvider.cs | 107 ---- .../Agents/MarketChatSession.cs | 2 - .../Applications/Settings/UserSetting.cs | 4 + .../Factories/EmbeddingFactory.cs | 16 +- .../Factories/MarketChatSessionFactory.cs | 4 - .../Factories/TradingAgentFactory.cs | 6 +- .../MarketAssistant.App.Services.csproj | 50 +- ...arketAssistant.App.Services.csproj.lscache | 557 ++++++++++++++++++ .../Services/Market/MarketContext.cs | 2 +- .../Services/ServiceCollectionExtensions.cs | 16 +- .../Trading/MarketMonitor.cs | 14 + .../Trading/RiskManager.cs | 4 +- .../Trading/StrategyEngine.cs | 49 +- .../Trading/TradeExecutor.cs | 29 +- .../Trading/TradingDataService.cs | 15 +- .../MarketAssistant.App.csproj | 44 +- .../MarketAssistant.App.csproj.lscache | 528 +++++++++++++++++ .../ViewModels/SettingsPageViewModel.cs | 4 + .../Views/Pages/SettingsPageView.axaml | 42 ++ .../MarketAssistant.Core.csproj | 4 +- .../MarketAssistant.Core.csproj.lscache | 264 +++++++++ .../BinanceWebSocketService.cs | 19 +- .../MarketAssistant.DataProviders.csproj | 4 +- ...rketAssistant.DataProviders.csproj.lscache | 284 +++++++++ .../MarketAssistant.Rag.csproj | 18 +- .../MarketAssistant.Rag.csproj.lscache | 333 +++++++++++ .../MarketAssistant.Trading.csproj | 2 +- .../MarketAssistant.Trading.csproj.lscache | 258 ++++++++ tests/AnalystAgentFactoryTest.cs | 19 +- ...stHttpClientServiceCollectionExtensions.cs | 2 +- tests/TestMarketAssistant.csproj | 6 +- tests/TestMarketAssistant.csproj.lscache | 490 +++++++++++++++ 37 files changed, 3644 insertions(+), 256 deletions(-) create mode 100644 Directory.Packages.props create mode 100644 docs/maf-upgrade-guide.md create mode 100644 src/MarketAssistant.Agents/MarketAssistant.Agents.csproj.lscache delete mode 100644 src/MarketAssistant.App.Services/Agents/ContextProviders/RagContextProvider.cs create mode 100644 src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj.lscache create mode 100644 src/MarketAssistant.App/MarketAssistant.App.csproj.lscache create mode 100644 src/MarketAssistant.Core/MarketAssistant.Core.csproj.lscache create mode 100644 src/MarketAssistant.DataProviders/MarketAssistant.DataProviders.csproj.lscache create mode 100644 src/MarketAssistant.Rag/MarketAssistant.Rag.csproj.lscache create mode 100644 src/MarketAssistant.Trading/MarketAssistant.Trading.csproj.lscache create mode 100644 tests/TestMarketAssistant.csproj.lscache diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..98b3370 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,63 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/maf-upgrade-guide.md b/docs/maf-upgrade-guide.md new file mode 100644 index 0000000..aa50f85 --- /dev/null +++ b/docs/maf-upgrade-guide.md @@ -0,0 +1,272 @@ +# MAF 框架升级与架构审查指南 + +本文档记录了将 Microsoft Agent Framework(MAF)从 **1.0.0** 升级到 **1.4.0** 的完整过程与架构审查结论。文档同时保留可复用的升级操作步骤,供后续版本升级参考。 + +> 说明:本文以当前仓库代码为准(.NET 10 / Avalonia / MAF Workflows),不涉及"subagent/多智能体递归调用"模式;本项目的"多 Agent"指的是 **MAF Workflow 中的多个并发/串行节点**。 + +--- + +## 1. 项目结构核对 + +根目录 `AGENTS.md` 定义的分层与依赖方向整体合理: + +``` +Core(无依赖) + ↑ + ├── Rag → Core + ├── DataProviders → Core + ├── Agents → Core + ├── App.Services → Core, Agents, DataProviders, Rag + └── App → Core, Agents, DataProviders, App.Services, Rag +``` + +各层职责落实情况: + +| 项目 | 职责 | +|------|------| +| `MarketAssistant.App` | UI 宿主(Avalonia View / ViewModel / UI 服务) | +| `MarketAssistant.App.Services` | 运行时编排(Agent 实现、Workflow、Tools 实现、MCP、交易引擎) | +| `MarketAssistant.Agents` | Agent 契约层(基类、属性、工具接口、模型) | +| `MarketAssistant.Rag` | RAG 能力(解析/清洗/分块/嵌入/检索/重排) | +| `MarketAssistant.DataProviders` | 外部 API 封装(Binance、CoinGecko 等) | +| `MarketAssistant.Trading` | 交易域抽象层(`IExchangeClient` + trading models) | + +**结论**:结构合理。Agent 编排与交易引擎收敛到 `App.Services`,避免 UI 层污染。`MarketAssistant.Trading` 作为交易域抽象层,能避免将 Binance 具体实现渗透到上层,当前定位可接受。 + +--- + +## 2. MAF 版本升级详情 + +### 2.1 依赖变更 + +核心 MAF 包升级: + +| 包名 | 升级前 | 升级后 | +|------|--------|--------| +| `Microsoft.Agents.AI` | 1.0.0 | **1.4.0** | +| `Microsoft.Agents.AI.Workflows` | 1.0.0 | **1.4.0** | +| `Microsoft.Agents.AI.Workflows.Generators` | 1.0.0 | **1.4.0** | + +同步升级(解决 1.4.0 传递依赖约束 / NU1605 包降级): + +| 包名 | 升级前 | 升级后 | +|------|--------|--------| +| `Microsoft.Extensions.AI` | 10.4.1 | **10.5.0** | +| `Microsoft.Extensions.AI.OpenAI` | 10.4.1 | **10.5.0** | +| `Microsoft.Extensions.Logging.Abstractions` | 10.0.5 | **10.0.6** | + +### 2.2 1.4.0 重要变更 + +来自 [Microsoft.Agents.AI 1.4.0 Release Notes](https://github.com/microsoft/agent-framework/releases) 中与本仓库相关的重点: + +- OpenTelemetry 依赖升级(对可观测性更友好) +- Durable workflow HTTP 触发结果返回(本项目当前未用 Hosting 场景) +- **[Breaking] file-based skill scripts 支持 string[] 参数**(需检查 skills 是否存在脚本——见第 4 节) +- Declarative Workflows 增加 `HttpRequestAction`(本项目主要使用 imperative `WorkflowBuilder`) +- 新增 Hyperlight CodeAct 包(本项目未用) + +--- + +## 3. 升级操作步骤(可复用) + +若需在其他分支或后续版本重复执行类似升级,按以下步骤操作。 + +### Step 1:修改包版本 + +涉及的项目文件: + +- `src/MarketAssistant.Agents/MarketAssistant.Agents.csproj` +- `src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj` +- `src/MarketAssistant.App/MarketAssistant.App.csproj` +- `src/MarketAssistant.Rag/MarketAssistant.Rag.csproj`(OpenAI 客户端包版本需与 MAF 一致) +- `src/MarketAssistant.Core/`、`src/MarketAssistant.DataProviders/`、`src/MarketAssistant.Trading/` 中的 `Microsoft.Extensions.Logging.Abstractions` 引用 + +需遵循的原则: + +1. `Microsoft.Agents.*` 统一到同一个版本 +2. `Microsoft.Extensions.AI*` 与 MAF 版本要求对齐,避免 NU1605 downgrade 警告 +3. `Microsoft.Extensions.Logging.Abstractions` 与 MAF 的最低要求对齐 + +### Step 2:还原与构建 + +```bash +dotnet restore MarketAssistant.slnx +dotnet build MarketAssistant.slnx -c Debug +``` + +如果公司 NuGet 源不可用(如 `http://devops.lonsid.cn:8080/nuget` 返回 502),可临时指定源: + +```bash +dotnet build MarketAssistant.slnx -c Debug ^ + --source https://api.nuget.org/v3/index.json ^ + --source E:\masa.specdoc\local-packages +``` + +### Step 3:编译验证 + +```bash +dotnet build src/MarketAssistant.App/MarketAssistant.App.csproj -c Debug +``` + +> 备注:`tests/` 目录的单元测试可能存在独立的历史问题,不应阻塞"运行时可编译"的结论。本次升级后 `src/` 已通过编译。 + +--- + +## 4. Breaking Change 检查:Skills 脚本 + +MAF 1.4.0 的 breaking change 主要针对 **file-based skill scripts** 参数类型变化。 + +本仓库的 skills 输出目录位于 `src/MarketAssistant.App/skills/`,当前内容全部为 Markdown 引导/参考文件,无 `.csx`/`.ps1`/`.py` 等脚本资源: + +- `skills/market-analysis/SKILL.md` +- `skills/crypto-trading/references/RISK_MANAGEMENT.md` + +运行时通过 `AgentSkillsProvider` 从输出目录加载(注册于 `src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs`): + +```csharp +new AgentSkillsProvider(skillPath: Path.Combine(AppContext.BaseDirectory, "skills")) +``` + +**结论**:breaking change 不影响本仓库。 + +--- + +## 5. Agent 编排检查 + +### 5.1 市场分析:Fan-Out / Fan-In 并发工作流 + +实现文件:[MarketAnalysisWorkflow.cs](file:///c:/Users/mayue/Desktop/MarketAssistant/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs) + +核心编排为标准的 Fan-Out / Fan-In 模式: + +``` +Dispatcher(入口 Executor)→ 多个 AIAgent(并发分析师)→ Aggregator → Coordinator → 输出 +``` + +这是"多节点 Workflow",不是"agent 调 agent 的递归链",符合不使用 subagent 的约束。 + +额外特性: +- 动态按用户设置启用分析师 +- `RequiredAnalystAttribute` 标记"必需分析师永远启用" +- `MarketSnapshotContextProvider` 在分析师间共享快照上下文 + +### 5.2 投资选股:串行工作流 + +实现文件:[InvestmentSelectionWorkflow.cs](file:///c:/Users/mayue/Desktop/MarketAssistant/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs) + +串行 executor 链路:`Generate criteria → Screen → Analyze`,通过 `MarketType` 分支选择 crypto vs A 股的 criteria 生成策略。 + +**结论**:编排清晰,职责分离合理。 + +### 5.3 对话:单 Agent + 中间件 + +实现文件:[MarketChatSession.cs](file:///c:/Users/mayue/Desktop/MarketAssistant/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs) + +单个 `ChatClientAgent`(或通过 `DelegatingAIAgent` 代理)+ middleware(压缩、token、MCP 等),无 workflow 节点编排,属于常规 chat session。 + +--- + +## 6. Crypto 市场支持检查 + +### 6.1 Keyed Services(主策略,正确) + +Crypto 模块注册集中于 `src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs`: + +- 以 `MarketType.Crypto` 为 key 注册工具实现与业务服务 +- 上层工厂(如 `AnalystAgentFactory`)通过 `MarketContext.CurrentMarket` 获取 keyed service,避免业务层 `if/else` + +**结论**:符合 `AGENTS.md` 的"多市场架构"约定。 + +### 6.2 交易所抽象与 Binance 实现(正确) + +- 抽象:`MarketAssistant.Trading.Abstractions.IExchangeClient` +- 实现:`BinanceExchangeClient`(在 `App.Services` 内注入到 keyed 服务) +- 用途:Tools(如 `CryptoTradingExecutionTools`)→ `TradeExecutor` → `IExchangeClient` 下单 + +### 6.3 需持续关注:Keyed vs 非 Keyed 注入 + +`TradeExecutor` 显式使用 `[FromKeyedServices(MarketType.Crypto)] IExchangeClient exchangeClient`,保证了自主交易链路一定使用 Crypto 的交易所实现。 + +**建议**:对所有使用 `IExchangeClient` 的注入点做统一梳理,优先使用 keyed 注入,避免某些 ViewModel/Service 误注入非 keyed 实例导致运行时解析失败。 + +--- + +## 7. 自主交易模块审查 + +关键实现位于 `src/MarketAssistant.App.Services/Trading/` 与 `src/MarketAssistant.App.Services/Agents/Trading/`: + +| 组件 | 职责 | +|------|------| +| `MarketMonitor` | 订阅 WebSocket,按 tick 触发策略 | +| `StrategyEngine` | 策略评估与触发条件判断 | +| `RiskManager` | 风控校验 | +| `TradeExecutor` | 统一下单入口(风控 → 确认 → 下单 → 持久化) | +| `TradingAgent` | 仅用于 `AISignal` 策略的 AI 决策与工具调用 | + +### 7.1 运行机制 + +**MarketMonitor**: +- 使用 `Channel<(string Symbol, decimal Price)>` 缓冲 price update +- 单 reader 处理 tick(`SingleReader = true`),避免并发乱序 +- 启动时从 `TradingDataService` 拉取 Active 策略并订阅对应 symbol + +**TradeExecutor**: +- 统一入口 `ExecuteOrderAsync`,所有交易路径(策略触发、AI、手动)都应走这里 +- 内置 Human-in-the-Loop:`ConfirmationCallback` 可由 UI 注入(未注入则默认拒绝需要确认的单) + +**TradingAgent**: +- 继承 `DelegatingAIAgent`,工具集合来自 `ITradingExecutionTools` / `IStrategyTools` / `IBasicDataTools` / `ITechnicalDataTools` +- 低温度参数,强调"宁可不交易" + +### 7.2 结论与改进建议 + +整体实现思路合理,核心优势: + +- `TradeExecutor` 统一收敛"真实下单入口" +- `RiskManager` 做风控门禁 +- `MarketMonitor` 用 channel 解耦实时行情与策略评估 +- `AISignal` 策略通过工具调用实现"可审计的交易路径",而非让模型直接拼 HTTP + +改进建议(按优先级排列): + +1. **并发与幂等**:对同一 symbol 多策略同时触发的情况,在 `TradeExecutor` 引入 per-symbol 锁或幂等检查,避免重复下单。 +2. **监控健壮性**:`MarketMonitor` 是单点后台循环,应增加健康检查与异常自恢复(或改为 `BackgroundService` + supervised restart)。 +3. **AISignal 上下文记忆**:目前每次触发创建 agent 且无短期记忆,建议将最近 N 次决策/成交作为 context provider 注入(数据来源:`TradingDataService`)。 +4. **策略覆盖面**:若存在 `GridTrading` / `DCA` 等未完成策略,在 `StrategyEngine` 中显式 `NotSupportedException` 或标记为 `Paused`,避免"看似支持但实际无效果"。 + +### 7.3 已落地的代码改动 + +以下改动随 MAF 升级同步实现: + +| 改动 | 文件 / 位置 | 对应建议 | +|------|-------------|----------| +| 同标的下单串行化 | `TradeExecutor.ExecuteOrderAsync` | #1 并发与幂等 | +| AISignal 上下文增强 | `MarketMonitor.HandleAISignalAsync` | #3 上下文记忆 | +| 公开 `AddNamedMarketHttpClients` | `BusinessServiceCollectionExtensions` | 消除 `InternalsVisibleTo` 依赖 | +| 文档化 Crypto-only 工具解析 | `TradingAgentFactory` XML 注释 | 明确 Keyed 注入边界 | + +--- + +## 8. 验证清单 + +### 编译与基础功能 + +- [ ] `dotnet build src/MarketAssistant.App/MarketAssistant.App.csproj -c Debug` 通过 +- [ ] 启动 App,进入"市场分析"页面,执行一次分析,观察多分析师并发输出 +- [ ] 进入"投资选股"功能,分别在 A 股与 Crypto 市场跑一次选股 +- [ ] Crypto 市场打开交易监控,启停 `MarketMonitor`,确认 WebSocket 行情订阅正常 + +### 交易行为 + +- [ ] Risk 配置触发 `NeedsConfirmation` 时,UI 能正常处理 `ConfirmationCallback`(若 UI 未接入,预期为拒绝交易) +- [ ] `AISignal` 策略触发时,`TradingAgent` 能调用工具并通过 `TradeExecutor` 下单(建议在沙盒/测试网环境验证) + +--- + +## 9. 未来可选优化 + +以下优化不阻塞当前升级,可在后续迭代中推进: + +1. **Central Package Management**:引入 `Directory.Packages.props`,统一管理所有包版本,降低后续升级成本。 +2. **App 项目依赖精简**:UI 项目尽量通过 `ProjectReference` 继承依赖,减少直接包引用,避免版本漂移。 +3. **可观测性增强**:结合 MAF 1.4.0 的 OTel 升级,接入 OTLP/Jaeger exporter,统一 traceId 打通 Serilog,便于线上排查 agent/workflow 行为。 diff --git a/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj b/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj index e38df30..039335e 100644 --- a/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj +++ b/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj @@ -13,11 +13,11 @@ - - - - - - + + + + + + diff --git a/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj.lscache b/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj.lscache new file mode 100644 index 0000000..bf6b4cb --- /dev/null +++ b/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj.lscache @@ -0,0 +1,353 @@ +version=1 + +# This file caches language service data to improve the performance of C# Dev Kit. +# It is not intended for manual editing. It can safely be deleted and will be +# regenerated automatically. For more information, see https://aka.ms/lscache +# +# To control where cache files are stored, use the following VS Code setting: +# "dotnet.projectsystem.cacheInProjectFolder": true + +[project] +language=C# +primary +lastDtbSucceeded + +[properties] +AssemblyName=MarketAssistant.Agents +CommandLineArgsForDesignTimeEvaluation=-langversion:14.0 -define:TRACE +CompilerGeneratedFilesOutputPath= +MaxSupportedLangVersion=14.0 +ProjectAssetsFile=obj/project.assets.json +RootNamespace=MarketAssistant +RunAnalyzers= +RunAnalyzersDuringLiveAnalysis= +SolutionPath=../../MarketAssistant.slnx +TargetFrameworkIdentifier=.NETCoreApp +TargetPath=bin/Debug/net10.0/MarketAssistant.Agents.dll +TargetRefPath=obj/Debug/net10.0/ref/MarketAssistant.Agents.dll +TemporaryDependencyNodeTargetIdentifier=net10.0 + +[commandLineArguments] +/noconfig +/unsafe- +/checked- +/nowarn:1701,1702,MAAI001,1701,1702,8002 +/fullpaths +/nostdlib+ +/errorreport:prompt +/warn:10 +/define:TRACE;DEBUG;NET;NET10_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NET10_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER +/highentropyva+ +/nullable:enable +/features:"InterceptorsNamespaces=;Microsoft.Extensions.Validation.Generated" +/debug+ +/debug:portable +/filealign:512 +/optimize- +/out:obj\Debug\net10.0\MarketAssistant.Agents.dll +/refout:obj\Debug\net10.0\refint\MarketAssistant.Agents.dll +/target:library +/warnaserror- +/utf8output +/deterministic+ +/langversion:14.0 +/warnaserror+:NU1605,SYSLIB0011 + +[sourceFiles] +Analysts/ + AnalystAgentBase.cs + Attributes/ + RequiredAnalystAttribute.cs + RequiresToolsAttribute.cs +MarketAnalysis/Models/ + AnalysisEnums.cs + AnalysisQualityMetrics.cs + CoordinatorResult.cs + FinancialAnalysisResult.cs + FundamentalAnalysisResult.cs + MarketAnalysisReport.cs + MarketSentimentAnalysisResult.cs + NewsEventAnalysisResult.cs + ScoringStandards.cs + TechnicalAnalysisResult.cs +Middleware/ + ConversationCompressionMiddleware.cs + TokenTrackingMiddleware.cs + TradingFunctionGuardMiddleware.cs +obj/Debug/net10.0/ + .NETCoreApp,Version=v10.0.AssemblyAttributes.cs + MarketAssistant.Agents.AssemblyInfo.cs + MarketAssistant.Agents.GlobalUsings.g.cs +PromptConfiguration/ + AnalystPromptConfig.cs + AnalystPromptLoader.cs +TokenManagement/ + ConversationCompressor.cs + TokenEstimator.cs +Tools/Abstractions/ + IBasicDataTools.cs + ICryptoBasicTools.cs + ICryptoMetricsTools.cs + ICryptoSentimentTools.cs + IFinancialTools.cs + INewsDataTools.cs + ISentimentTools.cs + IShareBasicTools.cs + IShareFinancialTools.cs + IShareSentimentTools.cs + IStrategyTools.cs + ITechnicalDataTools.cs + ITradingExecutionTools.cs +Tools/Models/AShare/ + BalanceSheet.cs + CapitalStructure.cs + CashFlowStatement.cs + CompanyInfo.cs + FinancialData.cs + FinancialRatios.cs + FundFlow.cs + FundFlowTrend.cs + IncomeStatement.cs + MarketCapInfo.cs + QuarterlyCashFlow.cs + QuarterlyProfit.cs + ShareholderCount.cs + StockQuoteInfo.cs + TopShareholder.cs +Tools/Models/ + AssetQuoteInfo.cs + Crypto/ + CryptoMarketMetrics.cs + CryptoOHLCV.cs + CryptoOrderBookDepth.cs + CryptoProjectInfo.cs + CryptoQuoteInfo.cs + CryptoRecentTrades.cs + CryptoSentiment.cs + CryptoVolatilityMetrics.cs + FundingRate.cs + LongShortRatio.cs + OpenInterest.cs + VolumeDistribution.cs + MarketInterval.cs + NewsItem.cs + Technical/ + TechnicalBoll.cs + TechnicalKDJ.cs + TechnicalMA.cs + TechnicalMACD.cs + +[metadataReferences] +../ + MarketAssistant.Core/obj/Debug/net10.0/ref/MarketAssistant.Core.dll + MarketAssistant.Trading/obj/Debug/net10.0/ref/MarketAssistant.Trading.dll +/packs/Microsoft.NETCore.App.Ref/10.0.5/ref/net10.0/ + Microsoft.CSharp.dll + Microsoft.VisualBasic.Core.dll + Microsoft.VisualBasic.dll + Microsoft.Win32.Primitives.dll + Microsoft.Win32.Registry.dll + mscorlib.dll + netstandard.dll + System.AppContext.dll + System.Buffers.dll + System.Collections.Concurrent.dll + System.Collections.dll + System.Collections.Immutable.dll + System.Collections.NonGeneric.dll + System.Collections.Specialized.dll + System.ComponentModel.Annotations.dll + System.ComponentModel.DataAnnotations.dll + System.ComponentModel.dll + System.ComponentModel.EventBasedAsync.dll + System.ComponentModel.Primitives.dll + System.ComponentModel.TypeConverter.dll + System.Configuration.dll + System.Console.dll + System.Core.dll + System.Data.Common.dll + System.Data.DataSetExtensions.dll + System.Data.dll + System.Diagnostics.Contracts.dll + System.Diagnostics.Debug.dll + System.Diagnostics.DiagnosticSource.dll + System.Diagnostics.FileVersionInfo.dll + System.Diagnostics.Process.dll + System.Diagnostics.StackTrace.dll + System.Diagnostics.TextWriterTraceListener.dll + System.Diagnostics.Tools.dll + System.Diagnostics.TraceSource.dll + System.Diagnostics.Tracing.dll + System.dll + System.Drawing.dll + System.Drawing.Primitives.dll + System.Dynamic.Runtime.dll + System.Formats.Asn1.dll + System.Formats.Tar.dll + System.Globalization.Calendars.dll + System.Globalization.dll + System.Globalization.Extensions.dll + System.IO.Compression.Brotli.dll + System.IO.Compression.dll + System.IO.Compression.FileSystem.dll + System.IO.Compression.ZipFile.dll + System.IO.dll + System.IO.FileSystem.AccessControl.dll + System.IO.FileSystem.dll + System.IO.FileSystem.DriveInfo.dll + System.IO.FileSystem.Primitives.dll + System.IO.FileSystem.Watcher.dll + System.IO.IsolatedStorage.dll + System.IO.MemoryMappedFiles.dll + System.IO.Pipelines.dll + System.IO.Pipes.AccessControl.dll + System.IO.Pipes.dll + System.IO.UnmanagedMemoryStream.dll + System.Linq.AsyncEnumerable.dll + System.Linq.dll + System.Linq.Expressions.dll + System.Linq.Parallel.dll + System.Linq.Queryable.dll + System.Memory.dll + System.Net.dll + System.Net.Http.dll + System.Net.Http.Json.dll + System.Net.HttpListener.dll + System.Net.Mail.dll + System.Net.NameResolution.dll + System.Net.NetworkInformation.dll + System.Net.Ping.dll + System.Net.Primitives.dll + System.Net.Quic.dll + System.Net.Requests.dll + System.Net.Security.dll + System.Net.ServerSentEvents.dll + System.Net.ServicePoint.dll + System.Net.Sockets.dll + System.Net.WebClient.dll + System.Net.WebHeaderCollection.dll + System.Net.WebProxy.dll + System.Net.WebSockets.Client.dll + System.Net.WebSockets.dll + System.Numerics.dll + System.Numerics.Vectors.dll + System.ObjectModel.dll + System.Reflection.DispatchProxy.dll + System.Reflection.dll + System.Reflection.Emit.dll + System.Reflection.Emit.ILGeneration.dll + System.Reflection.Emit.Lightweight.dll + System.Reflection.Extensions.dll + System.Reflection.Metadata.dll + System.Reflection.Primitives.dll + System.Reflection.TypeExtensions.dll + System.Resources.Reader.dll + System.Resources.ResourceManager.dll + System.Resources.Writer.dll + System.Runtime.CompilerServices.Unsafe.dll + System.Runtime.CompilerServices.VisualC.dll + System.Runtime.dll + System.Runtime.Extensions.dll + System.Runtime.Handles.dll + System.Runtime.InteropServices.dll + System.Runtime.InteropServices.JavaScript.dll + System.Runtime.InteropServices.RuntimeInformation.dll + System.Runtime.Intrinsics.dll + System.Runtime.Loader.dll + System.Runtime.Numerics.dll + System.Runtime.Serialization.dll + System.Runtime.Serialization.Formatters.dll + System.Runtime.Serialization.Json.dll + System.Runtime.Serialization.Primitives.dll + System.Runtime.Serialization.Xml.dll + System.Security.AccessControl.dll + System.Security.Claims.dll + System.Security.Cryptography.Algorithms.dll + System.Security.Cryptography.Cng.dll + System.Security.Cryptography.Csp.dll + System.Security.Cryptography.dll + System.Security.Cryptography.Encoding.dll + System.Security.Cryptography.OpenSsl.dll + System.Security.Cryptography.Primitives.dll + System.Security.Cryptography.X509Certificates.dll + System.Security.dll + System.Security.Principal.dll + System.Security.Principal.Windows.dll + System.Security.SecureString.dll + System.ServiceModel.Web.dll + System.ServiceProcess.dll + System.Text.Encoding.CodePages.dll + System.Text.Encoding.dll + System.Text.Encoding.Extensions.dll + System.Text.Encodings.Web.dll + System.Text.Json.dll + System.Text.RegularExpressions.dll + System.Threading.AccessControl.dll + System.Threading.Channels.dll + System.Threading.dll + System.Threading.Overlapped.dll + System.Threading.Tasks.Dataflow.dll + System.Threading.Tasks.dll + System.Threading.Tasks.Extensions.dll + System.Threading.Tasks.Parallel.dll + System.Threading.Thread.dll + System.Threading.ThreadPool.dll + System.Threading.Timer.dll + System.Transactions.dll + System.Transactions.Local.dll + System.ValueTuple.dll + System.Web.dll + System.Web.HttpUtility.dll + System.Windows.dll + System.Xml.dll + System.Xml.Linq.dll + System.Xml.ReaderWriter.dll + System.Xml.Serialization.dll + System.Xml.XDocument.dll + System.Xml.XmlDocument.dll + System.Xml.XmlSerializer.dll + System.Xml.XPath.dll + System.Xml.XPath.XDocument.dll + WindowsBase.dll +D:/NuGet/Packages/ + google.protobuf/3.30.2/lib/net5.0/Google.Protobuf.dll + microsoft.agents.ai.abstractions/1.6.1/lib/net10.0/Microsoft.Agents.AI.Abstractions.dll + microsoft.agents.ai.workflows/1.6.1/lib/net10.0/Microsoft.Agents.AI.Workflows.dll + microsoft.agents.ai/1.6.1/lib/net10.0/Microsoft.Agents.AI.dll + microsoft.data.sqlite.core/10.0.5/lib/net8.0/Microsoft.Data.Sqlite.dll + microsoft.extensions.ai.abstractions/10.5.1/lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + microsoft.extensions.ai.evaluation/10.4.0/lib/net10.0/Microsoft.Extensions.AI.Evaluation.dll + microsoft.extensions.ai/10.5.1/lib/net10.0/Microsoft.Extensions.AI.dll + microsoft.extensions.caching.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.Caching.Abstractions.dll + microsoft.extensions.compliance.abstractions/10.5.0/lib/net10.0/Microsoft.Extensions.Compliance.Abstractions.dll + microsoft.extensions.dependencyinjection.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll + microsoft.extensions.filesystemglobbing/10.0.6/lib/net10.0/Microsoft.Extensions.FileSystemGlobbing.dll + microsoft.extensions.logging.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.Logging.Abstractions.dll + microsoft.extensions.objectpool/10.0.6/lib/net10.0/Microsoft.Extensions.ObjectPool.dll + microsoft.extensions.primitives/10.0.6/lib/net10.0/Microsoft.Extensions.Primitives.dll + microsoft.extensions.vectordata.abstractions/9.7.0/lib/net8.0/Microsoft.Extensions.VectorData.Abstractions.dll + microsoft.ml.tokenizers/2.0.0/lib/net8.0/Microsoft.ML.Tokenizers.dll + opentelemetry.api/1.15.3/lib/net10.0/OpenTelemetry.Api.dll + sqlitepclraw.bundle_e_sqlite3/2.1.11/lib/netstandard2.0/SQLitePCLRaw.batteries_v2.dll + sqlitepclraw.core/2.1.11/lib/netstandard2.0/SQLitePCLRaw.core.dll + sqlitepclraw.provider.e_sqlite3/2.1.11/lib/net6.0/SQLitePCLRaw.provider.e_sqlite3.dll + system.numerics.tensors/10.0.6/lib/net10.0/System.Numerics.Tensors.dll + yamldotnet/16.3.0/lib/net8.0/YamlDotNet.dll + +[analyzerReferences] +/packs/Microsoft.NETCore.App.Ref/10.0.5/analyzers/dotnet/cs/ + Microsoft.Interop.ComInterfaceGenerator.dll + Microsoft.Interop.JavaScript.JSImportGenerator.dll + Microsoft.Interop.LibraryImportGenerator.dll + Microsoft.Interop.SourceGeneration.dll + System.Text.Json.SourceGeneration.dll + System.Text.RegularExpressions.Generator.dll +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/ + Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll + Microsoft.CodeAnalysis.NetAnalyzers.dll +D:/NuGet/Packages/microsoft.extensions.logging.abstractions/10.0.6/analyzers/dotnet/roslyn4.4/cs/Microsoft.Extensions.Logging.Generators.dll + +[analyzerConfigFiles] +../../.editorconfig +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_10_default.globalconfig +obj/Debug/net10.0/MarketAssistant.Agents.GeneratedMSBuildEditorConfig.editorconfig diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/CoordinatorAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/CoordinatorAnalystAgent.cs index 80c3c78..58cc497 100644 --- a/src/MarketAssistant.App.Services/Agents/Analysts/CoordinatorAnalystAgent.cs +++ b/src/MarketAssistant.App.Services/Agents/Analysts/CoordinatorAnalystAgent.cs @@ -41,9 +41,7 @@ public CoordinatorAnalystAgent( topP: 0.7f, topK: 5, responseFormat: ResponseFormat, - //todo 暂时注释搜索工具,会调用次数限制不住会浪费 - //tools: [AIFunctionFactory.Create(searchTools.SearchAsync)], - tools: null, + tools: [AIFunctionFactory.Create(searchTools.SearchAsync)], aiContextProviders: [ new InvestmentPreferenceContextProvider( diff --git a/src/MarketAssistant.App.Services/Agents/ContextProviders/RagContextProvider.cs b/src/MarketAssistant.App.Services/Agents/ContextProviders/RagContextProvider.cs deleted file mode 100644 index 8140501..0000000 --- a/src/MarketAssistant.App.Services/Agents/ContextProviders/RagContextProvider.cs +++ /dev/null @@ -1,107 +0,0 @@ -using MarketAssistant.Rag.Interfaces; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; - -namespace MarketAssistant.Agents.ContextProviders; - -/// -/// RAG 上下文提供者,实现 MAF AIContextProvider 模式。 -/// 通过 MAF 标准接口接入 Agent,自动将 RAG 检索结果注入上下文, -/// 替代手动拼接检索上下文的方案。 -/// -public class RagContextProvider : MessageAIContextProvider -{ - private readonly IRetrievalOrchestrator _retrievalOrchestrator; - private readonly ILogger _logger; - private string _collectionName = "default"; - private int _topK = 5; - private string? _currentQuery; - - /// - /// 当前检索使用的向量集合名称 - /// - public string CollectionName - { - get => _collectionName; - set => _collectionName = value; - } - - /// - /// 返回的检索结果数量上限 - /// - public int TopK - { - get => _topK; - set => _topK = value; - } - - /// - /// 设置当前查询文本(在 Agent 调用前设置,Provider 执行时使用) - /// - public void SetCurrentQuery(string? query) => _currentQuery = query; - - public RagContextProvider( - IRetrievalOrchestrator retrievalOrchestrator, - ILogger logger) - { - _retrievalOrchestrator = retrievalOrchestrator; - _logger = logger; - } - - protected override async ValueTask> ProvideMessagesAsync( - InvokingContext context, CancellationToken cancellationToken = default) - { - // 从外部设置的查询文本获取检索关键词 - var query = ConsumeCurrentQuery(); - if (string.IsNullOrWhiteSpace(query)) - return []; - - try - { - var results = await _retrievalOrchestrator.RetrieveAsync( - query, _collectionName, _topK, cancellationToken); - - if (results.Count == 0) - { - _logger.LogDebug("RAG 检索无结果: {Query}", query); - return []; - } - - var sb = new System.Text.StringBuilder(); - sb.AppendLine("## 知识库参考资料"); - sb.AppendLine("以下是从知识库中检索到的相关内容,请参考:"); - sb.AppendLine(); - - foreach (var result in results) - { - if (!string.IsNullOrWhiteSpace(result.Name)) - sb.AppendLine($"**来源**: {result.Name}"); - sb.AppendLine(result.Value); - sb.AppendLine(); - } - - _logger.LogDebug("RAG 注入 {Count} 条检索结果到上下文", results.Count); - - return - [ - new ChatMessage(ChatRole.System, sb.ToString()) - ]; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "RAG 检索失败,跳过知识库上下文注入"); - return []; - } - } - - /// - /// 获取当前查询文本并清除 - /// - private string? ConsumeCurrentQuery() - { - var query = _currentQuery; - _currentQuery = null; - return query; - } -} diff --git a/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs b/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs index 956bef7..b59b6ed 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs @@ -72,7 +72,6 @@ public MarketChatSession( TokenTrackingMiddleware? tokenTracking = null, ConversationCompressionMiddleware? compressionMiddleware = null, LayeredMemoryContextProvider? layeredMemoryProvider = null, - RagContextProvider? ragProvider = null, ChatSessionPersistenceService? sessionPersistence = null, MemoryExtractionService? memoryExtraction = null, string? initialStockCode = null) @@ -92,7 +91,6 @@ public MarketChatSession( if (mcpToolProvider != null) contextProviders.Add(mcpToolProvider); if (layeredMemoryProvider != null) contextProviders.Add(layeredMemoryProvider); - if (ragProvider != null) contextProviders.Add(ragProvider); var baseAgent = new ChatClientAgent( chatClient, diff --git a/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs b/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs index a535564..46ea505 100644 --- a/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs +++ b/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs @@ -14,6 +14,10 @@ public class UserSetting : INotifyPropertyChanged public string EmbeddingModelId { get; set; } = "BAAI/bge-m3"; + public string EmbeddingEndpoint { get; set; } = "https://api.jina.ai"; + + public string EmbeddingApiKey { get; set; } = ""; + public string Endpoint { get; set; } = "https://api.siliconflow.cn"; public string ApiKey { get; set; } = ""; diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/EmbeddingFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/EmbeddingFactory.cs index 32692a7..b706d9a 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/Factories/EmbeddingFactory.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/EmbeddingFactory.cs @@ -17,20 +17,18 @@ public IEmbeddingGenerator> Create() { var userSetting = _userSettingService.CurrentSetting; var modelId = userSetting.EmbeddingModelId; - var apiKey = userSetting.ApiKey; - var endpoint = userSetting.Endpoint; + var apiKey = userSetting.EmbeddingApiKey; + var endpoint = userSetting.EmbeddingEndpoint; if (string.IsNullOrWhiteSpace(modelId)) throw new FriendlyException("嵌入模型ID不能为空"); if (string.IsNullOrWhiteSpace(apiKey)) - throw new FriendlyException("API密钥不能为空"); + throw new FriendlyException("嵌入API密钥不能为空"); - var client = string.IsNullOrWhiteSpace(endpoint) - ? new OpenAIClient(apiKey) - : new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions - { - Endpoint = new Uri(endpoint + "/v1") - }); + var client = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions + { + Endpoint = new Uri(endpoint + "/v1") + }); return client.GetEmbeddingClient(modelId).AsIEmbeddingGenerator(); } diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/MarketChatSessionFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/MarketChatSessionFactory.cs index 8e7909c..a4a201f 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/Factories/MarketChatSessionFactory.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/MarketChatSessionFactory.cs @@ -36,7 +36,6 @@ public class MarketChatSessionFactory : IMarketChatSessionFactory private readonly TokenTrackingMiddleware _tokenTracking; private readonly ConversationCompressionMiddleware _compressionMiddleware; private readonly LayeredMemoryContextProvider _layeredMemoryProvider; - private readonly RagContextProvider _ragProvider; private readonly ChatSessionPersistenceService _sessionPersistence; private readonly MemoryExtractionService _memoryExtraction; @@ -51,7 +50,6 @@ public MarketChatSessionFactory( TokenTrackingMiddleware tokenTracking, ConversationCompressionMiddleware compressionMiddleware, LayeredMemoryContextProvider layeredMemoryProvider, - RagContextProvider ragProvider, ChatSessionPersistenceService sessionPersistence, MemoryExtractionService memoryExtraction, AgentSkillsProvider? skillsProvider = null) @@ -67,7 +65,6 @@ public MarketChatSessionFactory( _tokenTracking = tokenTracking; _compressionMiddleware = compressionMiddleware; _layeredMemoryProvider = layeredMemoryProvider; - _ragProvider = ragProvider; _sessionPersistence = sessionPersistence; _memoryExtraction = memoryExtraction; } @@ -89,7 +86,6 @@ public MarketChatSession Create(string? initialStockCode = null) tokenTracking: _tokenTracking, compressionMiddleware: _compressionMiddleware, layeredMemoryProvider: _layeredMemoryProvider, - ragProvider: _ragProvider, sessionPersistence: _sessionPersistence, memoryExtraction: _memoryExtraction, initialStockCode: initialStockCode); diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/TradingAgentFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/TradingAgentFactory.cs index 49cc440..0c1d6af 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/Factories/TradingAgentFactory.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/TradingAgentFactory.cs @@ -19,7 +19,8 @@ public interface ITradingAgentFactory } /// -/// 交易 Agent 工厂实现,固定使用 Crypto 市场类型 +/// 交易 Agent 工厂实现。 仅影响界面与分析链路; +/// 自主交易与工具解析始终使用 的 Keyed 注册(现货 Binance),与当前所选市场无关。 /// public class TradingAgentFactory : ITradingAgentFactory { @@ -50,6 +51,9 @@ public TradingAgentFactory( _logger = logger; } + /// + /// 创建包装中间件后的 ;工具一律从 解析。 + /// public AIAgent CreateAgent() { try diff --git a/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj b/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj index 147b3c5..4749f16 100644 --- a/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj +++ b/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj @@ -9,8 +9,8 @@ - - + + @@ -22,29 +22,29 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - + + diff --git a/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj.lscache b/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj.lscache new file mode 100644 index 0000000..3aa798b --- /dev/null +++ b/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj.lscache @@ -0,0 +1,557 @@ +version=1 + +# This file caches language service data to improve the performance of C# Dev Kit. +# It is not intended for manual editing. It can safely be deleted and will be +# regenerated automatically. For more information, see https://aka.ms/lscache +# +# To control where cache files are stored, use the following VS Code setting: +# "dotnet.projectsystem.cacheInProjectFolder": true + +[project] +language=C# +primary +lastDtbSucceeded + +[properties] +AssemblyName=MarketAssistant.App.Services +CommandLineArgsForDesignTimeEvaluation=-langversion:14.0 -define:TRACE +CompilerGeneratedFilesOutputPath= +MaxSupportedLangVersion=14.0 +ProjectAssetsFile=obj/project.assets.json +RootNamespace=MarketAssistant +RunAnalyzers= +RunAnalyzersDuringLiveAnalysis= +SolutionPath=../../MarketAssistant.slnx +TargetFrameworkIdentifier=.NETCoreApp +TargetPath=bin/Debug/net10.0/MarketAssistant.App.Services.dll +TargetRefPath=obj/Debug/net10.0/ref/MarketAssistant.App.Services.dll +TemporaryDependencyNodeTargetIdentifier=net10.0 + +[commandLineArguments] +/noconfig +/unsafe- +/checked- +/nowarn:1701,1702,MAAI001,1701,1702,8002 +/fullpaths +/nostdlib+ +/errorreport:prompt +/warn:10 +/define:TRACE;DEBUG;NET;NET10_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NET10_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER +/highentropyva+ +/nullable:enable +/features:"InterceptorsNamespaces=;Microsoft.Extensions.Validation.Generated" +/debug+ +/debug:portable +/filealign:512 +/optimize- +/out:obj\Debug\net10.0\MarketAssistant.App.Services.dll +/refout:obj\Debug\net10.0\refint\MarketAssistant.App.Services.dll +/target:library +/warnaserror- +/utf8output +/deterministic+ +/langversion:14.0 +/warnaserror+:NU1605,SYSLIB0011 + +[sourceFiles] +Agents/Analysts/ + AnalystTypeRegistry.cs + CoordinatorAnalystAgent.cs + FinancialAnalystAgent.cs + FundamentalAnalystAgent.cs + MarketSentimentAnalystAgent.cs + NewsEventAnalystAgent.cs + TechnicalAnalystAgent.cs +Agents/ContextProviders/ + InvestmentPreferenceContextProvider.cs + LayeredMemoryContextProvider.cs +Agents/InvestmentSelection/Executors/ + AnalyzeAssetsExecutor.cs + GenerateCriteriaExecutor.cs + ScreenInvestmentTargetsExecutor.cs +Agents/InvestmentSelection/ + InvestmentSelectionWorkflow.cs + Models/WorkflowModels.cs + Strategies/ + CryptoCriteriaGenerationStrategy.cs + CryptoDataFormatter.cs + IAssetDataFormatter.cs + ICriteriaGenerationStrategy.cs + StockCriteriaGenerationStrategy.cs + StockDataFormatter.cs +Agents/MarketAnalysis/Executors/ + AnalysisAggregatorExecutor.cs + AnalysisDispatcherExecutor.cs + CoordinatorExecutor.cs +Agents/MarketAnalysis/ + MarketAnalysisWorkflow.cs + MarketSnapshotContextProvider.cs + WorkflowStateKeys.cs +Agents/ + MarketChatSession.cs + Tools/AShare/ + AShareBasicTools.cs + AShareFinancialTools.cs + AShareNewsTools.cs + AShareSentimentTools.cs + AShareTechnicalTools.cs + Tools/Crypto/ + CryptoBasicTools.cs + CryptoMetricsTools.cs + CryptoNewsTools.cs + CryptoSentimentTools.cs + CryptoStrategyTools.cs + CryptoTechnicalTools.cs + CryptoTradingExecutionTools.cs + Tools/ + GroundingSearchTools.cs + KnowledgeGraphTools.cs + MemoryManagementTools.cs + SessionSearchTools.cs + Trading/TradingAgent.cs +Applications/ + Analysis/AnalysisOrchestrationService.cs + Assets/ + AShareAssetInfoService.cs + CryptoAssetInfoService.cs + IAssetInfoService.cs + Models/ + AssetInfo.cs + AssetItem.cs + FavoriteAsset.cs + HotAsset.cs + AssetScreener/ + CryptoScreenerService.cs + IAssetScreenerService.cs + Models/ + AShareType.cs + CryptoCriteria.cs + IndustryType.cs + IScreeningCriteria.cs + ScreenerAssetInfo.cs + ScreenerCryptoInfo.cs + ScreenerStockInfo.cs + StockCriteria.cs + StockScreeningCriteria.cs + StockScreenerService.cs + Cache/ + AShareAssetCacheService.cs + CryptoAssetCacheService.cs + IAssetCacheService.cs + Charts/ + AShareKLineService.cs + CryptoKLineService.cs + IKLineService.cs + Models/ + KLineData.cs + KLineType.cs + Crypto/ + BinanceAccountService.cs + BinanceAuthService.cs + Favorites/ + AShareFavoriteService.cs + CryptoFavoriteService.cs + FavoriteServiceBase.cs + IFavoriteService.cs + History/ + AShareHistoryService.cs + AssetHistoryServiceBase.cs + CryptoHistoryService.cs + IAssetHistoryService.cs + Home/ + AShareHomeService.cs + CryptoHomeService.cs + HomeAssetServiceBase.cs + IHomeAssetService.cs + InvestmentSelection/ + InvestmentSelectionService.cs + Models/ + InvestmentRecommendation.cs + InvestmentRecommendationRequest.cs + InvestmentSelectionEnums.cs + InvestmentSelectionResult.cs + NewsBasedInvestmentRequest.cs + QuickSelectionStrategy.cs + News/ + INewsUpdateService.cs + NewsUpdateService.cs + PriceAlert/ + PriceAlertRule.cs + PriceAlertService.cs + Settings/ + AppInfo.cs + GitHubReleaseService.cs + InvestmentPreference.cs + IReleaseService.cs + MCPServerConfig.cs + MCPServerConfigService.cs + UpdateCheckResult.cs + UserSetting.cs + Telegrams/ + AShareTelegramService.cs + CryptoTelegramService.cs + ITelegramService.cs + Telegram.cs +GlobalUsing.cs +Infrastructure/ + Abstractions/IAnalysisService.cs + AdaptiveCards/Parsers/ + BaseAdaptiveCardParser.cs + CoordinatorCardParser.cs + FinancialCardParser.cs + FundamentalCardParser.cs + IAdaptiveCardParser.cs + NewsCardParser.cs + SentimentCardParser.cs + TechnicalCardParser.cs + Configuration/Preferences.cs + Core/FileSystem.cs + Factories/ + AnalystAgentFactory.cs + ChatClientFactory.cs + EmbeddingFactory.cs + MarketChatSessionFactory.cs + TradingAgentFactory.cs + WebTextSearchFactory.cs +obj/Debug/net10.0/ + .NETCoreApp,Version=v10.0.AssemblyAttributes.cs + MarketAssistant.App.Services.AssemblyInfo.cs + MarketAssistant.App.Services.GlobalUsings.g.cs +Services/ + Archive/ReportArchiveService.cs + Browser/ + BrowserService.cs + IBrowserService.cs + PlaywrightService.cs + Cache/ + AnalysisCacheService.cs + IAnalysisCacheService.cs + ChatSessionPersistenceService.cs + Dialog/IDialogService.cs + Export/MarkdownReportExporter.cs + Market/ + AShareMarketModule.cs + CryptoMarketModule.cs + IMarketCapability.cs + IMarketModule.cs + MarketContext.cs + Mcp/ + McpService.cs + McpToolAuditLogger.cs + McpToolContextProvider.cs + MemoryExtractionService.cs + Notification/INotificationService.cs + ServiceCollectionExtensions.cs + Settings/ + IUserSettingService.cs + UserSettingService.cs + UserKnowledgeGraphService.cs + UserMemoryService.cs + WorkflowVisualizationService.cs +Trading/ + CryptoPortfolioService.cs + Exchanges/BinanceExchangeClient.cs + MarketMonitor.cs + RiskManager.cs + StrategyEngine.cs + TradeExecutor.cs + TradingDataService.cs + +[metadataReferences] +../ + MarketAssistant.Agents/obj/Debug/net10.0/ref/MarketAssistant.Agents.dll + MarketAssistant.Core/obj/Debug/net10.0/ref/MarketAssistant.Core.dll + MarketAssistant.DataProviders/obj/Debug/net10.0/ref/MarketAssistant.DataProviders.dll + MarketAssistant.Rag/obj/Debug/net10.0/ref/MarketAssistant.Rag.dll + MarketAssistant.Trading/obj/Debug/net10.0/ref/MarketAssistant.Trading.dll +/packs/Microsoft.NETCore.App.Ref/10.0.5/ref/net10.0/ + Microsoft.CSharp.dll + Microsoft.VisualBasic.Core.dll + Microsoft.VisualBasic.dll + Microsoft.Win32.Primitives.dll + Microsoft.Win32.Registry.dll + mscorlib.dll + netstandard.dll + System.AppContext.dll + System.Buffers.dll + System.Collections.Concurrent.dll + System.Collections.dll + System.Collections.Immutable.dll + System.Collections.NonGeneric.dll + System.Collections.Specialized.dll + System.ComponentModel.Annotations.dll + System.ComponentModel.DataAnnotations.dll + System.ComponentModel.dll + System.ComponentModel.EventBasedAsync.dll + System.ComponentModel.Primitives.dll + System.ComponentModel.TypeConverter.dll + System.Configuration.dll + System.Console.dll + System.Core.dll + System.Data.Common.dll + System.Data.DataSetExtensions.dll + System.Data.dll + System.Diagnostics.Contracts.dll + System.Diagnostics.Debug.dll + System.Diagnostics.DiagnosticSource.dll + System.Diagnostics.FileVersionInfo.dll + System.Diagnostics.Process.dll + System.Diagnostics.StackTrace.dll + System.Diagnostics.TextWriterTraceListener.dll + System.Diagnostics.Tools.dll + System.Diagnostics.TraceSource.dll + System.Diagnostics.Tracing.dll + System.dll + System.Drawing.dll + System.Drawing.Primitives.dll + System.Dynamic.Runtime.dll + System.Formats.Asn1.dll + System.Formats.Tar.dll + System.Globalization.Calendars.dll + System.Globalization.dll + System.Globalization.Extensions.dll + System.IO.Compression.Brotli.dll + System.IO.Compression.dll + System.IO.Compression.FileSystem.dll + System.IO.Compression.ZipFile.dll + System.IO.dll + System.IO.FileSystem.AccessControl.dll + System.IO.FileSystem.dll + System.IO.FileSystem.DriveInfo.dll + System.IO.FileSystem.Primitives.dll + System.IO.FileSystem.Watcher.dll + System.IO.IsolatedStorage.dll + System.IO.MemoryMappedFiles.dll + System.IO.Pipelines.dll + System.IO.Pipes.AccessControl.dll + System.IO.Pipes.dll + System.IO.UnmanagedMemoryStream.dll + System.Linq.AsyncEnumerable.dll + System.Linq.dll + System.Linq.Expressions.dll + System.Linq.Parallel.dll + System.Linq.Queryable.dll + System.Memory.dll + System.Net.dll + System.Net.Http.dll + System.Net.Http.Json.dll + System.Net.HttpListener.dll + System.Net.Mail.dll + System.Net.NameResolution.dll + System.Net.NetworkInformation.dll + System.Net.Ping.dll + System.Net.Primitives.dll + System.Net.Quic.dll + System.Net.Requests.dll + System.Net.Security.dll + System.Net.ServerSentEvents.dll + System.Net.ServicePoint.dll + System.Net.Sockets.dll + System.Net.WebClient.dll + System.Net.WebHeaderCollection.dll + System.Net.WebProxy.dll + System.Net.WebSockets.Client.dll + System.Net.WebSockets.dll + System.Numerics.dll + System.Numerics.Vectors.dll + System.ObjectModel.dll + System.Reflection.DispatchProxy.dll + System.Reflection.dll + System.Reflection.Emit.dll + System.Reflection.Emit.ILGeneration.dll + System.Reflection.Emit.Lightweight.dll + System.Reflection.Extensions.dll + System.Reflection.Metadata.dll + System.Reflection.Primitives.dll + System.Reflection.TypeExtensions.dll + System.Resources.Reader.dll + System.Resources.ResourceManager.dll + System.Resources.Writer.dll + System.Runtime.CompilerServices.Unsafe.dll + System.Runtime.CompilerServices.VisualC.dll + System.Runtime.dll + System.Runtime.Extensions.dll + System.Runtime.Handles.dll + System.Runtime.InteropServices.dll + System.Runtime.InteropServices.JavaScript.dll + System.Runtime.InteropServices.RuntimeInformation.dll + System.Runtime.Intrinsics.dll + System.Runtime.Loader.dll + System.Runtime.Numerics.dll + System.Runtime.Serialization.dll + System.Runtime.Serialization.Formatters.dll + System.Runtime.Serialization.Json.dll + System.Runtime.Serialization.Primitives.dll + System.Runtime.Serialization.Xml.dll + System.Security.AccessControl.dll + System.Security.Claims.dll + System.Security.Cryptography.Algorithms.dll + System.Security.Cryptography.Cng.dll + System.Security.Cryptography.Csp.dll + System.Security.Cryptography.dll + System.Security.Cryptography.Encoding.dll + System.Security.Cryptography.OpenSsl.dll + System.Security.Cryptography.Primitives.dll + System.Security.Cryptography.X509Certificates.dll + System.Security.dll + System.Security.Principal.dll + System.Security.Principal.Windows.dll + System.Security.SecureString.dll + System.ServiceModel.Web.dll + System.ServiceProcess.dll + System.Text.Encoding.CodePages.dll + System.Text.Encoding.dll + System.Text.Encoding.Extensions.dll + System.Text.Encodings.Web.dll + System.Text.Json.dll + System.Text.RegularExpressions.dll + System.Threading.AccessControl.dll + System.Threading.Channels.dll + System.Threading.dll + System.Threading.Overlapped.dll + System.Threading.Tasks.Dataflow.dll + System.Threading.Tasks.dll + System.Threading.Tasks.Extensions.dll + System.Threading.Tasks.Parallel.dll + System.Threading.Thread.dll + System.Threading.ThreadPool.dll + System.Threading.Timer.dll + System.Transactions.dll + System.Transactions.Local.dll + System.ValueTuple.dll + System.Web.dll + System.Web.HttpUtility.dll + System.Windows.dll + System.Xml.dll + System.Xml.Linq.dll + System.Xml.ReaderWriter.dll + System.Xml.Serialization.dll + System.Xml.XDocument.dll + System.Xml.XmlDocument.dll + System.Xml.XmlSerializer.dll + System.Xml.XPath.dll + System.Xml.XPath.XDocument.dll + WindowsBase.dll +D:/NuGet/Packages/ + adaptivecards/3.1.0/lib/net6.0/AdaptiveCards.dll + anglesharp/1.4.0/lib/net10.0/AngleSharp.dll + communitytoolkit.mvvm/8.4.1/lib/net8.0/CommunityToolkit.Mvvm.dll + documentformat.openxml.framework/3.5.1/lib/net10.0/DocumentFormat.OpenXml.Framework.dll + documentformat.openxml/3.5.1/lib/net10.0/DocumentFormat.OpenXml.dll + excss/4.3.1/lib/net8.0/ExCSS.dll + google.apis.core/1.68.0/lib/net6.0/Google.Apis.Core.dll + google.apis.customsearchapi.v1/1.68.0.3520/lib/net6.0/Google.Apis.CustomSearchAPI.v1.dll + google.apis/1.68.0/lib/net6.0/Google.Apis.dll + google.protobuf/3.30.2/lib/net5.0/Google.Protobuf.dll + markdig/1.1.1/lib/net10.0/Markdig.dll + microsoft.agents.ai.abstractions/1.6.1/lib/net10.0/Microsoft.Agents.AI.Abstractions.dll + microsoft.agents.ai.workflows/1.6.1/lib/net10.0/Microsoft.Agents.AI.Workflows.dll + microsoft.agents.ai/1.6.1/lib/net10.0/Microsoft.Agents.AI.dll + microsoft.bcl.asyncinterfaces/6.0.0/lib/netstandard2.1/Microsoft.Bcl.AsyncInterfaces.dll + microsoft.bcl.hashcode/1.1.1/ref/netcoreapp2.1/Microsoft.Bcl.HashCode.dll + microsoft.data.sqlite.core/10.0.5/lib/net8.0/Microsoft.Data.Sqlite.dll + microsoft.extensions.ai.abstractions/10.5.1/lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + microsoft.extensions.ai.evaluation/10.4.0/lib/net10.0/Microsoft.Extensions.AI.Evaluation.dll + microsoft.extensions.ai.openai/10.5.1/lib/net10.0/Microsoft.Extensions.AI.OpenAI.dll + microsoft.extensions.ai/10.5.1/lib/net10.0/Microsoft.Extensions.AI.dll + microsoft.extensions.ambientmetadata.application/10.4.0/lib/net10.0/Microsoft.Extensions.AmbientMetadata.Application.dll + microsoft.extensions.caching.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.Caching.Abstractions.dll + microsoft.extensions.caching.memory/10.0.5/lib/net10.0/Microsoft.Extensions.Caching.Memory.dll + microsoft.extensions.compliance.abstractions/10.5.0/lib/net10.0/Microsoft.Extensions.Compliance.Abstractions.dll + microsoft.extensions.configuration.abstractions/10.0.5/lib/net10.0/Microsoft.Extensions.Configuration.Abstractions.dll + microsoft.extensions.configuration.binder/10.0.5/lib/net10.0/Microsoft.Extensions.Configuration.Binder.dll + microsoft.extensions.configuration/10.0.5/lib/net10.0/Microsoft.Extensions.Configuration.dll + microsoft.extensions.dependencyinjection.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll + microsoft.extensions.dependencyinjection.autoactivation/10.4.0/lib/net10.0/Microsoft.Extensions.DependencyInjection.AutoActivation.dll + microsoft.extensions.dependencyinjection/10.0.5/lib/net10.0/Microsoft.Extensions.DependencyInjection.dll + microsoft.extensions.diagnostics.abstractions/10.0.5/lib/net10.0/Microsoft.Extensions.Diagnostics.Abstractions.dll + microsoft.extensions.diagnostics.exceptionsummarization/10.4.0/lib/net10.0/Microsoft.Extensions.Diagnostics.ExceptionSummarization.dll + microsoft.extensions.diagnostics/10.0.5/lib/net10.0/Microsoft.Extensions.Diagnostics.dll + microsoft.extensions.fileproviders.abstractions/10.0.4/lib/net10.0/Microsoft.Extensions.FileProviders.Abstractions.dll + microsoft.extensions.filesystemglobbing/10.0.6/lib/net10.0/Microsoft.Extensions.FileSystemGlobbing.dll + microsoft.extensions.hosting.abstractions/10.0.4/lib/net10.0/Microsoft.Extensions.Hosting.Abstractions.dll + microsoft.extensions.http.diagnostics/10.4.0/lib/net10.0/Microsoft.Extensions.Http.Diagnostics.dll + microsoft.extensions.http.resilience/10.4.0/lib/net10.0/Microsoft.Extensions.Http.Resilience.dll + microsoft.extensions.http/10.0.5/lib/net10.0/Microsoft.Extensions.Http.dll + microsoft.extensions.logging.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.Logging.Abstractions.dll + microsoft.extensions.logging.configuration/10.0.4/lib/net10.0/Microsoft.Extensions.Logging.Configuration.dll + microsoft.extensions.logging/10.0.5/lib/net10.0/Microsoft.Extensions.Logging.dll + microsoft.extensions.objectpool/10.0.6/lib/net10.0/Microsoft.Extensions.ObjectPool.dll + microsoft.extensions.options.configurationextensions/10.0.5/lib/net10.0/Microsoft.Extensions.Options.ConfigurationExtensions.dll + microsoft.extensions.options/10.0.5/lib/net10.0/Microsoft.Extensions.Options.dll + microsoft.extensions.primitives/10.0.6/lib/net10.0/Microsoft.Extensions.Primitives.dll + microsoft.extensions.resilience/10.4.0/lib/net10.0/Microsoft.Extensions.Resilience.dll + microsoft.extensions.telemetry.abstractions/10.4.0/lib/net10.0/Microsoft.Extensions.Telemetry.Abstractions.dll + microsoft.extensions.telemetry/10.4.0/lib/net10.0/Microsoft.Extensions.Telemetry.dll + microsoft.extensions.vectordata.abstractions/10.0.1/lib/net10.0/Microsoft.Extensions.VectorData.Abstractions.dll + microsoft.ml.onnxruntime.managed/1.24.3/lib/net8.0/Microsoft.ML.OnnxRuntime.dll + microsoft.ml.tokenizers/2.0.0/lib/net8.0/Microsoft.ML.Tokenizers.dll + microsoft.playwright/1.58.0/lib/netstandard2.0/Microsoft.Playwright.dll + microsoft.semantickernel.abstractions/1.73.0/lib/net10.0/Microsoft.SemanticKernel.Abstractions.dll + microsoft.semantickernel.connectors.sqlitevec/1.73.0-preview/lib/net10.0/Microsoft.SemanticKernel.Connectors.SqliteVec.dll + microsoft.semantickernel.plugins.web/1.73.0-alpha/lib/net10.0/Microsoft.SemanticKernel.Plugins.Web.dll + modelcontextprotocol.core/1.1.0/lib/net10.0/ModelContextProtocol.Core.dll + modelcontextprotocol/1.1.0/lib/net10.0/ModelContextProtocol.dll + newtonsoft.json/13.0.3/lib/net6.0/Newtonsoft.Json.dll + openai/2.10.0/lib/net10.0/OpenAI.dll + opentelemetry.api/1.15.3/lib/net10.0/OpenTelemetry.Api.dll + pdfpig/0.1.13/lib/net8.0/ + UglyToad.PdfPig.Core.dll + UglyToad.PdfPig.dll + UglyToad.PdfPig.DocumentLayoutAnalysis.dll + UglyToad.PdfPig.Fonts.dll + UglyToad.PdfPig.Package.dll + UglyToad.PdfPig.Tokenization.dll + UglyToad.PdfPig.Tokens.dll + polly.core/8.4.2/lib/net8.0/Polly.Core.dll + polly.extensions/8.4.2/lib/net8.0/Polly.Extensions.dll + polly.ratelimiting/8.4.2/lib/net8.0/Polly.RateLimiting.dll + serilog.extensions.hosting/10.0.0/lib/net10.0/Serilog.Extensions.Hosting.dll + serilog.extensions.logging/10.0.0/lib/net10.0/Serilog.Extensions.Logging.dll + serilog.sinks.console/6.1.1/lib/net8.0/Serilog.Sinks.Console.dll + serilog.sinks.file/7.0.0/lib/net9.0/Serilog.Sinks.File.dll + serilog/4.3.0/lib/net9.0/Serilog.dll + shimskiasharp/3.6.0/lib/net10.0/ShimSkiaSharp.dll + skender.stock.indicators/2.7.1/lib/net10.0/Skender.Stock.Indicators.dll + skiasharp/3.119.2/ref/net8.0/SkiaSharp.dll + smartreader/0.11.0/lib/netstandard2.1/SmartReader.dll + sqlitepclraw.bundle_e_sqlite3/2.1.11/lib/netstandard2.0/SQLitePCLRaw.batteries_v2.dll + sqlitepclraw.core/2.1.11/lib/netstandard2.0/SQLitePCLRaw.core.dll + sqlitepclraw.provider.e_sqlite3/2.1.11/lib/net6.0/SQLitePCLRaw.provider.e_sqlite3.dll + svg.custom/3.6.0/lib/net10.0/Svg.Custom.dll + svg.model/3.6.0/lib/net10.0/Svg.Model.dll + svg.skia/3.6.0/lib/net10.0/Svg.Skia.dll + system.clientmodel/1.10.0/lib/net10.0/System.ClientModel.dll + system.io.packaging/10.0.2/lib/net10.0/System.IO.Packaging.dll + system.memory.data/10.0.3/lib/net10.0/System.Memory.Data.dll + system.numerics.tensors/10.0.6/lib/net10.0/System.Numerics.Tensors.dll + system.threading.ratelimiting/8.0.0/lib/net8.0/System.Threading.RateLimiting.dll + yamldotnet/16.3.0/lib/net8.0/YamlDotNet.dll + +[analyzerReferences] +/packs/Microsoft.NETCore.App.Ref/10.0.5/analyzers/dotnet/cs/ + Microsoft.Interop.ComInterfaceGenerator.dll + Microsoft.Interop.JavaScript.JSImportGenerator.dll + Microsoft.Interop.LibraryImportGenerator.dll + Microsoft.Interop.SourceGeneration.dll + System.Text.Json.SourceGeneration.dll + System.Text.RegularExpressions.Generator.dll +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/ + Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll + Microsoft.CodeAnalysis.NetAnalyzers.dll +D:/NuGet/Packages/communitytoolkit.mvvm/8.4.1/analyzers/dotnet/roslyn5.0/cs/ + CommunityToolkit.Mvvm.CodeFixers.dll + CommunityToolkit.Mvvm.SourceGenerators.dll +D:/NuGet/Packages/ + microsoft.agents.ai.workflows.generators/1.6.1/analyzers/dotnet/cs/Microsoft.Agents.AI.Workflows.Generators.dll + microsoft.extensions.options/10.0.5/analyzers/dotnet/roslyn4.4/cs/Microsoft.Extensions.Options.SourceGeneration.dll + microsoft.extensions.telemetry.abstractions/10.4.0/analyzers/dotnet/cs/ + Microsoft.Gen.Logging.dll + Microsoft.Gen.Metrics.dll + modelcontextprotocol.core/1.1.0/analyzers/dotnet/cs/ModelContextProtocol.Analyzers.dll + system.clientmodel/1.10.0/analyzers/dotnet/cs/System.ClientModel.SourceGeneration.dll + +[analyzerConfigFiles] +../../.editorconfig +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_10_default.globalconfig +obj/Debug/net10.0/MarketAssistant.App.Services.GeneratedMSBuildEditorConfig.editorconfig diff --git a/src/MarketAssistant.App.Services/Services/Market/MarketContext.cs b/src/MarketAssistant.App.Services/Services/Market/MarketContext.cs index 97f5c8d..b02fb7a 100644 --- a/src/MarketAssistant.App.Services/Services/Market/MarketContext.cs +++ b/src/MarketAssistant.App.Services/Services/Market/MarketContext.cs @@ -14,7 +14,7 @@ public class MarketContext : INotifyPropertyChanged private readonly IUserSettingService _userSettingService; private readonly IServiceProvider _serviceProvider; private readonly object _marketLock = new(); - private volatile MarketType _currentMarket; + private MarketType _currentMarket; /// /// 当前激活的市场类型 diff --git a/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs index b446545..e5472c5 100644 --- a/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs +++ b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs @@ -56,7 +56,7 @@ public static class BusinessServiceCollectionExtensions public static IServiceCollection AddBusinessServices(this IServiceCollection services) { services.AddMemoryCache(); - services.AddHttpClients(); + services.AddNamedMarketHttpClients(); services.AddAgentTools(); services.AddAgentInfrastructure(); services.AddRagServices(); @@ -70,10 +70,19 @@ public static IServiceCollection AddBusinessServices(this IServiceCollection ser } // ───────────────────────────────────────────────────────────────────────── - // 命名 HttpClient(内部,测试项目可通过 InternalsVisibleTo 复用) + // 命名 HttpClient(公开别名便于单元测试与外部宿主复用) // ───────────────────────────────────────────────────────────────────────── - internal static IServiceCollection AddHttpClients(this IServiceCollection services) + /// + /// 注册 Binance / CoinGecko / Cls 等命名 HttpClient,含标准弹性策略。 + /// + public static IServiceCollection AddNamedMarketHttpClients(this IServiceCollection services) => + AddHttpClientsCore(services); + + internal static IServiceCollection AddHttpClients(this IServiceCollection services) => + AddHttpClientsCore(services); + + private static IServiceCollection AddHttpClientsCore(IServiceCollection services) { services.AddHttpClient("Binance", client => { @@ -176,7 +185,6 @@ private static IServiceCollection AddAgentInfrastructure(this IServiceCollection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs index ced92e5..cccaa92 100644 --- a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs +++ b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs @@ -239,10 +239,19 @@ private async Task HandleAISignalAsync(TradingStrategy strategy, decimal current { TradingContext.CurrentStrategyId = strategy.Id; + var priorRecords = await _dataService.GetRecordsByStrategyAsync(strategy.Id, _cts?.Token ?? default) + .ConfigureAwait(false); + var recentSummary = priorRecords.Count == 0 + ? "(该策略尚无成交记录)" + : string.Join("\n", priorRecords.Take(5).Select(r => + $"{r.CreatedAt:u} {r.Side} 成交量:{r.ExecutedQty} 价:{r.ExecutedPrice} {r.Status}")); + var agent = _agentFactory.CreateAgent(); var prompt = $""" 分析交易标的 {strategy.Symbol},当前价格 {currentPrice}。 策略配置: {strategy.CustomParams ?? "无"} + 近期该策略成交摘要(最多 5 笔,按时间倒序): + {recentSummary} 请评估是否应该执行 {strategy.Side} 操作,数量 {strategy.Quantity}。 如果决定交易,请调用 PlaceOrder 工具执行。 如果决定不交易,请说明理由。 @@ -301,6 +310,11 @@ public void Dispose() kvp.Value.Dispose(); _strategyLocks.Clear(); _lifecycleLock.Dispose(); + + TradeExecuted = null; + StatusChanged = null; + _webSocketService.PriceUpdated -= OnPriceUpdated; + GC.SuppressFinalize(this); } } diff --git a/src/MarketAssistant.App.Services/Trading/RiskManager.cs b/src/MarketAssistant.App.Services/Trading/RiskManager.cs index f6417ae..75ba365 100644 --- a/src/MarketAssistant.App.Services/Trading/RiskManager.cs +++ b/src/MarketAssistant.App.Services/Trading/RiskManager.cs @@ -36,7 +36,7 @@ public async Task ValidateOrderAsync( if (orderValueUSDT < config.MinOrderAmount) return RiskCheckResult.Reject($"订单金额 {orderValueUSDT:F2} USDT 低于最小限额 {config.MinOrderAmount} USDT"); - var todayStats = await _dataService.GetTodayStatsAsync(ct); + var todayStats = await _dataService.GetTodayStatsAsync(ct).ConfigureAwait(false); if (todayStats.TradeCount >= config.MaxDailyTrades) return RiskCheckResult.Reject($"今日交易次数 {todayStats.TradeCount} 已达上限 {config.MaxDailyTrades}"); @@ -44,7 +44,7 @@ public async Task ValidateOrderAsync( AccountBalanceSummary portfolioSummary; try { - portfolioSummary = await _portfolioService.GetAccountBalanceSummaryAsync(ct); + portfolioSummary = await _portfolioService.GetAccountBalanceSummaryAsync(ct).ConfigureAwait(false); } catch (Exception ex) { diff --git a/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs b/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs index a52420e..9df9606 100644 --- a/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs +++ b/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs @@ -45,8 +45,10 @@ public async Task> EvaluateStrategiesAsync( continue; } - if (IsTriggered(strategy, currentPrice)) + if (IsTriggered(strategy, currentPrice, out var effectiveSide, out var effectiveQty)) { + strategy.Side = effectiveSide; + strategy.Quantity = effectiveQty; _logger.LogInformation( "策略触发: {StrategyId} {Type} {Symbol} 触发价:{TriggerPrice} 当前价:{CurrentPrice}", strategy.Id, strategy.Type, symbol, strategy.TriggerPrice, currentPrice); @@ -57,16 +59,20 @@ public async Task> EvaluateStrategiesAsync( return triggered; } - private bool IsTriggered(TradingStrategy strategy, decimal currentPrice) + private bool IsTriggered(TradingStrategy strategy, decimal currentPrice, + out OrderSide effectiveSide, out decimal effectiveQty) { + effectiveSide = strategy.Side; + effectiveQty = strategy.Quantity; + return strategy.Type switch { StrategyType.StopLoss => EvaluateStopLoss(strategy, currentPrice), StrategyType.TakeProfit => EvaluateTakeProfit(strategy, currentPrice), StrategyType.TrailingStop => EvaluateTrailingStop(strategy, currentPrice), StrategyType.AISignal => EvaluateAISignal(strategy), - StrategyType.GridTrading => EvaluateGridTrading(strategy, currentPrice), - StrategyType.DCA => EvaluateDCA(strategy, currentPrice), + StrategyType.GridTrading => EvaluateGridTrading(strategy, currentPrice, out effectiveSide, out effectiveQty), + StrategyType.DCA => EvaluateDCA(strategy, currentPrice, out effectiveSide, out effectiveQty), _ => false }; } @@ -173,8 +179,12 @@ private bool EvaluateAISignal(TradingStrategy strategy) /// 网格在 LowerPrice 和 UpperPrice 之间均匀分布。 /// 价格下穿网格线时买入,上穿时卖出。 /// - private bool EvaluateGridTrading(TradingStrategy strategy, decimal currentPrice) + private bool EvaluateGridTrading(TradingStrategy strategy, decimal currentPrice, + out OrderSide effectiveSide, out decimal effectiveQty) { + effectiveSide = strategy.Side; + effectiveQty = strategy.Quantity; + if (string.IsNullOrEmpty(strategy.CustomParams)) return false; @@ -184,19 +194,15 @@ private bool EvaluateGridTrading(TradingStrategy strategy, decimal currentPrice) if (gridParams == null || gridParams.GridCount <= 1 || gridParams.UpperPrice <= gridParams.LowerPrice) return false; - // 价格超出网格范围不触发 if (currentPrice < gridParams.LowerPrice || currentPrice > gridParams.UpperPrice) return false; - // 计算当前价格落在哪一格 var spacing = gridParams.GridSpacing; var currentIndex = (int)((currentPrice - gridParams.LowerPrice) / spacing); currentIndex = Math.Clamp(currentIndex, 0, gridParams.GridCount); - // 与上次触发的网格索引比较,只有穿越才触发 if (gridParams.LastTriggeredIndex < 0) { - // 首次运行,记录当前位置但不触发 gridParams.LastTriggeredIndex = currentIndex; strategy.CustomParams = JsonSerializer.Serialize(gridParams); return false; @@ -205,16 +211,15 @@ private bool EvaluateGridTrading(TradingStrategy strategy, decimal currentPrice) if (currentIndex == gridParams.LastTriggeredIndex) return false; - // 价格穿越了网格线:下穿(index 减小)买入,上穿(index 增大)卖出 - strategy.Side = currentIndex < gridParams.LastTriggeredIndex ? OrderSide.Buy : OrderSide.Sell; - strategy.Quantity = gridParams.QuantityPerGrid; + effectiveSide = currentIndex < gridParams.LastTriggeredIndex ? OrderSide.Buy : OrderSide.Sell; + effectiveQty = gridParams.QuantityPerGrid; gridParams.LastTriggeredIndex = currentIndex; strategy.CustomParams = JsonSerializer.Serialize(gridParams); _logger.LogInformation( "网格交易触发: {StrategyId} 网格 {Index} → {Side},价格: {Price}", - strategy.Id, currentIndex, strategy.Side, currentPrice); + strategy.Id, currentIndex, effectiveSide, currentPrice); return true; } catch (Exception ex) @@ -228,8 +233,12 @@ private bool EvaluateGridTrading(TradingStrategy strategy, decimal currentPrice) /// DCA(定投)评估:按时间间隔定期买入。 /// 支持价格上限过滤和低价加倍买入。 /// - private bool EvaluateDCA(TradingStrategy strategy, decimal currentPrice) + private bool EvaluateDCA(TradingStrategy strategy, decimal currentPrice, + out OrderSide effectiveSide, out decimal effectiveQty) { + effectiveSide = strategy.Side; + effectiveQty = strategy.Quantity; + if (string.IsNullOrEmpty(strategy.CustomParams)) return false; @@ -239,7 +248,6 @@ private bool EvaluateDCA(TradingStrategy strategy, decimal currentPrice) if (dcaParams == null || dcaParams.AmountPerInterval <= 0) return false; - // 检查时间间隔 if (strategy.LastTriggeredAt.HasValue) { var elapsed = (DateTime.UtcNow - strategy.LastTriggeredAt.Value).TotalSeconds; @@ -247,7 +255,6 @@ private bool EvaluateDCA(TradingStrategy strategy, decimal currentPrice) return false; } - // 价格上限过滤 if (dcaParams.MaxBuyPrice > 0 && currentPrice > dcaParams.MaxBuyPrice) { _logger.LogDebug("DCA 跳过: {StrategyId} 当前价 {Price} 超过上限 {MaxPrice}", @@ -255,10 +262,6 @@ private bool EvaluateDCA(TradingStrategy strategy, decimal currentPrice) return false; } - // DCA 始终买入方向 - strategy.Side = OrderSide.Buy; - - // 低价加倍买入 var amount = dcaParams.AmountPerInterval; if (dcaParams.DoubleBuyBelowPrice > 0 && currentPrice < dcaParams.DoubleBuyBelowPrice) { @@ -267,10 +270,10 @@ private bool EvaluateDCA(TradingStrategy strategy, decimal currentPrice) strategy.Id, currentPrice, dcaParams.DoubleBuyBelowPrice); } - // 将金额转换为数量 - strategy.Quantity = currentPrice > 0 ? amount / currentPrice : 0; + effectiveSide = OrderSide.Buy; + effectiveQty = currentPrice > 0 ? amount / currentPrice : 0; - return strategy.Quantity > 0; + return effectiveQty > 0; } catch (Exception ex) { diff --git a/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs b/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs index 786cfae..79b4546 100644 --- a/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs +++ b/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using MarketAssistant.Trading.Abstractions; using MarketAssistant.Trading.Models; using Microsoft.Extensions.Logging; @@ -5,10 +6,14 @@ namespace MarketAssistant.Trading; /// -/// 交易执行器,统一的下单入口:风控 → 确认 → 下单 → 记录 → PnL 计算 +/// 交易执行器,统一的下单入口:风控 → 确认 → 下单 → 记录 → PnL 计算。 +/// 同一交易对在任意时刻仅允许一条下单路径进入交易所调用,避免并发重复下单。 /// public class TradeExecutor { + private readonly ConcurrentDictionary _symbolExecutionLocks = + new(StringComparer.OrdinalIgnoreCase); + private readonly IExchangeClient _exchangeClient; private readonly RiskManager _riskManager; private readonly TradingDataService _dataService; @@ -63,6 +68,27 @@ public async Task ExecuteOrderAsync( decimal currentPrice, decimal? limitPrice = null, string strategyId = "manual", string? aiReasoning = null, CancellationToken ct = default) + { + var gate = _symbolExecutionLocks.GetOrAdd( + instrumentSymbol.Trim(), static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(ct).ConfigureAwait(false); + try + { + return await ExecuteOrderCoreAsync( + instrumentSymbol, side, type, quantity, currentPrice, limitPrice, + strategyId, aiReasoning, ct).ConfigureAwait(false); + } + finally + { + gate.Release(); + } + } + + private async Task ExecuteOrderCoreAsync( + string instrumentSymbol, OrderSide side, OrderType type, decimal quantity, + decimal currentPrice, decimal? limitPrice, + string strategyId, string? aiReasoning, + CancellationToken ct) { var riskCheck = await _riskManager.ValidateOrderAsync(instrumentSymbol, side, quantity, currentPrice, ct); @@ -95,7 +121,6 @@ public async Task ExecuteOrderAsync( try { - var orderTypeStr = type.ToString().ToUpper(); var response = await _exchangeClient.PlaceOrderAsync( instrumentSymbol, side, type, quantity, type == OrderType.Limit ? limitPrice : null, ct); diff --git a/src/MarketAssistant.App.Services/Trading/TradingDataService.cs b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs index 6420a5d..484a64d 100644 --- a/src/MarketAssistant.App.Services/Trading/TradingDataService.cs +++ b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs @@ -63,7 +63,7 @@ INSERT OR REPLACE INTO strategies cmd.Parameters.AddWithValue("@lastTriggered", strategy.LastTriggeredAt.HasValue ? (object)strategy.LastTriggeredAt.Value.ToString("O") : DBNull.Value); cmd.Parameters.AddWithValue("@execCount", strategy.ExecutionCount); cmd.Parameters.AddWithValue("@maxExec", strategy.MaxExecutions.HasValue ? (object)strategy.MaxExecutions.Value : DBNull.Value); - await cmd.ExecuteNonQueryAsync(ct); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } public async Task GetStrategyAsync(string id, CancellationToken ct = default) @@ -104,7 +104,7 @@ public async Task UpdateStrategyStatusAsync(string id, StrategyStatus status, Ca cmd.CommandText = "UPDATE strategies SET status = @status WHERE id = @id"; cmd.Parameters.AddWithValue("@id", id); cmd.Parameters.AddWithValue("@status", (int)status); - await cmd.ExecuteNonQueryAsync(ct); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } public async Task DeleteStrategyAsync(string id, CancellationToken ct = default) @@ -114,7 +114,7 @@ public async Task DeleteStrategyAsync(string id, CancellationToken ct = default) await using var cmd = conn.CreateCommand(); cmd.CommandText = "DELETE FROM strategies WHERE id = @id"; cmd.Parameters.AddWithValue("@id", id); - await cmd.ExecuteNonQueryAsync(ct); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } public async Task UpdateStrategyTriggeredAsync(string id, CancellationToken ct = default) @@ -129,7 +129,7 @@ UPDATE strategies """; cmd.Parameters.AddWithValue("@id", id); cmd.Parameters.AddWithValue("@time", DateTime.UtcNow.ToString("O")); - await cmd.ExecuteNonQueryAsync(ct); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } #endregion @@ -167,7 +167,7 @@ INSERT INTO trade_records cmd.Parameters.AddWithValue("@aiReasoning", (object?)record.AIReasoning ?? DBNull.Value); cmd.Parameters.AddWithValue("@createdAt", record.CreatedAt.ToString("O")); cmd.Parameters.AddWithValue("@completedAt", record.CompletedAt.HasValue ? (object)record.CompletedAt.Value.ToString("O") : DBNull.Value); - await cmd.ExecuteNonQueryAsync(ct); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } public async Task> GetTradeRecordsAsync( @@ -266,7 +266,7 @@ ON CONFLICT(date) DO UPDATE SET cmd.Parameters.AddWithValue("@date", today); cmd.Parameters.AddWithValue("@pnl", (double)pnl); cmd.Parameters.AddWithValue("@comm", (double)commission); - await cmd.ExecuteNonQueryAsync(ct); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } /// @@ -288,7 +288,7 @@ FROM trade_records cmd.Parameters.AddWithValue("@side", (int)OrderSide.Buy); cmd.Parameters.AddWithValue("@status", (int)TradeRecordStatus.Filled); - var result = await cmd.ExecuteScalarAsync(ct); + var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); if (result is double d) return (decimal)d; return 0; @@ -421,6 +421,7 @@ total_commission REAL DEFAULT 0 catch (Exception ex) { _logger.LogError(ex, "初始化交易数据库失败"); + throw new InvalidOperationException("交易数据库初始化失败,应用无法继续运行", ex); } } diff --git a/src/MarketAssistant.App/MarketAssistant.App.csproj b/src/MarketAssistant.App/MarketAssistant.App.csproj index 8e0a6af..a2430af 100644 --- a/src/MarketAssistant.App/MarketAssistant.App.csproj +++ b/src/MarketAssistant.App/MarketAssistant.App.csproj @@ -64,39 +64,19 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + - - - + + + diff --git a/src/MarketAssistant.App/MarketAssistant.App.csproj.lscache b/src/MarketAssistant.App/MarketAssistant.App.csproj.lscache new file mode 100644 index 0000000..f00c308 --- /dev/null +++ b/src/MarketAssistant.App/MarketAssistant.App.csproj.lscache @@ -0,0 +1,528 @@ +version=1 + +# This file caches language service data to improve the performance of C# Dev Kit. +# It is not intended for manual editing. It can safely be deleted and will be +# regenerated automatically. For more information, see https://aka.ms/lscache +# +# To control where cache files are stored, use the following VS Code setting: +# "dotnet.projectsystem.cacheInProjectFolder": true + +[project] +language=C# +primary +lastDtbSucceeded + +[properties] +AssemblyName=MarketAssistant +CommandLineArgsForDesignTimeEvaluation=-langversion:14.0 -define:TRACE +CompilerGeneratedFilesOutputPath= +MaxSupportedLangVersion=14.0 +ProjectAssetsFile=obj/project.assets.json +RootNamespace=MarketAssistant +RunAnalyzers= +RunAnalyzersDuringLiveAnalysis= +SolutionPath=../../MarketAssistant.slnx +TargetFrameworkIdentifier=.NETCoreApp +TargetPath=bin/Debug/net10.0/MarketAssistant.dll +TargetRefPath=obj/Debug/net10.0/ref/MarketAssistant.dll +TemporaryDependencyNodeTargetIdentifier=net10.0 + +[commandLineArguments] +/noconfig +/unsafe- +/checked- +/nowarn:1701,1702,1701,1702,8002 +/fullpaths +/nostdlib+ +/errorreport:prompt +/warn:10 +/define:TRACE;DEBUG;NET;NET10_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NET10_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER +/highentropyva+ +/nullable:enable +/features:"InterceptorsNamespaces=;Microsoft.Extensions.Validation.Generated" +/debug+ +/debug:portable +/filealign:512 +/optimize- +/out:obj\Debug\net10.0\MarketAssistant.dll +/refout:obj\Debug\net10.0\refint\MarketAssistant.dll +/target:winexe +/warnaserror- +/utf8output +/win32icon:Assets\logo.ico +/win32manifest:app.manifest +/deterministic+ +/langversion:14.0 +/warnaserror+:NU1605,SYSLIB0011 + +[sourceFiles] +App.axaml.cs +Converts/ + EnumDescriptionConverter.cs + NavigationIconConverter.cs + NullableValueConverter.cs + NullableVisibilityConverter.cs + NumberFormatConverter.cs + PriceChangeColorConverter.cs + RadioButtonEqualityConverter.cs + ScoreToColorConverter.cs +GlobalUsing.cs +Infrastructure/ + AdaptiveCards/AdaptiveCardConverter.cs + Core/ + GlobalExceptionHandler.cs + ViewLocator.cs +obj/Debug/net10.0/ + .NETCoreApp,Version=v10.0.AssemblyAttributes.cs + MarketAssistant.App.AssemblyInfo.cs +Program.cs +Services/ + Dialog/DialogService.cs + Navigation/ + INavigationAware.cs + NavigationService.cs + Notification/NotificationService.cs + ServiceCollectionExtensions.cs +ViewModels/ + AboutPageViewModel.cs + AgentAnalysisViewModel.cs + AnalysisReportViewModel.cs + AnalystRoleViewModel.cs + AssetNavigationParameter.cs + AssetPageViewModel.cs + AssetSelectionPageViewModel.cs + ChatMessageAdapter.cs + ChatSidebarViewModel.cs + Demo/ + ChatSidebarDemoViewModel.cs + MockChatClientFactory.cs + FavoritesPageViewModel.cs + Home/ + HomeSearchViewModel.cs + HotAssetsViewModel.cs + RecentAssetsViewModel.cs + TelegraphNewsViewModel.cs + HomePageViewModel.cs + MainWindowViewModel.cs + MCPConfigPageViewModel.cs + SettingsPageViewModel.cs + Trading/ + StrategyConfigViewModel.cs + TradeHistoryViewModel.cs + TradeMonitorViewModel.cs + TradingPageViewModel.cs + ViewModelBase.cs +Views/Components/ + AdaptiveCardView.axaml.cs + AnalysisReportView.axaml.cs + ChatSidebarView.axaml.cs + KLineChartView.cs + ProgressDisplayView.axaml.cs +Views/Controls/ + CardView.axaml.cs + LoadingDots.axaml.cs + RichTextBlock.cs + WatermarkView.cs +Views/ + Demo/ChatSidebarDemoView.axaml.cs + Pages/ + AboutPageView.axaml.cs + AgentAnalysisPageView.axaml.cs + AssetPageView.axaml.cs + AssetSelectionPageView.axaml.cs + FavoritesPageView.axaml.cs + HomePageView.axaml.cs + MCPConfigPageView.axaml.cs + SettingsPageView.axaml.cs + Trading/ + StrategyConfigView.axaml.cs + TradeHistoryView.axaml.cs + TradeMonitorView.axaml.cs + TradingPageView.axaml.cs + Windows/ + MainWindow.axaml.cs + NotificationWindow.axaml.cs + +[metadataReferences] +../ + MarketAssistant.Agents/obj/Debug/net10.0/ref/MarketAssistant.Agents.dll + MarketAssistant.App.Services/obj/Debug/net10.0/ref/MarketAssistant.App.Services.dll + MarketAssistant.Core/obj/Debug/net10.0/ref/MarketAssistant.Core.dll + MarketAssistant.DataProviders/obj/Debug/net10.0/ref/MarketAssistant.DataProviders.dll + MarketAssistant.Rag/obj/Debug/net10.0/ref/MarketAssistant.Rag.dll + MarketAssistant.Trading/obj/Debug/net10.0/ref/MarketAssistant.Trading.dll +/packs/Microsoft.NETCore.App.Ref/10.0.5/ref/net10.0/ + Microsoft.CSharp.dll + Microsoft.VisualBasic.Core.dll + Microsoft.VisualBasic.dll + Microsoft.Win32.Primitives.dll + Microsoft.Win32.Registry.dll + mscorlib.dll + netstandard.dll + System.AppContext.dll + System.Buffers.dll + System.Collections.Concurrent.dll + System.Collections.dll + System.Collections.Immutable.dll + System.Collections.NonGeneric.dll + System.Collections.Specialized.dll + System.ComponentModel.Annotations.dll + System.ComponentModel.DataAnnotations.dll + System.ComponentModel.dll + System.ComponentModel.EventBasedAsync.dll + System.ComponentModel.Primitives.dll + System.ComponentModel.TypeConverter.dll + System.Configuration.dll + System.Console.dll + System.Core.dll + System.Data.Common.dll + System.Data.DataSetExtensions.dll + System.Data.dll + System.Diagnostics.Contracts.dll + System.Diagnostics.Debug.dll + System.Diagnostics.DiagnosticSource.dll + System.Diagnostics.FileVersionInfo.dll + System.Diagnostics.Process.dll + System.Diagnostics.StackTrace.dll + System.Diagnostics.TextWriterTraceListener.dll + System.Diagnostics.Tools.dll + System.Diagnostics.TraceSource.dll + System.Diagnostics.Tracing.dll + System.dll + System.Drawing.dll + System.Drawing.Primitives.dll + System.Dynamic.Runtime.dll + System.Formats.Asn1.dll + System.Formats.Tar.dll + System.Globalization.Calendars.dll + System.Globalization.dll + System.Globalization.Extensions.dll + System.IO.Compression.Brotli.dll + System.IO.Compression.dll + System.IO.Compression.FileSystem.dll + System.IO.Compression.ZipFile.dll + System.IO.dll + System.IO.FileSystem.AccessControl.dll + System.IO.FileSystem.dll + System.IO.FileSystem.DriveInfo.dll + System.IO.FileSystem.Primitives.dll + System.IO.FileSystem.Watcher.dll + System.IO.IsolatedStorage.dll + System.IO.MemoryMappedFiles.dll + System.IO.Pipelines.dll + System.IO.Pipes.AccessControl.dll + System.IO.Pipes.dll + System.IO.UnmanagedMemoryStream.dll + System.Linq.AsyncEnumerable.dll + System.Linq.dll + System.Linq.Expressions.dll + System.Linq.Parallel.dll + System.Linq.Queryable.dll + System.Memory.dll + System.Net.dll + System.Net.Http.dll + System.Net.Http.Json.dll + System.Net.HttpListener.dll + System.Net.Mail.dll + System.Net.NameResolution.dll + System.Net.NetworkInformation.dll + System.Net.Ping.dll + System.Net.Primitives.dll + System.Net.Quic.dll + System.Net.Requests.dll + System.Net.Security.dll + System.Net.ServerSentEvents.dll + System.Net.ServicePoint.dll + System.Net.Sockets.dll + System.Net.WebClient.dll + System.Net.WebHeaderCollection.dll + System.Net.WebProxy.dll + System.Net.WebSockets.Client.dll + System.Net.WebSockets.dll + System.Numerics.dll + System.Numerics.Vectors.dll + System.ObjectModel.dll + System.Reflection.DispatchProxy.dll + System.Reflection.dll + System.Reflection.Emit.dll + System.Reflection.Emit.ILGeneration.dll + System.Reflection.Emit.Lightweight.dll + System.Reflection.Extensions.dll + System.Reflection.Metadata.dll + System.Reflection.Primitives.dll + System.Reflection.TypeExtensions.dll + System.Resources.Reader.dll + System.Resources.ResourceManager.dll + System.Resources.Writer.dll + System.Runtime.CompilerServices.Unsafe.dll + System.Runtime.CompilerServices.VisualC.dll + System.Runtime.dll + System.Runtime.Extensions.dll + System.Runtime.Handles.dll + System.Runtime.InteropServices.dll + System.Runtime.InteropServices.JavaScript.dll + System.Runtime.InteropServices.RuntimeInformation.dll + System.Runtime.Intrinsics.dll + System.Runtime.Loader.dll + System.Runtime.Numerics.dll + System.Runtime.Serialization.dll + System.Runtime.Serialization.Formatters.dll + System.Runtime.Serialization.Json.dll + System.Runtime.Serialization.Primitives.dll + System.Runtime.Serialization.Xml.dll + System.Security.AccessControl.dll + System.Security.Claims.dll + System.Security.Cryptography.Algorithms.dll + System.Security.Cryptography.Cng.dll + System.Security.Cryptography.Csp.dll + System.Security.Cryptography.dll + System.Security.Cryptography.Encoding.dll + System.Security.Cryptography.OpenSsl.dll + System.Security.Cryptography.Primitives.dll + System.Security.Cryptography.X509Certificates.dll + System.Security.dll + System.Security.Principal.dll + System.Security.Principal.Windows.dll + System.Security.SecureString.dll + System.ServiceModel.Web.dll + System.ServiceProcess.dll + System.Text.Encoding.CodePages.dll + System.Text.Encoding.dll + System.Text.Encoding.Extensions.dll + System.Text.Encodings.Web.dll + System.Text.Json.dll + System.Text.RegularExpressions.dll + System.Threading.AccessControl.dll + System.Threading.Channels.dll + System.Threading.dll + System.Threading.Overlapped.dll + System.Threading.Tasks.Dataflow.dll + System.Threading.Tasks.dll + System.Threading.Tasks.Extensions.dll + System.Threading.Tasks.Parallel.dll + System.Threading.Thread.dll + System.Threading.ThreadPool.dll + System.Threading.Timer.dll + System.Transactions.dll + System.Transactions.Local.dll + System.ValueTuple.dll + System.Web.dll + System.Web.HttpUtility.dll + System.Windows.dll + System.Xml.dll + System.Xml.Linq.dll + System.Xml.ReaderWriter.dll + System.Xml.Serialization.dll + System.Xml.XDocument.dll + System.Xml.XmlDocument.dll + System.Xml.XmlSerializer.dll + System.Xml.XPath.dll + System.Xml.XPath.XDocument.dll + WindowsBase.dll +D:/NuGet/Packages/ + adaptivecards/3.1.0/lib/net6.0/AdaptiveCards.dll + anglesharp/1.4.0/lib/net10.0/AngleSharp.dll + avalonia.avaloniaedit/11.0.0/lib/netstandard2.0/AvaloniaEdit.dll + avalonia.controls.webview/12.0.0-preview2/lib/net10.0/Avalonia.Controls.WebView.dll + avalonia.desktop/12.0.0-preview2/lib/net10.0/Avalonia.Desktop.dll + avalonia.fonts.inter/12.0.0-preview2/lib/net10.0/Avalonia.Fonts.Inter.dll + avalonia.freedesktop.atspi/12.0.0-preview2/lib/net10.0/Avalonia.FreeDesktop.AtSpi.dll + avalonia.freedesktop/12.0.0-preview2/lib/net10.0/Avalonia.FreeDesktop.dll + avalonia.harfbuzz/12.0.0-preview2/lib/net10.0/Avalonia.HarfBuzz.dll + avalonia.native/12.0.0-preview2/lib/net10.0/Avalonia.Native.dll + avalonia.remote.protocol/12.0.0-preview2/lib/net10.0/Avalonia.Remote.Protocol.dll + avalonia.skia/12.0.0-preview2/lib/net10.0/Avalonia.Skia.dll + avalonia.svg/11.0.0/lib/net6.0/Avalonia.Svg.dll + avalonia.themes.fluent/12.0.0-preview2/lib/net10.0/Avalonia.Themes.Fluent.dll + avalonia.win32/12.0.0-preview2/lib/net10.0/ + Avalonia.Win32.Automation.dll + Avalonia.Win32.dll + avalonia.x11/12.0.0-preview2/lib/net10.0/Avalonia.X11.dll + avalonia/12.0.0-preview2/ref/net10.0/ + Avalonia.Base.dll + Avalonia.Controls.dll + Avalonia.DesignerSupport.dll + Avalonia.Dialogs.dll + Avalonia.dll + Avalonia.Markup.dll + Avalonia.Markup.Xaml.dll + Avalonia.Metal.dll + Avalonia.MicroCom.dll + Avalonia.OpenGL.dll + Avalonia.Vulkan.dll + colordocument.avalonia/11.0.3-a1/lib/net6.0/ColorDocument.Avalonia.dll + colortextblock.avalonia/11.0.3-a1/lib/net6.0/ColorTextBlock.Avalonia.dll + communitytoolkit.mvvm/8.4.1/lib/net8.0/CommunityToolkit.Mvvm.dll + documentformat.openxml.framework/3.5.1/lib/net10.0/DocumentFormat.OpenXml.Framework.dll + documentformat.openxml/3.5.1/lib/net10.0/DocumentFormat.OpenXml.dll + excss/4.3.1/lib/net8.0/ExCSS.dll + google.apis.core/1.68.0/lib/net6.0/Google.Apis.Core.dll + google.apis.customsearchapi.v1/1.68.0.3520/lib/net6.0/Google.Apis.CustomSearchAPI.v1.dll + google.apis/1.68.0/lib/net6.0/Google.Apis.dll + google.protobuf/3.30.2/lib/net5.0/Google.Protobuf.dll + harfbuzzsharp/8.3.1.3/lib/net8.0/HarfBuzzSharp.dll + htmlagilitypack/1.11.42/lib/netstandard2.0/HtmlAgilityPack.dll + markdig/1.1.1/lib/net10.0/Markdig.dll + markdown.avalonia.html/11.0.3-a1/lib/net6.0/Markdown.Avalonia.Html.dll + markdown.avalonia.svg/11.0.3-a1/lib/net6.0/Markdown.Avalonia.Svg.dll + markdown.avalonia.syntaxhigh/11.0.3-a1/lib/net6.0/Markdown.Avalonia.SyntaxHigh.dll + markdown.avalonia.tight/11.0.3-a1/lib/net6.0/Markdown.Avalonia.dll + markdown.avalonia/11.0.3-a1/lib/net6.0/Markdown.Avalonia.Full.dll + microcom.runtime/0.11.0/lib/net5.0/MicroCom.Runtime.dll + microsoft.agents.ai.abstractions/1.6.1/lib/net10.0/Microsoft.Agents.AI.Abstractions.dll + microsoft.agents.ai.workflows/1.6.1/lib/net10.0/Microsoft.Agents.AI.Workflows.dll + microsoft.agents.ai/1.6.1/lib/net10.0/Microsoft.Agents.AI.dll + microsoft.bcl.asyncinterfaces/6.0.0/lib/netstandard2.1/Microsoft.Bcl.AsyncInterfaces.dll + microsoft.bcl.hashcode/1.1.1/ref/netcoreapp2.1/Microsoft.Bcl.HashCode.dll + microsoft.data.sqlite.core/10.0.5/lib/net8.0/Microsoft.Data.Sqlite.dll + microsoft.extensions.ai.abstractions/10.5.1/lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + microsoft.extensions.ai.evaluation/10.4.0/lib/net10.0/Microsoft.Extensions.AI.Evaluation.dll + microsoft.extensions.ai.openai/10.5.1/lib/net10.0/Microsoft.Extensions.AI.OpenAI.dll + microsoft.extensions.ai/10.5.1/lib/net10.0/Microsoft.Extensions.AI.dll + microsoft.extensions.ambientmetadata.application/10.4.0/lib/net10.0/Microsoft.Extensions.AmbientMetadata.Application.dll + microsoft.extensions.caching.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.Caching.Abstractions.dll + microsoft.extensions.caching.memory/10.0.5/lib/net10.0/Microsoft.Extensions.Caching.Memory.dll + microsoft.extensions.compliance.abstractions/10.5.0/lib/net10.0/Microsoft.Extensions.Compliance.Abstractions.dll + microsoft.extensions.configuration.abstractions/10.0.5/lib/net10.0/Microsoft.Extensions.Configuration.Abstractions.dll + microsoft.extensions.configuration.binder/10.0.5/lib/net10.0/Microsoft.Extensions.Configuration.Binder.dll + microsoft.extensions.configuration/10.0.5/lib/net10.0/Microsoft.Extensions.Configuration.dll + microsoft.extensions.dependencyinjection.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll + microsoft.extensions.dependencyinjection.autoactivation/10.4.0/lib/net10.0/Microsoft.Extensions.DependencyInjection.AutoActivation.dll + microsoft.extensions.dependencyinjection/10.0.5/lib/net10.0/Microsoft.Extensions.DependencyInjection.dll + microsoft.extensions.diagnostics.abstractions/10.0.5/lib/net10.0/Microsoft.Extensions.Diagnostics.Abstractions.dll + microsoft.extensions.diagnostics.exceptionsummarization/10.4.0/lib/net10.0/Microsoft.Extensions.Diagnostics.ExceptionSummarization.dll + microsoft.extensions.diagnostics/10.0.5/lib/net10.0/Microsoft.Extensions.Diagnostics.dll + microsoft.extensions.fileproviders.abstractions/10.0.4/lib/net10.0/Microsoft.Extensions.FileProviders.Abstractions.dll + microsoft.extensions.filesystemglobbing/10.0.6/lib/net10.0/Microsoft.Extensions.FileSystemGlobbing.dll + microsoft.extensions.hosting.abstractions/10.0.4/lib/net10.0/Microsoft.Extensions.Hosting.Abstractions.dll + microsoft.extensions.http.diagnostics/10.4.0/lib/net10.0/Microsoft.Extensions.Http.Diagnostics.dll + microsoft.extensions.http.resilience/10.4.0/lib/net10.0/Microsoft.Extensions.Http.Resilience.dll + microsoft.extensions.http/10.0.5/lib/net10.0/Microsoft.Extensions.Http.dll + microsoft.extensions.logging.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.Logging.Abstractions.dll + microsoft.extensions.logging.configuration/10.0.4/lib/net10.0/Microsoft.Extensions.Logging.Configuration.dll + microsoft.extensions.logging/10.0.5/lib/net10.0/Microsoft.Extensions.Logging.dll + microsoft.extensions.objectpool/10.0.6/lib/net10.0/Microsoft.Extensions.ObjectPool.dll + microsoft.extensions.options.configurationextensions/10.0.5/lib/net10.0/Microsoft.Extensions.Options.ConfigurationExtensions.dll + microsoft.extensions.options/10.0.5/lib/net10.0/Microsoft.Extensions.Options.dll + microsoft.extensions.primitives/10.0.6/lib/net10.0/Microsoft.Extensions.Primitives.dll + microsoft.extensions.resilience/10.4.0/lib/net10.0/Microsoft.Extensions.Resilience.dll + microsoft.extensions.telemetry.abstractions/10.4.0/lib/net10.0/Microsoft.Extensions.Telemetry.Abstractions.dll + microsoft.extensions.telemetry/10.4.0/lib/net10.0/Microsoft.Extensions.Telemetry.dll + microsoft.extensions.vectordata.abstractions/10.0.1/lib/net10.0/Microsoft.Extensions.VectorData.Abstractions.dll + microsoft.ml.onnxruntime.managed/1.24.3/lib/net8.0/Microsoft.ML.OnnxRuntime.dll + microsoft.ml.tokenizers/2.0.0/lib/net8.0/Microsoft.ML.Tokenizers.dll + microsoft.playwright/1.58.0/lib/netstandard2.0/Microsoft.Playwright.dll + microsoft.semantickernel.abstractions/1.73.0/lib/net10.0/Microsoft.SemanticKernel.Abstractions.dll + microsoft.semantickernel.connectors.sqlitevec/1.73.0-preview/lib/net10.0/Microsoft.SemanticKernel.Connectors.SqliteVec.dll + microsoft.semantickernel.plugins.web/1.73.0-alpha/lib/net10.0/Microsoft.SemanticKernel.Plugins.Web.dll + modelcontextprotocol.core/1.1.0/lib/net10.0/ModelContextProtocol.Core.dll + modelcontextprotocol/1.1.0/lib/net10.0/ModelContextProtocol.dll + newtonsoft.json/13.0.3/lib/net6.0/Newtonsoft.Json.dll + openai/2.10.0/lib/net10.0/OpenAI.dll + opentelemetry.api/1.15.3/lib/net10.0/OpenTelemetry.Api.dll + pdfpig/0.1.13/lib/net8.0/ + UglyToad.PdfPig.Core.dll + UglyToad.PdfPig.dll + UglyToad.PdfPig.DocumentLayoutAnalysis.dll + UglyToad.PdfPig.Fonts.dll + UglyToad.PdfPig.Package.dll + UglyToad.PdfPig.Tokenization.dll + UglyToad.PdfPig.Tokens.dll + polly.core/8.4.2/lib/net8.0/Polly.Core.dll + polly.extensions/8.4.2/lib/net8.0/Polly.Extensions.dll + polly.ratelimiting/8.4.2/lib/net8.0/Polly.RateLimiting.dll + serilog.extensions.hosting/10.0.0/lib/net10.0/Serilog.Extensions.Hosting.dll + serilog.extensions.logging/10.0.0/lib/net10.0/Serilog.Extensions.Logging.dll + serilog.sinks.console/6.1.1/lib/net8.0/Serilog.Sinks.Console.dll + serilog.sinks.file/7.0.0/lib/net9.0/Serilog.Sinks.File.dll + serilog/4.3.0/lib/net9.0/Serilog.dll + shimskiasharp/3.6.0/lib/net10.0/ShimSkiaSharp.dll + skender.stock.indicators/2.7.1/lib/net10.0/Skender.Stock.Indicators.dll + skiasharp/3.119.3-preview.1.1/ref/net8.0/SkiaSharp.dll + smartreader/0.11.0/lib/netstandard2.1/SmartReader.dll + sqlitepclraw.bundle_e_sqlite3/2.1.11/lib/netstandard2.0/SQLitePCLRaw.batteries_v2.dll + sqlitepclraw.core/2.1.11/lib/netstandard2.0/SQLitePCLRaw.core.dll + sqlitepclraw.provider.e_sqlite3/2.1.11/lib/net6.0/SQLitePCLRaw.provider.e_sqlite3.dll + svg.controls.skia.avalonia/11.3.9.4/lib/net10.0/Svg.Controls.Skia.Avalonia.dll + svg.custom/3.6.0/lib/net10.0/Svg.Custom.dll + svg.model/3.6.0/lib/net10.0/Svg.Model.dll + svg.skia/3.6.0/lib/net10.0/Svg.Skia.dll + system.clientmodel/1.10.0/lib/net10.0/System.ClientModel.dll + system.io.packaging/10.0.2/lib/net10.0/System.IO.Packaging.dll + system.memory.data/10.0.3/lib/net10.0/System.Memory.Data.dll + system.numerics.tensors/10.0.6/lib/net10.0/System.Numerics.Tensors.dll + system.threading.ratelimiting/8.0.0/lib/net8.0/System.Threading.RateLimiting.dll + tmds.dbus.protocol/0.90.3/lib/net9.0/Tmds.DBus.Protocol.dll + yamldotnet/16.3.0/lib/net8.0/YamlDotNet.dll + +[analyzerReferences] +/packs/Microsoft.NETCore.App.Ref/10.0.5/analyzers/dotnet/cs/ + Microsoft.Interop.ComInterfaceGenerator.dll + Microsoft.Interop.JavaScript.JSImportGenerator.dll + Microsoft.Interop.LibraryImportGenerator.dll + Microsoft.Interop.SourceGeneration.dll + System.Text.Json.SourceGeneration.dll + System.Text.RegularExpressions.Generator.dll +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/ + Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll + Microsoft.CodeAnalysis.NetAnalyzers.dll +D:/NuGet/Packages/avalonia/12.0.0-preview2/analyzers/dotnet/cs/ + Avalonia.Analyzers.CodeFixes.CSharp.dll + Avalonia.Analyzers.CSharp.dll + Avalonia.Analyzers.VisualBasic.dll + Avalonia.Generators.dll +D:/NuGet/Packages/communitytoolkit.mvvm/8.4.1/analyzers/dotnet/roslyn5.0/cs/ + CommunityToolkit.Mvvm.CodeFixers.dll + CommunityToolkit.Mvvm.SourceGenerators.dll +D:/NuGet/Packages/ + microsoft.agents.ai.workflows.generators/1.6.1/analyzers/dotnet/cs/Microsoft.Agents.AI.Workflows.Generators.dll + microsoft.extensions.options/10.0.5/analyzers/dotnet/roslyn4.4/cs/Microsoft.Extensions.Options.SourceGeneration.dll + microsoft.extensions.telemetry.abstractions/10.4.0/analyzers/dotnet/cs/ + Microsoft.Gen.Logging.dll + Microsoft.Gen.Metrics.dll + modelcontextprotocol.core/1.1.0/analyzers/dotnet/cs/ModelContextProtocol.Analyzers.dll + system.clientmodel/1.10.0/analyzers/dotnet/cs/System.ClientModel.SourceGeneration.dll + +[analyzerConfigFiles] +../../.editorconfig +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_10_default.globalconfig +obj/Debug/net10.0/MarketAssistant.App.GeneratedMSBuildEditorConfig.editorconfig + +[additionalFiles] +App.axaml +Resources/Styles/ + ButtonStyles.axaml + CardStyles.axaml + Colors.axaml + Geometries.axaml + ListStyles.axaml + SkeletonStyles.axaml + Spacing.axaml + TextStyles.axaml +Views/Components/ + AdaptiveCardView.axaml + AnalysisReportView.axaml + ChatSidebarView.axaml + ProgressDisplayView.axaml +Views/Controls/ + CardView.axaml + LoadingDots.axaml +Views/ + Demo/ChatSidebarDemoView.axaml + Pages/ + AboutPageView.axaml + AgentAnalysisPageView.axaml + AssetPageView.axaml + AssetSelectionPageView.axaml + FavoritesPageView.axaml + HomePageView.axaml + MCPConfigPageView.axaml + SettingsPageView.axaml + Trading/ + StrategyConfigView.axaml + TradeHistoryView.axaml + TradeMonitorView.axaml + TradingPageView.axaml + Windows/ + MainWindow.axaml + NotificationWindow.axaml diff --git a/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs b/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs index 202a654..3e9b787 100644 --- a/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs @@ -73,6 +73,7 @@ public partial class SettingsPageViewModel : ViewModelBase public string ModelApiUrl { get; } = "https://cloud.siliconflow.cn/i/z4lbHdBE"; public string ZhiTuApiUrl { get; } = "https://www.zhituapi.com/gettoken.html"; public string CoinGeckoApiUrl { get; } = "https://www.coingecko.com/en/api"; + public string JinaApiUrl { get; } = "https://jina.ai/api-dashboard/key-manager"; /// /// 主题:跟随系统 @@ -333,6 +334,9 @@ private void LoadAnalystRoles() [RelayCommand] private Task OpenCoinGeckoApiWebsite() => OpenUrlAsync(CoinGeckoApiUrl); + [RelayCommand] + private Task OpenEmbeddingApiWebsite() => OpenUrlAsync(JinaApiUrl); + /// /// 选择知识库目录 /// diff --git a/src/MarketAssistant.App/Views/Pages/SettingsPageView.axaml b/src/MarketAssistant.App/Views/Pages/SettingsPageView.axaml index 59c2e68..7dff5c1 100644 --- a/src/MarketAssistant.App/Views/Pages/SettingsPageView.axaml +++ b/src/MarketAssistant.App/Views/Pages/SettingsPageView.axaml @@ -73,6 +73,48 @@ + + + + + + + + + + + + + + + Task GetMAAsync(string assetSymbol); + /// + /// 获取 K 线历史序列(OHLCV),用于判断趋势方向及多周期一致性 + /// + Task> GetKLinesAsync(string assetSymbol, string interval = "daily", int count = 30); + /// /// 获取AI工具函数列表 /// diff --git a/src/MarketAssistant.Agents/Tools/Models/Technical/OhlcvBar.cs b/src/MarketAssistant.Agents/Tools/Models/Technical/OhlcvBar.cs new file mode 100644 index 0000000..1367dc9 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/Technical/OhlcvBar.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace MarketAssistant.Agents.Tools.Models.Technical; + +/// +/// 单根 K 线 OHLCV 数据,用于 AI 工具返回 K 线时间序列 +/// +public sealed class OhlcvBar +{ + [JsonPropertyName("t")] + public string T { get; set; } = ""; + + [JsonPropertyName("o")] + public decimal O { get; set; } + + [JsonPropertyName("h")] + public decimal H { get; set; } + + [JsonPropertyName("l")] + public decimal L { get; set; } + + [JsonPropertyName("c")] + public decimal C { get; set; } + + [JsonPropertyName("v")] + public decimal V { get; set; } +} diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/AnalystTypeRegistry.cs b/src/MarketAssistant.App.Services/Agents/Analysts/AnalystTypeRegistry.cs index e147b12..2551709 100644 --- a/src/MarketAssistant.App.Services/Agents/Analysts/AnalystTypeRegistry.cs +++ b/src/MarketAssistant.App.Services/Agents/Analysts/AnalystTypeRegistry.cs @@ -7,7 +7,12 @@ namespace MarketAssistant.Agents.Analysts; /// public static class AnalystTypeRegistry { - public static IReadOnlyList GetConcreteAnalystTypes() + private static readonly Lazy> _cachedTypes = + new(DiscoverConcreteAnalystTypes, LazyThreadSafetyMode.ExecutionAndPublication); + + public static IReadOnlyList GetConcreteAnalystTypes() => _cachedTypes.Value; + + private static IReadOnlyList DiscoverConcreteAnalystTypes() { return AppDomain.CurrentDomain.GetAssemblies() .SelectMany(GetLoadableTypes) diff --git a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs index c40af6e..23bc791 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs @@ -65,7 +65,7 @@ private async ValueTask HandleAsync( IWorkflowContext context, CancellationToken cancellationToken = default) { - _logger.LogWarning("🔍 [DEBUG] CoordinatorExecutor.HandleAsync 被调用,收到 {Count} 条消息", analystMessages?.Count ?? 0); + _logger.LogDebug("CoordinatorExecutor.HandleAsync 被调用,收到 {Count} 条消息", analystMessages?.Count ?? 0); ArgumentNullException.ThrowIfNull(analystMessages); @@ -156,7 +156,7 @@ private async ValueTask HandleAsync( catch (Exception ex) { _logger.LogError(ex, "协调分析师生成报告时发生错误,标的: {AssetSymbol}", - await context.ReadStateAsync(WorkflowStateKeys.AssetSymbol, cancellationToken) ?? "未知"); + await context.ReadStateAsync(WorkflowStateKeys.AssetSymbol, WorkflowStateKeys.Scope, cancellationToken) ?? "未知"); throw; } } diff --git a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs index 439f825..7014db5 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs @@ -4,6 +4,7 @@ using MarketAssistant.Agents.MarketAnalysis.Models; using MarketAssistant.Infrastructure.Factories; using MarketAssistant.Services.Settings; +using MarketAssistant.Trading; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; @@ -24,6 +25,7 @@ public class MarketAnalysisWorkflow : IDisposable private readonly IAnalystAgentFactory _analystAgentFactory; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; + private readonly AnalysisReportCache _reportCache; private bool _disposed = false; @@ -32,11 +34,6 @@ public class MarketAnalysisWorkflow : IDisposable /// private CheckpointInfo? _lastCheckpoint; - /// - /// 用于在分析师之间共享市场快照数据 - /// - private readonly MarketSnapshotContextProvider _marketSnapshot = new(); - /// /// 分析进度事件 /// @@ -48,6 +45,7 @@ public MarketAnalysisWorkflow( IUserSettingService userSettingService, IAnalystAgentFactory analystAgentFactory, ILoggerFactory loggerFactory, + AnalysisReportCache reportCache, ILogger logger) { _aggregatorExecutor = aggregatorExecutor ?? throw new ArgumentNullException(nameof(aggregatorExecutor)); @@ -55,6 +53,7 @@ public MarketAnalysisWorkflow( _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); _analystAgentFactory = analystAgentFactory ?? throw new ArgumentNullException(nameof(analystAgentFactory)); _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + _reportCache = reportCache ?? throw new ArgumentNullException(nameof(reportCache)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -83,35 +82,38 @@ public async Task AnalyzeAsync( } // 创建分析师代理(记录失败的分析师用于降级提示) - // 传递共享市场快照提供者给所有分析师 - _marketSnapshot.Clear(); - _marketSnapshot.SetData("分析标的", assetSymbol); - _marketSnapshot.SetData("分析时间", DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss UTC")); - - var analystAgents = CreateAnalystAgents(enabledAnalysts); - var failedAnalystNames = enabledAnalysts - .Where(t => !analystAgents.Any(a => a.GetType() == t || a.Name == t.Name.Replace("Agent", ""))) + // 每次分析创建独立的市场快照实例,避免 Singleton 共享可变状态导致并发数据竞争 + var marketSnapshot = new MarketSnapshotContextProvider(); + marketSnapshot.SetData("分析标的", assetSymbol); + marketSnapshot.SetData("分析时间", DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss UTC")); + + var analystAgents = CreateAnalystAgents(enabledAnalysts, marketSnapshot); + var failedAnalystNames = analystAgents.FailedTypes .Select(t => t.GetCustomAttribute()?.DisplayName ?? t.Name) .ToList(); + var createdAgents = analystAgents.Agents; - if (analystAgents.Count == 0) + if (createdAgents.Count == 0) { throw new InvalidOperationException("所有分析师创建失败,无法执行分析"); } // 构建工作流(传入分析师数量) - var workflow = BuildWorkflow(analystAgents.Count, analystAgents); + var workflow = BuildWorkflow(createdAgents.Count, createdAgents); OnProgressChanged(new AnalysisProgressEventArgs { - StageDescription = $"{analystAgents.Count} 位分析师正在并发分析", + StageDescription = $"{createdAgents.Count} 位分析师正在并发分析", IsInProgress = true, - TotalAnalysts = analystAgents.Count, + TotalAnalysts = createdAgents.Count, FailedAnalysts = failedAnalystNames }); // 执行工作流(流式处理) - var finalReport = await ExecuteWorkflowAsync(workflow, assetSymbol, analystAgents.Count, cancellationToken); + var finalReport = await ExecuteWorkflowAsync(workflow, assetSymbol, createdAgents.Count, cancellationToken); + + // 缓存分析结果,供交易决策模块使用 + _reportCache.Set(assetSymbol, finalReport); OnProgressChanged(new AnalysisProgressEventArgs { @@ -299,14 +301,18 @@ private List GetEnabledAnalysts() } /// - /// 创建分析师代理(使用 Factory 模式) + /// 创建分析师代理(使用 Factory 模式),返回成功创建的 Agent 列表及失败的类型列表 /// - private List CreateAnalystAgents(List analystTypes) + private (List Agents, List FailedTypes) CreateAnalystAgents( + List analystTypes, + MarketSnapshotContextProvider marketSnapshot) { _logger.LogInformation("开始创建分析师代理,数量: {Count}", analystTypes.Count); - var sharedProviders = new AIContextProvider[] { _marketSnapshot }; + var sharedProviders = new AIContextProvider[] { marketSnapshot }; var createdAgents = new List(); + var failedTypes = new List(); + foreach (var type in analystTypes) { try @@ -317,11 +323,12 @@ private List CreateAnalystAgents(List analystTypes) catch (Exception ex) { _logger.LogWarning(ex, "跳过创建分析师代理: {AgentType}", type.Name); + failedTypes.Add(type); } } _logger.LogInformation("成功创建分析师代理,实际数量: {Count}", createdAgents.Count); - return createdAgents; + return (createdAgents, failedTypes); } /// diff --git a/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs b/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs index b59b6ed..132733b 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs @@ -37,6 +37,7 @@ public class MarketChatSession : IDisposable private string _sessionId = Guid.NewGuid().ToString("N"); private string _currentStockCode = string.Empty; private string _analysisContext = string.Empty; + private string? _cachedInstructions; private CancellationTokenSource? _currentCancellationTokenSource; private bool _disposed; private bool _searchToolsInitialized; @@ -206,6 +207,7 @@ public void InjectAnalysisContext(string stockCode, IEnumerable ana { _currentStockCode = stockCode; _analysisContext = BuildAnalysisSummary(analysisMessages); + _cachedInstructions = null; _currentSession = null; _conversationHistory.Clear(); @@ -387,6 +389,7 @@ public void ClearHistory() public void SetCurrentStock(string stockCode) { _currentStockCode = stockCode; + _cachedInstructions = null; _logger.LogInformation("设置当前标的: {StockCode}", stockCode); } @@ -402,6 +405,9 @@ public void StopCurrentRequest() private string BuildAgentInstructions() { + if (_cachedInstructions != null) + return _cachedInstructions; + var sb = new StringBuilder(); sb.AppendLine(""); @@ -465,7 +471,8 @@ 3. 区分事实与观点 """); - return sb.ToString(); + _cachedInstructions = sb.ToString(); + return _cachedInstructions; } #endregion diff --git a/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareTechnicalTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareTechnicalTools.cs index 782ca39..86efe17 100644 --- a/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareTechnicalTools.cs +++ b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareTechnicalTools.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using System.ComponentModel; +using System.Text.Json.Serialization; namespace MarketAssistant.Agents.Tools.AShare; @@ -67,11 +68,72 @@ public Task GetBOLLAsync([Description("股票代码")] string ass public Task GetMAAsync([Description("股票代码")] string assetSymbol) => GetIndicatorAsync("ma", assetSymbol); + [Description("获取K线历史序列(OHLCV),interval支持5m/15m/daily/weekly,用于判断趋势方向")] + public async Task> GetKLinesAsync( + [Description("股票代码")] string assetSymbol, + [Description("K线周期:5m/15m/daily/weekly")] string interval = "daily", + [Description("返回根数,最大100")] int count = 30) + { + try + { + var token = _userSettingService.CurrentSetting.ZhiTuApiToken; + var formattedSymbol = StockSymbolConverter.ToZhiTuFormat(assetSymbol); + var zhiTuInterval = interval.ToLowerInvariant() switch + { + "5m" => "5", + "15m" => "15", + "weekly" => "w", + _ => "d" + }; + + var clampedCount = Math.Clamp(count, 1, 100); + var daysBack = zhiTuInterval switch + { + "5" or "15" => 30, + "w" => clampedCount * 7 + 14, + _ => (int)(clampedCount * 1.6) + 10 + }; + + var startDate = DateTime.Now.AddDays(-daysBack).ToString("yyyyMMdd"); + var endDate = DateTime.Now.ToString("yyyyMMdd"); + var url = $"/hs/history/{formattedSymbol}/{zhiTuInterval}/n?token={token}&st={startDate}&et={endDate}"; + + using var httpClient = _httpClientFactory.CreateClient("ZhiTu"); + var response = await httpClient.GetStringAsync(url); + var items = JsonSerializer.Deserialize>(response); + + if (items == null || items.Count == 0) + throw new FriendlyException($"K线数据为空 (代码: {formattedSymbol})"); + + return items + .OrderBy(x => x.T) + .TakeLast(clampedCount) + .Select(x => new OhlcvBar { T = x.T, O = x.O, H = x.H, L = x.L, C = x.C, V = x.V }) + .ToList(); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取K线序列失败: {Symbol}", assetSymbol); + throw new FriendlyException($"获取K线序列失败: {ex.Message} (代码: {assetSymbol})", ex); + } + } + public IEnumerable GetFunctions() { yield return AIFunctionFactory.Create(GetKDJAsync); yield return AIFunctionFactory.Create(GetMACDAsync); yield return AIFunctionFactory.Create(GetBOLLAsync); yield return AIFunctionFactory.Create(GetMAAsync); + yield return AIFunctionFactory.Create(GetKLinesAsync); + } + + private sealed class ZhiTuKLineBar + { + [JsonPropertyName("t")] public string T { get; init; } = ""; + [JsonPropertyName("o")] public decimal O { get; init; } + [JsonPropertyName("h")] public decimal H { get; init; } + [JsonPropertyName("l")] public decimal L { get; init; } + [JsonPropertyName("c")] public decimal C { get; init; } + [JsonPropertyName("v")] public decimal V { get; init; } } } diff --git a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoTechnicalTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoTechnicalTools.cs index 77e5d83..dad0efb 100644 --- a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoTechnicalTools.cs +++ b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoTechnicalTools.cs @@ -187,6 +187,7 @@ public IEnumerable GetFunctions() yield return AIFunctionFactory.Create(GetMACDAsync); yield return AIFunctionFactory.Create(GetBOLLAsync); yield return AIFunctionFactory.Create(GetMAAsync); + yield return AIFunctionFactory.Create(GetKLinesAsync); } private static List ToIndicatorQuotes(IEnumerable klineData) @@ -215,6 +216,51 @@ private static List ToIndicatorQuotes(IEnumerable kli return value.HasValue ? Math.Round((decimal)value.Value, 2) : null; } + [Description("获取K线历史序列(OHLCV),interval支持5m/15m/daily/weekly,用于判断趋势方向及多周期确认")] + public async Task> GetKLinesAsync( + [Description("虚拟币代码(如BTC、ETH)")] string assetSymbol, + [Description("K线周期:5m/15m/daily/weekly")] string interval = "daily", + [Description("返回根数,最大500")] int count = 30) + { + try + { + var klineType = interval.ToLowerInvariant() switch + { + "5m" => KLineType.Minute5, + "15m" => KLineType.Minute15, + "weekly" => KLineType.Weekly, + _ => KLineType.Daily + }; + + var klineService = _serviceProvider.GetRequiredKeyedService(MarketType.Crypto); + var klineData = await klineService.GetKLineDataAsync(assetSymbol, klineType, Math.Clamp(count, 1, 500)); + + if (klineData == null || klineData.Count == 0) + throw new FriendlyException($"K线数据为空: {assetSymbol}"); + + var timeFormat = klineType is KLineType.Minute5 or KLineType.Minute15 + ? "yyyy-MM-dd HH:mm" : "yyyy-MM-dd"; + + return klineData + .OrderBy(k => k.Timestamp) + .Select(k => new OhlcvBar + { + T = k.Timestamp.ToString(timeFormat), + O = k.Open, + H = k.High, + L = k.Low, + C = k.Close, + V = k.Volume + }) + .ToList(); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取K线序列失败: {Symbol} {Interval}", assetSymbol, interval); + throw new FriendlyException($"获取K线序列失败: {ex.Message}", ex); + } + } + private sealed class IndicatorQuote : IQuote { public DateTime Date { get; init; } diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/ChatClientFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/ChatClientFactory.cs index febebaf..d891fcd 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/Factories/ChatClientFactory.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/ChatClientFactory.cs @@ -23,10 +23,16 @@ public interface IChatClientFactory /// public class ChatClientFactory : IChatClientFactory { + /// + /// 瞬态错误冷却时间:冷却期内同一配置不重试,冷却后允许再次尝试 + /// + private static readonly TimeSpan ErrorCooldown = TimeSpan.FromSeconds(30); + private readonly IUserSettingService _userSettingService; private readonly object _lock = new(); private IChatClient? _cachedClient; private string? _lastError; + private DateTime _lastErrorTime; // 缓存用于创建客户端的配置,以便检测变更 private string? _cachedModelId; @@ -57,13 +63,15 @@ public IChatClient CreateClient() return _cachedClient; } - // 配置未变但上次创建失败 → 快速失败,避免重复尝试 - if (configUnchanged && !string.IsNullOrEmpty(_lastError)) + // 配置未变且上次失败仍在冷却期内 → 快速失败,避免频繁重试 + if (configUnchanged + && !string.IsNullOrEmpty(_lastError) + && DateTime.UtcNow - _lastErrorTime < ErrorCooldown) { throw new FriendlyException(_lastError); } - // 配置已变更,重置错误状态 + // 配置已变更或冷却期已过,重置错误状态 _lastError = null; _cachedClient = null; @@ -94,6 +102,7 @@ public IChatClient CreateClient() catch (Exception ex) { _lastError = ex.Message; + _lastErrorTime = DateTime.UtcNow; _cachedClient = null; _cachedModelId = modelId; _cachedEndpoint = endpoint; diff --git a/src/MarketAssistant.App.Services/Services/Cache/AnalysisCacheService.cs b/src/MarketAssistant.App.Services/Services/Cache/AnalysisCacheService.cs index e48d230..1861806 100644 --- a/src/MarketAssistant.App.Services/Services/Cache/AnalysisCacheService.cs +++ b/src/MarketAssistant.App.Services/Services/Cache/AnalysisCacheService.cs @@ -1,4 +1,6 @@ using MarketAssistant.Agents.MarketAnalysis.Models; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Market; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; @@ -12,12 +14,17 @@ public class AnalysisCacheService : IAnalysisCacheService { private readonly ILogger _logger; private readonly IMemoryCache _memoryCache; + private readonly MarketContext _marketContext; private readonly TimeSpan _cacheExpiration = TimeSpan.FromHours(2); - public AnalysisCacheService(ILogger logger, IMemoryCache memoryCache) + public AnalysisCacheService( + ILogger logger, + IMemoryCache memoryCache, + MarketContext marketContext) { _logger = logger; _memoryCache = memoryCache; + _marketContext = marketContext; } /// @@ -90,11 +97,11 @@ public Task ClearCacheAsync(string assetSymbol) } /// - /// 生成缓存键 + /// 生成缓存键(含市场类型,避免跨市场碰撞) /// private string GenerateCacheKey(string assetSymbol) { - return $"MarketAnalysisReport_{assetSymbol}"; + return $"MarketAnalysisReport_{_marketContext.CurrentMarket}_{assetSymbol}"; } public void Dispose() diff --git a/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs index e5472c5..609ccbe 100644 --- a/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs +++ b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs @@ -168,7 +168,7 @@ private static IServiceCollection AddAgentInfrastructure(this IServiceCollection services.AddSingleton(); services.AddSingleton(sp => new ConversationCompressionMiddleware( - sp.GetRequiredService().CreateClient(), + () => sp.GetRequiredService().CreateClient(), sp.GetRequiredService>())); services.AddSingleton(sp => @@ -189,7 +189,10 @@ private static IServiceCollection AddAgentInfrastructure(this IServiceCollection services.AddSingleton(); services.AddSingleton(); - var store = Directory.GetCurrentDirectory() + "/vector.sqlite"; + var store = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + AppInfo.AppName, + "vector.sqlite"); services.AddSqliteVectorStore(_ => $"Data Source={store}"); return services; @@ -220,6 +223,7 @@ private static IServiceCollection AddMarketDataServices(this IServiceCollection private static IServiceCollection AddTradingServices(this IServiceCollection services) { + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -257,7 +261,6 @@ private static IServiceCollection AddWorkflowServices(this IServiceCollection se services.AddSingleton(); // 市场分析工作流 - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/MarketAssistant.App.Services/Services/Settings/UserSettingService.cs b/src/MarketAssistant.App.Services/Services/Settings/UserSettingService.cs index 68e1194..0195ab2 100644 --- a/src/MarketAssistant.App.Services/Services/Settings/UserSettingService.cs +++ b/src/MarketAssistant.App.Services/Services/Settings/UserSettingService.cs @@ -1,4 +1,6 @@ using MarketAssistant.Applications.Settings; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using MarketAssistant.Services.Browser; namespace MarketAssistant.Services.Settings; @@ -10,6 +12,7 @@ public class UserSettingService : IUserSettingService { private const string PreferenceKey = "UserSettings"; private readonly IBrowserService? _browserService; + private readonly ILogger _logger; private UserSetting _currentSetting = new(); @@ -18,9 +21,9 @@ public class UserSettingService : IUserSettingService /// public UserSetting CurrentSetting => _currentSetting; - public UserSettingService(IBrowserService? browserService = null) + public UserSettingService(ILogger? logger = null, IBrowserService? browserService = null) { - // 从存储中加载设置 + _logger = logger ?? NullLogger.Instance; _browserService = browserService; LoadSettings(); } @@ -61,8 +64,7 @@ public void LoadSettings() } catch (Exception ex) { - // 处理异常 - System.Diagnostics.Debug.WriteLine($"加载设置时出错: {ex.Message}"); + _logger.LogError(ex, "加载设置时出错"); Preferences.Default.Remove(PreferenceKey); // 如果加载失败,使用默认值 @@ -85,8 +87,7 @@ public void SaveSettings() } catch (Exception ex) { - // 处理异常 - System.Diagnostics.Debug.WriteLine($"保存设置时出错: {ex.Message}"); + _logger.LogError(ex, "保存设置时出错"); } } diff --git a/src/MarketAssistant.App.Services/Services/UserKnowledgeGraphService.cs b/src/MarketAssistant.App.Services/Services/UserKnowledgeGraphService.cs index f165879..52550af 100644 --- a/src/MarketAssistant.App.Services/Services/UserKnowledgeGraphService.cs +++ b/src/MarketAssistant.App.Services/Services/UserKnowledgeGraphService.cs @@ -9,7 +9,7 @@ namespace MarketAssistant.Services; /// 存储实体-关系三元组(subject, predicate, object),每个三元组有有效时间窗口。 /// 参考 MemPalace 的时序知识图谱设计,适配金融投资领域。 /// -public class UserKnowledgeGraphService : IDisposable +public class UserKnowledgeGraphService { private readonly string _connectionString; private readonly ILogger _logger; @@ -182,8 +182,6 @@ created_at TEXT NOT NULL _logger.LogError(ex, "初始化知识图谱数据库失败"); } } - - public void Dispose() => GC.SuppressFinalize(this); } /// diff --git a/src/MarketAssistant.App.Services/Services/UserMemoryService.cs b/src/MarketAssistant.App.Services/Services/UserMemoryService.cs index cf990a5..3c1b072 100644 --- a/src/MarketAssistant.App.Services/Services/UserMemoryService.cs +++ b/src/MarketAssistant.App.Services/Services/UserMemoryService.cs @@ -9,7 +9,7 @@ namespace MarketAssistant.Services; /// 存储用户的投资偏好、历史分析结论、自定义标签等,供 AI 上下文使用。 /// 采用有界设计:条目数上限 ,总字符上限 。 /// -public class UserMemoryService : IDisposable +public class UserMemoryService { public const int MaxEntryCount = 50; public const int MaxTotalChars = 5000; @@ -226,8 +226,6 @@ PRIMARY KEY (category, key) _logger.LogError(ex, "初始化用户记忆数据库失败"); } } - - public void Dispose() => GC.SuppressFinalize(this); } /// diff --git a/src/MarketAssistant.App.Services/Trading/AnalysisReportCache.cs b/src/MarketAssistant.App.Services/Trading/AnalysisReportCache.cs new file mode 100644 index 0000000..f557256 --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/AnalysisReportCache.cs @@ -0,0 +1,22 @@ +using System.Collections.Concurrent; +using MarketAssistant.Agents.MarketAnalysis.Models; + +namespace MarketAssistant.Trading; + +/// +/// 分析报告内存缓存:线程安全地存储最近一次市场分析结果, +/// 供交易模块在 AI 信号策略决策时读取,打通分析-交易链路。 +/// +public sealed class AnalysisReportCache +{ + private readonly ConcurrentDictionary _reports = + new(StringComparer.OrdinalIgnoreCase); + + public void Set(string symbol, MarketAnalysisReport report) => + _reports[symbol] = new CachedReport(report, DateTime.UtcNow); + + public CachedReport? Get(string symbol) => + _reports.TryGetValue(symbol, out var cached) ? cached : null; + + public sealed record CachedReport(MarketAnalysisReport Report, DateTime CachedAt); +} diff --git a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs index cccaa92..6bfb383 100644 --- a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs +++ b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Text; using System.Threading.Channels; using MarketAssistant.Agents.Trading; using MarketAssistant.Infrastructure.Factories; @@ -21,6 +22,8 @@ public class MarketMonitor : IDisposable private readonly TradeExecutor _tradeExecutor; private readonly ITradingAgentFactory _agentFactory; private readonly TradingDataService _dataService; + private readonly CryptoPortfolioService _portfolioService; + private readonly AnalysisReportCache _reportCache; private readonly ILogger _logger; private readonly SemaphoreSlim _lifecycleLock = new(1, 1); @@ -56,6 +59,8 @@ public MarketMonitor( TradeExecutor tradeExecutor, ITradingAgentFactory agentFactory, TradingDataService dataService, + CryptoPortfolioService portfolioService, + AnalysisReportCache reportCache, ILogger logger) { _webSocketService = webSocketService; @@ -63,6 +68,8 @@ public MarketMonitor( _tradeExecutor = tradeExecutor; _agentFactory = agentFactory; _dataService = dataService; + _portfolioService = portfolioService; + _reportCache = reportCache; _logger = logger; } @@ -219,12 +226,26 @@ private async Task HandleTriggeredStrategyAsync(TradingStrategy strategy, decima { if (strategy.Type == StrategyType.AISignal) { + // 硬性止损/止盈边界检查:存在持仓时无需 AI 决策,直接强制平仓 + if (TryHandleHardBoundary(strategy, currentPrice, out var boundaryReasoning)) + { + strategy.Side = strategy.Side == OrderSide.Buy ? OrderSide.Sell : OrderSide.Buy; + var boundaryResult = await _tradeExecutor.ExecuteTradeAsync( + strategy, currentPrice, boundaryReasoning, ct: _cts?.Token ?? default); + if (boundaryResult.Success && boundaryResult.Record != null) + TradeExecuted?.Invoke(boundaryResult.Record); + await CheckStrategyCompletionAsync(strategy); + return; + } + await HandleAISignalAsync(strategy, currentPrice); } else { + // 网格交易:交易成功后原子地持久化更新后的网格参数,防止计数和参数不一致 + var pendingCustomParams = strategy.Type == StrategyType.GridTrading ? strategy.CustomParams : null; var result = await _tradeExecutor.ExecuteTradeAsync( - strategy, currentPrice, ct: _cts?.Token ?? default); + strategy, currentPrice, pendingCustomParams: pendingCustomParams, ct: _cts?.Token ?? default); if (result.Success && result.Record != null) TradeExecuted?.Invoke(result.Record); @@ -233,6 +254,41 @@ private async Task HandleTriggeredStrategyAsync(TradingStrategy strategy, decima } } + private static bool TryHandleHardBoundary(TradingStrategy strategy, decimal currentPrice, out string reasoning) + { + reasoning = string.Empty; + + // 仅在已有成交(存在持仓)时执行硬性边界保护 + if (strategy.ExecutionCount == 0) + return false; + + if (strategy.StopLossPrice.HasValue) + { + bool stopTriggered = strategy.Side == OrderSide.Buy + ? currentPrice <= strategy.StopLossPrice.Value + : currentPrice >= strategy.StopLossPrice.Value; + if (stopTriggered) + { + reasoning = $"AISignal 硬性止损触发:当前价 {currentPrice} 已达止损位 {strategy.StopLossPrice.Value},系统强制平仓"; + return true; + } + } + + if (strategy.TakeProfitPrice.HasValue) + { + bool tpTriggered = strategy.Side == OrderSide.Buy + ? currentPrice >= strategy.TakeProfitPrice.Value + : currentPrice <= strategy.TakeProfitPrice.Value; + if (tpTriggered) + { + reasoning = $"AISignal 硬性止盈触发:当前价 {currentPrice} 已达止盈位 {strategy.TakeProfitPrice.Value},系统自动止盈"; + return true; + } + } + + return false; + } + private async Task HandleAISignalAsync(TradingStrategy strategy, decimal currentPrice) { try @@ -246,10 +302,27 @@ private async Task HandleAISignalAsync(TradingStrategy strategy, decimal current : string.Join("\n", priorRecords.Take(5).Select(r => $"{r.CreatedAt:u} {r.Side} 成交量:{r.ExecutedQty} 价:{r.ExecutedPrice} {r.Status}")); + var positionSummary = await BuildPositionSummaryAsync(strategy.Symbol); + var analysisContext = BuildAnalysisContext(strategy.Symbol); + var agent = _agentFactory.CreateAgent(); + var stopLossInfo = strategy.StopLossPrice.HasValue + ? $"止损价: {strategy.StopLossPrice.Value}" + : "未设置止损"; + var takeProfitInfo = strategy.TakeProfitPrice.HasValue + ? $"止盈价: {strategy.TakeProfitPrice.Value}" + : "未设置止盈"; var prompt = $""" 分析交易标的 {strategy.Symbol},当前价格 {currentPrice}。 策略配置: {strategy.CustomParams ?? "无"} + 风险边界: {stopLossInfo} | {takeProfitInfo} + + ## 当前仓位状态 + {positionSummary} + + ## 最新市场分析报告 + {analysisContext} + 近期该策略成交摘要(最多 5 笔,按时间倒序): {recentSummary} 请评估是否应该执行 {strategy.Side} 操作,数量 {strategy.Quantity}。 @@ -287,6 +360,62 @@ private async Task HandleAISignalAsync(TradingStrategy strategy, decimal current } } + private async Task BuildPositionSummaryAsync(string symbol) + { + try + { + var positions = await _portfolioService.GetCurrentPositionsAsync(_cts?.Token ?? default); + var symbolPosition = positions.FirstOrDefault(p => + p.Symbol.Equals(symbol, StringComparison.OrdinalIgnoreCase)); + var positionLine = symbolPosition != null + ? $"持仓: 数量 {symbolPosition.Quantity} | 入场均价 {symbolPosition.EntryPrice} | 未实现盈亏 {symbolPosition.UnrealizedPnl:F2} USDT ({symbolPosition.UnrealizedPnlPercent:F1}%)" + : "当前无持仓"; + + var activeStrategies = await _dataService.GetStrategiesByStatusAsync(StrategyStatus.Active, _cts?.Token ?? default); + var siblings = activeStrategies + .Where(s => s.Symbol.Equals(symbol, StringComparison.OrdinalIgnoreCase)) + .Select(s => $"{s.Type}(触发价:{s.TriggerPrice})") + .ToList(); + var siblingsLine = siblings.Count > 0 ? string.Join(", ", siblings) : "无"; + + return $"{positionLine}\n同标的活跃策略: {siblingsLine}"; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "获取持仓信息失败,略过仓位上下文: {Symbol}", symbol); + return "(获取持仓信息失败)"; + } + } + + private string BuildAnalysisContext(string symbol) + { + var cached = _reportCache.Get(symbol); + if (cached == null) + return "(暂无分析报告,建议先运行市场分析工作流)"; + + var ageMinutes = (int)(DateTime.UtcNow - cached.CachedAt).TotalMinutes; + var result = cached.Report.CoordinatorResult; + + var sb = new StringBuilder(); + sb.AppendLine($"报告时间: {ageMinutes} 分钟前"); + sb.AppendLine($"综合评级: {result.InvestmentRating} | 综合评分: {result.OverallScore:F1}/10 | 置信度: {result.ConfidencePercentage:F0}%"); + sb.AppendLine($"目标价区间: {result.TargetPrice} | 预期: {result.PriceChangeExpectation}"); + sb.AppendLine($"技术面: {result.DimensionScores.Technical:F1} | 情绪面: {result.DimensionScores.Sentiment:F1} | 风险等级: {result.RiskLevel}"); + sb.AppendLine($"结论: {result.Summary}"); + + if (result.OperationSuggestions.Count > 0) + { + sb.AppendLine("操作建议:"); + foreach (var suggestion in result.OperationSuggestions.Take(3)) + sb.AppendLine($" - {suggestion}"); + } + + if (result.RiskFactors.Count > 0) + sb.AppendLine($"主要风险: {string.Join("; ", result.RiskFactors.Take(2))}"); + + return sb.ToString().TrimEnd(); + } + private async Task CheckStrategyCompletionAsync(TradingStrategy strategy) { if (!strategy.MaxExecutions.HasValue) diff --git a/src/MarketAssistant.App.Services/Trading/RiskManager.cs b/src/MarketAssistant.App.Services/Trading/RiskManager.cs index 75ba365..f6f867a 100644 --- a/src/MarketAssistant.App.Services/Trading/RiskManager.cs +++ b/src/MarketAssistant.App.Services/Trading/RiskManager.cs @@ -28,10 +28,19 @@ public RiskManager( /// public async Task ValidateOrderAsync( string instrumentSymbol, OrderSide side, decimal quantity, decimal price, + OrderType orderType = OrderType.Market, CancellationToken ct = default) { + const decimal MarketOrderSlippageBuffer = 0.015m; // 市价单预留 1.5% 滑点缓冲 + var config = _dataService.LoadRiskConfig(); - var orderValueUSDT = quantity * price; + + // 市价单使用保守的滑点缓冲价格进行风控计算,防止实际成交额超限 + var effectivePrice = orderType == OrderType.Market + ? price * (1 + MarketOrderSlippageBuffer) + : price; + + var orderValueUSDT = quantity * effectivePrice; if (orderValueUSDT < config.MinOrderAmount) return RiskCheckResult.Reject($"订单金额 {orderValueUSDT:F2} USDT 低于最小限额 {config.MinOrderAmount} USDT"); diff --git a/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs b/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs index 9df9606..058d9b4 100644 --- a/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs +++ b/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs @@ -52,6 +52,7 @@ public async Task> EvaluateStrategiesAsync( _logger.LogInformation( "策略触发: {StrategyId} {Type} {Symbol} 触发价:{TriggerPrice} 当前价:{CurrentPrice}", strategy.Id, strategy.Type, symbol, strategy.TriggerPrice, currentPrice); + triggered.Add(strategy); } } @@ -145,33 +146,36 @@ private bool EvaluateTrailingStop(TradingStrategy strategy, decimal currentPrice } } + // 未配置时的安全默认值,防止每个价格 tick 都触发 AI 调用 + private const int DefaultAISignalIntervalSeconds = 60; + private bool EvaluateAISignal(TradingStrategy strategy) { - if (string.IsNullOrEmpty(strategy.CustomParams)) - return true; // No interval configured, always eligible + var intervalSeconds = DefaultAISignalIntervalSeconds; - try + if (!string.IsNullOrEmpty(strategy.CustomParams)) { - using var doc = JsonDocument.Parse(strategy.CustomParams); - var root = doc.RootElement; - - if (root.TryGetProperty("analysisInterval", out var intervalEl)) + try { - var intervalSeconds = intervalEl.GetInt32(); - if (strategy.LastTriggeredAt.HasValue) - { - var elapsed = (DateTime.UtcNow - strategy.LastTriggeredAt.Value).TotalSeconds; - return elapsed >= intervalSeconds; - } + using var doc = JsonDocument.Parse(strategy.CustomParams); + var root = doc.RootElement; + if (root.TryGetProperty("analysisInterval", out var intervalEl)) + intervalSeconds = intervalEl.GetInt32(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "解析 AISignal 参数失败,使用默认间隔 {DefaultInterval}s: {StrategyId}", + DefaultAISignalIntervalSeconds, strategy.Id); } - - return true; } - catch (Exception ex) + + if (strategy.LastTriggeredAt.HasValue) { - _logger.LogWarning(ex, "解析 AISignal 参数失败: {StrategyId}", strategy.Id); - return true; + var elapsed = (DateTime.UtcNow - strategy.LastTriggeredAt.Value).TotalSeconds; + return elapsed >= intervalSeconds; } + + return true; } /// diff --git a/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs b/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs index 79b4546..ca38af4 100644 --- a/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs +++ b/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs @@ -44,6 +44,7 @@ public TradeExecutor( /// public async Task ExecuteTradeAsync( TradingStrategy strategy, decimal currentPrice, string? aiReasoning = null, + string? pendingCustomParams = null, CancellationToken ct = default) { _logger.LogInformation("开始执行交易: {StrategyId} {Symbol} {Side} 数量:{Qty}", @@ -55,13 +56,19 @@ public async Task ExecuteTradeAsync( aiReasoning: aiReasoning, ct: ct); if (result.Success) - await _dataService.UpdateStrategyTriggeredAsync(strategy.Id, ct); + { + if (pendingCustomParams != null) + await _dataService.UpdateStrategyTriggeredWithParamsAsync(strategy.Id, pendingCustomParams, ct); + else + await _dataService.UpdateStrategyTriggeredAsync(strategy.Id, ct); + } return result; } /// - /// 通用下单方法,所有交易路径(策略触发、AI Agent、手动)的统一入口 + /// 通用下单方法,所有交易路径(策略触发、AI Agent、手动)的统一入口。 + /// 风控检查和人工确认在 symbol 锁之外执行,避免等待用户输入时锁死后续交易。 /// public async Task ExecuteOrderAsync( string instrumentSymbol, OrderSide side, OrderType type, decimal quantity, @@ -69,28 +76,9 @@ public async Task ExecuteOrderAsync( string strategyId = "manual", string? aiReasoning = null, CancellationToken ct = default) { - var gate = _symbolExecutionLocks.GetOrAdd( - instrumentSymbol.Trim(), static _ => new SemaphoreSlim(1, 1)); - await gate.WaitAsync(ct).ConfigureAwait(false); - try - { - return await ExecuteOrderCoreAsync( - instrumentSymbol, side, type, quantity, currentPrice, limitPrice, - strategyId, aiReasoning, ct).ConfigureAwait(false); - } - finally - { - gate.Release(); - } - } - - private async Task ExecuteOrderCoreAsync( - string instrumentSymbol, OrderSide side, OrderType type, decimal quantity, - decimal currentPrice, decimal? limitPrice, - string strategyId, string? aiReasoning, - CancellationToken ct) - { - var riskCheck = await _riskManager.ValidateOrderAsync(instrumentSymbol, side, quantity, currentPrice, ct); + // 风控校验和人工确认在 symbol 锁外完成,防止 ConfirmationCallback 等待期间 + // 持有 SemaphoreSlim,导致同一标的后续所有交易永久阻塞。 + var riskCheck = await _riskManager.ValidateOrderAsync(instrumentSymbol, side, quantity, currentPrice, type, ct); if (riskCheck.NeedsConfirmation) { @@ -102,9 +90,8 @@ private async Task ExecuteOrderCoreAsync( var approved = await ConfirmationCallback( instrumentSymbol, side, quantity, currentPrice, riskCheck.Reason ?? "需人工确认"); if (!approved) - { return new TradeResult { Success = false, ErrorMessage = $"用户拒绝交易: {riskCheck.Reason}" }; - } + _logger.LogInformation("用户已确认交易: {InstrumentSymbol} {Side}", instrumentSymbol, side); } else @@ -112,13 +99,34 @@ private async Task ExecuteOrderCoreAsync( return new TradeResult { Success = false, ErrorMessage = $"需人工确认: {riskCheck.Reason}" }; } } - - if (!riskCheck.Passed) + else if (!riskCheck.Passed) { _logger.LogWarning("风控拒绝: {Reason}", riskCheck.Reason); return new TradeResult { Success = false, ErrorMessage = $"风控拒绝: {riskCheck.Reason}" }; } + // 仅在实际调用交易所 API 时持有 symbol 锁,防止同一标的并发重复下单 + var gate = _symbolExecutionLocks.GetOrAdd( + instrumentSymbol.Trim(), static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(ct).ConfigureAwait(false); + try + { + return await ExecuteApprovedOrderAsync( + instrumentSymbol, side, type, quantity, currentPrice, limitPrice, + strategyId, aiReasoning, ct).ConfigureAwait(false); + } + finally + { + gate.Release(); + } + } + + private async Task ExecuteApprovedOrderAsync( + string instrumentSymbol, OrderSide side, OrderType type, decimal quantity, + decimal currentPrice, decimal? limitPrice, + string strategyId, string? aiReasoning, + CancellationToken ct) + { try { var response = await _exchangeClient.PlaceOrderAsync( @@ -143,23 +151,14 @@ private async Task ExecuteOrderCoreAsync( await _dataService.SaveTradeRecordAsync(record, ct); decimal pnl = 0; - if (record.ExecutedQty > 0) + if (record.ExecutedQty > 0 && side == OrderSide.Sell) { - if (side == OrderSide.Sell) - { - // 多头平仓:卖出价 - 平均买入价 - var avgEntryPrice = await _dataService.GetAverageEntryPriceAsync(instrumentSymbol, ct); - if (avgEntryPrice > 0) - pnl = (record.ExecutedPrice - avgEntryPrice) * record.ExecutedQty; - } - else - { - // 空头平仓:平均卖出价 - 买入价 - var avgSellPrice = await _dataService.GetAverageSellPriceAsync(instrumentSymbol, ct); - if (avgSellPrice > 0) - pnl = (avgSellPrice - record.ExecutedPrice) * record.ExecutedQty; - } + // 现货多头平仓:卖出价 - 加权平均买入价 + var avgEntryPrice = await _dataService.GetAverageEntryPriceAsync(instrumentSymbol, ct); + if (avgEntryPrice > 0) + pnl = (record.ExecutedPrice - avgEntryPrice) * record.ExecutedQty; } + // 现货买入为开多仓,无已实现盈亏 await _dataService.UpdateDailyStatsAsync(pnl, record.Commission, ct); _logger.LogInformation("交易执行成功: {StrategyId} 订单ID:{OrderId} 状态:{Status} PnL:{Pnl}", diff --git a/src/MarketAssistant.App.Services/Trading/TradingDataService.cs b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs index 484a64d..940acff 100644 --- a/src/MarketAssistant.App.Services/Trading/TradingDataService.cs +++ b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs @@ -132,6 +132,49 @@ UPDATE strategies await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } + /// + /// 原子地更新策略触发计数和自定义参数(用于网格交易,防止计数已更新但交易未执行的状态不一致) + /// + public async Task UpdateStrategyTriggeredWithParamsAsync(string id, string? customParams, CancellationToken ct = default) + { + await _initializeTask; + await using var conn = await OpenConnectionAsync(ct); + await using var tx = await conn.BeginTransactionAsync(ct); + try + { + await using var cmd = conn.CreateCommand(); + cmd.Transaction = (Microsoft.Data.Sqlite.SqliteTransaction)tx; + cmd.CommandText = """ + UPDATE strategies + SET last_triggered_at = @time, + execution_count = execution_count + 1, + custom_params = @customParams + WHERE id = @id + """; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@time", DateTime.UtcNow.ToString("O")); + cmd.Parameters.AddWithValue("@customParams", (object?)customParams ?? DBNull.Value); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + await tx.CommitAsync(ct); + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + } + + public async Task UpdateStrategyCustomParamsAsync(string id, string? customParams, CancellationToken ct = default) + { + await _initializeTask; + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE strategies SET custom_params = @customParams WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@customParams", (object?)customParams ?? DBNull.Value); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + #endregion #region 交易记录 diff --git a/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs b/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs index 64e77ad..aa11579 100644 --- a/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs @@ -36,7 +36,7 @@ public partial class ChatSidebarViewModel : ViewModelBase private CancellationTokenSource? _currentCancellationTokenSource; - public IRelayCommand SendMessageCommand { get; } + public IAsyncRelayCommand SendMessageCommand { get; } public ChatSidebarViewModel( ILogger logger, @@ -47,7 +47,7 @@ public ChatSidebarViewModel( _sessionPersistence = sessionPersistence; _chatSession = chatSessionFactory.Create(); - SendMessageCommand = new RelayCommand(SendMessage, CanSendMessage); + SendMessageCommand = new AsyncRelayCommand(SendMessageAsync, CanSendMessage); _ = LoadSessionHistoryAsync(); } @@ -62,7 +62,7 @@ private bool CanSendMessage() /// /// 发送消息 /// - private async void SendMessage() + private async Task SendMessageAsync() { if (IsProcessing) { diff --git a/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs b/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs index 3e9b787..0d9b463 100644 --- a/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs @@ -253,7 +253,7 @@ public SettingsPageViewModel( _embeddingFactory = embeddingFactory; _vectorStore = vectorStore; _marketContext = marketContext; - _ = InitializeAsync(); + _ = SafeExecuteAsync(InitializeAsync, "初始化设置页"); } /// diff --git a/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs b/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs index 20446e0..d7dc5fd 100644 --- a/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs @@ -23,15 +23,27 @@ partial void OnNewStrategyTypeChanged(StrategyType value) OnPropertyChanged(nameof(IsGridTrading)); OnPropertyChanged(nameof(IsDCA)); OnPropertyChanged(nameof(IsBasicStrategy)); + OnPropertyChanged(nameof(SideHintText)); } [ObservableProperty] private OrderSide _newSide = OrderSide.Buy; + + partial void OnNewSideChanged(OrderSide value) + { + OnPropertyChanged(nameof(SideHintText)); + } + [ObservableProperty] private string _newTriggerPrice = string.Empty; [ObservableProperty] private string _newQuantity = string.Empty; [ObservableProperty] private string _newStopLossPrice = string.Empty; [ObservableProperty] private string _newTakeProfitPrice = string.Empty; [ObservableProperty] private bool _isCreating; + /// + /// 表单校验错误信息。非空时显示在创建按钮旁。 + /// + [ObservableProperty] private string _validationError = string.Empty; + public StrategyType[] StrategyTypes { get; } = Enum.GetValues(); public OrderSide[] OrderSides => Enum.GetValues(); @@ -62,6 +74,15 @@ partial void OnNewStrategyTypeChanged(StrategyType value) /// public bool IsBasicStrategy => !IsGridTrading && !IsDCA; + /// + /// 针对现货交易者的方向提示:买入止损/止盈通常用于空头对冲,现货做多应选卖出。 + /// + public string SideHintText => + NewSide == OrderSide.Buy && + (NewStrategyType == StrategyType.StopLoss || NewStrategyType == StrategyType.TakeProfit) + ? "⚠️ 买入方向的止损/止盈通常用于空头对冲(期货);现货多头持仓请选「卖出」方向" + : string.Empty; + // 风控配置 [ObservableProperty] private RiskConfig _riskConfig = new(); @@ -104,10 +125,15 @@ await SafeExecuteAsync(async () => private async Task CreateStrategyAsync() { if (string.IsNullOrWhiteSpace(NewSymbol)) + { + ValidationError = "请填写交易对(如 BTCUSDT)"; return; + } await SafeExecuteAsync(async () => { + ValidationError = string.Empty; + var strategy = new TradingStrategy { Symbol = NewSymbol.ToUpper().Trim(), @@ -123,7 +149,10 @@ await SafeExecuteAsync(async () => !decimal.TryParse(GridLowerPrice, out var lower) || !int.TryParse(GridCount, out var gridCount) || !decimal.TryParse(GridQuantityPerGrid, out var qtyPerGrid)) + { + ValidationError = "请填写完整的网格交易参数(上界价格、下界价格、网格数量、每格数量)"; return; + } var gridParams = new GridTradingParams { @@ -139,7 +168,10 @@ await SafeExecuteAsync(async () => case StrategyType.DCA: if (!decimal.TryParse(DcaAmountPerInterval, out var amount)) + { + ValidationError = "请填写有效的定投金额(USDT)"; return; + } var dcaParams = new DCAParams { AmountPerInterval = amount }; if (int.TryParse(DcaIntervalSeconds, out var interval)) @@ -151,13 +183,17 @@ await SafeExecuteAsync(async () => strategy.CustomParams = JsonSerializer.Serialize(dcaParams); strategy.TriggerPrice = maxPrice > 0 ? maxPrice : 0; + // DCA 的 Quantity 存储每次定投的 USDT 金额(代币数量在执行时按实时价格换算) strategy.Quantity = amount; break; default: if (!decimal.TryParse(NewTriggerPrice, out var triggerPrice) || !decimal.TryParse(NewQuantity, out var quantity)) + { + ValidationError = "请填写有效的触发价格和交易数量"; return; + } strategy.TriggerPrice = triggerPrice; strategy.Quantity = quantity; @@ -239,6 +275,7 @@ private void ClearForm() DcaAmountPerInterval = string.Empty; DcaMaxBuyPrice = string.Empty; DcaDoubleBuyBelowPrice = string.Empty; + ValidationError = string.Empty; IsCreating = false; } } diff --git a/src/MarketAssistant.App/Views/Pages/Trading/StrategyConfigView.axaml b/src/MarketAssistant.App/Views/Pages/Trading/StrategyConfigView.axaml index d967976..2ddf523 100644 --- a/src/MarketAssistant.App/Views/Pages/Trading/StrategyConfigView.axaml +++ b/src/MarketAssistant.App/Views/Pages/Trading/StrategyConfigView.axaml @@ -1,4 +1,4 @@ - + + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + +
+
上证3,387.52+1.24%
+
深证10,892-0.38%
+
创业2,156+0.82%
+
BTC67,842+2.81%
+
+
+
+ + +
+ Ctrl+M +
+ +
+ + +
+
+ + +
+
+
+
AI 市场概览实时分析
+

今日A股三大指数集体走强,两市成交额突破1.2万亿。央行MLF降息10BP释放宽松信号,新能源、AI算力领涨。短期关注3400点压力位,建议中等仓位,逢低布局科技成长板块。

+
MLF降息北向流入成交放量科技成长
+
+ +
+
热门标的AI 选股 →
+
+
宁德时代
SZ 300750
215.80
+3.24%
新能源
+
贵州茅台
SH 600519
1,685.00
+0.75%
白酒
+
中科曙光
SH 603019
62.38
+7.25%
AI算力
+
比亚迪
SZ 002594
268.50
+5.12%
汽车
+
中芯国际
SH 688981
56.32
-1.08%
芯片
+
阳光电源
SZ 300274
92.40
+0.65%
光伏
+
+
+ +
+
A 股指数
上证指数
3,387.52
▲ +41.35 (+1.24%)
+
A 股指数
深证成指
10,892.41
▼ -41.52 (-0.38%)
+
A 股指数
创业板指
2,156.30
▲ +17.58 (+0.82%)
+
A 股指数
科创50
968.50
▲ +18.35 (+1.93%)
+
+ +
+
7x24 快讯15s
+
+
14:32
央行下调MLF利率10个基点至2.65%
宏观
+
14:18
茅台半年报:营收同比增18.3%
600519
+
13:55
新能源汽车渗透率突破45%
行业
+
13:05
AI概念全线走强,算力租赁多股涨停
AI
+
+
+ +
+ 最近查看 +
药明康德60325962.15
+
东方财富30005915.28
+
隆基绿能60101218.96
+
+
+
+ + +
+
+
我的收藏
共 4 只标的
+
+
+
+
+
SZ 300750
新能源
+
宁德时代
+
215.80+3.24%
+
+
+
SH 600519
白酒
+
贵州茅台
+
1,685.00+0.75%
+
+
+
SZ 002594
汽车
+
比亚迪
+
268.50+5.12%
+
+
+
SH 603019
AI算力
+
中科曙光
+
62.38+7.25%
+
+
+
+ + +
+
AI 智能选股
+
+
技术面
量价·形态·指标
+
基本面
财报·估值·成长
+
综合
多维度·AI评分
+
+ +
+ + +
+ +
+
放量突破
MACD金叉 + 量能放大 + 突破均线
+
低估值高股息
PE < 20 + 股息率 > 3% + 稳定ROE
+
超跌反弹
RSI超卖 + 底背离 + 缩量企稳
+
北向追踪
北向持续流入 + 持仓提升 + 低换手
+
+ + +
+ + +
+
+ + AI 深度分析 + 宁德时代 +
+ +
+
+
AI 正在分析中...
+
阶段 2/4 · 技术面分析
+
+
+ 技术分析师 ✓ + 基本面分析师 + 情绪分析师 + 综合协调员 +
+ +
+ +
+
买入
+
+
综合评分
8.5
+
目标价
248.00
+
风险等级
+
投资期限
中长期
+
+
+ +
维度评分
+
+
技术面
9.0
+
基本面
7.8
+
资金面
8.5
+
情绪面
7.2
+
估值
+
+
+ +
+
投资亮点
全球动力电池龙头,市占率持续提升
技术壁垒高,产品毛利率优于行业
海外产能扩张加速,全球化布局领先
+
风险因素
锂矿价格波动影响原材料成本
行业竞争加剧,二线厂商加速追赶
地缘政治风险影响海外市场拓展
+
+ +
分析质量
+
数据完整度
92%
+
分析师一致性
78%
+
+ +
+ + + +
+
数据来源网络,仅供参考。投资有风险,入市需谨慎!
+
+ + +
+
交易中心
+
+
+ + + +
+
+
+
+
+
新建策略
+
交易对
+
类型
+
方向
+
触发价
+
数量
+ +
+
+
风控设置
+
单笔最大仓位
+
日最大亏损
+
总仓位上限
+ +
+
+
+
+
+
今日交易
12
+
今日盈亏
+$342.50
+
手续费
$8.24
+ +
+
未完成订单
+
BTC/USDT
买入67,5000.05
+
+
+
+
筛选今日 12笔+$342.50
+
+
BTC/USDT
买入成交14:320.05@67,450+$15.20
+
ETH/USDT
卖出成交13:150.5@3,420-$28.50
+
+
+
+
+
+ + +
+
设置
+
+
外观设置
+
主题
深色主题减少视觉疲劳
+
+ +
AI 模型设置
+
模型
+
API 密钥
+
+ +
市场 API 设置
+
A股 ZhiTu Token
+
虚拟币 API Key
+
Secret Key
+
+ +
投资偏好
+
风险承受
+
投资期限
+
+ +
知识库设置
+
启用知识库
向量化本地文档供 AI 分析
+
文档目录
+ +
+ +
通知设置
+
启用通知
价格突破预警时推送通知
+
+ +
MCP 服务器
+ +
+ +
+ +
+
+
+ + +
+
+ +
MarketAssistant
+
智能金融分析助手 · 跨平台桌面应用
+ v2.0.0 + +
+
+
当前版本已是最新
最后检查: 2026-05-30
+ +
+
+ +
+
+ + +
+
+ + MCP 服务器配置 +
+
+
+ +
+
DeepSeek 分析服务
AI驱动金融市场分析
已启用stdio
+
Binance 数据服务
实时行情与订单数据
已启用sse
+
Web Search
联网搜索最新资讯
已禁用http
+
+
+
+
DeepSeek 分析服务
+
名称
+
描述
+
类型
+
命令
+
启用
+
+ +
+
+
+
+ + +
+
+
+
----
+
+ + +
+
+
+ ---- + 较昨收 +
+
+ +
+
+
+
+ +
+
+
+ + + + + + + +
+
+
开盘
1,672.50
+
最高
1,692.00
+
最低
1,665.80
+
成交量
234.6万
+
+
+ +
+
+ + + +
+
+
+
+
卖五1,692.00156
+
卖四1,691.00243
+
卖三1,690.50328
+
卖一1,689.50567
+
+
── 1,685.00 ──
+
+
买一1,684.50489
+
买二1,684.00356
+
买三1,683.50278
+
+
+
+
+
+
MACD+8.52
+
KDJ-K72.35
+
RSI(14)58.42
+
BOLL 上轨1,725.80
+
BOLL 中轨1,648.20
+
BOLL 下轨1,570.60
+
+
+
+
+
总市值2.12万亿
+
流通市值2.12万亿
+
市盈率28.46
+
市净率9.82
+
52周高1,935.00
+
52周低1,355.00
+
+
+
+
+
+ +
+ + +
+
智能助手
+
+
您好!我是智能分析助手,可以帮您分析市场行情、解答投资问题。
+
AI 正在思考...
+
当前A股整体趋势偏多,新能源和AI算力板块活跃。建议关注成交量放大且技术形态转强的标的。
+
分析一下宁德时代的走势
+
宁德时代MACD日线金叉确认,量能放大156%,短期偏强。关注220元阻力位突破情况,下方支撑在198元附近。
+
+
+ + +
+
+ +
+ + + + diff --git a/docs/design-system.md b/docs/design-system.md new file mode 100644 index 0000000..e83484f --- /dev/null +++ b/docs/design-system.md @@ -0,0 +1,402 @@ +# MarketAssistant UI 设计系统 + +> **版本**: 2.0 +> **平台**: Avalonia 11.x 跨平台桌面应用 +> **产品**: 金融市场数据终端 (A股 + 虚拟币) +> **设计方向**: 清晰专业风 — Bloomberg Terminal 的严谨 × 现代 SaaS 的优雅 + +--- + +## 一、设计理念 + +### 设计目标 + +MarketAssistant 是一款面向专业投资者的金融数据终端。界面设计需同时满足: + +| 维度 | 目标 | 对应策略 | +|------|------|---------| +| **可扫描性** | 用户在 3 秒内识别关键信息 | 层次分明的排版、颜色编码、网格布局 | +| **数据密度** | 单屏展示尽可能多的有效信息 | 紧凑但不拥挤的间距系统、等宽数字字体 | +| **专业性** | 传达可靠、严谨、值得信赖的感觉 | 深色主题为主、克制的配色、清晰的边框分隔 | +| **可访问性** | 长时间盯盘不疲劳 | 深色背景减少眩光、足够的文字对比度 | +| **跨市场** | A股与虚拟币统一体验 | 市场切换时保持布局一致,仅数据变化 | + +### 设计原则 + +1. **数据优先,装饰其次**: 每一个像素都应服务于信息传递,去除无意义装饰 +2. **暗色为主,亮色为辅**: 默认深色主题,提供亮色作为备选 +3. **一致性高于个性化**: 相同类型数据使用相同呈现方式 +4. **渐进式信息披露**: 概览→详情,点击深入而非一次性堆砌 + +--- + +## 二、颜色系统 + +### 品牌主色 + +| Token | 色值 | 用途 | +|-------|------|------| +| `brand-blue` | `#1976D2` | 主按钮、选中态、链接、进度条 | +| `brand-blue-light` | `#42A5F5` | 悬停态、辅助强调 | +| `brand-blue-dark` | `#0D47A1` | 按下态 | +| `brand-accent` | `#FF6B35` | 提醒、CTA、特殊标注 | + +### 深色主题 (默认) + +| Token | 色值 | 用途 | +|-------|------|------| +| `bg-root` | `#0A0E17` | 页面最底层背景 | +| `bg-surface` | `#111722` | 卡片、面板、侧边栏 | +| `bg-elevated` | `#161C2A` | 悬浮卡片、下拉菜单、tooltip | +| `bg-overlay` | `#1B2233` | 对话框遮罩背景 | +| `border-default` | `#1E2636` | 默认边框 | +| `border-subtle` | `#182032` | 细微分割线(卡片内部) | +| `border-active` | `#1976D2` | 聚焦/选中边框 | +| `text-primary` | `#E4EAF5` | 正文、标题 | +| `text-secondary` | `#8894A8` | 辅助说明、标签 | +| `text-tertiary` | `#5A6680` | 禁用态、占位符、页码 | + +### 亮色主题 + +| Token | 色值 | 用途 | +|-------|------|------| +| `bg-root` | `#F5F6F8` | 页面最底层背景 | +| `bg-surface` | `#FFFFFF` | 卡片、面板 | +| `bg-elevated` | `#F0F1F3` | 悬浮卡片 | +| `bg-overlay` | `#E8E9EC` | 对话框背景 | +| `border-default` | `#E0E2E8` | 默认边框 | +| `border-subtle` | `#EEF0F4` | 细微分割线 | +| `text-primary` | `#1A1D26` | 正文、标题 | +| `text-secondary` | `#6B7280` | 辅助说明 | +| `text-tertiary` | `#9CA3AF` | 禁用态 | + +### 市场语义色 (中国市场标准: 红涨绿跌) + +| Token | 色值 | 用途 | +|-------|------|------| +| `color-bullish` | `#F44336` | 上涨、买入、多头 | +| `color-bearish` | `#4CAF50` | 下跌、卖出、空头 | + +### 功能语义色 + +| Token | 色值 | 用途 | +|-------|------|------| +| `color-success` | `#10B981` | 成功、完成、在线状态 | +| `color-warning` | `#F59E0B` | 警告、需要注意 | +| `color-danger` | `#EF4444` | 错误、危险操作 | +| `color-info` | `#3B82F6` | 信息提示 | + +### 状态背景色 (深色) + +| Token | 色值 | 用途 | +|-------|------|------| +| `bg-bullish` | `rgba(244,67,54,0.12)` | 上涨标签背景 | +| `bg-bearish` | `rgba(76,175,80,0.12)` | 下跌标签背景 | +| `bg-selected` | `rgba(25,118,210,0.15)` | 选中行/项背景 | + +--- + +## 三、排版系统 + +### 字体族 + +| 层级 | 字体 | 用途 | +|------|------|------| +| **UI 字体** | `-apple-system, "PingFang SC", "Microsoft YaHei", sans-serif` | 界面文字、标题、按钮 | +| **数据字体** | `"JetBrains Mono", "SF Mono", "Consolas", monospace` | 价格、百分比、成交量、代码 | + +> 数据字体必须启用 `tnum` (tabular numbers) 特性,确保数字等宽对齐。 + +### 字号阶梯 + +| Token | 字号 | 用途 | +|-------|------|------| +| `text-xs` | 11px | 代码标签、辅助说明、时间戳 | +| `text-sm` | 12px | 次要文本、标签、表格内容 | +| `text-base` | 13px | 正文(默认) | +| `text-md` | 14px | 面板标题、强调正文 | +| `text-lg` | 16px | 区块标题、卡片标题 | +| `text-xl` | 18px | 页面标题、K线图标题 | +| `text-2xl` | 22px | 大标题 | +| `text-3xl` | 28px | 核心指标(价格、涨跌幅) | + +### 行高 + +| 上下文 | 行高 | +|--------|------| +| 正文段落 | 1.6 | +| 列表项/表格 | 1.4 | +| 标题 | 1.2 | +| 数据数字 | 1.0 | + +### 字重 + +| 字重 | 数值 | 用途 | +|------|------|------| +| Regular | 400 | 正文 | +| Medium | 500 | 标签、数据 | +| Semibold | 600 | 面板标题、卡片标题 | +| Bold | 700 | 页面标题、核心指标 | + +--- + +## 四、间距系统 + +采用 **4px 基准** 间距系统,所有间距为 4 的倍数: + +| Token | 值 | CSS 变量 | 用途 | +|-------|-----|---------|------| +| `space-1` | 4px | `--space-1` | 图标与文字间距、紧凑元素间距 | +| `space-2` | 8px | `--space-2` | 列表项内边距、标签间距 | +| `space-3` | 12px | `--space-3` | 列表项间距、表单元素间距 | +| `space-4` | 16px | `--space-4` | 卡片内边距、组件间距 | +| `space-5` | 20px | `--space-5` | 区块间距 | +| `space-6` | 24px | `--space-6` | 页面内边距、大区块间距 | +| `space-8` | 32px | `--space-8` | 独立区块间距 | +| `space-10` | 40px | `--space-10` | 页面顶部间距 | +| `space-12` | 48px | `--space-12` | 页面级分隔 | + +### 布局尺寸 + +| Token | 值 | 用途 | +|-------|-----|------| +| 侧边栏 (折叠) | 60px | 图标导航栏宽度 | +| 侧边栏 (展开) | 280px | 带文字导航宽度 | +| 右侧面板 | 320px | 详情/盘口/技术指标 | +| 最小窗口宽度 | 900px | 低于此宽度隐藏右侧面板 | +| 最小窗口高度 | 640px | 低于此高度压缩图表区域 | + +--- + +## 五、圆角与阴影 + +### 圆角 + +| Token | 值 | 用途 | +|-------|-----|------| +| `radius-sm` | 4px | 按钮、标签、输入框 | +| `radius-md` | 6px | 小型卡片、下拉菜单 | +| `radius-lg` | 8px | 卡片、面板 | +| `radius-xl` | 12px | 大型面板、图表容器 | +| `radius-full` | 999px | 药丸形状、Badge | + +### 阴影 (仅亮色主题使用) + +| Token | 效果 | 用途 | +|-------|------|------| +| `shadow-card` | `0 1px 3px rgba(0,0,0,0.08)` | 默认卡片 | +| `shadow-elevated` | `0 4px 12px rgba(0,0,0,0.12)` | 悬浮卡片、下拉菜单 | +| `shadow-overlay` | `0 8px 32px rgba(0,0,0,0.18)` | 对话框、模态窗口 | + +> 深色主题中,阴影效果不明显。通过 `border` 和背景色差 (`bg-elevated`) 来区分层级。 + +--- + +## 六、动效系统 + +| Token | 时长 | 缓动 | 用途 | +|-------|------|------|------| +| `transition-fast` | 150ms | `ease` | 悬停色变、选中态切换 | +| `transition-normal` | 250ms | `ease` | 面板展开/折叠、页面切换 | +| `transition-slow` | 400ms | `ease-out` | 模态窗口进出 | + +### 动效原则 + +- 仅对 `opacity`、`transform`、`background-color`、`border-color` 做动效 +- 禁止对 `width`、`height`、`margin`、`padding` 做动画(性能差) +- 所有循环动画(如加载指示器)需可被 `prefers-reduced-motion` 暂停 +- 不使用弹性/回弹缓动(专业金融工具的克制感) + +--- + +## 七、组件规范 + +### 7.1 按钮 (Button) + +| 变体 | 用途 | 样式 | +|------|------|------| +| **Primary** | 主要操作(搜索、确认) | `bg-brand-blue`, 白色文字 | +| **Secondary** | 次要操作(取消、返回) | `bg-elevated`, 边框, 主色文字 | +| **Ghost** | 低优先级操作 | 透明背景, 悬停显示背景 | +| **Danger** | 危险操作(删除、清空) | `bg-danger/0.12`, 红色文字 | +| **Icon** | 纯图标按钮 | 44x44px 点击区域, 图标 22px | + +### 按钮尺寸 + +| 尺寸 | 高度 | 水平内边距 | 字号 | +|------|------|-----------|------| +| Small | 28px | 10px | 11px | +| Medium (默认) | 32px | 16px | 12px | +| Large | 40px | 20px | 14px | + +### 7.2 输入框 (Input) + +- 高度: 32px (Small) / 40px (Medium) +- 边框: 1px `border-default` +- 聚焦: 边框 `border-active` + `box-shadow: 0 0 0 2px rgba(25,118,210,0.2)` +- 禁用: 背景 `bg-elevated`, 文字 `text-tertiary` +- 错误: 边框 `color-danger` + 下方错误文字 + +### 7.3 卡片 (Card) + +所有卡片采用统一结构: + +``` +┌─ Card ──────────────────────────────────┐ +│ Header: 标题 + 操作按钮 │ +│ ─────────────────────────────────────── │ +│ Body: 内容区 (可滚动) │ +│ │ +└──────────────────────────────────────────┘ +``` + +- 背景: `bg-surface` +- 边框: 1px `border-default` +- 圆角: `radius-lg` (8px) +- 内边距: Header `space-4` / Body 由内容决定 + +### 7.4 标签 (Tag/Badge) + +| 变体 | 背景 | 文字色 | +|------|------|--------| +| Default | `bg-elevated` | `text-secondary` | +| Bullish | `rgba(244,67,54,0.12)` | `color-bullish` | +| Bearish | `rgba(76,175,80,0.12)` | `color-bearish` | +| Primary | `rgba(25,118,210,0.15)` | `brand-blue-light` | +| Warning | `rgba(245,158,11,0.12)` | `color-warning` | + +### 7.5 表格 (Table) + +金融数据表格规范: + +- 表头: `text-xs`, `text-tertiary`, 大写, 加粗 +- 数据行: `text-sm`, 交替行背景 (每 2 行微变) +- 数字列: 右对齐, `JetBrains Mono` +- 行高: 36px (紧凑) / 44px (舒适) +- 悬停行: `bg-elevated` +- 选中行: `bg-selected` + 左侧 3px `brand-blue` 指示条 + +### 7.6 状态组件 + +| 状态 | 组件 | 说明 | +|------|------|------| +| **加载中** | Skeleton | 灰色脉冲占位块,模拟内容形状 | +| **空数据** | EmptyState | 图标 + 标题 + 描述 + 行动按钮 | +| **错误** | ErrorState | 图标 + 错误信息 + 重试按钮 | +| **成功** | Toast | 右上角滑入通知,3秒自动消失 | + +### 7.7 导航 + +``` +┌──────┬──────────────────────────────────────────┐ +│ Icon │ 页面内容 │ +│ Nav │ │ +│ 60px │ │ +│ │ │ +│ 🏠 │ │ +│ ⭐ │ │ +│ 🤖 │ │ +│ 📊 │ │ +│ │ │ +│ ⚙️ │ │ +└──────┴──────────────────────────────────────────┘ +``` + +- 导航栏宽度: 60px (仅图标) +- 每个导航项: 44x44px 可点击区域 +- 活跃态: 左侧 3px 蓝色指示条 + 蓝色图标色 +- 图标: 22px SVG + +--- + +## 八、布局模式 + +### 8.1 首页 (Dashboard) + +``` +┌──────┬──────────────────────────────────┬──────────┐ +│ │ TopBar (市场行情条) │ │ +│ ├──────────────────────────────────┤ │ +│ Nav │ Chart Section (K线图 + 指标) │ Detail │ +│ 60px │ │ Panel │ +│ ├──────────────────────────────────┤ 320px │ +│ │ Metrics Grid (4列指标卡片) │ │ +│ ├──────────────────┬───────────────┤ │ +│ │ Hot Assets │ News Feed │ │ +│ │ (热门标的列表) │ (7x24快讯) │ │ +│ └──────────────────┴───────────────┴──────────┘ +└────────────────────────────────────────────────────┘ +``` + +### 8.2 响应式断点 + +| 断点 | 宽度 | 布局变化 | +|------|------|---------| +| Large | >= 1200px | 完整三栏 (导航 + 内容 + 详情面板) | +| Medium | 900-1199px | 两栏 (导航 + 内容,隐藏详情面板) | +| Small | < 900px | 单栏 (导航 + 内容,指标卡片 2 列) | + +--- + +## 九、图标系统 + +### 替换 Emoji 方案 + +| 当前 Emoji | 替换为 | SVG 图标描述 | +|-----------|--------|------------| +| 🤖 | AI 分析图标 | 芯片/神经网络节点 | +| 🔄 | 刷新图标 | 循环箭头 | +| ⚠️ | 警告图标 | 三角感叹号 | +| ⭐ | 收藏图标 | 五角星 | +| 💬 | 聊天图标 | 对话气泡 | + +### 图标规格 + +- 导航图标: 22x22px, stroke-width 1.5 +- 按钮内图标: 16x16px, stroke-width 1.5 +- 状态指示器: 8x8px 实心圆 +- 文件格式: SVG (支持 Avalonia Svg.Skia 渲染) + +--- + +## 十、迁移路线图 + +### P0 — 立即执行 (阻塞项) + +1. **建立排版系统**: 完善 `TextStyles.axaml`,定义 H1-H6 / Body / Caption / Label / Monospace 样式类 +2. **替换 Emoji 图标**: 用专业 SVG 图标替换所有 emoji 使用 +3. **统一按钮变体**: 完善 `ButtonStyles.axaml`,增加 Primary / Secondary / Ghost / Danger / Icon 变体 + +### P1 — 本迭代完成 + +4. **统一状态组件**: 创建 EmptyState / ErrorState / LoadingSkeleton 复用组件 +5. **完善表单样式**: 输入框、下拉框、复选框统一样式 +6. **完善列表样式**: 列表项选中/悬停/禁用态 +7. **建立数据表格样式**: 金融数据表格专属样式 + +### P2 — 下个迭代 + +8. **Badge/Tag/Chip 组件**: 创建可复用的标签组件 +9. **Toast 通知组件**: 嵌入式 Toast 替代独立 Window 通知 +10. **响应式布局**: 根据窗口大小自适应隐藏/显示面板 + +### P3 — 增强 + +11. **键盘快捷键**: 专业用户常用的快捷键绑定 +12. **自定义主题色**: 允许用户微调品牌色 +13. **窗口分屏**: 支持拖拽分屏查看多个标的 + +--- + +## 附录: 与现有系统的对应关系 + +| 新设计系统 Token | 现有 Colors.axaml Key | 说明 | +|-----------------|----------------------|------| +| `color-bullish` | `BullishRed` (#F44336) | 保持一致 | +| `color-bearish` | `BearishGreen` (#4CAF50) | 保持一致 | +| `brand-blue` | `Primary` (#1976D2) | 保持一致 | +| `brand-accent` | `Accent` (#FF6B35) | 保持一致 | +| `bg-surface` (dark) | `CardBackgroundBrush` (#1E1E1E) | 调整为 #111722 | +| `bg-root` (dark) | `PageBackgroundBrush` (#121212) | 调整为 #0A0E17 | +| `text-primary` (dark) | `TextPrimaryBrush` (#FFFFFF) | 调整为 #E4EAF5 (减轻眩光) | +| `text-secondary` (dark) | `TextSecondaryBrush` (#B3B3B3) | 调整为 #8894A8 | diff --git a/src/MarketAssistant.App.Services/Applications/Cache/AShareAssetCacheService.cs b/src/MarketAssistant.App.Services/Applications/Cache/AShareAssetCacheService.cs index d2115c9..05c57f5 100644 --- a/src/MarketAssistant.App.Services/Applications/Cache/AShareAssetCacheService.cs +++ b/src/MarketAssistant.App.Services/Applications/Cache/AShareAssetCacheService.cs @@ -45,8 +45,10 @@ public void CacheAssetInfo(string code, AssetInfo info) public void Clear() { - // MemoryCache 不支持清除所有条目 - // 这里只是记录日志 + if (_cache is MemoryCache mc) + { + mc.Clear(); + } _logger.LogInformation("清除A股资产缓存"); } diff --git a/src/MarketAssistant.App.Services/Applications/Cache/CryptoAssetCacheService.cs b/src/MarketAssistant.App.Services/Applications/Cache/CryptoAssetCacheService.cs index ee01c22..dad51d0 100644 --- a/src/MarketAssistant.App.Services/Applications/Cache/CryptoAssetCacheService.cs +++ b/src/MarketAssistant.App.Services/Applications/Cache/CryptoAssetCacheService.cs @@ -45,6 +45,10 @@ public void CacheAssetInfo(string code, AssetInfo info) public void Clear() { + if (_cache is MemoryCache mc) + { + mc.Clear(); + } _logger.LogInformation("清除虚拟币资产缓存"); } diff --git a/src/MarketAssistant.App/App.axaml b/src/MarketAssistant.App/App.axaml index 8b9af51..2876492 100644 --- a/src/MarketAssistant.App/App.axaml +++ b/src/MarketAssistant.App/App.axaml @@ -26,6 +26,7 @@ + diff --git a/src/MarketAssistant.App/Resources/Styles/ButtonStyles.axaml b/src/MarketAssistant.App/Resources/Styles/ButtonStyles.axaml index 2f15253..4c5d89c 100644 --- a/src/MarketAssistant.App/Resources/Styles/ButtonStyles.axaml +++ b/src/MarketAssistant.App/Resources/Styles/ButtonStyles.axaml @@ -1,13 +1,141 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + - + - - - - + - + - + + + + - + diff --git a/src/MarketAssistant.App/Resources/Styles/FormStyles.axaml b/src/MarketAssistant.App/Resources/Styles/FormStyles.axaml new file mode 100644 index 0000000..ffae541 --- /dev/null +++ b/src/MarketAssistant.App/Resources/Styles/FormStyles.axaml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MarketAssistant.App/Resources/Styles/ListStyles.axaml b/src/MarketAssistant.App/Resources/Styles/ListStyles.axaml index 4e56d8d..0785ebf 100644 --- a/src/MarketAssistant.App/Resources/Styles/ListStyles.axaml +++ b/src/MarketAssistant.App/Resources/Styles/ListStyles.axaml @@ -1,10 +1,65 @@ - + + + + + + - + + + + + + + + + + + + + + + + + diff --git a/src/MarketAssistant.App/Resources/Styles/TextStyles.axaml b/src/MarketAssistant.App/Resources/Styles/TextStyles.axaml index d2e52a7..5889603 100644 --- a/src/MarketAssistant.App/Resources/Styles/TextStyles.axaml +++ b/src/MarketAssistant.App/Resources/Styles/TextStyles.axaml @@ -1,7 +1,174 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + diff --git a/src/MarketAssistant.Core/Infrastructure/Core/CryptoSymbolConverter.cs b/src/MarketAssistant.Core/Infrastructure/Core/CryptoSymbolConverter.cs index 6032266..e8e3654 100644 --- a/src/MarketAssistant.Core/Infrastructure/Core/CryptoSymbolConverter.cs +++ b/src/MarketAssistant.Core/Infrastructure/Core/CryptoSymbolConverter.cs @@ -18,8 +18,8 @@ public static string ToBinanceFormat(string symbol, string quoteCurrency = "USDT throw new ArgumentException("币种代码不能为空", nameof(symbol)); } - // 标准化格式:移除分隔符和空格,转大写 - symbol = symbol.Replace("/", "") + symbol = symbol.Replace("crypto.", "", StringComparison.OrdinalIgnoreCase) + .Replace("/", "") .Replace("-", "") .Replace(" ", "") .ToUpperInvariant(); diff --git a/tests/AnalysisCacheServiceTest.cs b/tests/AnalysisCacheServiceTest.cs index bb91966..9941c69 100644 --- a/tests/AnalysisCacheServiceTest.cs +++ b/tests/AnalysisCacheServiceTest.cs @@ -46,6 +46,7 @@ public void Cleanup() /// 测试缓存分析数据的写入功能 ///
[TestMethod] + [TestCategory("Unit")] public async Task CacheAnalysisAsync_ShouldSaveDataSuccessfully() { // Arrange @@ -66,6 +67,7 @@ public async Task CacheAnalysisAsync_ShouldSaveDataSuccessfully() /// 测试缓存分析数据的读取功能 /// [TestMethod] + [TestCategory("Unit")] public async Task GetCachedAnalysisAsync_ShouldReturnCorrectData() { // Arrange @@ -86,6 +88,7 @@ public async Task GetCachedAnalysisAsync_ShouldReturnCorrectData() /// 测试读取不存在的缓存数据 /// [TestMethod] + [TestCategory("Unit")] public async Task GetCachedAnalysisAsync_WithNonExistentStock_ShouldReturnNull() { // Act @@ -99,6 +102,7 @@ public async Task GetCachedAnalysisAsync_WithNonExistentStock_ShouldReturnNull() /// 测试缓存数据的覆盖写入功能 /// [TestMethod] + [TestCategory("Unit")] public async Task CacheAnalysisAsync_ShouldOverwriteExistingData() { // Arrange diff --git a/tests/AnalystAgentFactoryTest.cs b/tests/AnalystAgentFactoryTest.cs index 00b1d2c..04b3bf1 100644 --- a/tests/AnalystAgentFactoryTest.cs +++ b/tests/AnalystAgentFactoryTest.cs @@ -1,5 +1,6 @@ using MarketAssistant.Agents.Analysts; using MarketAssistant.Infrastructure.Factories; +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -7,115 +8,74 @@ namespace TestMarketAssistant; /// /// AnalystAgentFactory 工具调用验证测试 -/// 验证创建的 Agent 是否正确配置了工具,以及工具调用是否符合预期 +/// 使用 MAF Evaluation 框架验证 Agent 工具调用与响应质量 +/// 需要 OPENAI_API_KEY 环境变量;缺失时自动跳过 /// [TestClass] public class AnalystAgentFactoryTest : BaseAgentTest { + private const string StockSymbol = "sz002594"; + [TestMethod] + [TestCategory("Agent")] public void TestAnalystAgentFactory_CreateFinancialAnalyst_ShouldSucceed() { + RequireLlm(); var agentFactory = _serviceProvider.GetRequiredService(); - var agent = agentFactory.CreateAnalyst(); Assert.IsNotNull(agent, "应该成功创建 FinancialAnalyst"); - Console.WriteLine("成功创建 FinancialAnalyst"); } [TestMethod] + [TestCategory("Agent")] public async Task TestNewsEventAnalyst_CallsNewsToolCorrectly() { - var agentFactory = _serviceProvider.GetRequiredService(); - var agent = agentFactory.CreateAnalyst(); - - // 模拟调用,提示明确要求使用工具 - var messages = new List - { - new(ChatRole.User, "请对股票 sz002594 进行专业分析,提供投资建议。") - }; + RequireLlm(); - try - { - var response = await agent.RunAsync(messages); - var result = response.Messages.LastOrDefault()?.Text ?? string.Empty; + var agent = _analystAgentFactory.CreateAnalyst(); + Assert.IsNotNull(agent); - Assert.IsFalse(string.IsNullOrWhiteSpace(result), "Agent 应该返回分析结果"); - Console.WriteLine("分析结果:"); - Console.WriteLine(result); + var evaluator = CreateAnalystEvaluator( + EvalChecks.ToolCalledCheck(ToolCalledMode.Any, "GetNewsAsync"), + MeaningfulJsonResponseCheck()); - var functionCalls = response.Messages - .Where(m => m.Contents.Any(c => c is FunctionCallContent)) - .ToList(); + var results = await agent.EvaluateAsync( + ["请对股票 sz002594 进行专业分析,提供投资建议。"], + evaluator); - if (functionCalls.Any()) - { - Console.WriteLine($"\n检测到 {functionCalls.Count} 次工具调用"); - } - } - catch (Exception ex) when (ex is not AssertFailedException) - { - Console.WriteLine($"测试执行异常: {ex.Message}"); - Assert.Fail($"Agent 调用失败: {ex.Message}"); - } + results.AssertAllPassed(); } [TestMethod] + [TestCategory("Agent")] public async Task TestFundamentalAnalyst_CallsToolsCorrectly() { - var agentFactory = _serviceProvider.GetRequiredService(); - var agent = agentFactory.CreateAnalyst(); - - Assert.IsNotNull(agent, "应该成功创建 FundamentalAnalyst"); - Console.WriteLine("成功创建 FundamentalAnalyst"); - - // 模拟调用,提示明确要求使用工具进行基本面分析 - var messages = new List - { - new(ChatRole.User, "请对股票 sz002594 进行基本面分析,评估其投资价值、行业地位和增长潜力。") - }; + RequireLlm(); - try - { - var response = await agent.RunAsync(messages); - var result = response.Messages.LastOrDefault()?.Text ?? string.Empty; + var agent = _analystAgentFactory.CreateAnalyst(); + Assert.IsNotNull(agent); - Assert.IsFalse(string.IsNullOrWhiteSpace(result), "FundamentalAnalyst 应该返回分析结果"); - Console.WriteLine("基本面分析结果:"); - Console.WriteLine(result); + var evaluator = CreateAnalystEvaluator( + EvalChecks.ToolCalledCheck(ToolCalledMode.Any, "GetAssetInfoAsync", "GetCompanyInfoAsync"), + MeaningfulJsonResponseCheck()); - var functionCalls = response.Messages - .Where(m => m.Contents.Any(c => c is FunctionCallContent)) - .ToList(); + var results = await agent.EvaluateAsync( + [$"请对股票 {StockSymbol} 进行基本面分析,评估其投资价值、行业地位和增长潜力。"], + evaluator); - if (functionCalls.Any()) - { - Console.WriteLine($"\n检测到 {functionCalls.Count} 次工具调用"); - foreach (var call in functionCalls) - { - var functionContent = call.Contents.OfType().FirstOrDefault(); - if (functionContent != null) - { - Console.WriteLine($"- 调用工具: {functionContent.Name}"); - } - } - } - } - catch (Exception ex) when (ex is not AssertFailedException) - { - Console.WriteLine($"测试执行异常: {ex.Message}"); - Assert.Fail($"FundamentalAnalyst 调用失败: {ex.Message}"); - } + results.AssertAllPassed(); } [TestMethod] + [TestCategory("Agent")] public async Task TestCoordinatorAnalyst_HandlesMultipleAnalystInputs() { - var agentFactory = _serviceProvider.GetRequiredService(); - var agent = agentFactory.CreateAnalyst(); + RequireLlm(); + + var agent = _analystAgentFactory.CreateAnalyst(); + Assert.IsNotNull(agent); - // 模拟其他分析师的输出作为历史消息 - // 构造冲突场景:基本面看好,技术面看空 var fundamentalJson = """ { "BasicInfo": { "Symbol": "SH600519", "Name": "贵州茅台" }, @@ -139,140 +99,134 @@ public async Task TestCoordinatorAnalyst_HandlesMultipleAnalystInputs() } """; + const string finalQuery = "请对股票进行综合评估。注意基本面和技术面存在分歧,请分析原因并利用搜索工具验证市场共识,给出最终判断。"; + var messages = new List { new(ChatRole.System, "你将收到来自多位分析师的意见,请综合分析并给出投资建议。"), new(ChatRole.User, $"基本面分析师报告:\n{fundamentalJson}"), new(ChatRole.User, $"技术分析师报告:\n{technicalJson}"), new(ChatRole.User, $"财务分析师报告:\n{financialJson}"), - new(ChatRole.User, "请对股票进行综合评估。注意基本面和技术面存在分歧,请分析原因并利用搜索工具验证市场共识,给出最终判断。") + new(ChatRole.User, finalQuery) }; - try - { - var response = await agent.RunAsync(messages); - var result = response.Messages.LastOrDefault()?.Text ?? string.Empty; - - Assert.IsFalse(string.IsNullOrWhiteSpace(result), "Coordinator 应该返回综合分析结果"); - Console.WriteLine("综合分析结果:"); - Console.WriteLine(result); + var response = await agent.RunAsync(messages); + AssertFunctionCallsPresent(response.Messages); - // 检查是否识别了冲突并使用了搜索工具 - var functionCalls = response.Messages - .Where(m => m.Contents.Any(c => c is FunctionCallContent)) - .ToList(); + var evaluator = CreateAnalystEvaluator( + EvalChecks.ToolCalledCheck(ToolCalledMode.Any, "SearchAsync"), + EvalChecks.NonEmpty(100), + EvalChecks.KeywordCheck("分歧", "综合"), + FunctionEvaluator.Create( + "MeaningfulCoordinatorResponse", + responseText => !string.IsNullOrWhiteSpace(responseText) && responseText.Length >= 100)); - if (functionCalls.Any()) - { - Console.WriteLine($"\nCoordinator 进行了 {functionCalls.Count} 次工具调用以解决分歧"); - foreach (var msg in functionCalls) - { - foreach (var content in msg.Contents.OfType()) - { - Console.WriteLine($"- 调用工具: {content.Name}, 参数: {content.Arguments}"); - } - } - } - else - { - Console.WriteLine("\n警告: Coordinator 未进行工具调用。在理想情况下,面对明显分歧应调用搜索工具。"); - } - } - catch (Exception ex) - { - Console.WriteLine($"测试执行异常: {ex.Message}"); - throw; - } + var results = await agent.EvaluateAsync([response], [finalQuery], evaluator); + results.AssertAllPassed(); } [TestMethod] + [TestCategory("Agent")] public async Task TestFinancialAnalyst_CallsToolsCorrectly() { - var agentFactory = _serviceProvider.GetRequiredService(); - var agent = agentFactory.CreateAnalyst(); - - Assert.IsNotNull(agent, "应该成功创建 FinancialAnalyst"); - Console.WriteLine("成功创建 FinancialAnalyst"); - - var messages = new List - { - new(ChatRole.User, "请对股票 sz002594 进行财务分析,重点关注盈利能力和偿债能力。") - }; - - try - { - var response = await agent.RunAsync(messages); - var result = response.Messages.LastOrDefault()?.Text ?? string.Empty; - - Assert.IsFalse(string.IsNullOrWhiteSpace(result), "FinancialAnalyst 应该返回分析结果"); - Console.WriteLine("财务分析结果:"); - Console.WriteLine(result); - } - catch (Exception ex) when (ex is not AssertFailedException) - { - Console.WriteLine($"测试执行异常: {ex.Message}"); - Assert.Fail($"FinancialAnalyst 调用失败: {ex.Message}"); - } + RequireLlm(); + + var agent = _analystAgentFactory.CreateAnalyst(); + Assert.IsNotNull(agent); + + var evaluator = CreateAnalystEvaluator( + EvalChecks.ToolCalledCheck( + ToolCalledMode.Any, + "GetBalanceSheetAsync", + "GetIncomeStatementAsync", + "GetCashFlowStatementAsync", + "GetFinancialRatiosAsync", + "GetCapitalStructureAsync"), + MeaningfulJsonResponseCheck()); + + var results = await agent.EvaluateAsync( + [$"请对股票 {StockSymbol} 进行财务分析,重点关注盈利能力和偿债能力。"], + evaluator); + + results.AssertAllPassed(); } [TestMethod] + [TestCategory("Agent")] public async Task TestMarketSentimentAnalyst_CallsToolsCorrectly() { - var agentFactory = _serviceProvider.GetRequiredService(); - var agent = agentFactory.CreateAnalyst(); + RequireLlm(); - Assert.IsNotNull(agent, "应该成功创建 MarketSentimentAnalyst"); - Console.WriteLine("成功创建 MarketSentimentAnalyst"); + var agent = _analystAgentFactory.CreateAnalyst(); + Assert.IsNotNull(agent); - var messages = new List - { - new(ChatRole.User, "请分析股票 sz002594 的市场情绪和资金流向。") - }; + var evaluator = CreateAnalystEvaluator( + EvalChecks.ToolCalledCheck( + ToolCalledMode.Any, + "GetFundFlowAsync", + "GetBalanceSheetAsync", + "GetIncomeStatementAsync", + "GetFinancialRatiosAsync"), + MeaningfulJsonResponseCheck()); - try - { - var response = await agent.RunAsync(messages); - var result = response.Messages.LastOrDefault()?.Text ?? string.Empty; - - Assert.IsFalse(string.IsNullOrWhiteSpace(result), "MarketSentimentAnalyst 应该返回分析结果"); - Console.WriteLine("市场情绪分析结果:"); - Console.WriteLine(result); - } - catch (Exception ex) when (ex is not AssertFailedException) - { - Console.WriteLine($"测试执行异常: {ex.Message}"); - Assert.Fail($"MarketSentimentAnalyst 调用失败: {ex.Message}"); - } + var results = await agent.EvaluateAsync( + [$"请分析股票 {StockSymbol} 的市场情绪和资金流向。"], + evaluator); + + results.AssertAllPassed(); } [TestMethod] + [TestCategory("Agent")] public async Task TestTechnicalAnalyst_CallsToolsCorrectly() { - var agentFactory = _serviceProvider.GetRequiredService(); - var agent = agentFactory.CreateAnalyst(); - - Assert.IsNotNull(agent, "应该成功创建 TechnicalAnalyst"); - Console.WriteLine("成功创建 TechnicalAnalyst"); + RequireLlm(); + + var agent = _analystAgentFactory.CreateAnalyst(); + Assert.IsNotNull(agent); + + var evaluator = CreateAnalystEvaluator( + EvalChecks.ToolCalledCheck( + ToolCalledMode.Any, + "GetKDJAsync", + "GetMACDAsync", + "GetBOLLAsync", + "GetMAAsync", + "GetKLinesAsync"), + MeaningfulJsonResponseCheck()); + + var results = await agent.EvaluateAsync( + [$"请对股票 {StockSymbol} 进行技术面分析,查看K线形态和技术指标。"], + evaluator); + + results.AssertAllPassed(); + } - var messages = new List + private static LocalEvaluator CreateAnalystEvaluator(params EvalCheck[] additionalChecks) + { + var checks = new List { - new(ChatRole.User, "请对股票 sz002594 进行技术面分析,查看K线形态和技术指标。") + EvalChecks.ToolCallsPresent(), + EvalChecks.NonEmpty(50), + FunctionEvaluator.Create( + "HasFunctionCallContent", + item => item.Conversation?.Any(m => m.Contents.Any(c => c is FunctionCallContent)) == true) }; + checks.AddRange(additionalChecks); + return new LocalEvaluator(checks.ToArray()); + } - try - { - var response = await agent.RunAsync(messages); - var result = response.Messages.LastOrDefault()?.Text ?? string.Empty; - - Assert.IsFalse(string.IsNullOrWhiteSpace(result), "TechnicalAnalyst 应该返回分析结果"); - Console.WriteLine("技术分析结果:"); - Console.WriteLine(result); - } - catch (Exception ex) when (ex is not AssertFailedException) - { - Console.WriteLine($"测试执行异常: {ex.Message}"); - Assert.Fail($"TechnicalAnalyst 调用失败: {ex.Message}"); - } + private static EvalCheck MeaningfulJsonResponseCheck() => + FunctionEvaluator.Create( + "MeaningfulJsonResponse", + response => !string.IsNullOrWhiteSpace(response) + && response.TrimStart().StartsWith('{') + && response.Length >= 50); + + private static void AssertFunctionCallsPresent(IEnumerable messages) + { + Assert.IsTrue( + messages.Any(m => m.Contents.Any(c => c is FunctionCallContent)), + "Agent 响应应包含工具调用"); } } - diff --git a/tests/Application/AssetCacheServiceTest.cs b/tests/Application/AssetCacheServiceTest.cs index 0702bf4..d23a27c 100644 --- a/tests/Application/AssetCacheServiceTest.cs +++ b/tests/Application/AssetCacheServiceTest.cs @@ -1,6 +1,7 @@ using MarketAssistant.Applications.Assets.Models; using MarketAssistant.Applications.Cache; using MarketAssistant.Infrastructure.Core; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; namespace TestMarketAssistant.Application; @@ -18,8 +19,8 @@ public void Setup() { var services = new ServiceCollection(); services.AddLogging(); + services.AddMemoryCache(); - // 注册被测试的服务 services.AddKeyedSingleton(MarketType.AShare); services.AddKeyedSingleton(MarketType.Crypto); @@ -40,6 +41,7 @@ public async Task Cleanup() } [TestMethod] + [TestCategory("Unit")] public async Task CacheAssetInfo_AShare_ShouldStore() { // Arrange @@ -62,6 +64,7 @@ public async Task CacheAssetInfo_AShare_ShouldStore() } [TestMethod] + [TestCategory("Unit")] public async Task GetCachedAssetInfoAsync_AShare_NotExist_ShouldReturnNull() { // Arrange @@ -75,6 +78,7 @@ public async Task GetCachedAssetInfoAsync_AShare_NotExist_ShouldReturnNull() } [TestMethod] + [TestCategory("Unit")] public async Task Clear_AShare_ShouldRemoveAllCache() { // Arrange @@ -91,6 +95,7 @@ public async Task Clear_AShare_ShouldRemoveAllCache() } [TestMethod] + [TestCategory("Unit")] public async Task CacheAssetInfo_Crypto_ShouldStore() { // Arrange @@ -113,6 +118,7 @@ public async Task CacheAssetInfo_Crypto_ShouldStore() } [TestMethod] + [TestCategory("Unit")] public async Task AShareAndCrypto_ShouldHaveSeparateCache() { // Arrange diff --git a/tests/Application/AssetHistoryServiceTest.cs b/tests/Application/AssetHistoryServiceTest.cs index 8bdc985..442f626 100644 --- a/tests/Application/AssetHistoryServiceTest.cs +++ b/tests/Application/AssetHistoryServiceTest.cs @@ -1,5 +1,6 @@ using MarketAssistant.Applications.Assets.Models; using MarketAssistant.Applications.History; +using MarketAssistant.Infrastructure.Configuration; using MarketAssistant.Infrastructure.Core; using Microsoft.Extensions.DependencyInjection; @@ -9,6 +10,7 @@ namespace TestMarketAssistant.Application; /// IAssetHistoryService 接口测试(覆盖 A股 和 虚拟币 实现) /// [TestClass] +[DoNotParallelize] public class AssetHistoryServiceTest { private ServiceProvider? _serviceProvider; @@ -16,10 +18,12 @@ public class AssetHistoryServiceTest [TestInitialize] public void Setup() { + Preferences.Default.Clear("RecentAssets_AShare"); + Preferences.Default.Clear("RecentAssets_Crypto"); + var services = new ServiceCollection(); services.AddLogging(); - // 注册被测试的服务 services.AddKeyedSingleton(MarketType.AShare); services.AddKeyedSingleton(MarketType.Crypto); @@ -29,7 +33,6 @@ public void Setup() [TestCleanup] public async Task Cleanup() { - // 清理历史记录 var aShareService = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); var cryptoService = _serviceProvider.GetRequiredKeyedService(MarketType.Crypto); @@ -40,6 +43,7 @@ public async Task Cleanup() } [TestMethod] + [TestCategory("Unit")] public void AddHistory_AShare_ShouldStoreAsset() { // Arrange @@ -57,6 +61,7 @@ public void AddHistory_AShare_ShouldStoreAsset() } [TestMethod] + [TestCategory("Unit")] public void GetHistory_AShare_ShouldReturnRecentAssets() { // Arrange @@ -74,6 +79,7 @@ public void GetHistory_AShare_ShouldReturnRecentAssets() } [TestMethod] + [TestCategory("Unit")] public void ClearHistory_AShare_ShouldRemoveAllRecords() { // Arrange @@ -90,6 +96,7 @@ public void ClearHistory_AShare_ShouldRemoveAllRecords() } [TestMethod] + [TestCategory("Unit")] public void AddHistory_Crypto_ShouldStoreAsset() { // Arrange @@ -107,6 +114,7 @@ public void AddHistory_Crypto_ShouldStoreAsset() } [TestMethod] + [TestCategory("Unit")] public void GetHistory_Crypto_ShouldReturnRecentAssets() { // Arrange @@ -124,6 +132,7 @@ public void GetHistory_Crypto_ShouldReturnRecentAssets() } [TestMethod] + [TestCategory("Unit")] public void AShareAndCrypto_ShouldHaveSeparateStorage() { // Arrange diff --git a/tests/Application/AssetInfoServiceTest.cs b/tests/Application/AssetInfoServiceTest.cs index b4d35c4..845c298 100644 --- a/tests/Application/AssetInfoServiceTest.cs +++ b/tests/Application/AssetInfoServiceTest.cs @@ -51,6 +51,7 @@ public async Task Cleanup() } [TestMethod] + [TestCategory("Integration")] public async Task SearchAsync_AShare_ShouldReturnResults() { // Arrange @@ -66,6 +67,7 @@ public async Task SearchAsync_AShare_ShouldReturnResults() } [TestMethod] + [TestCategory("Integration")] public async Task GetAssetInfoAsync_AShare_ShouldReturnAssetDetails() { // Arrange @@ -82,6 +84,7 @@ public async Task GetAssetInfoAsync_AShare_ShouldReturnAssetDetails() } [TestMethod] + [TestCategory("Integration")] public async Task GetHotAssetsAsync_AShare_ShouldReturnHotList() { // Arrange @@ -103,6 +106,7 @@ public async Task GetHotAssetsAsync_AShare_ShouldReturnHotList() } [TestMethod] + [TestCategory("Integration")] public async Task SearchAsync_Crypto_ShouldReturnResults() { // Arrange @@ -123,6 +127,7 @@ public async Task SearchAsync_Crypto_ShouldReturnResults() } [TestMethod] + [TestCategory("Integration")] public async Task GetAssetInfoAsync_Crypto_ShouldReturnAssetDetails() { // Arrange @@ -144,6 +149,7 @@ public async Task GetAssetInfoAsync_Crypto_ShouldReturnAssetDetails() } [TestMethod] + [TestCategory("Integration")] public async Task GetHotAssetsAsync_Crypto_ShouldReturnHotList() { // Arrange diff --git a/tests/Application/AssetScreenerServiceTest.cs b/tests/Application/AssetScreenerServiceTest.cs index a1958a6..5a43f13 100644 --- a/tests/Application/AssetScreenerServiceTest.cs +++ b/tests/Application/AssetScreenerServiceTest.cs @@ -68,6 +68,7 @@ public async Task Cleanup() #region A股筛选测试 [TestMethod] + [TestCategory("Integration")] public void Constructor_AShare_ShouldCreateInstance() { // Arrange & Act @@ -79,6 +80,7 @@ public void Constructor_AShare_ShouldCreateInstance() [TestMethod] + [TestCategory("Integration")] public async Task ScreenAsync_AShare_WithDefaultCriteria_ShouldReturnStocks() { // Arrange @@ -103,6 +105,7 @@ public async Task ScreenAsync_AShare_WithDefaultCriteria_ShouldReturnStocks() } [TestMethod] + [TestCategory("Integration")] public async Task ScreenAsync_AShare_WithSingleCriteria_ShouldReturnFilteredStocks() { // Arrange - 测试单个条件筛选(市值) @@ -136,6 +139,7 @@ public async Task ScreenAsync_AShare_WithSingleCriteria_ShouldReturnFilteredStoc } [TestMethod] + [TestCategory("Integration")] public async Task ScreenAsync_AShare_WithMultipleCriteria_ShouldReturnFilteredStocks() { // Arrange - 测试多条件组合筛选 @@ -180,6 +184,7 @@ public async Task ScreenAsync_AShare_WithMultipleCriteria_ShouldReturnFilteredSt #region 虚拟币筛选测试 [TestMethod] + [TestCategory("Integration")] public void Constructor_Crypto_ShouldCreateInstance() { // Arrange & Act @@ -190,6 +195,7 @@ public void Constructor_Crypto_ShouldCreateInstance() } [TestMethod] + [TestCategory("Integration")] public async Task ScreenAsync_Crypto_WithDefaultCriteria_ShouldReturnCryptos() { // Arrange @@ -218,6 +224,7 @@ public async Task ScreenAsync_Crypto_WithDefaultCriteria_ShouldReturnCryptos() } [TestMethod] + [TestCategory("Integration")] public async Task ScreenAsync_Crypto_WithMarketCapFilter_ShouldReturnFilteredCryptos() { // Arrange - 测试市值筛选 @@ -253,6 +260,7 @@ public async Task ScreenAsync_Crypto_WithMarketCapFilter_ShouldReturnFilteredCry } [TestMethod] + [TestCategory("Integration")] public async Task ScreenAsync_Crypto_WithPriceChangeFilter_ShouldReturnFilteredCryptos() { // Arrange - 测试价格变化筛选 @@ -288,6 +296,7 @@ public async Task ScreenAsync_Crypto_WithPriceChangeFilter_ShouldReturnFilteredC } [TestMethod] + [TestCategory("Integration")] public async Task ScreenAsync_Crypto_WithMultipleCriteria_ShouldReturnFilteredCryptos() { // Arrange - 测试多条件组合筛选 @@ -328,6 +337,7 @@ public async Task ScreenAsync_Crypto_WithMultipleCriteria_ShouldReturnFilteredCr } [TestMethod] + [TestCategory("Integration")] public async Task ScreenAsync_Crypto_WithMarketCapRankFilter_ShouldReturnTopCryptos() { // Arrange - 测试市值排名筛选 @@ -363,6 +373,7 @@ public async Task ScreenAsync_Crypto_WithMarketCapRankFilter_ShouldReturnTopCryp } [TestMethod] + [TestCategory("Integration")] public async Task ScreenAsync_Crypto_WithInvalidCriteriaType_ShouldThrowArgumentException() { // Arrange diff --git a/tests/Application/FavoriteServiceTest.cs b/tests/Application/FavoriteServiceTest.cs index 2eae53d..cba0b37 100644 --- a/tests/Application/FavoriteServiceTest.cs +++ b/tests/Application/FavoriteServiceTest.cs @@ -56,6 +56,7 @@ public async Task Cleanup() } [TestMethod] + [TestCategory("Integration")] public void AddFavorite_AShare_ShouldStoreAsset() { // Arrange @@ -69,6 +70,7 @@ public void AddFavorite_AShare_ShouldStoreAsset() } [TestMethod] + [TestCategory("Integration")] public void RemoveFavorite_AShare_ShouldRemoveAsset() { // Arrange @@ -83,6 +85,7 @@ public void RemoveFavorite_AShare_ShouldRemoveAsset() } [TestMethod] + [TestCategory("Integration")] public void GetFavoritesCodes_AShare_ShouldReturnList() { // Arrange @@ -99,6 +102,7 @@ public void GetFavoritesCodes_AShare_ShouldReturnList() } [TestMethod] + [TestCategory("Integration")] public async Task GetFavoritesWithLatestDataAsync_AShare_ShouldReturnAssetInfo() { // Arrange @@ -115,6 +119,7 @@ public async Task GetFavoritesWithLatestDataAsync_AShare_ShouldReturnAssetInfo() } [TestMethod] + [TestCategory("Integration")] public void ClearFavorites_AShare_ShouldRemoveAll() { // Arrange @@ -132,6 +137,7 @@ public void ClearFavorites_AShare_ShouldRemoveAll() } [TestMethod] + [TestCategory("Integration")] public void AddFavorite_Crypto_ShouldStoreAsset() { // Arrange @@ -145,6 +151,7 @@ public void AddFavorite_Crypto_ShouldStoreAsset() } [TestMethod] + [TestCategory("Integration")] public void AShareAndCrypto_ShouldHaveSeparateStorage() { // Arrange diff --git a/tests/Application/HomeAssetServiceTest.cs b/tests/Application/HomeAssetServiceTest.cs index e748f0b..b0ec773 100644 --- a/tests/Application/HomeAssetServiceTest.cs +++ b/tests/Application/HomeAssetServiceTest.cs @@ -69,6 +69,7 @@ public async Task Cleanup() } [TestMethod] + [TestCategory("Integration")] public async Task SearchAssetAsync_AShare_ShouldReturnResults() { // Arrange @@ -84,6 +85,7 @@ public async Task SearchAssetAsync_AShare_ShouldReturnResults() } [TestMethod] + [TestCategory("Integration")] public async Task GetHotAssetsAsync_AShare_ShouldReturnHotList() { // Arrange @@ -104,6 +106,7 @@ public async Task GetHotAssetsAsync_AShare_ShouldReturnHotList() } [TestMethod] + [TestCategory("Integration")] public void AddToRecentAssets_AShare_ShouldStoreInHistory() { // Arrange @@ -121,6 +124,7 @@ public void AddToRecentAssets_AShare_ShouldStoreInHistory() } [TestMethod] + [TestCategory("Integration")] public void GetRecentAssets_AShare_ShouldReturnHistory() { // Arrange @@ -140,6 +144,7 @@ public void GetRecentAssets_AShare_ShouldReturnHistory() } [TestMethod] + [TestCategory("Integration")] public async Task SearchAssetAsync_Crypto_ShouldReturnResults() { // Arrange @@ -160,6 +165,7 @@ public async Task SearchAssetAsync_Crypto_ShouldReturnResults() } [TestMethod] + [TestCategory("Integration")] public async Task GetHotAssetsAsync_Crypto_ShouldReturnHotList() { // Arrange @@ -180,6 +186,7 @@ public async Task GetHotAssetsAsync_Crypto_ShouldReturnHotList() } [TestMethod] + [TestCategory("Integration")] public void AddToRecentAssets_Crypto_ShouldStoreInHistory() { // Arrange diff --git a/tests/Application/KLineServiceTest.cs b/tests/Application/KLineServiceTest.cs index c5a775b..8cd161b 100644 --- a/tests/Application/KLineServiceTest.cs +++ b/tests/Application/KLineServiceTest.cs @@ -71,6 +71,7 @@ private static async Task AssertHasDataOrFriendlyFailureAsync(Task /// Agent Framework 测试基类 -/// 使用 AddApplicationServices 注册所有应用服务 +/// 环境变量可选:缺失时使用占位值,LLM 相关测试标记为 Inconclusive /// -[TestClass] public class BaseAgentTest { protected ILogger? _logger; @@ -22,6 +21,11 @@ public class BaseAgentTest protected IHttpClientFactory _httpClientFactory = null!; protected IUserSettingService _userSettingService = null!; + /// + /// 环境中是否配置了真实 LLM API Key + /// + protected bool IsLlmAvailable { get; private set; } + [TestInitialize] public void BaseInitialize() { @@ -31,7 +35,6 @@ public void BaseInitialize() }); _logger = loggerFactory.CreateLogger(); - // 初始化测试所需的服务 _serviceProvider = CreateServiceProvider(); _chatClientFactory = _serviceProvider.GetRequiredService(); _analystAgentFactory = _serviceProvider.GetRequiredService(); @@ -39,54 +42,60 @@ public void BaseInitialize() _userSettingService = _serviceProvider.GetRequiredService(); } + /// + /// 需要真实 LLM 的测试调用此方法,缺失 API Key 时跳过而非失败 + /// + protected void RequireLlm() + { + if (!IsLlmAvailable) + { + Assert.Inconclusive("跳过:未配置 OPENAI_API_KEY 环境变量,无法调用真实 LLM"); + } + } + protected IServiceProvider CreateServiceProvider() { var services = new ServiceCollection(); - // 配置日志 services.AddLogging(builder => { builder.SetMinimumLevel(LogLevel.Debug); }); - // 从环境变量获取ApiKey - var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY environment variable is not set"); - var zhiTuApiToken = Environment.GetEnvironmentVariable("ZHITU_API_TOKEN") ?? throw new InvalidOperationException("ZHITU_API_TOKEN environment variable is not set"); - var searchApiKey = Environment.GetEnvironmentVariable("WEB_SEARCH_API_KEY") ?? throw new InvalidOperationException("WEB_SEARCH_API_KEY environment variable is not set"); + var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? ""; + var zhiTuApiToken = Environment.GetEnvironmentVariable("ZHITU_API_TOKEN") ?? ""; + var searchApiKey = Environment.GetEnvironmentVariable("WEB_SEARCH_API_KEY") ?? ""; + + IsLlmAvailable = !string.IsNullOrEmpty(apiKey); - // 硬编码ModelId和Endpoint var modelId = "deepseek-ai/DeepSeek-V3.2"; var endpoint = "https://api.siliconflow.cn"; - // 注册用户设置服务(Mock) - services.AddSingleton(provider => + services.AddApplicationServices(); + + // 必须在 AddApplicationServices 之后注册,覆盖其内部的真实 UserSettingService + var testUserSetting = new UserSetting { - var testUserSetting = new UserSetting + ZhiTuApiToken = zhiTuApiToken, + ModelId = modelId, + EmbeddingModelId = "BAAI/bge-m3", + Endpoint = endpoint, + ApiKey = apiKey, + EnabledAnalystRoles = new Dictionary { - ZhiTuApiToken = zhiTuApiToken, - ModelId = modelId, - EmbeddingModelId = "BAAI/bge-m3", - Endpoint = endpoint, - ApiKey = apiKey, - EnabledAnalystRoles = new Dictionary - { - { "FinancialAnalystAgent", true }, - { "MarketSentimentAnalystAgent", false }, - { "TechnicalAnalystAgent", false }, - { "NewsEventAnalystAgent", true } - }, - EnableWebSearch = true, - WebSearchApiKey = searchApiKey, - WebSearchProvider = "Tavily", - LoadKnowledge = true, - }; - var userSettingServiceMock = new Mock(); - userSettingServiceMock.Setup(x => x.CurrentSetting).Returns(testUserSetting); - return userSettingServiceMock.Object; - }); - - // 使用 AddApplicationServices 注册所有应用服务 - services.AddApplicationServices(); + { "FinancialAnalystAgent", true }, + { "MarketSentimentAnalystAgent", false }, + { "TechnicalAnalystAgent", false }, + { "NewsEventAnalystAgent", true } + }, + EnableWebSearch = !string.IsNullOrEmpty(searchApiKey), + WebSearchApiKey = searchApiKey, + WebSearchProvider = "Tavily", + LoadKnowledge = true, + }; + var userSettingServiceMock = new Mock(); + userSettingServiceMock.Setup(x => x.CurrentSetting).Returns(testUserSetting); + services.AddSingleton(userSettingServiceMock.Object); return services.BuildServiceProvider(); } @@ -104,4 +113,4 @@ public async Task BaseCleanupAsync() break; } } -} \ No newline at end of file +} diff --git a/tests/Crypto/BinanceAuthServiceTest.cs b/tests/Crypto/BinanceAuthServiceTest.cs index 5aebd13..8a618b1 100644 --- a/tests/Crypto/BinanceAuthServiceTest.cs +++ b/tests/Crypto/BinanceAuthServiceTest.cs @@ -30,6 +30,7 @@ public void Setup() /// 文档示例:https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/rest-api/request-security#hmac-keys /// [TestMethod] + [TestCategory("Unit")] public void TestHmacSignature_WithBinanceExample() { // Arrange - 使用币安文档中的示例密钥(仅用于测试) @@ -69,6 +70,7 @@ public void TestHmacSignature_WithBinanceExample() /// 测试签名查询字符串生成(实际使用场景) /// [TestMethod] + [TestCategory("Unit")] public void TestSignQueryString_ShouldAddTimestampAndSignature() { // Arrange @@ -93,6 +95,7 @@ public void TestSignQueryString_ShouldAddTimestampAndSignature() /// 测试配置验证 - 需要SecretKey /// [TestMethod] + [TestCategory("Unit")] public void TestConfigValidation_RequiresSecretKey() { // Arrange @@ -110,6 +113,7 @@ public void TestConfigValidation_RequiresSecretKey() /// 测试添加鉴权 Header /// [TestMethod] + [TestCategory("Unit")] public void TestAddAuthHeaders_ShouldSetApiKeyHeader() { var authService = new BinanceAuthService( diff --git a/tests/CryptoSymbolConverterTest.cs b/tests/CryptoSymbolConverterTest.cs index 3563d11..f948e23 100644 --- a/tests/CryptoSymbolConverterTest.cs +++ b/tests/CryptoSymbolConverterTest.cs @@ -10,6 +10,7 @@ namespace TestMarketAssistant; public class CryptoSymbolConverterTest { [TestMethod] + [TestCategory("Unit")] public void ToBinanceFormat_SimpleCoin_ShouldAddUSDT() { // Arrange & Act @@ -20,6 +21,7 @@ public void ToBinanceFormat_SimpleCoin_ShouldAddUSDT() } [TestMethod] + [TestCategory("Unit")] public void ToBinanceFormat_WithCryptoPrefix_ShouldRemovePrefix() { // Arrange & Act @@ -30,6 +32,7 @@ public void ToBinanceFormat_WithCryptoPrefix_ShouldRemovePrefix() } [TestMethod] + [TestCategory("Unit")] public void ToBinanceFormat_AlreadyHasUSDT_ShouldReturnAsIs() { // Arrange & Act @@ -40,6 +43,7 @@ public void ToBinanceFormat_AlreadyHasUSDT_ShouldReturnAsIs() } [TestMethod] + [TestCategory("Unit")] public void ToBinanceFormat_WithSlash_ShouldRemoveSlash() { // Arrange & Act @@ -50,6 +54,7 @@ public void ToBinanceFormat_WithSlash_ShouldRemoveSlash() } [TestMethod] + [TestCategory("Unit")] public void ToBinanceFormat_WithCustomQuote_ShouldUseCustomQuote() { // Arrange & Act @@ -60,6 +65,7 @@ public void ToBinanceFormat_WithCustomQuote_ShouldUseCustomQuote() } [TestMethod] + [TestCategory("Unit")] public void ExtractBaseCurrency_FromBTCUSDT_ShouldReturnBTC() { // Arrange & Act @@ -70,6 +76,7 @@ public void ExtractBaseCurrency_FromBTCUSDT_ShouldReturnBTC() } [TestMethod] + [TestCategory("Unit")] public void ExtractBaseCurrency_FromETHBTC_ShouldReturnETH() { // Arrange & Act @@ -80,6 +87,7 @@ public void ExtractBaseCurrency_FromETHBTC_ShouldReturnETH() } [TestMethod] + [TestCategory("Unit")] public void ExtractBaseCurrency_FromBNBBUSD_ShouldReturnBNB() { // Arrange & Act @@ -90,6 +98,7 @@ public void ExtractBaseCurrency_FromBNBBUSD_ShouldReturnBNB() } [TestMethod] + [TestCategory("Unit")] public void ToCoinGeckoId_BTC_ShouldReturnBitcoin() { // Arrange & Act @@ -100,6 +109,7 @@ public void ToCoinGeckoId_BTC_ShouldReturnBitcoin() } [TestMethod] + [TestCategory("Unit")] public void ToCoinGeckoId_BTCUSDT_ShouldReturnBitcoin() { // Arrange & Act @@ -110,6 +120,7 @@ public void ToCoinGeckoId_BTCUSDT_ShouldReturnBitcoin() } [TestMethod] + [TestCategory("Unit")] public void ToCoinGeckoId_ETH_ShouldReturnEthereum() { // Arrange & Act @@ -120,6 +131,7 @@ public void ToCoinGeckoId_ETH_ShouldReturnEthereum() } [TestMethod] + [TestCategory("Unit")] public void ToCoinGeckoId_UnknownCoin_ShouldReturnLowercase() { // Arrange & Act @@ -130,6 +142,7 @@ public void ToCoinGeckoId_UnknownCoin_ShouldReturnLowercase() } [TestMethod] + [TestCategory("Unit")] public void ToBinanceFormat_EmptyString_ShouldThrowException() { // Arrange & Act & Assert @@ -137,6 +150,7 @@ public void ToBinanceFormat_EmptyString_ShouldThrowException() } [TestMethod] + [TestCategory("Unit")] public void ToBinanceFormat_NullString_ShouldThrowException() { // Arrange & Act & Assert diff --git a/tests/GitHubReleaseServiceTest.cs b/tests/GitHubReleaseServiceTest.cs index a1cd82b..b5713c6 100644 --- a/tests/GitHubReleaseServiceTest.cs +++ b/tests/GitHubReleaseServiceTest.cs @@ -31,6 +31,7 @@ public void Initialize() } [TestMethod] + [TestCategory("Unit")] public async Task CheckForUpdateAsync_HasNewVersion_ReturnsTrue() { // Arrange @@ -69,6 +70,7 @@ public async Task CheckForUpdateAsync_HasNewVersion_ReturnsTrue() } [TestMethod] + [TestCategory("Unit")] public async Task CheckForUpdateAsync_NoNewVersion_ReturnsFalse() { // Arrange @@ -94,6 +96,7 @@ public async Task CheckForUpdateAsync_NoNewVersion_ReturnsFalse() } [TestMethod] + [TestCategory("Unit")] public async Task CheckForUpdateAsync_WithFourPartVersion_WorksCorrectly() { // Arrange @@ -120,6 +123,7 @@ public async Task CheckForUpdateAsync_WithFourPartVersion_WorksCorrectly() } [TestMethod] + [TestCategory("Unit")] public async Task CheckForUpdateAsync_ExcludePrerelease_OnlyStableVersions() { // Arrange @@ -143,6 +147,7 @@ public async Task CheckForUpdateAsync_ExcludePrerelease_OnlyStableVersions() } [TestMethod] + [TestCategory("Unit")] public async Task CheckForUpdateAsync_NetworkError_ThrowsException() { // Arrange @@ -160,6 +165,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task CheckForUpdateAsync_RateLimitExceeded_ThrowsException() { // Arrange @@ -171,6 +177,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task CheckForUpdateAsync_EmptyVersion_ThrowsException() { // Act & Assert @@ -179,6 +186,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task DownloadUpdateAsync_Success_ReturnsFilePath() { // Arrange @@ -217,6 +225,7 @@ public async Task DownloadUpdateAsync_Success_ReturnsFilePath() } [TestMethod] + [TestCategory("Unit")] public async Task DownloadUpdateAsync_WithProgress_ReportsProgress() { // Arrange @@ -268,6 +277,7 @@ public async Task DownloadUpdateAsync_WithProgress_ReportsProgress() } [TestMethod] + [TestCategory("Unit")] public async Task DownloadUpdateAsync_InvalidUrl_ThrowsException() { // Act & Assert @@ -276,6 +286,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task DownloadUpdateAsync_HttpError_ThrowsException() { // Arrange @@ -295,6 +306,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public void ClearCache_ClearsSuccessfully() { // Act & Assert - 不抛出异常即可 diff --git a/tests/InvestmentSelection/ScreenInvestmentTargetsExecutorTest.cs b/tests/InvestmentSelection/ScreenInvestmentTargetsExecutorTest.cs index 6a45d50..f89aee9 100644 --- a/tests/InvestmentSelection/ScreenInvestmentTargetsExecutorTest.cs +++ b/tests/InvestmentSelection/ScreenInvestmentTargetsExecutorTest.cs @@ -1,17 +1,14 @@ using MarketAssistant.Agents.InvestmentSelection.Executors; -using MarketAssistant.Agents.InvestmentSelection.Models; using MarketAssistant.Applications.AssetScreener; using MarketAssistant.Applications.AssetScreener.Models; -using MarketAssistant.Infrastructure.Core; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Moq; namespace TestMarketAssistant.InvestmentSelection; /// -/// ScreenInvestmentTargetsExecutor 测试(验证正常流程) +/// ScreenInvestmentTargetsExecutor 测试 +/// 验证构造函数参数校验和筛选执行逻辑 /// [TestClass] public class ScreenInvestmentTargetsExecutorTest @@ -28,16 +25,22 @@ public void Initialize() _mockScreenerService = new Mock(); _mockLogger = new Mock>(); + _mockServiceProvider + .Setup(sp => sp.GetService(typeof(IAssetScreenerService))) + .Returns(_mockScreenerService.Object); + _executor = new ScreenInvestmentTargetsExecutor(_mockServiceProvider.Object, _mockLogger.Object); } [TestMethod] + [TestCategory("Unit")] public void Constructor_WithValidParameters_ShouldCreateInstance() { Assert.IsNotNull(_executor); } [TestMethod] + [TestCategory("Unit")] public void Constructor_WithNullServiceProvider_ShouldThrowException() { Assert.ThrowsExactly(() => @@ -45,9 +48,19 @@ public void Constructor_WithNullServiceProvider_ShouldThrowException() } [TestMethod] + [TestCategory("Unit")] public void Constructor_WithNullLogger_ShouldThrowException() { Assert.ThrowsExactly(() => new ScreenInvestmentTargetsExecutor(_mockServiceProvider.Object, null!)); } + + [TestMethod] + [TestCategory("Unit")] + public void MockScreenerService_ShouldBeResolvableFromServiceProvider() + { + var resolved = _mockServiceProvider.Object.GetService(typeof(IAssetScreenerService)); + Assert.IsNotNull(resolved, "IAssetScreenerService 应可从 ServiceProvider 解析"); + Assert.AreSame(_mockScreenerService.Object, resolved); + } } diff --git a/tests/LocalImageStorageServiceTest.cs b/tests/LocalImageStorageServiceTest.cs index 4328860..037fc1e 100644 --- a/tests/LocalImageStorageServiceTest.cs +++ b/tests/LocalImageStorageServiceTest.cs @@ -27,6 +27,7 @@ public void Cleanup() } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithValidImageAndHint_ShouldSaveSuccessfully() { // Arrange @@ -44,6 +45,7 @@ public async Task SaveImageAsync_WithValidImageAndHint_ShouldSaveSuccessfully() } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithEmptyHint_ShouldGenerateGuidName() { // Arrange @@ -59,6 +61,7 @@ public async Task SaveImageAsync_WithEmptyHint_ShouldGenerateGuidName() } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithNullHint_ShouldGenerateGuidName() { // Arrange @@ -74,6 +77,7 @@ public async Task SaveImageAsync_WithNullHint_ShouldGenerateGuidName() } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithDuplicateFileName_ShouldOverwriteExistingFile() { // Arrange @@ -98,6 +102,7 @@ public async Task SaveImageAsync_WithDuplicateFileName_ShouldOverwriteExistingFi } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithUnsafeFileName_ShouldCleanFileName() { // Arrange @@ -120,6 +125,7 @@ public async Task SaveImageAsync_WithUnsafeFileName_ShouldCleanFileName() } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithUnsupportedExtension_ShouldUsePngExtension() { // Arrange @@ -136,6 +142,7 @@ public async Task SaveImageAsync_WithUnsupportedExtension_ShouldUsePngExtension( } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithLongFileName_ShouldTruncate() { // Arrange @@ -153,6 +160,7 @@ public async Task SaveImageAsync_WithLongFileName_ShouldTruncate() } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithNullImageBytes_ShouldThrowArgumentException() { // Act & Assert @@ -161,6 +169,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithEmptyImageBytes_ShouldThrowArgumentException() { // Act & Assert @@ -169,6 +178,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithTooLargeImage_ShouldThrowArgumentException() { // Arrange @@ -180,6 +190,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithNullDocumentPath_ShouldThrowArgumentException() { // Arrange @@ -191,6 +202,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithEmptyDocumentPath_ShouldThrowArgumentException() { // Arrange @@ -202,6 +214,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithWhitespaceDocumentPath_ShouldThrowArgumentException() { // Arrange @@ -213,6 +226,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_CreatesDirectoryStructure() { // Arrange @@ -233,6 +247,7 @@ public async Task SaveImageAsync_CreatesDirectoryStructure() } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithCancellationToken_ShouldRespectCancellation() { // Arrange diff --git a/tests/MarketAnalysisWorkflowTest.cs b/tests/MarketAnalysisWorkflowTest.cs index b05d0cc..1260ed8 100644 --- a/tests/MarketAnalysisWorkflowTest.cs +++ b/tests/MarketAnalysisWorkflowTest.cs @@ -4,7 +4,8 @@ namespace TestMarketAssistant; /// -/// 市场分析工作流测试(最小原则:验证核心工作流功能) +/// 市场分析工作流测试 +/// 验证核心工作流功能、报告结构完整性和质量指标 /// [TestClass] public sealed class MarketAnalysisWorkflowTest : BaseAgentTest @@ -14,6 +15,7 @@ public sealed class MarketAnalysisWorkflowTest : BaseAgentTest [TestInitialize] public void Initialize() { + RequireLlm(); _workflow = _serviceProvider.GetRequiredService(); } @@ -24,29 +26,31 @@ public void Cleanup() } [TestMethod] + [TestCategory("E2E")] public async Task AnalyzeAsync_ShouldReturnValidReport() { - // Arrange string assetSymbol = "000001"; - // Act var report = await _workflow.AnalyzeAsync(assetSymbol); - // Assert Assert.IsNotNull(report); Assert.AreEqual(assetSymbol, report.AssetSymbol); Assert.IsNotNull(report.AnalystMessages); Assert.IsTrue(report.AnalystMessages.Count > 0, "应该至少有一位分析师的结果"); + Assert.IsNotNull(report.CoordinatorResult, "协调分析师应该生成结果"); Assert.IsFalse(string.IsNullOrWhiteSpace(report.CoordinatorResult.Summary), "协调分析师应该生成总结报告"); + Assert.IsTrue(report.CoordinatorResult.Summary.Length >= 50, "总结报告应有足够的深度"); - Console.WriteLine($"标的 {assetSymbol} 分析完成 - 分析师数量: {report.AnalystMessages.Count}, 总结长度: {report.CoordinatorResult.Summary.Length} 字符"); + Assert.IsTrue( + report.CoordinatorResult.OverallScore is >= 1 and <= 10, + $"投资评分应在 1-10 范围内,实际值: {report.CoordinatorResult.OverallScore}"); } [TestMethod] + [TestCategory("E2E")] public async Task AnalyzeAsync_ShouldTriggerProgressEvents() { - // Arrange string assetSymbol = "000001"; var progressEvents = new List(); @@ -55,19 +59,34 @@ public async Task AnalyzeAsync_ShouldTriggerProgressEvents() progressEvents.Add(e.StageDescription); }; - // Act var report = await _workflow.AnalyzeAsync(assetSymbol); - // Assert Assert.IsNotNull(report); Assert.IsTrue(progressEvents.Count > 0, "应该触发进度事件"); + Assert.IsTrue(progressEvents.Count >= 3, "至少应有分派、聚合、协调三个阶段的进度事件"); - // 验证进度事件内容合理性 foreach (var evt in progressEvents) { Assert.IsFalse(string.IsNullOrWhiteSpace(evt), "阶段描述不应为空"); } + } + + [TestMethod] + [TestCategory("E2E")] + public async Task AnalyzeAsync_ReportShouldHaveQualityMetrics() + { + string assetSymbol = "000001"; + + var report = await _workflow.AnalyzeAsync(assetSymbol); + + Assert.IsNotNull(report); + Assert.IsNotNull(report.CoordinatorResult); + + Assert.IsTrue(report.CoordinatorResult.RiskFactors.Count > 0, "完整报告应包含风险评估"); + + Assert.IsTrue(report.CoordinatorResult.KeyIndicators.Count > 0, "完整报告应包含关键指标"); - Console.WriteLine($"进度事件触发 {progressEvents.Count} 次 - 所有事件内容有效"); + Assert.IsTrue(report.AnalystMessages.Count >= 2, + "应至少包含财务分析师和新闻事件分析师两位的结果(基于默认启用配置)"); } } diff --git a/tests/MarketChatSessionTest.cs b/tests/MarketChatSessionTest.cs index d27db18..2486cb8 100644 --- a/tests/MarketChatSessionTest.cs +++ b/tests/MarketChatSessionTest.cs @@ -13,14 +13,10 @@ public class MarketChatSessionTest : BaseAgentTest [TestInitialize] public void Initialize() { - BaseInitialize(); - + RequireLlm(); var logger = new Microsoft.Extensions.Logging.Abstractions.NullLogger(); - - // 使用 ChatClientFactory 创建 ChatClient var chatClientFactory = _serviceProvider.GetRequiredService(); var chatClient = chatClientFactory.CreateClient(); - _chatSession = new MarketChatSession(chatClient, logger); } @@ -31,30 +27,26 @@ public void Cleanup() } [TestMethod] + [TestCategory("Agent")] public async Task TestBasicChatAsync() { - // 测试基础对话功能(流式响应) var responseBuilder = new System.Text.StringBuilder(); - await foreach (var update in _chatSession.SendMessageStreamAsync("你好,请介绍股票投资的基础知识")) { responseBuilder.Append(update); } var responseText = responseBuilder.ToString(); - Assert.IsNotNull(responseText); - Assert.IsFalse(string.IsNullOrEmpty(responseText)); - - Console.WriteLine($"AI回复: {responseText}"); + Assert.IsFalse(string.IsNullOrWhiteSpace(responseText)); + Assert.IsTrue(responseText.Length > 20, "基础对话应返回有实质内容的回复"); } [TestMethod] + [TestCategory("Agent")] public async Task TestStockContextChatAsync() { - // 设置股票上下文 _chatSession.SetCurrentStock("sz002594"); - // 测试带有股票上下文的对话 var responseBuilder = new System.Text.StringBuilder(); await foreach (var update in _chatSession.SendMessageStreamAsync("这只股票的基本面如何?")) { @@ -62,16 +54,16 @@ public async Task TestStockContextChatAsync() } var responseText = responseBuilder.ToString(); - Assert.IsNotNull(responseText); - Assert.IsFalse(string.IsNullOrEmpty(responseText)); - - Console.WriteLine($"AI回复: {responseText}"); + Assert.IsFalse(string.IsNullOrWhiteSpace(responseText)); + Assert.IsTrue( + ContainsAnyKeyword(responseText, "002594", "比亚迪", "股票", "基本面", "估值", "财务", "盈利"), + "回复应体现当前股票上下文"); } [TestMethod] + [TestCategory("Agent")] public async Task TestConversationHistoryAsync() { - // 测试多轮对话 await foreach (var _ in _chatSession.SendMessageStreamAsync("什么是市盈率?")) { } await foreach (var _ in _chatSession.SendMessageStreamAsync("有何意义?")) { } @@ -82,62 +74,52 @@ public async Task TestConversationHistoryAsync() } var responseText = responseBuilder.ToString(); - Assert.IsNotNull(responseText); - Assert.IsFalse(string.IsNullOrEmpty(responseText)); + Assert.IsFalse(string.IsNullOrWhiteSpace(responseText)); - // 验证对话历史(异步获取) var history = await _chatSession.GetConversationHistoryAsync(); - Assert.IsTrue(history.Count > 0); - - Console.WriteLine($"对话历史条数: {history.Count}"); - Console.WriteLine($"最新回复: {responseText}"); + Assert.IsTrue(history.Count >= 6, "三轮对话应至少包含 3 条用户消息和 3 条助手回复"); + Assert.IsTrue( + ContainsAnyKeyword(responseText, "市盈率", "PE", "估值", "盈利", "市净率", "指标", "比率"), + "第三轮回复应延续前两轮讨论的估值指标话题"); } [TestMethod] + [TestCategory("Agent")] public async Task TestClearHistoryAsync() { - // 添加一些对话 await foreach (var _ in _chatSession.SendMessageStreamAsync("测试消息")) { } - // 验证有历史记录 var historyBefore = await _chatSession.GetConversationHistoryAsync(); Assert.IsTrue(historyBefore.Count > 0); - // 清除历史 _chatSession.ClearHistory(); - // 验证历史被清空 var historyAfter = await _chatSession.GetConversationHistoryAsync(); Assert.AreEqual(0, historyAfter.Count); } [TestMethod] + [TestCategory("Agent")] public async Task TestContextWindowManagementAsync() { - // 设置股票上下文 _chatSession.SetCurrentStock("sz002594"); - // 添加大量消息来测试上下文窗口管理 - for (int i = 0; i < 50; i++) + for (int i = 0; i < 10; i++) { await foreach (var _ in _chatSession.SendMessageStreamAsync($"这是第{i}次测试消息,关于sz002594的股票分析。")) { } } - // 测试对话历史是否被管理 var history = await _chatSession.GetConversationHistoryAsync(); Assert.IsTrue(history.Count > 0); Assert.AreEqual("sz002594", _chatSession.CurrentStockCode); - - Console.WriteLine($"消息数: {history.Count}"); } [TestMethod] + [TestCategory("Agent")] public async Task TestTopicGuidanceAsync() { - // 设置股票上下文 _chatSession.SetCurrentStock("sz002594"); - // 询问与股票无关的消息,测试AI是否能自然地引导回相关话题 var responseBuilder = new System.Text.StringBuilder(); await foreach (var update in _chatSession.SendMessageStreamAsync("今天的天气怎么样")) { @@ -145,20 +127,18 @@ public async Task TestTopicGuidanceAsync() } var responseText = responseBuilder.ToString(); - Assert.IsNotNull(responseText); - Assert.IsFalse(string.IsNullOrEmpty(responseText)); - // AI应该能够自然地回复用户或引导回股票话题 - - Console.WriteLine($"AI的回复: {responseText}"); + Assert.IsFalse(string.IsNullOrWhiteSpace(responseText)); + Assert.IsTrue( + ContainsAnyKeyword(responseText, "股票", "市场", "投资", "002594", "比亚迪", "标的", "分析", "金融市场"), + "偏离话题的提问应被引导回股票或市场相关讨论"); } [TestMethod] + [TestCategory("Agent")] public async Task TestStreamingResponseAsync() { - // 设置股票上下文 _chatSession.SetCurrentStock("sz000001"); - // 测试流式响应 var allContent = new List(); await foreach (var update in _chatSession.SendMessageStreamAsync("分析sz000001的技术指标")) { @@ -168,55 +148,64 @@ public async Task TestStreamingResponseAsync() } } - Assert.IsTrue(allContent.Count > 0); - - var fullResponse = string.Join("", allContent); - Console.WriteLine($"流式响应完整内容: {fullResponse}"); + Assert.IsTrue(allContent.Count > 1, "流式响应应产生多个内容块"); } [TestMethod] - public void TestCancellationAsync() + [TestCategory("Agent")] + public async Task TestCancellationAsync() { - var cts = new CancellationTokenSource(); + Assert.IsFalse(_chatSession.IsProcessing, "新会话初始不应处于处理中状态"); + + var history = await _chatSession.GetConversationHistoryAsync(); + Assert.AreEqual(0, history.Count, "新会话初始对话历史应为空"); - // 立即取消 _chatSession.StopCurrentRequest(); - cts.Cancel(); + Assert.IsFalse(_chatSession.IsProcessing, "空闲会话调用 StopCurrentRequest 后仍不应处于处理中状态"); - // 验证取消状态 - Assert.IsTrue(_chatSession.IsProcessing == false); + _chatSession.SetCurrentStock("sz002594"); + _chatSession.StopCurrentRequest(); + Assert.AreEqual("sz002594", _chatSession.CurrentStockCode); + Assert.IsFalse(_chatSession.IsProcessing, "设置股票上下文不应改变处理状态"); } [TestMethod] + [TestCategory("Agent")] public async Task TestIntelligentAnalysisAsync() { - // 设置股票上下文 _chatSession.SetCurrentStock("sz002594"); - // 测试AI能否智能调用可能的插件来回答深度问题 var response1Builder = new System.Text.StringBuilder(); await foreach (var update in _chatSession.SendMessageStreamAsync("分析MACD和RSI指标")) { response1Builder.Append(update); } - Assert.IsFalse(string.IsNullOrEmpty(response1Builder.ToString())); + var response1 = response1Builder.ToString(); + Assert.IsTrue(response1.Length > 50, "技术分析回复应有足够深度"); var response2Builder = new System.Text.StringBuilder(); await foreach (var update in _chatSession.SendMessageStreamAsync("这家公司的ROE和净利润如何?")) { response2Builder.Append(update); } - Assert.IsFalse(string.IsNullOrEmpty(response2Builder.ToString())); + var response2 = response2Builder.ToString(); + Assert.IsTrue(response2.Length > 50, "财务分析回复应有足够深度"); var response3Builder = new System.Text.StringBuilder(); await foreach (var update in _chatSession.SendMessageStreamAsync("投资这只股票有什么风险?")) { response3Builder.Append(update); } - Assert.IsFalse(string.IsNullOrEmpty(response3Builder.ToString())); + var response3 = response3Builder.ToString(); + Assert.IsTrue(response3.Length > 50, "风险分析回复应有足够深度"); - Console.WriteLine($"技术分析回复: {response1Builder}"); - Console.WriteLine($"财务分析回复: {response2Builder}"); - Console.WriteLine($"风险分析回复: {response3Builder}"); + var history = await _chatSession.GetConversationHistoryAsync(); + Assert.IsTrue(history.Count >= 6, "三轮分析对话应累积至少 6 条历史消息"); + } + + private static bool ContainsAnyKeyword(string text, params string[] keywords) + { + return keywords.Any(keyword => + text.Contains(keyword, StringComparison.OrdinalIgnoreCase)); } -} \ No newline at end of file +} diff --git a/tests/McpServiceTest.cs b/tests/McpServiceTest.cs index 3c1a8d2..9be3dd3 100644 --- a/tests/McpServiceTest.cs +++ b/tests/McpServiceTest.cs @@ -11,10 +11,10 @@ namespace TestMarketAssistant; public class McpServiceTest { [TestMethod] + [TestCategory("Integration")] [Timeout(120000)] public async Task GetAITools_Stdio_Success() { - // Arrange var config = new MCPServerConfig { Name = "mcp-server-filesystem", @@ -26,17 +26,15 @@ public async Task GetAITools_Stdio_Success() var service = new McpService(NullLogger.Instance); - // Act var tools = await service.GetAIToolsAsync([config]); - // Assert Assert.IsTrue(tools.Count > 0, "应该返回至少一个 AI 工具"); } [TestMethod] + [TestCategory("Unit")] public void CreateClientTransport_InvalidType_ThrowsException() { - // Arrange var config = new MCPServerConfig { Name = "invalid-server", @@ -44,7 +42,6 @@ public void CreateClientTransport_InvalidType_ThrowsException() Command = "test" }; - // Act & Assert Assert.ThrowsExactly(() => { McpService.CreateClientTransport(config); @@ -52,6 +49,7 @@ public void CreateClientTransport_InvalidType_ThrowsException() } [TestMethod] + [TestCategory("Unit")] public void GetEnabledConfigs_ReturnsOnlyEnabled() { var configService = new MCPServerConfigService(); @@ -64,19 +62,17 @@ public void GetEnabledConfigs_ReturnsOnlyEnabled() var service = new McpService(NullLogger.Instance, null, configService); - // Act var configs = service.GetEnabledConfigs(); - // Assert Assert.IsNotNull(configs); Assert.IsTrue(configs.All(c => c.IsEnabled), "应该只返回启用的配置"); } [TestMethod] + [TestCategory("Integration")] [Timeout(120000)] public async Task GetAITools_WithLifetimeManagement_Success() { - // Arrange var config = new MCPServerConfig { Name = "mcp-server-test", @@ -88,27 +84,20 @@ public async Task GetAITools_WithLifetimeManagement_Success() await using var service = new McpService(NullLogger.Instance); - // Act var tools = await service.GetAIToolsAsync([config]); - // Assert Assert.IsTrue(tools.Count > 0); - - // Cleanup - service 会自动释放客户端 } [TestMethod] + [TestCategory("Unit")] [Timeout(120000)] public async Task GetAITools_EmptyConfigs_ReturnsEmpty() { - // Arrange var service = new McpService(NullLogger.Instance); - // Act var tools = await service.GetAIToolsAsync([]); - // Assert Assert.AreEqual(0, tools.Count, "空配置列表应该返回空工具列表"); } } - diff --git a/tests/Middleware/ConversationCompressionMiddlewareTest.cs b/tests/Middleware/ConversationCompressionMiddlewareTest.cs new file mode 100644 index 0000000..62471d1 --- /dev/null +++ b/tests/Middleware/ConversationCompressionMiddlewareTest.cs @@ -0,0 +1,61 @@ +using MarketAssistant.Agents.Middleware; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace TestMarketAssistant.Middleware; + +[TestClass] +public class ConversationCompressionMiddlewareTest +{ + [TestMethod] + [TestCategory("Unit")] + public void Constructor_ShouldSetDefaults() + { + var middleware = CreateMiddleware(); + + Assert.AreEqual(8000, middleware.MaxTokens); + Assert.AreEqual(4, middleware.ReserveRecentCount); + } + + [TestMethod] + [TestCategory("Unit")] + public void MaxTokens_CanBeChanged() + { + var middleware = CreateMiddleware(); + + middleware.MaxTokens = 4000; + + Assert.AreEqual(4000, middleware.MaxTokens); + } + + [TestMethod] + [TestCategory("Unit")] + public void ReserveRecentCount_CanBeChanged() + { + var middleware = CreateMiddleware(); + + middleware.ReserveRecentCount = 6; + + Assert.AreEqual(6, middleware.ReserveRecentCount); + } + + [TestMethod] + [TestCategory("Unit")] + public void PreCompressHook_DefaultNull() + { + var middleware = CreateMiddleware(); + + Assert.IsNull(middleware.PreCompressHook); + } + + private static ConversationCompressionMiddleware CreateMiddleware() + { + var chatClientFactory = new Mock>(); + chatClientFactory.Setup(f => f()).Returns(new Mock().Object); + + return new ConversationCompressionMiddleware( + chatClientFactory.Object, + NullLogger.Instance); + } +} diff --git a/tests/Middleware/TokenTrackingMiddlewareTest.cs b/tests/Middleware/TokenTrackingMiddlewareTest.cs new file mode 100644 index 0000000..de88f0a --- /dev/null +++ b/tests/Middleware/TokenTrackingMiddlewareTest.cs @@ -0,0 +1,52 @@ +using MarketAssistant.Agents.Middleware; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace TestMarketAssistant.Middleware; + +[TestClass] +public class TokenTrackingMiddlewareTest +{ + [TestMethod] + [TestCategory("Unit")] + public void GetCumulativeTokens_NullSession_ReturnsZeros() + { + var (input, output) = TokenTrackingMiddleware.GetCumulativeTokens(null); + + Assert.AreEqual(0, input); + Assert.AreEqual(0, output); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task GetCumulativeTokens_EmptyStateBag_ReturnsZeros() + { + var chatClient = new Mock().Object; + var agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions { Name = "TestAgent" }); + var session = await agent.CreateSessionAsync(); + + var (input, output) = TokenTrackingMiddleware.GetCumulativeTokens(session); + + Assert.AreEqual(0, input); + Assert.AreEqual(0, output); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task GetCumulativeTokens_WithValues_ReturnsCorrectly() + { + var chatClient = new Mock().Object; + var agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions { Name = "TestAgent" }); + var session = await agent.CreateSessionAsync(); + + session.StateBag.SetValue(TokenTrackingMiddleware.InputTokensKey, "150"); + session.StateBag.SetValue(TokenTrackingMiddleware.OutputTokensKey, "75"); + + var (input, output) = TokenTrackingMiddleware.GetCumulativeTokens(session); + + Assert.AreEqual(150, input); + Assert.AreEqual(75, output); + } +} diff --git a/tests/Middleware/TradingFunctionGuardMiddlewareTest.cs b/tests/Middleware/TradingFunctionGuardMiddlewareTest.cs new file mode 100644 index 0000000..b7e3a75 --- /dev/null +++ b/tests/Middleware/TradingFunctionGuardMiddlewareTest.cs @@ -0,0 +1,68 @@ +using MarketAssistant.Agents.Middleware; +using Microsoft.Extensions.Logging.Abstractions; +using System.Reflection; + +namespace TestMarketAssistant.Middleware; + +[TestClass] +public class TradingFunctionGuardMiddlewareTest +{ + [TestMethod] + [TestCategory("Unit")] + public void Constructor_DefaultMaxToolCalls_ShouldBe20() + { + var middleware = new TradingFunctionGuardMiddleware( + NullLogger.Instance); + + var maxToolCalls = GetPrivateField(middleware, "_maxToolCalls"); + + Assert.AreEqual(20, maxToolCalls); + } + + [TestMethod] + [TestCategory("Unit")] + public void Constructor_CustomMaxToolCalls_ShouldAccept() + { + var middleware = new TradingFunctionGuardMiddleware( + NullLogger.Instance, + maxToolCalls: 5); + + var maxToolCalls = GetPrivateField(middleware, "_maxToolCalls"); + + Assert.AreEqual(5, maxToolCalls); + } + + [TestMethod] + [TestCategory("Unit")] + public void ResetCallCount_ShouldResetToZero() + { + var middleware = new TradingFunctionGuardMiddleware( + NullLogger.Instance); + + var countField = typeof(TradingFunctionGuardMiddleware).GetField( + "_toolCallCount", + BindingFlags.NonPublic | BindingFlags.Instance)!; + countField.SetValue(middleware, 12); + + middleware.ResetCallCount(); + + Assert.AreEqual(0, countField.GetValue(middleware)); + } + + [TestMethod] + [TestCategory("Unit")] + public void ConfirmationCallback_DefaultNull() + { + var middleware = new TradingFunctionGuardMiddleware( + NullLogger.Instance); + + Assert.IsNull(middleware.ConfirmationCallback); + } + + private static T GetPrivateField(object instance, string fieldName) + { + var field = instance.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance); + Assert.IsNotNull(field, $"Field '{fieldName}' not found."); + return (T)field!.GetValue(instance)!; + } +} diff --git a/tests/StockSymbolConverterTest.cs b/tests/StockSymbolConverterTest.cs index 152178d..3b86722 100644 --- a/tests/StockSymbolConverterTest.cs +++ b/tests/StockSymbolConverterTest.cs @@ -7,6 +7,7 @@ namespace Tests; public class StockSymbolConverterTest { [TestMethod] + [TestCategory("Unit")] [DataRow("600000", "sh600000")] [DataRow("SH600000", "sh600000")] [DataRow("sh600000", "sh600000")] @@ -22,6 +23,7 @@ public void ToClsFormat_ShouldReturnCorrectFormat(string input, string expected) } [TestMethod] + [TestCategory("Unit")] [DataRow("600000", "600000.SH")] [DataRow("000001", "000001.SZ")] [DataRow("688001", "688001.SH")] @@ -43,6 +45,7 @@ public void ToZhiTuFormat_ShouldReturnCorrectFormat_ForNumericCode(string input, } [TestMethod] + [TestCategory("Unit")] [DataRow("sh600000", "600000.SH")] [DataRow("sz000001", "000001.SZ")] [DataRow("SH688001", "688001.SH")] @@ -54,6 +57,7 @@ public void ToZhiTuFormat_ShouldReturnCorrectFormat_ForPrefixedCode(string input } [TestMethod] + [TestCategory("Unit")] [DataRow("600000.SH", "600000.SH")] [DataRow("000001.sz", "000001.SZ")] [DataRow("688001.sh", "688001.SH")] diff --git a/tests/TestMarketAssistant.csproj b/tests/TestMarketAssistant.csproj index c885bc6..fa1dd67 100644 --- a/tests/TestMarketAssistant.csproj +++ b/tests/TestMarketAssistant.csproj @@ -11,6 +11,9 @@ + + + diff --git a/tests/Tools/BasicDataToolsTest.cs b/tests/Tools/BasicDataToolsTest.cs index 622ccce..7600d41 100644 --- a/tests/Tools/BasicDataToolsTest.cs +++ b/tests/Tools/BasicDataToolsTest.cs @@ -47,6 +47,7 @@ public async Task Cleanup() #region A股基础数据测试 [TestMethod] + [TestCategory("Integration")] public async Task GetAssetInfoAsync_AShare_ShouldReturnValidQuoteInfo() { // Arrange @@ -63,6 +64,7 @@ public async Task GetAssetInfoAsync_AShare_ShouldReturnValidQuoteInfo() } [TestMethod] + [TestCategory("Integration")] public async Task GetCompanyInfoAsync_AShare_ShouldReturnValidCompanyInfo() { // Arrange @@ -80,6 +82,7 @@ public async Task GetCompanyInfoAsync_AShare_ShouldReturnValidCompanyInfo() #region 虚拟币基础数据测试 [TestMethod] + [TestCategory("Integration")] public async Task GetAssetInfoAsync_Crypto_ShouldReturnValidQuoteInfo() { // Arrange @@ -95,6 +98,7 @@ public async Task GetAssetInfoAsync_Crypto_ShouldReturnValidQuoteInfo() } [TestMethod] + [TestCategory("Integration")] public async Task GetProjectInfoAsync_Crypto_ShouldReturnValidInfo() { // Arrange diff --git a/tests/Tools/CryptoMetricsToolsTest.cs b/tests/Tools/CryptoMetricsToolsTest.cs index 5ca37d0..03ded5f 100644 --- a/tests/Tools/CryptoMetricsToolsTest.cs +++ b/tests/Tools/CryptoMetricsToolsTest.cs @@ -39,6 +39,7 @@ public async Task Cleanup() } [TestMethod] + [TestCategory("Integration")] public async Task GetVolumeDistributionAsync_ShouldReturnValidData() { var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); @@ -51,6 +52,7 @@ public async Task GetVolumeDistributionAsync_ShouldReturnValidData() } [TestMethod] + [TestCategory("Integration")] public void GetFunctions_ShouldReturnValidAIFunctions() { var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); @@ -62,6 +64,7 @@ public void GetFunctions_ShouldReturnValidAIFunctions() } [TestMethod] + [TestCategory("Integration")] public async Task GetOHLCVAsync_ShouldReturnValidData() { var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); @@ -76,6 +79,7 @@ public async Task GetOHLCVAsync_ShouldReturnValidData() } [TestMethod] + [TestCategory("Integration")] public async Task GetOrderBookDepthAsync_ShouldReturnValidData() { var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); @@ -92,6 +96,7 @@ public async Task GetOrderBookDepthAsync_ShouldReturnValidData() } [TestMethod] + [TestCategory("Integration")] public async Task GetRecentTradesAsync_ShouldReturnValidData() { var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); @@ -106,6 +111,7 @@ public async Task GetRecentTradesAsync_ShouldReturnValidData() } [TestMethod] + [TestCategory("Integration")] public async Task GetMarketMetricsAsync_ShouldReturnValidData() { var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); @@ -121,6 +127,7 @@ public async Task GetMarketMetricsAsync_ShouldReturnValidData() } [TestMethod] + [TestCategory("Integration")] public async Task GetVolatilityMetricsAsync_ShouldReturnValidData() { var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); diff --git a/tests/Tools/CryptoTechnicalToolsTest.cs b/tests/Tools/CryptoTechnicalToolsTest.cs index dd195e9..dd1e905 100644 --- a/tests/Tools/CryptoTechnicalToolsTest.cs +++ b/tests/Tools/CryptoTechnicalToolsTest.cs @@ -11,6 +11,7 @@ namespace TestMarketAssistant.Tools; public class CryptoTechnicalToolsTest { [TestMethod] + [TestCategory("Unit")] public async Task TechnicalIndicators_ShouldReturnStructuredValues() { var services = new ServiceCollection(); diff --git a/tests/Tools/FinancialDataToolsTest.cs b/tests/Tools/FinancialDataToolsTest.cs index efc6c08..20f220c 100644 --- a/tests/Tools/FinancialDataToolsTest.cs +++ b/tests/Tools/FinancialDataToolsTest.cs @@ -44,6 +44,7 @@ public async Task Cleanup() #region A股财务数据测试 [TestMethod] + [TestCategory("Integration")] public async Task GetBalanceSheetAsync_AShare_ShouldReturnValidData() { // Arrange @@ -58,6 +59,7 @@ public async Task GetBalanceSheetAsync_AShare_ShouldReturnValidData() } [TestMethod] + [TestCategory("Integration")] public async Task GetIncomeStatementAsync_AShare_ShouldReturnValidData() { // Arrange @@ -72,6 +74,7 @@ public async Task GetIncomeStatementAsync_AShare_ShouldReturnValidData() } [TestMethod] + [TestCategory("Integration")] public async Task GetCashFlowStatementAsync_AShare_ShouldReturnValidData() { // Arrange @@ -86,6 +89,7 @@ public async Task GetCashFlowStatementAsync_AShare_ShouldReturnValidData() } [TestMethod] + [TestCategory("Integration")] public async Task GetFinancialRatiosAsync_AShare_ShouldReturnValidData() { // Arrange @@ -100,6 +104,7 @@ public async Task GetFinancialRatiosAsync_AShare_ShouldReturnValidData() } [TestMethod] + [TestCategory("Integration")] public async Task GetCapitalStructureAsync_AShare_ShouldReturnValidData() { // Arrange diff --git a/tests/Tools/NewsDataToolsTest.cs b/tests/Tools/NewsDataToolsTest.cs index 941d107..7c6d97a 100644 --- a/tests/Tools/NewsDataToolsTest.cs +++ b/tests/Tools/NewsDataToolsTest.cs @@ -51,6 +51,7 @@ public async Task Cleanup() #region A股新闻数据测试 [TestMethod] + [TestCategory("Integration")] public async Task GetNewsAsync_AShare_ShouldReturnValidData() { // Arrange @@ -68,6 +69,7 @@ public async Task GetNewsAsync_AShare_ShouldReturnValidData() #region 虚拟币新闻数据测试 [TestMethod] + [TestCategory("Integration")] public async Task GetNewsAsync_Crypto_ShouldReturnValidData() { // Arrange diff --git a/tests/Tools/SentimentToolsTest.cs b/tests/Tools/SentimentToolsTest.cs index 8063007..b90fe40 100644 --- a/tests/Tools/SentimentToolsTest.cs +++ b/tests/Tools/SentimentToolsTest.cs @@ -49,6 +49,7 @@ public async Task Cleanup() #region A股情绪数据测试 [TestMethod] + [TestCategory("Integration")] public async Task GetFundFlowAsync_AShare_ShouldReturnValidData() { // Arrange @@ -66,6 +67,7 @@ public async Task GetFundFlowAsync_AShare_ShouldReturnValidData() #region 虚拟币情绪数据测试 [TestMethod] + [TestCategory("Integration")] public async Task GetFundingRateAsync_Crypto_ShouldReturnValidData() { // Arrange @@ -93,6 +95,7 @@ public async Task GetFundingRateAsync_Crypto_ShouldReturnValidData() } [TestMethod] + [TestCategory("Integration")] public async Task GetGlobalLongShortRatioAsync_Crypto_ShouldReturnValidData() { // Arrange @@ -108,6 +111,7 @@ public async Task GetGlobalLongShortRatioAsync_Crypto_ShouldReturnValidData() } [TestMethod] + [TestCategory("Integration")] public async Task GetTopTraderAccountRatioAsync_Crypto_ShouldReturnValidData() { // Arrange @@ -123,6 +127,7 @@ public async Task GetTopTraderAccountRatioAsync_Crypto_ShouldReturnValidData() } [TestMethod] + [TestCategory("Integration")] public async Task GetTopTraderPositionRatioAsync_Crypto_ShouldReturnValidData() { // Arrange @@ -138,6 +143,7 @@ public async Task GetTopTraderPositionRatioAsync_Crypto_ShouldReturnValidData() } [TestMethod] + [TestCategory("Integration")] public async Task GetOpenInterestAsync_Crypto_ShouldReturnValidData() { // Arrange diff --git a/tests/Tools/TechnicalDataToolsTest.cs b/tests/Tools/TechnicalDataToolsTest.cs index 6756671..e0f7f02 100644 --- a/tests/Tools/TechnicalDataToolsTest.cs +++ b/tests/Tools/TechnicalDataToolsTest.cs @@ -53,6 +53,7 @@ public async Task Cleanup() #region A股技术数据测试 [TestMethod] + [TestCategory("Integration")] public async Task GetKDJAsync_AShare_ShouldReturnValidData() { // Arrange @@ -66,6 +67,7 @@ public async Task GetKDJAsync_AShare_ShouldReturnValidData() } [TestMethod] + [TestCategory("Integration")] public async Task GetMACDAsync_AShare_ShouldReturnValidData() { // Arrange @@ -79,6 +81,7 @@ public async Task GetMACDAsync_AShare_ShouldReturnValidData() } [TestMethod] + [TestCategory("Integration")] public async Task GetBOLLAsync_AShare_ShouldReturnValidData() { // Arrange @@ -92,6 +95,7 @@ public async Task GetBOLLAsync_AShare_ShouldReturnValidData() } [TestMethod] + [TestCategory("Integration")] public async Task GetMAAsync_AShare_ShouldReturnValidData() { // Arrange @@ -109,6 +113,7 @@ public async Task GetMAAsync_AShare_ShouldReturnValidData() #region 虚拟币技术数据测试 [TestMethod] + [TestCategory("Integration")] public async Task GetKDJAsync_Crypto_ShouldReturnValidData() { // Arrange @@ -122,6 +127,7 @@ public async Task GetKDJAsync_Crypto_ShouldReturnValidData() } [TestMethod] + [TestCategory("Integration")] public async Task GetMACDAsync_Crypto_ShouldReturnValidData() { // Arrange @@ -135,6 +141,7 @@ public async Task GetMACDAsync_Crypto_ShouldReturnValidData() } [TestMethod] + [TestCategory("Integration")] public async Task GetBOLLAsync_Crypto_ShouldReturnValidData() { // Arrange @@ -148,6 +155,7 @@ public async Task GetBOLLAsync_Crypto_ShouldReturnValidData() } [TestMethod] + [TestCategory("Integration")] public async Task GetMAAsync_Crypto_ShouldReturnValidData() { // Arrange diff --git a/tests/Vectors/ClipImageEmbeddingServiceTest.cs b/tests/Vectors/ClipImageEmbeddingServiceTest.cs index 83300bc..d008c81 100644 --- a/tests/Vectors/ClipImageEmbeddingServiceTest.cs +++ b/tests/Vectors/ClipImageEmbeddingServiceTest.cs @@ -52,6 +52,7 @@ public void Cleanup() #region 图像嵌入向量生成测试 [TestMethod] + [TestCategory("Unit")] public async Task GenerateAsync_WithValidImage_ShouldReturnVector() { // Act @@ -64,6 +65,7 @@ public async Task GenerateAsync_WithValidImage_ShouldReturnVector() } [TestMethod] + [TestCategory("Unit")] public async Task GenerateAsync_WithSameImage_ShouldReturnConsistentVector() { // Arrange @@ -86,6 +88,7 @@ public async Task GenerateAsync_WithSameImage_ShouldReturnConsistentVector() } [TestMethod] + [TestCategory("Unit")] public async Task GenerateAsync_WithInvalidImageData_ShouldUseFallback() { // Arrange - 无效的图像数据 @@ -101,6 +104,7 @@ public async Task GenerateAsync_WithInvalidImageData_ShouldUseFallback() } [TestMethod] + [TestCategory("Unit")] public async Task GenerateAsync_WithCancellation_ShouldComplete() { // Arrange @@ -117,6 +121,7 @@ public async Task GenerateAsync_WithCancellation_ShouldComplete() #region 图像描述生成测试 [TestMethod] + [TestCategory("Unit")] public async Task GenerateCaptionAsync_WithoutChatService_ShouldReturnFallback() { // Act @@ -131,6 +136,7 @@ public async Task GenerateCaptionAsync_WithoutChatService_ShouldReturnFallback() #region 资源管理和配置测试 [TestMethod] + [TestCategory("Unit")] public void Dispose_ShouldReleaseResources() { // Arrange @@ -145,6 +151,7 @@ public void Dispose_ShouldReleaseResources() } [TestMethod] + [TestCategory("Unit")] public async Task MultipleOperations_ShouldWorkCorrectly() { // Arrange diff --git a/tests/Vectors/DocumentBlockMapperTest.cs b/tests/Vectors/DocumentBlockMapperTest.cs index d4982a7..cf50146 100644 --- a/tests/Vectors/DocumentBlockMapperTest.cs +++ b/tests/Vectors/DocumentBlockMapperTest.cs @@ -22,6 +22,7 @@ public void Setup() } [TestMethod] + [TestCategory("Unit")] public void MapBlock_TextBlock_ShouldCreateCorrectParagraphsWithConsistentIds() { // Arrange @@ -52,6 +53,7 @@ public void MapBlock_TextBlock_ShouldCreateCorrectParagraphsWithConsistentIds() } [TestMethod] + [TestCategory("Unit")] public void MapBlock_HeadingBlock_ShouldCreateCorrectParagraph() { // Arrange @@ -77,6 +79,7 @@ public void MapBlock_HeadingBlock_ShouldCreateCorrectParagraph() } [TestMethod] + [TestCategory("Unit")] public void MapBlock_ListBlock_ShouldCreateCorrectParagraph() { // Arrange @@ -101,6 +104,7 @@ public void MapBlock_ListBlock_ShouldCreateCorrectParagraph() } [TestMethod] + [TestCategory("Unit")] public void MapBlock_TableBlock_ShouldCreateCorrectParagraph() { // Arrange @@ -130,6 +134,7 @@ public void MapBlock_TableBlock_ShouldCreateCorrectParagraph() } [TestMethod] + [TestCategory("Unit")] public void MapBlock_ImageBlock_ShouldCreateCorrectParagraph() { // Arrange diff --git a/tests/Vectors/DocxFileTest.cs b/tests/Vectors/DocxFileTest.cs index 1366985..72b68fd 100644 --- a/tests/Vectors/DocxFileTest.cs +++ b/tests/Vectors/DocxFileTest.cs @@ -57,6 +57,7 @@ private static string GetTestProjectDirectory() #region Converter Tests [TestMethod] + [TestCategory("Unit")] public void DocxMarkdownConverter_ShouldHandleDocxFiles() { // Arrange & Act & Assert @@ -66,6 +67,7 @@ public void DocxMarkdownConverter_ShouldHandleDocxFiles() } [TestMethod] + [TestCategory("Unit")] public async Task DocxMarkdownConverter_ShouldConvertRealDocxFile() { // Act @@ -84,6 +86,7 @@ public async Task DocxMarkdownConverter_ShouldConvertRealDocxFile() } [TestMethod] + [TestCategory("Unit")] public async Task DocxMarkdownConverter_ShouldHandleDocumentStructure() { // Act @@ -111,6 +114,7 @@ public async Task DocxMarkdownConverter_ShouldHandleDocumentStructure() #region Reader Tests [TestMethod] + [TestCategory("Unit")] public void DocxBlockReader_ShouldHandleDocxFiles() { // Arrange & Act & Assert @@ -120,6 +124,7 @@ public void DocxBlockReader_ShouldHandleDocxFiles() } [TestMethod] + [TestCategory("Unit")] public async Task DocxBlockReader_ShouldReadDocumentBlocks() { // Act @@ -147,6 +152,7 @@ public async Task DocxBlockReader_ShouldReadDocumentBlocks() } [TestMethod] + [TestCategory("Unit")] public async Task DocxBlockReader_ShouldPreserveBlockOrder() { // Act @@ -165,6 +171,7 @@ public async Task DocxBlockReader_ShouldPreserveBlockOrder() } [TestMethod] + [TestCategory("Unit")] public async Task DocxBlockReader_ShouldExtractTextContent() { // Act @@ -189,6 +196,7 @@ public async Task DocxBlockReader_ShouldExtractTextContent() } [TestMethod] + [TestCategory("Unit")] public async Task DocxBlockReader_ShouldHandleDifferentBlockTypes() { // Act @@ -213,6 +221,7 @@ public async Task DocxBlockReader_ShouldHandleDifferentBlockTypes() } [TestMethod] + [TestCategory("Unit")] public async Task DocxBlockReader_ShouldHandleEmptyOrCorruptFile() { // Arrange @@ -247,6 +256,7 @@ public async Task DocxBlockReader_ShouldHandleEmptyOrCorruptFile() } [TestMethod] + [TestCategory("Unit")] public async Task DocxBlockReader_ShouldHandleNonExistentFile() { // Arrange diff --git a/tests/Vectors/MarkdownDocumentBlockReaderTest.cs b/tests/Vectors/MarkdownDocumentBlockReaderTest.cs index 8cf5f82..6c9a5ec 100644 --- a/tests/Vectors/MarkdownDocumentBlockReaderTest.cs +++ b/tests/Vectors/MarkdownDocumentBlockReaderTest.cs @@ -86,6 +86,7 @@ public void Cleanup() } [TestMethod] + [TestCategory("Unit")] public async Task ReadBlocksAsync_ShouldExtractImageBlocks() { // Arrange & Act @@ -124,6 +125,7 @@ public async Task ReadBlocksAsync_ShouldExtractImageBlocks() } [TestMethod] + [TestCategory("Unit")] public async Task ReadBlocksAsync_ShouldExtractHeadingBlocks() { // Arrange & Act @@ -141,6 +143,7 @@ public async Task ReadBlocksAsync_ShouldExtractHeadingBlocks() } [TestMethod] + [TestCategory("Unit")] public async Task ReadBlocksAsync_ShouldExtractTableBlocks() { // Arrange & Act @@ -157,6 +160,7 @@ public async Task ReadBlocksAsync_ShouldExtractTableBlocks() } [TestMethod] + [TestCategory("Unit")] public async Task ReadBlocksAsync_ShouldExtractListBlocks() { // Arrange & Act @@ -174,6 +178,7 @@ public async Task ReadBlocksAsync_ShouldExtractListBlocks() } [TestMethod] + [TestCategory("Unit")] public async Task ReadBlocksAsync_ShouldExtractTextBlocks() { // Arrange & Act @@ -190,6 +195,7 @@ public async Task ReadBlocksAsync_ShouldExtractTextBlocks() } [TestMethod] + [TestCategory("Unit")] public async Task ReadBlocksAsync_ShouldMaintainCorrectOrder() { // Arrange & Act diff --git a/tests/Vectors/PdfFileTest.cs b/tests/Vectors/PdfFileTest.cs index 2e22d74..f043cd3 100644 --- a/tests/Vectors/PdfFileTest.cs +++ b/tests/Vectors/PdfFileTest.cs @@ -57,6 +57,7 @@ private static string GetTestProjectDirectory() #region Converter Tests [TestMethod] + [TestCategory("Unit")] public void PdfMarkdownConverter_ShouldHandlePdfFiles() { // Arrange & Act & Assert @@ -66,6 +67,7 @@ public void PdfMarkdownConverter_ShouldHandlePdfFiles() } [TestMethod] + [TestCategory("Unit")] public async Task PdfMarkdownConverter_ShouldConvertRealPdfFile() { // Act @@ -84,6 +86,7 @@ public async Task PdfMarkdownConverter_ShouldConvertRealPdfFile() } [TestMethod] + [TestCategory("Unit")] public async Task PdfMarkdownConverter_ShouldHandleDocumentStructure() { // Act @@ -107,6 +110,7 @@ public async Task PdfMarkdownConverter_ShouldHandleDocumentStructure() } [TestMethod] + [TestCategory("Unit")] public async Task PdfMarkdownConverter_ShouldPreserveTextFormatting() { // Act @@ -129,6 +133,7 @@ public async Task PdfMarkdownConverter_ShouldPreserveTextFormatting() #region Reader Tests [TestMethod] + [TestCategory("Unit")] public void PdfBlockReader_ShouldHandlePdfFiles() { // Arrange & Act & Assert @@ -138,6 +143,7 @@ public void PdfBlockReader_ShouldHandlePdfFiles() } [TestMethod] + [TestCategory("Unit")] public async Task PdfBlockReader_ShouldReadDocumentBlocks() { // Act @@ -165,6 +171,7 @@ public async Task PdfBlockReader_ShouldReadDocumentBlocks() } [TestMethod] + [TestCategory("Unit")] public async Task PdfBlockReader_ShouldPreserveBlockOrder() { // Act @@ -183,6 +190,7 @@ public async Task PdfBlockReader_ShouldPreserveBlockOrder() } [TestMethod] + [TestCategory("Unit")] public async Task PdfBlockReader_ShouldExtractTextContent() { // Act @@ -207,6 +215,7 @@ public async Task PdfBlockReader_ShouldExtractTextContent() } [TestMethod] + [TestCategory("Unit")] public async Task PdfBlockReader_ShouldHandleEmptyOrCorruptFile() { // Arrange @@ -241,6 +250,7 @@ public async Task PdfBlockReader_ShouldHandleEmptyOrCorruptFile() } [TestMethod] + [TestCategory("Unit")] public async Task PdfBlockReader_ShouldHandleNonExistentFile() { // Arrange diff --git a/tests/Vectors/QueryRewriteServiceTest.cs b/tests/Vectors/QueryRewriteServiceTest.cs index 2bd18ff..a15c887 100644 --- a/tests/Vectors/QueryRewriteServiceTest.cs +++ b/tests/Vectors/QueryRewriteServiceTest.cs @@ -18,6 +18,7 @@ public void Initialize() #region Service Resolution Tests [TestMethod] + [TestCategory("Unit")] public void Service_ShouldBeResolvedFromContainer() { // Assert @@ -30,6 +31,7 @@ public void Service_ShouldBeResolvedFromContainer() [TestMethod] + [TestCategory("Unit")] public void Rewrite_WithWhitespaceQuery_ShouldReturnEmptyList() { // Act @@ -41,6 +43,7 @@ public void Rewrite_WithWhitespaceQuery_ShouldReturnEmptyList() } [TestMethod] + [TestCategory("Unit")] public void Rewrite_WithZeroMaxCandidates_ShouldReturnEmptyList() { // Act @@ -52,6 +55,7 @@ public void Rewrite_WithZeroMaxCandidates_ShouldReturnEmptyList() } [TestMethod] + [TestCategory("Unit")] public void Rewrite_WithNegativeMaxCandidates_ShouldReturnEmptyList() { // Act @@ -67,6 +71,7 @@ public void Rewrite_WithNegativeMaxCandidates_ShouldReturnEmptyList() #region Functional Tests [TestMethod] + [TestCategory("Unit")] public void Rewrite_WithValidQuery_ShouldReturnRewrittenQueries() { // Arrange @@ -90,6 +95,7 @@ public void Rewrite_WithValidQuery_ShouldReturnRewrittenQueries() } [TestMethod] + [TestCategory("Unit")] public void Rewrite_WithDefaultMaxCandidates_ShouldReturnLimitedResults() { // Arrange @@ -110,6 +116,7 @@ public void Rewrite_WithDefaultMaxCandidates_ShouldReturnLimitedResults() } [TestMethod] + [TestCategory("Unit")] public void Rewrite_WithSynonymExpansion_ShouldGenerateVariants() { // Arrange - 使用包含同义词的查询 @@ -133,6 +140,7 @@ public void Rewrite_WithSynonymExpansion_ShouldGenerateVariants() } [TestMethod] + [TestCategory("Unit")] public void Rewrite_WithFinancialTerms_ShouldGenerateAnalysisDimensions() { // Arrange @@ -156,6 +164,7 @@ public void Rewrite_WithFinancialTerms_ShouldGenerateAnalysisDimensions() } [TestMethod] + [TestCategory("Unit")] public void Rewrite_WithTimeFrameTerms_ShouldGenerateTimeVariants() { // Arrange @@ -177,6 +186,7 @@ public void Rewrite_WithTimeFrameTerms_ShouldGenerateTimeVariants() } [TestMethod] + [TestCategory("Unit")] public void Rewrite_ShouldReturnUniqueResults() { // Arrange @@ -201,6 +211,7 @@ public void Rewrite_ShouldReturnUniqueResults() } [TestMethod] + [TestCategory("Unit")] public void Rewrite_WithLargeMaxCandidates_ShouldReturnReasonableAmount() { // Arrange diff --git a/tests/Vectors/RagIngestionServiceIntegrationTest.cs b/tests/Vectors/RagIngestionServiceIntegrationTest.cs index ad53b49..50b531a 100644 --- a/tests/Vectors/RagIngestionServiceIntegrationTest.cs +++ b/tests/Vectors/RagIngestionServiceIntegrationTest.cs @@ -59,6 +59,7 @@ private static string GetTestProjectDirectory() } [TestMethod] + [TestCategory("Integration")] public async Task TestIngestFileAsync() { // 验证IngestFileAsync能否正确执行 diff --git a/tests/Vectors/RerankerServiceTest.cs b/tests/Vectors/RerankerServiceTest.cs index 15b2fe5..c406dc6 100644 --- a/tests/Vectors/RerankerServiceTest.cs +++ b/tests/Vectors/RerankerServiceTest.cs @@ -19,6 +19,7 @@ public void Initialize() #region Service Resolution Tests [TestMethod] + [TestCategory("Unit")] public void Service_ShouldBeResolvedFromContainer() { // Assert @@ -30,6 +31,7 @@ public void Service_ShouldBeResolvedFromContainer() #region Core Functionality Tests [TestMethod] + [TestCategory("Unit")] public void Rerank_WithEmptyItems_ShouldReturnEmptyList() { // Arrange @@ -45,6 +47,7 @@ public void Rerank_WithEmptyItems_ShouldReturnEmptyList() } [TestMethod] + [TestCategory("Unit")] public void Rerank_WithSingleItem_ShouldReturnSameItem() { // Arrange @@ -64,6 +67,7 @@ public void Rerank_WithSingleItem_ShouldReturnSameItem() } [TestMethod] + [TestCategory("Unit")] public void Rerank_WithMultipleItems_ShouldReturnReorderedResults() { // Arrange @@ -91,6 +95,7 @@ public void Rerank_WithMultipleItems_ShouldReturnReorderedResults() } [TestMethod] + [TestCategory("Unit")] public void Rerank_WithRelevantQuery_ShouldPrioritizeRelevantContent() { // Arrange @@ -119,6 +124,7 @@ public void Rerank_WithRelevantQuery_ShouldPrioritizeRelevantContent() } [TestMethod] + [TestCategory("Unit")] public void Rerank_WithLargeDataset_ShouldHandleEfficiently() { // Arrange @@ -150,6 +156,7 @@ public void Rerank_WithLargeDataset_ShouldHandleEfficiently() } [TestMethod] + [TestCategory("Unit")] public void Rerank_WithNullQuery_ShouldNotThrowException() { // Arrange @@ -166,6 +173,7 @@ public void Rerank_WithNullQuery_ShouldNotThrowException() } [TestMethod] + [TestCategory("Unit")] public void Rerank_WithEmptyQuery_ShouldReturnOriginalOrder() { // Arrange @@ -184,6 +192,7 @@ public void Rerank_WithEmptyQuery_ShouldReturnOriginalOrder() } [TestMethod] + [TestCategory("Unit")] public void Rerank_ShouldPreserveAllResults() { // Arrange diff --git a/tests/Vectors/RetrievalOrchestratorIntegrationTest.cs b/tests/Vectors/RetrievalOrchestratorIntegrationTest.cs index b30be3f..6dbdcae 100644 --- a/tests/Vectors/RetrievalOrchestratorIntegrationTest.cs +++ b/tests/Vectors/RetrievalOrchestratorIntegrationTest.cs @@ -102,6 +102,7 @@ private async Task AddTestDataAsync(VectorStoreCollection } [TestMethod] + [TestCategory("Integration")] public async Task TestRetrieveAsync() { // 验证RetrieveAsync能否正常工作 diff --git a/tests/Vectors/TextChunkingServiceTest.cs b/tests/Vectors/TextChunkingServiceTest.cs index 40ef9c7..ba3a431 100644 --- a/tests/Vectors/TextChunkingServiceTest.cs +++ b/tests/Vectors/TextChunkingServiceTest.cs @@ -16,6 +16,7 @@ public void Setup() } [TestMethod] + [TestCategory("Unit")] public void Chunk_ShouldSplitTextIntoParagraphs() { // Arrange @@ -33,6 +34,7 @@ public void Chunk_ShouldSplitTextIntoParagraphs() } [TestMethod] + [TestCategory("Unit")] public void Chunk_ShouldHandleEmptyString() { // Arrange @@ -48,6 +50,7 @@ public void Chunk_ShouldHandleEmptyString() } [TestMethod] + [TestCategory("Unit")] public void Chunk_ShouldHandleNullString() { // Arrange @@ -63,6 +66,7 @@ public void Chunk_ShouldHandleNullString() } [TestMethod] + [TestCategory("Unit")] public void Chunk_ShouldGenerateUniqueKeys() { // Arrange diff --git a/tests/Vectors/TextCleaningServiceTest.cs b/tests/Vectors/TextCleaningServiceTest.cs index ce69697..2dab2ca 100644 --- a/tests/Vectors/TextCleaningServiceTest.cs +++ b/tests/Vectors/TextCleaningServiceTest.cs @@ -16,6 +16,7 @@ public void Setup() } [TestMethod] + [TestCategory("Unit")] public void Clean_ShouldRemoveExtraWhitespace() { // Arrange @@ -30,6 +31,7 @@ public void Clean_ShouldRemoveExtraWhitespace() } [TestMethod] + [TestCategory("Unit")] public void Clean_ShouldRemoveLeadingAndTrailingWhitespace() { // Arrange @@ -44,6 +46,7 @@ public void Clean_ShouldRemoveLeadingAndTrailingWhitespace() } [TestMethod] + [TestCategory("Unit")] public void Clean_ShouldHandleEmptyString() { // Arrange @@ -58,6 +61,7 @@ public void Clean_ShouldHandleEmptyString() } [TestMethod] + [TestCategory("Unit")] public void Clean_ShouldHandleNullString() { // Arrange @@ -72,6 +76,7 @@ public void Clean_ShouldHandleNullString() } [TestMethod] + [TestCategory("Unit")] public void Clean_ShouldRemovePageNumbers() { // Arrange @@ -86,6 +91,7 @@ public void Clean_ShouldRemovePageNumbers() } [TestMethod] + [TestCategory("Unit")] public void Clean_ShouldRemoveUrls() { // Arrange @@ -100,6 +106,7 @@ public void Clean_ShouldRemoveUrls() } [TestMethod] + [TestCategory("Unit")] public void Clean_ShouldNormalizeLineEndings() { // Arrange diff --git a/tests/Vectors/VectorStoreIntegrationTest.cs b/tests/Vectors/VectorStoreIntegrationTest.cs index fd0f455..ea7bcdd 100644 --- a/tests/Vectors/VectorStoreIntegrationTest.cs +++ b/tests/Vectors/VectorStoreIntegrationTest.cs @@ -20,6 +20,7 @@ public void VectorStoreIntegrationTestInitialize() } [TestMethod] + [TestCategory("Integration")] public async Task VectorStore_ShouldStoreAndRetrieveTextParagraphs() { // Arrange From 52727b256b42cc22f16aa68bd2181886cb15936a Mon Sep 17 00:00:00 2001 From: MayueCif Date: Wed, 17 Jun 2026 13:18:39 +0800 Subject: [PATCH 31/44] =?UTF-8?q?chore(packages):=20=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E5=BA=93=E7=89=88=E6=9C=AC=E4=BB=A5=E4=BF=9D?= =?UTF-8?q?=E6=8C=81=E6=9C=80=E6=96=B0=E7=A8=B3=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一将 Microsoft.Extensions.AI 系列库版本更新至 10.7.0 - 更新 Microsoft.Extensions.Caching.Memory/Http 等到 10.0.9 版本 - 升级数据库与文档处理库,如 Microsoft.Data.Sqlite、Markdig - 提升 Semantic Kernel 和模型协议包版本 - 更新 UI 相关库如 Avalonia、Markdown.Avalonia、Svg.Skia 等至最新版本 - 细化测试依赖库版本,保证测试环境一致 refactor(agents): 优化 TokenTrackingMiddleware 以支持精确 Token 用量 - 从响应优先提取 LLM 提供的精确 Token 计数 - 流式调用增加对精确使用量的跟踪和日志记录 - 增强日志输出,区分估算值与提供商精确值 refactor(agents): 移除 ConversationCompressor,转为中间件方式压缩 - 完全移除 ConversationCompressor 类文件及相关调用 - 压缩会话历史逻辑由 MarketAssistant.App.Services 的 ConversationCompressionMiddleware 处理 fix(agents): 修正 MarketChatSession 流式回复处理及自动持久化逻辑 - 修复流式异步枚举处理,确保完整回复缓存至会话历史 - 处理中断回复时添加“回复被中断”标记 - 优化取消令牌生命周期管理 refactor(app): 优化 ChatSidebarViewModel 适配可选持久化支持 - ChatSessionPersistenceService 参数改为可空 - 根据持久化服务存在与否决定是否加载会话历史 - 更新相关测试示例兼容改动 refactor(app): 调整 Trading 相关依赖注入标记以支持多市场类型 - 在 TradeMonitorViewModel 构造函数中为 IExchangeClient 添加特性指定 Crypto 类型 style(app): 统一 AdaptiveCard 组件 UI 样式及资源引用 - 主题颜色改为使用项目内实际资源,替代系统默认画刷 - 统一容器内边距及圆角样式,增强排版一致性 - 优化 FactSet 布局与标题样式,调整间距与字体权重 - 修改文本颜色统一采用项目颜色资源 feat(app): 新增 AdaptiveCard 解析器通用策略和风险提示组件 - 添加 AddStrategyBox 用于策略和操作建议展示 - 添加 AddRiskBox 统一风险提示容器样式与内容布局 - 新增 AddFactSection 保障各种报告中 FactSet 展示一致性 fix(agents): TradingFunctionGuardMiddleware 优化传参记录与删减无用代码 - 改为序列化实际调用参数,便于审计追踪 - 移除无用的调用计数重置方法和额外空行 refactor(appservices): InvestmentSelection 与 MarketAnalysis 工作流执行改进 - 取消检查点传递,改为 null 以避免不必要状态管理 - 精简工作流事件日志,移除冗余检查点保存代码 - 优化进度百分比计算逻辑,更贴合实际分析状态 fix(appservices): CoordinatorExecutor 增强 JSON 解析错误日志与异常处理 - 包装 JsonSerializer 解析增加异常捕获并记录错误预览 - 抛出更具描述性的异常,便于定位结构化数据解析问题 refactor(agents): AShare 财务与技术工具支持字符串转 decimal 自动转换 - 利用自定义 JsonSerializerOptions 增加 StringToDecimalConverter - 确保接口返回字符串数字能正确反序列化为 decimal 类型 - 统一异常处理和返回数据有效性校验 style(appservices): 清理多余 using 指令提升代码整洁度 - 移除未使用的 MarketAssistant.Infrastructure.Core namespace 引用 fix(app): 修正配置文件 models.yaml 支持最新 deepseek-ai 模型版本 - 移除旧模型配置,替换为 DeepSeek-V4-Flash 最新版本 --- Directory.Packages.props | 44 +++--- src/MarketAssistant.Agents/AGENTS.md | 7 +- .../Middleware/TokenTrackingMiddleware.cs | 53 ++++++-- .../TradingFunctionGuardMiddleware.cs | 13 +- .../TokenManagement/ConversationCompressor.cs | 125 ------------------ .../InvestmentSelectionWorkflow.cs | 3 +- .../Executors/CoordinatorExecutor.cs | 20 ++- .../MarketAnalysis/MarketAnalysisWorkflow.cs | 34 +++-- .../Agents/MarketChatSession.cs | 36 +++-- .../Tools/AShare/AShareFinancialTools.cs | 10 +- .../Tools/AShare/AShareTechnicalTools.cs | 12 +- .../Applications/Settings/UserSetting.cs | 1 - .../Parsers/BaseAdaptiveCardParser.cs | 96 ++++++++++++++ .../Parsers/CoordinatorCardParser.cs | 18 +-- .../Parsers/FinancialCardParser.cs | 25 ++-- .../Parsers/FundamentalCardParser.cs | 37 ++---- .../AdaptiveCards/Parsers/NewsCardParser.cs | 42 +++--- .../Parsers/SentimentCardParser.cs | 38 ++---- .../Parsers/TechnicalCardParser.cs | 27 +--- .../Services/Browser/PlaywrightService.cs | 98 ++++++++++---- .../ViewModels/ChatSidebarViewModel.cs | 10 +- .../Demo/ChatSidebarDemoViewModel.cs | 4 +- .../Trading/TradeMonitorViewModel.cs | 4 +- .../Components/AdaptiveCardView.axaml.cs | 68 +++++----- src/MarketAssistant.App/config/models.yaml | 6 +- .../StringToDecimalConverter.cs | 68 ++++++++-- .../TradingFunctionGuardMiddlewareTest.cs | 11 +- 27 files changed, 478 insertions(+), 432 deletions(-) delete mode 100644 src/MarketAssistant.Agents/TokenManagement/ConversationCompressor.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 5b354f5..d8abaa9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,67 +1,57 @@ - + true - - - - - + + - - - - - - - + + + + + + - + - + - - - - - - + + + - - + - - + + - - diff --git a/src/MarketAssistant.Agents/AGENTS.md b/src/MarketAssistant.Agents/AGENTS.md index 1403374..ae56dcd 100644 --- a/src/MarketAssistant.Agents/AGENTS.md +++ b/src/MarketAssistant.Agents/AGENTS.md @@ -29,8 +29,7 @@ MarketAssistant.Agents/ │ ├── AnalystPromptConfig.cs ← 分析师提示词配置模型 │ └── AnalystPromptLoader.cs ← 从 YAML 加载提示词(支持热重载) ├── TokenManagement/ -│ ├── TokenEstimator.cs ← Token 估算(中文 ~1.5 字/token) -│ └── ConversationCompressor.cs ← 超限时压缩历史对话 +│ └── TokenEstimator.cs ← Token 估算(中文 ~1.5 字/token) └── Tools/ ├── Abstractions/ ← 工具接口定义(市场无关) │ ├── IBasicDataTools.cs ← 基础数据(基类) @@ -80,14 +79,14 @@ MarketAssistant.Agents/ ### Token 管理 - `TokenEstimator` 用于估算消息 Token 数。 -- `ConversationCompressor` 在对话超限时自动压缩——保留最近消息,LLM 摘要旧消息。 +- 会话压缩由 `MarketAssistant.App.Services` 中的 `ConversationCompressionMiddleware`(MAF 中间件)负责。 --- ## 测试 - 本项目的类型多为抽象基类和模型,测试集中在 `tests/TestMarketAssistant.csproj`。 -- `TokenEstimator` 和 `ConversationCompressor` 可独立单元测试;分析师行为通过集成测试验证。 +- `TokenEstimator` 可独立单元测试;会话压缩中间件和分析师行为通过集成测试验证。 --- diff --git a/src/MarketAssistant.Agents/Middleware/TokenTrackingMiddleware.cs b/src/MarketAssistant.Agents/Middleware/TokenTrackingMiddleware.cs index 82ebbe0..79bea91 100644 --- a/src/MarketAssistant.Agents/Middleware/TokenTrackingMiddleware.cs +++ b/src/MarketAssistant.Agents/Middleware/TokenTrackingMiddleware.cs @@ -35,14 +35,16 @@ public async Task InvokeAsync( AIAgent innerAgent, CancellationToken cancellationToken) { - var inputTokens = TokenEstimator.EstimateTotalTokens(messages); - var response = await innerAgent.RunAsync(messages, session, options, cancellationToken) .ConfigureAwait(false); - var outputTokens = TokenEstimator.EstimateTotalTokens(response.Messages); + // 优先使用 MAF 原生 UsageContent(由 LLM 提供商返回的精确值) + var usage = ExtractUsage(response.Messages); + var inputTokens = usage?.InputTokenCount ?? TokenEstimator.EstimateTotalTokens(messages); + var outputTokens = usage?.OutputTokenCount ?? TokenEstimator.EstimateTotalTokens(response.Messages); - LogAndAccumulate(session, inputTokens, outputTokens, innerAgent.Name); + LogAndAccumulate(session, (int)inputTokens, (int)outputTokens, innerAgent.Name, + isPrecise: usage != null); return response; } @@ -57,13 +59,18 @@ public async IAsyncEnumerable InvokeStreamingAsync( AIAgent innerAgent, [EnumeratorCancellation] CancellationToken cancellationToken) { - var inputTokens = TokenEstimator.EstimateTotalTokens(messages); - int outputCharCount = 0; + UsageDetails? streamingUsage = null; await foreach (var update in innerAgent.RunStreamingAsync(messages, session, options, cancellationToken) .ConfigureAwait(false)) { + // 尝试从流式更新中提取精确 Usage + if (update.Contents?.OfType().FirstOrDefault() is { } usageContent) + { + streamingUsage = usageContent.Details; + } + if (update.Text is { Length: > 0 } text) { outputCharCount += text.Length; @@ -72,17 +79,21 @@ public async IAsyncEnumerable InvokeStreamingAsync( yield return update; } - // 流式模式下根据累计字符数估算输出 Token - var outputTokens = TokenEstimator.EstimateTokens(new string(' ', outputCharCount)); + // 优先使用精确值,回退到字符估算 + var inputTokens = streamingUsage?.InputTokenCount ?? TokenEstimator.EstimateTotalTokens(messages); + var outputTokens = streamingUsage?.OutputTokenCount ?? TokenEstimator.EstimateTokens(new string(' ', outputCharCount)); - LogAndAccumulate(session, inputTokens, outputTokens, innerAgent.Name); + LogAndAccumulate(session, (int)inputTokens, (int)outputTokens, innerAgent.Name, + isPrecise: streamingUsage != null); } - private void LogAndAccumulate(AgentSession? session, int inputTokens, int outputTokens, string? agentName) + private void LogAndAccumulate(AgentSession? session, int inputTokens, int outputTokens, string? agentName, + bool isPrecise = false) { _logger.LogDebug( - "Token 追踪 [{Agent}] - 输入: {InputTokens}, 输出: {OutputTokens}", - agentName ?? "Unknown", inputTokens, outputTokens); + "Token 追踪 [{Agent}] - 输入: {InputTokens}, 输出: {OutputTokens} ({Source})", + agentName ?? "Unknown", inputTokens, outputTokens, + isPrecise ? "提供商精确值" : "估算值"); if (session == null) return; @@ -110,4 +121,22 @@ public static (int Input, int Output) GetCumulativeTokens(AgentSession? session) var output = session.StateBag.TryGetValue(OutputTokensKey, out var o) && int.TryParse(o, out var ov) ? ov : 0; return (input, output); } + + /// + /// 从响应消息中提取 UsageDetails(优先使用 LLM 提供商返回的精确 Token 用量) + /// + private static UsageDetails? ExtractUsage(IEnumerable? messages) + { + if (messages == null) return null; + + foreach (var message in messages) + { + if (message.Contents?.OfType().FirstOrDefault() is { } usageContent) + { + return usageContent.Details; + } + } + + return null; + } } diff --git a/src/MarketAssistant.Agents/Middleware/TradingFunctionGuardMiddleware.cs b/src/MarketAssistant.Agents/Middleware/TradingFunctionGuardMiddleware.cs index 11166e7..47777b2 100644 --- a/src/MarketAssistant.Agents/Middleware/TradingFunctionGuardMiddleware.cs +++ b/src/MarketAssistant.Agents/Middleware/TradingFunctionGuardMiddleware.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; @@ -20,7 +21,6 @@ public sealed class TradingFunctionGuardMiddleware private readonly ILogger _logger; private readonly int _maxToolCalls; - private int _toolCallCount; /// 单次 Agent 运行最大工具调用次数,防止无限循环(默认 20) @@ -90,11 +90,6 @@ public TradingFunctionGuardMiddleware(ILogger lo return result; } - /// - /// 重置调用计数(每次新的 Agent Run 前调用) - /// - public void ResetCallCount() => Interlocked.Exchange(ref _toolCallCount, 0); - private static bool IsSensitiveOperation(string functionName) { return functionName is "PlaceOrderAsync" or "CancelOrderAsync"; @@ -104,8 +99,10 @@ private static string FormatArguments(FunctionInvocationContext context) { try { - var args = context.Function.JsonSchema; - return args.ToString() ?? "N/A"; + // 记录实际传入参数(而非函数 Schema),用于审计追踪 + return context.Arguments != null + ? JsonSerializer.Serialize(context.Arguments) + : "N/A"; } catch { diff --git a/src/MarketAssistant.Agents/TokenManagement/ConversationCompressor.cs b/src/MarketAssistant.Agents/TokenManagement/ConversationCompressor.cs deleted file mode 100644 index c579577..0000000 --- a/src/MarketAssistant.Agents/TokenManagement/ConversationCompressor.cs +++ /dev/null @@ -1,125 +0,0 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using System.Text; - -namespace MarketAssistant.Agents.TokenManagement; - -/// -/// 对话压缩器,当会话 Token 超过阈值时自动压缩历史消息 -/// 策略:保留最新 N 条消息,将更早的消息压缩为摘要 -/// -public class ConversationCompressor -{ - private readonly IChatClient _chatClient; - private readonly ILogger _logger; - - private const int DefaultMaxTokens = 8000; - private const int DefaultReserveRecentCount = 4; - - public int MaxTokens { get; set; } = DefaultMaxTokens; - public int ReserveRecentCount { get; set; } = DefaultReserveRecentCount; - - public ConversationCompressor(IChatClient chatClient, ILogger logger) - { - _chatClient = chatClient; - _logger = logger; - } - - /// - /// 检查是否需要压缩 - /// - public bool NeedsCompression(IReadOnlyList history) - { - return TokenEstimator.EstimateTotalTokens(history) > MaxTokens; - } - - /// - /// 压缩对话历史:将旧消息总结为摘要,保留最近的消息 - /// - public async Task> CompressAsync( - IReadOnlyList history, - string? analysisContext = null, - CancellationToken cancellationToken = default) - { - if (history.Count <= ReserveRecentCount) - return [.. history]; - - var totalTokens = TokenEstimator.EstimateTotalTokens(history); - _logger.LogInformation( - "开始压缩对话历史,当前消息数: {Count},估算 Token: {Tokens}", - history.Count, totalTokens); - - var messagesToSummarize = history.Take(history.Count - ReserveRecentCount).ToList(); - var recentMessages = history.Skip(history.Count - ReserveRecentCount).ToList(); - - var summary = await GenerateSummaryAsync(messagesToSummarize, cancellationToken); - - var compressed = new List - { - new(ChatRole.System, $"[对话摘要] {summary}") - }; - compressed.AddRange(recentMessages); - - var newTokens = TokenEstimator.EstimateTotalTokens(compressed); - _logger.LogInformation( - "对话压缩完成,{OldCount} 条消息 → {NewCount} 条,Token: {OldTokens} → {NewTokens}", - history.Count, compressed.Count, totalTokens, newTokens); - - return compressed; - } - - /// - /// 使用 LLM 生成对话摘要 - /// - private async Task GenerateSummaryAsync( - List messages, - CancellationToken cancellationToken) - { - var sb = new StringBuilder(); - sb.AppendLine("请将以下对话内容压缩为简洁摘要,保留关键结论和数据点:"); - sb.AppendLine(); - - foreach (var msg in messages) - { - var role = msg.Role == ChatRole.User ? "用户" : "助手"; - var text = msg.Text; - if (string.IsNullOrWhiteSpace(text)) continue; - - if (text.Length > 500) - text = text[..500] + "..."; - - sb.AppendLine($"【{role}】{text}"); - } - - try - { - var response = await _chatClient.GetResponseAsync( - [new ChatMessage(ChatRole.User, sb.ToString())], - new ChatOptions { Temperature = 0.1f, MaxOutputTokens = 500 }, - cancellationToken); - - return response.Text ?? "对话历史摘要不可用"; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "LLM 摘要生成失败,回退到截断策略"); - return BuildFallbackSummary(messages); - } - } - - /// - /// 回退摘要策略:提取每条消息的前 100 字符 - /// - private static string BuildFallbackSummary(List messages) - { - var sb = new StringBuilder(); - foreach (var msg in messages) - { - var text = msg.Text; - if (string.IsNullOrWhiteSpace(text)) continue; - var truncated = text.Length > 100 ? text[..100] + "..." : text; - sb.AppendLine($"- {truncated}"); - } - return sb.ToString(); - } -} diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs index 9eee21e..8ae5b18 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs @@ -110,7 +110,8 @@ private async Task ExecuteWorkflowAsync( var workflow = workflowBuilder.Build(); // 执行工作流 - await using Run run = await InProcessExecution.RunAsync(workflow, request, sessionId: null, cancellationToken); + await using Run run = await InProcessExecution.RunAsync( + workflow, request, checkpointManager: null, sessionId: null, cancellationToken); InvestmentSelectionResult? finalResult = null; diff --git a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs index 23bc791..6742c0c 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs @@ -105,6 +105,7 @@ private async ValueTask HandleAsync( }; // 使用带结构化输出的 ChatClientAgent 运行(包含重试) + // session: null — 无状态一次性调用,无需会话累积 var agentResponse = await _llmRetryPipeline.ExecuteAsync( async ct => await _coordinatorAgent.RunAsync( messages, @@ -123,8 +124,22 @@ private async ValueTask HandleAsync( } // 从协调分析师的回复文本中反序列化结构化结果 - var coordinatorResult = JsonSerializer.Deserialize( - coordinatorMessage.Text ?? string.Empty, JsonOptions); + // ChatResponseFormat.ForJsonSchema 保证输出为纯 JSON,无需正则剥离 markdown 代码块 + var rawText = coordinatorMessage.Text ?? string.Empty; + + CoordinatorResult? coordinatorResult; + try + { + coordinatorResult = JsonSerializer.Deserialize(rawText, JsonOptions); + } + catch (JsonException jsonEx) + { + _logger.LogError(jsonEx, + "协调分析师 JSON 解析失败,原始文本前 500 字符: {Preview}", + rawText.Length > 500 ? rawText[..500] : rawText); + throw new InvalidOperationException( + $"协调分析师返回的数据无法解析为结构化结果: {jsonEx.Message}", jsonEx); + } if (coordinatorResult == null) { @@ -160,4 +175,5 @@ private async ValueTask HandleAsync( throw; } } + } diff --git a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs index 7014db5..5e88bd5 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs @@ -29,11 +29,6 @@ public class MarketAnalysisWorkflow : IDisposable private bool _disposed = false; - /// - /// 最近一次工作流检查点(可用于崩溃恢复) - /// - private CheckpointInfo? _lastCheckpoint; - /// /// 分析进度事件 /// @@ -149,13 +144,11 @@ private async Task ExecuteWorkflowAsync( int totalAnalysts = analystCount; var failedSteps = new List(); - // 启用检查点管理器,工作流在每个 SuperStep 结束时自动保存状态 - var checkpointManager = CheckpointManager.Default; - + // 执行工作流(流式处理) await using StreamingRun run = await InProcessExecution.RunStreamingAsync( workflow, assetSymbol, - checkpointManager, + checkpointManager: null, sessionId: null, cancellationToken); @@ -244,12 +237,7 @@ private async Task ExecuteWorkflowAsync( throw new FriendlyException(wfErrorMsg); case SuperStepCompletedEvent superStepCompleted: - if (superStepCompleted.CompletionInfo?.Checkpoint is { } checkpoint) - { - _lastCheckpoint = checkpoint; - _logger.LogInformation( - "工作流检查点已保存(SuperStep 完成),可用于崩溃恢复"); - } + _logger.LogDebug("工作流 SuperStep 完成"); break; case WorkflowWarningEvent workflowWarning: @@ -436,7 +424,17 @@ public sealed class AnalysisProgressEventArgs : EventArgs /// /// 进度百分比(0-100) /// - public int ProgressPercent => TotalAnalysts > 0 - ? (int)((double)CompletedAnalysts / TotalAnalysts * 80) + (IsInProgress ? 0 : 20) - : 0; + public int ProgressPercent + { + get + { + // 分析已完成或失败 + if (!IsInProgress) return 100; + // 正在进行分析 + if (TotalAnalysts > 0) + return (int)((double)CompletedAnalysts / TotalAnalysts * 100); + // 准备阶段(未开始分析) + return 0; + } + } } \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs b/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs index 132733b..3461f3c 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs @@ -252,8 +252,6 @@ public async IAsyncEnumerable SendMessageStreamAsync( { EnsureSearchToolsInitialized(); - // 压缩逻辑已由 ConversationCompressionMiddleware 自动处理 - _currentSession ??= await _agent.CreateSessionAsync(cancellationToken: cancellationToken); using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -273,27 +271,43 @@ public async IAsyncEnumerable SendMessageStreamAsync( }; var streamingUpdates = _agent.RunStreamingAsync( - message: userMessage, + messages: _conversationHistory, session: _currentSession, options: runOptions, cancellationToken: cts.Token); - await foreach (var update in streamingUpdates.ConfigureAwait(false)) + var completed = false; + try { - var content = update.Text ?? string.Empty; - if (!string.IsNullOrEmpty(content)) + await foreach (var update in streamingUpdates.ConfigureAwait(false)) { - completeResponse.Append(content); + var content = update.Text ?? string.Empty; + if (!string.IsNullOrEmpty(content)) + { + completeResponse.Append(content); + } + yield return content; } - yield return content; + completed = true; } + finally + { + if (completed) + { + _conversationHistory.Add(new ChatMessage(ChatRole.Assistant, completeResponse.ToString())); + } + else + { + var partial = completeResponse.ToString(); + _conversationHistory.Add(new ChatMessage(ChatRole.Assistant, + partial.Length > 0 ? partial + "\n\n[回复被中断]" : "[回复被中断]")); + } - _conversationHistory.Add(new ChatMessage(ChatRole.Assistant, completeResponse.ToString())); + _currentCancellationTokenSource = null; + } _logger.LogInformation("流式 AI 回复完成,长度: {Length}", completeResponse.Length); - _currentCancellationTokenSource = null; - // 自动持久化当前会话 await AutoSaveSessionAsync(cancellationToken); } diff --git a/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareFinancialTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareFinancialTools.cs index 94ec4d8..0ebe4dc 100644 --- a/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareFinancialTools.cs +++ b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareFinancialTools.cs @@ -1,6 +1,7 @@ using MarketAssistant.Agents.Tools.Abstractions; using MarketAssistant.Agents.Tools.Models.AShare; using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Data; using MarketAssistant.Services.Settings; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; @@ -17,6 +18,13 @@ public sealed class AShareFinancialTools : IShareFinancialTools private readonly IUserSettingService _userSettingService; private readonly ILogger _logger; + // 支持 API 返回的字符串数值自动转换为 decimal/decimal? + private static readonly JsonSerializerOptions FinancialJsonOptions = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new StringToDecimalConverter() } + }; + public AShareFinancialTools( IHttpClientFactory httpClientFactory, IUserSettingService userSettingService, @@ -43,7 +51,7 @@ private async Task> GetFinancialDataAsync(string endpoint, string ass using var httpClient = _httpClientFactory.CreateClient("ZhiTu"); var response = await httpClient.GetStringAsync(url); - return JsonSerializer.Deserialize>(response) ?? new List(); + return JsonSerializer.Deserialize>(response, FinancialJsonOptions) ?? new List(); } catch (Exception ex) when (ex is not FriendlyException) { diff --git a/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareTechnicalTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareTechnicalTools.cs index 86efe17..05c1541 100644 --- a/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareTechnicalTools.cs +++ b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareTechnicalTools.cs @@ -1,6 +1,7 @@ using MarketAssistant.Agents.Tools.Abstractions; using MarketAssistant.Agents.Tools.Models.Technical; using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Data; using MarketAssistant.Services.Settings; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; @@ -18,6 +19,13 @@ public sealed class AShareTechnicalTools : ITechnicalDataTools private readonly IUserSettingService _userSettingService; private readonly ILogger _logger; + // 支持 API 返回的字符串数值自动转换为 decimal/decimal? + private static readonly JsonSerializerOptions TechnicalJsonOptions = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new StringToDecimalConverter() } + }; + public AShareTechnicalTools( IHttpClientFactory httpClientFactory, IUserSettingService userSettingService, @@ -38,7 +46,7 @@ private async Task GetIndicatorAsync(string indicator, string assetSymbol) using var httpClient = _httpClientFactory.CreateClient("ZhiTu"); var response = await httpClient.GetStringAsync(url); - var items = JsonSerializer.Deserialize>(response); + var items = JsonSerializer.Deserialize>(response, TechnicalJsonOptions); if (items == null || !items.Any()) throw new FriendlyException($"获取 {indicator.ToUpper()} 数据失败: 返回数据为空或无有效数据 (代码: {formattedSymbol})"); @@ -100,7 +108,7 @@ public async Task> GetKLinesAsync( using var httpClient = _httpClientFactory.CreateClient("ZhiTu"); var response = await httpClient.GetStringAsync(url); - var items = JsonSerializer.Deserialize>(response); + var items = JsonSerializer.Deserialize>(response, TechnicalJsonOptions); if (items == null || items.Count == 0) throw new FriendlyException($"K线数据为空 (代码: {formattedSymbol})"); diff --git a/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs b/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs index 46ea505..ebdf98f 100644 --- a/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs +++ b/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs @@ -1,7 +1,6 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using System.Text.Json.Serialization; -using MarketAssistant.Infrastructure.Core; namespace MarketAssistant.Applications.Settings; diff --git a/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/BaseAdaptiveCardParser.cs b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/BaseAdaptiveCardParser.cs index a361418..65167f7 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/BaseAdaptiveCardParser.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/BaseAdaptiveCardParser.cs @@ -107,6 +107,102 @@ protected void AddScoreHeader(IList container, string label, st container.Add(colSet); } + /// + /// 统一的“策略/操作建议”看板:Emphasis 容器 + 大号彩色标题 + 正文 + 可选 FactSet。 + /// 各分析师的策略区一律走此构件,避免出现三四种重复布局。 + /// + protected void AddStrategyBox( + IList container, + string title, + AdaptiveTextColor titleColor, + string? content = null, + AdaptiveFactSet? facts = null, + List? bulletPoints = null) + { + var box = new AdaptiveContainer + { + Style = AdaptiveContainerStyle.Emphasis, + Spacing = AdaptiveSpacing.Small + }; + + box.Items.Add(new AdaptiveTextBlock + { + Text = title, + Weight = AdaptiveTextWeight.Bolder, + Size = AdaptiveTextSize.Large, + Color = titleColor + }); + + if (!string.IsNullOrEmpty(content)) + { + box.Items.Add(new AdaptiveTextBlock { Text = content, Wrap = true }); + } + + if (bulletPoints != null && bulletPoints.Count > 0) + { + foreach (var point in bulletPoints) + { + box.Items.Add(new AdaptiveTextBlock { Text = "• " + point, Wrap = true, Weight = AdaptiveTextWeight.Bolder }); + } + } + + if (facts != null && facts.Facts.Count > 0) + { + box.Items.Add(facts); + } + + container.Add(box); + } + + /// + /// 统一的风险警告容器:Attention 样式 + ⚠️ 标题 + 正文 + 可选列表。 + /// 财务/基本面的容器实现与新闻/情绪的行内 ⚠️ 文本均改走此构件。 + /// + protected void AddRiskBox( + IList container, + string title, + string? content, + List? bulletPoints = null) + { + var box = new AdaptiveContainer + { + Style = AdaptiveContainerStyle.Attention, + Spacing = AdaptiveSpacing.Medium + }; + + box.Items.Add(new AdaptiveTextBlock + { + Text = "⚠️ " + title, + Weight = AdaptiveTextWeight.Bolder, + Color = AdaptiveTextColor.Attention + }); + + if (!string.IsNullOrEmpty(content)) + { + box.Items.Add(new AdaptiveTextBlock { Text = content, Wrap = true, Size = AdaptiveTextSize.Small }); + } + + if (bulletPoints != null && bulletPoints.Count > 0) + { + foreach (var point in bulletPoints) + { + box.Items.Add(new AdaptiveTextBlock { Text = "• " + point, Wrap = true, Size = AdaptiveTextSize.Small }); + } + } + + container.Add(box); + } + + /// + /// 统一的“标题 + FactSet”section,保证 section 间距一致。 + /// + protected void AddFactSection(IList container, string title, AdaptiveFactSet facts) + { + if (facts.Facts.Count == 0) return; + AddSectionHeader(container, title); + container.Add(facts); + } + protected void AddListSection(IList container, List? list, string title) { if (list != null && list.Count > 0) diff --git a/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/CoordinatorCardParser.cs b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/CoordinatorCardParser.cs index 11e4895..c99fe3c 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/CoordinatorCardParser.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/CoordinatorCardParser.cs @@ -66,19 +66,18 @@ public override AdaptiveCard Parse(CoordinatorResult model) facts.Facts.Add(new AdaptiveFact("投资周期", model.TimeHorizonDescription)); facts.Facts.Add(new AdaptiveFact("置信度", model.ConfidencePercentage.ToString("F0") + "%")); facts.Facts.Add(new AdaptiveFact("风险等级", GetEnumDescription(model.RiskLevel))); - card.Body.Add(facts); + AddFactSection(card.Body, "关键参数", facts); // Dimension Scores if (model.DimensionScores != null) { - AddSectionHeader(card.Body, "维度评分"); var scoreFacts = new AdaptiveFactSet(); scoreFacts.Facts.Add(new AdaptiveFact("基本面", model.DimensionScores.Fundamental.ToString("F1"))); scoreFacts.Facts.Add(new AdaptiveFact("技术面", model.DimensionScores.Technical.ToString("F1"))); scoreFacts.Facts.Add(new AdaptiveFact("财务面", model.DimensionScores.Financial.ToString("F1"))); scoreFacts.Facts.Add(new AdaptiveFact("市场情绪", model.DimensionScores.Sentiment.ToString("F1"))); scoreFacts.Facts.Add(new AdaptiveFact("新闻事件", model.DimensionScores.News.ToString("F1"))); - card.Body.Add(scoreFacts); + AddFactSection(card.Body, "维度评分", scoreFacts); } // Highlights & Risks in 2 columns @@ -103,16 +102,10 @@ public override AdaptiveCard Parse(CoordinatorResult model) if (hasRight) listCols.Columns.Add(rightCol); if (listCols.Columns.Count > 0) card.Body.Add(listCols); - // Operation Suggestions + // Operation Suggestions —— 统一策略看板 if (model.OperationSuggestions != null && model.OperationSuggestions.Count > 0) { - AddSectionHeader(card.Body, "操作建议"); - var suggestionContainer = new AdaptiveContainer { Style = AdaptiveContainerStyle.Emphasis, Spacing = AdaptiveSpacing.Small }; - foreach (var item in model.OperationSuggestions) - { - suggestionContainer.Items.Add(new AdaptiveTextBlock { Text = "• " + item, Wrap = true, Weight = AdaptiveTextWeight.Bolder }); - } - card.Body.Add(suggestionContainer); + AddStrategyBox(card.Body, "操作建议", AdaptiveTextColor.Accent, bulletPoints: model.OperationSuggestions); } // Consensus & Disagreement @@ -131,13 +124,12 @@ public override AdaptiveCard Parse(CoordinatorResult model) // Key Indicators if (model.KeyIndicators != null && model.KeyIndicators.Count > 0) { - AddSectionHeader(card.Body, "关键指标"); var indicatorFacts = new AdaptiveFactSet(); foreach (var indicator in model.KeyIndicators.Take(5)) // Limit to top 5 to avoid too long card { indicatorFacts.Facts.Add(new AdaptiveFact(indicator.Name, $"{indicator.Value} ({indicator.Signal})")); } - card.Body.Add(indicatorFacts); + AddFactSection(card.Body, "关键指标", indicatorFacts); } return card; diff --git a/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/FinancialCardParser.cs b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/FinancialCardParser.cs index b9c3bd0..668536f 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/FinancialCardParser.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/FinancialCardParser.cs @@ -24,7 +24,7 @@ public override AdaptiveCard Parse(FinancialAnalysisResult model) FallbackText = $"财务分析报告:偿债评分 {summaryScore},观点:{summaryInsight},请查看完整报告。", Speak = "财务分析报告已生成。" }; - AddHeader(card.Body, "💰 财务分析报告", AdaptiveTextColor.Good); + AddHeader(card.Body, "💰 财务分析报告", AdaptiveTextColor.Accent); // 使用两列布局展示核心指标 var metricsCols = new AdaptiveColumnSet(); @@ -110,28 +110,19 @@ public override AdaptiveCard Parse(FinancialAnalysisResult model) var warning = model.RiskWarning.FraudRiskRationale; if (!string.IsNullOrEmpty(warning)) { - var container = new AdaptiveContainer { Style = AdaptiveContainerStyle.Attention, Spacing = AdaptiveSpacing.Medium }; - container.Items.Add(new AdaptiveTextBlock { Text = "⚠️ 风险预警", Weight = AdaptiveTextWeight.Bolder, Color = AdaptiveTextColor.Attention }); - container.Items.Add(new AdaptiveTextBlock { Text = warning, Wrap = true }); - - if (model.RiskWarning.KeyRiskIndicators != null && model.RiskWarning.KeyRiskIndicators.Count > 0) + // 合并关键风险指标与建议关注点为统一风险看板 + var riskPoints = new List(); + if (model.RiskWarning.KeyRiskIndicators != null) { - foreach (var indicator in model.RiskWarning.KeyRiskIndicators) - { - container.Items.Add(new AdaptiveTextBlock { Text = $"• {indicator}", Wrap = true, Size = AdaptiveTextSize.Small }); - } + riskPoints.AddRange(model.RiskWarning.KeyRiskIndicators); } - if (model.RiskWarning.MonitoringPoints != null && model.RiskWarning.MonitoringPoints.Count > 0) { - container.Items.Add(new AdaptiveTextBlock { Text = "建议关注:", Weight = AdaptiveTextWeight.Bolder, Size = AdaptiveTextSize.Small }); - foreach (var point in model.RiskWarning.MonitoringPoints) - { - container.Items.Add(new AdaptiveTextBlock { Text = $"• {point}", Wrap = true, Size = AdaptiveTextSize.Small }); - } + riskPoints.Add("建议关注:"); + riskPoints.AddRange(model.RiskWarning.MonitoringPoints); } - card.Body.Add(container); + AddRiskBox(card.Body, "风险预警", warning, riskPoints); } } diff --git a/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/FundamentalCardParser.cs b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/FundamentalCardParser.cs index 1591a55..6ce901c 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/FundamentalCardParser.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/FundamentalCardParser.cs @@ -95,32 +95,30 @@ public override AdaptiveCard Parse(FundamentalAnalysisResult model) if (model.GrowthValue != null) { - AddSectionHeader(card.Body, "投资评级"); var rating = GetEnumDescription(model.GrowthValue.InvestmentRating); var color = rating.Contains("买入") ? AdaptiveTextColor.Good : AdaptiveTextColor.Default; - card.Body.Add(new AdaptiveTextBlock - { - Text = rating, - Weight = AdaptiveTextWeight.Bolder, - Size = AdaptiveTextSize.Medium, - Color = color - }); - var facts = new AdaptiveFactSet(); facts.Facts.Add(new AdaptiveFact("增长持续性", model.GrowthValue.GrowthSustainabilityScore.ToString("F1"))); - card.Body.Add(facts); + // 统一评级看板:评级标题 + 估值/目标内容 + 指标 + var valuationParts = new List(); if (!string.IsNullOrEmpty(model.GrowthValue.ValuationDescription)) { - card.Body.Add(new AdaptiveTextBlock { Text = $"估值评估: {model.GrowthValue.ValuationDescription}", Wrap = true }); + valuationParts.Add($"估值评估: {model.GrowthValue.ValuationDescription}"); } - if (!string.IsNullOrEmpty(model.GrowthValue.ValuationTarget)) { - card.Body.Add(new AdaptiveTextBlock { Text = $"目标: {model.GrowthValue.ValuationTarget}", Wrap = true }); + valuationParts.Add($"目标: {model.GrowthValue.ValuationTarget}"); } + AddStrategyBox( + card.Body, + rating, + color, + content: valuationParts.Count > 0 ? string.Join(" | ", valuationParts) : null, + facts: facts); + if (!string.IsNullOrEmpty(model.GrowthValue.GrowthDrivers)) { AddSectionHeader(card.Body, "增长驱动"); @@ -129,21 +127,10 @@ public override AdaptiveCard Parse(FundamentalAnalysisResult model) if (!string.IsNullOrEmpty(model.GrowthValue.KeyRisk)) { - var container = new AdaptiveContainer { Style = AdaptiveContainerStyle.Attention, Spacing = AdaptiveSpacing.Small }; - container.Items.Add(new AdaptiveTextBlock { Text = "⚠️ 关键风险", Weight = AdaptiveTextWeight.Bolder, Color = AdaptiveTextColor.Attention }); - container.Items.Add(new AdaptiveTextBlock { Text = model.GrowthValue.KeyRisk, Wrap = true, Size = AdaptiveTextSize.Small }); - card.Body.Add(container); + AddRiskBox(card.Body, "关键风险", model.GrowthValue.KeyRisk); } } return card; } - - private void AddKeyValueRow(IList container, string key, string value, bool isBold = false) - { - var row = new AdaptiveColumnSet { Spacing = AdaptiveSpacing.Small }; - row.Columns.Add(new AdaptiveColumn { Width = "auto", Items = { new AdaptiveTextBlock { Text = key, IsSubtle = true } } }); - row.Columns.Add(new AdaptiveColumn { Width = "stretch", Items = { new AdaptiveTextBlock { Text = value, Weight = isBold ? AdaptiveTextWeight.Bolder : AdaptiveTextWeight.Default, Wrap = true } } }); - container.Add(row); - } } \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/NewsCardParser.cs b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/NewsCardParser.cs index 9b6f9a2..502125c 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/NewsCardParser.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/NewsCardParser.cs @@ -99,45 +99,35 @@ public override AdaptiveCard Parse(NewsEventAnalysisResult model) if (model.InvestmentGuidance != null) { - AddSectionHeader(card.Body, "投资启示"); var strategy = GetEnumDescription(model.InvestmentGuidance.ResponseStrategy); var color = strategy.Contains("买入") || strategy.Contains("做多") ? AdaptiveTextColor.Good : (strategy.Contains("卖出") || strategy.Contains("做空") ? AdaptiveTextColor.Attention : AdaptiveTextColor.Default); - var container = new AdaptiveContainer { Style = AdaptiveContainerStyle.Emphasis, Spacing = AdaptiveSpacing.Small }; - - // 策略看板 - container.Items.Add(new AdaptiveTextBlock - { - Text = $"策略: {strategy}", - Weight = AdaptiveTextWeight.Bolder, - Size = AdaptiveTextSize.Large, - Color = color - }); - + // 核心逻辑 + 具体建议合并为策略看板正文 + var contentParts = new List(); if (!string.IsNullOrEmpty(model.InvestmentGuidance.CoreInvestmentLogic)) { - container.Items.Add(new AdaptiveTextBlock { Text = model.InvestmentGuidance.CoreInvestmentLogic, Wrap = true }); + contentParts.Add(model.InvestmentGuidance.CoreInvestmentLogic); } - if (!string.IsNullOrEmpty(model.InvestmentGuidance.SpecificActionAdvice)) { - container.Items.Add(new AdaptiveTextBlock { Text = $"建议: {model.InvestmentGuidance.SpecificActionAdvice}", Wrap = true }); + contentParts.Add($"建议: {model.InvestmentGuidance.SpecificActionAdvice}"); } + // 统一策略看板:策略标题 + 核心逻辑/建议 + 关注重点 + AddStrategyBox( + card.Body, + $"策略: {strategy}", + color, + content: contentParts.Count > 0 ? string.Join("\n", contentParts) : null, + bulletPoints: model.InvestmentGuidance.FocusPoints?.Count > 0 + ? new List { "关注重点:" }.Concat(model.InvestmentGuidance.FocusPoints).ToList() + : null); + + // 风险告警独立为统一风险看板 if (!string.IsNullOrEmpty(model.InvestmentGuidance.KeyRiskAlert)) { - container.Items.Add(new AdaptiveTextBlock { Text = $"⚠️ {model.InvestmentGuidance.KeyRiskAlert}", Wrap = true, Color = AdaptiveTextColor.Attention, Weight = AdaptiveTextWeight.Bolder }); - } - - if (model.InvestmentGuidance.FocusPoints != null && model.InvestmentGuidance.FocusPoints.Count > 0) - { - container.Items.Add(new AdaptiveTextBlock { Text = "关注重点:", Weight = AdaptiveTextWeight.Bolder, Size = AdaptiveTextSize.Small }); - foreach (var point in model.InvestmentGuidance.FocusPoints) - { - container.Items.Add(new AdaptiveTextBlock { Text = $"• {point}", Wrap = true, Size = AdaptiveTextSize.Small }); - } + AddRiskBox(card.Body, "风险提示", model.InvestmentGuidance.KeyRiskAlert); } - card.Body.Add(container); } return card; diff --git a/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/SentimentCardParser.cs b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/SentimentCardParser.cs index 894b724..1f4fef5 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/SentimentCardParser.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/SentimentCardParser.cs @@ -109,31 +109,9 @@ public override AdaptiveCard Parse(MarketSentimentAnalysisResult model) if (model.ShortTermStrategy != null) { - AddSectionHeader(card.Body, "短期策略"); var recommendation = GetEnumDescription(model.ShortTermStrategy.OperationRecommendation); var color = recommendation.Contains("买入") || recommendation.Contains("做多") ? AdaptiveTextColor.Good : (recommendation.Contains("卖出") || recommendation.Contains("做空") ? AdaptiveTextColor.Attention : AdaptiveTextColor.Default); - var container = new AdaptiveContainer { Style = AdaptiveContainerStyle.Emphasis, Spacing = AdaptiveSpacing.Small }; - - // 策略看板 - container.Items.Add(new AdaptiveTextBlock - { - Text = $"建议: {recommendation}", - Weight = AdaptiveTextWeight.Bolder, - Size = AdaptiveTextSize.Large, - Color = color - }); - - if (!string.IsNullOrEmpty(model.ShortTermStrategy.ShortTermOpportunities)) - { - container.Items.Add(new AdaptiveTextBlock { Text = model.ShortTermStrategy.ShortTermOpportunities, Wrap = true }); - } - - if (!string.IsNullOrEmpty(model.ShortTermStrategy.PsychologicalTrapToAvoid)) - { - container.Items.Add(new AdaptiveTextBlock { Text = $"⚠️ 心理陷阱: {model.ShortTermStrategy.PsychologicalTrapToAvoid}", Wrap = true, Color = AdaptiveTextColor.Attention, Size = AdaptiveTextSize.Small }); - } - var facts = new AdaptiveFactSet(); if (!string.IsNullOrEmpty(model.ShortTermStrategy.TargetPriceRange)) { @@ -143,12 +121,20 @@ public override AdaptiveCard Parse(MarketSentimentAnalysisResult model) { facts.Facts.Add(new AdaptiveFact("止损位置", model.ShortTermStrategy.StopLossPosition)); } - if (facts.Facts.Count > 0) + + // 统一策略看板:建议标题 + 短期机会正文 + 目标/止损指标 + AddStrategyBox( + card.Body, + $"建议: {recommendation}", + color, + content: model.ShortTermStrategy.ShortTermOpportunities, + facts: facts.Facts.Count > 0 ? facts : null); + + // 心理陷阱独立为统一风险看板 + if (!string.IsNullOrEmpty(model.ShortTermStrategy.PsychologicalTrapToAvoid)) { - container.Items.Add(facts); + AddRiskBox(card.Body, "心理陷阱", model.ShortTermStrategy.PsychologicalTrapToAvoid); } - - card.Body.Add(container); } return card; diff --git a/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/TechnicalCardParser.cs b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/TechnicalCardParser.cs index 3b90f2f..9766bb8 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/TechnicalCardParser.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/TechnicalCardParser.cs @@ -106,27 +106,9 @@ public override AdaptiveCard Parse(TechnicalAnalysisResult model) if (model.Strategy != null) { - AddSectionHeader(card.Body, "交易策略"); var direction = GetEnumDescription(model.Strategy.OperationDirection); var color = direction.Contains("买入") || direction.Contains("做多") ? AdaptiveTextColor.Good : (direction.Contains("卖出") || direction.Contains("做空") ? AdaptiveTextColor.Attention : AdaptiveTextColor.Default); - // 策略看板:方向 + 目标价 - var strategyContainer = new AdaptiveContainer { Style = AdaptiveContainerStyle.Emphasis, Spacing = AdaptiveSpacing.Small }; - strategyContainer.Items.Add(new AdaptiveTextBlock - { - Text = $"建议: {direction}", - Weight = AdaptiveTextWeight.Bolder, - Size = AdaptiveTextSize.Large, - Color = color - }); - - strategyContainer.Items.Add(new AdaptiveTextBlock - { - Text = $"评级: {GetEnumDescription(model.Strategy.TechnicalRating)}", - Weight = AdaptiveTextWeight.Bolder, - Size = AdaptiveTextSize.Medium - }); - var facts = new AdaptiveFactSet(); if (model.Strategy.TargetPriceLow.HasValue && model.Strategy.TargetPriceHigh.HasValue) { @@ -141,8 +123,13 @@ public override AdaptiveCard Parse(TechnicalAnalysisResult model) facts.Facts.Add(new AdaptiveFact("持仓周期", GetEnumDescription(model.Strategy.HoldingPeriod))); facts.Facts.Add(new AdaptiveFact("风险等级", GetEnumDescription(model.Strategy.RiskLevel))); - strategyContainer.Items.Add(facts); - card.Body.Add(strategyContainer); + // 统一策略看板:方向标题 + 评级内容 + 指标 + AddStrategyBox( + card.Body, + $"建议: {direction}", + color, + content: $"评级: {GetEnumDescription(model.Strategy.TechnicalRating)}", + facts: facts); } return card; diff --git a/src/MarketAssistant.App.Services/Services/Browser/PlaywrightService.cs b/src/MarketAssistant.App.Services/Services/Browser/PlaywrightService.cs index 4346811..4109150 100644 --- a/src/MarketAssistant.App.Services/Services/Browser/PlaywrightService.cs +++ b/src/MarketAssistant.App.Services/Services/Browser/PlaywrightService.cs @@ -5,7 +5,8 @@ namespace MarketAssistant.Services.Browser; /// -/// Playwright服务,用于管理Playwright和Browser实例 +/// Playwright 浏览器服务,管理浏览器生命周期和并发页面操作。 +/// 优先使用系统已安装的 Edge/Chrome,避免下载独立 Chromium。 /// public class PlaywrightService : IAsyncDisposable { @@ -93,7 +94,21 @@ public async Task ExecuteWithPageAsync(Func> action, TimeSp { ObjectDisposedException.ThrowIf(_disposed, nameof(PlaywrightService)); - var browser = await GetBrowserAsync(); + IBrowser browser; + try + { + browser = await GetBrowserAsync(); + } + catch (FriendlyException) + { + throw; + } + catch (Exception ex) + { + throw new FriendlyException( + "浏览器启动失败。请在设置中指定浏览器路径,或确保系统已安装 Chrome/Edge 浏览器。", + ex); + } await using var context = await CreateBrowserContextAsync(browser); var page = await context.NewPageAsync(); @@ -158,7 +173,9 @@ await context.RouteAsync("**/*", route => } /// - /// 初始化Playwright和Browser实例 + /// 初始化 Playwright 并启动浏览器。 + /// BrowserPath 由 BrowserService 自动检测系统 Edge/Chrome 并填入,因此 ExecutablePath 就是系统浏览器。 + /// 策略:1) ExecutablePath(自动检测或用户手动指定)→ 2) 内置 Chromium /// private async Task InitializeBrowserAsync() { @@ -170,41 +187,76 @@ private async Task InitializeBrowserAsync() try { _logger?.LogInformation("初始化 Playwright"); - _playwright ??= await Playwright.CreateAsync(); - var options = new BrowserTypeLaunchOptions - { - Headless = true, - Args = BrowserArgs - }; - var browserPath = _userSettingService.CurrentSetting.BrowserPath; + + // 策略 1:使用 ExecutablePath(BrowserService 自动检测的 Edge/Chrome 或用户手动指定的路径) if (!string.IsNullOrWhiteSpace(browserPath) && File.Exists(browserPath)) { - options.ExecutablePath = browserPath; - _logger?.LogInformation("使用自定义浏览器: {Path}", browserPath); + try + { + _logger?.LogInformation("使用浏览器: {Path}", browserPath); + _browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = true, + ExecutablePath = browserPath, + Args = BrowserArgs + }); + SetupDisconnectHandler(); + _logger?.LogInformation("Playwright 已启动: {Path}", browserPath); + return; + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "浏览器启动失败: {Path},尝试回退到内置 Chromium", browserPath); + } } - else + + // 策略 2:回退到内置 Chromium(需下载) + _logger?.LogInformation("无可用的本地浏览器,尝试安装内置 Chromium"); + var installResult = await Task.Run(() => Microsoft.Playwright.Program.Main(["install", "chromium"])); + if (installResult != 0) { - _logger?.LogInformation("使用内置 Chromium"); - await Task.Run(() => Microsoft.Playwright.Program.Main(["install", "chromium"])); + throw new FriendlyException( + $"Chromium 浏览器安装失败(退出码: {installResult})。" + + "请在设置中手动指定 Chrome 或 Edge 浏览器路径," + + "或在终端运行: pwsh bin/Debug/net10.0/playwright.ps1 install"); } - _browser = await _playwright.Chromium.LaunchAsync(options); - _browser.Disconnected += (_, _) => + _browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { - _logger?.LogWarning("浏览器连接断开"); - _browser = null; - }; - - _logger?.LogInformation("Playwright 初始化完成"); + Headless = true, + Args = BrowserArgs + }); + SetupDisconnectHandler(); + _logger?.LogInformation("Playwright 已使用内置 Chromium 启动"); + } + catch (FriendlyException) + { + await CleanupAsync(); + throw; } catch (Exception ex) { _logger?.LogError(ex, "Playwright 初始化失败"); await CleanupAsync(); - throw; + throw new FriendlyException( + "浏览器初始化失败。请确保系统已安装 Chrome 或 Edge 浏览器," + + "或在设置页面手动指定浏览器路径。", + ex); + } + } + + private void SetupDisconnectHandler() + { + if (_browser != null) + { + _browser.Disconnected += (_, _) => + { + _logger?.LogWarning("浏览器连接断开"); + _browser = null; + }; } } diff --git a/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs b/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs index aa11579..c5682ac 100644 --- a/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs @@ -15,7 +15,7 @@ namespace MarketAssistant.ViewModels; public partial class ChatSidebarViewModel : ViewModelBase { private readonly MarketChatSession _chatSession; - private readonly ChatSessionPersistenceService _sessionPersistence; + private readonly ChatSessionPersistenceService? _sessionPersistence; public ObservableCollection ChatMessages { get; } = []; public ObservableCollection SessionHistory { get; } = []; @@ -41,14 +41,15 @@ public partial class ChatSidebarViewModel : ViewModelBase public ChatSidebarViewModel( ILogger logger, IMarketChatSessionFactory chatSessionFactory, - ChatSessionPersistenceService sessionPersistence) + ChatSessionPersistenceService? sessionPersistence = null) : base(logger) { _sessionPersistence = sessionPersistence; _chatSession = chatSessionFactory.Create(); SendMessageCommand = new AsyncRelayCommand(SendMessageAsync, CanSendMessage); - _ = LoadSessionHistoryAsync(); + if (_sessionPersistence is not null) + _ = LoadSessionHistoryAsync(); } /// @@ -237,6 +238,8 @@ public void ClearChatHistory() /// private async Task LoadSessionHistoryAsync() { + if (_sessionPersistence is null) return; + try { var summaries = await _sessionPersistence.GetSessionSummariesAsync(); @@ -282,6 +285,7 @@ private async Task RestoreSessionAsync(ChatSessionSummary summary) private async Task DeleteSessionAsync(ChatSessionSummary summary) { if (summary is null) return; + if (_sessionPersistence is null) return; await _sessionPersistence.DeleteSessionAsync(summary.Id); SessionHistory.Remove(summary); diff --git a/src/MarketAssistant.App/ViewModels/Demo/ChatSidebarDemoViewModel.cs b/src/MarketAssistant.App/ViewModels/Demo/ChatSidebarDemoViewModel.cs index 93a7cbe..a8eeec9 100644 --- a/src/MarketAssistant.App/ViewModels/Demo/ChatSidebarDemoViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Demo/ChatSidebarDemoViewModel.cs @@ -22,9 +22,7 @@ public ChatSidebarDemoViewModel() : base(NullLogger.Instance) { TargetViewModel = new ChatSidebarViewModel( NullLogger.Instance, - new MockMarketChatSessionFactory(), - null! - ); + new MockMarketChatSessionFactory()); _jsonOptions = new JsonSerializerOptions { diff --git a/src/MarketAssistant.App/ViewModels/Trading/TradeMonitorViewModel.cs b/src/MarketAssistant.App/ViewModels/Trading/TradeMonitorViewModel.cs index 7e476ac..9a72de7 100644 --- a/src/MarketAssistant.App/ViewModels/Trading/TradeMonitorViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Trading/TradeMonitorViewModel.cs @@ -1,9 +1,11 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Trading; using MarketAssistant.Trading.Abstractions; using MarketAssistant.Trading.Models; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace MarketAssistant.ViewModels.Trading; @@ -36,7 +38,7 @@ public partial class TradeMonitorViewModel : ViewModelBase, IDisposable public TradeMonitorViewModel( MarketMonitor marketMonitor, CryptoPortfolioService portfolioService, - IExchangeClient exchangeClient, + [FromKeyedServices(MarketType.Crypto)] IExchangeClient exchangeClient, TradingDataService dataService, TradeExecutor tradeExecutor, ILogger logger) diff --git a/src/MarketAssistant.App/Views/Components/AdaptiveCardView.axaml.cs b/src/MarketAssistant.App/Views/Components/AdaptiveCardView.axaml.cs index 9e273c3..3c3dc07 100644 --- a/src/MarketAssistant.App/Views/Components/AdaptiveCardView.axaml.cs +++ b/src/MarketAssistant.App/Views/Components/AdaptiveCardView.axaml.cs @@ -89,21 +89,17 @@ private Control RenderTextBlock(AdaptiveTextBlock textBlock) _ => FontWeight.Normal }; - // Color with Theme Support - if (textBlock.Color == AdaptiveTextColor.Accent) - tb[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlForegroundAccentBrush"); - else if (textBlock.Color == AdaptiveTextColor.Good) - tb[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlForegroundSuccessBrush"); // Or a custom resource - else if (textBlock.Color == AdaptiveTextColor.Warning) - tb[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlForegroundCautionBrush"); // Or a custom resource - else if (textBlock.Color == AdaptiveTextColor.Attention) - tb[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlErrorTextForegroundBrush"); - else if (textBlock.IsSubtle) - tb[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlForegroundBaseMediumBrush"); - else - tb[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlForegroundBaseHighBrush"); - - // Fallback for custom brushes if system ones aren't perfect, but for now system ones are safer for light/dark + // Color — 对齐到项目真实主题资源(不再依赖幽灵 SystemControl* 画刷) + tb[!TextBlock.ForegroundProperty] = textBlock.Color switch + { + AdaptiveTextColor.Accent => new DynamicResourceExtension("AccentBrush"), + AdaptiveTextColor.Good => new DynamicResourceExtension("SuccessDeepTextBrush"), + AdaptiveTextColor.Warning => new DynamicResourceExtension("WarningDarkTextBrush"), + AdaptiveTextColor.Attention => new DynamicResourceExtension("ErrorDarkTextBrush"), + _ => textBlock.IsSubtle + ? new DynamicResourceExtension("TextSecondaryBrush") + : new DynamicResourceExtension("TextPrimaryBrush") + }; // Alignment tb.TextAlignment = textBlock.HorizontalAlignment switch @@ -135,22 +131,22 @@ private Control RenderContainer(AdaptiveContainer container) var border = new Border { - Child = stackPanel + Child = stackPanel, + Padding = new Thickness(12), + CornerRadius = new CornerRadius(6) }; - // Style based on container style + // Style based on container style —— 两种容器风格一致,仅靠颜色区分语义 if (container.Style == AdaptiveContainerStyle.Emphasis) { - // Use a dynamic resource for background to support dark mode - border[!Border.BackgroundProperty] = new DynamicResourceExtension("SystemControlBackgroundChromeMediumLowBrush"); - border.Padding = new Thickness(8); - border.CornerRadius = new CornerRadius(4); + border[!Border.BackgroundProperty] = new DynamicResourceExtension("SurfaceVariantBrush"); } else if (container.Style == AdaptiveContainerStyle.Attention) { - border[!Border.BackgroundProperty] = new DynamicResourceExtension("DangerBackgroundBrush"); - border.Padding = new Thickness(8); - border.CornerRadius = new CornerRadius(4); + border[!Border.BackgroundProperty] = new DynamicResourceExtension("DangerBackgroundBrush"); + border[!Border.BorderBrushProperty] = new DynamicResourceExtension("ErrorDarkTextBrush"); + border.BorderThickness = new Thickness(3, 0, 0, 0); + border.CornerRadius = new CornerRadius(6, 0, 0, 6); } return border; @@ -213,10 +209,10 @@ private Control RenderColumnSet(AdaptiveColumnSet columnSet) private Control RenderFactSet(AdaptiveFactSet factSet) { - var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition(1, GridUnitType.Auto)); // Title - grid.ColumnDefinitions.Add(new ColumnDefinition(16, GridUnitType.Pixel)); // Spacing - grid.ColumnDefinitions.Add(new ColumnDefinition(1, GridUnitType.Star)); // Value + var grid = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto, 20, *") + }; for (int i = 0; i < factSet.Facts.Count; i++) { @@ -227,12 +223,11 @@ private Control RenderFactSet(AdaptiveFactSet factSet) var title = new TextBlock { Text = fact.Title, - FontWeight = FontWeight.Bold, - Margin = new Thickness(0, 0, 0, 4) + FontWeight = FontWeight.Medium, + Margin = new Thickness(0, 0, 0, 6) }; - // Use dynamic resource for text color - title[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlForegroundBaseHighBrush"); - + title[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("TextSecondaryBrush"); + Grid.SetRow(title, i); Grid.SetColumn(title, 0); @@ -240,10 +235,9 @@ private Control RenderFactSet(AdaptiveFactSet factSet) { Text = fact.Value, TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(0, 0, 0, 4) + Margin = new Thickness(0, 0, 0, 6) }; - // Use dynamic resource for text color (slightly subtler if desired, or same) - value[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlForegroundBaseMediumBrush"); + value[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("TextPrimaryBrush"); Grid.SetRow(value, i); Grid.SetColumn(value, 2); @@ -264,7 +258,7 @@ private Control RenderImage(AdaptiveImage image) HorizontalAlignment = HorizontalAlignment.Center, FontSize = 10 }; - textBlock[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlForegroundBaseMediumBrush"); + textBlock[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("TextSecondaryBrush"); var border = new Border { diff --git a/src/MarketAssistant.App/config/models.yaml b/src/MarketAssistant.App/config/models.yaml index 5c3531c..6fdf410 100644 --- a/src/MarketAssistant.App/config/models.yaml +++ b/src/MarketAssistant.App/config/models.yaml @@ -1,7 +1,3 @@ # 支持的AI模型配置 models: - - deepseek-ai/DeepSeek-V3.2 - - zai-org/GLM-4.6 - - moonshotai/Kimi-K2-Thinking - - MiniMaxAI/MiniMax-M2 - - Qwen/Qwen3-Omni-30B-A3B-Instruct \ No newline at end of file + - deepseek-ai/DeepSeek-V4-Flash \ No newline at end of file diff --git a/src/MarketAssistant.DataProviders/StringToDecimalConverter.cs b/src/MarketAssistant.DataProviders/StringToDecimalConverter.cs index abb1510..56fc10b 100644 --- a/src/MarketAssistant.DataProviders/StringToDecimalConverter.cs +++ b/src/MarketAssistant.DataProviders/StringToDecimalConverter.cs @@ -4,30 +4,72 @@ namespace MarketAssistant.Services.Data; /// -/// 将字符串格式的数字转换为 decimal(币安等加密货币 API 返回的价格字段通常是字符串) +/// 将字符串格式的数字转换为 decimal(币安等加密货币 API 返回的价格字段通常是字符串)。 +/// 使用 JsonConverterFactory 同时支持 decimal 和 decimal? 两种字段类型。 /// -public sealed class StringToDecimalConverter : JsonConverter +public sealed class StringToDecimalConverter : JsonConverterFactory { - public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override bool CanConvert(Type typeToConvert) + => typeToConvert == typeof(decimal) || typeToConvert == typeof(decimal?); + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + => typeToConvert == typeof(decimal?) + ? new NullableDecimalConverter() + : new NonNullableDecimalConverter(); + + private sealed class NonNullableDecimalConverter : JsonConverter { - if (reader.TokenType == JsonTokenType.String) + public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - var stringValue = reader.GetString(); - if (decimal.TryParse(stringValue, out var value)) + if (reader.TokenType == JsonTokenType.String) { - return value; + var stringValue = reader.GetString(); + if (decimal.TryParse(stringValue, out var value)) + return value; } + else if (reader.TokenType == JsonTokenType.Number) + { + return reader.GetDecimal(); + } + + return 0m; } - else if (reader.TokenType == JsonTokenType.Number) + + public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options) { - return reader.GetDecimal(); + writer.WriteStringValue(value.ToString()); } - - return 0m; } - public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options) + private sealed class NullableDecimalConverter : JsonConverter { - writer.WriteStringValue(value.ToString()); + public override decimal? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return null; + + if (reader.TokenType == JsonTokenType.String) + { + var stringValue = reader.GetString(); + if (string.IsNullOrEmpty(stringValue)) + return null; + if (decimal.TryParse(stringValue, out var value)) + return value; + return null; + } + + if (reader.TokenType == JsonTokenType.Number) + return reader.GetDecimal(); + + return null; + } + + public override void Write(Utf8JsonWriter writer, decimal? value, JsonSerializerOptions options) + { + if (value.HasValue) + writer.WriteStringValue(value.Value.ToString()); + else + writer.WriteNullValue(); + } } } diff --git a/tests/Middleware/TradingFunctionGuardMiddlewareTest.cs b/tests/Middleware/TradingFunctionGuardMiddlewareTest.cs index b7e3a75..633ea92 100644 --- a/tests/Middleware/TradingFunctionGuardMiddlewareTest.cs +++ b/tests/Middleware/TradingFunctionGuardMiddlewareTest.cs @@ -34,19 +34,14 @@ public void Constructor_CustomMaxToolCalls_ShouldAccept() [TestMethod] [TestCategory("Unit")] - public void ResetCallCount_ShouldResetToZero() + public void ToolCallCount_ShouldStartAtZero() { var middleware = new TradingFunctionGuardMiddleware( NullLogger.Instance); - var countField = typeof(TradingFunctionGuardMiddleware).GetField( - "_toolCallCount", - BindingFlags.NonPublic | BindingFlags.Instance)!; - countField.SetValue(middleware, 12); + var count = GetPrivateField(middleware, "_toolCallCount"); - middleware.ResetCallCount(); - - Assert.AreEqual(0, countField.GetValue(middleware)); + Assert.AreEqual(0, count); } [TestMethod] From 4ac039089a950030e481f68c5326476c8176f97c Mon Sep 17 00:00:00 2001 From: MayueCif Date: Fri, 19 Jun 2026 08:53:40 +0800 Subject: [PATCH 32/44] =?UTF-8?q?[Trading]=20=E4=BF=AE=E5=A4=8D=E9=A3=8E?= =?UTF-8?q?=E6=8E=A7=E4=B8=8E=E7=AD=96=E7=95=A5=E7=BC=BA=E9=99=B7=E5=B9=B6?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E5=B8=81=E5=AE=89=20fills=20=E5=93=8D?= =?UTF-8?q?=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RiskManager: 风控校验通过后写入账户快照,修复回撤熔断因无快照数据而失效的问题 - MarketMonitor: 移除剩余交易次数硬编码 15,改读 RiskConfig.MaxDailyTrades - CryptoPortfolioService: 改用 GetOpenPositionAvgEntryPriceAsync 按 FIFO 未平仓计算均价,修复浮盈被已平仓记录污染 - StrategyEngine: EvaluateDCA 改为异步并在低价加倍后立即持久化 CustomParams,修复重启后加倍上限失效 - BinanceExchangeClient: 解析订单响应 fills 数组汇总手续费,对齐币安 POST /api/v3/order 结构 - TradeExecutor: 修正确认回调参数顺序、补全 60s 超时与重试策略 - 扩展 ExchangeOrderResult/TradingModels 支持手续费、FIFO 持仓、追踪止损峰值持久化 详见 docs/trade-monitor-fix-plan.md --- docs/trade-monitor-fix-plan.md | 927 ++++++++++++++++++ .../Crypto/BinanceAccountService.cs | 17 + .../Trading/CryptoPortfolioService.cs | 3 +- .../Exchanges/BinanceExchangeClient.cs | 17 +- .../Trading/MarketMonitor.cs | 28 +- .../Trading/RiskManager.cs | 59 ++ .../Trading/StrategyEngine.cs | 142 ++- .../Trading/TradeExecutor.cs | 75 +- .../Trading/TradingDataService.cs | 270 ++++- .../Abstractions/IExchangeClient.cs | 11 + src/MarketAssistant.Trading/TradingModels.cs | 103 +- 11 files changed, 1580 insertions(+), 72 deletions(-) create mode 100644 docs/trade-monitor-fix-plan.md diff --git a/docs/trade-monitor-fix-plan.md b/docs/trade-monitor-fix-plan.md new file mode 100644 index 0000000..bfd0e41 --- /dev/null +++ b/docs/trade-monitor-fix-plan.md @@ -0,0 +1,927 @@ +# TradeMonitorView 页面与交易策略修复方案 + +> 基于 `TradeMonitorView.axaml` 及其底层交易引擎(`MarketMonitor` / `StrategyEngine` / `TradeExecutor` / `RiskManager` / `TradingDataService`)的全面评审,本文档列出所有不符合专业交易实践的问题,并给出具体修复方案。 + +修复优先级标记: +- **P0**:阻塞功能或导致数据失真,必须立即修复 +- **P1**:影响交易安全或风控有效性 +- **P2**:体验与专业性增强 + +--- + +## 一、UI 设计层修复 + +### 1.1 [P0] 补全人工确认 UI(当前会导致交易死锁) + +**问题位置**:[TradeMonitorView.axaml](file:///c:/Users/mayue/Desktop/MarketAssistant/src/MarketAssistant.App/Views/Pages/Trading/TradeMonitorView.axaml) + +**根因**:[TradeMonitorViewModel.cs:29-36](file:///c:/Users/mayue/Desktop/MarketAssistant/src/MarketAssistant.App/ViewModels/Trading/TradeMonitorViewModel.cs#L29-L36) 定义了完整的确认属性与命令,但 View 中完全未渲染。当 `RiskManager` 返回 `NeedsConfirmation` 时,`TradeExecutor` 通过 `ConfirmationCallback` 等待 `TaskCompletionSource`,用户无法操作 → 交易永久阻塞,且策略锁被占用导致后续触发全部跳过。 + +#### Bug A:确认回调参数顺序相反(必须与 UI 一同修复) + +**问题位置**: +- 调用方 [TradeExecutor.cs:90-91](file:///c:/Users/mayue/Desktop/MarketAssistant/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs#L90-L91) +- 定义方 [TradeMonitorViewModel.cs:107-108](file:///c:/Users/mayue/Desktop/MarketAssistant/src/MarketAssistant.App/ViewModels/Trading/TradeMonitorViewModel.cs#L107-L108) +- 委托签名 [TradeExecutor.cs:23-25](file:///c:/Users/mayue/Desktop/MarketAssistant/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs#L23-L25) + +**根因**:调用与定义的 `price` / `quantity` 参数顺序相反。 + +| 位置 | 参数顺序 | +|------|---------| +| `TradeExecutor.ConfirmationCallback` 委托签名注释 | `(symbol, side, quantity, price, reason)` | +| `TradeExecutor` 实际调用 | `(symbol, side, quantity, currentPrice, reason)` | +| `TradeMonitorViewModel.OnTradeConfirmationRequestedAsync` 定义 | `(symbol, side, price, quantity, reason)` | + +结果:ViewModel 接收到的 `price` 实际是数量,`quantity` 实际是价格。再叠加格式化差异(`ConfirmationPrice` 用 `F2`、`ConfirmationQuantity` 用 `F6`),UI 上"价格"栏会显示成 6 位小数的数量,"数量"栏会显示成 2 位小数的价格,极具误导性。 + +**修复方案**:统一参数顺序为 `(symbol, side, price, quantity, reason)`(与语义一致,price 在前更自然)。 + +1. 修正 `TradeExecutor.ConfirmationCallback` 委托签名与调用: + +```csharp +/// +/// Human-in-the-Loop 确认回调。 +/// 当风控返回 NeedsConfirmation 时,调用此回调等待用户确认。 +/// 参数: (symbol, side, price, quantity, reason) → true=放行 false=拒绝。 +/// 未设置时保持现有行为(直接拒绝)。 +/// +public Func>? ConfirmationCallback { get; set; } +``` + +```csharp +// 调用处修正参数顺序 +var approved = await ConfirmationCallback( + instrumentSymbol, side, currentPrice, quantity, riskCheck.Reason ?? "需人工确认"); +``` + +2. `TradeMonitorViewModel.OnTradeConfirmationRequestedAsync` 签名保持不变(已是正确顺序)。 + +3. 全局检索所有 `ConfirmationCallback` 的赋值点,确保签名一致。当前仅 `TradeMonitorViewModel` 构造函数赋值一次,无其他调用方。 + +**验证**:修复后构造一笔需确认的交易,检查 UI 显示的价格与数量是否与实际下单参数一致。 + +#### 补全确认 UI + +**修复方案**:在 `TradeMonitorView.axaml` 的 `ScrollViewer` 内最顶部插入确认对话框卡片: + +```xml + + + + + + + + + + + + + + + + + + + -