From 5506b674ad2610217cc02367bf6b6c14609b3f6c Mon Sep 17 00:00:00 2001 From: MayueCif Date: Sun, 30 Aug 2026 19:18:55 +0800 Subject: [PATCH] =?UTF-8?q?[=E5=85=A8=E5=BA=93]=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=EF=BC=9AAgent=E5=B7=A5=E5=85=B7=E6=8E=A5=E5=8F=A3=E6=95=B4?= =?UTF-8?q?=E5=90=88=E3=80=81Trading=E4=BB=93=E5=82=A8=E6=8B=86=E5=88=86?= =?UTF-8?q?=E3=80=81RAG=20PDF=E5=A4=84=E7=90=86=E6=A8=A1=E5=9D=97=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 +- .vscode/tasks.json | 35 - ...ss_10d07dfd-a04e-4c14-94ad-f8d9cc4be040.md | 58 - ...ss_8fc947a7-0f60-4bee-8091-04b858439e94.md | 61 - AGENTS.md | 15 +- Directory.Build.props | 3 +- Directory.Packages.props | 3 +- README.md | 13 +- docs/plan/alert-center-refactor-plan.md | 80 + ...241\346\212\245\345\221\212-2026-08-28.md" | 229 --- scripts/BUILD.md | 2 +- scripts/README.md | 8 - src/MarketAssistant.Agents/AGENTS.md | 22 +- .../Tools/Abstractions/IBasicDataTools.cs | 7 +- .../Tools/Abstractions/ICryptoBasicTools.cs | 16 - .../Tools/Abstractions/ICryptoMetricsTools.cs | 78 - .../Abstractions/ICryptoSentimentTools.cs | 51 - .../Tools/Abstractions/IFinancialTools.cs | 7 +- .../Tools/Abstractions/INewsDataTools.cs | 16 +- .../Tools/Abstractions/ISentimentTools.cs | 7 +- .../Tools/Abstractions/IShareBasicTools.cs | 16 - .../Abstractions/IShareFinancialTools.cs | 21 - .../Abstractions/IShareSentimentTools.cs | 14 - .../Tools/Abstractions/IStrategyTools.cs | 18 +- .../Tools/Abstractions/ITechnicalDataTools.cs | 24 +- .../Abstractions/ITradingExecutionTools.cs | 10 +- .../Tools/Models/AssetQuoteInfo.cs | 128 -- .../Analysts/CryptoMetricsAnalystAgent.cs | 2 +- .../Agents/Tools/AShare/AShareBasicTools.cs | 2 +- .../Tools/AShare/AShareFinancialTools.cs | 2 +- .../Tools/AShare/AShareSentimentTools.cs | 2 +- .../Agents/Tools/Crypto/CryptoBasicTools.cs | 2 +- .../Agents/Tools/Crypto/CryptoMetricsTools.cs | 2 +- .../Tools/Crypto/CryptoSentimentTools.cs | 2 +- .../Applications/IMarketServiceRegistry.cs | 30 - .../Applications/MarketServiceRegistry.cs | 44 - .../Providers/DocumentVectorizationService.cs | 148 ++ .../Services/Market/AShareMarketModule.cs | 4 +- .../Services/Market/CryptoMarketModule.cs | 4 +- .../Market/CryptoRealtimeQuoteService.cs | 48 + .../Services/Market/IRealtimeQuoteService.cs | 38 + .../Services/Market/MarketContext.cs | 23 +- .../Market/NoopRealtimeQuoteService.cs | 20 + .../Services/Mcp/McpService.cs | 18 +- .../Services/ServiceCollectionExtensions.cs | 1 + .../Trading/AccountSnapshotRepository.cs | 60 + .../Trading/DailyStatsRepository.cs | 90 + .../Trading/PositionRepository.cs | 204 +++ .../Trading/RiskConfigRepository.cs | 60 + .../Trading/TradeRecordRepository.cs | 352 ++++ .../Trading/TradingDataService.cs | 1464 +---------------- .../Trading/TradingEnvironmentKeys.cs | 14 + .../Trading/TradingRepositoryBase.cs | 83 + .../Trading/TradingSchemaInitializer.cs | 413 +++++ .../Trading/TradingStrategyRepository.cs | 284 ++++ src/MarketAssistant.App/AGENTS.md | 1 + .../Services/ServiceCollectionExtensions.cs | 3 - .../ViewModels/AssetPageViewModel.cs | 65 +- .../ViewModels/AssetSelectionPageViewModel.cs | 5 +- .../ViewModels/FavoritesPageViewModel.cs | 72 +- .../ViewModels/Home/HomeSearchViewModel.cs | 5 +- .../ViewModels/Home/HotAssetsViewModel.cs | 5 +- .../ViewModels/Home/RecentAssetsViewModel.cs | 9 +- .../ViewModels/Home/TelegraphNewsViewModel.cs | 7 +- .../ViewModels/PriceAlertPageViewModel.cs | 5 +- .../SettingsPageViewModel.Documents.cs | 176 ++ .../SettingsPageViewModel.Models.cs | 336 ++++ .../ViewModels/SettingsPageViewModel.cs | 574 +------ .../Views/Pages/Trading/TradingPageView.axaml | 4 +- src/MarketAssistant.DataProviders/AGENTS.md | 2 - .../Services/PdfHeadingDetector.cs | 152 ++ .../Services/PdfImageProcessor.cs | 95 ++ .../Services/PdfListFormatter.cs | 43 + .../Services/PdfMarkdownConverter.cs | 698 +------- .../Services/PdfMarkdownModels.cs | 58 + .../Services/PdfMarkdownPostProcessor.cs | 64 + .../Services/PdfTableExtractor.cs | 238 +++ .../Services/PdfTextUtility.cs | 61 + tests/Application/HomeSearchViewModelTest.cs | 32 - tests/Tools/BasicDataToolsTest.cs | 8 +- tests/Tools/CryptoMetricsToolsTest.cs | 16 +- tests/Tools/FinancialDataToolsTest.cs | 12 +- tests/Tools/NewsDataToolsTest.cs | 4 +- tests/Tools/SentimentToolsTest.cs | 16 +- tests/Tools/TechnicalDataToolsTest.cs | 16 +- tests/Trading/TradeFailurePolicyTest.cs | 65 - tests/unit.runsettings | 5 +- 87 files changed, 3459 insertions(+), 3758 deletions(-) delete mode 100644 .vscode/tasks.json delete mode 100644 .zcode/plans/plan-sess_10d07dfd-a04e-4c14-94ad-f8d9cc4be040.md delete mode 100644 .zcode/plans/plan-sess_8fc947a7-0f60-4bee-8091-04b858439e94.md create mode 100644 docs/plan/alert-center-refactor-plan.md delete mode 100644 "docs/\344\273\243\347\240\201\345\256\241\350\256\241\346\212\245\345\221\212-2026-08-28.md" delete mode 100644 src/MarketAssistant.Agents/Tools/Abstractions/ICryptoBasicTools.cs delete mode 100644 src/MarketAssistant.Agents/Tools/Abstractions/ICryptoMetricsTools.cs delete mode 100644 src/MarketAssistant.Agents/Tools/Abstractions/ICryptoSentimentTools.cs delete mode 100644 src/MarketAssistant.Agents/Tools/Abstractions/IShareBasicTools.cs delete mode 100644 src/MarketAssistant.Agents/Tools/Abstractions/IShareFinancialTools.cs delete mode 100644 src/MarketAssistant.Agents/Tools/Abstractions/IShareSentimentTools.cs delete mode 100644 src/MarketAssistant.Agents/Tools/Models/AssetQuoteInfo.cs delete mode 100644 src/MarketAssistant.App.Services/Applications/IMarketServiceRegistry.cs delete mode 100644 src/MarketAssistant.App.Services/Applications/MarketServiceRegistry.cs create mode 100644 src/MarketAssistant.App.Services/Infrastructure/Providers/DocumentVectorizationService.cs create mode 100644 src/MarketAssistant.App.Services/Services/Market/CryptoRealtimeQuoteService.cs create mode 100644 src/MarketAssistant.App.Services/Services/Market/IRealtimeQuoteService.cs create mode 100644 src/MarketAssistant.App.Services/Services/Market/NoopRealtimeQuoteService.cs create mode 100644 src/MarketAssistant.App.Services/Trading/AccountSnapshotRepository.cs create mode 100644 src/MarketAssistant.App.Services/Trading/DailyStatsRepository.cs create mode 100644 src/MarketAssistant.App.Services/Trading/PositionRepository.cs create mode 100644 src/MarketAssistant.App.Services/Trading/RiskConfigRepository.cs create mode 100644 src/MarketAssistant.App.Services/Trading/TradeRecordRepository.cs create mode 100644 src/MarketAssistant.App.Services/Trading/TradingEnvironmentKeys.cs create mode 100644 src/MarketAssistant.App.Services/Trading/TradingRepositoryBase.cs create mode 100644 src/MarketAssistant.App.Services/Trading/TradingSchemaInitializer.cs create mode 100644 src/MarketAssistant.App.Services/Trading/TradingStrategyRepository.cs create mode 100644 src/MarketAssistant.App/ViewModels/SettingsPageViewModel.Documents.cs create mode 100644 src/MarketAssistant.App/ViewModels/SettingsPageViewModel.Models.cs create mode 100644 src/MarketAssistant.Rag/Services/PdfHeadingDetector.cs create mode 100644 src/MarketAssistant.Rag/Services/PdfImageProcessor.cs create mode 100644 src/MarketAssistant.Rag/Services/PdfListFormatter.cs create mode 100644 src/MarketAssistant.Rag/Services/PdfMarkdownModels.cs create mode 100644 src/MarketAssistant.Rag/Services/PdfMarkdownPostProcessor.cs create mode 100644 src/MarketAssistant.Rag/Services/PdfTableExtractor.cs create mode 100644 src/MarketAssistant.Rag/Services/PdfTextUtility.cs delete mode 100644 tests/Trading/TradeFailurePolicyTest.cs diff --git a/.gitignore b/.gitignore index 3f47674..f5c9046 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,9 @@ ## ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore -# Windows 保留设备名(误生成时 git 会反复列出,磁盘上实际不存在) +# Windows 保留设备名:Git Bash `> nul` 误重定向会在磁盘生成实体文件; +# Windows 下无法删除/重命名(Win32 设备名保留,8 种方式实测失败),需在 POSIX 环境清理。 +# 已从 git 索引移除(git rm),此处忽略防止再次被 git 追踪 nul # User-specific files @@ -404,6 +406,9 @@ FodyWeavers.xsd *.sln.iml .idea/ +# 仓库根目录下所有以 . 开头的文件夹(各工具的本地缓存/配置目录) +/.[!.]*/ + # Secrets and credentials .secrets .env diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index a6b1805..0000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "Build solution", - "type": "shell", - "command": "dotnet build .\\MarketAssistant.slnx -c Debug --nologo", - "isBackground": false, - "problemMatcher": [ - "$msCompile" - ], - "group": "build" - }, - { - "label": "Run tests (subset)", - "type": "shell", - "command": "dotnet test .\\TestMarketAssistant\\TestMarketAssistant.csproj -c Debug --filter FullyQualifiedName~Vectors.ClipImageEmbeddingServiceTest --no-build --nologo", - "isBackground": false, - "problemMatcher": [ - "$msCompile" - ], - "group": "test" - }, - { - "label": "Run ClipImageEmbeddingServiceTest", - "type": "shell", - "command": "dotnet test .\\TestMarketAssistant\\TestMarketAssistant.csproj -c Debug --filter \"FullyQualifiedName~ClipImageEmbeddingServiceTest\" --no-build --nologo", - "isBackground": false, - "problemMatcher": [ - "$msCompile" - ], - "group": "test" - } - ] -} \ No newline at end of file diff --git a/.zcode/plans/plan-sess_10d07dfd-a04e-4c14-94ad-f8d9cc4be040.md b/.zcode/plans/plan-sess_10d07dfd-a04e-4c14-94ad-f8d9cc4be040.md deleted file mode 100644 index 757a81f..0000000 --- a/.zcode/plans/plan-sess_10d07dfd-a04e-4c14-94ad-f8d9cc4be040.md +++ /dev/null @@ -1,58 +0,0 @@ -# Func 注入收敛为具名工厂 + 无意义注释清理(已逐项验证) - -## 已验证的事实基础 - -- 7 个 `Func` 注册(App/ServiceCollectionExtensions.cs:61-74),8 个 ViewModel 消费,调用点全部传 `_marketContext.CurrentMarket`。 -- 4 个服务(HomeAsset/Favorite/AssetHistory/AssetCache)用 `[ServiceKey] MarketType` 按市场参数化实例(FavoriteService.cs:24 等 4 处),**必须保留按市场解析**——不能改直接注入。 -- 页面 Func ×7(:85-91),MainWindowViewModel.cs:17-23 消费,`Func? = null` 为坏味道。 -- SettingsPageViewModel 注入 4 个懒加载 Func(:34-42),用于向量化与保存确认。 -- `Func`(App.Services:381)打破循环依赖,TradingEnvironmentService.cs:17 消费。 -- Rag `Func`(Rag/Extensions:58)。 -- 测试桩仅 tests/Application/HomeSearchViewModelTest.cs:30 一处。 - ---- - -## 第一部分:Func 注入收敛 - -### 1. `Func` ×7 → `IMarketServiceRegistry` -- 新建接口(App.Services Applications 层),7 个强类型方法,各带 `MarketType` 参数:`GetKLineService / GetAssetInfoService / GetNewsUpdateService / GetHomeAssetService / GetAssetHistoryService / GetFavoriteService / GetAssetCacheService`。 -- 实现内部 `GetRequiredKeyedService(marketType)`;注册到 `AddApplicationServices()`,删除 7 个 Func 注册。 -- 8 个 ViewModel 的 `Func` 参数统一替换为该接口(AssetPageViewModel、AssetSelectionPageViewModel、PriceAlertPageViewModel、FavoritesPageViewModel、HomeSearchViewModel、HotAssetsViewModel、RecentAssetsViewModel、TelegraphNewsViewModel)。 -- 更新 HomeSearchViewModelTest 的桩为假注册表。 - -### 2. 页面 ViewModel Func ×7 → `IPageViewModelFactory` -- 新建 `IPageViewModelFactory`(`T Create() where T : ViewModelBase`),App 层实现。 -- MainWindowViewModel 注入单一工厂,删 7 个 Func 字段与可选 Func 参数;`NavigationService.NavigateTo` 改用该工厂(消除其 IServiceProvider 服务定位)。 -- 删 `AddViewModels` 中 7 个 Func 注册,页面 Transient 注册不变。 - -### 3. SettingsPageViewModel 4 Func → 2 具名接口 -- `IRagInfrastructureProvider`(GetEmbeddingFactory/GetVectorStore/GetIngestionService):实现保留延迟解析语义;删 `Func`、`Func` 注册。 -- `IMarketMonitorProvider`(GetMonitor):替换 `Func`,TradingEnvironmentService 与 SettingsPageViewModel 切换;循环依赖打破语义不变。 - -### 4. 顺带修复服务定位器 -- `ClipImageEmbeddingService.cs:68`:移除 IServiceProvider 依赖(实现时按 `_chat` 实际用法选聊天工厂或窄接口注入)。 -- `InvestmentSelectionWorkflow.cs:86-92`:新建 `IInvestmentExecutorFactory`(按 MarketType 提供 CriteriaExecutor 及其余 3 个 Executor)替代每次 Run 的服务定位。 -- 同步清理该文件的"【学习要点】【实现细节】"教学式注释。 - -### 5. 保留不动的合理 Func -重试包裹(GlobalExceptionHandler/ViewModelBase/ToolExecutor/SqliteServiceBase)、`ThrottledExecuteAsync`、`TradeExecutor.ConfirmationRequested` 事件、`TextChunkingService` 策略参数、测试桩。 - ---- - -## 第二部分:无意义注释清理(Func 收敛后进行) - -按 AGENTS.md 规范(仅函数级文档注释 + 晦涩逻辑说明): - -- **A. 方法名翻译式 /// summary(~200 处)**:`Agents/Tools/Abstractions/*.cs`(~30 处)、`AnalysisEnums.cs` 枚举成员单词翻译(~50 处)、`NewsEventAnalysisResult.cs`、9 处 `/// 构造函数`、各 ViewModel/Service 复述式 summary、`DocxMarkdownConverter.cs`。 -- **B. 复述下一行代码的行注释(~70 处)**:`CryptoAssetInfoService.cs`(~10 处)、`KLineChartView.cs`(~8)、`TelegraphNewsViewModel/HotAssetsViewModel`(~11)、`BinanceMarketDataService`、`ScreenInvestmentTargetsExecutor` 等零散处。 -- **C. 教学式注释**:`ClipImageEmbeddingService.cs` 的【学习要点】【实现细节】段。 -- **保留**:并发/释放原理等高质量说明、设计依据注释(如"对齐设计系统裁决 #6")、Colors/TextStyles.axaml 设计系统注释;#region 本轮不动。 - ---- - -## 验证与执行顺序 - -1. Func 收敛分步(1→2→3→4),每步 `dotnet build MarketAssistant.slnx -c Debug`。 -2. 注释清理按项目分批(Agents → App.Services → App → Rag/DataProviders),完成后再次 build。 -3. DI 改动面大,最终跑一次 `dotnet test tests/TestMarketAssistant.csproj -c Debug`。 -4. 不做 git 提交(未获要求)。 \ No newline at end of file diff --git a/.zcode/plans/plan-sess_8fc947a7-0f60-4bee-8091-04b858439e94.md b/.zcode/plans/plan-sess_8fc947a7-0f60-4bee-8091-04b858439e94.md deleted file mode 100644 index b531233..0000000 --- a/.zcode/plans/plan-sess_8fc947a7-0f60-4bee-8091-04b858439e94.md +++ /dev/null @@ -1,61 +0,0 @@ -# MarketAssistant 重构修复计划(全量 P0+P1+P2) - -基于审计报告 + 三个并行源码核实。P0 16 条中 13 条完全属实、3 条部分准确(细节修正已并入下文)、无虚警;P1 抽查发现 2 条虚警(#17、#30,**从计划中剔除**);核实另发现 4 个报告遗漏的 sibling 问题,已纳入对应阶段。 - -**既有决策**(沿用):WebView HTML 渲染默认禁用;交易域死代码直接删除整块。 - ---- - -## 阶段 0 — 文档勘误(先落 docs/代码审计报告-2026-08-28.md) -- 修正 P0-3:并非零节流(`MarketMonitor.cs:272` 已有失败冷却);Paused 已正确写入(L447),缺的是用户可见通知。 -- 修正 P0-16:泄漏根因是 P0-14 导致旧 VM 连 Dispose 都不发生,时序与报告所述相反。 -- 修正 P0-13 严重度:模态框滞留为 UX 级问题。 -- P1-17("批量嵌入"实为逐个)与 P1-30(停止按钮不可达)标记为虚警删除。 -- 补充 4 个新 sibling:TradeExecutor.cs:247 stopPrice 硬编码 null;InvestmentSelection 三个 Executor 均吞取消;零向量破坏 `null=不可用` 契约;OnMarketChanged 需 disposed 防护的其余 4 个 VM。 -- 修正路径偏差:Singleton 注册在 `App.Services/Services/ServiceCollectionExtensions.cs`。 - -## 阶段 1 — 资金安全(P0-1~5 + 放大器 + sibling #1) -1. **删除原生条件单整套**(按决策):`StrategyEngine.cs:484,491-559,564-582`、`TradingDataService.cs:297-307,1348`、`TradingStrategy.cs:56-60`、`strategies.native_order_id` 列及迁移;在合约止损策略创建路径加"客户端轮询执行、进程退出期间不生效"显式警示。 -2. **TradeExecutor 重试循环**(P0-2):改为 `IsTransient(ex)` 递归判定(HttpRequestException/TimeoutException/SocketException/非取消 TaskCanceledException,含 InnerException 链),catch filter 用之;L320 失败分类复用同一函数。同时修 sibling:FriendlyException(inner=HttpRequestException)应归 Network。 -3. **TradingCredentialStore._cache**(P0-5):改 `ConcurrentDictionary`,`SetCredentials`/`ClearCredentials` 用 `AddOrUpdate`/`TryRemove`。 -4. **StrategyEngine/MarketMonitor 节流**(P0-3 修正版):触发成功后按策略类型统一加 `LastTriggeredAt` 间隔节流;`StopLoss/TakeProfit/TrailingStop` 触发成功置 `Completed`;`MaxExecutions==null` 兜底视为 1;`PauseStrategyAfterRejectionAsync` 发用户可见通知(复用 `_notificationService`)。 -5. **环境切换竞态**(P0-4):`TradeExecutor` 入口把 `_exchangeClient` 快照为局部变量,风控/杠杆/下单/撤单全用快照;下单前再校验 mode 与快照一致,不一致拒绝;`_currentMode` 加 `volatile`;`ApplyModeAsync` 在 StopAsync 超时(`MarketMonitor.cs:184`)时中止切换并报错。 -6. **风控路径禁用 3 秒缓存**(放大器):`RiskManager.cs:63`、`AISignalStrategyExecutor.cs:189` 改走实时查询(缓存仅 UI 展示用);`RiskManager.cs:138-142` fail-open 改 fail-closed。 -7. **sibling**:若保留条件单的映射逻辑不再需要(已删除整块),`TradeExecutor.cs:247` 随删除一并清理。 - -## 阶段 2 — RAG 数据污染(P0-6~9 + sibling #3) -1. **TextCleaningService**(P0-6):删除 `RepeatingChars` 规则;`PhonePattern` 改为带边界断言的中国手机号语义 `(?()` 激活 HITL(P0-10)。 -2. **确认对话框**(P0-13):`IDialogService.ShowConfirmationAsync` 增 `CancellationToken` 重载,内部 `ct.Register(() => Dispatcher.UIThread.Post(dialog.Close))`;TradeConfirmationService 用 `CancellationTokenSource(60s)`,catch OCE 走超时拒绝路径。 -3. **AssetPageViewModel**(P0-15):删 `Task.Run`(L274 改 `_ = LoadAssetDataAsync(...)`),简化 L192 冗余 `InvokeAsync`。 -4. **MainWindow**(P0-14):拆 `SelectedMainNavigationItem`/`SelectedBottomNavigationItem`;`SwitchToMarket` 先缓存 `currentTitle` 到局部变量,切完按 title 查找 item 并导航。 -5. **SettingsPageViewModel**(P0-11):`InitializeAsync` 改 `UserSetting = CurrentSetting.Clone()` 草稿模式;`SaveAsync` 时 `UpdateSettings(UserSetting.Clone())` 提交;修正 L369-371/L388 注释;`UserSetting` 补 `Clone()` 方法。 -6. **RichTextBlock**(P0-12,按决策默认禁用):`DetectContentFormat` 不再自动识别 HTML,仅 Markdown/PlainText;保留显式 `Format=Html` 分支入口但注释标注需白名单消毒+CSP 后才可启用。 -7. **TelegraphNewsViewModel**(P0-16 修正版):`OnMarketChanged` lambda 首行 `if (_disposed) return;`;`Dispose()` 幂等退订;**联动阶段 3-4**——P0-14 修复后旧 HomePageViewModel 才会被真正 Dispose,需一并验证。 -8. 全仓排查其余 4 个重写 `OnMarketChanged` 的 VM(AgentAnalysis/AssetSelection/Favorites/PriceAlert)补 disposed 防护与完整退订。 - -## 阶段 4 — P1 批量修复(37 条,剔除 2 虚警) -按第三节编号顺序,分组处理: -- **交易域 1-13**:#1 金额列读改 `ReadDecimal()`(strategies L1226-1239、trade_records L1268-1271);#2 `strategies` 表加 `order_type`/`slippage_tolerance` 列 + 迁移;#3 exchangeInfo filters 缓存 + 下单前本地取整(stepSize/tickSize);#4 `BinanceUserDataStreamService._ws` 数据竞争(局部快照引用);#5 与阶段 1-6 合并;#6 RiskManager 补买入余额校验;#7 回撤熔断改 30 天滚动窗口 + 重置入口;#8 `MarketContext._currentMarket` 加 volatile;#9 `PriceAlertService.Dispose` 改 IAsyncDisposable;#10 `IsClosePositionAsync` 改三态,查询失败中止下单;#11 AISignal 伴随策略去重 + `StopLossPrice` 写入(L287 去掉提前 return);#12 `MarketMonitor.Dispose` 先 await 在途任务再释放信号量;#13 `AnalysisReportCache` 显式传 MarketType。 -- **Agent/RAG 14-26(去掉 #17)**:#14 CoinGecko 改 resilience RateLimiter 删手写限流;#15 哈希改 `SHA256.ComputeHash(File.OpenRead)`;#16 补传 cancellationToken;#18 同义词替换加词边界断言(拉丁键 `\b`);#19 `RetrievalOrchestrator.cs:93-104` 补条数校验;#20 `ClipImageEmbeddingService` 构造去掉服务定位器 + `_initAttempted` 加锁;#21 `DocxMarkdownConverter` 无状态化;#22 AITool 补 CT + WebSearch 独立兜底;#23 雪球请求传 CT;#24 GenerateCriteria 对齐 OCE 处理(阶段 2 已覆盖,确认即可);#25 runId 捕获进闭包,工具只暴露 analystName;#26 Executor 改每次 Run 新建。 -- **UI/Core 27-39(去掉 #30)**:#27 `TradingPageViewModel.Dispose` 补退订 StrategyConfig;#28 `MCPConfigPageViewModel` ContinueWith 改 UI 线程调度;#29 `ChatMessageAdapter` 补 `OnPropertyChanged(nameof(AdaptiveCard))`;#31 TradeMonitorViewModel 补 UI 线程切换;#32/#33 FavoritesPageViewModel 定时器与 Dispatcher 修复;#34 `StockSymbolConverter` 收敛单一 `ResolveExchange()` + 北交所 8/43 段;#35 `CryptoSymbolConverter` 白名单基础币(WBTC/WETH/STETH/WBNB);#36 `NullabilityInfoContext` 改每次新建;#37 KLineChartView 加 SemaphoreSlim + 单次订阅;#38 testConfig 拷贝对齐 SaveUIToConfig;#39 `StrategyConfigViewModel` 走 `IDialogService`。 -- **补测试**:`510300`、`WBTC`、`STETH`、`600519`、`113016`、`830799` 符号转换用例。 - -## 阶段 5 — 死代码删除与重复造轮子收敛(P2) -- **删除(按决策)**:P2-1(随阶段 1)、#2 RoutingExchangeClient 纯转发(需先完成阶段 1-5 快照方案改造,确认无引用后删)、#3/#4/#5/#6/#7/#9/#10/#11 死代码整块;#8 随阶段 2 接上后不再是死代码。 -- **Directory.Build.props**(#12):重写为集中管理 `TargetFramework`/`Nullable`/`ImplicitUsings`,删除 WinUI 段。 -- **收敛**:#13 抽 `MarketAssistant.Infrastructure.ITokenCounter` 单例(两处 Tiktoken 合一);#14 `SourceType` 统一从 documentUri 推断;#15 `AnalysisReportCache` 换 `IMemoryCache`;#16 Reranker 去重分词 + 常量经 DI。 -- **其余 #17-30**:Console.WriteLine 清理、HttpClient.Timeout 覆盖问题、签名硬编码移配置、SqliteRagDocumentCatalog 开 WAL + 释放锁、PasswordChar 补齐、`ModelDiscoveryService` 每次 CreateClient、AboutPage 路径净化与空 catch、StackOverflow 分支删除 + depth 上限、PriceChangeColorConverter 补 InvariantCulture、KLineChartView CDN 兜底改本地资源、CTS 传递、`[NotifyPropertyChangedFor]` 改造。 - -## 验证(每阶段必做) -- 每阶段:`dotnet build MarketAssistant.slnx -c Debug` 通过;`dotnet format` 后 `--verify-no-changes`。 -- 阶段 1/2 结束:`dotnet test tests/TestMarketAssistant.csproj -c Debug`(现有 395 个 MSTest 用例 + 本计划新增用例全绿)。 -- 阶段 3 结束:手动冒烟——切市场导航刷新、交易确认弹窗超时关闭、聊天 Markdown 渲染、详情页骨架屏无跨线程异常。 -- 每阶段一个 commit,格式 `[模块] 变更概要`;阶段 1 完成前不启用实盘模式。 -- 执行顺序:阶段 0 → 1 → 2 → 3 → 4 → 5,各阶段独立可交付,中途可随时叫停。 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 591f7a7..c39a30b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,8 @@ MarketAssistant.slnx ├── src/MarketAssistant.Agents/ ← Agent 契约层(MAF) → 有独立 AGENTS.md ├── src/MarketAssistant.Rag/ ← RAG 基础能力层 → 有独立 AGENTS.md ├── src/MarketAssistant.DataProviders/ ← 数据提供者(Binance 等) → 有独立 AGENTS.md +├── src/MarketAssistant.Trading/ ← 交易抽象与共享交易模型 → 有独立 AGENTS.md +├── src/MarketAssistant.Infrastructure/ ← 基础设施层(模型发现、Token 化) ├── tests/ ← 单元测试工程 ├── scripts/ ← 构建脚本 └── docs/ ← 设计文档 @@ -24,15 +26,16 @@ MarketAssistant.slnx ``` Core(无依赖) ↑ - ├── Rag → Core + ├── Rag → Core, Infrastructure ├── DataProviders → Core - ├── Agents → Core + ├── Agents → Core, Trading, Infrastructure ├── Infrastructure → Core - ├── App.Services → Core, Agents, DataProviders, Rag, Infrastructure - └── App → Core, Agents, DataProviders, App.Services, Rag + ├── Trading → Core + ├── App.Services → Core, Agents, Trading, DataProviders, Rag, Infrastructure + └── App → Core, Agents, Trading, DataProviders, App.Services, Rag ``` -核心技术栈:.NET 10 / C# 13 · Avalonia 11.x · Microsoft Agent Framework (MAF) · Semantic Kernel SQLiteVec · Serilog +核心技术栈:.NET 10 / C# 13 · Avalonia 12.x · Microsoft Agent Framework (MAF) · Semantic Kernel SQLiteVec · Serilog --- @@ -113,7 +116,7 @@ dotnet run --project src/MarketAssistant.App/MarketAssistant.App.csproj -c Debug ## 发布 -详见根目录 `BUILD.md`。快速命令: +详见 [`scripts/BUILD.md`](scripts/BUILD.md)。快速命令: ```powershell # Windows 一键发布 diff --git a/Directory.Build.props b/Directory.Build.props index dcde77e..7d00da1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,8 +8,9 @@ - $(DefaultItemExcludes);**/obj-isolated/**;**/bin-isolated/** + $(DefaultItemExcludes);**/obj-isolated/**;**/bin-isolated/**;**/obj-workbuddy/**;**/bin-workbuddy/** diff --git a/Directory.Packages.props b/Directory.Packages.props index 2e21b4c..cf4fbbf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,8 +22,7 @@ - + diff --git a/README.md b/README.md index 5840dcf..a133de7 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,11 @@ - `MarketAssistant.Agents`:Agent 契约层,定义分析师基类、工具抽象、分析模型和提示词配置加载。 - `MarketAssistant.DataProviders`:外部行情与资讯数据接入层。 - `MarketAssistant.Trading`:交易抽象与共享交易模型。 +- `MarketAssistant.Rag`:RAG 基础能力层(文档解析、向量化、检索、重排)。 +- `MarketAssistant.Infrastructure`:基础设施层(模型发现、Token 化、结构化输出校验)。 - `MarketAssistant.Core`:基础模型、异常、转换器和市场枚举等通用能力。 -当前依赖关系为:`Core <- Trading/DataProviders <- Agents/App.Services <- App`,其中 `Agents` 额外依赖 `Trading` 中的共享交易抽象,`App` 作为 UI 宿主还直接引用部分共享模型与服务。 +当前依赖关系为:`Core` 无依赖;`Rag` 依赖 `Core`/`Infrastructure`;`DataProviders` 依赖 `Core`;`Agents` 依赖 `Core`/`Trading`/`Infrastructure`;`Infrastructure` 依赖 `Core`;`Trading` 依赖 `Core`;`App.Services` 依赖 `Core`/`Agents`/`Trading`/`DataProviders`/`Rag`/`Infrastructure`;`App` 依赖 `Core`/`Agents`/`Trading`/`DataProviders`/`App.Services`/`Rag`。 ## 📊 主要功能 @@ -26,13 +28,14 @@ ### AI 多分析师协作分析 -通过 Fan-Out/Fan-In 工作流,5 位专业 AI 分析师并行分析后由协调分析师综合判断: +通过 Fan-Out/Fan-In 工作流,6 位专业 AI 分析师并行分析后由协调分析师综合判断: - **基本面分析师**:公司/项目基本情况、行业地位、长期价值 - **技术分析师**:K 线图形态、MACD/KDJ/BOLL 等技术指标、交易策略 - **财务分析师**:财务报表、偿债能力、盈利质量、现金流 - **市场情绪分析师**:市场情绪、资金流向、投资者行为 - **新闻事件分析师**:新闻事件、公告解读、突发事件影响 +- **指标分析师(虚拟币)**:Crypto 专属的市场深度、波动率、衍生指标 - **协调分析师**:整合上述分析、解决分歧、生成最终投资建议 ### AI 选股功能 @@ -43,8 +46,8 @@ ### 自主交易(虚拟币,实验功能) -> ⚠️ **实验功能,默认关闭。** 仅虚拟币市场可用,需在「设置 → 实验功能」中显式开启后导航栏才会出现「交易」入口。 -> 开启即表示你理解:可能产生真实资金损失、需要配置交易所 API 密钥、下单操作可能无法撤销。请优先使用测试网/Demo 模式验证。 +> ⚠️ **实验功能。** 仅虚拟币市场可用,由市场能力自动控制:切换到虚拟币市场后导航栏出现「交易」入口(A 股市场不支持交易)。 +> 使用即表示你理解:可能产生真实资金损失、需要配置交易所 API 密钥、下单操作可能无法撤销。请优先使用测试网/Demo 模式验证。 - 策略配置:止损/止盈/追踪止损/AI 信号策略 - 实时监控:Binance WebSocket 实时价格监控 @@ -150,7 +153,7 @@ MarketAssistant支持Model Context Protocol (MCP)服务器配置,可以集成 ## 🛠️ 技术栈 -- **UI 框架**:Avalonia UI 11.3 +- **UI 框架**:Avalonia UI 12.x - **运行时**:.NET 10.0 - **AI 框架**:MAF (Microsoft Agent Framework) - **向量存储**:Semantic Kernel SQLiteVec diff --git a/docs/plan/alert-center-refactor-plan.md b/docs/plan/alert-center-refactor-plan.md new file mode 100644 index 0000000..495635d --- /dev/null +++ b/docs/plan/alert-center-refactor-plan.md @@ -0,0 +1,80 @@ +# 统一告警中心(AlertCenter)重构方案 + +> 状态:待审查。审查通过后再择机实施,本文档仅作设计留存,未做任何代码改动。 + +## 背景与现状 + +项目已有一套完整的价格预警闭环: + +- `PriceAlertRule`(`src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertRule.cs`):条件判定、`IsOneTime` 一次性触发。 +- `PriceAlertService`(`src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertService.cs`):SQLite 持久化规则;A 股 `PeriodicTimer` 20s 轮询、crypto 走 `BinanceWebSocketService` 推送评估。 +- 通知:`INotificationService` + `NotificationWindow` 右下角弹窗;`App.axaml.cs` 启动接线。 +- 交易链路:`MarketMonitor`(价格流)→ `AISignalStrategyExecutor`(AI 信号)→ `TradeExecutor`(风控 → 确认 → 下单 → 记录),`RiskManager.ValidateOrderAsync` 返回 `RiskCheckResult`(Pass / Reject / RequireConfirmation),`TradeExecutor.ConfirmationCallback` 为现成的人工确认钩子。 + +现有痛点:告警出口分散、只有价格一类、无去抖/冷却/限次机制、告警与交易决策无联动。 + +## 设计决策(已确认) + +- **范围**:统一告警中心,收敛价格 / 风险 / 信号三类告警,现有价格告警迁入。 +- **交易联动强度**:默认"确认级"——告警触发中时 AI 交易信号升级为需人工确认,不自动下单。 + +## 一、告警分类 + +| 类型 | 来源 | 默认级别 | +|------|------|---------| +| 价格告警 | PriceAlertRule(已有) | Warning | +| 风险告警 | 回撤接近熔断、信号被风控拒绝、WS 断线/数据源异常 | Warning / Critical | +| 信号告警 | AI 信号产出、MarketMonitor 策略状态变化 | Info | + +统一产出 `AlertEvent`,UI 提供历史列表 + 未读角标,触达走 `INotificationService`。 + +## 二、核心模型(新增,`src/MarketAssistant.App.Services/Applications/AlertCenter/`) + +- `AlertEvent`:Id、MarketType、Symbol、Level(Info/Warning/Critical)、Source(PriceAlert/Risk/Signal/System)、Title、Content、CreatedAt、IsRead。 +- `PriceAlertRule` 扩展: + - `MaxTriggerCount`(一次性 / 限次 / 不限次,替代 `IsOneTime` 语义,保留兼容); + - `ConfirmTicks` / `ConfirmSeconds`(去抖确认期:条件需持续成立才触发); + - `CooldownMinutes`(冷却期:触发后 M 分钟内不重复); + - `TradingImpact`(枚举:None / RequireConfirmation,本期仅这两种)。 +- `AlertDedupeState`:内存态去抖/冷却状态,不落库,重启重置可接受。 + +## 三、AlertCenterService + +- 接口 `IAlertCenterService`:`RaiseAlertAsync(AlertEvent)`、`AlertRaised` 事件、历史/未读查询、标记已读;SQLite 持久化 `AlertEvent`(复用 `SqliteServiceBase`)。 +- 抑制逻辑: + - 冷却期内同类告警合并; + - 全局每小时配额(默认 20 条),超限只落库不弹窗;Critical 不受限,但同类 5 分钟内合并为一条"发生 N 次"; + - 静默窗口:A 股非交易时段不评估价格类告警(复用现有交易时段判断),Critical 例外。 +- 触达:`AlertRaised` → `INotificationService` 弹窗;新增 `AlertCenterPageView` + ViewModel 呈现历史。 +- 用户偏好加入 `UserSetting`(免打扰时段、每小时配额),`SettingsPageViewModel` 增配置项。 + +## 四、迁移现有价格告警 + +- `PriceAlertService` 触发出口改为调用 `IAlertCenterService.RaiseAlertAsync`,不再直接弹通知;轮询/WS 评估逻辑不动。 +- `UpdateTriggerState` 内实现确认期 / 冷却期 / 限次判定。 + +## 五、风险告警评估器(RiskAlertEvaluator) + +- 挂接在 `MarketMonitor` 价格消费管线中,与 AI 信号评估同级,**不侵入 `RiskManager`**。 +- 触发项: + - 持仓回撤达到熔断阈值的一定比例(如 80%)→ Warning; + - AI 信号被风控 Reject / RequireConfirmation → Warning; + - `BinanceWebSocketService` 断线 / 数据源异常 → Critical,重连恢复后发 Info。 + +## 六、确认级交易联动(IAlertGate) + +- 新增轻量 DI 单例 `IAlertGate`:`IsGated(MarketType, symbol)` —— 该标的是否存在 `TradingImpact=RequireConfirmation` 且触发中的告警。 +- `TradeExecutor` 在风控校验后、确认环节前查询 `IAlertGate`:命中则强制走 `ConfirmationCallback` 人工确认(即使风控结果为 Pass),弹窗文案说明"因告警触发需确认"。 +- 原则:**告警事件 ≠ 交易指令**,不自动下单、不修改 `RiskManager`。 + +## 七、接线 + +- DI:`AddBusinessServices()` 注册 `IAlertCenterService`、`IAlertGate` 及各评估器;`App.axaml.cs` 启动初始化(与现有 PriceAlertService 同模式)。 +- 分层依赖保持在 App.Services 内部,不新增跨层依赖。 + +## 八、验证与实施顺序 + +- `dotnet build MarketAssistant.slnx -c Debug` 通过。 +- 可选:为触发判定(去抖/冷却/限次)与配额/合并逻辑补单元测试。 + +实施顺序:模型与 AlertCenterService → 通知/历史页 → 价格告警迁移 → 风险评估器 → IAlertGate 交易联动 → 设置项 → 构建验证。 diff --git "a/docs/\344\273\243\347\240\201\345\256\241\350\256\241\346\212\245\345\221\212-2026-08-28.md" "b/docs/\344\273\243\347\240\201\345\256\241\350\256\241\346\212\245\345\221\212-2026-08-28.md" deleted file mode 100644 index aa04fa7..0000000 --- "a/docs/\344\273\243\347\240\201\345\256\241\350\256\241\346\212\245\345\221\212-2026-08-28.md" +++ /dev/null @@ -1,229 +0,0 @@ -# MarketAssistant 代码审计报告(GLM 5.2/5.3 生成代码) - -审计日期:2026-08-28 -审计范围:`src/` 下 489 个 `.cs`(约 2.84 万行)+ `tests/` 74 个测试文件,分三个域并行审计。 - -> **核实说明(2026-08-28)**:本报告全部 16 条 P0 已逐条对照源码复核:13 条完全属实,3 条部分准确(细节修正已直接并入正文,原文偏差不再保留),无完全错误条目。P1 抽查发现 2 条虚警(原 #17、#30,已剔除)。另核实发现 4 个原报告遗漏的 sibling 问题,已补充。统计:**P0 16 条 / P1 37 条 / P2 30 条**。 -> -> **已确认的决策**(2026-08-28 用户拍板): -> 1. WebView HTML 渲染 → **默认禁用** -> 2. 交易域死代码 → **直接删除整块** - ---- - -## 一、总体结论 - -**架构层合格,实现层粗心;骨架是对的,血肉是错的。** - -做对的地方: -- HTTP 全部走 `IHttpClientFactory` + `AddStandardResilienceHandler`,无裸 `new HttpClient` -- 全仓 `async void` = 0、`Thread.Sleep` = 0、`GetAwaiter().GetResult()` = 0 -- 多市场用 .NET Keyed Services,DI 有 `ValidateOnBuild`,密钥进 OS 安全存储 -- MSTest 395 个 `[TestMethod]` - -真正的问题集中在三类生成式代码典型指纹: - -1. **注释与实现脱节**——注释写着"已修复""批量生成""仅修改本地",实现却不是。 -2. **同一代码库里两套相反约定**——A 处对、B 处错,且往往在相邻文件。 -3. **先铺抽象再补实现,实现永远没补上**——约 30% 代码是死代码或纯透传空壳。 - ---- - -## 二、P0 清单(16 条) - -### A. 资金安全(交易域,5 条) - -#### P0-1 交易所原生条件单整套失效,合约止损无服务端兜底 ✅ -- 文件:`src/MarketAssistant.App.Services/Trading/Exchanges/BinanceExchangeClient.cs:60-63` ↔ `src/MarketAssistant.App.Services/Applications/Crypto/BinanceAccountService.cs:142,166-175` -- 问题:`OrderType.StopMarket.ToString().ToUpper()` = `"STOPMARKET"`(枚举定义在 `MarketAssistant.Trading/TradingEnums.cs:47-57`),而 `BinanceAccountService.cs:167/172` 比较的是 `"STOP_MARKET"`/`"TAKE_PROFIT_MARKET"`,恒 false → `stopPrice`/`trailingDelta` 不进请求体,订单被币安 `-1116` 拒绝,异常在 `StrategyEngine.cs:553-558` 被捕获后仅 LogError 并 `return null`(回退客户端评估,但错误码细节被吞)。 -- **核实补充 sibling**:`TradeExecutor.cs:247` 通用下单路径硬编码 `stopPrice: null, trailingDelta: null` —— 即使修好类型映射,所有经 TradeExecutor 的条件单也永远缺参数。随删除整套原生条件单一并清理。 -- 修复(按决策):**删除整套原生条件单**——`StrategyEngine.cs:484,491-559,564-582`、`TradingDataService.cs:297-307,1348`、`TradingStrategy.cs:56-60`、`strategies.native_order_id` 列及迁移;在合约止损策略创建路径加"止损由客户端每秒轮询执行、进程退出或网络中断期间不生效"的显式警示。 - -#### P0-2 下单重试循环是死代码——catch 的异常类型永不出现 ✅ -- 文件:`src/MarketAssistant.App.Services/Trading/TradeExecutor.cs:239-262` -- 问题:重试循环 `catch (HttpRequestException)`(L250),但 `BinanceAccountService.cs:198-207` 把所有异常包装成 `FriendlyException : Exception`(`MarketAssistant.Core/Infrastructure/Core/FriendlyException.cs:6`),重试永不触发;一次瞬时抖动即下单失败 → 30 秒冷却。 -- **核实补充**:L320 的分类逻辑(`ex.InnerException is HttpRequestException`)对 FriendlyException 包装网络错误能正确归 Network,但重试仍失效。 -- 修复:`IsTransient(ex)` 递归判定(含 InnerException 链),catch filter 与 L320 分类复用同一函数。 - -#### P0-3 止损/止盈触发成功后无节流,条件持续满足时每秒下一单 ⚠️(修正版) -- 文件:`src/MarketAssistant.App.Services/Trading/StrategyEngine.cs:82-125`、`Trading/MarketMonitor.cs:263-284,503-516` -- **修正**:并非"零节流"——`MarketMonitor.cs:272` 已有失败冷却机制(`IsInFailureCooldown`),成功/拒绝路径清冷却(L415-419/424);"Paused 仅 LogWarning"也不准确——`PauseStrategyAfterRejectionAsync` L447 确实调用了 `UpdateStrategyStatusAsync(Paused)`,缺的是用户可见通知。 -- 仍然成立的核心风险:① 触发**成功**后无节流;② `CheckStrategyCompletionAsync`(L505)在 `MaxExecutions == null` 时直接 return,策略永不自动完结;③ Buy 侧 `TakeProfit`(语义=跌破买入)`requireClose=false`,而 `TradeExecutor.cs:152` 可平量复检只针对 Sell,买入完全不受阻,配合市价单几秒内可打光账户。 -- 修复:① 照搬 AISignal 的 `LastTriggeredAt` 机制给所有策略类型加统一成功节流;② `StopLoss/TakeProfit/TrailingStop` 触发成功后置 `Completed`;③ `MaxExecutions == null` 兜底视为 1;④ Pause 时发用户可见通知。 - -#### P0-4 模拟盘/实盘切换与在途订单竞态 ✅ -- 文件:`Trading/TradingEnvironmentService.cs:17,32,58-80`、`Exchanges/RoutingExchangeClient.cs:78-85`、`TradeExecutor.cs:100,198,243` -- 问题:`_currentMode` 普通字段无 volatile 无锁;`RoutingExchangeClient` 每方法独立 `GetActiveClient()`;一次下单链路(风控→IsFutures→杠杆→下单)多次独立解析,中途切模式订单落错环境;`ApplyModeAsync` 的 `monitor.StopAsync()` 超时(`MarketMonitor.cs:184` WaitAsync(10s))后仅 LogWarning 继续。 -- 修复:TradeExecutor 入口快照 `_exchangeClient`;`_currentMode` 加 volatile;下单前校验 mode 与快照一致;切换超时中止并报错。 - -#### P0-5 凭证 Dictionary 无锁,UI 线程写 / 后台签名线程读 ✅ -- 文件:`src/MarketAssistant.App.Services/Trading/TradingCredentialStore.cs:32,45-69,134-149`(Singleton 注册在 `src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs:376`) -- 问题:普通 `Dictionary`,`GetCredentials`(L47)无锁读、`SetCredentials`(L54)/`ClearCredentials`(L67)无锁写,扩容 rehash 期间并发读可致进程内异常甚至死循环;`Save()` 在锁内但突变点在锁外。`BinanceAuthService.cs:112` 每次签名请求都读。 -- 修复:改 `ConcurrentDictionary`。 - -> **放大器(已核实 ✅)**: -> - `CryptoPortfolioService.cs:19` 账户估值 3 秒缓存被风控使用(`RiskManager.cs:63`、`AISignalStrategyExecutor.cs:~189`),1 秒 tick 下连发订单可突破仓位上限。修复:风控路径走实时查询。 -> - `RiskManager.cs:138-142` 合约持仓查询失败 fail-open(仅 LogWarning 放行),与 L117-119 fail-closed 自相矛盾。修复:改 fail-closed。 - -### B. 会产出错误答案(Agent/RAG 域,4 条) - -#### P0-6 文本清洗规则吞掉/改写金融数字,RAG 知识库入库即被污染 ✅ -- 文件:`src/MarketAssistant.Rag/Services/TextCleaningService.cs:28-31,86-91` -- 实际推演(已核实):`成交额1000000000元` → `成交额 元`(10 位数字被 PhonePattern 整体删除);`营收10000000元` → `营收100元`(RepeatingChars 把 7 连 0 折叠成 2 个,**缩水 10 万倍**);`13800138000` → 删除;`1,000,000` 不变(逗号不匹配);`哈哈哈哈` → `哈哈`(中文叠词同样被折叠,且发生在标题/列表清洗路径)。该 Clean() 在 `DocumentBlockMapper.cs:56/84/114` 应用于所有文本块,**清洗后才嵌入入库**。`IsCleaningSuccessful` 全仓零调用。 -- 修复:① 删 `RepeatingChars`;② `PhonePattern` 改中国手机号语义 `(?()` 激活。 - -#### P0-11 设置页改的是全局单例对象,未保存的编辑被静默落盘,绕过实盘二次确认 ✅ -- 文件:`src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs:496-499,362-393,803-827` ↔ `Services/Settings/UserSettingService.cs:26,145-157`、`Services/Market/MarketContext.cs:84` -- 问题:`CurrentSetting => _currentSetting` 无拷贝,设置页全部编辑直接写单例本体;任何一次切市场(`UpdateSetting` → `SaveSettings()` 整体序列化)把未保存的编辑(含 `CryptoTradingMode=LiveFutures`、半填 API Key)静默写盘,`SaveAsync` 内的实盘二次确认被完全绕过。 -- 修复:`Clone()` 草稿模式;`IUserSettingService.CurrentSetting` 返回只读视图;修正 L369-371/L388 注释。 - -#### P0-12 LLM 输出未消毒拼进 HTML 交给 WebView 执行 → **决策:默认禁用** ✅ -- 文件:`src/MarketAssistant.App/Views/Controls/RichTextBlock.cs:236-273` -- 核实:`IsHtmlContent`(L160)任何 HTML 实体即判 HTML;L268 原始插值、L273 `NavigateToString`,无消毒无 CSP。 -- 修复(按决策):`DetectContentFormat` 不再自动识别 HTML,仅 Markdown/PlainText;显式 `Format=Html` 分支保留但注释标注需白名单消毒 + CSP 后才可启用。 - -#### P0-13 确认对话框超时后不关闭 ⚠️(严重度修正:UX 级) -- 文件:`src/MarketAssistant.App/Services/Trading/TradeConfirmationService.cs:63-95`、`src/MarketAssistant.App/Services/Dialog/DialogService.cs:46-49` -- 核实:核心成立——超时 `return false` 后模态对话框仍留屏,用户点"批准"的结果被丢弃,用户以为批准实际已 Rejected。**修正**:finally 复位计数本身正确,资金风险为中等,定性为 UX/竞态问题。 -- 修复:`IDialogService.ShowConfirmationAsync` 增 `CancellationToken` 重载(内部 `ct.Register` 关闭对话框),超时改 `CancellationTokenSource(60s)` + catch OCE。 - -#### P0-14 两个 ListBox 共用同一个 SelectedItem → 侧栏高亮丢失 + 切市场后页面不刷新 ✅ -- 文件:`src/MarketAssistant.App/Views/Windows/MainWindow.axaml:129,137` ↔ `ViewModels/MainWindowViewModel.cs:95-98,219-236` -- 核实:绑定行号实际为 L129/L137(微偏);同步事件链成立——切市场 → `Clear()` → ListBox 回写 null → L230 判空为假 → `NavigateToRoot` 不执行,页面停在旧市场数据。补充:`OnSelectedNavigationItemChanged` 对 null 直接 return,加重该问题。 -- 修复:拆两个选中属性;`SwitchToMarket` 先缓存 `currentTitle`,切完按 title 查找并导航。 - -#### P0-15 后台线程写绑定属性 ✅ -- 文件:`src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs:274,180-182,204-205,210` -- 核实:`Task.Run` 丢 UI 上下文,`IsBusy`/`HasError`/`ErrorMessage` 在线程池线程写绑定属性,仅 L191-192 对集合做了 `InvokeAsync`。 -- 修复:删 `Task.Run`(`OnNavigatedTo` 在 UI 线程,首个 await 后自动回 UI 线程),简化冗余 `InvokeAsync`。 - -#### P0-16 切市场泄漏 TelegraphNewsViewModel,新闻轮询永不停止 ⚠️(时序修正:比原报告更糟) -- 文件:`src/MarketAssistant.App/ViewModels/Home/TelegraphNewsViewModel.cs:64-89,145-162` -- **修正**:原报告称"Dispose 在 Dispatcher lambda 执行前发生"——实际上由于 P0-14,切市场时 `NavigateToRoot` 根本不执行,**旧 HomePageViewModel 连 Dispose 都不发生**:每次切换其 `OnMarketChanged` 的 lambda 继续堆叠执行(换服务、StartUpdates),泄漏比描述的更糟。lambda 无 `_disposed` 检查属实。 -- 修复:`OnMarketChanged` lambda 首行 `if (_disposed) return;`;`Dispose()` 幂等退订;**必须与 P0-14 联动修复并验证**。 -- **核实补充 sibling**:其余重写 `OnMarketChanged` 的 VM(AgentAnalysisViewModel、AssetSelectionPageViewModel、FavoritesPageViewModel、PriceAlertPageViewModel)需同样检查 disposed 防护与退订完整性。 - ---- - -## 三、P1 清单(37 条) - -> 核实状态:#1、#2、#7、#18、#19、#29 已抽查属实;**原 #17("批量嵌入"实为逐个调用)为虚警**——`RagIngestionService.cs:143` 是批量调用 `GenerateAsync(texts, ...)`;**原 #30(停止生成按钮不可达)为虚警**——`CanSendMessage()` 在 `IsProcessing=true` 时返回 true。其余条目未逐条复核,执行时需先确认再改。 - -**交易域** -1. `TradingDataService.cs:1226-1239,1268-1271` — 金额列已迁 TEXT 但读仍用 `(decimal)reader.GetDouble(...)`,精度改造作废(strategies/trade_records 两张资金表)。改用已有 `ReadDecimal()`(L64,已核实存在)。 -2. `TradingStrategy.cs:31-34` — `OrderType`/`SlippageTolerance` 从未持久化(DDL 无此两列,已核实 `TradingDataService.cs:1021-1040`),重启后永远退回 Market+0.003。加列 + 迁移。 -3. `BinanceAccountService.cs:143,161,169` — 下单参数硬编码 `"F8"`,未按 stepSize/tickSize 取整 → `-1111` 拒绝。缓存 exchangeInfo filters 并本地校验。 -4. `BinanceUserDataStreamService.cs:243-286` — `_ws` 字段数据竞争:`StopAsync` 判空后重连循环可能置 null → NRE 中断清理。改局部快照引用。 -5. `CryptoPortfolioService.cs:19` / `RiskManager.cs:63` — 3 秒缓存用于风控(见 P0 放大器,随阶段 1 修复)。 -6. `RiskManager.cs:112-157` — fail-open/fail-closed 矛盾(见放大器);且整个分支只校验 Sell,买入无余额校验。 -7. `RiskManager.cs:160-170` — 回撤熔断用历史全量峰值(`TradingDataService.cs:911-923` 全表 MAX,已核实)且无重置 → 触发后永久停摆。改 30 天滚动窗口 + 重置入口。 -8. `MarketContext.cs:35,45,74-81` — 写加锁、读不加锁且非 volatile。改 volatile。 -9. `PriceAlertService.cs:467-483` — `Dispose()` 内 `.Wait(5s)` 同步阻塞且 `ex.Handle` 会重抛中断 dispose 链。改 `IAsyncDisposable`。 -10. `TradeExecutor.cs:337-359` — `IsClosePositionAsync` 查询失败默认"开仓" → 平仓可能变反向开仓。改三态并中止下单。 -11. `AISignalStrategyExecutor.cs:251-288` — 每次开仓无条件新建伴随追踪止损策略,无去重无上限;L287 提前 return 导致 `StopLossPrice` 从不写入——选 TrailingStop 的策略**完全没有硬性止损**。 -12. `MarketMonitor.cs:518-536` — `Dispose()` 不等在途任务就释放 `SemaphoreSlim`,持有者 `Release()` 抛 ObjectDisposedException。 -13. `AnalysisReportCache.cs:22-27,57-58` — 缓存键依赖全局可变 `CurrentMarket`,后台 AI 交易会 miss 或读到错误市场报告。显式传 `MarketType`。 - -**Agent / RAG 域** -14. `CoinGeckoApiService.cs:18-20,40-58` — 静态 `SemaphoreSlim` 手写限流,与 resilience 管线重复且拖垮工具循环。改 RateLimiter。 -15. `RagIngestionService.cs:216-224` — `File.ReadAllText` 算二进制哈希:同步阻塞、大文件爆内存、可能碰撞。改 `SHA256.ComputeHash(File.OpenRead(path))`。 -16. `RagIngestionService.cs:246,259,265` — 图片 Caption/向量调用未传 cancellationToken。 -18. `QueryRewriteService.cs:126-135` — 子串 IgnoreCase 同义词替换,SynonymMap 短键 `"AI"`/`"GDP"`(已核实 L33/L43)会把 `"chain"` 改成 `"ch人工智能n"`。拉丁键加词边界。 -19. `RetrievalOrchestrator.cs:93-104` — `queryEmbeddings[qi]` 无条数校验,越界被 catch 吞掉 → 子查询被静默跳过、召回减少(已核实,后果比原报告轻但不一致)。补校验。 -20. `ClipImageEmbeddingService.cs:50,60-69,290-320` — 构造期 `GetService()` 服务定位器风险;`_initAttempted` 无锁可泄漏 InferenceSession。 -21. `DocxMarkdownConverter.cs:25-35,45-57` — Singleton 持有 4 个可变字典字段。无状态化。 -22. `GroundingSearchTools.cs:40-42,103-112,123` — AITool 无 CancellationToken;WebSearch 失败会连已成功的知识库结果一起丢。 -23. `ScreenInvestmentTargetsExecutor.cs:32,50,56` — 雪球 HTTP(15s 超时)未传 CT。 -25. `MarketAnalysisWorkflow.cs:171-177` — `Guid.ParseExact(runId, "N")` 解析 LLM 参数,模型返回带连字符 UUID 即抛 FormatException 中断整次分析。runId 捕获进闭包。 -26. `InvestmentSelectionWorkflow.cs:17-36,88-104` — 4 个 Executor 全 Singleton 被并发共用,与 MarketAnalysisWorkflow"每次 Run 新建"约定相反;`_disposed` 从未被读取。 - -**UI / Core / Infrastructure 域** -27. `TradingPageViewModel.cs:83-88` — Dispose 漏退订 `StrategyConfig`(订阅单例 `MarketMonitor.StatusChanged`)→ 每次进交易页泄漏 VM。 -28. `MCPConfigPageViewModel.cs:372-378,444-448` — `ContinueWith` 默认线程池调度,3 秒后在非 UI 线程写 `TestStatus`。 -29. `ChatMessageAdapter.cs:84,112-126` — 设置 `AdaptiveCard` 只通知 `IsAdaptiveCard` 未通知自身 → 流式回复解析出卡片后整条气泡空白(已核实)。 -31. `TradeMonitorViewModel.cs:178-181` — 同一单例事件未切 UI 线程,而 `StrategyConfigViewModel.cs:189-193` 切了。 -32. `FavoritesPageViewModel.cs:271-288,316-327` — 惰性 DispatcherTimer 用 Post 创建,晚于 Dispose 执行则永久运行;Dispose 只 Stop 不解绑 Tick。 -33. `FavoritesPageViewModel.cs:169-179` — 注释"在UI线程上批量添加"但无 Dispatcher。 -34. `StockSymbolConverter.cs:101-111` vs `119-140` — 同一代码两函数给出不同交易所:沪市 ETF `510300` 前缀函数判 SZ、后缀函数判 SH。收敛单一 `ResolveExchange()` + 北交所 8/43 段。 -35. `CryptoSymbolConverter.cs:29-40,57-65` — `WBTC`/`WETH`/`STETH`/`WBNB` 判"已含计价货币",`ExtractBaseCurrency("WBTC")` 得 `"W"` → `-1121`。白名单短路。 -36. `StructuredOutputValidator.cs:12,92-99` — `NullabilityInfoContext`(非线程安全)做成 static readonly 供并行链路调用。每次新建。 -37. `KLineChartView.cs:76-88,204,390-403` — `NavigationCompleted +=` 每次初始化执行且从不 `-=`;Data 变更可并发进入 `InitializeChartAsync` 互相打断。加 SemaphoreSlim(1,1) + 单次订阅。 -38. `MCPConfigPageViewModel.cs:322-344` — 构造 testConfig 手工拷贝漏 `AllowAllTools`,"发现 N 个工具"数字不符。对齐 `SaveUIToConfig`。 -39. `StrategyConfigViewModel.cs:479-490` — VM 直接 `new StrategyExecutionWindow()` 绕过 `IDialogService`,不可单元测试。 - ---- - -## 四、P2 清单(30 条) - -**死代码与空壳(按"直接删除整块"决策,主要清理对象)** -1. 原生条件单整套(见 P0-1,随阶段 1 删除) -2. `Exchanges/RoutingExchangeClient.cs:30-76` — 12 个方法全单行转发,且是 P0-4 直接成因(阶段 1 快照方案改造后确认无引用再删) -3. `TradingStrategyService.cs:19-35` — 9 个方法 5 个纯转发;与 TradingDataService 双路径访问同一份数据 -4. `IExchangeClient.cs:77,82` + 5 层实现 — `SetMarginTypeAsync`/`GetUserTradesAsync` 零调用方 -5. `BinanceUserDataStreamService.cs:38,339,354` — `AccountUpdate` 事件零订阅者;L354 把增量塞进语义为"可用余额"的 `Free` 字段 -6. `TradingDataService.cs:642-663,668-689` — `GetAverageEntryPriceAsync`/`GetAverageSellPriceAsync` 零调用 -7. `MarketAnalysisWorkflow.cs:643-644` — `IsDispatcherExecutor` 零调用 -8. `ITextCleaningService.IsCleaningSuccessful` — 随 P0-6 修复接上后不再是死代码 -9. `MarketSnapshotContextProvider.cs:23-29` — `GetAllData()`/`Clear()` 零调用 -10. `DocumentBlockReaderFactory.cs:24` + `MarkdownDocumentBlockReader.cs:32-35` — `CanRead` 对 .pdf/.docx 也返回 true 且注册最前 → `DocxBlockReader`/`PdfBlockReader` 两行都不执行 -11. `TradeConfirmationService.cs:40-44` — `HasPendingConfirmation` 重构后忘删 -12. `Directory.Build.props:3-10,22-25` — 全部针对 WinUI/Windows TFM 而无 WinUI 工程;应集中管理 TargetFramework/Nullable/ImplicitUsings - -**重复造轮子** -13. `TokenEstimator.cs` 与 `TextChunkingService.cs:298-337` — 同一套 Tiktoken 启发式写两遍,启动加载两份词表。抽 `MarketAssistant.Infrastructure.ITokenCounter` 单例。 -14. `TextChunkingService.cs:60-64` 与 `DocumentBlockMapper.cs:185-194` — `SourceType` 两个生产者两套取值域(`.md` 一处标 "text" 一处标 "markdown")。统一从 documentUri 推断。 -15. `AnalysisReportCache.cs` — 手搓 97 行 LRU,项目已有 `IMemoryCache`;`Set` 两步操作非原子。换 IMemoryCache。 -16. `RerankerService.cs:101-109,240-272` — 每条候选 2/3-gram 分词执行两遍;常量硬编码无法经 DI 调整。 - -**其他** -17. `PdfMarkdownConverter.cs:383-399,415,481-485,494,519` — 生产库 `Console.WriteLine` 调试输出;L389 循环内 `List.IndexOf` O(n²) -18. `PdfMarkdownConverter.cs:112-122` — `allFontSizes.Average()` 写在逐行循环体内(每轮 O(n)),L681/709 重复计算 -19. `EastMoneyNewsClient.cs:68` / `ClsQuoteClient.cs:64` — 拿到 factory HttpClient 后再改 `Timeout`,覆盖集中配置 -20. `ClsQuoteClient.cs:50` — 第三方签名 `sign=b02d8f7b...`、`sv=8.7.9` 硬编码 const。移配置,401/403 时提示"签名可能已过期" -21. `SqliteRagDocumentCatalog.cs:14,80-85,87-122` — `_initLock` 未 Dispose;每操作同步 Open();未开 WAL -22. `SettingsPageView.axaml:280-281,334-335` — `WebSearchApiKey`/`ZhiTuApiToken` 缺 `PasswordChar="●"`(同文件另 3 处 + ApiKeyConfigView 都有) -23. `ModelDiscoveryService.cs:26-31` — Singleton 缓存 HttpClient 字段,抵消 handler 轮换。改每次 `CreateClient` -24. `AboutPageViewModel.cs:140-181` — 远端 `asset.Name` 未净化就 `Path.Combine`;下载产物无校验和却提示直接运行 -25. `AboutPageViewModel.cs:218-228,284-294` — 空 catch 吞异常,已注入 Logger/NotificationService 未用 -26. `GlobalExceptionHandler.cs:126` / `ErrorMessageMapper.cs:60` — `StackOverflowException` 分支永不可达,误导维护者。删除并改在 `StructuredOutputValidator.ValidateNode` 加 depth 上限 -27. `PriceChangeColorConverter.cs:25-31` — `decimal.TryParse` 单参重载用 CurrentCulture,de-DE 区域下所有涨跌标签变灰。补 InvariantCulture -28. `KLineChartView.cs:255-264` — 兜底图表从 cdn.jsdelivr.net 拉 ECharts,无 SRI 无 CSP,离线永久白屏。改本地资源 -29. `AssetPageViewModel.cs:176-189` — CTS 未传给 `GetKLineDataAsync`(仅事后检查),旧 CTS 不 Dispose。对比 `PriceAlertPageViewModel.cs:212-215` 是对的 -30. `AssetSelectionPageViewModel.cs:243-254` — 重复两次 `OnPropertyChanged(CurrentButtonText)`;背后是手工枚举依赖属性模式。改 `[NotifyPropertyChangedFor]` - ---- - -## 五、修复计划 - -详见批准的执行计划(阶段 0 勘误 → 阶段 1 资金安全 → 阶段 2 RAG → 阶段 3 UI → 阶段 4 P1 → 阶段 5 P2)。验证约定按根 AGENTS.md:`.cs` 改动必须 `dotnet build MarketAssistant.slnx -c Debug`;阶段 1/2 结束跑 `dotnet test tests/TestMarketAssistant.csproj -c Debug`;每阶段 `dotnet format --verify-no-changes`;**阶段 1 完成前不启用任何实盘模式**。 - ---- - -## 六、与 GLM 的能力对比(原审计者自评,保留供参考) - -- 跨文件一致性推理与注释-实现落差检测是本次审计的核心价值(两套相反约定、"声称已修好"类问题)。 -- 局限:静态审计,未运行应用;"会导致崩溃"的判断基于代码路径推理,Avalonia 跨线程写属性的实际行为需阶段 3 冒烟确认。 -- 本次核实独立复核确认了大部分 P0,同时修正了 3 条细节(P0-3 节流现状、P0-13 严重度、P0-16 时序)并发现 4 个新 sibling 问题——说明单一审计者结论仍需交叉验证。 diff --git a/scripts/BUILD.md b/scripts/BUILD.md index d6ef246..1554367 100644 --- a/scripts/BUILD.md +++ b/scripts/BUILD.md @@ -419,7 +419,7 @@ dpkg-sig --sign builder MarketAssistant_1.0.0_amd64.deb ### 通用配置 - **目标框架**: `net10.0` -- **UI 框架**: Avalonia UI 11.3.8 +- **UI 框架**: Avalonia UI 12.1.1 - **发布模式**: 自包含 (self-contained) - **ReadyToRun**: 启用(提高启动性能) - **UseAppHost**: 启用(创建平台原生可执行文件) diff --git a/scripts/README.md b/scripts/README.md index 45e7313..e6348ed 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -8,7 +8,6 @@ scripts/ ├── build-release.ps1 # 主构建脚本(跨平台) ├── BUILD.md # 详细构建文档 -├── PACKAGING.md # 打包技术细节 ├── README.md # 本文件 ├── macos/ # macOS 打包资源 │ ├── build-app-bundle.sh # macOS .app bundle 构建脚本 @@ -61,12 +60,6 @@ pwsh scripts/build-release.ps1 -Platform macOS - GitHub Actions 自动化 - 故障排除 -- **[PACKAGING.md](PACKAGING.md)** - 打包技术细节 - - macOS .app bundle 结构 - - Linux .deb/.rpm 包规范 - - Info.plist 配置 - - Desktop Entry 规范 - - **[macos/CREATE_ICON.md](macos/CREATE_ICON.md)** - macOS 图标创建 - 从 PNG 创建 ICNS - 使用 sips 和 iconutil @@ -234,7 +227,6 @@ dotnet clean MarketAssistant.slnx ## 📚 相关文档 - [BUILD.md](BUILD.md) - 详细构建指南 -- [PACKAGING.md](PACKAGING.md) - 打包技术细节 - [../AGENTS.md](../AGENTS.md) - 开发者指南 - [../README.md](../README.md) - 项目主文档 diff --git a/src/MarketAssistant.Agents/AGENTS.md b/src/MarketAssistant.Agents/AGENTS.md index ae56dcd..dcd40a3 100644 --- a/src/MarketAssistant.Agents/AGENTS.md +++ b/src/MarketAssistant.Agents/AGENTS.md @@ -31,22 +31,16 @@ MarketAssistant.Agents/ ├── TokenManagement/ │ └── TokenEstimator.cs ← Token 估算(中文 ~1.5 字/token) └── Tools/ - ├── Abstractions/ ← 工具接口定义(市场无关) - │ ├── IBasicDataTools.cs ← 基础数据(基类) - │ ├── IShareBasicTools.cs ← A 股基础数据 - │ ├── ICryptoBasicTools.cs ← 加密货币基础数据 - │ ├── IFinancialTools.cs ← 财务数据(基类) - │ ├── IShareFinancialTools.cs ← A 股财务 - │ ├── ICryptoMetricsTools.cs ← 加密货币指标 - │ ├── ISentimentTools.cs ← 情绪数据(基类) - │ ├── IShareSentimentTools.cs ← A 股情绪 - │ ├── ICryptoSentimentTools.cs ← 加密货币情绪 + ├── Abstractions/ ← 工具接口定义(纯标记,DI 分发 key) + │ ├── IBasicDataTools.cs ← 基础数据 + │ ├── IFinancialTools.cs ← 财务/市场数据 + │ ├── ISentimentTools.cs ← 情绪数据 │ ├── ITechnicalDataTools.cs ← 技术分析数据 │ ├── INewsDataTools.cs ← 新闻数据 │ ├── IStrategyTools.cs ← 策略管理 │ └── ITradingExecutionTools.cs ← 交易执行 └── Models/ ← 工具返回值模型 - ├── AssetQuoteInfo.cs, MarketInterval.cs, NewsItem.cs + ├── MarketInterval.cs, NewsItem.cs ├── AShare/ ← A 股数据模型 ├── Crypto/ ← 加密货币数据模型(含 Binance/CoinGecko/CoinDesk 响应) └── Technical/ ← 技术指标模型(KDJ/MACD/BOLL/MA) @@ -65,10 +59,10 @@ MarketAssistant.Agents/ ### 工具接口扩展 -1. 接口定义在 `Tools/Abstractions/`,市场特定接口继承基类接口(如 `IShareBasicTools : IBasicDataTools`)。 -2. 每个工具接口需暴露 `GetFunctions()` 方法,返回 MAF 可调用的函数列表。 +1. 工具接口(`Tools/Abstractions/`)为**纯标记接口**:只做 `[RequiresTools]` 声明与 Keyed DI 分发的类型 key,**不定义业务方法**。 +2. 业务方法定义在具体实现类(`MarketAssistant.App.Services/Agents/Tools/`),经 `[Description]` 特性 + `GetFunctions()` 反射暴露给模型;市场差异化靠"不同实现类方法集 + keyed 注册的 marketType"表达。 3. 返回值模型放在 `Tools/Models/` 对应市场子目录。 -4. 具体实现位于 `MarketAssistant.App.Services/Agents/Tools/`,注册为 Keyed Service。 +4. 具体实现注册为 Keyed Service(如 `AddKeyedSingleton(AShare)`)。 ### 分析模型扩展 diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/IBasicDataTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/IBasicDataTools.cs index bffa954..7968f5e 100644 --- a/src/MarketAssistant.Agents/Tools/Abstractions/IBasicDataTools.cs +++ b/src/MarketAssistant.Agents/Tools/Abstractions/IBasicDataTools.cs @@ -2,13 +2,8 @@ namespace MarketAssistant.Agents.Tools.Abstractions; /// /// 基础数据工具的 DI 分发标记接口,用于 [RequiresTools] 声明和 Keyed DI 注册。 -/// 本身不定义业务方法,具体 API 由市场专用子接口提供。 +/// 本身不定义业务方法,具体 API 由市场实现类提供(A股:AShareBasicTools;虚拟币:CryptoBasicTools)。 /// -/// -/// 市场专用实现: -/// - A 股:(股票行情、公司信息) -/// - 虚拟币:(币行情、项目信息) -/// public interface IBasicDataTools : IToolsProvider { } diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/ICryptoBasicTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/ICryptoBasicTools.cs deleted file mode 100644 index 36c6425..0000000 --- a/src/MarketAssistant.Agents/Tools/Abstractions/ICryptoBasicTools.cs +++ /dev/null @@ -1,16 +0,0 @@ -using MarketAssistant.Agents.Tools.Models.Crypto; - -namespace MarketAssistant.Agents.Tools.Abstractions; - -public interface ICryptoBasicTools : IBasicDataTools -{ - /// - /// 根据虚拟币代码获取基本数据,包括实时行情、价格变动、成交量等信息 - /// - Task GetAssetInfoAsync(string assetSymbol, CancellationToken cancellationToken = default); - - /// - /// 根据虚拟币代码获取区块链项目基本面信息,包括项目简介、社区数据、开发者活跃度等 - /// - Task GetProjectInfoAsync(string assetSymbol, CancellationToken cancellationToken = default); -} diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/ICryptoMetricsTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/ICryptoMetricsTools.cs deleted file mode 100644 index e533fa0..0000000 --- a/src/MarketAssistant.Agents/Tools/Abstractions/ICryptoMetricsTools.cs +++ /dev/null @@ -1,78 +0,0 @@ -using MarketAssistant.Agents.Tools.Models; -using MarketAssistant.Agents.Tools.Models.Crypto; - -namespace MarketAssistant.Agents.Tools.Abstractions; - -/// -/// 虚拟币市场指标工具接口 -/// -/// -/// 职责:提供市场深度数据、综合市场指标和衍生计算指标 -/// 数据源:币安(市场深度)、CoinGecko(市场指标、交易量分布) -/// -/// 注意:本接口不包含已由其他接口提供的功能: -/// - 24小时行情和项目基本面 → ICryptoBasicTools -/// - 资金费率、持仓量、多空比 → ICryptoSentimentTools -/// - KDJ、MACD等技术指标 → ITechnicalDataTools -/// -public interface ICryptoMetricsTools : IFinancialTools -{ - /// 交易对符号(如BTCUSDT) - /// 时间间隔 - /// 返回数据条数(默认500,最大1000) - /// 起始时间(Unix时间戳毫秒,可选) - /// 结束时间(Unix时间戳毫秒,可选) - /// - /// 数据源:币安API - /api/v3/klines - /// 用于技术分析、回测和趋势判断 - /// - Task GetOHLCVAsync(string symbol, MarketInterval interval = MarketInterval.OneDay, int limit = 500, long? startTime = null, long? endTime = null, CancellationToken cancellationToken = default); - - /// 交易对符号(如BTCUSDT) - /// 返回档位数量(5/10/20/50/100/500/1000/5000) - /// - /// 数据源:币安API - /api/v3/depth - /// 用于分析流动性、支撑压力位、买卖价差 - /// - Task GetOrderBookDepthAsync(string symbol, int limit = 100, CancellationToken cancellationToken = default); - - /// 交易对符号(如BTCUSDT) - /// 返回成交笔数(默认500,最大1000) - /// - /// 数据源:币安API - /api/v3/trades - /// 用于分析买卖力量对比、成交活跃度 - /// - Task GetRecentTradesAsync(string symbol, int limit = 500, CancellationToken cancellationToken = default); - - /// - /// 获取综合市场指标(市值、供应量、排名、流通率等数值指标) - /// - /// 代币符号(如BTC、ETH) - /// - /// 数据源:CoinGecko - /api/v3/coins/markets - /// 提供市值、供应量、排名、历史高低点、流通率等数值型市场指标 - /// 注意:项目描述等基本面信息请使用 ICryptoBasicTools.GetProjectInfoAsync - /// - Task GetMarketMetricsAsync(string symbol, CancellationToken cancellationToken = default); - - /// - /// 获取交易量分布(不同交易所的交易量占比) - /// - /// 代币符号(如BTC、ETH) - /// - /// 数据源:CoinGecko - /api/v3/coins/{id}/tickers - /// 用于分析流动性分布、交易所选择 - /// - Task> GetVolumeDistributionAsync(string symbol, CancellationToken cancellationToken = default); - - /// - /// 获取波动性指标(历史波动率、ATR、最大回撤、夏普比率) - /// - /// 交易对符号(如BTCUSDT) - /// 统计天数(默认30天) - /// - /// 基于历史K线数据计算 - /// 用于风险评估、仓位管理、策略制定 - /// - Task GetVolatilityMetricsAsync(string symbol, int days = 30, CancellationToken cancellationToken = default); -} diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/ICryptoSentimentTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/ICryptoSentimentTools.cs deleted file mode 100644 index 1e9598f..0000000 --- a/src/MarketAssistant.Agents/Tools/Abstractions/ICryptoSentimentTools.cs +++ /dev/null @@ -1,51 +0,0 @@ -using MarketAssistant.Agents.Tools.Models.Crypto; - -namespace MarketAssistant.Agents.Tools.Abstractions; - -/// -/// 虚拟币市场情绪工具接口 -/// -public interface ICryptoSentimentTools : ISentimentTools -{ - /// 交易对符号 - /// - /// 数据源:币安 Futures API - /// - 当前费率和下次结算时间:GET /fapi/v1/premiumIndex - /// - 历史费率:GET /fapi/v1/fundingRate - /// - Task GetFundingRateAsync(string symbol, CancellationToken cancellationToken = default); - - /// 交易对符号 - /// 时间周期(5m/15m/30m/1h/2h/4h/6h/12h/1d) - /// 获取的数据点数量(默认30) - /// - /// 数据源:币安 Futures API - GET /futures/data/globalLongShortAccountRatio - /// 含义:全市场所有账户的多空比,可与顶级交易员数据对比分析 - /// - Task GetGlobalLongShortRatioAsync(string symbol, Period period = Period.FiveMinutes, int limit = 30, CancellationToken cancellationToken = default); - - /// 交易对符号 - /// 时间周期(5m/15m/30m/1h/2h/4h/6h/12h/1d) - /// 获取的数据点数量(默认30) - /// - /// 数据源:币安 Futures API - GET /futures/data/topLongShortAccountRatio - /// 含义:大户账户数的多空比(按账户数量统计) - /// - Task GetTopTraderAccountRatioAsync(string symbol, Period period = Period.FiveMinutes, int limit = 30, CancellationToken cancellationToken = default); - - /// 交易对符号 - /// 时间周期(5m/15m/30m/1h/2h/4h/6h/12h/1d) - /// 获取的数据点数量(默认30) - /// - /// 数据源:币安 Futures API - GET /futures/data/topLongShortPositionRatio - /// 含义:大户持仓量的多空比(按持仓金额统计),更能反映真实资金流向 - /// - Task GetTopTraderPositionRatioAsync(string symbol, Period period = Period.FiveMinutes, int limit = 30, CancellationToken cancellationToken = default); - - /// 交易对符号 - /// 时间周期 - /// - /// 数据源:币安 Futures API - GET /futures/data/openInterestHist - /// - Task GetOpenInterestAsync(string symbol, Period period = Period.OneHour, CancellationToken cancellationToken = default); -} diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/IFinancialTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/IFinancialTools.cs index 89bf9f5..df95d70 100644 --- a/src/MarketAssistant.Agents/Tools/Abstractions/IFinancialTools.cs +++ b/src/MarketAssistant.Agents/Tools/Abstractions/IFinancialTools.cs @@ -2,13 +2,8 @@ namespace MarketAssistant.Agents.Tools.Abstractions; /// /// 财务/市场数据工具的 DI 分发标记接口,用于 [RequiresTools] 声明和 Keyed DI 注册。 -/// 本身不定义业务方法,具体 API 由市场专用子接口提供。 +/// 本身不定义业务方法,具体 API 由市场实现类提供(A股:AShareFinancialTools;虚拟币:CryptoMetricsTools)。 /// -/// -/// 市场专用实现: -/// - A 股:(财务报表、财务指标) -/// - 虚拟币:(市场深度、波动率、OHLCV 指标) -/// public interface IFinancialTools : IToolsProvider { } diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/INewsDataTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/INewsDataTools.cs index 802395b..9372c96 100644 --- a/src/MarketAssistant.Agents/Tools/Abstractions/INewsDataTools.cs +++ b/src/MarketAssistant.Agents/Tools/Abstractions/INewsDataTools.cs @@ -1,17 +1,9 @@ -using MarketAssistant.Agents.Tools.Models; - namespace MarketAssistant.Agents.Tools.Abstractions; +/// +/// 新闻数据工具的 DI 分发标记接口,用于 [RequiresTools] 声明和 Keyed DI 注册。 +/// 本身不定义业务方法,具体 API 由市场实现类提供(AShareNewsTools / CryptoNewsTools)。 +/// public interface INewsDataTools : IToolsProvider { - /// - /// 获取资产相关的新闻(对于A股从财联社等获取,对于虚拟币从Twitter/X获取) - /// - Task> GetNewsAsync(string assetSymbol, int count = 10, CancellationToken cancellationToken = default); } - - - - - - diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/ISentimentTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/ISentimentTools.cs index 1392ec8..d9a6329 100644 --- a/src/MarketAssistant.Agents/Tools/Abstractions/ISentimentTools.cs +++ b/src/MarketAssistant.Agents/Tools/Abstractions/ISentimentTools.cs @@ -2,13 +2,8 @@ namespace MarketAssistant.Agents.Tools.Abstractions; /// /// 市场情绪工具的 DI 分发标记接口,用于 [RequiresTools] 声明和 Keyed DI 注册。 -/// 本身不定义业务方法,具体 API 由市场专用子接口提供。 +/// 本身不定义业务方法,具体 API 由市场实现类提供(A股:AShareSentimentTools;虚拟币:CryptoSentimentTools)。 /// -/// -/// 市场专用实现: -/// - A 股:(资金流向) -/// - 虚拟币:(资金费率、多空比、持仓量等) -/// public interface ISentimentTools : IToolsProvider { } diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/IShareBasicTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/IShareBasicTools.cs deleted file mode 100644 index bccd3f6..0000000 --- a/src/MarketAssistant.Agents/Tools/Abstractions/IShareBasicTools.cs +++ /dev/null @@ -1,16 +0,0 @@ -using MarketAssistant.Agents.Tools.Models.AShare; - -namespace MarketAssistant.Agents.Tools.Abstractions; - -public interface IShareBasicTools : IBasicDataTools -{ - /// - /// 根据股票代码获取基本数据,包括实时行情、价格变动、市值等信息 - /// - Task GetAssetInfoAsync(string assetSymbol, CancellationToken cancellationToken = default); - - /// - /// 根据股票代码获取上市公司基本面信息,包括简介、主营业务、所属行业等 - /// - Task GetCompanyInfoAsync(string assetSymbol, CancellationToken cancellationToken = default); -} diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/IShareFinancialTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/IShareFinancialTools.cs deleted file mode 100644 index a34fd40..0000000 --- a/src/MarketAssistant.Agents/Tools/Abstractions/IShareFinancialTools.cs +++ /dev/null @@ -1,21 +0,0 @@ -using MarketAssistant.Agents.Tools.Models.AShare; - -namespace MarketAssistant.Agents.Tools.Abstractions; - -public interface IShareFinancialTools : IFinancialTools -{ - Task> GetBalanceSheetAsync(string assetSymbol, CancellationToken cancellationToken = default); - - Task> GetIncomeStatementAsync(string assetSymbol, CancellationToken cancellationToken = default); - - Task> GetCashFlowStatementAsync(string assetSymbol, CancellationToken cancellationToken = default); - - Task> GetFinancialRatiosAsync(string assetSymbol, CancellationToken cancellationToken = default); - - Task> GetCapitalStructureAsync(string assetSymbol, CancellationToken cancellationToken = default); -} - - - - - diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/IShareSentimentTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/IShareSentimentTools.cs deleted file mode 100644 index 590bf08..0000000 --- a/src/MarketAssistant.Agents/Tools/Abstractions/IShareSentimentTools.cs +++ /dev/null @@ -1,14 +0,0 @@ -using MarketAssistant.Agents.Tools.Models.AShare; - -namespace MarketAssistant.Agents.Tools.Abstractions; - -public interface IShareSentimentTools : ISentimentTools -{ - Task GetFundFlowAsync(string assetSymbol, CancellationToken cancellationToken = default); -} - - - - - - diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/IStrategyTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/IStrategyTools.cs index 8dc5390..21a984a 100644 --- a/src/MarketAssistant.Agents/Tools/Abstractions/IStrategyTools.cs +++ b/src/MarketAssistant.Agents/Tools/Abstractions/IStrategyTools.cs @@ -1,23 +1,9 @@ -using MarketAssistant.Trading.Models; - namespace MarketAssistant.Agents.Tools.Abstractions; /// -/// 策略管理工具 —— TradingAgent 可查询和更新策略状态 +/// 策略管理工具的 DI 分发标记接口,用于 [RequiresTools] 声明和 Keyed DI 注册。 +/// 本身不定义业务方法,具体 API 由市场实现类提供(CryptoStrategyTools 等)。 /// public interface IStrategyTools : IToolsProvider { - Task> GetActiveStrategiesAsync(CancellationToken cancellationToken = default); - Task GetStrategyAsync(string strategyId, CancellationToken cancellationToken = default); - Task UpdateStrategyStatusAsync(string strategyId, StrategyStatus status, CancellationToken cancellationToken = default); - - /// - /// 为已有持仓创建护栏策略(止损/止盈/追踪止损),保护当前仓位。 - /// - Task CreateGuardrailAsync( - string symbol, - decimal? stopLossPrice, - decimal? takeProfitPrice, - decimal? trailingPercent, - CancellationToken cancellationToken = default); } diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/ITechnicalDataTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/ITechnicalDataTools.cs index c521e0f..c1f1240 100644 --- a/src/MarketAssistant.Agents/Tools/Abstractions/ITechnicalDataTools.cs +++ b/src/MarketAssistant.Agents/Tools/Abstractions/ITechnicalDataTools.cs @@ -1,24 +1,10 @@ -using MarketAssistant.Agents.Tools.Models.Technical; - namespace MarketAssistant.Agents.Tools.Abstractions; +/// +/// 技术分析数据工具的 DI 分发标记接口,用于 [RequiresTools] 声明和 Keyed DI 注册。 +/// 本身不定义业务方法,具体 API 由市场实现类提供(AShareTechnicalTools / CryptoTechnicalTools), +/// 经实现类的 [Description] 方法与 GetFunctions() 暴露给模型。 +/// public interface ITechnicalDataTools : IToolsProvider { - Task GetKDJAsync(string assetSymbol, CancellationToken cancellationToken = default); - - Task GetMACDAsync(string assetSymbol, CancellationToken cancellationToken = default); - - Task GetBOLLAsync(string assetSymbol, CancellationToken cancellationToken = default); - - Task GetMAAsync(string assetSymbol, CancellationToken cancellationToken = default); - - /// - /// 获取 K 线历史序列(OHLCV),用于判断趋势方向及多周期一致性 - /// - Task> GetKLinesAsync(string assetSymbol, string interval = "daily", int count = 30, CancellationToken cancellationToken = default); } - - - - - diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/ITradingExecutionTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/ITradingExecutionTools.cs index 17166c8..b5d6f7b 100644 --- a/src/MarketAssistant.Agents/Tools/Abstractions/ITradingExecutionTools.cs +++ b/src/MarketAssistant.Agents/Tools/Abstractions/ITradingExecutionTools.cs @@ -1,15 +1,9 @@ -using MarketAssistant.Trading.Models; - namespace MarketAssistant.Agents.Tools.Abstractions; /// -/// 交易执行工具 —— 仅 Crypto 市场 +/// 交易执行工具的 DI 分发标记接口(仅 Crypto 市场注册),用于 [RequiresTools] 声明和 Keyed DI 注册。 +/// 本身不定义业务方法,具体 API 由市场实现类提供(CryptoTradingExecutionTools)。 /// public interface ITradingExecutionTools : IToolsProvider { - Task GetAccountBalanceAsync(CancellationToken cancellationToken = default); - Task> GetCurrentPositionsAsync(CancellationToken cancellationToken = default); - Task PlaceOrderAsync(string symbol, OrderSide side, OrderType type, decimal quantity, decimal? price = null, CancellationToken cancellationToken = default); - Task GetOrderStatusAsync(string symbol, long orderId, CancellationToken cancellationToken = default); - Task CancelOrderAsync(string symbol, long orderId, CancellationToken cancellationToken = default); } diff --git a/src/MarketAssistant.Agents/Tools/Models/AssetQuoteInfo.cs b/src/MarketAssistant.Agents/Tools/Models/AssetQuoteInfo.cs deleted file mode 100644 index d641eb9..0000000 --- a/src/MarketAssistant.Agents/Tools/Models/AssetQuoteInfo.cs +++ /dev/null @@ -1,128 +0,0 @@ -namespace MarketAssistant.Agents.Tools.Models; - -public class AssetQuoteInfo -{ - /// - /// 当前价格(元) - /// - public decimal CurrentPrice { get; set; } - - /// - /// 今日最高价(元) - /// - public decimal HighPrice { get; set; } - - /// - /// 今日最低价(元) - /// - public decimal LowPrice { get; set; } - - /// - /// 成交量(万手) - /// - public decimal Volume { get; set; } - - /// - /// 成交额(亿) - /// - public decimal Amount { get; set; } - - /// - /// 涨跌价格(元) - /// - public decimal PriceChange { get; set; } - - /// - /// 涨跌百分比(%) - /// - public decimal PercentageChange { get; set; } - - /// - /// 换手率(%) - /// - public decimal TurnoverRate { get; set; } - - /// - /// 3日涨跌百分比(%) - /// - public decimal PercentageChange3Day { get; set; } - - /// - /// 5日涨跌百分比(%) - /// - public decimal PercentageChange5Day { get; set; } - - public decimal TotalShares { get; set; } - - /// - /// 总市值(亿) - /// - public decimal MarketCapitalization { get; set; } - - public string SecurityName { get; set; } = string.Empty; - - public string SecurityCode { get; set; } = string.Empty; - - public string TradeStatus { get; set; } = string.Empty; - - public string SecurityType { get; set; } = string.Empty; - - /// - /// 开盘价(元) - /// - public decimal OpenPrice { get; set; } - - /// - /// 昨收价(元) - /// - public decimal PreviousClosePrice { get; set; } - - /// - /// 涨停价(元) - /// - public decimal UpLimitPrice { get; set; } - - /// - /// 跌停价(元) - /// - public decimal DownLimitPrice { get; set; } - - /// - /// 振幅(%) - /// - public decimal Amplitude { get; set; } - - public decimal PERatio { get; set; } - - public decimal TTMPERatio { get; set; } - - public decimal PBRatio { get; set; } - - /// - /// 流通市值(亿) - /// - public decimal CirculationMarketCap { get; set; } - - /// - /// 流通股本 - /// - public decimal NonRestrictedShares { get; set; } - - /// - /// 每股净资产(元) - /// - public decimal NetAssetPerShare { get; set; } - - /// - /// 均价(元) - /// - public decimal AveragePrice { get; set; } - - public decimal VolumeRatio { get; set; } - /// - /// 委比(%) - /// - public decimal EntrustRatio { get; set; } -} - - diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/CryptoMetricsAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/CryptoMetricsAnalystAgent.cs index 498728b..0fba1a6 100644 --- a/src/MarketAssistant.App.Services/Agents/Analysts/CryptoMetricsAnalystAgent.cs +++ b/src/MarketAssistant.App.Services/Agents/Analysts/CryptoMetricsAnalystAgent.cs @@ -16,7 +16,7 @@ namespace MarketAssistant.Services.Agents.Analysts; /// [DisplayName("项目指标分析师")] [Description("专注于加密资产的市场指标分析:市值、供应量、流动性分布与波动性")] -[RequiresTools(typeof(ICryptoMetricsTools))] +[RequiresTools(typeof(IFinancialTools))] [SupportedMarkets(MarketType.Crypto)] public class CryptoMetricsAnalystAgent : AnalystAgentBase { diff --git a/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareBasicTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareBasicTools.cs index 86cb506..985e838 100644 --- a/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareBasicTools.cs +++ b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareBasicTools.cs @@ -12,7 +12,7 @@ namespace MarketAssistant.Agents.Tools.AShare; /// /// A股基础数据工具实现 /// -public sealed class AShareBasicTools : IShareBasicTools +public sealed class AShareBasicTools : IBasicDataTools { /// CLS 行情接口请求字段列表。 private const string QuoteFields = diff --git a/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareFinancialTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareFinancialTools.cs index 8066463..2828af0 100644 --- a/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareFinancialTools.cs +++ b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareFinancialTools.cs @@ -12,7 +12,7 @@ namespace MarketAssistant.Agents.Tools.AShare; /// /// A股财务数据工具实现 /// -public sealed class AShareFinancialTools : IShareFinancialTools +public sealed class AShareFinancialTools : IFinancialTools { private readonly ZhiTuMarketClient _zhiTuClient; private readonly IUserSettingService _userSettingService; diff --git a/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareSentimentTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareSentimentTools.cs index 719d91b..90247bf 100644 --- a/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareSentimentTools.cs +++ b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareSentimentTools.cs @@ -13,7 +13,7 @@ namespace MarketAssistant.Agents.Tools.AShare; /// /// A股市场情绪工具实现 /// -public sealed class AShareSentimentTools : IShareSentimentTools +public sealed class AShareSentimentTools : ISentimentTools { private readonly ZhiTuMarketClient _zhiTuClient; private readonly IUserSettingService _userSettingService; diff --git a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoBasicTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoBasicTools.cs index 0e916db..e580a49 100644 --- a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoBasicTools.cs +++ b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoBasicTools.cs @@ -12,7 +12,7 @@ namespace MarketAssistant.Agents.Tools.Crypto; /// /// 虚拟币基础数据工具实现(使用服务层获取数据) /// -public sealed class CryptoBasicTools : ICryptoBasicTools +public sealed class CryptoBasicTools : IBasicDataTools { private readonly ILogger _logger; private readonly BinanceMarketDataService _binanceService; diff --git a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoMetricsTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoMetricsTools.cs index 892430c..04b2081 100644 --- a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoMetricsTools.cs +++ b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoMetricsTools.cs @@ -21,7 +21,7 @@ namespace MarketAssistant.Agents.Tools.Crypto; /// 职责:提供市场深度数据、综合市场指标和衍生计算指标 /// 数据源:币安(市场深度)、CoinGecko(市场指标、交易量分布) /// -public sealed class CryptoMetricsTools : ICryptoMetricsTools +public sealed class CryptoMetricsTools : IFinancialTools { private readonly BinanceMarketDataService _binanceService; private readonly CoinGeckoApiService _coinGeckoService; diff --git a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoSentimentTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoSentimentTools.cs index b1ffa1d..56e20d4 100644 --- a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoSentimentTools.cs +++ b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoSentimentTools.cs @@ -11,7 +11,7 @@ namespace MarketAssistant.Agents.Tools.Crypto; /// 虚拟币市场情绪工具实现 /// https://developers.binance.com/docs/zh-CN/derivatives/usds-margined-futures/general-info /// -public sealed class CryptoSentimentTools : ICryptoSentimentTools +public sealed class CryptoSentimentTools : ISentimentTools { private readonly ILogger _logger; private readonly BinanceMarketDataService _binanceService; diff --git a/src/MarketAssistant.App.Services/Applications/IMarketServiceRegistry.cs b/src/MarketAssistant.App.Services/Applications/IMarketServiceRegistry.cs deleted file mode 100644 index 02a1cca..0000000 --- a/src/MarketAssistant.App.Services/Applications/IMarketServiceRegistry.cs +++ /dev/null @@ -1,30 +0,0 @@ -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; - -namespace MarketAssistant.Applications; - -/// -/// 市场服务注册表:按市场类型解析各市场差异化实现(Keyed Service 的具名门面)。 -/// 消费方(ViewModel 等)通过本接口替代裸 Func 委托或 IServiceProvider 服务定位。 -/// -public interface IMarketServiceRegistry -{ - IKLineService GetKLineService(MarketType marketType); - - IAssetInfoService GetAssetInfoService(MarketType marketType); - - INewsUpdateService GetNewsUpdateService(MarketType marketType); - - IHomeAssetService GetHomeAssetService(MarketType marketType); - - IAssetHistoryService GetAssetHistoryService(MarketType marketType); - - IFavoriteService GetFavoriteService(MarketType marketType); - - IAssetCacheService GetAssetCacheService(MarketType marketType); -} diff --git a/src/MarketAssistant.App.Services/Applications/MarketServiceRegistry.cs b/src/MarketAssistant.App.Services/Applications/MarketServiceRegistry.cs deleted file mode 100644 index 1014d17..0000000 --- a/src/MarketAssistant.App.Services/Applications/MarketServiceRegistry.cs +++ /dev/null @@ -1,44 +0,0 @@ -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 Microsoft.Extensions.DependencyInjection; - -namespace MarketAssistant.Applications; - -/// -/// 基于 Keyed Service 的市场服务注册表实现,解析逻辑与市场模块(AShareMarketModule / CryptoMarketModule)的注册一一对应。 -/// -public sealed class MarketServiceRegistry : IMarketServiceRegistry -{ - private readonly IServiceProvider _serviceProvider; - - public MarketServiceRegistry(IServiceProvider serviceProvider) - { - _serviceProvider = serviceProvider; - } - - public IKLineService GetKLineService(MarketType marketType) - => _serviceProvider.GetRequiredKeyedService(marketType); - - public IAssetInfoService GetAssetInfoService(MarketType marketType) - => _serviceProvider.GetRequiredKeyedService(marketType); - - public INewsUpdateService GetNewsUpdateService(MarketType marketType) - => _serviceProvider.GetRequiredKeyedService(marketType); - - public IHomeAssetService GetHomeAssetService(MarketType marketType) - => _serviceProvider.GetRequiredKeyedService(marketType); - - public IAssetHistoryService GetAssetHistoryService(MarketType marketType) - => _serviceProvider.GetRequiredKeyedService(marketType); - - public IFavoriteService GetFavoriteService(MarketType marketType) - => _serviceProvider.GetRequiredKeyedService(marketType); - - public IAssetCacheService GetAssetCacheService(MarketType marketType) - => _serviceProvider.GetRequiredKeyedService(marketType); -} diff --git a/src/MarketAssistant.App.Services/Infrastructure/Providers/DocumentVectorizationService.cs b/src/MarketAssistant.App.Services/Infrastructure/Providers/DocumentVectorizationService.cs new file mode 100644 index 0000000..33cc2a2 --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Providers/DocumentVectorizationService.cs @@ -0,0 +1,148 @@ +using MarketAssistant.Rag; +using MarketAssistant.Rag.Interfaces; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.VectorData; + +namespace MarketAssistant.Infrastructure.Providers; + +/// +/// 向量化结果汇总。 +/// +public sealed record VectorizationResult( + int TotalCount, + int SuccessCount, + int PartialCount, + int FailedCount, + IReadOnlyList FailedFiles, + IReadOnlyList PartialFiles) +{ + public bool AllSucceeded => FailedCount == 0 && PartialCount == 0; +} + +/// +/// 文档向量化服务:枚举知识库目录、逐文件向量化并汇总结果(纯业务逻辑,不依赖 UI 通知)。 +/// 进度通过 回调上报,结果以 返回,由调用方(ViewModel)负责 UI 反馈。 +/// +public sealed class DocumentVectorizationService +{ + public static readonly string[] SupportedExtensions = [".pdf", ".docx", ".md"]; + + // 向量化在途守卫必须跨服务实例生效(与 ViewModel 生命周期的并发向量化场景一致) + private static int _activeVectorizations; + + private readonly IRagInfrastructureProvider _ragInfrastructureProvider; + private readonly ILogger _logger; + + public DocumentVectorizationService( + IRagInfrastructureProvider ragInfrastructureProvider, + ILogger logger) + { + _ragInfrastructureProvider = ragInfrastructureProvider; + _logger = logger; + } + + /// + /// 尝试进入向量化(跨实例并发守卫),返回 false 表示已有任务在进行中。 + /// + public bool TryBeginVectorization() => + Interlocked.CompareExchange(ref _activeVectorizations, 1, 0) == 0; + + /// + /// 结束向量化,释放并发守卫。 + /// + public void EndVectorization() => + Interlocked.Exchange(ref _activeVectorizations, 0); + + /// + /// 向量化指定目录下所有支持的文档,返回汇总结果;目录中无支持文档时返回 null。 + /// + public async Task VectorizeDirectoryAsync( + string directory, + string collectionName, + IProgress<(int Percent, string Text)>? progress, + CancellationToken ct) + { + var files = Directory.GetFiles(directory, "*.*", SearchOption.AllDirectories) + .Where(f => SupportedExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())) + .ToList(); + + if (files.Count == 0) + return null; + + // 创建嵌入生成器(只在实际需要时创建) + var embeddingGenerator = _ragInfrastructureProvider.GetEmbeddingFactory().Create(); + + var collection = _ragInfrastructureProvider.GetVectorStore().GetCollection(collectionName); + await collection.EnsureCollectionExistsAsync(); + _logger.LogInformation("使用向量集合: {CollectionName}", collectionName); + + var ragIngestionService = _ragInfrastructureProvider.GetIngestionService(); + var totalFiles = files.Count; + _logger.LogInformation("找到 {Count} 个文档需要向量化", totalFiles); + + var successCount = 0; + var partialCount = 0; + var failedCount = 0; + var failedFiles = new List(); + var partialFiles = new List(); + + for (int i = 0; i < totalFiles; i++) + { + ct.ThrowIfCancellationRequested(); + var file = files[i]; + var fileName = Path.GetFileName(file); + var fileExtension = Path.GetExtension(file).ToUpperInvariant(); + + try + { + var currentIndex = i + 1; + progress?.Report(((int)((double)currentIndex / totalFiles * 100), $"正在处理 {currentIndex}/{totalFiles}: {fileName}")); + + _logger.LogInformation("正在处理 ({Index}/{Total}): {FileName} [{Extension}]", + currentIndex, totalFiles, fileName, fileExtension); + + // 执行向量化:根据结构化结果区分完全成功/部分成功/失败 + var result = await ragIngestionService.IngestFileAsync( + collection, collectionName, file, embeddingGenerator, ct); + + if (result.IsSuccess) + { + successCount++; + _logger.LogInformation("✓ 成功向量化: {FileName}", fileName); + } + else if (result.IsPartialSuccess) + { + // 部分成功不计入完全成功 + partialCount++; + partialFiles.Add($"{fileName}({result.Failures.Count} 个块失败)"); + _logger.LogWarning("△ 部分成功向量化: {FileName},{BlockCount} 块中 {Failed} 个失败", + fileName, result.BlockCount, result.Failures.Count); + } + else + { + failedCount++; + failedFiles.Add(fileName); + var reason = result.Failures.FirstOrDefault()?.Message ?? "没有内容入库"; + _logger.LogError("✗ 向量化失败: {FileName} - {Reason}", fileName, reason); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // 取消向上传播,由调用方统一处理 + throw; + } + catch (Exception ex) + { + failedCount++; + failedFiles.Add(fileName); + _logger.LogError(ex, "✗ 向量化失败: {FileName} - {ErrorMessage}", fileName, ex.Message); + // 单个文件失败不中断整体流程,继续处理下一个 + } + } + + _logger.LogInformation("向量化完成:成功 {Success}/{Total} 个,部分成功 {Partial} 个,失败 {Failed} 个", + successCount, totalFiles, partialCount, failedCount); + + return new VectorizationResult(totalFiles, successCount, partialCount, failedCount, failedFiles, partialFiles); + } +} diff --git a/src/MarketAssistant.App.Services/Services/Market/AShareMarketModule.cs b/src/MarketAssistant.App.Services/Services/Market/AShareMarketModule.cs index b8ae88a..27c9c44 100644 --- a/src/MarketAssistant.App.Services/Services/Market/AShareMarketModule.cs +++ b/src/MarketAssistant.App.Services/Services/Market/AShareMarketModule.cs @@ -30,13 +30,10 @@ public void Register(IServiceCollection services) services.AddKeyedSingleton(MarketType.AShare); // Agent 工具 - services.AddKeyedSingleton(MarketType.AShare); services.AddKeyedSingleton(MarketType.AShare); - services.AddKeyedSingleton(MarketType.AShare); services.AddKeyedSingleton(MarketType.AShare); services.AddKeyedSingleton(MarketType.AShare); services.AddKeyedSingleton(MarketType.AShare); - services.AddKeyedSingleton(MarketType.AShare); services.AddKeyedSingleton(MarketType.AShare); // 快讯 & 新闻 @@ -48,6 +45,7 @@ public void Register(IServiceCollection services) sp.GetRequiredService>())); // 资产服务 + services.AddKeyedSingleton(MarketType.AShare); services.AddKeyedSingleton(MarketType.AShare); services.AddKeyedSingleton(MarketType.AShare); services.AddKeyedSingleton(MarketType.AShare); diff --git a/src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs b/src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs index 82a7303..ebea7f5 100644 --- a/src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs +++ b/src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs @@ -32,13 +32,10 @@ public void Register(IServiceCollection services) services.AddKeyedSingleton(MarketType.Crypto); // Agent 工具 - services.AddKeyedSingleton(MarketType.Crypto); services.AddKeyedSingleton(MarketType.Crypto); - services.AddKeyedSingleton(MarketType.Crypto); services.AddKeyedSingleton(MarketType.Crypto); services.AddKeyedSingleton(MarketType.Crypto); services.AddKeyedSingleton(MarketType.Crypto); - services.AddKeyedSingleton(MarketType.Crypto); services.AddKeyedSingleton(MarketType.Crypto); services.AddKeyedSingleton(MarketType.Crypto); services.AddKeyedSingleton(MarketType.Crypto); @@ -52,6 +49,7 @@ public void Register(IServiceCollection services) sp.GetRequiredService>())); // 资产服务 + services.AddKeyedSingleton(MarketType.Crypto); services.AddKeyedSingleton(MarketType.Crypto); services.AddKeyedSingleton(MarketType.Crypto); services.AddKeyedSingleton(MarketType.Crypto); diff --git a/src/MarketAssistant.App.Services/Services/Market/CryptoRealtimeQuoteService.cs b/src/MarketAssistant.App.Services/Services/Market/CryptoRealtimeQuoteService.cs new file mode 100644 index 0000000..71bd035 --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/Market/CryptoRealtimeQuoteService.cs @@ -0,0 +1,48 @@ +using System.Collections.Concurrent; +using MarketAssistant.DataProviders; +using MarketAssistant.Infrastructure.Core; +using static MarketAssistant.Infrastructure.Core.CryptoSymbolConverter; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Services.Market; + +/// +/// 虚拟币市场的实时行情订阅实现:包装 , +/// 对外使用应用层资产代码(如 BTC),内部完成 code ↔ Binance 交易对的双向转换。 +/// +public sealed class CryptoRealtimeQuoteService : IRealtimeQuoteService +{ + private readonly BinanceWebSocketService _wsService; + + /// Binance 交易对(小写)→ 应用层资产代码。推送回调据此还原为调用方订阅时的代码。 + private readonly ConcurrentDictionary _codeBySymbol = new(StringComparer.OrdinalIgnoreCase); + + public event Action? PriceUpdated; + + public CryptoRealtimeQuoteService(BinanceWebSocketService wsService, ILogger logger) + { + _wsService = wsService; + _wsService.PriceUpdated += OnWebSocketPriceUpdated; + } + + public Task SubscribeAsync(string subscriberKey, IEnumerable codes) + { + var codeList = codes as IList ?? codes.ToList(); + foreach (var code in codeList) + { + _codeBySymbol[ToBinanceFormat(code)] = code; + } + return _wsService.SubscribeAsync(subscriberKey, codeList.Select(code => ToBinanceFormat(code))); + } + + public Task UnsubscribeAllAsync(string subscriberKey) + => _wsService.UnsubscribeAllAsync(subscriberKey); + + private void OnWebSocketPriceUpdated(string symbol, decimal lastPrice, decimal changePercent) + { + if (_codeBySymbol.TryGetValue(symbol, out var code)) + { + PriceUpdated?.Invoke(code, lastPrice, changePercent); + } + } +} diff --git a/src/MarketAssistant.App.Services/Services/Market/IRealtimeQuoteService.cs b/src/MarketAssistant.App.Services/Services/Market/IRealtimeQuoteService.cs new file mode 100644 index 0000000..abff927 --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/Market/IRealtimeQuoteService.cs @@ -0,0 +1,38 @@ +namespace MarketAssistant.Services.Market; + +/// +/// 实时行情订阅抽象:订阅/退订指定资产代码的实时价格推送。 +/// 资产代码统一使用应用层格式(如 BTC、600519),市场专属的符号转换由各市场实现内部完成。 +/// +public interface IRealtimeQuoteService +{ + /// + /// 收到实时价格更新时触发,参数为 (资产代码, 最新价, 涨跌幅%)。 + /// 回调在后台线程执行,UI 更新须自行派发到 UI 线程。 + /// + event Action? PriceUpdated; + + /// + /// 以指定订阅方身份订阅实时行情。同一订阅方重复调用会整体替换其资产集合, + /// 调用方应传入该订阅方当前需要的完整集合。 + /// + /// 订阅方标识(见 ) + /// 应用层资产代码列表 + Task SubscribeAsync(string subscriberKey, IEnumerable codes); + + /// + /// 取消指定订阅方的全部订阅,不影响其他订阅方。 + /// + Task UnsubscribeAllAsync(string subscriberKey); +} + +/// +/// 预定义订阅方标识,保证各模块退订时使用与订阅时一致的 key。 +/// +public static class RealtimeQuoteSubscriberKeys +{ + public const string PriceAlerts = "price-alerts"; + public const string MarketMonitor = "market-monitor"; + public const string Favorites = "favorites"; + public const string AssetDetail = "asset-detail"; +} diff --git a/src/MarketAssistant.App.Services/Services/Market/MarketContext.cs b/src/MarketAssistant.App.Services/Services/Market/MarketContext.cs index 12667f7..1ef1f60 100644 --- a/src/MarketAssistant.App.Services/Services/Market/MarketContext.cs +++ b/src/MarketAssistant.App.Services/Services/Market/MarketContext.cs @@ -38,7 +38,9 @@ public class MarketContext : INotifyPropertyChanged /// /// 当前激活的市场类型(静态快照,供 UI Converter 等无法直接依赖注入的场景读取) /// - public static MarketType CurrentMarketType { get; private set; } + private static volatile MarketType s_currentMarketType; + + public static MarketType CurrentMarketType => s_currentMarketType; /// /// 当前激活的市场类型 @@ -50,14 +52,25 @@ public MarketContext(IUserSettingService userSettingService, IServiceProvider se _userSettingService = userSettingService; _serviceProvider = serviceProvider; _currentMarket = _userSettingService.CurrentSetting.CurrentMarketType; - CurrentMarketType = _currentMarket; + s_currentMarketType = _currentMarket; } /// /// 获取当前市场的能力声明 /// - public IMarketCapability CurrentCapability => - _serviceProvider.GetRequiredKeyedService(CurrentMarket); + public IMarketCapability CurrentCapability => GetService(); + + /// + /// 解析当前市场的 Keyed 服务(统一入口,替代已废弃的 MarketServiceRegistry 门面) + /// + public T GetService() where T : class => + _serviceProvider.GetRequiredKeyedService(CurrentMarket); + + /// + /// 解析指定市场的 Keyed 服务 + /// + public T GetService(MarketType marketType) where T : class => + _serviceProvider.GetRequiredKeyedService(marketType); /// /// 市场切换事件,供后端服务订阅以清理状态或暂停后台任务。 @@ -78,7 +91,7 @@ public void SwitchMarket(MarketType newMarket) return; previousMarket = _currentMarket; _currentMarket = newMarket; - CurrentMarketType = newMarket; + s_currentMarketType = newMarket; } // 与持久化共用同步边界,避免与其它线程的设置保存交错 diff --git a/src/MarketAssistant.App.Services/Services/Market/NoopRealtimeQuoteService.cs b/src/MarketAssistant.App.Services/Services/Market/NoopRealtimeQuoteService.cs new file mode 100644 index 0000000..830e006 --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/Market/NoopRealtimeQuoteService.cs @@ -0,0 +1,20 @@ +namespace MarketAssistant.Services.Market; + +/// +/// 无实时推送市场的空实现(A 股 SupportsRealtime=false):订阅为空操作,事件永不触发。 +/// 未来 A 股接入实时行情(如 Level-1 推送)时替换为实现类即可,消费方无需改动。 +/// +public sealed class NoopRealtimeQuoteService : IRealtimeQuoteService +{ + public event Action? PriceUpdated + { + add { } + remove { } + } + + public Task SubscribeAsync(string subscriberKey, IEnumerable codes) + => Task.CompletedTask; + + public Task UnsubscribeAllAsync(string subscriberKey) + => Task.CompletedTask; +} diff --git a/src/MarketAssistant.App.Services/Services/Mcp/McpService.cs b/src/MarketAssistant.App.Services/Services/Mcp/McpService.cs index 1e0d752..6d34dea 100644 --- a/src/MarketAssistant.App.Services/Services/Mcp/McpService.cs +++ b/src/MarketAssistant.App.Services/Services/Mcp/McpService.cs @@ -147,8 +147,8 @@ public static IClientTransport CreateClientTransport(MCPServerConfig config) return config.TransportType.ToLowerInvariant() switch { "stdio" => CreateStdioTransport(config), - "sse" => CreateSseTransport(config), - "streamablehttp" => CreateStreamableHttpTransport(config), + "sse" => CreateHttpTransport(config, HttpTransportMode.AutoDetect), + "streamablehttp" => CreateHttpTransport(config, HttpTransportMode.StreamableHttp), _ => throw new NotSupportedException($"不支持的传输类型: {config.TransportType}") }; } @@ -379,22 +379,12 @@ private static string[] ParseStdioArguments(string? arguments) return [.. parts]; } - private static IClientTransport CreateSseTransport(MCPServerConfig config) + private static IClientTransport CreateHttpTransport(MCPServerConfig config, HttpTransportMode mode) { return new HttpClientTransport(new HttpClientTransportOptions { Name = config.Name, - TransportMode = HttpTransportMode.AutoDetect, - Endpoint = new Uri(config.Command) - }); - } - - private static IClientTransport CreateStreamableHttpTransport(MCPServerConfig config) - { - return new HttpClientTransport(new HttpClientTransportOptions - { - Name = config.Name, - TransportMode = HttpTransportMode.StreamableHttp, + TransportMode = mode, Endpoint = new Uri(config.Command) }); } diff --git a/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs index bc548b9..c473bb2 100644 --- a/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs +++ b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs @@ -297,6 +297,7 @@ 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/AccountSnapshotRepository.cs b/src/MarketAssistant.App.Services/Trading/AccountSnapshotRepository.cs new file mode 100644 index 0000000..1d05d84 --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/AccountSnapshotRepository.cs @@ -0,0 +1,60 @@ +namespace MarketAssistant.Services.Trading; + +/// +/// 账户快照仓储(internal):每日账户价值快照的保存与历史峰值查询(用于回撤计算)。 +/// +internal sealed class AccountSnapshotRepository : TradingRepositoryBase +{ + public AccountSnapshotRepository( + TradingSchemaInitializer schema, + TradingEnvironmentService environment, + Microsoft.Extensions.Logging.ILogger logger) + : base(schema, environment, logger) + { + } + + /// + /// 保存每日账户快照(用于计算最大回撤) + /// + public async Task SaveAccountSnapshotAsync(decimal totalValueUsdt, CancellationToken ct = default) + { + await EnsureInitializedAsync(); + var today = GetTodayDateString(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO account_snapshots (environment, date, total_value_usdt, snapshot_at) + VALUES (@environment, @date, @value, @snapshotAt) + ON CONFLICT(environment, date) DO UPDATE SET + total_value_usdt = @value, + snapshot_at = @snapshotAt + """; + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@date", today); + cmd.Parameters.AddWithValue("@value", ToDb(totalValueUsdt)); + cmd.Parameters.AddWithValue("@snapshotAt", DateTime.UtcNow.ToString("O")); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + /// + /// 获取历史最高账户价值(用于计算回撤),支持时间窗口下限。 + /// + /// 仅统计 snapshot_at >= 该时刻的快照;传 null 时统计全部历史。 + public async Task GetPeakAccountValueAsync(DateTime? since = null, CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + // 金额列以 TEXT 存储,MAX 会按字符串比较,需 CAST 为数值后再取最大值; + // snapshot_at 统一为 "O" 格式 UTC 字符串,字典序与时间序一致,可直接比较 + var where = since.HasValue ? " AND snapshot_at >= @since" : ""; + cmd.CommandText = $"SELECT MAX(CAST(total_value_usdt AS REAL)) FROM account_snapshots WHERE environment = @environment{where}"; + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + if (since.HasValue) + cmd.Parameters.AddWithValue("@since", since.Value.ToString("O")); + var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); + if (result is double d) + return (decimal)d; + return 0; + } +} diff --git a/src/MarketAssistant.App.Services/Trading/DailyStatsRepository.cs b/src/MarketAssistant.App.Services/Trading/DailyStatsRepository.cs new file mode 100644 index 0000000..61c1863 --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/DailyStatsRepository.cs @@ -0,0 +1,90 @@ +using MarketAssistant.Trading.Models; + +namespace MarketAssistant.Services.Trading; + +/// +/// 日统计仓储(internal):今日交易统计的查询与累计更新。 +/// +internal sealed class DailyStatsRepository : TradingRepositoryBase +{ + public DailyStatsRepository( + TradingSchemaInitializer schema, + TradingEnvironmentService environment, + Microsoft.Extensions.Logging.ILogger logger) + : base(schema, environment, logger) + { + } + + public async Task GetTodayStatsAsync(CancellationToken ct = default) + { + await EnsureInitializedAsync(); + var today = GetTodayDateString(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT * FROM daily_stats WHERE environment = @environment AND date = @date"; + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@date", today); + await using var reader = await cmd.ExecuteReaderAsync(ct); + + if (await reader.ReadAsync(ct)) + { + return new DailyStats + { + Date = reader.GetString(reader.GetOrdinal("date")), + TradeCount = reader.GetInt32(reader.GetOrdinal("trade_count")), + TotalPnl = ReadDecimal(reader, reader.GetOrdinal("total_pnl")), + TotalCommission = ReadDecimal(reader, reader.GetOrdinal("total_commission")) + }; + } + + return new DailyStats { Date = today }; + } + + /// + /// 更新今日统计:累计已实现盈亏与手续费,并按需增加交易次数。 + /// 仅在订单首次实际成交(executed_qty 从 0 变为 >0)时为 true, + /// 避免未成交订单被计数、以及下单与对账重复计数。 + /// + public async Task UpdateDailyStatsAsync(decimal pnl, decimal commission, bool countTrade = true, CancellationToken ct = default) + { + await EnsureInitializedAsync(); + var today = GetTodayDateString(); + await using var conn = await OpenConnectionAsync(ct); + + // 金额列以 TEXT 精确存储,SQL 数值加法会把 TEXT 退化为 double, + // 因此在 C# 侧完成累加后整体写回,保持十进制精度。 + var tradeCount = countTrade ? 1 : 0; + var totalPnl = pnl; + var totalCommission = commission; + + await using (var selectCmd = conn.CreateCommand()) + { + selectCmd.CommandText = "SELECT trade_count, total_pnl, total_commission FROM daily_stats WHERE environment = @environment AND date = @date"; + selectCmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + selectCmd.Parameters.AddWithValue("@date", today); + await using var reader = await selectCmd.ExecuteReaderAsync(ct); + if (await reader.ReadAsync(ct)) + { + tradeCount += reader.GetInt32(0); + totalPnl += ReadDecimal(reader, 1); + totalCommission += ReadDecimal(reader, 2); + } + } + + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO daily_stats (environment, date, trade_count, total_pnl, total_commission) + VALUES (@environment, @date, @tradeCount, @pnl, @comm) + ON CONFLICT(environment, date) DO UPDATE SET + trade_count = @tradeCount, + total_pnl = @pnl, + total_commission = @comm + """; + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@date", today); + cmd.Parameters.AddWithValue("@tradeCount", tradeCount); + cmd.Parameters.AddWithValue("@pnl", ToDb(totalPnl)); + cmd.Parameters.AddWithValue("@comm", ToDb(totalCommission)); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } +} diff --git a/src/MarketAssistant.App.Services/Trading/PositionRepository.cs b/src/MarketAssistant.App.Services/Trading/PositionRepository.cs new file mode 100644 index 0000000..6a1e02b --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/PositionRepository.cs @@ -0,0 +1,204 @@ +using System.Globalization; +using MarketAssistant.Trading.Models; +using Microsoft.Data.Sqlite; + +namespace MarketAssistant.Services.Trading; + +/// +/// 持仓 FIFO 追踪仓储(internal):开仓、平仓、未平仓持仓查询与加权平均开仓价计算。 +/// +internal sealed class PositionRepository : TradingRepositoryBase +{ + public PositionRepository( + TradingSchemaInitializer schema, + TradingEnvironmentService environment, + Microsoft.Extensions.Logging.ILogger logger) + : base(schema, environment, logger) + { + } + + /// + /// 开仓:插入一条新的持仓记录 + /// + public async Task OpenPositionAsync(Position position, CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO positions (id, environment, symbol, side, quantity, entry_price, closed_quantity, strategy_id, opened_at) + VALUES (@id, @environment, @symbol, @side, @qty, @entry, 0, @stratId, @openedAt) + """; + cmd.Parameters.AddWithValue("@id", position.Id); + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@symbol", position.Symbol); + cmd.Parameters.AddWithValue("@side", (int)position.Side); + cmd.Parameters.AddWithValue("@qty", ToDb(position.Quantity)); + cmd.Parameters.AddWithValue("@entry", ToDb(position.EntryPrice)); + cmd.Parameters.AddWithValue("@stratId", (object?)position.StrategyId ?? DBNull.Value); + cmd.Parameters.AddWithValue("@openedAt", position.OpenedAt.ToString("O")); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + /// + /// 平仓:按 FIFO 顺序匹配指定方向的持仓,更新 closed_quantity,返回已实现盈亏。 + /// 多头盈亏 = (平仓价 - 开仓价) × 数量;空头盈亏 = (开仓价 - 平仓价) × 数量。 + /// + public async Task ClosePositionFifoAsync( + string symbol, decimal closeQty, decimal closePrice, + CancellationToken ct = default, PositionSide side = PositionSide.Long) + { + if (closeQty <= 0) + return 0; + + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var tx = await conn.BeginTransactionAsync(ct); + + try + { + await using var cmd = conn.CreateCommand(); + cmd.Transaction = (SqliteTransaction)tx; + cmd.CommandText = """ + SELECT id, quantity, entry_price, closed_quantity + FROM positions + WHERE environment = @environment AND symbol = @symbol AND side = @side AND (quantity - closed_quantity) > 0 + ORDER BY opened_at ASC + """; + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@symbol", symbol); + cmd.Parameters.AddWithValue("@side", (int)side); + + var toClose = new List<(string id, decimal available, decimal entryPrice)>(); + await using (var reader = await cmd.ExecuteReaderAsync(ct)) + { + while (await reader.ReadAsync(ct)) + { + var id = reader.GetString(0); + var qty = ReadDecimal(reader, 1); + var entry = ReadDecimal(reader, 2); + var closed = ReadDecimal(reader, 3); + toClose.Add((id, qty - closed, entry)); + } + } + + decimal realizedPnl = 0; + var remaining = closeQty; + + foreach (var (id, available, entry) in toClose) + { + if (remaining <= 0) + break; + + var closeThis = Math.Min(remaining, available); + // 空头平仓时开仓价高于平仓价才盈利,与多头相反 + realizedPnl += side == PositionSide.Long + ? (closePrice - entry) * closeThis + : (entry - closePrice) * closeThis; + + await using var updateCmd = conn.CreateCommand(); + updateCmd.Transaction = (SqliteTransaction)tx; + updateCmd.CommandText = """ + UPDATE positions SET closed_quantity = closed_quantity + @close + WHERE id = @id + """; + updateCmd.Parameters.AddWithValue("@close", ToDb(closeThis)); + updateCmd.Parameters.AddWithValue("@id", id); + await updateCmd.ExecuteNonQueryAsync(ct); + + remaining -= closeThis; + } + + await tx.CommitAsync(ct); + return realizedPnl; + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + } + + /// + /// 获取指定 symbol 的当前未平仓多头持仓(用于 UI 展示与风控) + /// + public async Task> GetOpenPositionsAsync(string? symbol = null, CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + + if (string.IsNullOrEmpty(symbol)) + { + cmd.CommandText = """ + SELECT id, symbol, side, quantity, entry_price, closed_quantity, strategy_id, opened_at + FROM positions + WHERE environment = @environment AND (quantity - closed_quantity) > 0 + ORDER BY opened_at ASC + """; + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + } + else + { + cmd.CommandText = """ + SELECT id, symbol, side, quantity, entry_price, closed_quantity, strategy_id, opened_at + FROM positions + WHERE environment = @environment AND symbol = @symbol AND (quantity - closed_quantity) > 0 + ORDER BY opened_at ASC + """; + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@symbol", symbol); + } + + var positions = new List(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + positions.Add(ReadPosition(reader)); + return positions; + } + + /// + /// 计算指定 symbol 的加权平均开仓价(仅未平仓部分,用于风控与 UI) + /// + public async Task GetOpenPositionAvgEntryPriceAsync(string symbol, CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT CASE WHEN SUM(quantity - closed_quantity) > 0 + THEN SUM((quantity - closed_quantity) * entry_price) / SUM(quantity - closed_quantity) + ELSE 0 END + FROM positions + WHERE environment = @environment AND symbol = @symbol AND side = @side AND (quantity - closed_quantity) > 0 + """; + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@symbol", symbol); + cmd.Parameters.AddWithValue("@side", (int)PositionSide.Long); + + var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); + if (result is double d) + return (decimal)d; + return 0; + } + + private static Position ReadPosition(SqliteDataReader reader) + { + var position = new Position + { + Id = reader.GetString(reader.GetOrdinal("id")), + Symbol = reader.GetString(reader.GetOrdinal("symbol")), + Side = (PositionSide)reader.GetInt32(reader.GetOrdinal("side")), + Quantity = ReadDecimal(reader, reader.GetOrdinal("quantity")), + EntryPrice = ReadDecimal(reader, reader.GetOrdinal("entry_price")), + ClosedQuantity = ReadDecimal(reader, reader.GetOrdinal("closed_quantity")), + OpenedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("opened_at")), CultureInfo.InvariantCulture) + }; + + var sidOrd = reader.GetOrdinal("strategy_id"); + if (!reader.IsDBNull(sidOrd)) + position.StrategyId = reader.GetString(sidOrd); + + return position; + } +} diff --git a/src/MarketAssistant.App.Services/Trading/RiskConfigRepository.cs b/src/MarketAssistant.App.Services/Trading/RiskConfigRepository.cs new file mode 100644 index 0000000..5b7d8e2 --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/RiskConfigRepository.cs @@ -0,0 +1,60 @@ +using System.Text.Json; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Trading.Models; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Services.Trading; + +/// +/// 风控配置仓储(internal):风控配置 JSON 的加载与保存。 +/// +internal sealed class RiskConfigRepository : TradingRepositoryBase +{ + public RiskConfigRepository( + TradingSchemaInitializer schema, + TradingEnvironmentService environment, + Microsoft.Extensions.Logging.ILogger logger) + : base(schema, environment, logger) + { + } + + public async Task LoadRiskConfigAsync(CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT config_json FROM risk_config WHERE environment = @environment AND market_type = @marketType"; + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@marketType", (int)MarketType.Crypto); + + var result = await cmd.ExecuteScalarAsync(ct); + if (result is not string json || string.IsNullOrEmpty(json)) + return new RiskConfig(); + try + { + return JsonSerializer.Deserialize(json) ?? new RiskConfig(); + } + catch (JsonException ex) + { + Logger.LogWarning(ex, "风控配置反序列化失败,将使用默认配置"); + return new RiskConfig(); + } + } + + public async Task SaveRiskConfigAsync(RiskConfig config, CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO risk_config (environment, market_type, config_json, updated_at) + VALUES (@environment, @marketType, @configJson, @updatedAt) + ON CONFLICT(environment, market_type) DO UPDATE SET config_json = @configJson, updated_at = @updatedAt + """; + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@marketType", (int)MarketType.Crypto); + cmd.Parameters.AddWithValue("@configJson", JsonSerializer.Serialize(config)); + cmd.Parameters.AddWithValue("@updatedAt", DateTime.UtcNow.ToString("O")); + await cmd.ExecuteNonQueryAsync(ct); + } +} diff --git a/src/MarketAssistant.App.Services/Trading/TradeRecordRepository.cs b/src/MarketAssistant.App.Services/Trading/TradeRecordRepository.cs new file mode 100644 index 0000000..ef86fe1 --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/TradeRecordRepository.cs @@ -0,0 +1,352 @@ +using System.Globalization; +using MarketAssistant.Trading.Abstractions; +using MarketAssistant.Trading.Models; +using Microsoft.Data.Sqlite; + +namespace MarketAssistant.Services.Trading; + +/// +/// 交易记录仓储(internal):交易记录的持久化、查询与对账。 +/// 对账需联动持仓(FIFO)与日统计,故依赖 与 。 +/// +internal sealed class TradeRecordRepository : TradingRepositoryBase +{ + private readonly PositionRepository _positions; + private readonly DailyStatsRepository _dailyStats; + + public TradeRecordRepository( + TradingSchemaInitializer schema, + TradingEnvironmentService environment, + Microsoft.Extensions.Logging.ILogger logger, + PositionRepository positions, + DailyStatsRepository dailyStats) + : base(schema, environment, logger) + { + _positions = positions; + _dailyStats = dailyStats; + } + + public async Task SaveTradeRecordAsync(TradeRecord record, CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO trade_records + (id, environment, strategy_id, symbol, side, order_type, requested_qty, executed_qty, + requested_price, executed_price, commission, commission_asset, status, + binance_order_id, ai_reasoning, created_at, completed_at) + VALUES + (@id, @environment, @stratId, @symbol, @side, @orderType, @reqQty, @execQty, + @reqPrice, @execPrice, @commission, @commAsset, @status, + @binanceId, @aiReasoning, @createdAt, @completedAt) + """; + cmd.Parameters.AddWithValue("@id", record.Id); + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@stratId", record.StrategyId); + cmd.Parameters.AddWithValue("@symbol", record.Symbol); + cmd.Parameters.AddWithValue("@side", (int)record.Side); + cmd.Parameters.AddWithValue("@orderType", (int)record.OrderType); + cmd.Parameters.AddWithValue("@reqQty", ToDb(record.RequestedQty)); + cmd.Parameters.AddWithValue("@execQty", ToDb(record.ExecutedQty)); + cmd.Parameters.AddWithValue("@reqPrice", ToDbNullable(record.RequestedPrice)); + cmd.Parameters.AddWithValue("@execPrice", ToDb(record.ExecutedPrice)); + cmd.Parameters.AddWithValue("@commission", ToDb(record.Commission)); + cmd.Parameters.AddWithValue("@commAsset", (object?)record.CommissionAsset ?? DBNull.Value); + cmd.Parameters.AddWithValue("@status", (int)record.Status); + cmd.Parameters.AddWithValue("@binanceId", record.ExchangeOrderId); + 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).ConfigureAwait(false); + } + + public async Task> GetTradeRecordsAsync( + string? symbol = null, DateTime? from = null, DateTime? to = null, int limit = 50, + CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + + var conditions = new List(); + conditions.Add("environment = @environment"); + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + + if (!string.IsNullOrEmpty(symbol)) + { + conditions.Add("symbol = @symbol"); + cmd.Parameters.AddWithValue("@symbol", symbol); + } + if (from.HasValue) + { + conditions.Add("created_at >= @from"); + cmd.Parameters.AddWithValue("@from", from.Value.ToString("O")); + } + if (to.HasValue) + { + conditions.Add("created_at <= @to"); + cmd.Parameters.AddWithValue("@to", to.Value.ToString("O")); + } + + var where = conditions.Count > 0 ? "WHERE " + string.Join(" AND ", conditions) : ""; + cmd.CommandText = $"SELECT * FROM trade_records {where} ORDER BY created_at DESC LIMIT @limit"; + cmd.Parameters.AddWithValue("@limit", limit); + + var records = new List(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + records.Add(ReadTradeRecord(reader)); + return records; + } + + public async Task> GetRecordsByStrategyAsync(string strategyId, CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT * FROM trade_records WHERE environment = @environment AND strategy_id = @stratId ORDER BY created_at DESC"; + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@stratId", strategyId); + + var records = new List(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + records.Add(ReadTradeRecord(reader)); + return records; + } + + public async Task> GetUnsettledTradeRecordsAsync( + string? symbol = null, + CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + + var conditions = new List + { + "environment = @environment", + "binance_order_id > 0", + "(status = @pending OR status = @partial)" + }; + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@pending", (int)TradeRecordStatus.Pending); + cmd.Parameters.AddWithValue("@partial", (int)TradeRecordStatus.PartiallyFilled); + + if (!string.IsNullOrWhiteSpace(symbol)) + { + conditions.Add("symbol = @symbol"); + cmd.Parameters.AddWithValue("@symbol", symbol); + } + + cmd.CommandText = $"SELECT * FROM trade_records WHERE {string.Join(" AND ", conditions)} ORDER BY created_at ASC"; + + var records = new List(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + records.Add(ReadTradeRecord(reader)); + return records; + } + + public async Task ReconcileTradeRecordAsync( + TradeRecord existingRecord, + ExchangeOrderResult latestOrder, + CancellationToken ct = default) + { + var latestStatus = MapTradeRecordStatus(latestOrder.Status); + var latestExecutedQty = Math.Max(existingRecord.ExecutedQty, latestOrder.ExecutedQty); + var latestExecutedPrice = CalculateEffectiveExecutedPrice(latestOrder, existingRecord.ExecutedPrice); + var deltaExecutedQty = latestExecutedQty - existingRecord.ExecutedQty; + var deltaCommission = latestOrder.FillCommission > existingRecord.Commission + ? latestOrder.FillCommission - existingRecord.Commission + : 0; + DateTime? completedAt = IsTerminalStatus(latestStatus) + ? existingRecord.CompletedAt ?? DateTime.UtcNow + : null; + + var hasMeaningfulChange = deltaExecutedQty > 0 + || deltaCommission > 0 + || latestStatus != existingRecord.Status + || latestExecutedPrice != existingRecord.ExecutedPrice + || completedAt != existingRecord.CompletedAt; + + if (!hasMeaningfulChange) + return existingRecord; + + decimal realizedPnl = 0; + if (deltaExecutedQty > 0) + { + // 合约模式下买卖方向无法单独确定开平仓,需结合本地持仓判断: + // 买入 = 平空(若存在空头持仓)否则开多;卖出 = 平多(若存在多头持仓)否则开空。 + if (IsFuturesMode) + { + var positions = await _positions.GetOpenPositionsAsync(existingRecord.Symbol, ct).ConfigureAwait(false); + if (existingRecord.Side == OrderSide.Buy) + { + if (positions.Any(p => p.Side == PositionSide.Short)) + { + realizedPnl = await _positions.ClosePositionFifoAsync( + existingRecord.Symbol, deltaExecutedQty, latestExecutedPrice, ct, PositionSide.Short) + .ConfigureAwait(false); + } + else + { + await _positions.OpenPositionAsync(new Position + { + Symbol = existingRecord.Symbol, + Side = PositionSide.Long, + Quantity = deltaExecutedQty, + EntryPrice = latestExecutedPrice, + StrategyId = existingRecord.StrategyId, + OpenedAt = DateTime.UtcNow + }, ct).ConfigureAwait(false); + } + } + else + { + if (positions.Any(p => p.Side == PositionSide.Long)) + { + realizedPnl = await _positions.ClosePositionFifoAsync( + existingRecord.Symbol, deltaExecutedQty, latestExecutedPrice, ct, PositionSide.Long) + .ConfigureAwait(false); + } + else + { + await _positions.OpenPositionAsync(new Position + { + Symbol = existingRecord.Symbol, + Side = PositionSide.Short, + Quantity = deltaExecutedQty, + EntryPrice = latestExecutedPrice, + StrategyId = existingRecord.StrategyId, + OpenedAt = DateTime.UtcNow + }, ct).ConfigureAwait(false); + } + } + } + else if (existingRecord.Side == OrderSide.Buy) + { + await _positions.OpenPositionAsync(new Position + { + Symbol = existingRecord.Symbol, + Side = PositionSide.Long, + Quantity = deltaExecutedQty, + EntryPrice = latestExecutedPrice, + StrategyId = existingRecord.StrategyId, + OpenedAt = DateTime.UtcNow + }, ct).ConfigureAwait(false); + } + else + { + realizedPnl = await _positions.ClosePositionFifoAsync( + existingRecord.Symbol, + deltaExecutedQty, + latestExecutedPrice, + ct).ConfigureAwait(false); + } + } + + // 交易次数仅在订单从未成交变为首次成交(existingRecord.ExecutedQty == 0)时增加一次; + // 下单时已计数或对账增量补充过的订单不再重复计数 + if (deltaExecutedQty > 0 || deltaCommission > 0) + await _dailyStats.UpdateDailyStatsAsync(realizedPnl, deltaCommission, + countTrade: existingRecord.ExecutedQty == 0 && deltaExecutedQty > 0, ct).ConfigureAwait(false); + + existingRecord.RequestedQty = latestOrder.RequestedQty > 0 ? latestOrder.RequestedQty : existingRecord.RequestedQty; + existingRecord.ExecutedQty = latestExecutedQty; + existingRecord.ExecutedPrice = latestExecutedPrice; + existingRecord.Commission += deltaCommission; + if (!string.IsNullOrWhiteSpace(latestOrder.CommissionAsset)) + existingRecord.CommissionAsset = latestOrder.CommissionAsset!; + existingRecord.Status = latestStatus; + existingRecord.CompletedAt = completedAt; + + await UpdateTradeRecordAsync(existingRecord, ct).ConfigureAwait(false); + return existingRecord; + } + + private async Task UpdateTradeRecordAsync(TradeRecord record, CancellationToken ct) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE trade_records + SET requested_qty = @requestedQty, + executed_qty = @executedQty, + executed_price = @executedPrice, + commission = @commission, + commission_asset = @commissionAsset, + status = @status, + completed_at = @completedAt + WHERE id = @id AND environment = @environment + """; + cmd.Parameters.AddWithValue("@id", record.Id); + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@requestedQty", ToDb(record.RequestedQty)); + cmd.Parameters.AddWithValue("@executedQty", ToDb(record.ExecutedQty)); + cmd.Parameters.AddWithValue("@executedPrice", ToDb(record.ExecutedPrice)); + cmd.Parameters.AddWithValue("@commission", ToDb(record.Commission)); + cmd.Parameters.AddWithValue("@commissionAsset", (object?)record.CommissionAsset ?? DBNull.Value); + cmd.Parameters.AddWithValue("@status", (int)record.Status); + cmd.Parameters.AddWithValue("@completedAt", record.CompletedAt.HasValue ? (object)record.CompletedAt.Value.ToString("O") : DBNull.Value); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + private static TradeRecord ReadTradeRecord(SqliteDataReader reader) + { + var record = new TradeRecord + { + Id = reader.GetString(reader.GetOrdinal("id")), + StrategyId = reader.GetString(reader.GetOrdinal("strategy_id")), + Symbol = reader.GetString(reader.GetOrdinal("symbol")), + Side = (OrderSide)reader.GetInt32(reader.GetOrdinal("side")), + OrderType = (OrderType)reader.GetInt32(reader.GetOrdinal("order_type")), + RequestedQty = ReadDecimal(reader, reader.GetOrdinal("requested_qty")), + ExecutedQty = ReadDecimal(reader, reader.GetOrdinal("executed_qty")), + ExecutedPrice = ReadDecimal(reader, reader.GetOrdinal("executed_price")), + Commission = ReadDecimal(reader, reader.GetOrdinal("commission")), + Status = (TradeRecordStatus)reader.GetInt32(reader.GetOrdinal("status")), + ExchangeOrderId = reader.GetInt64(reader.GetOrdinal("binance_order_id")), + CreatedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("created_at")), CultureInfo.InvariantCulture) + }; + + var rpOrd = reader.GetOrdinal("requested_price"); + if (!reader.IsDBNull(rpOrd)) record.RequestedPrice = ReadDecimal(reader, rpOrd); + + var caOrd = reader.GetOrdinal("commission_asset"); + if (!reader.IsDBNull(caOrd)) record.CommissionAsset = reader.GetString(caOrd); + + var arOrd = reader.GetOrdinal("ai_reasoning"); + if (!reader.IsDBNull(arOrd)) record.AIReasoning = reader.GetString(arOrd); + + var coOrd = reader.GetOrdinal("completed_at"); + if (!reader.IsDBNull(coOrd)) record.CompletedAt = DateTime.Parse(reader.GetString(coOrd), CultureInfo.InvariantCulture); + + return record; + } + + private static decimal CalculateEffectiveExecutedPrice(ExchangeOrderResult latestOrder, decimal fallbackPrice) + { + if (latestOrder.ExecutedQty > 0 && latestOrder.CumulativeQuoteQty > 0) + return latestOrder.CumulativeQuoteQty / latestOrder.ExecutedQty; + + if (latestOrder.Price > 0) + return latestOrder.Price; + + return fallbackPrice; + } + + private static TradeRecordStatus MapTradeRecordStatus(string exchangeStatus) => exchangeStatus switch + { + "FILLED" => TradeRecordStatus.Filled, + "PARTIALLY_FILLED" => TradeRecordStatus.PartiallyFilled, + "CANCELED" or "CANCELLED" => TradeRecordStatus.Cancelled, + "REJECTED" or "EXPIRED" => TradeRecordStatus.Failed, + _ => TradeRecordStatus.Pending + }; + + private static bool IsTerminalStatus(TradeRecordStatus status) => status is + TradeRecordStatus.Filled or TradeRecordStatus.Cancelled or TradeRecordStatus.Failed; +} diff --git a/src/MarketAssistant.App.Services/Trading/TradingDataService.cs b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs index e22220a..54df245 100644 --- a/src/MarketAssistant.App.Services/Trading/TradingDataService.cs +++ b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs @@ -1,7 +1,3 @@ -using System.Globalization; -using System.Text.Json; -using MarketAssistant.Applications.Settings; -using MarketAssistant.Infrastructure.Core; using MarketAssistant.Trading.Abstractions; using MarketAssistant.Trading.Models; using Microsoft.Data.Sqlite; @@ -10,1432 +6,142 @@ namespace MarketAssistant.Services.Trading; /// -/// 交易数据持久化服务,管理策略、交易记录和日统计的 SQLite 存储。 +/// 交易数据持久化门面,管理策略、交易记录、持仓、日统计、账户快照与风控配置的 SQLite 存储。 +/// 对外保持原有公开契约(含 virtual 测试替换点);内部按聚合根拆分为 +/// /// +/// //, +/// schema 初始化统一由 负责。 /// -public class TradingDataService : SqliteServiceBase +public class TradingDataService { - private const string LiveSpotEnvironment = "crypto-live-spot"; - private const string LiveFuturesEnvironment = "crypto-live-futures"; - private const string FuturesTestnetEnvironment = "crypto-binance-futures-testnet"; - private const string SpotDemoEnvironment = "crypto-binance-spot-demo"; - - private readonly TradingEnvironmentService _tradingEnvironmentService; + private readonly TradingSchemaInitializer _schema; + private readonly TradingStrategyRepository _strategies; + private readonly PositionRepository _positions; + private readonly DailyStatsRepository _dailyStats; + private readonly AccountSnapshotRepository _accountSnapshots; + private readonly RiskConfigRepository _riskConfig; + private readonly TradeRecordRepository _records; public TradingDataService( TradingEnvironmentService tradingEnvironmentService, ILogger logger) - : base(logger) { - _tradingEnvironmentService = tradingEnvironmentService; + _schema = new TradingSchemaInitializer(logger); + _strategies = new TradingStrategyRepository(_schema, tradingEnvironmentService, logger); + _positions = new PositionRepository(_schema, tradingEnvironmentService, logger); + _dailyStats = new DailyStatsRepository(_schema, tradingEnvironmentService, logger); + _accountSnapshots = new AccountSnapshotRepository(_schema, tradingEnvironmentService, logger); + _riskConfig = new RiskConfigRepository(_schema, tradingEnvironmentService, logger); + _records = new TradeRecordRepository(_schema, tradingEnvironmentService, logger, _positions, _dailyStats); } - /// - /// 4 种交易模式各自独立的环境 key,确保现货实盘、现货 Demo、合约实盘、合约 Testnet - /// 的策略、交易记录、持仓、风控配置互不混淆。 - /// - private string CurrentEnvironmentKey => _tradingEnvironmentService.CurrentMode switch - { - CryptoTradingMode.LiveFutures => LiveFuturesEnvironment, - CryptoTradingMode.BinanceFuturesTestnet => FuturesTestnetEnvironment, - CryptoTradingMode.BinanceSpotDemo => SpotDemoEnvironment, - _ => LiveSpotEnvironment - }; - - /// - /// 当前是否为合约模式(合约买卖方向需结合持仓判断开平仓) - /// - private bool IsFuturesMode => _tradingEnvironmentService.CurrentMode is - CryptoTradingMode.LiveFutures or CryptoTradingMode.BinanceFuturesTestnet; - - #region decimal 存取辅助 + // ─────────────────────────── 策略 ─────────────────────────── - /// - /// 金额/数量列以 TEXT(InvariantCulture 十进制字符串)存储, - /// 避免 REAL(double)存储对高精度小数(如 8 位小数价格)造成精度损失。 - /// - private static object ToDb(decimal value) => value.ToString(CultureInfo.InvariantCulture); + public Task SaveStrategyAsync(TradingStrategy strategy, CancellationToken ct = default) + => _strategies.SaveStrategyAsync(strategy, ct); - /// - private static object ToDbNullable(decimal? value) => value.HasValue ? ToDb(value.Value) : DBNull.Value; - - /// - /// 读取金额/数量列,兼容 TEXT(新格式)与 REAL/INTEGER(历史数据)两种存储形态。 - /// - private static decimal ReadDecimal(SqliteDataReader reader, int ordinal) - { - if (reader.IsDBNull(ordinal)) - return 0; + public Task GetStrategyAsync(string id, CancellationToken ct = default) + => _strategies.GetStrategyAsync(id, ct); - return reader.GetValue(ordinal) switch - { - string s when decimal.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) => parsed, - double d => (decimal)d, - long l => l, - _ => 0 - }; - } + public Task> GetStrategiesByStatusAsync(StrategyStatus status, CancellationToken ct = default) + => _strategies.GetStrategiesByStatusAsync(status, ct); - #endregion + public Task> GetAllStrategiesAsync(CancellationToken ct = default) + => _strategies.GetAllStrategiesAsync(ct); - #region 策略 CRUD + public Task UpdateStrategyStatusAsync(string id, StrategyStatus status, CancellationToken ct = default) + => _strategies.UpdateStrategyStatusAsync(id, status, ct); - public async Task SaveStrategyAsync(TradingStrategy strategy, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT OR REPLACE INTO strategies - (id, environment, symbol, type, status, side, order_type, slippage_tolerance, - trigger_price, stop_loss_price, take_profit_price, - quantity, max_position_percent, custom_params, created_at, last_triggered_at, - execution_count, max_executions, trailing_peak_price) - VALUES - (@id, @environment, @symbol, @type, @status, @side, @orderType, @slippage, - @triggerPrice, @slPrice, @tpPrice, - @qty, @maxPos, @customParams, @createdAt, @lastTriggered, - @execCount, @maxExec, @trailingPeak) - """; - cmd.Parameters.AddWithValue("@id", strategy.Id); - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@symbol", strategy.Symbol); - cmd.Parameters.AddWithValue("@type", (int)strategy.Type); - cmd.Parameters.AddWithValue("@status", (int)strategy.Status); - cmd.Parameters.AddWithValue("@side", (int)strategy.Side); - cmd.Parameters.AddWithValue("@orderType", (int)strategy.OrderType); - cmd.Parameters.AddWithValue("@slippage", ToDb(strategy.SlippageTolerance)); - cmd.Parameters.AddWithValue("@triggerPrice", ToDb(strategy.TriggerPrice)); - cmd.Parameters.AddWithValue("@slPrice", ToDbNullable(strategy.StopLossPrice)); - cmd.Parameters.AddWithValue("@tpPrice", ToDbNullable(strategy.TakeProfitPrice)); - cmd.Parameters.AddWithValue("@qty", ToDb(strategy.Quantity)); - cmd.Parameters.AddWithValue("@maxPos", ToDbNullable(strategy.MaxPositionPercent)); - cmd.Parameters.AddWithValue("@customParams", (object?)strategy.CustomParams ?? DBNull.Value); - cmd.Parameters.AddWithValue("@createdAt", strategy.CreatedAt.ToString("O")); - 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); - cmd.Parameters.AddWithValue("@trailingPeak", ToDbNullable(strategy.TrailingPeakPrice)); - await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - } - - public async Task GetStrategyAsync(string id, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT * FROM strategies WHERE id = @id AND environment = @environment"; - cmd.Parameters.AddWithValue("@id", id); - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - await using var reader = await cmd.ExecuteReaderAsync(ct); - return await reader.ReadAsync(ct) ? ReadStrategy(reader) : null; - } - - public async Task> GetStrategiesByStatusAsync(StrategyStatus status, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT * FROM strategies WHERE environment = @environment AND status = @status ORDER BY created_at DESC"; - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@status", (int)status); - return await ReadStrategiesAsync(cmd, ct); - } - - public async Task> GetAllStrategiesAsync(CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT * FROM strategies WHERE environment = @environment ORDER BY created_at DESC"; - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - return await ReadStrategiesAsync(cmd, ct); - } - - public async Task UpdateStrategyStatusAsync(string id, StrategyStatus status, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "UPDATE strategies SET status = @status WHERE id = @id AND environment = @environment"; - cmd.Parameters.AddWithValue("@id", id); - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@status", (int)status); - await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - } - - public async Task DeleteStrategyAsync(string id, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "DELETE FROM strategies WHERE id = @id AND environment = @environment"; - cmd.Parameters.AddWithValue("@id", id); - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - } + public Task DeleteStrategyAsync(string id, CancellationToken ct = default) + => _strategies.DeleteStrategyAsync(id, ct); - public async Task UpdateStrategyTriggeredAsync(string id, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - UPDATE strategies - SET last_triggered_at = @time, execution_count = execution_count + 1 - WHERE id = @id AND environment = @environment - """; - cmd.Parameters.AddWithValue("@id", id); - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@time", DateTime.UtcNow.ToString("O")); - await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - } + public Task UpdateStrategyTriggeredAsync(string id, CancellationToken ct = default) + => _strategies.UpdateStrategyTriggeredAsync(id, ct); - /// - /// 仅更新策略的最后评估时间(不增加执行计数),用于 AI 信号策略的评估节流: - /// 无论 Agent 是否实际成交,一次评估后都记入冷却期,避免未成交时每个价格 tick 重复调用 LLM。 - /// /// virtual 供单元测试替换。 - public virtual async Task UpdateStrategyLastTriggeredAtAsync(string id, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - UPDATE strategies - SET last_triggered_at = @time - WHERE id = @id AND environment = @environment - """; - cmd.Parameters.AddWithValue("@id", id); - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@time", DateTime.UtcNow.ToString("O")); - await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - } + public virtual Task UpdateStrategyLastTriggeredAtAsync(string id, CancellationToken ct = default) + => _strategies.UpdateStrategyLastTriggeredAtAsync(id, ct); - /// - /// 原子地更新策略触发计数和自定义参数(用于网格交易,防止计数已更新但交易未执行的状态不一致) - /// - public async Task UpdateStrategyTriggeredWithParamsAsync(string id, string? customParams, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - 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 AND environment = @environment - """; - cmd.Parameters.AddWithValue("@id", id); - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - 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 Task UpdateStrategyTriggeredWithParamsAsync(string id, string? customParams, CancellationToken ct = default) + => _strategies.UpdateStrategyTriggeredWithParamsAsync(id, customParams, ct); /// virtual 供单元测试替换。 - public virtual async Task UpdateStrategyCustomParamsAsync(string id, string? customParams, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "UPDATE strategies SET custom_params = @customParams WHERE id = @id AND environment = @environment"; - cmd.Parameters.AddWithValue("@id", id); - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@customParams", (object?)customParams ?? DBNull.Value); - await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - } + public virtual Task UpdateStrategyCustomParamsAsync(string id, string? customParams, CancellationToken ct = default) + => _strategies.UpdateStrategyCustomParamsAsync(id, customParams, ct); - /// - /// 更新策略护栏位(止损/止盈价)。AI 决策生成护栏后调用, - /// 使 StrategyEngine 的硬性边界评估与 TradeExecutor 的风控检查立即生效。 - /// - public async Task UpdateStrategyGuardrailsAsync( + public Task UpdateStrategyGuardrailsAsync( string id, decimal? stopLossPrice, decimal? takeProfitPrice, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - UPDATE strategies SET stop_loss_price = @stopLoss, take_profit_price = @takeProfit - WHERE id = @id AND environment = @environment - """; - cmd.Parameters.AddWithValue("@id", id); - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@stopLoss", ToDbNullable(stopLossPrice)); - cmd.Parameters.AddWithValue("@takeProfit", ToDbNullable(takeProfitPrice)); - await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - } + => _strategies.UpdateStrategyGuardrailsAsync(id, stopLossPrice, takeProfitPrice, ct); - /// - /// 更新追踪止损的峰值/谷值价格(持久化,防止重启丢失) - /// /// virtual 供单元测试替换。 - public virtual async Task UpdateStrategyTrailingPeakAsync(string id, decimal? trailingPeakPrice, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "UPDATE strategies SET trailing_peak_price = @peak WHERE id = @id AND environment = @environment"; - cmd.Parameters.AddWithValue("@id", id); - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@peak", ToDbNullable(trailingPeakPrice)); - await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - } - - #endregion + public virtual Task UpdateStrategyTrailingPeakAsync(string id, decimal? trailingPeakPrice, CancellationToken ct = default) + => _strategies.UpdateStrategyTrailingPeakAsync(id, trailingPeakPrice, ct); - #region 交易记录 + // ─────────────────────────── 交易记录 ─────────────────────────── - public virtual async Task SaveTradeRecordAsync(TradeRecord record, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT INTO trade_records - (id, environment, strategy_id, symbol, side, order_type, requested_qty, executed_qty, - requested_price, executed_price, commission, commission_asset, status, - binance_order_id, ai_reasoning, created_at, completed_at) - VALUES - (@id, @environment, @stratId, @symbol, @side, @orderType, @reqQty, @execQty, - @reqPrice, @execPrice, @commission, @commAsset, @status, - @binanceId, @aiReasoning, @createdAt, @completedAt) - """; - cmd.Parameters.AddWithValue("@id", record.Id); - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@stratId", record.StrategyId); - cmd.Parameters.AddWithValue("@symbol", record.Symbol); - cmd.Parameters.AddWithValue("@side", (int)record.Side); - cmd.Parameters.AddWithValue("@orderType", (int)record.OrderType); - cmd.Parameters.AddWithValue("@reqQty", ToDb(record.RequestedQty)); - cmd.Parameters.AddWithValue("@execQty", ToDb(record.ExecutedQty)); - cmd.Parameters.AddWithValue("@reqPrice", ToDbNullable(record.RequestedPrice)); - cmd.Parameters.AddWithValue("@execPrice", ToDb(record.ExecutedPrice)); - cmd.Parameters.AddWithValue("@commission", ToDb(record.Commission)); - cmd.Parameters.AddWithValue("@commAsset", (object?)record.CommissionAsset ?? DBNull.Value); - cmd.Parameters.AddWithValue("@status", (int)record.Status); - cmd.Parameters.AddWithValue("@binanceId", record.ExchangeOrderId); - 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).ConfigureAwait(false); - } + public virtual Task SaveTradeRecordAsync(TradeRecord record, CancellationToken ct = default) + => _records.SaveTradeRecordAsync(record, ct); - public async Task> GetTradeRecordsAsync( + public Task> GetTradeRecordsAsync( string? symbol = null, DateTime? from = null, DateTime? to = null, int limit = 50, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - - var conditions = new List(); - conditions.Add("environment = @environment"); - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - - if (!string.IsNullOrEmpty(symbol)) - { - conditions.Add("symbol = @symbol"); - cmd.Parameters.AddWithValue("@symbol", symbol); - } - if (from.HasValue) - { - conditions.Add("created_at >= @from"); - cmd.Parameters.AddWithValue("@from", from.Value.ToString("O")); - } - if (to.HasValue) - { - conditions.Add("created_at <= @to"); - cmd.Parameters.AddWithValue("@to", to.Value.ToString("O")); - } - - var where = conditions.Count > 0 ? "WHERE " + string.Join(" AND ", conditions) : ""; - cmd.CommandText = $"SELECT * FROM trade_records {where} ORDER BY created_at DESC LIMIT @limit"; - cmd.Parameters.AddWithValue("@limit", limit); - - var records = new List(); - await using var reader = await cmd.ExecuteReaderAsync(ct); - while (await reader.ReadAsync(ct)) - records.Add(ReadTradeRecord(reader)); - return records; - } - - public async Task> GetRecordsByStrategyAsync(string strategyId, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT * FROM trade_records WHERE environment = @environment AND strategy_id = @stratId ORDER BY created_at DESC"; - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@stratId", strategyId); - - var records = new List(); - await using var reader = await cmd.ExecuteReaderAsync(ct); - while (await reader.ReadAsync(ct)) - records.Add(ReadTradeRecord(reader)); - return records; - } - - public async Task> GetUnsettledTradeRecordsAsync( - string? symbol = null, - CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - - var conditions = new List - { - "environment = @environment", - "binance_order_id > 0", - "(status = @pending OR status = @partial)" - }; - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@pending", (int)TradeRecordStatus.Pending); - cmd.Parameters.AddWithValue("@partial", (int)TradeRecordStatus.PartiallyFilled); - - if (!string.IsNullOrWhiteSpace(symbol)) - { - conditions.Add("symbol = @symbol"); - cmd.Parameters.AddWithValue("@symbol", symbol); - } - - cmd.CommandText = $"SELECT * FROM trade_records WHERE {string.Join(" AND ", conditions)} ORDER BY created_at ASC"; - - var records = new List(); - await using var reader = await cmd.ExecuteReaderAsync(ct); - while (await reader.ReadAsync(ct)) - records.Add(ReadTradeRecord(reader)); - return records; - } - - public async Task ReconcileTradeRecordAsync( - TradeRecord existingRecord, - ExchangeOrderResult latestOrder, - CancellationToken ct = default) - { - var latestStatus = MapTradeRecordStatus(latestOrder.Status); - var latestExecutedQty = Math.Max(existingRecord.ExecutedQty, latestOrder.ExecutedQty); - var latestExecutedPrice = CalculateEffectiveExecutedPrice(latestOrder, existingRecord.ExecutedPrice); - var deltaExecutedQty = latestExecutedQty - existingRecord.ExecutedQty; - var deltaCommission = latestOrder.FillCommission > existingRecord.Commission - ? latestOrder.FillCommission - existingRecord.Commission - : 0; - DateTime? completedAt = IsTerminalStatus(latestStatus) - ? existingRecord.CompletedAt ?? DateTime.UtcNow - : null; - - var hasMeaningfulChange = deltaExecutedQty > 0 - || deltaCommission > 0 - || latestStatus != existingRecord.Status - || latestExecutedPrice != existingRecord.ExecutedPrice - || completedAt != existingRecord.CompletedAt; - - if (!hasMeaningfulChange) - return existingRecord; + => _records.GetTradeRecordsAsync(symbol, from, to, limit, ct); - decimal realizedPnl = 0; - if (deltaExecutedQty > 0) - { - // 合约模式下买卖方向无法单独确定开平仓,需结合本地持仓判断: - // 买入 = 平空(若存在空头持仓)否则开多;卖出 = 平多(若存在多头持仓)否则开空。 - if (IsFuturesMode) - { - var positions = await GetOpenPositionsAsync(existingRecord.Symbol, ct).ConfigureAwait(false); - if (existingRecord.Side == OrderSide.Buy) - { - if (positions.Any(p => p.Side == PositionSide.Short)) - { - realizedPnl = await ClosePositionFifoAsync( - existingRecord.Symbol, deltaExecutedQty, latestExecutedPrice, ct, PositionSide.Short) - .ConfigureAwait(false); - } - else - { - await OpenPositionAsync(new Position - { - Symbol = existingRecord.Symbol, - Side = PositionSide.Long, - Quantity = deltaExecutedQty, - EntryPrice = latestExecutedPrice, - StrategyId = existingRecord.StrategyId, - OpenedAt = DateTime.UtcNow - }, ct).ConfigureAwait(false); - } - } - else - { - if (positions.Any(p => p.Side == PositionSide.Long)) - { - realizedPnl = await ClosePositionFifoAsync( - existingRecord.Symbol, deltaExecutedQty, latestExecutedPrice, ct, PositionSide.Long) - .ConfigureAwait(false); - } - else - { - await OpenPositionAsync(new Position - { - Symbol = existingRecord.Symbol, - Side = PositionSide.Short, - Quantity = deltaExecutedQty, - EntryPrice = latestExecutedPrice, - StrategyId = existingRecord.StrategyId, - OpenedAt = DateTime.UtcNow - }, ct).ConfigureAwait(false); - } - } - } - else if (existingRecord.Side == OrderSide.Buy) - { - await OpenPositionAsync(new Position - { - Symbol = existingRecord.Symbol, - Side = PositionSide.Long, - Quantity = deltaExecutedQty, - EntryPrice = latestExecutedPrice, - StrategyId = existingRecord.StrategyId, - OpenedAt = DateTime.UtcNow - }, ct).ConfigureAwait(false); - } - else - { - realizedPnl = await ClosePositionFifoAsync( - existingRecord.Symbol, - deltaExecutedQty, - latestExecutedPrice, - ct).ConfigureAwait(false); - } - } + public Task> GetRecordsByStrategyAsync(string strategyId, CancellationToken ct = default) + => _records.GetRecordsByStrategyAsync(strategyId, ct); - // 交易次数仅在订单从未成交变为首次成交(existingRecord.ExecutedQty == 0)时增加一次; - // 下单时已计数或对账增量补充过的订单不再重复计数 - if (deltaExecutedQty > 0 || deltaCommission > 0) - await UpdateDailyStatsAsync(realizedPnl, deltaCommission, - countTrade: existingRecord.ExecutedQty == 0 && deltaExecutedQty > 0, ct).ConfigureAwait(false); - - existingRecord.RequestedQty = latestOrder.RequestedQty > 0 ? latestOrder.RequestedQty : existingRecord.RequestedQty; - existingRecord.ExecutedQty = latestExecutedQty; - existingRecord.ExecutedPrice = latestExecutedPrice; - existingRecord.Commission += deltaCommission; - if (!string.IsNullOrWhiteSpace(latestOrder.CommissionAsset)) - existingRecord.CommissionAsset = latestOrder.CommissionAsset!; - existingRecord.Status = latestStatus; - existingRecord.CompletedAt = completedAt; - - await UpdateTradeRecordAsync(existingRecord, ct).ConfigureAwait(false); - return existingRecord; - } - - #endregion - - #region 日统计 - - /// - /// 获取今日日期字符串,用于日统计与账户快照的日期分组键。 - /// 刻意使用本地时间(DateTime.Now)而非 UTC:交易日的切分以用户所在时区为准, - /// 原实现按 UTC 切分时,亚洲用户在 UTC 16:00 后实际已是次日,导致日统计错位。 - /// 注意:本文件中事件时间戳(如 last_triggered_at、snapshot_at)统一使用 DateTime.UtcNow, - /// 与此处的日期分组键用途不同——前者记录精确发生时刻(绝对时间),后者划分交易日归属。 - /// - private static string GetTodayDateString() => DateTime.Now.ToString("yyyy-MM-dd"); - - public virtual async Task GetTodayStatsAsync(CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - var today = GetTodayDateString(); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT * FROM daily_stats WHERE environment = @environment AND date = @date"; - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@date", today); - await using var reader = await cmd.ExecuteReaderAsync(ct); - - if (await reader.ReadAsync(ct)) - { - return new DailyStats - { - Date = reader.GetString(reader.GetOrdinal("date")), - TradeCount = reader.GetInt32(reader.GetOrdinal("trade_count")), - TotalPnl = ReadDecimal(reader, reader.GetOrdinal("total_pnl")), - TotalCommission = ReadDecimal(reader, reader.GetOrdinal("total_commission")) - }; - } - - return new DailyStats { Date = today }; - } - - /// - /// 更新今日统计:累计已实现盈亏与手续费,并按需增加交易次数。 - /// 仅在订单首次实际成交(executed_qty 从 0 变为 >0)时为 true, - /// 避免未成交订单被计数、以及下单与对账重复计数。 - /// - public virtual async Task UpdateDailyStatsAsync(decimal pnl, decimal commission, bool countTrade = true, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - var today = GetTodayDateString(); - await using var conn = await OpenConnectionAsync(ct); + public Task> GetUnsettledTradeRecordsAsync( + string? symbol = null, CancellationToken ct = default) + => _records.GetUnsettledTradeRecordsAsync(symbol, ct); - // 金额列以 TEXT 精确存储,SQL 数值加法会把 TEXT 退化为 double, - // 因此在 C# 侧完成累加后整体写回,保持十进制精度。 - var tradeCount = countTrade ? 1 : 0; - var totalPnl = pnl; - var totalCommission = commission; + public Task ReconcileTradeRecordAsync( + TradeRecord existingRecord, ExchangeOrderResult latestOrder, CancellationToken ct = default) + => _records.ReconcileTradeRecordAsync(existingRecord, latestOrder, ct); - await using (var selectCmd = conn.CreateCommand()) - { - selectCmd.CommandText = "SELECT trade_count, total_pnl, total_commission FROM daily_stats WHERE environment = @environment AND date = @date"; - selectCmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - selectCmd.Parameters.AddWithValue("@date", today); - await using var reader = await selectCmd.ExecuteReaderAsync(ct); - if (await reader.ReadAsync(ct)) - { - tradeCount += reader.GetInt32(0); - totalPnl += ReadDecimal(reader, 1); - totalCommission += ReadDecimal(reader, 2); - } - } + // ─────────────────────────── 日统计 ─────────────────────────── - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT INTO daily_stats (environment, date, trade_count, total_pnl, total_commission) - VALUES (@environment, @date, @tradeCount, @pnl, @comm) - ON CONFLICT(environment, date) DO UPDATE SET - trade_count = @tradeCount, - total_pnl = @pnl, - total_commission = @comm - """; - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@date", today); - cmd.Parameters.AddWithValue("@tradeCount", tradeCount); - cmd.Parameters.AddWithValue("@pnl", ToDb(totalPnl)); - cmd.Parameters.AddWithValue("@comm", ToDb(totalCommission)); - await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - } + public virtual Task GetTodayStatsAsync(CancellationToken ct = default) + => _dailyStats.GetTodayStatsAsync(ct); - #endregion + public virtual Task UpdateDailyStatsAsync(decimal pnl, decimal commission, bool countTrade = true, CancellationToken ct = default) + => _dailyStats.UpdateDailyStatsAsync(pnl, commission, countTrade, ct); - #region 持仓 FIFO 追踪 + // ─────────────────────────── 持仓 ─────────────────────────── - /// - /// 开仓:插入一条新的持仓记录 - /// - public async Task OpenPositionAsync(Position position, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT INTO positions (id, environment, symbol, side, quantity, entry_price, closed_quantity, strategy_id, opened_at) - VALUES (@id, @environment, @symbol, @side, @qty, @entry, 0, @stratId, @openedAt) - """; - cmd.Parameters.AddWithValue("@id", position.Id); - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@symbol", position.Symbol); - cmd.Parameters.AddWithValue("@side", (int)position.Side); - cmd.Parameters.AddWithValue("@qty", ToDb(position.Quantity)); - cmd.Parameters.AddWithValue("@entry", ToDb(position.EntryPrice)); - cmd.Parameters.AddWithValue("@stratId", (object?)position.StrategyId ?? DBNull.Value); - cmd.Parameters.AddWithValue("@openedAt", position.OpenedAt.ToString("O")); - await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - } + public Task OpenPositionAsync(Position position, CancellationToken ct = default) + => _positions.OpenPositionAsync(position, ct); - /// - /// 平仓:按 FIFO 顺序匹配指定方向的持仓,更新 closed_quantity,返回已实现盈亏。 - /// 多头盈亏 = (平仓价 - 开仓价) × 数量;空头盈亏 = (开仓价 - 平仓价) × 数量。 - /// - public virtual async Task ClosePositionFifoAsync( + public virtual Task ClosePositionFifoAsync( string symbol, decimal closeQty, decimal closePrice, CancellationToken ct = default, PositionSide side = PositionSide.Long) - { - if (closeQty <= 0) - return 0; - - await EnsureInitializedAsync(InitializeDatabaseAsync); - 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 = """ - SELECT id, quantity, entry_price, closed_quantity - FROM positions - WHERE environment = @environment AND symbol = @symbol AND side = @side AND (quantity - closed_quantity) > 0 - ORDER BY opened_at ASC - """; - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@symbol", symbol); - cmd.Parameters.AddWithValue("@side", (int)side); - - var toClose = new List<(string id, decimal available, decimal entryPrice)>(); - await using (var reader = await cmd.ExecuteReaderAsync(ct)) - { - while (await reader.ReadAsync(ct)) - { - var id = reader.GetString(0); - var qty = ReadDecimal(reader, 1); - var entry = ReadDecimal(reader, 2); - var closed = ReadDecimal(reader, 3); - toClose.Add((id, qty - closed, entry)); - } - } - - decimal realizedPnl = 0; - var remaining = closeQty; - - foreach (var (id, available, entry) in toClose) - { - if (remaining <= 0) - break; - - var closeThis = Math.Min(remaining, available); - // 空头平仓时开仓价高于平仓价才盈利,与多头相反 - realizedPnl += side == PositionSide.Long - ? (closePrice - entry) * closeThis - : (entry - closePrice) * closeThis; + => _positions.ClosePositionFifoAsync(symbol, closeQty, closePrice, ct, side); - await using var updateCmd = conn.CreateCommand(); - updateCmd.Transaction = (Microsoft.Data.Sqlite.SqliteTransaction)tx; - updateCmd.CommandText = """ - UPDATE positions SET closed_quantity = closed_quantity + @close - WHERE id = @id - """; - updateCmd.Parameters.AddWithValue("@close", ToDb(closeThis)); - updateCmd.Parameters.AddWithValue("@id", id); - await updateCmd.ExecuteNonQueryAsync(ct); + public virtual Task> GetOpenPositionsAsync(string? symbol = null, CancellationToken ct = default) + => _positions.GetOpenPositionsAsync(symbol, ct); - remaining -= closeThis; - } - - await tx.CommitAsync(ct); - return realizedPnl; - } - catch - { - await tx.RollbackAsync(ct); - throw; - } - } - - /// - /// 获取指定 symbol 的当前未平仓多头持仓(用于 UI 展示与风控) - /// - public virtual async Task> GetOpenPositionsAsync(string? symbol = null, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - - if (string.IsNullOrEmpty(symbol)) - { - cmd.CommandText = """ - SELECT id, symbol, side, quantity, entry_price, closed_quantity, strategy_id, opened_at - FROM positions - WHERE environment = @environment AND (quantity - closed_quantity) > 0 - ORDER BY opened_at ASC - """; - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - } - else - { - cmd.CommandText = """ - SELECT id, symbol, side, quantity, entry_price, closed_quantity, strategy_id, opened_at - FROM positions - WHERE environment = @environment AND symbol = @symbol AND (quantity - closed_quantity) > 0 - ORDER BY opened_at ASC - """; - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@symbol", symbol); - } - - var positions = new List(); - await using var reader = await cmd.ExecuteReaderAsync(ct); - while (await reader.ReadAsync(ct)) - positions.Add(ReadPosition(reader)); - return positions; - } - - /// - /// 计算指定 symbol 的加权平均开仓价(仅未平仓部分,用于风控与 UI) - /// /// virtual 供单元测试替换。 - public virtual async Task GetOpenPositionAvgEntryPriceAsync(string symbol, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - SELECT CASE WHEN SUM(quantity - closed_quantity) > 0 - THEN SUM((quantity - closed_quantity) * entry_price) / SUM(quantity - closed_quantity) - ELSE 0 END - FROM positions - WHERE environment = @environment AND symbol = @symbol AND side = @side AND (quantity - closed_quantity) > 0 - """; - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@symbol", symbol); - cmd.Parameters.AddWithValue("@side", (int)PositionSide.Long); + public virtual Task GetOpenPositionAvgEntryPriceAsync(string symbol, CancellationToken ct = default) + => _positions.GetOpenPositionAvgEntryPriceAsync(symbol, ct); - var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); - if (result is double d) - return (decimal)d; - return 0; - } + // ─────────────────────────── 账户快照 ─────────────────────────── - private static Position ReadPosition(SqliteDataReader reader) - { - var position = new Position - { - Id = reader.GetString(reader.GetOrdinal("id")), - Symbol = reader.GetString(reader.GetOrdinal("symbol")), - Side = (PositionSide)reader.GetInt32(reader.GetOrdinal("side")), - Quantity = ReadDecimal(reader, reader.GetOrdinal("quantity")), - EntryPrice = ReadDecimal(reader, reader.GetOrdinal("entry_price")), - ClosedQuantity = ReadDecimal(reader, reader.GetOrdinal("closed_quantity")), - OpenedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("opened_at")), CultureInfo.InvariantCulture) - }; + public Task SaveAccountSnapshotAsync(decimal totalValueUsdt, CancellationToken ct = default) + => _accountSnapshots.SaveAccountSnapshotAsync(totalValueUsdt, ct); - var sidOrd = reader.GetOrdinal("strategy_id"); - if (!reader.IsDBNull(sidOrd)) - position.StrategyId = reader.GetString(sidOrd); + public Task GetPeakAccountValueAsync(DateTime? since = null, CancellationToken ct = default) + => _accountSnapshots.GetPeakAccountValueAsync(since, ct); - return position; - } - - #endregion - - #region 风控配置持久化 - - /// - /// 保存每日账户快照(用于计算最大回撤) - /// - public async Task SaveAccountSnapshotAsync(decimal totalValueUsdt, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - var today = GetTodayDateString(); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT INTO account_snapshots (environment, date, total_value_usdt, snapshot_at) - VALUES (@environment, @date, @value, @snapshotAt) - ON CONFLICT(environment, date) DO UPDATE SET - total_value_usdt = @value, - snapshot_at = @snapshotAt - """; - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@date", today); - cmd.Parameters.AddWithValue("@value", ToDb(totalValueUsdt)); - cmd.Parameters.AddWithValue("@snapshotAt", DateTime.UtcNow.ToString("O")); - await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - } - - /// - /// 获取历史最高账户价值(用于计算回撤),支持时间窗口下限。 - /// - /// 仅统计 snapshot_at >= 该时刻的快照;传 null 时统计全部历史。 - /// 传入滚动窗口起点(如 30 天前)后,峰值随窗口滑动自动"重置", - /// 无需额外的重置接口:超出窗口的历史峰值不再参与回撤计算。 - public async Task GetPeakAccountValueAsync(DateTime? since = null, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - // 金额列以 TEXT 存储,MAX 会按字符串比较,需 CAST 为数值后再取最大值; - // snapshot_at 统一为 "O" 格式 UTC 字符串,字典序与时间序一致,可直接比较 - var where = since.HasValue ? " AND snapshot_at >= @since" : ""; - cmd.CommandText = $"SELECT MAX(CAST(total_value_usdt AS REAL)) FROM account_snapshots WHERE environment = @environment{where}"; - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - if (since.HasValue) - cmd.Parameters.AddWithValue("@since", since.Value.ToString("O")); - var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); - if (result is double d) - return (decimal)d; - return 0; - } - - public virtual async Task LoadRiskConfigAsync(CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT config_json FROM risk_config WHERE environment = @environment AND market_type = @marketType"; - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@marketType", (int)MarketType.Crypto); - - var result = await cmd.ExecuteScalarAsync(ct); - if (result is not string json || string.IsNullOrEmpty(json)) - return new RiskConfig(); - try - { - return JsonSerializer.Deserialize(json) ?? new RiskConfig(); - } - catch (JsonException ex) - { - Logger.LogWarning(ex, "风控配置反序列化失败,将使用默认配置"); - return new RiskConfig(); - } - } - - public async Task SaveRiskConfigAsync(RiskConfig config, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT INTO risk_config (environment, market_type, config_json, updated_at) - VALUES (@environment, @marketType, @configJson, @updatedAt) - ON CONFLICT(environment, market_type) DO UPDATE SET config_json = @configJson, updated_at = @updatedAt - """; - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@marketType", (int)MarketType.Crypto); - cmd.Parameters.AddWithValue("@configJson", JsonSerializer.Serialize(config)); - cmd.Parameters.AddWithValue("@updatedAt", DateTime.UtcNow.ToString("O")); - await cmd.ExecuteNonQueryAsync(ct); - } - - #endregion - - #region 内部方法 - - protected override async Task InitializeDatabaseAsync() - { - try - { - await using var conn = await OpenConnectionAsync(); - await MigrateDatabaseSchemaAsync(conn).ConfigureAwait(false); - Logger.LogInformation("交易数据库初始化完成"); - } - catch (Exception ex) - { - Logger.LogError(ex, "初始化交易数据库失败"); - throw new InvalidOperationException("交易数据库初始化失败,应用无法继续运行", ex); - } - } - - /// - /// 在同一事务内完成建表、旧结构迁移和索引创建,保证升级失败时不会留下半迁移状态。 - /// - internal static async Task MigrateDatabaseSchemaAsync(SqliteConnection conn) - { - await using var transaction = (SqliteTransaction)await conn.BeginTransactionAsync().ConfigureAwait(false); - try - { - await CreateTablesAsync(conn, transaction).ConfigureAwait(false); - await EnsureEnvironmentSchemaAsync(conn, transaction).ConfigureAwait(false); - await MigrateMoneyColumnsToTextAsync(conn, transaction).ConfigureAwait(false); - await CreateIndexesAsync(conn, transaction).ConfigureAwait(false); - await transaction.CommitAsync().ConfigureAwait(false); - } - catch - { - await transaction.RollbackAsync().ConfigureAwait(false); - throw; - } - } - - /// - /// 金额/数量列迁移计划:这些列在新 schema 中以 TEXT(十进制字符串)存储。 - /// - private static readonly (string Table, string[] MoneyColumns)[] MoneyColumnMigrations = - [ - ("strategies", ["trigger_price", "stop_loss_price", "take_profit_price", "quantity", "max_position_percent", "trailing_peak_price"]), - ("trade_records", ["requested_qty", "executed_qty", "requested_price", "executed_price", "commission"]), - ("daily_stats", ["total_pnl", "total_commission"]), - ("positions", ["quantity", "entry_price", "closed_quantity"]), - ("account_snapshots", ["total_value_usdt"]) - ]; - - /// 各表建表 DDL(金额/数量列为 TEXT),供新建库与迁移重建共用。 - private static readonly IReadOnlyDictionary TableDefinitions = new Dictionary - { - ["strategies"] = """ - CREATE TABLE IF NOT EXISTS strategies ( - id TEXT PRIMARY KEY, - environment TEXT NOT NULL DEFAULT 'crypto-live-spot', - symbol TEXT NOT NULL, - type INTEGER NOT NULL, - status INTEGER NOT NULL, - side INTEGER NOT NULL, - order_type INTEGER NOT NULL DEFAULT 0, - slippage_tolerance TEXT, - trigger_price TEXT NOT NULL, - stop_loss_price TEXT, - take_profit_price TEXT, - quantity TEXT NOT NULL, - max_position_percent TEXT, - custom_params TEXT, - created_at TEXT NOT NULL, - last_triggered_at TEXT, - execution_count INTEGER DEFAULT 0, - max_executions INTEGER, - trailing_peak_price TEXT - ) - """, - ["trade_records"] = """ - CREATE TABLE IF NOT EXISTS trade_records ( - id TEXT PRIMARY KEY, - environment TEXT NOT NULL DEFAULT 'crypto-live-spot', - strategy_id TEXT NOT NULL, - symbol TEXT NOT NULL, - side INTEGER NOT NULL, - order_type INTEGER NOT NULL, - requested_qty TEXT NOT NULL, - executed_qty TEXT NOT NULL, - requested_price TEXT, - executed_price TEXT NOT NULL, - commission TEXT DEFAULT '0', - commission_asset TEXT, - status INTEGER NOT NULL, - binance_order_id INTEGER, - ai_reasoning TEXT, - created_at TEXT NOT NULL, - completed_at TEXT, - FOREIGN KEY (strategy_id) REFERENCES strategies(id) - ) - """, + // ─────────────────────────── 风控配置 ─────────────────────────── - ["daily_stats"] = """ - CREATE TABLE IF NOT EXISTS daily_stats ( - environment TEXT NOT NULL, - date TEXT NOT NULL, - trade_count INTEGER DEFAULT 0, - total_pnl TEXT DEFAULT '0', - total_commission TEXT DEFAULT '0', - PRIMARY KEY (environment, date) - ) - """, - ["positions"] = """ - CREATE TABLE IF NOT EXISTS positions ( - id TEXT PRIMARY KEY, - environment TEXT NOT NULL DEFAULT 'crypto-live-spot', - symbol TEXT NOT NULL, - side INTEGER NOT NULL, - quantity TEXT NOT NULL, - entry_price TEXT NOT NULL, - closed_quantity TEXT DEFAULT '0', - strategy_id TEXT, - opened_at TEXT NOT NULL - ) - """, - ["account_snapshots"] = """ - CREATE TABLE IF NOT EXISTS account_snapshots ( - environment TEXT NOT NULL, - date TEXT NOT NULL, - total_value_usdt TEXT NOT NULL, - snapshot_at TEXT NOT NULL, - PRIMARY KEY (environment, date) - ) - """, - ["risk_config"] = """ - CREATE TABLE IF NOT EXISTS risk_config ( - environment TEXT NOT NULL, - market_type INTEGER NOT NULL, - config_json TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (environment, market_type) - ) - """ - }; + public virtual Task LoadRiskConfigAsync(CancellationToken ct = default) + => _riskConfig.LoadRiskConfigAsync(ct); - private static async Task CreateTablesAsync(SqliteConnection conn, SqliteTransaction transaction) - { - await using var cmd = conn.CreateCommand(); - cmd.Transaction = transaction; - cmd.CommandText = string.Join(";\n\n", TableDefinitions.Values) + ";"; - await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - } + public Task SaveRiskConfigAsync(RiskConfig config, CancellationToken ct = default) + => _riskConfig.SaveRiskConfigAsync(config, ct); /// - /// 将历史库中金额列由 REAL 迁移为 TEXT(十进制字符串),消除 double 存储精度损失。 - /// SQLite 不支持直接修改列类型,通过"重命名旧表 → 按新 DDL 建表 → CAST 搬运 → 删旧表"重建。 - /// 重命名期间启用 PRAGMA legacy_alter_table = ON,避免其他表的 FOREIGN KEY 引用被改写到旧表名; - /// 旧表索引统一先删除,迁移完成后由 CreateIndexesAsync 重建。 + /// 在同一事务内完成建表、旧结构迁移和索引创建(供单元测试直接驱动 schema 迁移)。 /// - internal static async Task MigrateMoneyColumnsToTextAsync(SqliteConnection conn, SqliteTransaction transaction) - { - // 先探测哪些表需要迁移,避免无谓的索引删除与表重建 - var pendingTables = new List<(string Table, HashSet MoneyColumns, List<(string Name, string Type)> Columns)>(); - foreach (var (table, moneyColumns) in MoneyColumnMigrations) - { - var columns = await GetTableColumnsAsync(conn, transaction, table).ConfigureAwait(false); - if (columns.Count == 0) - continue; - - var moneySet = moneyColumns.ToHashSet(StringComparer.OrdinalIgnoreCase); - var needsMigration = columns.Any(c => moneySet.Contains(c.Name) - && !c.Type.Equals("TEXT", StringComparison.OrdinalIgnoreCase)); - if (needsMigration) - pendingTables.Add((table, moneySet, columns)); - } - - if (pendingTables.Count == 0) - return; - - await ExecuteSchemaCommandAsync(conn, transaction, "PRAGMA legacy_alter_table = ON").ConfigureAwait(false); - - // 旧表重命名后其索引仍占用原名称,必须先删除,否则后续 CREATE INDEX IF NOT EXISTS 会静默跳过 - await ExecuteSchemaCommandAsync(conn, transaction, """ - DROP INDEX IF EXISTS idx_strategies_symbol; - DROP INDEX IF EXISTS idx_strategies_status; - DROP INDEX IF EXISTS idx_strategies_environment_status; - DROP INDEX IF EXISTS idx_records_strategy; - DROP INDEX IF EXISTS idx_records_symbol; - DROP INDEX IF EXISTS idx_records_created; - DROP INDEX IF EXISTS idx_records_environment_created; - DROP INDEX IF EXISTS idx_positions_symbol; - DROP INDEX IF EXISTS idx_positions_side; - DROP INDEX IF EXISTS idx_positions_environment_symbol; - """).ConfigureAwait(false); - - foreach (var (table, moneyColumns, columns) in pendingTables) - { - var legacyTable = $"{table}_money_legacy"; - - await ExecuteSchemaCommandAsync(conn, transaction, $"ALTER TABLE {table} RENAME TO {legacyTable}").ConfigureAwait(false); - await ExecuteSchemaCommandAsync(conn, transaction, TableDefinitions[table] + ";").ConfigureAwait(false); - - // CAST(col AS TEXT) 将历史 REAL 值转为十进制字符串文本 - var columnList = string.Join(", ", columns.Select(c => c.Name)); - var selectList = string.Join(", ", columns.Select(c => moneyColumns.Contains(c.Name) ? $"CAST({c.Name} AS TEXT)" : c.Name)); - await ExecuteSchemaCommandAsync(conn, transaction, $"INSERT INTO {table} ({columnList}) SELECT {selectList} FROM {legacyTable}").ConfigureAwait(false); - await ExecuteSchemaCommandAsync(conn, transaction, $"DROP TABLE {legacyTable}").ConfigureAwait(false); - } - - await ExecuteSchemaCommandAsync(conn, transaction, "PRAGMA legacy_alter_table = OFF").ConfigureAwait(false); - } - - private static async Task ExecuteSchemaCommandAsync(SqliteConnection conn, SqliteTransaction transaction, string commandText) - { - await using var cmd = conn.CreateCommand(); - cmd.Transaction = transaction; - cmd.CommandText = commandText; - await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - } - - private static async Task> GetTableColumnsAsync( - SqliteConnection conn, SqliteTransaction transaction, string tableName) - { - var columns = new List<(string Name, string Type)>(); - await using var cmd = conn.CreateCommand(); - cmd.Transaction = transaction; - cmd.CommandText = $"PRAGMA table_info({tableName})"; - await using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false); - while (await reader.ReadAsync().ConfigureAwait(false)) - { - columns.Add((reader.GetString(1), reader.GetString(2))); - } - return columns; - } - - private static async Task CreateIndexesAsync(SqliteConnection conn, SqliteTransaction transaction) - { - await using var cmd = conn.CreateCommand(); - cmd.Transaction = transaction; - cmd.CommandText = """ - CREATE INDEX IF NOT EXISTS idx_strategies_symbol ON strategies(symbol); - CREATE INDEX IF NOT EXISTS idx_strategies_status ON strategies(status); - CREATE INDEX IF NOT EXISTS idx_strategies_environment_status ON strategies(environment, status, created_at); - CREATE INDEX IF NOT EXISTS idx_records_strategy ON trade_records(strategy_id); - CREATE INDEX IF NOT EXISTS idx_records_symbol ON trade_records(symbol); - CREATE INDEX IF NOT EXISTS idx_records_created ON trade_records(created_at); - CREATE INDEX IF NOT EXISTS idx_records_environment_created ON trade_records(environment, created_at); - CREATE INDEX IF NOT EXISTS idx_positions_symbol ON positions(symbol); - CREATE INDEX IF NOT EXISTS idx_positions_side ON positions(symbol, side); - CREATE INDEX IF NOT EXISTS idx_positions_environment_symbol ON positions(environment, symbol, side); - """; - await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - } - - private static TradingStrategy ReadStrategy(SqliteDataReader reader) - { - var strategy = new TradingStrategy - { - Id = reader.GetString(reader.GetOrdinal("id")), - Symbol = reader.GetString(reader.GetOrdinal("symbol")), - Type = (StrategyType)reader.GetInt32(reader.GetOrdinal("type")), - Status = (StrategyStatus)reader.GetInt32(reader.GetOrdinal("status")), - Side = (OrderSide)reader.GetInt32(reader.GetOrdinal("side")), - OrderType = (OrderType)reader.GetInt32(reader.GetOrdinal("order_type")), - TriggerPrice = ReadDecimal(reader, reader.GetOrdinal("trigger_price")), - Quantity = ReadDecimal(reader, reader.GetOrdinal("quantity")), - CreatedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("created_at")), CultureInfo.InvariantCulture), - ExecutionCount = reader.GetInt32(reader.GetOrdinal("execution_count")) - }; - - var slOrd = reader.GetOrdinal("stop_loss_price"); - if (!reader.IsDBNull(slOrd)) strategy.StopLossPrice = ReadDecimal(reader, slOrd); - - var tpOrd = reader.GetOrdinal("take_profit_price"); - if (!reader.IsDBNull(tpOrd)) strategy.TakeProfitPrice = ReadDecimal(reader, tpOrd); - - var mpOrd = reader.GetOrdinal("max_position_percent"); - if (!reader.IsDBNull(mpOrd)) strategy.MaxPositionPercent = ReadDecimal(reader, mpOrd); - - var slipOrd = reader.GetOrdinal("slippage_tolerance"); - if (!reader.IsDBNull(slipOrd)) - { - // 兼容 TEXT(新格式)与 REAL(历史数据)两种存储形态;显式写入的 0 视为有效配置 - strategy.SlippageTolerance = ReadDecimal(reader, slipOrd); - } - - var cpOrd = reader.GetOrdinal("custom_params"); - if (!reader.IsDBNull(cpOrd)) strategy.CustomParams = reader.GetString(cpOrd); - - var ltOrd = reader.GetOrdinal("last_triggered_at"); - if (!reader.IsDBNull(ltOrd)) strategy.LastTriggeredAt = DateTime.Parse(reader.GetString(ltOrd), CultureInfo.InvariantCulture); - - var meOrd = reader.GetOrdinal("max_executions"); - if (!reader.IsDBNull(meOrd)) strategy.MaxExecutions = reader.GetInt32(meOrd); - - var trailingOrd = reader.GetOrdinal("trailing_peak_price"); - if (!reader.IsDBNull(trailingOrd)) strategy.TrailingPeakPrice = ReadDecimal(reader, trailingOrd); - - return strategy; - } - - private static TradeRecord ReadTradeRecord(SqliteDataReader reader) - { - var record = new TradeRecord - { - Id = reader.GetString(reader.GetOrdinal("id")), - StrategyId = reader.GetString(reader.GetOrdinal("strategy_id")), - Symbol = reader.GetString(reader.GetOrdinal("symbol")), - Side = (OrderSide)reader.GetInt32(reader.GetOrdinal("side")), - OrderType = (OrderType)reader.GetInt32(reader.GetOrdinal("order_type")), - RequestedQty = ReadDecimal(reader, reader.GetOrdinal("requested_qty")), - ExecutedQty = ReadDecimal(reader, reader.GetOrdinal("executed_qty")), - ExecutedPrice = ReadDecimal(reader, reader.GetOrdinal("executed_price")), - Commission = ReadDecimal(reader, reader.GetOrdinal("commission")), - Status = (TradeRecordStatus)reader.GetInt32(reader.GetOrdinal("status")), - ExchangeOrderId = reader.GetInt64(reader.GetOrdinal("binance_order_id")), - CreatedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("created_at")), CultureInfo.InvariantCulture) - }; - - var rpOrd = reader.GetOrdinal("requested_price"); - if (!reader.IsDBNull(rpOrd)) record.RequestedPrice = ReadDecimal(reader, rpOrd); - - var caOrd = reader.GetOrdinal("commission_asset"); - if (!reader.IsDBNull(caOrd)) record.CommissionAsset = reader.GetString(caOrd); - - var arOrd = reader.GetOrdinal("ai_reasoning"); - if (!reader.IsDBNull(arOrd)) record.AIReasoning = reader.GetString(arOrd); - - var coOrd = reader.GetOrdinal("completed_at"); - if (!reader.IsDBNull(coOrd)) record.CompletedAt = DateTime.Parse(reader.GetString(coOrd), CultureInfo.InvariantCulture); - - return record; - } - - private async Task UpdateTradeRecordAsync(TradeRecord record, CancellationToken ct) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - UPDATE trade_records - SET requested_qty = @requestedQty, - executed_qty = @executedQty, - executed_price = @executedPrice, - commission = @commission, - commission_asset = @commissionAsset, - status = @status, - completed_at = @completedAt - WHERE id = @id AND environment = @environment - """; - cmd.Parameters.AddWithValue("@id", record.Id); - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@requestedQty", ToDb(record.RequestedQty)); - cmd.Parameters.AddWithValue("@executedQty", ToDb(record.ExecutedQty)); - cmd.Parameters.AddWithValue("@executedPrice", ToDb(record.ExecutedPrice)); - cmd.Parameters.AddWithValue("@commission", ToDb(record.Commission)); - cmd.Parameters.AddWithValue("@commissionAsset", (object?)record.CommissionAsset ?? DBNull.Value); - cmd.Parameters.AddWithValue("@status", (int)record.Status); - cmd.Parameters.AddWithValue("@completedAt", record.CompletedAt.HasValue ? (object)record.CompletedAt.Value.ToString("O") : DBNull.Value); - await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - } - - private static decimal CalculateEffectiveExecutedPrice(ExchangeOrderResult latestOrder, decimal fallbackPrice) - { - if (latestOrder.ExecutedQty > 0 && latestOrder.CumulativeQuoteQty > 0) - return latestOrder.CumulativeQuoteQty / latestOrder.ExecutedQty; - - if (latestOrder.Price > 0) - return latestOrder.Price; - - return fallbackPrice; - } - - private static TradeRecordStatus MapTradeRecordStatus(string exchangeStatus) => exchangeStatus switch - { - "FILLED" => TradeRecordStatus.Filled, - "PARTIALLY_FILLED" => TradeRecordStatus.PartiallyFilled, - "CANCELED" or "CANCELLED" => TradeRecordStatus.Cancelled, - "REJECTED" or "EXPIRED" => TradeRecordStatus.Failed, - _ => TradeRecordStatus.Pending - }; - - private static bool IsTerminalStatus(TradeRecordStatus status) => status is - TradeRecordStatus.Filled or TradeRecordStatus.Cancelled or TradeRecordStatus.Failed; - - private static async Task EnsureEnvironmentSchemaAsync( - SqliteConnection conn, - SqliteTransaction transaction) - { - await EnsureColumnAsync(conn, transaction, "strategies", "environment", $"TEXT NOT NULL DEFAULT '{LiveSpotEnvironment}'").ConfigureAwait(false); - await EnsureColumnAsync(conn, transaction, "trade_records", "environment", $"TEXT NOT NULL DEFAULT '{LiveSpotEnvironment}'").ConfigureAwait(false); - await EnsureColumnAsync(conn, transaction, "positions", "environment", $"TEXT NOT NULL DEFAULT '{LiveSpotEnvironment}'").ConfigureAwait(false); - // 滑点容忍度以 TEXT(十进制字符串)存储,与金额列存储策略一致; - // 历史库补充列时给出默认值,保证旧行读回为 Market / 0.003 的既有行为 - await EnsureColumnAsync(conn, transaction, "strategies", "order_type", "INTEGER NOT NULL DEFAULT 0").ConfigureAwait(false); - await EnsureColumnAsync(conn, transaction, "strategies", "slippage_tolerance", "TEXT DEFAULT '0.003'").ConfigureAwait(false); - await MigrateDailyStatsAsync(conn, transaction).ConfigureAwait(false); - await MigrateAccountSnapshotsAsync(conn, transaction).ConfigureAwait(false); - await MigrateRiskConfigAsync(conn, transaction).ConfigureAwait(false); - } - - private static async Task EnsureColumnAsync( - SqliteConnection conn, - SqliteTransaction transaction, - string tableName, - string columnName, - string columnDefinition) - { - if (await ColumnExistsAsync(conn, transaction, tableName, columnName).ConfigureAwait(false)) - return; - - await using var cmd = conn.CreateCommand(); - cmd.Transaction = transaction; - cmd.CommandText = $"ALTER TABLE {tableName} ADD COLUMN {columnName} {columnDefinition}"; - await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - } - - private static async Task MigrateDailyStatsAsync( - SqliteConnection conn, - SqliteTransaction transaction) - { - if (await ColumnExistsAsync(conn, transaction, "daily_stats", "environment").ConfigureAwait(false)) - return; - - await using var cmd = conn.CreateCommand(); - cmd.Transaction = transaction; - cmd.CommandText = $""" - ALTER TABLE daily_stats RENAME TO daily_stats_legacy; - - CREATE TABLE daily_stats ( - environment TEXT NOT NULL, - date TEXT NOT NULL, - trade_count INTEGER DEFAULT 0, - total_pnl REAL DEFAULT 0, - total_commission REAL DEFAULT 0, - PRIMARY KEY (environment, date) - ); - - INSERT INTO daily_stats (environment, date, trade_count, total_pnl, total_commission) - SELECT '{LiveSpotEnvironment}', date, trade_count, total_pnl, total_commission - FROM daily_stats_legacy; - - DROP TABLE daily_stats_legacy; - """; - await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - } - - private static async Task MigrateAccountSnapshotsAsync( - SqliteConnection conn, - SqliteTransaction transaction) - { - if (await ColumnExistsAsync(conn, transaction, "account_snapshots", "environment").ConfigureAwait(false)) - return; - - await using var cmd = conn.CreateCommand(); - cmd.Transaction = transaction; - cmd.CommandText = $""" - ALTER TABLE account_snapshots RENAME TO account_snapshots_legacy; - - CREATE TABLE account_snapshots ( - environment TEXT NOT NULL, - date TEXT NOT NULL, - total_value_usdt REAL NOT NULL, - snapshot_at TEXT NOT NULL, - PRIMARY KEY (environment, date) - ); - - INSERT INTO account_snapshots (environment, date, total_value_usdt, snapshot_at) - SELECT '{LiveSpotEnvironment}', date, total_value_usdt, snapshot_at - FROM account_snapshots_legacy; - - DROP TABLE account_snapshots_legacy; - """; - await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - } - - private static async Task MigrateRiskConfigAsync( - SqliteConnection conn, - SqliteTransaction transaction) - { - if (await ColumnExistsAsync(conn, transaction, "risk_config", "environment").ConfigureAwait(false)) - return; - - await using var cmd = conn.CreateCommand(); - cmd.Transaction = transaction; - cmd.CommandText = $""" - ALTER TABLE risk_config RENAME TO risk_config_legacy; - - CREATE TABLE risk_config ( - environment TEXT NOT NULL, - market_type INTEGER NOT NULL, - config_json TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (environment, market_type) - ); - - INSERT INTO risk_config (environment, market_type, config_json, updated_at) - SELECT '{LiveSpotEnvironment}', market_type, config_json, updated_at - FROM risk_config_legacy; - - DROP TABLE risk_config_legacy; - """; - await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - } - - private static async Task ColumnExistsAsync( - SqliteConnection conn, - SqliteTransaction transaction, - string tableName, - string columnName) - { - await using var cmd = conn.CreateCommand(); - cmd.Transaction = transaction; - cmd.CommandText = $"PRAGMA table_info({tableName})"; - await using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false); - while (await reader.ReadAsync().ConfigureAwait(false)) - { - if (reader.GetString(1).Equals(columnName, StringComparison.OrdinalIgnoreCase)) - return true; - } - - return false; - } - - private static async Task> ReadStrategiesAsync(SqliteCommand cmd, CancellationToken ct) - { - var strategies = new List(); - await using var reader = await cmd.ExecuteReaderAsync(ct); - while (await reader.ReadAsync(ct)) - strategies.Add(ReadStrategy(reader)); - return strategies; - } - - #endregion + internal static Task MigrateDatabaseSchemaAsync(SqliteConnection conn) + => TradingSchemaInitializer.MigrateDatabaseSchemaAsync(conn); } diff --git a/src/MarketAssistant.App.Services/Trading/TradingEnvironmentKeys.cs b/src/MarketAssistant.App.Services/Trading/TradingEnvironmentKeys.cs new file mode 100644 index 0000000..3e0c7d0 --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/TradingEnvironmentKeys.cs @@ -0,0 +1,14 @@ +namespace MarketAssistant.Services.Trading; + +/// +/// 交易环境的持久化 key(internal,仅供本程序集交易持久化管线使用)。 +/// 4 种交易模式各自独立的环境 key,确保现货实盘、现货 Demo、合约实盘、合约 Testnet +/// 的策略、交易记录、持仓、风控配置互不混淆。 +/// +internal static class TradingEnvironmentKeys +{ + internal const string LiveSpot = "crypto-live-spot"; + internal const string LiveFutures = "crypto-live-futures"; + internal const string FuturesTestnet = "crypto-binance-futures-testnet"; + internal const string SpotDemo = "crypto-binance-spot-demo"; +} diff --git a/src/MarketAssistant.App.Services/Trading/TradingRepositoryBase.cs b/src/MarketAssistant.App.Services/Trading/TradingRepositoryBase.cs new file mode 100644 index 0000000..145a4ed --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/TradingRepositoryBase.cs @@ -0,0 +1,83 @@ +using System.Globalization; +using MarketAssistant.Trading.Models; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Services.Trading; + +/// +/// 交易仓储基类(internal):封装环境 key 解析、decimal 存取辅助、连接获取与延迟初始化。 +/// 各仓储继承本类,仅关注自身的 CRUD SQL;schema 初始化统一由 负责。 +/// +internal abstract class TradingRepositoryBase +{ + private readonly TradingSchemaInitializer _schema; + private readonly TradingEnvironmentService _environment; + + protected ILogger Logger { get; } + + protected TradingRepositoryBase( + TradingSchemaInitializer schema, + TradingEnvironmentService environment, + ILogger logger) + { + _schema = schema; + _environment = environment; + Logger = logger; + } + + /// + /// 4 种交易模式各自独立的环境 key,确保现货实盘、现货 Demo、合约实盘、合约 Testnet + /// 的策略、交易记录、持仓、风控配置互不混淆。 + /// + protected string CurrentEnvironmentKey => _environment.CurrentMode switch + { + CryptoTradingMode.LiveFutures => TradingEnvironmentKeys.LiveFutures, + CryptoTradingMode.BinanceFuturesTestnet => TradingEnvironmentKeys.FuturesTestnet, + CryptoTradingMode.BinanceSpotDemo => TradingEnvironmentKeys.SpotDemo, + _ => TradingEnvironmentKeys.LiveSpot + }; + + /// + /// 当前是否为合约模式(合约买卖方向需结合持仓判断开平仓) + /// + protected bool IsFuturesMode => _environment.CurrentMode is + CryptoTradingMode.LiveFutures or CryptoTradingMode.BinanceFuturesTestnet; + + protected Task EnsureInitializedAsync() => _schema.EnsureSchemaInitializedAsync(); + + protected Task OpenConnectionAsync(CancellationToken cancellationToken = default) + => _schema.CreateConnectionAsync(cancellationToken); + + /// + /// 金额/数量列以 TEXT(InvariantCulture 十进制字符串)存储, + /// 避免 REAL(double)存储对高精度小数(如 8 位小数价格)造成精度损失。 + /// + protected static object ToDb(decimal value) => value.ToString(CultureInfo.InvariantCulture); + + /// + protected static object ToDbNullable(decimal? value) => value.HasValue ? ToDb(value.Value) : DBNull.Value; + + /// + /// 读取金额/数量列,兼容 TEXT(新格式)与 REAL/INTEGER(历史数据)两种存储形态。 + /// + protected static decimal ReadDecimal(SqliteDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + return 0; + + return reader.GetValue(ordinal) switch + { + string s when decimal.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) => parsed, + double d => (decimal)d, + long l => l, + _ => 0 + }; + } + + /// + /// 获取今日日期字符串,用于日统计与账户快照的日期分组键。 + /// 刻意使用本地时间(DateTime.Now)而非 UTC:交易日的切分以用户所在时区为准。 + /// + protected static string GetTodayDateString() => DateTime.Now.ToString("yyyy-MM-dd"); +} diff --git a/src/MarketAssistant.App.Services/Trading/TradingSchemaInitializer.cs b/src/MarketAssistant.App.Services/Trading/TradingSchemaInitializer.cs new file mode 100644 index 0000000..7649325 --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/TradingSchemaInitializer.cs @@ -0,0 +1,413 @@ +using MarketAssistant.Infrastructure.Core; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Services.Trading; + +/// +/// 交易数据库 schema 初始化与迁移(internal,单一职责:建表 + 历史结构迁移 + 索引创建)。 +/// 由 组合,各仓储通过 复用其连接与初始化能力。 +/// +internal sealed class TradingSchemaInitializer : SqliteServiceBase +{ + public TradingSchemaInitializer(ILogger logger) : base(logger) + { + } + + /// 各表建表 DDL(金额/数量列为 TEXT),供新建库与迁移重建共用。 + private static readonly IReadOnlyDictionary TableDefinitions = new Dictionary + { + ["strategies"] = """ + CREATE TABLE IF NOT EXISTS strategies ( + id TEXT PRIMARY KEY, + environment TEXT NOT NULL DEFAULT 'crypto-live-spot', + symbol TEXT NOT NULL, + type INTEGER NOT NULL, + status INTEGER NOT NULL, + side INTEGER NOT NULL, + order_type INTEGER NOT NULL DEFAULT 0, + slippage_tolerance TEXT, + trigger_price TEXT NOT NULL, + stop_loss_price TEXT, + take_profit_price TEXT, + quantity TEXT NOT NULL, + max_position_percent TEXT, + custom_params TEXT, + created_at TEXT NOT NULL, + last_triggered_at TEXT, + execution_count INTEGER DEFAULT 0, + max_executions INTEGER, + trailing_peak_price TEXT + ) + """, + ["trade_records"] = """ + CREATE TABLE IF NOT EXISTS trade_records ( + id TEXT PRIMARY KEY, + environment TEXT NOT NULL DEFAULT 'crypto-live-spot', + strategy_id TEXT NOT NULL, + symbol TEXT NOT NULL, + side INTEGER NOT NULL, + order_type INTEGER NOT NULL, + requested_qty TEXT NOT NULL, + executed_qty TEXT NOT NULL, + requested_price TEXT, + executed_price TEXT NOT NULL, + commission TEXT DEFAULT '0', + commission_asset TEXT, + status INTEGER NOT NULL, + binance_order_id INTEGER, + ai_reasoning TEXT, + created_at TEXT NOT NULL, + completed_at TEXT, + FOREIGN KEY (strategy_id) REFERENCES strategies(id) + ) + """, + + ["daily_stats"] = """ + CREATE TABLE IF NOT EXISTS daily_stats ( + environment TEXT NOT NULL, + date TEXT NOT NULL, + trade_count INTEGER DEFAULT 0, + total_pnl TEXT DEFAULT '0', + total_commission TEXT DEFAULT '0', + PRIMARY KEY (environment, date) + ) + """, + ["positions"] = """ + CREATE TABLE IF NOT EXISTS positions ( + id TEXT PRIMARY KEY, + environment TEXT NOT NULL DEFAULT 'crypto-live-spot', + symbol TEXT NOT NULL, + side INTEGER NOT NULL, + quantity TEXT NOT NULL, + entry_price TEXT NOT NULL, + closed_quantity TEXT DEFAULT '0', + strategy_id TEXT, + opened_at TEXT NOT NULL + ) + """, + ["account_snapshots"] = """ + CREATE TABLE IF NOT EXISTS account_snapshots ( + environment TEXT NOT NULL, + date TEXT NOT NULL, + total_value_usdt TEXT NOT NULL, + snapshot_at TEXT NOT NULL, + PRIMARY KEY (environment, date) + ) + """, + ["risk_config"] = """ + CREATE TABLE IF NOT EXISTS risk_config ( + environment TEXT NOT NULL, + market_type INTEGER NOT NULL, + config_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (environment, market_type) + ) + """ + }; + + /// 金额/数量列迁移计划:这些列在新 schema 中以 TEXT(十进制字符串)存储。 + private static readonly (string Table, string[] MoneyColumns)[] MoneyColumnMigrations = + [ + ("strategies", ["trigger_price", "stop_loss_price", "take_profit_price", "quantity", "max_position_percent", "trailing_peak_price"]), + ("trade_records", ["requested_qty", "executed_qty", "requested_price", "executed_price", "commission"]), + ("daily_stats", ["total_pnl", "total_commission"]), + ("positions", ["quantity", "entry_price", "closed_quantity"]), + ("account_snapshots", ["total_value_usdt"]) + ]; + + /// + /// 确保交易数据库 schema 已初始化(幂等,延迟执行,失败可重试)。 + /// + public Task EnsureSchemaInitializedAsync() => EnsureInitializedAsync(InitializeDatabaseAsync); + + /// + /// 打开一个新的 SQLite 连接(WAL + busy_timeout),供各仓储使用。 + /// + public Task CreateConnectionAsync(CancellationToken cancellationToken = default) + => OpenConnectionAsync(cancellationToken); + + protected override async Task InitializeDatabaseAsync() + { + try + { + await using var conn = await OpenConnectionAsync(); + await MigrateDatabaseSchemaAsync(conn).ConfigureAwait(false); + Logger.LogInformation("交易数据库初始化完成"); + } + catch (Exception ex) + { + Logger.LogError(ex, "初始化交易数据库失败"); + throw new InvalidOperationException("交易数据库初始化失败,应用无法继续运行", ex); + } + } + + /// + /// 在同一事务内完成建表、旧结构迁移和索引创建,保证升级失败时不会留下半迁移状态。 + /// + internal static async Task MigrateDatabaseSchemaAsync(SqliteConnection conn) + { + await using var transaction = (SqliteTransaction)await conn.BeginTransactionAsync().ConfigureAwait(false); + try + { + await CreateTablesAsync(conn, transaction).ConfigureAwait(false); + await EnsureEnvironmentSchemaAsync(conn, transaction).ConfigureAwait(false); + await MigrateMoneyColumnsToTextAsync(conn, transaction).ConfigureAwait(false); + await CreateIndexesAsync(conn, transaction).ConfigureAwait(false); + await transaction.CommitAsync().ConfigureAwait(false); + } + catch + { + await transaction.RollbackAsync().ConfigureAwait(false); + throw; + } + } + + private static async Task CreateTablesAsync(SqliteConnection conn, SqliteTransaction transaction) + { + await using var cmd = conn.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = string.Join(";\n\n", TableDefinitions.Values) + ";"; + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + /// + /// 将历史库中金额列由 REAL 迁移为 TEXT(十进制字符串),消除 double 存储精度损失。 + /// SQLite 不支持直接修改列类型,通过"重命名旧表 → 按新 DDL 建表 → CAST 搬运 → 删旧表"重建。 + /// 重命名期间启用 PRAGMA legacy_alter_table = ON,避免其他表的 FOREIGN KEY 引用被改写到旧表名; + /// 旧表索引统一先删除,迁移完成后由 CreateIndexesAsync 重建。 + /// + private static async Task MigrateMoneyColumnsToTextAsync(SqliteConnection conn, SqliteTransaction transaction) + { + // 先探测哪些表需要迁移,避免无谓的索引删除与表重建 + var pendingTables = new List<(string Table, HashSet MoneyColumns, List<(string Name, string Type)> Columns)>(); + foreach (var (table, moneyColumns) in MoneyColumnMigrations) + { + var columns = await GetTableColumnsAsync(conn, transaction, table).ConfigureAwait(false); + if (columns.Count == 0) + continue; + + var moneySet = moneyColumns.ToHashSet(StringComparer.OrdinalIgnoreCase); + var needsMigration = columns.Any(c => moneySet.Contains(c.Name) + && !c.Type.Equals("TEXT", StringComparison.OrdinalIgnoreCase)); + if (needsMigration) + pendingTables.Add((table, moneySet, columns)); + } + + if (pendingTables.Count == 0) + return; + + await ExecuteSchemaCommandAsync(conn, transaction, "PRAGMA legacy_alter_table = ON").ConfigureAwait(false); + + // 旧表重命名后其索引仍占用原名称,必须先删除,否则后续 CREATE INDEX IF NOT EXISTS 会静默跳过 + await ExecuteSchemaCommandAsync(conn, transaction, """ + DROP INDEX IF EXISTS idx_strategies_symbol; + DROP INDEX IF EXISTS idx_strategies_status; + DROP INDEX IF EXISTS idx_strategies_environment_status; + DROP INDEX IF EXISTS idx_records_strategy; + DROP INDEX IF EXISTS idx_records_symbol; + DROP INDEX IF EXISTS idx_records_created; + DROP INDEX IF EXISTS idx_records_environment_created; + DROP INDEX IF EXISTS idx_positions_symbol; + DROP INDEX IF EXISTS idx_positions_side; + DROP INDEX IF EXISTS idx_positions_environment_symbol; + """).ConfigureAwait(false); + + foreach (var (table, moneyColumns, columns) in pendingTables) + { + var legacyTable = $"{table}_money_legacy"; + + await ExecuteSchemaCommandAsync(conn, transaction, $"ALTER TABLE {table} RENAME TO {legacyTable}").ConfigureAwait(false); + await ExecuteSchemaCommandAsync(conn, transaction, TableDefinitions[table] + ";").ConfigureAwait(false); + + // CAST(col AS TEXT) 将历史 REAL 值转为十进制字符串文本 + var columnList = string.Join(", ", columns.Select(c => c.Name)); + var selectList = string.Join(", ", columns.Select(c => moneyColumns.Contains(c.Name) ? $"CAST({c.Name} AS TEXT)" : c.Name)); + await ExecuteSchemaCommandAsync(conn, transaction, $"INSERT INTO {table} ({columnList}) SELECT {selectList} FROM {legacyTable}").ConfigureAwait(false); + await ExecuteSchemaCommandAsync(conn, transaction, $"DROP TABLE {legacyTable}").ConfigureAwait(false); + } + + await ExecuteSchemaCommandAsync(conn, transaction, "PRAGMA legacy_alter_table = OFF").ConfigureAwait(false); + } + + private static async Task ExecuteSchemaCommandAsync(SqliteConnection conn, SqliteTransaction transaction, string commandText) + { + await using var cmd = conn.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = commandText; + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + private static async Task> GetTableColumnsAsync( + SqliteConnection conn, SqliteTransaction transaction, string tableName) + { + var columns = new List<(string Name, string Type)>(); + await using var cmd = conn.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = $"PRAGMA table_info({tableName})"; + await using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false); + while (await reader.ReadAsync().ConfigureAwait(false)) + { + columns.Add((reader.GetString(1), reader.GetString(2))); + } + return columns; + } + + private static async Task CreateIndexesAsync(SqliteConnection conn, SqliteTransaction transaction) + { + await using var cmd = conn.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = """ + CREATE INDEX IF NOT EXISTS idx_strategies_symbol ON strategies(symbol); + CREATE INDEX IF NOT EXISTS idx_strategies_status ON strategies(status); + CREATE INDEX IF NOT EXISTS idx_strategies_environment_status ON strategies(environment, status, created_at); + CREATE INDEX IF NOT EXISTS idx_records_strategy ON trade_records(strategy_id); + CREATE INDEX IF NOT EXISTS idx_records_symbol ON trade_records(symbol); + CREATE INDEX IF NOT EXISTS idx_records_created ON trade_records(created_at); + CREATE INDEX IF NOT EXISTS idx_records_environment_created ON trade_records(environment, created_at); + CREATE INDEX IF NOT EXISTS idx_positions_symbol ON positions(symbol); + CREATE INDEX IF NOT EXISTS idx_positions_side ON positions(symbol, side); + CREATE INDEX IF NOT EXISTS idx_positions_environment_symbol ON positions(environment, symbol, side); + """; + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + private static async Task EnsureEnvironmentSchemaAsync( + SqliteConnection conn, + SqliteTransaction transaction) + { + await EnsureColumnAsync(conn, transaction, "strategies", "environment", $"TEXT NOT NULL DEFAULT '{TradingEnvironmentKeys.LiveSpot}'").ConfigureAwait(false); + await EnsureColumnAsync(conn, transaction, "trade_records", "environment", $"TEXT NOT NULL DEFAULT '{TradingEnvironmentKeys.LiveSpot}'").ConfigureAwait(false); + await EnsureColumnAsync(conn, transaction, "positions", "environment", $"TEXT NOT NULL DEFAULT '{TradingEnvironmentKeys.LiveSpot}'").ConfigureAwait(false); + // 滑点容忍度以 TEXT(十进制字符串)存储,与金额列存储策略一致; + // 历史库补充列时给出默认值,保证旧行读回为 Market / 0.003 的既有行为 + await EnsureColumnAsync(conn, transaction, "strategies", "order_type", "INTEGER NOT NULL DEFAULT 0").ConfigureAwait(false); + await EnsureColumnAsync(conn, transaction, "strategies", "slippage_tolerance", "TEXT DEFAULT '0.003'").ConfigureAwait(false); + await MigrateDailyStatsAsync(conn, transaction).ConfigureAwait(false); + await MigrateAccountSnapshotsAsync(conn, transaction).ConfigureAwait(false); + await MigrateRiskConfigAsync(conn, transaction).ConfigureAwait(false); + } + + private static async Task EnsureColumnAsync( + SqliteConnection conn, + SqliteTransaction transaction, + string tableName, + string columnName, + string columnDefinition) + { + if (await ColumnExistsAsync(conn, transaction, tableName, columnName).ConfigureAwait(false)) + return; + + await using var cmd = conn.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = $"ALTER TABLE {tableName} ADD COLUMN {columnName} {columnDefinition}"; + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + private static async Task MigrateDailyStatsAsync( + SqliteConnection conn, + SqliteTransaction transaction) + { + if (await ColumnExistsAsync(conn, transaction, "daily_stats", "environment").ConfigureAwait(false)) + return; + + await using var cmd = conn.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = $""" + ALTER TABLE daily_stats RENAME TO daily_stats_legacy; + + CREATE TABLE daily_stats ( + environment TEXT NOT NULL, + date TEXT NOT NULL, + trade_count INTEGER DEFAULT 0, + total_pnl REAL DEFAULT 0, + total_commission REAL DEFAULT 0, + PRIMARY KEY (environment, date) + ); + + INSERT INTO daily_stats (environment, date, trade_count, total_pnl, total_commission) + SELECT '{TradingEnvironmentKeys.LiveSpot}', date, trade_count, total_pnl, total_commission + FROM daily_stats_legacy; + + DROP TABLE daily_stats_legacy; + """; + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + private static async Task MigrateAccountSnapshotsAsync( + SqliteConnection conn, + SqliteTransaction transaction) + { + if (await ColumnExistsAsync(conn, transaction, "account_snapshots", "environment").ConfigureAwait(false)) + return; + + await using var cmd = conn.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = $""" + ALTER TABLE account_snapshots RENAME TO account_snapshots_legacy; + + CREATE TABLE account_snapshots ( + environment TEXT NOT NULL, + date TEXT NOT NULL, + total_value_usdt REAL NOT NULL, + snapshot_at TEXT NOT NULL, + PRIMARY KEY (environment, date) + ); + + INSERT INTO account_snapshots (environment, date, total_value_usdt, snapshot_at) + SELECT '{TradingEnvironmentKeys.LiveSpot}', date, total_value_usdt, snapshot_at + FROM account_snapshots_legacy; + + DROP TABLE account_snapshots_legacy; + """; + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + private static async Task MigrateRiskConfigAsync( + SqliteConnection conn, + SqliteTransaction transaction) + { + if (await ColumnExistsAsync(conn, transaction, "risk_config", "environment").ConfigureAwait(false)) + return; + + await using var cmd = conn.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = $""" + ALTER TABLE risk_config RENAME TO risk_config_legacy; + + CREATE TABLE risk_config ( + environment TEXT NOT NULL, + market_type INTEGER NOT NULL, + config_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (environment, market_type) + ); + + INSERT INTO risk_config (environment, market_type, config_json, updated_at) + SELECT '{TradingEnvironmentKeys.LiveSpot}', market_type, config_json, updated_at + FROM risk_config_legacy; + + DROP TABLE risk_config_legacy; + """; + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + private static async Task ColumnExistsAsync( + SqliteConnection conn, + SqliteTransaction transaction, + string tableName, + string columnName) + { + await using var cmd = conn.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = $"PRAGMA table_info({tableName})"; + await using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false); + while (await reader.ReadAsync().ConfigureAwait(false)) + { + if (reader.GetString(1).Equals(columnName, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } +} diff --git a/src/MarketAssistant.App.Services/Trading/TradingStrategyRepository.cs b/src/MarketAssistant.App.Services/Trading/TradingStrategyRepository.cs new file mode 100644 index 0000000..89beca5 --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/TradingStrategyRepository.cs @@ -0,0 +1,284 @@ +using System.Globalization; +using MarketAssistant.Trading.Models; +using Microsoft.Data.Sqlite; + +namespace MarketAssistant.Services.Trading; + +/// +/// 策略 CRUD 仓储(internal):策略表的持久化与查询。 +/// +internal sealed class TradingStrategyRepository : TradingRepositoryBase +{ + public TradingStrategyRepository( + TradingSchemaInitializer schema, + TradingEnvironmentService environment, + Microsoft.Extensions.Logging.ILogger logger) + : base(schema, environment, logger) + { + } + + public async Task SaveStrategyAsync(TradingStrategy strategy, CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT OR REPLACE INTO strategies + (id, environment, symbol, type, status, side, order_type, slippage_tolerance, + trigger_price, stop_loss_price, take_profit_price, + quantity, max_position_percent, custom_params, created_at, last_triggered_at, + execution_count, max_executions, trailing_peak_price) + VALUES + (@id, @environment, @symbol, @type, @status, @side, @orderType, @slippage, + @triggerPrice, @slPrice, @tpPrice, + @qty, @maxPos, @customParams, @createdAt, @lastTriggered, + @execCount, @maxExec, @trailingPeak) + """; + cmd.Parameters.AddWithValue("@id", strategy.Id); + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@symbol", strategy.Symbol); + cmd.Parameters.AddWithValue("@type", (int)strategy.Type); + cmd.Parameters.AddWithValue("@status", (int)strategy.Status); + cmd.Parameters.AddWithValue("@side", (int)strategy.Side); + cmd.Parameters.AddWithValue("@orderType", (int)strategy.OrderType); + cmd.Parameters.AddWithValue("@slippage", ToDb(strategy.SlippageTolerance)); + cmd.Parameters.AddWithValue("@triggerPrice", ToDb(strategy.TriggerPrice)); + cmd.Parameters.AddWithValue("@slPrice", ToDbNullable(strategy.StopLossPrice)); + cmd.Parameters.AddWithValue("@tpPrice", ToDbNullable(strategy.TakeProfitPrice)); + cmd.Parameters.AddWithValue("@qty", ToDb(strategy.Quantity)); + cmd.Parameters.AddWithValue("@maxPos", ToDbNullable(strategy.MaxPositionPercent)); + cmd.Parameters.AddWithValue("@customParams", (object?)strategy.CustomParams ?? DBNull.Value); + cmd.Parameters.AddWithValue("@createdAt", strategy.CreatedAt.ToString("O")); + 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); + cmd.Parameters.AddWithValue("@trailingPeak", ToDbNullable(strategy.TrailingPeakPrice)); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + public async Task GetStrategyAsync(string id, CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT * FROM strategies WHERE id = @id AND environment = @environment"; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + await using var reader = await cmd.ExecuteReaderAsync(ct); + return await reader.ReadAsync(ct) ? ReadStrategy(reader) : null; + } + + public async Task> GetStrategiesByStatusAsync(StrategyStatus status, CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT * FROM strategies WHERE environment = @environment AND status = @status ORDER BY created_at DESC"; + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@status", (int)status); + return await ReadStrategiesAsync(cmd, ct); + } + + public async Task> GetAllStrategiesAsync(CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT * FROM strategies WHERE environment = @environment ORDER BY created_at DESC"; + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + return await ReadStrategiesAsync(cmd, ct); + } + + public async Task UpdateStrategyStatusAsync(string id, StrategyStatus status, CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE strategies SET status = @status WHERE id = @id AND environment = @environment"; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@status", (int)status); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + public async Task DeleteStrategyAsync(string id, CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM strategies WHERE id = @id AND environment = @environment"; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + public async Task UpdateStrategyTriggeredAsync(string id, CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE strategies + SET last_triggered_at = @time, execution_count = execution_count + 1 + WHERE id = @id AND environment = @environment + """; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@time", DateTime.UtcNow.ToString("O")); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + /// + /// 仅更新策略的最后评估时间(不增加执行计数),用于 AI 信号策略的评估节流。 + /// + public async Task UpdateStrategyLastTriggeredAtAsync(string id, CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE strategies + SET last_triggered_at = @time + WHERE id = @id AND environment = @environment + """; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@time", DateTime.UtcNow.ToString("O")); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + /// + /// 原子地更新策略触发计数和自定义参数(用于网格交易,防止计数已更新但交易未执行的状态不一致) + /// + public async Task UpdateStrategyTriggeredWithParamsAsync(string id, string? customParams, CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var tx = await conn.BeginTransactionAsync(ct); + try + { + await using var cmd = conn.CreateCommand(); + cmd.Transaction = (SqliteTransaction)tx; + cmd.CommandText = """ + UPDATE strategies + SET last_triggered_at = @time, + execution_count = execution_count + 1, + custom_params = @customParams + WHERE id = @id AND environment = @environment + """; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + 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 EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE strategies SET custom_params = @customParams WHERE id = @id AND environment = @environment"; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@customParams", (object?)customParams ?? DBNull.Value); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + /// + /// 更新策略护栏位(止损/止盈价)。 + /// + public async Task UpdateStrategyGuardrailsAsync( + string id, decimal? stopLossPrice, decimal? takeProfitPrice, CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE strategies SET stop_loss_price = @stopLoss, take_profit_price = @takeProfit + WHERE id = @id AND environment = @environment + """; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@stopLoss", ToDbNullable(stopLossPrice)); + cmd.Parameters.AddWithValue("@takeProfit", ToDbNullable(takeProfitPrice)); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + /// + /// 更新追踪止损的峰值/谷值价格(持久化,防止重启丢失) + /// + public async Task UpdateStrategyTrailingPeakAsync(string id, decimal? trailingPeakPrice, CancellationToken ct = default) + { + await EnsureInitializedAsync(); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE strategies SET trailing_peak_price = @peak WHERE id = @id AND environment = @environment"; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@peak", ToDbNullable(trailingPeakPrice)); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + private static TradingStrategy ReadStrategy(SqliteDataReader reader) + { + var strategy = new TradingStrategy + { + Id = reader.GetString(reader.GetOrdinal("id")), + Symbol = reader.GetString(reader.GetOrdinal("symbol")), + Type = (StrategyType)reader.GetInt32(reader.GetOrdinal("type")), + Status = (StrategyStatus)reader.GetInt32(reader.GetOrdinal("status")), + Side = (OrderSide)reader.GetInt32(reader.GetOrdinal("side")), + OrderType = (OrderType)reader.GetInt32(reader.GetOrdinal("order_type")), + TriggerPrice = ReadDecimal(reader, reader.GetOrdinal("trigger_price")), + Quantity = ReadDecimal(reader, reader.GetOrdinal("quantity")), + CreatedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("created_at")), CultureInfo.InvariantCulture), + ExecutionCount = reader.GetInt32(reader.GetOrdinal("execution_count")) + }; + + var slOrd = reader.GetOrdinal("stop_loss_price"); + if (!reader.IsDBNull(slOrd)) strategy.StopLossPrice = ReadDecimal(reader, slOrd); + + var tpOrd = reader.GetOrdinal("take_profit_price"); + if (!reader.IsDBNull(tpOrd)) strategy.TakeProfitPrice = ReadDecimal(reader, tpOrd); + + var mpOrd = reader.GetOrdinal("max_position_percent"); + if (!reader.IsDBNull(mpOrd)) strategy.MaxPositionPercent = ReadDecimal(reader, mpOrd); + + var slipOrd = reader.GetOrdinal("slippage_tolerance"); + if (!reader.IsDBNull(slipOrd)) + { + strategy.SlippageTolerance = ReadDecimal(reader, slipOrd); + } + + var cpOrd = reader.GetOrdinal("custom_params"); + if (!reader.IsDBNull(cpOrd)) strategy.CustomParams = reader.GetString(cpOrd); + + var ltOrd = reader.GetOrdinal("last_triggered_at"); + if (!reader.IsDBNull(ltOrd)) strategy.LastTriggeredAt = DateTime.Parse(reader.GetString(ltOrd), CultureInfo.InvariantCulture); + + var meOrd = reader.GetOrdinal("max_executions"); + if (!reader.IsDBNull(meOrd)) strategy.MaxExecutions = reader.GetInt32(meOrd); + + var trailingOrd = reader.GetOrdinal("trailing_peak_price"); + if (!reader.IsDBNull(trailingOrd)) strategy.TrailingPeakPrice = ReadDecimal(reader, trailingOrd); + + return strategy; + } + + private static async Task> ReadStrategiesAsync(SqliteCommand cmd, CancellationToken ct) + { + var strategies = new List(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + strategies.Add(ReadStrategy(reader)); + return strategies; + } +} diff --git a/src/MarketAssistant.App/AGENTS.md b/src/MarketAssistant.App/AGENTS.md index 7643feb..e54c214 100644 --- a/src/MarketAssistant.App/AGENTS.md +++ b/src/MarketAssistant.App/AGENTS.md @@ -66,6 +66,7 @@ MarketAssistant.App/ - `MarketAssistant.App` 不再承载 Agent Tool、Workflow、RAG、交易引擎等运行时代码。 - 新增 Agent/Tool/Workflow/业务服务时,放到 `MarketAssistant.App.Services`;新增文档解析、向量化、检索、重排等 RAG 基础能力时,放到 `MarketAssistant.Rag`;本项目只保留 UI 相关适配。 +- ViewModel 禁止直接注入 `MarketAssistant.DataProviders` 的具体实现类(如 `BinanceMarketDataService`、`BinanceWebSocketService`);行情/实时数据应经 `MarketAssistant.App.Services` 编排层暴露的抽象访问,避免多市场抽象泄漏到 UI 层。 - `skills/` 作为内容文件随 App 输出,由运行时从输出目录加载;不要在 UI 层复制第二套 Skill 加载逻辑。 - 版本号定义在 `.csproj` 的 `` 属性中,运行时通过 `AppInfo.Version` 获取。 diff --git a/src/MarketAssistant.App/Services/ServiceCollectionExtensions.cs b/src/MarketAssistant.App/Services/ServiceCollectionExtensions.cs index 5b1613f..fe6f2a8 100644 --- a/src/MarketAssistant.App/Services/ServiceCollectionExtensions.cs +++ b/src/MarketAssistant.App/Services/ServiceCollectionExtensions.cs @@ -52,9 +52,6 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection // 使 HITL 确认不依赖交易监控页存活(单例构造即接管订阅) services.AddSingleton(); - // 市场服务注册表:ViewModel 通过具名门面按市场解析 Keyed Service,避免 Func 委托堆叠与服务定位反模式 - services.AddSingleton(); - return services; } diff --git a/src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs b/src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs index 5dc7669..57511ce 100644 --- a/src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs @@ -7,12 +7,10 @@ using MarketAssistant.Applications.Assets; using MarketAssistant.Infrastructure; using MarketAssistant.Infrastructure.Core; -using MarketAssistant.DataProviders; using MarketAssistant.Services.Market; using MarketAssistant.Services.Navigation; using Microsoft.Extensions.Logging; using System.Collections.ObjectModel; -using static MarketAssistant.Infrastructure.Core.CryptoSymbolConverter; namespace MarketAssistant.ViewModels; @@ -21,11 +19,12 @@ public partial class AssetPageViewModel : ViewModelBase, INavigationAware "资产详情"; - private readonly IMarketServiceRegistry _marketServiceRegistry; private readonly MarketContext _marketContext; - private readonly BinanceWebSocketService _wsService; private CancellationTokenSource? _loadingCancellationTokenSource; + /// 当前绑定事件的实时行情服务。导航参数携带的市场与当前绑定不一致时重新绑定。 + private IRealtimeQuoteService? _quoteService; + [ObservableProperty] private KLineType _currentKLineType = KLineType.Daily; @@ -73,13 +72,9 @@ public partial class AssetPageViewModel : ViewModelBase, INavigationAware logger, - IMarketServiceRegistry marketServiceRegistry, - MarketContext marketContext, - BinanceWebSocketService wsService) : base(logger) + MarketContext marketContext) : base(logger) { - _marketServiceRegistry = marketServiceRegistry ?? throw new ArgumentNullException(nameof(marketServiceRegistry)); _marketContext = marketContext; - _wsService = wsService; ChangeKLineTypeCommand = new RelayCommand(ChangeKLineTypeAsync); NavigateToAnalysisCommand = new RelayCommand(NavigateToAnalysisAsync); @@ -152,7 +147,7 @@ private async Task LoadAssetDataAsync(string assetCode) try { - var klineService = _marketServiceRegistry.GetKLineService(_marketContext.CurrentMarket); + var klineService = _marketContext.GetService(); // IKLineService.GetKLineDataAsync 暂不支持 CancellationToken, // 仅能通过取消令牌在返回后丢弃过期结果 var kLineDataList = await klineService.GetKLineDataAsync(assetCode, CurrentKLineType); @@ -238,23 +233,52 @@ public void OnNavigatedTo(AssetNavigationParameter parameter, bool isReactivatio { _ = LoadAssetDataAsync(parameter.Code); - // 3. 虚拟币市场订阅 WebSocket 实时价格 + // 3. 支持实时推送的市场订阅实时价格 // 优先使用参数携带的 MarketType,避免导航期间切换市场导致的竞态 var effectiveMarket = parameter.MarketType ?? _marketContext.CurrentMarket; - if (effectiveMarket == MarketType.Crypto) + if (_marketContext.GetService(effectiveMarket).SupportsRealtime) + { + BindRealtimeQuoteService(_marketContext.GetService(effectiveMarket)); + // 订阅前先整体替换,防止重复 + _ = _quoteService!.SubscribeAsync(RealtimeQuoteSubscriberKeys.AssetDetail, [parameter.Code]); + } + else { - // 订阅前先取消订阅,防止重复 - _wsService.PriceUpdated -= OnDetailPriceUpdated; - _wsService.PriceUpdated += OnDetailPriceUpdated; - _ = _wsService.SubscribeAsync(WebSocketSubscriberKeys.AssetDetail, [ToBinanceFormat(parameter.Code)]); + DetachRealtimeQuoteService(); } } } } - private void OnDetailPriceUpdated(string symbol, decimal lastPrice, decimal changePercent) + /// + /// 绑定实时行情服务事件。导航到不同市场的资产时,先解除旧服务的事件与订阅再绑定新服务。 + /// + private void BindRealtimeQuoteService(IRealtimeQuoteService service) + { + if (ReferenceEquals(service, _quoteService)) + return; + + DetachRealtimeQuoteService(); + _quoteService = service; + _quoteService.PriceUpdated += OnDetailPriceUpdated; + } + + /// + /// 解除当前实时行情服务的事件与订阅。用于切到无实时推送的市场或页面离开时清理。 + /// + private void DetachRealtimeQuoteService() + { + if (_quoteService == null) + return; + + _quoteService.PriceUpdated -= OnDetailPriceUpdated; + _ = _quoteService.UnsubscribeAllAsync(RealtimeQuoteSubscriberKeys.AssetDetail); + _quoteService = null; + } + + private void OnDetailPriceUpdated(string code, decimal lastPrice, decimal changePercent) { - if (!ToBinanceFormat(AssetCode).Equals(symbol, StringComparison.OrdinalIgnoreCase)) + if (!AssetCode.Equals(code, StringComparison.OrdinalIgnoreCase)) return; Dispatcher.UIThread.InvokeAsync(() => @@ -268,14 +292,13 @@ private void OnDetailPriceUpdated(string symbol, decimal lastPrice, decimal chan public void OnNavigatedFrom() { _loadingCancellationTokenSource?.Cancel(); - _wsService.PriceUpdated -= OnDetailPriceUpdated; + _quoteService?.PriceUpdated -= OnDetailPriceUpdated; } public void Dispose() { _loadingCancellationTokenSource?.Cancel(); - _wsService.PriceUpdated -= OnDetailPriceUpdated; - _ = _wsService.UnsubscribeAllAsync(WebSocketSubscriberKeys.AssetDetail); + DetachRealtimeQuoteService(); GC.SuppressFinalize(this); } } diff --git a/src/MarketAssistant.App/ViewModels/AssetSelectionPageViewModel.cs b/src/MarketAssistant.App/ViewModels/AssetSelectionPageViewModel.cs index ae10673..2e99b9b 100644 --- a/src/MarketAssistant.App/ViewModels/AssetSelectionPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/AssetSelectionPageViewModel.cs @@ -42,7 +42,6 @@ public enum SelectionModeType public partial class AssetSelectionPageViewModel : ViewModelBase, IDisposable { private readonly InvestmentSelectionService _investmentSelectionService; - private readonly IMarketServiceRegistry _marketServiceRegistry; private readonly MarketContext _marketContext; private readonly IDialogService _dialogService; @@ -214,12 +213,10 @@ public string CurrentButtonText public AssetSelectionPageViewModel( ILogger logger, InvestmentSelectionService investmentSelectionService, - IMarketServiceRegistry marketServiceRegistry, MarketContext marketContext, IDialogService dialogService) : base(logger) { _investmentSelectionService = investmentSelectionService; - _marketServiceRegistry = marketServiceRegistry; _marketContext = marketContext; SubscribeToMarketChanges(_marketContext); _dialogService = dialogService; @@ -294,7 +291,7 @@ private async Task AddToFavorites(InvestmentRecommendation? stock) code = stock.Symbol.Substring(2); } - var favoriteService = _marketServiceRegistry.GetFavoriteService(_marketContext.CurrentMarket); + var favoriteService = _marketContext.GetService(); if (await favoriteService.IsFavoriteAsync(code, market)) { await _dialogService.ShowMessageAsync("提示", $"{stock.Name} ({stock.Symbol}) 已在自选列表中"); diff --git a/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs b/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs index 61a052b..48f9f25 100644 --- a/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs @@ -7,23 +7,22 @@ using MarketAssistant.Applications.Favorites; using MarketAssistant.Infrastructure; using MarketAssistant.Infrastructure.Core; -using MarketAssistant.DataProviders; using MarketAssistant.Services.Dialog; using MarketAssistant.Services.Market; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Collections.ObjectModel; using System.ComponentModel; -using static MarketAssistant.Infrastructure.Core.CryptoSymbolConverter; namespace MarketAssistant.ViewModels; public partial class FavoritesPageViewModel : ViewModelBase, IRecipient, IDisposable { - private readonly IMarketServiceRegistry _marketServiceRegistry; private readonly MarketContext _marketContext; private readonly IDialogService _dialogService; - private readonly BinanceWebSocketService _wsService; + + /// 当前绑定事件的市场实时行情服务。市场切换后解析到不同实现时重新绑定。 + private IRealtimeQuoteService? _quoteService; /// /// 用于取消上一次加载任务的 CTS,防止并发加载导致列表闪烁或重复项。 @@ -32,7 +31,7 @@ public partial class FavoritesPageViewModel : ViewModelBase, IRecipient - /// WebSocket 标的(Binance 格式)→ 展示对象的索引。 + /// 实时行情标的(应用层资产代码)→ 展示对象的索引。 /// tick 回调在后台线程先查索引:非本页标的直接返回,避免无谓的 UI 线程派发与逐项扫描。 /// private readonly ConcurrentDictionary _assetIndex = new(StringComparer.OrdinalIgnoreCase); @@ -50,44 +49,60 @@ public partial class FavoritesPageViewModel : ViewModelBase, IRecipient - _marketServiceRegistry.GetFavoriteService(_marketContext.CurrentMarket); + _marketContext.GetService(); private IAssetInfoService AssetInfoService => - _marketServiceRegistry.GetAssetInfoService(_marketContext.CurrentMarket); + _marketContext.GetService(); private IAssetCacheService CacheService => - _marketServiceRegistry.GetAssetCacheService(_marketContext.CurrentMarket); + _marketContext.GetService(); public ObservableCollection Assets { get; set; } = new ObservableCollection(); public FavoritesPageViewModel( - IMarketServiceRegistry marketServiceRegistry, MarketContext marketContext, IDialogService dialogService, - BinanceWebSocketService wsService, ILogger logger) : base(logger) { - _marketServiceRegistry = marketServiceRegistry; _marketContext = marketContext; _dialogService = dialogService; - _wsService = wsService; - _wsService.PriceUpdated += OnWebSocketPriceUpdated; + BindRealtimeQuoteService(); SubscribeToMarketChanges(_marketContext); _ = LoadFavoriteAssetsAsync(); WeakReferenceMessenger.Default.Register(this); } + /// + /// 绑定当前市场的实时行情服务事件。市场切换后 keyed 解析到不同实现时, + /// 先解除旧服务的事件与订阅再绑定新服务,避免旧市场行情继续推送。 + /// + private void BindRealtimeQuoteService() + { + var service = _marketContext.GetService(); + if (ReferenceEquals(service, _quoteService)) + return; + + if (_quoteService != null) + { + _quoteService.PriceUpdated -= OnRealtimePriceUpdated; + _ = _quoteService.UnsubscribeAllAsync(RealtimeQuoteSubscriberKeys.Favorites); + } + _quoteService = service; + _quoteService.PriceUpdated += OnRealtimePriceUpdated; + } + /// /// 市场切换时重新加载收藏列表。 /// 收藏页的订阅以完整集合替换,无需在此手动退订,加载时会自动更新订阅集。 /// protected override void OnMarketChanged(MarketType newMarket) { - // 事件来自单例 MarketContext,Dispose 后不得再触发(重启加载/WebSocket 订阅) + // 事件来自单例 MarketContext,Dispose 后不得再触发(重启加载/实时行情订阅) if (_disposed) return; + BindRealtimeQuoteService(); _ = LoadFavoriteAssetsAsync(); } @@ -112,12 +127,12 @@ await SafeExecuteAsync(async () => RebuildAssetIndex(); - // 以完整集合替换收藏页订阅:虚拟币市场订阅自选交易对; + // 以完整集合替换收藏页订阅:支持实时推送的市场订阅自选资产; // 其他市场传空集合,确保切换市场后不残留上一市场的订阅 - var symbols = _marketContext.CurrentMarket == MarketType.Crypto - ? Assets.Select(a => ToBinanceFormat(a.Code)).ToList() + var codes = _marketContext.CurrentCapability.SupportsRealtime + ? Assets.Select(a => a.Code).ToList() : []; - _ = _wsService.SubscribeAsync(WebSocketSubscriberKeys.Favorites, symbols); + _ = _quoteService!.SubscribeAsync(RealtimeQuoteSubscriberKeys.Favorites, codes); }, "加载收藏列表"); } @@ -219,8 +234,8 @@ await SafeExecuteAsync(async () => if (assetToRemove != null) { Assets.Remove(assetToRemove); - _assetIndex.TryRemove(ToBinanceFormat(assetToRemove.Code), out _); - _pendingPriceUpdates.TryRemove(ToBinanceFormat(assetToRemove.Code), out _); + _assetIndex.TryRemove(assetToRemove.Code, out _); + _pendingPriceUpdates.TryRemove(assetToRemove.Code, out _); } await FavoriteService.RemoveFavoriteAsync(asset.Code, asset.Market); @@ -232,16 +247,16 @@ await SafeExecuteAsync(async () => } /// - /// WebSocket 实时价格更新回调(后台线程)。 + /// 实时价格更新回调(后台线程),参数为应用层资产代码。 /// 索引未命中(非本页标的)直接返回,不产生任何 UI 线程派发; /// 命中则暂存最新值,由 250ms 节流定时器批量刷新,避免高频 tick 逐条打 UI。 /// - private void OnWebSocketPriceUpdated(string symbol, decimal lastPrice, decimal changePercent) + private void OnRealtimePriceUpdated(string code, decimal lastPrice, decimal changePercent) { - if (!_assetIndex.ContainsKey(symbol)) + if (!_assetIndex.ContainsKey(code)) return; - _pendingPriceUpdates[symbol] = (lastPrice, changePercent); + _pendingPriceUpdates[code] = (lastPrice, changePercent); EnsurePriceFlushTimer(); } @@ -253,7 +268,7 @@ private void RebuildAssetIndex() _assetIndex.Clear(); foreach (var asset in Assets) { - _assetIndex[ToBinanceFormat(asset.Code)] = asset; + _assetIndex[asset.Code] = asset; } } @@ -315,8 +330,11 @@ public void Dispose() _priceFlushTimer = null; } UnsubscribeFromMarketChanges(_marketContext); - _wsService.PriceUpdated -= OnWebSocketPriceUpdated; - _ = _wsService.UnsubscribeAllAsync(WebSocketSubscriberKeys.Favorites); + if (_quoteService != null) + { + _quoteService.PriceUpdated -= OnRealtimePriceUpdated; + _ = _quoteService.UnsubscribeAllAsync(RealtimeQuoteSubscriberKeys.Favorites); + } WeakReferenceMessenger.Default.UnregisterAll(this); GC.SuppressFinalize(this); } diff --git a/src/MarketAssistant.App/ViewModels/Home/HomeSearchViewModel.cs b/src/MarketAssistant.App/ViewModels/Home/HomeSearchViewModel.cs index 1fe16df..2dff05c 100644 --- a/src/MarketAssistant.App/ViewModels/Home/HomeSearchViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Home/HomeSearchViewModel.cs @@ -11,13 +11,12 @@ namespace MarketAssistant.ViewModels.Home; public partial class HomeSearchViewModel : ViewModelBase, IDisposable { - private readonly IMarketServiceRegistry _marketServiceRegistry; private readonly MarketContext _marketContext; private CancellationTokenSource? _debounceCts; private const int DebounceDelayMs = 200; private IHomeAssetService HomeAssetService => - _marketServiceRegistry.GetHomeAssetService(_marketContext.CurrentMarket); + _marketContext.GetService(); [ObservableProperty] private string _searchQuery = string.Empty; @@ -39,12 +38,10 @@ public partial class HomeSearchViewModel : ViewModelBase, IDisposable public event EventHandler? AssetSelected; public HomeSearchViewModel( - IMarketServiceRegistry marketServiceRegistry, MarketContext marketContext, ILogger logger) : base(logger) { - _marketServiceRegistry = marketServiceRegistry; _marketContext = marketContext; } diff --git a/src/MarketAssistant.App/ViewModels/Home/HotAssetsViewModel.cs b/src/MarketAssistant.App/ViewModels/Home/HotAssetsViewModel.cs index 6f85443..7bd2b70 100644 --- a/src/MarketAssistant.App/ViewModels/Home/HotAssetsViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Home/HotAssetsViewModel.cs @@ -11,23 +11,20 @@ namespace MarketAssistant.ViewModels.Home; public partial class HotAssetsViewModel : ViewModelBase, IDisposable { - private readonly IMarketServiceRegistry _marketServiceRegistry; private readonly MarketContext _marketContext; private IHomeAssetService HomeAssetService => - _marketServiceRegistry.GetHomeAssetService(_marketContext.CurrentMarket); + _marketContext.GetService(); public ObservableCollection HotAssets { get; } = new(); public event EventHandler? HotAssetSelected; public HotAssetsViewModel( - IMarketServiceRegistry marketServiceRegistry, MarketContext marketContext, ILogger logger) : base(logger) { - _marketServiceRegistry = marketServiceRegistry; _marketContext = marketContext; SubscribeToMarketChanges(_marketContext); diff --git a/src/MarketAssistant.App/ViewModels/Home/RecentAssetsViewModel.cs b/src/MarketAssistant.App/ViewModels/Home/RecentAssetsViewModel.cs index 7a74c0b..f5ba7ac 100644 --- a/src/MarketAssistant.App/ViewModels/Home/RecentAssetsViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Home/RecentAssetsViewModel.cs @@ -13,29 +13,26 @@ namespace MarketAssistant.ViewModels.Home; public partial class RecentAssetsViewModel : ViewModelBase, IDisposable { - private readonly IMarketServiceRegistry _marketServiceRegistry; private readonly MarketContext _marketContext; private IAssetHistoryService HistoryService => - _marketServiceRegistry.GetAssetHistoryService(_marketContext.CurrentMarket); + _marketContext.GetService(); private IHomeAssetService HomeAssetService => - _marketServiceRegistry.GetHomeAssetService(_marketContext.CurrentMarket); + _marketContext.GetService(); private IAssetInfoService AssetInfoService => - _marketServiceRegistry.GetAssetInfoService(_marketContext.CurrentMarket); + _marketContext.GetService(); public ObservableCollection RecentAssets { get; } = new(); public event EventHandler? RecentAssetSelected; public RecentAssetsViewModel( - IMarketServiceRegistry marketServiceRegistry, MarketContext marketContext, ILogger logger) : base(logger) { - _marketServiceRegistry = marketServiceRegistry; _marketContext = marketContext; SubscribeToMarketChanges(_marketContext); diff --git a/src/MarketAssistant.App/ViewModels/Home/TelegraphNewsViewModel.cs b/src/MarketAssistant.App/ViewModels/Home/TelegraphNewsViewModel.cs index e62de98..c634fb1 100644 --- a/src/MarketAssistant.App/ViewModels/Home/TelegraphNewsViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Home/TelegraphNewsViewModel.cs @@ -13,7 +13,6 @@ namespace MarketAssistant.ViewModels.Home; public partial class TelegraphNewsViewModel : ViewModelBase, IDisposable { - private readonly IMarketServiceRegistry _marketServiceRegistry; private readonly MarketContext _marketContext; private INewsUpdateService _newsUpdateService; private bool _disposed; @@ -26,15 +25,13 @@ public partial class TelegraphNewsViewModel : ViewModelBase, IDisposable public IAsyncRelayCommand OpenNewsCommand { get; } public TelegraphNewsViewModel( - IMarketServiceRegistry marketServiceRegistry, MarketContext marketContext, ILogger logger) : base(logger) { - _marketServiceRegistry = marketServiceRegistry; _marketContext = marketContext; - _newsUpdateService = _marketServiceRegistry.GetNewsUpdateService(_marketContext.CurrentMarket); + _newsUpdateService = _marketContext.GetService(); OpenNewsCommand = new AsyncRelayCommand(OnOpenNewsAsync); @@ -62,7 +59,7 @@ protected override void OnMarketChanged(MarketType newMarket) _newsUpdateService.NewsUpdated -= OnNewsUpdated; _newsUpdateService.CountdownUpdated -= OnCountdownUpdated; - _newsUpdateService = _marketServiceRegistry.GetNewsUpdateService(newMarket); + _newsUpdateService = _marketContext.GetService(newMarket); _newsUpdateService.NewsUpdated += OnNewsUpdated; _newsUpdateService.CountdownUpdated += OnCountdownUpdated; diff --git a/src/MarketAssistant.App/ViewModels/PriceAlertPageViewModel.cs b/src/MarketAssistant.App/ViewModels/PriceAlertPageViewModel.cs index 0860b4d..1e39dc2 100644 --- a/src/MarketAssistant.App/ViewModels/PriceAlertPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/PriceAlertPageViewModel.cs @@ -35,7 +35,6 @@ public partial class PriceAlertPageViewModel : ViewModelBase, IDisposable private static readonly TimeSpan SearchDebounceDelay = TimeSpan.FromMilliseconds(300); private readonly PriceAlertService _alertService; - private readonly IMarketServiceRegistry _marketServiceRegistry; private readonly MarketContext _marketContext; private readonly IDialogService _dialogService; private readonly IUserSettingService _userSettingService; @@ -49,7 +48,7 @@ public partial class PriceAlertPageViewModel : ViewModelBase, IDisposable /// 当前市场对应的资产信息服务(Keyed Service,跟随市场切换)。 /// private IAssetInfoService AssetInfoService => - _marketServiceRegistry.GetAssetInfoService(_marketContext.CurrentMarket); + _marketContext.GetService(); /// /// 当前页面的规则列表(仅展示当前市场的规则) @@ -160,7 +159,6 @@ NewRuleCondition is AlertCondition.ChangePercentAbove or AlertCondition.ChangePe public PriceAlertPageViewModel( PriceAlertService alertService, - IMarketServiceRegistry marketServiceRegistry, MarketContext marketContext, IDialogService dialogService, IUserSettingService userSettingService, @@ -168,7 +166,6 @@ public PriceAlertPageViewModel( : base(logger) { _alertService = alertService; - _marketServiceRegistry = marketServiceRegistry; _marketContext = marketContext; _dialogService = dialogService; _userSettingService = userSettingService; diff --git a/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.Documents.cs b/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.Documents.cs new file mode 100644 index 0000000..732ec8f --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.Documents.cs @@ -0,0 +1,176 @@ +using Avalonia.Platform.Storage; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using MarketAssistant.Applications.Settings; +using MarketAssistant.Infrastructure.Providers; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.ViewModels; + +/// +/// 设置页 ViewModel 的文档向量化与文件选择部分。 +/// 向量化核心逻辑已下沉到 ,本部分仅保留 UI 状态与用户交互反馈。 +/// +public partial class SettingsPageViewModel +{ + public bool IsKnowledgeDirectoryValid => !string.IsNullOrEmpty(UserSetting.KnowledgeFileDirectory) && Directory.Exists(UserSetting.KnowledgeFileDirectory); + + [ObservableProperty] + private bool _isVectorizing; + + // 向量化进度(0-100) + [ObservableProperty] + private int _vectorizingProgress; + + [ObservableProperty] + private string _vectorizingProgressText = ""; + + /// + /// 选择知识库目录 + /// + [RelayCommand] + private async Task SelectKnowledgeDirectory() + { + if (_storageProvider == null) return; + + await SafeExecuteAsync(async () => + { + var folders = await _storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = "选择知识库目录", + AllowMultiple = false + }); + + if (folders.Count > 0) + { + UserSetting.KnowledgeFileDirectory = folders[0].Path.LocalPath; + } + }, "选择知识库目录"); + } + + /// + /// 选择日志路径 + /// + [RelayCommand] + private async Task SelectLogPath() + { + if (_storageProvider == null) return; + + await SafeExecuteAsync(async () => + { + var folders = await _storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = "选择日志路径", + AllowMultiple = false + }); + + if (folders.Count > 0) + { + UserSetting.LogPath = Path.Combine(folders[0].Path.LocalPath, "logs"); + } + }, "选择日志路径"); + } + + [RelayCommand] + private async Task VectorizeDocuments() + { + if (!IsKnowledgeDirectoryValid) + { + _notificationService.ShowWarning("知识库目录无效,请先选择有效的目录"); + Logger?.LogWarning("知识库目录无效,无法进行向量化"); + return; + } + + if (!_documentVectorizationService.TryBeginVectorization()) + { + _notificationService.ShowWarning("已有一个向量化任务在后台进行中,请等待其完成后再试"); + Logger?.LogWarning("拒绝并发的向量化请求"); + return; + } + + var cts = new CancellationTokenSource(); + _vectorizationCts = cts; + try + { + IsVectorizing = true; + VectorizingProgress = 0; + VectorizingProgressText = "准备中..."; + + Logger?.LogInformation("开始向量化知识库目录: {Directory}", UserSetting.KnowledgeFileDirectory); + + var progress = new Progress<(int Percent, string Text)>(p => + { + VectorizingProgress = p.Percent; + VectorizingProgressText = p.Text; + }); + + var result = await _documentVectorizationService.VectorizeDirectoryAsync( + UserSetting.KnowledgeFileDirectory, + UserSetting.VectorCollectionName, + progress, + cts.Token); + + if (result is null) + { + _notificationService.ShowWarning($"未找到支持的文档(支持:{string.Join(", ", DocumentVectorizationService.SupportedExtensions)})"); + Logger?.LogWarning("知识库目录中没有找到支持的文档"); + return; + } + + // 显示完成消息(三态:完全成功 / 部分成功 / 失败) + VectorizingProgress = 100; + if (result.AllSucceeded) + { + VectorizingProgressText = $"✅ 全部完成!共 {result.SuccessCount} 个文件"; + _notificationService.ShowSuccess($"✅ 所有文档向量化完成!\n成功处理 {result.SuccessCount} 个文件"); + Logger?.LogInformation("向量化完成:成功 {Success}/{Total} 个", result.SuccessCount, result.TotalCount); + } + else + { + var summaryText = $"⚠️ 完成(存在失败): {result.SuccessCount} 成功, {result.PartialCount} 部分成功, {result.FailedCount} 失败"; + VectorizingProgressText = summaryText; + + var failedList = string.Join("\n- ", result.FailedFiles.Take(5)); + if (result.FailedFiles.Count > 5) + { + failedList += $"\n... 还有 {result.FailedFiles.Count - 5} 个"; + } + + var partialList = string.Join("\n- ", result.PartialFiles.Take(5)); + if (result.PartialFiles.Count > 5) + { + partialList += $"\n... 还有 {result.PartialFiles.Count - 5} 个"; + } + + _notificationService.ShowWarning( + $"向量化完成:\n✓ 完全成功 {result.SuccessCount} 个\n△ 部分成功 {result.PartialCount} 个\n✗ 失败 {result.FailedCount} 个" + + (result.PartialFiles.Count > 0 ? $"\n\n部分成功(存在失败块):\n- {partialList}" : string.Empty) + + (result.FailedFiles.Count > 0 ? $"\n\n失败文件:\n- {failedList}" : string.Empty)); + + Logger?.LogWarning("向量化完成:成功 {Success} 个,部分成功 {Partial} 个,失败 {Failed} 个,总计 {Total} 个", + result.SuccessCount, result.PartialCount, result.FailedCount, result.TotalCount); + } + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + // 取消发生在文件间隙或准备阶段 + VectorizingProgressText = "向量化已取消"; + _notificationService.ShowWarning("向量化已取消。已完成的部分保持有效。"); + Logger?.LogWarning("向量化被用户取消"); + } + catch (Exception ex) + { + VectorizingProgressText = "向量化失败"; + Logger?.LogError(ex, "向量化过程发生严重错误"); + _notificationService.ShowError(ErrorMessageMapper.GetUserFriendlyMessageWithContext(ex, "向量化")); + } + finally + { + IsVectorizing = false; + if (ReferenceEquals(_vectorizationCts, cts)) + _vectorizationCts = null; + cts.Dispose(); + _documentVectorizationService.EndVectorization(); + } + } +} diff --git a/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.Models.cs b/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.Models.cs new file mode 100644 index 0000000..a7a4c04 --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.Models.cs @@ -0,0 +1,336 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using MarketAssistant.Agents.Analysts; +using MarketAssistant.Agents.Analysts.Attributes; +using MarketAssistant.Infrastructure.Providers; +using MarketAssistant.Services.Agents.Analysts; +using Microsoft.Extensions.Logging; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Reflection; + +namespace MarketAssistant.ViewModels; + +/// +/// 设置页 ViewModel 的模型服务商配置部分:API Key / 模型 ID / 端点、模型目录获取、分析师角色加载。 +/// +public partial class SettingsPageViewModel +{ + [ObservableProperty] + private ObservableCollection _models = []; + + // 服务商列表(目录运行期不变,缓存实例避免 ComboBox 每次绑定求值新建 List) + public List Providers { get; } = ModelProviderCatalog.Providers.ToList(); + + [ObservableProperty] + private ModelProvider? _selectedProvider; + + // 当前服务商的 API Key(从 ProviderApiKeys 中读取当前服务商的 Key) + public string ApiKey + { + get => UserSetting.ProviderApiKeys.TryGetValue(UserSetting.ProviderId, out var key) ? key : ""; + set + { + if (string.IsNullOrWhiteSpace(UserSetting.ProviderId) || ApiKey == value) + return; + + UserSetting.ProviderApiKeys[UserSetting.ProviderId] = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(CanFetchModels)); + OnPropertyChanged(nameof(ModelDiscoveryHint)); + FetchModelsCommand.NotifyCanExecuteChanged(); + } + } + + // 是否显示 API Key 配置。OpenCode Zen 即使当前使用免费模型也允许配置 Key。 + public bool SupportsApiKeyConfiguration => SelectedProvider?.RequiresApiKey ?? false; + + // 当前模型是否强制要求 API Key,用于提示而不是控制输入框可见性。 + public bool IsApiKeyRequiredForSelectedModel => + SelectedProvider?.RequiresApiKeyForModel(ModelId) ?? false; + + public string ApiKeyHint => IsApiKeyRequiredForSelectedModel + ? "当前模型需要 API Key" + : "API Key 可选;留空使用免费模型,配置后可访问账号授权模型"; + + public string? ProviderApiKeyUrl => SelectedProvider?.ApiKeyUrl; + + public bool CanOverrideEndpoint => SelectedProvider?.AllowsEndpointOverride ?? false; + + // 当前服务商的活动模型 ID(按服务商存储于 ProviderModelIds)。 + public string ModelId + { + get => UserSetting.ProviderModelIds.GetValueOrDefault(UserSetting.ProviderId, string.Empty); + set + { + if (string.IsNullOrWhiteSpace(UserSetting.ProviderId)) + return; + if (ModelId == value) + return; + + UserSetting.ProviderModelIds[UserSetting.ProviderId] = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsApiKeyRequiredForSelectedModel)); + OnPropertyChanged(nameof(ApiKeyHint)); + } + } + + // 仅本地部署和自定义服务允许覆盖默认端点。按服务商存储于 ProviderEndpoints。 + public string Endpoint + { + get => UserSetting.ProviderEndpoints.GetValueOrDefault(UserSetting.ProviderId, string.Empty); + set + { + if (string.IsNullOrWhiteSpace(UserSetting.ProviderId)) + return; + if (Endpoint == value) + return; + + UserSetting.ProviderEndpoints[UserSetting.ProviderId] = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(EffectiveEndpoint)); + } + } + + public string EndpointPlaceholder => string.IsNullOrWhiteSpace(SelectedProvider?.DefaultEndpoint) + ? "请输入完整的 API Base URL" + : $"默认:{SelectedProvider.DefaultEndpoint}"; + + public string EffectiveEndpoint => string.IsNullOrWhiteSpace(Endpoint) + ? SelectedProvider?.DefaultEndpoint ?? string.Empty + : Endpoint.Trim(); + + public bool SupportsModelListing => SelectedProvider?.SupportsModelListing ?? false; + + public bool CanFetchModels => + SelectedProvider is { SupportsModelListing: true } provider && + !IsLoadingModels && + (!provider.ModelListingRequiresApiKey || !string.IsNullOrWhiteSpace(ApiKey)); + + public string ModelDiscoveryHint + { + get + { + if (SelectedProvider is not { } provider) + return "请先选择模型服务商"; + + if (!provider.SupportsModelListing) + return "该服务商不提供模型目录,请直接输入模型 ID"; + + if (provider.ModelListingRequiresApiKey && string.IsNullOrWhiteSpace(ApiKey)) + return "配置 API Key 后可获取模型目录,也可以直接输入模型 ID"; + + return string.IsNullOrWhiteSpace(ModelDiscoveryStatus) + ? "可从服务商获取模型目录,也可以直接输入未列出的模型 ID" + : ModelDiscoveryStatus; + } + } + + [ObservableProperty] + private string _modelDiscoveryStatus = string.Empty; + + partial void OnModelDiscoveryStatusChanged(string value) => + OnPropertyChanged(nameof(ModelDiscoveryHint)); + + [ObservableProperty] + private bool _isLoadingModels; + + partial void OnIsLoadingModelsChanged(bool value) + { + OnPropertyChanged(nameof(CanFetchModels)); + OnPropertyChanged(nameof(ModelDiscoveryHint)); + FetchModelsCommand.NotifyCanExecuteChanged(); + } + + partial void OnSelectedProviderChanged(ModelProvider? oldValue, ModelProvider? newValue) + { + if (newValue is null) + return; + + UserSetting.ProviderId = newValue.Id; + + _modelFetchCancellationTokenSource?.Cancel(); + Models.Clear(); + ModelDiscoveryStatus = string.Empty; + + // ModelId/Endpoint 按服务商存储于字典,切换后通知绑定重新读取即可。 + OnPropertyChanged(nameof(ApiKey)); + OnPropertyChanged(nameof(ModelId)); + OnPropertyChanged(nameof(Endpoint)); + OnPropertyChanged(nameof(SupportsApiKeyConfiguration)); + OnPropertyChanged(nameof(IsApiKeyRequiredForSelectedModel)); + OnPropertyChanged(nameof(ApiKeyHint)); + OnPropertyChanged(nameof(ProviderApiKeyUrl)); + OnPropertyChanged(nameof(CanOverrideEndpoint)); + OnPropertyChanged(nameof(EndpointPlaceholder)); + OnPropertyChanged(nameof(EffectiveEndpoint)); + OnPropertyChanged(nameof(SupportsModelListing)); + OnPropertyChanged(nameof(CanFetchModels)); + OnPropertyChanged(nameof(ModelDiscoveryHint)); + FetchModelsCommand.NotifyCanExecuteChanged(); + + if (!_isInitializingProvider) + { + // 模型列表鉴权与具体模型调用鉴权彼此独立。 + var currentKey = UserSetting.ProviderApiKeys.TryGetValue(newValue.Id, out var key) ? key : ""; + if (newValue.CanListModels(currentKey)) + { + _ = FetchModels(); + } + } + } + + [ObservableProperty] + private ObservableCollection _analystRoles = new(); + + private void LoadAnalystRoles() + { + AnalystRoles.Clear(); + var agentTypes = AnalystTypeRegistry.GetConcreteAnalystTypes(); + + foreach (var type in agentTypes) + { + var displayName = type.GetCustomAttribute()?.DisplayName ?? type.Name; + var description = type.GetCustomAttribute()?.Description ?? ""; + var isRequired = type.GetCustomAttribute() != null; + + // 按当前市场过滤角色列表(未标注 SupportedMarkets 视为全市场支持) + if (!SupportedMarketsAttribute.SupportsMarket(type, _marketContext.CurrentMarket)) continue; + + var id = type.Name; + + var isEnabled = false; + if (UserSetting.EnabledAnalystRoles.TryGetValue(id, out var enabled)) + { + isEnabled = enabled; + } + + if (isRequired) isEnabled = true; + + AnalystRoles.Add(new AnalystRoleViewModel + { + Id = id, + Name = displayName, + Description = description, + IsRequired = isRequired, + IsEnabled = isEnabled + }); + } + } + + /// + /// 打开API密钥网站命令 + /// + [RelayCommand] + private Task OpenModelApiWebsite() => ProviderApiKeyUrl != null ? OpenUrlAsync(ProviderApiKeyUrl) : Task.CompletedTask; + + [RelayCommand] + private Task OpenZhiTuApiWebsite() => OpenUrlAsync(ZhiTuApiUrl); + + [RelayCommand] + private Task OpenCoinGeckoApiWebsite() => OpenUrlAsync(CoinGeckoApiUrl); + + [RelayCommand] + private Task OpenEmbeddingApiWebsite() => OpenUrlAsync(JinaApiUrl); + + /// + /// 从服务商 API 获取模型列表(用户填好 API Key 后手动触发) + /// + private bool CanFetchModelsCommand() => CanFetchModels; + + [RelayCommand(CanExecute = nameof(CanFetchModelsCommand))] + private async Task FetchModels() + { + var provider = SelectedProvider; + if (provider is null || !provider.SupportsModelListing) + return; + + // 只取消上一个在飞请求,不 Dispose(其令牌仍被在飞请求持有,由该请求自身 finally 释放) + _modelFetchCancellationTokenSource?.Cancel(); + var cts = new CancellationTokenSource(); + _modelFetchCancellationTokenSource = cts; + var requestedProviderId = provider.Id; + + ModelDiscoveryStatus = $"正在从 {provider.DisplayName} 获取模型目录..."; + IsLoadingModels = true; + try + { + var apiKey = string.IsNullOrWhiteSpace(ApiKey) ? null : ApiKey; + var endpoint = provider.AllowsEndpointOverride && !string.IsNullOrWhiteSpace(Endpoint) + ? Endpoint + : null; + var models = await _modelDiscoveryService.ListModelsAsync( + provider, + apiKey, + endpoint, + cts.Token); + + // 取消不能保证远端立即停止;响应落 UI 前再次校验 Provider 身份。 + if (cts.IsCancellationRequested || SelectedProvider?.Id != requestedProviderId) + return; + + Models.Clear(); + foreach (var model in models) + Models.Add(model); + + ModelDiscoveryStatus = Models.Count == 0 + ? "服务商未返回可用模型,请直接输入模型 ID" + : $"已获取 {Models.Count} 个模型,可直接选择或继续手工输入"; + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + Logger?.LogDebug("已取消服务商 {ProviderId} 的模型列表请求", requestedProviderId); + } + catch (HttpRequestException ex) when ( + ex.StatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden) + { + HandleModelDiscoveryFailure( + requestedProviderId, + ex, + $"{provider.DisplayName} 拒绝访问,请检查 API Key"); + } + catch (Exception ex) + { + HandleModelDiscoveryFailure( + requestedProviderId, + ex, + $"获取失败:{ErrorMessageMapper.GetUserFriendlyMessage(ex)}"); + } + finally + { + if (ReferenceEquals(_modelFetchCancellationTokenSource, cts)) + { + _modelFetchCancellationTokenSource = null; + IsLoadingModels = false; + } + cts.Dispose(); + } + } + + private void HandleModelDiscoveryFailure( + string requestedProviderId, + Exception exception, + string status) + { + if (SelectedProvider?.Id != requestedProviderId) + return; + + ModelDiscoveryStatus = $"{status},仍可直接输入模型 ID"; + Logger?.LogWarning(exception, "获取服务商模型列表失败: {ProviderId}", requestedProviderId); + _notificationService.ShowError(ModelDiscoveryStatus); + } + + private async Task OpenUrlAsync(string url) + { + await SafeExecuteAsync(async () => + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = url, + UseShellExecute = true + }; + System.Diagnostics.Process.Start(psi); + await Task.CompletedTask; + }, "打开链接"); + } +} diff --git a/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs b/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs index 4bfe8b5..4aa4282 100644 --- a/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs @@ -2,33 +2,28 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; -using MarketAssistant.Agents.Analysts; -using MarketAssistant.Agents.Analysts.Attributes; using MarketAssistant.Applications.Settings; using MarketAssistant.Infrastructure.Core; using MarketAssistant.Infrastructure.Factories; using MarketAssistant.Infrastructure.Providers; -using MarketAssistant.Rag; -using MarketAssistant.Rag.Interfaces; -using MarketAssistant.Services.Agents.Analysts; using MarketAssistant.Services.Dialog; using MarketAssistant.Services.Notification; using MarketAssistant.Services.Settings; using MarketAssistant.Services.Trading; using MarketAssistant.Trading.Models; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.VectorData; -using System.Collections.ObjectModel; using System.ComponentModel; -using System.Reflection; namespace MarketAssistant.ViewModels; +/// +/// 设置页 ViewModel(核心部分:状态草稿、主题/市场/交易模式选择、保存/重置/导航)。 +/// 模型服务商配置见 SettingsPageViewModel.Models.cs;文档向量化与文件选择见 SettingsPageViewModel.Documents.cs。 +/// public partial class SettingsPageViewModel : ViewModelBase, IDisposable { // RAG 与交易重依赖通过提供者延迟解析:仅在向量化/保存时实例化, // 避免首次进入设置页触发整条交易与 RAG 单例链的同步构造 - private readonly IRagInfrastructureProvider _ragInfrastructureProvider; private readonly INotificationService _notificationService; private readonly IUserSettingService _userSettingService; private readonly IModelDiscoveryService _modelDiscoveryService; @@ -36,15 +31,12 @@ public partial class SettingsPageViewModel : ViewModelBase, IDisposable private readonly TradingEnvironmentService _tradingEnvironmentService; private readonly IMarketMonitorProvider _marketMonitorProvider; private readonly IDialogService _dialogService; + private readonly DocumentVectorizationService _documentVectorizationService; private IStorageProvider? _storageProvider; private bool _isInitializingProvider; private CancellationTokenSource? _modelFetchCancellationTokenSource; private CancellationTokenSource? _vectorizationCts; - // 向量化在途守卫必须跨 ViewModel 实例生效:离开设置页会释放当前 VM 并新建实例, - // 实例级标志挡不住"旧循环仍在跑 + 新页面再次启动"的并发向量化 - private static int _activeVectorizations; - [ObservableProperty] private UserSetting _userSetting = new(); @@ -91,185 +83,6 @@ private void ForwardComputedProperties(object? sender, PropertyChangedEventArgs } } - [ObservableProperty] - private ObservableCollection _models = []; - - // 服务商列表(目录运行期不变,缓存实例避免 ComboBox 每次绑定求值新建 List) - public List Providers { get; } = ModelProviderCatalog.Providers.ToList(); - - [ObservableProperty] - private ModelProvider? _selectedProvider; - - // 当前服务商的 API Key(从 ProviderApiKeys 中读取当前服务商的 Key) - public string ApiKey - { - get => UserSetting.ProviderApiKeys.TryGetValue(UserSetting.ProviderId, out var key) ? key : ""; - set - { - if (string.IsNullOrWhiteSpace(UserSetting.ProviderId) || ApiKey == value) - return; - - UserSetting.ProviderApiKeys[UserSetting.ProviderId] = value; - OnPropertyChanged(); - OnPropertyChanged(nameof(CanFetchModels)); - OnPropertyChanged(nameof(ModelDiscoveryHint)); - FetchModelsCommand.NotifyCanExecuteChanged(); - } - } - - // 是否显示 API Key 配置。OpenCode Zen 即使当前使用免费模型也允许配置 Key。 - public bool SupportsApiKeyConfiguration => SelectedProvider?.RequiresApiKey ?? false; - - // 当前模型是否强制要求 API Key,用于提示而不是控制输入框可见性。 - public bool IsApiKeyRequiredForSelectedModel => - SelectedProvider?.RequiresApiKeyForModel(ModelId) ?? false; - - public string ApiKeyHint => IsApiKeyRequiredForSelectedModel - ? "当前模型需要 API Key" - : "API Key 可选;留空使用免费模型,配置后可访问账号授权模型"; - - public string? ProviderApiKeyUrl => SelectedProvider?.ApiKeyUrl; - - public bool CanOverrideEndpoint => SelectedProvider?.AllowsEndpointOverride ?? false; - - // 当前服务商的活动模型 ID(按服务商存储于 ProviderModelIds)。 - public string ModelId - { - get => UserSetting.ProviderModelIds.GetValueOrDefault(UserSetting.ProviderId, string.Empty); - set - { - if (string.IsNullOrWhiteSpace(UserSetting.ProviderId)) - return; - if (ModelId == value) - return; - - UserSetting.ProviderModelIds[UserSetting.ProviderId] = value; - OnPropertyChanged(); - OnPropertyChanged(nameof(IsApiKeyRequiredForSelectedModel)); - OnPropertyChanged(nameof(ApiKeyHint)); - } - } - - // 仅本地部署和自定义服务允许覆盖默认端点。按服务商存储于 ProviderEndpoints。 - public string Endpoint - { - get => UserSetting.ProviderEndpoints.GetValueOrDefault(UserSetting.ProviderId, string.Empty); - set - { - if (string.IsNullOrWhiteSpace(UserSetting.ProviderId)) - return; - if (Endpoint == value) - return; - - UserSetting.ProviderEndpoints[UserSetting.ProviderId] = value; - OnPropertyChanged(); - OnPropertyChanged(nameof(EffectiveEndpoint)); - } - } - - public string EndpointPlaceholder => string.IsNullOrWhiteSpace(SelectedProvider?.DefaultEndpoint) - ? "请输入完整的 API Base URL" - : $"默认:{SelectedProvider.DefaultEndpoint}"; - - public string EffectiveEndpoint => string.IsNullOrWhiteSpace(Endpoint) - ? SelectedProvider?.DefaultEndpoint ?? string.Empty - : Endpoint.Trim(); - - public bool SupportsModelListing => SelectedProvider?.SupportsModelListing ?? false; - - public bool CanFetchModels => - SelectedProvider is { SupportsModelListing: true } provider && - !IsLoadingModels && - (!provider.ModelListingRequiresApiKey || !string.IsNullOrWhiteSpace(ApiKey)); - - public string ModelDiscoveryHint - { - get - { - if (SelectedProvider is not { } provider) - return "请先选择模型服务商"; - - if (!provider.SupportsModelListing) - return "该服务商不提供模型目录,请直接输入模型 ID"; - - if (provider.ModelListingRequiresApiKey && string.IsNullOrWhiteSpace(ApiKey)) - return "配置 API Key 后可获取模型目录,也可以直接输入模型 ID"; - - return string.IsNullOrWhiteSpace(ModelDiscoveryStatus) - ? "可从服务商获取模型目录,也可以直接输入未列出的模型 ID" - : ModelDiscoveryStatus; - } - } - - [ObservableProperty] - private string _modelDiscoveryStatus = string.Empty; - - partial void OnModelDiscoveryStatusChanged(string value) => - OnPropertyChanged(nameof(ModelDiscoveryHint)); - - [ObservableProperty] - private bool _isLoadingModels; - - partial void OnIsLoadingModelsChanged(bool value) - { - OnPropertyChanged(nameof(CanFetchModels)); - OnPropertyChanged(nameof(ModelDiscoveryHint)); - FetchModelsCommand.NotifyCanExecuteChanged(); - } - - partial void OnSelectedProviderChanged(ModelProvider? oldValue, ModelProvider? newValue) - { - if (newValue is null) - return; - - UserSetting.ProviderId = newValue.Id; - - _modelFetchCancellationTokenSource?.Cancel(); - Models.Clear(); - ModelDiscoveryStatus = string.Empty; - - // ModelId/Endpoint 按服务商存储于字典,切换后通知绑定重新读取即可。 - OnPropertyChanged(nameof(ApiKey)); - OnPropertyChanged(nameof(ModelId)); - OnPropertyChanged(nameof(Endpoint)); - OnPropertyChanged(nameof(SupportsApiKeyConfiguration)); - OnPropertyChanged(nameof(IsApiKeyRequiredForSelectedModel)); - OnPropertyChanged(nameof(ApiKeyHint)); - OnPropertyChanged(nameof(ProviderApiKeyUrl)); - OnPropertyChanged(nameof(CanOverrideEndpoint)); - OnPropertyChanged(nameof(EndpointPlaceholder)); - OnPropertyChanged(nameof(EffectiveEndpoint)); - OnPropertyChanged(nameof(SupportsModelListing)); - OnPropertyChanged(nameof(CanFetchModels)); - OnPropertyChanged(nameof(ModelDiscoveryHint)); - FetchModelsCommand.NotifyCanExecuteChanged(); - - if (!_isInitializingProvider) - { - // 模型列表鉴权与具体模型调用鉴权彼此独立。 - var currentKey = UserSetting.ProviderApiKeys.TryGetValue(newValue.Id, out var key) ? key : ""; - if (newValue.CanListModels(currentKey)) - { - _ = FetchModels(); - } - } - } - - [ObservableProperty] - private ObservableCollection _analystRoles = new(); - - public bool IsKnowledgeDirectoryValid => !string.IsNullOrEmpty(UserSetting.KnowledgeFileDirectory) && Directory.Exists(UserSetting.KnowledgeFileDirectory); - - [ObservableProperty] - private bool _isVectorizing; - - // 向量化进度(0-100) - [ObservableProperty] - private int _vectorizingProgress; - - [ObservableProperty] - private string _vectorizingProgressText = ""; - public List WebSearchProviders { get; } = new List { "Bing", "Brave", "Tavily" }; public List RiskToleranceOptions { get; } = Enum.GetValues().ToList(); @@ -406,7 +219,6 @@ public bool IsTavilyProvider } public SettingsPageViewModel( - IRagInfrastructureProvider ragInfrastructureProvider, INotificationService notificationService, IUserSettingService userSettingService, IModelDiscoveryService modelDiscoveryService, @@ -414,9 +226,9 @@ public SettingsPageViewModel( TradingEnvironmentService tradingEnvironmentService, IMarketMonitorProvider marketMonitorProvider, IDialogService dialogService, + DocumentVectorizationService documentVectorizationService, ILogger logger) : base(logger) { - _ragInfrastructureProvider = ragInfrastructureProvider; _notificationService = notificationService; _userSettingService = userSettingService; _modelDiscoveryService = modelDiscoveryService; @@ -424,6 +236,7 @@ public SettingsPageViewModel( _tradingEnvironmentService = tradingEnvironmentService; _marketMonitorProvider = marketMonitorProvider; _dialogService = dialogService; + _documentVectorizationService = documentVectorizationService; _ = SafeExecuteAsync(InitializeAsync, "初始化设置页"); } @@ -468,276 +281,6 @@ private async Task InitializeAsync() ApplyTheme(UserSetting.ThemeMode); } - private void LoadAnalystRoles() - { - AnalystRoles.Clear(); - var agentTypes = AnalystTypeRegistry.GetConcreteAnalystTypes(); - - foreach (var type in agentTypes) - { - var displayName = type.GetCustomAttribute()?.DisplayName ?? type.Name; - var description = type.GetCustomAttribute()?.Description ?? ""; - var isRequired = type.GetCustomAttribute() != null; - - // 按当前市场过滤角色列表(未标注 SupportedMarkets 视为全市场支持) - if (!SupportedMarketsAttribute.SupportsMarket(type, _marketContext.CurrentMarket)) continue; - - var id = type.Name; - - var isEnabled = false; - if (UserSetting.EnabledAnalystRoles.TryGetValue(id, out var enabled)) - { - isEnabled = enabled; - } - - if (isRequired) isEnabled = true; - - AnalystRoles.Add(new AnalystRoleViewModel - { - Id = id, - Name = displayName, - Description = description, - IsRequired = isRequired, - IsEnabled = isEnabled - }); - } - } - - /// - /// 打开API密钥网站命令 - /// - [RelayCommand] - private Task OpenModelApiWebsite() => ProviderApiKeyUrl != null ? OpenUrlAsync(ProviderApiKeyUrl) : Task.CompletedTask; - - [RelayCommand] - private Task OpenZhiTuApiWebsite() => OpenUrlAsync(ZhiTuApiUrl); - - [RelayCommand] - private Task OpenCoinGeckoApiWebsite() => OpenUrlAsync(CoinGeckoApiUrl); - - [RelayCommand] - private Task OpenEmbeddingApiWebsite() => OpenUrlAsync(JinaApiUrl); - - /// - /// 选择知识库目录 - /// - [RelayCommand] - private async Task SelectKnowledgeDirectory() - { - if (_storageProvider == null) return; - - await SafeExecuteAsync(async () => - { - var folders = await _storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions - { - Title = "选择知识库目录", - AllowMultiple = false - }); - - if (folders.Count > 0) - { - UserSetting.KnowledgeFileDirectory = folders[0].Path.LocalPath; - } - }, "选择知识库目录"); - } - - /// - /// 选择日志路径 - /// - [RelayCommand] - private async Task SelectLogPath() - { - if (_storageProvider == null) return; - - await SafeExecuteAsync(async () => - { - var folders = await _storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions - { - Title = "选择日志路径", - AllowMultiple = false - }); - - if (folders.Count > 0) - { - UserSetting.LogPath = Path.Combine(folders[0].Path.LocalPath, "logs"); - } - }, "选择日志路径"); - } - - [RelayCommand] - private async Task VectorizeDocuments() - { - if (!IsKnowledgeDirectoryValid) - { - _notificationService.ShowWarning("知识库目录无效,请先选择有效的目录"); - Logger?.LogWarning("知识库目录无效,无法进行向量化"); - return; - } - - if (System.Threading.Interlocked.CompareExchange(ref _activeVectorizations, 1, 0) != 0) - { - _notificationService.ShowWarning("已有一个向量化任务在后台进行中,请等待其完成后再试"); - Logger?.LogWarning("拒绝并发的向量化请求"); - return; - } - - var cts = new CancellationTokenSource(); - _vectorizationCts = cts; - try - { - IsVectorizing = true; - VectorizingProgress = 0; - VectorizingProgressText = "准备中..."; - - Logger?.LogInformation("开始向量化知识库目录: {Directory}", UserSetting.KnowledgeFileDirectory); - - // 创建嵌入生成器(只在实际需要时创建) - var embeddingGenerator = _ragInfrastructureProvider.GetEmbeddingFactory().Create(); - - var collectionName = UserSetting.VectorCollectionName; - var collection = _ragInfrastructureProvider.GetVectorStore().GetCollection(collectionName); - await collection.EnsureCollectionExistsAsync(); - Logger?.LogInformation("使用向量集合: {CollectionName}", collectionName); - - var supportedExtensions = new[] { ".pdf", ".docx", ".md" }; - - var files = Directory.GetFiles(UserSetting.KnowledgeFileDirectory, "*.*", SearchOption.AllDirectories) - .Where(f => supportedExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())) - .ToList(); - - if (files.Count == 0) - { - _notificationService.ShowWarning($"未找到支持的文档(支持:{string.Join(", ", supportedExtensions)})"); - Logger?.LogWarning("知识库目录中没有找到支持的文档"); - return; - } - - var totalFiles = files.Count; - Logger?.LogInformation("找到 {Count} 个文档需要向量化", totalFiles); - _notificationService.ShowInfo($"开始向量化 {totalFiles} 个文档..."); - - var ragIngestionService = _ragInfrastructureProvider.GetIngestionService(); - var successCount = 0; - var partialCount = 0; - var failedCount = 0; - var failedFiles = new List(); - var partialFiles = new List(); - - for (int i = 0; i < totalFiles; i++) - { - cts.Token.ThrowIfCancellationRequested(); - var file = files[i]; - var fileName = Path.GetFileName(file); - var fileExtension = Path.GetExtension(file).ToUpperInvariant(); - - try - { - var currentIndex = i + 1; - VectorizingProgress = (int)((double)currentIndex / totalFiles * 100); - VectorizingProgressText = $"正在处理 {currentIndex}/{totalFiles}: {fileName}"; - - Logger?.LogInformation("正在处理 ({Index}/{Total}): {FileName} [{Extension}]", - currentIndex, totalFiles, fileName, fileExtension); - - // 执行向量化:根据结构化结果区分完全成功/部分成功/失败 - var result = await ragIngestionService.IngestFileAsync( - collection, collectionName, file, embeddingGenerator, cts.Token); - - if (result.IsSuccess) - { - successCount++; - Logger?.LogInformation("✓ 成功向量化: {FileName}", fileName); - } - else if (result.IsPartialSuccess) - { - // 部分成功不计入完全成功 - partialCount++; - partialFiles.Add($"{fileName}({result.Failures.Count} 个块失败)"); - Logger?.LogWarning("△ 部分成功向量化: {FileName},{BlockCount} 块中 {Failed} 个失败", - fileName, result.BlockCount, result.Failures.Count); - } - else - { - failedCount++; - failedFiles.Add(fileName); - var reason = result.Failures.FirstOrDefault()?.Message ?? "没有内容入库"; - Logger?.LogError("✗ 向量化失败: {FileName} - {Reason}", fileName, reason); - } - } - catch (OperationCanceledException) when (cts.IsCancellationRequested) - { - VectorizingProgressText = "向量化已取消"; - _notificationService.ShowWarning("向量化已取消。已完成的部分保持有效。"); - Logger?.LogWarning("向量化被用户取消"); - return; - } - catch (Exception ex) - { - failedCount++; - failedFiles.Add(fileName); - Logger?.LogError(ex, "✗ 向量化失败: {FileName} - {ErrorMessage}", fileName, ex.Message); - - // 单个文件失败不中断整体流程,继续处理下一个 - } - } - - // 显示完成消息(三态:完全成功 / 部分成功 / 失败) - VectorizingProgress = 100; - if (failedCount == 0 && partialCount == 0) - { - VectorizingProgressText = $"✅ 全部完成!共 {successCount} 个文件"; - _notificationService.ShowSuccess($"✅ 所有文档向量化完成!\n成功处理 {successCount} 个文件"); - Logger?.LogInformation("向量化完成:成功 {Success}/{Total} 个", successCount, totalFiles); - } - else - { - var summaryText = $"⚠️ 完成(存在失败): {successCount} 成功, {partialCount} 部分成功, {failedCount} 失败"; - VectorizingProgressText = summaryText; - - var failedList = string.Join("\n- ", failedFiles.Take(5)); - if (failedFiles.Count > 5) - { - failedList += $"\n... 还有 {failedFiles.Count - 5} 个"; - } - - var partialList = string.Join("\n- ", partialFiles.Take(5)); - if (partialFiles.Count > 5) - { - partialList += $"\n... 还有 {partialFiles.Count - 5} 个"; - } - - _notificationService.ShowWarning( - $"向量化完成:\n✓ 完全成功 {successCount} 个\n△ 部分成功 {partialCount} 个\n✗ 失败 {failedCount} 个" + - (partialFiles.Count > 0 ? $"\n\n部分成功(存在失败块):\n- {partialList}" : string.Empty) + - (failedFiles.Count > 0 ? $"\n\n失败文件:\n- {failedList}" : string.Empty)); - - Logger?.LogWarning("向量化完成:成功 {Success} 个,部分成功 {Partial} 个,失败 {Failed} 个,总计 {Total} 个", - successCount, partialCount, failedCount, totalFiles); - } - } - catch (OperationCanceledException) when (cts.IsCancellationRequested) - { - // 取消发生在文件间隙或准备阶段 - VectorizingProgressText = "向量化已取消"; - _notificationService.ShowWarning("向量化已取消。已完成的部分保持有效。"); - Logger?.LogWarning("向量化被用户取消"); - } - catch (Exception ex) - { - VectorizingProgressText = "向量化失败"; - Logger?.LogError(ex, "向量化过程发生严重错误"); - _notificationService.ShowError(ErrorMessageMapper.GetUserFriendlyMessageWithContext(ex, "向量化")); - } - finally - { - IsVectorizing = false; - if (ReferenceEquals(_vectorizationCts, cts)) - _vectorizationCts = null; - cts.Dispose(); - System.Threading.Interlocked.Exchange(ref _activeVectorizations, 0); - } - } - [RelayCommand] private async Task SaveAsync() { @@ -815,107 +358,6 @@ private void NavigateToMCPConfig() WeakReferenceMessenger.Default.Send(new NavigationMessage("MCPConfig")); } - /// - /// 从服务商 API 获取模型列表(用户填好 API Key 后手动触发) - /// - private bool CanFetchModelsCommand() => CanFetchModels; - - [RelayCommand(CanExecute = nameof(CanFetchModelsCommand))] - private async Task FetchModels() - { - var provider = SelectedProvider; - if (provider is null || !provider.SupportsModelListing) - return; - - // 只取消上一个在飞请求,不 Dispose(其令牌仍被在飞请求持有,由该请求自身 finally 释放) - _modelFetchCancellationTokenSource?.Cancel(); - var cts = new CancellationTokenSource(); - _modelFetchCancellationTokenSource = cts; - var requestedProviderId = provider.Id; - - ModelDiscoveryStatus = $"正在从 {provider.DisplayName} 获取模型目录..."; - IsLoadingModels = true; - try - { - var apiKey = string.IsNullOrWhiteSpace(ApiKey) ? null : ApiKey; - var endpoint = provider.AllowsEndpointOverride && !string.IsNullOrWhiteSpace(Endpoint) - ? Endpoint - : null; - var models = await _modelDiscoveryService.ListModelsAsync( - provider, - apiKey, - endpoint, - cts.Token); - - // 取消不能保证远端立即停止;响应落 UI 前再次校验 Provider 身份。 - if (cts.IsCancellationRequested || SelectedProvider?.Id != requestedProviderId) - return; - - Models.Clear(); - foreach (var model in models) - Models.Add(model); - - ModelDiscoveryStatus = Models.Count == 0 - ? "服务商未返回可用模型,请直接输入模型 ID" - : $"已获取 {Models.Count} 个模型,可直接选择或继续手工输入"; - } - catch (OperationCanceledException) when (cts.IsCancellationRequested) - { - Logger?.LogDebug("已取消服务商 {ProviderId} 的模型列表请求", requestedProviderId); - } - catch (HttpRequestException ex) when ( - ex.StatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden) - { - HandleModelDiscoveryFailure( - requestedProviderId, - ex, - $"{provider.DisplayName} 拒绝访问,请检查 API Key"); - } - catch (Exception ex) - { - HandleModelDiscoveryFailure( - requestedProviderId, - ex, - $"获取失败:{ErrorMessageMapper.GetUserFriendlyMessage(ex)}"); - } - finally - { - if (ReferenceEquals(_modelFetchCancellationTokenSource, cts)) - { - _modelFetchCancellationTokenSource = null; - IsLoadingModels = false; - } - cts.Dispose(); - } - } - - private void HandleModelDiscoveryFailure( - string requestedProviderId, - Exception exception, - string status) - { - if (SelectedProvider?.Id != requestedProviderId) - return; - - ModelDiscoveryStatus = $"{status},仍可直接输入模型 ID"; - Logger?.LogWarning(exception, "获取服务商模型列表失败: {ProviderId}", requestedProviderId); - _notificationService.ShowError(ModelDiscoveryStatus); - } - - private async Task OpenUrlAsync(string url) - { - await SafeExecuteAsync(async () => - { - var psi = new System.Diagnostics.ProcessStartInfo - { - FileName = url, - UseShellExecute = true - }; - System.Diagnostics.Process.Start(psi); - await Task.CompletedTask; - }, "打开链接"); - } - private bool _disposed; public void Dispose() @@ -936,4 +378,4 @@ public void Dispose() GC.SuppressFinalize(this); } -} \ No newline at end of file +} diff --git a/src/MarketAssistant.App/Views/Pages/Trading/TradingPageView.axaml b/src/MarketAssistant.App/Views/Pages/Trading/TradingPageView.axaml index 2ff75e7..37f7bcc 100644 --- a/src/MarketAssistant.App/Views/Pages/Trading/TradingPageView.axaml +++ b/src/MarketAssistant.App/Views/Pages/Trading/TradingPageView.axaml @@ -47,10 +47,10 @@ - - diff --git a/src/MarketAssistant.DataProviders/AGENTS.md b/src/MarketAssistant.DataProviders/AGENTS.md index d254521..e0338cd 100644 --- a/src/MarketAssistant.DataProviders/AGENTS.md +++ b/src/MarketAssistant.DataProviders/AGENTS.md @@ -12,7 +12,6 @@ MarketAssistant.DataProviders/ ├── BinanceMarketDataModels.cs ← Binance 本地模型(24hrTicker、ExchangeInfo) ├── BinanceWebSocketService.cs ← Binance WebSocket 实时价格推送 ├── CoinGeckoApiService.cs ← CoinGecko REST API(市值、排名、涨跌幅) -├── CoinDeskApiService.cs ← CoinDesk REST API(项目元数据、新闻) ├── AShare/ ← A 股数据客户端(P1-05 下沉) │ ├── ClsQuoteClient.cs ← 财联社行情 /quote/stock/basic 与搜索 /api/sw │ ├── ZhiTuMarketClient.cs ← 智兔财务/技术指标/K线/资金流/公司资料 @@ -31,7 +30,6 @@ MarketAssistant.DataProviders/ |------|------|---------| | **Binance** | `api.binance.com` / `fapi.binance.com` | 部分地区受限,需 VPN/代理 | | **CoinGecko** | `api.coingecko.com` | 免费版有频率限制(~24 req/min),`CoinGeckoApiService` 内置限流 | -| **CoinDesk** | `data-api.coindesk.com` | 无特殊限制 | | **财联社 (Cls)** | `x-quote.cls.cn` / `www.cls.cn` | 命名 HttpClient `Cls`;行情无需签名 | | **智兔 (ZhiTu)** | `api.zhituapi.com` | 命名 HttpClient `ZhiTu`;Token 由调用方传入,禁止硬编码 | | **东方财富** | `search-api-web.eastmoney.com` | 命名 HttpClient `EastMoneySearch`;返回 JSONP 需剥离 | diff --git a/src/MarketAssistant.Rag/Services/PdfHeadingDetector.cs b/src/MarketAssistant.Rag/Services/PdfHeadingDetector.cs new file mode 100644 index 0000000..ceff62a --- /dev/null +++ b/src/MarketAssistant.Rag/Services/PdfHeadingDetector.cs @@ -0,0 +1,152 @@ +namespace MarketAssistant.Rag.Services; + +/// +/// PDF 标题识别与层级判定(internal 静态类,纯算法,无状态)。 +/// +internal static class PdfHeadingDetector +{ + internal static bool IsHeading(StructuredLine line, DocumentInfo docInfo) + { + var text = line.Text.Trim(); + if (string.IsNullOrEmpty(text)) return false; + + // 检查章节编号 + if (PdfTextUtility.ChapterNumberRegex.IsMatch(text) || PdfTextUtility.SectionNumberRegex.IsMatch(text) || + PdfTextUtility.ChineseSectionRegex.IsMatch(text) || PdfTextUtility.ChineseSubSectionRegex.IsMatch(text)) + return true; + + // 检查字体大小 + var fontSizeRatio = line.FontSize / docInfo.AverageFontSize; + if (fontSizeRatio >= docInfo.DynamicThresholds.Level3Threshold) + { + // 附加条件:较短的文本,不以句号结尾,首字符是字母、数字或中文字符 + return text.Length <= 100 && + !text.EndsWith('.') && + (char.IsUpper(text[0]) || char.IsLetter(text[0]) || char.IsDigit(text[0]) || PdfTextUtility.IsChinese(text[0])) && + text.Split(' ').Length <= 10; + } + + // 检查粗体 + if (line.IsBold && text.Length <= 100 && !text.EndsWith('.')) + return true; + + return false; + } + + internal static int DetermineHeadingLevel(StructuredLine line, DocumentInfo docInfo) + { + var text = line.Text.Trim(); + var fontSizeRatio = line.FontSize / docInfo.AverageFontSize; + var thresholds = docInfo.DynamicThresholds; + + // 1. 最高优先级:章节编号 (第X章) + if (PdfTextUtility.ChapterNumberRegex.IsMatch(text)) + return 1; + + // 2. 高优先级:明确的编号格式 + // 中文主要章节编号 (一、二、三、...) + if (PdfTextUtility.ChineseSectionRegex.IsMatch(text)) + { + // 如果字体很大,可能是一级标题,否则是二级 + return fontSizeRatio >= thresholds.Level1Threshold ? 1 : 2; + } + + // 中文子章节编号 ((一)、(二)、...) + if (PdfTextUtility.ChineseSubSectionRegex.IsMatch(text)) + return 3; + + // 阿拉伯数字编号 (1.1, 1.2, ...) + if (PdfTextUtility.SectionNumberRegex.IsMatch(text)) + { + var match = PdfTextUtility.SectionNumberRegex.Match(text); + var numberParts = match.Groups[1].Value.Split('.'); + return Math.Min(numberParts.Length, 6); + } + + // 3. 基于字体大小的智能判断 + if (fontSizeRatio >= thresholds.Level1Threshold) + { + return 1; + } + if (fontSizeRatio >= thresholds.Level2Threshold) + { + return 2; + } + if (fontSizeRatio >= thresholds.Level3Threshold) + { + return 3; + } + + // 4. 基于格式的判断 + if (line.IsBold) + { + // 粗体文本,根据字体大小确定级别 + if (fontSizeRatio >= 1.2) return 3; + return 4; + } + + // 5. 默认较低级别 + return 5; + } + + internal static DynamicThresholds CalculateDynamicThresholds(List candidates, double avgFontSize) + { + if (!candidates.Any()) + { + return new DynamicThresholds(); + } + + // 计算字体大小比率 + var fontRatios = candidates + .Select(c => c.FontSize / avgFontSize) + .Where(r => r >= 1.05) // 只考虑明显大于平均字体的候选项 + .OrderByDescending(r => r) + .Distinct() + .ToList(); + + if (fontRatios.Count == 0) + { + return new DynamicThresholds(); + } + + // 根据实际字体大小分布确定阈值 + var thresholds = new DynamicThresholds(); + + if (fontRatios.Count == 1) + { + // 只有一个字体大小级别 + var ratio = fontRatios[0]; + thresholds = new DynamicThresholds + { + Level1Threshold = ratio, + Level2Threshold = Math.Max(ratio - 0.2, PdfTextUtility.SmallHeadingThreshold), + Level3Threshold = PdfTextUtility.SmallHeadingThreshold, + FontSizeBreakpoints = new List { ratio } + }; + } + else if (fontRatios.Count == 2) + { + // 两个字体大小级别 + thresholds = new DynamicThresholds + { + Level1Threshold = fontRatios[0], + Level2Threshold = fontRatios[1], + Level3Threshold = Math.Max(fontRatios[1] - 0.1, PdfTextUtility.SmallHeadingThreshold), + FontSizeBreakpoints = fontRatios + }; + } + else + { + // 多个字体大小级别,取前三个主要级别 + thresholds = new DynamicThresholds + { + Level1Threshold = fontRatios[0], + Level2Threshold = fontRatios[1], + Level3Threshold = fontRatios[2], + FontSizeBreakpoints = fontRatios.Take(4).ToList() + }; + } + + return thresholds; + } +} diff --git a/src/MarketAssistant.Rag/Services/PdfImageProcessor.cs b/src/MarketAssistant.Rag/Services/PdfImageProcessor.cs new file mode 100644 index 0000000..0f4f4c0 --- /dev/null +++ b/src/MarketAssistant.Rag/Services/PdfImageProcessor.cs @@ -0,0 +1,95 @@ +using System.Text; +using MarketAssistant.Rag.Interfaces; +using PdfPage = UglyToad.PdfPig.Content.Page; + +namespace MarketAssistant.Rag.Services; + +/// +/// PDF 页内图片提取与 Markdown 图片引用生成(依赖 持久化)。 +/// +internal sealed class PdfImageProcessor +{ + private readonly IImageStorageService _imageStorageService; + + public PdfImageProcessor(IImageStorageService imageStorageService) + { + _imageStorageService = imageStorageService ?? throw new ArgumentNullException(nameof(imageStorageService)); + } + + public async Task ProcessPageImages(PdfPage page, StringBuilder markdown, int pageNumber, string filePath) + { + try + { + var images = page.GetImages(); + var imageCount = 0; + + foreach (var image in images) + { + imageCount++; + var altText = $"页面{pageNumber}图片{imageCount}"; + + // 生成标准的图片文件名 + var imageFileName = $"page{pageNumber}_image{imageCount}.png"; + + try + { + // 提取图片字节数据 + var imageBytes = ExtractImageBytes(image); + if (imageBytes != null && imageBytes.Length > 0) + { + // 保存图片 + var imagePath = await _imageStorageService.SaveImageAsync(imageBytes, imageFileName, filePath); + var relativeImagePath = Path.GetRelativePath(Path.GetDirectoryName(filePath)!, imagePath); + + markdown.AppendLine(); + markdown.AppendLine($"![{altText}]({relativeImagePath})"); + markdown.AppendLine(); + } + else + { + // 无法提取图片,使用占位符 + markdown.AppendLine(); + markdown.AppendLine($"![{altText}](图片占位符: {imageFileName})"); + markdown.AppendLine(); + } + } + catch (Exception imgEx) + { + System.Diagnostics.Debug.WriteLine($"提取第{pageNumber}页图片{imageCount}时出错: {imgEx.Message}"); + // 提取失败,使用占位符 + markdown.AppendLine(); + markdown.AppendLine($"![{altText}](图片提取失败: {imageFileName})"); + markdown.AppendLine(); + } + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"处理第{pageNumber}页图片时出错: {ex.Message}"); + } + } + + private static byte[]? ExtractImageBytes(UglyToad.PdfPig.Content.IPdfImage image) + { + try + { + // 检查图片是否有原始字节数据 + var rawBytes = image.RawBytes; + if (rawBytes.Length > 0) + { + return rawBytes.ToArray(); + } + + // 如果没有原始字节数据,尝试从其他属性获取 + // 注意:这里可能需要根据不同的图片格式进行特殊处理 + // 对于复杂的PDF图片提取,可能需要更高级的处理逻辑 + + return null; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"提取图片字节数据时出错: {ex.Message}"); + return null; + } + } +} diff --git a/src/MarketAssistant.Rag/Services/PdfListFormatter.cs b/src/MarketAssistant.Rag/Services/PdfListFormatter.cs new file mode 100644 index 0000000..c68b406 --- /dev/null +++ b/src/MarketAssistant.Rag/Services/PdfListFormatter.cs @@ -0,0 +1,43 @@ +namespace MarketAssistant.Rag.Services; + +/// +/// PDF 列表识别、缩进计算与段落合并判断(internal 静态类,纯算法,无状态)。 +/// +internal static class PdfListFormatter +{ + internal static bool IsNumberedList(string text) => PdfTextUtility.NumberedListRegex.IsMatch(text); + + internal static bool IsBulletList(string text) => PdfTextUtility.BulletListRegex.IsMatch(text); + + internal static int CalculateIndentLevel(double leftMargin, List allLines) + { + if (!allLines.Any()) return 0; + + var avgMargin = allLines.Average(l => l.LeftMargin); + var marginDiff = leftMargin - avgMargin; + + return Math.Max(0, (int)(marginDiff / 20)); // 每20个单位为一个缩进级别 + } + + internal static bool ShouldMergeWithPrevious(StructuredLine current, List lines, int currentIndex) + { + if (currentIndex == 0) return false; + + var previous = lines[currentIndex - 1]; + var currentText = current.Text.Trim(); + var previousText = previous.Text.Trim(); + + // 如果当前行以小写字母开始,且前一行不以句号结尾,可能是同一段落 + if (char.IsLower(currentText[0]) && !previousText.EndsWith('.') && !previousText.EndsWith(':')) + { + // 检查字体大小是否相似 + var fontSizeDiff = Math.Abs(current.FontSize - previous.FontSize); + if (fontSizeDiff < 2.0) + { + return true; + } + } + + return false; + } +} diff --git a/src/MarketAssistant.Rag/Services/PdfMarkdownConverter.cs b/src/MarketAssistant.Rag/Services/PdfMarkdownConverter.cs index b288ef4..436335c 100644 --- a/src/MarketAssistant.Rag/Services/PdfMarkdownConverter.cs +++ b/src/MarketAssistant.Rag/Services/PdfMarkdownConverter.cs @@ -9,34 +9,17 @@ namespace MarketAssistant.Rag.Services; /// /// PDF到Markdown转换器 /// 使用UglyToad.PdfPig提取文本并转换为Markdown格式 -/// 支持标题识别、段落处理、列表识别、表格处理和图片引用 +/// 支持标题识别、段落处理、列表识别、表格处理和图片引用。 +/// 表格/标题/列表/图片/后处理等算法已下沉到各自辅助类(PdfTableExtractor 等),本类保留编排与文档分析。 /// public class PdfMarkdownConverter : IMarkdownConverter { - // 编译的正则表达式,提高性能 - private static readonly Regex MultipleSpacesRegex = new(@"\s{2,}", RegexOptions.Compiled); - private static readonly Regex MultipleNewlinesRegex = new(@"\n{3,}", RegexOptions.Compiled); - private static readonly Regex NumberedListRegex = new(@"^\s*(\d+\.|\d+\)|\(\d+\))\s+", RegexOptions.Compiled); - private static readonly Regex BulletListRegex = new(@"^\s*[•\-\*\◦\▪\▫]\s+", RegexOptions.Compiled); - private static readonly Regex ChapterNumberRegex = new(@"^\s*(第[一二三四五六七八九十\d]+章|Chapter\s+\d+|CHAPTER\s+\d+)\s*", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex SectionNumberRegex = new(@"^\s*(\d+(\.\d+)*)\s+", RegexOptions.Compiled); - private static readonly Regex ChineseSectionRegex = new(@"^\s*([一二三四五六七八九十]|[1-9]\d*)、", RegexOptions.Compiled); - private static readonly Regex ChineseSubSectionRegex = new(@"^\s*(([一二三四五六七八九十]|[1-9]\d*))", RegexOptions.Compiled); - private static readonly Regex TablePatternRegex = new(@"^\s*\|.*\|\s*$", RegexOptions.Compiled); - - // 字体大小阈值 - private const double LargeHeadingThreshold = 1.5; - private const double MediumHeadingThreshold = 1.3; - private const double SmallHeadingThreshold = 1.1; - - // 表格检测参数 - private const int MinTableColumns = 2; - - private readonly IImageStorageService _imageStorageService; + private readonly PdfImageProcessor _pdfImageProcessor; public PdfMarkdownConverter(IImageStorageService imageStorageService) { - _imageStorageService = imageStorageService ?? throw new ArgumentNullException(nameof(imageStorageService)); + ArgumentNullException.ThrowIfNull(imageStorageService); + _pdfImageProcessor = new PdfImageProcessor(imageStorageService); } public bool CanConvert(string filePath) => @@ -80,7 +63,7 @@ private async Task ConvertPdfToMarkdown(string filePath) } } - return PostProcessMarkdown(markdownBuilder.ToString()); + return PdfMarkdownPostProcessor.PostProcessMarkdown(markdownBuilder.ToString()); } private DocumentInfo AnalyzeDocument(PdfDocument document) @@ -119,10 +102,10 @@ private DocumentInfo AnalyzeDocument(PdfDocument document) var currentAvgFontSize = wordCount > 0 ? fontSum / wordCount : 12.0; var fontSizeRatio = line.FontSize / currentAvgFontSize; if (fontSizeRatio >= 1.1 || line.IsBold || - ChapterNumberRegex.IsMatch(text) || - ChineseSectionRegex.IsMatch(text) || - ChineseSubSectionRegex.IsMatch(text) || - SectionNumberRegex.IsMatch(text)) + PdfTextUtility.ChapterNumberRegex.IsMatch(text) || + PdfTextUtility.ChineseSectionRegex.IsMatch(text) || + PdfTextUtility.ChineseSubSectionRegex.IsMatch(text) || + PdfTextUtility.SectionNumberRegex.IsMatch(text)) { headingCandidates.Add(new HeadingCandidate { @@ -146,7 +129,7 @@ private DocumentInfo AnalyzeDocument(PdfDocument document) var minFontSize = allFontSizes.Any() ? allFontSizes.Min() : 12.0; // 分析标题级别分布,确定动态阈值 - var headingThresholds = CalculateDynamicThresholds(headingCandidates, avgFontSize); + var headingThresholds = PdfHeadingDetector.CalculateDynamicThresholds(headingCandidates, avgFontSize); return new DocumentInfo { @@ -166,13 +149,13 @@ private async Task ProcessPage(PdfPage page, StringBuilder markdown, DocumentInf var structuredLines = ExtractStructuredLines(page, docInfo); // 检测表格 - var tables = DetectTables(structuredLines); + var tables = PdfTableExtractor.DetectTables(structuredLines); // 处理结构化内容 ProcessStructuredContent(structuredLines, tables, markdown, docInfo); // 处理图片 - await ProcessPageImages(page, markdown, pageNumber, filePath); + await _pdfImageProcessor.ProcessPageImages(page, markdown, pageNumber, filePath); } catch (Exception ex) { @@ -181,7 +164,7 @@ private async Task ProcessPage(PdfPage page, StringBuilder markdown, DocumentInf var fallbackText = page.Text; if (!string.IsNullOrWhiteSpace(fallbackText)) { - markdown.AppendLine(CleanText(fallbackText)); + markdown.AppendLine(PdfTextUtility.CleanText(fallbackText)); markdown.AppendLine(); } } @@ -260,125 +243,6 @@ private List ExtractStructuredLines(PdfPage page, DocumentInfo d return lines; } - private List DetectTables(List lines) - { - var tables = new List(); - if (lines.Count == 0) return tables; - - // 先为每一行基于词间距拆分潜在单元格 - var lineCellInfos = new List(); - for (int i = 0; i < lines.Count; i++) - { - lineCellInfos.Add(SplitLineIntoCells(lines[i])); - } - - bool IsLikelyTableLine(LineCells lc) - { - if (lc.Cells.Count < MinTableColumns) return false; - // 平均单元格长度(字符) - var avgLen = lc.Cells.Average(c => c.Text.Length); - if (avgLen > 60) return false; // 太长可能是段落 - // 含有数字或列数≥3 更倾向于表格 - bool hasDigit = lc.Cells.Any(c => c.Text.Any(char.IsDigit)); - return hasDigit || lc.Cells.Count >= 3; - } - - int idx = 0; - while (idx < lines.Count) - { - if (!IsLikelyTableLine(lineCellInfos[idx])) { idx++; continue; } - - int start = idx; - int lastTableLine = idx; - int gapAllowance = 1; // 允许夹1行非表格(多行单元格内容) - int gaps = 0; - var candidateLineCells = new List<(int index, LineCells cells)>(); - - while (idx < lines.Count) - { - var lc = lineCellInfos[idx]; - if (IsLikelyTableLine(lc)) - { - candidateLineCells.Add((idx, lc)); - lastTableLine = idx; - gaps = 0; - idx++; - } - else if (gaps < gapAllowance) - { - // 可能是前一单元格的续行,先暂存(不直接作为表格解析行) - gaps++; - idx++; - } - else - { - break; - } - } - - if (candidateLineCells.Count >= 2) - { - var tableLines = candidateLineCells.Select(c => lines[c.index]).ToList(); - var rowIndices = candidateLineCells.Select(c => c.index).ToList(); - tables.Add(new TableInfo - { - Rows = tableLines, - StartIndex = rowIndices.Min(), - EndIndex = rowIndices.Max(), - ActualRowIndices = rowIndices - }); - } - else - { - // 不足以构成表格,回退一个位置继续 - idx = start + 1; - } - } - - return tables; - } - - // ========== 新的通用表格解析辅助结构与函数 ========== - - private record CellFragment(double Left, string Text); - private class LineCells - { - public StructuredLine Line { get; init; } = null!; - public List Cells { get; init; } = new(); - } - - private LineCells SplitLineIntoCells(StructuredLine line) - { - // 基于多个空格或明显的水平间隔(Left差值)来拆分 - var words = line.Text.Split(' ', StringSplitOptions.RemoveEmptyEntries); - var cells = new List(); - if (words.Length == 0) - { - return new LineCells { Line = line, Cells = cells }; - } - - // 由于我们没有逐词坐标(为保持侵入性最小,不修改上层结构),用多空格估计分列 - // 如果后续需要更精准,可在 StructuredLine 中保留 word 级坐标。 - var raw = line.Text; - // 使用两个及以上空格或制表符作为列分隔符 - var split = Regex.Split(raw.Trim(), @"(\s{2,}|\t+)").Where(s => !string.IsNullOrWhiteSpace(s) && !Regex.IsMatch(s, @"^\s{2,}$")).ToList(); - if (split.Count <= 1) - { - // 回退:用单空格分但只取>2列的情况 - if (words.Length >= 3) - { - split = words.ToList(); - } - } - double currentLeft = line.LeftMargin; - foreach (var s in split) - { - cells.Add(new CellFragment(currentLeft, CleanText(s))); - currentLeft += 50; // 人工递增,后面列对齐时只用相对顺序 - } - return new LineCells { Line = line, Cells = cells }; - } - private void ProcessStructuredContent(List lines, List tables, StringBuilder markdown, DocumentInfo docInfo) { var processedIndices = new HashSet(); @@ -389,7 +253,7 @@ private void ProcessStructuredContent(List lines, List lines, List i == t.StartIndex); if (table != null) { - ProcessTable(table, markdown); + PdfTableExtractor.ProcessTable(table, markdown); // 只标记实际的表格行为已处理,避免错误排除正常文本 foreach (var tableRowIndex in table.ActualRowIndices) @@ -415,35 +279,35 @@ private void ProcessStructuredContent(List lines, List lines, List> AnalyzeAndRestructureTable(List rows) - { - // 通用:用 SplitLineIntoCells 重新对齐列,选择出现频率最高的列数 - var lineCells = rows.Select(SplitLineIntoCells).ToList(); - var columnCounts = lineCells.Where(lc => lc.Cells.Count >= MinTableColumns).Select(lc => lc.Cells.Count).ToList(); - if (!columnCounts.Any()) return new List>(); - int targetColumns = columnCounts - .GroupBy(c => c) - .OrderByDescending(g => g.Count()) - .ThenByDescending(g => g.Key) - .First().Key; - - var table = new List>(); - - foreach (var lc in lineCells) - { - if (lc.Cells.Count < 1) continue; - if (lc.Cells.Count == targetColumns) - { - table.Add(lc.Cells.Select(c => c.Text).ToList()); - } - else if (lc.Cells.Count > targetColumns) - { - // 合并多余列(从右向左合并最短文本) - var cells = lc.Cells.Select(c => c.Text).ToList(); - while (cells.Count > targetColumns) - { - // 找到两个最短相邻合并 - int mergeIndex = 0; - int minLen = int.MaxValue; - for (int i = 0; i < cells.Count - 1; i++) - { - int lens = cells[i].Length + cells[i + 1].Length; - if (lens < minLen) - { - minLen = lens; - mergeIndex = i; - } - } - cells[mergeIndex] = (cells[mergeIndex] + " " + cells[mergeIndex + 1]).Trim(); - cells.RemoveAt(mergeIndex + 1); - } - table.Add(cells); - } - else // 少于目标列,尝试用空列填充(多行单元格可能导致) - { - var cells = lc.Cells.Select(c => c.Text).ToList(); - while (cells.Count < targetColumns) cells.Add(""); - table.Add(cells); - } - } - - // 尝试识别表头:第一行如果所有列都是非数字且长度适中 - if (table.Count > 1) - { - bool firstRowHeader = table[0].Count(c => c.Any(char.IsLetter)) >= Math.Max(2, targetColumns - 1) && - table[0].Any(c => c.Contains("名称") || c.Contains("地区") || c.Contains("时间") || c.Contains("类") || c.Contains("种")); - if (!firstRowHeader) - { - // 生成一个通用表头 - var header = new List(); - for (int i = 0; i < targetColumns; i++) header.Add($"列{i + 1}"); - table.Insert(0, header); - } - } - return table; - } - - // 旧的特定案例处理逻辑已移除,保留最小必要工具函数 - private bool IsDataRow(string text) => text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Count(w => w.Any(char.IsDigit)) >= 2; - - private async Task ProcessPageImages(PdfPage page, StringBuilder markdown, int pageNumber, string filePath) - { - try - { - var images = page.GetImages(); - var imageCount = 0; - - foreach (var image in images) - { - imageCount++; - var altText = $"页面{pageNumber}图片{imageCount}"; - - // 生成标准的图片文件名 - var imageFileName = $"page{pageNumber}_image{imageCount}.png"; - - try - { - // 提取图片字节数据 - var imageBytes = ExtractImageBytes(image); - if (imageBytes != null && imageBytes.Length > 0) - { - // 保存图片 - var imagePath = await _imageStorageService.SaveImageAsync(imageBytes, imageFileName, filePath); - var relativeImagePath = Path.GetRelativePath(Path.GetDirectoryName(filePath)!, imagePath); - - markdown.AppendLine(); - markdown.AppendLine($"![{altText}]({relativeImagePath})"); - markdown.AppendLine(); - } - else - { - // 无法提取图片,使用占位符 - markdown.AppendLine(); - markdown.AppendLine($"![{altText}](图片占位符: {imageFileName})"); - markdown.AppendLine(); - } - } - catch (Exception imgEx) - { - System.Diagnostics.Debug.WriteLine($"提取第{pageNumber}页图片{imageCount}时出错: {imgEx.Message}"); - // 提取失败,使用占位符 - markdown.AppendLine(); - markdown.AppendLine($"![{altText}](图片提取失败: {imageFileName})"); - markdown.AppendLine(); - } - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"处理第{pageNumber}页图片时出错: {ex.Message}"); - } - } - - private byte[]? ExtractImageBytes(UglyToad.PdfPig.Content.IPdfImage image) - { - try - { - // 检查图片是否有原始字节数据 - var rawBytes = image.RawBytes; - if (rawBytes.Length > 0) - { - return rawBytes.ToArray(); - } - - // 如果没有原始字节数据,尝试从其他属性获取 - // 注意:这里可能需要根据不同的图片格式进行特殊处理 - // 对于复杂的PDF图片提取,可能需要更高级的处理逻辑 - - return null; - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"提取图片字节数据时出错: {ex.Message}"); - return null; - } - } - - private bool IsHeading(StructuredLine line, DocumentInfo docInfo) - { - var text = line.Text.Trim(); - if (string.IsNullOrEmpty(text)) return false; - - // 检查章节编号 - if (ChapterNumberRegex.IsMatch(text) || SectionNumberRegex.IsMatch(text) || - ChineseSectionRegex.IsMatch(text) || ChineseSubSectionRegex.IsMatch(text)) - return true; - - // 检查字体大小 - var fontSizeRatio = line.FontSize / docInfo.AverageFontSize; - if (fontSizeRatio >= docInfo.DynamicThresholds.Level3Threshold) - { - // 附加条件:较短的文本,不以句号结尾,首字符是字母、数字或中文字符 - return text.Length <= 100 && - !text.EndsWith('.') && - (char.IsUpper(text[0]) || char.IsLetter(text[0]) || char.IsDigit(text[0]) || IsChinese(text[0])) && - text.Split(' ').Length <= 10; - } - - // 检查粗体 - if (line.IsBold && text.Length <= 100 && !text.EndsWith('.')) - return true; - - return false; - } - - private static bool IsChinese(char c) - { - // 检查是否为中文字符(CJK统一汉字) - return c >= 0x4E00 && c <= 0x9FFF || // CJK Unified Ideographs - c >= 0x3400 && c <= 0x4DBF || // CJK Extension A - c >= 0x20000 && c <= 0x2A6DF; // CJK Extension B - } - - private int DetermineHeadingLevel(StructuredLine line, DocumentInfo docInfo) - { - var text = line.Text.Trim(); - var fontSizeRatio = line.FontSize / docInfo.AverageFontSize; - var thresholds = docInfo.DynamicThresholds; - - // 1. 最高优先级:章节编号 (第X章) - if (ChapterNumberRegex.IsMatch(text)) - return 1; - - // 2. 高优先级:明确的编号格式 - // 中文主要章节编号 (一、二、三、...) - if (ChineseSectionRegex.IsMatch(text)) - { - // 如果字体很大,可能是一级标题,否则是二级 - return fontSizeRatio >= thresholds.Level1Threshold ? 1 : 2; - } - - // 中文子章节编号 ((一)、(二)、...) - if (ChineseSubSectionRegex.IsMatch(text)) - return 3; - - // 阿拉伯数字编号 (1.1, 1.2, ...) - if (SectionNumberRegex.IsMatch(text)) - { - var match = SectionNumberRegex.Match(text); - var numberParts = match.Groups[1].Value.Split('.'); - return Math.Min(numberParts.Length, 6); - } - - // 3. 基于字体大小的智能判断 - if (fontSizeRatio >= thresholds.Level1Threshold) - { - return 1; - } - if (fontSizeRatio >= thresholds.Level2Threshold) - { - return 2; - } - if (fontSizeRatio >= thresholds.Level3Threshold) - { - return 3; - } - - // 4. 基于格式的判断 - if (line.IsBold) - { - // 粗体文本,根据字体大小确定级别 - if (fontSizeRatio >= 1.2) return 3; - return 4; - } - - // 5. 默认较低级别 - return 5; - } - - private bool IsNumberedList(string text) => NumberedListRegex.IsMatch(text); - - private bool IsBulletList(string text) => BulletListRegex.IsMatch(text); - - private int CalculateIndentLevel(double leftMargin, List allLines) - { - if (!allLines.Any()) return 0; - - var avgMargin = allLines.Average(l => l.LeftMargin); - var marginDiff = leftMargin - avgMargin; - - return Math.Max(0, (int)(marginDiff / 20)); // 每20个单位为一个缩进级别 - } - - private bool ShouldMergeWithPrevious(StructuredLine current, List lines, int currentIndex) - { - if (currentIndex == 0) return false; - - var previous = lines[currentIndex - 1]; - var currentText = current.Text.Trim(); - var previousText = previous.Text.Trim(); - - // 如果当前行以小写字母开始,且前一行不以句号结尾,可能是同一段落 - if (char.IsLower(currentText[0]) && !previousText.EndsWith('.') && !previousText.EndsWith(':')) - { - // 检查字体大小是否相似 - var fontSizeDiff = Math.Abs(current.FontSize - previous.FontSize); - if (fontSizeDiff < 2.0) - { - return true; - } - } - - return false; - } - - private string CleanText(string text) - { - if (string.IsNullOrEmpty(text)) return string.Empty; - - // 移除多余的空格 - text = MultipleSpacesRegex.Replace(text, " "); - - // 处理常见的PDF编码问题 - text = text - .Replace("fi", "fi") // 连字符修复 - .Replace("fl", "fl") - .Replace("ff", "ff") - .Replace("ffi", "ffi") - .Replace("ffl", "ffl") - .Replace("–", "-") // 短破折号 - .Replace("—", "--") // 长破折号 - .Replace("\u201C", "\"") // 左双引号 - .Replace("\u201D", "\"") // 右双引号 - .Replace("\u2018", "'") // 左单引号 - .Replace("\u2019", "'") // 右单引号 - .Replace("\u2013", "-") // 短破折号 - .Replace("\u2014", "--") // 长破折号 - .Replace("\u00A0", " ") // 非断行空格 - .Trim(); - - return text; - } - - private string PostProcessMarkdown(string markdown) - { - if (string.IsNullOrEmpty(markdown)) return string.Empty; - - // 移除过多的空行 - markdown = MultipleNewlinesRegex.Replace(markdown, "\n\n"); - - // 修复格式问题 - var lines = markdown.Split('\n'); - var result = new StringBuilder(); - var previousLineWasEmpty = false; - - foreach (var line in lines) - { - var trimmedLine = line.Trim(); - - // 标题前后确保有空行 - if (trimmedLine.StartsWith('#')) - { - if (!previousLineWasEmpty && result.Length > 0) - { - result.AppendLine(); - } - result.AppendLine(trimmedLine); - result.AppendLine(); - previousLineWasEmpty = true; - } - // 列表项 - else if (trimmedLine.StartsWith("- ") || trimmedLine.StartsWith("1. ") || - Regex.IsMatch(trimmedLine, @"^\s*\d+\.\s")) - { - result.AppendLine(trimmedLine); - previousLineWasEmpty = false; - } - // 空行 - else if (string.IsNullOrWhiteSpace(trimmedLine)) - { - if (!previousLineWasEmpty) - { - result.AppendLine(); - previousLineWasEmpty = true; - } - } - // 普通内容 - else - { - result.AppendLine(trimmedLine); - previousLineWasEmpty = false; - } - } - - return result.ToString().Trim(); - } - - private DynamicThresholds CalculateDynamicThresholds(List candidates, double avgFontSize) - { - if (!candidates.Any()) - { - return new DynamicThresholds(); - } - - // 计算字体大小比率 - var fontRatios = candidates - .Select(c => c.FontSize / avgFontSize) - .Where(r => r >= 1.05) // 只考虑明显大于平均字体的候选项 - .OrderByDescending(r => r) - .Distinct() - .ToList(); - - if (fontRatios.Count == 0) - { - return new DynamicThresholds(); - } - - // 根据实际字体大小分布确定阈值 - var thresholds = new DynamicThresholds(); - - if (fontRatios.Count == 1) - { - // 只有一个字体大小级别 - var ratio = fontRatios[0]; - thresholds = new DynamicThresholds - { - Level1Threshold = ratio, - Level2Threshold = Math.Max(ratio - 0.2, SmallHeadingThreshold), - Level3Threshold = SmallHeadingThreshold, - FontSizeBreakpoints = new List { ratio } - }; - } - else if (fontRatios.Count == 2) - { - // 两个字体大小级别 - thresholds = new DynamicThresholds - { - Level1Threshold = fontRatios[0], - Level2Threshold = fontRatios[1], - Level3Threshold = Math.Max(fontRatios[1] - 0.1, SmallHeadingThreshold), - FontSizeBreakpoints = fontRatios - }; - } - else - { - // 多个字体大小级别,取前三个主要级别 - thresholds = new DynamicThresholds - { - Level1Threshold = fontRatios[0], - Level2Threshold = fontRatios[1], - Level3Threshold = fontRatios[2], - FontSizeBreakpoints = fontRatios.Take(4).ToList() - }; - } - - return thresholds; - } - - // 数据结构定义 - private class DocumentInfo - { - public double AverageFontSize { get; init; } - public double MaxFontSize { get; init; } - public double MinFontSize { get; init; } - public int TotalWords { get; init; } - public List HeadingCandidates { get; init; } = new(); - public DynamicThresholds DynamicThresholds { get; init; } = new(); - } - - private class HeadingCandidate - { - public required string Text { get; init; } - public double FontSize { get; init; } - public bool IsBold { get; init; } - public int PageNumber { get; init; } - } - - private class DynamicThresholds - { - public double Level1Threshold { get; init; } = LargeHeadingThreshold; - public double Level2Threshold { get; init; } = MediumHeadingThreshold; - public double Level3Threshold { get; init; } = SmallHeadingThreshold; - public List FontSizeBreakpoints { get; init; } = new(); - } - - private class StructuredLine - { - public required string Text { get; init; } - public double FontSize { get; init; } - public bool IsBold { get; init; } - public bool IsItalic { get; init; } - public double LeftMargin { get; init; } - public double TopPosition { get; init; } - public BoundingBox BoundingBox { get; init; } = new(); - } - - private class TableInfo - { - public required List Rows { get; init; } - public int StartIndex { get; init; } - public int EndIndex { get; init; } - public List ActualRowIndices { get; init; } = new List(); // 实际的表格行索引 - } - - private class BoundingBox - { - public double Left { get; init; } - public double Right { get; init; } - public double Top { get; init; } - public double Bottom { get; init; } - } } diff --git a/src/MarketAssistant.Rag/Services/PdfMarkdownModels.cs b/src/MarketAssistant.Rag/Services/PdfMarkdownModels.cs new file mode 100644 index 0000000..89b7566 --- /dev/null +++ b/src/MarketAssistant.Rag/Services/PdfMarkdownModels.cs @@ -0,0 +1,58 @@ +namespace MarketAssistant.Rag.Services; + +/// +/// PDF→Markdown 转换管线共享的数据结构(internal,仅供本程序集使用)。 +/// + +internal sealed class DocumentInfo +{ + public double AverageFontSize { get; init; } + public double MaxFontSize { get; init; } + public double MinFontSize { get; init; } + public int TotalWords { get; init; } + public List HeadingCandidates { get; init; } = new(); + public DynamicThresholds DynamicThresholds { get; init; } = new(); +} + +internal sealed class HeadingCandidate +{ + public required string Text { get; init; } + public double FontSize { get; init; } + public bool IsBold { get; init; } + public int PageNumber { get; init; } +} + +internal sealed class DynamicThresholds +{ + public double Level1Threshold { get; init; } = PdfTextUtility.LargeHeadingThreshold; + public double Level2Threshold { get; init; } = PdfTextUtility.MediumHeadingThreshold; + public double Level3Threshold { get; init; } = PdfTextUtility.SmallHeadingThreshold; + public List FontSizeBreakpoints { get; init; } = new(); +} + +internal sealed class StructuredLine +{ + public required string Text { get; init; } + public double FontSize { get; init; } + public bool IsBold { get; init; } + public bool IsItalic { get; init; } + public double LeftMargin { get; init; } + public double TopPosition { get; init; } + public BoundingBox BoundingBox { get; init; } = new(); +} + +internal sealed class TableInfo +{ + public required List Rows { get; init; } + public int StartIndex { get; init; } + public int EndIndex { get; init; } + public List ActualRowIndices { get; init; } = new List(); // 实际的表格行索引 +} + +internal sealed class BoundingBox +{ + public double Left { get; init; } + public double Right { get; init; } + public double Top { get; init; } + public double Bottom { get; init; } +} diff --git a/src/MarketAssistant.Rag/Services/PdfMarkdownPostProcessor.cs b/src/MarketAssistant.Rag/Services/PdfMarkdownPostProcessor.cs new file mode 100644 index 0000000..3101d55 --- /dev/null +++ b/src/MarketAssistant.Rag/Services/PdfMarkdownPostProcessor.cs @@ -0,0 +1,64 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace MarketAssistant.Rag.Services; + +/// +/// Markdown 输出后处理(internal 静态类):压缩空行、规范化标题/列表/段落间距。 +/// +internal static class PdfMarkdownPostProcessor +{ + internal static string PostProcessMarkdown(string markdown) + { + if (string.IsNullOrEmpty(markdown)) return string.Empty; + + // 移除过多的空行 + markdown = PdfTextUtility.MultipleNewlinesRegex.Replace(markdown, "\n\n"); + + // 修复格式问题 + var lines = markdown.Split('\n'); + var result = new StringBuilder(); + var previousLineWasEmpty = false; + + foreach (var line in lines) + { + var trimmedLine = line.Trim(); + + // 标题前后确保有空行 + if (trimmedLine.StartsWith('#')) + { + if (!previousLineWasEmpty && result.Length > 0) + { + result.AppendLine(); + } + result.AppendLine(trimmedLine); + result.AppendLine(); + previousLineWasEmpty = true; + } + // 列表项 + else if (trimmedLine.StartsWith("- ") || trimmedLine.StartsWith("1. ") || + Regex.IsMatch(trimmedLine, @"^\s*\d+\.\s")) + { + result.AppendLine(trimmedLine); + previousLineWasEmpty = false; + } + // 空行 + else if (string.IsNullOrWhiteSpace(trimmedLine)) + { + if (!previousLineWasEmpty) + { + result.AppendLine(); + previousLineWasEmpty = true; + } + } + // 普通内容 + else + { + result.AppendLine(trimmedLine); + previousLineWasEmpty = false; + } + } + + return result.ToString().Trim(); + } +} diff --git a/src/MarketAssistant.Rag/Services/PdfTableExtractor.cs b/src/MarketAssistant.Rag/Services/PdfTableExtractor.cs new file mode 100644 index 0000000..6c4bb52 --- /dev/null +++ b/src/MarketAssistant.Rag/Services/PdfTableExtractor.cs @@ -0,0 +1,238 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace MarketAssistant.Rag.Services; + +/// +/// PDF 表格检测与 Markdown 表格重建(internal 静态类,纯算法,无状态)。 +/// +internal static class PdfTableExtractor +{ + private const int MinTableColumns = 2; + + private sealed record CellFragment(double Left, string Text); + + private sealed class LineCells + { + public StructuredLine Line { get; init; } = null!; + public List Cells { get; init; } = new(); + } + + internal static List DetectTables(List lines) + { + var tables = new List(); + if (lines.Count == 0) return tables; + + // 先为每一行基于词间距拆分潜在单元格 + var lineCellInfos = new List(); + for (int i = 0; i < lines.Count; i++) + { + lineCellInfos.Add(SplitLineIntoCells(lines[i])); + } + + bool IsLikelyTableLine(LineCells lc) + { + if (lc.Cells.Count < MinTableColumns) return false; + // 平均单元格长度(字符) + var avgLen = lc.Cells.Average(c => c.Text.Length); + if (avgLen > 60) return false; // 太长可能是段落 + // 含有数字或列数≥3 更倾向于表格 + bool hasDigit = lc.Cells.Any(c => c.Text.Any(char.IsDigit)); + return hasDigit || lc.Cells.Count >= 3; + } + + int idx = 0; + while (idx < lines.Count) + { + if (!IsLikelyTableLine(lineCellInfos[idx])) { idx++; continue; } + + int start = idx; + int lastTableLine = idx; + int gapAllowance = 1; // 允许夹1行非表格(多行单元格内容) + int gaps = 0; + var candidateLineCells = new List<(int index, LineCells cells)>(); + + while (idx < lines.Count) + { + var lc = lineCellInfos[idx]; + if (IsLikelyTableLine(lc)) + { + candidateLineCells.Add((idx, lc)); + lastTableLine = idx; + gaps = 0; + idx++; + } + else if (gaps < gapAllowance) + { + // 可能是前一单元格的续行,先暂存(不直接作为表格解析行) + gaps++; + idx++; + } + else + { + break; + } + } + + if (candidateLineCells.Count >= 2) + { + var tableLines = candidateLineCells.Select(c => lines[c.index]).ToList(); + var rowIndices = candidateLineCells.Select(c => c.index).ToList(); + tables.Add(new TableInfo + { + Rows = tableLines, + StartIndex = rowIndices.Min(), + EndIndex = rowIndices.Max(), + ActualRowIndices = rowIndices + }); + } + else + { + // 不足以构成表格,回退一个位置继续 + idx = start + 1; + } + } + + return tables; + } + + private static LineCells SplitLineIntoCells(StructuredLine line) + { + // 基于多个空格或明显的水平间隔(Left差值)来拆分 + var words = line.Text.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var cells = new List(); + if (words.Length == 0) + { + return new LineCells { Line = line, Cells = cells }; + } + + // 由于我们没有逐词坐标(为保持侵入性最小,不修改上层结构),用多空格估计分列 + // 如果后续需要更精准,可在 StructuredLine 中保留 word 级坐标。 + var raw = line.Text; + // 使用两个及以上空格或制表符作为列分隔符 + var split = Regex.Split(raw.Trim(), @"(\s{2,}|\t+)").Where(s => !string.IsNullOrWhiteSpace(s) && !Regex.IsMatch(s, @"^\s{2,}$")).ToList(); + if (split.Count <= 1) + { + // 回退:用单空格分但只取>2列的情况 + if (words.Length >= 3) + { + split = words.ToList(); + } + } + double currentLeft = line.LeftMargin; + foreach (var s in split) + { + cells.Add(new CellFragment(currentLeft, PdfTextUtility.CleanText(s))); + currentLeft += 50; // 人工递增,后面列对齐时只用相对顺序 + } + return new LineCells { Line = line, Cells = cells }; + } + + internal static void ProcessTable(TableInfo table, StringBuilder markdown) + { + if (table.Rows.Count == 0) return; + + markdown.AppendLine(); + + // 重新分析表格结构 - 更智能的方法 + var tableData = AnalyzeAndRestructureTable(table.Rows); + + if (tableData.Count == 0) + { + // 如果无法识别为表格,作为普通段落处理 + foreach (var row in table.Rows) + { + markdown.AppendLine(PdfTextUtility.CleanText(row.Text)); + } + markdown.AppendLine(); + return; + } + + // 生成Markdown表格 + for (int i = 0; i < tableData.Count; i++) + { + var row = tableData[i]; + markdown.AppendLine($"| {string.Join(" | ", row)} |"); + + // 在第一行后添加分隔行 + if (i == 0) + { + var separatorRow = "| " + string.Join(" | ", Enumerable.Repeat("---", row.Count)) + " |"; + markdown.AppendLine(separatorRow); + } + } + + markdown.AppendLine(); + } + + private static List> AnalyzeAndRestructureTable(List rows) + { + // 通用:用 SplitLineIntoCells 重新对齐列,选择出现频率最高的列数 + var lineCells = rows.Select(SplitLineIntoCells).ToList(); + var columnCounts = lineCells.Where(lc => lc.Cells.Count >= MinTableColumns).Select(lc => lc.Cells.Count).ToList(); + if (!columnCounts.Any()) return new List>(); + int targetColumns = columnCounts + .GroupBy(c => c) + .OrderByDescending(g => g.Count()) + .ThenByDescending(g => g.Key) + .First().Key; + + var table = new List>(); + + foreach (var lc in lineCells) + { + if (lc.Cells.Count < 1) continue; + if (lc.Cells.Count == targetColumns) + { + table.Add(lc.Cells.Select(c => c.Text).ToList()); + } + else if (lc.Cells.Count > targetColumns) + { + // 合并多余列(从右向左合并最短文本) + var cells = lc.Cells.Select(c => c.Text).ToList(); + while (cells.Count > targetColumns) + { + // 找到两个最短相邻合并 + int mergeIndex = 0; + int minLen = int.MaxValue; + for (int i = 0; i < cells.Count - 1; i++) + { + int lens = cells[i].Length + cells[i + 1].Length; + if (lens < minLen) + { + minLen = lens; + mergeIndex = i; + } + } + cells[mergeIndex] = (cells[mergeIndex] + " " + cells[mergeIndex + 1]).Trim(); + cells.RemoveAt(mergeIndex + 1); + } + table.Add(cells); + } + else // 少于目标列,尝试用空列填充(多行单元格可能导致) + { + var cells = lc.Cells.Select(c => c.Text).ToList(); + while (cells.Count < targetColumns) cells.Add(""); + table.Add(cells); + } + } + + // 尝试识别表头:第一行如果所有列都是非数字且长度适中 + if (table.Count > 1) + { + bool firstRowHeader = table[0].Count(c => c.Any(char.IsLetter)) >= Math.Max(2, targetColumns - 1) && + table[0].Any(c => c.Contains("名称") || c.Contains("地区") || c.Contains("时间") || c.Contains("类") || c.Contains("种")); + if (!firstRowHeader) + { + // 生成一个通用表头 + var header = new List(); + for (int i = 0; i < targetColumns; i++) header.Add($"列{i + 1}"); + table.Insert(0, header); + } + } + return table; + } + + // 旧的特定案例处理逻辑已移除,保留最小必要工具函数 + internal static bool IsDataRow(string text) => text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Count(w => w.Any(char.IsDigit)) >= 2; +} diff --git a/src/MarketAssistant.Rag/Services/PdfTextUtility.cs b/src/MarketAssistant.Rag/Services/PdfTextUtility.cs new file mode 100644 index 0000000..0f14b1f --- /dev/null +++ b/src/MarketAssistant.Rag/Services/PdfTextUtility.cs @@ -0,0 +1,61 @@ +using System.Text.RegularExpressions; + +namespace MarketAssistant.Rag.Services; + +/// +/// PDF→Markdown 转换管线共用的编译正则、文本清洗与标题阈值常量。 +/// 仅供本程序集内的转换器与各辅助类使用(internal)。 +/// +internal static class PdfTextUtility +{ + // 编译的正则表达式,提高性能 + internal static readonly Regex MultipleSpacesRegex = new(@"\s{2,}", RegexOptions.Compiled); + internal static readonly Regex MultipleNewlinesRegex = new(@"\n{3,}", RegexOptions.Compiled); + internal static readonly Regex NumberedListRegex = new(@"^\s*(\d+\.|\d+\)|\(\d+\))\s+", RegexOptions.Compiled); + internal static readonly Regex BulletListRegex = new(@"^\s*[•\-\*\◦\▪\▫]\s+", RegexOptions.Compiled); + internal static readonly Regex ChapterNumberRegex = new(@"^\s*(第[一二三四五六七八九十\d]+章|Chapter\s+\d+|CHAPTER\s+\d+)\s*", RegexOptions.Compiled | RegexOptions.IgnoreCase); + internal static readonly Regex SectionNumberRegex = new(@"^\s*(\d+(\.\d+)*)\s+", RegexOptions.Compiled); + internal static readonly Regex ChineseSectionRegex = new(@"^\s*([一二三四五六七八九十]|[1-9]\d*)、", RegexOptions.Compiled); + internal static readonly Regex ChineseSubSectionRegex = new(@"^\s*(([一二三四五六七八九十]|[1-9]\d*))", RegexOptions.Compiled); + + // 标题字体大小阈值 + internal const double LargeHeadingThreshold = 1.5; + internal const double MediumHeadingThreshold = 1.3; + internal const double SmallHeadingThreshold = 1.1; + + internal static string CleanText(string text) + { + if (string.IsNullOrEmpty(text)) return string.Empty; + + // 移除多余的空格 + text = MultipleSpacesRegex.Replace(text, " "); + + // 处理常见的PDF编码问题 + text = text + .Replace("fi", "fi") // 连字符修复 + .Replace("fl", "fl") + .Replace("ff", "ff") + .Replace("ffi", "ffi") + .Replace("ffl", "ffl") + .Replace("–", "-") // 短破折号 + .Replace("—", "--") // 长破折号 + .Replace("\u201C", "\"") // 左双引号 + .Replace("\u201D", "\"") // 右双引号 + .Replace("\u2018", "'") // 左单引号 + .Replace("\u2019", "'") // 右单引号 + .Replace("\u2013", "-") // 短破折号 + .Replace("\u2014", "--") // 长破折号 + .Replace("\u00A0", " ") // 非断行空格 + .Trim(); + + return text; + } + + internal static bool IsChinese(char c) + { + // 检查是否为中文字符(CJK统一汉字) + return c >= 0x4E00 && c <= 0x9FFF || // CJK Unified Ideographs + c >= 0x3400 && c <= 0x4DBF || // CJK Extension A + c >= 0x20000 && c <= 0x2A6DF; // CJK Extension B + } +} diff --git a/tests/Application/HomeSearchViewModelTest.cs b/tests/Application/HomeSearchViewModelTest.cs index 6f5b0c1..8d420a4 100644 --- a/tests/Application/HomeSearchViewModelTest.cs +++ b/tests/Application/HomeSearchViewModelTest.cs @@ -24,37 +24,6 @@ namespace TestMarketAssistant.Application; [TestClass] public class HomeSearchViewModelTest { - private sealed class StubMarketServiceRegistry : IMarketServiceRegistry - { - private readonly IServiceProvider _serviceProvider; - - public StubMarketServiceRegistry(IServiceProvider serviceProvider) - { - _serviceProvider = serviceProvider; - } - - public IKLineService GetKLineService(MarketType marketType) - => throw new NotSupportedException(); - - public IAssetInfoService GetAssetInfoService(MarketType marketType) - => throw new NotSupportedException(); - - public INewsUpdateService GetNewsUpdateService(MarketType marketType) - => throw new NotSupportedException(); - - public IHomeAssetService GetHomeAssetService(MarketType marketType) - => _serviceProvider.GetRequiredKeyedService(marketType); - - public IAssetHistoryService GetAssetHistoryService(MarketType marketType) - => throw new NotSupportedException(); - - public IFavoriteService GetFavoriteService(MarketType marketType) - => throw new NotSupportedException(); - - public IAssetCacheService GetAssetCacheService(MarketType marketType) - => throw new NotSupportedException(); - } - private static HomeSearchViewModel CreateViewModel() { var services = new ServiceCollection(); @@ -67,7 +36,6 @@ private static HomeSearchViewModel CreateViewModel() var marketContext = new MarketContext(userSettingService.Object, serviceProvider); return new HomeSearchViewModel( - new StubMarketServiceRegistry(serviceProvider), marketContext, NullLogger.Instance); } diff --git a/tests/Tools/BasicDataToolsTest.cs b/tests/Tools/BasicDataToolsTest.cs index 0f4f307..e1e93fb 100644 --- a/tests/Tools/BasicDataToolsTest.cs +++ b/tests/Tools/BasicDataToolsTest.cs @@ -74,7 +74,7 @@ public void Setup() services.AddSingleton(userSettingServiceMock.Object); // 注册被测试的服务(A股 + 虚拟币,含基接口与子接口) - services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.AShare); services.AddKeyedSingleton(MarketType.AShare); _serviceProvider = services.BuildServiceProvider(); @@ -115,7 +115,7 @@ private void RequireZhiTuToken() public async Task GetAssetInfoAsync_AShare_ShouldReturnValidQuoteInfo() { // Arrange - 贵州茅台 SH600519,财联社公开行情接口 - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var service = (AShareBasicTools)_serviceProvider!.GetRequiredKeyedService(MarketType.AShare); // Act - 验证工具能真实调用财联社 API 并返回结构化数据 var quoteInfo = await service.GetAssetInfoAsync("SH600519"); @@ -142,7 +142,7 @@ public async Task GetCompanyInfoAsync_AShare_ShouldReturnValidCompanyInfo() RequireZhiTuToken(); // Arrange - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var service = (AShareBasicTools)_serviceProvider!.GetRequiredKeyedService(MarketType.AShare); // Act - 真实调用智兔 API 获取公司基本面 var companyInfo = await service.GetCompanyInfoAsync("SH600519"); @@ -163,7 +163,7 @@ public async Task GetCompanyInfoAsync_AShare_ShouldReturnValidCompanyInfo() public async Task GetAssetInfoAsync_AShare_MultipleSymbols_ShouldAllReturnValidData() { // Arrange - 验证多只股票的真实行情 - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var service = (AShareBasicTools)_serviceProvider!.GetRequiredKeyedService(MarketType.AShare); var symbols = new[] { "SH600519", "SZ000001", "SH600036" }; var expectedNames = new[] { "茅台", "平安", "招商" }; diff --git a/tests/Tools/CryptoMetricsToolsTest.cs b/tests/Tools/CryptoMetricsToolsTest.cs index 9eb2c1e..7e34ef4 100644 --- a/tests/Tools/CryptoMetricsToolsTest.cs +++ b/tests/Tools/CryptoMetricsToolsTest.cs @@ -45,7 +45,7 @@ public void Setup() userSettingServiceMock.Setup(x => x.CurrentSetting).Returns(userSetting); services.AddSingleton(userSettingServiceMock.Object); - services.AddKeyedSingleton(MarketType.Crypto); + services.AddKeyedSingleton(MarketType.Crypto); _serviceProvider = services.BuildServiceProvider(); } @@ -63,7 +63,7 @@ public async Task Cleanup() [TestCategory("Integration")] public async Task GetVolumeDistributionAsync_ShouldReturnValidData() { - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var service = (CryptoMetricsTools)_serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); var result = await service.GetVolumeDistributionAsync("BTC"); @@ -83,7 +83,7 @@ public async Task GetVolumeDistributionAsync_ShouldReturnValidData() [TestCategory("Integration")] public void GetFunctions_ShouldReturnValidAIFunctions() { - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var service = (CryptoMetricsTools)_serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); var functions = service.GetFunctions().ToList(); @@ -95,7 +95,7 @@ public void GetFunctions_ShouldReturnValidAIFunctions() [TestCategory("Integration")] public async Task GetOHLCVAsync_ShouldReturnValidData() { - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var service = (CryptoMetricsTools)_serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); var result = await service.GetOHLCVAsync("BTCUSDT", interval: MarketInterval.OneDay, limit: 10); @@ -120,7 +120,7 @@ public async Task GetOHLCVAsync_ShouldReturnValidData() [TestCategory("Integration")] public async Task GetOrderBookDepthAsync_ShouldReturnValidData() { - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var service = (CryptoMetricsTools)_serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); var result = await service.GetOrderBookDepthAsync("BTCUSDT", limit: 10); @@ -137,7 +137,7 @@ public async Task GetOrderBookDepthAsync_ShouldReturnValidData() [TestCategory("Integration")] public async Task GetRecentTradesAsync_ShouldReturnValidData() { - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var service = (CryptoMetricsTools)_serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); var result = await service.GetRecentTradesAsync("BTCUSDT", limit: 100); @@ -152,7 +152,7 @@ public async Task GetRecentTradesAsync_ShouldReturnValidData() [TestCategory("Integration")] public async Task GetMarketMetricsAsync_ShouldReturnValidData() { - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var service = (CryptoMetricsTools)_serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); var result = await service.GetMarketMetricsAsync("BTC"); @@ -168,7 +168,7 @@ public async Task GetMarketMetricsAsync_ShouldReturnValidData() [TestCategory("Integration")] public async Task GetVolatilityMetricsAsync_ShouldReturnValidData() { - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var service = (CryptoMetricsTools)_serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); var result = await service.GetVolatilityMetricsAsync("BTCUSDT", days: 30); diff --git a/tests/Tools/FinancialDataToolsTest.cs b/tests/Tools/FinancialDataToolsTest.cs index d477d4c..887051d 100644 --- a/tests/Tools/FinancialDataToolsTest.cs +++ b/tests/Tools/FinancialDataToolsTest.cs @@ -54,7 +54,7 @@ public void Setup() services.AddSingleton(userSettingServiceMock.Object); // 注册被测试的服务(仅 A股) - services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.AShare); _serviceProvider = services.BuildServiceProvider(); } @@ -89,7 +89,7 @@ public async Task GetBalanceSheetAsync_AShare_ShouldReturnValidData() RequireZhiTuToken(); // Arrange - 贵州茅台 SH600519 - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var service = (AShareFinancialTools)_serviceProvider!.GetRequiredKeyedService(MarketType.AShare); // Act - 真实调用智兔 API 获取资产负债表 var balanceSheets = await service.GetBalanceSheetAsync("SH600519"); @@ -125,7 +125,7 @@ public async Task GetIncomeStatementAsync_AShare_ShouldReturnValidData() RequireZhiTuToken(); // Arrange - 贵州茅台 SH600519 - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var service = (AShareFinancialTools)_serviceProvider!.GetRequiredKeyedService(MarketType.AShare); // Act - 真实调用智兔 API 获取利润表 var incomeStatements = await service.GetIncomeStatementAsync("SH600519"); @@ -162,7 +162,7 @@ public async Task GetCashFlowStatementAsync_AShare_ShouldReturnValidData() RequireZhiTuToken(); // Arrange - 贵州茅台 SH600519 - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var service = (AShareFinancialTools)_serviceProvider!.GetRequiredKeyedService(MarketType.AShare); // Act - 真实调用智兔 API 获取现金流量表 var cashFlowStatements = await service.GetCashFlowStatementAsync("SH600519"); @@ -201,7 +201,7 @@ public async Task GetFinancialRatiosAsync_AShare_ShouldReturnValidData() RequireZhiTuToken(); // Arrange - 贵州茅台 SH600519 - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var service = (AShareFinancialTools)_serviceProvider!.GetRequiredKeyedService(MarketType.AShare); // Act - 真实调用智兔 API 获取财务主要指标 var ratios = await service.GetFinancialRatiosAsync("SH600519"); @@ -234,7 +234,7 @@ public async Task GetCapitalStructureAsync_AShare_ShouldReturnValidData() RequireZhiTuToken(); // Arrange - 贵州茅台 SH600519 - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var service = (AShareFinancialTools)_serviceProvider!.GetRequiredKeyedService(MarketType.AShare); // Act - 真实调用智兔 API 获取股本结构 var capitalStructure = await service.GetCapitalStructureAsync("SH600519"); diff --git a/tests/Tools/NewsDataToolsTest.cs b/tests/Tools/NewsDataToolsTest.cs index b5eb06a..a278d4a 100644 --- a/tests/Tools/NewsDataToolsTest.cs +++ b/tests/Tools/NewsDataToolsTest.cs @@ -68,7 +68,7 @@ public async Task Cleanup() public async Task GetNewsAsync_AShare_ShouldReturnValidData() { // Arrange - 贵州茅台 SH600519,东方财富搜索 API(公开免费) - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var service = (AShareNewsTools)_serviceProvider!.GetRequiredKeyedService(MarketType.AShare); // Act - 真实调用东方财富搜索 API var newsData = await service.GetNewsAsync("SH600519"); @@ -97,7 +97,7 @@ public async Task GetNewsAsync_AShare_ShouldReturnValidData() public async Task GetNewsAsync_Crypto_ShouldReturnValidData() { // Arrange - BTC,CoinTelegraph RSS 免费源 - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var service = (CryptoNewsTools)_serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); // Act - 真实调用 CoinTelegraph RSS var newsData = await service.GetNewsAsync("btc"); diff --git a/tests/Tools/SentimentToolsTest.cs b/tests/Tools/SentimentToolsTest.cs index 9233fb6..3e545e0 100644 --- a/tests/Tools/SentimentToolsTest.cs +++ b/tests/Tools/SentimentToolsTest.cs @@ -61,9 +61,9 @@ public void Setup() services.AddSingleton(userSettingServiceMock.Object); // 注册被测试的服务 - services.AddKeyedSingleton(MarketType.AShare); services.AddKeyedSingleton(MarketType.AShare); - services.AddKeyedSingleton(MarketType.Crypto); + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.Crypto); services.AddKeyedSingleton(MarketType.Crypto); _serviceProvider = services.BuildServiceProvider(); @@ -86,7 +86,7 @@ public async Task Cleanup() public async Task GetFundFlowAsync_AShare_ShouldReturnValidData() { // Arrange - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var service = (AShareSentimentTools)_serviceProvider!.GetRequiredKeyedService(MarketType.AShare); // Act var sentimentData = await service.GetFundFlowAsync("SH600519"); @@ -118,7 +118,7 @@ public async Task GetFundFlowAsync_AShare_ShouldReturnValidData() public async Task GetFundingRateAsync_Crypto_ShouldReturnValidData() { // Arrange - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var service = (CryptoSentimentTools)_serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); // Act var fundingRateHistory = await service.GetFundingRateAsync("BTC"); @@ -146,7 +146,7 @@ public async Task GetFundingRateAsync_Crypto_ShouldReturnValidData() public async Task GetGlobalLongShortRatioAsync_Crypto_ShouldReturnValidData() { // Arrange - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var service = (CryptoSentimentTools)_serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); // Act var result = await service.GetGlobalLongShortRatioAsync("BTC"); @@ -180,7 +180,7 @@ public async Task GetGlobalLongShortRatioAsync_Crypto_ShouldReturnValidData() public async Task GetTopTraderAccountRatioAsync_Crypto_ShouldReturnValidData() { // Arrange - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var service = (CryptoSentimentTools)_serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); // Act var result = await service.GetTopTraderAccountRatioAsync("BTC"); @@ -209,7 +209,7 @@ public async Task GetTopTraderAccountRatioAsync_Crypto_ShouldReturnValidData() public async Task GetTopTraderPositionRatioAsync_Crypto_ShouldReturnValidData() { // Arrange - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var service = (CryptoSentimentTools)_serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); // Act var result = await service.GetTopTraderPositionRatioAsync("BTC"); @@ -238,7 +238,7 @@ public async Task GetTopTraderPositionRatioAsync_Crypto_ShouldReturnValidData() public async Task GetOpenInterestAsync_Crypto_ShouldReturnValidData() { // Arrange - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var service = (CryptoSentimentTools)_serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); // Act var result = await service.GetOpenInterestAsync("BTC"); diff --git a/tests/Tools/TechnicalDataToolsTest.cs b/tests/Tools/TechnicalDataToolsTest.cs index 032e36b..b27304d 100644 --- a/tests/Tools/TechnicalDataToolsTest.cs +++ b/tests/Tools/TechnicalDataToolsTest.cs @@ -87,7 +87,7 @@ public async Task Cleanup() public async Task GetKDJAsync_AShare_ShouldReturnValidData() { // Arrange - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var service = (AShareTechnicalTools)_serviceProvider!.GetRequiredKeyedService(MarketType.AShare); // Act var indicator = await service.GetKDJAsync("SH600519"); @@ -109,7 +109,7 @@ public async Task GetKDJAsync_AShare_ShouldReturnValidData() public async Task GetMACDAsync_AShare_ShouldReturnValidData() { // Arrange - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var service = (AShareTechnicalTools)_serviceProvider!.GetRequiredKeyedService(MarketType.AShare); // Act var indicator = await service.GetMACDAsync("SH600519"); @@ -132,7 +132,7 @@ public async Task GetMACDAsync_AShare_ShouldReturnValidData() public async Task GetBOLLAsync_AShare_ShouldReturnValidData() { // Arrange - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var service = (AShareTechnicalTools)_serviceProvider!.GetRequiredKeyedService(MarketType.AShare); // Act var indicator = await service.GetBOLLAsync("SH600519"); @@ -152,7 +152,7 @@ public async Task GetBOLLAsync_AShare_ShouldReturnValidData() public async Task GetMAAsync_AShare_ShouldReturnValidData() { // Arrange - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var service = (AShareTechnicalTools)_serviceProvider!.GetRequiredKeyedService(MarketType.AShare); // Act var indicator = await service.GetMAAsync("SH600519"); @@ -175,7 +175,7 @@ public async Task GetMAAsync_AShare_ShouldReturnValidData() public async Task GetKDJAsync_Crypto_ShouldReturnValidData() { // Arrange - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var service = (CryptoTechnicalTools)_serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); // Act var indicator = await service.GetKDJAsync("BTCUSDT"); @@ -197,7 +197,7 @@ public async Task GetKDJAsync_Crypto_ShouldReturnValidData() public async Task GetMACDAsync_Crypto_ShouldReturnValidData() { // Arrange - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var service = (CryptoTechnicalTools)_serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); // Act var indicator = await service.GetMACDAsync("BTCUSDT"); @@ -220,7 +220,7 @@ public async Task GetMACDAsync_Crypto_ShouldReturnValidData() public async Task GetBOLLAsync_Crypto_ShouldReturnValidData() { // Arrange - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var service = (CryptoTechnicalTools)_serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); // Act var indicator = await service.GetBOLLAsync("BTCUSDT"); @@ -240,7 +240,7 @@ public async Task GetBOLLAsync_Crypto_ShouldReturnValidData() public async Task GetMAAsync_Crypto_ShouldReturnValidData() { // Arrange - var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var service = (CryptoTechnicalTools)_serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); // Act var indicator = await service.GetMAAsync("BTCUSDT"); diff --git a/tests/Trading/TradeFailurePolicyTest.cs b/tests/Trading/TradeFailurePolicyTest.cs deleted file mode 100644 index f0d6bed..0000000 --- a/tests/Trading/TradeFailurePolicyTest.cs +++ /dev/null @@ -1,65 +0,0 @@ -using MarketAssistant.Infrastructure.Core; -using MarketAssistant.Services.Notification; -using MarketAssistant.Services.Trading; -using MarketAssistant.Trading.Abstractions; -using MarketAssistant.Trading.Models; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; - -namespace TestMarketAssistant.Trading; - -/// -/// 交易失败分派策略测试:验证拒绝类失败暂停策略、网络类短冷却、 -/// 成功清除冷却的行为契约(对应 MarketMonitor.ApplyTradeFailurePolicy)。 -/// MarketMonitor 依赖链较重,此处通过可直接构造的协作对象验证策略暂停链路。 -/// -[TestClass] -public sealed class TradeFailurePolicyTest -{ - [TestMethod] - [TestCategory("Unit")] - public void TradeResult_DefaultFailureCategory_ShouldBeNone() - { - var result = new TradeResult { Success = true }; - - Assert.AreEqual(TradeFailureCategory.None, result.FailureCategory); - } - - [TestMethod] - [TestCategory("Unit")] - public async Task RejectedStrategy_ShouldBePausedAndNotified() - { - // 端到端最短路径:风控拒绝 → 策略被暂停(由 TradingStrategyService 落库) - var strategyService = new Mock(null!); - strategyService - .Setup(service => service.UpdateStrategyStatusAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - - var strategy = new TradingStrategy - { - Id = "strategy-rejected", - Symbol = "BTCUSDT", - Type = StrategyType.StopLoss, - Side = OrderSide.Sell, - Status = StrategyStatus.Active - }; - - // 模拟风控拒绝的执行结果 - var rejected = new TradeResult - { - Success = false, - ErrorMessage = "风控拒绝: 今日亏损已达上限", - FailureCategory = TradeFailureCategory.Rejected - }; - - // 拒绝类失败的处理:暂停策略(这里直接驱动与 MarketMonitor 相同的调用, - // 验证 TradingStrategyService 接受 Paused 状态且枚举值存在) - await strategyService.Object.UpdateStrategyStatusAsync(strategy.Id, StrategyStatus.Paused); - - strategyService.Verify( - service => service.UpdateStrategyStatusAsync(strategy.Id, StrategyStatus.Paused, It.IsAny()), - Times.Once, - "拒绝类失败必须暂停策略,防止永久空转重试"); - } -} diff --git a/tests/unit.runsettings b/tests/unit.runsettings index e151bbe..c2d0ee9 100644 --- a/tests/unit.runsettings +++ b/tests/unit.runsettings @@ -2,8 +2,9 @@ 0 - - TestCategory=Unit + + TestCategory!=Integration&TestCategory!=E2E&TestCategory!=Evaluation