diff --git a/.actrc b/.actrc deleted file mode 100644 index b6a721f..0000000 --- a/.actrc +++ /dev/null @@ -1,10 +0,0 @@ -# act 配置文件 -# 使用中等大小的 Docker 镜像 - ---container-architecture linux/amd64 --P ubuntu-latest=catthehacker/ubuntu:act-latest --P windows-latest=catthehacker/ubuntu:act-latest --P macos-latest=catthehacker/ubuntu:act-latest - -# 设置默认平台 ---platform ubuntu-latest diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f44fccc..c44938b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,68 +2,67 @@ name: Build on: push: - branches: [ main, develop, feat/* ] + branches: [main, develop, feat/*] pull_request: - branches: [ main, develop ] + branches: [main, develop] env: - DOTNET_VERSION: '10.0.x' + DOTNET_VERSION: "10.0.x" jobs: build-windows: name: Build Windows runs-on: windows-latest - + steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: ${{ env.DOTNET_VERSION }} - - - name: Restore dependencies - run: dotnet restore MarketAssistant.slnx - - - name: Build - run: dotnet build src/MarketAssistant.csproj -c Release --no-restore + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore dependencies + run: dotnet restore MarketAssistant.slnx + + - name: Build + run: dotnet build src/MarketAssistant.App/MarketAssistant.App.csproj -c Release --no-restore build-macos: name: Build macOS runs-on: macos-latest - + steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: ${{ env.DOTNET_VERSION }} - - - name: Restore dependencies - run: dotnet restore MarketAssistant.slnx - - - name: Build - run: dotnet build src/MarketAssistant.csproj -c Release --no-restore -r osx-x64 -p:ErrorOnDuplicatePublishOutputFiles=false + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore dependencies + run: dotnet restore MarketAssistant.slnx + + - name: Build + run: dotnet build src/MarketAssistant.App/MarketAssistant.App.csproj -c Release --no-restore -r osx-x64 -p:ErrorOnDuplicatePublishOutputFiles=false build-linux: name: Build Linux runs-on: ubuntu-latest - + steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: ${{ env.DOTNET_VERSION }} - - - name: Restore dependencies - run: dotnet restore MarketAssistant.slnx - - - name: Build - run: dotnet build src/MarketAssistant.csproj -c Release --no-restore -r linux-x64 + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore dependencies + run: dotnet restore MarketAssistant.slnx + - name: Build + run: dotnet build src/MarketAssistant.App/MarketAssistant.App.csproj -c Release --no-restore -r linux-x64 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cca6ce4..aa8d379 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,12 +31,28 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Resolve release version + id: version + shell: bash + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + elif [ "${{ github.event_name }}" = "release" ]; then + VERSION="${{ github.event.release.tag_name }}" + else + VERSION="${GITHUB_REF#refs/tags/}" + fi + # 去掉前缀 v + VERSION="${VERSION#v}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Resolved version: $VERSION" + - name: Restore dependencies run: dotnet restore MarketAssistant.slnx - name: Build Windows x64 run: | - dotnet publish src/MarketAssistant.csproj ` + dotnet publish src/MarketAssistant.App/MarketAssistant.App.csproj ` -c Release ` -r win-x64 ` --self-contained ` @@ -45,13 +61,31 @@ jobs: -p:UseAppHost=true ` -p:DebugType=None ` -p:DebugSymbols=false ` + -p:Version=${{ steps.version.outputs.version }} ` + -p:InformationalVersion=${{ steps.version.outputs.version }} ` -o ./publish/windows + shell: pwsh - name: Clean debug files run: | Get-ChildItem -Path ./publish/windows -Include "*.pdb", "*.xml" -Recurse | Remove-Item -Force shell: pwsh + - name: Install Inno Setup + run: choco install innosetup --no-progress -y + + - name: Build installer + shell: pwsh + env: + APP_VERSION: ${{ steps.version.outputs.version }} + run: | + $version = $env:APP_VERSION + $issFile = "scripts\windows\MarketAssistant.iss" + $content = Get-Content $issFile -Raw + $content = $content -replace '#define MyAppVersion ".*?"', "#define MyAppVersion `"$version`"" + Set-Content $issFile -Value $content -NoNewline + & "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" $issFile + - name: Create ZIP archive run: | Compress-Archive -Path ./publish/windows/* ` @@ -63,7 +97,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: windows-x64 - path: ${{ env.APP_NAME }}-Windows-x64.zip + path: | + ${{ env.APP_NAME }}-Windows-x64.zip + Release/Windows/*.exe # macOS 构建 build-macos: @@ -78,6 +114,21 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Resolve release version + id: version + shell: bash + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + elif [ "${{ github.event_name }}" = "release" ]; then + VERSION="${{ github.event.release.tag_name }}" + else + VERSION="${GITHUB_REF#refs/tags/}" + fi + VERSION="${VERSION#v}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Resolved version: $VERSION" + - name: Restore dependencies run: dotnet restore MarketAssistant.slnx @@ -99,6 +150,7 @@ jobs: - name: Build macOS app run: ./scripts/macos/build-app-bundle.sh env: + APP_VERSION: ${{ steps.version.outputs.version }} # 如果配置了签名,取消注释以下行 # SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} # NOTARYTOOL_PROFILE: "AC_PASSWORD" @@ -130,6 +182,21 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Resolve release version + id: version + shell: bash + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + elif [ "${{ github.event_name }}" = "release" ]; then + VERSION="${{ github.event.release.tag_name }}" + else + VERSION="${GITHUB_REF#refs/tags/}" + fi + VERSION="${VERSION#v}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Resolved version: $VERSION" + - name: Install dependencies run: | sudo apt-get update @@ -145,9 +212,13 @@ jobs: - name: Build Debian package run: ./scripts/linux/build-deb.sh + env: + APP_VERSION: ${{ steps.version.outputs.version }} - name: Build RPM package run: ./scripts/linux/build-rpm.sh + env: + APP_VERSION: ${{ steps.version.outputs.version }} continue-on-error: true - name: Upload .deb package @@ -178,6 +249,9 @@ jobs: contents: write steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Download all artifacts uses: actions/download-artifact@v4 with: @@ -189,7 +263,7 @@ jobs: - name: Get release version id: get_version run: | - if [ "${{ github.event_name }}" == "release" ]; then + if [ "${{ github.event_name }}" = "release" ]; then echo "version=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT else echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT @@ -210,17 +284,34 @@ jobs: echo "Release $TAG not found. Skipping asset cleanup." fi + - name: Generate SHA256 checksums + working-directory: ./artifacts + run: | + find . -type f \( -name "*.zip" -o -name "*.exe" -o -name "*.dmg" -o -name "*.deb" -o -name "*.rpm" \) -exec sha256sum {} \; > ../SHA256SUMS.txt + cat ../SHA256SUMS.txt + - name: Upload release assets uses: softprops/action-gh-release@v2 with: files: | ./artifacts/**/*.zip + ./artifacts/**/*.exe ./artifacts/**/*.dmg ./artifacts/**/*.deb ./artifacts/**/*.rpm + ./SHA256SUMS.txt tag_name: ${{ steps.get_version.outputs.version }} name: ${{ steps.get_version.outputs.version }} draft: false - prerelease: false + prerelease: ${{ contains(steps.get_version.outputs.version, '-') }} + body: | + ## 下载说明 + + 请根据操作系统选择对应安装包: + - **Windows**: `MarketAssistant-Windows-x64.zip`(便携版)或 `MarketAssistant-Setup-.exe`(安装版) + - **macOS**: `MarketAssistant-.dmg`(Universal Binary,同时支持 Apple Silicon 和 Intel) + - **Linux**: `MarketAssistant--amd64.deb`(Debian/Ubuntu)或 `MarketAssistant--x86_64.rpm`(Fedora/RHEL) + + 完整性校验请参考 `SHA256SUMS.txt`。 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index a4fe18b..9420128 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ ## ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore +# Windows 保留设备名(误生成时 git 会反复列出,磁盘上实际不存在) +nul + # User-specific files *.rsuser *.suo @@ -398,3 +401,10 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml +.idea/ + +# Secrets and credentials +.secrets +.env +*.pem +*.key \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 41e275a..9ff015b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,223 +1,145 @@ # AGENTS.md -面向代码智能体(Agents)的专用说明文件。本项目为基于 Avalonia UI 的跨平台桌面应用,支持 Windows、macOS 和 Linux 平台,以及独立的测试工程。智能体应根据改动类型选择合适的验证方式,确保提交前代码可正常构建。 +面向代码智能体(Agents)的专用说明文件。本项目为基于 Avalonia UI 的跨平台桌面应用,支持 A 股与虚拟币两个市场。各子项目拥有独立的 `AGENTS.md`,就近原则生效——请优先参阅离改动最近的 `AGENTS.md`。 --- -## 一、项目概览 +## 项目结构 -- 解决方案:`MarketAssistant.slnx` -- 主项目(Avalonia 应用):`src/` -- 单元测试工程:`tests/` -- 构建脚本:根目录 `build-release.ps1`,构建说明见根目录 `BUILD.md` - -主要功能模块: - -- 业务与设置:`src/Applications/` -- 视图与视图模型:`src/Views/`, `src/ViewModels/` -- 插件能力(基础/财务/新闻/技术/筛选):`src/Agents/Plugins/` -- 代理与多分析角色:`src/Agents/` 及 `Agents/Yaml` -- 资源与样式:`src/Resources/Styles/` -- 资产文件:`src/Assets/` -- 模型配置:`src/config/models.yaml` - ---- - -## 二、开发环境与准备 - -### 1. 必备工具 - -- .NET SDK 10.0(或以上) -- 无需额外工作负载,Avalonia 通过 NuGet 包提供 - -```bash -dotnet --info ``` - -可选:安装 Playwright CLI(用于首次拉起浏览器依赖) - -```bash -dotnet tool update --global Microsoft.Playwright.CLI -playwright install +MarketAssistant.slnx +├── src/MarketAssistant.App/ ← UI 宿主(Avalonia) → 有独立 AGENTS.md +├── src/MarketAssistant.App.Services/ ← 应用运行时/业务编排层 → 有独立 AGENTS.md +├── src/MarketAssistant.Core/ ← 核心抽象层(MarketType 等) → 有独立 AGENTS.md +├── src/MarketAssistant.Agents/ ← Agent 契约层(MAF) → 有独立 AGENTS.md +├── src/MarketAssistant.Rag/ ← RAG 基础能力层 → 有独立 AGENTS.md +├── src/MarketAssistant.DataProviders/ ← 数据提供者(Binance 等) → 有独立 AGENTS.md +├── tests/ ← 单元测试工程 +├── scripts/ ← 构建脚本 +└── docs/ ← 设计文档 ``` -### 2. 还原与编译 +依赖方向: -```bash -dotnet restore MarketAssistant.slnx -dotnet build MarketAssistant.slnx -c Debug ``` - -或针对主项目: - -```bash -dotnet restore src/MarketAssistant.csproj -dotnet build src/MarketAssistant.csproj -c Debug +Core(无依赖) + ↑ + ├── Rag → Core + ├── DataProviders → Core + ├── Agents → Core + ├── App.Services → Core, Agents, DataProviders, Rag + └── App → Core, Agents, DataProviders, App.Services, Rag ``` -### 3. 运行(开发) - -跨平台运行(Windows/macOS/Linux): - -```bash -dotnet run --project src/MarketAssistant.csproj -c Debug -``` +核心技术栈:.NET 10 / C# 13 · Avalonia 11.x · Microsoft Agent Framework (MAF) · Semantic Kernel SQLiteVec · Serilog --- -## 三、测试与质量检查 - -### 1. 智能体验证策略(必需) - -智能体在完成代码编辑后,**必须**根据改动类型执行验证: - -#### A. 必须执行构建验证 (`dotnet build`) -涉及以下目录或文件的修改,必须确保编译通过: -- **业务逻辑**:`src/Applications/`, `src/Services/`, `src/Agents/` (含 Plugins) -- **基础设施**:`src/Infrastructure/`, `src/Rag/`, `src/Models/`, `src/Parsers/` -- **UI 层**:`src/Views/`, `src/ViewModels/`, `src/Resources/`, `src/Converts/` -- **配置**:`src/config/` - -#### B. 可选执行单元测试 (`dotnet test`) -仅在以下情况执行: -- 用户明确要求执行测试 -- 进行重大架构重构 -- 修复已知的测试失败问题 - -#### C. 无需验证 -- 文档(README.md, AGENTS.md 等) -- 纯注释修改 -- 资产文件(图片等) - -### 2. 运行全部测试 - -```bash -dotnet test tests/TestMarketAssistant.csproj -c Debug --logger "trx;LogFileName=TestResults.trx" -``` - -### 3. 按名称过滤运行 - -```bash -dotnet test tests/TestMarketAssistant.csproj --filter FullyQualifiedName~StockServiceTest -``` +## 开发环境 -### 4. 代码格式(若需) +- .NET SDK 10.0+ +- 无需额外工作负载,Avalonia 通过 NuGet 提供 ```bash -dotnet format --verify-no-changes -# 如需自动修复: -dotnet format +dotnet restore MarketAssistant.slnx +dotnet build MarketAssistant.slnx -c Debug +dotnet run --project src/MarketAssistant.App/MarketAssistant.App.csproj -c Debug ``` --- -## 四、配置与运行时约定 +## 智能体验证策略 -- 模型与供应商配置:`src/config/models.yaml` -- MCP 相关设置:`src/Applications/Settings/MCPServerConfig*.cs` -- MCP 配置页面:`src/Views/Pages/MCPServerConfigPageView.axaml` -- 插件的 YAML 能力描述:`src/Agents/Plugins/Yaml/` -- 代理 YAML 配置:`src/Agents/Yaml/` +完成代码编辑后,**必须**根据改动类型选择验证方式: -建议: - -- 不要在仓库中提交任何密钥或令牌。密钥应通过应用内设置页或安全存储注入。 -- 如引入新外部依赖,需在 README 或本文件中注明安装步骤与运行前置条件。 +| 改动类型 | 验证方式 | +|---------|---------| +| `src/` 下任何 `.cs`、`.axaml`、`.csproj`、`config/` | `dotnet build MarketAssistant.slnx -c Debug` | +| 用户明确要求 或 重大架构重构 | `dotnet test tests/TestMarketAssistant.csproj -c Debug` | +| 文档、注释、资产文件 | 无需验证 | --- -## 五、代码风格与工程约束 - -通用(C# 13 / .NET 10): +## 通用代码风格(C# 13 / .NET 10) +- 所有子项目共享 `RootNamespace = MarketAssistant`,新增文件的命名空间按目录结构拼接(如 `MarketAssistant.Infrastructure.Core`),而非项目名。 - 仅在函数级添加文档注释;仅对晦涩逻辑添加必要行上方说明,不写赘余注释。 - 命名清晰、可读;优先完整词汇,避免缩写;异步方法以 `Async` 结尾。 - 控制流优先使用早返回与卫语句,避免深层嵌套;不要吞并异常。 - 避免 `TODO` 留存,能实现则实现;公共 API 明确类型标注。 -- 变更应保持现有依赖注入与分层结构(`ViewModels`、`Applications`、`Services`、`Agents` 等)。 +- 变更应保持现有依赖注入与分层结构。 +- 代码格式检查:`dotnet format --verify-no-changes`,自动修复:`dotnet format`。 -UI 与样式(Avalonia AXAML): +--- -- **文件格式**:视图文件必须使用 `.axaml` 扩展名。 -- **布局约束**: - - `Padding`/`Margin`/间距必须使用 **4 的倍数**(4/8/12/16),且原则上不超过 16。 - - **禁止硬编码数值**:布局数值应使用 `src/Resources/Styles/Spacing.axaml` 中的资源(如 `{StaticResource SmallMargin}`),或在 `UserControl.Resources` 中定义局部资源。 -- **样式管理**: - - 统一遵循 `src/Resources/Styles/` 中的集中式样式资源。 - - 避免在视图中硬编码颜色与字体。 -- **控件使用**:优先使用 Avalonia 内置控件,必要时参考现有自定义控件。 -- **资产管理**:非必要不改动图片与资产文件;若必须更改,需监控构建体积。 +## 避免重复造轮子 -> 说明:以上 UI 间距约束来自项目偏好设置 [[memory:4590929]]。 +- 新增基础能力前,先检索项目内现有抽象、服务和已引入 NuGet 依赖,优先复用,不要在业务层重新实现一套。 +- 对 HTTP、缓存、配置、消息分发、技术指标、文档解析等通用能力,优先使用成熟开源库或项目内统一组件。 +- 禁止在 ViewModel 或业务服务中直接手写新的 retry、timeout、限流、缓存、配置加载框架;优先复用 `HttpClient`、resilience pipeline、`IMemoryCache`、统一设置服务。 +- 技术指标优先复用统一指标组件或成熟库,不要在多个服务重复实现 `MA`、`EMA`、`MACD`、`BOLL`、`KDJ` 等公式。 +- 引入新三方库时,优先选择社区成熟、维护活跃、与当前技术栈兼容的方案,并在代码或文档中说明替换理由与迁移风险。 --- -## 六、目录导航(常用) +## 安全与配置 -- 代理与分析角色:`src/Agents/`(含多角色 YAML 配置 `Yaml/`) -- 业务能力:`src/Applications/`(股票、资讯、收藏、K 线、设置等) -- 插件能力:`src/Agents/Plugins/`(基础/财务/新闻/技术/筛选;含 YAML 描述文件) -- 视图与 VM:`src/Views/`, `src/ViewModels/` -- 资源与样式:`src/Resources/Styles/`(Avalonia 样式资源字典) -- 资产文件:`src/Assets/`(图片、图标、HTML 等) -- 类型转换器:`src/Converts/` -- 基础设施:`src/Infrastructure/`(配置、核心、工厂等) -- 服务层:`src/Services/`(浏览器、缓存、对话框、导航等) -- RAG 相关:`src/Rag/`(向量化与检索增强生成) -- 测试:`tests/` +- API 密钥(Binance、AI 模型等)通过应用内设置界面输入,持久化在用户本地目录,**禁止提交到仓库**。 +- 不要提交 `.env`、`appsettings.*.json`、用户偏好文件等包含密钥的文件。 +- DI 注册入口:`Program.ConfigureServices()` → `src/MarketAssistant.App/Services/ServiceCollectionExtensions.cs`。 +- 业务服务注册入口:`AddApplicationServices()` 内部调用 `AddBusinessServices()`,后者定义在 `src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs`。 +- RAG 注册入口:`src/MarketAssistant.Rag/Extensions/ServiceCollectionExtensions.cs` 中的 `AddRagServices()`。 --- -## 七、发布与打包 +## 多市场架构 -### Windows 发布 +- 支持 **A 股**(`MarketType.AShare`)和 **虚拟币**(`MarketType.Crypto`)。 +- 使用 .NET **Keyed Services**(`IServiceProvider.GetRequiredKeyedService(MarketType)`)实现同一接口的市场特定实现。 +- `MarketContext` 单例管理当前活跃市场。 +- 新增市场需在 `MarketType` 枚举添加值,并为所有 Keyed Service 接口注册新实现。 -使用一键发布脚本(PowerShell): +--- -```powershell -powershell -ExecutionPolicy Bypass -File .\build-release.ps1 -``` +## PR 与提交规范 -### 跨平台发布 +- 提交信息格式:`[模块] 变更概要`,例如:`[Agents] 新增情绪分析工具接口`。 +- 所有代码改动需确保构建通过。 +- 不要在仓库中提交密钥或令牌。 +- 对基础设施或脚手架的新增,请在相应 `AGENTS.md` 或 `README.md` 补充说明。 -手动发布到特定平台: +--- -```bash -# Windows -dotnet publish src/MarketAssistant.csproj -c Release -r win-x64 --self-contained +## 发布 -# macOS -dotnet publish src/MarketAssistant.csproj -c Release -r osx-x64 --self-contained +详见根目录 `BUILD.md`。快速命令: -# Linux -dotnet publish src/MarketAssistant.csproj -c Release -r linux-x64 --self-contained -``` +```powershell +# Windows 一键发布 +powershell -ExecutionPolicy Bypass -File .\scripts\build-release.ps1 -更详细的发布与生成说明请参阅根目录 `BUILD.md`。 +# 手动跨平台发布 +dotnet publish src/MarketAssistant.App/MarketAssistant.App.csproj -c Release -r win-x64 --self-contained +dotnet publish src/MarketAssistant.App/MarketAssistant.App.csproj -c Release -r osx-x64 --self-contained +dotnet publish src/MarketAssistant.App/MarketAssistant.App.csproj -c Release -r linux-x64 --self-contained +``` --- ---- +## 测试 -## 八、PR 与提交规范 +```bash +# 全量测试 +dotnet test tests/TestMarketAssistant.csproj -c Debug --logger "trx;LogFileName=TestResults.trx" -- 提交信息建议格式:`[模块] 变更概要`,例如:`[Plugins] 新增资金流插件与测试`。 -- 所有代码改动需确保构建通过;单元测试为可选,由开发者根据实际情况决定是否执行。 -- 如涉及平台相关改动,至少在一个目标平台(Windows 或 macOS)完成启动验证。 -- 对基础设施或脚手架的新增,请在 `README.md` 或本文件补充对应说明与命令。 +# 过滤运行 +dotnet test tests/TestMarketAssistant.csproj --filter FullyQualifiedName~StockServiceTest +``` --- -## 九、常见问题(FAQ) - -- Q:测试是否为必需? - - A:不是必需的。智能体默认只确保代码编译通过,测试为可选项,仅在用户明确要求或重大重构时执行。 -- Q:智能体会自动执行测试吗? - - A:不会。智能体只会执行 `dotnet build` 确保编译通过,不会自动执行单元测试,除非用户明确要求。 -- Q:如何运行特定模块的测试? - - A:使用过滤器,例如:`dotnet test --filter FullyQualifiedName~StockServiceTest` -- Q:是否可为子目录添加更细化的 AGENTS.md? - - A:可以。若在子项目放置更近的 `AGENTS.md`,就近原则生效。 - - +## FAQ +- **测试是否为必需?** 不是。智能体默认只确保编译通过,测试为可选项。 +- **子目录的 AGENTS.md 如何生效?** 就近原则——离改动文件最近的 `AGENTS.md` 优先。 +- **设计文档放哪?** `docs/` 目录,不放入 AGENTS.md。 diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..b423cff --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,57 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/MarketAssistant.slnx b/MarketAssistant.slnx index c42add1..9319e20 100644 --- a/MarketAssistant.slnx +++ b/MarketAssistant.slnx @@ -1,9 +1,16 @@ + - + + + + + + + diff --git a/README.md b/README.md index 6f75c4a..a663511 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,70 @@ ## ✨ 简介 -本项目基于 Avalonia UI 开发,结合 AI 大模型构建的跨平台股票分析工具。目前已支持 A 股,未来计划加入港股、美股、虚拟币等支持。 +本项目基于 Avalonia UI 开发,结合 AI 大模型构建的跨平台金融市场分析工具。目前已支持 **A 股** 和 **虚拟币(Crypto)** 两个市场,通过多 AI 分析师并行协作,自动化生成多维度投资分析报告。 -基于实时数据,旨在做出明智的决策,主要专注于金融市场。它利用分析大量数据,提供股票等的动态实时洞察。快速汇集相关信息,全面审核整个情况,旨在为投资者、交易员和顾问提供最佳结果 +基于实时数据,利用 AI 分析大量金融数据,提供动态实时洞察。快速汇集相关信息,全面审核整个情况,旨在为投资者、交易员和研究者提供专业的分析参考。 -。本项目仅供学习研究,投资有风险,入市需谨慎。 +本项目仅供学习研究,投资有风险,入市需谨慎。 + +## 🧱 架构概览 + +- `MarketAssistant.App`:Avalonia UI 宿主,只负责视图、ViewModel、导航、通知、样式和内容文件输出。 +- `MarketAssistant.App.Services`:应用运行时与业务编排层,承载 Agent 实现、Workflow、Tool 实现、RAG、MCP、交易引擎和业务服务。 +- `MarketAssistant.Agents`:Agent 契约层,定义分析师基类、工具抽象、分析模型和提示词配置加载。 +- `MarketAssistant.DataProviders`:外部行情与资讯数据接入层。 +- `MarketAssistant.Trading`:交易抽象与共享交易模型。 +- `MarketAssistant.Core`:基础模型、异常、转换器和市场枚举等通用能力。 + +当前依赖关系为:`Core <- Trading/DataProviders <- Agents/App.Services <- App`,其中 `Agents` 额外依赖 `Trading` 中的共享交易抽象,`App` 作为 UI 宿主还直接引用部分共享模型与服务。 ## 📊 主要功能 -### 股票分析 +### 多市场支持 -- **基本面分析**:公司基本情况、财务状况、行业地位等 -- **技术面分析**:K线图、技术指标、趋势分析 -- **新闻事件分析**:相关新闻、公告解读 -- **市场情绪分析**:市场整体情绪、个股情绪 -- **财务分析**:季度财报、现金流、盈利能力 - -### AI选股功能 -- **用户需求分析**: 根据投资偏好智能推荐股票 -- **新闻驱动选股**: 基于市场新闻事件进行选股 -- **快速策略选股**: 预设多种选股策略 - - 价值股策略:PE<20, PB<3, ROE>10% - - 成长股策略:营收增长>20%, 利润增长>15% - - 大盘股策略:市值>500亿的蓝筹股 - - 小盘股策略:50-200亿市值的成长股 - - 红利股策略:股息率>3%的稳定分红股 - -### 文档向量化 - -- **向量化搜索**: 支持PDF、DOCX文档向量化 -- **知识库集成**: 结合本地文档进行深度分析 -- **上下文感知**: AI模型结合文档内容提供精准建议 +- **A 股市场**:股票搜索、基本面/技术面/财务/情绪/新闻五维分析 +- **虚拟币市场**:支持 Binance 实时行情、K 线、资金费率、多空比等数据 + +### AI 多分析师协作分析 + +通过 Fan-Out/Fan-In 工作流,5 位专业 AI 分析师并行分析后由协调分析师综合判断: + +- **基本面分析师**:公司/项目基本情况、行业地位、长期价值 +- **技术分析师**:K 线图形态、MACD/KDJ/BOLL 等技术指标、交易策略 +- **财务分析师**:财务报表、偿债能力、盈利质量、现金流 +- **市场情绪分析师**:市场情绪、资金流向、投资者行为 +- **新闻事件分析师**:新闻事件、公告解读、突发事件影响 +- **协调分析师**:整合上述分析、解决分歧、生成最终投资建议 + +### AI 选股功能 + +- **用户需求分析**:根据投资偏好智能推荐 +- **新闻驱动选股**:基于市场新闻事件进行选股 +- **快速策略选股**:预设多种选股策略(价值股、成长股、大盘股、小盘股、红利股) + +### 自主交易(虚拟币) + +- 策略配置:止损/止盈/追踪止损/AI 信号策略 +- 实时监控:Binance WebSocket 实时价格监控 +- 风控管理:多维度风控(仓位限制、单日亏损限制等) +- 当前交易执行链以 `IExchangeClient` 统一抽象接入 Binance 现货能力,合约支持仍在规划中 + +### 文档向量化(RAG) + +- **向量化搜索**:支持 PDF、DOCX 文档向量化 +- **知识库集成**:结合本地文档进行深度分析 +- **查询改写与重排序**:优化检索质量 + +### MCP 工具扩展 + +- 支持 Model Context Protocol (MCP) 服务器集成 +- 支持 stdio/SSE/StreamableHttp 三种传输方式 +- 可扩展外部工具和数据源 ### 数据可视化 -- 股票K线图展示 +- K 线图展示(WebView) - 技术指标可视化 -- 分析结果直观展示 +- 分析报告结构化展示(评分、评级、关键指标) ## ⚙️ 高级配置 @@ -119,9 +147,13 @@ MarketAssistant支持Model Context Protocol (MCP)服务器配置,可以集成 ## 🛠️ 技术栈 -- Avalonia UI 11.3.8 -- .NET 10.0 -- MAF(Microsoft Agent Framework) +- **UI 框架**:Avalonia UI 11.3 +- **运行时**:.NET 10.0 +- **AI 框架**:MAF (Microsoft Agent Framework) +- **向量存储**:Semantic Kernel SQLiteVec +- **日志**:Serilog +- **MVVM**:CommunityToolkit.Mvvm +- **技术指标**:Skender.Stock.Indicators ## 🙏 鸣谢 diff --git a/docs/Todo.md b/docs/Todo.md index a669ee5..14cee3d 100644 --- a/docs/Todo.md +++ b/docs/Todo.md @@ -1,19 +1,25 @@ ## TODO -- 会话内Token管理:当单次会话过长时进行压缩 -- 缺少分析质量的自我评估和改进机制 -- xml prompt - -- 选定目录文档(Pdf,Docx)向量化搜索 - pandoc.net 文档转换 -- MCP Client 支持 - 问题:所有 MCP 工具被合并在一个 mcp 命名空间下,模型可自动调用,但“无法限制/引导只调用某个 MCP 或某类 MCP 工具”,特别是用户可添加任意 MCP 时,缺少选择性暴露与强约束。 - 1.注册分组与最小暴露 每个 MCP 服务器的工具注册为独立插件名,例如 mcp.,或按业务域归类,例如 mcp.search.、mcp.finance. - 2.函数调用范围限制(强约束) allow_only_functions 中加入支持函数名称 - 3.系统提示词规范(软约束)在 Agent 的 YAML 模板中加入“工具使用策略”,明确只在匹配场景时使用某些 MCP 工具,并将允许的工具清单注入到模板变量,减少模型试探无关工具 - -- SK to Agent Framework - -- 与其让子Agent把大块的结构化结果(代码、报告)通过对话历史传给主Agent(这会失真且昂贵),不如让它直接调用工具将产出物存到文件系统,再把轻量的“引用/指针”传回去。这能最大化保证并降低Token成本。 -- 总结已完成的阶段性工作,存入外部记忆,然后带着干净的上下文继续,通过“记忆”来保持连续性 -- 精细化任务指导(目前还是硬编码规划),主Agent会给子Agent非常明确的目标、输出格式和任务边界 \ No newline at end of file +以下仅保留仍未完成或仍需深化的事项。 + +### Agent 与 Prompt + +- `xml prompt` 方案评估与落地。 +- `SK to Agent Framework` 的进一步迁移与能力收口。 +- 精细化任务指导:让主 Agent 输出更明确的目标、边界和结果格式。 +- 将阶段性工作总结沉淀到外部记忆,并以轻量上下文继续后续任务。 + +### MCP 工具约束 + +- 继续增强 MCP 工具的分组与最小暴露策略,避免模型在多服务器场景下无约束试探。 +- 评估按服务器或业务域拆分插件命名空间,例如 `mcp.` 或 `mcp.search.`。 +- 继续完善函数调用范围限制与提示词级工具使用策略。 + +### RAG 与文档链路 + +- 继续收敛文档摄取链,避免 `PDF/DOCX -> Markdown -> 再解析 Markdown` 的双重解析路径持续扩散。 +- 评估是否需要更稳定的文档块抽取方案,而不是继续堆叠格式转换启发式。 + +### 多 Agent 协作 + +- 与其让子 Agent 通过对话历史传递大块结构化结果,不如优先写入文件系统并传回轻量引用,进一步降低失真与 Token 成本。 \ No newline at end of file diff --git a/docs/design-prototype.html b/docs/design-prototype.html new file mode 100644 index 0000000..9e39273 --- /dev/null +++ b/docs/design-prototype.html @@ -0,0 +1,901 @@ + + + + +MarketAssistant — UI 设计原型 v5 + + + + +
+ + + +
+ +
+ + +
+
上证3,387.52+1.24%
+
深证10,892-0.38%
+
创业2,156+0.82%
+
BTC67,842+2.81%
+
+
+
+ + +
+ Ctrl+M +
+ +
+ + +
+
+ + +
+
+
+
AI 市场概览实时分析
+

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

+
MLF降息北向流入成交放量科技成长
+
+ +
+
热门标的AI 选股 →
+
+
宁德时代
SZ 300750
215.80
+3.24%
新能源
+
贵州茅台
SH 600519
1,685.00
+0.75%
白酒
+
中科曙光
SH 603019
62.38
+7.25%
AI算力
+
比亚迪
SZ 002594
268.50
+5.12%
汽车
+
中芯国际
SH 688981
56.32
-1.08%
芯片
+
阳光电源
SZ 300274
92.40
+0.65%
光伏
+
+
+ +
+
A 股指数
上证指数
3,387.52
▲ +41.35 (+1.24%)
+
A 股指数
深证成指
10,892.41
▼ -41.52 (-0.38%)
+
A 股指数
创业板指
2,156.30
▲ +17.58 (+0.82%)
+
A 股指数
科创50
968.50
▲ +18.35 (+1.93%)
+
+ +
+
7x24 快讯15s
+
+
14:32
央行下调MLF利率10个基点至2.65%
宏观
+
14:18
茅台半年报:营收同比增18.3%
600519
+
13:55
新能源汽车渗透率突破45%
行业
+
13:05
AI概念全线走强,算力租赁多股涨停
AI
+
+
+ +
+ 最近查看 +
药明康德60325962.15
+
东方财富30005915.28
+
隆基绿能60101218.96
+
+
+
+ + +
+
+
我的收藏
共 4 只标的
+
+
+
+
+
SZ 300750
新能源
+
宁德时代
+
215.80+3.24%
+
+
+
SH 600519
白酒
+
贵州茅台
+
1,685.00+0.75%
+
+
+
SZ 002594
汽车
+
比亚迪
+
268.50+5.12%
+
+
+
SH 603019
AI算力
+
中科曙光
+
62.38+7.25%
+
+
+
+ + +
+
AI 智能选股
+
+
技术面
量价·形态·指标
+
基本面
财报·估值·成长
+
综合
多维度·AI评分
+
+ +
+ + +
+ +
+
放量突破
MACD金叉 + 量能放大 + 突破均线
+
低估值高股息
PE < 20 + 股息率 > 3% + 稳定ROE
+
超跌反弹
RSI超卖 + 底背离 + 缩量企稳
+
北向追踪
北向持续流入 + 持仓提升 + 低换手
+
+ + +
+ + +
+
+ + AI 深度分析 + 宁德时代 +
+ +
+
+
AI 正在分析中...
+
阶段 2/4 · 技术面分析
+
+
+ 技术分析师 ✓ + 基本面分析师 + 情绪分析师 + 综合协调员 +
+ +
+ +
+
买入
+
+
综合评分
8.5
+
目标价
248.00
+
风险等级
+
投资期限
中长期
+
+
+ +
维度评分
+
+
技术面
9.0
+
基本面
7.8
+
资金面
8.5
+
情绪面
7.2
+
估值
+
+
+ +
+
投资亮点
全球动力电池龙头,市占率持续提升
技术壁垒高,产品毛利率优于行业
海外产能扩张加速,全球化布局领先
+
风险因素
锂矿价格波动影响原材料成本
行业竞争加剧,二线厂商加速追赶
地缘政治风险影响海外市场拓展
+
+ +
分析质量
+
数据完整度
92%
+
分析师一致性
78%
+
+ +
+ + + +
+
数据来源网络,仅供参考。投资有风险,入市需谨慎!
+
+ + +
+
交易中心
+
+
+ + + +
+
+
+
+
+
新建策略
+
交易对
+
类型
+
方向
+
触发价
+
数量
+ +
+
+
风控设置
+
单笔最大仓位
+
日最大亏损
+
总仓位上限
+ +
+
+
+
+
+
今日交易
12
+
今日盈亏
+$342.50
+
手续费
$8.24
+ +
+
未完成订单
+
BTC/USDT
买入67,5000.05
+
+
+
+
筛选今日 12笔+$342.50
+
+
BTC/USDT
买入成交14:320.05@67,450+$15.20
+
ETH/USDT
卖出成交13:150.5@3,420-$28.50
+
+
+
+
+
+ + +
+
设置
+
+
外观设置
+
主题
深色主题减少视觉疲劳
+
+ +
AI 模型设置
+
模型
+
API 密钥
+
+ +
市场 API 设置
+
A股 ZhiTu Token
+
虚拟币 API Key
+
Secret Key
+
+ +
投资偏好
+
风险承受
+
投资期限
+
+ +
知识库设置
+
启用知识库
向量化本地文档供 AI 分析
+
文档目录
+ +
+ +
通知设置
+
启用通知
价格突破预警时推送通知
+
+ +
MCP 服务器
+ +
+ +
+ +
+
+
+ + +
+
+ +
MarketAssistant
+
智能金融分析助手 · 跨平台桌面应用
+ v2.0.0 + +
+
+
当前版本已是最新
最后检查: 2026-05-30
+ +
+
+ +
+
+ + +
+
+ + MCP 服务器配置 +
+
+
+ +
+
DeepSeek 分析服务
AI驱动金融市场分析
已启用stdio
+
Binance 数据服务
实时行情与订单数据
已启用sse
+
Web Search
联网搜索最新资讯
已禁用http
+
+
+
+
DeepSeek 分析服务
+
名称
+
描述
+
类型
+
命令
+
启用
+
+ +
+
+
+
+ + +
+
+
+
----
+
+ + +
+
+
+ ---- + 较昨收 +
+
+ +
+
+
+
+ +
+
+
+ + + + + + + +
+
+
开盘
1,672.50
+
最高
1,692.00
+
最低
1,665.80
+
成交量
234.6万
+
+
+ +
+
+ + + +
+
+
+
+
卖五1,692.00156
+
卖四1,691.00243
+
卖三1,690.50328
+
卖一1,689.50567
+
+
── 1,685.00 ──
+
+
买一1,684.50489
+
买二1,684.00356
+
买三1,683.50278
+
+
+
+
+
+
MACD+8.52
+
KDJ-K72.35
+
RSI(14)58.42
+
BOLL 上轨1,725.80
+
BOLL 中轨1,648.20
+
BOLL 下轨1,570.60
+
+
+
+
+
总市值2.12万亿
+
流通市值2.12万亿
+
市盈率28.46
+
市净率9.82
+
52周高1,935.00
+
52周低1,355.00
+
+
+
+
+
+ +
+ + +
+
智能助手
+
+
您好!我是智能分析助手,可以帮您分析市场行情、解答投资问题。
+
AI 正在思考...
+
当前A股整体趋势偏多,新能源和AI算力板块活跃。建议关注成交量放大且技术形态转强的标的。
+
分析一下宁德时代的走势
+
宁德时代MACD日线金叉确认,量能放大156%,短期偏强。关注220元阻力位突破情况,下方支撑在198元附近。
+
+
+ + +
+
+ +
+ + + + diff --git a/docs/design-system.md b/docs/design-system.md new file mode 100644 index 0000000..e83484f --- /dev/null +++ b/docs/design-system.md @@ -0,0 +1,402 @@ +# MarketAssistant UI 设计系统 + +> **版本**: 2.0 +> **平台**: Avalonia 11.x 跨平台桌面应用 +> **产品**: 金融市场数据终端 (A股 + 虚拟币) +> **设计方向**: 清晰专业风 — Bloomberg Terminal 的严谨 × 现代 SaaS 的优雅 + +--- + +## 一、设计理念 + +### 设计目标 + +MarketAssistant 是一款面向专业投资者的金融数据终端。界面设计需同时满足: + +| 维度 | 目标 | 对应策略 | +|------|------|---------| +| **可扫描性** | 用户在 3 秒内识别关键信息 | 层次分明的排版、颜色编码、网格布局 | +| **数据密度** | 单屏展示尽可能多的有效信息 | 紧凑但不拥挤的间距系统、等宽数字字体 | +| **专业性** | 传达可靠、严谨、值得信赖的感觉 | 深色主题为主、克制的配色、清晰的边框分隔 | +| **可访问性** | 长时间盯盘不疲劳 | 深色背景减少眩光、足够的文字对比度 | +| **跨市场** | A股与虚拟币统一体验 | 市场切换时保持布局一致,仅数据变化 | + +### 设计原则 + +1. **数据优先,装饰其次**: 每一个像素都应服务于信息传递,去除无意义装饰 +2. **暗色为主,亮色为辅**: 默认深色主题,提供亮色作为备选 +3. **一致性高于个性化**: 相同类型数据使用相同呈现方式 +4. **渐进式信息披露**: 概览→详情,点击深入而非一次性堆砌 + +--- + +## 二、颜色系统 + +### 品牌主色 + +| Token | 色值 | 用途 | +|-------|------|------| +| `brand-blue` | `#1976D2` | 主按钮、选中态、链接、进度条 | +| `brand-blue-light` | `#42A5F5` | 悬停态、辅助强调 | +| `brand-blue-dark` | `#0D47A1` | 按下态 | +| `brand-accent` | `#FF6B35` | 提醒、CTA、特殊标注 | + +### 深色主题 (默认) + +| Token | 色值 | 用途 | +|-------|------|------| +| `bg-root` | `#0A0E17` | 页面最底层背景 | +| `bg-surface` | `#111722` | 卡片、面板、侧边栏 | +| `bg-elevated` | `#161C2A` | 悬浮卡片、下拉菜单、tooltip | +| `bg-overlay` | `#1B2233` | 对话框遮罩背景 | +| `border-default` | `#1E2636` | 默认边框 | +| `border-subtle` | `#182032` | 细微分割线(卡片内部) | +| `border-active` | `#1976D2` | 聚焦/选中边框 | +| `text-primary` | `#E4EAF5` | 正文、标题 | +| `text-secondary` | `#8894A8` | 辅助说明、标签 | +| `text-tertiary` | `#5A6680` | 禁用态、占位符、页码 | + +### 亮色主题 + +| Token | 色值 | 用途 | +|-------|------|------| +| `bg-root` | `#F5F6F8` | 页面最底层背景 | +| `bg-surface` | `#FFFFFF` | 卡片、面板 | +| `bg-elevated` | `#F0F1F3` | 悬浮卡片 | +| `bg-overlay` | `#E8E9EC` | 对话框背景 | +| `border-default` | `#E0E2E8` | 默认边框 | +| `border-subtle` | `#EEF0F4` | 细微分割线 | +| `text-primary` | `#1A1D26` | 正文、标题 | +| `text-secondary` | `#6B7280` | 辅助说明 | +| `text-tertiary` | `#9CA3AF` | 禁用态 | + +### 市场语义色 (中国市场标准: 红涨绿跌) + +| Token | 色值 | 用途 | +|-------|------|------| +| `color-bullish` | `#F44336` | 上涨、买入、多头 | +| `color-bearish` | `#4CAF50` | 下跌、卖出、空头 | + +### 功能语义色 + +| Token | 色值 | 用途 | +|-------|------|------| +| `color-success` | `#10B981` | 成功、完成、在线状态 | +| `color-warning` | `#F59E0B` | 警告、需要注意 | +| `color-danger` | `#EF4444` | 错误、危险操作 | +| `color-info` | `#3B82F6` | 信息提示 | + +### 状态背景色 (深色) + +| Token | 色值 | 用途 | +|-------|------|------| +| `bg-bullish` | `rgba(244,67,54,0.12)` | 上涨标签背景 | +| `bg-bearish` | `rgba(76,175,80,0.12)` | 下跌标签背景 | +| `bg-selected` | `rgba(25,118,210,0.15)` | 选中行/项背景 | + +--- + +## 三、排版系统 + +### 字体族 + +| 层级 | 字体 | 用途 | +|------|------|------| +| **UI 字体** | `-apple-system, "PingFang SC", "Microsoft YaHei", sans-serif` | 界面文字、标题、按钮 | +| **数据字体** | `"JetBrains Mono", "SF Mono", "Consolas", monospace` | 价格、百分比、成交量、代码 | + +> 数据字体必须启用 `tnum` (tabular numbers) 特性,确保数字等宽对齐。 + +### 字号阶梯 + +| Token | 字号 | 用途 | +|-------|------|------| +| `text-xs` | 11px | 代码标签、辅助说明、时间戳 | +| `text-sm` | 12px | 次要文本、标签、表格内容 | +| `text-base` | 13px | 正文(默认) | +| `text-md` | 14px | 面板标题、强调正文 | +| `text-lg` | 16px | 区块标题、卡片标题 | +| `text-xl` | 18px | 页面标题、K线图标题 | +| `text-2xl` | 22px | 大标题 | +| `text-3xl` | 28px | 核心指标(价格、涨跌幅) | + +### 行高 + +| 上下文 | 行高 | +|--------|------| +| 正文段落 | 1.6 | +| 列表项/表格 | 1.4 | +| 标题 | 1.2 | +| 数据数字 | 1.0 | + +### 字重 + +| 字重 | 数值 | 用途 | +|------|------|------| +| Regular | 400 | 正文 | +| Medium | 500 | 标签、数据 | +| Semibold | 600 | 面板标题、卡片标题 | +| Bold | 700 | 页面标题、核心指标 | + +--- + +## 四、间距系统 + +采用 **4px 基准** 间距系统,所有间距为 4 的倍数: + +| Token | 值 | CSS 变量 | 用途 | +|-------|-----|---------|------| +| `space-1` | 4px | `--space-1` | 图标与文字间距、紧凑元素间距 | +| `space-2` | 8px | `--space-2` | 列表项内边距、标签间距 | +| `space-3` | 12px | `--space-3` | 列表项间距、表单元素间距 | +| `space-4` | 16px | `--space-4` | 卡片内边距、组件间距 | +| `space-5` | 20px | `--space-5` | 区块间距 | +| `space-6` | 24px | `--space-6` | 页面内边距、大区块间距 | +| `space-8` | 32px | `--space-8` | 独立区块间距 | +| `space-10` | 40px | `--space-10` | 页面顶部间距 | +| `space-12` | 48px | `--space-12` | 页面级分隔 | + +### 布局尺寸 + +| Token | 值 | 用途 | +|-------|-----|------| +| 侧边栏 (折叠) | 60px | 图标导航栏宽度 | +| 侧边栏 (展开) | 280px | 带文字导航宽度 | +| 右侧面板 | 320px | 详情/盘口/技术指标 | +| 最小窗口宽度 | 900px | 低于此宽度隐藏右侧面板 | +| 最小窗口高度 | 640px | 低于此高度压缩图表区域 | + +--- + +## 五、圆角与阴影 + +### 圆角 + +| Token | 值 | 用途 | +|-------|-----|------| +| `radius-sm` | 4px | 按钮、标签、输入框 | +| `radius-md` | 6px | 小型卡片、下拉菜单 | +| `radius-lg` | 8px | 卡片、面板 | +| `radius-xl` | 12px | 大型面板、图表容器 | +| `radius-full` | 999px | 药丸形状、Badge | + +### 阴影 (仅亮色主题使用) + +| Token | 效果 | 用途 | +|-------|------|------| +| `shadow-card` | `0 1px 3px rgba(0,0,0,0.08)` | 默认卡片 | +| `shadow-elevated` | `0 4px 12px rgba(0,0,0,0.12)` | 悬浮卡片、下拉菜单 | +| `shadow-overlay` | `0 8px 32px rgba(0,0,0,0.18)` | 对话框、模态窗口 | + +> 深色主题中,阴影效果不明显。通过 `border` 和背景色差 (`bg-elevated`) 来区分层级。 + +--- + +## 六、动效系统 + +| Token | 时长 | 缓动 | 用途 | +|-------|------|------|------| +| `transition-fast` | 150ms | `ease` | 悬停色变、选中态切换 | +| `transition-normal` | 250ms | `ease` | 面板展开/折叠、页面切换 | +| `transition-slow` | 400ms | `ease-out` | 模态窗口进出 | + +### 动效原则 + +- 仅对 `opacity`、`transform`、`background-color`、`border-color` 做动效 +- 禁止对 `width`、`height`、`margin`、`padding` 做动画(性能差) +- 所有循环动画(如加载指示器)需可被 `prefers-reduced-motion` 暂停 +- 不使用弹性/回弹缓动(专业金融工具的克制感) + +--- + +## 七、组件规范 + +### 7.1 按钮 (Button) + +| 变体 | 用途 | 样式 | +|------|------|------| +| **Primary** | 主要操作(搜索、确认) | `bg-brand-blue`, 白色文字 | +| **Secondary** | 次要操作(取消、返回) | `bg-elevated`, 边框, 主色文字 | +| **Ghost** | 低优先级操作 | 透明背景, 悬停显示背景 | +| **Danger** | 危险操作(删除、清空) | `bg-danger/0.12`, 红色文字 | +| **Icon** | 纯图标按钮 | 44x44px 点击区域, 图标 22px | + +### 按钮尺寸 + +| 尺寸 | 高度 | 水平内边距 | 字号 | +|------|------|-----------|------| +| Small | 28px | 10px | 11px | +| Medium (默认) | 32px | 16px | 12px | +| Large | 40px | 20px | 14px | + +### 7.2 输入框 (Input) + +- 高度: 32px (Small) / 40px (Medium) +- 边框: 1px `border-default` +- 聚焦: 边框 `border-active` + `box-shadow: 0 0 0 2px rgba(25,118,210,0.2)` +- 禁用: 背景 `bg-elevated`, 文字 `text-tertiary` +- 错误: 边框 `color-danger` + 下方错误文字 + +### 7.3 卡片 (Card) + +所有卡片采用统一结构: + +``` +┌─ Card ──────────────────────────────────┐ +│ Header: 标题 + 操作按钮 │ +│ ─────────────────────────────────────── │ +│ Body: 内容区 (可滚动) │ +│ │ +└──────────────────────────────────────────┘ +``` + +- 背景: `bg-surface` +- 边框: 1px `border-default` +- 圆角: `radius-lg` (8px) +- 内边距: Header `space-4` / Body 由内容决定 + +### 7.4 标签 (Tag/Badge) + +| 变体 | 背景 | 文字色 | +|------|------|--------| +| Default | `bg-elevated` | `text-secondary` | +| Bullish | `rgba(244,67,54,0.12)` | `color-bullish` | +| Bearish | `rgba(76,175,80,0.12)` | `color-bearish` | +| Primary | `rgba(25,118,210,0.15)` | `brand-blue-light` | +| Warning | `rgba(245,158,11,0.12)` | `color-warning` | + +### 7.5 表格 (Table) + +金融数据表格规范: + +- 表头: `text-xs`, `text-tertiary`, 大写, 加粗 +- 数据行: `text-sm`, 交替行背景 (每 2 行微变) +- 数字列: 右对齐, `JetBrains Mono` +- 行高: 36px (紧凑) / 44px (舒适) +- 悬停行: `bg-elevated` +- 选中行: `bg-selected` + 左侧 3px `brand-blue` 指示条 + +### 7.6 状态组件 + +| 状态 | 组件 | 说明 | +|------|------|------| +| **加载中** | Skeleton | 灰色脉冲占位块,模拟内容形状 | +| **空数据** | EmptyState | 图标 + 标题 + 描述 + 行动按钮 | +| **错误** | ErrorState | 图标 + 错误信息 + 重试按钮 | +| **成功** | Toast | 右上角滑入通知,3秒自动消失 | + +### 7.7 导航 + +``` +┌──────┬──────────────────────────────────────────┐ +│ Icon │ 页面内容 │ +│ Nav │ │ +│ 60px │ │ +│ │ │ +│ 🏠 │ │ +│ ⭐ │ │ +│ 🤖 │ │ +│ 📊 │ │ +│ │ │ +│ ⚙️ │ │ +└──────┴──────────────────────────────────────────┘ +``` + +- 导航栏宽度: 60px (仅图标) +- 每个导航项: 44x44px 可点击区域 +- 活跃态: 左侧 3px 蓝色指示条 + 蓝色图标色 +- 图标: 22px SVG + +--- + +## 八、布局模式 + +### 8.1 首页 (Dashboard) + +``` +┌──────┬──────────────────────────────────┬──────────┐ +│ │ TopBar (市场行情条) │ │ +│ ├──────────────────────────────────┤ │ +│ Nav │ Chart Section (K线图 + 指标) │ Detail │ +│ 60px │ │ Panel │ +│ ├──────────────────────────────────┤ 320px │ +│ │ Metrics Grid (4列指标卡片) │ │ +│ ├──────────────────┬───────────────┤ │ +│ │ Hot Assets │ News Feed │ │ +│ │ (热门标的列表) │ (7x24快讯) │ │ +│ └──────────────────┴───────────────┴──────────┘ +└────────────────────────────────────────────────────┘ +``` + +### 8.2 响应式断点 + +| 断点 | 宽度 | 布局变化 | +|------|------|---------| +| Large | >= 1200px | 完整三栏 (导航 + 内容 + 详情面板) | +| Medium | 900-1199px | 两栏 (导航 + 内容,隐藏详情面板) | +| Small | < 900px | 单栏 (导航 + 内容,指标卡片 2 列) | + +--- + +## 九、图标系统 + +### 替换 Emoji 方案 + +| 当前 Emoji | 替换为 | SVG 图标描述 | +|-----------|--------|------------| +| 🤖 | AI 分析图标 | 芯片/神经网络节点 | +| 🔄 | 刷新图标 | 循环箭头 | +| ⚠️ | 警告图标 | 三角感叹号 | +| ⭐ | 收藏图标 | 五角星 | +| 💬 | 聊天图标 | 对话气泡 | + +### 图标规格 + +- 导航图标: 22x22px, stroke-width 1.5 +- 按钮内图标: 16x16px, stroke-width 1.5 +- 状态指示器: 8x8px 实心圆 +- 文件格式: SVG (支持 Avalonia Svg.Skia 渲染) + +--- + +## 十、迁移路线图 + +### P0 — 立即执行 (阻塞项) + +1. **建立排版系统**: 完善 `TextStyles.axaml`,定义 H1-H6 / Body / Caption / Label / Monospace 样式类 +2. **替换 Emoji 图标**: 用专业 SVG 图标替换所有 emoji 使用 +3. **统一按钮变体**: 完善 `ButtonStyles.axaml`,增加 Primary / Secondary / Ghost / Danger / Icon 变体 + +### P1 — 本迭代完成 + +4. **统一状态组件**: 创建 EmptyState / ErrorState / LoadingSkeleton 复用组件 +5. **完善表单样式**: 输入框、下拉框、复选框统一样式 +6. **完善列表样式**: 列表项选中/悬停/禁用态 +7. **建立数据表格样式**: 金融数据表格专属样式 + +### P2 — 下个迭代 + +8. **Badge/Tag/Chip 组件**: 创建可复用的标签组件 +9. **Toast 通知组件**: 嵌入式 Toast 替代独立 Window 通知 +10. **响应式布局**: 根据窗口大小自适应隐藏/显示面板 + +### P3 — 增强 + +11. **键盘快捷键**: 专业用户常用的快捷键绑定 +12. **自定义主题色**: 允许用户微调品牌色 +13. **窗口分屏**: 支持拖拽分屏查看多个标的 + +--- + +## 附录: 与现有系统的对应关系 + +| 新设计系统 Token | 现有 Colors.axaml Key | 说明 | +|-----------------|----------------------|------| +| `color-bullish` | `BullishRed` (#F44336) | 保持一致 | +| `color-bearish` | `BearishGreen` (#4CAF50) | 保持一致 | +| `brand-blue` | `Primary` (#1976D2) | 保持一致 | +| `brand-accent` | `Accent` (#FF6B35) | 保持一致 | +| `bg-surface` (dark) | `CardBackgroundBrush` (#1E1E1E) | 调整为 #111722 | +| `bg-root` (dark) | `PageBackgroundBrush` (#121212) | 调整为 #0A0E17 | +| `text-primary` (dark) | `TextPrimaryBrush` (#FFFFFF) | 调整为 #E4EAF5 (减轻眩光) | +| `text-secondary` (dark) | `TextSecondaryBrush` (#B3B3B3) | 调整为 #8894A8 | diff --git a/docs/trading-module-design.md b/docs/trading-module-design.md new file mode 100644 index 0000000..c937cb5 --- /dev/null +++ b/docs/trading-module-design.md @@ -0,0 +1,405 @@ +# 虚拟币自主交易模块设计 + +> 状态:现货交易主链已实现,并已优先通过 `IExchangeClient` 抽象接入;合约支持和部分策略类型仍在规划中。 + +## 一、设计目标 + +独立的虚拟币自主交易助手,支持: + +- **混合运行模式**:后台持续监控 + 价格触发分析 + 策略自动执行 +- **策略自动化**:用户设置止损/止盈/仓位等参数,助手在限制内自动执行 +- **交易平台**:Binance(当前为现货,合约待补充) + +--- + +## 二、架构概览 + +### 核心组件 + +当前核心实现主要位于 `src/MarketAssistant.App/Trading/`,包含以下核心组件: + +- **MarketMonitor**:后台市场监控,基于 `BinanceWebSocketService` 实时价格流 + `PriceAlertService` 触发条件。 +- **TradingAgent**:MAF `ChatClientAgent`,持有交易专用工具集,接收 Monitor 信号后自主分析并决策。 +- **StrategyEngine**:用户定义的策略管理(止损/止盈/网格/追踪等),解析策略规则为可执行条件。 +- **RiskManager**:风控网关,所有交易指令必须经过风控检查(单笔限额、日限额、最大持仓比例等)。 +- **TradeExecutor**:通过 `IExchangeClient` 执行下单并记录交易日志。 + +### 数据流 + +``` +BinanceWebSocket → MarketMonitor → [触发条件匹配] + ↓ + TradingAgent(AI 分析决策) + ↓ + StrategyEngine(策略验证) + ↓ + RiskManager(风控检查) + ↓ + TradeExecutor(执行下单) + ↓ + TradingDataService(持久化) + UI 通知 +``` + +### 与现有服务的边界 + +| 类别 | 服务 | 说明 | +|------|------|------| +| **复用** | `BinanceMarketDataService` | 现货+合约行情数据 | +| **复用** | `BinanceWebSocketService` | 实时价格流(`PriceUpdated` 事件) | +| **复用** | `BinanceAccountService` | Binance 现货 API 底层实现,由 `BinanceExchangeClient` 适配为统一抽象 | +| **复用** | `BinanceAuthService` | HMAC-SHA256 签名,已通过设置服务动态读取密钥 | +| **复用** | `PriceAlertService` | 价格触发逻辑参考 | +| **新建** | `MarketMonitor` | 后台监控 + 策略触发 | +| **新建** | `TradingAgent` | AI 自主决策 | +| **新建** | `StrategyEngine` | 策略管理与条件匹配 | +| **新建** | `RiskManager` | 风控校验 | +| **新建** | `TradeExecutor` | 下单执行 + 日志 | +| **新建** | `TradingDataService` | SQLite 持久化 | + +--- + +## 三、模型定义 + +### 3.1 枚举类型 + +```csharp +public enum StrategyType +{ + StopLoss, // 止损 + TakeProfit, // 止盈 + TrailingStop, // 追踪止损 + GridTrading, // 网格交易 + DCA, // 定投(Dollar Cost Averaging) + AISignal // AI 信号触发(TradingAgent 自主决策) +} + +public enum StrategyStatus { Active, Paused, Completed, Failed } +public enum OrderSide { Buy, Sell } +public enum OrderType { Market, Limit } +public enum TradeRecordStatus { Pending, Filled, PartiallyFilled, Cancelled, Failed } +``` + +### 3.2 交易策略(TradingStrategy) + +```csharp +public class TradingStrategy +{ + public string Id { get; set; } + public string Symbol { get; set; } // e.g. "BTCUSDT" + public StrategyType Type { get; set; } + public StrategyStatus Status { get; set; } + public OrderSide Side { get; set; } + public decimal TriggerPrice { get; set; } // 触发价格 + public decimal? StopLossPrice { get; set; } // 止损价 + public decimal? TakeProfitPrice { get; set; } // 止盈价 + public decimal Quantity { get; set; } // 交易数量 + public decimal? MaxPositionPercent { get; set; } // 最大仓位占比 (0-100) + public string? CustomParams { get; set; } // JSON:策略特定参数 + public DateTime CreatedAt { get; set; } + public DateTime? LastTriggeredAt { get; set; } + public int ExecutionCount { get; set; } // 已执行次数 + public int? MaxExecutions { get; set; } // 最大执行次数 (null=无限) +} +``` + +#### CustomParams 策略特定参数 + +| 策略类型 | JSON 结构 | 说明 | +|---------|-----------|------| +| TrailingStop | `{ "trailingPercent": 3.0, "activationPrice": 50000 }` | 追踪幅度、激活价格 | +| GridTrading | `{ "upperPrice": 52000, "lowerPrice": 48000, "gridCount": 10, "amountPerGrid": 0.01 }` | 上下界、网格数、每格数量 | +| DCA | `{ "intervalMinutes": 1440, "amountPerBuy": 100 }` | 间隔时间、每次金额(USDT) | +| AISignal | `{ "confidenceThreshold": 0.8, "analysisInterval": 3600 }` | 置信度阈值、分析间隔秒数 | + +### 3.3 交易记录(TradeRecord) + +```csharp +public class TradeRecord +{ + public string Id { get; set; } + public string StrategyId { get; set; } // 关联策略 + public string Symbol { get; set; } + public OrderSide Side { get; set; } + public OrderType OrderType { get; set; } + public decimal RequestedQty { get; set; } // 请求数量 + public decimal ExecutedQty { get; set; } // 实际成交数量 + public decimal? RequestedPrice { get; set; } // 限价单价格 + public decimal ExecutedPrice { get; set; } // 实际成交均价 + public decimal Commission { get; set; } // 手续费 + public string CommissionAsset { get; set; } // 手续费币种 + public TradeRecordStatus Status { get; set; } + public long BinanceOrderId { get; set; } // Binance 订单号 + public string? AIReasoning { get; set; } // AI 决策推理过程 + public DateTime CreatedAt { get; set; } + public DateTime? CompletedAt { get; set; } +} +``` + +### 3.4 风控配置(RiskConfig) + +```csharp +public class RiskConfig +{ + public decimal MaxSingleOrderPercent { get; set; } = 5; // 单笔最大仓位 % + public decimal MaxDailyLossPercent { get; set; } = 10; // 日最大亏损 % + public decimal MaxTotalPositionPercent { get; set; } = 80; // 总仓位上限 % + public int MaxDailyTrades { get; set; } = 20; // 日最大交易次数 + public decimal MinOrderAmount { get; set; } = 10; // 最小下单金额 (USDT) + public bool RequireConfirmation { get; set; } = false; // 大额单是否需人工确认 + public decimal ConfirmationThreshold { get; set; } = 1000; // 需确认的金额阈值 (USDT) +} +``` + +### 3.5 日统计(DailyStats) + +```csharp +public class DailyStats +{ + public string Date { get; set; } // yyyy-MM-dd + public int TradeCount { get; set; } + public decimal TotalPnl { get; set; } + public decimal TotalCommission { get; set; } +} +``` + +--- + +## 四、TradingAgent 工具集 + +### 4.1 工具接口 + +基于现有 `[RequiresTools]` + Keyed Services 模式设计: + +```csharp +/// +/// 交易执行工具 —— 仅 Crypto 市场 +/// +public interface ITradingExecutionTools +{ + Task GetAccountBalanceAsync(); + Task> GetCurrentPositionsAsync(); + Task PlaceOrderAsync(string symbol, OrderSide side, + OrderType type, decimal quantity, decimal? price = null); + Task GetOrderStatusAsync(string symbol, long orderId); + Task CancelOrderAsync(string symbol, long orderId); +} + +/// +/// 策略管理工具 —— TradingAgent 可查询和更新策略状态 +/// +public interface IStrategyTools +{ + Task> GetActiveStrategiesAsync(); + Task GetStrategyAsync(string strategyId); + Task UpdateStrategyStatusAsync(string strategyId, StrategyStatus status); +} +``` + +### 4.2 辅助模型 + +```csharp +public class AccountBalanceSummary +{ + public decimal TotalValueUSDT { get; set; } + public List Assets { get; set; } = []; +} + +public class AssetBalance +{ + public string Asset { get; set; } = string.Empty; + public decimal Free { get; set; } + public decimal Locked { get; set; } + public decimal ValueUSDT { get; set; } +} + +public class PositionInfo +{ + public string Symbol { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public decimal EntryPrice { get; set; } + public decimal CurrentPrice { get; set; } + public decimal UnrealizedPnl { get; set; } + public decimal UnrealizedPnlPercent { get; set; } +} + +public class TradeResult +{ + public bool Success { get; set; } + public string? ErrorMessage { get; set; } + public TradeRecord? Record { get; set; } +} + +public class OrderStatusInfo +{ + public long OrderId { get; set; } + public string Symbol { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public decimal ExecutedQty { get; set; } + public decimal ExecutedPrice { get; set; } +} +``` + +### 4.3 TradingAgent 声明 + +```csharp +[RequiresTools(typeof(ITradingExecutionTools))] +[RequiresTools(typeof(IStrategyTools))] +[RequiresTools(typeof(IBasicDataTools))] // 复用:价格/K线/市值 +[RequiresTools(typeof(ITechnicalDataTools))] // 复用:技术指标 +public class TradingAgent : ChatClientAgent { ... } +``` + +TradingAgent 不继承 `AnalystAgentBase`(它不是分析师),由专用的 `TradingAgentFactory` 创建,使用与 `AnalystAgentFactory` 相同的 `[RequiresTools]` 解析模式。 + +### 4.4 系统提示词要点 + +``` +角色:虚拟币自主交易助手 + +能力: +- 查询账户余额和持仓(GetAccountBalance / GetCurrentPositions) +- 分析市场数据:价格、K线、技术指标(复用 IBasicDataTools / ITechnicalDataTools) +- 根据策略规则和风控约束决定是否交易 +- 执行买卖操作(PlaceOrder)并记录决策推理 + +约束: +- 所有交易必须经过 RiskManager 风控检查(PlaceOrder 内部自动调用) +- 单笔不超过账户 {MaxSingleOrderPercent}% +- 日亏损不超过 {MaxDailyLossPercent}% +- 必须记录每次决策的推理过程到 AIReasoning 字段 +- 遇到不确定情况,倾向于不交易(宁可错过,不可做错) +``` + +### 4.5 工具实现位置 + +| 接口 | 实现类 | 位置 | 依赖 | +|------|--------|------|------| +| `ITradingExecutionTools` | `CryptoTradingExecutionTools` | `src/MarketAssistant.App/Agents/Tools/Crypto/` | `TradeExecutor`, `IExchangeClient` | +| `IStrategyTools` | `CryptoStrategyTools` | `src/MarketAssistant.App/Agents/Tools/Crypto/` | `StrategyEngine` | +| `IBasicDataTools` | `CryptoBasicTools`(已有) | `src/MarketAssistant.App/Agents/Tools/Crypto/` | `BinanceMarketDataService` | +| `ITechnicalDataTools` | `CryptoTechnicalTools`(已有) | `src/MarketAssistant.App/Agents/Tools/Crypto/` | `IKLineService`, `Skender.Stock.Indicators` | + +--- + +## 五、数据持久化方案 + +### 5.1 SQLite 数据库 + +数据库路径:`%AppData%/MarketAssistant/trading.db`(与 `reports.db` 同级) + +#### 表结构 + +```sql +CREATE TABLE strategies ( + id TEXT PRIMARY KEY, + symbol TEXT NOT NULL, + type INTEGER NOT NULL, + status INTEGER NOT NULL, + side INTEGER NOT NULL, + trigger_price REAL NOT NULL, + stop_loss_price REAL, + take_profit_price REAL, + quantity REAL NOT NULL, + max_position_percent REAL, + custom_params TEXT, + created_at TEXT NOT NULL, + last_triggered_at TEXT, + execution_count INTEGER DEFAULT 0, + max_executions INTEGER +); +CREATE INDEX idx_strategies_symbol ON strategies(symbol); +CREATE INDEX idx_strategies_status ON strategies(status); + +CREATE TABLE trade_records ( + id TEXT PRIMARY KEY, + strategy_id TEXT NOT NULL, + symbol TEXT NOT NULL, + side INTEGER NOT NULL, + order_type INTEGER NOT NULL, + requested_qty REAL NOT NULL, + executed_qty REAL NOT NULL, + requested_price REAL, + executed_price REAL NOT NULL, + commission REAL 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) +); +CREATE INDEX idx_records_strategy ON trade_records(strategy_id); +CREATE INDEX idx_records_symbol ON trade_records(symbol); +CREATE INDEX idx_records_created ON trade_records(created_at); + +CREATE TABLE daily_stats ( + date TEXT PRIMARY KEY, + trade_count INTEGER DEFAULT 0, + total_pnl REAL DEFAULT 0, + total_commission REAL DEFAULT 0 +); +``` + +### 5.2 持久化服务(TradingDataService) + +```csharp +public class TradingDataService +{ + // 策略 CRUD + Task SaveStrategyAsync(TradingStrategy strategy); + Task GetStrategyAsync(string id); + Task> GetStrategiesByStatusAsync(StrategyStatus status); + Task UpdateStrategyStatusAsync(string id, StrategyStatus status); + Task DeleteStrategyAsync(string id); + + // 交易记录 + Task SaveTradeRecordAsync(TradeRecord record); + Task> GetTradeRecordsAsync( + string? symbol, DateTime? from, DateTime? to, int limit = 50); + Task> GetRecordsByStrategyAsync(string strategyId); + + // 日统计(风控查询) + Task GetTodayStatsAsync(); + Task UpdateDailyStatsAsync(decimal pnl, decimal commission); +} +``` + +参考 `ReportArchiveService` 的实现模式:使用 `Microsoft.Data.Sqlite`,在构造函数中自动初始化表结构。 + +### 5.3 风控配置持久化 + +使用 `Preferences`(与 `UserSettingService` 一致): + +- Key = `"TradingRiskConfig"` +- JSON 序列化 `RiskConfig` 对象 +- 默认值在 `RiskConfig` 类中定义 + +--- + +## 六、现有服务适配清单 + +| 服务 | 现状 | 需要补充 | +|------|------|---------| +| `BinanceAccountService` | 已覆盖现货账户、下单、查单、撤单、挂单查询 | 继续评估合约 API 和更细粒度错误映射 | +| `BinanceAuthService` | 完整可用 | 维持通过 `IUserSettingService` 动态读取密钥 | +| `BinanceWebSocketService` | `PriceUpdated` 事件可用 | 无需修改 | +| `PriceAlertService` | 价格触发逻辑可参考 | MarketMonitor 独立实现,但复用相同模式 | + +--- + +## 七、UI 页面规划 + +- **策略配置页**:创建/编辑/启停策略,设置止损止盈参数,显示策略运行状态 +- **交易监控页**:实时显示当前持仓、未完成订单、账户余额概览 +- **交易历史页**:查看历史交易记录,按币种/时间筛选,收益统计图表 + +--- + +## 八、实施步骤(按优先级) + +1. **继续收敛抽象**:保持 `RiskManager`、`TradeExecutor`、Agent 工具与监控链优先依赖 `IExchangeClient`,避免重复封装 Binance 账户逻辑。 +2. **扩展交易能力**:补充合约 API、完善订单错误映射与可观测性。 +3. **补齐策略类型**:逐步实现 `GridTrading`、`DCA` 等仍未完成的策略类型。 +4. **Agent 层增强**:完善 `TradingAgent` 的策略上下文、提示词和人工确认边界。 +5. **UI 层完善**:持续增强策略配置页、监控页和交易历史页的联动能力。 diff --git a/scripts/BUILD.md b/scripts/BUILD.md index 644eee8..d6ef246 100644 --- a/scripts/BUILD.md +++ b/scripts/BUILD.md @@ -107,7 +107,7 @@ pwsh scripts/build-release.ps1 -Platform macOS # 或 Linux ```bash # 发布 -dotnet publish src/MarketAssistant.csproj \ +dotnet publish src/MarketAssistant.App/MarketAssistant.App.csproj \ -c Release \ -r win-x64 \ --self-contained \ @@ -129,6 +129,8 @@ Compress-Archive -Path ./publish/windows/* -DestinationPath MarketAssistant-Wind macOS 构建遵循 [Avalonia 官方 macOS 部署指南](https://docs.avaloniaui.net/docs/deployment/macOS),创建标准的 `.app` bundle 和 `.dmg` 磁盘映像。 +构建产物为 **Universal Binary**(同时包含 arm64 和 x86_64 架构),一份 `.dmg` 适用于所有 Mac 设备(Apple Silicon 和 Intel)。脚本内部会分别 publish 两个架构,再用 `lipo` 合并主可执行文件和 `.dylib` 原生库。 + #### 使用脚本(推荐) ```bash @@ -146,9 +148,9 @@ pwsh scripts/build-release.ps1 -Platform macOS #### 配置文件 - **Info.plist** - 应用程序元数据 - - CFBundleIdentifier: `com.marketassistant.app` + - CFBundleIdentifier: `xyz.haoai.market` - CFBundleName: `MarketAssistant` - - CFBundleVersion: 从 `.csproj` 读取 + - CFBundleVersion: 从 tag 版本号注入(CI 通过 `-p:Version` 传递) - **Entitlements** - 应用权限 - 网络访问(客户端/服务器) @@ -177,8 +179,8 @@ export NOTARYTOOL_PROFILE="AC_PASSWORD" #### 输出 -- `Release/macOS/MarketAssistant.app` - 应用程序 bundle -- `Release/macOS/MarketAssistant-1.0.0.dmg` - DMG 磁盘映像 +- `Release/macOS/MarketAssistant.app` - 应用程序 bundle(Universal Binary) +- `Release/macOS/MarketAssistant-1.0.0.dmg` - DMG 磁盘映像(Universal Binary,支持 arm64 + x86_64) #### 验证 @@ -251,7 +253,7 @@ sudo rpm -ivh Release/Linux/MarketAssistant-1.0.0-x86_64.rpm ```bash # 在任何平台上 -dotnet publish src/MarketAssistant.csproj \ +dotnet publish src/MarketAssistant.App/MarketAssistant.App.csproj \ -c Release \ -r linux-x64 \ --self-contained \ @@ -316,7 +318,8 @@ git push origin v1.0.0 工作流自动生成以下文件并附加到 Release: - `MarketAssistant-Windows-x64.zip` - Windows 便携版 -- `MarketAssistant-1.0.0.dmg` - macOS 磁盘映像 +- `MarketAssistant-Setup-.exe` - Windows 安装版(Inno Setup) +- `MarketAssistant-1.0.0.dmg` - macOS 磁盘映像(Universal Binary,支持 arm64 + x86_64) - `MarketAssistant_1.0.0_amd64.deb` - Debian/Ubuntu 安装包 - `MarketAssistant-1.0.0-x86_64.rpm` - Fedora/RHEL 安装包 - `MarketAssistant-Linux-x64.zip` - Linux 便携版 @@ -395,7 +398,7 @@ dpkg-sig --sign builder MarketAssistant_1.0.0_amd64.deb ### 启用 Native AOT -在 `MarketAssistant.csproj` 中添加: +在 `src/MarketAssistant.App/MarketAssistant.App.csproj` 中添加: ```xml @@ -424,8 +427,7 @@ dpkg-sig --sign builder MarketAssistant_1.0.0_amd64.deb ### 平台运行时标识符 - **Windows**: `win-x64` -- **macOS Intel**: `osx-x64` -- **macOS Apple Silicon**: `osx-arm64` +- **macOS**: `osx-arm64` + `osx-x64`(构建为 Universal Binary,一份产物支持所有 Mac) - **Linux**: `linux-x64` --- @@ -439,17 +441,10 @@ dpkg-sig --sign builder MarketAssistant_1.0.0_amd64.deb ```bash dotnet restore MarketAssistant.slnx dotnet clean MarketAssistant.slnx -dotnet restore src/MarketAssistant.csproj +dotnet restore src/MarketAssistant.App/MarketAssistant.App.csproj ``` -#### 2. Playwright 浏览器未安装 - -```bash -dotnet tool update --global Microsoft.Playwright.CLI -playwright install -``` - -#### 3. macOS: "App is damaged and can't be opened" +#### 2. macOS: "App is damaged and can't be opened" 这通常是因为 Gatekeeper 阻止了未签名的应用。解决方法: @@ -484,7 +479,7 @@ sudo apt-get install libicu70 libssl3 ```bash # 详细构建输出 -dotnet publish src/MarketAssistant.csproj -c Release -r win-x64 -v detailed +dotnet publish src/MarketAssistant.App/MarketAssistant.App.csproj -c Release -r win-x64 -v detailed ``` #### GitHub Actions 日志 @@ -536,35 +531,29 @@ dpkg-deb --contents Release/Linux/MarketAssistant_1.0.0_amd64.deb ## 更新版本号 -在发布新版本前,更新以下文件: +版本号通过 Git tag 统一管理,CI 会自动注入到所有构建产物,**无需手动修改任何文件**。 -### 1. 项目文件 +### 发版流程 -```xml - - - 1.0.0 - 1.0.0 - 1.0.0 - +```bash +# 1. 打 tag(版本号不带 v 前缀也可,CI 会自动去除) +git tag v1.0.0 +git push origin v1.0.0 + +# 2. CI 自动触发 release.yml,从 tag 提取版本号并注入: +# - dotnet publish -p:Version= -p:InformationalVersion= +# - macOS/Linux 脚本通过 APP_VERSION 环境变量接收 +# - Windows .iss 通过 sed 替换 MyAppVersion +# - Info.plist 通过 sed 替换 __APP_VERSION__ 占位符 ``` -### 2. macOS Info.plist +### 预发布版本 -```xml - -CFBundleVersion -1.0.0 -CFBundleShortVersionString -1.0.0 -``` +tag 包含 `-` 即视为预发布(如 `v1.0.0-beta1`),GitHub Release 会自动标记为 prerelease,且正式版用户不会被提示升级到预发布版本。 -### 3. 构建脚本 +### 本地开发版本 -更新脚本中的 `VERSION` 变量: -- `scripts/macos/build-app-bundle.sh` -- `scripts/linux/build-deb.sh` -- `scripts/linux/build-rpm.sh` +本地 `dotnet run` 时使用 [csproj](../src/MarketAssistant.App/MarketAssistant.App.csproj) 中的 `` 默认值(当前为 `1.0.0-beta1`),不影响发布。 --- diff --git a/scripts/build-release.ps1 b/scripts/build-release.ps1 index 000c7a8..0268f87 100644 --- a/scripts/build-release.ps1 +++ b/scripts/build-release.ps1 @@ -41,7 +41,7 @@ function Build-Windows { $publishDir = "$outputDir/Windows-x64" $buildStart = Get-Date - dotnet publish src/MarketAssistant.csproj ` + dotnet publish src/MarketAssistant.App/MarketAssistant.App.csproj ` -c $Configuration ` -r win-x64 ` --self-contained ` @@ -127,7 +127,7 @@ function Build-macOS { $publishDir = "$outputDir/macOS-x64" - dotnet publish src/MarketAssistant.csproj ` + dotnet publish src/MarketAssistant.App/MarketAssistant.App.csproj ` -c $Configuration ` -r osx-x64 ` --self-contained ` @@ -206,7 +206,7 @@ function Build-Linux { $publishDir = "$outputDir/Linux-x64" - dotnet publish src/MarketAssistant.csproj ` + dotnet publish src/MarketAssistant.App/MarketAssistant.App.csproj ` -c $Configuration ` -r linux-x64 ` --self-contained ` diff --git a/scripts/linux/build-deb.sh b/scripts/linux/build-deb.sh index 059e3e4..fded1e6 100644 --- a/scripts/linux/build-deb.sh +++ b/scripts/linux/build-deb.sh @@ -5,10 +5,13 @@ set -e # 遵循 Debian 软件包规范 APP_NAME="marketassistant" -VERSION="1.0.0" +# 优先使用 CI 注入的版本号,否则回退到默认值 +VERSION="${APP_VERSION:-1.0.0}" ARCH="amd64" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +APP_CSProj="$PROJECT_ROOT/src/MarketAssistant.App/MarketAssistant.App.csproj" +APP_ASSETS_DIR="$PROJECT_ROOT/src/MarketAssistant.App/Assets" BUILD_DIR="$PROJECT_ROOT/Release/Linux" PUBLISH_DIR="$BUILD_DIR/publish" DEB_DIR="$BUILD_DIR/deb" @@ -34,7 +37,7 @@ mkdir -p "$BUILD_DIR" echo -e "${YELLOW}📦 Publishing app...${NC}" cd "$PROJECT_ROOT" -dotnet publish src/MarketAssistant.csproj \ +dotnet publish "$APP_CSProj" \ -c Release \ -r linux-x64 \ --self-contained \ @@ -44,6 +47,8 @@ dotnet publish src/MarketAssistant.csproj \ -p:DebugType=None \ -p:DebugSymbols=false \ -p:ErrorOnDuplicatePublishOutputFiles=false \ + -p:Version="$VERSION" \ + -p:InformationalVersion="$VERSION" \ -o "$PUBLISH_DIR" if [ $? -ne 0 ]; then @@ -78,9 +83,9 @@ echo -e "${YELLOW}📝 Installing desktop entry...${NC}" cp "$SCRIPT_DIR/marketassistant.desktop" "$DEB_DIR/$PACKAGE_NAME/usr/share/applications/" # 6. 复制图标 -if [ -f "$PROJECT_ROOT/src/Assets/logo.png" ]; then +if [ -f "$APP_ASSETS_DIR/logo.png" ]; then echo -e "${YELLOW}🎨 Installing icon...${NC}" - cp "$PROJECT_ROOT/src/Assets/logo.png" "$DEB_DIR/$PACKAGE_NAME/usr/share/icons/hicolor/256x256/apps/marketassistant.png" + cp "$APP_ASSETS_DIR/logo.png" "$DEB_DIR/$PACKAGE_NAME/usr/share/icons/hicolor/256x256/apps/marketassistant.png" fi # 7. 创建 control 文件 @@ -95,11 +100,11 @@ Priority: optional Architecture: $ARCH Installed-Size: $INSTALLED_SIZE Maintainer: MarketAssistant Team -Homepage: https://github.com/yourusername/MarketAssistant -Description: AI-powered market analysis assistant - Market Assistant is a cross-platform desktop application that provides - AI-powered market analysis and insights. It features real-time data - analysis, technical indicators, and intelligent recommendations. +Homepage: https://github.com/X2Agent/MarketAssistant +Description: AI 智能市场分析助手 + 市场分析助手是一款跨平台的桌面应用程序,提供 + AI 智能市场分析与洞察。它具有实时数据分析、 + 技术指标和智能推荐功能。 Depends: libicu70 | libicu72, libssl3 | libssl1.1 EOF diff --git a/scripts/linux/build-rpm.sh b/scripts/linux/build-rpm.sh index 118c521..447179f 100644 --- a/scripts/linux/build-rpm.sh +++ b/scripts/linux/build-rpm.sh @@ -5,11 +5,13 @@ set -e # 适用于 Fedora、RHEL、CentOS APP_NAME="marketassistant" -VERSION="1.0.0" +# 优先使用 CI 注入的版本号,否则回退到默认值 +VERSION="${APP_VERSION:-1.0.0}" RELEASE="1" ARCH="x86_64" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +APP_ASSETS_DIR="$PROJECT_ROOT/src/MarketAssistant.App/Assets" BUILD_DIR="$PROJECT_ROOT/Release/Linux" PUBLISH_DIR="$BUILD_DIR/publish" RPM_DIR="$BUILD_DIR/rpm" @@ -44,17 +46,17 @@ cat > "$RPM_DIR/SPECS/$APP_NAME.spec" << EOF Name: $APP_NAME Version: $VERSION Release: $RELEASE%{?dist} -Summary: AI-powered market analysis assistant +Summary: AI 智能市场分析助手 License: Proprietary -URL: https://github.com/yourusername/MarketAssistant +URL: https://github.com/X2Agent/MarketAssistant BuildArch: $ARCH Requires: libicu >= 60, openssl-libs >= 1.1 %description -Market Assistant is a cross-platform desktop application that provides -AI-powered market analysis and insights. It features real-time data -analysis, technical indicators, and intelligent recommendations. +市场分析助手是一款跨平台的桌面应用程序,提供 +AI 智能市场分析与洞察。它具有实时数据分析、 +技术指标和智能推荐功能。 %prep # Nothing to prep @@ -76,8 +78,8 @@ cp -r $PUBLISH_DIR/* %{buildroot}/opt/MarketAssistant/ cp $SCRIPT_DIR/marketassistant.desktop %{buildroot}/usr/share/applications/ # 复制图标 -if [ -f $PROJECT_ROOT/src/Assets/logo.png ]; then - cp $PROJECT_ROOT/src/Assets/logo.png %{buildroot}/usr/share/icons/hicolor/256x256/apps/marketassistant.png +if [ -f "$APP_ASSETS_DIR/logo.png" ]; then + cp "$APP_ASSETS_DIR/logo.png" %{buildroot}/usr/share/icons/hicolor/256x256/apps/marketassistant.png fi # 创建符号链接 diff --git a/scripts/linux/marketassistant.desktop b/scripts/linux/marketassistant.desktop index 90d9c15..645e714 100644 --- a/scripts/linux/marketassistant.desktop +++ b/scripts/linux/marketassistant.desktop @@ -2,7 +2,7 @@ Version=1.0 Type=Application Name=Market Assistant -Comment=AI-powered market analysis assistant +Comment=AI 智能市场分析助手 Exec=/opt/MarketAssistant/MarketAssistant Icon=marketassistant Terminal=false diff --git a/scripts/macos/Info.plist.template b/scripts/macos/Info.plist.template index a707318..2725c08 100644 --- a/scripts/macos/Info.plist.template +++ b/scripts/macos/Info.plist.template @@ -7,11 +7,11 @@ CFBundleDisplayName Market Assistant CFBundleIdentifier - com.marketassistant.app + __BUNDLE_ID__ CFBundleVersion - 1.0.0 + __APP_VERSION__ CFBundleShortVersionString - 1.0.0 + __APP_VERSION__ CFBundlePackageType APPL CFBundleSignature @@ -27,11 +27,10 @@ LSMinimumSystemVersion 10.15 NSHumanReadableCopyright - Copyright © 2025 MarketAssistant. All rights reserved. + Copyright © 2026 X2Agent. All rights reserved. LSApplicationCategoryType public.app-category.finance NSSupportsAutomaticGraphicsSwitching - diff --git a/scripts/macos/build-app-bundle.sh b/scripts/macos/build-app-bundle.sh index ac358dc..e13fa15 100644 --- a/scripts/macos/build-app-bundle.sh +++ b/scripts/macos/build-app-bundle.sh @@ -3,14 +3,19 @@ set -e # MarketAssistant macOS App Bundle 构建脚本 # 遵循 Avalonia 官方 macOS 部署规范 +# 构建 Universal Binary(同时支持 Apple Silicon arm64 和 Intel x64) APP_NAME="MarketAssistant" -VERSION="1.0.0" -BUNDLE_ID="com.marketassistant.app" +# 优先使用 CI 注入的版本号,否则回退到默认值 +VERSION="${APP_VERSION:-1.0.0}" +BUNDLE_ID="xyz.haoai.market" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +APP_CSProj="$PROJECT_ROOT/src/MarketAssistant.App/MarketAssistant.App.csproj" +APP_ASSETS_DIR="$PROJECT_ROOT/src/MarketAssistant.App/Assets" BUILD_DIR="$PROJECT_ROOT/Release/macOS" -PUBLISH_DIR="$BUILD_DIR/publish" +PUBLISH_DIR_ARM64="$BUILD_DIR/publish-arm64" +PUBLISH_DIR_X64="$BUILD_DIR/publish-x64" APP_BUNDLE="$BUILD_DIR/$APP_NAME.app" # 颜色输出 @@ -19,7 +24,7 @@ YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' -echo -e "${GREEN}🚀 Building $APP_NAME for macOS...${NC}" +echo -e "${GREEN}🚀 Building $APP_NAME for macOS (Universal Binary)...${NC}" # 清理旧构建 if [ -d "$BUILD_DIR" ]; then @@ -29,71 +34,128 @@ fi mkdir -p "$BUILD_DIR" -# 1. 发布应用 -echo -e "${YELLOW}📦 Publishing app...${NC}" +# 通用 publish 参数 +PUBLISH_ARGS=( + "$APP_CSProj" + -c Release + --self-contained + -p:PublishSingleFile=false + -p:PublishReadyToRun=true + -p:UseAppHost=true + -p:DebugType=None + -p:DebugSymbols=false + -p:ErrorOnDuplicatePublishOutputFiles=false + -p:Version="$VERSION" + -p:InformationalVersion="$VERSION" +) + +# 1. 发布 arm64 架构 +echo -e "${YELLOW}📦 Publishing arm64 (Apple Silicon)...${NC}" cd "$PROJECT_ROOT" +dotnet publish "${PUBLISH_ARGS[@]}" -r osx-arm64 -o "$PUBLISH_DIR_ARM64" +if [ $? -ne 0 ]; then + echo -e "${RED}✗ arm64 build failed${NC}" + exit 1 +fi -dotnet publish src/MarketAssistant.csproj \ - -c Release \ - -r osx-x64 \ - --self-contained \ - -p:PublishSingleFile=false \ - -p:PublishReadyToRun=true \ - -p:UseAppHost=true \ - -p:DebugType=None \ - -p:DebugSymbols=false \ - -p:ErrorOnDuplicatePublishOutputFiles=false \ - -o "$PUBLISH_DIR" - +# 2. 发布 x64 架构 +echo -e "${YELLOW}📦 Publishing x64 (Intel)...${NC}" +dotnet publish "${PUBLISH_ARGS[@]}" -r osx-x64 -o "$PUBLISH_DIR_X64" if [ $? -ne 0 ]; then - echo -e "${RED}✗ Build failed${NC}" + echo -e "${RED}✗ x64 build failed${NC}" exit 1 fi -# 2. 创建 .app bundle 结构 +# 3. 创建 .app bundle 结构 echo -e "${YELLOW}📂 Creating .app bundle structure...${NC}" mkdir -p "$APP_BUNDLE/Contents/MacOS" mkdir -p "$APP_BUNDLE/Contents/Resources" -# 3. 复制二进制文件 -echo -e "${YELLOW}📋 Copying binaries...${NC}" -cp -r "$PUBLISH_DIR/"* "$APP_BUNDLE/Contents/MacOS/" +# 4. 复制 arm64 的所有文件作为基础(managed 代码与架构无关) +echo -e "${YELLOW}📋 Copying base files (arm64)...${NC}" +cp -r "$PUBLISH_DIR_ARM64/"* "$APP_BUNDLE/Contents/MacOS/" + +# 5. 用 lipo 合并主可执行文件为 Universal Binary +echo -e "${YELLOW}🔗 Creating Universal Binary for main executable...${NC}" +lipo -create \ + "$PUBLISH_DIR_ARM64/$APP_NAME" \ + "$PUBLISH_DIR_X64/$APP_NAME" \ + -output "$APP_BUNDLE/Contents/MacOS/$APP_NAME" + +if [ $? -ne 0 ]; then + echo -e "${RED}✗ lipo merge failed for main executable${NC}" + exit 1 +fi # 确保可执行文件有执行权限 chmod +x "$APP_BUNDLE/Contents/MacOS/$APP_NAME" -# 4. 创建 Info.plist +# 6. 遍历合并所有 .dylib 文件(native 库与架构相关,需合并) +echo -e "${YELLOW}🔗 Merging native libraries (.dylib) into Universal...${NC}" +DYLIB_COUNT=0 +while IFS= read -r -d '' dylib_arm64; do + # 计算相对路径 + rel_path="${dylib_arm64#$PUBLISH_DIR_ARM64/}" + dylib_x64="$PUBLISH_DIR_X64/$rel_path" + + if [ -f "$dylib_x64" ]; then + # 两个架构都存在,合并为 Universal(失败时保留 arm64 版本,不中断构建) + if lipo -create "$dylib_arm64" "$dylib_x64" \ + -output "$APP_BUNDLE/Contents/MacOS/$rel_path" 2>/dev/null; then + DYLIB_COUNT=$((DYLIB_COUNT + 1)) + else + echo -e "${YELLOW} ⚠ lipo failed for $rel_path, keeping arm64${NC}" + fi + fi + # 若 x64 不存在该 dylib,arm64 版本已通过 cp 复制,无需处理 +done < <(find "$PUBLISH_DIR_ARM64" -type f -name "*.dylib" -print0) + +echo -e "${GREEN}✓ Merged $DYLIB_COUNT native libraries${NC}" + +# 7. 验证 Universal Binary +echo -e "${YELLOW}🔍 Verifying Universal Binary...${NC}" +ARCH_INFO=$(lipo -archs "$APP_BUNDLE/Contents/MacOS/$APP_NAME") +echo -e "${GREEN}✓ Main executable architectures: $ARCH_INFO${NC}" +if [[ "$ARCH_INFO" != *"arm64"* ]] || [[ "$ARCH_INFO" != *"x86_64"* ]]; then + echo -e "${RED}✗ Universal Binary verification failed${NC}" + exit 1 +fi + +# 8. 创建 Info.plist echo -e "${YELLOW}📝 Creating Info.plist...${NC}" -cp "$SCRIPT_DIR/Info.plist.template" "$APP_BUNDLE/Contents/Info.plist" +# 基于 tag 版本号注入到 Info.plist +sed -e "s|__APP_VERSION__|$VERSION|g" \ + -e "s|__BUNDLE_ID__|$BUNDLE_ID|g" \ + "$SCRIPT_DIR/Info.plist.template" > "$APP_BUNDLE/Contents/Info.plist" -# 5. 复制图标(如果存在) -if [ -f "$PROJECT_ROOT/src/Assets/MarketAssistant.icns" ]; then +# 9. 复制图标(如果存在) +if [ -f "$APP_ASSETS_DIR/MarketAssistant.icns" ]; then echo -e "${YELLOW}🎨 Copying icon...${NC}" - cp "$PROJECT_ROOT/src/Assets/MarketAssistant.icns" "$APP_BUNDLE/Contents/Resources/" -elif [ -f "$PROJECT_ROOT/src/Assets/logo.png" ]; then + cp "$APP_ASSETS_DIR/MarketAssistant.icns" "$APP_BUNDLE/Contents/Resources/" +elif [ -f "$APP_ASSETS_DIR/logo.png" ]; then echo -e "${YELLOW}🎨 Converting PNG to ICNS...${NC}" # 如果只有 PNG,尝试转换(需要 imagemagick 或 sips) if command -v sips &> /dev/null; then mkdir -p /tmp/iconset.iconset - sips -z 16 16 "$PROJECT_ROOT/src/Assets/logo.png" --out /tmp/iconset.iconset/icon_16x16.png - sips -z 32 32 "$PROJECT_ROOT/src/Assets/logo.png" --out /tmp/iconset.iconset/icon_16x16@2x.png - sips -z 32 32 "$PROJECT_ROOT/src/Assets/logo.png" --out /tmp/iconset.iconset/icon_32x32.png - sips -z 64 64 "$PROJECT_ROOT/src/Assets/logo.png" --out /tmp/iconset.iconset/icon_32x32@2x.png - sips -z 128 128 "$PROJECT_ROOT/src/Assets/logo.png" --out /tmp/iconset.iconset/icon_128x128.png - sips -z 256 256 "$PROJECT_ROOT/src/Assets/logo.png" --out /tmp/iconset.iconset/icon_128x128@2x.png - sips -z 256 256 "$PROJECT_ROOT/src/Assets/logo.png" --out /tmp/iconset.iconset/icon_256x256.png - sips -z 512 512 "$PROJECT_ROOT/src/Assets/logo.png" --out /tmp/iconset.iconset/icon_256x256@2x.png - sips -z 512 512 "$PROJECT_ROOT/src/Assets/logo.png" --out /tmp/iconset.iconset/icon_512x512.png - sips -z 1024 1024 "$PROJECT_ROOT/src/Assets/logo.png" --out /tmp/iconset.iconset/icon_512x512@2x.png + sips -z 16 16 "$APP_ASSETS_DIR/logo.png" --out /tmp/iconset.iconset/icon_16x16.png + sips -z 32 32 "$APP_ASSETS_DIR/logo.png" --out /tmp/iconset.iconset/icon_16x16@2x.png + sips -z 32 32 "$APP_ASSETS_DIR/logo.png" --out /tmp/iconset.iconset/icon_32x32.png + sips -z 64 64 "$APP_ASSETS_DIR/logo.png" --out /tmp/iconset.iconset/icon_32x32@2x.png + sips -z 128 128 "$APP_ASSETS_DIR/logo.png" --out /tmp/iconset.iconset/icon_128x128.png + sips -z 256 256 "$APP_ASSETS_DIR/logo.png" --out /tmp/iconset.iconset/icon_128x128@2x.png + sips -z 256 256 "$APP_ASSETS_DIR/logo.png" --out /tmp/iconset.iconset/icon_256x256.png + sips -z 512 512 "$APP_ASSETS_DIR/logo.png" --out /tmp/iconset.iconset/icon_256x256@2x.png + sips -z 512 512 "$APP_ASSETS_DIR/logo.png" --out /tmp/iconset.iconset/icon_512x512.png + sips -z 1024 1024 "$APP_ASSETS_DIR/logo.png" --out /tmp/iconset.iconset/icon_512x512@2x.png iconutil -c icns /tmp/iconset.iconset -o "$APP_BUNDLE/Contents/Resources/MarketAssistant.icns" rm -rf /tmp/iconset.iconset fi fi -# 6. 代码签名(如果有证书) +# 10. 代码签名(如果有证书) if [ -n "$SIGNING_IDENTITY" ]; then echo -e "${YELLOW}🔐 Code signing...${NC}" - + # 签名所有可执行文件和库 find "$APP_BUNDLE/Contents/MacOS" -type f \( -name "*.dylib" -o -perm +111 \) | while read file; do codesign --force --timestamp --options=runtime \ @@ -101,19 +163,19 @@ if [ -n "$SIGNING_IDENTITY" ]; then --sign "$SIGNING_IDENTITY" \ "$file" 2>/dev/null || true done - + # 签名整个 bundle codesign --force --timestamp --options=runtime \ --entitlements "$SCRIPT_DIR/MarketAssistant.entitlements" \ --sign "$SIGNING_IDENTITY" \ "$APP_BUNDLE" - + echo -e "${GREEN}✓ Code signing completed${NC}" else echo -e "${YELLOW}⚠ Skipping code signing (no SIGNING_IDENTITY set)${NC}" fi -# 7. 创建 DMG +# 11. 创建 DMG echo -e "${YELLOW}💿 Creating DMG...${NC}" DMG_PATH="$BUILD_DIR/$APP_NAME-$VERSION.dmg" @@ -131,15 +193,15 @@ if [ $? -eq 0 ]; then echo -e "${GREEN}✓ DMG created: $DMG_SIZE${NC}" fi -# 8. 公证(如果配置了) +# 12. 公证(如果配置了) if [ -n "$NOTARYTOOL_PROFILE" ]; then echo -e "${YELLOW}📜 Notarizing app...${NC}" - + # 提交公证 xcrun notarytool submit "$DMG_PATH" \ --keychain-profile "$NOTARYTOOL_PROFILE" \ --wait - + if [ $? -eq 0 ]; then # 附加公证票据 xcrun stapler staple "$APP_BUNDLE" @@ -152,7 +214,7 @@ else echo -e "${YELLOW}⚠ Skipping notarization (no NOTARYTOOL_PROFILE set)${NC}" fi -# 9. 验证 +# 13. 验证 echo -e "${YELLOW}🔍 Verifying bundle...${NC}" if [ -n "$SIGNING_IDENTITY" ]; then codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE" 2>&1 | head -5 @@ -163,10 +225,9 @@ echo "" echo -e "${GREEN}✅ Build completed successfully!${NC}" echo "" echo "📦 Output:" -echo " • App Bundle: $APP_BUNDLE" +echo " • App Bundle: $APP_BUNDLE (Universal: arm64 + x86_64)" echo " • DMG: $DMG_PATH" echo "" echo "🧪 To test locally:" echo " open \"$APP_BUNDLE\"" echo "" - diff --git a/scripts/windows/MarketAssistant.iss b/scripts/windows/MarketAssistant.iss index 0521629..7045aeb 100644 --- a/scripts/windows/MarketAssistant.iss +++ b/scripts/windows/MarketAssistant.iss @@ -5,7 +5,7 @@ #define MyAppName "Market Assistant" #define MyAppVersion "1.0.0" #define MyAppPublisher "MarketAssistant Team" -#define MyAppURL "https://github.com/yourusername/MarketAssistant" +#define MyAppURL "https://github.com/X2Agent/MarketAssistant" #define MyAppExeName "MarketAssistant.exe" #define MyAppId "{{8A5B3C2D-9E1F-4A7B-8C6D-3E2F1A9B8C7D}" @@ -25,7 +25,7 @@ LicenseFile=..\..\LICENSE InfoBeforeFile=..\..\README.md OutputDir=..\..\Release\Windows OutputBaseFilename=MarketAssistant-Setup-{#MyAppVersion} -SetupIconFile=..\..\src\Assets\logo.ico +SetupIconFile=..\..\src\MarketAssistant.App\Assets\logo.ico Compression=lzma2 SolidCompression=yes WizardStyle=modern @@ -37,7 +37,7 @@ ArchitecturesInstallIn64BitMode=x64 VersionInfoVersion={#MyAppVersion} VersionInfoCompany={#MyAppPublisher} VersionInfoDescription={#MyAppName} Installer -VersionInfoCopyright=Copyright (C) 2025 {#MyAppPublisher} +VersionInfoCopyright=Copyright (C) 2026 {#MyAppPublisher} VersionInfoProductName={#MyAppName} VersionInfoProductVersion={#MyAppVersion} diff --git a/src/Agents/Analysts/FinancialAnalystAgent.cs b/src/Agents/Analysts/FinancialAnalystAgent.cs deleted file mode 100644 index a016bcc..0000000 --- a/src/Agents/Analysts/FinancialAnalystAgent.cs +++ /dev/null @@ -1,65 +0,0 @@ -using MarketAssistant.Agents.MarketAnalysis.Models; -using MarketAssistant.Agents.Tools; -using Microsoft.Extensions.AI; -using System.ComponentModel; - -namespace MarketAssistant.Agents.Analysts; - -/// -/// 财务分析师代理 -/// 专注于深入分析公司财务报表和财务健康状况 -/// -[DisplayName("财务分析师")] -[Description("专注于财务报表和财务健康分析")] -public class FinancialAnalystAgent : AnalystAgentBase -{ - private static readonly object Schema = AIJsonUtilities.CreateJsonSchema(typeof(FinancialAnalysisResult)); - - private static readonly ChatResponseFormat ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema: (JsonElement)Schema, - schemaName: nameof(FinancialAnalysisResult), - schemaDescription: "财务分析师的结构化分析结果,包含财务健康、盈利质量、现金流和风险预警" - ); - - public FinancialAnalystAgent( - IChatClient chatClient, - StockFinancialTools financialTools) - : base( - chatClient, - instructions: GetInstructions(), - name: "FinancialAnalyst", - description: "专注于深入获取、分析公司财务报表和财务健康状况。分析严格聚焦于财务数据、比率及趋势,旨在全面评估公司的财务健康、盈利质量与现金流状况,并识别潜在的财务风险。分析不涉及估值和具体的投资建议。", - temperature: 0.1f, - topP: 0.9f, - topK: 10, - responseFormat: null, - tools: [.. financialTools.GetFunctions()]) - { - } - - private static string GetInstructions() - { - var schemaJson = JsonSerializer.Serialize(Schema, new JsonSerializerOptions { WriteIndented = true }); - return $@" -## 核心职责 -深入评估公司财务报表,剖析财务健康状况、盈利能力、盈利质量和现金流状况,识别并预警潜在的财务风险点,提供基于财务数据的客观分析洞察。 - -## 评估维度 -1. **财务健康评估**:偿债能力(流动比率、速动比率)、资产负债结构(负债率及变化趋势、债务结构)、整体财务稳健性 -2. **盈利质量分析**:盈利能力(毛利率、净利率及趋势)、投入产出效率(ROE、ROA及行业对比)、利润质量及可持续性 -3. **现金流评估**:经营现金流(净额、与净利润比值)、自由现金流(状态、趋势、可持续性)、现金转换周期及效率 -4. **财务风险预警**:主要风险指标识别、财务造假风险评估、需持续关注的改善点 - -## 分析要点 -- 必须使用可用工具获取的最新财务数据和财务报表 -- 评分指标(1-10分)应基于行业对比和历史趋势综合判断 -- 偿债能力和现金流分析是财务健康的核心指标 -- 利润质量评估需结合现金流验证盈利真实性 -- 财务造假风险需关注异常指标和关联交易 -- 如工具调用失败或数据不完整,应明确说明缺少哪些数据 - -## 输出格式 -仅输出符合以下 Schema 的纯 JSON 字符串,严禁包含 Markdown 格式(如 ```json)或任何解释性文字: -{schemaJson}"; - } -} diff --git a/src/Agents/Analysts/FundamentalAnalystAgent.cs b/src/Agents/Analysts/FundamentalAnalystAgent.cs deleted file mode 100644 index b7b95be..0000000 --- a/src/Agents/Analysts/FundamentalAnalystAgent.cs +++ /dev/null @@ -1,66 +0,0 @@ -using MarketAssistant.Agents.Analysts.Attributes; -using MarketAssistant.Agents.MarketAnalysis.Models; -using MarketAssistant.Agents.Tools; -using Microsoft.Extensions.AI; -using System.ComponentModel; - -namespace MarketAssistant.Agents.Analysts; - -/// -/// 基本面分析师代理 -/// 专注于分析公司基本面、行业地位和长期价值 -/// -[DisplayName("基本面分析师")] -[Description("整合了策略分析师和股票研究分析师的功能")] -[RequiredAnalyst] -public class FundamentalAnalystAgent : AnalystAgentBase -{ - private static readonly object Schema = AIJsonUtilities.CreateJsonSchema(typeof(FundamentalAnalysisResult)); - - private static readonly ChatResponseFormat ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema: (JsonElement)Schema, - schemaName: nameof(FundamentalAnalysisResult), - schemaDescription: "基本面分析师的结构化分析结果,包含公司基本面、行业竞争和投资价值评估" - ); - - public FundamentalAnalystAgent( - IChatClient chatClient, - StockBasicTools basicTools) - : base( - chatClient, - instructions: GetInstructions(), - name: "FundamentalAnalyst", - description: "专注于分析公司基本面、行业地位和长期价值。", - temperature: 0.2f, - topP: 0.6f, - topK: 8, - responseFormat: null, - tools: [.. basicTools.GetFunctions()]) - { - } - - private static string GetInstructions() - { - var schemaJson = JsonSerializer.Serialize(Schema, new JsonSerializerOptions { WriteIndented = true }); - return $@" -## 核心职责 -透彻分析公司的基本面状况、商业模式及盈利能力,准确评估公司在所属行业中的地位、竞争格局与优势,预测并识别公司的长期增长驱动因素和投资价值,揭示潜在的关键风险因素与投资亮点。 - -## 评估维度 -1. **股票基本信息**:代码、名称、当前价格、日涨跌幅及涨跌额 -2. **公司基本面**:行业定位及成长性、核心业务与质量、盈利能力(毛利率/净利率)、财务稳健性(负债率/现金流) -3. **行业与竞争**:行业生命周期判断、市场地位与份额、核心竞争力与强度、长期壁垒水平 -4. **增长潜力与价值**:增长驱动因素与持续性、当前估值水平(PE/PB/PS对比)、投资评级、投资亮点与关键风险 - -## 分析要点 -- 必须使用可用工具获取的实时公司数据和市场数据 -- 评分指标(1-10分)应基于行业对比和历史数据综合判断 -- 估值分析需对比行业均值,判断高估/低估程度 -- 投资亮点聚焦1-2个最核心优势,关键风险突出最主要的风险因素 -- 如工具调用失败或数据不完整,应明确说明缺少哪些数据 - -## 输出格式 -仅输出符合以下 Schema 的纯 JSON 字符串,严禁包含 Markdown 格式(如 ```json)或任何解释性文字: -{schemaJson}"; - } -} diff --git a/src/Agents/Analysts/MarketSentimentAnalystAgent.cs b/src/Agents/Analysts/MarketSentimentAnalystAgent.cs deleted file mode 100644 index bf4cfe7..0000000 --- a/src/Agents/Analysts/MarketSentimentAnalystAgent.cs +++ /dev/null @@ -1,68 +0,0 @@ -using MarketAssistant.Agents.Analysts.Attributes; -using MarketAssistant.Agents.MarketAnalysis.Models; -using MarketAssistant.Agents.Tools; -using Microsoft.Extensions.AI; -using System.ComponentModel; - -namespace MarketAssistant.Agents.Analysts; - -/// -/// 市场情绪分析师代理 -/// 专注于分析市场情绪、资金流向和投资者行为 -/// -[DisplayName("市场情绪分析师")] -[Description("整合了行为金融分析师和市场分析师的功能")] -public class MarketSentimentAnalystAgent : AnalystAgentBase -{ - private static readonly object Schema = AIJsonUtilities.CreateJsonSchema(typeof(MarketSentimentAnalysisResult)); - - private static readonly ChatResponseFormat ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema: (JsonElement)Schema, - schemaName: nameof(MarketSentimentAnalysisResult), - schemaDescription: "市场情绪分析师的结构化分析结果,包含市场情绪、资金流向、投资者行为和短期策略" - ); - - public MarketSentimentAnalystAgent( - IChatClient chatClient, - StockFinancialTools financialTools, - MarketSentimentTools marketSentimentTools) - : base( - chatClient, - instructions: GetInstructions(), - name: "MarketSentimentAnalyst", - description: "专注于分析市场情绪、资金流向和投资者行为。", - temperature: 0.4f, - topP: 0.7f, - topK: 10, - responseFormat: null, - tools: [.. financialTools.GetFunctions(), .. marketSentimentTools.GetFunctions()]) - { - } - - private static string GetInstructions() - { - var schemaJson = JsonSerializer.Serialize(Schema, new JsonSerializerOptions { WriteIndented = true }); - return $@" -## 核心职责 -全面评估当前市场情绪与投资者心理状态,精准追踪资金流向与机构投资者行为,识别并解析投资者行为偏差与市场热点规律,预测短期市场波动并提供可操作的交易机会与策略。 - -## 评估维度 -1. **市场情绪评估**:主导情绪及强度、恐慌与信心指标(VIX等)、投资者信心水平及变化、整体市场氛围及强度 -2. **资金流向分析**:主力资金流向及金额、机构动向及持仓变化、北向资金流向及占比、融资融券变化及杠杆率 -3. **投资者行为分析**:主要行为偏差及严重程度、散户特征及活跃度、机构行为一致性及主要动向、风险偏好及变化 -4. **短期市场洞察与策略**:市场节奏判断、热点板块及持续性、短线机会识别、操作建议及仓位策略、最佳时机及价格区间、需规避的心理陷阱 - -## 分析要点 -- 优先使用可用工具获取的资金流向数据、市场情绪指标、机构持仓数据 -- 评分指标(1-10分)应基于历史数据对比和市场氛围综合判断 -- 资金流向是市场情绪的重要验证指标,需关注连续性和金额规模 -- 行为偏差分析需结合当前市场阶段和投资者特征 -- 短期策略应明确具体的时间窗口和价格区间 -- 心理陷阱识别有助于投资者避免情绪化决策 -- 如缺乏数据,应明确说明并基于可用信息给出合理推断 - -## 输出格式 -仅输出符合以下 Schema 的纯 JSON 字符串,严禁包含 Markdown 格式(如 ```json)或任何解释性文字: -{schemaJson}"; - } -} diff --git a/src/Agents/Analysts/NewsEventAnalystAgent.cs b/src/Agents/Analysts/NewsEventAnalystAgent.cs deleted file mode 100644 index 447bded..0000000 --- a/src/Agents/Analysts/NewsEventAnalystAgent.cs +++ /dev/null @@ -1,69 +0,0 @@ -using MarketAssistant.Agents.Analysts.Attributes; -using MarketAssistant.Agents.MarketAnalysis.Models; -using MarketAssistant.Agents.Tools; -using Microsoft.Extensions.AI; -using System.ComponentModel; - -namespace MarketAssistant.Agents.Analysts; - -/// -/// 新闻事件分析师代理 -/// 专注于分析新闻事件、公告和突发事件对股票的影响 -/// -[DisplayName("新闻事件分析师")] -[Description("专注于新闻事件对股票的影响分析")] -public class NewsEventAnalystAgent : AnalystAgentBase -{ - private static readonly object Schema = AIJsonUtilities.CreateJsonSchema(typeof(NewsEventAnalysisResult)); - - private static readonly ChatResponseFormat ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema: (JsonElement)Schema, - schemaName: nameof(NewsEventAnalysisResult), - schemaDescription: "新闻事件分析师的结构化分析结果,包含事件解读、影响评估和投资启示" - ); - - public NewsEventAnalystAgent( - IChatClient chatClient, - StockNewsTools newsTools) - : base( - chatClient, - instructions: GetInstructions(), - name: "NewsEventAnalyst", - description: "专注于分析新闻事件、公告和突发事件对股票的影响。", - temperature: 0.2f, - topP: 0.75f, - topK: 10, - responseFormat: null, - tools: [.. newsTools.GetFunctions()]) - { - } - - private static string GetInstructions() - { - var schemaJson = JsonSerializer.Serialize(Schema, new JsonSerializerOptions { WriteIndented = true }); - return $@" -## 核心职责 -精准分析新闻事件对股票的短期与中期影响。分析聚焦于事件的真实性、重要性、市场影响和潜在的投资启示,严格避免技术面分析和不基于事件的长期投资建议。 - -## 数据获取与分析流程 -使用可用的新闻获取工具获取与目标股票相关的聚合新闻要点。优先选择最相关且具有潜在影响力的2-3条新闻进行分析。 - -## 评估维度 -1. **事件解读与定性**:事件类型分类、事件核心概要、信息来源及可信度、事件性质及重要性 -2. **影响评估与市场反应**:基本面影响及逻辑、情绪影响及预期变化、影响范围及持续时长、市场预期反应及股价变化、资金流向预期及规模 -3. **投资启示与建议**:投资影响评估及核心逻辑、应对策略建议及具体操作、需持续关注的重点、关键风险提示 - -## 分析要点 -- 必须使用可用工具获取最新新闻数据和公告信息 -- 评分指标(1-10分)应基于事件重要性、可信度和市场影响综合判断 -- 区分事件的短期情绪影响和中长期基本面影响 -- 信息来源的可信度直接影响事件分析的权重 -- 关注事件的后续发展和潜在催化剂 -- 市场反应可能存在过度或不足,需理性判断 -- 如工具调用失败或无新闻数据,应明确说明无法进行事件分析 - -## 输出格式 -仅输出符合以下 Schema 的纯 JSON 字符串,严禁包含 Markdown 格式(如 ```json)或任何解释性文字: -{schemaJson}"; - } -} diff --git a/src/Agents/Analysts/TechnicalAnalystAgent.cs b/src/Agents/Analysts/TechnicalAnalystAgent.cs deleted file mode 100644 index f5e4336..0000000 --- a/src/Agents/Analysts/TechnicalAnalystAgent.cs +++ /dev/null @@ -1,67 +0,0 @@ -using MarketAssistant.Agents.Analysts.Attributes; -using MarketAssistant.Agents.MarketAnalysis.Models; -using MarketAssistant.Agents.Tools; -using Microsoft.Extensions.AI; -using System.ComponentModel; - -namespace MarketAssistant.Agents.Analysts; - -/// -/// 技术分析师代理 -/// 专注于图表形态、技术指标和交易策略分析 -/// -[DisplayName("技术分析师")] -[Description("专注于图表模式和技术指标分析")] -public class TechnicalAnalystAgent : AnalystAgentBase -{ - private static readonly object Schema = AIJsonUtilities.CreateJsonSchema(typeof(TechnicalAnalysisResult)); - - private static readonly ChatResponseFormat ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema: (JsonElement)Schema, - schemaName: nameof(TechnicalAnalysisResult), - schemaDescription: "技术分析师的结构化分析结果,包含图表形态、关键价位、技术指标和交易策略" - ); - - public TechnicalAnalystAgent( - IChatClient chatClient, - StockTechnicalTools technicalTools) - : base( - chatClient, - instructions: GetInstructions(), - name: "TechnicalAnalyst", - description: "专注于通过图表形态和技术指标分析预测股票价格走势。基于历史价格和成交量数据,识别趋势、支撑阻力位和交易信号,为短期交易提供技术面洞察和操作建议。所有分析严格基于技术面,不涉及任何基本面或市场情绪考量。", - temperature: 0.0f, - topP: 0.0f, - topK: 1, - responseFormat: null, - tools: [.. technicalTools.GetFunctions()]) - { - } - - private static string GetInstructions() - { - var schemaJson = JsonSerializer.Serialize(Schema, new JsonSerializerOptions { WriteIndented = true }); - return $@" -## 核心职责 -解析图表形态、技术指标信号,定位关键价位,提供量化交易建议。所有分析严格基于技术面,不涉及任何基本面或市场情绪考量。 - -## 评估维度 -1. **图表形态与趋势**:当前趋势判断及强度、关键图表形态识别及可靠性、主要分析时间框架及一致性 -2. **关键价位分析**:当前价格、核心支撑位及强度、核心阻力位及强度、突破方向及概率 -3. **技术指标综合解读**:趋势指标信号(MA、MACD等)、动量指标信号(RSI、KDJ等)、成交量状态及量价关系、指标一致性及协同程度 -4. **交易策略建议**:技术面评级、操作方向、目标价位区间、止损位置、持仓周期及风险等级 - -## 分析要点 -- 必须使用可用工具获取的K线数据、技术指标数据(MACD、KDJ、BOLL、MA等) -- 评分指标(1-10分)应基于技术形态强度和指标信号可靠性综合判断 -- 支撑阻力位需结合历史价格、成交密集区、重要均线等多因素确定 -- 量价关系是验证趋势有效性的重要依据 -- 多个技术指标应相互验证,提高信号可靠性 -- 交易策略需明确具体的价位区间和风险控制点位 -- 如工具调用失败或数据不完整,应明确说明缺少哪些数据 - -## 输出格式 -仅输出符合以下 Schema 的纯 JSON 字符串,严禁包含 Markdown 格式(如 ```json)或任何解释性文字: -{schemaJson}"; - } -} diff --git a/src/Agents/MarketAnalysis/Executors/AnalysisAggregatorExecutor.cs b/src/Agents/MarketAnalysis/Executors/AnalysisAggregatorExecutor.cs deleted file mode 100644 index 01f8fdd..0000000 --- a/src/Agents/MarketAnalysis/Executors/AnalysisAggregatorExecutor.cs +++ /dev/null @@ -1,74 +0,0 @@ -using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; - -namespace MarketAssistant.Agents.MarketAnalysis.Executors; - -/// -/// 分析聚合器 Executor(基于官方 Fan-In 模式) -/// 参考: https://learn.microsoft.com/zh-cn/agent-framework/tutorials/workflows/simple-concurrent-workflow -/// -/// Fan-In 工作原理: -/// 1. HandleAsync 会被多次调用(每个源 Agent 一次) -/// 2. 每次接收该 Agent 的消息列表(List) -/// 3. 内部维护列表收集所有消息 -/// 4. 收齐后使用 context.YieldOutputAsync 输出给下游 -/// -/// 注意:使用 Executor 而不是 Executor -/// -public sealed class AnalysisAggregatorExecutor : Executor, List> -{ - private readonly List _collectedMessages = []; - private int _receivedCount = 0; - private readonly ILogger _logger; - - public AnalysisAggregatorExecutor( - ILogger logger) - : base(id: "AnalysisAggregator") - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - /// 接收单个分析师的消息列表(会被调用多次) - /// 收集完所有分析师消息后,使用 YieldOutputAsync 传递给 Coordinator - /// - public override async ValueTask> HandleAsync( - List messages, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(messages); - - // 每次调用表示收到一个分析师的结果 - _receivedCount++; - - _logger.LogDebug( - "收到分析师消息 {Received} 个,消息数: {MessageCount}", - _receivedCount, messages.Count); - - // 收集消息 - _collectedMessages.AddRange(messages); - - // 从 state 读取期望的分析师数量 - var expectedCount = await context.ReadStateAsync( - WorkflowStateKeys.ExpectedAnalystCount, - WorkflowStateKeys.Scope, - cancellationToken); - - _logger.LogInformation( - "已收集 {Current}/{Expected} 位分析师的结果,共 {TotalMessages} 条消息(Context Hash: {ContextHash})", - _receivedCount, expectedCount, _collectedMessages.Count, context.GetHashCode()); - - // 判断是否收齐了所有分析师的消息(通过调用次数判断) - if (_receivedCount >= expectedCount) - { - _logger.LogInformation( - "所有 {Count} 位分析师的结果已收集完成(共 {Total} 条消息),准备传递给 Coordinator", - _receivedCount, _collectedMessages.Count); - - return _collectedMessages; - } - return null; - } -} diff --git a/src/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs b/src/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs deleted file mode 100644 index 62349ed..0000000 --- a/src/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs +++ /dev/null @@ -1,328 +0,0 @@ -using MarketAssistant.Agents.Analysts; -using MarketAssistant.Agents.Analysts.Attributes; -using MarketAssistant.Agents.MarketAnalysis.Executors; -using MarketAssistant.Agents.MarketAnalysis.Models; -using MarketAssistant.Infrastructure.Factories; -using MarketAssistant.Services.Settings; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using System.Reflection; - -namespace MarketAssistant.Agents.MarketAnalysis; - -/// -/// 市场分析并发工作流(基于 Agent Framework 最佳实践) -/// 参考: https://learn.microsoft.com/zh-cn/agent-framework/tutorials/workflows/agents-in-workflows -/// -public class MarketAnalysisWorkflow : IDisposable -{ - private readonly AnalysisAggregatorExecutor _aggregatorExecutor; - private readonly CoordinatorExecutor _coordinatorExecutor; - private readonly IUserSettingService _userSettingService; - private readonly IAnalystAgentFactory _analystAgentFactory; - private readonly ILoggerFactory _loggerFactory; - private readonly ILogger _logger; - - private bool _disposed = false; - - /// - /// 分析进度事件 - /// - public event EventHandler? ProgressChanged; - - public MarketAnalysisWorkflow( - AnalysisAggregatorExecutor aggregatorExecutor, - CoordinatorExecutor coordinatorExecutor, - IUserSettingService userSettingService, - IAnalystAgentFactory analystAgentFactory, - ILoggerFactory loggerFactory, - ILogger logger) - { - _aggregatorExecutor = aggregatorExecutor ?? throw new ArgumentNullException(nameof(aggregatorExecutor)); - _coordinatorExecutor = coordinatorExecutor ?? throw new ArgumentNullException(nameof(coordinatorExecutor)); - _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); - _analystAgentFactory = analystAgentFactory ?? throw new ArgumentNullException(nameof(analystAgentFactory)); - _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - /// 执行市场分析工作流 - /// - public async Task AnalyzeAsync( - string stockSymbol, - CancellationToken cancellationToken = default) - { - try - { - _logger.LogInformation("开始执行市场分析工作流,股票代码: {StockSymbol}", stockSymbol); - - OnProgressChanged(new AnalysisProgressEventArgs - { - StageDescription = "正在准备分析环境", - IsInProgress = true - }); - - // 获取启用的分析师列表 - var enabledAnalysts = GetEnabledAnalysts(); - if (enabledAnalysts.Count == 0) - { - throw new InvalidOperationException("没有启用任何分析师,请在设置中至少启用一位分析师"); - } - - // 创建分析师代理 - var analystAgents = CreateAnalystAgents(enabledAnalysts); - - // 构建工作流(传入分析师数量) - var workflow = BuildWorkflow(analystAgents.Count, analystAgents); - - OnProgressChanged(new AnalysisProgressEventArgs - { - StageDescription = $"{enabledAnalysts.Count} 位分析师正在并发分析", - IsInProgress = true - }); - - // 执行工作流(流式处理) - var finalReport = await ExecuteWorkflowAsync(workflow, stockSymbol, cancellationToken); - - OnProgressChanged(new AnalysisProgressEventArgs - { - StageDescription = "分析完成", - IsInProgress = false - }); - - return finalReport; - } - catch (Exception ex) - { - _logger.LogError(ex, "执行市场分析工作流时发生错误"); - OnProgressChanged(new AnalysisProgressEventArgs - { - StageDescription = $"分析失败: {ex.Message}", - IsInProgress = false - }); - throw; - } - } - - /// - /// 执行工作流并处理事件 - /// - private async Task ExecuteWorkflowAsync( - Workflow workflow, - string stockSymbol, - CancellationToken cancellationToken) - { - MarketAnalysisReport? finalReport = null; - - // 使用流式执行,将股票代码作为输入 - // Dispatcher 会接收股票代码并广播给所有分析师 - await using StreamingRun run = await InProcessExecution.StreamAsync( - workflow, - stockSymbol, - runId: null, - cancellationToken); - - // 发送 TurnToken 触发工作流开始处理 - // 根据官方文档:代理会缓存消息,只有收到 TurnToken 才开始处理 - await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); - - // 监听工作流事件 - await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false)) - { - switch (evt) - { - case ExecutorInvokedEvent executorInvoked: - _logger.LogDebug("工作流步骤开始: {ExecutorId}", executorInvoked.ExecutorId); - - // 更新进度:显示当前正在执行的步骤 - string stageName = executorInvoked.ExecutorId switch - { - "AnalysisDispatcher" => "正在分发分析任务", - "AnalysisAggregator" => "正在聚合分析结果", - "Coordinator" => "正在生成综合报告", - _ => "正在分析" - }; - - OnProgressChanged(new AnalysisProgressEventArgs - { - StageDescription = stageName, - IsInProgress = true - }); - break; - - case ExecutorCompletedEvent executorComplete: - _logger.LogDebug("工作流步骤完成: {ExecutorId}", executorComplete.ExecutorId); - break; - - case AgentRunUpdateEvent agentUpdate: - // 处理代理的流式更新 - _logger.LogDebug("代理更新 [{AgentId}]: {Data}", - agentUpdate.ExecutorId, - agentUpdate.Data); - break; - - case WorkflowOutputEvent workflowOutput: - finalReport = workflowOutput.Data as MarketAnalysisReport; - _logger.LogInformation("工作流完成,生成最终报告"); - break; - case ExecutorFailedEvent executorFailed: - _logger.LogError("步骤失败: {ExecutorId}, 错误: {Error}", - executorFailed.ExecutorId, - executorFailed.Data.Message); - throw new FriendlyException(executorFailed.Data.Message); - } - } - - return finalReport ?? throw new FriendlyException("工作流未返回分析报告"); - } - - /// - /// 获取启用的分析师列表 - /// - private List GetEnabledAnalysts() - { - var enabledAnalysts = new List(); - var enabledAnalystRoles = _userSettingService.CurrentSetting.EnabledAnalystRoles; - - // 获取所有 AnalystAgentBase 的非抽象子类 - var agentTypes = typeof(AnalystAgentBase).Assembly.GetTypes() - .Where(t => t.IsSubclassOf(typeof(AnalystAgentBase)) && !t.IsAbstract); - - foreach (var agentType in agentTypes) - { - // 排除 CoordinatorAnalystAgent,它由CoordinatorExecutor独自管理 - if (agentType.Name == nameof(CoordinatorAnalystAgent)) continue; - - var agentClassName = agentType.Name; - - bool isRequired = agentType.GetCustomAttribute() != null; - - // 确定启用状态 - bool isEnabled = false; - if (enabledAnalystRoles.TryGetValue(agentClassName, out var userEnabled)) - { - isEnabled = userEnabled; - } - - // 必需的角色始终启用 - if (isRequired) isEnabled = true; - - if (isEnabled) - { - enabledAnalysts.Add(agentType); - } - } - - return enabledAnalysts; - } - - /// - /// 创建分析师代理(使用 Factory 模式) - /// - private List CreateAnalystAgents(List analystTypes) - { - _logger.LogInformation("开始创建分析师代理,数量: {Count}", analystTypes.Count); - - var createdAgents = new List(); - foreach (var type in analystTypes) - { - try - { - var agent = _analystAgentFactory.CreateAnalyst(type); - createdAgents.Add(agent); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "跳过创建分析师代理: {AgentType}", type.Name); - } - } - - _logger.LogInformation("成功创建分析师代理,实际数量: {Count}", createdAgents.Count); - return createdAgents; - } - - /// - /// 构建并发工作流(使用框架原生并发编排) - /// 参考: https://learn.microsoft.com/zh-cn/agent-framework/tutorials/workflows/simple-concurrent-workflow - /// - /// 流程: - /// [Dispatcher] → [并发分析师团队] → [Aggregator] → [Coordinator] - /// - private Workflow BuildWorkflow(int analystCount, List analystAgents) - { - // 构建标准 Fan-Out/Fan-In 工作流: - // - // [Dispatcher] string (stockSymbol) → broadcast ChatMessage - // ↓ (Fan-Out) - // [Analyst1] [Analyst2] [Analyst3] ... (并发执行,每个返回 ChatMessage) - // ↓ ↓ ↓ ↓ (Fan-In: 框架逐个传递给 Aggregator) - // [Aggregator] 收集所有 ChatMessage → List - // ↓ - // [Coordinator] List → MarketAnalysisReport (输出) - - // 1. 动态创建 Dispatcher(需要知道分析师数量) - var dispatcher = new AnalysisDispatcherExecutor( - analystCount, - _loggerFactory.CreateLogger()); - - // 2. 创建工作流,Dispatcher 作为入口节点 - var builder = new WorkflowBuilder(dispatcher); - - // 3. Fan-Out: Dispatcher → 所有分析师(Dispatcher 广播 ChatMessage) - // AIAgent 可以直接用于工作流,框架会自动处理 - builder.AddFanOutEdge(dispatcher, [.. analystAgents]); - - // 4. Fan-In: 所有分析师 → Aggregator - // 框架会自动收集所有源(分析师)的消息,并作为 List 一次性传递给 Aggregator - builder.AddFanInEdge([.. analystAgents], _aggregatorExecutor); - - // 5. Aggregator → Coordinator(将聚合结果传递给协调分析师) - builder.AddEdge(_aggregatorExecutor, _coordinatorExecutor); - - // 6. 设置输出来自 Coordinator - builder.WithOutputFrom(_coordinatorExecutor); - - return builder.Build(); - } - - /// - /// 触发进度事件 - /// - protected virtual void OnProgressChanged(AnalysisProgressEventArgs e) - { - ProgressChanged?.Invoke(this, e); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!_disposed && disposing) - { - _disposed = true; - } - } -} - -/// -/// 分析进度变化事件参数 -/// -public sealed class AnalysisProgressEventArgs : EventArgs -{ - /// - /// 当前阶段描述 - /// - public string StageDescription { get; set; } = string.Empty; - - /// - /// 是否正在进行中 - /// - public bool IsInProgress { get; set; } = true; -} \ No newline at end of file diff --git a/src/Agents/MarketChatSession.cs b/src/Agents/MarketChatSession.cs deleted file mode 100644 index 8c78651..0000000 --- a/src/Agents/MarketChatSession.cs +++ /dev/null @@ -1,680 +0,0 @@ -using MarketAssistant.Services.Mcp; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; - -namespace MarketAssistant.Agents; - -/// -/// 市场对话会话管理器,处理用户与AI的对话交互 -/// 管理对话历史、上下文和工具集成 -/// -public class MarketChatSession : IDisposable -{ - #region 常量定义 - - /// - /// 最大上下文消息数量 - /// - private const int MaxContextMessages = 100; - - /// - /// 压缩后保留的最小消息数量 - /// - private const int MinMessagesAfterCompression = 20; - - /// - /// 压缩时保留的重要消息数量 - /// - private const int ImportantMessagesCount = 10; - - #endregion - - #region 私有字段 - - private readonly IChatClient _chatClient; - private readonly ILogger _logger; - - /// - /// 对话历史记录 - /// - private readonly List _conversationHistory = new(); - - /// - /// MCP 工具列表 - /// - private readonly List _mcpTools = new(); - - /// - /// MCP 服务 - /// - private readonly McpService _mcpService; - - /// - /// 当前股票代码 - /// - private string _currentStockCode = string.Empty; - - /// - /// 取消令牌源 - /// - private CancellationTokenSource? _currentCancellationTokenSource; - - /// - /// 是否已释放 - /// - private bool _disposed; - - #endregion - - #region 构造函数 - - /// - /// 创建市场对话会话 - /// - /// 聊天客户端 - /// 日志记录器 - /// MCP 服务 - /// 可选的初始股票代码 - public MarketChatSession( - IChatClient chatClient, - ILogger logger, - McpService mcpService, - string? initialStockCode = null) - { - _chatClient = chatClient ?? throw new ArgumentNullException(nameof(chatClient)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _mcpService = mcpService ?? throw new ArgumentNullException(nameof(mcpService)); - - // 设置初始股票代码 - _currentStockCode = initialStockCode ?? string.Empty; - if (!string.IsNullOrEmpty(_currentStockCode)) - { - _logger.LogInformation("初始化对话会话,股票代码: {StockCode}", _currentStockCode); - } - - // 初始化系统消息 - InitializeSystemContext(); - - // 初始化MCP服务 - _ = InitializeMcpServicesAsync(); - } - - #endregion - - #region 事件定义 - - /// - /// 流式响应事件 - /// - public event EventHandler? StreamingResponse; - - #endregion - - #region 公共属性 - - /// - /// 获取当前对话历史(只读) - /// - public IReadOnlyList ConversationHistory => _conversationHistory.AsReadOnly(); - - /// - /// 当前股票代码 - /// - public string CurrentStockCode => _currentStockCode; - - /// - /// 是否正在处理请求 - /// - public bool IsProcessing => _currentCancellationTokenSource != null && !_currentCancellationTokenSource.Token.IsCancellationRequested; - - #endregion - - #region 公共方法 - - /// - /// 发送消息并获取AI回复 - /// - /// 用户消息 - /// 取消令牌 - /// AI回复 - public async Task SendMessageAsync(string userMessage, CancellationToken cancellationToken = default) - { - try - { - _logger.LogInformation("处理用户消息: {Message}", userMessage); - - // 添加用户消息到历史 - _conversationHistory.Add(new ChatMessage(ChatRole.User, userMessage)); - - // 检查并管理上下文窗口 - await ManageContextWindowAsync(); - - // 创建取消令牌源 - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - _currentCancellationTokenSource = cts; - - try - { - // 构建聊天选项 - var chatOptions = new ChatOptions - { - Tools = _mcpTools, - Temperature = 0.7f - }; - - // 调用AI服务获取回复 - var chatCompletion = await _chatClient.GetResponseAsync( - _conversationHistory, - chatOptions, - cts.Token); - - // 从响应中获取助手消息(假设返回类型有 Choices 或 Message 属性) - var assistantMessage = new ChatMessage(ChatRole.Assistant, chatCompletion.Text ?? string.Empty); - - // 添加AI回复到历史 - _conversationHistory.Add(assistantMessage); - - _logger.LogInformation("AI回复成功"); - return assistantMessage; - } - finally - { - _currentCancellationTokenSource = null; - } - } - catch (OperationCanceledException) - { - _logger.LogInformation("用户取消了对话请求"); - var cancelResponse = new ChatMessage(ChatRole.Assistant, "对话已被取消。"); - return cancelResponse; - } - catch (Exception ex) - { - _logger.LogError(ex, "处理AI对话时发生错误"); - - // 创建错误回复 - var errorResponse = new ChatMessage(ChatRole.Assistant, "抱歉,我暂时无法回复您的问题,请稍后重试。"); - - // 将错误回复也添加到历史中 - _conversationHistory.Add(errorResponse); - - return errorResponse; - } - } - - /// - /// 发送消息并获取流式回复 - /// - /// 用户消息 - /// 取消令牌 - /// 流式响应的异步枚举 - public async IAsyncEnumerable SendMessageStreamAsync( - string userMessage, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) - { - _logger.LogInformation("开始流式处理用户消息: {Message}", userMessage); - - // 添加用户消息到历史 - _conversationHistory.Add(new ChatMessage(ChatRole.User, userMessage)); - - // 检查并管理上下文窗口 - await ManageContextWindowAsync(); - - // 创建取消令牌源 - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - _currentCancellationTokenSource = cts; - - var completeResponse = new StringBuilder(); - - try - { - // 构建聊天选项 - var chatOptions = new ChatOptions - { - Tools = _mcpTools, - Temperature = 0.7f - }; - - // 获取流式响应 - await foreach (var update in _chatClient.GetStreamingResponseAsync( - _conversationHistory, - chatOptions, - cts.Token)) - { - var content = update.Text ?? string.Empty; - - // 累积完整响应 - if (!string.IsNullOrEmpty(content)) - { - completeResponse.Append(content); - } - - // 触发流式响应事件 - StreamingResponse?.Invoke(this, new StreamingResponseEventArgs - { - Content = content, - IsComplete = false - }); - - // 包装为统一的返回类型 - yield return new StreamingChatUpdate { Content = content }; - } - } - finally - { - _currentCancellationTokenSource = null; - - // 将完整响应添加到历史 - if (completeResponse.Length > 0) - { - var cleanedContent = completeResponse.ToString().Trim(); - if (!string.IsNullOrEmpty(cleanedContent)) - { - var fullResponse = new ChatMessage(ChatRole.Assistant, cleanedContent); - _conversationHistory.Add(fullResponse); - } - } - - // 触发完成事件 - StreamingResponse?.Invoke(this, new StreamingResponseEventArgs - { - Content = completeResponse.ToString().Trim(), - IsComplete = true - }); - - _logger.LogInformation("流式AI回复完成"); - } - } - - /// - /// 取消当前对话请求 - /// - public void CancelCurrentRequest() - { - _currentCancellationTokenSource?.Cancel(); - _logger.LogInformation("已取消当前对话请求"); - } - - /// - /// 设置股票代码(用于初始化场景,不修改对话历史) - /// - /// 股票代码 - public void SetStockCode(string stockCode) - { - if (_currentStockCode == stockCode) - return; - - _currentStockCode = stockCode; - _logger.LogInformation("设置股票代码: {StockCode}", stockCode); - } - - /// - /// 清空对话历史 - /// - public void ClearHistory() - { - _conversationHistory.Clear(); - InitializeSystemContext(); - _logger.LogInformation("已清空对话历史"); - } - - /// - /// 添加系统消息 - /// - /// 系统消息内容 - public void AddAssistantMessage(string message) - { - _conversationHistory.Add(new ChatMessage(ChatRole.Assistant, message)); - } - - /// - /// 释放资源 - /// - public void Dispose() - { - if (_disposed) - return; - - _currentCancellationTokenSource?.Cancel(); - _currentCancellationTokenSource?.Dispose(); - - try - { - _mcpService.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "释放 MCP 服务时发生错误"); - } - - _mcpTools.Clear(); - - _disposed = true; - GC.SuppressFinalize(this); - } - - #endregion - - #region 私有方法 - - /// - /// 初始化系统上下文 - /// - private void InitializeSystemContext() - { - var systemPrompt = BuildSystemPrompt(_currentStockCode); - _conversationHistory.Add(new ChatMessage(ChatRole.System, systemPrompt)); - } - - /// - /// 初始化并加载所有配置的MCP服务 - /// - private async Task InitializeMcpServicesAsync() - { - try - { - _logger.LogInformation("开始初始化MCP服务"); - - var enabledConfigs = McpService.GetEnabledConfigs(); - var tools = await _mcpService.GetAIToolsAsync(enabledConfigs); - _mcpTools.AddRange(tools); - - if (_mcpTools.Count == 0) - { - _logger.LogInformation("未加载任何 MCP 工具"); - } - else - { - _logger.LogInformation("成功加载 {Count} 个 MCP 工具", _mcpTools.Count); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "初始化 MCP 服务失败"); - } - } - - /// - /// 管理上下文窗口 - /// - private async Task ManageContextWindowAsync() - { - var currentMessageCount = _conversationHistory.Count; - - if (currentMessageCount <= MaxContextMessages) - return; - - _logger.LogInformation("上下文窗口超限,当前消息数: {CurrentMessages},开始压缩", currentMessageCount); - - var originalCount = _conversationHistory.Count; - - // 保留系统消息 - var systemMessages = _conversationHistory.Where(m => m.Role == ChatRole.System).ToList(); - var nonSystemMessages = _conversationHistory.Where(m => m.Role != ChatRole.System).ToList(); - - // 智能选择要保留的消息 - var selectedMessages = await SelectMessagesForRetentionAsync(nonSystemMessages); - - // 获取需要压缩的消息(除了选中保留的消息) - var messagesToCompress = nonSystemMessages.Except(selectedMessages).ToList(); - - // 清空历史并重新构建 - _conversationHistory.Clear(); - - // 添加系统消息 - foreach (var systemMessage in systemMessages) - { - _conversationHistory.Add(systemMessage); - } - - // 添加压缩摘要(如果有需要压缩的消息) - if (messagesToCompress.Count > 0) - { - try - { - var compressionSummary = await CreateCompressionSummaryAsync(messagesToCompress); - if (!string.IsNullOrEmpty(compressionSummary)) - { - _conversationHistory.Add(new ChatMessage(ChatRole.System, $"之前对话摘要(压缩了 {messagesToCompress.Count} 条消息):\n{compressionSummary}")); - } - else - { - // 如果压缩失败,添加基本的统计信息作为备用 - var userMsgCount = messagesToCompress.Count(m => m.Role == ChatRole.User); - var assistantMsgCount = messagesToCompress.Count(m => m.Role == ChatRole.Assistant); - var fallbackSummary = $"之前进行了 {userMsgCount} 轮用户询问和 {assistantMsgCount} 次AI回复,主要围绕股票 {_currentStockCode} 的相关分析。"; - _conversationHistory.Add(new ChatMessage(ChatRole.System, fallbackSummary)); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "压缩摘要生成失败,使用备用方案"); - var fallbackSummary = $"之前的 {messagesToCompress.Count} 条对话已压缩,主要内容涉及股票分析和投资讨论。"; - _conversationHistory.Add(new ChatMessage(ChatRole.System, fallbackSummary)); - } - } - - // 添加保留的消息(按时间顺序) - var orderedSelectedMessages = selectedMessages - .OrderBy(m => nonSystemMessages.IndexOf(m)) - .ToList(); - - foreach (var message in orderedSelectedMessages) - { - _conversationHistory.Add(message); - } - - var newCount = _conversationHistory.Count; - - _logger.LogInformation("上下文压缩完成,消息数: {OriginalCount} -> {NewCount},保留重要消息: {RetainedCount}", - originalCount, newCount, selectedMessages.Count); - } - - /// - /// 智能选择要保留的消息 - /// - /// 所有非系统消息 - /// 选中保留的消息列表 - private async Task> SelectMessagesForRetentionAsync(List messages) - { - if (messages.Count <= MinMessagesAfterCompression) - return messages; - - var selectedMessages = new List(); - - // 1. 始终保留最近的消息(保证对话连续性) - var recentMessages = messages.TakeLast(MinMessagesAfterCompression / 2).ToList(); - selectedMessages.AddRange(recentMessages); - - // 2. 从剩余消息中选择重要消息 - var remainingMessages = messages.Except(recentMessages).ToList(); - var importantMessages = await SelectImportantMessagesAsync(remainingMessages, ImportantMessagesCount); - selectedMessages.AddRange(importantMessages); - - return selectedMessages.Distinct().ToList(); - } - - /// - /// 选择重要消息 - /// - /// 候选消息列表 - /// 要选择的数量 - /// 重要消息列表 - private Task> SelectImportantMessagesAsync(List messages, int count) - { - if (messages.Count <= count) - return Task.FromResult(messages); - - // 按重要性评分排序 - var scoredMessages = messages.Select(m => new - { - Message = m, - Score = CalculateMessageImportanceScore(m) - }).OrderByDescending(x => x.Score).ToList(); - - var result = scoredMessages.Take(count).Select(x => x.Message).ToList(); - return Task.FromResult(result); - } - - /// - /// 计算消息重要性得分 - /// - /// 消息内容 - /// 重要性得分 - private double CalculateMessageImportanceScore(ChatMessage message) - { - var messageText = message.Text ?? string.Empty; - if (string.IsNullOrWhiteSpace(messageText)) - return 0; - - var content = messageText.ToLowerInvariant(); - double score = 0; - - // 基础得分:消息长度(较长的消息通常包含更多信息) - score += Math.Min(content.Length / 100.0, 5.0); - - // 股票相关关键词加分 - var stockKeywords = new[] { "股票", "股价", "涨跌", "市盈率", "市净率", "成交量", "技术分析", "基本面", "财务", "收益", "风险", "投资", "分析", "建议", "趋势", "指标" }; - score += stockKeywords.Count(keyword => content.Contains(keyword)) * 2.0; - - // 当前股票代码相关加分 - if (!string.IsNullOrEmpty(_currentStockCode) && content.Contains(_currentStockCode.ToLowerInvariant())) - { - score += 5.0; - } - - // 数字和数据加分(通常包含重要的分析数据) - var numberMatches = System.Text.RegularExpressions.Regex.Matches(content, @"\d+\.?\d*%?"); - score += Math.Min(numberMatches.Count * 0.5, 3.0); - - // 问句和分析结论加分 - if (content.Contains("?") || content.Contains("?")) - score += 1.0; - - if (content.Contains("结论") || content.Contains("建议") || content.Contains("总结")) - score += 2.0; - - // 用户消息相对于AI回复有更高权重(用户问题通常很重要) - if (message.Role == ChatRole.User) - score *= 1.5; - - return score; - } - - /// - /// 创建压缩摘要 - /// - /// 要压缩的消息 - /// 压缩摘要 - private async Task CreateCompressionSummaryAsync(IEnumerable messages) - { - try - { - var messageTexts = messages - .Where(m => !string.IsNullOrWhiteSpace(m.Text)) - .Select(m => $"{m.Role}: {m.Text}") - .ToList(); - - if (messageTexts.Count == 0) - return string.Empty; - - // 限制输入长度,避免压缩请求过长 - var combinedText = string.Join("\n\n", messageTexts); - if (combinedText.Length > 4000) - { - combinedText = combinedText.Substring(0, 4000) + "...(内容已截断)"; - } - - // 创建临时对话历史用于压缩 - var compressionHistory = new List - { - new(ChatRole.System, "请将以下股票分析对话内容压缩成简洁的摘要。重点保留:\n" + - "1. 涉及的股票代码和名称\n" + - "2. 关键的分析结论和数据\n" + - "3. 重要的投资建议或风险提示\n" + - "4. 用户关心的核心问题\n" + - "请用3-5句话概括,保持专业性:"), - new(ChatRole.User, combinedText) - }; - - var chatCompletion = await _chatClient.GetResponseAsync(compressionHistory, cancellationToken: CancellationToken.None); - return chatCompletion.Text?.Trim() ?? string.Empty; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "创建压缩摘要失败"); - return "之前的对话已被压缩以节省空间。"; - } - } - - - /// - /// 构建系统提示词 - /// - /// 股票代码 - /// 系统提示词 - private static string BuildSystemPrompt(string stockCode) - { - var basePrompt = """ - 你是一个专业的股票市场分析助手,具备以下能力: - 1. 提供专业的股票分析和投资建议 - 2. 解答用户关于股票市场的各种问题 - 3. 基于技术分析、基本面分析等多维度提供见解 - 4. 主动使用可用的分析工具获取实时数据 - 5. 保持客观、专业的态度,提醒投资风险 - - 工具使用指导: - - 当需要股票基本信息时,优先使用股票基础信息插件 - - 当需要财务数据时,使用财务分析插件获取准确数据 - - 当需要技术指标时,使用技术分析插件计算指标 - - 当需要最新新闻时,使用新闻搜索插件获取资讯 - - 当需要筛选股票时,使用股票筛选插件 - - 回复格式要求: - - 使用结构化格式:【核心观点】、【数据支撑】、【技术分析】、【风险提示】 - - 语言简洁明了,避免过于技术化的术语 - - 提供具体的数据和分析依据 - - 重要数据用**粗体**标注 - - 始终在结尾提醒投资风险 - - 根据用户问题的类型,自动调整分析角度: - * 技术分析问题 → 重点分析图表形态、技术指标、价格趋势 - * 基本面问题 → 重点分析财务数据、业务模式、行业地位 - * 风险相关问题 → 特别强调风险提示和风险管理建议 - """; - - return string.IsNullOrEmpty(stockCode) - ? basePrompt - : $"{basePrompt}\n\n**当前分析焦点:{stockCode}**"; - } - - #endregion -} - -/// -/// 流式响应事件参数 -/// -public class StreamingResponseEventArgs : EventArgs -{ - /// - /// 响应内容 - /// - public string Content { get; set; } = string.Empty; - - /// - /// 是否完成 - /// - public bool IsComplete { get; set; } -} - -/// -/// 流式聊天更新 -/// -public class StreamingChatUpdate -{ - /// - /// 更新的内容 - /// - public string Content { get; set; } = string.Empty; -} - - diff --git a/src/Agents/StockSelection/Executors/AnalyzeStocksExecutor.cs b/src/Agents/StockSelection/Executors/AnalyzeStocksExecutor.cs deleted file mode 100644 index f203843..0000000 --- a/src/Agents/StockSelection/Executors/AnalyzeStocksExecutor.cs +++ /dev/null @@ -1,361 +0,0 @@ -using MarketAssistant.Agents.StockSelection.Models; -using MarketAssistant.Applications.StockSelection.Models; -using MarketAssistant.Infrastructure.Factories; -using MarketAssistant.Services.StockScreener.Models; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; - -namespace MarketAssistant.Agents.StockSelection.Executors; - -/// -/// 步骤3: AI分析筛选结果的 Executor(基于 Executor 模式) -/// 对筛选出的股票进行深度分析并生成推荐报告 -/// -public sealed class AnalyzeStocksExecutor : Executor -{ - private readonly IChatClientFactory _chatClientFactory; - private readonly ILogger _logger; - - private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions(JsonSerializerOptions.Web) - { - PropertyNameCaseInsensitive = true - }; - - public AnalyzeStocksExecutor( - IChatClientFactory chatClientFactory, - ILogger logger) : base("AnalyzeStocks") - { - if (chatClientFactory == null) throw new ArgumentNullException(nameof(chatClientFactory)); - _chatClientFactory = chatClientFactory; - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public override async ValueTask HandleAsync( - ScreeningResult input, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - _logger.LogInformation("[步骤3/3] 对筛选结果进行AI分析"); - - try - { - // 从输入中获取原始请求 - var originalRequest = input.OriginalRequest; - if (originalRequest == null) - { - _logger.LogError("[步骤3/3] 缺少原始请求信息"); - return new StockSelectionResult - { - Recommendations = new List(), - ConfidenceScore = 0, - AnalysisSummary = "分析失败:缺少原始请求信息" - }; - } - - // 检查是否有筛选结果 - if (input.ScreenedStocks.Count == 0) - { - _logger.LogWarning("[步骤3/3] 未筛选到符合条件的股票"); - return new StockSelectionResult - { - Recommendations = new List(), - ConfidenceScore = 0, - AnalysisSummary = "未找到符合条件的股票,建议放宽筛选条件。" - }; - } - - // 格式化股票数据为文本 - var stocksDataText = FormatScreenedStocksForAnalysis(input.ScreenedStocks); - - // 构建分析提示词 - var systemPrompt = GetAnalysisInstructions(originalRequest.IsNewsAnalysis); - var userPrompt = BuildAnalysisPrompt(originalRequest, stocksDataText); - - // 使用结构化输出 - var options = new ChatOptions - { - ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema: AIJsonUtilities.CreateJsonSchema(typeof(StockSelectionResult)), - schemaName: "StockSelectionResult", - schemaDescription: "股票选择分析结果,包含推荐股票列表和分析报告"), - Temperature = 0.2f, - MaxOutputTokens = 8000 - }; - - // 执行 AI 分析(纯分析,无工具调用) - var response = await _chatClientFactory.CreateClient().GetResponseAsync( - [ - new ChatMessage(ChatRole.System, systemPrompt), - new ChatMessage(ChatRole.User, userPrompt) - ], - options, - cancellationToken); - - // 添加调试日志:输出AI原始响应 - _logger.LogDebug("[步骤3/3] AI原始响应: {Response}", response.Text); - - var result = JsonSerializer.Deserialize(response.Text, JsonOptions); - - if (result == null) - { - _logger.LogWarning("[步骤3/3] 响应反序列化失败,原始响应: {Response}", response.Text); - result = CreateDefaultResult(); - } - else - { - // 验证必填字段 - var validationErrors = ValidateResult(result); - if (validationErrors.Count > 0) - { - _logger.LogWarning("[步骤3/3] AI返回数据验证失败: {Errors}", string.Join("; ", validationErrors)); - } - - if (result.Recommendations.Count == 0) - { - _logger.LogWarning("[步骤3/3] AI未生成任何推荐股票,原始响应: {Response}", response.Text); - } - } - - _logger.LogInformation("[步骤3/3] 分析完成,推荐 {Count} 只股票,置信度: {Score}", - result.Recommendations.Count, result.ConfidenceScore); - - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, "[步骤3/3] AI分析失败"); - return new StockSelectionResult - { - Recommendations = new List(), - ConfidenceScore = 0, - AnalysisSummary = $"分析失败: {ex.Message}" - }; - } - } - - /// - /// 验证 StockSelectionResult 的必填字段 - /// - private List ValidateResult(StockSelectionResult result) - { - var errors = new List(); - - // SelectionType 是枚举,不需要验证是否为空(枚举有默认值) - // 但可以验证是否为有效的枚举值 - if (!Enum.IsDefined(typeof(SelectionType), result.SelectionType)) - errors.Add($"SelectionType 值无效: {result.SelectionType}"); - - if (string.IsNullOrWhiteSpace(result.AnalysisSummary)) - errors.Add("AnalysisSummary 不能为空"); - - if (string.IsNullOrWhiteSpace(result.MarketEnvironmentAnalysis)) - errors.Add("MarketEnvironmentAnalysis 不能为空"); - - if (string.IsNullOrWhiteSpace(result.InvestmentAdvice)) - errors.Add("InvestmentAdvice 不能为空"); - - if (result.RiskWarnings == null || result.RiskWarnings.Count == 0) - errors.Add("RiskWarnings 不能为空"); - - // 验证每只推荐股票 - for (int i = 0; i < result.Recommendations.Count; i++) - { - var stock = result.Recommendations[i]; - if (string.IsNullOrWhiteSpace(stock.Symbol)) - errors.Add($"第{i + 1}只股票的 Symbol 不能为空"); - - if (string.IsNullOrWhiteSpace(stock.Name)) - errors.Add($"第{i + 1}只股票的 Name 不能为空"); - - if (string.IsNullOrWhiteSpace(stock.Reason)) - errors.Add($"第{i + 1}只股票的 Reason 不能为空"); - - // RiskLevel 是枚举,验证是否为有效值 - if (!Enum.IsDefined(typeof(RiskLevel), stock.RiskLevel)) - errors.Add($"第{i + 1}只股票的 RiskLevel 值无效: {stock.RiskLevel}"); - } - - return errors; - } - - /// - /// 格式化筛选结果为分析数据(JSON格式) - /// 只序列化非零值和非空值的属性,减少数据冗余 - /// - private string FormatScreenedStocksForAnalysis(List stocks) - { - var simplifiedStocks = stocks.Select(s => - { - var data = new Dictionary(); - - // 基本信息(必需) - data["名称"] = s.Name; - data["代码"] = s.Symbol; - - // 辅助方法:添加非零值 - void AddIfNotZero(string key, decimal value, int decimals = 2, decimal divisor = 1) - { - if (value != 0) - { - var convertedValue = divisor != 1 ? value / divisor : value; - data[key] = Math.Round(convertedValue, decimals); - } - } - - // 价格与涨跌 - AddIfNotZero("当前价_元", s.Current); - AddIfNotZero("涨跌幅_百分比", s.Pct); - AddIfNotZero("当日振幅_百分比", s.ChgPct); - - // 市值 - AddIfNotZero("总市值_亿元", s.Mc, 2, 100000000); - AddIfNotZero("流通市值_亿元", s.Fmc, 2, 100000000); - - // 成交数据 - AddIfNotZero("成交额_亿元", s.Amount, 2, 100000000); - AddIfNotZero("成交量_万股", s.Volume); - AddIfNotZero("量比", s.VolumeRatio); - AddIfNotZero("换手率_百分比", s.Tr); - - // 估值指标 - AddIfNotZero("市盈率TTM", s.PeTtm); - AddIfNotZero("市盈率LYR", s.PeLyr); - AddIfNotZero("市净率", s.Pb); - AddIfNotZero("市销率", s.Psr); - - // 每股指标 - AddIfNotZero("每股净资产_元", s.Bps); - AddIfNotZero("每股收益_元", s.Eps); - AddIfNotZero("股息收益率_百分比", s.DyL); - - // 盈利能力 - AddIfNotZero("净资产收益率ROE_百分比", s.RoeDiluted); - AddIfNotZero("总资产报酬率_百分比", s.Niota); - AddIfNotZero("净利润_亿元", s.NetProfit, 2, 100000000); - AddIfNotZero("营业收入_亿元", s.TotalRevenue, 2, 100000000); - - // 增长指标 - AddIfNotZero("净利润同比增长_百分比", s.Npay); - AddIfNotZero("营收同比增长_百分比", s.Oiy); - - // 历史涨跌 - AddIfNotZero("近5日涨跌幅_百分比", s.Pct5); - AddIfNotZero("近10日涨跌幅_百分比", s.Pct10); - AddIfNotZero("近20日涨跌幅_百分比", s.Pct20); - AddIfNotZero("近60日涨跌幅_百分比", s.Pct60); - AddIfNotZero("近120日涨跌幅_百分比", s.Pct120); - AddIfNotZero("近250日涨跌幅_百分比", s.Pct250); - AddIfNotZero("年初至今涨跌幅_百分比", s.PctCurrentYear); - - // 雪球社交热度 - AddIfNotZero("累计关注人数", s.Follow, 0); - AddIfNotZero("累计讨论次数", s.Tweet, 0); - AddIfNotZero("累计交易分享数", s.Deal, 0); - AddIfNotZero("一周新增关注", s.Follow7d, 0); - AddIfNotZero("一周新增讨论数", s.Tweet7d, 0); - AddIfNotZero("一周新增交易分享数", s.Deal7d, 0); - AddIfNotZero("一周关注增长率_百分比", s.Follow7dPct); - AddIfNotZero("一周讨论增长率_百分比", s.Tweet7dPct); - AddIfNotZero("一周交易分享增长率_百分比", s.Deal7dPct); - - return data; - }).ToList(); - - var jsonOptions = new JsonSerializerOptions - { - WriteIndented = true, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; - - return JsonSerializer.Serialize(simplifiedStocks, jsonOptions); - } - - /// - /// 构建分析提示词 - /// - private string BuildAnalysisPrompt(StockSelectionWorkflowRequest request, string stocksData) - { - var sb = new StringBuilder(); - - if (request.IsNewsAnalysis) - { - sb.AppendLine("## 新闻内容"); - sb.AppendLine(request.Content); - sb.AppendLine(); - } - else - { - sb.AppendLine("## 用户需求"); - sb.AppendLine($"- 需求描述: {request.Content}"); - sb.AppendLine($"- 风险偏好: {request.RiskPreference}"); - - if (request.InvestmentAmount.HasValue) - sb.AppendLine($"- 投资金额: {request.InvestmentAmount:C}"); - - if (request.InvestmentHorizon.HasValue) - sb.AppendLine($"- 投资期限: {request.InvestmentHorizon}天"); - - if (request.PreferredSectors.Any()) - sb.AppendLine($"- 偏好行业: {string.Join(", ", request.PreferredSectors)}"); - - if (request.ExcludedSectors.Any()) - sb.AppendLine($"- 排除行业: {string.Join(", ", request.ExcludedSectors)}"); - - sb.AppendLine(); - } - - sb.AppendLine("## 筛选出的股票数据(JSON格式)"); - sb.AppendLine(stocksData); - sb.AppendLine(); - sb.AppendLine("## 分析任务"); - sb.AppendLine("请基于以上股票数据和用户需求,进行综合分析并生成推荐报告。"); - sb.AppendLine("- 从中选择最优的3-8只股票进行推荐"); - sb.AppendLine("- 说明推荐理由和风险提示"); - - return sb.ToString(); - } - - /// - /// 获取分析指令(System Prompt)- 简洁版,依赖 JSON Schema 约束 - /// - private string GetAnalysisInstructions(bool isNewsAnalysis) - { - return @" -你是专业的投资顾问,基于用户需求/新闻热点和股票数据提供投资建议。 - -## 核心职责 -从筛选出的股票中进行多维度分析,输出结构化推荐报告。 - -## 评估维度(灵活权重) -1. **财务质量**:ROE、利润增长率、现金流、EPS/BPS -2. **估值水平**:PE/PB/PS 合理性、低估/高估判断、股息率 -3. **市场表现**:涨跌幅、流动性(成交额/换手率)、技术面趋势 -4. **需求匹配**:风险偏好、投资期限、行业偏好" + (isNewsAnalysis ? ",或新闻关联度" : "") + @" -5. **社交热度**:雪球关注/讨论及增长趋势(辅助参考) - -## 分析要点 -- 选出最优股票时,优先考虑财务健康度和估值合理性 -- 推荐理由必须包含具体数据支撑,避免空泛描述 -- 风险提示应针对个股和市场环境的具体风险 -- 如无合适标的,可返回空推荐列表 - -## 输出格式 -严格按 JSON Schema 定义的结构输出,所有必填字段不能为空或null。 -"; - } - - private StockSelectionResult CreateDefaultResult() - { - return new StockSelectionResult - { - SelectionType = SelectionType.UserRequest, // 设置默认枚举值 - Recommendations = new List(), - ConfidenceScore = 0, - AnalysisSummary = "解析分析结果失败", - MarketEnvironmentAnalysis = "无可用分析", - InvestmentAdvice = "建议重新尝试分析", - RiskWarnings = new List { "分析失败,请联系技术支持" } - }; - } -} diff --git a/src/Agents/StockSelection/Executors/ScreenStocksExecutor.cs b/src/Agents/StockSelection/Executors/ScreenStocksExecutor.cs deleted file mode 100644 index 56c1e46..0000000 --- a/src/Agents/StockSelection/Executors/ScreenStocksExecutor.cs +++ /dev/null @@ -1,63 +0,0 @@ -using MarketAssistant.Agents.StockSelection.Models; -using MarketAssistant.Services.StockScreener; -using MarketAssistant.Services.StockScreener.Models; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.Logging; - -namespace MarketAssistant.Agents.StockSelection.Executors; - -/// -/// 步骤2: 执行股票筛选的 Executor(基于 Executor 模式) -/// 直接调用 StockScreenerService 进行股票筛选 -/// -public sealed class ScreenStocksExecutor : Executor -{ - private readonly StockScreenerService _stockScreenerService; - private readonly ILogger _logger; - - public ScreenStocksExecutor( - StockScreenerService stockScreenerService, - ILogger logger) : base("ScreenStocks") - { - _stockScreenerService = stockScreenerService ?? throw new ArgumentNullException(nameof(stockScreenerService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public override async ValueTask HandleAsync( - CriteriaGenerationResult input, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - _logger.LogInformation("[步骤2/3] 执行股票筛选"); - - try - { - if (input?.Criteria == null) - { - throw new ArgumentNullException(nameof(input), "筛选条件不能为空"); - } - - _logger.LogInformation("[步骤2/3] 筛选条件: 市场={Market}, 行业={Industry}, 条件数={Count}", - input.Criteria.Market, input.Criteria.Industry, input.Criteria.Criteria.Count); - - // 调用 StockScreenerService 执行筛选 - List stocks = await _stockScreenerService.ScreenStocksAsync(input.Criteria); - - _logger.LogInformation("[步骤2/3] 筛选完成,获得 {Count} 只股票", stocks.Count); - - // 返回筛选结果 - return new ScreeningResult - { - ScreenedStocks = stocks, - Criteria = input.Criteria, - OriginalRequest = input.OriginalRequest - }; - } - catch (Exception ex) - { - _logger.LogError(ex, "[步骤2/3] 股票筛选失败"); - throw new InvalidOperationException($"股票筛选失败: {ex.Message}", ex); - } - } -} - diff --git a/src/Agents/StockSelection/StockSelectionWorkflow.cs b/src/Agents/StockSelection/StockSelectionWorkflow.cs deleted file mode 100644 index bbc6f20..0000000 --- a/src/Agents/StockSelection/StockSelectionWorkflow.cs +++ /dev/null @@ -1,150 +0,0 @@ -using MarketAssistant.Agents.StockSelection.Executors; -using MarketAssistant.Agents.StockSelection.Models; -using MarketAssistant.Applications.StockSelection.Models; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.Logging; - -namespace MarketAssistant.Agents.StockSelection; - -/// -/// AI选股工作流,使用 Agent Framework Workflows 实现确定性三步骤流程 -/// 第1步: 生成筛选条件 → 第2步: 执行筛选 → 第3步: AI分析结果 -/// -public class StockSelectionWorkflow : IDisposable -{ - private readonly GenerateCriteriaExecutor _generateCriteriaExecutor; - private readonly ScreenStocksExecutor _screenStocksExecutor; - private readonly AnalyzeStocksExecutor _analyzeStocksExecutor; - private readonly ILogger _logger; - private bool _disposed = false; - - public StockSelectionWorkflow( - GenerateCriteriaExecutor generateCriteriaExecutor, - ScreenStocksExecutor screenStocksExecutor, - AnalyzeStocksExecutor analyzeStocksExecutor, - ILogger logger) - { - _generateCriteriaExecutor = generateCriteriaExecutor ?? throw new ArgumentNullException(nameof(generateCriteriaExecutor)); - _screenStocksExecutor = screenStocksExecutor ?? throw new ArgumentNullException(nameof(screenStocksExecutor)); - _analyzeStocksExecutor = analyzeStocksExecutor ?? throw new ArgumentNullException(nameof(analyzeStocksExecutor)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - /// 执行基于用户需求的AI选股分析(使用工作流) - /// - public async Task AnalyzeUserRequirementAsync( - StockRecommendationRequest request, - CancellationToken cancellationToken = default) - { - var workflowRequest = new StockSelectionWorkflowRequest - { - IsNewsAnalysis = false, - Content = request.UserRequirements, - RiskPreference = request.RiskPreference, - InvestmentAmount = request.InvestmentAmount, - InvestmentHorizon = request.InvestmentHorizon, - PreferredSectors = request.PreferredSectors, - ExcludedSectors = request.ExcludedSectors, - MaxRecommendations = request.MaxRecommendations - }; - - return await ExecuteWorkflowAsync(workflowRequest, cancellationToken); - } - - /// - /// 执行基于新闻内容的AI选股分析(使用工作流) - /// - public async Task AnalyzeNewsHotspotAsync( - NewsBasedSelectionRequest request, - CancellationToken cancellationToken = default) - { - var workflowRequest = new StockSelectionWorkflowRequest - { - IsNewsAnalysis = true, - Content = request.NewsContent, - MaxRecommendations = request.MaxRecommendations - }; - - return await ExecuteWorkflowAsync(workflowRequest, cancellationToken); - } - - /// - /// 执行完整的选股工作流(确定性三步骤) - /// - private async Task ExecuteWorkflowAsync( - StockSelectionWorkflowRequest request, - CancellationToken cancellationToken) - { - _logger.LogInformation("开始执行选股工作流,分析类型: {Type}", - request.IsNewsAnalysis ? "新闻热点" : "用户需求"); - - // 构建顺序工作流: 步骤1 → 步骤2 → 步骤3 - var builder = new WorkflowBuilder(_generateCriteriaExecutor); - builder - .AddEdge(_generateCriteriaExecutor, _screenStocksExecutor) - .AddEdge(_screenStocksExecutor, _analyzeStocksExecutor) - .WithOutputFrom(_analyzeStocksExecutor); - - var workflow = builder.Build(); - - // 执行工作流 - await using Run run = await InProcessExecution.RunAsync(workflow, request, runId: null, cancellationToken); - - StockSelectionResult? finalResult = null; - - // 处理工作流事件 - foreach (WorkflowEvent evt in run.NewEvents) - { - switch (evt) - { - case ExecutorInvokedEvent executorInvoked: - _logger.LogInformation("步骤开始: {ExecutorId}", executorInvoked.ExecutorId); - break; - case ExecutorCompletedEvent executorComplete: - _logger.LogInformation("步骤完成: {ExecutorId}", executorComplete.ExecutorId); - break; - case WorkflowOutputEvent workflowOutput: - finalResult = workflowOutput.Data as StockSelectionResult; - _logger.LogInformation("工作流完成,推荐股票数量: {Count}", - finalResult?.Recommendations?.Count ?? 0); - break; - case ExecutorFailedEvent executorFailed: - _logger.LogError("步骤失败: {ExecutorId}, 错误: {Error}", - executorFailed.ExecutorId, - executorFailed.Data.Message); - throw new FriendlyException(executorFailed.Data.Message); - } - } - - return finalResult ?? CreateDefaultResult("工作流未返回结果"); - } - - /// - /// 创建默认结果 - /// - private StockSelectionResult CreateDefaultResult(string? problem = null) - { - return new StockSelectionResult - { - Recommendations = new List(), - ConfidenceScore = 0, - AnalysisSummary = problem ?? "分析过程中遇到问题,请稍后重试。" - }; - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!_disposed && disposing) - { - _disposed = true; - } - } -} - diff --git a/src/Agents/Tools/MarketSentimentTools.cs b/src/Agents/Tools/MarketSentimentTools.cs deleted file mode 100644 index aa89fc4..0000000 --- a/src/Agents/Tools/MarketSentimentTools.cs +++ /dev/null @@ -1,141 +0,0 @@ -using MarketAssistant.Agents.Plugins.Models; -using MarketAssistant.Services.Settings; -using Microsoft.Extensions.AI; -using System.ComponentModel; - -namespace MarketAssistant.Agents.Tools; - -public class MarketSentimentTools -{ - private readonly IHttpClientFactory _httpClientFactory; - private readonly IUserSettingService _userSettingService; - - public MarketSentimentTools(IHttpClientFactory httpClientFactory, IUserSettingService userSettingService) - { - _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); - _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); - } - - [Description("获取上市公司十大股东信息,默认返回最近1年的数据")] - public async Task> GetTopShareholdersAsync([Description("股票代码")] string stockSymbol) - { - try - { - var stockCode = StockSymbolConverter.ToZhiTuFormat(stockSymbol); - var token = _userSettingService.CurrentSetting.ZhiTuApiToken; - - // 十大股东数据量大(每期10个股东),只查询最近1年的数据 - var endDate = DateTime.Now.ToString("yyyyMMdd"); - var startDate = DateTime.Now.AddYears(-1).ToString("yyyyMMdd"); - - var url = $"https://api.zhituapi.com/hs/fin/topholder/{stockCode}?token={token}&st={startDate}&et={endDate}"; - - using var httpClient = _httpClientFactory.CreateClient(); - var response = await httpClient.GetStringAsync(url); - var topShareholders = JsonSerializer.Deserialize>(response); - - return topShareholders ?? new List(); - } - catch (Exception ex) - { - throw new Exception($"获取十大股东信息时发生错误: {ex.Message}", ex); - } - } - - [Description("获取上市公司十大流通股东信息,默认返回最近1年的数据")] - public async Task> GetTopCirculatingShareholdersAsync([Description("股票代码")] string stockSymbol) - { - try - { - var stockCode = StockSymbolConverter.ToZhiTuFormat(stockSymbol); - var token = _userSettingService.CurrentSetting.ZhiTuApiToken; - - // 十大流通股东数据量大(每期10个股东),只查询最近1年的数据 - var endDate = DateTime.Now.ToString("yyyyMMdd"); - var startDate = DateTime.Now.AddYears(-1).ToString("yyyyMMdd"); - - var url = $"https://api.zhituapi.com/hs/fin/flowholder/{stockCode}?token={token}&st={startDate}&et={endDate}"; - - using var httpClient = _httpClientFactory.CreateClient(); - var response = await httpClient.GetStringAsync(url); - var topCirculatingShareholders = JsonSerializer.Deserialize>(response); - - return topCirculatingShareholders ?? new List(); - } - catch (Exception ex) - { - throw new Exception($"获取十大流通股东信息时发生错误: {ex.Message}", ex); - } - } - - [Description("获取上市公司股东数变化,可用于分析筹码集中度和市场情绪,默认返回最近2年的数据")] - public async Task> GetShareholderCountAsync([Description("股票代码")] string stockSymbol) - { - try - { - var stockCode = StockSymbolConverter.ToZhiTuFormat(stockSymbol); - var token = _userSettingService.CurrentSetting.ZhiTuApiToken; - - // 查询最近2年的股东数变化,用于分析趋势 - var endDate = DateTime.Now.ToString("yyyyMMdd"); - var startDate = DateTime.Now.AddYears(-2).ToString("yyyyMMdd"); - - var url = $"https://api.zhituapi.com/hs/fin/hm/{stockCode}?token={token}&st={startDate}&et={endDate}"; - - using var httpClient = _httpClientFactory.CreateClient(); - var response = await httpClient.GetStringAsync(url); - var shareholderCounts = JsonSerializer.Deserialize>(response); - - return shareholderCounts ?? new List(); - } - catch (Exception ex) - { - throw new Exception($"获取公司股东数时发生错误: {ex.Message}", ex); - } - } - - [Description("获取股票资金流数据")] - public async Task GetFundFlowAsync([Description("股票代码")] string stockSymbol) - { - try - { - var url = $"https://x-quote.cls.cn/quote/stock/fundflow?secu_code={stockSymbol}&app=CailianpressWeb&os=web&sv=8.4.6"; - - using var httpClient = _httpClientFactory.CreateClient(); - var response = await httpClient.GetStringAsync(url); - var jsonDocument = JsonDocument.Parse(response); - - var fundFlow = new FundFlow(); - - var data = jsonDocument.RootElement.GetProperty("data"); - - fundFlow.MainFundIn = data.GetProperty("main_fund_in").GetInt64() / 10000f; // 转换为万元 - fundFlow.MainFundOut = data.GetProperty("main_fund_out").GetInt64() / 10000f; // 转换为万元 - fundFlow.MainFundDiff = data.GetProperty("main_fund_diff").GetInt64() / 10000f; // 转换为万元 - fundFlow.SuperFundDiff = data.GetProperty("super_fund_diff").GetInt64() / 10000f; // 转换为万元 - fundFlow.LargeFundDiff = data.GetProperty("large_fund_diff").GetInt64() / 10000f; // 转换为万元 - fundFlow.MediumFundDiff = data.GetProperty("medium_fund_diff").GetInt64() / 10000f; // 转换为万元 - fundFlow.LittleFundDiff = data.GetProperty("little_fund_diff").GetInt64() / 10000f; // 转换为万元 - fundFlow.MainFund3 = data.GetProperty("main_fund_3").GetInt64() / 10000f; // 转换为万元 - fundFlow.MainFund5 = data.GetProperty("main_fund_5").GetInt64() / 10000f; // 转换为万元 - fundFlow.MainFund10 = data.GetProperty("main_fund_10").GetInt64() / 10000f; // 转换为万元 - fundFlow.MainFund20 = data.GetProperty("main_fund_20").GetInt64() / 10000f; // 转换为万元 - fundFlow.Date = data.GetProperty("date").GetInt32(); // 解析日期 - - return fundFlow; - } - catch (Exception ex) - { - throw new Exception($"处理资金流数据时发生错误: {ex.Message}", ex); - } - } - - public IEnumerable GetFunctions() - { - yield return AIFunctionFactory.Create(GetTopShareholdersAsync); - yield return AIFunctionFactory.Create(GetTopCirculatingShareholdersAsync); - yield return AIFunctionFactory.Create(GetShareholderCountAsync); - yield return AIFunctionFactory.Create(GetFundFlowAsync); - } -} - diff --git a/src/Agents/Tools/Models/CapitalStructure.cs b/src/Agents/Tools/Models/CapitalStructure.cs deleted file mode 100644 index 3b9b9b5..0000000 --- a/src/Agents/Tools/Models/CapitalStructure.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Text.Json.Serialization; - -namespace MarketAssistant.Agents.Plugins.Models; - -/// -/// 公司股本结构 -/// -public class CapitalStructure -{ - /// - /// 变动日期 - /// - [JsonPropertyName("bdrq")] - public string ChangeDate { get; set; } = ""; - - /// - /// 公告日 - /// - [JsonPropertyName("ggr")] - public string AnnouncementDate { get; set; } = ""; - - /// - /// 总股本 - /// - [JsonPropertyName("zgb")] - public decimal? TotalShares { get; set; } - - /// - /// 已上市流通A股 - /// - [JsonPropertyName("ysltag")] - public decimal? CirculatingAShares { get; set; } - - /// - /// 限售流通股份 - /// - [JsonPropertyName("xsltgf")] - public decimal? RestrictedShares { get; set; } -} - diff --git a/src/Agents/Tools/Models/FundFlow.cs b/src/Agents/Tools/Models/FundFlow.cs deleted file mode 100644 index a4a74a7..0000000 --- a/src/Agents/Tools/Models/FundFlow.cs +++ /dev/null @@ -1,64 +0,0 @@ -namespace MarketAssistant.Agents.Plugins.Models; - -public class FundFlow -{ - /// - /// 主力流入 (万元) - /// - public float MainFundIn { get; set; } - - /// - /// 主力流出 (万元) - /// - public float MainFundOut { get; set; } - - /// - /// 主力净流入 (万元) - /// - public float MainFundDiff { get; set; } - - /// - /// 超大单流入 (万元) - /// - public float SuperFundDiff { get; set; } - - /// - /// 大单流入 (万元) - /// - public float LargeFundDiff { get; set; } - - /// - /// 中单流入 (万元) - /// - public float MediumFundDiff { get; set; } - - /// - /// 小单流入 (万元) - /// - public float LittleFundDiff { get; set; } - - /// - /// 3日主力净流入 (万元) - /// - public float MainFund3 { get; set; } - - /// - /// 5日主力净流入 (万元) - /// - public float MainFund5 { get; set; } - - /// - /// 10日主力净流入 (万元) - /// - public float MainFund10 { get; set; } - - /// - /// 20日主力净流入 (万元) - /// - public float MainFund20 { get; set; } - - /// - /// 日期 - /// - public int Date { get; set; } -} diff --git a/src/Agents/Tools/Models/StockBoll.cs b/src/Agents/Tools/Models/StockBoll.cs deleted file mode 100644 index 458ac9b..0000000 --- a/src/Agents/Tools/Models/StockBoll.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Text.Json.Serialization; - -namespace MarketAssistant.Agents.Plugins.Models; - -public class StockBoll -{ - /// - /// 交易时间,短分时级别格式为yyyy-MM-ddHH:mm:ss,日线级别为yyyy-MM-dd - /// - [JsonPropertyName("t")] - public string T { get; set; } = ""; - - /// - /// 上轨 - /// - [JsonPropertyName("u")] - public decimal? U { get; set; } - - /// - /// 下轨 - /// - [JsonPropertyName("d")] - public decimal? D { get; set; } - - /// - /// 中轨 - /// - [JsonPropertyName("m")] - public decimal? M { get; set; } - - /// - /// 数据的自然语言描述,辅助大模型理解 - /// - public string Description => $"日期: {T}, 上轨(Upper): {U}, 中轨(Middle): {M}, 下轨(Lower): {D}"; -} diff --git a/src/Agents/Tools/Models/StockCompanyInfo.cs b/src/Agents/Tools/Models/StockCompanyInfo.cs deleted file mode 100644 index 762407b..0000000 --- a/src/Agents/Tools/Models/StockCompanyInfo.cs +++ /dev/null @@ -1,256 +0,0 @@ -using System.Text.Json.Serialization; - -namespace MarketAssistant.Agents.Plugins.Models; - -/// -/// 股票公司信息实体类 -/// -public class StockCompanyInfo -{ - // ================= 基础信息 ================= - /// - /// 公司名称 - /// - [JsonPropertyName("name")] - public string Name { get; set; } = ""; - - /// - /// 公司英文名称 - /// - [JsonPropertyName("ename")] - public string EName { get; set; } = ""; - - /// - /// 上市市场 (例如: 沪市主板/深市创业板) - /// - [JsonPropertyName("market")] - public string Market { get; set; } = ""; - - // ================= 上市信息 ================= - /// - /// 概念及板块 (多个概念逗号分隔) - /// - [JsonPropertyName("idea")] - public string Concept { get; set; } = ""; - - /// - /// 上市日期 (格式 yyyy-MM-dd) - /// - [JsonPropertyName("ldate")] - public string ListingDate { get; set; } = ""; - - /// - /// 发行价格 (元) - /// - [JsonPropertyName("sprice")] - public string IssuePrice { get; set; } = ""; - - // ================= 发行信息 ================= - /// - /// 主承销商 - /// - [JsonPropertyName("principal")] - public string Underwriter { get; set; } = ""; - - /// - /// 成立日期 (格式 yyyy-MM-dd) - /// - [JsonPropertyName("rdate")] - public string EstablishmentDate { get; set; } = ""; - - /// - /// 注册资本 - /// - [JsonPropertyName("rprice")] - public string RegisteredCapital { get; set; } = ""; - - // ================= 机构信息 ================= - /// - /// 机构类型 - /// - [JsonPropertyName("instype")] - public string InstitutionType { get; set; } = ""; - - /// - /// 组织形式 - /// - [JsonPropertyName("organ")] - public string Organization { get; set; } = ""; - - // ================= 联系方式 ================= - /// - /// 董事会秘书 - /// - [JsonPropertyName("secre")] - public string Secretary { get; set; } = ""; - - /// - /// 公司电话 - /// - [JsonPropertyName("phone")] - public string CompanyPhone { get; set; } = ""; - - /// - /// 董秘电话 - /// - [JsonPropertyName("sphone")] - public string SecretaryPhone { get; set; } = ""; - - // ================= 其他信息 ================= - /// - /// 公司传真 - /// - [JsonPropertyName("fax")] - public string Fax { get; set; } = ""; - - /// - /// 董秘传真 - /// - [JsonPropertyName("sfax")] - public string SecretaryFax { get; set; } = ""; - - /// - /// 公司邮箱 - /// - [JsonPropertyName("email")] - public string Email { get; set; } = ""; - - /// - /// 董秘邮箱 - /// - [JsonPropertyName("semail")] - public string SecretaryEmail { get; set; } = ""; - - // ================= 网站信息 ================= - /// - /// 公司官网 - /// - [JsonPropertyName("site")] - public string Website { get; set; } = ""; - - /// - /// 邮政编码 - /// - [JsonPropertyName("post")] - public string PostalCode { get; set; } = ""; - - /// - /// 信息披露网站 - /// - [JsonPropertyName("infosite")] - public string InfoWebsite { get; set; } = ""; - - // ================= 历史信息 ================= - /// - /// 证券简称更名历史 - /// - [JsonPropertyName("oname")] - public string NameHistory { get; set; } = ""; - - // ================= 地址信息 ================= - /// - /// 注册地址 - /// - [JsonPropertyName("addr")] - public string RegisteredAddress { get; set; } = ""; - - /// - /// 办公地址 - /// - [JsonPropertyName("oaddr")] - public string OfficeAddress { get; set; } = ""; - - // ================= 描述信息 ================= - /// - /// 公司简介 - /// - [JsonPropertyName("desc")] - public string Description { get; set; } = ""; - - /// - /// 经营范围 - /// - [JsonPropertyName("bscope")] - public string BusinessScope { get; set; } = ""; - - // ================= 发行细节 ================= - /// - /// 承销方式 - /// - [JsonPropertyName("printype")] - public string UnderwritingType { get; set; } = ""; - - /// - /// 上市推荐人 - /// - [JsonPropertyName("referrer")] - public string ListingReferrer { get; set; } = ""; - - /// - /// 发行方式 - /// - [JsonPropertyName("putype")] - public string IssueType { get; set; } = ""; - - // ================= 财务指标 ================= - /// - /// 发行市盈率 (按发行后总股本) - /// - [JsonPropertyName("pe")] - public string PublishPE { get; set; } = ""; - - /// - /// 首发前总股本 (万股) - /// - [JsonPropertyName("firgu")] - public string PreIssueShares { get; set; } = ""; - - /// - /// 首发后总股本 (万股) - /// - [JsonPropertyName("lastgu")] - public string PostIssueShares { get; set; } = ""; - - /// - /// 实际发行量 (万股) - /// - [JsonPropertyName("realgu")] - public string ActualIssueShares { get; set; } = ""; - - // ================= 募集资金 ================= - /// - /// 预计募集资金 (万元) - /// - [JsonPropertyName("planm")] - public string PlannedFunds { get; set; } = ""; - - /// - /// 实际募集资金 (万元) - /// - [JsonPropertyName("realm")] - public string ActualFunds { get; set; } = ""; - - /// - /// 发行费用总额 (万元) - /// - [JsonPropertyName("pubfee")] - public string TotalIssueCost { get; set; } = ""; - - /// - /// 募集资金净额 (万元) - /// - [JsonPropertyName("collect")] - public string NetFunds { get; set; } = ""; - - /// - /// 承销费用 (万元) - /// - [JsonPropertyName("signfee")] - public string UnderwritingFee { get; set; } = ""; - - /// - /// 招股公告日 (格式 yyyy-MM-dd) - /// - [JsonPropertyName("pdate")] - public string ProspectusDate { get; set; } = ""; -} diff --git a/src/Agents/Tools/Models/StockKDJ.cs b/src/Agents/Tools/Models/StockKDJ.cs deleted file mode 100644 index 3387948..0000000 --- a/src/Agents/Tools/Models/StockKDJ.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Text.Json.Serialization; - -namespace MarketAssistant.Agents.Plugins.Models; - -public class StockKDJ -{ - /// - /// 交易时间,短分时级别格式为yyyy-MM-dd HH:mm:ss,日线级别为yyyy-MM-dd - /// - [JsonPropertyName("t")] - public string T { get; set; } = ""; - - /// - /// K值 - /// - [JsonPropertyName("k")] - public decimal? K { get; set; } - - /// - /// D值 - /// - [JsonPropertyName("d")] - public decimal? D { get; set; } - - /// - /// J值 - /// - [JsonPropertyName("j")] - public decimal? J { get; set; } -} diff --git a/src/Agents/Tools/Models/StockMA.cs b/src/Agents/Tools/Models/StockMA.cs deleted file mode 100644 index 3d94abd..0000000 --- a/src/Agents/Tools/Models/StockMA.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Text.Json.Serialization; - -namespace MarketAssistant.Agents.Plugins.Models; - -public class StockMA -{ - /// - /// 交易时间,短分时级别格式为yyyy-MM-ddHH:mm:ss,日线级别为yyyy-MM-dd - /// - [JsonPropertyName("t")] - public string T { get; set; } = ""; - - /// - /// MA3,没有则为null - /// - [JsonPropertyName("ma3")] - public decimal? MA3 { get; set; } - - /// - /// MA5,没有则为null - /// - [JsonPropertyName("ma5")] - public decimal? MA5 { get; set; } - - /// - /// MA10,没有则为null - /// - [JsonPropertyName("ma10")] - public decimal? MA10 { get; set; } - - /// - /// MA15,没有则为null - /// - [JsonPropertyName("ma15")] - public decimal? MA15 { get; set; } - - /// - /// MA20,没有则为null - /// - [JsonPropertyName("ma20")] - public decimal? MA20 { get; set; } - - /// - /// MA30,没有则为null - /// - [JsonPropertyName("ma30")] - public decimal? MA30 { get; set; } - - /// - /// MA60,没有则为null - /// - [JsonPropertyName("ma60")] - public decimal? MA60 { get; set; } - - /// - /// MA120,没有则为null - /// - [JsonPropertyName("ma120")] - public decimal? MA120 { get; set; } - - /// - /// MA200,没有则为null - /// - [JsonPropertyName("ma200")] - public decimal? MA200 { get; set; } - - /// - /// MA250,没有则为null - /// - [JsonPropertyName("ma250")] - public decimal? MA250 { get; set; } -} diff --git a/src/Agents/Tools/Models/StockMACD.cs b/src/Agents/Tools/Models/StockMACD.cs deleted file mode 100644 index 6926c4e..0000000 --- a/src/Agents/Tools/Models/StockMACD.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Text.Json.Serialization; - -namespace MarketAssistant.Agents.Plugins.Models; - -public class StockMACD -{ - /// - /// 交易时间,短分时级别格式为yyyy-MM-ddHH:mm:ss,日线级别为yyyy-MM-dd - /// - [JsonPropertyName("t")] - public string T { get; set; } = ""; - - /// - /// DIFF值 - /// - [JsonPropertyName("diff")] - public decimal Diff { get; set; } - - /// - /// DEA值 - /// - [JsonPropertyName("dea")] - public decimal Dea { get; set; } - - /// - /// MACD值 - /// - [JsonPropertyName("macd")] - public decimal Macd { get; set; } - - /// - /// EMA(12)值 - /// - [JsonPropertyName("ema12")] - public decimal Ema12 { get; set; } - - /// - /// EMA(26)值 - /// - [JsonPropertyName("ema26")] - public decimal Ema26 { get; set; } -} diff --git a/src/Agents/Tools/StockBasicTools.cs b/src/Agents/Tools/StockBasicTools.cs deleted file mode 100644 index 68b6921..0000000 --- a/src/Agents/Tools/StockBasicTools.cs +++ /dev/null @@ -1,101 +0,0 @@ -using MarketAssistant.Agents.Plugins.Models; -using MarketAssistant.Services.Settings; -using Microsoft.Extensions.AI; -using System.ComponentModel; - -namespace MarketAssistant.Agents.Tools; - -public sealed class StockBasicTools -{ - private readonly IHttpClientFactory _httpClientFactory; - private readonly IUserSettingService _userSettingService; - - public StockBasicTools(IHttpClientFactory httpClientFactory, IUserSettingService userSettingService) - { - _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); - _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); - } - - [Description("根据股票代码获取股票基本数据,包括实时行情、价格变动、市值等信息")] - public async Task GetStockInfoAsync([Description("股票代码")] string stockSymbol) - { - try - { - var url = $"https://x-quote.cls.cn/quote/stock/basic?secu_code={stockSymbol}&fields=open_px,av_px,high_px,low_px,change,change_px,down_price,change_3,change_5,qrr,entrust_rate,tr,amp,TotalShares,mc,NetAssetPS,NonRestrictedShares,cmc,business_amount,business_balance,pe,ttm_pe,pb,secu_name,secu_code,trade_status,secu_type,preclose_px,up_price,last_px&app=CailianpressWeb&os=web&sv=8.4.6"; - - using var httpClient = _httpClientFactory.CreateClient(); - var response = await httpClient.GetStringAsync(url); - var jsonDocument = JsonDocument.Parse(response); - - var stockPriceInfo = new StockQuoteInfo(); - - var data = jsonDocument.RootElement.GetProperty("data"); - - stockPriceInfo.CurrentPrice = data.GetProperty("last_px").GetDecimal(); - stockPriceInfo.PriceChange = data.GetProperty("change_px").GetDecimal(); - stockPriceInfo.PercentageChange = data.GetProperty("change").GetDecimal(); - stockPriceInfo.HighPrice = data.GetProperty("high_px").GetDecimal(); - stockPriceInfo.LowPrice = data.GetProperty("low_px").GetDecimal(); - stockPriceInfo.Volume = data.GetProperty("business_amount").GetDecimal() / 10000; // 转换为万手 - stockPriceInfo.Amount = data.GetProperty("business_balance").GetDecimal() / 100000000; // 转换为亿 - stockPriceInfo.TurnoverRate = data.GetProperty("tr").GetDecimal(); - stockPriceInfo.PercentageChange3Day = data.GetProperty("change_3").GetDecimal(); - stockPriceInfo.PercentageChange5Day = data.GetProperty("change_5").GetDecimal(); - stockPriceInfo.TotalShares = data.GetProperty("TotalShares").GetDecimal(); - stockPriceInfo.MarketCapitalization = data.GetProperty("mc").GetDecimal() / 100000000; // 转换为亿 - stockPriceInfo.SecurityName = data.GetProperty("secu_name").GetString() ?? string.Empty; - stockPriceInfo.SecurityCode = data.GetProperty("secu_code").GetString() ?? string.Empty; - stockPriceInfo.TradeStatus = data.GetProperty("trade_status").GetString() ?? string.Empty; - stockPriceInfo.SecurityType = data.GetProperty("secu_type").GetString() ?? string.Empty; - stockPriceInfo.OpenPrice = data.GetProperty("open_px").GetDecimal(); - stockPriceInfo.PreviousClosePrice = data.GetProperty("preclose_px").GetDecimal(); - stockPriceInfo.UpLimitPrice = data.GetProperty("up_price").GetDecimal(); - stockPriceInfo.DownLimitPrice = data.GetProperty("down_price").GetDecimal(); - stockPriceInfo.Amplitude = data.GetProperty("amp").GetDecimal(); - stockPriceInfo.PERatio = data.GetProperty("pe").GetDecimal(); - stockPriceInfo.TTMPERatio = data.GetProperty("ttm_pe").GetDecimal(); - stockPriceInfo.PBRatio = data.GetProperty("pb").GetDecimal(); - stockPriceInfo.CirculationMarketCap = data.GetProperty("cmc").GetDecimal() / 100000000; // 转换为亿 - stockPriceInfo.NonRestrictedShares = data.GetProperty("NonRestrictedShares").GetDecimal(); - stockPriceInfo.NetAssetPerShare = data.GetProperty("NetAssetPS").GetDecimal(); - stockPriceInfo.AveragePrice = data.GetProperty("av_px").GetDecimal(); - stockPriceInfo.VolumeRatio = data.GetProperty("qrr").GetDecimal(); - stockPriceInfo.EntrustRatio = data.GetProperty("entrust_rate").GetDecimal(); - - return stockPriceInfo; - } - catch (Exception ex) - { - throw new Exception($"处理股票价格数据时发生错误: {ex.Message}", ex); - } - } - - [Description("根据股票代码获取上市公司基本面信息,包括公司简介、主营业务、所属行业等")] - public async Task GetStockCompanyInfoAsync([Description("股票代码")] string stockSymbol) - { - try - { - // 只保留 stockSymbol 中的数字部分 - stockSymbol = new string(stockSymbol.Where(char.IsDigit).ToArray()); - - var token = _userSettingService.CurrentSetting.ZhiTuApiToken; - var url = $"https://api.zhituapi.com/hs/gs/gsjj/{stockSymbol}?token={token}"; - - using var httpClient = _httpClientFactory.CreateClient(); - var response = await httpClient.GetStringAsync(url); - var info = JsonSerializer.Deserialize(response); - - return info ?? throw new Exception("GetStockCompanyInfoAsync返回数据为空"); - } - catch (Exception ex) - { - throw new Exception($"处理公司基本面数据时发生错误: {ex.Message}", ex); - } - } - - public IEnumerable GetFunctions() - { - yield return AIFunctionFactory.Create(GetStockInfoAsync); - yield return AIFunctionFactory.Create(GetStockCompanyInfoAsync); - } -} diff --git a/src/Agents/Tools/StockFinancialTools.cs b/src/Agents/Tools/StockFinancialTools.cs deleted file mode 100644 index 3da1051..0000000 --- a/src/Agents/Tools/StockFinancialTools.cs +++ /dev/null @@ -1,157 +0,0 @@ -using MarketAssistant.Agents.Plugins.Models; -using MarketAssistant.Services.Settings; -using Microsoft.Extensions.AI; -using System.ComponentModel; - -namespace MarketAssistant.Agents.Tools; - -public class StockFinancialTools -{ - private readonly IHttpClientFactory _httpClientFactory; - private readonly IUserSettingService _userSettingService; - - public StockFinancialTools(IHttpClientFactory httpClientFactory, IUserSettingService userSettingService) - { - _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); - _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); - } - - [Description("获取上市公司资产负债表,默认返回最近2年的数据")] - public async Task> GetBalanceSheetAsync([Description("股票代码")] string stockSymbol) - { - try - { - var stockCode = StockSymbolConverter.ToZhiTuFormat(stockSymbol); - var token = _userSettingService.CurrentSetting.ZhiTuApiToken; - - // 默认查询最近2年的数据(约8个季度) - var endDate = DateTime.Now.ToString("yyyyMMdd"); - var startDate = DateTime.Now.AddYears(-2).ToString("yyyyMMdd"); - - var url = $"https://api.zhituapi.com/hs/fin/balance/{stockCode}?token={token}&st={startDate}&et={endDate}"; - - using var httpClient = _httpClientFactory.CreateClient(); - var response = await httpClient.GetStringAsync(url); - var balanceSheets = JsonSerializer.Deserialize>(response); - - return balanceSheets ?? new List(); - } - catch (Exception ex) - { - throw new Exception($"获取资产负债表时发生错误: {ex.Message}", ex); - } - } - - [Description("获取上市公司利润表,默认返回最近2年的数据")] - public async Task> GetIncomeStatementAsync([Description("股票代码")] string stockSymbol) - { - try - { - var stockCode = StockSymbolConverter.ToZhiTuFormat(stockSymbol); - var token = _userSettingService.CurrentSetting.ZhiTuApiToken; - - // 默认查询最近2年的数据(约8个季度) - var endDate = DateTime.Now.ToString("yyyyMMdd"); - var startDate = DateTime.Now.AddYears(-2).ToString("yyyyMMdd"); - - var url = $"https://api.zhituapi.com/hs/fin/income/{stockCode}?token={token}&st={startDate}&et={endDate}"; - - using var httpClient = _httpClientFactory.CreateClient(); - var response = await httpClient.GetStringAsync(url); - var incomeStatements = JsonSerializer.Deserialize>(response); - - return incomeStatements ?? new List(); - } - catch (Exception ex) - { - throw new Exception($"获取利润表时发生错误: {ex.Message}", ex); - } - } - - [Description("获取上市公司现金流量表,默认返回最近2年的数据")] - public async Task> GetCashFlowStatementAsync([Description("股票代码")] string stockSymbol) - { - try - { - var stockCode = StockSymbolConverter.ToZhiTuFormat(stockSymbol); - var token = _userSettingService.CurrentSetting.ZhiTuApiToken; - - // 默认查询最近2年的数据(约8个季度) - var endDate = DateTime.Now.ToString("yyyyMMdd"); - var startDate = DateTime.Now.AddYears(-2).ToString("yyyyMMdd"); - - var url = $"https://api.zhituapi.com/hs/fin/cashflow/{stockCode}?token={token}&st={startDate}&et={endDate}"; - - using var httpClient = _httpClientFactory.CreateClient(); - var response = await httpClient.GetStringAsync(url); - var cashFlowStatements = JsonSerializer.Deserialize>(response); - - return cashFlowStatements ?? new List(); - } - catch (Exception ex) - { - throw new Exception($"获取现金流量表时发生错误: {ex.Message}", ex); - } - } - - [Description("获取上市公司财务主要指标,默认返回最近2年的数据")] - public async Task> GetFinancialRatiosAsync([Description("股票代码")] string stockSymbol) - { - try - { - var stockCode = StockSymbolConverter.ToZhiTuFormat(stockSymbol); - var token = _userSettingService.CurrentSetting.ZhiTuApiToken; - - // 默认查询最近2年的数据(约8个季度) - var endDate = DateTime.Now.ToString("yyyyMMdd"); - var startDate = DateTime.Now.AddYears(-2).ToString("yyyyMMdd"); - - var url = $"https://api.zhituapi.com/hs/fin/ratios/{stockCode}?token={token}&st={startDate}&et={endDate}"; - - using var httpClient = _httpClientFactory.CreateClient(); - var response = await httpClient.GetStringAsync(url); - var financialRatios = JsonSerializer.Deserialize>(response); - - return financialRatios ?? new List(); - } - catch (Exception ex) - { - throw new Exception($"获取财务主要指标时发生错误: {ex.Message}", ex); - } - } - - [Description("获取上市公司股本结构,默认返回最近3年的变动记录")] - public async Task> GetCapitalStructureAsync([Description("股票代码")] string stockSymbol) - { - try - { - var stockCode = StockSymbolConverter.ToZhiTuFormat(stockSymbol); - var token = _userSettingService.CurrentSetting.ZhiTuApiToken; - - // 股本变动相对较少,查询最近3年的变动记录 - var endDate = DateTime.Now.ToString("yyyyMMdd"); - var startDate = DateTime.Now.AddYears(-3).ToString("yyyyMMdd"); - - var url = $"https://api.zhituapi.com/hs/fin/capital/{stockCode}?token={token}&st={startDate}&et={endDate}"; - - using var httpClient = _httpClientFactory.CreateClient(); - var response = await httpClient.GetStringAsync(url); - var capitalStructures = JsonSerializer.Deserialize>(response); - - return capitalStructures ?? new List(); - } - catch (Exception ex) - { - throw new Exception($"获取公司股本结构时发生错误: {ex.Message}", ex); - } - } - - public IEnumerable GetFunctions() - { - yield return AIFunctionFactory.Create(GetBalanceSheetAsync); - yield return AIFunctionFactory.Create(GetIncomeStatementAsync); - yield return AIFunctionFactory.Create(GetCashFlowStatementAsync); - yield return AIFunctionFactory.Create(GetFinancialRatiosAsync); - yield return AIFunctionFactory.Create(GetCapitalStructureAsync); - } -} diff --git a/src/Agents/Tools/StockNewsTools.cs b/src/Agents/Tools/StockNewsTools.cs deleted file mode 100644 index 49b94cb..0000000 --- a/src/Agents/Tools/StockNewsTools.cs +++ /dev/null @@ -1,197 +0,0 @@ -using MarketAssistant.Agents.Plugins.Models; -using MarketAssistant.Infrastructure.Factories; -using MarketAssistant.Services.Browser; -using Microsoft.Extensions.AI; -using Microsoft.Playwright; -using System.ComponentModel; - -namespace MarketAssistant.Agents.Tools; - -public class StockNewsTools -{ - private readonly PlaywrightService _playwrightService; - private readonly IChatClientFactory _chatClientFactory; - - public StockNewsTools( - PlaywrightService playwrightService, - IChatClientFactory chatClientFactory) - { - _playwrightService = playwrightService ?? throw new ArgumentNullException(nameof(playwrightService)); - _chatClientFactory = chatClientFactory ?? throw new ArgumentNullException(nameof(chatClientFactory)); - } - - /// - /// 根据新闻Url获取新闻详情 - /// - private async Task GetNewsContentAsync(string url, CancellationToken cancellationToken = default) - { - try - { - var sr = new SmartReader.Reader(url); - sr.Debug = false; - - var article = sr.GetArticle(); - if (article.IsReadable) - { - return article.TextContent; - } - - // 使用 IChatClient 直接进行内容提取(Agent Framework 方式) - return await ExtractArticleContentAsync(article.Content, cancellationToken); - } - catch (Exception ex) - { - throw new Exception($"处理新闻内容时发生错误: {ex.Message}", ex); - } - } - - /// - /// 从HTML内容中提取文章正文(使用AI) - /// - private async Task ExtractArticleContentAsync(string htmlContent, CancellationToken cancellationToken = default) - { - var chatClient = _chatClientFactory.CreateClient(); - - var systemPrompt = @"你是一个专业的网页内容提取专家。请从HTML内容中提取出文章的主要内容。 - -要求: -1. 去除所有广告、导航栏、页脚等非文章内容 -2. 保持文章的原始格式和段落结构 -3. 仅返回正文文本内容,不需要其他信息 - -请直接返回提取的正文内容,不需要JSON格式或其他标记。"; - - var userPrompt = $"HTML内容:\n{htmlContent}"; - - var response = await chatClient.GetResponseAsync( - [ - new ChatMessage(ChatRole.System, systemPrompt), - new ChatMessage(ChatRole.User, userPrompt) - ], - new ChatOptions - { - Temperature = 0.3f, - MaxOutputTokens = 4096 - }, - cancellationToken); - - return response.Text ?? string.Empty; - } - - /// - /// 获取指定股票新闻列表(标题/来源/链接) - /// - /// - /// - /// - private async Task> GetNewsListAsync(string stockSymbol) - { - try - { - stockSymbol = StockSymbolConverter.ToClsFormat(stockSymbol).ToLower(); - - var url = $"https://www.cls.cn/stock?code={stockSymbol}"; - - // 使用PlaywrightService获取Browser实例 - return await _playwrightService.ExecuteWithPageAsync(async page => - { - await page.GotoAsync(url); - await page.WaitForLoadStateAsync(LoadState.NetworkIdle); - - var newsElements = await page.QuerySelectorAllAsync("div.p-t-20.p-b-20.b-b-w-1.b-b-s-s.b-c-e6e7ea"); - - var newsList = new List(); - - foreach (var newsElement in newsElements) - { - var titleElement = await newsElement.QuerySelectorAsync("a.c-222.line3"); - if (titleElement != null) - { - var title = await titleElement.InnerTextAsync(); - var link = await titleElement.GetAttributeAsync("href"); - if (!string.IsNullOrEmpty(link) && !link.StartsWith("http")) - { - link = $"https://www.cls.cn{link}"; - } - - // 获取新闻来源 - string source = ""; - var sourceElement = await newsElement.QuerySelectorAsync("div.f-r"); - if (sourceElement != null) - { - source = await sourceElement.InnerTextAsync(); - } - - newsList.Add(new NewsItem() - { - Title = title, - Link = link ?? "", - Source = source - }); - } - } - - return newsList; - }); - } - catch (Exception ex) - { - throw new Exception($"处理新闻列表时发生错误: {ex.Message}", ex); - } - } - - /// - /// 获取指定股票的聚合新闻上下文(Tool Function) - /// - [Description("获取指定股票的聚合新闻上下文,一次返回最近且相关的新闻要点。默认返回精简要点,可通过 response_format 控制详细程度。")] - public async Task> GetStockNewsContextAsync( - [Description("股票代码")] string stockSymbol, - [Description("返回的新闻条数上限,默认 5,建议 1-10")] int topK = 5, - [Description("响应格式:concise | detailed。默认 concise 更省 token")] string responseFormat = "concise") - { - try - { - topK = Math.Clamp(topK, 1, 10); - - var list = await GetNewsListAsync(stockSymbol); - var results = list.Take(topK).ToList(); - - bool isDetailed = string.Equals(responseFormat, "detailed", StringComparison.OrdinalIgnoreCase); - - if (isDetailed) - { - foreach (var item in results) - { - try - { - var content = await GetNewsContentAsync(item.Link); - - // 简单截断作为摘要片段,避免长文本占用上下文 - if (!string.IsNullOrWhiteSpace(content)) - { - var normalized = content.Trim(); - item.Summary = normalized.Length <= 500 ? normalized : normalized.Substring(0, 500); - } - } - catch - { - // 单条失败不影响整体,保持 Summary 为空 - } - } - } - - return results; - } - catch (Exception ex) - { - // 以可读错误提示帮助代理调整调用策略 - throw new Exception($"获取聚合新闻上下文失败: {ex.Message}. 可尝试:降低 topK、将 response_format 设为 'concise' 或缩小时间范围。", ex); - } - } - - public IEnumerable GetFunctions() - { - yield return AIFunctionFactory.Create(GetStockNewsContextAsync); - } -} - diff --git a/src/Agents/Tools/StockTechnicalTools.cs b/src/Agents/Tools/StockTechnicalTools.cs deleted file mode 100644 index c976519..0000000 --- a/src/Agents/Tools/StockTechnicalTools.cs +++ /dev/null @@ -1,56 +0,0 @@ -using MarketAssistant.Agents.Plugins.Models; -using MarketAssistant.Services.Settings; -using Microsoft.Extensions.AI; -using System.ComponentModel; - -namespace MarketAssistant.Agents.Tools; - -public sealed class StockTechnicalTools -{ - private readonly IHttpClientFactory _httpClientFactory; - private readonly IUserSettingService _userSettingService; - - public StockTechnicalTools(IHttpClientFactory httpClientFactory, IUserSettingService userSettingService) - { - _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); - _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); - } - - private async Task GetStockIndicatorAsync(string indicator, string stockSymbol) - { - var token = _userSettingService.CurrentSetting.ZhiTuApiToken; - var url = $"https://api.zhituapi.com/hs/history/{indicator}/{StockSymbolConverter.ToZhiTuFormat(stockSymbol)}/d/n?token={token}<=30"; - using var httpClient = _httpClientFactory.CreateClient(); - var response = await httpClient.GetStringAsync(url); - var items = JsonSerializer.Deserialize>(response); - - if (items == null || !items.Any()) - throw new Exception($"获取{indicator.ToUpper()}数据失败: 返回数据为空或无有效数据"); - - return items.Last(); - } - - [Description("获取近30日最新日线KDJ")] - public Task GetStockKDJAsync([Description("股票代码")] string stockSymbol) - => GetStockIndicatorAsync("kdj", stockSymbol); - - [Description("获取近30日最新日线MACD")] - public Task GetStockMACDAsync([Description("股票代码")] string stockSymbol) - => GetStockIndicatorAsync("macd", stockSymbol); - - [Description("获取近30日最新日线BOLL")] - public Task GetStockBOLLAsync([Description("股票代码")] string stockSymbol) - => GetStockIndicatorAsync("boll", stockSymbol); - - [Description("获取近30日最新日线MA")] - public Task GetStockMAAsync([Description("股票代码")] string stockSymbol) - => GetStockIndicatorAsync("ma", stockSymbol); - - public IEnumerable GetFunctions() - { - yield return AIFunctionFactory.Create(GetStockKDJAsync); - yield return AIFunctionFactory.Create(GetStockMACDAsync); - yield return AIFunctionFactory.Create(GetStockBOLLAsync); - yield return AIFunctionFactory.Create(GetStockMAAsync); - } -} diff --git a/src/Applications/News/NewsUpdateService.cs b/src/Applications/News/NewsUpdateService.cs deleted file mode 100644 index e3bd647..0000000 --- a/src/Applications/News/NewsUpdateService.cs +++ /dev/null @@ -1,127 +0,0 @@ -using MarketAssistant.Applications.Telegrams; -using MarketAssistant.Infrastructure.Core; -using Microsoft.Extensions.Logging; -using System.Timers; - -namespace MarketAssistant.Applications.News; - -/// -/// 新闻更新服务实现 -/// -public class NewsUpdateService : INewsUpdateService -{ - private readonly TelegramService _telegramService; - private readonly ILogger _logger; - private System.Timers.Timer? _updateTimer; - private bool _disposed; - - public event EventHandler>? NewsUpdated; - public event EventHandler? CountdownUpdated; - - public bool IsRunning => _updateTimer?.Enabled ?? false; - - public NewsUpdateService(TelegramService telegramService, ILogger logger) - { - _telegramService = telegramService; - _logger = logger; - } - - /// - /// 启动定时更新 - /// - public void StartUpdates() - { - if (_disposed) - throw new ObjectDisposedException(nameof(NewsUpdateService)); - - if (_updateTimer != null && _updateTimer.Enabled) - return; - - // 设置统一的定时器,每秒触发一次 - _updateTimer = new System.Timers.Timer(1000); // 1秒 - _updateTimer.Elapsed += OnTimerElapsed; - _updateTimer.AutoReset = true; - _updateTimer.Start(); - - _logger?.LogInformation("新闻更新定时器已启动"); - - // 立即更新一次新闻 - _ = UpdateNewsItemsAsync(); - } - - /// - /// 停止定时更新 - /// - public void StopUpdates() - { - if (_updateTimer != null) - { - _updateTimer.Stop(); - _updateTimer.Elapsed -= OnTimerElapsed; - _updateTimer.Dispose(); - _updateTimer = null; - _logger?.LogInformation("新闻更新定时器已停止"); - } - } - - private async void OnTimerElapsed(object? sender, ElapsedEventArgs e) - { - await GlobalExceptionHandler.SafeExecuteAsync(async () => - { - // 更新倒计时 - UpdateCountdown(); - - // 每10秒更新一次新闻 - if (DateTime.Now.Second % 10 == 0) - { - await UpdateNewsItemsAsync(); - } - }, operationName: "定时器更新", logger: _logger); - } - - private void UpdateCountdown() - { - try - { - var seconds = DateTime.Now.Second % 10; - var nextUpdate = (seconds == 0) ? 10 : (10 - seconds); - var countdownText = $"{nextUpdate}秒后更新"; - - CountdownUpdated?.Invoke(this, countdownText); - } - catch (Exception ex) - { - _logger?.LogError(ex, "更新倒计时出错"); - CountdownUpdated?.Invoke(this, "更新中..."); - } - } - - private async Task UpdateNewsItemsAsync() - { - try - { - // 通知正在更新 - CountdownUpdated?.Invoke(this, "正在更新..."); - - var news = await _telegramService.GetTelegraphsAsync(CancellationToken.None); - - // 通知新闻已更新 - NewsUpdated?.Invoke(this, news); - } - catch (Exception ex) - { - _logger?.LogError(ex, "获取咨询时出错"); - CountdownUpdated?.Invoke(this, "更新失败"); - } - } - - public void Dispose() - { - if (!_disposed) - { - StopUpdates(); - _disposed = true; - } - } -} - diff --git a/src/Applications/StockSelection/Models/NewsBasedSelectionRequest.cs b/src/Applications/StockSelection/Models/NewsBasedSelectionRequest.cs deleted file mode 100644 index 090d554..0000000 --- a/src/Applications/StockSelection/Models/NewsBasedSelectionRequest.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace MarketAssistant.Applications.StockSelection.Models; - -/// -/// 热点新闻选股请求 -/// -public class NewsBasedSelectionRequest -{ - /// - /// 用户提供的新闻内容 - /// - public string NewsContent { get; set; } = string.Empty; - - /// - /// 最大推荐股票数量 - /// - public int MaxRecommendations { get; set; } = 10; -} - diff --git a/src/Applications/StockSelection/StockSelectionService.cs b/src/Applications/StockSelection/StockSelectionService.cs deleted file mode 100644 index 7280c42..0000000 --- a/src/Applications/StockSelection/StockSelectionService.cs +++ /dev/null @@ -1,344 +0,0 @@ -using MarketAssistant.Agents.StockSelection; -using MarketAssistant.Applications.StockSelection.Models; -using Microsoft.Extensions.Logging; - -namespace MarketAssistant.Applications.StockSelection; - -/// -/// AI选股服务 - 业务逻辑层,负责对外API和业务规则 -/// 使用 Agent Framework Workflows 实现确定性选股流程 -/// -public class StockSelectionService : IDisposable -{ - private readonly StockSelectionWorkflow _selectionWorkflow; - private readonly ILogger _logger; - private bool _disposed = false; - - public StockSelectionService( - StockSelectionWorkflow selectionWorkflow, - ILogger logger) - { - _selectionWorkflow = selectionWorkflow ?? throw new ArgumentNullException(nameof(selectionWorkflow)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - #region 业务API接口 - - /// - /// 功能1: 根据用户需求推荐股票 - /// - public async Task RecommendStocksByUserRequirementAsync( - StockRecommendationRequest request, - CancellationToken cancellationToken = default) - { - if (request == null || string.IsNullOrWhiteSpace(request.UserRequirements)) - { - throw new ArgumentException("用户需求不能为空", nameof(request)); - } - - try - { - _logger.LogInformation("开始基于用户需求的AI选股,需求: {Requirements}", request.UserRequirements); - - // 业务逻辑:验证和预处理请求 - var validatedRequest = ValidateAndNormalizeUserRequest(request); - - // 调用工作流进行分析 - var result = await _selectionWorkflow.AnalyzeUserRequirementAsync(validatedRequest, cancellationToken); - - // 业务逻辑:后处理和结果优化 - var optimizedResult = OptimizeUserBasedResult(result, validatedRequest); - - _logger.LogInformation("用户需求选股完成,推荐股票数量: {Count}, 置信度: {Confidence:F1}%", - optimizedResult.Recommendations.Count, optimizedResult.ConfidenceScore); - - return optimizedResult; - } - catch (Exception ex) - { - _logger.LogError(ex, "基于用户需求的选股过程中发生错误"); - throw; - } - } - - /// - /// 功能2: 根据新闻推荐股票 - /// - public async Task RecommendStocksByNewsAsync( - NewsBasedSelectionRequest request, - CancellationToken cancellationToken = default) - { - if (request == null) - { - request = new NewsBasedSelectionRequest(); // 使用默认设置 - } - - try - { - _logger.LogInformation("开始基于热点新闻的AI选股,推荐股票数: {Max}", request.MaxRecommendations); - - // 业务逻辑:验证和预处理请求 - var validatedRequest = ValidateAndNormalizeNewsRequest(request); - - // 调用工作流进行分析 - var result = await _selectionWorkflow.AnalyzeNewsHotspotAsync(validatedRequest, cancellationToken); - - // 业务逻辑:后处理和结果优化 - var optimizedResult = OptimizeNewsBasedResult(result, validatedRequest); - - _logger.LogInformation("热点新闻选股完成,推荐股票数量: {Count}, 置信度: {Confidence:F1}%", - optimizedResult.Recommendations.Count, optimizedResult.ConfidenceScore); - - return optimizedResult; - } - catch (Exception ex) - { - _logger.LogError(ex, "基于热点新闻的选股过程中发生错误"); - throw; - } - } - - /// - /// 功能4: 快速选股(预设策略) - /// - public async Task QuickSelectAsync( - QuickSelectionStrategy strategy, - CancellationToken cancellationToken = default) - { - try - { - _logger.LogInformation("开始执行快速选股,策略: {Strategy}", strategy); - - // 业务逻辑:将策略转换为用户需求 - var request = ConvertStrategyToUserRequest(strategy); - - // 调用用户需求分析 - var result = await RecommendStocksByUserRequirementAsync(request, cancellationToken); - - _logger.LogInformation("快速选股完成,策略: {Strategy},结果长度: {Length}", - strategy, result.Recommendations.Count); - - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, "执行快速选股时发生错误,策略: {Strategy}", strategy); - throw; - } - } - - /// - /// 功能5: 获取快速选股策略列表 - /// - public List GetQuickSelectionStrategies() - { - return new List - { - new QuickSelectionStrategyInfo - { - Strategy = QuickSelectionStrategy.ValueStocks, - Name = "价值股筛选", - Icon = "💎", - Description = "筛选PE低、PB低、ROE高的优质价值股", - Scenario = "适合稳健型投资者,追求长期价值投资", - RiskLevel = "低风险" - }, - new QuickSelectionStrategyInfo - { - Strategy = QuickSelectionStrategy.GrowthStocks, - Name = "成长股筛选", - Icon = "🚀", - Description = "筛选营收和利润高增长的成长型股票", - Scenario = "适合积极型投资者,追求高成长收益", - RiskLevel = "中高风险" - }, - new QuickSelectionStrategyInfo - { - Strategy = QuickSelectionStrategy.ActiveStocks, - Name = "活跃股筛选", - Icon = "🔥", - Description = "筛选换手率高、成交活跃的热门股票", - Scenario = "适合短线交易者,追求市场热点", - RiskLevel = "高风险" - }, - new QuickSelectionStrategyInfo - { - Strategy = QuickSelectionStrategy.LargeCap, - Name = "大盘股筛选", - Icon = "🏢", - Description = "筛选市值大、业绩稳定的蓝筹股", - Scenario = "适合保守型投资者,追求稳定收益", - RiskLevel = "低风险" - }, - new QuickSelectionStrategyInfo - { - Strategy = QuickSelectionStrategy.SmallCap, - Name = "小盘股筛选", - Icon = "🌱", - Description = "筛选市值较小、具有成长潜力的股票", - Scenario = "适合风险偏好较高的投资者", - RiskLevel = "高风险" - }, - new QuickSelectionStrategyInfo - { - Strategy = QuickSelectionStrategy.Dividend, - Name = "高股息筛选", - Icon = "💰", - Description = "筛选股息率高、分红稳定的股票", - Scenario = "适合追求稳定现金流的投资者", - RiskLevel = "低风险" - } - }; - } - - #endregion - - #region 业务逻辑处理 - - /// - /// 验证和规范化用户请求 - /// - private StockRecommendationRequest ValidateAndNormalizeUserRequest(StockRecommendationRequest request) - { - var normalized = new StockRecommendationRequest - { - UserRequirements = request.UserRequirements?.Trim() ?? "", - InvestmentAmount = request.InvestmentAmount, - RiskPreference = NormalizeRiskPreference(request.RiskPreference), - InvestmentHorizon = request.InvestmentHorizon - }; - - // 业务规则:设置默认值 - if (string.IsNullOrWhiteSpace(normalized.RiskPreference)) - { - normalized.RiskPreference = "moderate"; - } - - return normalized; - } - - /// - /// 验证和规范化新闻请求 - /// - private NewsBasedSelectionRequest ValidateAndNormalizeNewsRequest(NewsBasedSelectionRequest request) - { - var normalized = new NewsBasedSelectionRequest - { - NewsContent = request.NewsContent?.Trim() ?? "", - MaxRecommendations = Math.Max(1, Math.Min(10, request.MaxRecommendations)) // 限制在1-10只之间 - }; - - return normalized; - } - - /// - /// 优化用户需求分析结果 - /// - private StockSelectionResult OptimizeUserBasedResult(StockSelectionResult result, StockRecommendationRequest request) - { - // 业务逻辑:根据用户风险偏好调整推荐 - if (request.RiskPreference == "conservative") - { - // 保守型投资者,过滤掉高风险股票 - result.Recommendations = result.Recommendations - .Where(r => r.RiskLevel != RiskLevel.High) - .ToList(); - } - else if (request.RiskPreference == "aggressive") - { - // 激进型投资者,优先推荐高收益股票 - result.Recommendations = result.Recommendations - .OrderByDescending(r => r.ExpectedReturn ?? 0) - .ToList(); - } - - return result; - } - - /// - /// 优化新闻分析结果 - /// - private StockSelectionResult OptimizeNewsBasedResult(StockSelectionResult result, NewsBasedSelectionRequest request) - { - // 业务逻辑:根据请求的最大推荐数量限制结果 - if (result.Recommendations.Count > request.MaxRecommendations) - { - result.Recommendations = result.Recommendations - .Take(request.MaxRecommendations) - .ToList(); - } - - // 业务逻辑:添加新闻相关性标识 - foreach (var recommendation in result.Recommendations) - { - recommendation.Reason = $"[新闻热点] {recommendation.Reason}"; - } - - return result; - } - - /// - /// 将策略转换为用户请求 - /// - private StockRecommendationRequest ConvertStrategyToUserRequest(QuickSelectionStrategy strategy) - { - var (requirements, riskPreference) = strategy switch - { - QuickSelectionStrategy.ValueStocks => - ("请筛选价值股:PE低于20,PB低于3,ROE大于10%,负债率低于60%的优质价值股", "conservative"), - QuickSelectionStrategy.GrowthStocks => - ("请筛选成长股:营收增长率大于20%,净利润增长率大于15%,PEG小于1.5的高成长股", "aggressive"), - QuickSelectionStrategy.ActiveStocks => - ("请筛选活跃股:换手率大于2%,成交额大于5亿,量比大于1.5的活跃股票", "moderate"), - QuickSelectionStrategy.LargeCap => - ("请筛选大盘股:市值大于500亿,流动性好,业绩稳定的大盘蓝筹股", "conservative"), - QuickSelectionStrategy.SmallCap => - ("请筛选小盘股:市值在50-200亿之间,具有成长潜力的优质小盘股", "aggressive"), - QuickSelectionStrategy.Dividend => - ("请筛选高股息股:股息率大于3%,连续分红3年以上,现金流稳定的高股息股票", "conservative"), - _ => throw new ArgumentException($"不支持的选股策略: {strategy}") - }; - - return new StockRecommendationRequest - { - UserRequirements = requirements, - RiskPreference = riskPreference - }; - } - - /// - /// 规范化风险偏好 - /// - private string NormalizeRiskPreference(string riskPreference) - { - return riskPreference?.ToLower() switch - { - "conservative" or "保守" or "低风险" => "conservative", - "aggressive" or "激进" or "高风险" => "aggressive", - "moderate" or "稳健" or "中等风险" or "中风险" => "moderate", - _ => "moderate" - }; - } - - #endregion - - #region 资源管理 - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!_disposed && disposing) - { - _selectionWorkflow?.Dispose(); - _disposed = true; - } - } - - #endregion -} - diff --git a/src/Applications/Stocks/HomeStockService.cs b/src/Applications/Stocks/HomeStockService.cs deleted file mode 100644 index 03004f8..0000000 --- a/src/Applications/Stocks/HomeStockService.cs +++ /dev/null @@ -1,157 +0,0 @@ -using MarketAssistant.Applications.Stocks.Models; -using MarketAssistant.Services.Dialog; -using Microsoft.Extensions.Logging; - -namespace MarketAssistant.Applications.Stocks; - -/// -/// 主页股票相关服务实现 -/// -public class HomeStockService : IHomeStockService -{ - private readonly StockService _stockService; - private readonly StockSearchHistory _searchHistory; - private readonly StockFavoriteService _favoriteService; - private readonly IDialogService _dialogService; - private readonly ILogger _logger; - - public HomeStockService( - StockService stockService, - StockSearchHistory searchHistory, - StockFavoriteService favoriteService, - IDialogService dialogService, - ILogger logger) - { - _stockService = stockService; - _searchHistory = searchHistory; - _favoriteService = favoriteService; - _dialogService = dialogService; - _logger = logger; - } - - /// - /// 搜索股票 - /// - public async Task> SearchStockAsync(string query, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(query)) - return new List(); - - try - { - var results = await _stockService.SearchStockAsync(query, cancellationToken); - return results.Select(stock => new StockItem { Name = stock.Name, Code = stock.Code }).ToList(); - } - catch (Exception ex) - { - _logger?.LogError(ex, "搜索股票时出错,查询:{Query}", query); - return new List(); - } - } - - /// - /// 获取热门股票 - /// - public async Task> GetHotStocksAsync() - { - try - { - return await _stockService.GetHotStocksAsync(); - } - catch (Exception ex) - { - _logger?.LogError(ex, "获取热门股票时出错"); - return new List(); - } - } - - /// - /// 获取最近查看的股票 - /// - public List GetRecentStocks() - { - try - { - return _searchHistory.GetSearchHistory(); - } - catch (Exception ex) - { - _logger?.LogError(ex, "获取最近查看股票时出错"); - return new List(); - } - } - - /// - /// 添加到最近查看 - /// - public void AddToRecentStocks(StockItem stock) - { - try - { - _searchHistory.AddSearchHistory(stock); - } - catch (Exception ex) - { - _logger?.LogError(ex, "添加到最近查看时出错,股票:{StockName}", stock?.Name); - } - } - - /// - /// 添加到收藏 - /// - public async Task AddToFavoriteAsync(object stockParameter) - { - try - { - string stockName = ""; - string code = ""; - string market = ""; - - if (stockParameter is HotStock hotStock) - { - stockName = hotStock.Name; - code = hotStock.Code; - market = hotStock.Market; - } - else if (stockParameter is StockItem stockItem) - { - stockName = stockItem.Name; - code = stockItem.Code; - - // 尝试从股票代码中提取市场代码 - if (code.StartsWith("sh") || code.StartsWith("sz")) - { - market = code.Substring(0, 2).ToUpper(); - code = code.Substring(2); - } - } - else - { - return false; - } - - // 显示确认对话框 - bool confirmed = await _dialogService.ShowConfirmationAsync( - "添加收藏", - $"确定要将 {stockName} 添加到收藏列表吗?", - "确认", - "取消"); - - if (confirmed) - { - _favoriteService.AddFavorite(code, market); - await _dialogService.ShowMessageAsync("收藏成功", $"已将 {stockName} 添加到收藏列表"); - return true; - } - - return false; - } - catch (Exception ex) - { - _logger?.LogError(ex, "添加收藏时出错"); - await _dialogService.ShowMessageAsync("收藏失败", "添加收藏时发生错误,请稍后重试"); - return false; - } - } -} - diff --git a/src/Applications/Stocks/IHomeStockService.cs b/src/Applications/Stocks/IHomeStockService.cs deleted file mode 100644 index 4f8ed83..0000000 --- a/src/Applications/Stocks/IHomeStockService.cs +++ /dev/null @@ -1,35 +0,0 @@ -using MarketAssistant.Applications.Stocks.Models; - -namespace MarketAssistant.Applications.Stocks; - -/// -/// 主页股票相关服务接口 -/// -public interface IHomeStockService -{ - /// - /// 搜索股票 - /// - Task> SearchStockAsync(string query, CancellationToken cancellationToken = default); - - /// - /// 获取热门股票 - /// - Task> GetHotStocksAsync(); - - /// - /// 获取最近查看的股票 - /// - List GetRecentStocks(); - - /// - /// 添加到最近查看 - /// - void AddToRecentStocks(StockItem stock); - - /// - /// 添加到收藏 - /// - Task AddToFavoriteAsync(object stockParameter); -} - diff --git a/src/Applications/Stocks/Models/FavoriteStock.cs b/src/Applications/Stocks/Models/FavoriteStock.cs deleted file mode 100644 index bfa0f0b..0000000 --- a/src/Applications/Stocks/Models/FavoriteStock.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace MarketAssistant.Applications.Stocks.Models; - -public class FavoriteStock -{ - /// - /// 股票代码 - /// - public string Code { get; set; } = string.Empty; - - /// - /// 市场代码 - /// - public string Market { get; set; } = string.Empty; - - /// - /// 获取完整股票代码(市场+代码) - /// - public string FullCode => $"{Market}{Code}".ToLower(); -} \ No newline at end of file diff --git a/src/Applications/Stocks/Models/HotStock.cs b/src/Applications/Stocks/Models/HotStock.cs deleted file mode 100644 index e2bd854..0000000 --- a/src/Applications/Stocks/Models/HotStock.cs +++ /dev/null @@ -1,49 +0,0 @@ -namespace MarketAssistant.Applications.Stocks.Models; - -public class HotStock -{ - /// - /// 股票名称 - /// - public string Name { get; set; } = string.Empty; - - /// - /// 涨跌幅 - /// - public string ChangePercentage { get; set; } = string.Empty; - - /// - /// 所属板块名称 - /// - public string SectorName { get; set; } = string.Empty; - - /// - /// 市场代码 - /// - public string Code { get; set; } = string.Empty; - - /// - /// 现价 - /// - public string CurrentPrice { get; set; } = string.Empty; - - /// - /// 市场缩写 - /// - public string Market { get; set; } = string.Empty; - - /// - /// 排名变化 - /// - public string RankChange { get; set; } = string.Empty; - - /// - /// 市场类型 - /// - public string MarketType { get; set; } = string.Empty; - - /// - /// 综合热度 - /// - public string HeatIndex { get; set; } = string.Empty; -} \ No newline at end of file diff --git a/src/Applications/Stocks/Models/StockInfo.cs b/src/Applications/Stocks/Models/StockInfo.cs deleted file mode 100644 index 0db4b28..0000000 --- a/src/Applications/Stocks/Models/StockInfo.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace MarketAssistant.Applications.Stocks.Models; - -/// -/// 股票基本信息类 -/// -public class StockInfo -{ - /// - /// 股票代码 - /// - public string Code { get; set; } = string.Empty; - - /// - /// 股票名称 - /// - public string Name { get; set; } = string.Empty; - - /// - /// 市场代码 - /// - public string Market { get; set; } = string.Empty; - - /// - /// 当前价格 - /// - public string CurrentPrice { get; set; } = string.Empty; - - /// - /// 涨跌幅 - /// - public string ChangePercentage { get; set; } = string.Empty; - - /// - /// 所属板块名称 - /// - public string SectorName { get; set; } = string.Empty; - - /// - /// 获取完整股票代码(市场+代码) - /// - public string FullCode => $"{Market}{Code}".ToLower(); -} \ No newline at end of file diff --git a/src/Applications/Stocks/Models/StockItem.cs b/src/Applications/Stocks/Models/StockItem.cs deleted file mode 100644 index 5fa9fef..0000000 --- a/src/Applications/Stocks/Models/StockItem.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace MarketAssistant.Applications.Stocks.Models; - -public class StockItem -{ - public string Name { get; set; } = ""; - public string Code { get; set; } = ""; - - public override string ToString() - { - return Name; - } -} diff --git a/src/Applications/Stocks/StockFavoriteService.cs b/src/Applications/Stocks/StockFavoriteService.cs deleted file mode 100644 index 40325af..0000000 --- a/src/Applications/Stocks/StockFavoriteService.cs +++ /dev/null @@ -1,185 +0,0 @@ -using CommunityToolkit.Mvvm.Messaging; -using MarketAssistant.Infrastructure.Configuration; -using MarketAssistant.Applications.Stocks.Models; -using MarketAssistant.Infrastructure.Configuration; -using MarketAssistant.Infrastructure; -using MarketAssistant.Infrastructure.Configuration; -using Microsoft.Extensions.Logging; -using MarketAssistant.Infrastructure.Configuration; -using System.Text.Json; -using MarketAssistant.Infrastructure.Configuration; - -namespace MarketAssistant.Applications.Stocks; - -public record StockFavoritesChanged(); - -public class StockFavoriteService -{ - private const string PreferenceKey = "FavoriteStocks"; - private readonly StockService _stockService; - private readonly ILogger _logger; - - public StockFavoriteService(StockService stockService, ILogger logger) - { - _stockService = stockService; - _logger = logger; - } - - /// - /// 添加股票到收藏 - /// - /// 股票代码 - /// 市场代码 - /// 股票名称 - public void AddFavorite(string code, string market) - { - if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(market)) - return; - - var favoriteList = GetFavoritesCodes(); - - // 检查是否已经收藏过 - var existingItem = favoriteList.FirstOrDefault(x => x.Code == code && x.Market == market); - if (existingItem != null) - return; // 已经收藏过,不重复添加 - - // 添加到收藏列表 - favoriteList.Add(new FavoriteStock { Code = code, Market = market }); - - // 保存到本地存储 - SaveFavorites(favoriteList); - WeakReferenceMessenger.Default.Send(new StockFavoritesChanged()); - } - - /// - /// 从收藏中移除股票 - /// - /// 要移除的股票 - public void RemoveFavorite(string code, string market) - { - var favoriteList = GetFavoritesCodes(); - - // 查找并移除匹配的股票 - var itemToRemove = favoriteList.FirstOrDefault(x => x.Code == code && x.Market == market); - if (itemToRemove != null) - { - favoriteList.Remove(itemToRemove); - SaveFavorites(favoriteList); - WeakReferenceMessenger.Default.Send(new StockFavoritesChanged()); - } - } - - /// - /// 检查股票是否已收藏 - /// - /// 股票代码 - /// 市场代码 - /// 是否已收藏 - public bool IsFavorite(string code, string market) - { - var favoriteList = GetFavoritesCodes(); - return favoriteList.Any(x => x.Code == code && x.Market == market); - } - - /// - /// 获取所有收藏的股票代码 - /// - /// 收藏的股票代码列表 - public List GetFavoritesCodes() - { - try - { - var json = Preferences.Default.Get(PreferenceKey, string.Empty); - if (string.IsNullOrWhiteSpace(json)) - return new List(); - - var favoriteList = JsonSerializer.Deserialize>(json); - return favoriteList ?? new List(); - } - catch (Exception ex) - { - _logger.LogError(ex, $"获取收藏股票时出错: {ex.Message}"); - return new List(); - } - } - - /// - /// 获取所有收藏的股票(包含最新数据) - /// - /// 收藏的股票列表(带最新数据) - public async Task> GetFavoritesWithLatestDataAsync(CancellationToken cancellationToken = default) - { - var favoritesCodes = GetFavoritesCodes(); - if (favoritesCodes.Count == 0) - return new(); - - // 创建更新后的收藏股票列表 - var stockInfos = new List(); - - try - { - // 创建任务列表,为每个收藏的股票并行获取最新数据 - var tasks = new List>(); - - // 为每个收藏的股票创建一个任务 - foreach (var favorite in favoritesCodes) - { - var task = Task.Run(async () => - { - try - { - // 使用GetStockInfoAsync获取最新股票数据 - return await _stockService.GetStockInfoAsync(favorite.Code, favorite.Market, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, $"获取股票 {favorite.Code} 最新数据时出错: {ex.Message}"); - // 发生错误时返回基本信息 - return new StockInfo { Code = favorite.Code, Market = favorite.Market, Name = $"{favorite.Market}.{favorite.Code}" }; - } - }); - - tasks.Add(task); - } - - // 等待所有任务完成 - var results = await Task.WhenAll(tasks); - - // 将结果添加到列表中 - stockInfos.AddRange(results.Where(r => r != null)); - - return stockInfos; - } - catch (Exception ex) - { - _logger.LogError(ex, $"获取收藏股票最新数据时出错: {ex.Message}"); - return stockInfos; // 返回原始数据,不带最新价格和涨跌幅 - } - } - - /// - /// 清空所有收藏 - /// - public void ClearFavorites() - { - SaveFavorites(new List()); - WeakReferenceMessenger.Default.Send(new StockFavoritesChanged()); - } - - /// - /// 保存收藏列表到本地存储 - /// - /// 要保存的收藏列表 - private void SaveFavorites(List favoriteList) - { - try - { - var json = JsonSerializer.Serialize(favoriteList); - Preferences.Default.Set(PreferenceKey, json); - } - catch (Exception ex) - { - _logger.LogError(ex, $"保存收藏股票时出错: {ex.Message}"); - } - } -} diff --git a/src/Applications/Stocks/StockInfoCache.cs b/src/Applications/Stocks/StockInfoCache.cs deleted file mode 100644 index 0988408..0000000 --- a/src/Applications/Stocks/StockInfoCache.cs +++ /dev/null @@ -1,96 +0,0 @@ -using MarketAssistant.Applications.Stocks.Models; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; - -namespace MarketAssistant.Applications.Stocks; - -/// -/// 股票信息缓存服务 -/// -public class StockInfoCache -{ - private readonly IMemoryCache _cache; - private readonly ILogger _logger; - private const int CacheExpirationMinutes = 5; // 缓存5分钟 - - public StockInfoCache(IMemoryCache cache, ILogger logger) - { - _cache = cache; - _logger = logger; - } - - /// - /// 获取缓存的股票信息 - /// - public StockInfo? Get(string code, string market) - { - var cacheKey = GetCacheKey(code, market); - if (_cache.TryGetValue(cacheKey, out StockInfo? stockInfo)) - { - _logger.LogDebug($"从缓存获取股票信息: {code} ({market})"); - return stockInfo; - } - return null; - } - - /// - /// 设置股票信息到缓存 - /// - public void Set(StockInfo stockInfo) - { - var cacheKey = GetCacheKey(stockInfo.Code, stockInfo.Market); - var cacheOptions = new MemoryCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(CacheExpirationMinutes), - Size = 1 // 用于缓存大小限制 - }; - - _cache.Set(cacheKey, stockInfo, cacheOptions); - _logger.LogDebug($"缓存股票信息: {stockInfo.Code} ({stockInfo.Market})"); - } - - /// - /// 批量设置股票信息到缓存 - /// - public void SetRange(IEnumerable stockInfos) - { - foreach (var stockInfo in stockInfos) - { - Set(stockInfo); - } - } - - /// - /// 清除指定股票的缓存 - /// - public void Remove(string code, string market) - { - var cacheKey = GetCacheKey(code, market); - _cache.Remove(cacheKey); - _logger.LogDebug($"清除股票缓存: {code} ({market})"); - } - - /// - /// 清除所有缓存 - /// - public void Clear() - { - // MemoryCache 不支持清除所有条目,只能通过重新创建 - // 这里只是记录日志 - _logger.LogInformation("清除股票缓存"); - } - - private static string GetCacheKey(string code, string market) - { - return $"StockInfo_{market}_{code}"; - } -} - - - - - - - diff --git a/src/Applications/Stocks/StockKLineService.cs b/src/Applications/Stocks/StockKLineService.cs deleted file mode 100644 index 521b565..0000000 --- a/src/Applications/Stocks/StockKLineService.cs +++ /dev/null @@ -1,497 +0,0 @@ -using MarketAssistant.Applications.Stocks.Models; -using MarketAssistant.Infrastructure; -using MarketAssistant.Infrastructure.Core; -using MarketAssistant.Services.Settings; -using Microsoft.Extensions.Logging; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace MarketAssistant.Applications.Stocks; - -public class StockKLineService -{ - private readonly HttpClient _httpClient; - private readonly IUserSettingService _userSettingService; - private readonly ILogger _logger; - private const string ZHITU_API_BASE_URL = "https://api.zhituapi.com/hs/history"; - - public StockKLineService(ILogger logger, IUserSettingService userSettingService) - { - _httpClient = new HttpClient(); - _userSettingService = userSettingService; - _logger = logger; - } - - #region 通用辅助方法 - - /// - /// 验证K线参数 - /// - /// 股票代码 - private void ValidateKLineParameters(string symbol) - { - if (string.IsNullOrEmpty(symbol)) - { - throw new ArgumentException("股票代码参数必须提供"); - } - } - - /// - /// 构建zhituapi请求URL - /// - /// 股票代码(如000001.SZ) - /// 分时级别(如d、w、m、y、1、5、15、30、60) - /// 除权方式(n、f、b、fr、br) - /// 开始时间 - /// 结束时间 - /// 完整的API URL - private string BuildZhiTuApiUrl(string symbol, string interval, string adjustType = "n", DateTime? startDate = null, DateTime? endDate = null) - { - var token = _userSettingService.CurrentSetting.ZhiTuApiToken; - var url = $"{ZHITU_API_BASE_URL}/{symbol}/{interval}/{adjustType}?token={token}"; - - // 如果没有指定时间范围,根据不同的interval设置合理的默认时间范围 - DateTime defaultStartDate; - DateTime defaultEndDate = DateTime.Now; - - if (!startDate.HasValue && !endDate.HasValue) - { - switch (interval.ToLower()) - { - case "d": // 日K线,默认查询最近1年 - defaultStartDate = DateTime.Now.AddMonths(-6); - break; - case "w": // 周K线,默认查询最近2年 - defaultStartDate = DateTime.Now.AddYears(-1); - break; - case "m": // 月K线,默认查询最近5年 - defaultStartDate = DateTime.Now.AddYears(-3); - break; - case "y": // 年K线,默认查询最近10年 - defaultStartDate = DateTime.Now.AddYears(-10); - break; - case "1": - case "5": - case "15": - case "30": - case "60": // 分钟级别数据,默认查询最近30天 - defaultStartDate = DateTime.Now.AddDays(-30); - break; - default: // 其他情况,默认查询最近1年 - defaultStartDate = DateTime.Now.AddYears(-1); - break; - } - - startDate = defaultStartDate; - endDate = defaultEndDate; - } - - if (startDate.HasValue) - { - url += $"&st={startDate.Value:yyyyMMdd}"; - } - - if (endDate.HasValue) - { - url += $"&et={endDate.Value:yyyyMMdd}"; - } - - return url; - } - - /// - /// 从zhituapi获取数据 - /// - /// API URL - /// 数据类型描述 - /// 股票代码 - /// API响应数据 - private async Task> FetchZhiTuDataAsync(string url, string dataType, string symbol) - { - _logger.LogInformation("正在获取股票{DataType}数据: 股票代码: {Symbol}", dataType, symbol); - - try - { - var response = await _httpClient.GetAsync(url); - response.EnsureSuccessStatusCode(); - - var jsonContent = await response.Content.ReadAsStringAsync(); - var zhiTuData = JsonSerializer.Deserialize>(jsonContent); - - if (zhiTuData == null || !zhiTuData.Any()) - { - throw new FriendlyException($"获取{dataType}数据失败: 返回数据为空"); - } - - return zhiTuData; - } - catch (HttpRequestException ex) - { - throw new FriendlyException($"获取{dataType}数据失败: 网络请求错误 - {ex.Message}", ex); - } - catch (JsonException ex) - { - throw new FriendlyException($"获取{dataType}数据失败: 数据解析错误 - {ex.Message}", ex); - } - } - - /// - /// 解析zhituapi K线数据 - /// - /// zhituapi响应数据 - /// K线数据集 - private void ParseZhiTuKLineData(List zhiTuData, StockKLineDataSet klineDataSet) - { - foreach (var item in zhiTuData) - { - // 解析时间戳 - if (DateTime.TryParse(item.T, out DateTime timestamp)) - { - var klineData = new StockKLineData - { - Timestamp = timestamp, - Open = item.O, - High = item.H, - Low = item.L, - Close = item.C, - Volume = item.V, - Amount = item.A, - PreClose = item.Pc - }; - - // 计算涨跌额和涨跌幅 - if (item.Pc > 0) - { - klineData.Change = item.C - item.Pc; - klineData.PctChg = (klineData.Change / item.Pc) * 100; - } - else - { - klineData.Change = 0; - klineData.PctChg = 0; - } - - klineDataSet.Data.Add(klineData); - } - } - - // 按日期时间排序(从旧到新) - klineDataSet.Data = klineDataSet.Data.OrderBy(x => x.Timestamp).ToList(); - } - - /// - /// 记录错误并抛出异常 - /// - /// 数据类型 - /// 股票代码 - /// 异常 - private void LogAndThrowException(string dataType, string symbol, Exception ex) - { - // 构建错误日志信息 - string errorInfo = !string.IsNullOrEmpty(symbol) ? $"股票代码: {symbol}" : ""; - - _logger.LogError(ex, "获取{DataType}数据时发生错误 - {ErrorInfo}", dataType, errorInfo); - throw new FriendlyException($"获取{dataType}数据失败: {ex.Message}", ex); - } - - #endregion - - /// - /// 从zhituapi获取日K线数据 - /// - /// 股票代码(如000001.SZ) - /// 开始日期,默认为null - /// 结束日期,默认为null - /// 除权方式(n=不复权,f=前复权,b=后复权,fr=等比前复权,br=等比后复权),默认为n - /// K线数据集合 - public async Task GetDailyKLineDataAsync(string symbol, DateTime? startDate = null, DateTime? endDate = null, string adjustType = "n") - { - try - { - // 验证参数 - ValidateKLineParameters(symbol); - - // 处理股票代码格式 - string formattedSymbol = StockSymbolConverter.ToZhiTuFormat(symbol); - - // 构建API URL - string apiUrl = BuildZhiTuApiUrl(formattedSymbol, "d", adjustType, startDate, endDate); - - // 发送请求并获取数据 - var zhiTuData = await FetchZhiTuDataAsync(apiUrl, "日K线", symbol); - - // 转换为应用程序数据模型 - var klineDataSet = new StockKLineDataSet - { - Symbol = symbol, - Interval = "daily", - Data = new List() - }; - - // 解析数据 - ParseZhiTuKLineData(zhiTuData, klineDataSet); - - return klineDataSet; - } - catch (Exception ex) - { - LogAndThrowException("日K线", symbol, ex); - throw; // LogAndThrowException 已经抛出异常,这里不会执行到 - } - } - - /// - /// 从zhituapi获取周K线数据 - /// - /// 股票代码(如000001.SZ) - /// 开始日期,默认为null - /// 结束日期,默认为null - /// 除权方式(n=不复权,f=前复权,b=后复权,fr=等比前复权,br=等比后复权),默认为n - /// K线数据集合 - public async Task GetWeeklyKLineDataAsync(string symbol, DateTime? startDate = null, DateTime? endDate = null, string adjustType = "n") - { - try - { - // 验证参数 - ValidateKLineParameters(symbol); - - // 处理股票代码格式 - string formattedSymbol = StockSymbolConverter.ToZhiTuFormat(symbol); - - // 构建API URL - string apiUrl = BuildZhiTuApiUrl(formattedSymbol, "w", adjustType, startDate, endDate); - - // 发送请求并获取数据 - var zhiTuData = await FetchZhiTuDataAsync(apiUrl, "周K线", symbol); - - // 转换为应用程序数据模型 - var klineDataSet = new StockKLineDataSet - { - Symbol = symbol, - Interval = "weekly", - Data = new List() - }; - - // 解析数据 - ParseZhiTuKLineData(zhiTuData, klineDataSet); - - return klineDataSet; - } - catch (Exception ex) - { - LogAndThrowException("周K线", symbol, ex); - throw; // LogAndThrowException 已经抛出异常,这里不会执行到 - } - } - - /// - /// 从zhituapi获取月K线数据 - /// - /// 股票代码(如000001.SZ) - /// 开始日期,默认为null - /// 结束日期,默认为null - /// 除权方式(n=不复权,f=前复权,b=后复权,fr=等比前复权,br=等比后复权),默认为n - /// K线数据集合 - public async Task GetMonthlyKLineDataAsync(string symbol, DateTime? startDate = null, DateTime? endDate = null, string adjustType = "n") - { - try - { - // 验证参数 - ValidateKLineParameters(symbol); - - // 处理股票代码格式 - string formattedSymbol = StockSymbolConverter.ToZhiTuFormat(symbol); - - // 构建API URL - string apiUrl = BuildZhiTuApiUrl(formattedSymbol, "m", adjustType, startDate, endDate); - - // 发送请求并获取数据 - var zhiTuData = await FetchZhiTuDataAsync(apiUrl, "月K线", symbol); - - // 转换为应用程序数据模型 - var klineDataSet = new StockKLineDataSet - { - Symbol = symbol, - Interval = "monthly", - Data = new List() - }; - - // 解析数据 - ParseZhiTuKLineData(zhiTuData, klineDataSet); - - return klineDataSet; - } - catch (Exception ex) - { - LogAndThrowException("月K线", symbol, ex); - throw; // LogAndThrowException 已经抛出异常,这里不会执行到 - } - } - - /// - /// 从zhituapi获取分钟级K线数据 - /// - /// 股票代码(如000001.SZ) - /// 分钟频度(1、5、15、30、60) - /// 开始日期 - /// 结束日期 - /// 除权方式(n=不复权,f=前复权,b=后复权,fr=等比前复权,br=等比后复权),默认为n - /// K线数据集合 - public async Task GetMinuteKLineDataAsync(string symbol, string interval, DateTime? startDate = null, DateTime? endDate = null, string adjustType = "n") - { - try - { - // 验证频率参数 - if (!IsValidInterval(interval)) - { - throw new ArgumentException("无效的分钟频度参数,有效值为:1, 5, 15, 30, 60", nameof(interval)); - } - - // 验证参数 - ValidateKLineParameters(symbol); - - // 处理股票代码格式 - string formattedSymbol = StockSymbolConverter.ToZhiTuFormat(symbol); - - // 构建API URL - string apiUrl = BuildZhiTuApiUrl(formattedSymbol, interval, adjustType, startDate, endDate); - - // 发送请求并获取数据 - var zhiTuData = await FetchZhiTuDataAsync(apiUrl, $"{interval}分钟K线", symbol); - - // 转换为应用程序数据模型 - var klineDataSet = new StockKLineDataSet - { - Symbol = symbol, - Interval = $"{interval}min", - Data = new List() - }; - - // 解析数据 - ParseZhiTuKLineData(zhiTuData, klineDataSet); - - return klineDataSet; - } - catch (Exception ex) - { - LogAndThrowException($"{interval}分钟K线", symbol, ex); - throw; // LogAndThrowException 已经抛出异常,这里不会执行到 - } - } - - /// - /// 验证分钟频度参数是否有效 - /// - /// 分钟频度参数 - /// 是否有效 - private bool IsValidInterval(string interval) - { - string[] validIntervals = { "1", "5", "15", "30", "60" }; - return validIntervals.Contains(interval); - } - - /// - /// 从zhituapi获取年K线数据 - /// - /// 股票代码(如000001.SZ) - /// 开始日期,默认为null - /// 结束日期,默认为null - /// 除权方式(n=不复权,f=前复权,b=后复权,fr=等比前复权,br=等比后复权),默认为n - /// K线数据集合 - public async Task GetYearlyKLineDataAsync(string symbol, DateTime? startDate = null, DateTime? endDate = null, string adjustType = "n") - { - try - { - // 验证参数 - ValidateKLineParameters(symbol); - - // 处理股票代码格式 - string formattedSymbol = StockSymbolConverter.ToZhiTuFormat(symbol); - - // 构建API URL - string apiUrl = BuildZhiTuApiUrl(formattedSymbol, "y", adjustType, startDate, endDate); - - // 发送请求并获取数据 - var zhiTuData = await FetchZhiTuDataAsync(apiUrl, "年K线", symbol); - - // 转换为应用程序数据模型 - var klineDataSet = new StockKLineDataSet - { - Symbol = symbol, - Interval = "yearly", - Data = new List() - }; - - // 解析数据 - ParseZhiTuKLineData(zhiTuData, klineDataSet); - - return klineDataSet; - } - catch (Exception ex) - { - LogAndThrowException("年K线", symbol, ex); - throw; // LogAndThrowException 已经抛出异常,这里不会执行到 - } - } -} - -/// -/// ZhiTu API K线数据模型 -/// -[Serializable] -public class ZhiTuKLineData -{ - /// - /// 交易时间 - /// - [JsonPropertyName("t")] - public string T { get; set; } = string.Empty; - - /// - /// 开盘价 - /// - [JsonPropertyName("o")] - public decimal O { get; set; } - - /// - /// 最高价 - /// - [JsonPropertyName("h")] - public decimal H { get; set; } - - /// - /// 最低价 - /// - [JsonPropertyName("l")] - public decimal L { get; set; } - - /// - /// 收盘价 - /// - [JsonPropertyName("c")] - public decimal C { get; set; } - - /// - /// 成交量 - /// - [JsonPropertyName("v")] - public decimal V { get; set; } - - /// - /// 成交额 - /// - [JsonPropertyName("a")] - public decimal A { get; set; } - - /// - /// 前收盘价 - /// - [JsonPropertyName("pc")] - public decimal Pc { get; set; } - - /// - /// 停牌标志(1停牌,0不停牌) - /// - ///[JsonPropertyName("sf")] - ///public int Sf { get; set; } -} \ No newline at end of file diff --git a/src/Applications/Stocks/StockSearchHistory.cs b/src/Applications/Stocks/StockSearchHistory.cs deleted file mode 100644 index eda6f76..0000000 --- a/src/Applications/Stocks/StockSearchHistory.cs +++ /dev/null @@ -1,103 +0,0 @@ -using MarketAssistant.Applications.Stocks.Models; -using MarketAssistant.Infrastructure.Configuration; -using MarketAssistant.Infrastructure; -using MarketAssistant.Infrastructure.Configuration; -using Microsoft.Extensions.Logging; -using MarketAssistant.Infrastructure.Configuration; -using System.Text.Json; -using MarketAssistant.Infrastructure.Configuration; - -namespace MarketAssistant.Applications.Stocks; - -/// -/// 股票搜索历史记录管理类 -/// -public class StockSearchHistory -{ - private const string PreferenceKey = "RecentViewedStocks"; - private const int MaxHistoryCount = 10; // 最多保存10条历史记录 - private readonly ILogger _logger; - - public StockSearchHistory(ILogger logger) - { - _logger = logger; - } - - /// - /// 添加一条股票访问记录 - /// - /// 股票信息 - public void AddSearchHistory(StockItem stock) - { - if (stock == null || string.IsNullOrWhiteSpace(stock.Code)) - return; - - var historyList = GetSearchHistory(); - - // 如果已存在相同代码的记录,先移除旧记录 - var existingItem = historyList.FirstOrDefault(x => x.Code == stock.Code); - if (existingItem != null) - { - historyList.Remove(existingItem); - } - - // 添加到列表开头(最新的记录放在最前面) - historyList.Insert(0, stock); - - // 如果超过最大数量,移除最旧的记录 - if (historyList.Count > MaxHistoryCount) - { - historyList.RemoveAt(historyList.Count - 1); - } - - // 保存到本地存储 - SaveSearchHistory(historyList); - } - - /// - /// 获取搜索历史记录列表 - /// - /// 股票历史记录列表 - public List GetSearchHistory() - { - try - { - var json = Preferences.Default.Get(PreferenceKey, string.Empty); - if (string.IsNullOrWhiteSpace(json)) - return new List(); - - var historyList = JsonSerializer.Deserialize>(json); - return historyList ?? new List(); - } - catch (Exception ex) - { - _logger?.LogError(ex, "获取搜索历史记录时出错: {Message}", ex.Message); - return new List(); - } - } - - /// - /// 清空搜索历史记录 - /// - public void ClearSearchHistory() - { - SaveSearchHistory(new List()); - } - - /// - /// 保存搜索历史记录到本地存储 - /// - /// 要保存的历史记录列表 - private void SaveSearchHistory(List historyList) - { - try - { - var json = JsonSerializer.Serialize(historyList); - Preferences.Default.Set(PreferenceKey, json); - } - catch (Exception ex) - { - _logger?.LogError(ex, "保存搜索历史记录时出错: {Message}", ex.Message); - } - } -} diff --git a/src/Applications/Stocks/StockService.cs b/src/Applications/Stocks/StockService.cs deleted file mode 100644 index dfa8397..0000000 --- a/src/Applications/Stocks/StockService.cs +++ /dev/null @@ -1,292 +0,0 @@ -using MarketAssistant.Applications.Stocks.Models; -using MarketAssistant.Infrastructure; -using MarketAssistant.Services.Browser; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; -using System.Text.Json; - -namespace MarketAssistant.Applications.Stocks; - -public class StockService -{ - private readonly HttpClient _httpClient; - private readonly ILogger _logger; - private readonly PlaywrightService _playwrightService; - - public StockService(ILogger logger, PlaywrightService playwrightService) - { - _httpClient = new HttpClient(); - _logger = logger; - _playwrightService = playwrightService; - } - - public async Task> SearchStockAsync(string keyword, CancellationToken cancellationToken = default) - { - var url = $"https://www.cls.cn/searchPage?keyword={keyword.Trim()}&type=stock"; - - return await _playwrightService.ExecuteWithPageAsync(async page => - { - var stockList = new List<(string Name, string Code)>(); - - try - { - await page.GotoAsync(url, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); - await page.WaitForSelectorAsync(".search-stock-list", new PageWaitForSelectorOptions { State = WaitForSelectorState.Attached, Timeout = 15000 }); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "搜索页打开失败或超时,返回空结果"); - return stockList; - } - var stockElements = await page.QuerySelectorAllAsync(".search-stock-list"); - - foreach (var stockElement in stockElements) - { - var nameElement = await stockElement.QuerySelectorAsync("a.search-content"); - var codeElement = await stockElement.QuerySelectorAsync("a.search-content + a.search-content"); - - if (nameElement != null && codeElement != null) - { - var name = await nameElement.InnerTextAsync(); - var code = (await codeElement.InnerHTMLAsync()).Replace("", "").Replace("", "").Trim(); - stockList.Add((name, code)); - } - } - - return stockList; - }, cancellationToken: cancellationToken); - } - - public async Task GetStockInfoAsync(string stockCode, string market = "", CancellationToken cancellationToken = default) - { - // 创建股票信息对象 - var stockInfo = new StockInfo - { - Code = stockCode, - Name = "未知股票", - Market = market - }; - - // 尝试获取股票数据 - try - { - // 构建股票详情页URL - var fullCode = string.IsNullOrEmpty(market) ? stockCode : $"{market}{stockCode}".ToLower(); - var url = $"https://www.cls.cn/stock?code={fullCode}"; - - // 使用Playwright获取股票详情页(强制使用泛型重载,返回最新的 stockInfo) - stockInfo = await _playwrightService.ExecuteWithPageAsync(async page => - { - try - { - await page.GotoAsync(url, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); - await page.WaitForSelectorAsync(".stock-detail", new PageWaitForSelectorOptions { State = WaitForSelectorState.Attached, Timeout = 15000 }); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "股票详情页打开失败或超时,降级返回已有字段"); - return stockInfo; - } - - // 获取股票名称和代码 - 根据提供的HTML结构 - var stockDetailElement = await page.QuerySelectorAsync(".stock-detail"); - if (stockDetailElement != null) - { - // 获取股票名称 - var nameElement = await stockDetailElement.QuerySelectorAsync(".f-s-25.f-w-b"); - if (nameElement != null) - { - stockInfo.Name = (await nameElement.InnerTextAsync()).Trim(); - } - - // 获取股票代码和市场 - var codeElement = await stockDetailElement.QuerySelectorAsync(".f-s-20.f-w-b"); - if (codeElement != null) - { - var fullCodeText = (await codeElement.InnerTextAsync()).Trim(); - if (!string.IsNullOrEmpty(fullCodeText)) - { - // 从代码中提取市场信息(如sh601138中的sh表示上海市场) - if (fullCodeText.StartsWith("sh", StringComparison.OrdinalIgnoreCase)) - { - stockInfo.Market = "SH"; - stockInfo.Code = fullCodeText.Substring(2); - } - else if (fullCodeText.StartsWith("sz", StringComparison.OrdinalIgnoreCase)) - { - stockInfo.Market = "SZ"; - stockInfo.Code = fullCodeText.Substring(2); - } - else - { - stockInfo.Code = fullCodeText; - } - } - } - } - - // 获取价格和涨跌幅 - 根据提供的HTML结构 - var quoteChangeBox = await page.QuerySelectorAsync(".quote-change-box"); - if (quoteChangeBox != null) - { - // 获取当前价格 - var priceElement = await quoteChangeBox.QuerySelectorAsync(".quote-price"); - if (priceElement != null) - { - stockInfo.CurrentPrice = (await priceElement.InnerTextAsync()).Trim(); - } - - // 获取涨跌幅 - var changeElement = await quoteChangeBox.QuerySelectorAsync(".quote-change"); - if (changeElement != null) - { - var changeText = (await changeElement.InnerTextAsync()).Trim(); - // 提取涨跌幅百分比 - if (changeText.Contains("%")) - { - var startIndex = changeText.IndexOf("(") + 1; - var endIndex = changeText.IndexOf("%") + 1; - if (startIndex > 0 && endIndex > startIndex) - { - stockInfo.ChangePercentage = changeText.Substring(startIndex, endIndex - startIndex); - } - else - { - stockInfo.ChangePercentage = changeText; - } - } - else - { - stockInfo.ChangePercentage = changeText; - } - } - } - - // 获取所属板块 - 根据提供的HTML结构 - var stockRelatedBox = await page.QuerySelectorAsync(".stock-related-box"); - if (stockRelatedBox != null) - { - var stockPlage = await stockRelatedBox.QuerySelectorAsync(".stock-related-plate"); - - if (stockPlage != null) - { - var sectorElement = await stockPlage.QuerySelectorAsync(".m-r-10.f-s-20.c-222.f-w-b"); - if (sectorElement != null) - { - stockInfo.SectorName = (await sectorElement.InnerTextAsync()).Trim(); - } - } - // 获取所有板块元素 - //var stockRelatedPlates = await stockRelatedBox.QuerySelectorAllAsync(".stock-related-plate"); - } - - return stockInfo; - }, cancellationToken: cancellationToken); - } - catch (Exception ex) - { - _logger.LogWarning($"获取股票详细数据异常: {ex.Message}"); - } - - return stockInfo; - } - - public async Task> GetHotStocksAsync() - { - try - { - // 获取当前日期 - DateTime today = DateTime.Now; - - // 如果是周六或周日,调整为最近的周五 - if (today.DayOfWeek == DayOfWeek.Saturday) - { - today = today.AddDays(-1); // 周六减一天为周五 - } - else if (today.DayOfWeek == DayOfWeek.Sunday) - { - today = today.AddDays(-2); // 周日减两天为周五 - } - - // 格式化日期为yyyyMMdd - string formattedDate = today.ToString("yyyyMMdd"); - - // 百度股市通API地址,将固定日期替换为当前日期 - var url = $"https://finance.pae.baidu.com/vapi/v1/hotrank?product=stock&day={formattedDate}&pn=0&rn=8&market=ab&type=day&finClientType=pc"; - - var response = await _httpClient.GetStringAsync(url); - var jsonDocument = JsonDocument.Parse(response); - var root = jsonDocument.RootElement; - - // 检查API返回状态 - if (!root.TryGetProperty("Result", out var resultElement)) - { - _logger.LogError("GetHotStocksAsync: API返回数据格式不正确,缺少Result字段"); - return new List(); - } - - // 检查header和body数组 - if (!resultElement.TryGetProperty("header", out var headerElement) || - !resultElement.TryGetProperty("body", out var bodyElement)) - { - _logger.LogError("GetHotStocksAsync: API返回数据格式不正确,缺少header或body字段"); - return new List(); - } - - // 解析header数组,获取字段索引 - var headerIndices = new Dictionary(); - int index = 0; - foreach (var header in headerElement.EnumerateArray()) - { - headerIndices[header.GetString() ?? string.Empty] = index++; - } - - var hotStocks = new List(); - - // 遍历body数组中的每个股票数据 - foreach (var stockArray in bodyElement.EnumerateArray()) - { - // 确保数组长度与header长度一致 - if (stockArray.GetArrayLength() != headerIndices.Count) - { - _logger.LogError("GetHotStocksAsync: 股票数据数组长度与header不匹配"); - continue; - } - - var stockData = stockArray.EnumerateArray().ToArray(); - - var hotStock = new HotStock - { - Name = stockData[headerIndices["股票名称"]].GetString() ?? string.Empty, - ChangePercentage = stockData[headerIndices["涨跌幅"]].GetString() ?? string.Empty, - SectorName = stockData[headerIndices["所属板块名称"]].GetString() ?? string.Empty, - Code = stockData[headerIndices["市场代码"]].GetString() ?? string.Empty, - CurrentPrice = stockData[headerIndices["现价"]].GetString() ?? string.Empty, - Market = stockData[headerIndices["市场缩写"]].GetString() ?? string.Empty, - RankChange = stockData[headerIndices["排名变化"]].GetString() ?? string.Empty, - MarketType = stockData[headerIndices["市场"]].GetString() ?? string.Empty, - HeatIndex = stockData[headerIndices["综合热度"]].GetString() ?? string.Empty - }; - - hotStocks.Add(hotStock); - } - - return hotStocks; - } - catch (HttpRequestException ex) - { - _logger.LogError(ex, "GetHotStocksAsync HTTP请求异常: {Message}", ex.Message); - throw new FriendlyException($"获取热门股票失败: 网络请求错误 - {ex.Message}", ex); - } - catch (JsonException ex) - { - _logger.LogError(ex, "GetHotStocksAsync JSON解析异常: {Message}", ex.Message); - throw new FriendlyException($"获取热门股票失败: 数据解析错误 - {ex.Message}", ex); - } - catch (Exception ex) - { - _logger.LogError(ex, "GetHotStocksAsync未知异常: {Message}", ex.Message); - throw new FriendlyException($"获取热门股票失败: {ex.Message}", ex); - } - } -} diff --git a/src/Applications/Telegrams/TelegramService.cs b/src/Applications/Telegrams/TelegramService.cs deleted file mode 100644 index 3d0eb6b..0000000 --- a/src/Applications/Telegrams/TelegramService.cs +++ /dev/null @@ -1,165 +0,0 @@ -using Microsoft.Extensions.Logging; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace MarketAssistant.Applications.Telegrams; - -public class TelegramService -{ - private readonly ILogger _logger; - private readonly IHttpClientFactory _httpClientFactory; - - public TelegramService(ILogger logger, IHttpClientFactory httpClientFactory) - { - _logger = logger; - _httpClientFactory = httpClientFactory; - } - - /// - /// 通过同花顺快讯API获取实时新闻数据 - /// - /// 取消令牌 - /// 实时新闻列表 - public async Task> GetTelegraphsAsync(CancellationToken cancellationToken = default) - { - // var url = "https://news.10jqka.com.cn/realtimenews.html"; - var result = new List(); - try - { - var client = _httpClientFactory.CreateClient(); - using var request = new HttpRequestMessage(HttpMethod.Get, "https://news.10jqka.com.cn/tapp/news/push/stock/?page=1&track=website&pagesize=20"); - request.Headers.TryAddWithoutValidation("Accept", "application/json, text/plain, */*"); - request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"); - - using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("获取同花顺快讯API失败,状态码: {StatusCode}", (int)response.StatusCode); - return result; - } - - var json = await response.Content.ReadAsStringAsync(cancellationToken); - var api = JsonSerializer.Deserialize(json, JsonOptions); - if (api?.Data?.List == null) - { - _logger.LogWarning("快讯API解析为空或结构不匹配"); - return result; - } - - foreach (var item in api.Data.List) - { - try - { - var timeText = TryFormatUnixTime(item.Ctime); - var title = item.Title ?? string.Empty; - var content = !string.IsNullOrWhiteSpace(item.Short) ? item.Short! : (item.Digest ?? string.Empty); - var url = item.Url ?? item.AppUrl ?? item.ShareUrl ?? string.Empty; - var isImportant = ParseImportance(item.Import) || ParseColorImportant(item.Color); - var stocks = (item.Stock ?? new List()) - .Select(s => s.Name?.Trim()) - .Where(s => !string.IsNullOrWhiteSpace(s)) - .Select(s => s!) - .ToList(); - - result.Add(new Telegram - { - Time = timeText, - Title = title, - Content = content, - Url = url, - Stocks = stocks, - IsImportant = isImportant - }); - } - catch (Exception mapEx) - { - _logger.LogWarning(mapEx, "映射快讯项失败: {Message}", mapEx.Message); - } - } - - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, "GetTelegraphsAsync 调用API异常: {Message}", ex.Message); - return result; - } - } - - private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - NumberHandling = JsonNumberHandling.AllowReadingFromString - }; - - private static string TryFormatUnixTime(string? unixSeconds) - { - if (long.TryParse(unixSeconds, out var seconds)) - { - try - { - return DateTimeOffset.FromUnixTimeSeconds(seconds).ToLocalTime().ToString("HH:mm:ss"); - } - catch - { - } - } - return string.Empty; - } - - private static bool ParseImportance(string? import) - { - if (string.IsNullOrWhiteSpace(import)) return false; - return int.TryParse(import, out var val) && val > 0; - } - - private static bool ParseColorImportant(string? color) - { - // 某些返回用颜色标记重要性,保底处理 - return color == "2" || color == "3"; - } - - private class ThsNewsResponse - { - public string? Code { get; set; } - public string? Msg { get; set; } - public string? Time { get; set; } - public ThsNewsData? Data { get; set; } - } - - private class ThsNewsData - { - public List? List { get; set; } - } - - private class ThsNewsItem - { - public string? Id { get; set; } - public string? Seq { get; set; } - public string? Title { get; set; } - public string? Digest { get; set; } - public string? Url { get; set; } - public string? AppUrl { get; set; } - public string? ShareUrl { get; set; } - public string? Color { get; set; } - public string? Tag { get; set; } - public List? Tags { get; set; } - public string? Ctime { get; set; } - public string? Rtime { get; set; } - public string? Source { get; set; } - public string? PicUrl { get; set; } - public string? Nature { get; set; } - public List? Stock { get; set; } - public List? Field { get; set; } - public string? Short { get; set; } - public string? Import { get; set; } - public List? TagInfo { get; set; } - } - - private class ThsNewsStock - { - public string? Name { get; set; } - public string? StockCode { get; set; } - public string? StockMarket { get; set; } - } -} diff --git a/src/Applications/ToastMessage.cs b/src/Applications/ToastMessage.cs deleted file mode 100644 index 3b02258..0000000 --- a/src/Applications/ToastMessage.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace MarketAssistant.Applications; - -public record ToastMessage(string Content); diff --git a/src/Converts/NavigationIconConverter.cs b/src/Converts/NavigationIconConverter.cs deleted file mode 100644 index c175459..0000000 --- a/src/Converts/NavigationIconConverter.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Avalonia.Data.Converters; -using MarketAssistant.ViewModels; -using System.Globalization; - -namespace MarketAssistant.Converts -{ - /// - /// 导航图标转换器,根据选中状态返回对应的 SVG 路径 - /// - public class NavigationIconConverter : IMultiValueConverter - { - public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) - { - if (values.Count != 2 || - values[0] is not NavigationItemViewModel navigationItem || - values[1] is not bool isSelected) - { - return null; - } - - // 直接返回 SVG 路径,让 Svg 控件处理 - return isSelected ? navigationItem.SelectedIconPath : navigationItem.IconPath; - } - } -} diff --git a/src/Converts/PriceChangeColorConverter.cs b/src/Converts/PriceChangeColorConverter.cs deleted file mode 100644 index bd6d6e7..0000000 --- a/src/Converts/PriceChangeColorConverter.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Globalization; -using Avalonia.Data.Converters; -using Avalonia.Media; - -namespace MarketAssistant.Converts -{ - /// - /// 价格变化颜色转换器 - /// - public class PriceChangeColorConverter : IValueConverter - { - public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - // 处理decimal类型输入 - if (value is decimal priceChange) - { - // 价格上涨显示红色,下跌显示绿色 - if (priceChange > 0) - return new SolidColorBrush(Color.FromRgb(231, 76, 60)); // 红色 #e74c3c - else if (priceChange < 0) - return new SolidColorBrush(Color.FromRgb(46, 204, 113)); // 绿色 #2ecc71 - else - return new SolidColorBrush(Color.FromRgb(108, 117, 125)); // 灰色 #6c757d - } - - // 处理字符串类型输入(如:"+1.26%"或"-0.83%") - if (value is string percentageStr && !string.IsNullOrEmpty(percentageStr)) - { - // 移除百分号和其他非数字字符,保留正负号 - string numStr = percentageStr.Replace("%", "").Trim(); - if (decimal.TryParse(numStr, out decimal percentage)) - { - if (percentage > 0) - return new SolidColorBrush(Color.FromRgb(231, 76, 60)); // 红色 #e74c3c - else if (percentage < 0) - return new SolidColorBrush(Color.FromRgb(46, 204, 113)); // 绿色 #2ecc71 - else - return new SolidColorBrush(Color.FromRgb(108, 117, 125)); // 灰色 #6c757d - } - } - - return Brushes.Gray; - } - - public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} \ No newline at end of file diff --git a/src/Converts/RadioButtonEqualityConverter.cs b/src/Converts/RadioButtonEqualityConverter.cs deleted file mode 100644 index bf34090..0000000 --- a/src/Converts/RadioButtonEqualityConverter.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Avalonia.Data; -using Avalonia.Data.Converters; -using System.Globalization; - -namespace MarketAssistant.Converts -{ - /// - /// 双向转换器,用于RadioButton的IsChecked属性与字符串值的双向绑定 - /// - public class RadioButtonEqualityConverter : IValueConverter - { - /// - /// 将字符串值转换为布尔值,用于RadioButton的IsChecked属性 - /// - /// 源值(字符串) - /// 目标类型 - /// 参数(要比较的值) - /// 文化信息 - /// 如果源值等于参数,则返回true,否则返回false - public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value == null || parameter == null) - return false; - - return value.ToString() == parameter.ToString(); - } - - /// - /// 将布尔值转换回字符串值,用于更新源属性 - /// - /// 目标值(布尔值) - /// 目标类型 - /// 参数(要设置的值) - /// 文化信息 - /// 如果布尔值为true,则返回参数值,否则返回BindingOperations.DoNothing - public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value is bool isChecked) - { - if (isChecked && parameter != null) - { - return parameter.ToString(); - } - } - - return BindingOperations.DoNothing; - } - } -} \ No newline at end of file diff --git a/src/Infrastructure/Configuration/Preferences.cs b/src/Infrastructure/Configuration/Preferences.cs deleted file mode 100644 index b9e0978..0000000 --- a/src/Infrastructure/Configuration/Preferences.cs +++ /dev/null @@ -1,207 +0,0 @@ -using System.Collections.Concurrent; -using System.Text.Json; -using MarketAssistant.Applications.Settings; - -namespace MarketAssistant.Infrastructure.Configuration; - -/// -/// Avalonia平台的Preferences实现,提供与MAUI Preferences相同的API -/// -public static class Preferences -{ - private static readonly ConcurrentDictionary _preferences = new(); - private static readonly string _preferencesFilePath; - private static readonly object _lock = new(); - private static readonly JsonSerializerOptions _jsonOptions = new() - { - WriteIndented = true, - PropertyNameCaseInsensitive = true, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull - }; - - static Preferences() - { - // 使用用户配置目录存储preferences - var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - var appDataDir = Path.Combine(appDataPath, AppInfo.AppName); - - // 确保目录存在 - Directory.CreateDirectory(appDataDir); - - _preferencesFilePath = Path.Combine(appDataDir, AppInfo.PreferencesFileName); - LoadPreferences(); - } - - /// - /// 获取Preferences的默认实例 - /// - public static IPreferences Default { get; } = new PreferencesImpl(); - - /// - /// 从文件加载preferences - /// - private static void LoadPreferences() - { - try - { - if (File.Exists(_preferencesFilePath)) - { - var json = File.ReadAllText(_preferencesFilePath); - var dict = JsonSerializer.Deserialize>(json, _jsonOptions); - if (dict != null) - { - foreach (var kvp in dict) - { - _preferences[kvp.Key] = kvp.Value; - } - } - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"加载Preferences时出错: {ex.Message}"); - } - } - - /// - /// 保存preferences到文件 - /// - private static void SavePreferences() - { - try - { - lock (_lock) - { - var dict = _preferences.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - var json = JsonSerializer.Serialize(dict, _jsonOptions); - File.WriteAllText(_preferencesFilePath, json); - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"保存Preferences时出错: {ex.Message}"); - } - } - - /// - /// Preferences实现类 - /// - private class PreferencesImpl : IPreferences - { - public void Clear() - { - _preferences.Clear(); - SavePreferences(); - } - - public void Clear(string key) - { - _preferences.TryRemove(key, out _); - SavePreferences(); - } - - public bool ContainsKey(string key) - { - return _preferences.ContainsKey(key); - } - - public T Get(string key, T defaultValue) - { - if (_preferences.TryGetValue(key, out var value)) - { - try - { - // 直接类型匹配 - if (value is T directValue) - { - return directValue; - } - - // 处理JsonElement - if (value is JsonElement jsonElement) - { - return jsonElement.Deserialize(_jsonOptions) ?? defaultValue; - } - - // 处理字符串到枚举的转换 - if (typeof(T).IsEnum && value is string stringValue) - { - return (T)Enum.Parse(typeof(T), stringValue); - } - - // 处理基本类型转换 - if (value is IConvertible) - { - return (T)Convert.ChangeType(value, typeof(T)); - } - - // 尝试JSON反序列化 - if (value is string jsonString) - { - return JsonSerializer.Deserialize(jsonString, _jsonOptions) ?? defaultValue; - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Preferences类型转换失败: {ex.Message}"); - return defaultValue; - } - } - return defaultValue; - } - - public void Remove(string key) - { - _preferences.TryRemove(key, out _); - SavePreferences(); - } - - public void Set(string key, T value) - { - if (value == null) - { - Remove(key); - return; - } - - // 对于复杂类型,使用JSON序列化 - if (!IsSimpleType(typeof(T))) - { - var json = JsonSerializer.Serialize(value, _jsonOptions); - _preferences[key] = json; - } - else - { - _preferences[key] = value; - } - - SavePreferences(); - } - - private static bool IsSimpleType(Type type) - { - return type.IsPrimitive || - type.IsEnum || - type == typeof(string) || - type == typeof(decimal) || - type == typeof(DateTime) || - type == typeof(DateTimeOffset) || - type == typeof(TimeSpan) || - type == typeof(Guid); - } - } -} - -/// -/// Preferences接口,与MAUI Preferences接口保持一致 -/// -public interface IPreferences -{ - void Clear(); - void Clear(string key); - bool ContainsKey(string key); - T Get(string key, T defaultValue); - void Remove(string key); - void Set(string key, T value); -} - diff --git a/src/Infrastructure/Core/FileSystem.cs b/src/Infrastructure/Core/FileSystem.cs deleted file mode 100644 index 8a846f5..0000000 --- a/src/Infrastructure/Core/FileSystem.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System.IO; -using MarketAssistant.Applications.Settings; - -namespace MarketAssistant.Infrastructure.Core; - -/// -/// Avalonia平台的FileSystem实现,提供与MAUI FileSystem相同的API -/// -public static class FileSystem -{ - private static readonly string _appName = AppInfo.AppName; - - /// - /// 应用程序数据目录 - /// - public static string AppDataDirectory => GetAppDataDirectory(); - - /// - /// 缓存目录 - /// - public static string CacheDirectory => GetCacheDirectory(); - - /// - /// 应用程序包目录 - /// - public static string AppPackageDirectory => GetAppPackageDirectory(); - - private static string GetAppDataDirectory() - { - try - { - string appDataPath; - - if (OperatingSystem.IsWindows()) - { - appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - } - else if (OperatingSystem.IsMacOS()) - { - appDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Library", "Application Support"); - } - else // Linux和其他Unix系统 - { - appDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".config"); - } - - var appDataDir = Path.Combine(appDataPath, _appName); - - // 确保目录存在 - Directory.CreateDirectory(appDataDir); - return appDataDir; - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"获取AppDataDirectory时出错: {ex.Message}"); - - // 备用方案:使用当前目录 - var fallbackDir = Path.Combine(Directory.GetCurrentDirectory(), "AppData"); - Directory.CreateDirectory(fallbackDir); - return fallbackDir; - } - } - - private static string GetCacheDirectory() - { - try - { - string cachePath; - - if (OperatingSystem.IsWindows()) - { - cachePath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - } - else if (OperatingSystem.IsMacOS()) - { - cachePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Library", "Caches"); - } - else // Linux和其他Unix系统 - { - cachePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".cache"); - } - - var cacheDir = Path.Combine(cachePath, _appName, AppInfo.CacheDirectoryName); - - // 确保目录存在 - Directory.CreateDirectory(cacheDir); - return cacheDir; - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"获取CacheDirectory时出错: {ex.Message}"); - - // 备用方案:使用AppData目录下的Cache子目录 - var fallbackDir = Path.Combine(AppDataDirectory, AppInfo.CacheDirectoryName); - Directory.CreateDirectory(fallbackDir); - return fallbackDir; - } - } - - private static string GetAppPackageDirectory() - { - try - { - // 对于Avalonia应用,使用应用程序所在目录 - var baseDir = AppDomain.CurrentDomain.BaseDirectory; - - // 检查目录是否存在且可访问 - if (Directory.Exists(baseDir)) - { - return baseDir; - } - - // 备用方案:使用当前工作目录 - return Directory.GetCurrentDirectory(); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"获取AppPackageDirectory时出错: {ex.Message}"); - return Directory.GetCurrentDirectory(); - } - } -} - diff --git a/src/Infrastructure/Factories/AnalystAgentFactory.cs b/src/Infrastructure/Factories/AnalystAgentFactory.cs deleted file mode 100644 index 82505bf..0000000 --- a/src/Infrastructure/Factories/AnalystAgentFactory.cs +++ /dev/null @@ -1,85 +0,0 @@ -using MarketAssistant.Agents.Analysts; -using Microsoft.Agents.AI; -using Microsoft.Extensions.Logging; - -namespace MarketAssistant.Infrastructure.Factories; - -/// -/// 分析师代理工厂接口 -/// -public interface IAnalystAgentFactory -{ - /// - /// 根据类型创建对应的代理(动态调用,运行时检查) - /// - AIAgent CreateAnalyst(Type agentType); - - /// - /// 创建指定类型的分析师代理(泛型版本,提供编译时类型检查) - /// - /// 代理类型,必须继承自 AnalystAgentBase - TAgent CreateAnalyst() where TAgent : AnalystAgentBase; -} - -/// -/// 分析师代理工厂实现 -/// 负责创建配置好的分析师代理(使用 DI 容器) -/// -public class AnalystAgentFactory : IAnalystAgentFactory -{ - private readonly IServiceProvider _serviceProvider; - private readonly IChatClientFactory _chatClientFactory; - private readonly ILogger _logger; - - public AnalystAgentFactory( - IServiceProvider serviceProvider, - IChatClientFactory chatClientFactory, - ILogger logger) - { - _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - _chatClientFactory = chatClientFactory ?? throw new ArgumentNullException(nameof(chatClientFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - /// 根据类型创建对应的代理 - /// - public AIAgent CreateAnalyst(Type agentType) - { - try - { - // 严格限制必须是 AnalystAgentBase 的子类 - if (!typeof(AnalystAgentBase).IsAssignableFrom(agentType)) - { - throw new ArgumentException($"Type {agentType.Name} must inherit from AnalystAgentBase", nameof(agentType)); - } - - // 创建 ChatClient - var chatClient = _chatClientFactory.CreateClient(); - - // 使用 ActivatorUtilities.CreateInstance - // 显式传递 chatClient,其他依赖从 DI 获取 - var agent = (AIAgent)ActivatorUtilities.CreateInstance(_serviceProvider, agentType, chatClient); - - _logger.LogInformation( - "成功创建分析师代理: {AgentType}", - agentType.Name); - - return agent; - } - catch (Exception ex) - { - _logger.LogError(ex, "创建分析师代理时发生错误: {AgentType}", agentType.Name); - throw; - } - } - - /// - /// 创建指定类型的分析师代理(泛型版本) - /// - public TAgent CreateAnalyst() where TAgent : AnalystAgentBase - { - return (TAgent)CreateAnalyst(typeof(TAgent)); - } -} - diff --git a/src/Infrastructure/Factories/ChatClientFactory.cs b/src/Infrastructure/Factories/ChatClientFactory.cs deleted file mode 100644 index 86bd575..0000000 --- a/src/Infrastructure/Factories/ChatClientFactory.cs +++ /dev/null @@ -1,106 +0,0 @@ -using MarketAssistant.Services.Settings; -using Microsoft.Extensions.AI; -using OpenAI; -using System.ClientModel; - -namespace MarketAssistant.Infrastructure.Factories; - -/// -/// ChatClient 工厂接口 -/// 负责创建和管理底层的 IChatClient 实例 -/// -public interface IChatClientFactory -{ - /// - /// 创建配置好的 ChatClient 实例 - /// - IChatClient CreateClient(); -} - -/// -/// ChatClient 工厂实现 -/// 创建和缓存底层的 OpenAI ChatClient -/// -public class ChatClientFactory : IChatClientFactory -{ - private readonly IUserSettingService _userSettingService; - private readonly object _lock = new(); - private IChatClient? _cachedClient; - private string? _lastError; - - // 缓存用于创建客户端的配置,以便检测变更 - private string? _cachedModelId; - private string? _cachedEndpoint; - private string? _cachedApiKey; - - public ChatClientFactory(IUserSettingService userSettingService) - { - _userSettingService = userSettingService; - } - - public IChatClient CreateClient() - { - lock (_lock) - { - var userSetting = _userSettingService.CurrentSetting; - var modelId = userSetting.ModelId; - var apiKey = userSetting.ApiKey; - var endpoint = userSetting.Endpoint; - - // 检查配置是否变更,如果未变更且有缓存,则返回缓存 - if (_cachedClient != null && - _cachedModelId == modelId && - _cachedEndpoint == endpoint && - _cachedApiKey == apiKey) - { - return _cachedClient; - } - - // 如果配置变更,重置错误状态 - _lastError = null; - - // 如果之前创建失败且配置未变,返回缓存的错误 - if (!string.IsNullOrEmpty(_lastError) && - _cachedModelId == modelId && - _cachedEndpoint == endpoint && - _cachedApiKey == apiKey) - throw new FriendlyException(_lastError); - - try - { - if (string.IsNullOrWhiteSpace(modelId)) - throw new FriendlyException("AI 功能未配置:请先在设置页面选择 AI 模型"); - if (string.IsNullOrWhiteSpace(apiKey)) - throw new FriendlyException("AI 功能未配置:请先在设置页面配置 API Key"); - if (string.IsNullOrWhiteSpace(endpoint)) - throw new FriendlyException("AI 功能未配置:请先在设置页面配置 API 端点"); - - var openAIClient = new OpenAIClient( - new ApiKeyCredential(apiKey), - new OpenAIClientOptions - { - Endpoint = new Uri(endpoint) - } - ); - - _cachedClient = openAIClient.GetChatClient(modelId).AsIChatClient(); - - // 更新缓存的配置 - _cachedModelId = modelId; - _cachedEndpoint = endpoint; - _cachedApiKey = apiKey; - - return _cachedClient; - } - catch (Exception ex) - { - _lastError = ex.Message; - // 即使失败也记录当前配置,避免重复尝试相同配置 - _cachedModelId = modelId; - _cachedEndpoint = endpoint; - _cachedApiKey = apiKey; - throw new FriendlyException(_lastError); - } - } - } -} diff --git a/src/Infrastructure/NavigationParameters.cs b/src/Infrastructure/NavigationParameters.cs deleted file mode 100644 index f8520c5..0000000 --- a/src/Infrastructure/NavigationParameters.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace MarketAssistant.Infrastructure; - -/// -/// 股票导航参数 -/// -/// 股票代码 -/// 股票名称 -public record StockNavigationParameter(string StockCode, string StockName = ""); diff --git a/src/MarketAssistant.Agents/AGENTS.md b/src/MarketAssistant.Agents/AGENTS.md new file mode 100644 index 0000000..ae56dcd --- /dev/null +++ b/src/MarketAssistant.Agents/AGENTS.md @@ -0,0 +1,97 @@ +# MarketAssistant.Agents — AGENTS.md + +AI Agent 契约层,定义分析师基类、工具接口、分析模型、Token 管理和提示词配置。依赖 `MarketAssistant.Core`,使用 Microsoft Agent Framework (MAF)。 + +--- + +## 目录结构 + +``` +MarketAssistant.Agents/ +├── Analysts/ +│ ├── AnalystAgentBase.cs ← 分析师抽象基类(继承 DelegatingAIAgent) +│ └── Attributes/ +│ ├── RequiredAnalystAttribute.cs ← 标记必需分析师 +│ └── RequiresToolsAttribute.cs ← 声明分析师所需工具接口 +├── MarketAnalysis/ +│ └── Models/ ← 分析结果模型(各维度分析结果 + 综合报告) +│ ├── MarketAnalysisReport.cs ← 最终报告:符号、分析师消息、CoordinatorResult +│ ├── CoordinatorResult.cs ← 聚合结果:评分、评级、目标价、风险 +│ ├── TechnicalAnalysisResult.cs +│ ├── FundamentalAnalysisResult.cs +│ ├── FinancialAnalysisResult.cs +│ ├── MarketSentimentAnalysisResult.cs +│ ├── NewsEventAnalysisResult.cs +│ ├── AnalysisEnums.cs ← 通用分析枚举 +│ ├── ScoringStandards.cs ← 统一评分标准(1–10 分) +│ └── AnalysisQualityMetrics.cs ← 数据完整性与分析师共识度 +├── PromptConfiguration/ +│ ├── AnalystPromptConfig.cs ← 分析师提示词配置模型 +│ └── AnalystPromptLoader.cs ← 从 YAML 加载提示词(支持热重载) +├── 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 ← 加密货币情绪 + │ ├── ITechnicalDataTools.cs ← 技术分析数据 + │ ├── INewsDataTools.cs ← 新闻数据 + │ ├── IStrategyTools.cs ← 策略管理 + │ └── ITradingExecutionTools.cs ← 交易执行 + └── Models/ ← 工具返回值模型 + ├── AssetQuoteInfo.cs, MarketInterval.cs, NewsItem.cs + ├── AShare/ ← A 股数据模型 + ├── Crypto/ ← 加密货币数据模型(含 Binance/CoinGecko/CoinDesk 响应) + └── Technical/ ← 技术指标模型(KDJ/MACD/BOLL/MA) +``` + +--- + +## 核心约定 + +### 分析师扩展 + +1. 继承 `AnalystAgentBase`。 +2. 使用 `[RequiresTools(typeof(IXxxTools))]` 声明所需工具——`AnalystAgentFactory` 会自动按当前 `MarketType` 从 DI 解析。 +3. 可选使用 `[RequiredAnalyst]` 标记为必需分析师。 +4. 分析师的具体实现位于 `MarketAssistant.App.Services/Agents/Analysts/`,本项目只提供基类、属性和结果模型。 + +### 工具接口扩展 + +1. 接口定义在 `Tools/Abstractions/`,市场特定接口继承基类接口(如 `IShareBasicTools : IBasicDataTools`)。 +2. 每个工具接口需暴露 `GetFunctions()` 方法,返回 MAF 可调用的函数列表。 +3. 返回值模型放在 `Tools/Models/` 对应市场子目录。 +4. 具体实现位于 `MarketAssistant.App.Services/Agents/Tools/`,注册为 Keyed Service。 + +### 分析模型扩展 + +- 新增分析维度时,在 `MarketAnalysis/Models/` 添加结果类。 +- 结果类应包含结构化字段(非自由文本),便于聚合评分。 +- 评分遵循 `ScoringStandards` 定义的 1–10 标准。 + +### Token 管理 + +- `TokenEstimator` 用于估算消息 Token 数。 +- 会话压缩由 `MarketAssistant.App.Services` 中的 `ConversationCompressionMiddleware`(MAF 中间件)负责。 + +--- + +## 测试 + +- 本项目的类型多为抽象基类和模型,测试集中在 `tests/TestMarketAssistant.csproj`。 +- `TokenEstimator` 可独立单元测试;会话压缩中间件和分析师行为通过集成测试验证。 + +--- + +## 构建 + +```bash +dotnet build src/MarketAssistant.Agents/MarketAssistant.Agents.csproj -c Debug +``` diff --git a/src/Agents/Analysts/AnalystAgentBase.cs b/src/MarketAssistant.Agents/Analysts/AnalystAgentBase.cs similarity index 63% rename from src/Agents/Analysts/AnalystAgentBase.cs rename to src/MarketAssistant.Agents/Analysts/AnalystAgentBase.cs index ffee490..62aaafc 100644 --- a/src/Agents/Analysts/AnalystAgentBase.cs +++ b/src/MarketAssistant.Agents/Analysts/AnalystAgentBase.cs @@ -1,6 +1,6 @@ +using MarketAssistant.Agents.PromptConfiguration; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -using static Microsoft.Agents.AI.ChatClientAgentOptions; namespace MarketAssistant.Agents.Analysts; @@ -17,7 +17,7 @@ public abstract class AnalystAgentBase : DelegatingAIAgent ## 数据真实性与反幻觉原则 1. **严格依赖工具数据**:你的所有分析、评估、评分和决策必须严格基于通过工具调用获取的真实数据。 2. **严禁编造数据**:绝对禁止编造数值、捏造事实或臆造不存在的市场情况。如果不知道,就说不知道。 -3. **缺失数据处理**:如果工具未能提供所需数据,或者数据不完整,必须在分析结果中明确说明“缺少数据支持”或“数据不可用”,不得进行无依据的猜测或试图掩盖。 +3. **缺失数据处理**:如果工具未能提供所需数据,或者数据不完整,必须在分析结果中明确说明""缺少数据支持""或""数据不可用"",不得进行无依据的猜测或试图掩盖。 4. **拒绝幻觉**:对于未通过工具验证的信息,保持怀疑态度,不要将其作为分析依据。"; /// @@ -34,9 +34,27 @@ protected AnalystAgentBase( int? topK, ChatResponseFormat? responseFormat, IList? tools, - Func? aiContextProviderFactory = null) + AIContextProvider[]? aiContextProviders = null, + AgentSkillsProvider? skillsProvider = null) : base(CreateInnerAgent(chatClient, instructions + DataIntegrityInstructions, name, description, - temperature, topP, topK, responseFormat, tools, aiContextProviderFactory)) + temperature, topP, topK, responseFormat, tools, + skillsProvider != null ? [skillsProvider, .. (aiContextProviders ?? [])] : aiContextProviders)) + { + } + + /// + /// 初始化分析师代理基类(从 YAML 配置读取 name、description、temperature、topP、topK、instructions) + /// + protected AnalystAgentBase( + IChatClient chatClient, + AnalystPromptConfig config, + ChatResponseFormat? responseFormat, + IList? tools, + AIContextProvider[]? aiContextProviders = null, + AgentSkillsProvider? skillsProvider = null) + : this(chatClient, config.Instructions, config.Name, config.Description, + config.Temperature, config.TopP, config.TopK, + responseFormat, tools, aiContextProviders, skillsProvider) { } @@ -53,7 +71,7 @@ private static AIAgent CreateInnerAgent( int? topK, ChatResponseFormat? responseFormat, IList? tools, - Func? aiContextProviderFactory) + AIContextProvider[]? aiContextProviders) { var options = new ChatClientAgentOptions { @@ -68,14 +86,9 @@ private static AIAgent CreateInnerAgent( Tools = tools, ResponseFormat = responseFormat }, - AIContextProviderFactory = aiContextProviderFactory + AIContextProviders = aiContextProviders }; - return chatClient.CreateAIAgent(options); - } - - public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) - { - return base.RunAsync(messages, thread, options, cancellationToken); + return new ChatClientAgent(chatClient, options); } } diff --git a/src/Agents/Analysts/Attributes/RequiredAnalystAttribute.cs b/src/MarketAssistant.Agents/Analysts/Attributes/RequiredAnalystAttribute.cs similarity index 75% rename from src/Agents/Analysts/Attributes/RequiredAnalystAttribute.cs rename to src/MarketAssistant.Agents/Analysts/Attributes/RequiredAnalystAttribute.cs index 87f0b74..3aabdb8 100644 --- a/src/Agents/Analysts/Attributes/RequiredAnalystAttribute.cs +++ b/src/MarketAssistant.Agents/Analysts/Attributes/RequiredAnalystAttribute.cs @@ -1,11 +1,6 @@ -using System; - namespace MarketAssistant.Agents.Analysts.Attributes; [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] public sealed class RequiredAnalystAttribute : Attribute { - public RequiredAnalystAttribute() - { - } } diff --git a/src/MarketAssistant.Agents/Analysts/Attributes/RequiresToolsAttribute.cs b/src/MarketAssistant.Agents/Analysts/Attributes/RequiresToolsAttribute.cs new file mode 100644 index 0000000..8b1bd65 --- /dev/null +++ b/src/MarketAssistant.Agents/Analysts/Attributes/RequiresToolsAttribute.cs @@ -0,0 +1,19 @@ +namespace MarketAssistant.Agents.Analysts.Attributes; + +/// +/// 声明分析师所需的工具接口类型 +/// Factory 通过此 Attribute 自动解析并注入对应的市场特定工具实现 +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] +public sealed class RequiresToolsAttribute : Attribute +{ + /// + /// 所需的工具接口类型 + /// + public Type ToolInterfaceType { get; } + + public RequiresToolsAttribute(Type toolInterfaceType) + { + ToolInterfaceType = toolInterfaceType ?? throw new ArgumentNullException(nameof(toolInterfaceType)); + } +} diff --git a/src/Agents/MarketAnalysis/Models/AnalysisEnums.cs b/src/MarketAssistant.Agents/MarketAnalysis/Models/AnalysisEnums.cs similarity index 100% rename from src/Agents/MarketAnalysis/Models/AnalysisEnums.cs rename to src/MarketAssistant.Agents/MarketAnalysis/Models/AnalysisEnums.cs diff --git a/src/MarketAssistant.Agents/MarketAnalysis/Models/AnalysisQualityMetrics.cs b/src/MarketAssistant.Agents/MarketAnalysis/Models/AnalysisQualityMetrics.cs new file mode 100644 index 0000000..4fc2d09 --- /dev/null +++ b/src/MarketAssistant.Agents/MarketAnalysis/Models/AnalysisQualityMetrics.cs @@ -0,0 +1,61 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace MarketAssistant.Agents.MarketAnalysis.Models; + +/// +/// 分析质量评估指标,用于量化本次分析的可靠程度 +/// +[Description("分析质量自评估指标,反映本次分析的数据充分性和结论可靠度")] +public sealed class AnalysisQualityMetrics +{ + /// + /// 数据完整度(0-100%):实际获取到数据的维度占所需维度的比例 + /// + [Range(0, 100)] + [Description("数据完整度百分比,衡量工具调用成功获取数据的维度占全部分析维度的比例。100 表示所有所需数据均已获取,0 表示完全无数据")] + public int DataCompletenessPercent { get; set; } + + /// + /// 缺失的数据维度列表 + /// + [Description("本次分析中未能获取到数据或数据不完整的维度列表,例如:'技术指标BOLL数据缺失'、'近3年财报数据不全'")] + public List MissingDataDimensions { get; set; } = []; + + /// + /// 分析师一致性评分(0-100%):各分析师结论的方向一致程度 + /// + [Range(0, 100)] + [Description("分析师一致性百分比,衡量各分析师结论方向的一致程度。100 表示完全一致,0 表示完全矛盾")] + public int AnalystConsensusPercent { get; set; } + + /// + /// 分析局限性说明 + /// + [MinLength(10)] + [MaxLength(200)] + [Description("分析的局限性说明,诚实说明可能影响结论可靠性的因素,例如数据延迟、市场剧烈波动期间分析偏差等")] + public string LimitationsNote { get; set; } = string.Empty; + + /// + /// 总体质量等级 + /// + [Description("基于数据完整度、分析师一致性和置信度的综合质量等级")] + public AnalysisQualityLevel OverallQualityLevel { get; set; } +} + +/// +/// 分析质量等级 +/// +[Description("分析质量等级枚举")] +public enum AnalysisQualityLevel +{ + [Description("高质量:数据充分、分析师一致性高、置信度强")] + High, + + [Description("中等质量:部分数据缺失或分析师存在一定分歧")] + Medium, + + [Description("低质量:大量数据缺失或分析师严重分歧,结论仅供参考")] + Low +} diff --git a/src/Agents/MarketAnalysis/Models/CoordinatorResult.cs b/src/MarketAssistant.Agents/MarketAnalysis/Models/CoordinatorResult.cs similarity index 96% rename from src/Agents/MarketAnalysis/Models/CoordinatorResult.cs rename to src/MarketAssistant.Agents/MarketAnalysis/Models/CoordinatorResult.cs index 6e13f48..c06fbe7 100644 --- a/src/Agents/MarketAnalysis/Models/CoordinatorResult.cs +++ b/src/MarketAssistant.Agents/MarketAnalysis/Models/CoordinatorResult.cs @@ -126,6 +126,12 @@ public sealed class CoordinatorResult [MaxLength(10)] [Description("各专业分析师的自然语言分析中提取的最关键指标和数据点,数据具体、判断清晰、建议可行")] public List KeyIndicators { get; set; } = new(); + + /// + /// 分析质量自评估指标 + /// + [Description("分析质量自评估,包含数据完整度、分析师一致性和局限性说明")] + public AnalysisQualityMetrics QualityMetrics { get; set; } = new(); } /// diff --git a/src/Agents/MarketAnalysis/Models/FinancialAnalysisResult.cs b/src/MarketAssistant.Agents/MarketAnalysis/Models/FinancialAnalysisResult.cs similarity index 100% rename from src/Agents/MarketAnalysis/Models/FinancialAnalysisResult.cs rename to src/MarketAssistant.Agents/MarketAnalysis/Models/FinancialAnalysisResult.cs diff --git a/src/Agents/MarketAnalysis/Models/FundamentalAnalysisResult.cs b/src/MarketAssistant.Agents/MarketAnalysis/Models/FundamentalAnalysisResult.cs similarity index 100% rename from src/Agents/MarketAnalysis/Models/FundamentalAnalysisResult.cs rename to src/MarketAssistant.Agents/MarketAnalysis/Models/FundamentalAnalysisResult.cs diff --git a/src/Agents/MarketAnalysis/Models/MarketAnalysisReport.cs b/src/MarketAssistant.Agents/MarketAnalysis/Models/MarketAnalysisReport.cs similarity index 81% rename from src/Agents/MarketAnalysis/Models/MarketAnalysisReport.cs rename to src/MarketAssistant.Agents/MarketAnalysis/Models/MarketAnalysisReport.cs index a97a237..fc5e7b7 100644 --- a/src/Agents/MarketAnalysis/Models/MarketAnalysisReport.cs +++ b/src/MarketAssistant.Agents/MarketAnalysis/Models/MarketAnalysisReport.cs @@ -7,16 +7,22 @@ namespace MarketAssistant.Agents.MarketAnalysis.Models; /// public sealed class MarketAnalysisReport { + private readonly string _assetSymbol = string.Empty; + /// - /// 股票代码 + /// 分析标的代码 /// - public string StockSymbol { get; init; } = string.Empty; - + public string AssetSymbol + { + get => _assetSymbol; + init => _assetSymbol = value; + } + /// /// 各专业分析师的消息 /// public List AnalystMessages { get; init; } = new(); - + /// /// Coordinator 的综合分析结果(结构化数据) /// 这是经过 AI 智能聚合、冲突解决、搜索验证后的最终投资建议 diff --git a/src/Agents/MarketAnalysis/Models/MarketSentimentAnalysisResult.cs b/src/MarketAssistant.Agents/MarketAnalysis/Models/MarketSentimentAnalysisResult.cs similarity index 100% rename from src/Agents/MarketAnalysis/Models/MarketSentimentAnalysisResult.cs rename to src/MarketAssistant.Agents/MarketAnalysis/Models/MarketSentimentAnalysisResult.cs diff --git a/src/Agents/MarketAnalysis/Models/NewsEventAnalysisResult.cs b/src/MarketAssistant.Agents/MarketAnalysis/Models/NewsEventAnalysisResult.cs similarity index 100% rename from src/Agents/MarketAnalysis/Models/NewsEventAnalysisResult.cs rename to src/MarketAssistant.Agents/MarketAnalysis/Models/NewsEventAnalysisResult.cs diff --git a/src/Agents/MarketAnalysis/Models/ScoringStandards.cs b/src/MarketAssistant.Agents/MarketAnalysis/Models/ScoringStandards.cs similarity index 100% rename from src/Agents/MarketAnalysis/Models/ScoringStandards.cs rename to src/MarketAssistant.Agents/MarketAnalysis/Models/ScoringStandards.cs diff --git a/src/Agents/MarketAnalysis/Models/TechnicalAnalysisResult.cs b/src/MarketAssistant.Agents/MarketAnalysis/Models/TechnicalAnalysisResult.cs similarity index 100% rename from src/Agents/MarketAnalysis/Models/TechnicalAnalysisResult.cs rename to src/MarketAssistant.Agents/MarketAnalysis/Models/TechnicalAnalysisResult.cs diff --git a/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj b/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj new file mode 100644 index 0000000..039335e --- /dev/null +++ b/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + MarketAssistant + enable + $(NoWarn);MAAI001 + + + + + + + + + + + + + + + + diff --git a/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj.lscache b/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj.lscache new file mode 100644 index 0000000..bf6b4cb --- /dev/null +++ b/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj.lscache @@ -0,0 +1,353 @@ +version=1 + +# This file caches language service data to improve the performance of C# Dev Kit. +# It is not intended for manual editing. It can safely be deleted and will be +# regenerated automatically. For more information, see https://aka.ms/lscache +# +# To control where cache files are stored, use the following VS Code setting: +# "dotnet.projectsystem.cacheInProjectFolder": true + +[project] +language=C# +primary +lastDtbSucceeded + +[properties] +AssemblyName=MarketAssistant.Agents +CommandLineArgsForDesignTimeEvaluation=-langversion:14.0 -define:TRACE +CompilerGeneratedFilesOutputPath= +MaxSupportedLangVersion=14.0 +ProjectAssetsFile=obj/project.assets.json +RootNamespace=MarketAssistant +RunAnalyzers= +RunAnalyzersDuringLiveAnalysis= +SolutionPath=../../MarketAssistant.slnx +TargetFrameworkIdentifier=.NETCoreApp +TargetPath=bin/Debug/net10.0/MarketAssistant.Agents.dll +TargetRefPath=obj/Debug/net10.0/ref/MarketAssistant.Agents.dll +TemporaryDependencyNodeTargetIdentifier=net10.0 + +[commandLineArguments] +/noconfig +/unsafe- +/checked- +/nowarn:1701,1702,MAAI001,1701,1702,8002 +/fullpaths +/nostdlib+ +/errorreport:prompt +/warn:10 +/define:TRACE;DEBUG;NET;NET10_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NET10_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER +/highentropyva+ +/nullable:enable +/features:"InterceptorsNamespaces=;Microsoft.Extensions.Validation.Generated" +/debug+ +/debug:portable +/filealign:512 +/optimize- +/out:obj\Debug\net10.0\MarketAssistant.Agents.dll +/refout:obj\Debug\net10.0\refint\MarketAssistant.Agents.dll +/target:library +/warnaserror- +/utf8output +/deterministic+ +/langversion:14.0 +/warnaserror+:NU1605,SYSLIB0011 + +[sourceFiles] +Analysts/ + AnalystAgentBase.cs + Attributes/ + RequiredAnalystAttribute.cs + RequiresToolsAttribute.cs +MarketAnalysis/Models/ + AnalysisEnums.cs + AnalysisQualityMetrics.cs + CoordinatorResult.cs + FinancialAnalysisResult.cs + FundamentalAnalysisResult.cs + MarketAnalysisReport.cs + MarketSentimentAnalysisResult.cs + NewsEventAnalysisResult.cs + ScoringStandards.cs + TechnicalAnalysisResult.cs +Middleware/ + ConversationCompressionMiddleware.cs + TokenTrackingMiddleware.cs + TradingFunctionGuardMiddleware.cs +obj/Debug/net10.0/ + .NETCoreApp,Version=v10.0.AssemblyAttributes.cs + MarketAssistant.Agents.AssemblyInfo.cs + MarketAssistant.Agents.GlobalUsings.g.cs +PromptConfiguration/ + AnalystPromptConfig.cs + AnalystPromptLoader.cs +TokenManagement/ + ConversationCompressor.cs + TokenEstimator.cs +Tools/Abstractions/ + IBasicDataTools.cs + ICryptoBasicTools.cs + ICryptoMetricsTools.cs + ICryptoSentimentTools.cs + IFinancialTools.cs + INewsDataTools.cs + ISentimentTools.cs + IShareBasicTools.cs + IShareFinancialTools.cs + IShareSentimentTools.cs + IStrategyTools.cs + ITechnicalDataTools.cs + ITradingExecutionTools.cs +Tools/Models/AShare/ + BalanceSheet.cs + CapitalStructure.cs + CashFlowStatement.cs + CompanyInfo.cs + FinancialData.cs + FinancialRatios.cs + FundFlow.cs + FundFlowTrend.cs + IncomeStatement.cs + MarketCapInfo.cs + QuarterlyCashFlow.cs + QuarterlyProfit.cs + ShareholderCount.cs + StockQuoteInfo.cs + TopShareholder.cs +Tools/Models/ + AssetQuoteInfo.cs + Crypto/ + CryptoMarketMetrics.cs + CryptoOHLCV.cs + CryptoOrderBookDepth.cs + CryptoProjectInfo.cs + CryptoQuoteInfo.cs + CryptoRecentTrades.cs + CryptoSentiment.cs + CryptoVolatilityMetrics.cs + FundingRate.cs + LongShortRatio.cs + OpenInterest.cs + VolumeDistribution.cs + MarketInterval.cs + NewsItem.cs + Technical/ + TechnicalBoll.cs + TechnicalKDJ.cs + TechnicalMA.cs + TechnicalMACD.cs + +[metadataReferences] +../ + MarketAssistant.Core/obj/Debug/net10.0/ref/MarketAssistant.Core.dll + MarketAssistant.Trading/obj/Debug/net10.0/ref/MarketAssistant.Trading.dll +/packs/Microsoft.NETCore.App.Ref/10.0.5/ref/net10.0/ + Microsoft.CSharp.dll + Microsoft.VisualBasic.Core.dll + Microsoft.VisualBasic.dll + Microsoft.Win32.Primitives.dll + Microsoft.Win32.Registry.dll + mscorlib.dll + netstandard.dll + System.AppContext.dll + System.Buffers.dll + System.Collections.Concurrent.dll + System.Collections.dll + System.Collections.Immutable.dll + System.Collections.NonGeneric.dll + System.Collections.Specialized.dll + System.ComponentModel.Annotations.dll + System.ComponentModel.DataAnnotations.dll + System.ComponentModel.dll + System.ComponentModel.EventBasedAsync.dll + System.ComponentModel.Primitives.dll + System.ComponentModel.TypeConverter.dll + System.Configuration.dll + System.Console.dll + System.Core.dll + System.Data.Common.dll + System.Data.DataSetExtensions.dll + System.Data.dll + System.Diagnostics.Contracts.dll + System.Diagnostics.Debug.dll + System.Diagnostics.DiagnosticSource.dll + System.Diagnostics.FileVersionInfo.dll + System.Diagnostics.Process.dll + System.Diagnostics.StackTrace.dll + System.Diagnostics.TextWriterTraceListener.dll + System.Diagnostics.Tools.dll + System.Diagnostics.TraceSource.dll + System.Diagnostics.Tracing.dll + System.dll + System.Drawing.dll + System.Drawing.Primitives.dll + System.Dynamic.Runtime.dll + System.Formats.Asn1.dll + System.Formats.Tar.dll + System.Globalization.Calendars.dll + System.Globalization.dll + System.Globalization.Extensions.dll + System.IO.Compression.Brotli.dll + System.IO.Compression.dll + System.IO.Compression.FileSystem.dll + System.IO.Compression.ZipFile.dll + System.IO.dll + System.IO.FileSystem.AccessControl.dll + System.IO.FileSystem.dll + System.IO.FileSystem.DriveInfo.dll + System.IO.FileSystem.Primitives.dll + System.IO.FileSystem.Watcher.dll + System.IO.IsolatedStorage.dll + System.IO.MemoryMappedFiles.dll + System.IO.Pipelines.dll + System.IO.Pipes.AccessControl.dll + System.IO.Pipes.dll + System.IO.UnmanagedMemoryStream.dll + System.Linq.AsyncEnumerable.dll + System.Linq.dll + System.Linq.Expressions.dll + System.Linq.Parallel.dll + System.Linq.Queryable.dll + System.Memory.dll + System.Net.dll + System.Net.Http.dll + System.Net.Http.Json.dll + System.Net.HttpListener.dll + System.Net.Mail.dll + System.Net.NameResolution.dll + System.Net.NetworkInformation.dll + System.Net.Ping.dll + System.Net.Primitives.dll + System.Net.Quic.dll + System.Net.Requests.dll + System.Net.Security.dll + System.Net.ServerSentEvents.dll + System.Net.ServicePoint.dll + System.Net.Sockets.dll + System.Net.WebClient.dll + System.Net.WebHeaderCollection.dll + System.Net.WebProxy.dll + System.Net.WebSockets.Client.dll + System.Net.WebSockets.dll + System.Numerics.dll + System.Numerics.Vectors.dll + System.ObjectModel.dll + System.Reflection.DispatchProxy.dll + System.Reflection.dll + System.Reflection.Emit.dll + System.Reflection.Emit.ILGeneration.dll + System.Reflection.Emit.Lightweight.dll + System.Reflection.Extensions.dll + System.Reflection.Metadata.dll + System.Reflection.Primitives.dll + System.Reflection.TypeExtensions.dll + System.Resources.Reader.dll + System.Resources.ResourceManager.dll + System.Resources.Writer.dll + System.Runtime.CompilerServices.Unsafe.dll + System.Runtime.CompilerServices.VisualC.dll + System.Runtime.dll + System.Runtime.Extensions.dll + System.Runtime.Handles.dll + System.Runtime.InteropServices.dll + System.Runtime.InteropServices.JavaScript.dll + System.Runtime.InteropServices.RuntimeInformation.dll + System.Runtime.Intrinsics.dll + System.Runtime.Loader.dll + System.Runtime.Numerics.dll + System.Runtime.Serialization.dll + System.Runtime.Serialization.Formatters.dll + System.Runtime.Serialization.Json.dll + System.Runtime.Serialization.Primitives.dll + System.Runtime.Serialization.Xml.dll + System.Security.AccessControl.dll + System.Security.Claims.dll + System.Security.Cryptography.Algorithms.dll + System.Security.Cryptography.Cng.dll + System.Security.Cryptography.Csp.dll + System.Security.Cryptography.dll + System.Security.Cryptography.Encoding.dll + System.Security.Cryptography.OpenSsl.dll + System.Security.Cryptography.Primitives.dll + System.Security.Cryptography.X509Certificates.dll + System.Security.dll + System.Security.Principal.dll + System.Security.Principal.Windows.dll + System.Security.SecureString.dll + System.ServiceModel.Web.dll + System.ServiceProcess.dll + System.Text.Encoding.CodePages.dll + System.Text.Encoding.dll + System.Text.Encoding.Extensions.dll + System.Text.Encodings.Web.dll + System.Text.Json.dll + System.Text.RegularExpressions.dll + System.Threading.AccessControl.dll + System.Threading.Channels.dll + System.Threading.dll + System.Threading.Overlapped.dll + System.Threading.Tasks.Dataflow.dll + System.Threading.Tasks.dll + System.Threading.Tasks.Extensions.dll + System.Threading.Tasks.Parallel.dll + System.Threading.Thread.dll + System.Threading.ThreadPool.dll + System.Threading.Timer.dll + System.Transactions.dll + System.Transactions.Local.dll + System.ValueTuple.dll + System.Web.dll + System.Web.HttpUtility.dll + System.Windows.dll + System.Xml.dll + System.Xml.Linq.dll + System.Xml.ReaderWriter.dll + System.Xml.Serialization.dll + System.Xml.XDocument.dll + System.Xml.XmlDocument.dll + System.Xml.XmlSerializer.dll + System.Xml.XPath.dll + System.Xml.XPath.XDocument.dll + WindowsBase.dll +D:/NuGet/Packages/ + google.protobuf/3.30.2/lib/net5.0/Google.Protobuf.dll + microsoft.agents.ai.abstractions/1.6.1/lib/net10.0/Microsoft.Agents.AI.Abstractions.dll + microsoft.agents.ai.workflows/1.6.1/lib/net10.0/Microsoft.Agents.AI.Workflows.dll + microsoft.agents.ai/1.6.1/lib/net10.0/Microsoft.Agents.AI.dll + microsoft.data.sqlite.core/10.0.5/lib/net8.0/Microsoft.Data.Sqlite.dll + microsoft.extensions.ai.abstractions/10.5.1/lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + microsoft.extensions.ai.evaluation/10.4.0/lib/net10.0/Microsoft.Extensions.AI.Evaluation.dll + microsoft.extensions.ai/10.5.1/lib/net10.0/Microsoft.Extensions.AI.dll + microsoft.extensions.caching.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.Caching.Abstractions.dll + microsoft.extensions.compliance.abstractions/10.5.0/lib/net10.0/Microsoft.Extensions.Compliance.Abstractions.dll + microsoft.extensions.dependencyinjection.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll + microsoft.extensions.filesystemglobbing/10.0.6/lib/net10.0/Microsoft.Extensions.FileSystemGlobbing.dll + microsoft.extensions.logging.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.Logging.Abstractions.dll + microsoft.extensions.objectpool/10.0.6/lib/net10.0/Microsoft.Extensions.ObjectPool.dll + microsoft.extensions.primitives/10.0.6/lib/net10.0/Microsoft.Extensions.Primitives.dll + microsoft.extensions.vectordata.abstractions/9.7.0/lib/net8.0/Microsoft.Extensions.VectorData.Abstractions.dll + microsoft.ml.tokenizers/2.0.0/lib/net8.0/Microsoft.ML.Tokenizers.dll + opentelemetry.api/1.15.3/lib/net10.0/OpenTelemetry.Api.dll + sqlitepclraw.bundle_e_sqlite3/2.1.11/lib/netstandard2.0/SQLitePCLRaw.batteries_v2.dll + sqlitepclraw.core/2.1.11/lib/netstandard2.0/SQLitePCLRaw.core.dll + sqlitepclraw.provider.e_sqlite3/2.1.11/lib/net6.0/SQLitePCLRaw.provider.e_sqlite3.dll + system.numerics.tensors/10.0.6/lib/net10.0/System.Numerics.Tensors.dll + yamldotnet/16.3.0/lib/net8.0/YamlDotNet.dll + +[analyzerReferences] +/packs/Microsoft.NETCore.App.Ref/10.0.5/analyzers/dotnet/cs/ + Microsoft.Interop.ComInterfaceGenerator.dll + Microsoft.Interop.JavaScript.JSImportGenerator.dll + Microsoft.Interop.LibraryImportGenerator.dll + Microsoft.Interop.SourceGeneration.dll + System.Text.Json.SourceGeneration.dll + System.Text.RegularExpressions.Generator.dll +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/ + Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll + Microsoft.CodeAnalysis.NetAnalyzers.dll +D:/NuGet/Packages/microsoft.extensions.logging.abstractions/10.0.6/analyzers/dotnet/roslyn4.4/cs/Microsoft.Extensions.Logging.Generators.dll + +[analyzerConfigFiles] +../../.editorconfig +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_10_default.globalconfig +obj/Debug/net10.0/MarketAssistant.Agents.GeneratedMSBuildEditorConfig.editorconfig diff --git a/src/MarketAssistant.Agents/Middleware/ConversationCompressionMiddleware.cs b/src/MarketAssistant.Agents/Middleware/ConversationCompressionMiddleware.cs new file mode 100644 index 0000000..87afb0a --- /dev/null +++ b/src/MarketAssistant.Agents/Middleware/ConversationCompressionMiddleware.cs @@ -0,0 +1,218 @@ +using MarketAssistant.Agents.TokenManagement; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.Runtime.CompilerServices; +using System.Text; + +namespace MarketAssistant.Agents.Middleware; + +/// +/// 会话压缩中间件,拦截 Agent 运行并在消息历史超过 Token 阈值时自动压缩。 +/// 通过 agent.AsBuilder().Use(runFunc:, runStreamingFunc:).Build() 附加。 +/// +public sealed class ConversationCompressionMiddleware +{ + /// + /// AgentSession.StateBag 中标记是否正在执行压缩的键(防止递归) + /// + private const string IsCompressingKey = "middleware:isCompressing"; + + /// + /// AgentSession.StateBag 中存储压缩摘要的键 + /// + public const string CompressionSummaryKey = "middleware:compressionSummary"; + + private const int DefaultMaxTokens = 8000; + private const int DefaultReserveRecentCount = 4; + private const int SummaryTextTruncationThreshold = 500; + private const int SummaryMaxOutputTokens = 500; + private const float SummaryTemperature = 0.1f; + private const int FallbackSummaryTruncationThreshold = 100; + + private readonly ILogger _logger; + private readonly Func _chatClientFactory; + + /// + /// 压缩前回调钩子。在丢弃旧消息前调用,允许外部提取关键信息(紧急保存)。 + /// + public Func, CancellationToken, Task>? PreCompressHook { get; set; } + + /// + /// 触发压缩的 Token 阈值 + /// + public int MaxTokens { get; set; } = DefaultMaxTokens; + + /// + /// 压缩时保留最近消息数 + /// + public int ReserveRecentCount { get; set; } = DefaultReserveRecentCount; + + public ConversationCompressionMiddleware(Func chatClientFactory, ILogger logger) + { + _chatClientFactory = chatClientFactory; + _logger = logger; + } + + /// + /// 非流式 Agent Run 中间件 + /// + public async Task InvokeAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + AIAgent innerAgent, + CancellationToken cancellationToken) + { + var processedMessages = await TryCompressAsync(messages, session, cancellationToken); + + return await innerAgent.RunAsync(processedMessages, session, options, cancellationToken) + .ConfigureAwait(false); + } + + /// + /// 流式 Agent Run 中间件 + /// + public async IAsyncEnumerable InvokeStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + AIAgent innerAgent, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var processedMessages = await TryCompressAsync(messages, session, cancellationToken); + + await foreach (var update in innerAgent.RunStreamingAsync(processedMessages, session, options, cancellationToken) + .ConfigureAwait(false)) + { + yield return update; + } + } + + /// + /// 检查消息列表是否需要压缩,如需要则执行压缩并返回处理后的消息 + /// + private async Task> TryCompressAsync( + IEnumerable messages, + AgentSession? session, + CancellationToken cancellationToken) + { + var messageList = messages as IList ?? messages.ToList(); + + // 防止递归:压缩摘要生成过程中不再触发压缩 + if (session?.StateBag.TryGetValue(IsCompressingKey, out var flag) == true && flag == "true") + { + return messageList; + } + + var totalTokens = TokenEstimator.EstimateTotalTokens(messageList); + if (totalTokens <= MaxTokens || messageList.Count <= ReserveRecentCount) + { + return messageList; + } + + _logger.LogInformation( + "消息 Token ({Tokens}) 超过阈值 ({Max}),触发压缩,消息数: {Count}", + totalTokens, MaxTokens, messageList.Count); + + // 压缩前紧急保存钩子:让外部提取关键信息后再丢弃旧消息 + if (PreCompressHook != null) + { + try + { + await PreCompressHook(messageList, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "压缩前紧急保存钩子执行失败"); + } + } + + var messagesToSummarize = messageList.Take(messageList.Count - ReserveRecentCount).ToList(); + var recentMessages = messageList.Skip(messageList.Count - ReserveRecentCount).ToList(); + + var summary = await GenerateSummaryAsync(messagesToSummarize, session, cancellationToken); + + var compressed = new List(ReserveRecentCount + 1) + { + new(ChatRole.System, $"[对话摘要] {summary}") + }; + compressed.AddRange(recentMessages); + + // 将摘要存到 StateBag 以便外部(如 UI)读取 + if (session != null) + { + session.StateBag.SetValue(CompressionSummaryKey, summary); + } + + var newTokens = TokenEstimator.EstimateTotalTokens(compressed); + _logger.LogInformation( + "压缩完成:{OldCount} → {NewCount} 条消息,Token: {OldTokens} → {NewTokens}", + messageList.Count, compressed.Count, totalTokens, newTokens); + + return compressed; + } + + /// + /// 使用 LLM 生成对话摘要(设置防递归标记) + /// + private async Task GenerateSummaryAsync( + List messages, + AgentSession? session, + CancellationToken cancellationToken) + { + // 设置防递归标记 + if (session != null) session.StateBag.SetValue(IsCompressingKey, "true"); + + try + { + var sb = new StringBuilder(); + sb.AppendLine("请将以下对话内容压缩为简洁摘要,保留关键结论和数据点:"); + sb.AppendLine(); + + foreach (var msg in messages) + { + var role = msg.Role == ChatRole.User ? "用户" : "助手"; + var text = msg.Text; + if (string.IsNullOrWhiteSpace(text)) continue; + + if (text.Length > SummaryTextTruncationThreshold) + text = text[..SummaryTextTruncationThreshold] + "..."; + + sb.AppendLine($"【{role}】{text}"); + } + + var response = await _chatClientFactory().GetResponseAsync( + [new ChatMessage(ChatRole.User, sb.ToString())], + new ChatOptions { Temperature = SummaryTemperature, MaxOutputTokens = SummaryMaxOutputTokens }, + cancellationToken); + + return response.Text ?? "对话历史摘要不可用"; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "LLM 摘要生成失败,回退到截断策略"); + return BuildFallbackSummary(messages); + } + finally + { + if (session != null) session.StateBag.TryRemoveValue(IsCompressingKey); + } + } + + /// + /// 回退摘要策略:提取每条消息的前 100 字符 + /// + private static string BuildFallbackSummary(List messages) + { + var sb = new StringBuilder(); + foreach (var msg in messages) + { + var text = msg.Text; + if (string.IsNullOrWhiteSpace(text)) continue; + var truncated = text.Length > FallbackSummaryTruncationThreshold ? text[..FallbackSummaryTruncationThreshold] + "..." : text; + sb.AppendLine($"- {truncated}"); + } + return sb.ToString(); + } +} diff --git a/src/MarketAssistant.Agents/Middleware/TokenTrackingMiddleware.cs b/src/MarketAssistant.Agents/Middleware/TokenTrackingMiddleware.cs new file mode 100644 index 0000000..5f63979 --- /dev/null +++ b/src/MarketAssistant.Agents/Middleware/TokenTrackingMiddleware.cs @@ -0,0 +1,162 @@ +using MarketAssistant.Agents.TokenManagement; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.Runtime.CompilerServices; + +namespace MarketAssistant.Agents.Middleware; + +/// +/// Token 追踪中间件,拦截 Agent 运行以估算并记录输入/输出 Token 用量。 +/// 通过 agent.AsBuilder().Use(runFunc:, runStreamingFunc:).Build() 附加到任意 AIAgent。 +/// +public sealed class TokenTrackingMiddleware +{ + /// + /// AgentSession.StateBag 中累计 Token 数的键 + /// + public const string InputTokensKey = "middleware:cumulativeInputTokens"; + public const string OutputTokensKey = "middleware:cumulativeOutputTokens"; + + /// + /// 单个 Agent 会话累计 Token 上限,超过后抛出异常终止执行,防止工具调用循环失控。 + /// 正常多轮对话约消耗 30k~80k tokens,此值设为 200k 以只拦截真正的死循环。 + /// + public const int MaxCumulativeTokens = 200_000; + + private readonly ILogger _logger; + + public TokenTrackingMiddleware(ILogger logger) + { + _logger = logger; + } + + /// + /// 非流式 Agent Run 中间件 + /// + public async Task InvokeAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + AIAgent innerAgent, + CancellationToken cancellationToken) + { + var response = await innerAgent.RunAsync(messages, session, options, cancellationToken) + .ConfigureAwait(false); + + // 优先使用 MAF 原生 UsageContent(由 LLM 提供商返回的精确值) + var usage = ExtractUsage(response.Messages); + var inputTokens = usage?.InputTokenCount ?? TokenEstimator.EstimateTotalTokens(messages); + var outputTokens = usage?.OutputTokenCount ?? TokenEstimator.EstimateTotalTokens(response.Messages); + + LogAndAccumulate(session, (int)inputTokens, (int)outputTokens, innerAgent.Name, + isPrecise: usage != null); + + return response; + } + + /// + /// 流式 Agent Run 中间件 + /// + public async IAsyncEnumerable InvokeStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + AIAgent innerAgent, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + int outputCharCount = 0; + UsageDetails? streamingUsage = null; + var agentName = innerAgent.Name ?? "Unknown"; + + _logger.LogInformation("[{Agent}] 开始流式 LLM 调用", agentName); + + await foreach (var update in innerAgent.RunStreamingAsync(messages, session, options, cancellationToken) + .ConfigureAwait(false)) + { + // 尝试从流式更新中提取精确 Usage + if (update.Contents?.OfType().FirstOrDefault() is { } usageContent) + { + streamingUsage = usageContent.Details; + } + + if (update.Text is { Length: > 0 } text) + { + outputCharCount += text.Length; + } + + yield return update; + } + + // 优先使用精确值,回退到字符估算 + var inputTokens = streamingUsage?.InputTokenCount ?? TokenEstimator.EstimateTotalTokens(messages); + var outputTokens = streamingUsage?.OutputTokenCount ?? TokenEstimator.EstimateTokens(new string(' ', outputCharCount)); + + LogAndAccumulate(session, (int)inputTokens, (int)outputTokens, agentName, + isPrecise: streamingUsage != null); + } + + private void LogAndAccumulate(AgentSession? session, int inputTokens, int outputTokens, string? agentName, + bool isPrecise = false) + { + _logger.LogDebug( + "Token 追踪 [{Agent}] - 输入: {InputTokens}, 输出: {OutputTokens} ({Source})", + agentName ?? "Unknown", inputTokens, outputTokens, + isPrecise ? "提供商精确值" : "估算值"); + + if (session == null) return; + + var cumulativeInput = session.StateBag.TryGetValue(InputTokensKey, out var existing) + && int.TryParse(existing, out var existingVal) + ? existingVal + inputTokens + : inputTokens; + var cumulativeOutput = session.StateBag.TryGetValue(OutputTokensKey, out var existingOut) + && int.TryParse(existingOut, out var existingOutVal) + ? existingOutVal + outputTokens + : outputTokens; + + session.StateBag.SetValue(InputTokensKey, cumulativeInput.ToString()); + session.StateBag.SetValue(OutputTokensKey, cumulativeOutput.ToString()); + + // 熔断:累计 Token 超过上限时抛出异常,终止 Agent 执行,防止工具调用循环失控 + var total = cumulativeInput + cumulativeOutput; + if (total > MaxCumulativeTokens) + { + _logger.LogWarning( + "Token 熔断触发 [{Agent}] - 累计 {Total} 超过上限 {Limit}(输入 {In}, 输出 {Out})", + agentName ?? "Unknown", total, MaxCumulativeTokens, cumulativeInput, cumulativeOutput); + throw new InvalidOperationException( + $"Agent 累计 Token 用量 {total} 超过熔断上限 {MaxCumulativeTokens},已终止执行以防止工具调用循环失控"); + } + } + + /// + /// 从 Session 的 StateBag 中读取累计 Token 数 + /// + public static (int Input, int Output) GetCumulativeTokens(AgentSession? session) + { + if (session == null) return (0, 0); + + var input = session.StateBag.TryGetValue(InputTokensKey, out var i) && int.TryParse(i, out var iv) ? iv : 0; + var output = session.StateBag.TryGetValue(OutputTokensKey, out var o) && int.TryParse(o, out var ov) ? ov : 0; + return (input, output); + } + + /// + /// 从响应消息中提取 UsageDetails(优先使用 LLM 提供商返回的精确 Token 用量) + /// + private static UsageDetails? ExtractUsage(IEnumerable? messages) + { + if (messages == null) return null; + + foreach (var message in messages) + { + if (message.Contents?.OfType().FirstOrDefault() is { } usageContent) + { + return usageContent.Details; + } + } + + return null; + } +} diff --git a/src/MarketAssistant.Agents/Middleware/TradingFunctionGuardMiddleware.cs b/src/MarketAssistant.Agents/Middleware/TradingFunctionGuardMiddleware.cs new file mode 100644 index 0000000..bf5c1c6 --- /dev/null +++ b/src/MarketAssistant.Agents/Middleware/TradingFunctionGuardMiddleware.cs @@ -0,0 +1,115 @@ +using System.Text.Json; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Agents.Middleware; + +/// +/// 交易工具调用守卫中间件,拦截 Agent 的函数调用实现: +/// 1. 敏感操作(PlaceOrder)审计日志 +/// 2. 单次 Agent 运行内的工具调用计数限制 +/// 3. 需人工确认时可终止调用链 +/// +public sealed class TradingFunctionGuardMiddleware +{ + private const int DefaultMaxToolCalls = 20; + + /// + /// 需人工确认的回调。返回 true 表示用户确认放行,false 表示拒绝。 + /// 未设置时自动拒绝需确认的操作。 + /// + public Func>? ConfirmationCallback { get; set; } + + private readonly ILogger _logger; + private readonly int _maxToolCalls; + private int _toolCallCount; + + /// 单次 Agent 运行最大工具调用次数,防止无限循环(默认 20) + public TradingFunctionGuardMiddleware(ILogger logger, int maxToolCalls = DefaultMaxToolCalls) + { + _logger = logger; + _maxToolCalls = maxToolCalls; + } + + /// + /// Function Calling 中间件入口,通过 agent.AsBuilder().Use(this.InvokeAsync).Build() 附加 + /// + public async ValueTask InvokeAsync( + AIAgent agent, + FunctionInvocationContext context, + Func> next, + CancellationToken cancellationToken) + { + var functionName = context.Function.Name; + var callIndex = Interlocked.Increment(ref _toolCallCount); + + // 1. 调用计数守卫 + if (callIndex > _maxToolCalls) + { + _logger.LogWarning( + "TradingAgent 工具调用次数 {Count} 超过上限 {Max},终止调用链", + callIndex, _maxToolCalls); + context.Terminate = true; + return $"错误:工具调用次数已达上限 {_maxToolCalls},请直接给出结论。"; + } + + // 2. 敏感操作审计 + Human-in-the-Loop + if (IsSensitiveOperation(functionName)) + { + var args = FormatArguments(context); + + _logger.LogInformation( + "交易敏感操作拦截 [{Function}] 参数: {Args} (第 {Index} 次调用)", + functionName, args, callIndex); + + if (ConfirmationCallback != null) + { + var approved = await ConfirmationCallback(functionName, args); + if (!approved) + { + _logger.LogInformation("用户拒绝交易操作: {Function}", functionName); + return $"操作已被用户取消: {functionName}"; + } + } + } + else + { + _logger.LogDebug("TradingAgent 工具调用: {Function} (第 {Index} 次)", functionName, callIndex); + } + + // 3. 执行实际调用 + var result = await next(context, cancellationToken); + + // 4. 执行后审计 + if (IsSensitiveOperation(functionName)) + { + _logger.LogInformation( + "交易操作完成 [{Function}] 结果摘要: {ResultType}", + functionName, result?.GetType().Name ?? "null"); + } + + return result; + } + + private static bool IsSensitiveOperation(string functionName) + { + return functionName is "PlaceOrderAsync" or "CancelOrderAsync"; + } + + private string FormatArguments(FunctionInvocationContext context) + { + try + { + // 记录实际传入参数(而非函数 Schema),用于审计追踪 + return context.Arguments != null + ? JsonSerializer.Serialize(context.Arguments) + : "N/A"; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "序列化交易工具参数失败,函数: {Function}", context.Function.Name); + return "N/A"; + } + } +} diff --git a/src/MarketAssistant.Agents/PromptConfiguration/AnalystPromptConfig.cs b/src/MarketAssistant.Agents/PromptConfiguration/AnalystPromptConfig.cs new file mode 100644 index 0000000..c13e0ca --- /dev/null +++ b/src/MarketAssistant.Agents/PromptConfiguration/AnalystPromptConfig.cs @@ -0,0 +1,15 @@ +namespace MarketAssistant.Agents.PromptConfiguration; + +/// +/// 分析师提示词配置模型 +/// +public class AnalystPromptConfig +{ + public string Name { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public float Temperature { get; set; } + public float TopP { get; set; } + public int TopK { get; set; } + public string Instructions { get; set; } = string.Empty; +} diff --git a/src/MarketAssistant.Agents/PromptConfiguration/AnalystPromptLoader.cs b/src/MarketAssistant.Agents/PromptConfiguration/AnalystPromptLoader.cs new file mode 100644 index 0000000..45d81f5 --- /dev/null +++ b/src/MarketAssistant.Agents/PromptConfiguration/AnalystPromptLoader.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace MarketAssistant.Agents.PromptConfiguration; + +/// +/// 分析师提示词加载器,从独立 YAML 配置文件加载每个分析师的提示词和参数配置。 +/// 每个分析师对应一个文件:config/prompts/{AgentName}.yaml(如 FinancialAnalyst.yaml)。 +/// 支持热加载:每次访问时检查文件修改时间,文件变更后自动重新加载。 +/// +public class AnalystPromptLoader +{ + private static readonly string PromptsDir = Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "config", "prompts"); + + private static readonly IDeserializer Deserializer = new DeserializerBuilder() + .WithNamingConvention(PascalCaseNamingConvention.Instance) + .WithCaseInsensitivePropertyMatching() + .IgnoreUnmatchedProperties() + .Build(); + + private readonly ILogger _logger; + private readonly ConcurrentDictionary _cache = new(); + + public AnalystPromptLoader(ILogger logger) + { + _logger = logger; + } + + /// + /// 获取指定分析师的提示词配置。 + /// 自动从 config/prompts/{analystName}.yaml 加载,支持热重载。 + /// 文件缺失或关键字段(name、instructions)为空时抛出 。 + /// + public AnalystPromptConfig GetConfig(string analystName) + { + var filePath = Path.Combine(PromptsDir, $"{analystName}.yaml"); + + if (!File.Exists(filePath)) + throw new InvalidOperationException($"分析师配置文件不存在: {filePath}"); + + var lastWrite = File.GetLastWriteTimeUtc(filePath); + + if (_cache.TryGetValue(analystName, out var cached) && lastWrite <= cached.LoadTime) + return cached.Config; + + AnalystPromptConfig? config; + try + { + var yaml = File.ReadAllText(filePath); + config = Deserializer.Deserialize(yaml); + } + catch (Exception ex) + { + _logger.LogError(ex, "加载分析师配置失败: {Path}", filePath); + if (cached is not null) + { + _logger.LogWarning("将继续使用上一次成功加载的配置: {Name}", analystName); + return cached.Config; + } + throw new InvalidOperationException($"分析师 {analystName} 配置加载失败: {filePath}", ex); + } + + if (string.IsNullOrWhiteSpace(config.Name)) + throw new InvalidOperationException($"分析师 {analystName} 配置缺少关键信息: name 为空"); + if (string.IsNullOrWhiteSpace(config.Instructions)) + throw new InvalidOperationException($"分析师 {analystName} 配置缺少关键信息: instructions 为空"); + + _cache[analystName] = new CacheEntry(config, lastWrite); + _logger.LogInformation("已加载分析师配置: {Name} ← {Path}", analystName, filePath); + return config; + } + + private sealed record CacheEntry(AnalystPromptConfig Config, DateTime LoadTime); +} diff --git a/src/MarketAssistant.Agents/TokenManagement/TokenEstimator.cs b/src/MarketAssistant.Agents/TokenManagement/TokenEstimator.cs new file mode 100644 index 0000000..3796792 --- /dev/null +++ b/src/MarketAssistant.Agents/TokenManagement/TokenEstimator.cs @@ -0,0 +1,88 @@ +using Microsoft.Extensions.AI; +using Microsoft.ML.Tokenizers; + +namespace MarketAssistant.Agents.TokenManagement; + +/// +/// Token 估算器,基于 tiktoken(cl100k_base)精确计算 Token 数。 +/// 默认使用 GPT-4o 的分词模型;若初始化失败则回退到字符启发式估算。 +/// +public static class TokenEstimator +{ + private const double ChineseTokenRatio = 1.5; + private const double OtherTokenRatio = 4.0; + + private static readonly Tokenizer? _tokenizer; + + static TokenEstimator() + { + try + { + // 使用 cl100k_base 编码而非绑定特定模型名,与具体 LLM 提供商无关 + _tokenizer = TiktokenTokenizer.CreateForEncoding("cl100k_base"); + } + catch (Exception ex) + { + // 离线环境或编码数据不可用时回退到启发式估算 + System.Diagnostics.Debug.WriteLine($"TokenEstimator 初始化 tiktoken 失败,回退到启发式估算: {ex.Message}"); + _tokenizer = null; + } + } + + /// + /// 估算单条消息的 Token 数 + /// + public static int EstimateTokens(ChatMessage message) + { + var text = message.Text ?? string.Empty; + return EstimateTokens(text); + } + + /// + /// 估算文本的 Token 数 + /// + public static int EstimateTokens(string text) + { + if (string.IsNullOrEmpty(text)) return 0; + + if (_tokenizer != null) + return _tokenizer.CountTokens(text); + + return FallbackEstimate(text); + } + + /// + /// 估算对话历史的总 Token 数 + /// + public static int EstimateTotalTokens(IEnumerable messages) + { + return messages.Sum(EstimateTokens); + } + + /// + /// 回退启发式估算(tiktoken 不可用时) + /// + private static int FallbackEstimate(string text) + { + int chineseCount = 0; + int otherCount = 0; + + foreach (var ch in text) + { + if (ch is >= '\u4E00' and <= '\u9FFF' or + >= '\u3400' and <= '\u4DBF' or + >= '\u3000' and <= '\u303F' or + >= '\uFF00' and <= '\uFFEF') + { + chineseCount++; + } + else + { + otherCount++; + } + } + + var tokens = (int)(chineseCount / ChineseTokenRatio + otherCount / OtherTokenRatio); + return Math.Max(tokens, 1); + } +} diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/IBasicDataTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/IBasicDataTools.cs new file mode 100644 index 0000000..bffa954 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Abstractions/IBasicDataTools.cs @@ -0,0 +1,19 @@ +namespace MarketAssistant.Agents.Tools.Abstractions; + +/// +/// 基础数据工具的 DI 分发标记接口,用于 [RequiresTools] 声明和 Keyed DI 注册。 +/// 本身不定义业务方法,具体 API 由市场专用子接口提供。 +/// +/// +/// 市场专用实现: +/// - A 股:(股票行情、公司信息) +/// - 虚拟币:(币行情、项目信息) +/// +public interface IBasicDataTools : IToolsProvider +{ +} + + + + + diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/ICryptoBasicTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/ICryptoBasicTools.cs new file mode 100644 index 0000000..7dc8465 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Abstractions/ICryptoBasicTools.cs @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..03d6f84 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Abstractions/ICryptoMetricsTools.cs @@ -0,0 +1,93 @@ +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 +{ + // ==================== 市场深度数据(币安) ==================== + + /// + /// 获取历史K线数据(OHLCV) + /// + /// 交易对符号(如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); + + // ==================== 综合市场指标(CoinGecko) ==================== + + /// + /// 获取综合市场指标(市值、供应量、排名、流通率等数值指标) + /// + /// 代币符号(如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 new file mode 100644 index 0000000..16f2217 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Abstractions/ICryptoSentimentTools.cs @@ -0,0 +1,66 @@ +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 new file mode 100644 index 0000000..89bf9f5 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Abstractions/IFinancialTools.cs @@ -0,0 +1,14 @@ +namespace MarketAssistant.Agents.Tools.Abstractions; + +/// +/// 财务/市场数据工具的 DI 分发标记接口,用于 [RequiresTools] 声明和 Keyed DI 注册。 +/// 本身不定义业务方法,具体 API 由市场专用子接口提供。 +/// +/// +/// 市场专用实现: +/// - A 股:(财务报表、财务指标) +/// - 虚拟币:(市场深度、波动率、OHLCV 指标) +/// +public interface IFinancialTools : IToolsProvider +{ +} diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/INewsDataTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/INewsDataTools.cs new file mode 100644 index 0000000..77183f5 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Abstractions/INewsDataTools.cs @@ -0,0 +1,20 @@ +using MarketAssistant.Agents.Tools.Models; + +namespace MarketAssistant.Agents.Tools.Abstractions; + +/// +/// 新闻数据工具接口 +/// +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 new file mode 100644 index 0000000..1392ec8 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Abstractions/ISentimentTools.cs @@ -0,0 +1,14 @@ +namespace MarketAssistant.Agents.Tools.Abstractions; + +/// +/// 市场情绪工具的 DI 分发标记接口,用于 [RequiresTools] 声明和 Keyed DI 注册。 +/// 本身不定义业务方法,具体 API 由市场专用子接口提供。 +/// +/// +/// 市场专用实现: +/// - A 股:(资金流向) +/// - 虚拟币:(资金费率、多空比、持仓量等) +/// +public interface ISentimentTools : IToolsProvider +{ +} diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/IShareBasicTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/IShareBasicTools.cs new file mode 100644 index 0000000..45bca3b --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Abstractions/IShareBasicTools.cs @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..2b00ebe --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Abstractions/IShareFinancialTools.cs @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..0abd16a --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Abstractions/IShareSentimentTools.cs @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000..6dd8061 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Abstractions/IStrategyTools.cs @@ -0,0 +1,13 @@ +using MarketAssistant.Trading.Models; + +namespace MarketAssistant.Agents.Tools.Abstractions; + +/// +/// 策略管理工具 —— TradingAgent 可查询和更新策略状态 +/// +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); +} diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/ITechnicalDataTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/ITechnicalDataTools.cs new file mode 100644 index 0000000..6bfb34c --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Abstractions/ITechnicalDataTools.cs @@ -0,0 +1,39 @@ +using MarketAssistant.Agents.Tools.Models.Technical; + +namespace MarketAssistant.Agents.Tools.Abstractions; + +/// +/// 技术分析数据工具接口 +/// +public interface ITechnicalDataTools : IToolsProvider +{ + /// + /// 获取KDJ技术指标 + /// + Task GetKDJAsync(string assetSymbol, CancellationToken cancellationToken = default); + + /// + /// 获取MACD技术指标 + /// + Task GetMACDAsync(string assetSymbol, CancellationToken cancellationToken = default); + + /// + /// 获取BOLL技术指标 + /// + Task GetBOLLAsync(string assetSymbol, CancellationToken cancellationToken = default); + + /// + /// 获取MA技术指标 + /// + 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/IToolsProvider.cs b/src/MarketAssistant.Agents/Tools/Abstractions/IToolsProvider.cs new file mode 100644 index 0000000..4c3dcdb --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Abstractions/IToolsProvider.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.AI; + +namespace MarketAssistant.Agents.Tools.Abstractions; + +/// +/// 工具提供者基接口,统一暴露 AI 工具函数列表 +/// +public interface IToolsProvider +{ + /// + /// 获取 AI 工具函数列表 + /// + IEnumerable GetFunctions(); +} diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/ITradingExecutionTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/ITradingExecutionTools.cs new file mode 100644 index 0000000..17166c8 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Abstractions/ITradingExecutionTools.cs @@ -0,0 +1,15 @@ +using MarketAssistant.Trading.Models; + +namespace MarketAssistant.Agents.Tools.Abstractions; + +/// +/// 交易执行工具 —— 仅 Crypto 市场 +/// +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/Agents/Tools/Models/BalanceSheet.cs b/src/MarketAssistant.Agents/Tools/Models/AShare/BalanceSheet.cs similarity index 56% rename from src/Agents/Tools/Models/BalanceSheet.cs rename to src/MarketAssistant.Agents/Tools/Models/AShare/BalanceSheet.cs index ab2eb12..8782efc 100644 --- a/src/Agents/Tools/Models/BalanceSheet.cs +++ b/src/MarketAssistant.Agents/Tools/Models/AShare/BalanceSheet.cs @@ -1,274 +1,184 @@ +using System.ComponentModel; using System.Text.Json.Serialization; -namespace MarketAssistant.Agents.Plugins.Models; +namespace MarketAssistant.Agents.Tools.Models.AShare; -/// -/// 资产负债表 -/// +[Description("资产负债表")] public class BalanceSheet { - /// - /// 截止日期 - /// + [Description("报告截止日期")] [JsonPropertyName("jzrq")] - public string EndDate { get; set; } = ""; + public string EndDate { get; set; } = string.Empty; - /// - /// 披露日期 - /// + [Description("披露日期")] [JsonPropertyName("plrq")] - public string DisclosureDate { get; set; } = ""; + public string DisclosureDate { get; set; } = string.Empty; - /// - /// 货币资金 - /// + [Description("货币资金")] [JsonPropertyName("hbzj")] public decimal? MonetaryFunds { get; set; } - /// - /// 交易性金融资产 - /// + [Description("交易性金融资产")] [JsonPropertyName("jyxjrzc")] public decimal? TradingFinancialAssets { get; set; } - /// - /// 应收票据 - /// + [Description("应收票据")] [JsonPropertyName("yspj")] public decimal? NotesReceivable { get; set; } - /// - /// 应收账款 - /// + [Description("应收账款")] [JsonPropertyName("yszk")] public decimal? AccountsReceivable { get; set; } - /// - /// 预付款项 - /// + [Description("预付款项")] [JsonPropertyName("yfkx")] public decimal? AdvancePayments { get; set; } - /// - /// 其他应收款 - /// + [Description("其他应收款")] [JsonPropertyName("qtysk")] public decimal? OtherReceivables { get; set; } - /// - /// 存货 - /// + [Description("存货")] [JsonPropertyName("ch")] public decimal? Inventory { get; set; } - /// - /// 其他流动资产 - /// + [Description("其他流动资产")] [JsonPropertyName("qtldzc")] public decimal? OtherCurrentAssets { get; set; } - /// - /// 流动资产合计 - /// + [Description("流动资产合计")] [JsonPropertyName("ldzchj")] public decimal? TotalCurrentAssets { get; set; } - /// - /// 长期股权投资 - /// + [Description("长期股权投资")] [JsonPropertyName("cqgqtz")] public decimal? LongTermEquityInvestment { get; set; } - /// - /// 固定资产 - /// + [Description("固定资产")] [JsonPropertyName("gdzc")] public decimal? FixedAssets { get; set; } - /// - /// 在建工程 - /// + [Description("在建工程")] [JsonPropertyName("zjgc")] public decimal? ConstructionInProgress { get; set; } - /// - /// 无形资产 - /// + [Description("无形资产")] [JsonPropertyName("wxzc")] public decimal? IntangibleAssets { get; set; } - /// - /// 商誉 - /// + [Description("商誉")] [JsonPropertyName("sy")] public decimal? Goodwill { get; set; } - /// - /// 递延所得税资产 - /// + [Description("递延所得税资产")] [JsonPropertyName("dysdszc")] public decimal? DeferredTaxAssets { get; set; } - /// - /// 非流动资产合计 - /// + [Description("非流动资产合计")] [JsonPropertyName("fldzchj")] public decimal? TotalNonCurrentAssets { get; set; } - /// - /// 资产总计 - /// + [Description("资产总计")] [JsonPropertyName("zczj")] public decimal? TotalAssets { get; set; } - /// - /// 短期借款 - /// + [Description("短期借款")] [JsonPropertyName("dqjk")] public decimal? ShortTermBorrowings { get; set; } - /// - /// 应付票据 - /// + [Description("应付票据")] [JsonPropertyName("yfpj")] public decimal? NotesPayable { get; set; } - /// - /// 应付账款 - /// + [Description("应付账款")] [JsonPropertyName("yfzk")] public decimal? AccountsPayable { get; set; } - /// - /// 预收账款 - /// + [Description("预收账款")] [JsonPropertyName("ysk")] public decimal? AdvanceReceipts { get; set; } - /// - /// 应付职工薪酬 - /// + [Description("应付职工薪酬")] [JsonPropertyName("yfgzxc")] public decimal? EmployeeBenefitsPayable { get; set; } - /// - /// 应交税费 - /// + [Description("应交税费")] [JsonPropertyName("yjsf")] public decimal? TaxesPayable { get; set; } - /// - /// 应付利息 - /// + [Description("应付利息")] [JsonPropertyName("yflx")] public decimal? InterestPayable { get; set; } - /// - /// 其他应付款 - /// + [Description("其他应付款")] [JsonPropertyName("qtfzk")] public decimal? OtherPayables { get; set; } - /// - /// 一年内到期的非流动负债 - /// + [Description("一年内到期的非流动负债")] [JsonPropertyName("ynndqdfldfz")] public decimal? CurrentPortionOfNonCurrentLiabilities { get; set; } - /// - /// 其他流动负债 - /// + [Description("其他流动负债")] [JsonPropertyName("qtldfz")] public decimal? OtherCurrentLiabilities { get; set; } - /// - /// 流动负债合计 - /// + [Description("流动负债合计")] [JsonPropertyName("ldfzhj")] public decimal? TotalCurrentLiabilities { get; set; } - /// - /// 长期借款 - /// + [Description("长期借款")] [JsonPropertyName("cqjk")] public decimal? LongTermBorrowings { get; set; } - /// - /// 应付债券 - /// + [Description("应付债券")] [JsonPropertyName("yfzq")] public decimal? BondsPayable { get; set; } - /// - /// 递延所得税负债 - /// + [Description("递延所得税负债")] [JsonPropertyName("dysdsfz")] public decimal? DeferredTaxLiabilities { get; set; } - /// - /// 其他非流动负债 - /// + [Description("其他非流动负债")] [JsonPropertyName("qtfldfz")] public decimal? OtherNonCurrentLiabilities { get; set; } - /// - /// 非流动负债合计 - /// + [Description("非流动负债合计")] [JsonPropertyName("fldfzhj")] public decimal? TotalNonCurrentLiabilities { get; set; } - /// - /// 负债合计 - /// + [Description("负债合计")] [JsonPropertyName("fzhj")] public decimal? TotalLiabilities { get; set; } - /// - /// 实收资本(或股本) - /// + [Description("实收资本(或股本)")] [JsonPropertyName("sszb")] public decimal? PaidInCapital { get; set; } - /// - /// 资本公积 - /// + [Description("资本公积")] [JsonPropertyName("zbgj")] public decimal? CapitalReserve { get; set; } - /// - /// 盈余公积 - /// + [Description("盈余公积")] [JsonPropertyName("ylgj")] public decimal? SurplusReserve { get; set; } - /// - /// 未分配利润 - /// + [Description("未分配利润")] [JsonPropertyName("wfplr")] public decimal? RetainedEarnings { get; set; } - /// - /// 归属于母公司股东权益合计 - /// + [Description("归属于母公司股东权益合计")] [JsonPropertyName("gsmgdqsyhj")] public decimal? TotalEquityAttributableToParent { get; set; } - /// - /// 少数股东权益 - /// + [Description("少数股东权益")] [JsonPropertyName("ssgdqy")] public decimal? MinorityInterest { get; set; } - /// - /// 所有者权益合计 - /// + [Description("所有者权益合计")] [JsonPropertyName("syzqyhj")] public decimal? TotalEquity { get; set; } - /// - /// 负债和股东权益总计 - /// + [Description("负债和股东权益总计")] [JsonPropertyName("fzhgdqyzj")] public decimal? TotalLiabilitiesAndEquity { get; set; } } - diff --git a/src/MarketAssistant.Agents/Tools/Models/AShare/CapitalStructure.cs b/src/MarketAssistant.Agents/Tools/Models/AShare/CapitalStructure.cs new file mode 100644 index 0000000..067a4d7 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/AShare/CapitalStructure.cs @@ -0,0 +1,28 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace MarketAssistant.Agents.Tools.Models.AShare; + +[Description("公司股本结构")] +public class CapitalStructure +{ + [Description("变动日期")] + [JsonPropertyName("bdrq")] + public string ChangeDate { get; set; } = string.Empty; + + [Description("公告日")] + [JsonPropertyName("ggr")] + public string AnnouncementDate { get; set; } = string.Empty; + + [Description("总股本")] + [JsonPropertyName("zgb")] + public decimal? TotalShares { get; set; } + + [Description("已上市流通A股")] + [JsonPropertyName("ysltag")] + public decimal? CirculatingAShares { get; set; } + + [Description("限售流通股份")] + [JsonPropertyName("xsltgf")] + public decimal? RestrictedShares { get; set; } +} diff --git a/src/Agents/Tools/Models/CashFlowStatement.cs b/src/MarketAssistant.Agents/Tools/Models/AShare/CashFlowStatement.cs similarity index 53% rename from src/Agents/Tools/Models/CashFlowStatement.cs rename to src/MarketAssistant.Agents/Tools/Models/AShare/CashFlowStatement.cs index 094bbdd..59999b8 100644 --- a/src/Agents/Tools/Models/CashFlowStatement.cs +++ b/src/MarketAssistant.Agents/Tools/Models/AShare/CashFlowStatement.cs @@ -1,220 +1,148 @@ +using System.ComponentModel; using System.Text.Json.Serialization; -namespace MarketAssistant.Agents.Plugins.Models; +namespace MarketAssistant.Agents.Tools.Models.AShare; -/// -/// 现金流量表 -/// +[Description("现金流量表")] public class CashFlowStatement { - /// - /// 截止日期 - /// + [Description("报告截止日期")] [JsonPropertyName("jzrq")] public string EndDate { get; set; } = ""; - /// - /// 披露日期 - /// + [Description("披露日期")] [JsonPropertyName("plrq")] public string DisclosureDate { get; set; } = ""; - /// - /// 销售商品、提供劳务收到的现金 - /// + [Description("销售商品、提供劳务收到的现金")] [JsonPropertyName("xssptglwsddxj")] public decimal? CashFromSalesAndServices { get; set; } - /// - /// 收到的税费与返还 - /// + [Description("收到的税费返还")] [JsonPropertyName("sddsfyfh")] public decimal? TaxRefundsReceived { get; set; } - /// - /// 收到的其他与经营活动有关的现金 - /// + [Description("收到的其他与经营活动有关的现金")] [JsonPropertyName("sdqtyjyghdxj")] public decimal? OtherCashFromOperating { get; set; } - /// - /// 经营活动现金流入小计 - /// + [Description("经营活动现金流入小计")] [JsonPropertyName("jyhdxjlrxj")] public decimal? TotalCashInflowsFromOperating { get; set; } - /// - /// 购买商品、接受劳务支付的现金 - /// + [Description("购买商品、接受劳务支付的现金")] [JsonPropertyName("gmspjslwzfdxj")] public decimal? CashPaidForGoodsAndServices { get; set; } - /// - /// 支付给职工以及为职工支付的现金 - /// + [Description("支付给职工以及为职工支付的现金")] [JsonPropertyName("zfgzyjwzgzfdxj")] public decimal? CashPaidToEmployees { get; set; } - /// - /// 支付的各项税费 - /// + [Description("支付的各项税费")] [JsonPropertyName("zfdgxsf")] public decimal? TaxesPaid { get; set; } - /// - /// 支付其他与经营活动有关的现金 - /// + [Description("支付其他与经营活动有关的现金")] [JsonPropertyName("zfqtyjyghdxj")] public decimal? OtherCashPaidForOperating { get; set; } - /// - /// 经营活动现金流出小计 - /// + [Description("经营活动现金流出小计")] [JsonPropertyName("jyhdxjlcxj")] public decimal? TotalCashOutflowsFromOperating { get; set; } - /// - /// 经营活动产生的现金流量净额 - /// - [JsonPropertyName("jyhdcsdxjlje")] + [Description("经营活动产生的现金流量净额")] + [JsonPropertyName("jyhdcsdxjlxj")] public decimal? NetCashFlowFromOperating { get; set; } - /// - /// 收回投资所收到的现金 - /// + [Description("收回投资所收到的现金")] [JsonPropertyName("shtzssddxj")] public decimal? CashFromInvestmentRecovery { get; set; } - /// - /// 取得投资收益所收到的现金 - /// + [Description("取得投资收益所收到的现金")] [JsonPropertyName("qdtzsysddxj")] public decimal? CashFromInvestmentIncome { get; set; } - /// - /// 处置固定资产、无形资产和其他长期投资收到的现金 - /// + [Description("处置固定资产、无形资产和其他长期资产收到的现金")] [JsonPropertyName("czgdzcwxzhqtqctzssddxj")] public decimal? CashFromDisposalOfAssets { get; set; } - /// - /// 收到的其他与投资活动有关的现金 - /// + [Description("收到的其他与投资活动有关的现金")] [JsonPropertyName("sdqtytzghdxj")] public decimal? OtherCashFromInvesting { get; set; } - /// - /// 投资活动现金流入小计 - /// + [Description("投资活动现金流入小计")] [JsonPropertyName("tzhdxjlrxj")] public decimal? TotalCashInflowsFromInvesting { get; set; } - /// - /// 购建固定资产、无形资产和其他长期投资支付的现金 - /// + [Description("购建固定资产、无形资产和其他长期资产支付的现金")] [JsonPropertyName("gjgdzcwxzhqtqctzzfdxj")] public decimal? CashPaidForAssets { get; set; } - /// - /// 投资支付的现金 - /// + [Description("投资支付的现金")] [JsonPropertyName("tzzfdxj")] public decimal? CashPaidForInvestments { get; set; } - /// - /// 投资活动现金流出小计 - /// + [Description("投资活动现金流出小计")] [JsonPropertyName("tzhdxjlcxj")] public decimal? TotalCashOutflowsFromInvesting { get; set; } - /// - /// 投资活动产生的现金流量净额 - /// + [Description("投资活动产生的现金流量净额")] [JsonPropertyName("tzhdcsdxjlxj")] public decimal? NetCashFlowFromInvesting { get; set; } - /// - /// 吸收投资收到的现金 - /// + [Description("吸收投资收到的现金")] [JsonPropertyName("xstzsdj")] public decimal? CashFromEquityIssuance { get; set; } - /// - /// 取得借款收到的现金 - /// + [Description("取得借款收到的现金")] [JsonPropertyName("qdjkjddxj")] public decimal? CashFromBorrowings { get; set; } - /// - /// 发行债券收到的现金 - /// + [Description("发行债券收到的现金")] [JsonPropertyName("fxzjsddxj")] public decimal? CashFromBondIssuance { get; set; } - /// - /// 收到其他与筹资活动有关的现金 - /// + [Description("收到其他与筹资活动有关的现金")] [JsonPropertyName("sdqtczghdxj")] public decimal? OtherCashFromFinancing { get; set; } - /// - /// 筹资活动现金流入小计 - /// + [Description("筹资活动现金流入小计")] [JsonPropertyName("czhdxjlrxj")] public decimal? TotalCashInflowsFromFinancing { get; set; } - /// - /// 偿还债务支付现金 - /// + [Description("偿还债务支付的现金")] [JsonPropertyName("chzwzfxj")] public decimal? CashPaidForDebtRepayment { get; set; } - /// - /// 分配股利、利润或偿付利息支付的现金 - /// + [Description("分配股利、利润或偿付利息支付的现金")] [JsonPropertyName("fpglrlhcllxzfdxj")] public decimal? CashPaidForDividendsAndInterest { get; set; } - /// - /// 支付其他与筹资的现金 - /// + [Description("支付其他与筹资活动有关的现金")] [JsonPropertyName("zfqtczdxj")] public decimal? OtherCashPaidForFinancing { get; set; } - /// - /// 筹资活动现金流出小计 - /// + [Description("筹资活动现金流出小计")] [JsonPropertyName("czhdxjlcxj")] public decimal? TotalCashOutflowsFromFinancing { get; set; } - /// - /// 筹资活动产生的现金流量净额 - /// + [Description("筹资活动产生的现金流量净额")] [JsonPropertyName("czhdcsdxjlxj")] public decimal? NetCashFlowFromFinancing { get; set; } - /// - /// 汇率变动对现金的影响 - /// + [Description("汇率变动对现金的影响")] [JsonPropertyName("hlbddxjdxy")] public decimal? ExchangeRateEffect { get; set; } - /// - /// 现金及现金等价物净增加额 - /// + [Description("现金及现金等价物净增加额")] [JsonPropertyName("xjxjdhwjzje")] public decimal? NetIncreaseInCash { get; set; } - /// - /// 期初现金及现金等价物余额 - /// + [Description("期初现金及现金等价物余额")] [JsonPropertyName("qcxjjxjdhwye")] public decimal? BeginningCashBalance { get; set; } - /// - /// 期末现金及现金等价物余额 - /// + [Description("期末现金及现金等价物余额")] [JsonPropertyName("qmxjjxjdhwye")] public decimal? EndingCashBalance { get; set; } } - diff --git a/src/MarketAssistant.Agents/Tools/Models/AShare/CompanyInfo.cs b/src/MarketAssistant.Agents/Tools/Models/AShare/CompanyInfo.cs new file mode 100644 index 0000000..058bff3 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/AShare/CompanyInfo.cs @@ -0,0 +1,164 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace MarketAssistant.Agents.Tools.Models.AShare; + +[Description("上市公司基本信息")] +public class CompanyInfo +{ + [Description("公司名称")] + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [Description("公司英文名称")] + [JsonPropertyName("ename")] + public string EName { get; set; } = ""; + + [Description("所属市场(如:主板、创业板、科创板)")] + [JsonPropertyName("market")] + public string Market { get; set; } = ""; + + [Description("所属概念板块(多个以逗号分隔)")] + [JsonPropertyName("idea")] + public string Concept { get; set; } = ""; + + [Description("上市日期(yyyy-MM-dd)")] + [JsonPropertyName("ldate")] + public string ListingDate { get; set; } = ""; + + [Description("发行价格(元)")] + [JsonPropertyName("sprice")] + public string IssuePrice { get; set; } = ""; + + [Description("主承销商")] + [JsonPropertyName("principal")] + public string Underwriter { get; set; } = ""; + + [Description("成立日期(yyyy-MM-dd)")] + [JsonPropertyName("rdate")] + public string EstablishmentDate { get; set; } = ""; + + [Description("注册资本")] + [JsonPropertyName("rprice")] + public string RegisteredCapital { get; set; } = ""; + + [Description("机构类型")] + [JsonPropertyName("instype")] + public string InstitutionType { get; set; } = ""; + + [Description("组织形式")] + [JsonPropertyName("organ")] + public string Organization { get; set; } = ""; + + [Description("董事会秘书")] + [JsonPropertyName("secre")] + public string Secretary { get; set; } = ""; + + [Description("公司电话")] + [JsonPropertyName("phone")] + public string CompanyPhone { get; set; } = ""; + + [Description("董秘电话")] + [JsonPropertyName("sphone")] + public string SecretaryPhone { get; set; } = ""; + + [Description("公司传真")] + [JsonPropertyName("fax")] + public string Fax { get; set; } = ""; + + [Description("董秘传真")] + [JsonPropertyName("sfax")] + public string SecretaryFax { get; set; } = ""; + + [Description("公司邮箱")] + [JsonPropertyName("email")] + public string Email { get; set; } = ""; + + [Description("董秘邮箱")] + [JsonPropertyName("semail")] + public string SecretaryEmail { get; set; } = ""; + + [Description("公司网站")] + [JsonPropertyName("site")] + public string Website { get; set; } = ""; + + [Description("邮政编码")] + [JsonPropertyName("post")] + public string PostalCode { get; set; } = ""; + + [Description("信息披露网站")] + [JsonPropertyName("infosite")] + public string InfoWebsite { get; set; } = ""; + + [Description("公司曾用名(历史更名)")] + [JsonPropertyName("oname")] + public string NameHistory { get; set; } = ""; + + [Description("注册地址")] + [JsonPropertyName("addr")] + public string RegisteredAddress { get; set; } = ""; + + [Description("办公地址")] + [JsonPropertyName("oaddr")] + public string OfficeAddress { get; set; } = ""; + + [Description("公司简介")] + [JsonPropertyName("desc")] + public string Description { get; set; } = ""; + + [Description("经营范围")] + [JsonPropertyName("bscope")] + public string BusinessScope { get; set; } = ""; + + [Description("承销方式")] + [JsonPropertyName("printype")] + public string UnderwritingType { get; set; } = ""; + + [Description("上市推荐人")] + [JsonPropertyName("referrer")] + public string ListingReferrer { get; set; } = ""; + + [Description("发行方式")] + [JsonPropertyName("putype")] + public string IssueType { get; set; } = ""; + + [Description("发行市盈率")] + [JsonPropertyName("pe")] + public string PublishPE { get; set; } = ""; + + [Description("发行前总股本(万股)")] + [JsonPropertyName("firgu")] + public string PreIssueShares { get; set; } = ""; + + [Description("发行后总股本(万股)")] + [JsonPropertyName("lastgu")] + public string PostIssueShares { get; set; } = ""; + + [Description("实际发行数量(万股)")] + [JsonPropertyName("realgu")] + public string ActualIssueShares { get; set; } = ""; + + [Description("预计募集资金(万元)")] + [JsonPropertyName("planm")] + public string PlannedFunds { get; set; } = ""; + + [Description("实际募集资金(万元)")] + [JsonPropertyName("realm")] + public string ActualFunds { get; set; } = ""; + + [Description("发行总费用(万元)")] + [JsonPropertyName("pubfee")] + public string TotalIssueCost { get; set; } = ""; + + [Description("募集资金净额(万元)")] + [JsonPropertyName("collect")] + public string NetFunds { get; set; } = ""; + + [Description("承销保荐费用(万元)")] + [JsonPropertyName("signfee")] + public string UnderwritingFee { get; set; } = ""; + + [Description("招股书披露日期(yyyy-MM-dd)")] + [JsonPropertyName("pdate")] + public string ProspectusDate { get; set; } = ""; +} \ No newline at end of file diff --git a/src/Agents/Tools/Models/StockFinancialData.cs b/src/MarketAssistant.Agents/Tools/Models/AShare/FinancialData.cs similarity index 69% rename from src/Agents/Tools/Models/StockFinancialData.cs rename to src/MarketAssistant.Agents/Tools/Models/AShare/FinancialData.cs index d3c2dbf..1b22dc6 100644 --- a/src/Agents/Tools/Models/StockFinancialData.cs +++ b/src/MarketAssistant.Agents/Tools/Models/AShare/FinancialData.cs @@ -1,14 +1,14 @@ using System.Text.Json.Serialization; -namespace MarketAssistant.Agents.Plugins.Models; +namespace MarketAssistant.Agents.Tools.Models.AShare; /// -/// 股票财务指标实体类 +/// 财务指标实体 /// -public class StockFinancialData +public class FinancialData { /// - /// 报告日期yyyy-MM-dd + /// 报告日期 yyyy-MM-dd /// [JsonPropertyName("date")] public string Date { get; set; } = ""; @@ -17,503 +17,503 @@ public class StockFinancialData /// 摊薄每股收益(元) /// [JsonPropertyName("tbmg")] - public string Tbmg { get; set; } = ""; + public decimal? Tbmg { get; set; } /// /// 加权每股收益(元) /// [JsonPropertyName("jqmg")] - public string Jqmg { get; set; } = ""; + public decimal? Jqmg { get; set; } /// /// 每股收益_调整后(元) /// [JsonPropertyName("mgsy")] - public string Mgsy { get; set; } = ""; + public decimal? Mgsy { get; set; } /// /// 扣除非经常性损益后的每股收益(元) /// [JsonPropertyName("kfmg")] - public string Kfmg { get; set; } = ""; + public decimal? Kfmg { get; set; } /// - /// 每股净资产_调整前(元) + /// 每股净资产_调整后(元) /// [JsonPropertyName("mgjz")] - public string Mgjz { get; set; } = ""; + public decimal? Mgjz { get; set; } /// /// 每股净资产_调整后(元) /// [JsonPropertyName("mgjzad")] - public string Mgjzad { get; set; } = ""; + public decimal? Mgjzad { get; set; } /// /// 每股经营性现金流(元) /// [JsonPropertyName("mgjy")] - public string Mgjy { get; set; } = ""; + public decimal? Mgjy { get; set; } /// /// 每股资本公积金(元) /// [JsonPropertyName("mggjj")] - public string Mggjj { get; set; } = ""; + public decimal? Mggjj { get; set; } /// /// 每股未分配利润(元) /// [JsonPropertyName("mgwly")] - public string Mgwly { get; set; } = ""; + public decimal? Mgwly { get; set; } /// /// 总资产利润率(%) /// [JsonPropertyName("zclr")] - public string Zclr { get; set; } = ""; + public decimal? Zclr { get; set; } /// /// 主营业务利润率(%) /// [JsonPropertyName("zylr")] - public string Zylr { get; set; } = ""; + public decimal? Zylr { get; set; } /// /// 总资产净利润率(%) /// [JsonPropertyName("zzlr")] - public string Zzlr { get; set; } = ""; + public decimal? Zzlr { get; set; } /// /// 成本费用利润率(%) /// [JsonPropertyName("cblr")] - public string Cblr { get; set; } = ""; + public decimal? Cblr { get; set; } /// /// 营业利润率(%) /// [JsonPropertyName("yylr")] - public string Yylr { get; set; } = ""; + public decimal? Yylr { get; set; } /// /// 主营业务成本率(%) /// [JsonPropertyName("zycb")] - public string Zycb { get; set; } = ""; + public decimal? Zycb { get; set; } /// /// 销售净利率(%) /// [JsonPropertyName("xsjl")] - public string Xsjl { get; set; } = ""; + public decimal? Xsjl { get; set; } /// /// 股本报酬率(%) /// [JsonPropertyName("gbbc")] - public string Gbbc { get; set; } = ""; + public decimal? Gbbc { get; set; } /// /// 净资产报酬率(%) /// [JsonPropertyName("jzbc")] - public string Jzbc { get; set; } = ""; + public decimal? Jzbc { get; set; } /// /// 资产报酬率(%) /// [JsonPropertyName("zcbc")] - public string Zcbc { get; set; } = ""; + public decimal? Zcbc { get; set; } /// /// 销售毛利率(%) /// [JsonPropertyName("xsml")] - public string Xsml { get; set; } = ""; + public decimal? Xsml { get; set; } /// /// 三项费用比重 /// [JsonPropertyName("xxbz")] - public string Xxbz { get; set; } = ""; + public decimal? Xxbz { get; set; } /// /// 非主营比重 /// [JsonPropertyName("fzy")] - public string Fzy { get; set; } = ""; + public decimal? Fzy { get; set; } /// /// 主营利润比重 /// [JsonPropertyName("zybz")] - public string Zybz { get; set; } = ""; + public decimal? Zybz { get; set; } /// /// 股息发放率(%) /// [JsonPropertyName("gxff")] - public string Gxff { get; set; } = ""; + public decimal? Gxff { get; set; } /// /// 投资收益率(%) /// [JsonPropertyName("tzsy")] - public string Tzsy { get; set; } = ""; + public decimal? Tzsy { get; set; } /// /// 主营业务利润(元) /// [JsonPropertyName("zyyw")] - public string Zyyw { get; set; } = ""; + public decimal? Zyyw { get; set; } /// /// 净资产收益率(%) /// [JsonPropertyName("jzsy")] - public string Jzsy { get; set; } = ""; + public decimal? Jzsy { get; set; } /// /// 加权净资产收益率(%) /// [JsonPropertyName("jqjz")] - public string Jqjz { get; set; } = ""; + public decimal? Jqjz { get; set; } /// /// 扣除非经常性损益后的净利润(元) /// [JsonPropertyName("kflr")] - public string Kflr { get; set; } = ""; + public decimal? Kflr { get; set; } /// /// 主营业务收入增长率(%) /// [JsonPropertyName("zysr")] - public string Zysr { get; set; } = ""; + public decimal? Zysr { get; set; } /// /// 净利润增长率(%) /// [JsonPropertyName("jlzz")] - public string Jlzz { get; set; } = ""; + public decimal? Jlzz { get; set; } /// /// 净资产增长率(%) /// [JsonPropertyName("jzzz")] - public string Jzzz { get; set; } = ""; + public decimal? Jzzz { get; set; } /// /// 总资产增长率(%) /// [JsonPropertyName("zzzz")] - public string Zzzz { get; set; } = ""; + public decimal? Zzzz { get; set; } /// /// 应收账款周转率(次) /// [JsonPropertyName("yszz")] - public string Yszz { get; set; } = ""; + public decimal? Yszz { get; set; } /// /// 应收账款周转天数(天) /// [JsonPropertyName("yszzt")] - public string Yszzt { get; set; } = ""; + public decimal? Yszzt { get; set; } /// /// 存货周转天数(天) /// [JsonPropertyName("chzz")] - public string Chzz { get; set; } = ""; + public decimal? Chzz { get; set; } /// /// 存货周转率(次) /// [JsonPropertyName("chzzl")] - public string Chzzl { get; set; } = ""; + public decimal? Chzzl { get; set; } /// /// 固定资产周转率(次) /// [JsonPropertyName("gzzz")] - public string Gzzz { get; set; } = ""; + public decimal? Gzzz { get; set; } /// /// 总资产周转率(次) /// [JsonPropertyName("zzzzl")] - public string Zzzzl { get; set; } = ""; + public decimal? Zzzzl { get; set; } /// /// 总资产周转天数(天) /// [JsonPropertyName("zzzzt")] - public string Zzzzt { get; set; } = ""; + public decimal? Zzzzt { get; set; } /// /// 流动资产周转率(次) /// [JsonPropertyName("ldzz")] - public string Ldzz { get; set; } = ""; + public decimal? Ldzz { get; set; } /// /// 流动资产周转天数(天) /// [JsonPropertyName("ldzzt")] - public string Ldzzt { get; set; } = ""; + public decimal? Ldzzt { get; set; } /// /// 股东权益周转率(次) /// [JsonPropertyName("gdzz")] - public string Gdzz { get; set; } = ""; + public decimal? Gdzz { get; set; } /// /// 流动比率 /// [JsonPropertyName("ldbl")] - public string Ldbl { get; set; } = ""; + public decimal? Ldbl { get; set; } /// /// 速动比率 /// [JsonPropertyName("sdbl")] - public string Sdbl { get; set; } = ""; + public decimal? Sdbl { get; set; } /// /// 现金比率(%) /// [JsonPropertyName("xjbl")] - public string Xjbl { get; set; } = ""; + public decimal? Xjbl { get; set; } /// /// 利息支付倍数 /// [JsonPropertyName("lxzf")] - public string Lxzf { get; set; } = ""; + public decimal? Lxzf { get; set; } /// /// 长期债务与营运资金比率(%) /// [JsonPropertyName("zjbl")] - public string Zjbl { get; set; } = ""; + public decimal? Zjbl { get; set; } /// /// 股东权益比率(%) /// [JsonPropertyName("gdqy")] - public string Gdqy { get; set; } = ""; + public decimal? Gdqy { get; set; } /// /// 长期负债比率(%) /// [JsonPropertyName("cqfz")] - public string Cqfz { get; set; } = ""; + public decimal? Cqfz { get; set; } /// /// 股东权益与固定资产比率(%) /// [JsonPropertyName("gdgd")] - public string Gdgd { get; set; } = ""; + public decimal? Gdgd { get; set; } /// /// 负债与所有者权益比率(%) /// [JsonPropertyName("fzqy")] - public string Fzqy { get; set; } = ""; + public decimal? Fzqy { get; set; } /// /// 长期资产与长期资金比率(%) /// [JsonPropertyName("zczjbl")] - public string Zczjbl { get; set; } = ""; + public decimal? Zczjbl { get; set; } /// /// 资本化比率(%) /// [JsonPropertyName("zblv")] - public string Zblv { get; set; } = ""; + public decimal? Zblv { get; set; } /// /// 固定资产净值率(%) /// [JsonPropertyName("gdzcjz")] - public string Gdzcjz { get; set; } = ""; + public decimal? Gdzcjz { get; set; } /// /// 资本固定化比率(%) /// [JsonPropertyName("zbgdh")] - public string Zbgdh { get; set; } = ""; + public decimal? Zbgdh { get; set; } /// /// 产权比率(%) /// [JsonPropertyName("cqbl")] - public string Cqbl { get; set; } = ""; + public decimal? Cqbl { get; set; } /// /// 清算价值比率(%) /// [JsonPropertyName("qxjzb")] - public string Qxjzb { get; set; } = ""; + public decimal? Qxjzb { get; set; } /// /// 固定资产比重(%) /// [JsonPropertyName("gdzcbz")] - public string Gdzcbz { get; set; } = ""; + public decimal? Gdzcbz { get; set; } /// /// 资产负债率(%) /// [JsonPropertyName("zcfzl")] - public string Zcfzl { get; set; } = ""; + public decimal? Zcfzl { get; set; } /// /// 总资产(元) /// [JsonPropertyName("zzc")] - public string Zzc { get; set; } = ""; + public decimal? Zzc { get; set; } /// /// 经营现金净流量对销售收入比率(%) /// [JsonPropertyName("jyxj")] - public string Jyxj { get; set; } = ""; + public decimal? Jyxj { get; set; } /// /// 资产的经营现金流量回报率(%) /// [JsonPropertyName("zcjyxj")] - public string Zcjyxj { get; set; } = ""; + public decimal? Zcjyxj { get; set; } /// /// 经营现金净流量与净利润的比率(%) /// [JsonPropertyName("jylrb")] - public string Jylrb { get; set; } = ""; + public decimal? Jylrb { get; set; } /// /// 经营现金净流量对负债比率(%) /// [JsonPropertyName("jyfzl")] - public string Jyfzl { get; set; } = ""; + public decimal? Jyfzl { get; set; } /// /// 现金流量比率(%) /// [JsonPropertyName("xjlbl")] - public string Xjlbl { get; set; } = ""; + public decimal? Xjlbl { get; set; } /// /// 短期股票投资(元) /// [JsonPropertyName("dqgptz")] - public string Dqgptz { get; set; } = ""; + public decimal? Dqgptz { get; set; } /// /// 短期债券投资(元) /// [JsonPropertyName("dqzctz")] - public string Dqzctz { get; set; } = ""; + public decimal? Dqzctz { get; set; } /// /// 短期其它经营性投资(元) /// [JsonPropertyName("dqjytz")] - public string Dqjytz { get; set; } = ""; + public decimal? Dqjytz { get; set; } /// /// 长期股票投资(元) /// [JsonPropertyName("qcgptz")] - public string Qcgptz { get; set; } = ""; + public decimal? Qcgptz { get; set; } /// /// 长期债券投资(元) /// [JsonPropertyName("cqzqtz")] - public string Cqzqtz { get; set; } = ""; + public decimal? Cqzqtz { get; set; } /// /// 长期其它经营性投资(元) /// [JsonPropertyName("cqjyxtz")] - public string Cqjyxtz { get; set; } = ""; + public decimal? Cqjyxtz { get; set; } /// /// 1年以内应收帐款(元) /// [JsonPropertyName("yszk1")] - public string Yszk1 { get; set; } = ""; + public decimal? Yszk1 { get; set; } /// /// 1-2年以内应收帐款(元) /// [JsonPropertyName("yszk12")] - public string Yszk12 { get; set; } = ""; + public decimal? Yszk12 { get; set; } /// /// 2-3年以内应收帐款(元) /// [JsonPropertyName("yszk23")] - public string Yszk23 { get; set; } = ""; + public decimal? Yszk23 { get; set; } /// /// 3年以内应收帐款(元) /// [JsonPropertyName("yszk3")] - public string Yszk3 { get; set; } = ""; + public decimal? Yszk3 { get; set; } /// /// 1年以内预付货款(元) /// [JsonPropertyName("yfhk1")] - public string Yfhk1 { get; set; } = ""; + public decimal? Yfhk1 { get; set; } /// /// 1-2年以内预付货款(元) /// [JsonPropertyName("yfhk12")] - public string Yfhk12 { get; set; } = ""; + public decimal? Yfhk12 { get; set; } /// /// 2-3年以内预付货款(元) /// [JsonPropertyName("yfhk23")] - public string Yfhk23 { get; set; } = ""; + public decimal? Yfhk23 { get; set; } /// /// 3年以内预付货款(元) /// [JsonPropertyName("yfhk3")] - public string Yfhk3 { get; set; } = ""; + public decimal? Yfhk3 { get; set; } /// /// 1年以内其它应收款(元) /// [JsonPropertyName("ysk1")] - public string Ysk1 { get; set; } = ""; + public decimal? Ysk1 { get; set; } /// /// 1-2年以内其它应收款(元) /// [JsonPropertyName("ysk12")] - public string Ysk12 { get; set; } = ""; + public decimal? Ysk12 { get; set; } /// /// 2-3年以内其它应收款(元) /// [JsonPropertyName("ysk23")] - public string Ysk23 { get; set; } = ""; + public decimal? Ysk23 { get; set; } /// /// 3年以内其它应收款(元) /// [JsonPropertyName("ysk3")] - public string Ysk3 { get; set; } = ""; -} \ No newline at end of file + public decimal? Ysk3 { get; set; } +} diff --git a/src/Agents/Tools/Models/FinancialRatios.cs b/src/MarketAssistant.Agents/Tools/Models/AShare/FinancialRatios.cs similarity index 56% rename from src/Agents/Tools/Models/FinancialRatios.cs rename to src/MarketAssistant.Agents/Tools/Models/AShare/FinancialRatios.cs index 54829b6..d4a8f86 100644 --- a/src/Agents/Tools/Models/FinancialRatios.cs +++ b/src/MarketAssistant.Agents/Tools/Models/AShare/FinancialRatios.cs @@ -1,178 +1,120 @@ +using System.ComponentModel; using System.Text.Json.Serialization; -namespace MarketAssistant.Agents.Plugins.Models; +namespace MarketAssistant.Agents.Tools.Models.AShare; -/// -/// 财务主要指标 -/// +[Description("财务主要指标")] public class FinancialRatios { - /// - /// 截止日期 - /// + [Description("报告截止日期")] [JsonPropertyName("jzrq")] public string EndDate { get; set; } = ""; - /// - /// 披露日期 - /// + [Description("披露日期")] [JsonPropertyName("plrq")] public string DisclosureDate { get; set; } = ""; - /// - /// 每股经营活动现金流量 - /// + [Description("每股经营活动现金流量")] [JsonPropertyName("mgjyhdxjl")] public decimal? CashFlowPerShare { get; set; } - /// - /// 每股净资产 - /// + [Description("每股净资产")] [JsonPropertyName("mgjzc")] public decimal? NetAssetsPerShare { get; set; } - /// - /// 基本每股收益 - /// + [Description("基本每股收益")] [JsonPropertyName("jbmgsy")] public decimal? BasicEarningsPerShare { get; set; } - /// - /// 稀释每股收益 - /// + [Description("稀释每股收益")] [JsonPropertyName("xsmgsy")] public decimal? DilutedEarningsPerShare { get; set; } - /// - /// 每股未分配利润 - /// + [Description("每股未分配利润")] [JsonPropertyName("mgwfplr")] public decimal? RetainedEarningsPerShare { get; set; } - /// - /// 每股资本公积金 - /// + [Description("每股资本公积金")] [JsonPropertyName("mgzbgjj")] public decimal? CapitalReservePerShare { get; set; } - /// - /// 扣非每股收益 - /// + [Description("扣非每股收益")] [JsonPropertyName("kfmgsy")] public decimal? AdjustedEarningsPerShare { get; set; } - /// - /// 净资产收益率 - /// + [Description("净资产收益率(%)")] [JsonPropertyName("jzcsyl")] public decimal? ReturnOnEquity { get; set; } - /// - /// 加权净资产收益率 - /// + [Description("加权净资产收益率(%)")] [JsonPropertyName("jqjzcsyl")] public decimal? WeightedROE { get; set; } - /// - /// 摊薄净资产收益率 - /// + [Description("摊薄净资产收益率(%)")] [JsonPropertyName("tbjzcsyl")] public decimal? DilutedROE { get; set; } - /// - /// 摊薄总资产收益率 - /// + [Description("摊薄总资产收益率(%)")] [JsonPropertyName("tbzzcsyl")] public decimal? ReturnOnAssets { get; set; } - /// - /// 销售毛利率 - /// + [Description("销售毛利率(%)")] [JsonPropertyName("xsmlv")] public decimal? GrossMargin { get; set; } - /// - /// 毛利率 - /// + [Description("毛利率(%)")] [JsonPropertyName("mlv")] public decimal? GrossProfitMargin { get; set; } - /// - /// 净利率 - /// + [Description("净利率(%)")] [JsonPropertyName("jlv")] public decimal? NetProfitMargin { get; set; } - /// - /// 实际税率 - /// + [Description("实际税率(%)")] [JsonPropertyName("sjslv")] public decimal? EffectiveTaxRate { get; set; } - /// - /// 预收款营业收入 - /// + [Description("预收账款占营业收入比(%)")] [JsonPropertyName("yskyysr")] public decimal? AdvanceReceiptsToRevenue { get; set; } - /// - /// 销售现金流营业收入 - /// + [Description("销售现金流占营业收入比(%)")] [JsonPropertyName("xsxjlyysr")] public decimal? OperatingCashFlowToRevenue { get; set; } - /// - /// 资产负债比率 - /// + [Description("资产负债率(%)")] [JsonPropertyName("zcfzl")] public decimal? AssetLiabilityRatio { get; set; } - /// - /// 存货周转率 - /// + [Description("存货周转率")] [JsonPropertyName("chzzl")] public decimal? InventoryTurnoverRatio { get; set; } - /// - /// 主营收入同比增长 - /// + [Description("主营收入同比增长(%)")] [JsonPropertyName("zyyrsrzz")] public decimal? RevenueGrowthYoY { get; set; } - /// - /// 净利润同比增长 - /// + [Description("净利润同比增长(%)")] [JsonPropertyName("jlrzz")] public decimal? NetProfitGrowthYoY { get; set; } - /// - /// 归属于母公司所有者的净利润同比增长 - /// + [Description("归母净利润同比增长(%)")] [JsonPropertyName("gsmgsyzzdjlrzz")] public decimal? ParentNetProfitGrowthYoY { get; set; } - /// - /// 扣非净利润同比增长 - /// + [Description("扣非净利润同比增长(%)")] [JsonPropertyName("kfjlrzz")] public decimal? AdjustedNetProfitGrowthYoY { get; set; } - /// - /// 营业总收入滚动环比增长 - /// + [Description("营业总收入滚动环比增长(%)")] [JsonPropertyName("yyzsrgdhbzz")] public decimal? RevenueGrowthQoQ { get; set; } - /// - /// 归属净利润滚动环比增长 - /// + [Description("归属净利润滚动环比增长(%)")] [JsonPropertyName("sljlrjqhbzz")] public decimal? NetProfitGrowthQoQ { get; set; } - /// - /// 扣非净利润滚动环比增长 - /// + [Description("扣非净利润滚动环比增长(%)")] [JsonPropertyName("kfjlrgdhbzz")] public decimal? AdjustedNetProfitGrowthQoQ { get; set; } -} - +} \ No newline at end of file diff --git a/src/MarketAssistant.Agents/Tools/Models/AShare/FundFlow.cs b/src/MarketAssistant.Agents/Tools/Models/AShare/FundFlow.cs new file mode 100644 index 0000000..c13b449 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/AShare/FundFlow.cs @@ -0,0 +1,46 @@ +using System.ComponentModel; + +namespace MarketAssistant.Agents.Tools.Models.AShare; + +/// +/// 个股资金流向数据(主力 = 特大单 + 大单) +/// +[Description("个股资金流向数据,主力定义为特大单(成交额≥100万)与大单(≥20万)之和")] +public class FundFlow +{ + [Description("交易日期,格式YYYYMMDD")] + public int Date { get; set; } + + [Description("主力流入金额:特大单+大单的主动买入成交额合计")] + public decimal MainFundIn { get; set; } + + [Description("主力流出金额:特大单+大单的主动卖出成交额合计")] + public decimal MainFundOut { get; set; } + + [Description("主力净流入(正值表示主力资金流入,负值表示流出)")] + public decimal MainFundDiff { get; set; } + + [Description("超大单净流入(成交额≥100万或成交量≥5000手的订单)")] + public decimal SuperFundDiff { get; set; } + + [Description("大单净流入(成交额≥20万或成交量≥1000手的订单)")] + public decimal LargeFundDiff { get; set; } + + [Description("中单净流入(成交额≥4万或成交量≥200手的订单)")] + public decimal MediumFundDiff { get; set; } + + [Description("小单净流入(中单以下的散户订单)")] + public decimal LittleFundDiff { get; set; } + + [Description("近3个交易日主力累计净流入")] + public decimal MainFund3 { get; set; } + + [Description("近5个交易日主力累计净流入")] + public decimal MainFund5 { get; set; } + + [Description("近10个交易日主力累计净流入")] + public decimal MainFund10 { get; set; } + + [Description("近20个交易日主力累计净流入")] + public decimal MainFund20 { get; set; } +} diff --git a/src/Agents/Tools/Models/FundFlowTrend.cs b/src/MarketAssistant.Agents/Tools/Models/AShare/FundFlowTrend.cs similarity index 84% rename from src/Agents/Tools/Models/FundFlowTrend.cs rename to src/MarketAssistant.Agents/Tools/Models/AShare/FundFlowTrend.cs index 17ffcc9..8585138 100644 --- a/src/Agents/Tools/Models/FundFlowTrend.cs +++ b/src/MarketAssistant.Agents/Tools/Models/AShare/FundFlowTrend.cs @@ -1,9 +1,9 @@ using System.Text.Json.Serialization; -namespace MarketAssistant.Agents.Plugins.Models; +namespace MarketAssistant.Agents.Tools.Models.AShare; /// -/// 资金流向趋势数据(最近10天) +/// 资金流向趋势数据(最?0天) /// class FundFlowTrend { @@ -15,13 +15,13 @@ class FundFlowTrend public string Date { get; set; } = ""; /// - /// 涨跌幅 (单位:%) + /// 涨跌?(单位?) /// [JsonPropertyName("zdf")] public decimal ChangePercent { get; set; } /// - /// 换手率 (单位:%) + /// 换手?(单位?) /// [JsonPropertyName("hsl")] public decimal TurnoverRate { get; set; } @@ -34,7 +34,7 @@ class FundFlowTrend public long NetInflow { get; set; } /// - /// 净流入率 (单位:%) + /// 净流入?(单位?) /// [JsonPropertyName("jlrl")] public decimal NetInflowRatio { get; set; } @@ -47,7 +47,7 @@ class FundFlowTrend public long MainForceInflow { get; set; } /// - /// 主力净流入率 (单位:%) + /// 主力净流入?(单位?) /// [JsonPropertyName("zljlrl")] public decimal MainForceInflowRatio { get; set; } @@ -60,14 +60,14 @@ class FundFlowTrend public long IndustryInflow { get; set; } /// - /// 行业净流入率 (单位:%) + /// 行业净流入?(单位?) /// [JsonPropertyName("hyjlrl")] public decimal IndustryInflowRatio { get; set; } // ================= 辅助方法 ================= /// - /// 净流入金额转换为万元 + /// 净流入金额转换为万? /// public decimal NetInflowToTenThousand() => NetInflow / 10000m; } diff --git a/src/Agents/Tools/Models/IncomeStatement.cs b/src/MarketAssistant.Agents/Tools/Models/AShare/IncomeStatement.cs similarity index 57% rename from src/Agents/Tools/Models/IncomeStatement.cs rename to src/MarketAssistant.Agents/Tools/Models/AShare/IncomeStatement.cs index 8417c43..4e2b815 100644 --- a/src/Agents/Tools/Models/IncomeStatement.cs +++ b/src/MarketAssistant.Agents/Tools/Models/AShare/IncomeStatement.cs @@ -1,184 +1,124 @@ +using System.ComponentModel; using System.Text.Json.Serialization; -namespace MarketAssistant.Agents.Plugins.Models; +namespace MarketAssistant.Agents.Tools.Models.AShare; -/// -/// 利润表 -/// +[Description("利润表")] public class IncomeStatement { - /// - /// 截止日期 - /// + [Description("报告截止日期")] [JsonPropertyName("jzrq")] public string EndDate { get; set; } = ""; - /// - /// 披露日期 - /// + [Description("披露日期")] [JsonPropertyName("plrq")] public string DisclosureDate { get; set; } = ""; - /// - /// 营业收入 - /// + [Description("营业收入")] [JsonPropertyName("yysr")] public decimal? OperatingRevenue { get; set; } - /// - /// 营业总收入 - /// + [Description("营业总收入")] [JsonPropertyName("yyzsr")] public decimal? TotalOperatingRevenue { get; set; } - /// - /// 营业成本 - /// + [Description("营业成本")] [JsonPropertyName("yycb")] public decimal? OperatingCost { get; set; } - /// - /// 营业总成本 - /// + [Description("营业总成本")] [JsonPropertyName("yyzcb")] public decimal? TotalOperatingCost { get; set; } - /// - /// 营业税金及附加 - /// + [Description("营业税金及附加")] [JsonPropertyName("yysjjfj")] public decimal? BusinessTaxAndSurcharges { get; set; } - /// - /// 销售费用 - /// + [Description("销售费用")] [JsonPropertyName("xsfy")] public decimal? SellingExpenses { get; set; } - /// - /// 管理费用 - /// + [Description("管理费用")] [JsonPropertyName("glfy")] public decimal? AdministrativeExpenses { get; set; } - /// - /// 研发费用 - /// + [Description("研发费用")] [JsonPropertyName("yffy")] public decimal? RAndDExpenses { get; set; } - /// - /// 财务费用 - /// + [Description("财务费用")] [JsonPropertyName("cwfy")] public decimal? FinancialExpenses { get; set; } - /// - /// 利息收入 - /// + [Description("利息收入")] [JsonPropertyName("lxsr")] public decimal? InterestIncome { get; set; } - /// - /// 利息支出 - /// + [Description("利息支出")] [JsonPropertyName("lxzc")] public decimal? InterestExpense { get; set; } - /// - /// 资产减值损失 - /// + [Description("资产减值损失")] [JsonPropertyName("zcjzss")] public decimal? AssetImpairmentLoss { get; set; } - /// - /// 公允价值变动收益 - /// + [Description("公允价值变动收益")] [JsonPropertyName("gyjzbdsy")] public decimal? FairValueChangeGain { get; set; } - /// - /// 投资收益 - /// + [Description("投资收益")] [JsonPropertyName("tzsy")] public decimal? InvestmentIncome { get; set; } - /// - /// 联营企业和合营企业的投资收益 - /// + [Description("联营企业和合营企业的投资收益")] [JsonPropertyName("lyqyhhhqydtzsy")] public decimal? InvestmentIncomeFromAssociates { get; set; } - /// - /// 其他收益 - /// + [Description("其他收益")] [JsonPropertyName("qtsy")] public decimal? OtherIncome { get; set; } - /// - /// 营业利润 - /// + [Description("营业利润")] [JsonPropertyName("yylr")] public decimal? OperatingProfit { get; set; } - /// - /// 营业外收入 - /// + [Description("营业外收入")] [JsonPropertyName("ywsr")] public decimal? NonOperatingIncome { get; set; } - /// - /// 营业外支出 - /// + [Description("营业外支出")] [JsonPropertyName("ywzc")] public decimal? NonOperatingExpenses { get; set; } - /// - /// 利润总额 - /// + [Description("利润总额")] [JsonPropertyName("lrze")] public decimal? TotalProfit { get; set; } - /// - /// 所得税费用 - /// + [Description("所得税费用")] [JsonPropertyName("sdsfy")] public decimal? IncomeTaxExpense { get; set; } - /// - /// 净利润 - /// + [Description("净利润")] [JsonPropertyName("jlr")] public decimal? NetProfit { get; set; } - /// - /// 归属于母公司所有者的净利润 - /// + [Description("归属于母公司所有者的净利润")] [JsonPropertyName("gsmgsyzzdjlr")] public decimal? NetProfitAttributableToParent { get; set; } - /// - /// 少数股东损益 - /// + [Description("少数股东损益")] [JsonPropertyName("ssgdsy")] public decimal? MinorityInterestIncome { get; set; } - /// - /// 基本每股收益 - /// + [Description("基本每股收益")] [JsonPropertyName("jbmgsy")] public decimal? BasicEarningsPerShare { get; set; } - /// - /// 稀释每股收益 - /// + [Description("稀释每股收益")] [JsonPropertyName("xsmgsy")] public decimal? DilutedEarningsPerShare { get; set; } - /// - /// 综合收益总额 - /// + [Description("综合收益总额")] [JsonPropertyName("zhsyz")] public decimal? TotalComprehensiveIncome { get; set; } -} - +} \ No newline at end of file diff --git a/src/MarketAssistant.Agents/Tools/Models/AShare/MarketCapInfo.cs b/src/MarketAssistant.Agents/Tools/Models/AShare/MarketCapInfo.cs new file mode 100644 index 0000000..99435e4 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/AShare/MarketCapInfo.cs @@ -0,0 +1,52 @@ +namespace MarketAssistant.Agents.Tools.Models.AShare; + +/// +/// 市值与市场排名信息 +/// +public class MarketCapInfo +{ + /// + /// 代币符号 + /// + public string Symbol { get; set; } = string.Empty; + + /// + /// 代币名称 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 当前价格(USD? + /// + public decimal CurrentPrice { get; set; } + + /// + /// 市值(USD? + /// + public decimal MarketCap { get; set; } + + /// + /// 完全稀释市值(USD? + /// + public decimal? FullyDilutedValuation { get; set; } + + /// + /// 市场排名 + /// + public int MarketCapRank { get; set; } + + /// + /// 24 小时交易量(USD? + /// + public decimal Volume24h { get; set; } + + /// + /// 24 小时价格变化?? + /// + public decimal? PriceChange24h { get; set; } + + /// + /// 数据更新时间 + /// + public string UpdatedAt { get; set; } = string.Empty; +} diff --git a/src/Agents/Tools/Models/QuarterlyCashFlow.cs b/src/MarketAssistant.Agents/Tools/Models/AShare/QuarterlyCashFlow.cs similarity index 89% rename from src/Agents/Tools/Models/QuarterlyCashFlow.cs rename to src/MarketAssistant.Agents/Tools/Models/AShare/QuarterlyCashFlow.cs index 7da3e09..fca6d8d 100644 --- a/src/Agents/Tools/Models/QuarterlyCashFlow.cs +++ b/src/MarketAssistant.Agents/Tools/Models/AShare/QuarterlyCashFlow.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace MarketAssistant.Agents.Plugins.Models; +namespace MarketAssistant.Agents.Tools.Models.AShare; /// /// 季度现金流数据实体类 @@ -26,7 +26,7 @@ public class QuarterlyCashFlow public string OperatingCashflowOut { get; set; } = ""; /// - /// 经营活动产生的现金流量净额(万元) + /// 经营活动产生的现金流量净额(万元? /// [JsonPropertyName("jyfinal")] public string OperatingCashflowNet { get; set; } = ""; @@ -44,7 +44,7 @@ public class QuarterlyCashFlow public string InvestingCashflowOut { get; set; } = ""; /// - /// 投资活动产生的现金流量净额(万元) + /// 投资活动产生的现金流量净额(万元? /// [JsonPropertyName("tzfinal")] public string InvestingCashflowNet { get; set; } = ""; @@ -62,7 +62,7 @@ public class QuarterlyCashFlow public string FinancingCashflowOut { get; set; } = ""; /// - /// 筹资活动产生的现金流量净额(万元) + /// 筹资活动产生的现金流量净额(万元? /// [JsonPropertyName("czfinal")] public string FinancingCashflowNet { get; set; } = ""; @@ -74,7 +74,7 @@ public class QuarterlyCashFlow public string ExchangeRateEffect { get; set; } = ""; /// - /// 现金及现金等价物净增加额(万元) + /// 现金及现金等价物净增加额(万元? /// [JsonPropertyName("cashinc")] public string CashNetIncrease { get; set; } = ""; @@ -90,4 +90,4 @@ public class QuarterlyCashFlow /// [JsonPropertyName("cashe")] public string CashEnding { get; set; } = ""; -} \ No newline at end of file +} diff --git a/src/Agents/Tools/Models/QuarterlyProfit.cs b/src/MarketAssistant.Agents/Tools/Models/AShare/QuarterlyProfit.cs similarity index 91% rename from src/Agents/Tools/Models/QuarterlyProfit.cs rename to src/MarketAssistant.Agents/Tools/Models/AShare/QuarterlyProfit.cs index 55ce14f..c286091 100644 --- a/src/Agents/Tools/Models/QuarterlyProfit.cs +++ b/src/MarketAssistant.Agents/Tools/Models/AShare/QuarterlyProfit.cs @@ -1,9 +1,9 @@ using System.Text.Json.Serialization; -namespace MarketAssistant.Agents.Plugins.Models; +namespace MarketAssistant.Agents.Tools.Models.AShare; /// -/// 季度利润数据实体类 +/// 季度利润数据实体? /// public class QuarterlyProfit { @@ -44,13 +44,13 @@ public class QuarterlyProfit public string NetProfit { get; set; } = ""; /// - /// 基本每股收益(元/股) + /// 基本每股收益(?? /// [JsonPropertyName("basege")] public string BasicEarningsPerShare { get; set; } = ""; /// - /// 稀释每股收益(元/股) + /// 稀释每股收??? /// [JsonPropertyName("ettege")] public string DilutedEarningsPerShare { get; set; } = ""; @@ -66,4 +66,4 @@ public class QuarterlyProfit /// [JsonPropertyName("totalcp")] public string TotalComprehensiveIncome { get; set; } = ""; -} \ No newline at end of file +} diff --git a/src/Agents/Tools/Models/ShareholderCount.cs b/src/MarketAssistant.Agents/Tools/Models/AShare/ShareholderCount.cs similarity index 89% rename from src/Agents/Tools/Models/ShareholderCount.cs rename to src/MarketAssistant.Agents/Tools/Models/AShare/ShareholderCount.cs index 520fd68..771d4ed 100644 --- a/src/Agents/Tools/Models/ShareholderCount.cs +++ b/src/MarketAssistant.Agents/Tools/Models/AShare/ShareholderCount.cs @@ -1,9 +1,9 @@ using System.Text.Json.Serialization; -namespace MarketAssistant.Agents.Plugins.Models; +namespace MarketAssistant.Agents.Tools.Models.AShare; /// -/// 股东数信息 +/// 股东数信? /// public class ShareholderCount { @@ -38,13 +38,13 @@ public class ShareholderCount public string HShareholderCount { get; set; } = ""; /// - /// 已流通股东户数 + /// 已流通股东户? /// [JsonPropertyName("yltgdhs")] public string CirculatingShareholderCount { get; set; } = ""; /// - /// 未流通股东户数 + /// 未流通股东户? /// [JsonPropertyName("wltgdhs")] public string NonCirculatingShareholderCount { get; set; } = ""; diff --git a/src/MarketAssistant.Agents/Tools/Models/AShare/StockQuoteInfo.cs b/src/MarketAssistant.Agents/Tools/Models/AShare/StockQuoteInfo.cs new file mode 100644 index 0000000..a89e5c4 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/AShare/StockQuoteInfo.cs @@ -0,0 +1,97 @@ +using System.ComponentModel; + +namespace MarketAssistant.Agents.Tools.Models.AShare; + +[Description("A股个股实时行情数据")] +public class StockQuoteInfo +{ + [Description("证券名称")] + public string SecurityName { get; set; } = string.Empty; + + [Description("证券代码")] + public string SecurityCode { get; set; } = string.Empty; + + [Description("交易状态")] + public string TradeStatus { get; set; } = string.Empty; + + [Description("证券类型")] + public string SecurityType { get; set; } = string.Empty; + + [Description("当前价格(元)")] + public decimal CurrentPrice { get; set; } + + [Description("今日开盘价")] + public decimal OpenPrice { get; set; } + + [Description("昨日收盘价")] + public decimal PreviousClosePrice { get; set; } + + [Description("今日最高价(元)")] + public decimal HighPrice { get; set; } + + [Description("今日最低价(元)")] + public decimal LowPrice { get; set; } + + [Description("涨停价格")] + public decimal UpLimitPrice { get; set; } + + [Description("跌停价格")] + public decimal DownLimitPrice { get; set; } + + [Description("涨跌金额")] + public decimal PriceChange { get; set; } + + [Description("涨跌幅(%)")] + public decimal PercentageChange { get; set; } + + [Description("3日涨跌幅(%)")] + public decimal PercentageChange3Day { get; set; } + + [Description("5日涨跌幅(%)")] + public decimal PercentageChange5Day { get; set; } + + [Description("振幅(%)")] + public decimal Amplitude { get; set; } + + [Description("成交量(手)")] + public decimal Volume { get; set; } + + [Description("成交额(元)")] + public decimal Amount { get; set; } + + [Description("换手率(%)")] + public decimal TurnoverRate { get; set; } + + [Description("量比(大于1表示放量)")] + public decimal VolumeRatio { get; set; } + + [Description("委比(%),正值表示买盘强")] + public decimal EntrustRatio { get; set; } + + [Description("均价(元)")] + public decimal AveragePrice { get; set; } + + [Description("总股本")] + public decimal TotalShares { get; set; } + + [Description("流通股本")] + public decimal NonRestrictedShares { get; set; } + + [Description("总市值(元)")] + public decimal MarketCapitalization { get; set; } + + [Description("流通市值(元)")] + public decimal CirculationMarketCap { get; set; } + + [Description("市盈率(静)")] + public decimal PERatio { get; set; } + + [Description("市盈率(TTM)")] + public decimal TTMPERatio { get; set; } + + [Description("市净率")] + public decimal PBRatio { get; set; } + + [Description("每股净资产(元)")] + public decimal NetAssetPerShare { get; set; } +} \ No newline at end of file diff --git a/src/Agents/Tools/Models/TopShareholder.cs b/src/MarketAssistant.Agents/Tools/Models/AShare/TopShareholder.cs similarity index 96% rename from src/Agents/Tools/Models/TopShareholder.cs rename to src/MarketAssistant.Agents/Tools/Models/AShare/TopShareholder.cs index 739e5a6..af849cb 100644 --- a/src/Agents/Tools/Models/TopShareholder.cs +++ b/src/MarketAssistant.Agents/Tools/Models/AShare/TopShareholder.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace MarketAssistant.Agents.Plugins.Models; +namespace MarketAssistant.Agents.Tools.Models.AShare; /// /// 十大股东信息 diff --git a/src/Agents/Tools/Models/StockQuoteInfo.cs b/src/MarketAssistant.Agents/Tools/Models/AssetQuoteInfo.cs similarity index 96% rename from src/Agents/Tools/Models/StockQuoteInfo.cs rename to src/MarketAssistant.Agents/Tools/Models/AssetQuoteInfo.cs index 04a95ac..fa41643 100644 --- a/src/Agents/Tools/Models/StockQuoteInfo.cs +++ b/src/MarketAssistant.Agents/Tools/Models/AssetQuoteInfo.cs @@ -1,9 +1,9 @@ -namespace MarketAssistant.Agents.Plugins.Models; +namespace MarketAssistant.Agents.Tools.Models; /// -/// 股票行情数据模型 +/// 资产行情数据模型 /// -public class StockQuoteInfo +public class AssetQuoteInfo { /// /// 当前价格(元) @@ -155,3 +155,5 @@ public class StockQuoteInfo /// public decimal EntrustRatio { get; set; } } + + diff --git a/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoMarketMetrics.cs b/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoMarketMetrics.cs new file mode 100644 index 0000000..59bf0a7 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoMarketMetrics.cs @@ -0,0 +1,126 @@ +using System.ComponentModel; + +namespace MarketAssistant.Agents.Tools.Models.Crypto; + +/// +/// 加密货币综合市场指标 +/// +[Description("加密货币综合市值与供应量指标")] +public class CryptoMarketMetrics +{ + /// + /// 交易对符号 + /// + [Description("代币符号")] + public string Symbol { get; set; } = string.Empty; + + /// + /// 当前价格(USD) + /// + [Description("当前美元价格")] + public decimal CurrentPriceUsd { get; set; } + + /// + /// 市值(USD) + /// + [Description("总市值(USD)")] + public decimal MarketCapUsd { get; set; } + + /// + /// 完全稀释估值(USD) + /// + [Description("完全稀释估值 FDV(USD)")] + public decimal? FullyDilutedValuationUsd { get; set; } + + /// + /// 流通供应量 + /// + [Description("当前代币流通量")] + public decimal CirculatingSupply { get; set; } + + /// + /// 总供应量 + /// + [Description("代币总供应量")] + public decimal? TotalSupply { get; set; } + + /// + /// 最大供应量(null表示无上限) + /// + [Description("代币最大供应量上限(null表示无上限)")] + public decimal? MaxSupply { get; set; } + + /// + /// 24小时交易量(USD) + /// + [Description("24小时交易额(USD)")] + public decimal Volume24hUsd { get; set; } + + /// + /// 交易量/市值比率 + /// + [Description("交易量/市值比率")] + public decimal VolumeToMarketCapRatio => MarketCapUsd > 0 ? Volume24hUsd / MarketCapUsd : 0; + + /// + /// 24小时价格变动(%) + /// + [Description("24小时价格涨跌幅(%)")] + public decimal PriceChange24hPercent { get; set; } + + /// + /// 7天价格变动(%) + /// + [Description("7天价格涨跌幅(%)")] + public decimal? PriceChange7dPercent { get; set; } + + /// + /// 30天价格变动(%) + /// + [Description("30天价格涨跌幅(%)")] + public decimal? PriceChange30dPercent { get; set; } + + /// + /// 市值排名 + /// + [Description("市值规模全网排名")] + public int? MarketCapRank { get; set; } + + /// + /// 历史最高价(USD) + /// + [Description("历史最高价 ATH(USD)")] + public decimal? AllTimeHighUsd { get; set; } + + /// + /// 距离历史最高价跌幅(%) + /// + [Description("距离历史最高价的跌幅(%)")] + public decimal? AthChangePercent { get; set; } + + /// + /// 历史最低价(USD) + /// + [Description("历史最低价 ATL(USD)")] + public decimal? AllTimeLowUsd { get; set; } + + /// + /// 距离历史最低价涨幅(%) + /// + [Description("距离历史最低价的涨幅(%)")] + public decimal? AtlChangePercent { get; set; } + + /// + /// 流通率(流通量/最大供应,%) + /// + [Description("代币流通比率(%)")] + public decimal? CirculationRate => MaxSupply.HasValue && MaxSupply > 0 + ? CirculatingSupply / MaxSupply.Value * 100 + : null; + + /// + /// 数据更新时间 + /// + [Description("数据最后更新时间")] + public DateTime LastUpdated { get; set; } +} \ No newline at end of file diff --git a/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoOHLCV.cs b/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoOHLCV.cs new file mode 100644 index 0000000..fad0f83 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoOHLCV.cs @@ -0,0 +1,89 @@ +using System.ComponentModel; + +namespace MarketAssistant.Agents.Tools.Models.Crypto; + +/// +/// OHLCV K线数据(开盘、最高、最低、收盘、成交量) +/// +[Description("加密货币OHLCV历史蜡烛图K线数据")] +public class CryptoOHLCV +{ + /// + /// 交易对符号 + /// + [Description("代币符号")] + public string Symbol { get; set; } = string.Empty; + + /// + /// 时间间隔(如 1m, 5m, 1h, 1d) + /// + [Description("K线周期时间间隔(如1m, 1h, 1d等)")] + public string Interval { get; set; } = string.Empty; + + /// + /// K线数据点列表(按时间升序) + /// + [Description("K线柱列表(按时间升序)")] + public List Candles { get; set; } = []; +} + +/// +/// 单根K线数据 +/// +[Description("单根K线柱柱体明细")] +public class OHLCVCandle +{ + /// + /// 开盘时间(Unix时间戳毫秒) + /// + [Description("开盘时间戳(ms)")] + public long OpenTime { get; set; } + + /// + /// 收盘时间(Unix时间戳毫秒) + /// + [Description("收盘时间戳(ms)")] + public long CloseTime { get; set; } + + /// + /// 开盘价 + /// + [Description("开盘价")] + public decimal Open { get; set; } + + /// + /// 最高价 + /// + [Description("最高价")] + public decimal High { get; set; } + + /// + /// 最低价 + /// + [Description("最低价")] + public decimal Low { get; set; } + + /// + /// 收盘价 + /// + [Description("收盘价")] + public decimal Close { get; set; } + + /// + /// 成交量(基础货币) + /// + [Description("基础代币成交量")] + public decimal Volume { get; set; } + + /// + /// 成交额(计价货币) + /// + [Description("计价成交总额(通常为USDT)")] + public decimal QuoteVolume { get; set; } + + /// + /// 成交笔数 + /// + [Description("成交笔数(单数)")] + public int TradeCount { get; set; } +} \ No newline at end of file diff --git a/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoOrderBookDepth.cs b/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoOrderBookDepth.cs new file mode 100644 index 0000000..2ae0ef6 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoOrderBookDepth.cs @@ -0,0 +1,95 @@ +using System.ComponentModel; + +namespace MarketAssistant.Agents.Tools.Models.Crypto; + +/// +/// 订单簿深度数据 +/// +[Description("加密货币市场深度订单簿快照")] +public class CryptoOrderBookDepth +{ + /// + /// 交易对符号 + /// + [Description("代币符号")] + public string Symbol { get; set; } = string.Empty; + + /// + /// 最新更新ID + /// + [Description("最新更新ID")] + public long LastUpdateId { get; set; } + + /// + /// 买盘深度(按价格降序,最高买价在前) + /// + [Description("买单列表(Bids,按价格降序)")] + public List Bids { get; set; } = []; + + /// + /// 卖盘深度(按价格升序,最低卖价在前) + /// + [Description("卖单列表(Asks,按价格升序)")] + public List Asks { get; set; } = []; + + /// + /// 最优买价(最高买入价) + /// + [Description("最优买入价(盘口买一)")] + public decimal BestBidPrice => Bids.FirstOrDefault()?.Price ?? 0; + + /// + /// 最优卖价(最低卖出价) + /// + [Description("最优卖出价(盘口卖一)")] + public decimal BestAskPrice => Asks.FirstOrDefault()?.Price ?? 0; + + /// + /// 买卖价差(spread) + /// + [Description("买卖价差(点差)")] + public decimal Spread => BestAskPrice - BestBidPrice; + + /// + /// 价差百分比 + /// + [Description("价差百分比(%)")] + public decimal SpreadPercent => BestBidPrice > 0 ? (Spread / BestBidPrice * 100) : 0; + + /// + /// 买盘总量(前N档) + /// + [Description("买盘累计委托量")] + public decimal TotalBidVolume => Bids.Sum(b => b.Quantity); + + /// + /// 卖盘总量(前N档) + /// + [Description("卖盘累计委托量")] + public decimal TotalAskVolume => Asks.Sum(a => a.Quantity); +} + +/// +/// 订单簿价格档位 +/// +[Description("盘口单档深度详情")] +public class OrderBookLevel +{ + /// + /// 价格 + /// + [Description("档位委托价格")] + public decimal Price { get; set; } + + /// + /// 数量 + /// + [Description("档位委托数量")] + public decimal Quantity { get; set; } + + /// + /// 总价值(价格 * 数量) + /// + [Description("该档位累计委托价值")] + public decimal Value => Price * Quantity; +} \ No newline at end of file diff --git a/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoProjectInfo.cs b/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoProjectInfo.cs new file mode 100644 index 0000000..36b76a3 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoProjectInfo.cs @@ -0,0 +1,186 @@ +using System.ComponentModel; + +namespace MarketAssistant.Agents.Tools.Models.Crypto; + +/// +/// 虚拟币项目基本面信息模型(基于 CoinDesk API) +/// +[Description("加密货币项目基本面及市场概览信息")] +public class CryptoProjectInfo +{ + /// + /// 币种代码(如 BTC、ETH) + /// + [Description("代币符号(如 BTC)")] + public string Symbol { get; set; } = string.Empty; + + /// + /// 项目名称(如 Bitcoin、Ethereum) + /// + [Description("项目名称")] + public string Name { get; set; } = string.Empty; + + /// + /// URL 标识符 + /// + [Description("项目链接/详情URL")] + public string Uri { get; set; } = string.Empty; + + /// + /// 资产类型(如 BLOCKCHAIN) + /// + [Description("资产类型分类")] + public string AssetType { get; set; } = string.Empty; + + /// + /// 其他平台的 ID(CoinMarketCap、CoinGecko 等) + /// + [Description("其他多平台标识ID")] + public Dictionary AlternativeIds { get; set; } = new(); + + /// + /// 简短描述 + /// + [Description("简短项目描述")] + public string DescriptionSnippet { get; set; } = string.Empty; + + /// + /// 详细描述(包含用途、技术等) + /// + [Description("详细项目介绍(包含用途、技术等)")] + public string Description { get; set; } = string.Empty; + + /// + /// 安全审计信息(CertiK 等) + /// + [Description("安全审计指标(如CertiK)")] + public SecurityMetrics? SecurityMetrics { get; set; } + + /// + /// 最大供应量(-1 表示无上限) + /// + [Description("最大代币供应量")] + public decimal? MaxSupply { get; set; } + + /// + /// 总供应量 + /// + [Description("总代币供应量")] + public decimal? TotalSupply { get; set; } + + /// + /// 流通供应量 + /// + [Description("当前流通代币供应量")] + public decimal? CirculatingSupply { get; set; } + + /// + /// 当前价格(USD) + /// + [Description("当前价格(USD)")] + public decimal? PriceUsd { get; set; } + + /// + /// 总市值(USD) + /// + [Description("总市值(USD)")] + public decimal? TotalMarketCapUsd { get; set; } + + /// + /// 流通市值(USD) + /// + [Description("流通市值(USD)")] + public decimal? CirculatingMarketCapUsd { get; set; } + + /// + /// 24 小时现货交易额(USD) + /// + [Description("24小时现货交易额(USD)")] + public decimal? Volume24hUsd { get; set; } + + /// + /// 7 天现货交易额(USD) + /// + [Description("7天现货交易额(USD)")] + public decimal? Volume7dUsd { get; set; } + + /// + /// 30 天现货交易额(USD) + /// + [Description("30天现货交易额(USD)")] + public decimal? Volume30dUsd { get; set; } + + /// + /// 24 小时涨跌幅(%) + /// + [Description("24小时价格涨跌幅(%)")] + public decimal? Change24hPercent { get; set; } + + /// + /// 7 天涨跌幅(%) + /// + [Description("7天价格涨跌幅(%)")] + public decimal? Change7dPercent { get; set; } + + /// + /// 30 天涨跌幅(%) + /// + [Description("30天价格涨跌幅(%)")] + public decimal? Change30dPercent { get; set; } + + /// + /// 排名信息 + /// + [Description("各项指标排名信息")] + public RankingInfo? Rankings { get; set; } + + /// + /// 所属行业分类 + /// + [Description("行业板块/概念标签列表")] + public List Industries { get; set; } = new(); +} + +/// +/// 安全审计指标 +/// +[Description("安全审计指标(如 CertiK)")] +public class SecurityMetrics +{ + /// + /// CertiK 审计分数 + /// + [Description("CertiK 审计得分")] + public decimal? CertikScore { get; set; } + + /// + /// CertiK 排名 + /// + [Description("CertiK 安全排名")] + public int? CertikRank { get; set; } +} + +/// +/// 排名信息 +/// +[Description("市场各项排名指标")] +public class RankingInfo +{ + /// + /// 总市值排名 + /// + [Description("市值规模排名")] + public int? MarketCapRank { get; set; } + + /// + /// 24 小时交易额排名 + /// + [Description("24小时交易额排名")] + public int? Volume24hRank { get; set; } + + /// + /// 30 天交易额排名 + /// + [Description("30天交易额排名")] + public int? Volume30dRank { get; set; } +} \ No newline at end of file diff --git a/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoQuoteInfo.cs b/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoQuoteInfo.cs new file mode 100644 index 0000000..03a9e38 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoQuoteInfo.cs @@ -0,0 +1,100 @@ +using System.ComponentModel; + +namespace MarketAssistant.Agents.Tools.Models.Crypto; + +/// +/// 虚拟币行情数据模型 +/// +[Description("加密货币实时行情数据")] +public class CryptoQuoteInfo +{ + /// + /// 币种代码(如 BTC、ETH) + /// + [Description("证券代码/代币符号(如BTC, ETH)")] + public string SecurityCode { get; set; } = string.Empty; + + /// + /// 币种名称(如 Bitcoin、Ethereum) + /// + [Description("代币名称")] + public string SecurityName { get; set; } = string.Empty; + + /// + /// 交易状态 + /// + [Description("交易状态")] + public string TradeStatus { get; set; } = string.Empty; + + /// + /// 资产类型(虚拟币) + /// + [Description("资产类型(Crypto)")] + public string SecurityType { get; set; } = string.Empty; + + /// + /// 当前价格(USDT) + /// + [Description("当前最新价格(USD/USDT)")] + public decimal CurrentPrice { get; set; } + + /// + /// 开盘价(USDT) + /// + [Description("24小时开盘价(USD/USDT)")] + public decimal OpenPrice { get; set; } + + /// + /// 昨收价(USDT) + /// + [Description("昨日收盘价(USD/USDT)")] + public decimal PreviousClosePrice { get; set; } + + /// + /// 今日最高价(USDT) + /// + [Description("24小时最高价(USD/USDT)")] + public decimal HighPrice { get; set; } + + /// + /// 今日最低价(USDT) + /// + [Description("24小时最低价(USD/USDT)")] + public decimal LowPrice { get; set; } + + /// + /// 加权平均价(USDT) + /// + [Description("24小时平均均价")] + public decimal AveragePrice { get; set; } + + /// + /// 涨跌价格(USDT) + /// + [Description("价格涨跌额")] + public decimal PriceChange { get; set; } + + /// + /// 涨跌百分比(%) + /// + [Description("价格涨跌幅(%)")] + public decimal PercentageChange { get; set; } + + /// + /// 振幅(%) + /// + [Description("价格振幅(%)")] + public decimal Amplitude { get; set; } + + /// + /// 24h成交量(币数量) + /// + [Description("24小时成交量(代币数量)")] + public decimal Volume { get; set; } + + /// + /// 24h成交额(USDT) + /// + [Description("24小时成交额(USD/USDT)")] + public decimal Amount { get; set; } +} \ No newline at end of file diff --git a/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoRecentTrades.cs b/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoRecentTrades.cs new file mode 100644 index 0000000..b0a1b9c --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoRecentTrades.cs @@ -0,0 +1,95 @@ +using System.ComponentModel; + +namespace MarketAssistant.Agents.Tools.Models.Crypto; + +/// +/// 最近成交数据 +/// +[Description("加密货币最近实时成交记录及买卖力量统计")] +public class CryptoRecentTrades +{ + /// + /// 交易对符号 + /// + [Description("代币符号/交易对")] + public string Symbol { get; set; } = string.Empty; + + /// + /// 最近成交列表(按时间倒序,最新在前) + /// + [Description("最近成交记录明细列表")] + public List Trades { get; set; } = []; + + /// + /// 买方主导成交量占比(%) + /// + [Description("买方主动买入占比(Taker买入,%)")] + public decimal BuyerVolumePercent { get; set; } + + /// + /// 卖方主导成交量占比(%) + /// + [Description("卖方主动卖出占比(Taker卖出,%)")] + public decimal SellerVolumePercent { get; set; } + + /// + /// 总成交量 + /// + [Description("总成交量(代币数量)")] + public decimal TotalVolume => Trades.Sum(t => t.Quantity); + + /// + /// 总成交额 + /// + [Description("总成交额(USDT价值)")] + public decimal TotalQuoteVolume => Trades.Sum(t => t.QuoteQuantity); + + /// + /// 平均成交价 + /// + [Description("成交均价")] + public decimal AveragePrice => TotalVolume > 0 ? TotalQuoteVolume / TotalVolume : 0; +} + +/// +/// 单笔成交数据 +/// +[Description("单笔成交明细")] +public class CryptoTrade +{ + /// + /// 成交ID + /// + [Description("成交ID")] + public long TradeId { get; set; } + + /// + /// 成交价格 + /// + [Description("成交价格")] + public decimal Price { get; set; } + + /// + /// 成交数量(基础货币) + /// + [Description("成交数量(代币)")] + public decimal Quantity { get; set; } + + /// + /// 成交额(计价货币) + /// + [Description("成交额(计价USDT)")] + public decimal QuoteQuantity { get; set; } + + /// + /// 成交时间(Unix时间戳毫秒) + /// + [Description("成交时间戳(毫秒ms)")] + public long Timestamp { get; set; } + + /// + /// 是否为买方主动成交(Taker买入) + /// + [Description("是否为买方主动成交(true表示主动买入吃单)")] + public bool IsBuyerMaker { get; set; } +} \ No newline at end of file diff --git a/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoSentiment.cs b/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoSentiment.cs new file mode 100644 index 0000000..b18f72a --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoSentiment.cs @@ -0,0 +1,95 @@ +using System.ComponentModel; + +namespace MarketAssistant.Agents.Tools.Models.Crypto; + +/// +/// 虚拟币市场情绪综合数据 +/// +[Description("加密货币市场情绪及持仓综合数据")] +public class CryptoSentiment +{ + /// + /// 代币符号 + /// + [Description("代币符号(如 BTC)")] + public string Symbol { get; set; } = string.Empty; + + /// + /// 恐惧贪婪指数(0-100,0=极度恐惧,100=极度贪婪) + /// + /// + /// 当前无数据来源:需要集成 alternative.me Fear & Greed Index API + /// + [Description("恐惧贪婪指数(0-100,0=极度恐惧,100=极度贪婪)")] + public int? FearGreedIndex { get; set; } + + /// + /// 恐惧贪婪指数分类描述 + /// + /// + /// 当前无数据来源:需要集成 alternative.me Fear & Greed Index API + /// + [Description("恐惧贪婪指数分类描述")] + public string? FearGreedClassification { get; set; } + + /// + /// 资金费率(%) + /// + /// + /// 正值表示多头支付空头(市场看多),负值相反。 + /// 数据来源:币安 premiumIndex API + /// + [Description("永续合约资金费率(%),正值代表看多,负值看空")] + public decimal? FundingRate { get; set; } + + /// + /// 多空持仓人数比 + /// + [Description("多空持仓人数比率")] + public decimal? LongShortRatio { get; set; } + + /// + /// 大户多空持仓比 + /// + [Description("大户/精英多空持仓比率")] + public decimal? TopTraderLongShortRatio { get; set; } + + /// + /// 合约持仓量(USD) + /// + [Description("合约持仓量(USD价值)")] + public decimal? OpenInterest { get; set; } + + /// + /// 24小时爆仓金额(USD) + /// + /// + /// 当前无数据来源:需要集成 CoinGlass 或类似爆仓数据 API + /// + [Description("24小时爆仓总额(USD)")] + public decimal? Liquidation24h { get; set; } + + /// + /// 多头爆仓金额(USD) + /// + /// + /// 当前无数据来源:需要集成 CoinGlass 或类似爆仓数据 API + /// + [Description("多头爆仓总额(USD)")] + public decimal? LongLiquidation { get; set; } + + /// + /// 空头爆仓金额(USD) + /// + /// + /// 当前无数据来源:需要集成 CoinGlass 或类似爆仓数据 API + /// + [Description("空头爆仓总额(USD)")] + public decimal? ShortLiquidation { get; set; } + + /// + /// 数据更新时间 + /// + [Description("数据更新时间")] + public string UpdatedAt { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoVolatilityMetrics.cs b/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoVolatilityMetrics.cs new file mode 100644 index 0000000..7711d06 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoVolatilityMetrics.cs @@ -0,0 +1,79 @@ +using System.ComponentModel; + +namespace MarketAssistant.Agents.Tools.Models.Crypto; + +/// +/// 加密货币波动性指标 +/// +[Description("加密货币价格波动性与风险评估指标")] +public class CryptoVolatilityMetrics +{ + /// + /// 交易对符号 + /// + [Description("代币符号")] + public string Symbol { get; set; } = string.Empty; + + /// + /// 历史波动率(年化,%) + /// + [Description("年化历史波动率(%)")] + public decimal AnnualizedVolatility { get; set; } + + /// + /// 日波动率(%) + /// + [Description("日化波动率(%)")] + public decimal DailyVolatility { get; set; } + + /// + /// 平均真实波幅(ATR) + /// + [Description("平均真实波幅(ATR)")] + public decimal AverageTrueRange { get; set; } + + /// + /// 最大回撤(%) + /// + [Description("最大回撤百分比(%)")] + public decimal MaxDrawdown { get; set; } + + /// + /// 最大回撤起始时间 + /// + [Description("最大回撤起始时间戳(ms)")] + public long MaxDrawdownStartTime { get; set; } + + /// + /// 最大回撤结束时间 + /// + [Description("最大回撤结束/最低点时间戳(ms)")] + public long MaxDrawdownEndTime { get; set; } + + /// + /// 夏普比率(Sharpe Ratio) + /// + /// + /// 衡量风险调整后收益,数值越高越好。 + /// + [Description("夏普比率(Sharpe Ratio,衡量风险调整后的收益,数值越高越好)")] + public decimal? SharpeRatio { get; set; } + + /// + /// 统计周期(天数) + /// + [Description("统计分析周期(天数)")] + public int PeriodDays { get; set; } + + /// + /// 价格标准差 + /// + [Description("价格标准差")] + public decimal StandardDeviation { get; set; } + + /// + /// 平均收益率(%) + /// + [Description("日均收益率(%)")] + public decimal AverageReturn { get; set; } +} \ No newline at end of file diff --git a/src/MarketAssistant.Agents/Tools/Models/Crypto/FundingRate.cs b/src/MarketAssistant.Agents/Tools/Models/Crypto/FundingRate.cs new file mode 100644 index 0000000..3a30052 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/Crypto/FundingRate.cs @@ -0,0 +1,68 @@ +using System.ComponentModel; + +namespace MarketAssistant.Agents.Tools.Models.Crypto; + +/// +/// 资金费率历史数据 +/// +[Description("加密货币永续合约的资金费率历史趋势")] +public class FundingRateHistory +{ + /// + /// 交易对符号 + /// + [Description("交易对/代币符号")] + public string Symbol { get; set; } = string.Empty; + + /// + /// 当前最新资金费率(%) + /// + /// + /// 正值表示多头支付空头(市场看多),负值相反 + /// + [Description("最新资金费率(%),正值表示多头支付空头(看多),负值相反")] + public decimal CurrentRate { get; set; } + + /// + /// 当前费率结算时间(Unix 时间戳毫秒) + /// + [Description("当前费率结算时间戳(ms)")] + public long CurrentFundingTime { get; set; } + + /// + /// 下次费率结算时间(Unix 时间戳毫秒) + /// + [Description("下次费率结算时间戳(ms)")] + public long NextFundingTime { get; set; } + + /// + /// 平均资金费率(%) + /// + [Description("周期平均资金费率(%)")] + public decimal AverageRate { get; set; } + + /// + /// 历史费率数据点(按时间倒序,最新的在前) + /// + [Description("历史资金费率数据明细列表")] + public List History { get; set; } = []; +} + +/// +/// 单个资金费率数据点 +/// +[Description("单个历史资金费率点")] +public class FundingRatePoint +{ + /// + /// 资金费率(%) + /// + [Description("资金费率百分比(%)")] + public decimal Rate { get; set; } + + /// + /// 费率结算时间(Unix 时间戳) + /// + [Description("结算时间戳(ms)")] + public long FundingTime { get; set; } +} \ No newline at end of file diff --git a/src/MarketAssistant.Agents/Tools/Models/Crypto/LongShortRatio.cs b/src/MarketAssistant.Agents/Tools/Models/Crypto/LongShortRatio.cs new file mode 100644 index 0000000..c73f662 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/Crypto/LongShortRatio.cs @@ -0,0 +1,77 @@ +using System.ComponentModel; + +namespace MarketAssistant.Agents.Tools.Models.Crypto; + +/// +/// 多空比历史数据 +/// +[Description("加密货币永续合约的多空持仓比率历史趋势")] +public class LongShortRatioHistory +{ + /// + /// 交易对符号 + /// + [Description("交易对符号")] + public string Symbol { get; set; } = string.Empty; + + /// + /// 当前最新多头占比 + /// + [Description("当前最新多头仓位账户占比(%)")] + public decimal CurrentLongRatio { get; set; } + + /// + /// 当前最新空头占比 + /// + [Description("当前最新空头仓位账户占比(%)")] + public decimal CurrentShortRatio { get; set; } + + /// + /// 当前多空比率(Long/Short) + /// + [Description("当前多空人数或持仓比率")] + public decimal CurrentRatio { get; set; } + + /// + /// 平均多空比率 + /// + [Description("周期平均多空比率")] + public decimal AverageRatio { get; set; } + + /// + /// 历史多空比数据点(按时间倒序,最新的在前) + /// + [Description("历史多空比变化明细数据列表")] + public List History { get; set; } = []; +} + +/// +/// 单个多空比数据点 +/// +[Description("单笔历史多空比数据点")] +public class LongShortRatioPoint +{ + /// + /// 多头占比 + /// + [Description("多头占比(%)")] + public decimal LongRatio { get; set; } + + /// + /// 空头占比 + /// + [Description("空头占比(%)")] + public decimal ShortRatio { get; set; } + + /// + /// 多空比率(Long/Short) + /// + [Description("多空比率")] + public decimal Ratio { get; set; } + + /// + /// 数据时间戳(Unix 毫秒) + /// + [Description("数据记录时间戳(ms)")] + public long Timestamp { get; set; } +} \ No newline at end of file diff --git a/src/MarketAssistant.Agents/Tools/Models/Crypto/OpenInterest.cs b/src/MarketAssistant.Agents/Tools/Models/Crypto/OpenInterest.cs new file mode 100644 index 0000000..8219eb2 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/Crypto/OpenInterest.cs @@ -0,0 +1,137 @@ +using System.ComponentModel; + +namespace MarketAssistant.Agents.Tools.Models.Crypto; + +/// +/// 时间周期 +/// +public enum Period +{ + /// 5分钟 + [Description("5m")] + FiveMinutes, + + /// 15分钟 + [Description("15m")] + FifteenMinutes, + + /// 30分钟 + [Description("30m")] + ThirtyMinutes, + + /// 1小时 + [Description("1h")] + OneHour, + + /// 2小时 + [Description("2h")] + TwoHours, + + /// 4小时 + [Description("4h")] + FourHours, + + /// 6小时 + [Description("6h")] + SixHours, + + /// 12小时 + [Description("12h")] + TwelveHours, + + /// 1天 + [Description("1d")] + OneDay +} + +/// +/// 合约持仓量数据 +/// +[Description("加密货币期货合约总持仓量历史明细")] +public class OpenInterest +{ + /// + /// 交易对符号 + /// + [Description("交易对符号")] + public string Symbol { get; set; } = string.Empty; + + /// + /// 当前持仓总数量 + /// + [Description("当前持仓代币总数")] + public decimal CurrentOpenInterest { get; set; } + + /// + /// 当前持仓总价值(USD) + /// + [Description("当前持仓总价值(USD)")] + public decimal CurrentOpenInterestValue { get; set; } + + /// + /// 当前数据时间戳 + /// + [Description("当前记录时间戳(ms)")] + public long CurrentTimestamp { get; set; } + + /// + /// 平均持仓量 + /// + [Description("周期平均持仓数量")] + public decimal AverageOpenInterest { get; set; } + + /// + /// 平均持仓价值(USD) + /// + [Description("周期平均持仓总价值(USD)")] + public decimal AverageOpenInterestValue { get; set; } + + /// + /// 历史持仓量数据点(按时间倒序,最新在前) + /// + [Description("历史持仓量变化明细数据列表")] + public List History { get; set; } = []; +} + +/// +/// 单个持仓量数据点 +/// +[Description("单个合约持仓量点")] +public class OpenInterestPoint +{ + /// + /// 持仓总数量 + /// + [Description("未平仓合约代币数量")] + public decimal SumOpenInterest { get; set; } + + /// + /// 持仓总价值(USD) + /// + [Description("未平仓合约总价值(USD)")] + public decimal SumOpenInterestValue { get; set; } + + /// + /// 数据时间戳 + /// + [Description("数据记录时间戳(ms)")] + public long Timestamp { get; set; } +} + +/// +/// Period 枚举扩展方法 +/// +public static class PeriodExtensions +{ + /// + /// 获取 Period 枚举的 Description 特性值 + /// + public static string GetDescription(this Period period) + { + var field = period.GetType().GetField(period.ToString()); + if (field == null) return "1h"; + + var attribute = (DescriptionAttribute?)Attribute.GetCustomAttribute(field, typeof(DescriptionAttribute)); + return attribute?.Description ?? "1h"; + } +} \ No newline at end of file diff --git a/src/MarketAssistant.Agents/Tools/Models/Crypto/VolumeDistribution.cs b/src/MarketAssistant.Agents/Tools/Models/Crypto/VolumeDistribution.cs new file mode 100644 index 0000000..15b76bf --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/Crypto/VolumeDistribution.cs @@ -0,0 +1,34 @@ +using System.ComponentModel; + +namespace MarketAssistant.Agents.Tools.Models.Crypto; + +/// +/// 交易量分布(各交易所占比) +/// +[Description("加密货币在各个主要交易所的成交量分布情况")] +public class VolumeDistribution +{ + /// + /// 交易所名称 + /// + [Description("交易所名称(如 Binance)")] + public string Exchange { get; set; } = string.Empty; + + /// + /// 交易量(USD) + /// + [Description("24小时成交量(USD价值)")] + public decimal Volume { get; set; } + + /// + /// 占总交易量百分比 + /// + [Description("成交量份额占比(%)")] + public decimal Percentage { get; set; } + + /// + /// 交易对数量 + /// + [Description("支持交易的交易对总数")] + public int PairCount { get; set; } +} \ No newline at end of file diff --git a/src/MarketAssistant.Agents/Tools/Models/MarketInterval.cs b/src/MarketAssistant.Agents/Tools/Models/MarketInterval.cs new file mode 100644 index 0000000..f394fa2 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/MarketInterval.cs @@ -0,0 +1,42 @@ +using System.ComponentModel; + +namespace MarketAssistant.Agents.Tools.Models; + +/// +/// 市场行情 K 线时间间隔 +/// +public enum MarketInterval +{ + [Description("1s")] + OneSecond, + [Description("1m")] + OneMinute, + [Description("3m")] + ThreeMinutes, + [Description("5m")] + FiveMinutes, + [Description("15m")] + FifteenMinutes, + [Description("30m")] + ThirtyMinutes, + [Description("1h")] + OneHour, + [Description("2h")] + TwoHours, + [Description("4h")] + FourHours, + [Description("6h")] + SixHours, + [Description("8h")] + EightHours, + [Description("12h")] + TwelveHours, + [Description("1d")] + OneDay, + [Description("3d")] + ThreeDays, + [Description("1w")] + OneWeek, + [Description("1M")] + OneMonth +} diff --git a/src/Agents/Tools/Models/NewsItem.cs b/src/MarketAssistant.Agents/Tools/Models/NewsItem.cs similarity index 54% rename from src/Agents/Tools/Models/NewsItem.cs rename to src/MarketAssistant.Agents/Tools/Models/NewsItem.cs index b24e242..85ffa64 100644 --- a/src/Agents/Tools/Models/NewsItem.cs +++ b/src/MarketAssistant.Agents/Tools/Models/NewsItem.cs @@ -1,6 +1,6 @@ using System.ComponentModel; -namespace MarketAssistant.Agents.Plugins.Models; +namespace MarketAssistant.Agents.Tools.Models; /// /// 新闻上下文条目 @@ -8,17 +8,17 @@ namespace MarketAssistant.Agents.Plugins.Models; public class NewsItem { [Description("新闻标题")] - public string Title { get; set; } = ""; + public string Title { get; set; } = string.Empty; [Description("新闻来源站点或频道名称")] - public string Source { get; set; } = ""; + public string Source { get; set; } = string.Empty; [Description("新闻详情页面链接")] - public string Link { get; set; } = ""; + public string Link { get; set; } = string.Empty; [Description("新闻发布时间")] - public string PublishTime { get; set; } = ""; + public string PublishTime { get; set; } = string.Empty; [Description("精简要点摘要(concise 模式可为空,detailed 模式提供)")] - public string Summary { get; set; } = ""; + public string Summary { get; set; } = string.Empty; } diff --git a/src/MarketAssistant.Agents/Tools/Models/Technical/OhlcvBar.cs b/src/MarketAssistant.Agents/Tools/Models/Technical/OhlcvBar.cs new file mode 100644 index 0000000..a9c162b --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/Technical/OhlcvBar.cs @@ -0,0 +1,32 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace MarketAssistant.Agents.Tools.Models.Technical; + +[Description("单根K线OHLCV数据")] +public sealed class OhlcvBar +{ + [Description("交易时间")] + [JsonPropertyName("t")] + public string T { get; set; } = ""; + + [Description("开盘价")] + [JsonPropertyName("o")] + public decimal O { get; set; } + + [Description("最高价")] + [JsonPropertyName("h")] + public decimal H { get; set; } + + [Description("最低价")] + [JsonPropertyName("l")] + public decimal L { get; set; } + + [Description("收盘价")] + [JsonPropertyName("c")] + public decimal C { get; set; } + + [Description("成交量")] + [JsonPropertyName("v")] + public decimal V { get; set; } +} diff --git a/src/MarketAssistant.Agents/Tools/Models/Technical/TechnicalBoll.cs b/src/MarketAssistant.Agents/Tools/Models/Technical/TechnicalBoll.cs new file mode 100644 index 0000000..614c785 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/Technical/TechnicalBoll.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace MarketAssistant.Agents.Tools.Models.Technical; + +[Description("布林带指标,用于判断价格波动区间和突破信号")] +public class TechnicalBoll +{ + [Description("交易时间")] + [JsonPropertyName("t")] + public string T { get; set; } = ""; + + [Description("上轨(中轨+2倍标准差),价格触及上轨可能面临压力")] + [JsonPropertyName("u")] + public decimal? U { get; set; } + + [Description("下轨(中轨-2倍标准差),价格触及下轨可能获得支撑")] + [JsonPropertyName("d")] + public decimal? D { get; set; } + + [Description("中轨(20日移动平均线),趋势方向的参考基准")] + [JsonPropertyName("m")] + public decimal? M { get; set; } +} diff --git a/src/MarketAssistant.Agents/Tools/Models/Technical/TechnicalKDJ.cs b/src/MarketAssistant.Agents/Tools/Models/Technical/TechnicalKDJ.cs new file mode 100644 index 0000000..32398e3 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/Technical/TechnicalKDJ.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace MarketAssistant.Agents.Tools.Models.Technical; + +[Description("KDJ随机指标,用于判断超买超卖和趋势")] +public class TechnicalKDJ +{ + [Description("交易时间")] + [JsonPropertyName("t")] + public string T { get; set; } = ""; + + [Description("K值(快速随机值,0-100,高于80为超买,低于20为超卖)")] + [JsonPropertyName("k")] + public decimal? K { get; set; } + + [Description("D值(K值的平滑线,0-100,K线上穿D线为金叉买入信号)")] + [JsonPropertyName("d")] + public decimal? D { get; set; } + + [Description("J值(3K-2D),大于100为强烈超买,小于0为强烈超卖")] + [JsonPropertyName("j")] + public decimal? J { get; set; } +} \ No newline at end of file diff --git a/src/MarketAssistant.Agents/Tools/Models/Technical/TechnicalMA.cs b/src/MarketAssistant.Agents/Tools/Models/Technical/TechnicalMA.cs new file mode 100644 index 0000000..7f2881d --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/Technical/TechnicalMA.cs @@ -0,0 +1,52 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace MarketAssistant.Agents.Tools.Models.Technical; + +[Description("移动平均线指标,用于判断趋势方向和支撑压力位")] +public class TechnicalMA +{ + [Description("交易时间")] + [JsonPropertyName("t")] + public string T { get; set; } = ""; + + [Description("3日均线")] + [JsonPropertyName("ma3")] + public decimal? MA3 { get; set; } + + [Description("5日均线(短期趋势)")] + [JsonPropertyName("ma5")] + public decimal? MA5 { get; set; } + + [Description("10日均线(短期趋势)")] + [JsonPropertyName("ma10")] + public decimal? MA10 { get; set; } + + [Description("15日均线")] + [JsonPropertyName("ma15")] + public decimal? MA15 { get; set; } + + [Description("20日均线(中短期趋势)")] + [JsonPropertyName("ma20")] + public decimal? MA20 { get; set; } + + [Description("30日均线(中期趋势)")] + [JsonPropertyName("ma30")] + public decimal? MA30 { get; set; } + + [Description("60日均线(中期趋势,季线)")] + [JsonPropertyName("ma60")] + public decimal? MA60 { get; set; } + + [Description("120日均线(半年线,重要支撑/压力)")] + [JsonPropertyName("ma120")] + public decimal? MA120 { get; set; } + + [Description("200日均线(长期趋势分界线)")] + [JsonPropertyName("ma200")] + public decimal? MA200 { get; set; } + + [Description("250日均线(年线,牛熊分界参考)")] + [JsonPropertyName("ma250")] + public decimal? MA250 { get; set; } +} diff --git a/src/MarketAssistant.Agents/Tools/Models/Technical/TechnicalMACD.cs b/src/MarketAssistant.Agents/Tools/Models/Technical/TechnicalMACD.cs new file mode 100644 index 0000000..c4e12c4 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/Technical/TechnicalMACD.cs @@ -0,0 +1,32 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace MarketAssistant.Agents.Tools.Models.Technical; + +[Description("MACD指标(移动平均收敛/发散),用于判断趋势方向和买卖时机")] +public class TechnicalMACD +{ + [Description("交易时间")] + [JsonPropertyName("t")] + public string T { get; set; } = ""; + + [Description("DIFF快线(EMA12与EMA26之差),反映短期动能")] + [JsonPropertyName("diff")] + public decimal Diff { get; set; } + + [Description("DEA慢线(DIFF的9日均线),DIFF上穿DEA为金叉")] + [JsonPropertyName("dea")] + public decimal Dea { get; set; } + + [Description("MACD柱线(2*(DIFF-DEA)),红柱看多绿柱看空")] + [JsonPropertyName("macd")] + public decimal Macd { get; set; } + + [Description("12日指数移动均线")] + [JsonPropertyName("ema12")] + public decimal Ema12 { get; set; } + + [Description("26日指数移动均线")] + [JsonPropertyName("ema26")] + public decimal Ema26 { get; set; } +} \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/AGENTS.md b/src/MarketAssistant.App.Services/AGENTS.md new file mode 100644 index 0000000..b5fdb62 --- /dev/null +++ b/src/MarketAssistant.App.Services/AGENTS.md @@ -0,0 +1,51 @@ +# MarketAssistant.App.Services — AGENTS.md + +桌面应用的运行时与业务编排层。负责 Agent 实现、Workflow、Tool 实现、MCP、交易编排、业务服务与运行时基础设施;**不承载 Avalonia 视图、导航控件或页面样式**。 + +--- + +## 目录结构 + +``` +MarketAssistant.App.Services/ +├── Agents/ ← Agent 实现、工作流、执行器、工具实现 +├── Applications/ ← 面向 UI 的业务服务(Assets/Home/Favorites/Analysis 等) +├── Infrastructure/ ← 工厂、配置、通用运行时适配 +├── Services/ ← 横切服务(Archive/Browser/Cache/Market/Mcp/Settings 等) +└── Trading/ ← 交易引擎、风控、持久化与监控 +``` + +--- + +## 边界约定 + +- 本项目承载除 UI 和独立基础模块外的大部分运行时代码;新增 Agent、Tool、Workflow、MCP、交易服务时,优先放在这里。 +- Avalonia 视图、窗口、导航、通知、对话框实现属于 `MarketAssistant.App`,不要回流到本项目。 +- 文档解析、清洗、分块、嵌入、检索、重排等 RAG 基础能力属于 `MarketAssistant.Rag`,不要在本项目再复制一套实现。 +- 市场特定能力通过 `MarketType` Keyed Services 注册,不在业务逻辑中堆叠 `if/else` 区分市场。 +- Tool 接口定义保留在 `MarketAssistant.Agents/Tools/Abstractions/`,本项目只放实现。 +- 对外部 HTTP/API 的直接调用优先收敛到 `MarketAssistant.DataProviders` 或已存在的统一服务中,不在多个 Tool/Service 内重复实现。 + +--- + +## DI 注册 + +- 业务注册根:`Services/ServiceCollectionExtensions.cs` 中的 `AddBusinessServices()`。 +- UI 宿主通过 `MarketAssistant.App/Services/ServiceCollectionExtensions.cs` 调用本项目注册入口。 +- 新增服务时,按职责放到对应扩展方法附近,避免把所有注册逻辑散落到 ViewModel 或窗口构造中。 + +--- + +## MAF 约定 + +- 分析师实现放在 `Agents/Analysts/`,统一通过 `IAnalystAgentFactory` 创建。 +- 工作流实现放在 `Agents/*Workflow*/`,执行器放在 `Executors/`。 +- 通用知识型 Skill 资源保留在 `MarketAssistant.App/skills/` 作为内容文件输出,本项目通过 `FileAgentSkillsProvider` 加载,不在这里重复存放一份。 + +--- + +## 构建 + +```bash +dotnet build src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj -c Debug +``` \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/AnalystTypeRegistry.cs b/src/MarketAssistant.App.Services/Agents/Analysts/AnalystTypeRegistry.cs new file mode 100644 index 0000000..2551709 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Analysts/AnalystTypeRegistry.cs @@ -0,0 +1,41 @@ +using System.Reflection; + +namespace MarketAssistant.Agents.Analysts; + +/// +/// 统一发现当前应用中已加载的分析师实现类型。 +/// +public static class AnalystTypeRegistry +{ + private static readonly Lazy> _cachedTypes = + new(DiscoverConcreteAnalystTypes, LazyThreadSafetyMode.ExecutionAndPublication); + + public static IReadOnlyList GetConcreteAnalystTypes() => _cachedTypes.Value; + + private static IReadOnlyList DiscoverConcreteAnalystTypes() + { + return AppDomain.CurrentDomain.GetAssemblies() + .SelectMany(GetLoadableTypes) + .Where(IsConcreteAnalystType) + .DistinctBy(type => type.FullName) + .OrderBy(type => type.Name, StringComparer.Ordinal) + .ToList(); + } + + private static IEnumerable GetLoadableTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(type => type != null)!; + } + } + + private static bool IsConcreteAnalystType(Type type) + { + return type.IsSubclassOf(typeof(AnalystAgentBase)) && !type.IsAbstract; + } +} diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/CoordinatorAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/CoordinatorAnalystAgent.cs new file mode 100644 index 0000000..f47804f --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Analysts/CoordinatorAnalystAgent.cs @@ -0,0 +1,53 @@ +using MarketAssistant.Agents.Analysts.Attributes; +using MarketAssistant.Agents.ContextProviders; +using MarketAssistant.Agents.MarketAnalysis.Models; +using MarketAssistant.Agents.PromptConfiguration; +using MarketAssistant.Agents.Tools; +using MarketAssistant.Services.Settings; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.ComponentModel; + +namespace MarketAssistant.Agents.Analysts; + +/// +/// 协调分析师代理 +/// 整合多维度分析师结论并提供投资建议 +/// +[DisplayName("协调分析师")] +[Description("整合多维度分析师结论并提供投资建议")] +[RequiredAnalyst] + +public class CoordinatorAnalystAgent : AnalystAgentBase +{ + private static readonly ChatResponseFormat ResponseFormat = ChatResponseFormat.ForJsonSchema( + schema: AIJsonUtilities.CreateJsonSchema(typeof(CoordinatorResult)), + schemaName: nameof(CoordinatorResult), + schemaDescription: "协调分析师的综合分析结果,包含投资建议、评分、风险评估等结构化数据" + ); + + public CoordinatorAnalystAgent( + IChatClient chatClient, + IList tools, + GroundingSearchTools searchTools, + IUserSettingService userSettingService, + ILoggerFactory loggerFactory, + AnalystPromptLoader promptLoader, + AIContextProvider[]? aiContextProviders = null, + AgentSkillsProvider? skillsProvider = null) + : base( + chatClient, + promptLoader.GetConfig("CoordinatorAnalyst"), + ResponseFormat, + [.. tools, AIFunctionFactory.Create(searchTools.SearchAsync)], + [ + new InvestmentPreferenceContextProvider( + userSettingService.CurrentSetting.InvestmentPreference, + loggerFactory.CreateLogger()), + .. (aiContextProviders ?? []) + ], + skillsProvider) + { + } +} diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/FinancialAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/FinancialAnalystAgent.cs new file mode 100644 index 0000000..49fae23 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Analysts/FinancialAnalystAgent.cs @@ -0,0 +1,44 @@ +using MarketAssistant.Agents.Analysts.Attributes; +using MarketAssistant.Agents.MarketAnalysis.Models; +using MarketAssistant.Agents.PromptConfiguration; +using MarketAssistant.Agents.Tools.Abstractions; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using System.ComponentModel; + +namespace MarketAssistant.Agents.Analysts; + +/// +/// 财务分析师代理 +/// 专注于深入分析公司财务报表和财务健康状况 +/// +[DisplayName("财务分析师")] +[Description("专注于财务报表和财务健康分析")] +[RequiresTools(typeof(IFinancialTools))] +public class FinancialAnalystAgent : AnalystAgentBase +{ + private static readonly object Schema = AIJsonUtilities.CreateJsonSchema(typeof(FinancialAnalysisResult)); + + private static readonly ChatResponseFormat ResponseFormat = ChatResponseFormat.ForJsonSchema( + schema: (JsonElement)Schema, + schemaName: nameof(FinancialAnalysisResult), + schemaDescription: "财务分析师的结构化分析结果,包含财务健康、盈利质量、现金流和风险预警" + ); + + public FinancialAnalystAgent( + IChatClient chatClient, + IList tools, + AnalystPromptLoader promptLoader, + AIContextProvider[]? aiContextProviders = null, + AgentSkillsProvider? skillsProvider = null) + : base( + chatClient, + promptLoader.GetConfig("FinancialAnalyst"), + ResponseFormat, + tools, + aiContextProviders, + skillsProvider) + { + } + +} diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/FundamentalAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/FundamentalAnalystAgent.cs new file mode 100644 index 0000000..ada9dba --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Analysts/FundamentalAnalystAgent.cs @@ -0,0 +1,44 @@ +using MarketAssistant.Agents.Analysts.Attributes; +using MarketAssistant.Agents.MarketAnalysis.Models; +using MarketAssistant.Agents.PromptConfiguration; +using MarketAssistant.Agents.Tools.Abstractions; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using System.ComponentModel; + +namespace MarketAssistant.Agents.Analysts; + +/// +/// 基本面分析师代理 +/// 专注于分析公司基本面、行业地位和长期价值 +/// +[DisplayName("基本面分析师")] +[Description("整合了策略分析师和股票研究分析师的功能")] +[RequiredAnalyst] +[RequiresTools(typeof(IBasicDataTools))] +public class FundamentalAnalystAgent : AnalystAgentBase +{ + private static readonly object Schema = AIJsonUtilities.CreateJsonSchema(typeof(FundamentalAnalysisResult)); + + private static readonly ChatResponseFormat ResponseFormat = ChatResponseFormat.ForJsonSchema( + schema: (JsonElement)Schema, + schemaName: nameof(FundamentalAnalysisResult), + schemaDescription: "基本面分析师的结构化分析结果,包含公司基本面、行业竞争和投资价值评估" + ); + + public FundamentalAnalystAgent( + IChatClient chatClient, + IList tools, + AnalystPromptLoader promptLoader, + AIContextProvider[]? aiContextProviders = null, + AgentSkillsProvider? skillsProvider = null) + : base( + chatClient, + promptLoader.GetConfig("FundamentalAnalyst"), + ResponseFormat, + tools, + aiContextProviders, + skillsProvider) + { + } +} diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/MarketSentimentAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/MarketSentimentAnalystAgent.cs new file mode 100644 index 0000000..be92991 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Analysts/MarketSentimentAnalystAgent.cs @@ -0,0 +1,44 @@ +using MarketAssistant.Agents.Analysts.Attributes; +using MarketAssistant.Agents.MarketAnalysis.Models; +using MarketAssistant.Agents.PromptConfiguration; +using MarketAssistant.Agents.Tools.Abstractions; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using System.ComponentModel; + +namespace MarketAssistant.Agents.Analysts; + +/// +/// 市场情绪分析师代理 +/// 专注于分析市场情绪、资金流向和投资者行为 +/// +[DisplayName("市场情绪分析师")] +[Description("整合了行为金融分析师和市场分析师的功能")] +[RequiresTools(typeof(IFinancialTools))] +[RequiresTools(typeof(ISentimentTools))] +public class MarketSentimentAnalystAgent : AnalystAgentBase +{ + private static readonly object Schema = AIJsonUtilities.CreateJsonSchema(typeof(MarketSentimentAnalysisResult)); + + private static readonly ChatResponseFormat ResponseFormat = ChatResponseFormat.ForJsonSchema( + schema: (JsonElement)Schema, + schemaName: nameof(MarketSentimentAnalysisResult), + schemaDescription: "市场情绪分析师的结构化分析结果,包含市场情绪、资金流向、投资者行为和短期策略" + ); + + public MarketSentimentAnalystAgent( + IChatClient chatClient, + IList tools, + AnalystPromptLoader promptLoader, + AIContextProvider[]? aiContextProviders = null, + AgentSkillsProvider? skillsProvider = null) + : base( + chatClient, + promptLoader.GetConfig("MarketSentimentAnalyst"), + ResponseFormat, + tools, + aiContextProviders, + skillsProvider) + { + } +} diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/NewsEventAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/NewsEventAnalystAgent.cs new file mode 100644 index 0000000..cb35347 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Analysts/NewsEventAnalystAgent.cs @@ -0,0 +1,43 @@ +using MarketAssistant.Agents.Analysts.Attributes; +using MarketAssistant.Agents.MarketAnalysis.Models; +using MarketAssistant.Agents.PromptConfiguration; +using MarketAssistant.Agents.Tools.Abstractions; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using System.ComponentModel; + +namespace MarketAssistant.Agents.Analysts; + +/// +/// 新闻事件分析师代理 +/// 专注于分析新闻事件、公告和突发事件对股票的影响 +/// +[DisplayName("新闻事件分析师")] +[Description("专注于新闻事件对股票的影响分析")] +[RequiresTools(typeof(INewsDataTools))] +public class NewsEventAnalystAgent : AnalystAgentBase +{ + private static readonly object Schema = AIJsonUtilities.CreateJsonSchema(typeof(NewsEventAnalysisResult)); + + private static readonly ChatResponseFormat ResponseFormat = ChatResponseFormat.ForJsonSchema( + schema: (JsonElement)Schema, + schemaName: nameof(NewsEventAnalysisResult), + schemaDescription: "新闻事件分析师的结构化分析结果,包含事件解读、影响评估和投资启示" + ); + + public NewsEventAnalystAgent( + IChatClient chatClient, + IList tools, + AnalystPromptLoader promptLoader, + AIContextProvider[]? aiContextProviders = null, + AgentSkillsProvider? skillsProvider = null) + : base( + chatClient, + promptLoader.GetConfig("NewsEventAnalyst"), + ResponseFormat, + tools, + aiContextProviders, + skillsProvider) + { + } +} diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/TechnicalAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/TechnicalAnalystAgent.cs new file mode 100644 index 0000000..1536f9f --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Analysts/TechnicalAnalystAgent.cs @@ -0,0 +1,43 @@ +using MarketAssistant.Agents.Analysts.Attributes; +using MarketAssistant.Agents.MarketAnalysis.Models; +using MarketAssistant.Agents.PromptConfiguration; +using MarketAssistant.Agents.Tools.Abstractions; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using System.ComponentModel; + +namespace MarketAssistant.Agents.Analysts; + +/// +/// 技术分析师代理 +/// 专注于图表形态、技术指标和交易策略分析 +/// +[DisplayName("技术分析师")] +[Description("专注于图表模式和技术指标分析")] +[RequiresTools(typeof(ITechnicalDataTools))] +public class TechnicalAnalystAgent : AnalystAgentBase +{ + private static readonly object Schema = AIJsonUtilities.CreateJsonSchema(typeof(TechnicalAnalysisResult)); + + private static readonly ChatResponseFormat ResponseFormat = ChatResponseFormat.ForJsonSchema( + schema: (JsonElement)Schema, + schemaName: nameof(TechnicalAnalysisResult), + schemaDescription: "技术分析师的结构化分析结果,包含图表形态、关键价位、技术指标和交易策略" + ); + + public TechnicalAnalystAgent( + IChatClient chatClient, + IList tools, + AnalystPromptLoader promptLoader, + AIContextProvider[]? aiContextProviders = null, + AgentSkillsProvider? skillsProvider = null) + : base( + chatClient, + promptLoader.GetConfig("TechnicalAnalyst"), + ResponseFormat, + tools, + aiContextProviders, + skillsProvider) + { + } +} diff --git a/src/Agents/ContextProviders/InvestmentPreferenceContextProvider.cs b/src/MarketAssistant.App.Services/Agents/ContextProviders/InvestmentPreferenceContextProvider.cs similarity index 62% rename from src/Agents/ContextProviders/InvestmentPreferenceContextProvider.cs rename to src/MarketAssistant.App.Services/Agents/ContextProviders/InvestmentPreferenceContextProvider.cs index 1fbc824..4ba2314 100644 --- a/src/Agents/ContextProviders/InvestmentPreferenceContextProvider.cs +++ b/src/MarketAssistant.App.Services/Agents/ContextProviders/InvestmentPreferenceContextProvider.cs @@ -6,7 +6,7 @@ namespace MarketAssistant.Agents.ContextProviders; -public class InvestmentPreferenceContextProvider : AIContextProvider +public class InvestmentPreferenceContextProvider : MessageAIContextProvider { private readonly InvestmentPreference _preference; private readonly ILogger? _logger; @@ -17,7 +17,7 @@ public InvestmentPreferenceContextProvider(InvestmentPreference preference, ILog _logger = logger; } - public override ValueTask InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) + protected override ValueTask> ProvideMessagesAsync(InvokingContext context, CancellationToken cancellationToken = default) { var sb = new StringBuilder(); sb.AppendLine("## 用户投资偏好"); @@ -28,9 +28,14 @@ public override ValueTask InvokingAsync(InvokingContext context, Canc _logger?.LogDebug("Injecting investment preferences into context: {Preferences}", content.Replace("\n", ", ")); - return new ValueTask(new AIContext - { - Messages = [new ChatMessage(ChatRole.System, content) { AdditionalProperties = new AdditionalPropertiesDictionary() { ["IsInvestmentPreferenceProviderOutput"] = true } }] - }); + IEnumerable messages = + [ + new ChatMessage(ChatRole.System, content) + { + AdditionalProperties = new AdditionalPropertiesDictionary() { ["IsInvestmentPreferenceProviderOutput"] = true } + } + ]; + + return new ValueTask>(messages); } } diff --git a/src/MarketAssistant.App.Services/Agents/ContextProviders/LayeredMemoryContextProvider.cs b/src/MarketAssistant.App.Services/Agents/ContextProviders/LayeredMemoryContextProvider.cs new file mode 100644 index 0000000..3dcb4fb --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/ContextProviders/LayeredMemoryContextProvider.cs @@ -0,0 +1,120 @@ +using MarketAssistant.Applications.Settings; +using MarketAssistant.Infrastructure.Extensions; +using MarketAssistant.Services; +using MarketAssistant.Services.Settings; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.Text; + +namespace MarketAssistant.Agents.ContextProviders; + +/// +/// 分层记忆上下文提供者,实现 L0-L1 始终加载策略。 +/// L0: 用户身份 (~50 tokens) — 始终加载 +/// L1: 关键事实 (~200 tokens) — 高优先级记忆始终加载 +/// L2: 工作记忆 — 由 MarketChatSession 维护的会话上下文 +/// L3: 按需召回 — 通过 Agent 工具调用 (SessionSearch / RAG / KG) +/// 此 Provider 合并了原 UserMemoryContextProvider 和 InvestmentPreferenceContextProvider 的职责。 +/// +public class LayeredMemoryContextProvider : MessageAIContextProvider +{ + private readonly UserMemoryService _memoryService; + private readonly IUserSettingService _settingService; + private readonly ILogger _logger; + + public LayeredMemoryContextProvider( + UserMemoryService memoryService, + IUserSettingService settingService, + ILogger logger) + { + _memoryService = memoryService; + _settingService = settingService; + _logger = logger; + } + + protected override async ValueTask> ProvideMessagesAsync( + InvokingContext context, CancellationToken cancellationToken = default) + { + try + { + var sb = new StringBuilder(); + + // ── L0: 用户身份 (~50 tokens) ── + BuildL0Identity(sb); + + // ── L1: 关键事实 (~200 tokens) ── + await BuildL1CriticalFactsAsync(sb, cancellationToken); + + if (sb.Length == 0) + return []; + + _logger.LogDebug("分层记忆注入完成,总长度: {Length} 字符", sb.Length); + + return + [ + new ChatMessage(ChatRole.System, sb.ToString()) + ]; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "分层记忆上下文构建失败,跳过注入"); + return []; + } + } + + /// + /// L0: 用户身份层 — 从 UserSetting 提取基础身份信息 + /// + private void BuildL0Identity(StringBuilder sb) + { + var setting = _settingService.CurrentSetting; + + sb.AppendLine("## 用户概况"); + + var marketName = setting.CurrentMarketType switch + { + MarketType.AShare => "A股", + MarketType.Crypto => "加密货币", + _ => setting.CurrentMarketType.ToString() + }; + sb.AppendLine($"- 主要关注市场: {marketName}"); + + var pref = setting.InvestmentPreference; + sb.AppendLine($"- 风险承受能力: {pref.RiskTolerance.GetDescription()}"); + sb.AppendLine($"- 投资期限: {pref.InvestmentHorizon.GetDescription()}"); + sb.AppendLine(); + } + + /// + /// L1: 关键事实层 — 从 UserMemoryService 提取高优先级记忆 + /// + private async Task BuildL1CriticalFactsAsync(StringBuilder sb, CancellationToken ct) + { + var highPriorityMemories = await _memoryService.GetHighPriorityMemoriesAsync(minPriority: 1, ct: ct); + + if (highPriorityMemories.Count == 0) + { + var allMemories = await _memoryService.GetAllMemoriesAsync(ct); + if (allMemories.Count == 0) + return; + + // 无高优先级记忆时,加载最近的关键条目(上限 10 条,约 200 tokens) + highPriorityMemories = allMemories.Take(10).ToList(); + } + + sb.AppendLine("## 关键记忆"); + + string? currentCategory = null; + foreach (var (category, key, value) in highPriorityMemories) + { + if (currentCategory != category) + { + currentCategory = category; + sb.AppendLine($"### {category}"); + } + sb.AppendLine($"- **{key}**: {value}"); + } + sb.AppendLine(); + } +} diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/AnalyzeAssetsExecutor.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/AnalyzeAssetsExecutor.cs new file mode 100644 index 0000000..947a9b6 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/AnalyzeAssetsExecutor.cs @@ -0,0 +1,221 @@ +using MarketAssistant.Agents.InvestmentSelection.Models; +using MarketAssistant.Agents.InvestmentSelection.Strategies; +using MarketAssistant.Applications.InvestmentSelection.Models; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Infrastructure.Factories; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Agents.InvestmentSelection.Executors; + +/// +/// 统一的资产分析 Executor +/// 对筛选出的资产进行深度分析并生成推荐报告 +/// +public sealed partial class AnalyzeAssetsExecutor : Executor +{ + private readonly IChatClientFactory _chatClientFactory; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions(JsonSerializerOptions.Web) + { + PropertyNameCaseInsensitive = true + }; + + public AnalyzeAssetsExecutor( + IChatClientFactory chatClientFactory, + IServiceProvider serviceProvider, + ILogger logger) : base("AnalyzeAssets") + { + _chatClientFactory = chatClientFactory ?? throw new ArgumentNullException(nameof(chatClientFactory)); + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + [MessageHandler] + private async ValueTask HandleAsync( + AssetScreeningResult input, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + var originalRequest = input.OriginalRequest; + if (originalRequest == null) + { + _logger.LogError("[步骤3/3] 缺少原始请求信息"); + return CreateDefaultResult("分析失败:缺少原始请求信息"); + } + + _logger.LogInformation("[步骤3/3-{MarketType}] 对筛选结果进行AI分析", originalRequest.MarketType); + + try + { + if (input.ScreenedAssets.Count == 0) + { + _logger.LogWarning("[步骤3/3-{MarketType}] 未筛选到符合条件的资产", originalRequest.MarketType); + return CreateDefaultResult("未找到符合条件的资产,建议放宽筛选条件。"); + } + + // 根据市场类型获取对应的数据格式化器 + var formatter = _serviceProvider.GetRequiredKeyedService(originalRequest.MarketType); + + _logger.LogInformation("[步骤3/3-{MarketType}] 使用格式化器: {FormatterType}", + originalRequest.MarketType, formatter.GetType().Name); + + var assetsDataText = formatter.FormatAssetsForAnalysis(input.ScreenedAssets); + var systemPrompt = formatter.GetAnalysisInstructions(originalRequest.IsNewsAnalysis); + var userPrompt = BuildAnalysisPrompt(originalRequest, assetsDataText); + + var options = new ChatOptions + { + ResponseFormat = ChatResponseFormat.ForJsonSchema( + schema: AIJsonUtilities.CreateJsonSchema(typeof(InvestmentSelectionResult)), + schemaName: "InvestmentSelectionResult", + schemaDescription: "投资选择分析结果,包含推荐资产列表和分析报告"), + Temperature = 0.2f, + MaxOutputTokens = 8000 + }; + + var chatClient = _chatClientFactory.CreateClient(); + var response = await chatClient.GetResponseAsync( + [ + new ChatMessage(ChatRole.System, systemPrompt), + new ChatMessage(ChatRole.User, userPrompt) + ], + options, + cancellationToken); + + _logger.LogDebug("[步骤3/3-{MarketType}] AI原始响应: {Response}", originalRequest.MarketType, response.Text); + + var result = LlmJsonExtractor.Deserialize(response.Text, JsonOptions); + + if (result == null) + { + _logger.LogWarning("[步骤3/3-{MarketType}] 响应反序列化失败,原始响应: {Response}", + originalRequest.MarketType, response.Text); + result = CreateDefaultResult("解析分析结果失败"); + } + else + { + var validationErrors = ValidateResult(result); + if (validationErrors.Count > 0) + { + _logger.LogWarning("[步骤3/3-{MarketType}] AI返回数据验证失败: {Errors}", + originalRequest.MarketType, string.Join("; ", validationErrors)); + } + + if (result.Recommendations.Count == 0) + { + _logger.LogWarning("[步骤3/3-{MarketType}] AI未生成任何推荐,原始响应: {Response}", + originalRequest.MarketType, response.Text); + } + } + + _logger.LogInformation("[步骤3/3-{MarketType}] 分析完成,推荐 {Count} 个资产,置信度: {Score}", + originalRequest.MarketType, result.Recommendations.Count, result.ConfidenceScore); + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "[步骤3/3-{MarketType}] AI分析失败", originalRequest.MarketType); + return CreateDefaultResult($"分析失败: {ex.Message}"); + } + } + + private List ValidateResult(InvestmentSelectionResult result) + { + var errors = new List(); + + if (!Enum.IsDefined(typeof(SelectionType), result.SelectionType)) + errors.Add($"SelectionType 值无效: {result.SelectionType}"); + + if (string.IsNullOrWhiteSpace(result.AnalysisSummary)) + errors.Add("AnalysisSummary 不能为空"); + + if (string.IsNullOrWhiteSpace(result.MarketEnvironmentAnalysis)) + errors.Add("MarketEnvironmentAnalysis 不能为空"); + + if (string.IsNullOrWhiteSpace(result.InvestmentAdvice)) + errors.Add("InvestmentAdvice 不能为空"); + + if (result.RiskWarnings == null || result.RiskWarnings.Count == 0) + errors.Add("RiskWarnings 不能为空"); + + for (int i = 0; i < result.Recommendations.Count; i++) + { + var rec = result.Recommendations[i]; + if (string.IsNullOrWhiteSpace(rec.Symbol)) + errors.Add($"第{i + 1}个推荐的 Symbol 不能为空"); + + if (string.IsNullOrWhiteSpace(rec.Name)) + errors.Add($"第{i + 1}个推荐的 Name 不能为空"); + + if (string.IsNullOrWhiteSpace(rec.Reason)) + errors.Add($"第{i + 1}个推荐的 Reason 不能为空"); + + if (!Enum.IsDefined(typeof(RiskLevel), rec.RiskLevel)) + errors.Add($"第{i + 1}个推荐的 RiskLevel 值无效: {rec.RiskLevel}"); + } + + return errors; + } + + private string BuildAnalysisPrompt(InvestmentSelectionWorkflowRequest request, string assetsData) + { + var sb = new StringBuilder(); + + if (request.IsNewsAnalysis) + { + sb.AppendLine("## 新闻内容"); + sb.AppendLine(request.Content); + sb.AppendLine(); + } + else + { + sb.AppendLine("## 用户需求"); + sb.AppendLine($"- 需求描述: {request.Content}"); + sb.AppendLine($"- 风险偏好: {request.RiskPreference}"); + + if (request.InvestmentAmount.HasValue) + sb.AppendLine($"- 投资金额: {request.InvestmentAmount:C}"); + + if (request.InvestmentHorizon.HasValue) + sb.AppendLine($"- 投资期限: {request.InvestmentHorizon}天"); + + if (request.PreferredSectors.Any()) + sb.AppendLine($"- 偏好行业: {string.Join(", ", request.PreferredSectors)}"); + + if (request.ExcludedSectors.Any()) + sb.AppendLine($"- 排除行业: {string.Join(", ", request.ExcludedSectors)}"); + + sb.AppendLine(); + } + + string assetType = request.MarketType == Infrastructure.Core.MarketType.Crypto ? "虚拟币" : "股票"; + sb.AppendLine($"## 筛选出的{assetType}数据(JSON格式)"); + sb.AppendLine(assetsData); + sb.AppendLine(); + sb.AppendLine("## 分析任务"); + sb.AppendLine($"请基于以上{assetType}数据和用户需求,进行综合分析并生成推荐报告。"); + sb.AppendLine($"- 从中选择最优的3-8个{assetType}进行推荐"); + sb.AppendLine("- 说明推荐理由和风险提示"); + + return sb.ToString(); + } + + private InvestmentSelectionResult CreateDefaultResult(string message) + { + return new InvestmentSelectionResult + { + SelectionType = SelectionType.UserRequest, + Recommendations = new List(), + ConfidenceScore = 0, + AnalysisSummary = message, + MarketEnvironmentAnalysis = "无可用分析", + InvestmentAdvice = "建议重新尝试分析", + RiskWarnings = new List { "分析失败,请联系技术支持" } + }; + } +} diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/GenerateCriteriaExecutor.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/GenerateCriteriaExecutor.cs new file mode 100644 index 0000000..f197a49 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/GenerateCriteriaExecutor.cs @@ -0,0 +1,115 @@ +using MarketAssistant.Agents.InvestmentSelection.Models; +using MarketAssistant.Agents.InvestmentSelection.Strategies; +using MarketAssistant.Applications.AssetScreener.Models; +using MarketAssistant.Infrastructure.Factories; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Agents.InvestmentSelection.Executors; + +/// +/// 泛型筛选条件生成 Executor +/// 将用户需求或新闻内容转换为结构化的筛选条件 +/// +public sealed class GenerateCriteriaExecutor : Executor + where TCriteria : IScreeningCriteria +{ + private readonly IChatClientFactory _chatClientFactory; + private readonly ICriteriaGenerationStrategy _strategy; + private readonly ILogger> _logger; + + private static readonly JsonSerializerOptions SchemaOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public GenerateCriteriaExecutor( + IChatClientFactory chatClientFactory, + ICriteriaGenerationStrategy strategy, + ILogger> logger) + : base($"GenerateCriteria_{strategy.SupportedMarketType}") + { + _chatClientFactory = chatClientFactory ?? throw new ArgumentNullException(nameof(chatClientFactory)); + _strategy = strategy ?? throw new ArgumentNullException(nameof(strategy)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public override async ValueTask HandleAsync( + InvestmentSelectionWorkflowRequest input, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + if (input.MarketType != _strategy.SupportedMarketType) + { + throw new InvalidOperationException( + $"GenerateCriteriaExecutor<{typeof(TCriteria).Name}> 仅支持 {_strategy.SupportedMarketType} 市场,当前市场类型: {input.MarketType}"); + } + + _logger.LogInformation("[步骤1/3-{MarketType}] 将{Type}转换为筛选条件", + _strategy.SupportedMarketType, + input.IsNewsAnalysis ? "新闻内容" : "用户需求"); + + try + { + string systemPrompt = input.IsNewsAnalysis + ? _strategy.BuildNewsAnalysisSystemPrompt() + : _strategy.BuildUserRequirementSystemPrompt(); + + string userPrompt = _strategy.BuildUserPrompt(input); + + var chatClient = _chatClientFactory.CreateClient(); + + var schema = AIJsonUtilities.CreateJsonSchema(typeof(TCriteria), serializerOptions: SchemaOptions); + + var chatOptions = new ChatOptions + { + ResponseFormat = ChatResponseFormat.ForJsonSchema( + schema: schema, + schemaName: typeof(TCriteria).Name, + schemaDescription: $"包含筛选条件的{_strategy.SupportedMarketType}筛选参数"), + Temperature = 0.1f, + MaxOutputTokens = input.IsNewsAnalysis ? 3500 : 2000 + }; + + var response = await chatClient.GetResponseAsync( + [ + new ChatMessage(ChatRole.System, systemPrompt), + new ChatMessage(ChatRole.User, userPrompt) + ], + chatOptions, + cancellationToken); + + var criteria = _strategy.DeserializeCriteria(response.Text); + + _logger.LogInformation("[步骤1/3-{MarketType}] 筛选条件生成完成,包含 {Count} 个条件", + _strategy.SupportedMarketType, + GetCriteriaCount(criteria)); + + return new CriteriaGenerationResult + { + Criteria = criteria, + OriginalRequest = input + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "[步骤1/3-{MarketType}] 生成筛选条件失败", _strategy.SupportedMarketType); + if (ex is FriendlyException) + { + throw; + } + throw new FriendlyException(ex.Message); + } + } + + private static int GetCriteriaCount(TCriteria criteria) + { + return criteria switch + { + StockCriteria stock => stock.Criteria?.Count ?? 0, + CryptoCriteria crypto => crypto.Criteria?.Count ?? 0, + _ => 0 + }; + } +} diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/ScreenInvestmentTargetsExecutor.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/ScreenInvestmentTargetsExecutor.cs new file mode 100644 index 0000000..d159e2a --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/ScreenInvestmentTargetsExecutor.cs @@ -0,0 +1,79 @@ +using MarketAssistant.Agents.InvestmentSelection.Models; +using MarketAssistant.Applications.AssetScreener; +using MarketAssistant.Applications.AssetScreener.Models; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Agents.InvestmentSelection.Executors; + +/// +/// 步骤2: 执行投资标的筛选的 Executor(共用,支持多市场) +/// 通过 IAssetScreenerService 接口抽象,根据市场类型动态选择筛选服务 +/// +public sealed partial class ScreenInvestmentTargetsExecutor : Executor +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public ScreenInvestmentTargetsExecutor( + IServiceProvider serviceProvider, + ILogger logger) : base("ScreenInvestmentTargets") + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + [MessageHandler] + private async ValueTask HandleAsync( + CriteriaGenerationResult input, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + _logger.LogInformation("[步骤2/3] 执行投资标的筛选"); + + try + { + if (input?.Criteria == null) + { + throw new ArgumentNullException(nameof(input), "筛选条件不能为空"); + } + + var originalRequest = input.OriginalRequest; + if (originalRequest == null) + { + throw new InvalidOperationException("缺少原始请求信息"); + } + + // 根据市场类型获取对应的筛选服务(使用 Keyed Services) + var screenerService = _serviceProvider.GetRequiredKeyedService(originalRequest.MarketType); + + _logger.LogInformation("[步骤2/3] 使用市场类型: {MarketType}, 筛选服务: {ServiceType}", + originalRequest.MarketType, screenerService.GetType().Name); + + // 调用筛选服务 + List assets = await screenerService.ScreenAsync(input.Criteria); + + _logger.LogInformation("[步骤2/3] 筛选完成,获得 {Count} 个投资标的", assets.Count); + + // 返回筛选结果 + return new AssetScreeningResult + { + ScreenedAssets = assets, + Criteria = input.Criteria, + OriginalRequest = originalRequest + }; + } + catch (FriendlyException) + { + // FriendlyException 已包含用户友好信息,直接抛出避免双重包装丢失原始错误 + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "[步骤2/3] 投资标的筛选失败"); + throw new FriendlyException($"投资标的筛选失败: {ex.Message}", ex); + } + } +} + diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs new file mode 100644 index 0000000..47b6560 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs @@ -0,0 +1,192 @@ +using MarketAssistant.Agents.InvestmentSelection.Executors; +using MarketAssistant.Agents.InvestmentSelection.Models; +using MarketAssistant.Applications.AssetScreener.Models; +using MarketAssistant.Applications.InvestmentSelection.Models; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Agents.InvestmentSelection; + +/// +/// AI投资选择工作流,使用 Agent Framework Workflows 实现确定性三步骤流程 +/// 根据市场类型(股票/虚拟币)动态构建执行链 +/// 第1步: 生成筛选条件 → 第2步: 执行筛选 → 第3步: AI分析结果 +/// +public class InvestmentSelectionWorkflow : IDisposable +{ + private readonly GenerateCriteriaExecutor _generateStockCriteriaExecutor; + private readonly GenerateCriteriaExecutor _generateCryptoCriteriaExecutor; + private readonly ScreenInvestmentTargetsExecutor _screenTargetsExecutor; + private readonly AnalyzeAssetsExecutor _analyzeAssetsExecutor; + private readonly ILogger _logger; + private bool _disposed = false; + + public InvestmentSelectionWorkflow( + GenerateCriteriaExecutor generateStockCriteriaExecutor, + GenerateCriteriaExecutor generateCryptoCriteriaExecutor, + ScreenInvestmentTargetsExecutor screenTargetsExecutor, + AnalyzeAssetsExecutor analyzeAssetsExecutor, + ILogger logger) + { + _generateStockCriteriaExecutor = generateStockCriteriaExecutor ?? throw new ArgumentNullException(nameof(generateStockCriteriaExecutor)); + _generateCryptoCriteriaExecutor = generateCryptoCriteriaExecutor ?? throw new ArgumentNullException(nameof(generateCryptoCriteriaExecutor)); + _screenTargetsExecutor = screenTargetsExecutor ?? throw new ArgumentNullException(nameof(screenTargetsExecutor)); + _analyzeAssetsExecutor = analyzeAssetsExecutor ?? throw new ArgumentNullException(nameof(analyzeAssetsExecutor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// 执行基于用户需求的AI投资分析(使用工作流) + /// + public async Task AnalyzeUserRequirementAsync( + InvestmentRecommendationRequest request, + CancellationToken cancellationToken = default) + { + var workflowRequest = new InvestmentSelectionWorkflowRequest + { + MarketType = request.MarketType, + IsNewsAnalysis = false, + Content = request.UserRequirements, + RiskPreference = request.RiskPreference, + InvestmentAmount = request.InvestmentAmount, + InvestmentHorizon = request.InvestmentHorizon, + PreferredSectors = request.PreferredSectors, + ExcludedSectors = request.ExcludedSectors, + MaxRecommendations = request.MaxRecommendations + }; + + return await ExecuteWorkflowAsync(workflowRequest, cancellationToken); + } + + /// + /// 执行基于新闻内容的AI投资分析(使用工作流) + /// + public async Task AnalyzeNewsHotspotAsync( + NewsBasedInvestmentRequest request, + CancellationToken cancellationToken = default) + { + var workflowRequest = new InvestmentSelectionWorkflowRequest + { + MarketType = request.MarketType, + IsNewsAnalysis = true, + Content = request.NewsContent, + MaxRecommendations = request.MaxRecommendations + }; + + return await ExecuteWorkflowAsync(workflowRequest, cancellationToken); + } + + /// + /// 执行完整的投资选择工作流(确定性三步骤) + /// 根据市场类型动态构建执行链 + /// + private async Task ExecuteWorkflowAsync( + InvestmentSelectionWorkflowRequest request, + CancellationToken cancellationToken) + { + _logger.LogInformation("开始执行投资选择工作流,市场类型: {MarketType},分析类型: {Type}", + request.MarketType, + request.IsNewsAnalysis ? "新闻热点" : "用户需求"); + + // 根据市场类型选择对应的条件生成 Executor + WorkflowBuilder workflowBuilder = request.MarketType switch + { + MarketType.AShare => new WorkflowBuilder(_generateStockCriteriaExecutor) + .AddEdge(_generateStockCriteriaExecutor, _screenTargetsExecutor) + .AddEdge(_screenTargetsExecutor, _analyzeAssetsExecutor) + .WithOutputFrom(_analyzeAssetsExecutor), + + MarketType.Crypto => new WorkflowBuilder(_generateCryptoCriteriaExecutor) + .AddEdge(_generateCryptoCriteriaExecutor, _screenTargetsExecutor) + .AddEdge(_screenTargetsExecutor, _analyzeAssetsExecutor) + .WithOutputFrom(_analyzeAssetsExecutor), + + _ => throw new NotSupportedException($"不支持的市场类型: {request.MarketType}") + }; + + // 构建工作流 + var workflow = workflowBuilder.Build(); + + // 执行工作流 + await using Run run = await InProcessExecution.RunAsync( + workflow, request, checkpointManager: null, sessionId: null, cancellationToken); + + InvestmentSelectionResult? finalResult = null; + + // 处理工作流事件 + foreach (WorkflowEvent evt in run.NewEvents) + { + switch (evt) + { + case ExecutorInvokedEvent executorInvoked: + _logger.LogInformation("步骤开始: {ExecutorId}", executorInvoked.ExecutorId); + break; + case ExecutorCompletedEvent executorComplete: + _logger.LogInformation("步骤完成: {ExecutorId}", executorComplete.ExecutorId); + break; + // AgentResponseUpdateEvent 继承自 WorkflowOutputEvent,需在其之前匹配以防止误匹配。 + // 当前 executor 虽然不使用 AIAgent,但作为防御性措施保留。 + case AgentResponseUpdateEvent: + break; + + case WorkflowOutputEvent workflowOutput: + finalResult = workflowOutput.Data as InvestmentSelectionResult; + _logger.LogInformation("工作流完成,推荐数量: {Count}", + finalResult?.Recommendations?.Count ?? 0); + break; + case ExecutorFailedEvent executorFailed: + var failedMsg = executorFailed.Data?.Message ?? "未知错误"; + _logger.LogError(executorFailed.Data, + "步骤失败: {ExecutorId}, 错误: {Error}", + executorFailed.ExecutorId, + failedMsg); + throw new FriendlyException(failedMsg); + + case WorkflowErrorEvent workflowError: + var wfErrorMsg = workflowError.Exception?.Message ?? "工作流内部发生未知错误"; + _logger.LogError(workflowError.Exception, + "投资选择工作流发生严重错误: {Message}", wfErrorMsg); + throw new FriendlyException(wfErrorMsg); + + case WorkflowWarningEvent workflowWarning: + _logger.LogWarning("投资选择工作流警告: {Warning}", workflowWarning.Data); + break; + } + } + + return finalResult ?? CreateDefaultResult("工作流未返回结果"); + } + + /// + /// 创建默认结果 + /// + private InvestmentSelectionResult CreateDefaultResult(string? problem = null) + { + return new InvestmentSelectionResult + { + Recommendations = new List(), + ConfidenceScore = 0, + AnalysisSummary = problem ?? "分析过程中遇到问题,请稍后重试。", + MarketEnvironmentAnalysis = "无可用分析", + InvestmentAdvice = "建议稍后重试", + RiskWarnings = new List { "系统异常,请联系技术支持" } + }; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed && disposing) + { + _disposed = true; + } + } +} + diff --git a/src/Agents/StockSelection/Models/WorkflowModels.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Models/WorkflowModels.cs similarity index 56% rename from src/Agents/StockSelection/Models/WorkflowModels.cs rename to src/MarketAssistant.App.Services/Agents/InvestmentSelection/Models/WorkflowModels.cs index f3cfe5e..36a0dc7 100644 --- a/src/Agents/StockSelection/Models/WorkflowModels.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Models/WorkflowModels.cs @@ -1,12 +1,18 @@ -using MarketAssistant.Services.StockScreener.Models; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Applications.AssetScreener.Models; -namespace MarketAssistant.Agents.StockSelection.Models; +namespace MarketAssistant.Agents.InvestmentSelection.Models; /// /// 工作流请求模型(统一的输入) /// -public record StockSelectionWorkflowRequest +public record InvestmentSelectionWorkflowRequest { + /// + /// 市场类型 + /// + public MarketType MarketType { get; init; } = MarketType.AShare; + /// /// 是否为新闻分析(true=新闻分析,false=用户需求分析) /// @@ -23,39 +29,39 @@ public record StockSelectionWorkflowRequest } /// -/// 步骤1的输出:筛选条件生成结果 +/// 步骤1的输出:筛选条件生成结果(泛型支持不同市场) /// public record CriteriaGenerationResult { /// - /// 生成的筛选条件 + /// 生成的筛选条件(可以是 StockCriteria 或 CryptoCriteria) /// - public StockCriteria Criteria { get; init; } = new(); + public object Criteria { get; init; } = new(); /// /// 原始请求(用于传递到后续步骤) /// - public StockSelectionWorkflowRequest OriginalRequest { get; init; } = new(); + public InvestmentSelectionWorkflowRequest OriginalRequest { get; init; } = new(); } /// /// 步骤2的输出:筛选结果 /// -public record ScreeningResult +public record AssetScreeningResult { /// - /// 筛选得到的股票列表 + /// 筛选得到的资产列表 /// - public List ScreenedStocks { get; init; } = new(); + public List ScreenedAssets { get; init; } = new(); /// /// 使用的筛选条件 /// - public StockCriteria? Criteria { get; init; } + public object? Criteria { get; init; } /// /// 原始请求信息(用于步骤3分析) /// - public StockSelectionWorkflowRequest? OriginalRequest { get; init; } + public InvestmentSelectionWorkflowRequest? OriginalRequest { get; init; } } diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/AssetDataExtensions.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/AssetDataExtensions.cs new file mode 100644 index 0000000..7914403 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/AssetDataExtensions.cs @@ -0,0 +1,25 @@ +namespace MarketAssistant.Agents.InvestmentSelection.Strategies; + +/// +/// 资产数据字典的扩展方法,用于在格式化资产数据时统一添加非零字段 +/// +public static class AssetDataExtensions +{ + /// + /// 当 value 不为 0 时,将其按指定精度和除数转换后添加到字典中。 + /// 用于过滤掉无意义的零值字段,减少输出 JSON 体积。 + /// + /// 目标字典 + /// 字典键 + /// 原始数值(为 0 时跳过) + /// 保留小数位数,默认 2 + /// 除数(用于单位换算,如分→元),默认 1 表示不换算 + public static void AddIfNotZero(this Dictionary data, string key, decimal value, int decimals = 2, decimal divisor = 1) + { + if (value != 0) + { + var convertedValue = divisor != 1 ? value / divisor : value; + data[key] = Math.Round(convertedValue, decimals); + } + } +} diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/CryptoCriteriaGenerationStrategy.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/CryptoCriteriaGenerationStrategy.cs new file mode 100644 index 0000000..0d32589 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/CryptoCriteriaGenerationStrategy.cs @@ -0,0 +1,167 @@ +using MarketAssistant.Agents.InvestmentSelection.Models; +using MarketAssistant.Applications.AssetScreener.Models; +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Agents.InvestmentSelection.Strategies; + +/// +/// 虚拟币筛选条件生成策略 +/// +public class CryptoCriteriaGenerationStrategy : ICriteriaGenerationStrategy +{ + private static readonly JsonSerializerOptions DeserializationOptions = new(JsonSerializerOptions.Web) + { + PropertyNameCaseInsensitive = true + }; + + public MarketType SupportedMarketType => MarketType.Crypto; + + public string BuildUserRequirementSystemPrompt() + { + return """ +## 主要任务 +分析用户对虚拟币投资的需求,生成合理的筛选条件。 + +## 需求转换规则 + +### 市值类别 +- 大盘币/主流币 → market_cap >= 10000000000 (100亿美元) +- 中盘币 → market_cap: 1000000000-10000000000 (10亿-100亿美元) +- 小盘币 → market_cap < 1000000000 (10亿美元以下) +- 市值X亿美元以上 → market_cap >= X*100000000 + +### 市场表现 +- 活跃币/成交活跃 → volume_24h > 100000000 (1亿美元) +- 强势币 → price_change_7d > 10 +- 近期涨幅大 → price_change_24h > 5 +- 抗跌币 → price_change_24h > -5 +- 24小时涨幅X% → price_change_24h > X +- 7天涨幅X% → price_change_7d > X +- 30天涨幅X% → price_change_30d > X + +### 价格相关 +- 低价币 → price < 1 +- 中价币 → price: 1-100 +- 高价币 → price > 100 +- 价格X美元以下 → price < X +- 价格X美元以上 → price > X + +### 市值排名 +- 前10大币 → market_cap_rank <= 10 +- 前50大币 → market_cap_rank <= 50 +- 前100大币 → market_cap_rank <= 100 + +## 支持的筛选指标(仅使用以下指标,不要生成其他指标) + +### 基本指标 +- market_cap: 市值(美元) +- volume_24h: 24小时交易量(美元) +- price: 当前价格(USDT) +- market_cap_rank: 市值排名 + +### 市场表现指标 +- price_change_24h: 24小时涨跌幅(%) +- price_change_7d: 7天涨跌幅(%) +- price_change_30d: 30天涨跌幅(%) + +## 注意事项 +- 虚拟币市场波动性大,筛选条件应合理 +- 建议关注市值排名前100的主流币种 +- 小盘币风险较高,需谨慎推荐 +"""; + } + + public string BuildNewsAnalysisSystemPrompt() + { + return """ +## 任务 +分析新闻内容,识别相关虚拟币类别,判断情感倾向,并生成对应的筛选条件。 + +## 新闻类型识别规则 + +**区块链技术新闻**(以太坊升级、Layer2、DeFi协议)→ 关注 ETH、相关生态币种 +**比特币新闻**(BTC ETF、减半、机构采用)→ 关注 BTC +**稳定币新闻**(USDT、USDC监管)→ 关注稳定币相关 +**NFT/元宇宙新闻**→ 关注相关概念币 +**交易所新闻**(币安、Coinbase)→ 关注平台币 BNB、交易所代币 +**监管新闻**(SEC、各国政策)→ 关注合规性强的主流币 +**技术创新新闻**(AI+区块链、新共识机制)→ 关注创新项目代币 + +## 指标说明 + +### 基本指标 +- market_cap: 市值(美元) +- volume_24h: 24小时交易量(美元) +- market_cap_rank: 市值排名 +- price: 当前价格(USDT) + +### 市场表现指标(仅使用以下指标) +- price_change_24h: 24小时涨跌幅(%) +- price_change_7d: 7天涨跌幅(%) +- price_change_30d: 30天涨跌幅(%) + +## 情感判断与筛选策略 + +**积极新闻 → 成长币策略** +- price_change_7d > 0 +- volume_24h > 50000000 +- market_cap_rank <= 200 + +**技术突破 → 创新币策略** +- volume_24h > 50000000 +- price_change_24h > -5 +- market_cap_rank <= 100 + +**监管利好 → 主流币策略** +- market_cap_rank <= 20 +- market_cap > 1000000000 +- volume_24h > 100000000 + +**市场泡沫/消极新闻 → 防御策略** +- market_cap_rank <= 10 +- volume_24h > 500000000 +- price_change_30d > -10 + +**中性新闻 → 平衡策略** +- market_cap_rank <= 50 +- volume_24h > 10000000 +- price_change_7d > -20 +"""; + } + + public string BuildUserPrompt(InvestmentSelectionWorkflowRequest request) + { + if (request.IsNewsAnalysis) + { + return $""" + 新闻内容: + {request.Content} + + 推荐虚拟币数量限制:{request.MaxRecommendations} + + 请根据新闻内容生成虚拟币筛选条件。 + """; + } + else + { + return $""" + 用户需求: + {request.Content} + + 推荐虚拟币数量限制:{request.MaxRecommendations} + + 请根据用户需求生成虚拟币筛选条件。 + """; + } + } + + public CryptoCriteria DeserializeCriteria(string json) + { + var criteria = LlmJsonExtractor.Deserialize(json, DeserializationOptions); + if (criteria == null) + { + throw new InvalidOperationException("虚拟币筛选条件 JSON 解析失败"); + } + return criteria; + } +} diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/CryptoDataFormatter.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/CryptoDataFormatter.cs new file mode 100644 index 0000000..6b67cad --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/CryptoDataFormatter.cs @@ -0,0 +1,81 @@ +using MarketAssistant.Applications.AssetScreener.Models; +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Agents.InvestmentSelection.Strategies; + +/// +/// 虚拟币数据格式化器 +/// +public class CryptoDataFormatter : IAssetDataFormatter +{ + public MarketType SupportedMarketType => MarketType.Crypto; + + public string FormatAssetsForAnalysis(List assets) + { + var simplifiedCryptos = assets.OfType().Select(c => + { + var data = new Dictionary + { + ["名称"] = c.Name, + ["代码"] = c.Symbol + }; + + data.AddIfNotZero("当前价格_USDT", c.Current); + data.AddIfNotZero("市值_亿美元", c.Mc, 2, 100000000); + data.AddIfNotZero("完全稀释市值_亿美元", c.Fmc, 2, 100000000); + + data.AddIfNotZero("24h交易量_万", c.Volume, 0); + data.AddIfNotZero("24h成交额_亿美元", c.Amount, 2, 100000000); + + data.AddIfNotZero("24h涨跌幅_百分比", c.Pct); + data.AddIfNotZero("7天涨跌幅_百分比", c.PriceChange7d); + data.AddIfNotZero("30天涨跌幅_百分比", c.PriceChange30d); + data.AddIfNotZero("24h振幅_百分比", c.ChgPct); + + if (c.MarketCapRank > 0) + data["市值排名"] = c.MarketCapRank; + + data.AddIfNotZero("流通供应量", c.CirculatingSupply, 0); + data.AddIfNotZero("总供应量", c.TotalSupply, 0); + if (c.MaxSupply.HasValue && c.MaxSupply.Value > 0) + data["最大供应量"] = Math.Round(c.MaxSupply.Value, 0); + + return data; + }).ToList(); + + return JsonSerializer.Serialize(simplifiedCryptos, JsonOptions.AssetFormatterOptions); + } + + public string GetAnalysisInstructions(bool isNewsAnalysis) + { + return @" +你是专业的加密货币投资顾问,基于用户需求/新闻热点和虚拟币数据提供投资建议。 + +## 核心职责 +从筛选出的虚拟币中进行多维度分析,输出结构化推荐报告。 + +## 评估维度(灵活权重) +1. **项目基本面**:技术创新、团队背景、生态发展、实际应用 +2. **市场表现**:市值排名、交易量、价格走势、流动性 +3. **链上数据**:活跃地址、交易次数、持币集中度、大户动向 +4. **社区热度**:社交媒体讨论、开发者活跃度、社区支持 +5. **风险评估**:波动性、监管风险、技术风险、市场情绪" + (isNewsAnalysis ? "、新闻关联度" : "") + @" + +## 虚拟币特有分析要点 +- 优先考虑市值排名前100的主流币种 +- 关注项目的技术创新和实际应用场景 +- 评估代币经济模型的合理性 +- 注意市场情绪和恐慌贪婪指数 +- 虚拟币市场波动性大,风险提示要充分 + +## 分析要点 +- 推荐理由必须包含具体数据支撑,避免空泛描述 +- 风险提示应特别强调虚拟币的高波动性 +- 如无合适标的,可返回空推荐列表 + +## 输出格式 +严格按 JSON Schema 定义的结构输出,所有必填字段不能为空或null。 +Symbol 字段格式为交易对形式,如 BTC/USDT、ETH/USDT。 +"; + } +} diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/IAssetDataFormatter.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/IAssetDataFormatter.cs new file mode 100644 index 0000000..14b7d97 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/IAssetDataFormatter.cs @@ -0,0 +1,26 @@ +using MarketAssistant.Applications.AssetScreener.Models; +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Agents.InvestmentSelection.Strategies; + +/// +/// 资产数据格式化策略接口 +/// 用于将筛选结果格式化为适合 AI 分析的 JSON 文本 +/// +public interface IAssetDataFormatter +{ + /// + /// 支持的市场类型 + /// + MarketType SupportedMarketType { get; } + + /// + /// 将资产列表格式化为 JSON 字符串,供 AI 分析使用 + /// + string FormatAssetsForAnalysis(List assets); + + /// + /// 获取市场特定的 AI 分析指令 + /// + string GetAnalysisInstructions(bool isNewsAnalysis); +} diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/ICriteriaGenerationStrategy.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/ICriteriaGenerationStrategy.cs new file mode 100644 index 0000000..31a2329 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/ICriteriaGenerationStrategy.cs @@ -0,0 +1,37 @@ +using MarketAssistant.Agents.InvestmentSelection.Models; +using MarketAssistant.Applications.AssetScreener.Models; +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Agents.InvestmentSelection.Strategies; + +/// +/// 筛选条件生成策略接口 +/// 用于将用户需求或新闻内容转换为结构化的筛选条件 +/// +public interface ICriteriaGenerationStrategy where TCriteria : IScreeningCriteria +{ + /// + /// 支持的市场类型 + /// + MarketType SupportedMarketType { get; } + + /// + /// 构建用户需求分析的系统提示词 + /// + string BuildUserRequirementSystemPrompt(); + + /// + /// 构建新闻分析的系统提示词 + /// + string BuildNewsAnalysisSystemPrompt(); + + /// + /// 构建用户提示词 + /// + string BuildUserPrompt(InvestmentSelectionWorkflowRequest request); + + /// + /// 反序列化筛选条件 + /// + TCriteria DeserializeCriteria(string json); +} diff --git a/src/Agents/StockSelection/Executors/GenerateCriteriaExecutor.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/StockCriteriaGenerationStrategy.cs similarity index 58% rename from src/Agents/StockSelection/Executors/GenerateCriteriaExecutor.cs rename to src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/StockCriteriaGenerationStrategy.cs index 540e2e3..7f9afea 100644 --- a/src/Agents/StockSelection/Executors/GenerateCriteriaExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/StockCriteriaGenerationStrategy.cs @@ -1,122 +1,22 @@ -using MarketAssistant.Agents.StockSelection.Models; -using MarketAssistant.Infrastructure.Factories; -using MarketAssistant.Services.StockScreener.Models; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; +using MarketAssistant.Agents.InvestmentSelection.Models; +using MarketAssistant.Applications.AssetScreener.Models; +using MarketAssistant.Infrastructure.Core; -namespace MarketAssistant.Agents.StockSelection.Executors; +namespace MarketAssistant.Agents.InvestmentSelection.Strategies; /// -/// 步骤1: 生成股票筛选条件的 Executor(基于 Executor 模式) -/// 将用户需求或新闻内容转换为结构化的筛选条件 JSON +/// 股票筛选条件生成策略 /// -public sealed class GenerateCriteriaExecutor : Executor +public class StockCriteriaGenerationStrategy : ICriteriaGenerationStrategy { - private readonly IChatClientFactory _chatClientFactory; - private readonly ILogger _logger; - - /// - /// 用于生成 JSON Schema 的序列化选项(camelCase 属性命名) - /// - private static readonly JsonSerializerOptions SchemaOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - - /// - /// 用于反序列化 AI 响应的序列化选项(Web 默认配置 + 大小写不敏感) - /// private static readonly JsonSerializerOptions DeserializationOptions = new(JsonSerializerOptions.Web) { PropertyNameCaseInsensitive = true }; - public GenerateCriteriaExecutor( - IChatClientFactory chatClientFactory, - ILogger logger) : base("GenerateCriteria") - { - _chatClientFactory = chatClientFactory ?? throw new ArgumentNullException(nameof(chatClientFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public override async ValueTask HandleAsync( - StockSelectionWorkflowRequest input, - IWorkflowContext context, - CancellationToken cancellationToken = default) - { - _logger.LogInformation("[步骤1/3] 将{Type}转换为筛选条件", - input.IsNewsAnalysis ? "新闻内容" : "用户需求"); - - try - { - // 构建 System Prompt - string systemPrompt = input.IsNewsAnalysis - ? BuildNewsAnalysisSystemPrompt() - : BuildUserRequirementSystemPrompt(); - - // 构建 User Prompt - string userPrompt = BuildUserPrompt(input); - - // 创建 ChatClient - var chatClient = _chatClientFactory.CreateClient(); - - // 创建结构化输出的 JSON Schema - var schema = AIJsonUtilities.CreateJsonSchema(typeof(StockCriteria), serializerOptions: SchemaOptions); - - // 配置聊天选项 - var chatOptions = new ChatOptions - { - ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema: schema, - schemaName: "StockCriteria", - schemaDescription: "包含筛选条件、市场、行业和数量限制的股票筛选参数"), - Temperature = 0.1f, - MaxOutputTokens = input.IsNewsAnalysis ? 3500 : 2000 - }; - - // 执行聊天完成 - var response = await chatClient.GetResponseAsync( - [ - new ChatMessage(ChatRole.System, systemPrompt), - new ChatMessage(ChatRole.User, userPrompt) - ], - chatOptions, - cancellationToken); - - // 获取响应文本并反序列化为 StockCriteria 对象以验证格式 - var criteria = JsonSerializer.Deserialize(response.Text, DeserializationOptions); - if (criteria == null) - { - throw new InvalidOperationException("筛选条件 JSON 解析失败"); - } - _logger.LogInformation("[步骤1/3] 筛选条件生成完成,包含 {Count} 个条件", - criteria.Criteria?.Count ?? 0); - // 返回结果 - return new CriteriaGenerationResult - { - Criteria = criteria, - OriginalRequest = input - }; - } - catch (Exception ex) - { - _logger.LogError(ex, "[步骤1/3] 生成筛选条件失败"); - // 检查是否为 FriendlyException,如果是则直接抛出 - if (ex is FriendlyException) - { - throw; - } - - // 将其他异常包装为 FriendlyException,以便在 UI 层显示友好错误 - throw new FriendlyException(ex.Message); - } - } + public MarketType SupportedMarketType => MarketType.AShare; - /// - /// 构建用户需求分析的系统提示词 - /// - private string BuildUserRequirementSystemPrompt() + public string BuildUserRequirementSystemPrompt() { return """ ## 主要任务 @@ -188,7 +88,7 @@ private string BuildUserRequirementSystemPrompt() ## 支持的筛选指标 -### 基本指标 (basic) - 15个 +### 基本指标 (15个) - mc: 总市值 - fmc: 流通市值 - pettm: 市盈率TTM @@ -205,7 +105,7 @@ private string BuildUserRequirementSystemPrompt() - oiy: 营业收入同比增长 - niota: 总资产报酬率 -### 行情指标 (market) - 14个 +### 行情指标 (14个) - current: 当前价 - pct: 当日涨跌幅 - pct5: 近5日涨跌幅 @@ -221,7 +121,7 @@ private string BuildUserRequirementSystemPrompt() - tr: 当日换手率 - chgpct: 当日振幅 -### 雪球指标 (snowball) - 9个 +### 雪球指标 (9个) - follow: 累计关注人数 - tweet: 累计讨论次数 - deal: 累计交易分享数 @@ -234,10 +134,7 @@ private string BuildUserRequirementSystemPrompt() """; } - /// - /// 构建新闻分析的系统提示词 - /// - private string BuildNewsAnalysisSystemPrompt() + public string BuildNewsAnalysisSystemPrompt() { return """ ## 任务 @@ -286,7 +183,7 @@ private string BuildNewsAnalysisSystemPrompt() **积极新闻 → 成长股策略** - npay > 15 -- ro ediluted > 12 +- roediluted > 12 - pct20 > -5 **政策利好 → 龙头股策略** @@ -311,18 +208,15 @@ private string BuildNewsAnalysisSystemPrompt() """; } - /// - /// 构建用户提示词 - /// - private string BuildUserPrompt(StockSelectionWorkflowRequest input) + public string BuildUserPrompt(InvestmentSelectionWorkflowRequest request) { - if (input.IsNewsAnalysis) + if (request.IsNewsAnalysis) { return $""" 新闻内容: - {input.Content} + {request.Content} - 推荐股票数量限制:{input.MaxRecommendations} + 推荐股票数量限制:{request.MaxRecommendations} 请根据新闻内容生成股票筛选条件。 """; @@ -331,13 +225,22 @@ private string BuildUserPrompt(StockSelectionWorkflowRequest input) { return $""" 用户需求: - {input.Content} + {request.Content} - 推荐股票数量限制:{input.MaxRecommendations} + 推荐股票数量限制:{request.MaxRecommendations} 请根据用户需求生成股票筛选条件。 """; } } -} + public StockCriteria DeserializeCriteria(string json) + { + var criteria = LlmJsonExtractor.Deserialize(json, DeserializationOptions); + if (criteria == null) + { + throw new InvalidOperationException("股票筛选条件 JSON 解析失败"); + } + return criteria; + } +} diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/StockDataFormatter.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/StockDataFormatter.cs new file mode 100644 index 0000000..2690205 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/StockDataFormatter.cs @@ -0,0 +1,107 @@ +using MarketAssistant.Applications.AssetScreener.Models; +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Agents.InvestmentSelection.Strategies; + +/// +/// 股票数据格式化器 +/// +public class StockDataFormatter : IAssetDataFormatter +{ + public MarketType SupportedMarketType => MarketType.AShare; + + /// + /// 指标字段 → (显示名, 小数位数, 除数) + /// + private static readonly (string Field, string DisplayName, int Decimals, decimal Divisor)[] IndicatorConfig = + [ + ("current", "当前价_元", 2, 1), + ("pct", "涨跌幅_百分比", 2, 1), + ("mc", "总市值_亿元", 2, 100_000_000), + ("fmc", "流通市值_亿元", 2, 100_000_000), + ("amount", "成交额_亿元", 2, 100_000_000), + ("volume", "成交量_万股", 2, 1), + ("chgpct", "当日振幅_百分比", 2, 1), + ("volume_ratio", "量比", 2, 1), + ("tr", "换手率_百分比", 2, 1), + ("pettm", "市盈率TTM", 2, 1), + ("pelyr", "市盈率LYR", 2, 1), + ("pb", "市净率", 2, 1), + ("psr", "市销率", 2, 1), + ("bps", "每股净资产_元", 2, 1), + ("eps", "每股收益_元", 2, 1), + ("dy_l", "股息收益率_百分比", 2, 1), + ("roediluted", "净资产收益率ROE_百分比", 2, 1), + ("niota", "总资产报酬率_百分比", 2, 1), + ("netprofit", "净利润_亿元", 2, 100_000_000), + ("total_revenue", "营业收入_亿元", 2, 100_000_000), + ("npay", "净利润同比增长_百分比", 2, 1), + ("oiy", "营收同比增长_百分比", 2, 1), + ("pct5", "近5日涨跌幅_百分比", 2, 1), + ("pct10", "近10日涨跌幅_百分比", 2, 1), + ("pct20", "近20日涨跌幅_百分比", 2, 1), + ("pct60", "近60日涨跌幅_百分比", 2, 1), + ("pct120", "近120日涨跌幅_百分比", 2, 1), + ("pct250", "近250日涨跌幅_百分比", 2, 1), + ("pct_current_year", "年初至今涨跌幅_百分比", 2, 1), + ("follow", "累计关注人数", 0, 1), + ("tweet", "累计讨论次数", 0, 1), + ("deal", "累计交易分享数", 0, 1), + ("follow7d", "一周新增关注", 0, 1), + ("tweet7d", "一周新增讨论数", 0, 1), + ("deal7d", "一周新增交易分享数", 0, 1), + ("follow7dpct", "一周关注增长率_百分比", 2, 1), + ("tweet7dpct", "一周讨论增长率_百分比", 2, 1), + ("deal7dpct", "一周交易分享增长率_百分比", 2, 1), + ]; + + public string FormatAssetsForAnalysis(List assets) + { + var simplifiedStocks = assets.OfType().Select(s => + { + var data = new Dictionary + { + ["名称"] = s.Name, + ["代码"] = s.Symbol + }; + + foreach (var (field, displayName, decimals, divisor) in IndicatorConfig) + { + if (s.Indicators.TryGetValue(field, out var value)) + { + data.AddIfNotZero(displayName, value, decimals, divisor); + } + } + + return data; + }).ToList(); + + return JsonSerializer.Serialize(simplifiedStocks, JsonOptions.AssetFormatterOptions); + } + + public string GetAnalysisInstructions(bool isNewsAnalysis) + { + return @" +你是专业的投资顾问,基于用户需求/新闻热点和股票数据提供投资建议。 + +## 核心职责 +从筛选出的股票中进行多维度分析,输出结构化推荐报告。 + +## 评估维度(灵活权重) +1. **财务质量**:ROE、利润增长率、现金流、EPS/BPS +2. **估值水平**:PE/PB/PS 合理性、低估/高估判断、股息率 +3. **市场表现**:涨跌幅、流动性(成交额/换手率)、技术面趋势 +4. **需求匹配**:风险偏好、投资期限、行业偏好" + (isNewsAnalysis ? ",或新闻关联度" : "") + @" +5. **社交热度**:雪球关注/讨论及增长趋势(辅助参考) + +## 分析要点 +- 选出最优股票时,优先考虑财务健康度和估值合理性 +- 推荐理由必须包含具体数据支撑,避免空泛描述 +- 风险提示应针对个股和市场环境的具体风险 +- 如无合适标的,可返回空推荐列表 + +## 输出格式 +严格按 JSON Schema 定义的结构输出,所有必填字段不能为空或null。 +"; + } +} diff --git a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/AnalysisAggregatorExecutor.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/AnalysisAggregatorExecutor.cs new file mode 100644 index 0000000..2699c0f --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/AnalysisAggregatorExecutor.cs @@ -0,0 +1,49 @@ +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Agents.MarketAnalysis.Executors; + +/// +/// 分析聚合器 Executor(基于官方 Fan-In 模式) +/// 参考: https://learn.microsoft.com/zh-cn/agent-framework/tutorials/workflows/simple-concurrent-workflow +/// +/// Fan-In 工作原理: +/// 1. HandleAsync 会被多次调用(每个源 Agent 一次) +/// 2. 每次接收该 Agent 的消息列表(List) +/// 3. 内部维护列表收集所有消息 +/// 4. 收齐后使用 context.YieldOutputAsync 输出给下游 +/// +/// +public sealed partial class AnalysisAggregatorExecutor : Executor +{ + private readonly ILogger _logger; + + public AnalysisAggregatorExecutor( + ILogger logger) + : base(id: "AnalysisAggregator") + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + [MessageHandler] + private async ValueTask> HandleAsync( + List messages, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(messages); + + // AddFanInBarrierEdge 会在上游全部完成后,将聚合后的消息列表一次性传入。 + var expectedCount = await context.ReadStateAsync( + WorkflowStateKeys.ExpectedAnalystCount, + WorkflowStateKeys.Scope, + cancellationToken); + + _logger.LogInformation( + "已收集 {Expected} 位分析师的结果,共 {TotalMessages} 条消息(Context Hash: {ContextHash})", + expectedCount, messages.Count, context.GetHashCode()); + + return messages; + } +} diff --git a/src/Agents/MarketAnalysis/Executors/AnalysisDispatcherExecutor.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/AnalysisDispatcherExecutor.cs similarity index 55% rename from src/Agents/MarketAnalysis/Executors/AnalysisDispatcherExecutor.cs rename to src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/AnalysisDispatcherExecutor.cs index ca5c57b..f9dbf63 100644 --- a/src/Agents/MarketAnalysis/Executors/AnalysisDispatcherExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/AnalysisDispatcherExecutor.cs @@ -7,16 +7,18 @@ namespace MarketAssistant.Agents.MarketAnalysis.Executors; /// /// 分析分发器 Executor(基于官方 Fan-Out 模式) /// 参考: https://learn.microsoft.com/zh-cn/agent-framework/tutorials/workflows/simple-concurrent-workflow -/// +/// /// 职责: -/// 1. 接收股票代码 +/// 1. 接收标的代码 /// 2. 保存必要的配置到 workflow state -/// 3. 广播消息给所有分析师(通过 SendMessageAsync) -/// 4. 发送 TurnToken 触发分析师开始处理 +/// 3. 广播 ChatMessage 给所有分析师(通过 SendMessageAsync) +/// 4. 广播 TurnToken 触发分析师开始处理(AIAgent 收到 ChatMessage 后不会自动处理,必须收到 TurnToken 才会调用 LLM) /// -public sealed class AnalysisDispatcherExecutor : Executor +[SendsMessage(typeof(ChatMessage))] +[SendsMessage(typeof(TurnToken))] +public sealed partial class AnalysisDispatcherExecutor : Executor { - private const string AnalysisPromptTemplate = "请对股票 {0} 进行专业分析,提供投资建议。"; + private const string AnalysisPromptTemplate = "请对标的 {0} 进行专业分析,提供投资建议。"; private readonly int _expectedAnalystCount; private readonly ILogger _logger; @@ -30,30 +32,28 @@ public AnalysisDispatcherExecutor( _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - /// - /// 处理股票代码,广播分析任务给所有分析师 - /// - public override async ValueTask HandleAsync( - string stockSymbol, + [MessageHandler] + private async ValueTask HandleAsync( + string assetSymbol, IWorkflowContext context, CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(stockSymbol)) + if (string.IsNullOrWhiteSpace(assetSymbol)) { - throw new ArgumentException("股票代码不能为空", nameof(stockSymbol)); + throw new ArgumentException("标的代码不能为空", nameof(assetSymbol)); } try { _logger.LogInformation( - "分发器开始处理股票 {StockSymbol} 的分析请求,期望 {Count} 位分析师", - stockSymbol, _expectedAnalystCount); + "分发器开始处理标的 {AssetSymbol} 的分析请求,期望 {Count} 位分析师", + assetSymbol, _expectedAnalystCount); // https://github.com/microsoft/agent-framework/issues/2162 // 保存配置到 workflow state(显式指定 scope 确保跨 Executor 可见) await context.QueueStateUpdateAsync( - WorkflowStateKeys.StockSymbol, - stockSymbol, + WorkflowStateKeys.AssetSymbol, + assetSymbol, WorkflowStateKeys.Scope, cancellationToken); await context.QueueStateUpdateAsync( @@ -62,21 +62,25 @@ await context.QueueStateUpdateAsync( WorkflowStateKeys.Scope, cancellationToken); - // 构建分析提示词并广播给所有分析师(Fan-Out) - // 注意:接收的 Agent 会排队消息,但不会立即处理,直到收到 TurnToken - string prompt = string.Format(AnalysisPromptTemplate, stockSymbol); - await context.SendMessageAsync(new ChatMessage(ChatRole.User, prompt), cancellationToken); + // 1. 广播 ChatMessage 给所有分析师(AIAgent 会缓存消息但不会开始处理) + string prompt = string.Format(AnalysisPromptTemplate, assetSymbol); + await context.SendMessageAsync( + new ChatMessage(ChatRole.User, prompt), + cancellationToken); - // 发送 TurnToken 触发所有分析师开始处理 - await context.SendMessageAsync(new TurnToken(emitEvents: true), cancellationToken); + // 2. 广播 TurnToken 触发分析师开始处理(关键步骤!) + // AIAgent 是"懒加载"的,只有收到 TurnToken 才会调用 LLM 处理缓存的消息 + await context.SendMessageAsync( + new TurnToken(emitEvents: true), + cancellationToken); _logger.LogInformation( - "分发器已将分析任务分发给 {Count} 位分析师,股票: {StockSymbol}", - _expectedAnalystCount, stockSymbol); + "分发器已将分析任务分发给 {Count} 位分析师,标的: {AssetSymbol}", + _expectedAnalystCount, assetSymbol); } catch (Exception ex) { - _logger.LogError(ex, "分发分析请求时发生错误,股票代码: {StockSymbol}", stockSymbol); + _logger.LogError(ex, "分发分析请求时发生错误,标的代码: {AssetSymbol}", assetSymbol); throw; } } diff --git a/src/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs similarity index 55% rename from src/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs rename to src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs index c768d02..1a93a86 100644 --- a/src/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs @@ -1,5 +1,6 @@ using MarketAssistant.Agents.Analysts; using MarketAssistant.Agents.MarketAnalysis.Models; +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Infrastructure.Factories; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; @@ -14,7 +15,7 @@ namespace MarketAssistant.Agents.MarketAnalysis.Executors; /// 负责汇总各分析师的分析并生成最终报告 /// 使用 AIAgent 支持工具调用 + 结构化输出 /// -public sealed class CoordinatorExecutor : Executor, MarketAnalysisReport> +public sealed partial class CoordinatorExecutor : Executor { private readonly AIAgent _coordinatorAgent; private readonly ILogger _logger; @@ -38,20 +39,19 @@ public CoordinatorExecutor( _logger = logger ?? throw new ArgumentNullException(nameof(logger)); // 在构造函数中创建 Agent(确保 tools 配置正确) - _coordinatorAgent = analystAgentFactory.CreateAnalyst(); + // 使用非泛型方法:CreateAnalyst 返回的是中间件包装后的 AIAgent,无法强制转换为具体类型 + _coordinatorAgent = analystAgentFactory.CreateAnalyst(typeof(CoordinatorAnalystAgent)); _logger.LogInformation("协调分析师 Agent 已创建(支持工具调用 + 结构化输出)"); } - /// - /// 处理聚合的分析师消息,生成并返回最终分析报告 - /// - public override async ValueTask HandleAsync( + [MessageHandler] + private async ValueTask HandleAsync( List analystMessages, IWorkflowContext context, CancellationToken cancellationToken = default) { - _logger.LogWarning("🔍 [DEBUG] CoordinatorExecutor.HandleAsync 被调用,收到 {Count} 条消息", analystMessages?.Count ?? 0); + _logger.LogDebug("CoordinatorExecutor.HandleAsync 被调用,收到 {Count} 条消息", analystMessages?.Count ?? 0); ArgumentNullException.ThrowIfNull(analystMessages); @@ -60,17 +60,17 @@ public override async ValueTask HandleAsync( throw new ArgumentException("没有分析师数据", nameof(analystMessages)); } - // 从工作流状态读取股票代码 - var stockSymbol = await context.ReadStateAsync(WorkflowStateKeys.StockSymbol, WorkflowStateKeys.Scope, cancellationToken); + // 从工作流状态读取分析标的代码 + var assetSymbol = await context.ReadStateAsync(WorkflowStateKeys.AssetSymbol, WorkflowStateKeys.Scope, cancellationToken); - if (string.IsNullOrWhiteSpace(stockSymbol)) + if (string.IsNullOrWhiteSpace(assetSymbol)) { - throw new InvalidOperationException("无法从工作流状态中获取股票代码"); + throw new InvalidOperationException("无法从工作流状态中获取标的代码"); } _logger.LogInformation( - "协调分析师开始生成最终报告,股票: {StockSymbol}, 分析师数量: {Count}", - stockSymbol, + "协调分析师开始生成最终报告,标的: {AssetSymbol}, 分析师数量: {Count}", + assetSymbol, analystMessages.Count); try @@ -81,19 +81,28 @@ public override async ValueTask HandleAsync( .Where(m => !m.Contents.Any(c => c is FunctionCallContent or FunctionResultContent)) .ToList(); + // 所有分析师均无文本输出(仅产生工具调用)时,无法生成有意义的综合报告 + if (filteredMessages.Count == 0) + { + throw new InvalidOperationException( + "所有分析师均未产生文本结论,无法生成综合报告"); + } + // 构建聊天消息列表 var messages = new List(filteredMessages) { // 添加用户请求:生成综合报告 new ChatMessage( ChatRole.User, - $"请基于以上所有分析师的专业意见,为股票 {stockSymbol} 生成一份综合分析报告。") + $"请基于以上所有分析师的专业意见,为标的 {assetSymbol} 生成一份综合分析报告。") }; // 使用带结构化输出的 ChatClientAgent 运行 + // 重试由 ResilientChatClient 装饰器统一提供,此处无需额外重试管道 + // session: null — 无状态一次性调用,无需会话累积 var agentResponse = await _coordinatorAgent.RunAsync( messages, - thread: null, + session: null, options: null, cancellationToken); @@ -106,8 +115,24 @@ public override async ValueTask HandleAsync( throw new InvalidOperationException("协调分析师未能生成报告"); } - // 🎉 直接反序列化为 CoordinatorResult - var coordinatorResult = agentResponse.Deserialize(JsonOptions); + // 从协调分析师的回复文本中反序列化结构化结果 + // 某些 LLM 即使指定了 ForJsonSchema 也可能在 JSON 前后输出多余文本(前缀词、markdown 代码块等), + // 使用 LlmJsonExtractor 进行多层兜底解析(直接解析 → 剥离 markdown → Utf8JsonReader 精确定位) + var rawText = coordinatorMessage.Text ?? string.Empty; + + CoordinatorResult? coordinatorResult; + try + { + coordinatorResult = LlmJsonExtractor.Deserialize(rawText, JsonOptions); + } + catch (JsonException jsonEx) + { + _logger.LogError(jsonEx, + "协调分析师 JSON 解析失败,原始文本前 500 字符: {Preview}", + rawText.Length > 500 ? rawText[..500] : rawText); + throw new InvalidOperationException( + $"协调分析师返回的数据无法解析为结构化结果: {jsonEx.Message}", jsonEx); + } if (coordinatorResult == null) { @@ -120,10 +145,11 @@ public override async ValueTask HandleAsync( coordinatorResult.InvestmentRating); // 创建最终报告 + // 仅保存过滤后的分析师消息(不含工具调用细节),避免归档时序列化 FunctionResultContent 失败 var finalReport = new MarketAnalysisReport { - StockSymbol = stockSymbol, - AnalystMessages = new List(analystMessages) + AssetSymbol = assetSymbol, + AnalystMessages = new List(filteredMessages) { coordinatorMessage }, @@ -131,16 +157,24 @@ public override async ValueTask HandleAsync( CreatedAt = DateTime.UtcNow }; - _logger.LogInformation("协调分析师已完成最终报告生成,股票: {StockSymbol}", - stockSymbol); + _logger.LogInformation("协调分析师已完成最终报告生成,标的: {AssetSymbol}", + assetSymbol); + + // 显式将报告推入工作流输出队列。 + // WithOutputFrom 的 auto-yield 在 RunStreamingAsync(流式执行)路径下 + // 存在时序缺陷:WatchStreamAsync 的 IAsyncEnumerable 在 auto-yield 入队前 + // 已结束迭代,导致 WorkflowOutputEvent 丢失。显式调用 YieldOutputAsync + // 确保事件在 Executor 返回前入队,对非流式路径无副作用。 + await context.YieldOutputAsync(finalReport, cancellationToken); return finalReport; } catch (Exception ex) { - _logger.LogError(ex, "协调分析师生成报告时发生错误,股票: {StockSymbol}", - await context.ReadStateAsync(WorkflowStateKeys.StockSymbol, cancellationToken) ?? "未知"); + _logger.LogError(ex, "协调分析师生成报告时发生错误,标的: {AssetSymbol}", + await context.ReadStateAsync(WorkflowStateKeys.AssetSymbol, WorkflowStateKeys.Scope, cancellationToken) ?? "未知"); throw; } } + } diff --git a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs new file mode 100644 index 0000000..fcab70f --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs @@ -0,0 +1,626 @@ +using MarketAssistant.Agents.Analysts; +using MarketAssistant.Agents.Analysts.Attributes; +using MarketAssistant.Agents.MarketAnalysis.Executors; +using MarketAssistant.Agents.MarketAnalysis.Models; +using MarketAssistant.Infrastructure.Factories; +using MarketAssistant.Services.Settings; +using MarketAssistant.Trading; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.ComponentModel; +using System.Reflection; + +namespace MarketAssistant.Agents.MarketAnalysis; + +/// +/// 市场分析并发工作流(基于 Agent Framework 最佳实践) +/// 参考: https://learn.microsoft.com/zh-cn/agent-framework/tutorials/workflows/agents-in-workflows +/// +public class MarketAnalysisWorkflow +{ + private readonly AnalysisAggregatorExecutor _aggregatorExecutor; + private readonly CoordinatorExecutor _coordinatorExecutor; + private readonly IUserSettingService _userSettingService; + private readonly IAnalystAgentFactory _analystAgentFactory; + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + private readonly AnalysisReportCache _reportCache; + + /// + /// MAF Agent Name(ASCII 标识符,如 "FundamentalAnalyst")→ 中文显示名(如"基本面分析师")映射。 + /// 在 创建 Agent 时一并建立,供 + /// 把工作流 ExecutorId(如 + /// FundamentalAnalyst_826faad2...)翻译为用户可读的显示名。 + /// + /// + /// 为何不能直接把中文 DisplayName 作为 MAF Agent Name:MAF 的 + /// AIAgentExtensions.GetDescriptiveId 会用正则 [^0-9A-Za-z]+ 清洗 + /// Name + "_" + Id 生成 ExecutorId,中文字符会被整体替换为单个下划线, + /// 导致 ExecutorId 退化为 _826faad2...。故 Name 必须为 ASCII, + /// 显示名在本映射中维护。 + /// + private readonly Dictionary _agentNameToDisplayName = new(); + + /// + /// 分析进度事件 + /// + public event EventHandler? ProgressChanged; + + public MarketAnalysisWorkflow( + AnalysisAggregatorExecutor aggregatorExecutor, + CoordinatorExecutor coordinatorExecutor, + IUserSettingService userSettingService, + IAnalystAgentFactory analystAgentFactory, + ILoggerFactory loggerFactory, + AnalysisReportCache reportCache, + ILogger logger) + { + _aggregatorExecutor = aggregatorExecutor ?? throw new ArgumentNullException(nameof(aggregatorExecutor)); + _coordinatorExecutor = coordinatorExecutor ?? throw new ArgumentNullException(nameof(coordinatorExecutor)); + _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); + _analystAgentFactory = analystAgentFactory ?? throw new ArgumentNullException(nameof(analystAgentFactory)); + _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + _reportCache = reportCache ?? throw new ArgumentNullException(nameof(reportCache)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// 执行市场分析工作流 + /// + public async Task AnalyzeAsync( + string assetSymbol, + CancellationToken cancellationToken = default) + { + try + { + _logger.LogInformation("开始执行市场分析工作流,标的代码: {AssetSymbol}", assetSymbol); + + OnProgressChanged(new AnalysisProgressEventArgs + { + StageDescription = "正在准备分析环境", + IsInProgress = true + }); + + // 获取启用的分析师列表 + var enabledAnalysts = GetEnabledAnalysts(); + if (enabledAnalysts.Count == 0) + { + throw new InvalidOperationException("没有启用任何分析师,请在设置中至少启用一位分析师"); + } + + // 创建分析师代理(记录失败的分析师用于降级提示) + // 每次分析创建独立的市场快照实例,避免 Singleton 共享可变状态导致并发数据竞争 + var marketSnapshot = new MarketSnapshotContextProvider(); + marketSnapshot.SetData("分析标的", assetSymbol); + marketSnapshot.SetData("分析时间", DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss UTC")); + + var analystAgents = CreateAnalystAgents(enabledAnalysts, marketSnapshot); + var failedAnalystNames = analystAgents.FailedTypes + .Select(GetAnalystDisplayNameFromType) + .ToList(); + var createdAgents = analystAgents.Agents; + + if (createdAgents.Count == 0) + { + throw new InvalidOperationException("所有分析师创建失败,无法执行分析"); + } + + // 构建工作流(传入分析师数量) + var workflow = BuildWorkflow(createdAgents.Count, createdAgents); + + OnProgressChanged(new AnalysisProgressEventArgs + { + StageDescription = $"{createdAgents.Count} 位分析师正在并发分析", + IsInProgress = true, + TotalAnalysts = createdAgents.Count, + FailedAnalysts = failedAnalystNames + }); + + // 执行工作流(流式处理) + var finalReport = await ExecuteWorkflowAsync(workflow, assetSymbol, createdAgents.Count, cancellationToken); + + // 缓存分析结果,供交易决策模块使用 + _reportCache.Set(assetSymbol, finalReport); + + OnProgressChanged(new AnalysisProgressEventArgs + { + StageDescription = "分析完成", + IsInProgress = false + }); + + return finalReport; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "执行市场分析工作流时发生错误"); + OnProgressChanged(new AnalysisProgressEventArgs + { + StageDescription = $"分析失败: {ex.Message}", + IsInProgress = false + }); + throw; + } + } + + /// + /// 执行工作流并处理事件 + /// + private async Task ExecuteWorkflowAsync( + Workflow workflow, + string assetSymbol, + int analystCount, + CancellationToken cancellationToken) + { + MarketAnalysisReport? finalReport = null; + bool reportReceived = false; + int completedAnalysts = 0; + int totalAnalysts = analystCount; + var failedSteps = new List<(string DisplayName, string ErrorMessage)>(); + string? lastCompletedStep = null; + // 追踪当前正在运行的 Executor,超时时用于定位卡住的分析师 + var activeExecutors = new HashSet(); + + // 执行工作流(流式处理) + // 初始输入 assetSymbol 会触发 Dispatcher,Dispatcher 再通过 context.SendMessageAsync + // 广播 ChatMessage 和 TurnToken 给所有分析师,触发其 LLM 调用 + await using StreamingRun run = await InProcessExecution.RunStreamingAsync( + workflow, + assetSymbol, + checkpointManager: null, + sessionId: null, + cancellationToken); + + try + { + await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false)) + { + switch (evt) + { + case ExecutorInvokedEvent executorInvoked: + activeExecutors.Add(executorInvoked.ExecutorId); + _logger.LogDebug("工作流步骤开始: {ExecutorId}", executorInvoked.ExecutorId); + + string stageName = GetExecutorNamePrefix(executorInvoked.ExecutorId) switch + { + "AnalysisDispatcher" => "正在分发分析任务", + "AnalysisAggregator" => "正在聚合分析结果", + "Coordinator" => "正在生成综合报告", + _ => $"{GetDisplayNameForExecutorId(executorInvoked.ExecutorId)} 正在分析" + }; + + OnProgressChanged(new AnalysisProgressEventArgs + { + StageDescription = stageName, + IsInProgress = true, + TotalAnalysts = totalAnalysts, + CompletedAnalysts = completedAnalysts + }); + break; + + case ExecutorCompletedEvent executorComplete: + activeExecutors.Remove(executorComplete.ExecutorId); + lastCompletedStep = GetDisplayNameForExecutorId(executorComplete.ExecutorId); + _logger.LogDebug("工作流步骤完成: {ExecutorId}", executorComplete.ExecutorId); + + if (IsAnalystExecutor(executorComplete.ExecutorId)) + { + completedAnalysts++; + OnProgressChanged(new AnalysisProgressEventArgs + { + StageDescription = $"{lastCompletedStep} 分析完成", + IsInProgress = true, + TotalAnalysts = totalAnalysts, + CompletedAnalysts = completedAnalysts, + CompletedAnalystName = executorComplete.ExecutorId + }); + } + break; + + // AgentResponseUpdateEvent 继承自 WorkflowOutputEvent,必须在其之前匹配。 + // 分析师在 TurnToken(emitEvents: true) 模式下,每个 AgentResponseUpdate 都会 + // 通过 YieldOutputAsync 产生 AgentResponseUpdateEvent,这些是中间流式更新, + // 不是工作流的最终输出,应忽略。 + case AgentResponseUpdateEvent: + _logger.LogDebug("收到分析师流式更新,忽略(非最终输出)"); + break; + + case WorkflowOutputEvent workflowOutput: + if (!reportReceived) + { + finalReport = workflowOutput.Data as MarketAnalysisReport; + if (finalReport != null) + { + reportReceived = true; + _logger.LogInformation("工作流完成,生成最终报告"); + } + else + { + _logger.LogWarning( + "收到 WorkflowOutputEvent 但数据类型不匹配,期望 MarketAnalysisReport,实际: {ActualType}", + workflowOutput.Data?.GetType().FullName ?? "null"); + } + } + break; + + case ExecutorFailedEvent executorFailed: + activeExecutors.Remove(executorFailed.ExecutorId); + var failedDisplayName = GetDisplayNameForExecutorId(executorFailed.ExecutorId); + var errorDetail = executorFailed.Data?.Message ?? "未知错误"; + _logger.LogError(executorFailed.Data, + "步骤失败: {ExecutorId} ({DisplayName}), 错误: {Error}", + executorFailed.ExecutorId, failedDisplayName, errorDetail); + failedSteps.Add((failedDisplayName, errorDetail)); + + if (IsSystemExecutor(executorFailed.ExecutorId)) + { + throw new FriendlyException( + $"分析流程关键环节「{failedDisplayName}」执行失败: {errorDetail}"); + } + + OnProgressChanged(new AnalysisProgressEventArgs + { + StageDescription = $"{failedDisplayName} 分析失败,继续其他分析", + IsInProgress = true, + TotalAnalysts = totalAnalysts, + CompletedAnalysts = completedAnalysts, + FailedAnalysts = failedSteps.Select(f => f.DisplayName).ToList() + }); + break; + + case WorkflowErrorEvent workflowError: + var wfErrorMsg = workflowError.Exception?.Message ?? "市场分析工作流内部发生未知错误"; + _logger.LogError(workflowError.Exception, + "市场分析工作流发生严重错误: {Message}", wfErrorMsg); + throw new FriendlyException( + BuildWorkflowErrorMessage(wfErrorMsg, failedSteps)); + + case SuperStepCompletedEvent superStepCompleted: + _logger.LogDebug("工作流 SuperStep 完成"); + break; + + case WorkflowWarningEvent workflowWarning: + _logger.LogWarning("市场分析工作流警告: {Warning}", workflowWarning.Data); + break; + + default: + _logger.LogDebug("收到未处理的工作流事件: {EventType}", evt.GetType().Name); + break; + } + } + } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + // AI 模型 API 响应超时(NetworkTimeout),精确定位卡住的分析师 + var stuckAnalysts = activeExecutors + .Where(id => IsAnalystExecutor(id)) + .Select(GetDisplayNameForExecutorId) + .ToList(); + var stuckSystem = activeExecutors + .Where(id => IsSystemExecutor(id)) + .Select(GetDisplayNameForExecutorId) + .ToList(); + + var allStuck = stuckAnalysts.Concat(stuckSystem).ToList(); + var stuckDescription = allStuck.Count > 0 + ? string.Join("、", allStuck) + : "未知环节"; + + _logger.LogError(ex, + "AI 模型响应超时,卡在: [{StuckExecutors}],标的: {AssetSymbol},已完成: {Completed}/{Total}", + stuckDescription, assetSymbol, completedAnalysts, totalAnalysts); + + throw new FriendlyException( + $"「{stuckDescription}」调用 AI 模型时超时无响应,请检查模型服务是否正常或尝试更换模型"); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + + if (finalReport != null) + { + return finalReport; + } + + // 工作流正常结束但未收到 WorkflowOutputEvent,构建详细诊断信息 + _logger.LogError( + "工作流事件流已结束但未收到 WorkflowOutputEvent,标的: {AssetSymbol},已完成分析师: {Completed}/{Total},失败步骤: [{FailedSteps}],最后完成步骤: {LastStep}", + assetSymbol, completedAnalysts, totalAnalysts, + string.Join(", ", failedSteps.Select(f => f.DisplayName)), + lastCompletedStep ?? "无"); + + throw new FriendlyException( + BuildOutputMissingErrorMessage(assetSymbol, failedSteps, completedAnalysts, totalAnalysts)); + } + + /// + /// 构建工作流错误的用户可读消息 + /// + private static string BuildWorkflowErrorMessage( + string baseMessage, + List<(string DisplayName, string ErrorMessage)> failedSteps) + { + if (failedSteps.Count == 0) + { + return $"分析过程发生内部错误: {baseMessage}"; + } + + var failedDetails = string.Join(";", + failedSteps.Select(f => $"「{f.DisplayName}」({f.ErrorMessage})")); + return $"分析过程中以下环节出现问题: {failedDetails}。错误详情: {baseMessage}"; + } + + /// + /// 构建工作流未输出报告时的用户可读消息 + /// + private static string BuildOutputMissingErrorMessage( + string assetSymbol, + List<(string DisplayName, string ErrorMessage)> failedSteps, + int completedAnalysts, + int totalAnalysts) + { + if (failedSteps.Count > 0) + { + var failedDetails = string.Join(";", + failedSteps.Select(f => $"「{f.DisplayName}」({f.ErrorMessage})")); + return $"分析 {assetSymbol} 时部分环节失败导致无法生成报告: {failedDetails}"; + } + + if (completedAnalysts < totalAnalysts) + { + return $"分析 {assetSymbol} 未能完成: 仅 {completedAnalysts}/{totalAnalysts} 位分析师完成了分析,报告生成被中断"; + } + + return $"分析 {assetSymbol} 的所有分析师已完成,但综合报告生成环节异常,请重试。如果问题持续,请检查 AI 模型配置是否正确"; + } + + /// + /// 获取启用的分析师列表 + /// + private List GetEnabledAnalysts() + { + var enabledAnalysts = new List(); + var enabledAnalystRoles = _userSettingService.CurrentSetting.EnabledAnalystRoles; + + // 获取所有 AnalystAgentBase 的非抽象子类 + var agentTypes = AnalystTypeRegistry.GetConcreteAnalystTypes(); + + foreach (var agentType in agentTypes) + { + // 排除 CoordinatorAnalystAgent,它由CoordinatorExecutor独自管理 + if (agentType.Name == nameof(CoordinatorAnalystAgent)) continue; + + var agentClassName = agentType.Name; + + bool isRequired = agentType.GetCustomAttribute() != null; + + // 确定启用状态 + bool isEnabled = false; + if (enabledAnalystRoles.TryGetValue(agentClassName, out var userEnabled)) + { + isEnabled = userEnabled; + } + + // 必需的角色始终启用 + if (isRequired) isEnabled = true; + + if (isEnabled) + { + enabledAnalysts.Add(agentType); + } + } + + return enabledAnalysts; + } + + /// + /// 创建分析师代理(使用 Factory 模式),返回成功创建的 Agent 列表及失败的类型列表 + /// + private (List Agents, List FailedTypes) CreateAnalystAgents( + List analystTypes, + MarketSnapshotContextProvider marketSnapshot) + { + _logger.LogInformation("开始创建分析师代理,数量: {Count}", analystTypes.Count); + + var sharedProviders = new AIContextProvider[] { marketSnapshot }; + var createdAgents = new List(); + var failedTypes = new List(); + + foreach (var type in analystTypes) + { + try + { + var agent = _analystAgentFactory.CreateAnalyst(type, sharedProviders); + createdAgents.Add(agent); + + // 创建时即建立 Name → DisplayName 映射。 + // agent.Name 经 DelegatingAIAgent 透传,等于 YAML 中的 ASCII config.Name + // (如 "FundamentalAnalyst"),正是 MAF 生成 ExecutorId 时使用的前缀。 + // DisplayName 取类型上的 [DisplayName] 特性(与 YAML displayName 一致)。 + var displayName = GetAnalystDisplayNameFromType(type); + if (!string.IsNullOrEmpty(agent.Name)) + { + _agentNameToDisplayName[agent.Name] = displayName; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "跳过创建分析师代理: {AgentType}", type.Name); + failedTypes.Add(type); + } + } + + _logger.LogInformation("成功创建分析师代理,实际数量: {Count}", createdAgents.Count); + return (createdAgents, failedTypes); + } + + /// + /// 根据分析师类型获取显示名称(用于创建失败时的降级提示)。 + /// 优先读取 ,回退到类型名。 + /// + private static string GetAnalystDisplayNameFromType(Type analystType) + { + return analystType.GetCustomAttribute()?.DisplayName + ?? analystType.Name; + } + + /// + /// 从 ExecutorId 中提取 Name 前缀(第一个下划线之前的部分)。 + /// + /// MAF 的 AIAgentExtensions.GetDescriptiveId 生成 ExecutorId 的方式为 + /// Regex.Replace(Name + "_" + Id, "[^0-9A-Za-z]+", "_")。由于 Name + /// 为 ASCII 标识符、Id 为 GUID 的 "N" 格式(纯十六进制),二者均不含下划线, + /// 因此 ExecutorId 中有且仅有一个下划线作为分隔符。 + /// + /// + private static string GetExecutorNamePrefix(string executorId) + { + var separatorIndex = executorId.IndexOf('_'); + return separatorIndex > 0 ? executorId[..separatorIndex] : executorId; + } + + /// + /// 判断是否为系统 Executor(Dispatcher / Aggregator / Coordinator), + /// 即非分析师的业务执行器。 + /// + private static bool IsSystemExecutor(string executorId) + { + var prefix = GetExecutorNamePrefix(executorId); + return prefix is "AnalysisDispatcher" or "AnalysisAggregator" or "Coordinator"; + } + + /// + /// 判断是否为分析师 Executor(排除系统 Executor)。 + /// + private static bool IsAnalystExecutor(string executorId) => !IsSystemExecutor(executorId); + + /// + /// 判断是否为 Dispatcher Executor。 + /// + private static bool IsDispatcherExecutor(string executorId) + => GetExecutorNamePrefix(executorId) == "AnalysisDispatcher"; + + /// + /// 从工作流 ExecutorId 中提取分析师显示名称。 + /// 按第一个下划线切出 Name 前缀,再在 中查中文显示名。 + /// + private string GetDisplayNameForExecutorId(string executorId) + { + var namePrefix = GetExecutorNamePrefix(executorId); + + return _agentNameToDisplayName.TryGetValue(namePrefix, out var displayName) + ? displayName + : executorId; + } + + /// + /// 构建并发工作流(使用框架原生并发编排) + /// 参考: https://learn.microsoft.com/zh-cn/agent-framework/tutorials/workflows/simple-concurrent-workflow + /// + /// 流程: + /// [Dispatcher] → [并发分析师团队] → [Aggregator] → [Coordinator] + /// + private Workflow BuildWorkflow(int analystCount, List analystAgents) + { + // 构建标准 Fan-Out/Fan-In 工作流: + // + // [Dispatcher] string (assetSymbol) → broadcast ChatMessage + // ↓ (Fan-Out) + // [Analyst1] [Analyst2] [Analyst3] ... (并发执行,每个返回 ChatMessage) + // ↓ ↓ ↓ ↓ (Fan-In: 框架逐个传递给 Aggregator) + // [Aggregator] 收集所有 ChatMessage → List + // ↓ + // [Coordinator] List → MarketAnalysisReport (输出) + + // 1. 动态创建 Dispatcher(需要知道分析师数量) + var dispatcher = new AnalysisDispatcherExecutor( + analystCount, + _loggerFactory.CreateLogger()); + + // 2. 创建工作流,Dispatcher 作为入口节点 + var builder = new WorkflowBuilder(dispatcher); + + // 3. Fan-Out: Dispatcher → 所有分析师(Dispatcher 广播 ChatMessage) + // AIAgent 可以直接用于工作流,框架会自动处理 + builder.AddFanOutEdge(dispatcher, [.. analystAgents]); + + // 4. Fan-In: 所有分析师 → Aggregator + // 框架会自动收集所有源(分析师)的消息,并作为 List 一次性传递给 Aggregator + builder.AddFanInBarrierEdge([.. analystAgents], _aggregatorExecutor); + + // 5. Aggregator → Coordinator(将聚合结果传递给协调分析师) + builder.AddEdge(_aggregatorExecutor, _coordinatorExecutor); + + // 6. 设置输出来自 Coordinator + builder.WithOutputFrom(_coordinatorExecutor); + + return builder.Build(); + } + + /// + /// 触发进度事件 + /// + protected virtual void OnProgressChanged(AnalysisProgressEventArgs e) + { + ProgressChanged?.Invoke(this, e); + } +} + +/// +/// 分析进度变化事件参数 +/// +public sealed class AnalysisProgressEventArgs : EventArgs +{ + /// + /// 当前阶段描述 + /// + public string StageDescription { get; set; } = string.Empty; + + /// + /// 是否正在进行中 + /// + public bool IsInProgress { get; set; } = true; + + /// + /// 总分析师数量 + /// + public int TotalAnalysts { get; set; } + + /// + /// 已完成的分析师数量 + /// + public int CompletedAnalysts { get; set; } + + /// + /// 当前完成的分析师名称(如有) + /// + public string? CompletedAnalystName { get; set; } + + /// + /// 失败的分析师名称列表 + /// + public List FailedAnalysts { get; set; } = []; + + /// + /// 进度百分比(0-100) + /// + public int ProgressPercent + { + get + { + // 分析已完成或失败 + if (!IsInProgress) return 100; + // 正在进行分析 + if (TotalAnalysts > 0) + return Math.Min(100, (int)((double)CompletedAnalysts / TotalAnalysts * 100)); + // 准备阶段(未开始分析) + return 0; + } + } +} \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketSnapshotContextProvider.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketSnapshotContextProvider.cs new file mode 100644 index 0000000..22f606a --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketSnapshotContextProvider.cs @@ -0,0 +1,54 @@ +using System.Collections.Concurrent; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace MarketAssistant.Agents.MarketAnalysis; + +/// +/// 市场快照共享状态,通过 MAF AIContextProvider 模式在分析师之间共享市场数据。 +/// 避免每位分析师重复获取同一市场数据(如当前价格、成交量等)。 +/// +public class MarketSnapshotContextProvider : MessageAIContextProvider +{ + private readonly ConcurrentDictionary _sharedData = new(); + + /// + /// 设置共享数据项 + /// + public void SetData(string key, string value) => _sharedData[key] = value; + + /// + /// 获取所有共享数据 + /// + public IReadOnlyDictionary GetAllData() => + new Dictionary(_sharedData); + + /// + /// 清空共享数据 + /// + public void Clear() => _sharedData.Clear(); + + protected override ValueTask> ProvideMessagesAsync( + InvokingContext context, CancellationToken cancellationToken = default) + { + if (_sharedData.IsEmpty) + return new ValueTask>(Enumerable.Empty()); + + var sb = new System.Text.StringBuilder(); + sb.AppendLine("## 市场快照(共享数据)"); + sb.AppendLine("以下市场快照数据由协调器预加载,所有分析师共享,无需重复获取:"); + sb.AppendLine(); + + foreach (var (key, value) in _sharedData) + { + sb.AppendLine($"- **{key}**: {value}"); + } + + IEnumerable messages = + [ + new ChatMessage(ChatRole.System, sb.ToString()) + ]; + + return new ValueTask>(messages); + } +} diff --git a/src/Agents/MarketAnalysis/WorkflowStateKeys.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/WorkflowStateKeys.cs similarity index 86% rename from src/Agents/MarketAnalysis/WorkflowStateKeys.cs rename to src/MarketAssistant.App.Services/Agents/MarketAnalysis/WorkflowStateKeys.cs index c7d7dde..7687ad4 100644 --- a/src/Agents/MarketAnalysis/WorkflowStateKeys.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/WorkflowStateKeys.cs @@ -12,14 +12,12 @@ internal static class WorkflowStateKeys public const string Scope = "MarketAnalysisWorkflow"; /// - /// 股票代码的状态键 + /// 分析标的代码的状态键 /// - public const string StockSymbol = nameof(StockSymbol); + public const string AssetSymbol = nameof(AssetSymbol); /// /// 预期分析师数量的状态键 /// public const string ExpectedAnalystCount = nameof(ExpectedAnalystCount); } - - diff --git a/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs b/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs new file mode 100644 index 0000000..3859a45 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs @@ -0,0 +1,556 @@ +using MarketAssistant.Agents.ContextProviders; +using MarketAssistant.Agents.Middleware; +using MarketAssistant.Agents.TokenManagement; +using MarketAssistant.Agents.Tools; +using MarketAssistant.Services; +using MarketAssistant.Services.Mcp; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.Runtime.CompilerServices; +using System.Text; + +namespace MarketAssistant.Agents; + +/// +/// 市场对话会话管理器 +/// 基于分析上下文的对话助手:分析结果由 MarketAnalysis Workflow 产出并注入为上下文, +/// ChatSession 负责基于上下文回答追问,可通过搜索工具补充最新信息。 +/// MAF ChatClientAgent 通过 Function Calling 自动处理工具调用循环。 +/// 通过 MAF Middleware 实现 Token 追踪与自动会话压缩。 +/// +public class MarketChatSession : IDisposable +{ + private readonly AIAgent _agent; + private readonly ILogger _logger; + private readonly GroundingSearchTools? _searchTools; + private readonly MemoryManagementTools? _memoryTools; + private readonly SessionSearchTools? _sessionSearchTools; + private readonly KnowledgeGraphTools? _knowledgeGraphTools; + private readonly ChatSessionPersistenceService? _sessionPersistence; + private readonly MemoryExtractionService? _memoryExtraction; + private int _turnsSinceLastExtraction; + private AgentSession? _currentSession; + private readonly List _searchToolCache = []; + private readonly List _conversationHistory = []; + private readonly object _conversationLock = new(); + + private string _sessionId = Guid.NewGuid().ToString("N"); + private string _currentStockCode = string.Empty; + private string _analysisContext = string.Empty; + private string? _cachedInstructions; + private CancellationTokenSource? _currentCancellationTokenSource; + private bool _disposed; + private bool _searchToolsInitialized; + + /// + /// 当前会话 ID(用于持久化标识) + /// + public string SessionId => _sessionId; + + /// + /// 当前会话估算的 Token 数(优先从 Session StateBag 中间件数据读取,回退到本地估算) + /// + public int EstimatedTokenCount + { + get + { + var (input, output) = TokenTrackingMiddleware.GetCumulativeTokens(_currentSession); + if (input + output > 0) + return input + output; + + lock (_conversationLock) + { + return TokenEstimator.EstimateTotalTokens(_conversationHistory); + } + } + } + + public MarketChatSession( + IChatClient chatClient, + ILogger logger, + McpToolContextProvider? mcpToolProvider = null, + GroundingSearchTools? searchTools = null, + MemoryManagementTools? memoryTools = null, + SessionSearchTools? sessionSearchTools = null, + KnowledgeGraphTools? knowledgeGraphTools = null, + AgentSkillsProvider? skillsProvider = null, + TokenTrackingMiddleware? tokenTracking = null, + ConversationCompressionMiddleware? compressionMiddleware = null, + LayeredMemoryContextProvider? layeredMemoryProvider = null, + ChatSessionPersistenceService? sessionPersistence = null, + MemoryExtractionService? memoryExtraction = null, + string? initialStockCode = null) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _searchTools = searchTools; + _memoryTools = memoryTools; + _sessionSearchTools = sessionSearchTools; + _knowledgeGraphTools = knowledgeGraphTools; + _sessionPersistence = sessionPersistence; + _memoryExtraction = memoryExtraction; + _currentStockCode = initialStockCode ?? string.Empty; + + // 收集所有 AIContextProvider:Skills + MCP 工具 + LayeredMemory (优先) / Memory + RAG + var contextProviders = new List(); + if (skillsProvider != null) contextProviders.Add(skillsProvider); + if (mcpToolProvider != null) contextProviders.Add(mcpToolProvider); + if (layeredMemoryProvider != null) + contextProviders.Add(layeredMemoryProvider); + + var baseAgent = new ChatClientAgent( + chatClient, + new ChatClientAgentOptions + { + Name = "MarketAnalyst", + ChatOptions = new ChatOptions + { + Instructions = BuildAgentInstructions(), + Temperature = 0.7f + }, + AIContextProviders = contextProviders.Count > 0 ? [.. contextProviders] : null + }); + + // 挂接压缩前紧急保存钩子 + if (compressionMiddleware != null && _memoryExtraction != null) + { + compressionMiddleware.PreCompressHook = async (messages, ct) => + { + await _memoryExtraction.ExtractAndSaveAsync( + messages as IReadOnlyList ?? messages.ToList(), + isEmergency: true, ct: ct); + }; + } + + // 通过 MAF Builder 模式链式附加中间件 + _agent = BuildAgentWithMiddleware(baseAgent, tokenTracking, compressionMiddleware); + + _logger.LogInformation("MarketChatSession 初始化完成(工具待异步加载,已附加中间件)"); + } + + /// + /// 使用 MAF AsBuilder 模式为 Agent 附加中间件链 + /// 中间件执行顺序:压缩(外层)→ Token 追踪(内层)→ 原始 Agent + /// + private static AIAgent BuildAgentWithMiddleware( + AIAgent baseAgent, + TokenTrackingMiddleware? tokenTracking, + ConversationCompressionMiddleware? compression) + { + var builder = baseAgent.AsBuilder(); + var hasMiddleware = false; + + if (tokenTracking != null) + { + builder = builder.Use( + runFunc: tokenTracking.InvokeAsync, + runStreamingFunc: tokenTracking.InvokeStreamingAsync); + hasMiddleware = true; + } + + if (compression != null) + { + builder = builder.Use( + runFunc: compression.InvokeAsync, + runStreamingFunc: compression.InvokeStreamingAsync); + hasMiddleware = true; + } + + return hasMiddleware ? builder.Build() : baseAgent; + } + + #region 工具初始化 + + private void EnsureSearchToolsInitialized() + { + if (_searchToolsInitialized) return; + + if (_searchTools != null) + _searchToolCache.AddRange(_searchTools.GetFunctions().Select(f => (AITool)f)); + + if (_memoryTools != null) + _searchToolCache.AddRange(_memoryTools.GetFunctions().Select(f => (AITool)f)); + + if (_sessionSearchTools != null) + _searchToolCache.AddRange(_sessionSearchTools.GetFunctions().Select(f => (AITool)f)); + + if (_knowledgeGraphTools != null) + _searchToolCache.AddRange(_knowledgeGraphTools.GetFunctions().Select(f => (AITool)f)); + + _logger.LogInformation("加载工具完成,数量: {Count}", _searchToolCache.Count); + + // MCP 工具通过 McpToolContextProvider(AIContextProvider)自动注入,无需手动加载 + _searchToolsInitialized = true; + } + + #endregion + + #region 公共属性 + + /// + /// 获取对话历史(自维护的消息镜像) + /// + public Task> GetConversationHistoryAsync() + { + lock (_conversationLock) + { + return Task.FromResult>(_conversationHistory.AsReadOnly()); + } + } + + public string CurrentStockCode => _currentStockCode; + + public bool IsProcessing => _currentCancellationTokenSource != null && + !_currentCancellationTokenSource.Token.IsCancellationRequested; + + #endregion + + #region 分析上下文注入 + + /// + /// 注入分析上下文,将 Workflow 阶段的分析结果作为对话背景。 + /// 调用后重置会话,后续对话将基于此上下文进行。 + /// + public void InjectAnalysisContext(string stockCode, IEnumerable analysisMessages) + { + _currentStockCode = stockCode; + _analysisContext = BuildAnalysisSummary(analysisMessages); + _cachedInstructions = null; + _currentSession = null; + lock (_conversationLock) + { + _conversationHistory.Clear(); + } + + _logger.LogInformation( + "注入分析上下文,标的: {StockCode},摘要长度: {Length}", + stockCode, _analysisContext.Length); + } + + /// + /// 将多位分析师的分析结果提炼为结构化摘要 + /// + private static string BuildAnalysisSummary(IEnumerable analysisMessages) + { + var sb = new StringBuilder(); + int index = 0; + + foreach (var message in analysisMessages) + { + var text = message.Text; + if (string.IsNullOrWhiteSpace(text)) continue; + + index++; + var author = message.AuthorName ?? $"分析师{index}"; + sb.AppendLine($"### {author}"); + sb.AppendLine(text); + sb.AppendLine(); + } + + return sb.ToString(); + } + + #endregion + + #region 对话方法 + + /// + /// 发送消息并获取流式回复(MAF 通过 Function Calling 自动处理工具调用) + /// + public async IAsyncEnumerable SendMessageStreamAsync( + string userMessage, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + EnsureSearchToolsInitialized(); + + _currentSession ??= await _agent.CreateSessionAsync(cancellationToken: cancellationToken); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _currentCancellationTokenSource = cts; + + // 加锁保护 _conversationHistory 的 Add,并取快照传给流式调用 + // 避免流式枚举期间持锁,同时防止并发修改 + List historySnapshot; + lock (_conversationLock) + { + _conversationHistory.Add(new ChatMessage(ChatRole.User, userMessage)); + historySnapshot = _conversationHistory.ToList(); + } + + var completeResponse = new StringBuilder(); + + var runOptions = new ChatClientAgentRunOptions + { + ChatOptions = new ChatOptions + { + Tools = _searchToolCache.Count > 0 ? _searchToolCache : null, + Instructions = BuildAgentInstructions() + } + }; + + var streamingUpdates = _agent.RunStreamingAsync( + messages: historySnapshot, + session: _currentSession, + options: runOptions, + cancellationToken: cts.Token); + + var completed = false; + try + { + await foreach (var update in streamingUpdates.ConfigureAwait(false)) + { + var content = update.Text ?? string.Empty; + if (!string.IsNullOrEmpty(content)) + { + completeResponse.Append(content); + } + yield return content; + } + completed = true; + } + finally + { + lock (_conversationLock) + { + if (completed) + { + _conversationHistory.Add(new ChatMessage(ChatRole.Assistant, completeResponse.ToString())); + } + else + { + var partial = completeResponse.ToString(); + _conversationHistory.Add(new ChatMessage(ChatRole.Assistant, + partial.Length > 0 ? partial + "\n\n[回复被中断]" : "[回复被中断]")); + } + } + + _currentCancellationTokenSource = null; + } + + _logger.LogInformation("流式 AI 回复完成,长度: {Length}", completeResponse.Length); + + await AutoSaveSessionAsync(cancellationToken); + } + + /// + /// 从持久化存储恢复会话 + /// + public async Task RestoreSessionAsync(string sessionId, CancellationToken cancellationToken = default) + { + if (_sessionPersistence is null) return false; + + var snapshot = await _sessionPersistence.LoadSessionAsync(sessionId, cancellationToken); + if (snapshot is null) return false; + + _sessionId = snapshot.Id; + _currentStockCode = snapshot.StockCode; + _analysisContext = snapshot.AnalysisContext ?? string.Empty; + int messageCount; + lock (_conversationLock) + { + _conversationHistory.Clear(); + foreach (var dto in snapshot.Messages) + { + _conversationHistory.Add(new ChatMessage(new ChatRole(dto.Role), dto.Content) + { + AuthorName = dto.AuthorName + }); + } + messageCount = _conversationHistory.Count; + } + _currentSession = null; // 强制创建新 AgentSession + + _logger.LogInformation("恢复会话 {SessionId},消息数: {Count}", sessionId, messageCount); + return true; + } + + private async Task AutoSaveSessionAsync(CancellationToken cancellationToken) + { + if (_sessionPersistence is null) return; + + try + { + List historyCopy; + lock (_conversationLock) + { + historyCopy = _conversationHistory.ToList(); + } + + var snapshot = new ChatSessionSnapshot + { + Id = _sessionId, + StockCode = _currentStockCode, + Title = BuildSessionTitle(historyCopy), + AnalysisContext = _analysisContext, + Messages = historyCopy.Select(m => new ChatMessageDto + { + Role = m.Role.Value, + Content = m.Text ?? string.Empty, + AuthorName = m.AuthorName + }).ToList() + }; + await _sessionPersistence.SaveSessionAsync(snapshot, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "自动保存会话失败"); + } + + // 每 N 轮对话触发一次自动记忆提取(后台执行,不阻塞用户) + _turnsSinceLastExtraction++; + if (_memoryExtraction != null && + _turnsSinceLastExtraction >= _memoryExtraction.ExtractionInterval) + { + _turnsSinceLastExtraction = 0; + List extractionSnapshot; + lock (_conversationLock) + { + extractionSnapshot = _conversationHistory.ToList(); + } + _ = Task.Run(async () => + { + try + { + await _memoryExtraction.ExtractAndSaveAsync(extractionSnapshot, ct: CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "后台记忆提取失败"); + } + }, CancellationToken.None); + } + } + + private string BuildSessionTitle(List history) + { + var firstUserMsg = history.FirstOrDefault(m => m.Role == ChatRole.User); + var title = firstUserMsg?.Text ?? _currentStockCode; + return title.Length > 50 ? title[..50] + "…" : title; + } + + public void ClearHistory() + { + _currentSession = null; + lock (_conversationLock) + { + _conversationHistory.Clear(); + } + _analysisContext = string.Empty; + _logger.LogInformation("清除聊天历史(重置 Session 和上下文)"); + } + + public void SetCurrentStock(string stockCode) + { + _currentStockCode = stockCode; + _cachedInstructions = null; + _logger.LogInformation("设置当前标的: {StockCode}", stockCode); + } + + public void StopCurrentRequest() + { + _currentCancellationTokenSource?.Cancel(); + _logger.LogInformation("停止当前请求"); + } + + #endregion + + #region 提示词构建 + + private string BuildAgentInstructions() + { + if (_cachedInstructions != null) + return _cachedInstructions; + + var sb = new StringBuilder(); + + sb.AppendLine(""); + sb.AppendLine("你是专业的金融市场对话助手。"); + if (_currentStockCode.Length > 0) + sb.AppendLine($"当前关注标的:{_currentStockCode}"); + sb.AppendLine(""); + sb.AppendLine(); + + if (_analysisContext.Length > 0) + { + sb.AppendLine(""); + sb.AppendLine("以下是多位专业分析师对当前标的的深度分析报告,你的回答应优先基于这些分析结果:"); + sb.AppendLine(); + sb.AppendLine(_analysisContext); + sb.AppendLine(""); + sb.AppendLine(); + } + + sb.AppendLine(""" + + 1. 优先基于分析上下文回答用户问题,引用分析结果时注明来源(如"技术分析显示..."、"基本面数据表明...") + 2. 当需要最新信息(实时新闻、市场动态、政策变化)时,使用搜索工具补充 + 3. 综合已有分析和搜索结果给出完整、有理有据的回答 + 4. 如果分析上下文中没有相关信息且工具也无法获取,坦诚说明 + 5. 当用户提到过去讨论过的内容时,使用 SearchPastSessionsAsync 搜索历史对话来回忆 + + + + 你有一个持久化记忆系统,可以跨会话记住用户信息。请在以下情况主动保存记忆: + - 用户明确表达投资偏好或风格时(category=preference,如"我偏好价值投资"、"我不做短线") + - 用户纠正你的分析或认知时(category=correction,如"不对,我更看重现金流") + - 得出重要分析结论时(category=conclusion,如某标的的关键发现) + - 了解到用户身份信息时(category=profile,如职业、经验水平、资金规模) + 保存时 key 要简短唯一,value 要简洁信息密集。不要保存显而易见的信息或临时性内容。 + 重要的记忆使用后不要反复保存,先查询确认是否已存在。 + 对于特别重要的用户信息(如核心投资偏好、职业背景),保存后使用 SetMemoryPriorityAsync 将其设为高优先级(1), + 高优先级记忆会始终加载到上下文中,确保每次对话都能参考。 + + + + 你有一个知识图谱系统,用于记录实体之间的结构化关系。请在以下情况记录关系: + - 用户表示关注或持有某标的时(如 用户 --[持有]--> 贵州茅台) + - 完成标的分析后(如 用户 --[分析过]--> 比特币) + - 发现重要的行业/事件关联时(如 降息 --[影响]--> 银行板块) + - 用户提到不再持有某标的时,使用 InvalidateRelationAsync 标记过期 + 查询用户关注的标的时使用 QueryEntityAsync,回顾投资历史使用 GetTimelineAsync。 + + + + 1. 数值精确:价格保留2位小数,百分比保留1位小数 + 2. 注明数据来源和时间 + 3. 区分事实与观点 + + + + 不要说"好的"、"当然"、"我来帮你"等客套话 + 不要以问句结尾(如"需要我进一步分析吗?") + 不要说"以上是我的分析"、"希望对你有帮助" + 直接给出结论,避免冗余铺垫 + + """); + + _cachedInstructions = sb.ToString(); + return _cachedInstructions; + } + + #endregion + + #region IDisposable + + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _currentCancellationTokenSource?.Cancel(); + _currentCancellationTokenSource?.Dispose(); + } + _disposed = true; + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + #endregion +} + diff --git a/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareBasicTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareBasicTools.cs new file mode 100644 index 0000000..5a6504d --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareBasicTools.cs @@ -0,0 +1,160 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.Models.AShare; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace MarketAssistant.Agents.Tools.AShare; + +/// +/// A股基础数据工具实现 +/// +public sealed class AShareBasicTools : IShareBasicTools +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IUserSettingService _userSettingService; + private readonly ILogger _logger; + + public AShareBasicTools( + IHttpClientFactory httpClientFactory, + IUserSettingService userSettingService, + ILogger logger) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); + _logger = logger; + } + + [Description("根据股票代码获取股票基本数据,包括实时行情、价格变动、市值等信息")] + public async Task GetAssetInfoAsync([Description("股票代码")] string assetSymbol, CancellationToken cancellationToken = default) + { + try + { + var formattedSymbol = StockSymbolConverter.ToClsFormat(assetSymbol); + if (string.IsNullOrEmpty(formattedSymbol)) + throw new FriendlyException($"股票代码格式不正确: {assetSymbol}"); + + var url = $"/quote/stock/basic?secu_code={formattedSymbol}&fields=open_px,av_px,high_px,low_px,change,change_px,down_price,change_3,change_5,qrr,entrust_rate,tr,amp,TotalShares,mc,NetAssetPS,NonRestrictedShares,cmc,business_amount,business_balance,pe,ttm_pe,pb,secu_name,secu_code,trade_status,secu_type,preclose_px,up_price,last_px&app=CailianpressWeb&os=web&sv=8.4.6"; + + using var httpClient = _httpClientFactory.CreateClient("Cls"); + var response = await httpClient.GetStringAsync(url, cancellationToken); + using var jsonDocument = JsonDocument.Parse(response); + + if (jsonDocument.RootElement.TryGetProperty("data", out var data) == false || data.ValueKind == JsonValueKind.Null) + throw new FriendlyException($"未找到股票 {assetSymbol} ({formattedSymbol}) 的数据,请检查代码是否正确。"); + + // 通过 StringToDecimalConverter 容错反序列化:字符串数值/null/--占位均安全降级,不再抛出转换异常 + var raw = JsonSerializer.Deserialize(data.GetRawText(), JsonOptions.AShareApiOptions) + ?? throw new FriendlyException($"解析股票 {assetSymbol} 行情数据失败。"); + + return new StockQuoteInfo + { + CurrentPrice = raw.LastPrice, + PriceChange = raw.ChangePx, + PercentageChange = raw.Change, + HighPrice = raw.HighPx, + LowPrice = raw.LowPx, + Volume = raw.BusinessAmount / 10000m, + Amount = raw.BusinessBalance / 100000000m, + TurnoverRate = raw.TurnoverRate, + PercentageChange3Day = raw.Change3, + PercentageChange5Day = raw.Change5, + TotalShares = raw.TotalShares, + MarketCapitalization = raw.MarketCap / 100000000m, + SecurityName = raw.SecurityName ?? string.Empty, + SecurityCode = raw.SecurityCode ?? string.Empty, + TradeStatus = raw.TradeStatus ?? string.Empty, + SecurityType = raw.SecurityType ?? string.Empty, + OpenPrice = raw.OpenPx, + PreviousClosePrice = raw.PreClosePx, + UpLimitPrice = raw.UpPrice, + DownLimitPrice = raw.DownPrice, + Amplitude = raw.Amplitude, + PERatio = raw.PERatio, + TTMPERatio = raw.TTMPERatio, + PBRatio = raw.PBRatio, + CirculationMarketCap = raw.CirculationMarketCap / 100000000m, + NonRestrictedShares = raw.NonRestrictedShares, + NetAssetPerShare = raw.NetAssetPS, + AveragePrice = raw.AveragePx, + VolumeRatio = raw.VolumeRatio, + EntrustRatio = raw.EntrustRate + }; + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取股票行情失败: {Symbol}", assetSymbol); + throw new FriendlyException($"处理股票价格数据时发生错误: {ex.Message}", ex); + } + } + + [Description("根据股票代码获取上市公司基本面信息,包括公司简介、主营业务、所属行业等")] + public async Task GetCompanyInfoAsync([Description("股票代码")] string assetSymbol, CancellationToken cancellationToken = default) + { + try + { + assetSymbol = new string(assetSymbol.Where(char.IsDigit).ToArray()); + + var token = _userSettingService.CurrentSetting.ZhiTuApiToken; + var url = $"/hs/gs/gsjj/{assetSymbol}?token={token}"; + + using var httpClient = _httpClientFactory.CreateClient("ZhiTu"); + var response = await httpClient.GetStringAsync(url, cancellationToken); + var info = JsonSerializer.Deserialize(response, JsonOptions.AShareApiOptions); + + return info ?? throw new FriendlyException("GetCompanyInfoAsync返回数据为空"); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取公司基本面失败: {Symbol}", assetSymbol); + throw new FriendlyException($"处理公司基本面数据时发生错误: {ex.Message}", ex); + } + } + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(GetAssetInfoAsync); + yield return AIFunctionFactory.Create(GetCompanyInfoAsync); + } + + /// + /// 财联社(cls.cn)行情接口返回字段映射,统一以 decimal 接收数值, + /// 配合 StringToDecimalConverter 容错字符串/null/--占位。 + /// + private sealed class ClsStockQuoteData + { + [JsonPropertyName("last_px")] public decimal LastPrice { get; set; } + [JsonPropertyName("change_px")] public decimal ChangePx { get; set; } + [JsonPropertyName("change")] public decimal Change { get; set; } + [JsonPropertyName("high_px")] public decimal HighPx { get; set; } + [JsonPropertyName("low_px")] public decimal LowPx { get; set; } + [JsonPropertyName("business_amount")] public decimal BusinessAmount { get; set; } + [JsonPropertyName("business_balance")] public decimal BusinessBalance { get; set; } + [JsonPropertyName("tr")] public decimal TurnoverRate { get; set; } + [JsonPropertyName("change_3")] public decimal Change3 { get; set; } + [JsonPropertyName("change_5")] public decimal Change5 { get; set; } + [JsonPropertyName("TotalShares")] public decimal TotalShares { get; set; } + [JsonPropertyName("mc")] public decimal MarketCap { get; set; } + [JsonPropertyName("cmc")] public decimal CirculationMarketCap { get; set; } + [JsonPropertyName("NonRestrictedShares")] public decimal NonRestrictedShares { get; set; } + [JsonPropertyName("NetAssetPS")] public decimal NetAssetPS { get; set; } + [JsonPropertyName("open_px")] public decimal OpenPx { get; set; } + [JsonPropertyName("preclose_px")] public decimal PreClosePx { get; set; } + [JsonPropertyName("up_price")] public decimal UpPrice { get; set; } + [JsonPropertyName("down_price")] public decimal DownPrice { get; set; } + [JsonPropertyName("amp")] public decimal Amplitude { get; set; } + [JsonPropertyName("pe")] public decimal PERatio { get; set; } + [JsonPropertyName("ttm_pe")] public decimal TTMPERatio { get; set; } + [JsonPropertyName("pb")] public decimal PBRatio { get; set; } + [JsonPropertyName("av_px")] public decimal AveragePx { get; set; } + [JsonPropertyName("qrr")] public decimal VolumeRatio { get; set; } + [JsonPropertyName("entrust_rate")] public decimal EntrustRate { get; set; } + [JsonPropertyName("secu_name")] public string? SecurityName { get; set; } + [JsonPropertyName("secu_code")] public string? SecurityCode { get; set; } + [JsonPropertyName("trade_status")] public string? TradeStatus { get; set; } + [JsonPropertyName("secu_type")] public string? SecurityType { get; set; } + } +} diff --git a/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareFinancialTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareFinancialTools.cs new file mode 100644 index 0000000..8449366 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareFinancialTools.cs @@ -0,0 +1,83 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.Models.AShare; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.ComponentModel; + +namespace MarketAssistant.Agents.Tools.AShare; + +/// +/// A股财务数据工具实现 +/// +public sealed class AShareFinancialTools : IShareFinancialTools +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IUserSettingService _userSettingService; + private readonly ILogger _logger; + + public AShareFinancialTools( + IHttpClientFactory httpClientFactory, + IUserSettingService userSettingService, + ILogger logger) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); + _logger = logger; + } + + /// + /// 通用财务数据获取方法 + /// + private async Task> GetFinancialDataAsync(string endpoint, string assetSymbol, int years = 2, CancellationToken cancellationToken = default) + { + try + { + var stockCode = StockSymbolConverter.ToZhiTuFormat(assetSymbol); + var token = _userSettingService.CurrentSetting.ZhiTuApiToken; + var endDate = DateTime.Now.ToString("yyyyMMdd"); + var startDate = DateTime.Now.AddYears(-years).ToString("yyyyMMdd"); + + var url = $"/hs/fin/{endpoint}/{stockCode}?token={token}&st={startDate}&et={endDate}"; + + using var httpClient = _httpClientFactory.CreateClient("ZhiTu"); + var response = await httpClient.GetStringAsync(url, cancellationToken); + return JsonSerializer.Deserialize>(response, JsonOptions.AShareApiOptions) ?? new List(); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取财务数据失败: {Endpoint} {Symbol}", endpoint, assetSymbol); + throw new FriendlyException($"获取财务数据({endpoint})时发生错误: {ex.Message}", ex); + } + } + + [Description("获取上市公司资产负债表,默认返回最近2年的数据")] + public Task> GetBalanceSheetAsync([Description("股票代码")] string assetSymbol, CancellationToken cancellationToken = default) + => GetFinancialDataAsync("balance", assetSymbol, cancellationToken: cancellationToken); + + [Description("获取上市公司利润表,默认返回最近2年的数据")] + public Task> GetIncomeStatementAsync([Description("股票代码")] string assetSymbol, CancellationToken cancellationToken = default) + => GetFinancialDataAsync("income", assetSymbol, cancellationToken: cancellationToken); + + [Description("获取上市公司现金流量表,默认返回最近2年的数据")] + public Task> GetCashFlowStatementAsync([Description("股票代码")] string assetSymbol, CancellationToken cancellationToken = default) + => GetFinancialDataAsync("cashflow", assetSymbol, cancellationToken: cancellationToken); + + [Description("获取上市公司财务主要指标,默认返回最近2年的数据")] + public Task> GetFinancialRatiosAsync([Description("股票代码")] string assetSymbol, CancellationToken cancellationToken = default) + => GetFinancialDataAsync("ratios", assetSymbol, cancellationToken: cancellationToken); + + [Description("获取上市公司股本结构,默认返回最近3年的变动记录")] + public Task> GetCapitalStructureAsync([Description("股票代码")] string assetSymbol, CancellationToken cancellationToken = default) + => GetFinancialDataAsync("capital", assetSymbol, years: 3, cancellationToken: cancellationToken); + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(GetBalanceSheetAsync); + yield return AIFunctionFactory.Create(GetIncomeStatementAsync); + yield return AIFunctionFactory.Create(GetCashFlowStatementAsync); + yield return AIFunctionFactory.Create(GetFinancialRatiosAsync); + yield return AIFunctionFactory.Create(GetCapitalStructureAsync); + } +} diff --git a/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareNewsTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareNewsTools.cs new file mode 100644 index 0000000..80de072 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareNewsTools.cs @@ -0,0 +1,208 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.Models; +using MarketAssistant.Infrastructure.Factories; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.ComponentModel; + +namespace MarketAssistant.Agents.Tools.AShare; + +/// +/// A股新闻数据工具实现 +/// +public sealed class AShareNewsTools : INewsDataTools +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IChatClientFactory _chatClientFactory; + private readonly ILogger _logger; + + public AShareNewsTools( + IHttpClientFactory httpClientFactory, + IChatClientFactory chatClientFactory, + ILogger logger) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _chatClientFactory = chatClientFactory ?? throw new ArgumentNullException(nameof(chatClientFactory)); + _logger = logger; + } + + /// + /// 根据新闻Url获取新闻详情 + /// + private async Task GetNewsContentAsync(string url, CancellationToken cancellationToken = default) + { + try + { + var sr = new SmartReader.Reader(url); + sr.Debug = false; + + var article = sr.GetArticle(); + if (article.IsReadable) + { + return article.TextContent; + } + + // 使用 IChatClient 直接进行内容提取(Agent Framework 方式) + return await ExtractArticleContentAsync(article.Content, cancellationToken); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "处理新闻内容失败: {Url}", url); + throw new FriendlyException($"处理新闻内容时发生错误: {ex.Message}", ex); + } + } + + /// + /// 从HTML内容中提取文章正文(使用AI) + /// + private async Task ExtractArticleContentAsync(string htmlContent, CancellationToken cancellationToken = default) + { + var chatClient = _chatClientFactory.CreateClient(); + + var systemPrompt = @"你是一个专业的网页内容提取专家。请从HTML内容中提取出文章的主要内容。 + +要求: +1. 去除所有广告、导航栏、页脚等非文章内容 +2. 保持文章的原始格式和段落结构 +3. 仅返回正文文本内容,不需要其他信息 + +请直接返回提取的正文内容,不需要JSON格式或其他标记。"; + + var userPrompt = $"HTML内容:\n{htmlContent}"; + + var response = await chatClient.GetResponseAsync( + [ + new ChatMessage(ChatRole.System, systemPrompt), + new ChatMessage(ChatRole.User, userPrompt) + ], + new ChatOptions + { + Temperature = 0.3f, + MaxOutputTokens = 4096 + }, + cancellationToken); + + return response.Text ?? string.Empty; + } + + /// + /// 获取指定股票新闻列表(标题/来源/链接) + /// + /// 数据源:东方财富搜索 API(search-api-web.eastmoney.com) + /// - 公开免费、无需签名/密钥 + /// - 返回 JSONP 格式(jQuery(...) 包装),需剥离外层 + /// - 替代原财联社 cls.cn 接口(sign 签名已失效) + /// + private async Task> GetNewsListAsync(string assetSymbol, CancellationToken cancellationToken = default) + { + try + { + // 东方财富搜索 API 使用纯数字代码(如 600519) + var digits = new string(assetSymbol.Where(char.IsDigit).ToArray()); + if (string.IsNullOrEmpty(digits)) + return new List(); + + // 构造 JSONP 请求参数(与 eastmoney.com 前端一致) + var param = Uri.EscapeDataString( + $"{{\"uid\":\"\",\"keyword\":\"{digits}\",\"type\":[\"cmsArticleWebOld\"],\"client\":\"web\",\"clientType\":\"web\",\"clientVersion\":\"curr\",\"param\":{{\"cmsArticleWebOld\":{{\"searchScope\":\"default\",\"sort\":\"default\",\"pageIndex\":1,\"pageSize\":20,\"preTag\":\"\",\"postTag\":\"\"}}}}}}"); + var url = $"search/jsonp?cb=jQuery¶m={param}"; + + using var httpClient = _httpClientFactory.CreateClient("EastMoneySearch"); + httpClient.Timeout = TimeSpan.FromSeconds(15); + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.TryAddWithoutValidation("Referer", "https://so.eastmoney.com/"); + request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"); + + var response = await httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var jsonp = await response.Content.ReadAsStringAsync(cancellationToken); + var json = UnwrapJsonp(jsonp); + if (string.IsNullOrEmpty(json)) + return new List(); + + using var doc = JsonDocument.Parse(json); + + var newsList = new List(); + + // 数据路径:result.cmsArticleWebOld[] + if (!doc.RootElement.TryGetProperty("result", out var result) || + result.ValueKind != JsonValueKind.Object || + !result.TryGetProperty("cmsArticleWebOld", out var articles) || + articles.ValueKind != JsonValueKind.Array) + return newsList; + + foreach (var item in articles.EnumerateArray()) + { + var title = item.TryGetProperty("title", out var titleEl) ? titleEl.GetString()?.Trim() ?? "" : ""; + if (string.IsNullOrEmpty(title)) + continue; + + var source = item.TryGetProperty("mediaName", out var srcEl) ? srcEl.GetString()?.Trim() ?? "" : ""; + var link = item.TryGetProperty("url", out var urlEl) ? urlEl.GetString()?.Trim() ?? "" : ""; + var content = item.TryGetProperty("content", out var cntEl) ? cntEl.GetString()?.Trim() ?? "" : ""; + var publishTime = item.TryGetProperty("date", out var dateEl) ? dateEl.GetString()?.Trim() ?? "" : ""; + + newsList.Add(new NewsItem + { + Title = title, + Link = link, + Source = source, + PublishTime = publishTime, + Summary = content + }); + } + + return newsList; + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取新闻列表失败: {Symbol}", assetSymbol); + throw new FriendlyException($"获取新闻列表时发生错误: {ex.Message}", ex); + } + } + + /// + /// 剥离 JSONP 包装(jQuery({...}) → {...}) + /// + public static string UnwrapJsonp(string jsonp) + { + if (string.IsNullOrEmpty(jsonp)) return string.Empty; + + var start = jsonp.IndexOf('('); + var end = jsonp.LastIndexOf(')'); + if (start < 0 || end < 0 || end <= start) + return jsonp; + + return jsonp.Substring(start + 1, end - start - 1); + } + + /// + /// 获取指定股票的聚合新闻上下文(Tool Function) + /// + [Description("获取指定股票的聚合新闻上下文,一次返回最近且相关的新闻要点。默认返回精简要点,可通过 response_format 控制详细程度。")] + public Task> GetNewsAsync( + [Description("股票代码")] string assetSymbol, + [Description("返回的新闻条数上限,默认 5,建议 1-10")] int count = 5, + CancellationToken cancellationToken = default) + { + count = Math.Clamp(count, 1, 10); + + return ToolExecutor.ExecuteAsync( + operationName: "获取聚合新闻", + assetSymbol: assetSymbol, + logger: _logger, + action: async ct => + { + var list = await GetNewsListAsync(assetSymbol, ct); + return list.Take(count).ToList(); + }, + cancellationToken: cancellationToken); + } + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(GetNewsAsync); + } +} diff --git a/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareSentimentTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareSentimentTools.cs new file mode 100644 index 0000000..1a46996 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareSentimentTools.cs @@ -0,0 +1,140 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.Models.AShare; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace MarketAssistant.Agents.Tools.AShare; + +/// +/// A股市场情绪工具实现 +/// +public sealed class AShareSentimentTools : IShareSentimentTools +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IUserSettingService _userSettingService; + private readonly ILogger _logger; + + public AShareSentimentTools( + IHttpClientFactory httpClientFactory, + IUserSettingService userSettingService, + ILogger logger) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); + _logger = logger; + } + + [Description("根据股票代码获取资金流向数据")] + public async Task GetFundFlowAsync([Description("股票代码")] string assetSymbol, CancellationToken cancellationToken = default) + { + try + { + var stockCode = new string(assetSymbol.Where(char.IsDigit).ToArray()); + var token = _userSettingService.CurrentSetting.ZhiTuApiToken; + var url = $"/hs/history/transaction/{stockCode}?token={token}<=20"; + + using var httpClient = _httpClientFactory.CreateClient("ZhiTu"); + var response = await httpClient.GetStringAsync(url, cancellationToken); + + var dailyFlows = JsonSerializer.Deserialize>(response, JsonOptions.AShareApiOptions) + ?? throw new FriendlyException($"获取资金流向数据为空: {assetSymbol}"); + + if (dailyFlows.Count == 0) + throw new FriendlyException($"获取资金流向数据为空: {assetSymbol}"); + + return MapToFundFlow(dailyFlows); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取资金流向失败: {Symbol}", assetSymbol); + throw new FriendlyException($"获取资金流向数据时发生错误: {ex.Message}", ex); + } + } + + private static FundFlow MapToFundFlow(List dailyFlows) + { + // ZhiTu API 返回顺序不保证,按日期降序排列确保 [0] 为最新交易日 + dailyFlows.Sort((a, b) => b.T.CompareTo(a.T)); + + var latest = dailyFlows[0]; + + // 资金流向标准定义:净流入 = 主动买入成交额 − 主动卖出成交额 + var superDiff = latest.ZmbtdCje - latest.ZmstdCje; + var largeDiff = latest.ZmbddCje - latest.ZmsddCje; + var mediumDiff = latest.ZmbzdCje - latest.ZmszdCje; + var littleDiff = latest.ZmbxdCje - latest.ZmsxdCje; + + // 主力 = 特大单 + 大单(成交额 ≥ 20万 或 成交量 ≥ 1000手) + var mainIn = latest.ZmbtdCje + latest.ZmbddCje; + var mainOut = latest.ZmstdCje + latest.ZmsddCje; + var mainDiff = mainIn - mainOut; + + return new FundFlow + { + Date = latest.T, + MainFundIn = mainIn, + MainFundOut = mainOut, + MainFundDiff = mainDiff, + SuperFundDiff = superDiff, + LargeFundDiff = largeDiff, + MediumFundDiff = mediumDiff, + LittleFundDiff = littleDiff, + // 已按日期降序,Take(n) 即为最近 n 个交易日 + MainFund3 = CalcMainDiff(dailyFlows, 3), + MainFund5 = CalcMainDiff(dailyFlows, 5), + MainFund10 = CalcMainDiff(dailyFlows, 10), + MainFund20 = CalcMainDiff(dailyFlows, 20) + }; + } + + private static decimal CalcMainDiff(List flows, int days) => + flows.Take(days).Sum(d => d.ZmbtdCje + d.ZmbddCje - d.ZmstdCje - d.ZmsddCje); + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(GetFundFlowAsync); + } +} + +/// +/// 智兔API资金流向数据 (/hs/history/transaction/{code}) +/// +/// 订单分级:特大单(成交额≥100万或量≥5000手) / 大单(≥20万/1000手) / 中单(≥4万/200手) / 小单(其余) +/// +/// +/// 命名规则:zmb = 主买(主动买入), zms = 主卖(主动卖出); +/// td = 特大单, dd = 大单, zd = 中单, xd = 小单; +/// cje = 成交额 +/// +/// +internal sealed class ZhiTuFundFlowData +{ + /// 交易日期 (YYYYMMDD) + [JsonPropertyName("t")] public int T { get; set; } + + // ── 主动买入(zmb)成交额 ── + + /// 主买特大单成交额 + [JsonPropertyName("zmbtdcje")] public decimal ZmbtdCje { get; set; } + /// 主买大单成交额 + [JsonPropertyName("zmbddcje")] public decimal ZmbddCje { get; set; } + /// 主买中单成交额 + [JsonPropertyName("zmbzdcje")] public decimal ZmbzdCje { get; set; } + /// 主买小单成交额 + [JsonPropertyName("zmbxdcje")] public decimal ZmbxdCje { get; set; } + + // ── 主动卖出(zms)成交额 ── + + /// 主卖特大单成交额 + [JsonPropertyName("zmstdcje")] public decimal ZmstdCje { get; set; } + /// 主卖大单成交额 + [JsonPropertyName("zmsddcje")] public decimal ZmsddCje { get; set; } + /// 主卖中单成交额 + [JsonPropertyName("zmszdcje")] public decimal ZmszdCje { get; set; } + /// 主卖小单成交额 + [JsonPropertyName("zmsxdcje")] public decimal ZmsxdCje { get; set; } +} diff --git a/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareTechnicalTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareTechnicalTools.cs new file mode 100644 index 0000000..6736f35 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Tools/AShare/AShareTechnicalTools.cs @@ -0,0 +1,140 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.Models.Technical; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace MarketAssistant.Agents.Tools.AShare; + +/// +/// A股技术分析工具实现 +/// +public sealed class AShareTechnicalTools : ITechnicalDataTools +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IUserSettingService _userSettingService; + private readonly ILogger _logger; + + public AShareTechnicalTools( + IHttpClientFactory httpClientFactory, + IUserSettingService userSettingService, + ILogger logger) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); + _logger = logger; + } + + private async Task GetIndicatorAsync(string indicator, string assetSymbol, CancellationToken cancellationToken = default) + { + try + { + var token = _userSettingService.CurrentSetting.ZhiTuApiToken; + var formattedSymbol = StockSymbolConverter.ToZhiTuFormat(assetSymbol); + var url = $"/hs/history/{indicator}/{formattedSymbol}/d/n?token={token}<=30"; + + using var httpClient = _httpClientFactory.CreateClient("ZhiTu"); + var response = await httpClient.GetStringAsync(url, cancellationToken); + var items = JsonSerializer.Deserialize>(response, JsonOptions.AShareApiOptions); + + if (items == null || !items.Any()) + throw new FriendlyException($"获取 {indicator.ToUpper()} 数据失败: 返回数据为空或无有效数据 (代码: {formattedSymbol})"); + + return items.Last(); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取技术指标失败: {Indicator} {Symbol}", indicator, assetSymbol); + throw new FriendlyException($"获取技术指标 {indicator} 时发生错误: {ex.Message} (代码: {assetSymbol})", ex); + } + } + + [Description("获取近30日最新日线KDJ")] + public Task GetKDJAsync([Description("股票代码")] string assetSymbol, CancellationToken cancellationToken = default) + => GetIndicatorAsync("kdj", assetSymbol, cancellationToken); + + [Description("获取近30日最新日线MACD")] + public Task GetMACDAsync([Description("股票代码")] string assetSymbol, CancellationToken cancellationToken = default) + => GetIndicatorAsync("macd", assetSymbol, cancellationToken); + + [Description("获取近30日最新日线BOLL")] + public Task GetBOLLAsync([Description("股票代码")] string assetSymbol, CancellationToken cancellationToken = default) + => GetIndicatorAsync("boll", assetSymbol, cancellationToken); + + [Description("获取近30日最新日线MA")] + public Task GetMAAsync([Description("股票代码")] string assetSymbol, CancellationToken cancellationToken = default) + => GetIndicatorAsync("ma", assetSymbol, cancellationToken); + + [Description("获取K线历史序列(OHLCV),interval支持5m/15m/daily/weekly,用于判断趋势方向")] + public async Task> GetKLinesAsync( + [Description("股票代码")] string assetSymbol, + [Description("K线周期:5m/15m/daily/weekly")] string interval = "daily", + [Description("返回根数,最大250")] int count = 30, + CancellationToken cancellationToken = default) + { + try + { + var token = _userSettingService.CurrentSetting.ZhiTuApiToken; + var formattedSymbol = StockSymbolConverter.ToZhiTuFormat(assetSymbol); + var zhiTuInterval = interval.ToLowerInvariant() switch + { + "5m" => "5", + "15m" => "15", + "weekly" => "w", + _ => "d" + }; + + var clampedCount = Math.Clamp(count, 1, 250); + var daysBack = zhiTuInterval switch + { + "5" or "15" => 60, + "w" => clampedCount * 7 + 14, + _ => (int)(clampedCount * 1.6) + 10 + }; + + var startDate = DateTime.Now.AddDays(-daysBack).ToString("yyyyMMdd"); + var endDate = DateTime.Now.ToString("yyyyMMdd"); + var url = $"/hs/history/{formattedSymbol}/{zhiTuInterval}/n?token={token}&st={startDate}&et={endDate}"; + + using var httpClient = _httpClientFactory.CreateClient("ZhiTu"); + var response = await httpClient.GetStringAsync(url, cancellationToken); + var items = JsonSerializer.Deserialize>(response, JsonOptions.AShareApiOptions); + + if (items == null || items.Count == 0) + throw new FriendlyException($"K线数据为空 (代码: {formattedSymbol})"); + + return items + .OrderBy(x => x.T) + .TakeLast(clampedCount) + .Select(x => new OhlcvBar { T = x.T, O = x.O, H = x.H, L = x.L, C = x.C, V = x.V }) + .ToList(); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取K线序列失败: {Symbol}", assetSymbol); + throw new FriendlyException($"获取K线序列失败: {ex.Message} (代码: {assetSymbol})", ex); + } + } + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(GetKDJAsync); + yield return AIFunctionFactory.Create(GetMACDAsync); + yield return AIFunctionFactory.Create(GetBOLLAsync); + yield return AIFunctionFactory.Create(GetMAAsync); + yield return AIFunctionFactory.Create(GetKLinesAsync); + } + + private sealed class ZhiTuKLineBar + { + [JsonPropertyName("t")] public string T { get; init; } = ""; + [JsonPropertyName("o")] public decimal O { get; init; } + [JsonPropertyName("h")] public decimal H { get; init; } + [JsonPropertyName("l")] public decimal L { get; init; } + [JsonPropertyName("c")] public decimal C { get; init; } + [JsonPropertyName("v")] public decimal V { get; init; } + } +} diff --git a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoBasicTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoBasicTools.cs new file mode 100644 index 0000000..d92236f --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoBasicTools.cs @@ -0,0 +1,199 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.Models.Crypto; +using MarketAssistant.Services.Data; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.ComponentModel; +using static MarketAssistant.Infrastructure.Core.CryptoSymbolConverter; + +namespace MarketAssistant.Agents.Tools.Crypto; + +/// +/// 虚拟币基础数据工具实现(使用服务层获取数据) +/// +public sealed class CryptoBasicTools : ICryptoBasicTools +{ + private readonly ILogger _logger; + private readonly BinanceMarketDataService _binanceService; + private readonly CoinGeckoApiService _coinGeckoService; + private readonly IUserSettingService _userSettingService; + + public CryptoBasicTools( + ILogger logger, + BinanceMarketDataService binanceService, + CoinGeckoApiService coinGeckoService, + IUserSettingService userSettingService) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _binanceService = binanceService ?? throw new ArgumentNullException(nameof(binanceService)); + _coinGeckoService = coinGeckoService ?? throw new ArgumentNullException(nameof(coinGeckoService)); + _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); + } + + /// + /// 根据资产代码获取基本数据(使用币安 API 获取 24 小时行情统计) + /// + /// 虚拟币代码(如 BTC、ETH、BNB) + /// 包含价格、涨跌幅、成交量等行情信息的虚拟币报价数据 + [Description("根据虚拟币代码获取基本数据,包括实时行情、价格变动、成交量等信息。symbol格式不区分大小写,如BTC或BTCUSDT。")] + public async Task GetAssetInfoAsync([Description("虚拟币代码(如BTC、ETH)")] string assetSymbol, CancellationToken cancellationToken = default) + { + try + { + // 格式化交易对符号(如 "BTC" -> "BTCUSDT") + var symbol = ToBinanceFormat(assetSymbol); + + _logger.LogInformation("正在获取虚拟币行情数据: {Symbol}", symbol); + + var tickerData = await _binanceService.Get24hrTickerAsync(symbol, cancellationToken); + + if (tickerData == null) + { + _logger.LogError("币安API返回数据为空: {Symbol}", assetSymbol); + throw new FriendlyException($"无法获取虚拟币行情: {assetSymbol}"); + } + + // 映射到 CryptoQuoteInfo 模型 + var quoteInfo = new CryptoQuoteInfo + { + SecurityCode = assetSymbol.ToUpper(), + SecurityName = assetSymbol.ToUpper(), + SecurityType = "虚拟币", + TradeStatus = "交易中", + + // 价格信息 + CurrentPrice = tickerData.LastPrice, + OpenPrice = tickerData.OpenPrice, + HighPrice = tickerData.HighPrice, + LowPrice = tickerData.LowPrice, + PreviousClosePrice = tickerData.PrevClosePrice ?? tickerData.OpenPrice, + AveragePrice = tickerData.WeightedAvgPrice ?? tickerData.LastPrice, + + // 涨跌信息 + PriceChange = tickerData.PriceChange ?? 0m, + PercentageChange = tickerData.PriceChangePercent ?? 0m, + Amplitude = CalculateAmplitude(tickerData.HighPrice, tickerData.LowPrice, tickerData.PrevClosePrice ?? tickerData.OpenPrice), + + // 交易量(币安原始单位) + Volume = tickerData.Volume, // 24h成交量(币数量) + Amount = tickerData.QuoteVolume // 24h成交额(USDT) + }; + + _logger.LogInformation("成功获取虚拟币行情: {Symbol}, 当前价: {Price}, 涨跌幅: {Change}%", + assetSymbol, quoteInfo.CurrentPrice, quoteInfo.PercentageChange); + + return quoteInfo; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "调用币安API获取行情失败: {Symbol}", assetSymbol); + throw new FriendlyException($"获取虚拟币行情失败: {assetSymbol},请检查网络连接或交易对是否正确", ex); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取虚拟币行情时发生错误: {Symbol}", assetSymbol); + throw new FriendlyException($"获取虚拟币行情时发生错误: {ex.Message}", ex); + } + } + + /// + /// 计算振幅(当日最高价与最低价的差值占前收盘价的百分比) + /// + /// 当日最高价 + /// 当日最低价 + /// 前一日收盘价 + /// 振幅百分比(如 5.23 表示 5.23%) + private static decimal CalculateAmplitude(decimal high, decimal low, decimal prevClose) + { + if (prevClose == 0) return 0; + return Math.Round(((high - low) / prevClose) * 100, 2); + } + + /// + /// 根据虚拟币代码获取区块链项目基本面信息(使用 CoinGecko API) + /// + /// 虚拟币代码(如 BTC、ETH、BNB) + /// 包含项目描述、供应量、价格、市值、交易量、社区数据、开发者活跃度等完整基本面信息 + [Description("根据虚拟币代码获取区块链项目基本面信息,包括项目简介、社区数据、开发者活跃度等。symbol格式不区分大小写,如BTC。")] + public async Task GetProjectInfoAsync([Description("虚拟币代码(如BTC、ETH)")] string assetSymbol, CancellationToken cancellationToken = default) + { + try + { + // 提取基础币种 (如 BTCUSDT -> BTC) + var baseSymbol = CryptoSymbolConverter.ExtractBaseCurrency(assetSymbol); + var symbol = string.IsNullOrWhiteSpace(baseSymbol) ? assetSymbol.ToUpper() : baseSymbol; + + _logger.LogInformation("正在获取虚拟币项目信息: {Symbol}", symbol); + + var coinDetail = await _coinGeckoService.GetCoinBySymbolAsync(symbol, cancellationToken); + + if (coinDetail == null) + { + _logger.LogError("CoinGecko API 返回数据为空: {Symbol}", symbol); + throw new FriendlyException($"无法获取虚拟币项目信息: {symbol},可能是非主流币种或名称错误"); + } + + // 映射到 CryptoProjectInfo 模型 + var projectInfo = new CryptoProjectInfo + { + Symbol = coinDetail.Symbol?.ToUpper() ?? symbol, + Name = coinDetail.Name ?? symbol, + Uri = coinDetail.Links?.Homepage?.FirstOrDefault(h => !string.IsNullOrEmpty(h)) ?? "", + AssetType = "BLOCKCHAIN", + DescriptionSnippet = TruncateDescription(coinDetail.Description?.En, 200), + Description = coinDetail.Description?.En ?? "", + MaxSupply = coinDetail.MarketData?.MaxSupply, + TotalSupply = coinDetail.MarketData?.TotalSupply, + CirculatingSupply = coinDetail.MarketData?.CirculatingSupply, + PriceUsd = coinDetail.MarketData?.CurrentPrice?.GetValueOrDefault("usd"), + TotalMarketCapUsd = coinDetail.MarketData?.MarketCap?.GetValueOrDefault("usd"), + CirculatingMarketCapUsd = coinDetail.MarketData?.MarketCap?.GetValueOrDefault("usd"), + Volume24hUsd = coinDetail.MarketData?.TotalVolume?.GetValueOrDefault("usd"), + Change24hPercent = coinDetail.MarketData?.PriceChangePercentage24hInCurrency?.GetValueOrDefault("usd"), + Change7dPercent = coinDetail.MarketData?.PriceChangePercentage7dInCurrency?.GetValueOrDefault("usd"), + Change30dPercent = coinDetail.MarketData?.PriceChangePercentage30dInCurrency?.GetValueOrDefault("usd"), + Rankings = ExtractRankings(coinDetail), + Industries = coinDetail.Categories ?? new List() + }; + + _logger.LogInformation("成功获取虚拟币项目信息: {Name} ({Symbol})", projectInfo.Name, symbol); + + return projectInfo; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "调用 CoinGecko API 获取项目信息失败: {Symbol}", assetSymbol); + throw new FriendlyException($"获取虚拟币项目信息失败: {assetSymbol},请检查网络连接", ex); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取虚拟币项目信息时发生错误: {Symbol}", assetSymbol); + throw new FriendlyException($"获取虚拟币项目信息时发生错误: {ex.Message}", ex); + } + } + + /// + /// 截取描述前 N 个字符作为摘要 + /// + private static string TruncateDescription(string? description, int maxLength) + { + if (string.IsNullOrEmpty(description)) return ""; + return description.Length <= maxLength ? description : description.Substring(0, maxLength) + "..."; + } + + /// + /// 提取排名信息(CoinGecko 提供 market_cap_rank,其他排名字段不直接提供) + /// + private static RankingInfo? ExtractRankings(CoinGeckoCoinDetail detail) + { + if (detail.MarketCapRank == null) return null; + return new RankingInfo { MarketCapRank = detail.MarketCapRank }; + } + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(GetAssetInfoAsync); + yield return AIFunctionFactory.Create(GetProjectInfoAsync); + } +} \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoMetricsTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoMetricsTools.cs new file mode 100644 index 0000000..127ade5 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoMetricsTools.cs @@ -0,0 +1,556 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.Models; +using MarketAssistant.Agents.Tools.Models.Crypto; +using MarketAssistant.Applications.Charts; +using MarketAssistant.Applications.Charts.Models; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Data; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.ComponentModel; +using System.Globalization; +using System.Text.Json.Nodes; + +namespace MarketAssistant.Agents.Tools.Crypto; + +/// +/// 虚拟币市场指标工具实现 +/// +/// +/// 职责:提供市场深度数据、综合市场指标和衍生计算指标 +/// 数据源:币安(市场深度)、CoinGecko(市场指标、交易量分布) +/// +public sealed class CryptoMetricsTools : ICryptoMetricsTools +{ + private readonly BinanceMarketDataService _binanceService; + private readonly CoinGeckoApiService _coinGeckoService; + private readonly IKLineService _klineService; + private readonly ILogger _logger; + + public CryptoMetricsTools( + BinanceMarketDataService binanceService, + CoinGeckoApiService coinGeckoService, + [FromKeyedServices(MarketType.Crypto)] IKLineService klineService, + ILogger logger) + { + _binanceService = binanceService ?? throw new ArgumentNullException(nameof(binanceService)); + _coinGeckoService = coinGeckoService ?? throw new ArgumentNullException(nameof(coinGeckoService)); + _klineService = klineService ?? throw new ArgumentNullException(nameof(klineService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + // ==================== 市场深度数据(币安) ==================== + + /// + /// 获取历史K线数据(OHLCV) + /// + [Description("获取历史K线数据(开盘、最高、最低、收盘、成交量),用于技术分析。interval为枚举类型,支持如 OneDay, OneHour 等")] + public async Task GetOHLCVAsync(string symbol, MarketInterval interval = MarketInterval.OneDay, int limit = 500, long? startTime = null, long? endTime = null, CancellationToken cancellationToken = default) + { + var intervalStr = ToBinanceInterval(interval); + try + { + var binanceSymbol = CryptoSymbolConverter.ToBinanceFormat(symbol); + + // 当无自定义时间范围时,复用 IKLineService 与 CryptoTechnicalTools 共享同一数据路径 + if (startTime is null && endTime is null) + { + var klineType = ToKLineType(interval); + var klineData = await _klineService.GetKLineDataAsync(symbol, klineType, Math.Min(limit, 1000)); + + if (klineData is not { Count: > 0 }) + throw new FriendlyException($"未找到交易对 {binanceSymbol} 的K线数据,请确认代码是否正确"); + + var intervalMs = GetIntervalMilliseconds(interval); + return new CryptoOHLCV + { + Symbol = binanceSymbol, + Interval = intervalStr, + Candles = klineData.Select(k => new OHLCVCandle + { + OpenTime = new DateTimeOffset(k.Timestamp).ToUnixTimeMilliseconds(), + CloseTime = new DateTimeOffset(k.Timestamp).ToUnixTimeMilliseconds() + intervalMs - 1, + Open = k.Open, + High = k.High, + Low = k.Low, + Close = k.Close, + Volume = k.Volume, + QuoteVolume = k.Amount + }).ToList() + }; + } + + // 有自定义时间范围时直接调用 Binance API + var response = await _binanceService.GetKlinesAsync(binanceSymbol, intervalStr, limit, startTime, endTime, cancellationToken); + if (response == null || response.Count == 0) + throw new FriendlyException($"未找到交易对 {binanceSymbol} 的K线数据,请确认代码是否正确"); + + var candles = new List(); + foreach (var item in response) + { + if (item is not JsonArray arr || arr.Count < 11) continue; + + candles.Add(new OHLCVCandle + { + OpenTime = arr[0]?.GetValue() ?? 0, + Open = decimal.Parse(arr[1]?.GetValue() ?? "0"), + High = decimal.Parse(arr[2]?.GetValue() ?? "0"), + Low = decimal.Parse(arr[3]?.GetValue() ?? "0"), + Close = decimal.Parse(arr[4]?.GetValue() ?? "0"), + Volume = decimal.Parse(arr[5]?.GetValue() ?? "0"), + CloseTime = arr[6]?.GetValue() ?? 0, + QuoteVolume = decimal.Parse(arr[7]?.GetValue() ?? "0"), + TradeCount = arr[8]?.GetValue() ?? 0 + }); + } + + return new CryptoOHLCV + { + Symbol = binanceSymbol, + Interval = intervalStr, + Candles = candles + }; + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取K线数据失败: {Symbol}, Interval: {Interval}", symbol, intervalStr); + throw new FriendlyException($"获取K线数据失败: {ex.Message}", ex); + } + } + + private static KLineType ToKLineType(MarketInterval interval) => interval switch + { + MarketInterval.FiveMinutes => KLineType.Minute5, + MarketInterval.FifteenMinutes => KLineType.Minute15, + MarketInterval.OneWeek => KLineType.Weekly, + MarketInterval.OneMonth => KLineType.Monthly, + _ => KLineType.Daily + }; + + private static string ToBinanceInterval(MarketInterval interval) + { + return interval switch + { + MarketInterval.OneSecond => "1s", + MarketInterval.OneMinute => "1m", + MarketInterval.ThreeMinutes => "3m", + MarketInterval.FiveMinutes => "5m", + MarketInterval.FifteenMinutes => "15m", + MarketInterval.ThirtyMinutes => "30m", + MarketInterval.OneHour => "1h", + MarketInterval.TwoHours => "2h", + MarketInterval.FourHours => "4h", + MarketInterval.SixHours => "6h", + MarketInterval.EightHours => "8h", + MarketInterval.TwelveHours => "12h", + MarketInterval.OneDay => "1d", + MarketInterval.ThreeDays => "3d", + MarketInterval.OneWeek => "1w", + MarketInterval.OneMonth => "1M", + _ => throw new ArgumentOutOfRangeException(nameof(interval), interval, null) + }; + } + + /// + /// 根据 MarketInterval 计算区间时长(毫秒),用于推算 CloseTime + /// + private static long GetIntervalMilliseconds(MarketInterval interval) => interval switch + { + MarketInterval.OneSecond => 1000, + MarketInterval.OneMinute => 60 * 1000, + MarketInterval.ThreeMinutes => 3 * 60 * 1000, + MarketInterval.FiveMinutes => 5 * 60 * 1000, + MarketInterval.FifteenMinutes => 15 * 60 * 1000, + MarketInterval.ThirtyMinutes => 30 * 60 * 1000, + MarketInterval.OneHour => 60 * 60 * 1000, + MarketInterval.TwoHours => 2 * 60 * 60 * 1000, + MarketInterval.FourHours => 4 * 60 * 60 * 1000, + MarketInterval.SixHours => 6 * 60 * 60 * 1000, + MarketInterval.EightHours => 8 * 60 * 60 * 1000, + MarketInterval.TwelveHours => 12 * 60 * 60 * 1000, + MarketInterval.OneDay => 24 * 60 * 60 * 1000, + MarketInterval.ThreeDays => 3 * 24 * 60 * 60 * 1000, + MarketInterval.OneWeek => 7 * 24 * 60 * 60 * 1000, + MarketInterval.OneMonth => 30L * 24 * 60 * 60 * 1000, + _ => 24 * 60 * 60 * 1000 + }; + + /// + /// 获取订单簿深度数据 + /// + [Description("获取订单簿深度数据,包括买卖盘挂单,用于分析支撑压力位。limit通常为20/50/100")] + public async Task GetOrderBookDepthAsync(string symbol, int limit = 100, CancellationToken cancellationToken = default) + { + try + { + var binanceSymbol = CryptoSymbolConverter.ToBinanceFormat(symbol); + + var response = await _binanceService.GetDepthAsync(binanceSymbol, limit, cancellationToken); + if (response == null) + { + throw new FriendlyException($"未找到交易对 {binanceSymbol} 的深度数据,请确认代码是否正确。"); + } + + var bids = new List(); + var asks = new List(); + + if (response["bids"] is JsonArray bidsArray) + { + foreach (var bid in bidsArray) + { + if (bid is JsonArray bidArr && bidArr.Count >= 2) + { + bids.Add(new OrderBookLevel + { + Price = decimal.Parse(bidArr[0]?.GetValue() ?? "0", CultureInfo.InvariantCulture), + Quantity = decimal.Parse(bidArr[1]?.GetValue() ?? "0", CultureInfo.InvariantCulture) + }); + } + } + } + + if (response["asks"] is JsonArray asksArray) + { + foreach (var ask in asksArray) + { + if (ask is JsonArray askArr && askArr.Count >= 2) + { + asks.Add(new OrderBookLevel + { + Price = decimal.Parse(askArr[0]?.GetValue() ?? "0", CultureInfo.InvariantCulture), + Quantity = decimal.Parse(askArr[1]?.GetValue() ?? "0", CultureInfo.InvariantCulture) + }); + } + } + } + + return new CryptoOrderBookDepth + { + Symbol = binanceSymbol, + LastUpdateId = response["lastUpdateId"]?.GetValue() ?? 0, + Bids = bids, + Asks = asks + }; + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取订单簿深度失败: {Symbol}", symbol); + throw new FriendlyException($"获取订单簿深度失败: {ex.Message}", ex); + } + } + + /// + /// 获取最近成交数据 + /// + [Description("获取最近成交记录,分析主动买入/卖出力量对比")] + public async Task GetRecentTradesAsync(string symbol, int limit = 500, CancellationToken cancellationToken = default) + { + try + { + var binanceSymbol = CryptoSymbolConverter.ToBinanceFormat(symbol); + + var response = await _binanceService.GetRecentTradesAsync(binanceSymbol, limit, cancellationToken); + if (response == null || response.Count == 0) + { + throw new FriendlyException($"未找到交易对 {binanceSymbol} 的成交数据,请确认代码是否正确。"); + } + + var trades = new List(); + decimal buyVolume = 0; + decimal sellVolume = 0; + + foreach (var item in response) + { + if (item is not JsonObject obj) continue; + + var price = decimal.Parse(obj["price"]?.GetValue() ?? "0", CultureInfo.InvariantCulture); + var qty = decimal.Parse(obj["qty"]?.GetValue() ?? "0", CultureInfo.InvariantCulture); + var isBuyerMaker = obj["isBuyerMaker"]?.GetValue() ?? false; + + trades.Add(new CryptoTrade + { + TradeId = obj["id"]?.GetValue() ?? 0, + Price = price, + Quantity = qty, + QuoteQuantity = decimal.Parse(obj["quoteQty"]?.GetValue() ?? "0", CultureInfo.InvariantCulture), + Timestamp = obj["time"]?.GetValue() ?? 0, + IsBuyerMaker = isBuyerMaker + }); + + if (isBuyerMaker) + sellVolume += qty; // Buyer是maker表示主动卖出 + else + buyVolume += qty; // Buyer是taker表示主动买入 + } + + var totalVolume = buyVolume + sellVolume; + + return new CryptoRecentTrades + { + Symbol = binanceSymbol, + Trades = trades, + BuyerVolumePercent = totalVolume > 0 ? buyVolume / totalVolume * 100 : 0, + SellerVolumePercent = totalVolume > 0 ? sellVolume / totalVolume * 100 : 0 + }; + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取最近成交失败: {Symbol}", symbol); + throw new FriendlyException($"获取最近成交失败: {ex.Message}", ex); + } + } + + // ==================== 综合市场指标(CoinGecko) ==================== + + /// + /// 获取综合市场指标 + /// + [Description("获取CoinGecko全方位市场数据:市值、FDV、ATH/ATL、历史排名等")] + public async Task GetMarketMetricsAsync(string symbol, CancellationToken cancellationToken = default) + { + try + { + var coinId = await GetCoinGeckoIdAsync(symbol, cancellationToken); + + var response = await _coinGeckoService.GetCoinMarketDataAsync(coinId, "usd", "24h,7d,30d", cancellationToken); + if (response == null || response.Count == 0) + { + throw new FriendlyException($"未找到代币 {symbol} 的市场数据,可能是CoinGecko ID不匹配。"); + } + + var data = response[0] as JsonObject; + if (data == null) + { + throw new FriendlyException($"解析代币 {symbol} 的市场数据失败"); + } + + return new CryptoMarketMetrics + { + Symbol = symbol.ToUpperInvariant(), + CurrentPriceUsd = data["current_price"]?.GetValue() ?? 0, + MarketCapUsd = data["market_cap"]?.GetValue() ?? 0, + FullyDilutedValuationUsd = data["fully_diluted_valuation"]?.GetValue(), + CirculatingSupply = data["circulating_supply"]?.GetValue() ?? 0, + TotalSupply = data["total_supply"]?.GetValue(), + MaxSupply = data["max_supply"]?.GetValue(), + Volume24hUsd = data["total_volume"]?.GetValue() ?? 0, + PriceChange24hPercent = data["price_change_percentage_24h"]?.GetValue() ?? 0, + PriceChange7dPercent = data["price_change_percentage_7d_in_currency"]?.GetValue(), + PriceChange30dPercent = data["price_change_percentage_30d_in_currency"]?.GetValue(), + MarketCapRank = data["market_cap_rank"]?.GetValue(), + AllTimeHighUsd = data["ath"]?.GetValue(), + AthChangePercent = data["ath_change_percentage"]?.GetValue(), + AllTimeLowUsd = data["atl"]?.GetValue(), + AtlChangePercent = data["atl_change_percentage"]?.GetValue(), + LastUpdated = data["last_updated"]?.GetValue() ?? DateTime.UtcNow + }; + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取市场指标失败: {Symbol}", symbol); + throw new FriendlyException($"获取市场指标失败: {ex.Message}", ex); + } + } + + /// + /// 获取交易量分布 + /// + [Description("获取代币在不同交易所的交易量分布情况,用于分析流动性分布")] + public async Task> GetVolumeDistributionAsync(string symbol, CancellationToken cancellationToken = default) + { + try + { + var coinId = await GetCoinGeckoIdAsync(symbol, cancellationToken); + + var response = await _coinGeckoService.GetCoinTickersAsync(coinId, cancellationToken); + if (response?.Tickers == null || response.Tickers.Count == 0) + { + return []; + } + + // 聚合各交易所的交易量 + var exchangeVolumes = response.Tickers + .GroupBy(t => t.Market?.Name ?? "Unknown") + .Select(g => new + { + Exchange = g.Key, + Volume = g.Sum(t => t.ConvertedVolume?.Usd ?? 0), + PairCount = g.Count() + }) + .OrderByDescending(x => x.Volume) + .ToList(); + + var totalVolume = exchangeVolumes.Sum(x => x.Volume); + if (totalVolume == 0) return []; + + return exchangeVolumes + .Select(x => new VolumeDistribution + { + Exchange = x.Exchange, + Volume = (decimal)x.Volume, + Percentage = (decimal)(x.Volume / totalVolume * 100), + PairCount = x.PairCount + }) + .ToList(); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取交易量分布失败: {Symbol}", symbol); + throw new FriendlyException($"获取交易量分布失败: {ex.Message}", ex); + } + } + /// + /// 获取波动性指标 + /// + [Description("获取波动性指标,包括历史波动率、ATR、最大回撤、夏普比率等,用于风险评估")] + public async Task GetVolatilityMetricsAsync(string symbol, int days = 30, CancellationToken cancellationToken = default) + { + try + { + // 获取历史K线数据 + var ohlcv = await GetOHLCVAsync(symbol, MarketInterval.OneDay, days + 1, cancellationToken: cancellationToken); + if (ohlcv.Candles.Count < 2) + { + throw new FriendlyException($"数据不足,无法计算波动性指标"); + } + + var candles = ohlcv.Candles; + var returns = new List(); + var trueRanges = new List(); + + // 计算日收益率和真实波幅 + for (int i = 1; i < candles.Count; i++) + { + var prevClose = candles[i - 1].Close; + var currentClose = candles[i].Close; + var currentHigh = candles[i].High; + var currentLow = candles[i].Low; + + // 日收益率 + if (prevClose > 0) + { + returns.Add((currentClose - prevClose) / prevClose); + } + + // 真实波幅 = max(H-L, |H-PC|, |L-PC|) + var tr1 = currentHigh - currentLow; + var tr2 = Math.Abs(currentHigh - prevClose); + var tr3 = Math.Abs(currentLow - prevClose); + trueRanges.Add(Math.Max(tr1, Math.Max(tr2, tr3))); + } + + // 计算统计指标 + var avgReturn = returns.Average(); + var variance = returns.Sum(r => (r - avgReturn) * (r - avgReturn)) / (returns.Count - 1); + var stdDev = (decimal)Math.Sqrt((double)variance); + var dailyVol = stdDev * 100; // 转换为百分比 + var annualizedVol = dailyVol * (decimal)Math.Sqrt(365); // 年化(虚拟币市场7x24交易,365天) + var atr = trueRanges.Average(); + + // 计算最大回撤 + decimal maxDrawdown = 0; + long maxDDStart = 0; + long maxDDEnd = 0; + decimal peak = candles[0].Close; + long peakTime = candles[0].OpenTime; + + for (int i = 0; i < candles.Count; i++) + { + var price = candles[i].Close; + if (price > peak) + { + peak = price; + peakTime = candles[i].OpenTime; + } + else + { + var drawdown = (peak - price) / peak * 100; + if (drawdown > maxDrawdown) + { + maxDrawdown = drawdown; + maxDDStart = peakTime; + maxDDEnd = candles[i].OpenTime; + } + } + } + + // 计算夏普比率(假设无风险利率为0) + decimal? sharpeRatio = null; + if (stdDev > 0) + { + var annualizedReturn = avgReturn * 365; // 年化收益率(虚拟币365天) + sharpeRatio = annualizedReturn / (stdDev * (decimal)Math.Sqrt(365)); + } + + return new CryptoVolatilityMetrics + { + Symbol = symbol.ToUpperInvariant(), + AnnualizedVolatility = annualizedVol, + DailyVolatility = dailyVol, + AverageTrueRange = atr, + MaxDrawdown = maxDrawdown, + MaxDrawdownStartTime = maxDDStart, + MaxDrawdownEndTime = maxDDEnd, + SharpeRatio = sharpeRatio, + PeriodDays = days, + StandardDeviation = stdDev * 100, + AverageReturn = avgReturn * 100 + }; + } + catch (FriendlyException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "获取波动性指标失败: {Symbol}", symbol); + throw new FriendlyException($"获取波动性指标失败: {ex.Message}", ex); + } + } + + // ==================== AI Functions ==================== + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(GetOHLCVAsync); + yield return AIFunctionFactory.Create(GetOrderBookDepthAsync); + yield return AIFunctionFactory.Create(GetRecentTradesAsync); + yield return AIFunctionFactory.Create(GetMarketMetricsAsync); + yield return AIFunctionFactory.Create(GetVolumeDistributionAsync); + yield return AIFunctionFactory.Create(GetVolatilityMetricsAsync); + } + + // ==================== 辅助方法 ==================== + + private async Task GetCoinGeckoIdAsync(string symbol, CancellationToken cancellationToken = default) + { + try + { + // 1. 预处理:如果是交易对格式(如 BTC/USDT),先提取基础币种(BTC) + // 使用 ToBinanceFormat 清理分隔符,再提取 BaseCurrency + var cleanSymbol = CryptoSymbolConverter.ToBinanceFormat(symbol); + var searchSymbol = CryptoSymbolConverter.ExtractBaseCurrency(cleanSymbol); + + // 2. 搜索 CoinGecko + var response = await _coinGeckoService.SearchCoinsAsync(searchSymbol, cancellationToken); + + var coin = response?.Coins?.FirstOrDefault(c => + c.Symbol?.Equals(searchSymbol, StringComparison.OrdinalIgnoreCase) == true); + + if (coin != null) + { + return coin.Id; + } + + // 3. Fallback: 使用提取后的符号小写 + _logger.LogWarning("未找到代币 {Symbol} ({SearchSymbol}) 的CoinGecko ID,使用小写symbol作为fallback", symbol, searchSymbol); + return searchSymbol.ToLowerInvariant(); + } + catch (Exception ex) + { + _logger.LogError(ex, "搜索CoinGecko ID失败: {Symbol}", symbol); + return symbol.ToLowerInvariant().Replace("/", ""); + } + } +} diff --git a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoNewsTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoNewsTools.cs new file mode 100644 index 0000000..90cea1e --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoNewsTools.cs @@ -0,0 +1,141 @@ +using System.ServiceModel.Syndication; +using System.Xml; +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.Models; +using MarketAssistant.Applications.Assets; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Infrastructure.Factories; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.ComponentModel; +using static MarketAssistant.Infrastructure.Core.CryptoSymbolConverter; + +namespace MarketAssistant.Agents.Tools.Crypto; + +/// +/// 虚拟币新闻数据工具实现(使用 CoinTelegraph RSS 免费源) +/// +/// 设计说明: +/// 原 CoinDesk News API v1 已启用企业鉴权(401),免费 Personal 计划仅含历史价格数据。 +/// 切换到 CoinTelegraph RSS(https://cointelegraph.com/rss): +/// - 优点:免费、稳定、无需密钥 +/// - 缺点:无法精确搜索,需在客户端按 assetSymbol 过滤标题/摘要 +/// +public sealed class CryptoNewsTools : INewsDataTools +{ + private const string RssFeedUrl = "https://cointelegraph.com/rss"; + + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ICryptoAliasRegistry _aliasRegistry; + + public CryptoNewsTools( + ILogger logger, + IHttpClientFactory httpClientFactory, + ICryptoAliasRegistry aliasRegistry) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _aliasRegistry = aliasRegistry; + } + + /// + /// 获取虚拟币相关新闻(从 CoinTelegraph RSS 拉取并按币种过滤) + /// + [Description("获取虚拟币相关的最新新闻")] + public Task> GetNewsAsync( + [Description("虚拟币代码(如BTC、ETH)")] string assetSymbol, + int count = 10, + CancellationToken cancellationToken = default) + { + var searchQuery = ExtractBaseCurrency(assetSymbol); + _logger.LogInformation("正在获取虚拟币新闻(RSS): {Symbol} (query={Query})", assetSymbol, searchQuery); + + return ToolExecutor.ExecuteAsync( + operationName: "获取虚拟币新闻", + assetSymbol: assetSymbol, + logger: _logger, + action: async ct => + { + var allItems = await FetchRssFeedAsync(ct); + var filtered = await FilterBySymbolAsync(allItems, searchQuery, count, ct); + + _logger.LogInformation("成功获取虚拟币新闻: {Symbol}, 数量: {Count}/{Total}", assetSymbol, filtered.Count, allItems.Count); + return filtered; + }, + cancellationToken: cancellationToken); + } + + /// + /// 拉取并解析 RSS Feed + /// + private async Task> FetchRssFeedAsync(CancellationToken cancellationToken) + { + using var httpClient = _httpClientFactory.CreateClient("CryptoNewsRss"); + httpClient.Timeout = TimeSpan.FromSeconds(15); + + using var response = await httpClient.GetAsync(RssFeedUrl, cancellationToken); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var xmlReader = XmlReader.Create(stream, new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Ignore, + IgnoreWhitespace = true + }); + + var feed = SyndicationFeed.Load(xmlReader); + var items = new List(); + + foreach (var item in feed.Items) + { + var title = item.Title?.Text?.Trim() ?? ""; + if (string.IsNullOrEmpty(title)) + continue; + + var link = item.Links.FirstOrDefault()?.Uri?.ToString() ?? item.Id ?? ""; + var summary = item.Summary?.Text?.Trim() ?? ""; + var publishTime = item.PublishDate.LocalDateTime.ToString("yyyy-MM-dd HH:mm"); + + items.Add(new NewsItem + { + Title = title, + Link = link, + Source = "CoinTelegraph", + PublishTime = publishTime, + Summary = summary + }); + } + + return items; + } + + /// + /// 按币种符号过滤新闻(标题或摘要中包含符号或常见别名) + /// + private async Task> FilterBySymbolAsync( + List items, string symbol, int count, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(symbol)) + return items.Take(count).ToList(); + + var aliases = await _aliasRegistry.GetAliasesAsync(symbol, cancellationToken); + + var filtered = items + .Where(item => + { + var title = item.Title?.ToUpperInvariant() ?? ""; + var summary = item.Summary?.ToUpperInvariant() ?? ""; + return aliases.Any(a => title.Contains(a) || summary.Contains(a)); + }) + .Take(count) + .ToList(); + + return filtered.Count > 0 ? filtered : items.Take(count).ToList(); + } + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(GetNewsAsync); + } +} diff --git a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoSentimentTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoSentimentTools.cs new file mode 100644 index 0000000..f033ccd --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoSentimentTools.cs @@ -0,0 +1,269 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.Models.Crypto; +using MarketAssistant.Services.Data; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.Globalization; + +namespace MarketAssistant.Agents.Tools.Crypto; + +/// +/// 虚拟币市场情绪工具实现 +/// https://developers.binance.com/docs/zh-CN/derivatives/usds-margined-futures/general-info +/// +public sealed class CryptoSentimentTools : ICryptoSentimentTools +{ + private readonly ILogger _logger; + private readonly BinanceMarketDataService _binanceService; + + public CryptoSentimentTools( + ILogger logger, + BinanceMarketDataService binanceService) + { + _logger = logger; + _binanceService = binanceService; + } + + /// + /// 获取资金费率历史数据 + /// + public async Task GetFundingRateAsync(string symbol, CancellationToken cancellationToken = default) + { + try + { + var binanceSymbol = CryptoSymbolConverter.ToBinanceFormat(symbol); + + // 1. 获取当前资金费率 + var premiumResponse = await _binanceService.GetPremiumIndexAsync(binanceSymbol, cancellationToken); + + if (premiumResponse == null) + { + throw new FriendlyException($"获取当前资金费率失败: {symbol}"); + } + + // 2. 获取历史资金费率 + var historyResponse = await _binanceService.GetFundingRateHistoryAsync(binanceSymbol, 30, cancellationToken: cancellationToken); + + if (historyResponse == null || historyResponse.Count == 0) + { + throw new FriendlyException($"获取历史资金费率失败: {symbol}"); + } + + // 3. 构建历史数据点(倒序排列,最新在前) + var historyPoints = historyResponse + .OrderByDescending(h => h.FundingTime) + .Select(h => new FundingRatePoint + { + Rate = decimal.Parse(h.FundingRate) * 100, // 转换为百分比 + FundingTime = h.FundingTime + }) + .ToList(); + + if (historyPoints.Count == 0) + { + throw new FriendlyException($"解析历史资金费率数据为空: {symbol}"); + } + + // 4. 计算统计数据 + var currentRate = decimal.Parse(premiumResponse.LastFundingRate, CultureInfo.InvariantCulture) * 100; + var currentTime = historyPoints[0].FundingTime; + var averageRate = historyPoints.Average(p => p.Rate); + + return new FundingRateHistory + { + Symbol = premiumResponse.Symbol, + CurrentRate = currentRate, + CurrentFundingTime = currentTime, + NextFundingTime = premiumResponse.NextFundingTime, + AverageRate = averageRate, + History = historyPoints + }; + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取资金费率历史数据时发生错误: {Symbol}", symbol); + throw new FriendlyException($"获取资金费率数据失败: {ex.Message}", ex); + } + } + + /// + /// 获取全局账户多空比历史数据 + /// + public async Task GetGlobalLongShortRatioAsync(string symbol, Period period = Period.FiveMinutes, int limit = 30, CancellationToken cancellationToken = default) + { + return await GetLongShortRatioHistoryAsync( + symbol, + period, + limit, + "globalLongShortAccountRatio", + "全局账户多空比", + cancellationToken + ); + } + + /// + /// 获取顶级交易员账户多空比历史数据 + /// + public async Task GetTopTraderAccountRatioAsync(string symbol, Period period = Period.FiveMinutes, int limit = 30, CancellationToken cancellationToken = default) + { + return await GetLongShortRatioHistoryAsync( + symbol, + period, + limit, + "topLongShortAccountRatio", + "顶级交易员账户多空比", + cancellationToken + ); + } + + /// + /// 获取顶级交易员持仓多空比历史数据 + /// + public async Task GetTopTraderPositionRatioAsync(string symbol, Period period = Period.FiveMinutes, int limit = 30, CancellationToken cancellationToken = default) + { + return await GetLongShortRatioHistoryAsync( + symbol, + period, + limit, + "topLongShortPositionRatio", + "顶级交易员持仓多空比", + cancellationToken + ); + } + + /// + /// 通用多空比历史数据获取方法 + /// + private async Task GetLongShortRatioHistoryAsync( + string symbol, + Period period, + int limit, + string endpoint, + string dataType, + CancellationToken cancellationToken = default) + { + try + { + var binanceSymbol = CryptoSymbolConverter.ToBinanceFormat(symbol); + + // 转换枚举为 API 参数 + var periodParam = period.GetDescription(); + + // 获取历史数据 + var response = await _binanceService.GetLongShortRatioAsync(endpoint, binanceSymbol, periodParam, limit, cancellationToken); + + if (response == null || response.Count == 0) + { + throw new FriendlyException($"获取{dataType}失败: {symbol}"); + } + + // 按时间倒序排列(最新在前) + var sortedData = response.OrderByDescending(r => r.Timestamp).ToList(); + + // 构建历史数据点 + var historyPoints = sortedData + .Select(h => new LongShortRatioPoint + { + LongRatio = decimal.Parse(h.LongAccount, CultureInfo.InvariantCulture), + ShortRatio = decimal.Parse(h.ShortAccount, CultureInfo.InvariantCulture), + Ratio = decimal.Parse(h.LongShortRatio, CultureInfo.InvariantCulture), + Timestamp = h.Timestamp + }) + .ToList(); + + if (historyPoints.Count == 0) + { + throw new FriendlyException($"解析{dataType}数据为空: {symbol}"); + } + + // 计算统计数据 + var current = historyPoints[0]; + var averageRatio = historyPoints.Average(p => p.Ratio); + + return new LongShortRatioHistory + { + Symbol = binanceSymbol, + CurrentLongRatio = current.LongRatio, + CurrentShortRatio = current.ShortRatio, + CurrentRatio = current.Ratio, + AverageRatio = averageRatio, + History = historyPoints + }; + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取{DataType}时发生错误: {Symbol}", dataType, symbol); + throw new FriendlyException($"获取{dataType}失败: {ex.Message}", ex); + } + } + + /// + /// 获取合约持仓量 + /// + public async Task GetOpenInterestAsync(string symbol, Period period = Period.OneHour, CancellationToken cancellationToken = default) + { + try + { + var binanceSymbol = CryptoSymbolConverter.ToBinanceFormat(symbol); + + // 转换枚举为 API 参数 + var periodParam = period.GetDescription(); + + // 获取合约持仓量历史数据(默认获取最近30个数据点) + var response = await _binanceService.GetOpenInterestHistAsync(binanceSymbol, periodParam, 30, cancellationToken); + + if (response == null || response.Count == 0) + { + throw new FriendlyException($"获取合约持仓量失败: {symbol}"); + } + + // 按时间倒序排列(最新在前) + var sortedData = response.OrderByDescending(r => r.Timestamp).ToList(); + + // 构建历史数据点 + var historyPoints = sortedData + .Select(h => new OpenInterestPoint + { + SumOpenInterest = decimal.Parse(h.SumOpenInterest, CultureInfo.InvariantCulture), + SumOpenInterestValue = decimal.Parse(h.SumOpenInterestValue, CultureInfo.InvariantCulture), + Timestamp = h.Timestamp + }) + .ToList(); + + if (historyPoints.Count == 0) + { + throw new FriendlyException($"解析合约持仓量数据为空: {symbol}"); + } + + // 计算统计数据 + var current = historyPoints[0]; + var avgOpenInterest = historyPoints.Average(p => p.SumOpenInterest); + var avgOpenInterestValue = historyPoints.Average(p => p.SumOpenInterestValue); + + return new OpenInterest + { + Symbol = binanceSymbol, + CurrentOpenInterest = current.SumOpenInterest, + CurrentOpenInterestValue = current.SumOpenInterestValue, + CurrentTimestamp = current.Timestamp, + AverageOpenInterest = avgOpenInterest, + AverageOpenInterestValue = avgOpenInterestValue, + History = historyPoints + }; + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取合约持仓量时发生错误: {Symbol}", symbol); + throw new FriendlyException($"获取合约持仓量失败: {ex.Message}", ex); + } + } + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(GetFundingRateAsync); + yield return AIFunctionFactory.Create(GetGlobalLongShortRatioAsync); + yield return AIFunctionFactory.Create(GetTopTraderAccountRatioAsync); + yield return AIFunctionFactory.Create(GetTopTraderPositionRatioAsync); + yield return AIFunctionFactory.Create(GetOpenInterestAsync); + } +} diff --git a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoStrategyTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoStrategyTools.cs new file mode 100644 index 0000000..80121bf --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoStrategyTools.cs @@ -0,0 +1,78 @@ +using System.ComponentModel; +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Trading; +using MarketAssistant.Trading.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Agents.Tools.Crypto; + +/// +/// 策略管理工具实现,供 TradingAgent 查询和更新策略 +/// +public class CryptoStrategyTools : IStrategyTools +{ + private readonly TradingDataService _dataService; + private readonly ILogger _logger; + + public CryptoStrategyTools(TradingDataService dataService, ILogger logger) + { + _dataService = dataService; + _logger = logger; + } + + [Description("获取所有活跃状态的交易策略列表")] + public async Task> GetActiveStrategiesAsync(CancellationToken cancellationToken = default) + { + try + { + return await _dataService.GetStrategiesByStatusAsync(StrategyStatus.Active); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取活跃策略列表失败"); + throw new FriendlyException($"获取策略列表失败: {ex.Message}", ex); + } + } + + [Description("根据策略ID获取策略详情")] + public async Task GetStrategyAsync( + [Description("策略ID")] string strategyId, + CancellationToken cancellationToken = default) + { + try + { + return await _dataService.GetStrategyAsync(strategyId); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取策略详情失败: {StrategyId}", strategyId); + throw new FriendlyException($"获取策略详情失败: {ex.Message}", ex); + } + } + + [Description("更新策略状态(如暂停、完成、失败)")] + public async Task UpdateStrategyStatusAsync( + [Description("策略ID")] string strategyId, + [Description("新状态")] StrategyStatus status, + CancellationToken cancellationToken = default) + { + try + { + await _dataService.UpdateStrategyStatusAsync(strategyId, status); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "更新策略状态失败: {StrategyId} -> {Status}", strategyId, status); + throw new FriendlyException($"更新策略状态失败: {ex.Message}", ex); + } + } + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(GetActiveStrategiesAsync); + yield return AIFunctionFactory.Create(GetStrategyAsync); + yield return AIFunctionFactory.Create(UpdateStrategyStatusAsync); + } +} diff --git a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoTechnicalTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoTechnicalTools.cs new file mode 100644 index 0000000..685fd40 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoTechnicalTools.cs @@ -0,0 +1,274 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.Models.Technical; +using MarketAssistant.Applications.Charts; +using MarketAssistant.Applications.Charts.Models; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Skender.Stock.Indicators; +using System.ComponentModel; + +namespace MarketAssistant.Agents.Tools.Crypto; + +/// +/// 虚拟币技术分析工具实现(基于币安 K 线数据计算) +/// +public sealed class CryptoTechnicalTools : ITechnicalDataTools +{ + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + + public CryptoTechnicalTools(ILogger logger, IServiceProvider serviceProvider) + { + _logger = logger; + _serviceProvider = serviceProvider; + } + + [Description("获取近30日最新日线KDJ,支持BTC、ETH等币种")] + public async Task GetKDJAsync([Description("虚拟币代码(如BTC、ETH)")] string assetSymbol, CancellationToken cancellationToken = default) + { + try + { + var klineService = _serviceProvider.GetRequiredKeyedService(MarketType.Crypto); + var klineData = await klineService.GetKLineDataAsync(assetSymbol, KLineType.Daily, 250); + + if (klineData == null || klineData.Count < 9) + { + throw new FriendlyException($"K线数据不足,无法计算KDJ指标: {assetSymbol}"); + } + + var quotes = ToIndicatorQuotes(klineData); + var result = quotes.GetStoch(9, 3, 3) + .LastOrDefault(item => item.K is not null && item.D is not null && item.J is not null) + ?? throw new FriendlyException($"KDJ 指标结果为空: {assetSymbol}"); + + var kdj = new TechnicalKDJ + { + T = klineData.Last().Timestamp.ToString("yyyy-MM-dd"), + K = Round(result.K), + D = Round(result.D), + J = Round(result.J) + }; + + _logger.LogInformation("成功计算虚拟币KDJ指标: {Symbol}, K={K}, D={D}, J={J}", + assetSymbol, kdj.K, kdj.D, kdj.J); + + return kdj; + } + catch (Exception ex) + { + _logger.LogError(ex, "计算虚拟币KDJ指标失败: {Symbol}", assetSymbol); + throw new FriendlyException($"计算虚拟币KDJ指标失败: {ex.Message} (交易对: {assetSymbol})", ex); + } + } + + [Description("获取近30日最新日线MACD,支持BTC、ETH等币种")] + public async Task GetMACDAsync([Description("虚拟币代码(如BTC、ETH)")] string assetSymbol, CancellationToken cancellationToken = default) + { + try + { + var klineService = _serviceProvider.GetRequiredKeyedService(MarketType.Crypto); + var klineData = await klineService.GetKLineDataAsync(assetSymbol, KLineType.Daily, 250); + + if (klineData == null || klineData.Count < 26) + { + throw new FriendlyException($"K线数据不足,无法计算MACD指标: {assetSymbol}"); + } + + var quotes = ToIndicatorQuotes(klineData); + var result = quotes.GetMacd(12, 26, 9) + .LastOrDefault(item => item.Macd is not null && item.Signal is not null) + ?? throw new FriendlyException($"MACD 指标结果为空: {assetSymbol}"); + + var macd = new TechnicalMACD + { + T = klineData.Last().Timestamp.ToString("yyyy-MM-dd"), + Diff = Round(result.Macd) ?? 0, + Dea = Round(result.Signal) ?? 0, + Macd = Round(result.Histogram) ?? 0, + Ema12 = Round(result.FastEma) ?? 0, + Ema26 = Round(result.SlowEma) ?? 0 + }; + + _logger.LogInformation("成功计算虚拟币MACD指标: {Symbol}, DIFF={Diff}, DEA={Dea}, MACD={Macd}", + assetSymbol, macd.Diff, macd.Dea, macd.Macd); + + return macd; + } + catch (Exception ex) + { + _logger.LogError(ex, "计算虚拟币MACD指标失败: {Symbol}", assetSymbol); + throw new FriendlyException($"计算虚拟币MACD指标失败: {ex.Message} (交易对: {assetSymbol})", ex); + } + } + + [Description("获取近30日最新日线BOLL,支持BTC、ETH等币种")] + public async Task GetBOLLAsync([Description("虚拟币代码(如BTC、ETH)")] string assetSymbol, CancellationToken cancellationToken = default) + { + try + { + var klineService = _serviceProvider.GetRequiredKeyedService(MarketType.Crypto); + var klineData = await klineService.GetKLineDataAsync(assetSymbol, KLineType.Daily, 250); + + if (klineData == null || klineData.Count < 20) + { + throw new FriendlyException($"K线数据不足,无法计算BOLL指标: {assetSymbol}"); + } + + var quotes = ToIndicatorQuotes(klineData); + var result = quotes.GetBollingerBands(20, 2) + .LastOrDefault(item => item.UpperBand is not null && item.Sma is not null && item.LowerBand is not null) + ?? throw new FriendlyException($"BOLL 指标结果为空: {assetSymbol}"); + + var boll = new TechnicalBoll + { + T = klineData.Last().Timestamp.ToString("yyyy-MM-dd"), + U = Round(result.UpperBand), + M = Round(result.Sma), + D = Round(result.LowerBand) + }; + + _logger.LogInformation("成功计算虚拟币BOLL指标: {Symbol}, 上轨={U}, 中轨={M}, 下轨={D}", + assetSymbol, boll.U, boll.M, boll.D); + + return boll; + } + catch (Exception ex) + { + _logger.LogError(ex, "计算虚拟币BOLL指标失败: {Symbol}", assetSymbol); + throw new FriendlyException($"计算虚拟币BOLL指标失败: {ex.Message} (交易对: {assetSymbol})", ex); + } + } + + [Description("获取近30日最新日线MA,支持BTC、ETH等币种")] + public async Task GetMAAsync([Description("虚拟币代码(如BTC、ETH)")] string assetSymbol, CancellationToken cancellationToken = default) + { + try + { + var klineService = _serviceProvider.GetRequiredKeyedService(MarketType.Crypto); + var klineData = await klineService.GetKLineDataAsync(assetSymbol, KLineType.Daily, 250); + + if (klineData == null || klineData.Count < 3) + { + throw new FriendlyException($"K线数据不足,无法计算MA指标: {assetSymbol}"); + } + + var quotes = ToIndicatorQuotes(klineData); + var ma = new TechnicalMA + { + T = klineData.Last().Timestamp.ToString("yyyy-MM-dd"), + MA3 = GetLatestSma(quotes, 3), + MA5 = GetLatestSma(quotes, 5), + MA10 = GetLatestSma(quotes, 10), + MA15 = GetLatestSma(quotes, 15), + MA20 = GetLatestSma(quotes, 20), + MA30 = GetLatestSma(quotes, 30), + MA60 = GetLatestSma(quotes, 60), + MA120 = GetLatestSma(quotes, 120), + MA200 = GetLatestSma(quotes, 200), + MA250 = GetLatestSma(quotes, 250) + }; + + _logger.LogInformation("成功计算虚拟币MA指标: {Symbol}, MA5={MA5}, MA10={MA10}, MA20={MA20}", + assetSymbol, ma.MA5, ma.MA10, ma.MA20); + + return ma; + } + catch (Exception ex) + { + _logger.LogError(ex, "计算虚拟币MA指标失败: {Symbol}", assetSymbol); + throw new FriendlyException($"计算虚拟币MA指标失败: {ex.Message} (交易对: {assetSymbol})", ex); + } + } + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(GetKDJAsync); + yield return AIFunctionFactory.Create(GetMACDAsync); + yield return AIFunctionFactory.Create(GetBOLLAsync); + yield return AIFunctionFactory.Create(GetMAAsync); + yield return AIFunctionFactory.Create(GetKLinesAsync); + } + + private static List ToIndicatorQuotes(IEnumerable klineData) + { + return klineData + .OrderBy(item => item.Timestamp) + .Select(item => new IndicatorQuote + { + Date = item.Timestamp, + Open = item.Open, + High = item.High, + Low = item.Low, + Close = item.Close, + Volume = item.Volume + }) + .ToList(); + } + + private static decimal? GetLatestSma(List quotes, int period) + { + return Round(quotes.GetSma(period).LastOrDefault(item => item.Sma is not null)?.Sma); + } + + private static decimal? Round(double? value) + { + return value.HasValue ? Math.Round((decimal)value.Value, 2) : null; + } + + [Description("获取K线历史序列(OHLCV),interval支持5m/15m/daily/weekly,用于判断趋势方向及多周期确认")] + public async Task> GetKLinesAsync( + [Description("虚拟币代码(如BTC、ETH)")] string assetSymbol, + [Description("K线周期:5m/15m/daily/weekly")] string interval = "daily", + [Description("返回根数,最大500")] int count = 30, + CancellationToken cancellationToken = default) + { + try + { + var klineType = interval.ToLowerInvariant() switch + { + "5m" => KLineType.Minute5, + "15m" => KLineType.Minute15, + "weekly" => KLineType.Weekly, + _ => KLineType.Daily + }; + + var klineService = _serviceProvider.GetRequiredKeyedService(MarketType.Crypto); + var klineData = await klineService.GetKLineDataAsync(assetSymbol, klineType, Math.Clamp(count, 1, 500)); + + if (klineData == null || klineData.Count == 0) + throw new FriendlyException($"K线数据为空: {assetSymbol}"); + + var timeFormat = klineType is KLineType.Minute5 or KLineType.Minute15 + ? "yyyy-MM-dd HH:mm" : "yyyy-MM-dd"; + + return klineData + .OrderBy(k => k.Timestamp) + .Select(k => new OhlcvBar + { + T = k.Timestamp.ToString(timeFormat), + O = k.Open, + H = k.High, + L = k.Low, + C = k.Close, + V = k.Volume + }) + .ToList(); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取K线序列失败: {Symbol} {Interval}", assetSymbol, interval); + throw new FriendlyException($"获取K线序列失败: {ex.Message}", ex); + } + } + + private sealed class IndicatorQuote : IQuote + { + public DateTime Date { get; init; } + public decimal Open { get; init; } + public decimal High { get; init; } + public decimal Low { get; init; } + public decimal Close { get; init; } + public decimal Volume { get; init; } + } +} diff --git a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoTradingExecutionTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoTradingExecutionTools.cs new file mode 100644 index 0000000..c596c4a --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoTradingExecutionTools.cs @@ -0,0 +1,126 @@ +using System.ComponentModel; +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Services.Data; +using MarketAssistant.Trading; +using MarketAssistant.Trading.Abstractions; +using MarketAssistant.Trading.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Agents.Tools.Crypto; + +/// +/// 虚拟币交易执行工具实现,供 TradingAgent 使用。下单委托给 TradeExecutor 统一入口。 +/// +public class CryptoTradingExecutionTools : ITradingExecutionTools +{ + private readonly CryptoPortfolioService _portfolioService; + private readonly IExchangeClient _exchangeClient; + private readonly BinanceMarketDataService _marketDataService; + private readonly TradeExecutor _tradeExecutor; + private readonly ILogger _logger; + + public CryptoTradingExecutionTools( + CryptoPortfolioService portfolioService, + IExchangeClient exchangeClient, + BinanceMarketDataService marketDataService, + TradeExecutor tradeExecutor, + ILogger logger) + { + _portfolioService = portfolioService; + _exchangeClient = exchangeClient; + _marketDataService = marketDataService; + _tradeExecutor = tradeExecutor; + _logger = logger; + } + + [Description("查询Binance账户余额,返回总资产价值(USDT)和各币种余额明细")] + public async Task GetAccountBalanceAsync(CancellationToken cancellationToken = default) + { + return await _portfolioService.GetAccountBalanceSummaryAsync(); + } + + [Description("查询当前持仓列表,显示每个币种的数量、入场均价、当前价和未实现盈亏")] + public async Task> GetCurrentPositionsAsync(CancellationToken cancellationToken = default) + { + return await _portfolioService.GetCurrentPositionsAsync(); + } + + [Description("下单交易。所有订单会先经过风控检查。symbol格式如BTCUSDT,side为Buy或Sell,type为Market或Limit")] + public async Task PlaceOrderAsync( + [Description("交易对,如BTCUSDT")] string symbol, + [Description("买卖方向")] OrderSide side, + [Description("订单类型")] OrderType type, + [Description("交易数量")] decimal quantity, + [Description("限价单价格,市价单可不填")] decimal? price = null, + CancellationToken cancellationToken = default) + { + var effectivePrice = price ?? 0; + if (type == OrderType.Market && effectivePrice == 0) + { + try + { + var ticker = await _marketDataService.Get24hrTickerAsync(symbol); + if (ticker != null) + effectivePrice = ticker.LastPrice; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "无法获取 {Symbol} 当前价格", symbol); + } + } + + if (effectivePrice <= 0) + return new TradeResult { Success = false, ErrorMessage = $"无法确定 {symbol} 的有效价格,拒绝下单" }; + + var strategyId = TradingContext.CurrentStrategyId ?? "manual"; + return await _tradeExecutor.ExecuteOrderAsync( + symbol, side, type, quantity, effectivePrice, + type == OrderType.Limit ? price : null, + strategyId: strategyId); + } + + [Description("查询指定订单的状态")] + public async Task GetOrderStatusAsync( + [Description("交易对")] string symbol, + [Description("Binance订单ID")] long orderId, + CancellationToken cancellationToken = default) + { + var order = await _exchangeClient.GetOrderAsync(symbol, orderId.ToString()); + return new OrderStatusInfo + { + OrderId = long.TryParse(order.OrderId, out var parsedOrderId) ? parsedOrderId : 0, + Symbol = order.Symbol, + Status = order.Status, + ExecutedQty = order.ExecutedQty, + ExecutedPrice = order.Price + }; + } + + [Description("取消指定订单")] + public async Task CancelOrderAsync( + [Description("交易对")] string symbol, + [Description("Binance订单ID")] long orderId, + CancellationToken cancellationToken = default) + { + try + { + await _exchangeClient.CancelOrderAsync(symbol, orderId.ToString()); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "取消订单失败: {Symbol} {OrderId}", symbol, orderId); + return false; + } + } + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(GetAccountBalanceAsync); + yield return AIFunctionFactory.Create(GetCurrentPositionsAsync); + yield return AIFunctionFactory.Create(PlaceOrderAsync); + yield return AIFunctionFactory.Create(GetOrderStatusAsync); + yield return AIFunctionFactory.Create(CancelOrderAsync); + } +} diff --git a/src/Agents/Tools/GroundingSearchTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/GroundingSearchTools.cs similarity index 98% rename from src/Agents/Tools/GroundingSearchTools.cs rename to src/MarketAssistant.App.Services/Agents/Tools/GroundingSearchTools.cs index 91c0ffa..049e410 100644 --- a/src/Agents/Tools/GroundingSearchTools.cs +++ b/src/MarketAssistant.App.Services/Agents/Tools/GroundingSearchTools.cs @@ -1,3 +1,4 @@ +using MarketAssistant.Agents.Tools.Abstractions; using MarketAssistant.Applications.Settings; using MarketAssistant.Infrastructure.Factories; using MarketAssistant.Rag.Interfaces; @@ -16,7 +17,7 @@ namespace MarketAssistant.Agents.Tools; /// - 混合搜索:当用户同时启用知识库和网络搜索时 /// - 空结果:当用户都未启用时,返回空结果 /// -public class GroundingSearchTools +public class GroundingSearchTools : IToolsProvider { private readonly IRetrievalOrchestrator _orchestrator; private readonly IWebTextSearchFactory _webTextSearchFactory; diff --git a/src/MarketAssistant.App.Services/Agents/Tools/KnowledgeGraphTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/KnowledgeGraphTools.cs new file mode 100644 index 0000000..ed210b4 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Tools/KnowledgeGraphTools.cs @@ -0,0 +1,107 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.ComponentModel; +using System.Text; + +namespace MarketAssistant.Agents.Tools; + +/// +/// 知识图谱工具,允许 Agent 记录和查询实体间关系。 +/// 适用于记录:用户持有/关注的标的、分析过的股票/币种、行业关联、重大事件影响。 +/// +public class KnowledgeGraphTools : IToolsProvider +{ + private readonly UserKnowledgeGraphService _kgService; + private readonly ILogger _logger; + + public KnowledgeGraphTools(UserKnowledgeGraphService kgService, ILogger logger) + { + _kgService = kgService; + _logger = logger; + } + + [Description("记录一条实体关系。适用场景:用户持有/关注某标的、某事件影响某行业、分析过某标的等。" + + "predicate 常用值: 关注/持有/分析过/属于行业/影响/相关联。")] + public async Task AddRelationAsync( + [Description("主体实体,如 '用户'、'贵州茅台'、'降息'")] string subject, + [Description("关系类型,如 '关注'、'持有'、'分析过'、'属于行业'、'影响'")] string predicate, + [Description("客体实体,如 '贵州茅台'、'白酒行业'、'银行板块'")] string obj, + [Description("关系生效日期,格式 yyyy-MM-dd,默认今天")] string? validFrom = null, + [Description("可选的附加说明")] string? metadata = null) + { + if (string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(predicate) || string.IsNullOrWhiteSpace(obj)) + return "subject、predicate、object 不能为空。"; + + await _kgService.AddTripleAsync(subject, predicate, obj, validFrom, metadata); + _logger.LogInformation("Agent 添加知识图谱: {S} --[{P}]--> {O}", subject, predicate, obj); + return $"已记录关系: {subject} --[{predicate}]--> {obj}"; + } + + [Description("查询某个实体的所有当前有效关系。用于了解用户关注的标的、某标的的关联信息等。")] + public async Task QueryEntityAsync( + [Description("要查询的实体名称")] string entity, + [Description("查询截止日期(yyyy-MM-dd),默认今天")] string? asOf = null) + { + if (string.IsNullOrWhiteSpace(entity)) + return "请提供实体名称。"; + + var triples = await _kgService.QueryEntityAsync(entity, asOf); + if (triples.Count == 0) + return $"未找到与 \"{entity}\" 相关的关系记录。"; + + var sb = new StringBuilder(); + sb.AppendLine($"## {entity} 的关系网络 ({triples.Count} 条)"); + foreach (var t in triples) + { + var status = t.IsActive ? "" : " [已过期]"; + var meta = string.IsNullOrEmpty(t.Metadata) ? "" : $" ({t.Metadata})"; + sb.AppendLine($"- {t.Subject} --[{t.Predicate}]--> {t.Object} (自 {t.ValidFrom}){status}{meta}"); + } + return sb.ToString(); + } + + [Description("使一条关系过期。当用户不再持有某标的、某关系不再成立时调用。")] + public async Task InvalidateRelationAsync( + [Description("主体")] string subject, + [Description("关系类型")] string predicate, + [Description("客体")] string obj, + [Description("过期日期(yyyy-MM-dd),默认今天")] string? ended = null) + { + if (string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(predicate) || string.IsNullOrWhiteSpace(obj)) + return "参数不能为空。"; + + await _kgService.InvalidateAsync(subject, predicate, obj, ended); + return $"已标记过期: {subject} --[{predicate}]--> {obj}"; + } + + [Description("获取某实体的完整时间线,包含历史和当前关系。用于追溯投资历史。")] + public async Task GetTimelineAsync( + [Description("要查询时间线的实体名称")] string entity) + { + if (string.IsNullOrWhiteSpace(entity)) + return "请提供实体名称。"; + + var triples = await _kgService.TimelineAsync(entity); + if (triples.Count == 0) + return $"未找到 \"{entity}\" 的历史记录。"; + + var sb = new StringBuilder(); + sb.AppendLine($"## {entity} 时间线 ({triples.Count} 条记录)"); + foreach (var t in triples) + { + var period = t.ValidTo != null ? $"{t.ValidFrom} ~ {t.ValidTo}" : $"{t.ValidFrom} ~ 至今"; + sb.AppendLine($"- [{period}] {t.Subject} --[{t.Predicate}]--> {t.Object}"); + } + return sb.ToString(); + } + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(AddRelationAsync); + yield return AIFunctionFactory.Create(QueryEntityAsync); + yield return AIFunctionFactory.Create(InvalidateRelationAsync); + yield return AIFunctionFactory.Create(GetTimelineAsync); + } +} diff --git a/src/MarketAssistant.App.Services/Agents/Tools/MemoryManagementTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/MemoryManagementTools.cs new file mode 100644 index 0000000..7a06cc0 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Tools/MemoryManagementTools.cs @@ -0,0 +1,127 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.ComponentModel; +using System.Text; + +namespace MarketAssistant.Agents.Tools; + +/// +/// 用户长期记忆管理工具,暴露给 Agent 用于主动保存、查询和删除记忆。 +/// Agent 应在学习到用户偏好、被纠正、发现重要结论时主动调用。 +/// +public class MemoryManagementTools : IToolsProvider +{ + private readonly UserMemoryService _memoryService; + private readonly ILogger _logger; + + public MemoryManagementTools(UserMemoryService memoryService, ILogger logger) + { + _memoryService = memoryService; + _logger = logger; + } + + [Description("保存一条用户记忆。当你学习到用户的投资偏好、风格习惯、被纠正的认知、或重要分析结论时,主动调用此工具。" + + "category 分类包括: preference(偏好)、correction(纠正)、conclusion(结论)、profile(用户画像)、other(其它)。" + + "key 是唯一标识,如 '风险偏好' 或 '贵州茅台观点'。value 是记忆内容,尽量简洁。")] + public async Task SaveMemoryAsync( + [Description("记忆分类: preference/correction/conclusion/profile/other")] string category, + [Description("记忆键名,简短唯一标识")] string key, + [Description("记忆内容,保持简洁信息密集")] string value) + { + if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value)) + return "参数不能为空。"; + + var (success, error) = await _memoryService.SaveMemoryAsync(category, key, value); + if (!success) + { + _logger.LogWarning("保存记忆失败: {Error}", error); + return $"保存失败: {error}"; + } + + _logger.LogInformation("Agent 保存记忆 [{Category}] {Key}", category, key); + return $"已保存记忆: [{category}] {key}"; + } + + [Description("删除一条过时或错误的用户记忆。当用户偏好变更、之前的结论不再适用时调用。")] + public async Task DeleteMemoryAsync( + [Description("记忆分类")] string category, + [Description("记忆键名")] string key) + { + if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(key)) + return "参数不能为空。"; + + await _memoryService.DeleteMemoryAsync(category, key); + _logger.LogInformation("Agent 删除记忆 [{Category}] {Key}", category, key); + return $"已删除记忆: [{category}] {key}"; + } + + [Description("查询当前保存的用户记忆。可指定分类查询,或不传分类查询全部。用于确认已有记忆、避免重复保存。")] + public async Task GetMemoriesAsync( + [Description("可选的记忆分类筛选,为空则返回全部")] string? category = null) + { + var sb = new StringBuilder(); + + if (!string.IsNullOrWhiteSpace(category)) + { + var memories = await _memoryService.GetMemoriesAsync(category); + if (memories.Count == 0) + return $"分类 [{category}] 下没有记忆条目。"; + + sb.AppendLine($"## [{category}] 分类记忆 ({memories.Count} 条)"); + foreach (var (k, v) in memories) + sb.AppendLine($"- **{k}**: {v}"); + } + else + { + var allMemories = await _memoryService.GetAllMemoriesAsync(); + if (allMemories.Count == 0) + return "当前没有任何记忆条目。"; + + var usage = await _memoryService.GetUsageAsync(); + sb.AppendLine($"## 用户记忆 ({usage.EntryCount}/{usage.MaxEntryCount} 条, {usage.TotalChars}/{usage.MaxTotalChars} 字符)"); + + string? currentCategory = null; + foreach (var (cat, k, v) in allMemories) + { + if (currentCategory != cat) + { + currentCategory = cat; + sb.AppendLine($"### {cat}"); + } + sb.AppendLine($"- **{k}**: {v}"); + } + } + + return sb.ToString(); + } + + [Description("设置一条记忆的优先级。高优先级(>=1)的记忆会始终加载到上下文中(L1层),确保你始终记住最重要的信息。")] + public async Task SetMemoryPriorityAsync( + [Description("记忆分类")] string category, + [Description("记忆键名")] string key, + [Description("优先级: 0=普通, 1=高优先级(始终加载)")] int priority) + { + if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(key)) + return "参数不能为空。"; + + if (priority < 0) priority = 0; + + var exists = await _memoryService.GetMemoriesAsync(category); + if (!exists.ContainsKey(key)) + return $"记忆条目 [{category}] {key} 不存在。"; + + await _memoryService.SetPriorityAsync(category, key, priority); + _logger.LogInformation("Agent 设置记忆优先级 [{Category}] {Key} → {Priority}", category, key, priority); + return $"已设置 [{category}] {key} 优先级为 {priority}" + (priority >= 1 ? "(始终加载)" : "(普通)"); + } + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(SaveMemoryAsync); + yield return AIFunctionFactory.Create(DeleteMemoryAsync); + yield return AIFunctionFactory.Create(GetMemoriesAsync); + yield return AIFunctionFactory.Create(SetMemoryPriorityAsync); + } +} diff --git a/src/MarketAssistant.App.Services/Agents/Tools/SessionSearchTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/SessionSearchTools.cs new file mode 100644 index 0000000..395efa6 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Tools/SessionSearchTools.cs @@ -0,0 +1,70 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.ComponentModel; +using System.Text; + +namespace MarketAssistant.Agents.Tools; + +/// +/// 历史会话搜索工具,允许 Agent 跨会话搜索过去讨论过的内容。 +/// 基于 SQLite FTS5 全文索引实现。 +/// +public class SessionSearchTools : IToolsProvider +{ + private readonly ChatSessionPersistenceService _persistenceService; + private readonly ILogger _logger; + + public SessionSearchTools(ChatSessionPersistenceService persistenceService, ILogger logger) + { + _persistenceService = persistenceService; + _logger = logger; + } + + [Description("搜索历史对话记录。当用户提到过去讨论过的内容、之前的分析、或需要回忆跨会话信息时调用。" + + "返回匹配的对话片段、时间和标的代码。")] + public async Task SearchPastSessionsAsync( + [Description("搜索关键词或短语")] string query, + [Description("返回结果数量上限,默认5")] int limit = 5) + { + if (string.IsNullOrWhiteSpace(query)) + return "请提供搜索关键词。"; + + if (limit <= 0) limit = 5; + if (limit > 15) limit = 15; + + var results = await _persistenceService.SearchSessionsAsync(query, limit); + + if (results.Count == 0) + return $"未找到与 \"{query}\" 相关的历史对话。"; + + var sb = new StringBuilder(); + sb.AppendLine($"## 历史对话搜索结果 ({results.Count} 条匹配)"); + sb.AppendLine(); + + string? currentSession = null; + foreach (var r in results) + { + if (currentSession != r.SessionId) + { + currentSession = r.SessionId; + var stockInfo = string.IsNullOrEmpty(r.StockCode) ? "" : $" | 标的: {r.StockCode}"; + sb.AppendLine($"### {r.SessionTitle}{stockInfo} ({r.UpdatedAt:yyyy-MM-dd})"); + } + + var role = r.Role == "user" ? "用户" : (r.AuthorName ?? "助手"); + var content = r.Content.Length > 300 ? r.Content[..300] + "..." : r.Content; + sb.AppendLine($"**{role}**: {content}"); + sb.AppendLine(); + } + + _logger.LogInformation("历史会话搜索: {Query} → {Count} 条结果", query, results.Count); + return sb.ToString(); + } + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(SearchPastSessionsAsync); + } +} diff --git a/src/MarketAssistant.App.Services/Agents/Trading/TradingAgent.cs b/src/MarketAssistant.App.Services/Agents/Trading/TradingAgent.cs new file mode 100644 index 0000000..e1791fc --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Trading/TradingAgent.cs @@ -0,0 +1,68 @@ +using MarketAssistant.Agents.Analysts.Attributes; +using MarketAssistant.Agents.Tools.Abstractions; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace MarketAssistant.Agents.Trading; + +/// +/// 虚拟币自主交易 Agent,持有交易专用工具集,接收 Monitor 信号后自主分析并决策 +/// +[RequiresTools(typeof(ITradingExecutionTools))] +[RequiresTools(typeof(IStrategyTools))] +[RequiresTools(typeof(IBasicDataTools))] +[RequiresTools(typeof(ITechnicalDataTools))] +public class TradingAgent : DelegatingAIAgent +{ + private const string AgentName = "TradingAgent"; + private const string AgentDescription = "虚拟币自主交易助手,能分析市场数据并执行交易决策"; + + public TradingAgent( + IChatClient chatClient, + IList tools) + : base(CreateInnerAgent(chatClient, tools)) + { + } + + private static AIAgent CreateInnerAgent(IChatClient chatClient, IList tools) + { + var options = new ChatClientAgentOptions + { + Name = AgentName, + Description = AgentDescription, + ChatOptions = new ChatOptions + { + Instructions = BuildSystemPrompt(), + Temperature = 0.1f, + TopP = 0.1f, + Tools = tools + } + }; + + return new ChatClientAgent(chatClient, options); + } + + private static string BuildSystemPrompt() => """ + 你是一个专业的虚拟币自主交易助手。 + + ## 能力 + - 查询账户余额和持仓(GetAccountBalance / GetCurrentPositions) + - 分析市场数据:价格、K线、技术指标(GetAssetInfo / GetKLineData / CalculateMACD 等) + - 查询和管理交易策略(GetActiveStrategies / UpdateStrategyStatus) + - 根据策略规则和风控约束决定是否交易 + - 执行买卖操作(PlaceOrder)并记录决策推理 + + ## 决策流程 + 1. 收到分析请求时,先获取当前市场数据和技术指标 + 2. 结合策略规则评估是否满足交易条件 + 3. 检查账户余额和风控限制 + 4. 决定是否执行交易,并记录完整的推理过程 + + ## 约束 + - 所有交易必须经过风控检查(PlaceOrder 内部自动调用) + - 必须记录每次决策的推理过程 + - 遇到不确定情况,倾向于不交易(宁可错过,不可做错) + - 不要在没有充分分析的情况下执行交易 + - 每次只关注当前请求的交易标的 + """; +} diff --git a/src/MarketAssistant.App.Services/Applications/Analysis/AnalysisOrchestrationService.cs b/src/MarketAssistant.App.Services/Applications/Analysis/AnalysisOrchestrationService.cs new file mode 100644 index 0000000..c01d601 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Analysis/AnalysisOrchestrationService.cs @@ -0,0 +1,132 @@ +using MarketAssistant.Agents.MarketAnalysis; +using MarketAssistant.Agents.MarketAnalysis.Models; +using MarketAssistant.Services.Archive; +using MarketAssistant.Services.Cache; +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; + +namespace MarketAssistant.Applications.Analysis; + +/// +/// 分析编排服务,封装工作流执行、缓存管理和报告归档逻辑, +/// 将 ViewModel 与工作流实现解耦 +/// +public class AnalysisOrchestrationService : IDisposable +{ + private readonly MarketAnalysisWorkflow _workflow; + private readonly IAnalysisCacheService _cacheService; + private readonly ReportArchiveService _archiveService; + private readonly ILogger _logger; + + /// + /// 按标的代码加锁,防止缓存击穿:同一标的并发请求只执行一次工作流,其余等待缓存 + /// + private readonly ConcurrentDictionary _perAssetLocks = new(); + + public AnalysisOrchestrationService( + MarketAnalysisWorkflow workflow, + IAnalysisCacheService cacheService, + ReportArchiveService archiveService, + ILogger logger) + { + _workflow = workflow; + _cacheService = cacheService; + _archiveService = archiveService; + _logger = logger; + } + + /// + /// 工作流进度事件(透传给 UI 层) + /// + public event EventHandler? ProgressChanged + { + add => _workflow.ProgressChanged += value; + remove => _workflow.ProgressChanged -= value; + } + + /// + /// 执行分析:优先读缓存,缓存未命中则执行工作流。 + /// 使用按标的加锁防止缓存击穿,同一标的并发请求只执行一次工作流。 + /// + public async Task AnalyzeAsync( + string assetCode, CancellationToken cancellationToken = default) + { + var cached = await _cacheService.GetCachedAnalysisAsync(assetCode); + if (cached != null) + { + _logger.LogInformation("从缓存加载分析结果: {AssetCode}", assetCode); + return new AnalysisResult(cached, FromCache: true); + } + + // 按标的加锁:同一标的的并发请求串行化,第一个执行工作流,后续命中缓存 + var semaphore = _perAssetLocks.GetOrAdd(assetCode, _ => new SemaphoreSlim(1, 1)); + await semaphore.WaitAsync(cancellationToken); + try + { + // 二次检查缓存(可能在等待锁期间已被其他请求填充) + cached = await _cacheService.GetCachedAnalysisAsync(assetCode); + if (cached != null) + { + _logger.LogInformation("从缓存加载分析结果(二次检查): {AssetCode}", assetCode); + return new AnalysisResult(cached, FromCache: true); + } + + _logger.LogInformation("开始新的分析: {AssetCode}", assetCode); + var report = await _workflow.AnalyzeAsync(assetCode, cancellationToken); + + // 先归档再缓存:归档失败时不缓存,避免"缓存命中但历史缺失"的幽灵报告 + // 归档失败会抛异常,此时不缓存,让用户下次重试 + try + { + await _archiveService.SaveAsync(report, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "归档失败,跳过缓存以避免幽灵报告: {AssetCode}", assetCode); + throw; + } + + await _cacheService.CacheAnalysisAsync(assetCode, report); + + return new AnalysisResult(report, FromCache: false); + } + finally + { + semaphore.Release(); + } + } + + /// + /// 加载历史报告 + /// + public async Task LoadHistoryReportAsync( + long reportId, CancellationToken cancellationToken = default) + { + return await _archiveService.LoadAsync(reportId, cancellationToken); + } + + /// + /// 获取历史报告摘要 + /// + public async Task> GetReportHistoryAsync( + string assetCode, CancellationToken cancellationToken = default) + { + return await _archiveService.GetSummariesAsync(assetCode, 20, cancellationToken); + } + + /// + /// 释放所有按标的加锁的 SemaphoreSlim 资源,避免长期运行后内存泄漏。 + /// + public void Dispose() + { + foreach (var kvp in _perAssetLocks) + kvp.Value.Dispose(); + _perAssetLocks.Clear(); + GC.SuppressFinalize(this); + } +} + +/// +/// 分析结果封装 +/// +public record AnalysisResult(MarketAnalysisReport Report, bool FromCache); diff --git a/src/MarketAssistant.App.Services/Applications/AssetScreener/CryptoScreenerService.cs b/src/MarketAssistant.App.Services/Applications/AssetScreener/CryptoScreenerService.cs new file mode 100644 index 0000000..2a97781 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/AssetScreener/CryptoScreenerService.cs @@ -0,0 +1,326 @@ +using MarketAssistant.Applications.AssetScreener.Models; +using MarketAssistant.Services.Data; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Applications.AssetScreener; + +/// +/// 虚拟币筛选服务实现(基于CoinGecko免费API + 币安API补充) +/// +public sealed class CryptoScreenerService : IAssetScreenerService +{ + private readonly ILogger _logger; + private readonly CoinGeckoApiService _coinGeckoService; + private readonly BinanceMarketDataService _binanceService; + + // CoinGecko API 限制 + private const int COINGECKO_MAX_PER_PAGE = 250; + private const int COINGECKO_DEFAULT_PAGE_SIZE = 100; + + public CryptoScreenerService( + ILogger logger, + CoinGeckoApiService coinGeckoService, + BinanceMarketDataService binanceService) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _coinGeckoService = coinGeckoService ?? throw new ArgumentNullException(nameof(coinGeckoService)); + _binanceService = binanceService ?? throw new ArgumentNullException(nameof(binanceService)); + } + + /// + /// 根据筛选条件筛选虚拟币 + /// 优先使用 CoinGecko(含市值/排名/供应量数据),失败时降级到 Binance(仅价格/成交量/涨跌幅) + /// + public async Task> ScreenAsync(object criteria) + { + if (criteria is not CryptoCriteria cryptoCriteria) + { + throw new ArgumentException("筛选条件类型错误,期望 CryptoCriteria", nameof(criteria)); + } + + _logger.LogInformation("开始虚拟币筛选,条件数量: {Count}, 限制: {Limit}", + cryptoCriteria.Criteria.Count, cryptoCriteria.Limit); + + try + { + // 1. 优先从 CoinGecko 获取数据(含市值、排名、供应量) + List results; + try + { + var markets = await FetchFromCoinGeckoAsync(cryptoCriteria); + var filtered = ApplyFilters(markets, cryptoCriteria); + var limited = filtered.Take(cryptoCriteria.Limit).ToList(); + results = ConvertToScreenerInfo(limited); + _logger.LogInformation("CoinGecko 筛选完成,结果数量: {Count}", results.Count); + } + catch (Exception ex) when (ex is not ArgumentException) + { + _logger.LogWarning(ex, "CoinGecko 数据源不可用,降级到 Binance 兜底"); + results = await FetchFromBinanceFallbackAsync(cryptoCriteria); + _logger.LogInformation("Binance 兜底筛选完成,结果数量: {Count}", results.Count); + } + + return results; + } + catch (Exception ex) + { + _logger.LogError(ex, "虚拟币筛选过程中发生错误"); + throw new FriendlyException("虚拟币筛选失败,请稍后重试", ex); + } + } + + /// + /// 从CoinGecko获取数据 + /// + private async Task> FetchFromCoinGeckoAsync(CryptoCriteria criteria) + { + // 确定排序方式 + var order = DetermineOrder(criteria); + + // 确定分页参数(用于市值排名筛选) + var (page, perPage) = DeterminePagination(criteria); + + // 确定类别 + var category = GetCategoryFilter(criteria); + + // 确定需要的价格变化时间段 + var priceChangePercentage = HasPriceChangeFilter(criteria) ? "7d,30d" : null; + + _logger.LogDebug("CoinGecko查询参数 - Order: {Order}, Page: {Page}, PerPage: {PerPage}, Category: {Category}", + order, page, perPage, category); + + // 调用CoinGecko API + var markets = await _coinGeckoService.GetCoinsMarketsAsync( + vsCurrency: "usd", + category: category, + order: order, + perPage: perPage, + page: page, + priceChangePercentage: priceChangePercentage); + + return markets; + } + + /// + /// 应用本地筛选条件 + /// + private List ApplyFilters(List markets, CryptoCriteria criteria) + { + var filtered = markets.AsEnumerable(); + + foreach (var condition in criteria.Criteria) + { + filtered = condition.Code.ToLowerInvariant() switch + { + "market_cap" => filtered.Where(m => + (!condition.MinValue.HasValue || m.Market_Cap >= condition.MinValue) && + (!condition.MaxValue.HasValue || m.Market_Cap <= condition.MaxValue)), + + "market_cap_rank" => filtered.Where(m => + m.Market_Cap_Rank.HasValue && + (!condition.MinValue.HasValue || m.Market_Cap_Rank >= (int)condition.MinValue) && + (!condition.MaxValue.HasValue || m.Market_Cap_Rank <= (int)condition.MaxValue)), + + "volume_24h" or "total_volume" => filtered.Where(m => + (!condition.MinValue.HasValue || m.Total_Volume >= condition.MinValue) && + (!condition.MaxValue.HasValue || m.Total_Volume <= condition.MaxValue)), + + "price_change_24h" => filtered.Where(m => + (!condition.MinValue.HasValue || m.Price_Change_Percentage_24h >= condition.MinValue) && + (!condition.MaxValue.HasValue || m.Price_Change_Percentage_24h <= condition.MaxValue)), + + "price_change_7d" => filtered.Where(m => + (!condition.MinValue.HasValue || m.Price_Change_Percentage_7d_In_Currency >= condition.MinValue) && + (!condition.MaxValue.HasValue || m.Price_Change_Percentage_7d_In_Currency <= condition.MaxValue)), + + "price_change_30d" => filtered.Where(m => + (!condition.MinValue.HasValue || m.Price_Change_Percentage_30d_In_Currency >= condition.MinValue) && + (!condition.MaxValue.HasValue || m.Price_Change_Percentage_30d_In_Currency <= condition.MaxValue)), + + "current_price" or "price" => filtered.Where(m => + (!condition.MinValue.HasValue || m.Current_Price >= condition.MinValue) && + (!condition.MaxValue.HasValue || m.Current_Price <= condition.MaxValue)), + + _ => filtered + }; + } + + return filtered.ToList(); + } + + private List ConvertToScreenerInfo(List markets) + { + return markets.Select(m => (ScreenerAssetInfo)new ScreenerCryptoInfo + { + Name = m.Name, + Symbol = m.Symbol.ToUpperInvariant(), + Current = m.Current_Price ?? 0, + Pct = m.Price_Change_Percentage_24h ?? 0, + Amount = m.Total_Volume ?? 0, + Mc = m.Market_Cap ?? 0, + Fmc = m.Fully_Diluted_Valuation ?? 0, + Volume = m.Total_Volume ?? 0, + MarketCapRank = m.Market_Cap_Rank ?? 0, + PriceChange7d = m.Price_Change_Percentage_7d_In_Currency ?? 0, + PriceChange30d = m.Price_Change_Percentage_30d_In_Currency ?? 0, + CirculatingSupply = m.Circulating_Supply ?? 0, + TotalSupply = m.Total_Supply ?? 0, + MaxSupply = m.Max_Supply + }).ToList(); + } + + /// + /// Binance 兜底筛选:当 CoinGecko 不可用时,从币安获取 USDT 交易对行情 + /// 注意:Binance 不提供市值/排名/供应量数据,相关筛选条件将被忽略 + /// + private async Task> FetchFromBinanceFallbackAsync(CryptoCriteria criteria) + { + var tickers = await _binanceService.GetAll24hrTickersFullAsync(); + + // 仅保留指定计价货币的交易对(默认 USDT),排除杠杆/稳定币交易对 + var quoteCurrency = string.IsNullOrWhiteSpace(criteria.QuoteCurrency) ? "USDT" : criteria.QuoteCurrency; + var filtered = tickers.Where(t => + t.Symbol.EndsWith(quoteCurrency, StringComparison.OrdinalIgnoreCase) && + !IsStableCoinPair(t.Symbol)).ToList(); + + // 应用 Binance 支持的筛选条件(价格变化、成交量、当前价) + filtered = ApplyBinanceFilters(filtered, criteria); + + // 按成交额降序排序并限制数量 + var limited = filtered + .OrderByDescending(t => t.QuoteVolume) + .Take(criteria.Limit) + .ToList(); + + return ConvertBinanceToScreenerInfo(limited); + } + + /// + /// 应用 Binance 数据支持的筛选条件(仅价格变化、成交量、当前价) + /// + private List ApplyBinanceFilters(List tickers, CryptoCriteria criteria) + { + var filtered = tickers.AsEnumerable(); + + foreach (var condition in criteria.Criteria) + { + filtered = condition.Code.ToLowerInvariant() switch + { + "volume_24h" or "total_volume" => filtered.Where(t => + (!condition.MinValue.HasValue || t.QuoteVolume >= condition.MinValue) && + (!condition.MaxValue.HasValue || t.QuoteVolume <= condition.MaxValue)), + + "price_change_24h" => filtered.Where(t => + (!condition.MinValue.HasValue || t.PriceChangePercent >= condition.MinValue) && + (!condition.MaxValue.HasValue || t.PriceChangePercent <= condition.MaxValue)), + + "current_price" or "price" => filtered.Where(t => + (!condition.MinValue.HasValue || t.LastPrice >= condition.MinValue) && + (!condition.MaxValue.HasValue || t.LastPrice <= condition.MaxValue)), + + // market_cap、market_cap_rank、price_change_7d/30d 等 Binance 不支持,忽略 + _ => filtered + }; + } + + return filtered.ToList(); + } + + /// + /// 判断是否为稳定币交易对(排除 USDCUSDT、DAIUSDT 等) + /// + private static bool IsStableCoinPair(string symbol) + { + var stableCoins = new[] { "USDC", "DAI", "TUSD", "BUSD", "FDUSD", "USDP" }; + return stableCoins.Any(sc => symbol.StartsWith(sc + "USDT", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// 将 Binance Ticker 转换为 ScreenerCryptoInfo + /// + private List ConvertBinanceToScreenerInfo(List tickers) + { + return tickers.Select(t => + { + // BTCUSDT → BTC + var baseAsset = t.Symbol.EndsWith("USDT", StringComparison.OrdinalIgnoreCase) + ? t.Symbol[..^4] + : t.Symbol; + + return (ScreenerAssetInfo)new ScreenerCryptoInfo + { + Name = baseAsset, + Symbol = baseAsset, + Current = t.LastPrice, + Pct = t.PriceChangePercent ?? 0, + Amount = t.QuoteVolume, + Mc = 0, // Binance 不提供市值 + Fmc = 0, + Volume = t.QuoteVolume, + MarketCapRank = 0, // Binance 不提供排名 + PriceChange7d = 0, // Binance 不提供 7d 数据 + PriceChange30d = 0, + CirculatingSupply = 0, + TotalSupply = 0 + }; + }).ToList(); + } + + #region 辅助方法 + + private string DetermineOrder(CryptoCriteria criteria) + { + // 检查是否有交易量筛选 -> 按交易量排序 + if (criteria.Criteria.Any(c => c.Code.Contains("volume", StringComparison.OrdinalIgnoreCase))) + { + return "volume_desc"; + } + + // 检查是否有价格变化筛选 -> 按市值排序(更稳定) + if (criteria.Criteria.Any(c => c.Code.Contains("price_change", StringComparison.OrdinalIgnoreCase))) + { + return "market_cap_desc"; + } + + // 默认按市值排序 + return "market_cap_desc"; + } + + private (int page, int perPage) DeterminePagination(CryptoCriteria criteria) + { + var rankFilter = criteria.Criteria.FirstOrDefault(c => + c.Code.Equals("market_cap_rank", StringComparison.OrdinalIgnoreCase)); + + if (rankFilter != null) + { + var minRank = (int)(rankFilter.MinValue ?? 1); + var maxRank = (int)(rankFilter.MaxValue ?? COINGECKO_DEFAULT_PAGE_SIZE); + + // 计算需要的页数 + var page = (minRank - 1) / COINGECKO_DEFAULT_PAGE_SIZE + 1; + var perPage = Math.Min(maxRank - minRank + 1, COINGECKO_MAX_PER_PAGE); + + return (page, perPage); + } + + return (1, Math.Min(criteria.Limit, COINGECKO_MAX_PER_PAGE)); + } + + private string? GetCategoryFilter(CryptoCriteria criteria) + { + var categoryFilter = criteria.Criteria.FirstOrDefault(c => + c.Code.Equals("category", StringComparison.OrdinalIgnoreCase)); + + return categoryFilter?.MinValue?.ToString(); // 使用MinValue存储类别名称 + } + + private bool HasPriceChangeFilter(CryptoCriteria criteria) + { + return criteria.Criteria.Any(c => + c.Code.Contains("price_change_7d", StringComparison.OrdinalIgnoreCase) || + c.Code.Contains("price_change_30d", StringComparison.OrdinalIgnoreCase)); + } + + #endregion +} + diff --git a/src/MarketAssistant.App.Services/Applications/AssetScreener/IAssetScreenerService.cs b/src/MarketAssistant.App.Services/Applications/AssetScreener/IAssetScreenerService.cs new file mode 100644 index 0000000..bc31990 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/AssetScreener/IAssetScreenerService.cs @@ -0,0 +1,17 @@ +using MarketAssistant.Applications.AssetScreener.Models; + +namespace MarketAssistant.Applications.AssetScreener; + +/// +/// 资产筛选服务接口,支持股票、虚拟币等多种资产类型的筛选 +/// +public interface IAssetScreenerService +{ + /// + /// 根据筛选条件筛选资产 + /// + /// 筛选条件对象(支持 StockCriteria、CryptoCriteria 等) + /// 筛选结果列表 + Task> ScreenAsync(object criteria); +} + diff --git a/src/Services/StockScreener/Models/MarketType.cs b/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/AShareType.cs similarity index 76% rename from src/Services/StockScreener/Models/MarketType.cs rename to src/MarketAssistant.App.Services/Applications/AssetScreener/Models/AShareType.cs index 61dc7e1..9d28524 100644 --- a/src/Services/StockScreener/Models/MarketType.cs +++ b/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/AShareType.cs @@ -2,13 +2,13 @@ using System.Runtime.Serialization; using System.Text.Json.Serialization; -namespace MarketAssistant.Services.StockScreener.Models; +namespace MarketAssistant.Applications.AssetScreener.Models; /// -/// 市场类型枚举 +/// A股市场细分类型枚举 /// -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum MarketType +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AShareType { /// /// 全部A股 @@ -31,3 +31,4 @@ public enum MarketType [EnumMember(Value = "深市A股")] ShenzhenAShares } + diff --git a/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/CryptoCriteria.cs b/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/CryptoCriteria.cs new file mode 100644 index 0000000..ee11b67 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/CryptoCriteria.cs @@ -0,0 +1,55 @@ +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Applications.AssetScreener.Models; + +/// +/// 虚拟币筛选条件 +/// +public class CryptoCriteria : IScreeningCriteria +{ + /// + /// 市场类型 + /// + public MarketType MarketType => MarketType.Crypto; + /// + /// 筛选条件列表 + /// + public List Criteria { get; set; } = new(); + + /// + /// 返回结果数量限制 + /// + public int Limit { get; set; } = 100; + + /// + /// 交易所(如 Binance、Coinbase 等) + /// + public string? Exchange { get; set; } + + /// + /// 交易对基准货币(如 USDT、BTC 等) + /// + public string? QuoteCurrency { get; set; } = "USDT"; +} + +/// +/// 虚拟币筛选条件项 +/// +public class CryptoScreeningCondition +{ + /// + /// 指标代码(如 market_cap、volume_24h、price_change_24h 等) + /// + public string Code { get; set; } = string.Empty; + + /// + /// 最小值 + /// + public decimal? MinValue { get; set; } + + /// + /// 最大值 + /// + public decimal? MaxValue { get; set; } +} + diff --git a/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/IScreeningCriteria.cs b/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/IScreeningCriteria.cs new file mode 100644 index 0000000..ab28b6f --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/IScreeningCriteria.cs @@ -0,0 +1,19 @@ +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Applications.AssetScreener.Models; + +/// +/// 资产筛选条件统一接口 +/// +public interface IScreeningCriteria +{ + /// + /// 返回结果数量限制 + /// + int Limit { get; set; } + + /// + /// 市场类型 + /// + MarketType MarketType { get; } +} diff --git a/src/Services/StockScreener/Models/IndustryType.cs b/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/IndustryType.cs similarity index 98% rename from src/Services/StockScreener/Models/IndustryType.cs rename to src/MarketAssistant.App.Services/Applications/AssetScreener/Models/IndustryType.cs index b522d9f..47bf509 100644 --- a/src/Services/StockScreener/Models/IndustryType.cs +++ b/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/IndustryType.cs @@ -2,7 +2,7 @@ using System.Runtime.Serialization; using System.Text.Json.Serialization; -namespace MarketAssistant.Services.StockScreener.Models; +namespace MarketAssistant.Applications.AssetScreener.Models; /// /// 行业分类枚举(精确匹配雪球网行业分类) @@ -211,3 +211,4 @@ public enum IndustryType [EnumMember(Value = "小家电")] SmallAppliances } + diff --git a/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/ScreenerAssetInfo.cs b/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/ScreenerAssetInfo.cs new file mode 100644 index 0000000..9a64f9f --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/ScreenerAssetInfo.cs @@ -0,0 +1,47 @@ +namespace MarketAssistant.Applications.AssetScreener.Models; + +/// +/// 资产筛选结果基类,包含所有市场共有的字段 +/// +public class ScreenerAssetInfo +{ + /// + /// 资产名称 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 资产代码 + /// + public string Symbol { get; set; } = string.Empty; + + /// + /// 当前价格 + /// + public decimal Current { get; set; } + + /// + /// 当日涨跌幅(%) + /// + public decimal Pct { get; set; } + + /// + /// 当日成交额 + /// + public decimal Amount { get; set; } + + /// + /// 总市值 + /// + public decimal Mc { get; set; } + + /// + /// 流通/完全稀释市值 + /// + public decimal Fmc { get; set; } + + /// + /// 成交量 + /// + public decimal Volume { get; set; } +} diff --git a/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/ScreenerCryptoInfo.cs b/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/ScreenerCryptoInfo.cs new file mode 100644 index 0000000..d594b6e --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/ScreenerCryptoInfo.cs @@ -0,0 +1,42 @@ +namespace MarketAssistant.Applications.AssetScreener.Models; + +/// +/// 虚拟币筛选结果(CoinGecko + Binance 数据源) +/// +public class ScreenerCryptoInfo : ScreenerAssetInfo +{ + /// + /// 市值排名 + /// + public int MarketCapRank { get; set; } + + /// + /// 7天涨跌幅(%) + /// + public decimal PriceChange7d { get; set; } + + /// + /// 30天涨跌幅(%) + /// + public decimal PriceChange30d { get; set; } + + /// + /// 24h振幅(%) + /// + public decimal ChgPct { get; set; } + + /// + /// 流通供应量 + /// + public decimal CirculatingSupply { get; set; } + + /// + /// 总供应量 + /// + public decimal TotalSupply { get; set; } + + /// + /// 最大供应量 + /// + public decimal? MaxSupply { get; set; } +} diff --git a/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/ScreenerStockInfo.cs b/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/ScreenerStockInfo.cs new file mode 100644 index 0000000..aa78e6e --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/ScreenerStockInfo.cs @@ -0,0 +1,14 @@ +namespace MarketAssistant.Applications.AssetScreener.Models; + +/// +/// A股筛选结果(雪球 HTTP API 数据源) +/// +public class ScreenerStockInfo : ScreenerAssetInfo +{ + /// + /// 所有数值字段(API 字段名 → 数值),包括 current/pct/mc 等。 + /// 字段名与雪球 screener API 返回的 JSON key 一致。 + /// API 只返回 order_by 和筛选条件涉及的字段,因此不同查询返回的字段不同。 + /// + public Dictionary Indicators { get; set; } = new(); +} diff --git a/src/Services/StockScreener/Models/StockCriteria.cs b/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/StockCriteria.cs similarity index 67% rename from src/Services/StockScreener/Models/StockCriteria.cs rename to src/MarketAssistant.App.Services/Applications/AssetScreener/Models/StockCriteria.cs index df25559..61b599f 100644 --- a/src/Services/StockScreener/Models/StockCriteria.cs +++ b/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/StockCriteria.cs @@ -1,13 +1,18 @@ using System.ComponentModel; +using MarketAssistant.Infrastructure.Core; -namespace MarketAssistant.Services.StockScreener.Models; +namespace MarketAssistant.Applications.AssetScreener.Models; /// /// 股票筛选参数 /// [Description("包含筛选条件、市场、行业和数量限制的股票筛选参数")] -public class StockCriteria +public class StockCriteria : IScreeningCriteria { + /// + /// 市场类型 + /// + public MarketType MarketType => MarketType.AShare; /// /// 筛选条件列表 /// @@ -15,10 +20,10 @@ public class StockCriteria public List Criteria { get; set; } = new(); /// - /// 市场类型 + /// A股市场细分类型 /// - [Description("市场类型")] - public MarketType Market { get; set; } = MarketType.AllAShares; + [Description("A股市场细分类型")] + public AShareType Market { get; set; } = AShareType.AllAShares; /// /// 行业分类 diff --git a/src/Services/StockScreener/Models/StockScreeningCriteria.cs b/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/StockScreeningCriteria.cs similarity index 94% rename from src/Services/StockScreener/Models/StockScreeningCriteria.cs rename to src/MarketAssistant.App.Services/Applications/AssetScreener/Models/StockScreeningCriteria.cs index d61432b..b4fc6d1 100644 --- a/src/Services/StockScreener/Models/StockScreeningCriteria.cs +++ b/src/MarketAssistant.App.Services/Applications/AssetScreener/Models/StockScreeningCriteria.cs @@ -1,6 +1,6 @@ using System.ComponentModel; -namespace MarketAssistant.Services.StockScreener.Models; +namespace MarketAssistant.Applications.AssetScreener.Models; /// /// 股票筛选条件 diff --git a/src/MarketAssistant.App.Services/Applications/AssetScreener/StockScreenerService.cs b/src/MarketAssistant.App.Services/Applications/AssetScreener/StockScreenerService.cs new file mode 100644 index 0000000..2a7dccf --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/AssetScreener/StockScreenerService.cs @@ -0,0 +1,334 @@ +using System.Globalization; +using System.Net; +using MarketAssistant.Applications.AssetScreener.Models; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Applications.AssetScreener; + +/// +/// 雪球网股票筛选服务(基于 HTTP API) +/// API 端点: GET https://xueqiu.com/service/screener/screen +/// +public sealed class StockScreenerService : IAssetScreenerService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly CookieContainer _cookieContainer; + private readonly ILogger _logger; + + private const string ScreenerEndpoint = "/service/screener/screen"; + + /// + /// 需要拼接报告期日期后缀的财务指标(格式:field.YYYYMMDD=min_max) + /// + private static readonly HashSet Adj1Fields = new(StringComparer.OrdinalIgnoreCase) + { + "roediluted", "eps", "bps", "npay", "netprofit", + "total_revenue", "oiy", "niota" + }; + + /// + /// 标识字段和元数据字段,动态解析时跳过(不放入 Indicators 字典)。 + /// 其余数值字段全部入字典,由 StockDataFormatter 统一做字段名映射和单位转换。 + /// + private static readonly HashSet s_handledFields = new(StringComparer.OrdinalIgnoreCase) + { + "name", "symbol", + "exchange", "type", "tick_size", "has_follow", "indcode", "areacode" + }; + + private static readonly Dictionary IndustryCodeMap = new() + { + { IndustryType.ComputerEquipment, "S7101" }, + { IndustryType.SoftwareDevelopment, "S7104" }, + { IndustryType.Semiconductor, "S2701" }, + { IndustryType.Battery, "S6307" }, + { IndustryType.PhotovoltaicEquipment, "S6305" }, + { IndustryType.WindPowerEquipment, "S6306" }, + { IndustryType.ChemicalPharmaceutical, "S3701" }, + { IndustryType.BiologicalProducts, "S3703" }, + { IndustryType.MedicalDevices, "S3705" }, + { IndustryType.Liquor, "S3405" }, + { IndustryType.BeveragesDairy, "S3407" }, + { IndustryType.FoodProcessing, "S3404" }, + { IndustryType.JointStockBank, "S4803" }, + { IndustryType.StateBanks, "S4802" }, + { IndustryType.RealEstateDevelopment, "S4301" }, + { IndustryType.PassengerVehicles, "S2805" }, + { IndustryType.AutoParts, "S2802" }, + { IndustryType.CommunicationEquipment, "S7302" }, + { IndustryType.CommunicationServices, "S7301" }, + { IndustryType.Power, "S4101" }, + { IndustryType.ChemicalMaterials, "S2202" }, + { IndustryType.ChemicalProducts, "S2203" }, + { IndustryType.ConstructionMachinery, "S6406" }, + { IndustryType.SpecializedEquipment, "S6402" }, + { IndustryType.WhiteAppliances, "S3301" }, + { IndustryType.SmallAppliances, "S3303" } + }; + + public StockScreenerService( + IHttpClientFactory httpClientFactory, + CookieContainer xueqiuCookieContainer, + ILogger logger) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _cookieContainer = xueqiuCookieContainer ?? throw new ArgumentNullException(nameof(xueqiuCookieContainer)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task> ScreenAsync(object criteria) + { + if (criteria is not StockCriteria stockCriteria) + { + throw new ArgumentException("筛选条件类型错误,期望 StockCriteria", nameof(criteria)); + } + + _logger.LogInformation("开始雪球选股,条件数量: {Count}, 限制: {Limit}", + stockCriteria.Criteria.Count, stockCriteria.Limit); + + try + { + await EnsureCookiesAsync(); + + var queryParams = BuildQueryParams(stockCriteria); + var stocks = await FetchFromXueqiuAsync(queryParams); + + _logger.LogInformation("雪球选股完成,结果数量: {Count}", stocks.Count); + return stocks.Cast().ToList(); + } + catch (Exception ex) when (ex is not ArgumentException) + { + _logger.LogError(ex, "雪球选股过程中发生错误"); + throw new FriendlyException($"筛选股票失败: {ex.Message}", ex); + } + } + + private async Task EnsureCookiesAsync() + { + var cookies = _cookieContainer.GetCookies(new Uri("https://xueqiu.com")); + if (cookies.Count > 0) + { + return; + } + + _logger.LogDebug("雪球 Cookie 为空,访问首页获取 Cookie"); + using var client = _httpClientFactory.CreateClient("Xueqiu"); + using var request = new HttpRequestMessage(HttpMethod.Get, "/"); + using var response = await client.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("访问雪球首页获取 Cookie 失败,HTTP 状态码: {StatusCode}", (int)response.StatusCode); + throw new FriendlyException($"无法连接雪球网(HTTP {(int)response.StatusCode}),请检查网络连接后重试"); + } + + var cookiesAfter = _cookieContainer.GetCookies(new Uri("https://xueqiu.com")); + if (cookiesAfter.Count == 0) + { + _logger.LogWarning("访问雪球首页成功但未获取到 Cookie,可能被反爬机制拦截"); + throw new FriendlyException("雪球网未返回有效的 Cookie,可能被反爬机制拦截,请稍后重试"); + } + } + + /// + /// 获取最新可用财报报告期日期(YYYYMMDD 格式)。 + /// 当前日期减 30 天,取最近一个已结束的季度末。 + /// + private static string GetLatestReportingPeriod() + { + var referenceDate = DateTime.Now.AddDays(-30); + var year = referenceDate.Year; + var month = referenceDate.Month; + + var quarterEndMonth = month switch + { + >= 10 => 9, + >= 7 => 6, + >= 4 => 3, + _ => 12 + }; + + if (quarterEndMonth == 12) + { + year--; + } + + var day = quarterEndMonth switch + { + 3 => "0331", + 6 => "0630", + 9 => "0930", + _ => "1231" + }; + + return $"{year}{day}"; + } + + private string BuildQueryParams(StockCriteria criteria) + { + var exchange = criteria.Market switch + { + AShareType.ShanghaiAShares => "sha", + AShareType.ShenzhenAShares => "sza", + _ => "sh_sz" + }; + + var indcode = ""; + if (criteria.Industry != IndustryType.All && + IndustryCodeMap.TryGetValue(criteria.Industry, out var code)) + { + indcode = code; + } + + var reportingPeriod = GetLatestReportingPeriod(); + + var filterParts = new List(); + foreach (var condition in criteria.Criteria) + { + var min = condition.MinValue?.ToString(CultureInfo.InvariantCulture) ?? ""; + var max = condition.MaxValue?.ToString(CultureInfo.InvariantCulture) ?? ""; + var filterValue = $"{min}_{max}"; + + var fieldKey = Adj1Fields.Contains(condition.Code) + ? $"{condition.Code}.{reportingPeriod}" + : condition.Code; + + filterParts.Add($"{fieldKey}={filterValue}"); + } + + var orderField = criteria.Criteria.Count > 0 ? criteria.Criteria[0].Code : "mc"; + var orderByParam = Adj1Fields.Contains(orderField) + ? $"{orderField}.{reportingPeriod}" + : orderField; + + // 确保 mc(总市值)始终包含在查询中,使响应包含市值数据 + var hasMc = orderByParam.Equals("mc", StringComparison.OrdinalIgnoreCase) || + criteria.Criteria.Any(c => c.Code.Equals("mc", StringComparison.OrdinalIgnoreCase)); + if (!hasMc) + { + filterParts.Add("mc=0_99999999999999"); + } + + var filterString = filterParts.Count > 0 ? "&" + string.Join("&", filterParts) : ""; + + return $"category=CN&exchange={exchange}&indcode={indcode}" + + $"&order_by={orderByParam}&order=desc&page=1&size={criteria.Limit}&only_count=0" + + filterString; + } + + private async Task> FetchFromXueqiuAsync(string queryParams) + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var url = $"{ScreenerEndpoint}?{queryParams}&_={timestamp}"; + + _logger.LogDebug("调用雪球选股 API: {Url}", url); + + using var client = _httpClientFactory.CreateClient("Xueqiu"); + using var request = new HttpRequestMessage(HttpMethod.Get, url); + using var response = await client.SendAsync(request); + + var json = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("雪球选股 API 返回错误,状态码: {StatusCode},响应: {Body}", + (int)response.StatusCode, + json.Length > 500 ? json[..500] : json); + throw new FriendlyException($"雪球网选股接口返回错误(HTTP {(int)response.StatusCode}),请稍后重试"); + } + + return ParseResponse(json); + } + + private List ParseResponse(string json) + { + var stocks = new List(); + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(json); + } + catch (JsonException) + { + _logger.LogError("雪球 API 返回非 JSON 格式数据(可能是反爬验证页面),响应前 500 字符: {Body}", + json.Length > 500 ? json[..500] : json); + throw new FriendlyException("雪球网返回了非预期的数据格式,可能是反爬验证页面,请稍后重试"); + } + + using (doc) + { + if (!doc.RootElement.TryGetProperty("data", out var data) || + !data.TryGetProperty("list", out var list) || + list.ValueKind != JsonValueKind.Array) + { + _logger.LogWarning("雪球 API 返回数据格式异常,响应前 500 字符: {Body}", + json.Length > 500 ? json[..500] : json); + return stocks; + } + + foreach (var item in list.EnumerateArray()) + { + var stock = ParseStockItem(item); + if (stock != null) + { + stocks.Add(stock); + } + } + } + + return stocks; + } + + private static ScreenerStockInfo? ParseStockItem(JsonElement item) + { + var name = GetString(item, "name"); + var symbol = GetString(item, "symbol"); + + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(symbol)) + { + return null; + } + + var stock = new ScreenerStockInfo { Name = name, Symbol = symbol }; + + // 所有数值字段统一放入 Indicators 字典 + foreach (var prop in item.EnumerateObject()) + { + if (s_handledFields.Contains(prop.Name)) continue; + if (prop.Value.ValueKind is JsonValueKind.Number or JsonValueKind.String) + { + stock.Indicators[prop.Name] = GetDecimal(item, prop.Name); + } + } + + // 回填基类属性,保持 ScreenerAssetInfo 接口契约 + if (stock.Indicators.TryGetValue("current", out var current)) stock.Current = current; + if (stock.Indicators.TryGetValue("pct", out var pct)) stock.Pct = pct; + if (stock.Indicators.TryGetValue("amount", out var amount)) stock.Amount = amount; + if (stock.Indicators.TryGetValue("mc", out var mc)) stock.Mc = mc; + if (stock.Indicators.TryGetValue("fmc", out var fmc)) stock.Fmc = fmc; + if (stock.Indicators.TryGetValue("volume", out var volume)) stock.Volume = volume; + + return stock; + } + + private static string GetString(JsonElement element, string name) + { + return element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() ?? "" + : ""; + } + + private static decimal GetDecimal(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out var value)) return 0; + return value.ValueKind switch + { + JsonValueKind.Number => value.GetDecimal(), + JsonValueKind.String when decimal.TryParse( + value.GetString(), NumberStyles.Number, CultureInfo.InvariantCulture, out var d) => d, + _ => 0 + }; + } +} diff --git a/src/MarketAssistant.App.Services/Applications/Assets/AShareAssetInfoService.cs b/src/MarketAssistant.App.Services/Applications/Assets/AShareAssetInfoService.cs new file mode 100644 index 0000000..37b8f55 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Assets/AShareAssetInfoService.cs @@ -0,0 +1,240 @@ +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Extensions.Logging; +using System.Net; +using System.Text; +using System.Text.Json; + +namespace MarketAssistant.Applications.Assets; + +/// +/// A股资产信息服务实现 +/// +public class AShareAssetInfoService : IAssetInfoService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public AShareAssetInfoService( + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _logger = logger; + } + + public async Task> SearchAsync(string keyword, CancellationToken cancellationToken = default) + { + // cls.cn 搜索 JSON API,直接 POST 即可,无需 Playwright + const string apiUrl = "https://www.cls.cn/api/sw?app=CailianpressWeb&os=web&sv=8.7.9&sign=b02d8f7bc4c45eeb3e86904203597da2"; + + var body = new + { + type = "stock", + keyword = keyword.Trim(), + rn = 20, + page = 0, + os = "web", + sv = "8.7.9", + app = "CailianpressWeb" + }; + + try + { + using var httpClient = _httpClientFactory.CreateClient("Cls"); + httpClient.Timeout = TimeSpan.FromSeconds(10); + + using var content = new StringContent( + JsonSerializer.Serialize(body), + Encoding.UTF8, + "application/json"); + + var response = await httpClient.PostAsync(apiUrl, content, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var doc = JsonDocument.Parse(json); + + var stockList = new List<(string Name, string Code)>(); + + if (doc.RootElement.TryGetProperty("data", out var data) && + data.TryGetProperty("stock", out var stock) && + stock.TryGetProperty("data", out var items)) + { + foreach (var item in items.EnumerateArray()) + { + var title = item.GetProperty("title").GetString() ?? ""; + var stockId = item.GetProperty("stock_id").GetString() ?? ""; + + // 去除 高亮标签 + title = title.Replace("", "").Replace("", "").Trim(); + + if (!string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(stockId)) + stockList.Add((title, stockId)); + } + } + + return stockList; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "搜索股票失败,返回空结果"); + return []; + } + } + + public async Task GetAssetInfoAsync(string code, string market = "", CancellationToken cancellationToken = default) + { + var assetInfo = new AssetInfo + { + Code = code, + Name = "未知股票", + Market = market, + MarketType = MarketType.AShare + }; + + try + { + var fullCode = string.IsNullOrEmpty(market) ? code : $"{market}{code}"; + var clsCode = StockSymbolConverter.ToClsFormat(fullCode); + if (string.IsNullOrEmpty(clsCode)) + return assetInfo; + + var url = $"/quote/stock/basic?secu_code={clsCode}&fields=secu_name,secu_code,last_px,change&app=CailianpressWeb&os=web&sv=8.4.6"; + + using var httpClient = _httpClientFactory.CreateClient("Cls"); + var response = await httpClient.GetStringAsync(url, cancellationToken); + using var jsonDocument = JsonDocument.Parse(response); + + if (!jsonDocument.RootElement.TryGetProperty("data", out var data) || data.ValueKind == JsonValueKind.Null) + return assetInfo; + + // 股票名称 + if (data.TryGetProperty("secu_name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String) + assetInfo.Name = nameEl.GetString()?.Trim() ?? "未知股票"; + + // 股票代码 & 市场 + if (data.TryGetProperty("secu_code", out var codeEl) && codeEl.ValueKind == JsonValueKind.String) + { + var rawCode = codeEl.GetString()?.Trim() ?? ""; + if (rawCode.StartsWith("SH", StringComparison.OrdinalIgnoreCase)) + { + assetInfo.Market = "SH"; + assetInfo.Code = rawCode[2..]; + } + else if (rawCode.StartsWith("SZ", StringComparison.OrdinalIgnoreCase)) + { + assetInfo.Market = "SZ"; + assetInfo.Code = rawCode[2..]; + } + else + { + assetInfo.Code = rawCode; + } + } + + // 当前价格 + if (data.TryGetProperty("last_px", out var priceEl)) + assetInfo.CurrentPrice = priceEl.ToString(); + + // 涨跌幅 + if (data.TryGetProperty("change", out var changeEl)) + { + var changeText = changeEl.ToString(); + if (!string.IsNullOrEmpty(changeText)) + assetInfo.ChangePercentage = changeText.Contains('%') ? changeText : $"{changeText}%"; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "获取股票详细数据异常: {Code}", code); + } + + return assetInfo; + } + + public async Task> GetHotAssetsAsync() + { + // 新浪财经个股资金流排行 API:按净流入降序,返回 symbol/name/trade/changeratio/netamount 等 + // 原 push2.eastmoney.com 端点在部分网络环境下 TLS 重协商被中断("The response ended prematurely"), + // 新浪接口稳定且响应更快,直接作为唯一数据源。 + // 注意:新浪接口返回 Content-Type: application/json; charset=gbk, + // .NET 默认不支持 GBK 编码,需注册 CodePagesEncodingProvider 并手动用 GBK 解码。 + var url = "/quotes_service/api/json_v2.php/MoneyFlow.ssl_bkzj_ssggzj?page=1&num=8&sort=netamount&asc=0"; + + try + { + // 注册中文编码提供程序(幂等),使 GBK/GB2312 可用 + System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); + + using var httpClient = _httpClientFactory.CreateClient("SinaFinance"); + using var stream = await httpClient.GetStreamAsync(url); + using var reader = new StreamReader(stream, System.Text.Encoding.GetEncoding("gbk")); + var json = await reader.ReadToEndAsync(); + using var jsonDocument = JsonDocument.Parse(json); + + if (jsonDocument.RootElement.ValueKind != JsonValueKind.Array) + { + _logger.LogError("GetHotAssetsAsync: 新浪API返回数据格式异常"); + return []; + } + + var hotAssets = new List(); + + foreach (var item in jsonDocument.RootElement.EnumerateArray()) + { + var symbol = item.TryGetProperty("symbol", out var symEl) ? symEl.GetString() ?? "" : ""; + if (symbol.Length < 2) + continue; + + var market = symbol.StartsWith("sh", StringComparison.OrdinalIgnoreCase) ? "SH" : + symbol.StartsWith("sz", StringComparison.OrdinalIgnoreCase) ? "SZ" : + symbol.StartsWith("bj", StringComparison.OrdinalIgnoreCase) ? "BJ" : ""; + + var code = symbol[2..]; + var name = item.TryGetProperty("name", out var nameEl) ? nameEl.GetString() ?? "" : ""; + var price = item.TryGetProperty("trade", out var tradeEl) ? tradeEl.GetString() ?? "" : ""; + // 新浪接口的 changeratio 和 netamount 均为字符串类型,需手动解析 + var changeRatio = ParseDouble(item, "changeratio"); + var netAmount = ParseDouble(item, "netamount"); + + hotAssets.Add(new HotAsset + { + Name = name, + Code = code, + Market = market, + CurrentPrice = price, + ChangePercentage = $"{changeRatio * 100:+0.00;-0.00;0.00}%", + MetricLabel = "净流入", + MetricValue = netAmount.ToString("F0"), + MarketType = MarketType.AShare + }); + } + + return hotAssets; + } + catch (Exception ex) + { + _logger.LogError(ex, "GetHotAssetsAsync: {Message}", ex.Message); + throw new Infrastructure.Core.FriendlyException($"获取热门股票失败: {ex.Message}", ex); + } + } + + /// + /// 从 JSON 元素解析 double 值,兼容字符串和数字两种类型。 + /// 新浪接口返回的数值字段多为字符串类型。 + /// + private static double ParseDouble(JsonElement item, string propertyName) + { + if (!item.TryGetProperty(propertyName, out var element)) + return 0; + + return element.ValueKind switch + { + JsonValueKind.String => double.TryParse(element.GetString(), out var v) ? v : 0, + JsonValueKind.Number => element.GetDouble(), + _ => 0 + }; + } +} + diff --git a/src/MarketAssistant.App.Services/Applications/Assets/CryptoAliasRegistry.cs b/src/MarketAssistant.App.Services/Applications/Assets/CryptoAliasRegistry.cs new file mode 100644 index 0000000..5fe1873 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Assets/CryptoAliasRegistry.cs @@ -0,0 +1,196 @@ +using System.Text.RegularExpressions; +using MarketAssistant.Applications.Cache; +using MarketAssistant.Services.Data; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Applications.Assets; + +/// +/// 加密货币别名注册表:动态维护 symbol → (name, coinGeckoId, matchRegex) 映射。 +/// 数据源为 CoinGecko top-N 市值币种,缓存后供 Telegram 符号提取、RSS 过滤、CoinGecko ID 解析等场景复用。 +/// +public interface ICryptoAliasRegistry +{ + /// + /// 获取 symbol(大写)→ 编译后正则的映射,用于从文本中提取关联币种。 + /// + Task> GetMatchPatternsAsync(CancellationToken cancellationToken = default); + + /// + /// 获取 symbol(大写)→ CoinGecko ID 的映射。 + /// + Task> GetCoinGeckoIdMapAsync(CancellationToken cancellationToken = default); + + /// + /// 获取 symbol(大写)→ 英文全称的映射。 + /// + Task> GetNameMapAsync(CancellationToken cancellationToken = default); + + /// + /// 获取指定 symbol 的英文别名列表(含 symbol 自身 + 英文全称),用于 RSS 文本匹配。 + /// + Task> GetAliasesAsync(string symbol, CancellationToken cancellationToken = default); +} + +public sealed class CryptoAliasRegistry : ICryptoAliasRegistry +{ + private const string CacheKey = "CryptoAliasRegistry_Data"; + private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(6); + + private readonly CoinGeckoApiService _coinGeckoService; + private readonly IMemoryCache _memoryCache; + private readonly ILogger _logger; + private readonly SemaphoreSlim _initLock = new(1, 1); + + public CryptoAliasRegistry( + CoinGeckoApiService coinGeckoService, + IMemoryCache memoryCache, + ILogger logger) + { + _coinGeckoService = coinGeckoService; + _memoryCache = memoryCache; + _logger = logger; + } + + public async Task> GetMatchPatternsAsync(CancellationToken cancellationToken = default) + => (await GetDataAsync(cancellationToken)).Patterns; + + public async Task> GetCoinGeckoIdMapAsync(CancellationToken cancellationToken = default) + => (await GetDataAsync(cancellationToken)).CoinGeckoIds; + + public async Task> GetNameMapAsync(CancellationToken cancellationToken = default) + => (await GetDataAsync(cancellationToken)).Names; + + public async Task> GetAliasesAsync(string symbol, CancellationToken cancellationToken = default) + { + var names = await GetNameMapAsync(cancellationToken); + var upperSymbol = symbol.ToUpperInvariant(); + var aliases = new List { upperSymbol }; + if (names.TryGetValue(upperSymbol, out var name)) + aliases.Add(name.ToUpperInvariant()); + return aliases; + } + + private async Task GetDataAsync(CancellationToken cancellationToken) + { + if (_memoryCache.TryGetValue(CacheKey, out RegistryData? cached) && cached is not null) + return cached; + + await _initLock.WaitAsync(cancellationToken); + try + { + if (_memoryCache.TryGetValue(CacheKey, out cached) && cached is not null) + return cached; + + var data = await BuildRegistryDataAsync(cancellationToken); + _memoryCache.Set(CacheKey, data, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = CacheDuration + }); + return data; + } + finally + { + _initLock.Release(); + } + } + + private async Task BuildRegistryDataAsync(CancellationToken cancellationToken) + { + var patterns = new Dictionary(StringComparer.OrdinalIgnoreCase); + var coinGeckoIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + var names = new Dictionary(StringComparer.OrdinalIgnoreCase); + + try + { + var markets = await _coinGeckoService.GetCoinsMarketsAsync( + perPage: 100, cancellationToken: cancellationToken); + + foreach (var coin in markets) + { + var symbol = coin.Symbol.ToUpperInvariant(); + if (string.IsNullOrEmpty(symbol) || patterns.ContainsKey(symbol)) + continue; + + coinGeckoIds[symbol] = coin.Id; + names[symbol] = coin.Name; + + var parts = new List { $@"\b{Regex.Escape(symbol)}\b" }; + if (!string.IsNullOrWhiteSpace(coin.Name) + && !string.Equals(coin.Name, symbol, StringComparison.OrdinalIgnoreCase)) + { + parts.Add($@"\b{Regex.Escape(coin.Name)}\b"); + } + + var pattern = string.Join("|", parts); + patterns[symbol] = new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.Compiled); + } + + _logger.LogInformation("CryptoAliasRegistry 已加载 {Count} 个币种别名", patterns.Count); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "CryptoAliasRegistry 从 CoinGecko 加载失败,回退到静态列表"); + BuildFallbackData(patterns, coinGeckoIds, names); + } + + return new RegistryData(patterns, coinGeckoIds, names); + } + + /// + /// CoinGecko 不可用时的兜底数据,覆盖最常见的主流币种。 + /// + private static void BuildFallbackData( + Dictionary patterns, + Dictionary coinGeckoIds, + Dictionary names) + { + var fallback = new (string Symbol, string Name, string CoinGeckoId)[] + { + ("BTC", "Bitcoin", "bitcoin"), + ("ETH", "Ethereum", "ethereum"), + ("USDT", "Tether", "tether"), + ("BNB", "Binance Coin", "binancecoin"), + ("SOL", "Solana", "solana"), + ("XRP", "Ripple", "ripple"), + ("ADA", "Cardano", "cardano"), + ("DOGE", "Dogecoin", "dogecoin"), + ("DOT", "Polkadot", "polkadot"), + ("AVAX", "Avalanche", "avalanche-2"), + ("LINK", "Chainlink", "chainlink"), + ("UNI", "Uniswap", "uniswap"), + ("SHIB", "Shiba Inu", "shiba-inu"), + ("LTC", "Litecoin", "litecoin"), + ("MATIC", "Polygon", "matic-network"), + ("ATOM", "Cosmos", "cosmos"), + ("ARB", "Arbitrum", "arbitrum"), + ("OP", "Optimism", "optimism"), + ("APT", "Aptos", "aptos"), + ("SUI", "Sui", "sui"), + ("TON", "Toncoin", "toncoin"), + ("TRX", "TRON", "tron"), + ("NEAR", "NEAR Protocol", "near"), + ("PEPE", "Pepe", "pepe"), + }; + + foreach (var (symbol, name, id) in fallback) + { + coinGeckoIds[symbol] = id; + names[symbol] = name; + + var parts = new List { $@"\b{Regex.Escape(symbol)}\b" }; + if (!string.Equals(name, symbol, StringComparison.OrdinalIgnoreCase)) + parts.Add($@"\b{Regex.Escape(name)}\b"); + + patterns[symbol] = new Regex( + string.Join("|", parts), + RegexOptions.IgnoreCase | RegexOptions.Compiled); + } + } + + private sealed record RegistryData( + IReadOnlyDictionary Patterns, + IReadOnlyDictionary CoinGeckoIds, + IReadOnlyDictionary Names); +} diff --git a/src/MarketAssistant.App.Services/Applications/Assets/CryptoAssetInfoService.cs b/src/MarketAssistant.App.Services/Applications/Assets/CryptoAssetInfoService.cs new file mode 100644 index 0000000..8bc1678 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Assets/CryptoAssetInfoService.cs @@ -0,0 +1,281 @@ +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Applications.Cache; +using MarketAssistant.Services.Data; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using static MarketAssistant.Infrastructure.Core.CryptoSymbolConverter; + +namespace MarketAssistant.Applications.Assets; + +/// +/// 虚拟币资产信息服务实现(基于币安API + CoinGecko市值数据) +/// +public class CryptoAssetInfoService : IAssetInfoService +{ + private readonly BinanceMarketDataService _binanceService; + private readonly CoinGeckoApiService _coinGeckoService; + private readonly ICryptoAliasRegistry _aliasRegistry; + private readonly ILogger _logger; + private readonly IMemoryCache _memoryCache; + + public CryptoAssetInfoService( + BinanceMarketDataService binanceService, + CoinGeckoApiService coinGeckoService, + ICryptoAliasRegistry aliasRegistry, + ILogger logger, + IMemoryCache memoryCache) + { + _binanceService = binanceService ?? throw new ArgumentNullException(nameof(binanceService)); + _coinGeckoService = coinGeckoService ?? throw new ArgumentNullException(nameof(coinGeckoService)); + _aliasRegistry = aliasRegistry; + _logger = logger; + _memoryCache = memoryCache; + } + + /// + /// 搜索虚拟币(支持名称和代码) + /// + public async Task> SearchAsync(string keyword, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(keyword)) + { + return new List<(string Name, string Code)>(); + } + + keyword = keyword.Trim().ToUpperInvariant(); + + // 获取所有交易对信息(已过滤为 TRADING 状态) + var symbols = await GetSymbolsAsync(cancellationToken); + + // 搜索匹配的交易对 + var results = symbols + .Where(s => s.BaseAsset.Contains(keyword)) + .Select(s => (Name: s.BaseAsset, Code: s.Symbol)) + .Take(20) + .ToList(); + + _logger.LogInformation("虚拟币搜索: {Keyword}, 返回 {Count} 条结果", keyword, results.Count); + return results; + } + + /// + /// 获取虚拟币详细信息 + /// + public async Task GetAssetInfoAsync(string code, string market = "", CancellationToken cancellationToken = default) + { + + // 格式化交易对代码 + string symbol = ToBinanceFormat(code); + + // 调用币安服务获取24小时价格统计 + var ticker = await _binanceService.Get24hrTickerAsync(symbol, cancellationToken); + + if (ticker == null) + { + throw new FriendlyException("获取币安行情数据失败"); + } + + // 构建资产信息 + var assetInfo = new AssetInfo + { + Code = symbol, + Name = ExtractBaseCurrency(ticker.Symbol), // 提取基础币种 + MarketType = MarketType.Crypto, + Market = "Binance", + CurrentPrice = FormatPrice(ticker.LastPrice), + ChangePercentage = FormatPercentage(ticker.PriceChangePercent), + Volume24h = FormatVolume(ticker.Volume), + MarketCap = await FetchMarketCapAsync(ExtractBaseCurrency(ticker.Symbol), cancellationToken) + }; + + _logger.LogInformation("成功获取虚拟币详情: {Symbol}", symbol); + return assetInfo; + } + + /// + /// 获取热门虚拟币(按24小时交易量排序) + /// + public async Task> GetHotAssetsAsync() + { + // 调用币安服务获取所有交易对的24小时统计 + var tickers = await _binanceService.GetAll24hrTickersAsync(); + + if (tickers == null || tickers.Count == 0) + { + _logger.LogWarning("币安API返回数据为空"); + return new List(); + } + + // 稳定币列表(用于过滤稳定币互换交易对) + var stablecoins = new[] { "USDT", "USDC", "BUSD", "FDUSD", "DAI", "TUSD", "USDP" }; + + // 筛选USDT交易对,排除稳定币互换(需同时满足:基础币种是稳定币 且 价格接近1.0),按24小时交易量排序,取前8个 + var hotAssets = tickers + .Where(t => t.Symbol.EndsWith("USDT") && t.Symbol != "USDT") + // 过滤稳定币互换交易对(基础币种是稳定币 且 价格接近1.0,同时满足才过滤) + .Where(t => + { + var baseCurrency = ExtractBaseCurrency(t.Symbol); + var isStablecoin = stablecoins.Contains(baseCurrency); + var isPriceNearOne = t.LastPrice >= 0.95m && t.LastPrice <= 1.05m; + + // 只有同时满足"是稳定币"且"价格接近1.0"才过滤掉(返回false) + return !(isStablecoin && isPriceNearOne); + }) + .OrderByDescending(t => t.QuoteVolume) + .Take(8) + .Select(t => new HotAsset + { + Name = ExtractBaseCurrency(t.Symbol), + Code = t.Symbol, + Market = "Binance", + CurrentPrice = FormatPrice(t.LastPrice), + ChangePercentage = FormatOpenClosePercentage(t.OpenPrice, t.LastPrice), + MarketType = MarketType.Crypto, + MetricLabel = "交易量", + MetricValue = FormatVolume(t.QuoteVolume), + SectorName = "加密货币" + }) + .ToList(); + + _logger.LogInformation("成功获取热门虚拟币: {Count} 个", hotAssets.Count); + return hotAssets; + } + + /// + /// 从CoinGecko获取真实市值数据 + /// + private async Task FetchMarketCapAsync(string baseCurrency, CancellationToken cancellationToken) + { + try + { + var idMap = await _aliasRegistry.GetCoinGeckoIdMapAsync(cancellationToken); + var coinId = idMap.TryGetValue(baseCurrency.ToUpperInvariant(), out var id) + ? id + : ToCoinGeckoId(baseCurrency); + var marketData = await _coinGeckoService.GetCoinMarketDataAsync(coinId, cancellationToken: cancellationToken); + + if (marketData is { Count: > 0 } && marketData[0] is System.Text.Json.Nodes.JsonObject data) + { + var marketCap = data["market_cap"]?.GetValue(); + if (marketCap.HasValue && marketCap.Value > 0) + { + return FormatVolume(marketCap.Value); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "从CoinGecko获取市值失败: {BaseCurrency},将显示为N/A", baseCurrency); + } + + return "N/A"; + } + + #region 辅助方法 + + /// + /// 获取所有交易对信息(使用 IMemoryCache 缓存,只获取 TRADING 状态) + /// + private async Task> GetSymbolsAsync(CancellationToken cancellationToken) + { + // 尝试从缓存获取 + if (_memoryCache.TryGetValue(CacheKeys.CryptoSymbols, out List? cachedSymbols) && cachedSymbols != null) + { + return cachedSymbols; + } + + // 调用币安服务获取交易所信息 + var exchangeInfo = await _binanceService.GetExchangeInfoAsync(cancellationToken); + + var tradingSymbols = exchangeInfo?.Symbols ?? new List(); + + // 设置缓存(1小时过期) + var cacheOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) + }; + _memoryCache.Set(CacheKeys.CryptoSymbols, tradingSymbols, cacheOptions); + + _logger.LogInformation("已缓存 {Count} 个 TRADING 状态的交易对", tradingSymbols.Count); + return tradingSymbols; + } + + /// + /// 格式化价格显示 + /// + private string FormatPrice(decimal price) + { + // 根据价格大小选择精度 + if (price >= 1000) + { + return price.ToString("N2"); // 1000+ 显示2位小数 + } + else if (price >= 1) + { + return price.ToString("N4"); // 1-1000 显示4位小数 + } + else + { + return price.ToString("N6"); // <1 显示6位小数 + } + } + + /// + /// 格式化百分比显示 + /// + private string FormatPercentage(decimal? percent) + { + if (!percent.HasValue) + { + return "0.00%"; + } + + return $"{percent.Value:F2}%"; + } + + /// + /// 根据开盘价和收盘价计算涨跌幅(用于MINI数据) + /// + private string FormatOpenClosePercentage(decimal openPrice, decimal lastPrice) + { + if (openPrice == 0) + { + return "0.00%"; + } + + var changePercent = ((lastPrice - openPrice) / openPrice) * 100; + return $"{changePercent:F2}%"; + } + + /// + /// 格式化交易量显示(K, M, B) + /// + private string FormatVolume(decimal volume) + { + if (volume >= 1_000_000_000) + { + return $"{(volume / 1_000_000_000):N2}B"; + } + else if (volume >= 1_000_000) + { + return $"{(volume / 1_000_000):N2}M"; + } + else if (volume >= 1_000) + { + return $"{(volume / 1_000):N2}K"; + } + else + { + return $"{volume:N2}"; + } + } + + #endregion +} + + + + + + diff --git a/src/MarketAssistant.App.Services/Applications/Assets/IAssetInfoService.cs b/src/MarketAssistant.App.Services/Applications/Assets/IAssetInfoService.cs new file mode 100644 index 0000000..6d8aabc --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Assets/IAssetInfoService.cs @@ -0,0 +1,30 @@ +using MarketAssistant.Applications.Assets.Models; + +namespace MarketAssistant.Applications.Assets; + +/// +/// 资产信息服务接口 +/// +public interface IAssetInfoService +{ + /// + /// 搜索资产 + /// + Task> SearchAsync(string keyword, CancellationToken cancellationToken = default); + + /// + /// 获取资产详情 + /// + Task GetAssetInfoAsync(string code, string market = "", CancellationToken cancellationToken = default); + + /// + /// 获取热门资产 + /// + Task> GetHotAssetsAsync(); +} + + + + + + diff --git a/src/MarketAssistant.App.Services/Applications/Assets/Models/AssetInfo.cs b/src/MarketAssistant.App.Services/Applications/Assets/Models/AssetInfo.cs new file mode 100644 index 0000000..ef981f1 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Assets/Models/AssetInfo.cs @@ -0,0 +1,75 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Applications.Assets.Models; + +/// +/// 通用资产详情信息 +/// +public class AssetInfo : INotifyPropertyChanged +{ + /// + /// 资产代码 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 资产名称 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 市场类型 + /// + public MarketType MarketType { get; set; } + + private string _currentPrice = string.Empty; + /// + /// 当前价格 + /// + public string CurrentPrice + { + get => _currentPrice; + set => SetProperty(ref _currentPrice, value); + } + + private string _changePercentage = string.Empty; + /// + /// 涨跌幅百分比 + /// + public string ChangePercentage + { + get => _changePercentage; + set => SetProperty(ref _changePercentage, value); + } + + /// + /// 市场标识(如SH、SZ、BTC/USDT等) + /// + public string Market { get; set; } = string.Empty; + + /// + /// 所属板块名称(A股特有) + /// + public string? SectorName { get; set; } + + /// + /// 市值(虚拟币特有) + /// + public string? MarketCap { get; set; } + + /// + /// 24小时交易量(虚拟币特有) + /// + public string? Volume24h { get; set; } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) return; + field = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/src/MarketAssistant.App.Services/Applications/Assets/Models/AssetItem.cs b/src/MarketAssistant.App.Services/Applications/Assets/Models/AssetItem.cs new file mode 100644 index 0000000..ad3a4bd --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Assets/Models/AssetItem.cs @@ -0,0 +1,25 @@ +namespace MarketAssistant.Applications.Assets.Models; + +/// +/// 通用资产条目 +/// +public class AssetItem +{ + /// + /// 资产名称 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 资产代码 + /// + public string Code { get; set; } = string.Empty; + + public override string ToString() => string.IsNullOrEmpty(Name) ? Code : $"{Name} ({Code})"; +} + + + + + + diff --git a/src/MarketAssistant.App.Services/Applications/Assets/Models/FavoriteAsset.cs b/src/MarketAssistant.App.Services/Applications/Assets/Models/FavoriteAsset.cs new file mode 100644 index 0000000..3beb465 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Assets/Models/FavoriteAsset.cs @@ -0,0 +1,23 @@ +namespace MarketAssistant.Applications.Assets.Models; + +/// +/// 收藏的资产 +/// +public class FavoriteAsset +{ + /// + /// 资产代码 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 市场标识 + /// + public string Market { get; set; } = string.Empty; +} + + + + + + diff --git a/src/MarketAssistant.App.Services/Applications/Assets/Models/HotAsset.cs b/src/MarketAssistant.App.Services/Applications/Assets/Models/HotAsset.cs new file mode 100644 index 0000000..b457070 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Assets/Models/HotAsset.cs @@ -0,0 +1,90 @@ +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Applications.Assets.Models; + +/// +/// 热门资产 +/// +public class HotAsset +{ + /// + /// 资产名称 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 涨跌幅 + /// + public string ChangePercentage { get; set; } = string.Empty; + + /// + /// 资产代码 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 当前价格 + /// + public string CurrentPrice { get; set; } = string.Empty; + + /// + /// 市场标识 + /// + public string Market { get; set; } = string.Empty; + + /// + /// 市场类型 + /// + public MarketType MarketType { get; set; } + + /// + /// 所属板块或分类(A股为板块名称,加密货币暂不使用) + /// + public string? SectorName { get; set; } + + /// + /// 市值(仅加密货币使用) + /// + public string? MarketCap { get; set; } + + /// + /// 核心指标值(股票为热度,加密货币为24小时交易量) + /// + public string? MetricValue { get; set; } + + /// + /// 指标标签(根据市场类型动态返回) + /// + public string MetricLabel { get; set; } = "热度"; + + /// + /// 格式化的指标显示值 + /// + public string FormattedMetric => FormatMetric(MetricValue); + + private static string FormatMetric(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return "-"; + + if (decimal.TryParse(value, out var volume)) + { + if (volume >= 1_000_000_000) + return $"{volume / 1_000_000_000:N2}B"; + if (volume >= 1_000_000) + return $"{volume / 1_000_000:N2}M"; + if (volume >= 1_000) + return $"{volume / 1_000:N2}K"; + + return volume.ToString("N0"); + } + + return value; + } +} + + + + + + diff --git a/src/MarketAssistant.App.Services/Applications/Cache/AssetCacheService.cs b/src/MarketAssistant.App.Services/Applications/Cache/AssetCacheService.cs new file mode 100644 index 0000000..1a03c33 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Cache/AssetCacheService.cs @@ -0,0 +1,72 @@ +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; + +namespace MarketAssistant.Applications.Cache; + +/// +/// 通用资产缓存服务,通过 从 Keyed DI 注册键自动获取市场类型, +/// 内部计算缓存前缀。合并了原 AShareAssetCacheService / CryptoAssetCacheService 两个重复实现。 +/// +public sealed class AssetCacheService : IAssetCacheService +{ + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private readonly string _cacheKeyPrefix; + private const int CacheExpirationMinutes = 5; + + /// + /// 跟踪当前实例已缓存的键,用于按前缀清除,避免依赖 MemoryCache 内部反射。 + /// + private readonly ConcurrentDictionary _trackedKeys = new(); + + public AssetCacheService( + [ServiceKey] MarketType marketType, + IMemoryCache cache, + ILogger logger) + { + _cache = cache; + _logger = logger; + _cacheKeyPrefix = CacheKeys.GetAssetInfoPrefix(marketType); + } + + public Task GetCachedAssetInfoAsync(string code) + { + var cacheKey = GetCacheKey(code); + if (_cache.TryGetValue(cacheKey, out AssetInfo? assetInfo)) + { + _logger.LogDebug("从缓存获取资产信息: {Code}", code); + return Task.FromResult(assetInfo); + } + return Task.FromResult(null); + } + + public void CacheAssetInfo(string code, AssetInfo info) + { + var cacheKey = GetCacheKey(code); + var cacheOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(CacheExpirationMinutes), + Size = 1 + }; + + _cache.Set(cacheKey, info, cacheOptions); + _trackedKeys[cacheKey] = 0; + _logger.LogDebug("缓存资产信息: {Code}", code); + } + + public void Clear() + { + foreach (var key in _trackedKeys.Keys) + { + _cache.Remove(key); + } + _trackedKeys.Clear(); + _logger.LogInformation("清除资产缓存: {Prefix}", _cacheKeyPrefix); + } + + private string GetCacheKey(string code) => $"{_cacheKeyPrefix}{code}"; +} diff --git a/src/MarketAssistant.App.Services/Applications/Cache/CacheKeys.cs b/src/MarketAssistant.App.Services/Applications/Cache/CacheKeys.cs new file mode 100644 index 0000000..156363e --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Cache/CacheKeys.cs @@ -0,0 +1,54 @@ +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Applications.Cache; + +/// +/// 缓存键定义中心:所有 IMemoryCache 键在此统一管理,避免硬编码字符串散落在各处。 +/// 市场相关键通过 枚举名动态拼接前缀,新增市场无需修改本类。 +/// 与市场无关的全局键以常量形式提供。 +/// +public static class CacheKeys +{ + /// + /// 资产信息缓存键的业务前缀(不含市场部分)。 + /// + private const string AssetInfoPrefix = "AssetInfo"; + + /// + /// 分析报告缓存键的业务前缀(不含市场部分)。 + /// + private const string AnalysisReportPrefix = "MarketAnalysisReport"; + + /// + /// 虚拟币交易对列表缓存键。 + /// 仅虚拟币使用,与市场无关的全局缓存,无需市场前缀。 + /// + public const string CryptoSymbols = "AssetSymbols_Crypto_All"; + + /// + /// GitHub Release 缓存键。 + /// 与市场无关的应用更新检查缓存,无需市场前缀。 + /// + public const string GitHubReleases = "GitHubReleases_All"; + + /// + /// 根据市场类型获取资产信息缓存键前缀。 + /// 前缀由市场枚举名动态拼接,新增市场自动适配,无需修改本方法。 + /// + public static string GetAssetInfoPrefix(MarketType type) => $"{AssetInfoPrefix}_{type}_"; + + /// + /// 生成分析报告缓存键(含市场类型,避免跨市场碰撞)。 + /// 供 使用。 + /// + public static string GetAnalysisReportKey(MarketType market, string assetSymbol) + => $"{AnalysisReportPrefix}_{market}_{assetSymbol}"; + + /// + /// 生成交易模块分析报告缓存键(含市场类型,避免跨市场碰撞)。 + /// 供 Trading.AnalysisReportCache 的 ConcurrentDictionary 使用, + /// 与 分属不同存储介质,键格式独立。 + /// + public static string GetTradingAnalysisReportKey(MarketType market, string symbol) + => $"{market}:{symbol}"; +} diff --git a/src/MarketAssistant.App.Services/Applications/Cache/IAssetCacheService.cs b/src/MarketAssistant.App.Services/Applications/Cache/IAssetCacheService.cs new file mode 100644 index 0000000..4855ba0 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Cache/IAssetCacheService.cs @@ -0,0 +1,30 @@ +using MarketAssistant.Applications.Assets.Models; + +namespace MarketAssistant.Applications.Cache; + +/// +/// 资产缓存服务接口 +/// +public interface IAssetCacheService +{ + /// + /// 获取缓存的资产信息 + /// + Task GetCachedAssetInfoAsync(string code); + + /// + /// 缓存资产信息 + /// + void CacheAssetInfo(string code, AssetInfo info); + + /// + /// 清除所有缓存 + /// + void Clear(); +} + + + + + + diff --git a/src/MarketAssistant.App.Services/Applications/Charts/AShareKLineService.cs b/src/MarketAssistant.App.Services/Applications/Charts/AShareKLineService.cs new file mode 100644 index 0000000..621565b --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Charts/AShareKLineService.cs @@ -0,0 +1,289 @@ +using MarketAssistant.Applications.Charts.Models; +using MarketAssistant.Infrastructure; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Data; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.Logging; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MarketAssistant.Applications.Charts; + +/// +/// A股K线数据服务实现 +/// +public class AShareKLineService : IKLineService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IUserSettingService _userSettingService; + private readonly ILogger _logger; + // 支持 API 返回的字符串数值自动转换为 decimal + private static readonly JsonSerializerOptions KLineJsonOptions = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new StringToDecimalConverter() } + }; + + public AShareKLineService( + IHttpClientFactory httpClientFactory, + ILogger logger, + IUserSettingService userSettingService) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _userSettingService = userSettingService; + _logger = logger; + } + + /// + /// 获取K线数据(统一入口,根据类型调用对应的实现) + /// + public async Task> GetKLineDataAsync(string code, KLineType kLineType, int count = 250) + { + // 根据K线类型映射到智图API的时间周期参数 + var (interval, dataTypeName) = kLineType switch + { + KLineType.Daily => ("d", "日K线"), + KLineType.Weekly => ("w", "周K线"), + KLineType.Monthly => ("m", "月K线"), + KLineType.Minute5 => ("5", "5分钟K线"), + KLineType.Minute15 => ("15", "15分钟K线"), + _ => ("d", "日K线") // 默认日K线 + }; + + var dataset = await GetKLineDataInternalAsync(code, interval, dataTypeName, count: count); + return dataset?.Data ?? new List(); + } + + #region 内部实现方法 + + private async Task GetKLineDataInternalAsync( + string symbol, + string interval, + string dataType, + DateTime? startDate = null, + DateTime? endDate = null, + string adjustType = "n", + int? count = null) + { + try + { + // 验证参数 + if (string.IsNullOrEmpty(symbol)) + { + throw new ArgumentException("股票代码参数必须提供"); + } + + // 处理股票代码格式 + string formattedSymbol = StockSymbolConverter.ToZhiTuFormat(symbol); + + // 构建API URL + string apiUrl = BuildZhiTuApiUrl(formattedSymbol, interval, adjustType, startDate, endDate); + + // 发送请求并获取数据 + var zhiTuData = await FetchZhiTuDataAsync(apiUrl, dataType, symbol); + + // 转换为应用程序数据模型 + var klineDataSet = new KLineDataSet + { + Symbol = symbol, + Interval = interval, + Data = new List() + }; + + // 解析数据 + ParseZhiTuKLineData(zhiTuData, klineDataSet); + + // 按调用方请求的 count 截取最近的 N 条(已按时间升序排序,取最后 count 条) + if (count.HasValue && count.Value > 0 && klineDataSet.Data.Count > count.Value) + { + klineDataSet.Data = klineDataSet.Data + .Skip(klineDataSet.Data.Count - count.Value) + .ToList(); + } + + return klineDataSet; + } + catch (Exception ex) + { + _logger.LogError(ex, "获取{DataType}数据时发生错误 - 股票代码: {Symbol}", dataType, symbol); + throw new FriendlyException($"获取{dataType}数据失败: {ex.Message}", ex); + } + } + + private string BuildZhiTuApiUrl(string symbol, string interval, string adjustType = "n", DateTime? startDate = null, DateTime? endDate = null) + { + var token = _userSettingService.CurrentSetting.ZhiTuApiToken; + var url = $"/hs/history/{symbol}/{interval}/{adjustType}?token={token}"; + + // 如果没有指定时间范围,根据不同的interval设置合理的默认时间范围 + DateTime defaultStartDate; + DateTime defaultEndDate = DateTime.Now; + + if (!startDate.HasValue && !endDate.HasValue) + { + switch (interval.ToLower()) + { + case "d": // 日K线,默认查询最近1年 + defaultStartDate = DateTime.Now.AddYears(-1); + break; + case "w": // 周K线,默认查询最近3年 + defaultStartDate = DateTime.Now.AddYears(-3); + break; + case "m": // 月K线,默认查询最近10年 + defaultStartDate = DateTime.Now.AddYears(-10); + break; + case "y": // 年K线,默认查询最近10年 + defaultStartDate = DateTime.Now.AddYears(-10); + break; + case "1": + case "5": + case "15": + case "30": + case "60": // 分钟级别数据,默认查询最近60天 + defaultStartDate = DateTime.Now.AddDays(-60); + break; + default: // 其他情况,默认查询最近1年 + defaultStartDate = DateTime.Now.AddYears(-1); + break; + } + + startDate = defaultStartDate; + endDate = defaultEndDate; + } + + if (startDate.HasValue) + { + url += $"&st={startDate.Value:yyyyMMdd}"; + } + + if (endDate.HasValue) + { + url += $"&et={endDate.Value:yyyyMMdd}"; + } + + return url; + } + + private async Task> FetchZhiTuDataAsync(string url, string dataType, string symbol) + { + _logger.LogInformation("正在获取股票{DataType}数据: 股票代码: {Symbol}", dataType, symbol); + _logger.LogDebug("请求URL: {Url}", url); + + try + { + using var httpClient = _httpClientFactory.CreateClient("ZhiTu"); + var response = await httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.LogWarning( + "智图API返回错误 {StatusCode},股票代码: {Symbol},响应: {ErrorBody}", + (int)response.StatusCode, symbol, errorBody); + throw new FriendlyException( + $"获取{dataType}数据失败: 智图API返回 {(int)response.StatusCode} ({response.StatusCode}),{(string.IsNullOrWhiteSpace(errorBody) ? "请稍后重试" : errorBody)}"); + } + + var jsonContent = await response.Content.ReadAsStringAsync(); + var zhiTuData = JsonSerializer.Deserialize>(jsonContent, KLineJsonOptions); + + if (zhiTuData == null || !zhiTuData.Any()) + { + throw new FriendlyException($"获取{dataType}数据失败: 返回数据为空"); + } + + return zhiTuData; + } + catch (FriendlyException) + { + throw; + } + catch (HttpRequestException ex) + { + throw new FriendlyException($"获取{dataType}数据失败: 网络请求错误 - {ex.Message}", ex); + } + catch (JsonException ex) + { + throw new FriendlyException($"获取{dataType}数据失败: 数据解析错误 - {ex.Message}", ex); + } + } + + private void ParseZhiTuKLineData(List zhiTuData, KLineDataSet klineDataSet) + { + foreach (var item in zhiTuData) + { + // 解析时间戳 + if (DateTime.TryParse(item.T, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime timestamp)) + { + var klineData = new KLineData + { + Timestamp = timestamp, + Open = item.O, + High = item.H, + Low = item.L, + Close = item.C, + Volume = item.V, + Amount = item.A, + PreClose = item.Pc + }; + + // 计算涨跌额和涨跌幅 + if (item.Pc > 0) + { + klineData.Change = item.C - item.Pc; + klineData.PctChg = (klineData.Change / item.Pc) * 100; + } + else + { + klineData.Change = 0; + klineData.PctChg = 0; + } + + klineDataSet.Data.Add(klineData); + } + } + + // 按日期时间排序(从旧到新) + klineDataSet.Data = klineDataSet.Data.OrderBy(x => x.Timestamp).ToList(); + } + + #endregion +} + +/// +/// ZhiTu API K线数据模型 +/// +[Serializable] +internal class ZhiTuKLineData +{ + [JsonPropertyName("t")] + public string T { get; set; } = string.Empty; + + [JsonPropertyName("o")] + public decimal O { get; set; } + + [JsonPropertyName("h")] + public decimal H { get; set; } + + [JsonPropertyName("l")] + public decimal L { get; set; } + + [JsonPropertyName("c")] + public decimal C { get; set; } + + [JsonPropertyName("v")] + public decimal V { get; set; } + + [JsonPropertyName("a")] + public decimal A { get; set; } + + [JsonPropertyName("pc")] + public decimal Pc { get; set; } +} + + + + + + diff --git a/src/MarketAssistant.App.Services/Applications/Charts/CryptoKLineService.cs b/src/MarketAssistant.App.Services/Applications/Charts/CryptoKLineService.cs new file mode 100644 index 0000000..0808f14 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Charts/CryptoKLineService.cs @@ -0,0 +1,113 @@ +using MarketAssistant.Applications.Charts.Models; +using MarketAssistant.Infrastructure; +using MarketAssistant.Services.Data; +using Microsoft.Extensions.Logging; +using System.Globalization; +using System.Text.Json.Nodes; +using static MarketAssistant.Infrastructure.Core.CryptoSymbolConverter; + +namespace MarketAssistant.Applications.Charts; + +/// +/// 虚拟币K线数据服务实现,委托 BinanceMarketDataService 获取原始数据 +/// +public class CryptoKLineService : IKLineService +{ + private readonly BinanceMarketDataService _binanceService; + private readonly ILogger _logger; + + public CryptoKLineService( + BinanceMarketDataService binanceService, + ILogger logger) + { + _binanceService = binanceService ?? throw new ArgumentNullException(nameof(binanceService)); + _logger = logger; + } + + /// + /// 获取K线数据 + /// + public async Task> GetKLineDataAsync(string code, KLineType kLineType, int count = 250) + { + var interval = kLineType switch + { + KLineType.Daily => "1d", + KLineType.Weekly => "1w", + KLineType.Monthly => "1M", + KLineType.Minute5 => "5m", + KLineType.Minute15 => "15m", + _ => "1d" + }; + + var formattedSymbol = ToBinanceFormat(code); + var limit = Math.Min(count, 1000); + + _logger.LogInformation("获取币安K线: {Symbol}, 周期: {Interval}, 数量: {Limit}", formattedSymbol, interval, limit); + + var jsonArray = await _binanceService.GetKlinesAsync(formattedSymbol, interval, limit); + + if (jsonArray == null || jsonArray.Count == 0) + { + _logger.LogWarning("币安API返回K线数据为空: {Symbol}", formattedSymbol); + return []; + } + + var result = ParseKlineData(jsonArray); + CalculatePriceChanges(result); + + _logger.LogInformation("成功获取K线数据: {Symbol}, {Count} 条", formattedSymbol, result.Count); + return result; + } + + private static List ParseKlineData(JsonArray jsonArray) + { + var result = new List(jsonArray.Count); + + foreach (var item in jsonArray) + { + if (item is not JsonArray arr || arr.Count < 11) continue; + + result.Add(new KLineData + { + Timestamp = DateTimeOffset.FromUnixTimeMilliseconds(arr[0]!.GetValue()).DateTime, + Open = decimal.Parse(arr[1]!.GetValue(), CultureInfo.InvariantCulture), + High = decimal.Parse(arr[2]!.GetValue(), CultureInfo.InvariantCulture), + Low = decimal.Parse(arr[3]!.GetValue(), CultureInfo.InvariantCulture), + Close = decimal.Parse(arr[4]!.GetValue(), CultureInfo.InvariantCulture), + Volume = decimal.Parse(arr[5]!.GetValue(), CultureInfo.InvariantCulture), + Amount = decimal.Parse(arr[7]!.GetValue(), CultureInfo.InvariantCulture) + }); + } + + return result; + } + + private static void CalculatePriceChanges(List klineDataList) + { + for (int i = 0; i < klineDataList.Count; i++) + { + if (i == 0) + { + klineDataList[i].PreClose = klineDataList[i].Open; + klineDataList[i].Change = 0; + klineDataList[i].PctChg = 0; + } + else + { + var preClose = klineDataList[i - 1].Close; + klineDataList[i].PreClose = preClose; + + if (preClose > 0) + { + klineDataList[i].Change = klineDataList[i].Close - preClose; + klineDataList[i].PctChg = (klineDataList[i].Change / preClose) * 100; + } + else + { + klineDataList[i].Change = 0; + klineDataList[i].PctChg = 0; + } + } + } + } +} diff --git a/src/MarketAssistant.App.Services/Applications/Charts/IKLineService.cs b/src/MarketAssistant.App.Services/Applications/Charts/IKLineService.cs new file mode 100644 index 0000000..ae772bc --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Charts/IKLineService.cs @@ -0,0 +1,24 @@ +using MarketAssistant.Applications.Charts.Models; + +namespace MarketAssistant.Applications.Charts; + +/// +/// K线数据服务接口(统一接口,支持多种时间周期) +/// +public interface IKLineService +{ + /// + /// 获取K线数据 + /// + /// 资产代码 + /// K线类型(时间周期) + /// 数据条数 + /// K线数据列表 + Task> GetKLineDataAsync(string code, KLineType kLineType, int count = 250); +} + + + + + + diff --git a/src/MarketAssistant.App.Services/Applications/Charts/KLineServiceDefaults.cs b/src/MarketAssistant.App.Services/Applications/Charts/KLineServiceDefaults.cs new file mode 100644 index 0000000..57cf117 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Charts/KLineServiceDefaults.cs @@ -0,0 +1,22 @@ +using MarketAssistant.Applications.Charts.Models; + +namespace MarketAssistant.Applications.Charts; + +/// +/// K线服务默认值,根据K线周期返回适合图表展示的默认数据条数 +/// +public static class KLineServiceDefaults +{ + /// + /// 根据K线周期返回适合图表展示的默认数据条数 + /// + public static int GetDefaultCount(KLineType kLineType) => kLineType switch + { + KLineType.Minute5 => 240, // 约 5 个交易日 + KLineType.Minute15 => 240, // 约 15 个交易日 + KLineType.Daily => 250, // 约 1 年(年线) + KLineType.Weekly => 150, // 约 3 年 + KLineType.Monthly => 120, // 约 10 年 + _ => 250 + }; +} diff --git a/src/Applications/Stocks/Models/StockKLineData.cs b/src/MarketAssistant.App.Services/Applications/Charts/Models/KLineData.cs similarity index 87% rename from src/Applications/Stocks/Models/StockKLineData.cs rename to src/MarketAssistant.App.Services/Applications/Charts/Models/KLineData.cs index 9885eef..dc6e90d 100644 --- a/src/Applications/Stocks/Models/StockKLineData.cs +++ b/src/MarketAssistant.App.Services/Applications/Charts/Models/KLineData.cs @@ -1,11 +1,11 @@ using System.Text.Json.Serialization; -namespace MarketAssistant.Applications.Stocks.Models; +namespace MarketAssistant.Applications.Charts.Models; /// -/// K线图数据模型 +/// K线图数据模型(通用) /// -public class StockKLineData +public class KLineData { /// /// 交易日期/时间 @@ -71,10 +71,10 @@ public class StockKLineData /// /// K线图数据集合 /// -public class StockKLineDataSet +public class KLineDataSet { /// - /// 股票代码 + /// 资产代码 /// public string Symbol { get; set; } = string.Empty; @@ -86,5 +86,11 @@ public class StockKLineDataSet /// /// K线数据列表 /// - public List Data { get; set; } = new List(); -} \ No newline at end of file + public List Data { get; set; } = new List(); +} + + + + + + diff --git a/src/Applications/Stocks/Models/KLineType.cs b/src/MarketAssistant.App.Services/Applications/Charts/Models/KLineType.cs similarity index 84% rename from src/Applications/Stocks/Models/KLineType.cs rename to src/MarketAssistant.App.Services/Applications/Charts/Models/KLineType.cs index 6e74690..1f94411 100644 --- a/src/Applications/Stocks/Models/KLineType.cs +++ b/src/MarketAssistant.App.Services/Applications/Charts/Models/KLineType.cs @@ -1,4 +1,4 @@ -namespace MarketAssistant.Applications.Stocks.Models; +namespace MarketAssistant.Applications.Charts.Models; /// /// K线类型枚举 @@ -9,24 +9,30 @@ public enum KLineType /// 5分钟K线 /// Minute5, - + /// /// 15分钟K线 /// Minute15, - + /// /// 日K线 /// Daily, - + /// /// 周K线 /// Weekly, - + /// /// 月K线 /// Monthly } + + + + + + diff --git a/src/MarketAssistant.App.Services/Applications/Crypto/BinanceAccountService.cs b/src/MarketAssistant.App.Services/Applications/Crypto/BinanceAccountService.cs new file mode 100644 index 0000000..dc2fbd2 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Crypto/BinanceAccountService.cs @@ -0,0 +1,382 @@ +using System.Globalization; +using System.Net.Http.Json; +using System.Text.Json; +using MarketAssistant.Infrastructure; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Applications.Crypto; + +/// +/// 币安账户服务(需要鉴权) +/// 示例:如何使用BinanceAuthService进行鉴权调用 +/// +public class BinanceAccountService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly BinanceAuthService _authService; + public BinanceAccountService( + IHttpClientFactory httpClientFactory, + ILogger logger, + BinanceAuthService authService) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _logger = logger; + _authService = authService; + } + + /// + /// 获取账户信息(需要USER_DATA权限) + /// API文档:https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/rest-api/account-endpoints#account-information-user_data + /// + public async Task GetAccountInfoAsync(CancellationToken cancellationToken = default) + { + try + { + // 1. 准备请求参数(本接口无额外参数,只需要签名) + var signedQuery = _authService.SignQueryString(""); + + // 2. 构建完整URL + var url = $"/api/v3/account?{signedQuery}"; + + // 3. 创建HTTP请求并添加鉴权Header + var request = new HttpRequestMessage(HttpMethod.Get, url); + _authService.AddAuthHeaders(request); + + // 4. 发送请求 + _logger.LogInformation("正在获取币安账户信息..."); + using var httpClient = _httpClientFactory.CreateClient("Binance"); + var response = await httpClient.SendAsync(request, cancellationToken); + await EnsureSuccessWithBinanceErrorAsync(response, "获取账户信息", cancellationToken); + + var accountInfo = await response.Content.ReadFromJsonAsync(cancellationToken); + + if (accountInfo == null) + { + throw new FriendlyException("解析账户信息失败"); + } + + _logger.LogInformation("成功获取账户信息,账户类型: {AccountType}", accountInfo.AccountType); + return accountInfo; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "获取账户信息失败 - 网络错误"); + throw new FriendlyException("获取账户信息失败: 网络连接错误", ex); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取账户信息失败"); + throw new FriendlyException($"获取账户信息失败: {ex.Message}", ex); + } + } + + /// + /// 下单示例(需要TRADE权限) + /// API文档:https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/rest-api/market-data-endpoints#place-new-order-trade + /// + /// 交易对,如BTCUSDT + /// 买卖方向:BUY或SELL + /// 订单类型:LIMIT、MARKET等 + /// 数量 + /// 价格(限价单需要) + /// 客户端自定义订单 ID,用于幂等性保护 + public async Task PlaceOrderAsync( + string symbol, + string side, + string type, + decimal quantity, + decimal? price = null, + string? clientOrderId = null, + CancellationToken cancellationToken = default) + { + try + { + // 下单前校验账户交易权限,避免账户被限制时产生无效请求 + var accountInfo = await GetAccountInfoAsync(cancellationToken); + if (!accountInfo.CanTrade) + { + throw new FriendlyException("账户当前被限制交易,无法下单(可能因违规、KYC 未完成或地区限制)"); + } + + // 1. 构建请求参数 + // 注意:数量/价格必须使用 InvariantCulture 格式化,否则在逗号小数区域(如 de-DE) + // 会生成 "1,23456789",币安 API 返回 -1100 非法字符错误。 + var parameters = new Dictionary + { + ["symbol"] = symbol.ToUpper(), + ["side"] = side.ToUpper(), + ["type"] = type.ToUpper(), + ["quantity"] = quantity.ToString("F8", CultureInfo.InvariantCulture) + }; + + // 幂等性:传入 newClientOrderId 后,币安对同一 ID 的重复请求返回同一订单而非新建 + if (!string.IsNullOrEmpty(clientOrderId)) + parameters["newClientOrderId"] = clientOrderId; + + // 限价单需要价格和timeInForce + if (type.ToUpper() == "LIMIT") + { + if (!price.HasValue) + { + throw new ArgumentException("限价单必须指定价格"); + } + parameters["price"] = price.Value.ToString("F8", CultureInfo.InvariantCulture); + parameters["timeInForce"] = "GTC"; // Good Till Cancel + } + + // 2. 将参数转换为query string格式 + var queryString = string.Join("&", + parameters.Select(kvp => $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}")); + + // 3. 签名 + var signedQuery = _authService.SignQueryString(queryString); + + // 4. 构建请求 + var url = $"/api/v3/order?{signedQuery}"; + var request = new HttpRequestMessage(HttpMethod.Post, url); + _authService.AddAuthHeaders(request); + + _logger.LogInformation("正在下单: {Symbol} {Side} {Type} 数量:{Quantity}", + symbol, side, type, quantity); + + using var httpClient = _httpClientFactory.CreateClient("Binance"); + var response = await httpClient.SendAsync(request, cancellationToken); + await EnsureSuccessWithBinanceErrorAsync(response, "下单", cancellationToken); + + // 6. 解析响应 + var orderResponse = await response.Content.ReadFromJsonAsync(cancellationToken); + + if (orderResponse == null) + { + throw new FriendlyException("解析订单响应失败"); + } + + _logger.LogInformation("下单成功,订单ID: {OrderId}, 状态: {Status}", + orderResponse.OrderId, orderResponse.Status); + + return orderResponse; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "下单失败 - 网络错误"); + throw new FriendlyException("下单失败: 网络连接错误", ex); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "下单失败"); + throw new FriendlyException($"下单失败: {ex.Message}", ex); + } + } + + /// + /// 查询订单状态(需要USER_DATA权限) + /// + public async Task GetOrderAsync(string symbol, long orderId, CancellationToken cancellationToken = default) + { + try + { + var queryString = $"symbol={symbol.ToUpper()}&orderId={orderId}"; + var signedQuery = _authService.SignQueryString(queryString); + var url = $"/api/v3/order?{signedQuery}"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + _authService.AddAuthHeaders(request); + + using var httpClient = _httpClientFactory.CreateClient("Binance"); + var response = await httpClient.SendAsync(request, cancellationToken); + await EnsureSuccessWithBinanceErrorAsync(response, "查询订单", cancellationToken); + + var orderResponse = await response.Content.ReadFromJsonAsync(cancellationToken) + ?? throw new FriendlyException("解析订单信息失败"); + + return orderResponse; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "查询订单失败 - 网络错误"); + throw new FriendlyException("查询订单失败: 网络连接错误", ex); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "查询订单失败"); + throw new FriendlyException($"查询订单失败: {ex.Message}", ex); + } + } + + /// + /// 取消订单(需要TRADE权限) + /// + public async Task CancelOrderAsync(string symbol, long orderId, CancellationToken cancellationToken = default) + { + try + { + var queryString = $"symbol={symbol.ToUpper()}&orderId={orderId}"; + var signedQuery = _authService.SignQueryString(queryString); + var url = $"/api/v3/order?{signedQuery}"; + + var request = new HttpRequestMessage(HttpMethod.Delete, url); + _authService.AddAuthHeaders(request); + + _logger.LogInformation("正在取消订单: {Symbol} OrderId:{OrderId}", symbol, orderId); + + using var httpClient = _httpClientFactory.CreateClient("Binance"); + var response = await httpClient.SendAsync(request, cancellationToken); + await EnsureSuccessWithBinanceErrorAsync(response, "取消订单", cancellationToken); + + var orderResponse = await response.Content.ReadFromJsonAsync(cancellationToken) + ?? throw new FriendlyException("解析取消订单响应失败"); + + _logger.LogInformation("取消订单成功,订单ID: {OrderId}", orderId); + return orderResponse; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "取消订单失败 - 网络错误"); + throw new FriendlyException("取消订单失败: 网络连接错误", ex); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "取消订单失败"); + throw new FriendlyException($"取消订单失败: {ex.Message}", ex); + } + } + + /// + /// 查询当前挂单(需要USER_DATA权限) + /// + public async Task> GetOpenOrdersAsync(string? symbol = null, CancellationToken cancellationToken = default) + { + try + { + var queryString = string.IsNullOrEmpty(symbol) ? "" : $"symbol={symbol.ToUpper()}"; + var signedQuery = _authService.SignQueryString(queryString); + var url = $"/api/v3/openOrders?{signedQuery}"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + _authService.AddAuthHeaders(request); + + using var httpClient = _httpClientFactory.CreateClient("Binance"); + var response = await httpClient.SendAsync(request, cancellationToken); + await EnsureSuccessWithBinanceErrorAsync(response, "查询挂单", cancellationToken); + + return await response.Content.ReadFromJsonAsync>(cancellationToken) ?? []; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "查询挂单失败 - 网络错误"); + throw new FriendlyException("查询挂单失败: 网络连接错误", ex); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "查询挂单失败"); + throw new FriendlyException($"查询挂单失败: {ex.Message}", ex); + } + } + + /// + /// 统一的币安响应错误处理:读取响应体并解析币安错误码, + /// 抛出包含详细信息的 FriendlyException,而非泛化的 HTTP 状态码错误。 + /// 币安错误响应格式:{"code":-1013,"msg":"..."} + /// + private async Task EnsureSuccessWithBinanceErrorAsync( + HttpResponseMessage response, string operation, CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + return; + + var statusCode = (int)response.StatusCode; + string errorDetail; + try + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var error = JsonSerializer.Deserialize(content); + errorDetail = error != null && !string.IsNullOrEmpty(error.Msg) + ? $"[{error.Code}] {error.Msg}" + : (string.IsNullOrEmpty(content) ? $"HTTP {statusCode}" : content); + } + catch + { + errorDetail = $"HTTP {statusCode}"; + } + + _logger.LogError("{Operation} 失败: {Detail}", operation, errorDetail); + throw new FriendlyException($"{operation}失败: {errorDetail}"); + } + + /// + /// 币安 REST API 错误响应模型 + /// + private sealed class BinanceApiError + { + public int Code { get; set; } + public string Msg { get; set; } = string.Empty; + } +} + +#region 响应模型 + +/// +/// 币安账户信息 +/// +public class BinanceAccountInfo +{ + public int MakerCommission { get; set; } + public int TakerCommission { get; set; } + public int BuyerCommission { get; set; } + public int SellerCommission { get; set; } + public bool CanTrade { get; set; } + public bool CanWithdraw { get; set; } + public bool CanDeposit { get; set; } + public long UpdateTime { get; set; } + public string AccountType { get; set; } = string.Empty; + public List Balances { get; set; } = new(); +} + +/// +/// 币安账户余额 +/// +public class BinanceBalance +{ + public string Asset { get; set; } = string.Empty; + public string Free { get; set; } = string.Empty; + public string Locked { get; set; } = string.Empty; +} + +/// +/// 币安订单响应 +/// +public class BinanceOrderResponse +{ + public string Symbol { get; set; } = string.Empty; + public long OrderId { get; set; } + public string ClientOrderId { get; set; } = string.Empty; + public long TransactTime { get; set; } + public string Price { get; set; } = string.Empty; + public string OrigQty { get; set; } = string.Empty; + public string ExecutedQty { get; set; } = string.Empty; + public string CummulativeQuoteQty { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string TimeInForce { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string Side { get; set; } = string.Empty; + + /// + /// 成交流水(仅 POST /api/v3/order 响应包含,查询接口不返回)。 + /// 用于汇总手续费。 + /// + public List Fills { get; set; } = []; +} + +/// +/// Binance 单笔成交流水 +/// +public class BinanceFill +{ + public string Price { get; set; } = string.Empty; + public string Qty { get; set; } = string.Empty; + public string Commission { get; set; } = string.Empty; + public string CommissionAsset { get; set; } = string.Empty; +} + +#endregion diff --git a/src/MarketAssistant.App.Services/Applications/Crypto/BinanceAuthService.cs b/src/MarketAssistant.App.Services/Applications/Crypto/BinanceAuthService.cs new file mode 100644 index 0000000..1079c66 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Crypto/BinanceAuthService.cs @@ -0,0 +1,107 @@ +using System.Security.Cryptography; +using System.Text; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Applications.Crypto; + +/// +/// 币安API鉴权配置(运行时快照,由 BinanceAuthService 每次从设置动态读取) +/// +public class BinanceAuthConfig +{ + public string ApiKey { get; set; } = string.Empty; + public string SecretKey { get; set; } = string.Empty; + + /// + /// 请求有效期窗口(毫秒,默认5000ms,最大60000ms) + /// + public long RecvWindow { get; set; } = 5000; +} + +/// +/// 币安API鉴权服务(HMAC-SHA256签名) +/// 每次操作时从 IUserSettingService 动态读取密钥,以支持运行时更改 +/// +public class BinanceAuthService +{ + private readonly ILogger _logger; + private readonly IUserSettingService _userSettingService; + private readonly long _recvWindow; + + public BinanceAuthService(ILogger logger, IUserSettingService userSettingService) + { + _logger = logger; + _userSettingService = userSettingService; + _recvWindow = 5000; + } + + private BinanceAuthConfig CurrentConfig => new() + { + ApiKey = _userSettingService.CurrentSetting.BinanceApiKey, + SecretKey = _userSettingService.CurrentSetting.BinanceSecretKey, + RecvWindow = _recvWindow + }; + + /// + /// API 密钥是否已配置 + /// + public bool IsConfigured + { + get + { + var cfg = CurrentConfig; + return !string.IsNullOrEmpty(cfg.ApiKey) && !string.IsNullOrEmpty(cfg.SecretKey); + } + } + + /// + /// 为请求参数添加签名(URL query string格式) + /// + /// 已有的查询参数(不包含?),如 "symbol=BTCUSDT&side=BUY" + /// 添加了timestamp和signature的完整查询字符串 + public string SignQueryString(string queryString) + { + var config = CurrentConfig; + EnsureConfigured(config); + + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var payload = string.IsNullOrEmpty(queryString) + ? $"timestamp={timestamp}" + : $"{queryString}×tamp={timestamp}"; + + if (config.RecvWindow != 5000) + { + payload += $"&recvWindow={config.RecvWindow}"; + } + + var signature = GenerateSignature(payload, config.SecretKey); + return $"{payload}&signature={signature}"; + } + + /// + /// 为HttpClient请求添加必要的Headers + /// + public void AddAuthHeaders(HttpRequestMessage request) + { + var config = CurrentConfig; + EnsureConfigured(config); + request.Headers.Add("X-MBX-APIKEY", config.ApiKey); + } + + private static string GenerateSignature(string payload, string secretKey) + { + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + + private static void EnsureConfigured(BinanceAuthConfig config) + { + if (string.IsNullOrEmpty(config.ApiKey)) + throw new InvalidOperationException("Binance API Key 未配置,请在设置页面配置"); + + if (string.IsNullOrEmpty(config.SecretKey)) + throw new InvalidOperationException("Binance Secret Key 未配置,请在设置页面配置"); + } +} diff --git a/src/MarketAssistant.App.Services/Applications/Favorites/FavoriteService.cs b/src/MarketAssistant.App.Services/Applications/Favorites/FavoriteService.cs new file mode 100644 index 0000000..ec586fa --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Favorites/FavoriteService.cs @@ -0,0 +1,239 @@ +using CommunityToolkit.Mvvm.Messaging; +using MarketAssistant.Applications.Assets; +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Data.Sqlite; +using static MarketAssistant.Infrastructure.Core.CryptoSymbolConverter; + +namespace MarketAssistant.Applications.Favorites; + +/// +/// 收藏服务:封装 SQLite 持久化与最新行情查询的共同行为。 +/// 通过 从 Keyed DI 注册键自动获取市场类型。 +/// +public sealed class FavoriteService : SqliteServiceBase, IFavoriteService +{ + private readonly IAssetInfoService _assetInfoService; + private readonly ILogger _logger; + private readonly MarketType _marketType; + private readonly string _marketLabel; + + public FavoriteService( + [ServiceKey] MarketType marketType, + IServiceProvider serviceProvider, + ILogger logger) + : base(logger) + { + _marketType = marketType; + _assetInfoService = serviceProvider.GetRequiredKeyedService(marketType); + _logger = logger; + _marketLabel = marketType switch + { + MarketType.AShare => "A股", + MarketType.Crypto => "虚拟币", + _ => marketType.ToString() + }; + } + + protected override async Task InitializeDatabaseAsync() + { + await using var conn = await OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS favorite_assets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL, + market TEXT NOT NULL, + market_type INTEGER NOT NULL, + created_at TEXT NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_fav_code_mt ON favorite_assets(code, market_type); + CREATE INDEX IF NOT EXISTS idx_fav_mt ON favorite_assets(market_type); + """; + await cmd.ExecuteNonQueryAsync(); + } + + public async Task AddFavoriteAsync(string code, string market, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(code)) + return; + + code = code.Trim(); + market = market.Trim(); + + try + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT OR IGNORE INTO favorite_assets (code, market, market_type, created_at) + VALUES (@code, @market, @marketType, @createdAt) + """; + cmd.Parameters.AddWithValue("@code", code); + cmd.Parameters.AddWithValue("@market", market); + cmd.Parameters.AddWithValue("@marketType", (int)_marketType); + cmd.Parameters.AddWithValue("@createdAt", DateTime.UtcNow.ToString("O")); + await cmd.ExecuteNonQueryAsync(cancellationToken); + + WeakReferenceMessenger.Default.Send(new AssetFavoritesChanged()); + _logger.LogInformation("已添加{Market}到收藏: {Code}", _marketLabel, code); + } + catch (Exception ex) + { + _logger.LogError(ex, "保存收藏{Market}时出错: {Message}", _marketLabel, ex.Message); + } + } + + public async Task RemoveFavoriteAsync(string code, string market, CancellationToken cancellationToken = default) + { + code = code.Trim(); + market = market.Trim(); + + try + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + DELETE FROM favorite_assets WHERE code = @code AND market_type = @marketType + """; + cmd.Parameters.AddWithValue("@code", code); + cmd.Parameters.AddWithValue("@marketType", (int)_marketType); + var affected = await cmd.ExecuteNonQueryAsync(cancellationToken); + + if (affected > 0) + { + WeakReferenceMessenger.Default.Send(new AssetFavoritesChanged()); + _logger.LogInformation("已从收藏中移除{Market}: {Code}", _marketLabel, code); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "移除收藏{Market}时出错: {Message}", _marketLabel, ex.Message); + } + } + + public async Task IsFavoriteAsync(string code, string market, CancellationToken cancellationToken = default) + { + code = code.Trim(); + market = market.Trim(); + + try + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT COUNT(1) FROM favorite_assets WHERE code = @code AND market_type = @marketType + """; + cmd.Parameters.AddWithValue("@code", code); + cmd.Parameters.AddWithValue("@marketType", (int)_marketType); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + return result is long count && count > 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "检查收藏{Market}时出错: {Message}", _marketLabel, ex.Message); + return false; + } + } + + public async Task> GetFavoritesCodesAsync(CancellationToken cancellationToken = default) + { + try + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT code, market FROM favorite_assets WHERE market_type = @marketType ORDER BY created_at + """; + cmd.Parameters.AddWithValue("@marketType", (int)_marketType); + + var list = new List(); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + list.Add(new FavoriteAsset + { + Code = reader.GetString(0).Trim(), + Market = reader.GetString(1).Trim() + }); + } + return list; + } + catch (Exception ex) + { + _logger.LogError(ex, "获取收藏{Market}时出错: {Message}", _marketLabel, ex.Message); + return []; + } + } + + public async Task> GetFavoritesWithLatestDataAsync(CancellationToken cancellationToken = default) + { + var favoritesCodes = await GetFavoritesCodesAsync(cancellationToken); + if (favoritesCodes.Count == 0) + return []; + + var tasks = favoritesCodes.Select(async favorite => + { + try + { + return await _assetInfoService.GetAssetInfoAsync(favorite.Code, favorite.Market, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "获取{Market} {Code} 最新数据时出错: {Message}", _marketLabel, favorite.Code, ex.Message); + return CreateFallbackAssetInfo(favorite); + } + }); + + var results = await Task.WhenAll(tasks); + return results.Where(result => result != null).ToList(); + } + + public async Task ClearFavoritesAsync(CancellationToken cancellationToken = default) + { + try + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM favorite_assets WHERE market_type = @marketType"; + cmd.Parameters.AddWithValue("@marketType", (int)_marketType); + await cmd.ExecuteNonQueryAsync(cancellationToken); + + WeakReferenceMessenger.Default.Send(new AssetFavoritesChanged()); + _logger.LogInformation("已清空所有收藏{Market}", _marketLabel); + } + catch (Exception ex) + { + _logger.LogError(ex, "清空收藏{Market}时出错: {Message}", _marketLabel, ex.Message); + } + } + + /// + /// 行情获取失败时的兜底 AssetInfo:A股使用 Market.Code 形式,虚拟币使用基础币种名称。 + /// + private AssetInfo CreateFallbackAssetInfo(FavoriteAsset favorite) + { + var displayName = _marketType switch + { + MarketType.Crypto => ExtractBaseCurrency(favorite.Code), + _ => string.IsNullOrWhiteSpace(favorite.Market) + ? favorite.Code + : $"{favorite.Market}.{favorite.Code}" + }; + + return new AssetInfo + { + Code = favorite.Code, + Market = favorite.Market, + Name = displayName, + MarketType = _marketType + }; + } +} diff --git a/src/MarketAssistant.App.Services/Applications/Favorites/IFavoriteService.cs b/src/MarketAssistant.App.Services/Applications/Favorites/IFavoriteService.cs new file mode 100644 index 0000000..314fbf6 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Favorites/IFavoriteService.cs @@ -0,0 +1,39 @@ +using MarketAssistant.Applications.Assets.Models; + +namespace MarketAssistant.Applications.Favorites; + +/// +/// 收藏服务接口 +/// +public interface IFavoriteService +{ + /// + /// 添加资产到收藏 + /// + Task AddFavoriteAsync(string code, string market, CancellationToken cancellationToken = default); + + /// + /// 从收藏中移除资产 + /// + Task RemoveFavoriteAsync(string code, string market, CancellationToken cancellationToken = default); + + /// + /// 检查资产是否已收藏 + /// + Task IsFavoriteAsync(string code, string market, CancellationToken cancellationToken = default); + + /// + /// 获取所有收藏的资产代码 + /// + Task> GetFavoritesCodesAsync(CancellationToken cancellationToken = default); + + /// + /// 获取所有收藏的资产(包含最新数据) + /// + Task> GetFavoritesWithLatestDataAsync(CancellationToken cancellationToken = default); + + /// + /// 清空所有收藏 + /// + Task ClearFavoritesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/MarketAssistant.App.Services/Applications/History/AssetHistoryService.cs b/src/MarketAssistant.App.Services/Applications/History/AssetHistoryService.cs new file mode 100644 index 0000000..5833a83 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/History/AssetHistoryService.cs @@ -0,0 +1,160 @@ +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Applications.History; + +/// +/// 资产历史记录服务:封装 SQLite 持久化与容量控制逻辑。 +/// 通过 从 Keyed DI 注册键自动获取市场类型。 +/// +public sealed class AssetHistoryService : SqliteServiceBase, IAssetHistoryService +{ + private const int MaxHistoryCount = 10; + private readonly ILogger _logger; + private readonly MarketType _marketType; + private readonly string _marketLabel; + + public AssetHistoryService([ServiceKey] MarketType marketType, ILogger logger) + : base(logger) + { + _marketType = marketType; + _logger = logger; + _marketLabel = marketType switch + { + MarketType.AShare => "A股", + MarketType.Crypto => "虚拟币", + _ => marketType.ToString() + }; + } + + protected override async Task InitializeDatabaseAsync() + { + await using var conn = await OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS recent_assets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL, + name TEXT NOT NULL, + market_type INTEGER NOT NULL, + viewed_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_recent_mt_time ON recent_assets(market_type, viewed_at DESC); + """; + await cmd.ExecuteNonQueryAsync(); + } + + public async Task AddHistoryAsync(AssetItem asset, CancellationToken cancellationToken = default) + { + if (asset == null || string.IsNullOrWhiteSpace(asset.Code)) + return; + + try + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(cancellationToken); + + // 移除同 code 的旧记录,再插入新记录 + await using (var delCmd = conn.CreateCommand()) + { + delCmd.CommandText = "DELETE FROM recent_assets WHERE code = @code AND market_type = @marketType"; + delCmd.Parameters.AddWithValue("@code", asset.Code); + delCmd.Parameters.AddWithValue("@marketType", (int)_marketType); + await delCmd.ExecuteNonQueryAsync(cancellationToken); + } + + await using (var insCmd = conn.CreateCommand()) + { + insCmd.CommandText = """ + INSERT INTO recent_assets (code, name, market_type, viewed_at) + VALUES (@code, @name, @marketType, @viewedAt) + """; + insCmd.Parameters.AddWithValue("@code", asset.Code); + insCmd.Parameters.AddWithValue("@name", asset.Name); + insCmd.Parameters.AddWithValue("@marketType", (int)_marketType); + insCmd.Parameters.AddWithValue("@viewedAt", DateTime.UtcNow.ToString("O")); + await insCmd.ExecuteNonQueryAsync(cancellationToken); + } + + // 截断:只保留最新的 MaxHistoryCount 条 + await using (var trimCmd = conn.CreateCommand()) + { + trimCmd.CommandText = """ + DELETE FROM recent_assets + WHERE market_type = @marketType + AND id NOT IN ( + SELECT id FROM recent_assets + WHERE market_type = @marketType + ORDER BY viewed_at DESC + LIMIT @maxCount + ) + """; + trimCmd.Parameters.AddWithValue("@marketType", (int)_marketType); + trimCmd.Parameters.AddWithValue("@maxCount", MaxHistoryCount); + await trimCmd.ExecuteNonQueryAsync(cancellationToken); + } + + _logger.LogInformation("已添加{Market}到历史记录: {Code}", _marketLabel, asset.Code); + } + catch (Exception ex) + { + _logger.LogError(ex, "保存历史记录时出错: {Message}", ex.Message); + } + } + + public async Task> GetHistoryAsync(CancellationToken cancellationToken = default) + { + try + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT code, name FROM recent_assets + WHERE market_type = @marketType + ORDER BY viewed_at DESC + LIMIT @maxCount + """; + cmd.Parameters.AddWithValue("@marketType", (int)_marketType); + cmd.Parameters.AddWithValue("@maxCount", MaxHistoryCount); + + var list = new List(); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + list.Add(new AssetItem + { + Code = reader.GetString(0), + Name = reader.GetString(1) + }); + } + return list; + } + catch (Exception ex) + { + _logger.LogError(ex, "获取历史记录时出错: {Message}", ex.Message); + return []; + } + } + + public async Task ClearHistoryAsync(CancellationToken cancellationToken = default) + { + try + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM recent_assets WHERE market_type = @marketType"; + cmd.Parameters.AddWithValue("@marketType", (int)_marketType); + await cmd.ExecuteNonQueryAsync(cancellationToken); + + _logger.LogInformation("已清空{Market}历史记录", _marketLabel); + } + catch (Exception ex) + { + _logger.LogError(ex, "清空历史记录时出错: {Message}", ex.Message); + } + } +} diff --git a/src/MarketAssistant.App.Services/Applications/History/IAssetHistoryService.cs b/src/MarketAssistant.App.Services/Applications/History/IAssetHistoryService.cs new file mode 100644 index 0000000..d1cfc6a --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/History/IAssetHistoryService.cs @@ -0,0 +1,24 @@ +using MarketAssistant.Applications.Assets.Models; + +namespace MarketAssistant.Applications.History; + +/// +/// 资产历史记录服务接口 +/// +public interface IAssetHistoryService +{ + /// + /// 添加一条资产访问记录 + /// + Task AddHistoryAsync(AssetItem asset, CancellationToken cancellationToken = default); + + /// + /// 获取历史记录列表 + /// + Task> GetHistoryAsync(CancellationToken cancellationToken = default); + + /// + /// 清空历史记录 + /// + Task ClearHistoryAsync(CancellationToken cancellationToken = default); +} diff --git a/src/MarketAssistant.App.Services/Applications/Home/HomeAssetService.cs b/src/MarketAssistant.App.Services/Applications/Home/HomeAssetService.cs new file mode 100644 index 0000000..e3d1cfe --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Home/HomeAssetService.cs @@ -0,0 +1,134 @@ +using MarketAssistant.Applications.Assets; +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Applications.Favorites; +using MarketAssistant.Applications.History; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Dialog; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Applications.Home; + +/// +/// 首页资产服务:封装搜索、热门、历史与收藏流程。 +/// 通过 从 Keyed DI 注册键自动获取市场类型, +/// 构造时一次性解析同市场的 keyed 依赖。 +/// +public sealed class HomeAssetService : IHomeAssetService +{ + private readonly IAssetInfoService _assetInfoService; + private readonly IAssetHistoryService _historyService; + private readonly IFavoriteService _favoriteService; + private readonly IDialogService _dialogService; + private readonly ILogger _logger; + private readonly MarketType _marketType; + + public HomeAssetService( + [ServiceKey] MarketType marketType, + IServiceProvider serviceProvider, + IDialogService dialogService, + ILogger logger) + { + _marketType = marketType; + _assetInfoService = serviceProvider.GetRequiredKeyedService(marketType); + _historyService = serviceProvider.GetRequiredKeyedService(marketType); + _favoriteService = serviceProvider.GetRequiredKeyedService(marketType); + _dialogService = dialogService; + _logger = logger; + } + + public async Task> SearchAssetAsync(string query, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(query)) + return []; + + var results = await _assetInfoService.SearchAsync(query, cancellationToken); + return results.Select(asset => new AssetItem { Name = asset.Name, Code = asset.Code }).ToList(); + } + + public Task> GetHotAssetsAsync() + => _assetInfoService.GetHotAssetsAsync(); + + public Task> GetRecentAssetsAsync(CancellationToken cancellationToken = default) + => _historyService.GetHistoryAsync(cancellationToken); + + public async Task AddToRecentAssetsAsync(AssetItem asset, CancellationToken cancellationToken = default) + => await _historyService.AddHistoryAsync(asset, cancellationToken); + + public async Task AddToFavoriteAsync(object assetParameter) + { + if (!TryMapFavoriteAsset(assetParameter, out var code, out var market, out var assetName, out var errorMessage)) + { + if (!string.IsNullOrWhiteSpace(errorMessage)) + { + _logger.LogWarning("添加到收藏失败: {Reason}", errorMessage); + await _dialogService.ShowMessageAsync("错误", errorMessage); + } + return false; + } + + if (await _favoriteService.IsFavoriteAsync(code, market)) + { + var alreadyMsg = _marketType == MarketType.Crypto + ? "该虚拟币已在收藏列表中" + : "该资产已在收藏列表中"; + await _dialogService.ShowMessageAsync("提示", alreadyMsg); + return false; + } + + var confirmed = await _dialogService.ShowConfirmationAsync( + "添加收藏", + $"确定要将 {assetName} 添加到收藏列表吗?", + "确认", + "取消"); + + if (!confirmed) + return false; + + await _favoriteService.AddFavoriteAsync(code, market); + await _dialogService.ShowMessageAsync("收藏成功", $"已将 {assetName} 添加到收藏列表"); + return true; + } + + private bool TryMapFavoriteAsset( + object assetParameter, + out string code, + out string market, + out string assetName, + out string? errorMessage) + { + errorMessage = null; + code = string.Empty; + market = string.Empty; + assetName = string.Empty; + + if (assetParameter is HotAsset hotAsset) + { + assetName = hotAsset.Name; + code = hotAsset.Code; + market = _marketType == MarketType.AShare ? hotAsset.Market : string.Empty; + return true; + } + + if (assetParameter is AssetItem assetItem) + { + assetName = assetItem.Name; + code = assetItem.Code; + + if (_marketType == MarketType.AShare) + { + if (code.StartsWith("sh", StringComparison.OrdinalIgnoreCase) + || code.StartsWith("sz", StringComparison.OrdinalIgnoreCase)) + { + market = code[..2].ToUpperInvariant(); + code = code[2..]; + } + } + + return true; + } + + errorMessage = "添加到收藏失败:参数类型不匹配"; + return false; + } +} diff --git a/src/MarketAssistant.App.Services/Applications/Home/IHomeAssetService.cs b/src/MarketAssistant.App.Services/Applications/Home/IHomeAssetService.cs new file mode 100644 index 0000000..8adec09 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Home/IHomeAssetService.cs @@ -0,0 +1,40 @@ +using MarketAssistant.Applications.Assets.Models; + +namespace MarketAssistant.Applications.Home; + +/// +/// 首页资产服务接口 +/// +public interface IHomeAssetService +{ + /// + /// 搜索资产 + /// + Task> SearchAssetAsync(string query, CancellationToken cancellationToken = default); + + /// + /// 获取热门资产 + /// + Task> GetHotAssetsAsync(); + + /// + /// 获取最近查看的资产 + /// + Task> GetRecentAssetsAsync(CancellationToken cancellationToken = default); + + /// + /// 添加到最近查看 + /// + Task AddToRecentAssetsAsync(AssetItem asset, CancellationToken cancellationToken = default); + + /// + /// 添加到收藏 + /// + Task AddToFavoriteAsync(object assetParameter); +} + + + + + + diff --git a/src/MarketAssistant.App.Services/Applications/InvestmentSelection/InvestmentSelectionService.cs b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/InvestmentSelectionService.cs new file mode 100644 index 0000000..4c058fd --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/InvestmentSelectionService.cs @@ -0,0 +1,415 @@ +using MarketAssistant.Agents.InvestmentSelection; +using MarketAssistant.Applications.InvestmentSelection.Models; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Applications.InvestmentSelection; + +/// +/// AI投资选择服务 - 业务逻辑层,负责对外API和业务规则 +/// 使用 Agent Framework Workflows 实现确定性投资选择流程 +/// 支持股票和虚拟币市场 +/// +public class InvestmentSelectionService : IDisposable +{ + private readonly InvestmentSelectionWorkflow _selectionWorkflow; + private readonly ILogger _logger; + private bool _disposed = false; + + public InvestmentSelectionService( + InvestmentSelectionWorkflow selectionWorkflow, + ILogger logger) + { + _selectionWorkflow = selectionWorkflow ?? throw new ArgumentNullException(nameof(selectionWorkflow)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + #region 业务API接口 + + /// + /// 功能1: 根据用户需求推荐投资标的 + /// + public async Task RecommendByUserRequirementAsync( + InvestmentRecommendationRequest request, + CancellationToken cancellationToken = default) + { + if (request == null || string.IsNullOrWhiteSpace(request.UserRequirements)) + { + throw new ArgumentException("用户需求不能为空", nameof(request)); + } + + try + { + _logger.LogInformation("开始基于用户需求的AI投资选择,市场: {Market},需求: {Requirements}", + request.MarketType, request.UserRequirements); + + var validatedRequest = ValidateAndNormalizeUserRequest(request); + + var result = await _selectionWorkflow.AnalyzeUserRequirementAsync(validatedRequest, cancellationToken); + + var optimizedResult = OptimizeUserBasedResult(result, validatedRequest); + + _logger.LogInformation("用户需求投资选择完成,市场: {Market},推荐数量: {Count},置信度: {Confidence:F1}%", + request.MarketType, optimizedResult.Recommendations.Count, optimizedResult.ConfidenceScore); + + return optimizedResult; + } + catch (Exception ex) + { + _logger.LogError(ex, "基于用户需求的投资选择过程中发生错误"); + throw; + } + } + + /// + /// 功能2: 根据新闻推荐投资标的 + /// + public async Task RecommendByNewsAsync( + NewsBasedInvestmentRequest request, + CancellationToken cancellationToken = default) + { + if (request == null) + { + request = new NewsBasedInvestmentRequest(); + } + + try + { + _logger.LogInformation("开始基于热点新闻的AI投资选择,市场: {Market},推荐数: {Max}", + request.MarketType, request.MaxRecommendations); + + var validatedRequest = ValidateAndNormalizeNewsRequest(request); + + var result = await _selectionWorkflow.AnalyzeNewsHotspotAsync(validatedRequest, cancellationToken); + + var optimizedResult = OptimizeNewsBasedResult(result, validatedRequest); + + _logger.LogInformation("热点新闻投资选择完成,市场: {Market},推荐数量: {Count},置信度: {Confidence:F1}%", + request.MarketType, optimizedResult.Recommendations.Count, optimizedResult.ConfidenceScore); + + return optimizedResult; + } + catch (Exception ex) + { + _logger.LogError(ex, "基于热点新闻的投资选择过程中发生错误"); + throw; + } + } + + /// + /// 功能4: 快速选择(预设策略) + /// + public async Task QuickSelectAsync( + QuickSelectionStrategy strategy, + MarketType marketType, + CancellationToken cancellationToken = default) + { + try + { + _logger.LogInformation("开始执行快速选择,策略: {Strategy}, 市场: {Market}", strategy, marketType); + + var request = ConvertStrategyToUserRequest(strategy, marketType); + + var result = await RecommendByUserRequirementAsync(request, cancellationToken); + + _logger.LogInformation("快速选择完成,策略: {Strategy},结果数量: {Count}", + strategy, result.Recommendations.Count); + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "执行快速选择时发生错误,策略: {Strategy}", strategy); + throw; + } + } + + /// + /// 功能5: 获取快速选择策略列表(根据市场类型返回不同策略) + /// + public List GetQuickSelectionStrategies(MarketType marketType) + { + return marketType switch + { + MarketType.AShare => GetStockStrategies(), + MarketType.Crypto => GetCryptoStrategies(), + _ => throw new NotSupportedException($"不支持的市场类型: {marketType}") + }; + } + + /// + /// 获取股票市场预设策略 + /// + private List GetStockStrategies() + { + return new List + { + new QuickSelectionStrategyInfo + { + Strategy = QuickSelectionStrategy.ValueInvestment, + Name = "价值投资", + Icon = "💎", + Description = "筛选PE低、PB低、ROE高的优质价值标的", + Scenario = "适合稳健型投资者,追求长期价值投资", + RiskLevel = "低风险" + }, + new QuickSelectionStrategyInfo + { + Strategy = QuickSelectionStrategy.GrowthInvestment, + Name = "成长投资", + Icon = "🚀", + Description = "筛选营收和利润高增长的成长型标的", + Scenario = "适合积极型投资者,追求高成长收益", + RiskLevel = "中高风险" + }, + new QuickSelectionStrategyInfo + { + Strategy = QuickSelectionStrategy.ActiveTrading, + Name = "活跃标的", + Icon = "🔥", + Description = "筛选换手率高、成交活跃的热门标的", + Scenario = "适合短线交易者,追求市场热点", + RiskLevel = "高风险" + }, + new QuickSelectionStrategyInfo + { + Strategy = QuickSelectionStrategy.LargeCap, + Name = "大盘标的", + Icon = "🏢", + Description = "筛选市值大、业绩稳定的蓝筹标的", + Scenario = "适合保守型投资者,追求稳定收益", + RiskLevel = "低风险" + }, + new QuickSelectionStrategyInfo + { + Strategy = QuickSelectionStrategy.SmallCap, + Name = "小盘标的", + Icon = "🌱", + Description = "筛选市值较小、具有成长潜力的标的", + Scenario = "适合风险偏好较高的投资者", + RiskLevel = "高风险" + }, + new QuickSelectionStrategyInfo + { + Strategy = QuickSelectionStrategy.HighYield, + Name = "高股息", + Icon = "💰", + Description = "筛选股息率高、分红稳定的高股息标的", + Scenario = "适合追求稳定现金流的投资者", + RiskLevel = "低风险" + } + }; + } + + /// + /// 获取虚拟币市场预设策略 + /// + private List GetCryptoStrategies() + { + return new List + { + new QuickSelectionStrategyInfo + { + Strategy = QuickSelectionStrategy.ValueInvestment, + Name = "价值币种", + Icon = "💎", + Description = "筛选市值大、技术成熟、社区活跃的主流币种", + Scenario = "适合稳健型投资者,追求长期持有价值币", + RiskLevel = "低风险" + }, + new QuickSelectionStrategyInfo + { + Strategy = QuickSelectionStrategy.GrowthInvestment, + Name = "高成长币", + Icon = "🚀", + Description = "筛选7日/30日涨幅较高、交易量增长的高成长币种", + Scenario = "适合积极型投资者,追求高收益潜力币", + RiskLevel = "中高风险" + }, + new QuickSelectionStrategyInfo + { + Strategy = QuickSelectionStrategy.ActiveTrading, + Name = "热门币种", + Icon = "🔥", + Description = "筛选24h交易量大、价格波动活跃的热门币种", + Scenario = "适合短线交易者,追捧市场热点币", + RiskLevel = "高风险" + }, + new QuickSelectionStrategyInfo + { + Strategy = QuickSelectionStrategy.LargeCap, + Name = "主流大币", + Icon = "🏢", + Description = "筛选市值排名前50、流动性充足的蓝筹主流币", + Scenario = "适合保守型投资者,追求稳定的主流币", + RiskLevel = "低风险" + }, + new QuickSelectionStrategyInfo + { + Strategy = QuickSelectionStrategy.SmallCap, + Name = "潜力小币", + Icon = "🌱", + Description = "筛选市值排名100-500、具有创新性的潜力币种", + Scenario = "适合风险偏好较高的投资者,寻找黑马币", + RiskLevel = "高风险" + }, + new QuickSelectionStrategyInfo + { + Strategy = QuickSelectionStrategy.HighYield, + Name = "高波动币", + Icon = "⚡", + Description = "筛选24h价格波动大于±5%的高波动币种", + Scenario = "适合激进型交易者,追求短期高波动收益", + RiskLevel = "高风险" + } + }; + } + + #endregion + + #region 业务逻辑处理 + + private InvestmentRecommendationRequest ValidateAndNormalizeUserRequest(InvestmentRecommendationRequest request) + { + var normalized = new InvestmentRecommendationRequest + { + MarketType = request.MarketType, + UserRequirements = request.UserRequirements?.Trim() ?? "", + InvestmentAmount = request.InvestmentAmount, + RiskPreference = NormalizeRiskPreference(request.RiskPreference), + InvestmentHorizon = request.InvestmentHorizon, + PreferredSectors = request.PreferredSectors, + ExcludedSectors = request.ExcludedSectors, + MaxRecommendations = request.MaxRecommendations + }; + + if (string.IsNullOrWhiteSpace(normalized.RiskPreference)) + { + normalized.RiskPreference = "moderate"; + } + + return normalized; + } + + private NewsBasedInvestmentRequest ValidateAndNormalizeNewsRequest(NewsBasedInvestmentRequest request) + { + var normalized = new NewsBasedInvestmentRequest + { + MarketType = request.MarketType, + NewsContent = request.NewsContent?.Trim() ?? "", + MaxRecommendations = Math.Max(1, Math.Min(10, request.MaxRecommendations)) + }; + + return normalized; + } + + private InvestmentSelectionResult OptimizeUserBasedResult(InvestmentSelectionResult result, InvestmentRecommendationRequest request) + { + if (request.RiskPreference == "conservative") + { + result.Recommendations = result.Recommendations + .Where(r => r.RiskLevel != RiskLevel.High) + .ToList(); + } + else if (request.RiskPreference == "aggressive") + { + result.Recommendations = result.Recommendations + .OrderByDescending(r => r.ExpectedReturn ?? 0) + .ToList(); + } + + return result; + } + + private InvestmentSelectionResult OptimizeNewsBasedResult(InvestmentSelectionResult result, NewsBasedInvestmentRequest request) + { + if (result.Recommendations.Count > request.MaxRecommendations) + { + result.Recommendations = result.Recommendations + .Take(request.MaxRecommendations) + .ToList(); + } + + foreach (var recommendation in result.Recommendations) + { + recommendation.Reason = $"[新闻热点] {recommendation.Reason}"; + } + + return result; + } + + private InvestmentRecommendationRequest ConvertStrategyToUserRequest(QuickSelectionStrategy strategy, MarketType marketType) + { + var (requirements, riskPreference) = (marketType, strategy) switch + { + // 股票市场策略 + (MarketType.AShare, QuickSelectionStrategy.ValueInvestment) => + ("请筛选价值型标的:PE低于20,PB低于3,ROE大于10%的优质价值标的", "conservative"), + (MarketType.AShare, QuickSelectionStrategy.GrowthInvestment) => + ("请筛选成长型标的:营收增长率大于20%,净利润增长率大于15%的高成长标的", "aggressive"), + (MarketType.AShare, QuickSelectionStrategy.ActiveTrading) => + ("请筛选活跃标的:换手率大于2%,成交额大,量比大于1.5的活跃标的", "moderate"), + (MarketType.AShare, QuickSelectionStrategy.LargeCap) => + ("请筛选大盘标的:市值大,流动性好,业绩稳定的大盘蓝筹标的", "conservative"), + (MarketType.AShare, QuickSelectionStrategy.SmallCap) => + ("请筛选小盘标的:市值较小,具有成长潜力的优质小盘标的", "aggressive"), + (MarketType.AShare, QuickSelectionStrategy.HighYield) => + ("请筛选高收益标的:股息率大于3%,分红稳定的高股息标的", "conservative"), + + // 虚拟币市场策略 + (MarketType.Crypto, QuickSelectionStrategy.ValueInvestment) => + ("请筛选主流价值币种:市值排名前30,存在时间超过3年,社区活跃度高,技术成熟的价值币", "conservative"), + (MarketType.Crypto, QuickSelectionStrategy.GrowthInvestment) => + ("请筛选高成长币种:7日涨幅大于10%,30日涨幅大于20%,24h交易量增长大于50%的高成长币", "aggressive"), + (MarketType.Crypto, QuickSelectionStrategy.ActiveTrading) => + ("请筛选热门活跃币种:24h交易量大于1亿美元,价格波动大于5%,社交媒体讨论度高的热门币", "moderate"), + (MarketType.Crypto, QuickSelectionStrategy.LargeCap) => + ("请筛选主流大币:市值排名前50,市值大于100亿美元,流动性充足,风险相对较低的主流币", "conservative"), + (MarketType.Crypto, QuickSelectionStrategy.SmallCap) => + ("请筛选潜力小币:市值排名100-500,上市时间1-3年,技术创新性强,具有成长潜力的小市值币", "aggressive"), + (MarketType.Crypto, QuickSelectionStrategy.HighYield) => + ("请筛选高波动币种:24小时价格变化绝对值大于5%,波动活跃,适合短线交易的币种", "aggressive"), + + _ => throw new ArgumentException($"不支持的策略或市场类型: {strategy}, {marketType}") + }; + + return new InvestmentRecommendationRequest + { + MarketType = marketType, + UserRequirements = requirements, + RiskPreference = riskPreference + }; + } + + private string NormalizeRiskPreference(string riskPreference) + { + return riskPreference?.ToLower() switch + { + "conservative" or "保守" or "低风险" => "conservative", + "aggressive" or "激进" or "高风险" => "aggressive", + "moderate" or "稳健" or "中等风险" or "中风险" => "moderate", + _ => "moderate" + }; + } + + #endregion + + #region 资源管理 + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed && disposing) + { + _selectionWorkflow?.Dispose(); + _disposed = true; + } + } + + #endregion +} + diff --git a/src/Applications/StockSelection/Models/StockRecommendation.cs b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/InvestmentRecommendation.cs similarity index 76% rename from src/Applications/StockSelection/Models/StockRecommendation.cs rename to src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/InvestmentRecommendation.cs index 8d873a9..e744961 100644 --- a/src/Applications/StockSelection/Models/StockRecommendation.cs +++ b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/InvestmentRecommendation.cs @@ -1,25 +1,24 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; -namespace MarketAssistant.Applications.StockSelection.Models; +namespace MarketAssistant.Applications.InvestmentSelection.Models; /// -/// 股票推荐结果 +/// 投资标的推荐结果 /// -[Description("单只股票的推荐详情")] -public class StockRecommendation +[Description("单个投资标的的推荐详情")] +public class InvestmentRecommendation { /// - /// 股票代码 + /// 资产代码(股票如 SH600000,虚拟币如 BTC/USDT) /// - [RegularExpression(@"^(SH|SZ)\d{6}$", ErrorMessage = "股票代码格式错误,应为 SH000000 或 SZ000000")] - [Description("股票代码,格式如 SH600000、SZ000001")] + [Description("资产代码,格式:股票如 SH600000、SZ000001,虚拟币如 BTC/USDT、ETH/USDT")] public string Symbol { get; set; } = string.Empty; /// - /// 股票名称 + /// 资产名称 /// - [Description("股票名称")] + [Description("资产名称")] public string Name { get; set; } = string.Empty; /// @@ -67,15 +66,15 @@ public class StockRecommendation /// /// 目标价格 /// - [Range(0.01, 100000)] - [Description("目标价格,单位:元,范围0.01-100000,无法预测时设为null")] + [Range(0.01, 100000000)] + [Description("目标价格,单位:元或USDT,范围0.01-100000000,无法预测时设为null")] public decimal? TargetPrice { get; set; } /// /// 止损价格 /// - [Range(0.01, 100000)] - [Description("止损价格,单位:元,范围0.01-100000,无法预测时设为null")] + [Range(0.01, 100000000)] + [Description("止损价格,单位:元或USDT,范围0.01-100000000,无法预测时设为null")] public decimal? StopLoss { get; set; } } diff --git a/src/Applications/StockSelection/Models/StockRecommendationRequest.cs b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/InvestmentRecommendationRequest.cs similarity index 73% rename from src/Applications/StockSelection/Models/StockRecommendationRequest.cs rename to src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/InvestmentRecommendationRequest.cs index c7eef74..79e7a18 100644 --- a/src/Applications/StockSelection/Models/StockRecommendationRequest.cs +++ b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/InvestmentRecommendationRequest.cs @@ -1,10 +1,17 @@ -namespace MarketAssistant.Applications.StockSelection.Models; +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Applications.InvestmentSelection.Models; /// -/// 股票推荐请求 +/// 投资推荐请求 /// -public class StockRecommendationRequest +public class InvestmentRecommendationRequest { + /// + /// 市场类型 + /// + public MarketType MarketType { get; set; } = MarketType.AShare; + /// /// 用户需求描述 /// @@ -36,7 +43,7 @@ public class StockRecommendationRequest public List ExcludedSectors { get; set; } = new(); /// - /// 最大推荐股票数量 + /// 最大推荐数量 /// public int MaxRecommendations { get; set; } = 10; } diff --git a/src/Applications/StockSelection/Models/StockSelectionEnums.cs b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/InvestmentSelectionEnums.cs similarity index 91% rename from src/Applications/StockSelection/Models/StockSelectionEnums.cs rename to src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/InvestmentSelectionEnums.cs index 192c2de..28839a3 100644 --- a/src/Applications/StockSelection/Models/StockSelectionEnums.cs +++ b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/InvestmentSelectionEnums.cs @@ -2,7 +2,7 @@ using System.Runtime.Serialization; using System.Text.Json.Serialization; -namespace MarketAssistant.Applications.StockSelection.Models; +namespace MarketAssistant.Applications.InvestmentSelection.Models; /// /// 风险等级枚举 @@ -30,7 +30,7 @@ public enum RiskLevel } /// -/// 选股类型枚举 +/// 选择类型枚举 /// [JsonConverter(typeof(JsonStringEnumConverter))] public enum SelectionType @@ -49,3 +49,4 @@ public enum SelectionType [EnumMember(Value = "news_based")] NewsBased } + diff --git a/src/Applications/StockSelection/Models/StockSelectionResult.cs b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/InvestmentSelectionResult.cs similarity index 74% rename from src/Applications/StockSelection/Models/StockSelectionResult.cs rename to src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/InvestmentSelectionResult.cs index 7e6c6b6..bb76ee3 100644 --- a/src/Applications/StockSelection/Models/StockSelectionResult.cs +++ b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/InvestmentSelectionResult.cs @@ -1,18 +1,18 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; -namespace MarketAssistant.Applications.StockSelection.Models; +namespace MarketAssistant.Applications.InvestmentSelection.Models; /// -/// 股票选择结果 +/// 投资选择结果 /// -[Description("股票选择分析结果,包含推荐股票列表和分析报告")] -public class StockSelectionResult +[Description("投资选择分析结果,包含推荐投资标的列表和分析报告")] +public class InvestmentSelectionResult { /// - /// 选股类型 + /// 选择类型 /// - [Description("选股类型:user_request(用户需求分析) 或 news_based(新闻分析)")] + [Description("选择类型:user_request(用户需求分析) 或 news_based(新闻分析)")] public SelectionType SelectionType { get; set; } /// @@ -32,10 +32,10 @@ public class StockSelectionResult public string MarketEnvironmentAnalysis { get; set; } = string.Empty; /// - /// 推荐股票列表 + /// 推荐投资标的列表 /// - [Description("推荐股票列表,按推荐优先级从高到低排序。若无合适股票则返回空数组[]")] - public List Recommendations { get; set; } = new(); + [Description("推荐投资标的列表,按推荐优先级从高到低排序。若无合适标的则返回空数组[]")] + public List Recommendations { get; set; } = new(); /// /// 风险提示 diff --git a/src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/NewsBasedInvestmentRequest.cs b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/NewsBasedInvestmentRequest.cs new file mode 100644 index 0000000..74efdac --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/NewsBasedInvestmentRequest.cs @@ -0,0 +1,25 @@ +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Applications.InvestmentSelection.Models; + +/// +/// 热点新闻投资选择请求 +/// +public class NewsBasedInvestmentRequest +{ + /// + /// 市场类型 + /// + public MarketType MarketType { get; set; } = MarketType.AShare; + + /// + /// 用户提供的新闻内容 + /// + public string NewsContent { get; set; } = string.Empty; + + /// + /// 最大推荐数量 + /// + public int MaxRecommendations { get; set; } = 10; +} + diff --git a/src/Applications/StockSelection/Models/QuickSelectionStrategy.cs b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/QuickSelectionStrategy.cs similarity index 76% rename from src/Applications/StockSelection/Models/QuickSelectionStrategy.cs rename to src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/QuickSelectionStrategy.cs index e2e58b2..70902e1 100644 --- a/src/Applications/StockSelection/Models/QuickSelectionStrategy.cs +++ b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/QuickSelectionStrategy.cs @@ -1,43 +1,43 @@ -namespace MarketAssistant.Applications.StockSelection.Models; +namespace MarketAssistant.Applications.InvestmentSelection.Models; /// -/// 快速选股策略枚举 +/// 快速选择策略枚举 /// public enum QuickSelectionStrategy { /// - /// 价值股 + /// 价值股/币 /// - ValueStocks, + ValueInvestment, /// - /// 成长股 + /// 成长股/币 /// - GrowthStocks, + GrowthInvestment, /// - /// 活跃股 + /// 活跃标的 /// - ActiveStocks, + ActiveTrading, /// - /// 大盘股 + /// 大盘标的 /// LargeCap, /// - /// 小盘股 + /// 小盘标的 /// SmallCap, /// - /// 高股息股 + /// 高股息/高收益 /// - Dividend + HighYield } /// -/// 快速选股策略信息 +/// 快速选择策略信息 /// public class QuickSelectionStrategyInfo { diff --git a/src/Applications/News/INewsUpdateService.cs b/src/MarketAssistant.App.Services/Applications/News/INewsUpdateService.cs similarity index 100% rename from src/Applications/News/INewsUpdateService.cs rename to src/MarketAssistant.App.Services/Applications/News/INewsUpdateService.cs diff --git a/src/MarketAssistant.App.Services/Applications/News/NewsUpdateService.cs b/src/MarketAssistant.App.Services/Applications/News/NewsUpdateService.cs new file mode 100644 index 0000000..42846ca --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/News/NewsUpdateService.cs @@ -0,0 +1,117 @@ +using MarketAssistant.Applications.Telegrams; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Applications.News; + +/// +/// 新闻更新服务实现 +/// +public class NewsUpdateService : INewsUpdateService +{ + private const int UpdateIntervalSeconds = 60; + + private readonly ITelegramService _telegramService; + private readonly ILogger _logger; + private CancellationTokenSource? _cts; + private Task? _updateLoopTask; + private bool _disposed; + + public event EventHandler>? NewsUpdated; + public event EventHandler? CountdownUpdated; + + public bool IsRunning => _updateLoopTask != null && !_updateLoopTask.IsCompleted; + + public NewsUpdateService(ITelegramService telegramService, ILogger logger) + { + _telegramService = telegramService; + _logger = logger; + } + + /// + /// 启动定时更新 + /// + public void StartUpdates() + { + if (_disposed) + throw new ObjectDisposedException(nameof(NewsUpdateService)); + + if (_updateLoopTask != null && !_updateLoopTask.IsCompleted) + return; + + _cts = new CancellationTokenSource(); + _updateLoopTask = UpdateLoopAsync(_cts.Token); + + _logger?.LogInformation("新闻更新定时器已启动"); + } + + /// + /// 停止定时更新 + /// + public void StopUpdates() + { + if (_cts != null) + { + _cts.Cancel(); + _cts.Dispose(); + _cts = null; + } + _updateLoopTask = null; + _logger?.LogInformation("新闻更新定时器已停止"); + } + + private async Task UpdateLoopAsync(CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested) + { + await UpdateNewsItemsAsync(ct); + + for (var remaining = UpdateIntervalSeconds; remaining > 0; remaining--) + { + ct.ThrowIfCancellationRequested(); + CountdownUpdated?.Invoke(this, $"{remaining}秒后更新"); + await Task.Delay(1000, ct); + } + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _logger?.LogError(ex, "新闻更新循环异常退出"); + } + } + + private async Task UpdateNewsItemsAsync(CancellationToken ct) + { + try + { + CountdownUpdated?.Invoke(this, "正在更新..."); + + var news = await _telegramService.GetTelegraphsAsync(ct); + + NewsUpdated?.Invoke(this, news); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger?.LogError(ex, "获取快讯时出错"); + CountdownUpdated?.Invoke(this, "更新失败"); + await Task.Delay(3000, ct); + } + } + + public void Dispose() + { + if (!_disposed) + { + StopUpdates(); + _disposed = true; + } + } +} diff --git a/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertRule.cs b/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertRule.cs new file mode 100644 index 0000000..bf30f42 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertRule.cs @@ -0,0 +1,25 @@ +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Applications.PriceAlert; + +/// +/// 价格预警规则 +/// +public class PriceAlertRule +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8]; + public string AssetCode { get; set; } = string.Empty; + public string AssetName { get; set; } = string.Empty; + public MarketType MarketType { get; set; } = MarketType.Crypto; + public AlertCondition Condition { get; set; } + public decimal TargetPrice { get; set; } + public bool Triggered { get; set; } + public bool Enabled { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} + +public enum AlertCondition +{ + PriceAbove, + PriceBelow +} diff --git a/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertService.cs b/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertService.cs new file mode 100644 index 0000000..6982bc3 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertService.cs @@ -0,0 +1,436 @@ +using System.Globalization; +using System.Text.Json; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Data; +using MarketAssistant.Services.Notification; +using MarketAssistant.Services.Settings; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; +using static MarketAssistant.Infrastructure.Core.CryptoSymbolConverter; +using static MarketAssistant.Infrastructure.Core.StockSymbolConverter; + +namespace MarketAssistant.Applications.PriceAlert; + +/// +/// 价格预警服务,监听 WebSocket 价格并触发通知。 +/// 持久化通过 SQLite(market.db)实现,规则在启动时异步加载到内存。 +/// +public sealed class PriceAlertService : SqliteServiceBase, IDisposable +{ + private static readonly TimeSpan ASharePollingInterval = TimeSpan.FromSeconds(20); + + private readonly BinanceWebSocketService _wsService; + private readonly INotificationService _notificationService; + private readonly IUserSettingService _userSettingService; + private readonly IHttpClientFactory _httpClientFactory; + + private readonly object _syncRoot = new(); + private List _rules = []; + private readonly CancellationTokenSource _pollingCts = new(); + private Task? _pollingTask; + + public IReadOnlyList Rules + { + get + { + lock (_syncRoot) + { + return _rules.ToList(); + } + } + } + public event Action? RulesChanged; + + public PriceAlertService( + BinanceWebSocketService wsService, + INotificationService notificationService, + IUserSettingService userSettingService, + IHttpClientFactory httpClientFactory, + ILogger logger) + : base(logger) + { + _wsService = wsService; + _notificationService = notificationService; + _userSettingService = userSettingService; + _httpClientFactory = httpClientFactory; + + _wsService.PriceUpdated += OnCryptoPriceUpdated; + _pollingTask = Task.Run(() => PollASharePricesAsync(_pollingCts.Token)); + } + + /// + /// 从数据库异步加载规则到内存,并订阅活跃的虚拟币规则。 + /// 应在应用启动时调用。 + /// + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + await LoadRulesAsync(cancellationToken); + SubscribeActiveCryptoRules(); + } + + protected override async Task InitializeDatabaseAsync() + { + await using var conn = await OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS price_alert_rules ( + id TEXT PRIMARY KEY, + asset_code TEXT NOT NULL, + asset_name TEXT NOT NULL, + market_type INTEGER NOT NULL, + condition INTEGER NOT NULL, + target_price REAL NOT NULL, + triggered INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_alert_mt ON price_alert_rules(market_type); + CREATE INDEX IF NOT EXISTS idx_alert_enabled ON price_alert_rules(enabled, market_type); + """; + await cmd.ExecuteNonQueryAsync(); + } + + public async Task AddRuleAsync(PriceAlertRule rule, CancellationToken cancellationToken = default) + { + lock (_syncRoot) + { + _rules.Add(rule); + } + + try + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO price_alert_rules (id, asset_code, asset_name, market_type, condition, target_price, triggered, enabled, created_at) + VALUES (@id, @assetCode, @assetName, @marketType, @condition, @targetPrice, @triggered, @enabled, @createdAt) + """; + cmd.Parameters.AddWithValue("@id", rule.Id); + cmd.Parameters.AddWithValue("@assetCode", rule.AssetCode); + cmd.Parameters.AddWithValue("@assetName", rule.AssetName); + cmd.Parameters.AddWithValue("@marketType", (int)rule.MarketType); + cmd.Parameters.AddWithValue("@condition", (int)rule.Condition); + cmd.Parameters.AddWithValue("@targetPrice", (double)rule.TargetPrice); + cmd.Parameters.AddWithValue("@triggered", rule.Triggered ? 1 : 0); + cmd.Parameters.AddWithValue("@enabled", rule.Enabled ? 1 : 0); + cmd.Parameters.AddWithValue("@createdAt", rule.CreatedAt.ToString("O")); + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "保存价格预警规则失败"); + } + + RulesChanged?.Invoke(); + + if (rule.Enabled && rule.MarketType == MarketType.Crypto) + _ = SubscribeSafeAsync([ToBinanceFormat(rule.AssetCode)]); + } + + public async Task RemoveRuleAsync(string ruleId, CancellationToken cancellationToken = default) + { + lock (_syncRoot) + { + _rules.RemoveAll(r => r.Id == ruleId); + } + + try + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM price_alert_rules WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", ruleId); + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "删除价格预警规则失败"); + } + + RulesChanged?.Invoke(); + } + + public async Task ToggleRuleAsync(string ruleId, CancellationToken cancellationToken = default) + { + PriceAlertRule? rule; + lock (_syncRoot) + { + rule = _rules.FirstOrDefault(r => r.Id == ruleId); + if (rule == null) return; + + rule.Enabled = !rule.Enabled; + rule.Triggered = false; + } + + try + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE price_alert_rules SET enabled = @enabled, triggered = 0 WHERE id = @id"; + cmd.Parameters.AddWithValue("@enabled", rule.Enabled ? 1 : 0); + cmd.Parameters.AddWithValue("@id", ruleId); + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "更新价格预警规则失败"); + } + + RulesChanged?.Invoke(); + + if (rule.Enabled && rule.MarketType == MarketType.Crypto) + _ = SubscribeSafeAsync([ToBinanceFormat(rule.AssetCode)]); + } + + private void OnCryptoPriceUpdated(string symbol, decimal lastPrice, decimal changePercent) + { + if (!IsNotificationEnabled()) return; + + List rules; + lock (_syncRoot) + { + rules = _rules + .Where(r => r.MarketType == MarketType.Crypto && r.Enabled && !r.Triggered) + .ToList(); + } + + foreach (var rule in rules) + { + var ruleSymbol = ToBinanceFormat(rule.AssetCode); + if (!ruleSymbol.Equals(symbol, StringComparison.OrdinalIgnoreCase)) + continue; + + TryTriggerRule(rule.Id, lastPrice); + } + } + + private async Task PollASharePricesAsync(CancellationToken cancellationToken) + { + try + { + using var timer = new PeriodicTimer(ASharePollingInterval); + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + if (!IsNotificationEnabled()) continue; + + List rules; + lock (_syncRoot) + { + rules = _rules + .Where(r => r.MarketType == MarketType.AShare && r.Enabled && !r.Triggered) + .ToList(); + } + + foreach (var rule in rules) + { + var latestPrice = await GetAShareLatestPriceAsync(rule.AssetCode, cancellationToken); + if (latestPrice.HasValue) + TryTriggerRule(rule.Id, latestPrice.Value); + } + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + Logger.LogWarning(ex, "A股预警轮询异常"); + } + } + + private async Task GetAShareLatestPriceAsync(string assetCode, CancellationToken cancellationToken) + { + try + { + var clsCode = ToClsFormat(assetCode); + if (string.IsNullOrWhiteSpace(clsCode)) + return null; + + var url = + $"https://x-quote.cls.cn/quote/stock/basic?secu_code={clsCode}&fields=last_px&app=CailianpressWeb&os=web&sv=8.4.6"; + + using var httpClient = _httpClientFactory.CreateClient("Cls"); + using var response = await httpClient.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) + return null; + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var jsonDocument = JsonDocument.Parse(json); + if (!jsonDocument.RootElement.TryGetProperty("data", out var data) || data.ValueKind == JsonValueKind.Null) + return null; + + if (!data.TryGetProperty("last_px", out var priceElement)) + return null; + + if (priceElement.ValueKind == JsonValueKind.Number && priceElement.TryGetDecimal(out var price)) + return price; + + if (priceElement.ValueKind == JsonValueKind.String && + decimal.TryParse(priceElement.GetString(), NumberStyles.Number, CultureInfo.InvariantCulture, out var stringPrice)) + return stringPrice; + + return null; + } + catch (OperationCanceledException) + { + return null; + } + catch (Exception ex) + { + Logger.LogWarning(ex, "获取A股价格失败: {AssetCode}", assetCode); + return null; + } + } + + private void TryTriggerRule(string ruleId, decimal lastPrice) + { + PriceAlertRule? rule; + lock (_syncRoot) + { + rule = _rules.FirstOrDefault(r => r.Id == ruleId && r.Enabled && !r.Triggered); + if (rule == null) return; + + var triggered = rule.Condition switch + { + AlertCondition.PriceAbove => lastPrice >= rule.TargetPrice, + AlertCondition.PriceBelow => lastPrice <= rule.TargetPrice, + _ => false + }; + + if (!triggered) return; + + rule.Triggered = true; + rule.Enabled = false; + } + + // 异步持久化触发状态,不阻塞通知流程 + _ = PersistRuleTriggeredAsync(ruleId); + + if (IsNotificationEnabled()) + { + var direction = rule.Condition == AlertCondition.PriceAbove ? "突破上方" : "跌破下方"; + _notificationService.ShowWarning( + $"🔔 {rule.AssetName}({rule.AssetCode}) 当前价 {lastPrice},已{direction}预警价 {rule.TargetPrice}", + durationMs: 10000); + } + + RulesChanged?.Invoke(); + } + + private async Task PersistRuleTriggeredAsync(string ruleId) + { + try + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE price_alert_rules SET triggered = 1, enabled = 0 WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", ruleId); + await cmd.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "持久化预警触发状态失败: {RuleId}", ruleId); + } + } + + private void SubscribeActiveCryptoRules() + { + List symbols; + lock (_syncRoot) + { + symbols = _rules + .Where(r => r.MarketType == MarketType.Crypto && r.Enabled && !r.Triggered) + .Select(r => ToBinanceFormat(r.AssetCode)) + .Distinct() + .ToList(); + } + + if (symbols.Count > 0) + _ = SubscribeSafeAsync(symbols); + } + + /// + /// 安全的 WebSocket 订阅封装:捕获并记录异常,避免 fire-and-forget 调用吞掉错误。 + /// + private async Task SubscribeSafeAsync(IReadOnlyCollection symbols) + { + try + { + await _wsService.SubscribeAsync(symbols); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "WebSocket 订阅失败,价格预警可能无法实时推送: {Symbols}", + string.Join(", ", symbols)); + } + } + + private async Task LoadRulesAsync(CancellationToken cancellationToken = default) + { + try + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT id, asset_code, asset_name, market_type, condition, target_price, triggered, enabled, created_at + FROM price_alert_rules + """; + + var allRules = new List(); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + allRules.Add(new PriceAlertRule + { + Id = reader.GetString(0), + AssetCode = reader.GetString(1), + AssetName = reader.GetString(2), + MarketType = (MarketType)reader.GetInt32(3), + Condition = (AlertCondition)reader.GetInt32(4), + TargetPrice = (decimal)reader.GetDouble(5), + Triggered = reader.GetInt32(6) != 0, + Enabled = reader.GetInt32(7) != 0, + CreatedAt = DateTime.Parse(reader.GetString(8), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) + }); + } + + lock (_syncRoot) + { + _rules = allRules; + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "加载价格预警规则失败"); + lock (_syncRoot) + { + _rules = []; + } + } + } + + public void Dispose() + { + _wsService.PriceUpdated -= OnCryptoPriceUpdated; + _pollingCts.Cancel(); + try + { + _pollingTask?.Wait(TimeSpan.FromSeconds(5)); + } + catch (AggregateException ex) + { + ex.Handle(e => e is OperationCanceledException); + } + _pollingCts.Dispose(); + } + + private bool IsNotificationEnabled() + { + return _userSettingService.CurrentSetting.Notification; + } +} diff --git a/src/Applications/Settings/AppInfo.cs b/src/MarketAssistant.App.Services/Applications/Settings/AppInfo.cs similarity index 81% rename from src/Applications/Settings/AppInfo.cs rename to src/MarketAssistant.App.Services/Applications/Settings/AppInfo.cs index d8d6fe7..5d6a4fb 100644 --- a/src/Applications/Settings/AppInfo.cs +++ b/src/MarketAssistant.App.Services/Applications/Settings/AppInfo.cs @@ -7,7 +7,7 @@ namespace MarketAssistant.Applications.Settings; /// public static class AppInfo { - private static readonly Assembly _assembly = Assembly.GetExecutingAssembly(); + private static readonly Assembly _assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly(); #region 应用基本信息 @@ -88,9 +88,9 @@ public static string Version #region 文件和目录常量 /// - /// 偏好设置文件名 + /// 用户设置文件名 /// - public const string PreferencesFileName = "preferences.json"; + public const string UserSettingsFileName = "usersettings.json"; /// /// MCP服务器配置文件名称 @@ -109,6 +109,20 @@ public static string Version #endregion + #region GitHub 仓库常量 + + /// + /// GitHub 仓库所有者 + /// + public const string GitHubOwner = "X2Agent"; + + /// + /// GitHub 仓库名称 + /// + public const string GitHubRepo = "MarketAssistant"; + + #endregion + #region 网站和URL常量 /// @@ -119,22 +133,22 @@ public static string Version /// /// 更新日志地址 /// - public static string ChangelogUrl => $"https://github.com/{Company}/{AppName}/releases"; + public const string ChangelogUrl = $"https://github.com/{GitHubOwner}/{GitHubRepo}/releases"; /// /// 意见反馈地址 /// - public static string FeedbackUrl => $"https://github.com/{Company}/{AppName}/issues"; + public const string FeedbackUrl = $"https://github.com/{GitHubOwner}/{GitHubRepo}/issues"; /// /// GitHub仓库地址 /// - public static string GitHubRepoUrl => $"https://github.com/{Company}/{AppName}"; + public const string GitHubRepoUrl = $"https://github.com/{GitHubOwner}/{GitHubRepo}"; /// /// 许可证地址 /// - public static string LicenseUrl => $"https://github.com/{Company}/{AppName}/blob/main/LICENSE"; + public const string LicenseUrl = $"https://github.com/{GitHubOwner}/{GitHubRepo}/blob/main/LICENSE"; /// /// 官方QQ群号码 diff --git a/src/Applications/Settings/GitHubReleaseService.cs b/src/MarketAssistant.App.Services/Applications/Settings/GitHubReleaseService.cs similarity index 86% rename from src/Applications/Settings/GitHubReleaseService.cs rename to src/MarketAssistant.App.Services/Applications/Settings/GitHubReleaseService.cs index 9d0ae9d..6a4e2f1 100644 --- a/src/Applications/Settings/GitHubReleaseService.cs +++ b/src/MarketAssistant.App.Services/Applications/Settings/GitHubReleaseService.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Caching.Memory; using System.Net; using System.Net.Http.Json; +using MarketAssistant.Applications.Cache; namespace MarketAssistant.Applications.Settings; @@ -10,17 +12,21 @@ namespace MarketAssistant.Applications.Settings; public class GitHubReleaseService : IReleaseService { private readonly IHttpClientFactory _httpClientFactory; + private readonly IMemoryCache _memoryCache; private readonly ILogger _logger; - private readonly string _githubApiBaseUrl = $"{AppInfo.GitHubApiBaseUrl}/repos/{AppInfo.Company}/{AppInfo.AppName}/releases"; - private readonly string _githubApiLatestUrl = $"{AppInfo.GitHubApiBaseUrl}/repos/{AppInfo.Company}/{AppInfo.AppName}/releases/latest"; + private readonly string _githubApiBaseUrl = $"{AppInfo.GitHubApiBaseUrl}/repos/{AppInfo.GitHubOwner}/{AppInfo.GitHubRepo}/releases"; + private readonly string _githubApiLatestUrl = $"{AppInfo.GitHubApiBaseUrl}/repos/{AppInfo.GitHubOwner}/{AppInfo.GitHubRepo}/releases/latest"; - private CachedData>? _cachedReleases; - private readonly TimeSpan _cacheExpiration = TimeSpan.FromMinutes(10); + private static readonly TimeSpan CacheExpiration = TimeSpan.FromMinutes(10); private RateLimitInfo? _rateLimitInfo; - public GitHubReleaseService(IHttpClientFactory httpClientFactory, ILogger logger) + public GitHubReleaseService( + IHttpClientFactory httpClientFactory, + IMemoryCache memoryCache, + ILogger logger) { _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -51,10 +57,15 @@ public async Task CheckForUpdateAsync(string currentVersion, if (includePrerelease) { var allReleases = await GetAllReleasesInternalAsync(); - latestRelease = allReleases - .Where(r => !r.Draft) - .OrderByDescending(r => r.PublishedAt) - .FirstOrDefault(); + var nonDraftReleases = allReleases.Where(r => !r.Draft).ToList(); + if (nonDraftReleases.Count == 0) + { + throw new FriendlyException("未找到可用的发布版本"); + } + + latestRelease = nonDraftReleases + .Aggregate((best, next) => + CompareVersions(next.TagName.TrimStart('v'), best.TagName.TrimStart('v')) > 0 ? next : best); } else { @@ -92,10 +103,6 @@ public async Task CheckForUpdateAsync(string currentVersion, return result; } - catch (FriendlyException) - { - throw; - } catch (Exception ex) { _logger.LogError(ex, "检查更新失败"); @@ -126,9 +133,8 @@ public async Task DownloadUpdateAsync( _logger.LogInformation("开始下载更新文件:{Url} -> {SavePath}", downloadUrl, savePath); - using var httpClient = _httpClientFactory.CreateClient(); - httpClient.DefaultRequestHeaders.Add("User-Agent", AppInfo.UserAgent); - httpClient.Timeout = TimeSpan.FromMinutes(10); + // 下载二进制文件使用专用 HttpClient,避免携带 GitHub API 的 Accept 头 + var httpClient = CreateDownloadClient(); var response = await httpClient.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); @@ -199,10 +205,6 @@ public async Task DownloadUpdateAsync( _logger.LogError(ex, "下载更新失败:无权限访问文件"); throw new FriendlyException($"无权限保存文件:{ex.Message}", ex); } - catch (FriendlyException) - { - throw; - } catch (Exception ex) { _logger.LogError(ex, "下载更新失败:未知异常"); @@ -215,7 +217,7 @@ public async Task DownloadUpdateAsync( /// public void ClearCache() { - _cachedReleases = null; + _memoryCache.Remove(CacheKeys.GitHubReleases); _logger.LogInformation("已清除版本信息缓存"); } @@ -225,17 +227,16 @@ public void ClearCache() private async Task> GetAllReleasesInternalAsync() { // 检查缓存 - if (_cachedReleases != null && !_cachedReleases.IsExpired(_cacheExpiration)) + if (_memoryCache.TryGetValue(CacheKeys.GitHubReleases, out List? cachedReleases) && + cachedReleases is not null) { _logger.LogDebug("从缓存返回所有版本信息"); - return _cachedReleases.Data; + return cachedReleases; } try { - using var httpClient = _httpClientFactory.CreateClient(); - httpClient.DefaultRequestHeaders.Add("User-Agent", AppInfo.UserAgent); - httpClient.Timeout = TimeSpan.FromSeconds(30); + var httpClient = CreateGitHubClient(); var response = await httpClient.GetAsync(_githubApiBaseUrl); UpdateRateLimitInfo(response); @@ -244,9 +245,10 @@ private async Task> GetAllReleasesInternalAsync() { _logger.LogWarning("GitHub API 速率限制已达上限"); // 如果有缓存数据,返回缓存 - if (_cachedReleases != null) + if (_memoryCache.TryGetValue(CacheKeys.GitHubReleases, out List? fallbackReleases) && + fallbackReleases is not null) { - return _cachedReleases.Data; + return fallbackReleases; } throw new FriendlyException("GitHub API 速率限制已达上限"); } @@ -259,7 +261,7 @@ private async Task> GetAllReleasesInternalAsync() throw new FriendlyException("获取版本信息失败:响应数据为空"); } - _cachedReleases = new CachedData>(releases); + _memoryCache.Set(CacheKeys.GitHubReleases, releases, CacheExpiration); _logger.LogInformation("成功获取 {Count} 个版本信息", releases.Count); return releases; } @@ -267,19 +269,21 @@ private async Task> GetAllReleasesInternalAsync() { _logger.LogError(ex, "获取版本信息失败:HTTP 请求异常"); // 如果有缓存,返回缓存数据作为降级方案 - if (_cachedReleases != null) + if (_memoryCache.TryGetValue(CacheKeys.GitHubReleases, out List? cachedFallback) && + cachedFallback is not null) { _logger.LogInformation("返回缓存的版本信息作为降级方案"); - return _cachedReleases.Data; + return cachedFallback; } throw new FriendlyException($"网络请求失败:{ex.Message}", ex); } catch (TaskCanceledException ex) { _logger.LogError(ex, "获取版本信息失败:请求超时"); - if (_cachedReleases != null) + if (_memoryCache.TryGetValue(CacheKeys.GitHubReleases, out List? cachedFallback) && + cachedFallback is not null) { - return _cachedReleases.Data; + return cachedFallback; } throw new FriendlyException("请求超时", ex); } @@ -290,9 +294,10 @@ private async Task> GetAllReleasesInternalAsync() catch (Exception ex) { _logger.LogError(ex, "获取版本信息失败:未知异常"); - if (_cachedReleases != null) + if (_memoryCache.TryGetValue(CacheKeys.GitHubReleases, out List? cachedFallback) && + cachedFallback is not null) { - return _cachedReleases.Data; + return cachedFallback; } throw new FriendlyException($"获取版本信息失败:{ex.Message}", ex); } @@ -305,9 +310,7 @@ private async Task GetLatestReleaseInternalAsync() { try { - using var httpClient = _httpClientFactory.CreateClient(); - httpClient.DefaultRequestHeaders.Add("User-Agent", AppInfo.UserAgent); - httpClient.Timeout = TimeSpan.FromSeconds(30); + var httpClient = CreateGitHubClient(); var response = await httpClient.GetAsync(_githubApiLatestUrl); UpdateRateLimitInfo(response); @@ -401,6 +404,19 @@ private void UpdateRateLimitInfo(HttpResponseMessage response) } } + private HttpClient CreateGitHubClient() + { + return _httpClientFactory.CreateClient("GitHub"); + } + + /// + /// 创建用于下载 Release 二进制文件的 HttpClient(不带 GitHub API 专用 Accept 头) + /// + private HttpClient CreateDownloadClient() + { + return _httpClientFactory.CreateClient("GitHubDownload"); + } + /// /// 比较版本号(支持三位数和四位数版本号格式) /// @@ -514,26 +530,6 @@ private int ComparePrerelease(string prerelease1, string prerelease2) } } -/// -/// 缓存数据包装类 -/// -internal class CachedData -{ - public T Data { get; } - public DateTime Timestamp { get; } - - public CachedData(T data) - { - Data = data; - Timestamp = DateTime.UtcNow; - } - - public bool IsExpired(TimeSpan expiration) - { - return DateTime.UtcNow - Timestamp > expiration; - } -} - /// /// GitHub API 速率限制信息 /// diff --git a/src/Applications/Settings/IReleaseService.cs b/src/MarketAssistant.App.Services/Applications/Settings/IReleaseService.cs similarity index 100% rename from src/Applications/Settings/IReleaseService.cs rename to src/MarketAssistant.App.Services/Applications/Settings/IReleaseService.cs diff --git a/src/Applications/Settings/InvestmentPreference.cs b/src/MarketAssistant.App.Services/Applications/Settings/InvestmentPreference.cs similarity index 100% rename from src/Applications/Settings/InvestmentPreference.cs rename to src/MarketAssistant.App.Services/Applications/Settings/InvestmentPreference.cs diff --git a/src/Applications/Settings/MCPServerConfig.cs b/src/MarketAssistant.App.Services/Applications/Settings/MCPServerConfig.cs similarity index 83% rename from src/Applications/Settings/MCPServerConfig.cs rename to src/MarketAssistant.App.Services/Applications/Settings/MCPServerConfig.cs index 1eabd26..98d0502 100644 --- a/src/Applications/Settings/MCPServerConfig.cs +++ b/src/MarketAssistant.App.Services/Applications/Settings/MCPServerConfig.cs @@ -45,6 +45,17 @@ public class MCPServerConfig /// public bool IsEnabled { get; set; } = true; + /// + /// 工具分组标签,用于工具的选择性暴露 + /// 例如: "search", "finance", "code" + /// + public string Category { get; set; } = "general"; + + /// + /// 允许的工具名称列表(白名单),为空则允许所有工具 + /// + public List AllowedTools { get; set; } = []; + /// /// 获取传输选项字典 /// diff --git a/src/Applications/Settings/MCPServerConfigService.cs b/src/MarketAssistant.App.Services/Applications/Settings/MCPServerConfigService.cs similarity index 51% rename from src/Applications/Settings/MCPServerConfigService.cs rename to src/MarketAssistant.App.Services/Applications/Settings/MCPServerConfigService.cs index 1c0e52e..66cd94e 100644 --- a/src/Applications/Settings/MCPServerConfigService.cs +++ b/src/MarketAssistant.App.Services/Applications/Settings/MCPServerConfigService.cs @@ -1,29 +1,33 @@ namespace MarketAssistant.Applications.Settings; /// -/// MCP服务器配置服务,提供对MCPServerConfig的统一访问和管理 +/// MCP服务器配置服务,提供对MCPServerConfig的统一访问和管理。 +/// 所有读写操作通过锁保护,确保 UI 线程与后台线程并发访问时的线程安全。 /// public class MCPServerConfigService { - private static readonly Lazy _instance = new(() => new MCPServerConfigService()); - - /// - /// 获取MCPServerConfigService的单例实例 - /// - public static MCPServerConfigService Instance => _instance.Value; - private List _serverConfigs = new(); + private readonly object _lock = new(); /// - /// 当前所有MCP服务器配置 + /// 当前所有MCP服务器配置(返回副本,避免外部修改影响内部状态) /// - public List ServerConfigs => _serverConfigs; + public List ServerConfigs + { + get + { + lock (_lock) + { + return _serverConfigs.ToList(); + } + } + } // 配置文件路径 private readonly string _configFilePath = Path.Combine(FileSystem.AppDataDirectory, AppInfo.MCPServerConfigFileName); /// - /// 构造函数(支持依赖注入,也支持单例模式) + /// 构造函数 /// public MCPServerConfigService() { @@ -36,7 +40,7 @@ public MCPServerConfigService() /// public void LoadConfigs() { - try + lock (_lock) { if (File.Exists(_configFilePath)) { @@ -44,14 +48,6 @@ public void LoadConfigs() _serverConfigs = JsonSerializer.Deserialize>(json) ?? new List(); } } - catch (Exception ex) - { - // 处理异常 - System.Diagnostics.Debug.WriteLine($"加载MCP服务器配置时出错: {ex.Message}"); - - // 如果加载失败,使用空列表 - _serverConfigs = new List(); - } } /// @@ -59,26 +55,24 @@ public void LoadConfigs() /// public void SaveConfigs() { - try + List snapshot; + lock (_lock) { - // 确保目录存在 - var directory = Path.GetDirectoryName(_configFilePath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - { - Directory.CreateDirectory(directory); - } - - // 序列化配置对象 - var json = JsonSerializer.Serialize(_serverConfigs, new JsonSerializerOptions { WriteIndented = true }); - - // 保存到文件 - File.WriteAllText(_configFilePath, json); + snapshot = _serverConfigs.ToList(); } - catch (Exception ex) + + // 确保目录存在 + var directory = Path.GetDirectoryName(_configFilePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { - // 处理异常 - System.Diagnostics.Debug.WriteLine($"保存MCP服务器配置时出错: {ex.Message}"); + Directory.CreateDirectory(directory); } + + // 序列化配置对象 + var json = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true }); + + // 保存到文件 + File.WriteAllText(_configFilePath, json); } /// @@ -87,17 +81,20 @@ public void SaveConfigs() /// MCP服务器配置 public void AddOrUpdateConfig(MCPServerConfig config) { - // 查找是否已存在相同ID的配置 - int index = _serverConfigs.FindIndex(c => c.Id == config.Id); - if (index >= 0) - { - // 更新现有配置 - _serverConfigs[index] = config; - } - else + lock (_lock) { - // 添加新配置 - _serverConfigs.Add(config); + // 查找是否已存在相同ID的配置 + int index = _serverConfigs.FindIndex(c => c.Id == config.Id); + if (index >= 0) + { + // 更新现有配置 + _serverConfigs[index] = config; + } + else + { + // 添加新配置 + _serverConfigs.Add(config); + } } // 保存更改 @@ -110,8 +107,11 @@ public void AddOrUpdateConfig(MCPServerConfig config) /// 配置ID public void DeleteConfig(string id) { - // 查找并删除配置 - _serverConfigs.RemoveAll(c => c.Id == id); + lock (_lock) + { + // 查找并删除配置 + _serverConfigs.RemoveAll(c => c.Id == id); + } // 保存更改 SaveConfigs(); @@ -124,6 +124,9 @@ public void DeleteConfig(string id) /// MCP服务器配置,如果不存在则返回null public MCPServerConfig? GetConfig(string id) { - return _serverConfigs.FirstOrDefault(c => c.Id == id); + lock (_lock) + { + return _serverConfigs.FirstOrDefault(c => c.Id == id); + } } -} \ No newline at end of file +} diff --git a/src/Applications/Settings/UpdateCheckResult.cs b/src/MarketAssistant.App.Services/Applications/Settings/UpdateCheckResult.cs similarity index 100% rename from src/Applications/Settings/UpdateCheckResult.cs rename to src/MarketAssistant.App.Services/Applications/Settings/UpdateCheckResult.cs diff --git a/src/Applications/Settings/UserSetting.cs b/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs similarity index 64% rename from src/Applications/Settings/UserSetting.cs rename to src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs index bc536f4..6da4bea 100644 --- a/src/Applications/Settings/UserSetting.cs +++ b/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs @@ -11,7 +11,11 @@ public class UserSetting : INotifyPropertyChanged { public string ModelId { get; set; } = ""; - public string EmbeddingModelId { get; set; } = "BAAI/bge-m3"; + public string EmbeddingModelId { get; set; } = "jina-embeddings-v5-text-small"; + + public string EmbeddingEndpoint { get; set; } = "https://api.jina.ai"; + + public string EmbeddingApiKey { get; set; } = ""; public string Endpoint { get; set; } = "https://api.siliconflow.cn"; @@ -39,15 +43,41 @@ public string KnowledgeFileDirectory public string ZhiTuApiToken { get; set; } = ""; /// - /// 浏览器路径,如果为空则自动检测 + /// 主题模式:Default=跟随系统, Light=浅色, Dark=深色 /// - private string _browserPath = ""; - public string BrowserPath + private string _themeMode = "Default"; + public string ThemeMode { - get => _browserPath; - set => SetProperty(ref _browserPath, value); + get => _themeMode; + set => SetProperty(ref _themeMode, value); } + /// + /// 当前市场类型 + /// + private MarketType _currentMarketType = MarketType.AShare; + public MarketType CurrentMarketType + { + get => _currentMarketType; + set => SetProperty(ref _currentMarketType, value); + } + + /// + /// CoinGecko API 密钥(Demo 版免费,需在 https://www.coingecko.com/api/dashboard 注册获取) + /// /coins/markets 等端点现要求携带 Demo Key,留空可能导致虚拟币筛选失败 + /// + public string CoinGeckoApiKey { get; set; } = ""; + + /// + /// Binance API Key(交易功能必须) + /// + public string BinanceApiKey { get; set; } = ""; + + /// + /// Binance Secret Key(交易功能必须) + /// + public string BinanceSecretKey { get; set; } = ""; + /// /// 日志文件路径 /// @@ -76,7 +106,12 @@ public bool EnableWebSearch /// /// Web Search服务商 /// - public string WebSearchProvider { get; set; } = "Bing"; + private string _webSearchProvider = "Bing"; + public string WebSearchProvider + { + get => _webSearchProvider; + set => SetProperty(ref _webSearchProvider, value); + } /// /// 启用的分析师角色字典 Key: AgentName, Value: IsEnabled diff --git a/src/MarketAssistant.App.Services/Applications/Telegrams/AShareTelegramService.cs b/src/MarketAssistant.App.Services/Applications/Telegrams/AShareTelegramService.cs new file mode 100644 index 0000000..04466be --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Telegrams/AShareTelegramService.cs @@ -0,0 +1,178 @@ +using Microsoft.Extensions.Logging; +using System.Text.Json.Serialization; + +namespace MarketAssistant.Applications.Telegrams; + +/// +/// A股市场快讯服务,调用同花顺快讯API +/// +public class AShareTelegramService : ITelegramService +{ + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + + public AShareTelegramService(ILogger logger, IHttpClientFactory httpClientFactory) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + } + + /// + /// 通过同花顺快讯API获取实时新闻数据 + /// + public async Task> GetTelegraphsAsync(CancellationToken cancellationToken = default) + { + var result = new List(); + + var client = _httpClientFactory.CreateClient("AShareTelegram"); + client.Timeout = TimeSpan.FromSeconds(10); + + // 获取最新20条快讯(不使用 ctime 参数,避免增量更新导致数据为空) + var url = "https://news.10jqka.com.cn/tapp/news/push/stock/?page=1&tag=&track=website&pagesize=20"; + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + + // 设置必要的请求头(模拟真实浏览器请求) + request.Headers.TryAddWithoutValidation("Accept", "*/*"); + request.Headers.TryAddWithoutValidation("Accept-Language", "zh-CN,zh;q=0.9"); + request.Headers.TryAddWithoutValidation("Referer", "https://news.10jqka.com.cn/realtimenews.html"); + request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0"); + request.Headers.TryAddWithoutValidation("X-Requested-With", "XMLHttpRequest"); + request.Headers.TryAddWithoutValidation("sec-fetch-dest", "empty"); + request.Headers.TryAddWithoutValidation("sec-fetch-mode", "cors"); + request.Headers.TryAddWithoutValidation("sec-fetch-site", "same-origin"); + + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("获取同花顺快讯API失败,状态码: {StatusCode}", (int)response.StatusCode); + return result; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var api = JsonSerializer.Deserialize(json, JsonOptions); + if (api?.Data?.List == null) + { + _logger.LogError("快讯API解析为空或结构不匹配"); + return result; + } + + if (api.Data.List.Count == 0) + { + _logger.LogWarning("同花顺 API 未返回任何快讯"); + return result; + } + + // 处理返回的快讯 + foreach (var item in api.Data.List) + { + try + { + var timeText = TryFormatUnixTime(item.Ctime); + var title = item.Title ?? string.Empty; + var content = !string.IsNullOrWhiteSpace(item.Short) ? item.Short! : (item.Digest ?? string.Empty); + var itemUrl = item.Url ?? item.AppUrl ?? item.ShareUrl ?? string.Empty; + var isImportant = ParseImportance(item.Import) || ParseColorImportant(item.Color); + var stocks = (item.Stock ?? new List()) + .Select(s => s.Name?.Trim()) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s!) + .ToList(); + + result.Add(new Telegram + { + Time = timeText, + Title = title, + Content = content, + Url = itemUrl, + Symbols = stocks, + IsImportant = isImportant + }); + } + catch (Exception mapEx) + { + _logger.LogWarning(mapEx, "映射快讯项失败: {Message}", mapEx.Message); + } + } + + return result; + } + + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString + }; + + private static string TryFormatUnixTime(string? unixSeconds) + { + if (long.TryParse(unixSeconds, out var seconds)) + { + try + { + return DateTimeOffset.FromUnixTimeSeconds(seconds).ToLocalTime().ToString("HH:mm:ss"); + } + catch + { + } + } + return string.Empty; + } + + private static bool ParseImportance(string? import) + { + if (string.IsNullOrWhiteSpace(import)) return false; + return int.TryParse(import, out var val) && val > 0; + } + + private static bool ParseColorImportant(string? color) + { + // 某些返回用颜色标记重要性,保底处理 + return color == "2" || color == "3"; + } + + private class ThsNewsResponse + { + public string? Code { get; set; } + public string? Msg { get; set; } + public string? Time { get; set; } + public ThsNewsData? Data { get; set; } + } + + private class ThsNewsData + { + public List? List { get; set; } + } + + private class ThsNewsItem + { + public string? Id { get; set; } + public string? Seq { get; set; } + public string? Title { get; set; } + public string? Digest { get; set; } + public string? Url { get; set; } + public string? AppUrl { get; set; } + public string? ShareUrl { get; set; } + public string? Color { get; set; } + public string? Tag { get; set; } + public List? Tags { get; set; } + public string? Ctime { get; set; } + public string? Rtime { get; set; } + public string? Source { get; set; } + public string? PicUrl { get; set; } + public string? Nature { get; set; } + public List? Stock { get; set; } + public List? Field { get; set; } + public string? Short { get; set; } + public string? Import { get; set; } + public List? TagInfo { get; set; } + } + + private class ThsNewsStock + { + public string? Name { get; set; } + public string? StockCode { get; set; } + public string? StockMarket { get; set; } + } +} + diff --git a/src/MarketAssistant.App.Services/Applications/Telegrams/CryptoTelegramService.cs b/src/MarketAssistant.App.Services/Applications/Telegrams/CryptoTelegramService.cs new file mode 100644 index 0000000..869d520 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Telegrams/CryptoTelegramService.cs @@ -0,0 +1,164 @@ +using MarketAssistant.Applications.Assets; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace MarketAssistant.Applications.Telegrams; + +/// +/// 虚拟币市场快讯服务,调用 PANews 快讯API(中文) +/// 数据来源:https://www.panewslab.com/zh/newsflash +/// +public class CryptoTelegramService : ITelegramService +{ + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ICryptoAliasRegistry _aliasRegistry; + + private const string ApiUrl = + "https://universal-api.panewslab.com/articles?type=NEWS&isShowInList=true&take=20&skip=0&isImportant=true"; + + public CryptoTelegramService( + ILogger logger, + IHttpClientFactory httpClientFactory, + ICryptoAliasRegistry aliasRegistry) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _aliasRegistry = aliasRegistry; + } + + /// + /// 通过 PANews API 获取虚拟币市场实时快讯(中文) + /// + public async Task> GetTelegraphsAsync(CancellationToken cancellationToken = default) + { + var result = new List(); + + var client = _httpClientFactory.CreateClient("CryptoTelegram"); + + using var request = new HttpRequestMessage(HttpMethod.Get, ApiUrl); + request.Headers.TryAddWithoutValidation("Accept", "application/json"); + request.Headers.TryAddWithoutValidation("User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); + + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("获取 PANews 快讯失败,状态码: {StatusCode}", (int)response.StatusCode); + return result; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var articles = JsonSerializer.Deserialize>(json, JsonOptions); + + if (articles is not { Count: > 0 }) + { + _logger.LogWarning("PANews API 返回数据为空"); + return result; + } + + _logger.LogInformation("PANews API 返回 {Count} 条快讯", articles.Count); + + foreach (var article in articles) + { + try + { + var title = article.Title ?? string.Empty; + var content = !string.IsNullOrWhiteSpace(article.Desc) ? article.Desc : title; + + result.Add(new Telegram + { + Time = FormatIsoTime(article.PublishedAt), + Title = title, + Content = content, + Url = $"https://www.panewslab.com/zh/articles/{article.Id}", + Symbols = await ExtractCryptoSymbolsAsync(title, content, cancellationToken), + IsImportant = article.IsImportant == true + }); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "映射 PANews 快讯项失败: {Message}", ex.Message); + } + } + + _logger.LogInformation("成功获取 {Count} 条虚拟币快讯(PANews)", result.Count); + return result; + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString + }; + + /// + /// 格式化 ISO 8601 时间为本地时间字符串 + /// + private static string FormatIsoTime(string? isoTime) + { + if (string.IsNullOrWhiteSpace(isoTime)) + return string.Empty; + + try + { + return DateTimeOffset.Parse(isoTime).ToLocalTime().ToString("HH:mm:ss"); + } + catch + { + return string.Empty; + } + } + + /// + /// 从新闻文本中提取关联的加密货币符号(基于词边界正则匹配,动态从 CryptoAliasRegistry 获取) + /// + private async Task> ExtractCryptoSymbolsAsync( + string title, string content, CancellationToken cancellationToken) + { + var patterns = await _aliasRegistry.GetMatchPatternsAsync(cancellationToken); + var text = $"{title} {content}"; + return patterns + .Where(kv => kv.Value.IsMatch(text)) + .Select(kv => kv.Key) + .OrderBy(s => s) + .ToList(); + } + + // ───────────────────────────────────────────────────────────── + // PANews API 响应模型 + // ───────────────────────────────────────────────────────────── + + private class PanewsArticle + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("desc")] + public string? Desc { get; set; } + + [JsonPropertyName("publishedAt")] + public string? PublishedAt { get; set; } + + [JsonPropertyName("isImportant")] + public bool? IsImportant { get; set; } + + [JsonPropertyName("author")] + public PanewsAuthor? Author { get; set; } + } + + private class PanewsAuthor + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + } +} + diff --git a/src/MarketAssistant.App.Services/Applications/Telegrams/ITelegramService.cs b/src/MarketAssistant.App.Services/Applications/Telegrams/ITelegramService.cs new file mode 100644 index 0000000..2d6439b --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Telegrams/ITelegramService.cs @@ -0,0 +1,13 @@ +namespace MarketAssistant.Applications.Telegrams; + +/// +/// 快讯服务接口,支持多市场实现 +/// +public interface ITelegramService +{ + /// + /// 获取实时快讯 + /// + Task> GetTelegraphsAsync(CancellationToken cancellationToken = default); +} + diff --git a/src/Applications/Telegrams/Telegram.cs b/src/MarketAssistant.App.Services/Applications/Telegrams/Telegram.cs similarity index 84% rename from src/Applications/Telegrams/Telegram.cs rename to src/MarketAssistant.App.Services/Applications/Telegrams/Telegram.cs index 99e9834..7f044e2 100644 --- a/src/Applications/Telegrams/Telegram.cs +++ b/src/MarketAssistant.App.Services/Applications/Telegrams/Telegram.cs @@ -23,9 +23,9 @@ public class Telegram public string Url { get; set; } = ""; /// - /// 相关股票 + /// 相关标的符号(股票代码或加密货币符号) /// - public List Stocks { get; set; } = new(); + public List Symbols { get; set; } = new(); /// /// 是否为重要快讯 diff --git a/src/MarketAssistant.App.Services/GlobalUsing.cs b/src/MarketAssistant.App.Services/GlobalUsing.cs new file mode 100644 index 0000000..a0bd790 --- /dev/null +++ b/src/MarketAssistant.App.Services/GlobalUsing.cs @@ -0,0 +1,12 @@ +global using MarketAssistant.Infrastructure; +global using MarketAssistant.Infrastructure.Core; +global using Microsoft.Extensions.DependencyInjection; +global using System; +global using System.Collections.Generic; +global using System.IO; +global using System.Linq; +global using System.Net.Http; +global using System.Text; +global using System.Text.Json; +global using System.Threading; +global using System.Threading.Tasks; diff --git a/src/MarketAssistant.App.Services/Infrastructure/Abstractions/IAnalysisService.cs b/src/MarketAssistant.App.Services/Infrastructure/Abstractions/IAnalysisService.cs new file mode 100644 index 0000000..559e4fa --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Abstractions/IAnalysisService.cs @@ -0,0 +1,21 @@ +using MarketAssistant.Agents.MarketAnalysis; +using MarketAssistant.Agents.MarketAnalysis.Models; + +namespace MarketAssistant.Infrastructure.Abstractions; + +/// +/// 分析服务抽象接口,为未来模块化拆分提供边界。 +/// UI 层应通过此接口而非直接引用 Agent 层。 +/// +public interface IAnalysisService +{ + /// + /// 执行资产分析 + /// + Task AnalyzeAsync(string assetCode, CancellationToken ct = default); + + /// + /// 分析进度事件 + /// + event EventHandler? ProgressChanged; +} diff --git a/src/Infrastructure/AdaptiveCards/Parsers/BaseAdaptiveCardParser.cs b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/BaseAdaptiveCardParser.cs similarity index 56% rename from src/Infrastructure/AdaptiveCards/Parsers/BaseAdaptiveCardParser.cs rename to src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/BaseAdaptiveCardParser.cs index a361418..7b89909 100644 --- a/src/Infrastructure/AdaptiveCards/Parsers/BaseAdaptiveCardParser.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/BaseAdaptiveCardParser.cs @@ -45,9 +45,10 @@ public bool TryParse(string json, out AdaptiveCard? card) return true; } } - catch + catch (Exception ex) { - // Ignore deserialization errors + // 反序列化失败时记录警告,便于排查 JSON 格式问题 + System.Diagnostics.Debug.WriteLine($"AdaptiveCard 反序列化失败: {ex.Message}"); } return false; } @@ -107,6 +108,102 @@ protected void AddScoreHeader(IList container, string label, st container.Add(colSet); } + /// + /// 统一的“策略/操作建议”看板:Emphasis 容器 + 大号彩色标题 + 正文 + 可选 FactSet。 + /// 各分析师的策略区一律走此构件,避免出现三四种重复布局。 + /// + protected void AddStrategyBox( + IList container, + string title, + AdaptiveTextColor titleColor, + string? content = null, + AdaptiveFactSet? facts = null, + List? bulletPoints = null) + { + var box = new AdaptiveContainer + { + Style = AdaptiveContainerStyle.Emphasis, + Spacing = AdaptiveSpacing.Small + }; + + box.Items.Add(new AdaptiveTextBlock + { + Text = title, + Weight = AdaptiveTextWeight.Bolder, + Size = AdaptiveTextSize.Large, + Color = titleColor + }); + + if (!string.IsNullOrEmpty(content)) + { + box.Items.Add(new AdaptiveTextBlock { Text = content, Wrap = true }); + } + + if (bulletPoints != null && bulletPoints.Count > 0) + { + foreach (var point in bulletPoints) + { + box.Items.Add(new AdaptiveTextBlock { Text = "• " + point, Wrap = true, Weight = AdaptiveTextWeight.Bolder }); + } + } + + if (facts != null && facts.Facts.Count > 0) + { + box.Items.Add(facts); + } + + container.Add(box); + } + + /// + /// 统一的风险警告容器:Attention 样式 + ⚠️ 标题 + 正文 + 可选列表。 + /// 财务/基本面的容器实现与新闻/情绪的行内 ⚠️ 文本均改走此构件。 + /// + protected void AddRiskBox( + IList container, + string title, + string? content, + List? bulletPoints = null) + { + var box = new AdaptiveContainer + { + Style = AdaptiveContainerStyle.Attention, + Spacing = AdaptiveSpacing.Medium + }; + + box.Items.Add(new AdaptiveTextBlock + { + Text = "⚠️ " + title, + Weight = AdaptiveTextWeight.Bolder, + Color = AdaptiveTextColor.Attention + }); + + if (!string.IsNullOrEmpty(content)) + { + box.Items.Add(new AdaptiveTextBlock { Text = content, Wrap = true, Size = AdaptiveTextSize.Small }); + } + + if (bulletPoints != null && bulletPoints.Count > 0) + { + foreach (var point in bulletPoints) + { + box.Items.Add(new AdaptiveTextBlock { Text = "• " + point, Wrap = true, Size = AdaptiveTextSize.Small }); + } + } + + container.Add(box); + } + + /// + /// 统一的“标题 + FactSet”section,保证 section 间距一致。 + /// + protected void AddFactSection(IList container, string title, AdaptiveFactSet facts) + { + if (facts.Facts.Count == 0) return; + AddSectionHeader(container, title); + container.Add(facts); + } + protected void AddListSection(IList container, List? list, string title) { if (list != null && list.Count > 0) diff --git a/src/Infrastructure/AdaptiveCards/Parsers/CoordinatorCardParser.cs b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/CoordinatorCardParser.cs similarity index 88% rename from src/Infrastructure/AdaptiveCards/Parsers/CoordinatorCardParser.cs rename to src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/CoordinatorCardParser.cs index 11e4895..c99fe3c 100644 --- a/src/Infrastructure/AdaptiveCards/Parsers/CoordinatorCardParser.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/CoordinatorCardParser.cs @@ -66,19 +66,18 @@ public override AdaptiveCard Parse(CoordinatorResult model) facts.Facts.Add(new AdaptiveFact("投资周期", model.TimeHorizonDescription)); facts.Facts.Add(new AdaptiveFact("置信度", model.ConfidencePercentage.ToString("F0") + "%")); facts.Facts.Add(new AdaptiveFact("风险等级", GetEnumDescription(model.RiskLevel))); - card.Body.Add(facts); + AddFactSection(card.Body, "关键参数", facts); // Dimension Scores if (model.DimensionScores != null) { - AddSectionHeader(card.Body, "维度评分"); var scoreFacts = new AdaptiveFactSet(); scoreFacts.Facts.Add(new AdaptiveFact("基本面", model.DimensionScores.Fundamental.ToString("F1"))); scoreFacts.Facts.Add(new AdaptiveFact("技术面", model.DimensionScores.Technical.ToString("F1"))); scoreFacts.Facts.Add(new AdaptiveFact("财务面", model.DimensionScores.Financial.ToString("F1"))); scoreFacts.Facts.Add(new AdaptiveFact("市场情绪", model.DimensionScores.Sentiment.ToString("F1"))); scoreFacts.Facts.Add(new AdaptiveFact("新闻事件", model.DimensionScores.News.ToString("F1"))); - card.Body.Add(scoreFacts); + AddFactSection(card.Body, "维度评分", scoreFacts); } // Highlights & Risks in 2 columns @@ -103,16 +102,10 @@ public override AdaptiveCard Parse(CoordinatorResult model) if (hasRight) listCols.Columns.Add(rightCol); if (listCols.Columns.Count > 0) card.Body.Add(listCols); - // Operation Suggestions + // Operation Suggestions —— 统一策略看板 if (model.OperationSuggestions != null && model.OperationSuggestions.Count > 0) { - AddSectionHeader(card.Body, "操作建议"); - var suggestionContainer = new AdaptiveContainer { Style = AdaptiveContainerStyle.Emphasis, Spacing = AdaptiveSpacing.Small }; - foreach (var item in model.OperationSuggestions) - { - suggestionContainer.Items.Add(new AdaptiveTextBlock { Text = "• " + item, Wrap = true, Weight = AdaptiveTextWeight.Bolder }); - } - card.Body.Add(suggestionContainer); + AddStrategyBox(card.Body, "操作建议", AdaptiveTextColor.Accent, bulletPoints: model.OperationSuggestions); } // Consensus & Disagreement @@ -131,13 +124,12 @@ public override AdaptiveCard Parse(CoordinatorResult model) // Key Indicators if (model.KeyIndicators != null && model.KeyIndicators.Count > 0) { - AddSectionHeader(card.Body, "关键指标"); var indicatorFacts = new AdaptiveFactSet(); foreach (var indicator in model.KeyIndicators.Take(5)) // Limit to top 5 to avoid too long card { indicatorFacts.Facts.Add(new AdaptiveFact(indicator.Name, $"{indicator.Value} ({indicator.Signal})")); } - card.Body.Add(indicatorFacts); + AddFactSection(card.Body, "关键指标", indicatorFacts); } return card; diff --git a/src/Infrastructure/AdaptiveCards/Parsers/FinancialCardParser.cs b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/FinancialCardParser.cs similarity index 82% rename from src/Infrastructure/AdaptiveCards/Parsers/FinancialCardParser.cs rename to src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/FinancialCardParser.cs index b9c3bd0..668536f 100644 --- a/src/Infrastructure/AdaptiveCards/Parsers/FinancialCardParser.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/FinancialCardParser.cs @@ -24,7 +24,7 @@ public override AdaptiveCard Parse(FinancialAnalysisResult model) FallbackText = $"财务分析报告:偿债评分 {summaryScore},观点:{summaryInsight},请查看完整报告。", Speak = "财务分析报告已生成。" }; - AddHeader(card.Body, "💰 财务分析报告", AdaptiveTextColor.Good); + AddHeader(card.Body, "💰 财务分析报告", AdaptiveTextColor.Accent); // 使用两列布局展示核心指标 var metricsCols = new AdaptiveColumnSet(); @@ -110,28 +110,19 @@ public override AdaptiveCard Parse(FinancialAnalysisResult model) var warning = model.RiskWarning.FraudRiskRationale; if (!string.IsNullOrEmpty(warning)) { - var container = new AdaptiveContainer { Style = AdaptiveContainerStyle.Attention, Spacing = AdaptiveSpacing.Medium }; - container.Items.Add(new AdaptiveTextBlock { Text = "⚠️ 风险预警", Weight = AdaptiveTextWeight.Bolder, Color = AdaptiveTextColor.Attention }); - container.Items.Add(new AdaptiveTextBlock { Text = warning, Wrap = true }); - - if (model.RiskWarning.KeyRiskIndicators != null && model.RiskWarning.KeyRiskIndicators.Count > 0) + // 合并关键风险指标与建议关注点为统一风险看板 + var riskPoints = new List(); + if (model.RiskWarning.KeyRiskIndicators != null) { - foreach (var indicator in model.RiskWarning.KeyRiskIndicators) - { - container.Items.Add(new AdaptiveTextBlock { Text = $"• {indicator}", Wrap = true, Size = AdaptiveTextSize.Small }); - } + riskPoints.AddRange(model.RiskWarning.KeyRiskIndicators); } - if (model.RiskWarning.MonitoringPoints != null && model.RiskWarning.MonitoringPoints.Count > 0) { - container.Items.Add(new AdaptiveTextBlock { Text = "建议关注:", Weight = AdaptiveTextWeight.Bolder, Size = AdaptiveTextSize.Small }); - foreach (var point in model.RiskWarning.MonitoringPoints) - { - container.Items.Add(new AdaptiveTextBlock { Text = $"• {point}", Wrap = true, Size = AdaptiveTextSize.Small }); - } + riskPoints.Add("建议关注:"); + riskPoints.AddRange(model.RiskWarning.MonitoringPoints); } - card.Body.Add(container); + AddRiskBox(card.Body, "风险预警", warning, riskPoints); } } diff --git a/src/Infrastructure/AdaptiveCards/Parsers/FundamentalCardParser.cs b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/FundamentalCardParser.cs similarity index 77% rename from src/Infrastructure/AdaptiveCards/Parsers/FundamentalCardParser.cs rename to src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/FundamentalCardParser.cs index 1591a55..6ce901c 100644 --- a/src/Infrastructure/AdaptiveCards/Parsers/FundamentalCardParser.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/FundamentalCardParser.cs @@ -95,32 +95,30 @@ public override AdaptiveCard Parse(FundamentalAnalysisResult model) if (model.GrowthValue != null) { - AddSectionHeader(card.Body, "投资评级"); var rating = GetEnumDescription(model.GrowthValue.InvestmentRating); var color = rating.Contains("买入") ? AdaptiveTextColor.Good : AdaptiveTextColor.Default; - card.Body.Add(new AdaptiveTextBlock - { - Text = rating, - Weight = AdaptiveTextWeight.Bolder, - Size = AdaptiveTextSize.Medium, - Color = color - }); - var facts = new AdaptiveFactSet(); facts.Facts.Add(new AdaptiveFact("增长持续性", model.GrowthValue.GrowthSustainabilityScore.ToString("F1"))); - card.Body.Add(facts); + // 统一评级看板:评级标题 + 估值/目标内容 + 指标 + var valuationParts = new List(); if (!string.IsNullOrEmpty(model.GrowthValue.ValuationDescription)) { - card.Body.Add(new AdaptiveTextBlock { Text = $"估值评估: {model.GrowthValue.ValuationDescription}", Wrap = true }); + valuationParts.Add($"估值评估: {model.GrowthValue.ValuationDescription}"); } - if (!string.IsNullOrEmpty(model.GrowthValue.ValuationTarget)) { - card.Body.Add(new AdaptiveTextBlock { Text = $"目标: {model.GrowthValue.ValuationTarget}", Wrap = true }); + valuationParts.Add($"目标: {model.GrowthValue.ValuationTarget}"); } + AddStrategyBox( + card.Body, + rating, + color, + content: valuationParts.Count > 0 ? string.Join(" | ", valuationParts) : null, + facts: facts); + if (!string.IsNullOrEmpty(model.GrowthValue.GrowthDrivers)) { AddSectionHeader(card.Body, "增长驱动"); @@ -129,21 +127,10 @@ public override AdaptiveCard Parse(FundamentalAnalysisResult model) if (!string.IsNullOrEmpty(model.GrowthValue.KeyRisk)) { - var container = new AdaptiveContainer { Style = AdaptiveContainerStyle.Attention, Spacing = AdaptiveSpacing.Small }; - container.Items.Add(new AdaptiveTextBlock { Text = "⚠️ 关键风险", Weight = AdaptiveTextWeight.Bolder, Color = AdaptiveTextColor.Attention }); - container.Items.Add(new AdaptiveTextBlock { Text = model.GrowthValue.KeyRisk, Wrap = true, Size = AdaptiveTextSize.Small }); - card.Body.Add(container); + AddRiskBox(card.Body, "关键风险", model.GrowthValue.KeyRisk); } } return card; } - - private void AddKeyValueRow(IList container, string key, string value, bool isBold = false) - { - var row = new AdaptiveColumnSet { Spacing = AdaptiveSpacing.Small }; - row.Columns.Add(new AdaptiveColumn { Width = "auto", Items = { new AdaptiveTextBlock { Text = key, IsSubtle = true } } }); - row.Columns.Add(new AdaptiveColumn { Width = "stretch", Items = { new AdaptiveTextBlock { Text = value, Weight = isBold ? AdaptiveTextWeight.Bolder : AdaptiveTextWeight.Default, Wrap = true } } }); - container.Add(row); - } } \ No newline at end of file diff --git a/src/Infrastructure/AdaptiveCards/Parsers/IAdaptiveCardParser.cs b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/IAdaptiveCardParser.cs similarity index 100% rename from src/Infrastructure/AdaptiveCards/Parsers/IAdaptiveCardParser.cs rename to src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/IAdaptiveCardParser.cs diff --git a/src/Infrastructure/AdaptiveCards/Parsers/NewsCardParser.cs b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/NewsCardParser.cs similarity index 79% rename from src/Infrastructure/AdaptiveCards/Parsers/NewsCardParser.cs rename to src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/NewsCardParser.cs index 9b6f9a2..502125c 100644 --- a/src/Infrastructure/AdaptiveCards/Parsers/NewsCardParser.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/NewsCardParser.cs @@ -99,45 +99,35 @@ public override AdaptiveCard Parse(NewsEventAnalysisResult model) if (model.InvestmentGuidance != null) { - AddSectionHeader(card.Body, "投资启示"); var strategy = GetEnumDescription(model.InvestmentGuidance.ResponseStrategy); var color = strategy.Contains("买入") || strategy.Contains("做多") ? AdaptiveTextColor.Good : (strategy.Contains("卖出") || strategy.Contains("做空") ? AdaptiveTextColor.Attention : AdaptiveTextColor.Default); - var container = new AdaptiveContainer { Style = AdaptiveContainerStyle.Emphasis, Spacing = AdaptiveSpacing.Small }; - - // 策略看板 - container.Items.Add(new AdaptiveTextBlock - { - Text = $"策略: {strategy}", - Weight = AdaptiveTextWeight.Bolder, - Size = AdaptiveTextSize.Large, - Color = color - }); - + // 核心逻辑 + 具体建议合并为策略看板正文 + var contentParts = new List(); if (!string.IsNullOrEmpty(model.InvestmentGuidance.CoreInvestmentLogic)) { - container.Items.Add(new AdaptiveTextBlock { Text = model.InvestmentGuidance.CoreInvestmentLogic, Wrap = true }); + contentParts.Add(model.InvestmentGuidance.CoreInvestmentLogic); } - if (!string.IsNullOrEmpty(model.InvestmentGuidance.SpecificActionAdvice)) { - container.Items.Add(new AdaptiveTextBlock { Text = $"建议: {model.InvestmentGuidance.SpecificActionAdvice}", Wrap = true }); + contentParts.Add($"建议: {model.InvestmentGuidance.SpecificActionAdvice}"); } + // 统一策略看板:策略标题 + 核心逻辑/建议 + 关注重点 + AddStrategyBox( + card.Body, + $"策略: {strategy}", + color, + content: contentParts.Count > 0 ? string.Join("\n", contentParts) : null, + bulletPoints: model.InvestmentGuidance.FocusPoints?.Count > 0 + ? new List { "关注重点:" }.Concat(model.InvestmentGuidance.FocusPoints).ToList() + : null); + + // 风险告警独立为统一风险看板 if (!string.IsNullOrEmpty(model.InvestmentGuidance.KeyRiskAlert)) { - container.Items.Add(new AdaptiveTextBlock { Text = $"⚠️ {model.InvestmentGuidance.KeyRiskAlert}", Wrap = true, Color = AdaptiveTextColor.Attention, Weight = AdaptiveTextWeight.Bolder }); - } - - if (model.InvestmentGuidance.FocusPoints != null && model.InvestmentGuidance.FocusPoints.Count > 0) - { - container.Items.Add(new AdaptiveTextBlock { Text = "关注重点:", Weight = AdaptiveTextWeight.Bolder, Size = AdaptiveTextSize.Small }); - foreach (var point in model.InvestmentGuidance.FocusPoints) - { - container.Items.Add(new AdaptiveTextBlock { Text = $"• {point}", Wrap = true, Size = AdaptiveTextSize.Small }); - } + AddRiskBox(card.Body, "风险提示", model.InvestmentGuidance.KeyRiskAlert); } - card.Body.Add(container); } return card; diff --git a/src/Infrastructure/AdaptiveCards/Parsers/SentimentCardParser.cs b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/SentimentCardParser.cs similarity index 86% rename from src/Infrastructure/AdaptiveCards/Parsers/SentimentCardParser.cs rename to src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/SentimentCardParser.cs index 894b724..1f4fef5 100644 --- a/src/Infrastructure/AdaptiveCards/Parsers/SentimentCardParser.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/SentimentCardParser.cs @@ -109,31 +109,9 @@ public override AdaptiveCard Parse(MarketSentimentAnalysisResult model) if (model.ShortTermStrategy != null) { - AddSectionHeader(card.Body, "短期策略"); var recommendation = GetEnumDescription(model.ShortTermStrategy.OperationRecommendation); var color = recommendation.Contains("买入") || recommendation.Contains("做多") ? AdaptiveTextColor.Good : (recommendation.Contains("卖出") || recommendation.Contains("做空") ? AdaptiveTextColor.Attention : AdaptiveTextColor.Default); - var container = new AdaptiveContainer { Style = AdaptiveContainerStyle.Emphasis, Spacing = AdaptiveSpacing.Small }; - - // 策略看板 - container.Items.Add(new AdaptiveTextBlock - { - Text = $"建议: {recommendation}", - Weight = AdaptiveTextWeight.Bolder, - Size = AdaptiveTextSize.Large, - Color = color - }); - - if (!string.IsNullOrEmpty(model.ShortTermStrategy.ShortTermOpportunities)) - { - container.Items.Add(new AdaptiveTextBlock { Text = model.ShortTermStrategy.ShortTermOpportunities, Wrap = true }); - } - - if (!string.IsNullOrEmpty(model.ShortTermStrategy.PsychologicalTrapToAvoid)) - { - container.Items.Add(new AdaptiveTextBlock { Text = $"⚠️ 心理陷阱: {model.ShortTermStrategy.PsychologicalTrapToAvoid}", Wrap = true, Color = AdaptiveTextColor.Attention, Size = AdaptiveTextSize.Small }); - } - var facts = new AdaptiveFactSet(); if (!string.IsNullOrEmpty(model.ShortTermStrategy.TargetPriceRange)) { @@ -143,12 +121,20 @@ public override AdaptiveCard Parse(MarketSentimentAnalysisResult model) { facts.Facts.Add(new AdaptiveFact("止损位置", model.ShortTermStrategy.StopLossPosition)); } - if (facts.Facts.Count > 0) + + // 统一策略看板:建议标题 + 短期机会正文 + 目标/止损指标 + AddStrategyBox( + card.Body, + $"建议: {recommendation}", + color, + content: model.ShortTermStrategy.ShortTermOpportunities, + facts: facts.Facts.Count > 0 ? facts : null); + + // 心理陷阱独立为统一风险看板 + if (!string.IsNullOrEmpty(model.ShortTermStrategy.PsychologicalTrapToAvoid)) { - container.Items.Add(facts); + AddRiskBox(card.Body, "心理陷阱", model.ShortTermStrategy.PsychologicalTrapToAvoid); } - - card.Body.Add(container); } return card; diff --git a/src/Infrastructure/AdaptiveCards/Parsers/TechnicalCardParser.cs b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/TechnicalCardParser.cs similarity index 87% rename from src/Infrastructure/AdaptiveCards/Parsers/TechnicalCardParser.cs rename to src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/TechnicalCardParser.cs index 3b90f2f..9766bb8 100644 --- a/src/Infrastructure/AdaptiveCards/Parsers/TechnicalCardParser.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/AdaptiveCards/Parsers/TechnicalCardParser.cs @@ -106,27 +106,9 @@ public override AdaptiveCard Parse(TechnicalAnalysisResult model) if (model.Strategy != null) { - AddSectionHeader(card.Body, "交易策略"); var direction = GetEnumDescription(model.Strategy.OperationDirection); var color = direction.Contains("买入") || direction.Contains("做多") ? AdaptiveTextColor.Good : (direction.Contains("卖出") || direction.Contains("做空") ? AdaptiveTextColor.Attention : AdaptiveTextColor.Default); - // 策略看板:方向 + 目标价 - var strategyContainer = new AdaptiveContainer { Style = AdaptiveContainerStyle.Emphasis, Spacing = AdaptiveSpacing.Small }; - strategyContainer.Items.Add(new AdaptiveTextBlock - { - Text = $"建议: {direction}", - Weight = AdaptiveTextWeight.Bolder, - Size = AdaptiveTextSize.Large, - Color = color - }); - - strategyContainer.Items.Add(new AdaptiveTextBlock - { - Text = $"评级: {GetEnumDescription(model.Strategy.TechnicalRating)}", - Weight = AdaptiveTextWeight.Bolder, - Size = AdaptiveTextSize.Medium - }); - var facts = new AdaptiveFactSet(); if (model.Strategy.TargetPriceLow.HasValue && model.Strategy.TargetPriceHigh.HasValue) { @@ -141,8 +123,13 @@ public override AdaptiveCard Parse(TechnicalAnalysisResult model) facts.Facts.Add(new AdaptiveFact("持仓周期", GetEnumDescription(model.Strategy.HoldingPeriod))); facts.Facts.Add(new AdaptiveFact("风险等级", GetEnumDescription(model.Strategy.RiskLevel))); - strategyContainer.Items.Add(facts); - card.Body.Add(strategyContainer); + // 统一策略看板:方向标题 + 评级内容 + 指标 + AddStrategyBox( + card.Body, + $"建议: {direction}", + color, + content: $"评级: {GetEnumDescription(model.Strategy.TechnicalRating)}", + facts: facts); } return card; diff --git a/src/MarketAssistant.App.Services/Infrastructure/Core/FileSystem.cs b/src/MarketAssistant.App.Services/Infrastructure/Core/FileSystem.cs new file mode 100644 index 0000000..acc6925 --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Core/FileSystem.cs @@ -0,0 +1,127 @@ +using System.IO; +using MarketAssistant.Applications.Settings; + +namespace MarketAssistant.Infrastructure.Core; + +/// +/// Avalonia平台的FileSystem实现,提供与MAUI FileSystem相同的API +/// +public static class FileSystem +{ + private static readonly string _appName = AppInfo.AppName; + + /// + /// 应用程序数据目录 + /// + public static string AppDataDirectory => GetAppDataDirectory(); + + /// + /// 缓存目录 + /// + public static string CacheDirectory => GetCacheDirectory(); + + /// + /// 应用程序包目录 + /// + public static string AppPackageDirectory => GetAppPackageDirectory(); + + private static string GetAppDataDirectory() + { + try + { + string appDataPath; + + if (OperatingSystem.IsWindows()) + { + appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + } + else if (OperatingSystem.IsMacOS()) + { + appDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", "Application Support"); + } + else // Linux和其他Unix系统 + { + appDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config"); + } + + var appDataDir = Path.Combine(appDataPath, _appName); + + // 确保目录存在 + Directory.CreateDirectory(appDataDir); + return appDataDir; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"获取AppDataDirectory时出错: {ex.Message}"); + + // 备用方案:使用当前目录 + var fallbackDir = Path.Combine(Directory.GetCurrentDirectory(), "AppData"); + Directory.CreateDirectory(fallbackDir); + return fallbackDir; + } + } + + private static string GetCacheDirectory() + { + try + { + string cachePath; + + if (OperatingSystem.IsWindows()) + { + cachePath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + } + else if (OperatingSystem.IsMacOS()) + { + cachePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", "Caches"); + } + else // Linux和其他Unix系统 + { + cachePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".cache"); + } + + var cacheDir = Path.Combine(cachePath, _appName, AppInfo.CacheDirectoryName); + + // 确保目录存在 + Directory.CreateDirectory(cacheDir); + return cacheDir; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"获取CacheDirectory时出错: {ex.Message}"); + + // 备用方案:使用AppData目录下的Cache子目录 + var fallbackDir = Path.Combine(AppDataDirectory, AppInfo.CacheDirectoryName); + Directory.CreateDirectory(fallbackDir); + return fallbackDir; + } + } + + private static string GetAppPackageDirectory() + { + try + { + // 对于Avalonia应用,使用应用程序所在目录 + var baseDir = AppDomain.CurrentDomain.BaseDirectory; + + // 检查目录是否存在且可访问 + if (Directory.Exists(baseDir)) + { + return baseDir; + } + + // 备用方案:使用当前工作目录 + return Directory.GetCurrentDirectory(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"获取AppPackageDirectory时出错: {ex.Message}"); + return Directory.GetCurrentDirectory(); + } + } +} + diff --git a/src/MarketAssistant.App.Services/Infrastructure/Core/JsonOptions.cs b/src/MarketAssistant.App.Services/Infrastructure/Core/JsonOptions.cs new file mode 100644 index 0000000..fd5102f --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Core/JsonOptions.cs @@ -0,0 +1,29 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using MarketAssistant.Services.Data; + +namespace MarketAssistant.Infrastructure.Core; + +/// +/// 共享的 JsonSerializerOptions 实例,避免在多处重复定义相同配置 +/// +public static class JsonOptions +{ + /// + /// 用于 A 股 API 响应反序列化:属性名大小写不敏感 + 字符串数值自动转换为 decimal/decimal? + /// + public static readonly JsonSerializerOptions AShareApiOptions = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new StringToDecimalConverter() } + }; + + /// + /// 用于资产数据格式化输出:缩进排版 + 非转义中文输出 + /// + public static readonly JsonSerializerOptions AssetFormatterOptions = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; +} diff --git a/src/MarketAssistant.App.Services/Infrastructure/Core/SqliteServiceBase.cs b/src/MarketAssistant.App.Services/Infrastructure/Core/SqliteServiceBase.cs new file mode 100644 index 0000000..fe90065 --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Core/SqliteServiceBase.cs @@ -0,0 +1,97 @@ +using MarketAssistant.Applications.Settings; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Infrastructure.Core; + +/// +/// SQLite 服务基类,封装通用的连接字符串构建、连接打开(WAL 模式 + busy_timeout) +/// 以及延迟初始化(含重试)逻辑,供各 SQLite 持久化服务继承使用。 +/// 所有子类共享统一的 market.db 文件。 +/// +public abstract class SqliteServiceBase +{ + /// + /// 统一数据库文件名。所有持久化服务共享此文件。 + /// + protected internal const string UnifiedDbFileName = "market.db"; + + private readonly object _initLock = new(); + private Task? _initializeTask; + + /// + /// SQLite 连接字符串(格式:Data Source={dbPath}) + /// + protected string ConnectionString { get; } + + /// + /// 日志记录器 + /// + protected ILogger Logger { get; } + + /// + /// 构造函数,使用统一数据库文件 market.db 构建连接字符串并创建数据目录。 + /// + /// 日志记录器 + protected SqliteServiceBase(ILogger logger) + { + Logger = logger; + + var appDataDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + AppInfo.AppName); + Directory.CreateDirectory(appDataDir); + + var dbPath = Path.Combine(appDataDir, UnifiedDbFileName); + // 统一使用 Data Source={dbPath},不指定 Mode=ReadWriteCreate + // 文件不存在时由 SqliteConnection.OpenAsync 自动创建 + ConnectionString = $"Data Source={dbPath}"; + } + + /// + /// 打开一个新的 SQLite 连接,并统一启用 WAL 模式和 busy_timeout。 + /// + /// 取消令牌 + /// 已打开的 SqliteConnection(调用方负责释放) + protected async Task OpenConnectionAsync(CancellationToken cancellationToken = default) + { + var conn = new SqliteConnection(ConnectionString); + await conn.OpenAsync(cancellationToken); + + await using var pragmaCmd = conn.CreateCommand(); + // WAL 模式:读写不互斥,提升并发性能 + // busy_timeout:写冲突时等待 5 秒而非立即抛 "database is locked" + pragmaCmd.CommandText = """ + PRAGMA journal_mode=WAL; + PRAGMA busy_timeout=5000; + """; + await pragmaCmd.ExecuteNonQueryAsync(cancellationToken); + + return conn; + } + + /// + /// 延迟初始化数据库,失败后允许重试(避免 Task 处于 Faulted 状态导致后续调用永久失败)。 + /// + /// 初始化数据库的委托(通常是 InitializeDatabaseAsync) + protected async Task EnsureInitializedAsync(Func initializeFunc) + { + if (_initializeTask != null && _initializeTask.IsCompletedSuccessfully) + return; + + lock (_initLock) + { + // 如果之前失败,重置允许重试 + if (_initializeTask?.IsFaulted == true) + _initializeTask = null; + + _initializeTask ??= initializeFunc(); + } + await _initializeTask; + } + + /// + /// 初始化数据库表结构,由子类实现各服务特有的建表 SQL。 + /// + protected abstract Task InitializeDatabaseAsync(); +} diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/AnalystAgentFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/AnalystAgentFactory.cs new file mode 100644 index 0000000..8bc3a45 --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/AnalystAgentFactory.cs @@ -0,0 +1,149 @@ +using MarketAssistant.Agents.Analysts; +using MarketAssistant.Agents.Analysts.Attributes; +using MarketAssistant.Agents.Middleware; +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Market; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Reflection; + +namespace MarketAssistant.Infrastructure.Factories; + +/// +/// 分析师代理工厂接口 +/// +public interface IAnalystAgentFactory +{ + /// + /// 根据类型创建对应的代理(动态调用,运行时检查) + /// + AIAgent CreateAnalyst(Type agentType); + + /// + /// 根据类型创建代理,附加额外的 AIContextProvider(如共享市场快照) + /// + AIAgent CreateAnalyst(Type agentType, AIContextProvider[]? additionalProviders); +} + +/// +/// 分析师代理工厂实现 +/// 负责创建配置好的分析师代理(使用 DI 容器) +/// +public class AnalystAgentFactory : IAnalystAgentFactory +{ + private readonly IServiceProvider _serviceProvider; + private readonly IChatClientFactory _chatClientFactory; + private readonly MarketContext _marketContext; + private readonly TokenTrackingMiddleware _tokenTracking; + private readonly ILogger _logger; + + public AnalystAgentFactory( + IServiceProvider serviceProvider, + IChatClientFactory chatClientFactory, + MarketContext marketContext, + TokenTrackingMiddleware tokenTracking, + ILogger logger) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _chatClientFactory = chatClientFactory ?? throw new ArgumentNullException(nameof(chatClientFactory)); + _marketContext = marketContext ?? throw new ArgumentNullException(nameof(marketContext)); + _tokenTracking = tokenTracking ?? throw new ArgumentNullException(nameof(tokenTracking)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// 根据类型创建对应的代理 + /// + public AIAgent CreateAnalyst(Type agentType) => CreateAnalyst(agentType, additionalProviders: null); + + /// + /// 根据类型创建对应的代理,支持附加额外的 AIContextProvider + /// + public AIAgent CreateAnalyst(Type agentType, AIContextProvider[]? additionalProviders) + { + try + { + // 严格限制必须是 AnalystAgentBase 的子类 + if (!typeof(AnalystAgentBase).IsAssignableFrom(agentType)) + { + throw new ArgumentException($"Type {agentType.Name} must inherit from AnalystAgentBase", nameof(agentType)); + } + + // 创建 ChatClient + var chatClient = _chatClientFactory.CreateClient(); + + // 根据当前市场类型获取对应的工具实现 + var currentMarket = _marketContext.CurrentMarket; + + // 根据 Analyst 类型解析工具并合并为 AITool 列表 + var tools = ResolveToolsForAnalyst(agentType, currentMarket); + + // 显式传递 chatClient、合并后的工具列表和 AIContextProvider[],其余由 DI 自动解析 + var parameters = new List { chatClient, tools }; + + if (additionalProviders is { Length: > 0 }) + parameters.Add(additionalProviders); + + var agent = (AIAgent)ActivatorUtilities.CreateInstance(_serviceProvider, agentType, parameters.ToArray()); + + // 通过 MAF Builder 模式附加 Token 追踪中间件 + var middlewareAgent = agent + .AsBuilder() + .Use( + runFunc: _tokenTracking.InvokeAsync, + runStreamingFunc: _tokenTracking.InvokeStreamingAsync) + .Build(); + + _logger.LogInformation( + "成功创建分析师代理: {AgentType} (市场: {Market},已附加中间件)", + agentType.Name, currentMarket); + + return middlewareAgent; + } + catch (Exception ex) + { + _logger.LogError(ex, "创建分析师代理时发生错误: {AgentType}", agentType.Name); + throw; + } + } + + /// + /// 根据 Analyst 类上的 RequiresToolsAttribute 和市场类型解析工具, + /// 调用 合并为统一的 AITool 列表 + /// + private IList ResolveToolsForAnalyst(Type agentType, MarketType marketType) + { + var tools = new List(); + var resolvedCount = 0; + + var toolAttributes = agentType.GetCustomAttributes().ToList(); + foreach (var attr in toolAttributes) + { + var toolService = _serviceProvider.GetKeyedService(attr.ToolInterfaceType, marketType); + if (toolService is IToolsProvider provider) + { + tools.AddRange(provider.GetFunctions()); + resolvedCount++; + } + else + { + _logger.LogError( + "未找到 Analyst {AgentType} 所需的工具 {ToolType}(市场: {Market}),分析能力可能受限", + agentType.Name, attr.ToolInterfaceType.Name, marketType); + } + } + + if (resolvedCount != toolAttributes.Count) + { + _logger.LogError( + "Analyst {AgentType} 缺少 {Missing}/{Total} 个工具(市场: {Market})", + agentType.Name, toolAttributes.Count - resolvedCount, toolAttributes.Count, marketType); + } + + return tools; + } +} + diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/ChatClientFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/ChatClientFactory.cs new file mode 100644 index 0000000..f8d8abf --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/ChatClientFactory.cs @@ -0,0 +1,184 @@ +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.AI; +using OpenAI; +using Polly; +using Polly.Retry; +using System.ClientModel; + +namespace MarketAssistant.Infrastructure.Factories; + +/// +/// ChatClient 工厂接口 +/// 负责创建和管理底层的 IChatClient 实例 +/// +public interface IChatClientFactory +{ + /// + /// 创建配置好的 ChatClient 实例 + /// + IChatClient CreateClient(); +} + +/// +/// ChatClient 工厂实现 +/// 创建和缓存底层的 OpenAI ChatClient,并附加 LLM 瞬态错误重试管道 +/// +public class ChatClientFactory : IChatClientFactory +{ + /// + /// 瞬态错误冷却时间:冷却期内同一配置不重试,冷却后允许再次尝试 + /// + private static readonly TimeSpan ErrorCooldown = TimeSpan.FromSeconds(30); + + /// + /// LLM 调用重试管道:针对瞬态网络/服务端错误自动重试 2 次,指数退避 + 抖动 + /// 覆盖 Coordinator 和所有业务分析师的 LLM 调用 + /// + private static readonly ResiliencePipeline LlmRetryPipeline = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 2, + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, + Delay = TimeSpan.FromSeconds(2), + ShouldHandle = new PredicateBuilder() + .Handle() + .Handle(ex => IsNetworkTimeout(ex)) + }) + .Build(); + + /// + /// 判断 TaskCanceledException 是否由网络超时引起(而非用户主动取消)。 + /// System.ClientModel 超时时抛出的异常链为: + /// TaskCanceledException → TaskCanceledException → IOException → SocketException + /// + private static bool IsNetworkTimeout(TaskCanceledException ex) + { + if (ex.InnerException is TimeoutException) return true; + if (ex.CancellationToken.IsCancellationRequested) return false; + // System.ClientModel 的超时消息包含 "exceeded the configured timeout" + return ex.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase); + } + + private readonly IUserSettingService _userSettingService; + private readonly object _lock = new(); + private IChatClient? _cachedClient; + private string? _lastError; + private DateTime _lastErrorTime; + + // 缓存用于创建客户端的配置,以便检测变更 + private string? _cachedModelId; + private string? _cachedEndpoint; + private string? _cachedApiKey; + + public ChatClientFactory(IUserSettingService userSettingService) + { + _userSettingService = userSettingService; + } + + public IChatClient CreateClient() + { + IChatClient? oldClient = null; + try + { + lock (_lock) + { + var userSetting = _userSettingService.CurrentSetting; + var modelId = userSetting.ModelId; + var apiKey = userSetting.ApiKey; + var endpoint = userSetting.Endpoint; + + bool configUnchanged = _cachedModelId == modelId + && _cachedEndpoint == endpoint + && _cachedApiKey == apiKey; + + // 配置未变且有成功缓存 → 直接返回 + if (configUnchanged && _cachedClient != null) + { + return _cachedClient; + } + + // 配置未变且上次失败仍在冷却期内 → 快速失败,避免频繁重试 + if (configUnchanged + && !string.IsNullOrEmpty(_lastError) + && DateTime.UtcNow - _lastErrorTime < ErrorCooldown) + { + throw new FriendlyException(_lastError); + } + + // 配置已变更或冷却期已过,重置错误状态 + _lastError = null; + // 保存旧客户端引用,稍后在 lock 外 Dispose(避免持锁等待网络连接关闭) + oldClient = _cachedClient; + _cachedClient = null; + + try + { + if (string.IsNullOrWhiteSpace(modelId)) + throw new FriendlyException("AI 功能未配置:请先在设置页面选择 AI 模型"); + if (string.IsNullOrWhiteSpace(apiKey)) + throw new FriendlyException("AI 功能未配置:请先在设置页面配置 API Key"); + if (string.IsNullOrWhiteSpace(endpoint)) + throw new FriendlyException("AI 功能未配置:请先在设置页面配置 API 端点"); + + // 与 EmbeddingFactory 保持一致:OpenAI SDK 需要带 /v1 的 base URL + // 规范化处理:去掉末尾斜杠,若未包含 /v1 则追加,避免重复拼接 + var normalizedEndpoint = endpoint.TrimEnd('/'); + if (!normalizedEndpoint.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) + { + normalizedEndpoint += "/v1"; + } + + var openAIClient = new OpenAIClient( + new ApiKeyCredential(apiKey), + new OpenAIClientOptions + { + Endpoint = new Uri(normalizedEndpoint), + // 分析工作流中 Agent 使用流式调用,Tool-Call 链路在等待外部 API + // 返回期间不产生 token,默认 100s 超时过于激进。 + // 设为 3 分钟兼顾长链路 Tool-Call 和异常检测。 + NetworkTimeout = TimeSpan.FromMinutes(3) + } + ); + + // 使用 ResilientChatClient 装饰器附加重试管道,所有 LLM 调用自动获得瞬态错误重试 + var rawClient = openAIClient.GetChatClient(modelId).AsIChatClient(); + _cachedClient = new ResilientChatClient(rawClient, LlmRetryPipeline); + + _cachedModelId = modelId; + _cachedEndpoint = endpoint; + _cachedApiKey = apiKey; + + return _cachedClient; + } + catch (Exception ex) + { + _lastError = ex.Message; + _lastErrorTime = DateTime.UtcNow; + _cachedClient = null; + _cachedModelId = modelId; + _cachedEndpoint = endpoint; + _cachedApiKey = apiKey; + throw new FriendlyException(_lastError); + } + } + } + finally + { + // 在 lock 外 Dispose 旧客户端,避免持锁等待网络连接关闭。 + // 用 try-catch 包裹防止 Dispose 抛出异常覆盖 try 块中的原始异常 + if (oldClient != null) + { + try + { + oldClient.Dispose(); + } + catch (Exception) + { + // Dispose 失败不应影响主流程,仅记录 + // 此处无法使用 ILogger(工厂不持有 logger),异常被静默吞并 + } + } + } + } +} diff --git a/src/Infrastructure/Factories/EmbeddingFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/EmbeddingFactory.cs similarity index 62% rename from src/Infrastructure/Factories/EmbeddingFactory.cs rename to src/MarketAssistant.App.Services/Infrastructure/Factories/EmbeddingFactory.cs index 11059b3..b706d9a 100644 --- a/src/Infrastructure/Factories/EmbeddingFactory.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/EmbeddingFactory.cs @@ -5,11 +5,6 @@ namespace MarketAssistant.Infrastructure.Factories; -public interface IEmbeddingFactory -{ - IEmbeddingGenerator> Create(); -} - public class EmbeddingFactory : IEmbeddingFactory { private readonly IUserSettingService _userSettingService; @@ -22,20 +17,18 @@ public IEmbeddingGenerator> Create() { var userSetting = _userSettingService.CurrentSetting; var modelId = userSetting.EmbeddingModelId; - var apiKey = userSetting.ApiKey; - var endpoint = userSetting.Endpoint; + var apiKey = userSetting.EmbeddingApiKey; + var endpoint = userSetting.EmbeddingEndpoint; if (string.IsNullOrWhiteSpace(modelId)) throw new FriendlyException("嵌入模型ID不能为空"); if (string.IsNullOrWhiteSpace(apiKey)) - throw new FriendlyException("API密钥不能为空"); + throw new FriendlyException("嵌入API密钥不能为空"); - var client = string.IsNullOrWhiteSpace(endpoint) - ? new OpenAIClient(apiKey) - : new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions - { - Endpoint = new Uri(endpoint + "/v1") - }); + var client = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions + { + Endpoint = new Uri(endpoint + "/v1") + }); return client.GetEmbeddingClient(modelId).AsIEmbeddingGenerator(); } diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/MarketChatSessionFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/MarketChatSessionFactory.cs new file mode 100644 index 0000000..a4a201f --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/MarketChatSessionFactory.cs @@ -0,0 +1,93 @@ +using MarketAssistant.Agents; +using MarketAssistant.Agents.ContextProviders; +using MarketAssistant.Agents.Middleware; +using MarketAssistant.Agents.Tools; +using MarketAssistant.Services; +using MarketAssistant.Services.Mcp; +using Microsoft.Agents.AI; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Infrastructure.Factories; + +/// +/// MarketChatSession 工厂接口 +/// +public interface IMarketChatSessionFactory +{ + /// + /// 创建新的 MarketChatSession 实例(每个聊天侧边栏对应一个独立会话) + /// + MarketChatSession Create(string? initialStockCode = null); +} + +/// +/// MarketChatSession 工厂实现,从 DI 容器解析所有依赖并组装会话 +/// +public class MarketChatSessionFactory : IMarketChatSessionFactory +{ + private readonly IChatClientFactory _chatClientFactory; + private readonly ILoggerFactory _loggerFactory; + private readonly McpToolContextProvider _mcpToolProvider; + private readonly GroundingSearchTools _searchTools; + private readonly MemoryManagementTools _memoryTools; + private readonly SessionSearchTools _sessionSearchTools; + private readonly KnowledgeGraphTools _knowledgeGraphTools; + private readonly AgentSkillsProvider? _skillsProvider; + private readonly TokenTrackingMiddleware _tokenTracking; + private readonly ConversationCompressionMiddleware _compressionMiddleware; + private readonly LayeredMemoryContextProvider _layeredMemoryProvider; + private readonly ChatSessionPersistenceService _sessionPersistence; + private readonly MemoryExtractionService _memoryExtraction; + + public MarketChatSessionFactory( + IChatClientFactory chatClientFactory, + ILoggerFactory loggerFactory, + McpToolContextProvider mcpToolProvider, + GroundingSearchTools searchTools, + MemoryManagementTools memoryTools, + SessionSearchTools sessionSearchTools, + KnowledgeGraphTools knowledgeGraphTools, + TokenTrackingMiddleware tokenTracking, + ConversationCompressionMiddleware compressionMiddleware, + LayeredMemoryContextProvider layeredMemoryProvider, + ChatSessionPersistenceService sessionPersistence, + MemoryExtractionService memoryExtraction, + AgentSkillsProvider? skillsProvider = null) + { + _chatClientFactory = chatClientFactory; + _loggerFactory = loggerFactory; + _mcpToolProvider = mcpToolProvider; + _searchTools = searchTools; + _memoryTools = memoryTools; + _sessionSearchTools = sessionSearchTools; + _knowledgeGraphTools = knowledgeGraphTools; + _skillsProvider = skillsProvider; + _tokenTracking = tokenTracking; + _compressionMiddleware = compressionMiddleware; + _layeredMemoryProvider = layeredMemoryProvider; + _sessionPersistence = sessionPersistence; + _memoryExtraction = memoryExtraction; + } + + public MarketChatSession Create(string? initialStockCode = null) + { + var chatClient = _chatClientFactory.CreateClient(); + var logger = _loggerFactory.CreateLogger(); + + return new MarketChatSession( + chatClient, + logger, + mcpToolProvider: _mcpToolProvider, + searchTools: _searchTools, + memoryTools: _memoryTools, + sessionSearchTools: _sessionSearchTools, + knowledgeGraphTools: _knowledgeGraphTools, + skillsProvider: _skillsProvider, + tokenTracking: _tokenTracking, + compressionMiddleware: _compressionMiddleware, + layeredMemoryProvider: _layeredMemoryProvider, + sessionPersistence: _sessionPersistence, + memoryExtraction: _memoryExtraction, + initialStockCode: initialStockCode); + } +} diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/ResilientChatClient.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/ResilientChatClient.cs new file mode 100644 index 0000000..b1be037 --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/ResilientChatClient.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.AI; +using Polly; + +namespace MarketAssistant.Infrastructure.Factories; + +/// +/// IChatClient 装饰器:为所有 LLM 调用附加 Polly 瞬态错误重试管道。 +/// 覆盖 Coordinator 和所有业务分析师的 LLM 调用,避免单次瞬态错误导致分析维度缺失。 +/// +internal sealed class ResilientChatClient : IChatClient +{ + private readonly IChatClient _inner; + private readonly ResiliencePipeline _pipeline; + + public ResilientChatClient(IChatClient inner, ResiliencePipeline pipeline) + { + _inner = inner; + _pipeline = pipeline; + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + return await _pipeline.ExecuteAsync( + async ct => await _inner.GetResponseAsync(messages, options, ct), + cancellationToken); + } + + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + // 流式调用不支持简单重试(流一旦开始输出无法回放),直接透传 + return _inner.GetStreamingResponseAsync(messages, options, cancellationToken); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return _inner.GetService(serviceType, serviceKey); + } + + public void Dispose() + { + _inner.Dispose(); + } +} diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/ToolExecutor.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/ToolExecutor.cs new file mode 100644 index 0000000..d8433c0 --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/ToolExecutor.cs @@ -0,0 +1,45 @@ +using System.Diagnostics; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Infrastructure.Factories; + +/// +/// 工具执行器:统一封装 Agent Tool 的执行流程,包含日志记录、异常转换和计时。 +/// +public static class ToolExecutor +{ + /// + /// 执行工具操作并统一处理日志与异常。 + /// + public static async Task ExecuteAsync( + string operationName, + string assetSymbol, + ILogger logger, + Func> action, + CancellationToken cancellationToken = default) + { + var sw = Stopwatch.StartNew(); + logger.LogInformation("开始{Operation}: {Symbol}", operationName, assetSymbol); + + try + { + var result = await action(cancellationToken); + logger.LogInformation("完成{Operation}: {Symbol}, 耗时 {Elapsed}ms", operationName, assetSymbol, sw.ElapsedMilliseconds); + return result; + } + catch (OperationCanceledException) + { + throw; + } + catch (FriendlyException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "{Operation}失败: {Symbol}", operationName, assetSymbol); + throw new FriendlyException($"{operationName}失败: {ex.Message}", ex); + } + } +} diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/TradingAgentFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/TradingAgentFactory.cs new file mode 100644 index 0000000..b17bbd0 --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/TradingAgentFactory.cs @@ -0,0 +1,114 @@ +using System.Reflection; +using MarketAssistant.Agents.Analysts.Attributes; +using MarketAssistant.Agents.Middleware; +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Trading; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Market; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Infrastructure.Factories; + +/// +/// 交易 Agent 工厂接口 +/// +public interface ITradingAgentFactory +{ + AIAgent CreateAgent(); +} + +/// +/// 交易 Agent 工厂实现。 仅影响界面与分析链路; +/// 自主交易与工具解析始终使用 的 Keyed 注册(现货 Binance),与当前所选市场无关。 +/// +public class TradingAgentFactory : ITradingAgentFactory +{ + private readonly IServiceProvider _serviceProvider; + private readonly IChatClientFactory _chatClientFactory; + private readonly TokenTrackingMiddleware _tokenTracking; + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + + /// + /// Human-in-the-Loop 确认回调。 + /// 参数: (functionName, argsDescription) → true=放行 false=拒绝。 + /// UI 层可在创建工厂后设置此属性以接入用户确认对话框。 + /// + public Func>? TradeConfirmationCallback { get; set; } + + public TradingAgentFactory( + IServiceProvider serviceProvider, + IChatClientFactory chatClientFactory, + TokenTrackingMiddleware tokenTracking, + ILoggerFactory loggerFactory, + ILogger logger) + { + _serviceProvider = serviceProvider; + _chatClientFactory = chatClientFactory; + _tokenTracking = tokenTracking; + _loggerFactory = loggerFactory; + _logger = logger; + } + + /// + /// 创建包装中间件后的 ;工具一律从 解析。 + /// + public AIAgent CreateAgent() + { + try + { + var chatClient = _chatClientFactory.CreateClient(); + var tools = ResolveToolParameters(); + + var agent = (AIAgent)ActivatorUtilities.CreateInstance( + _serviceProvider, typeof(TradingAgent), chatClient, tools); + + // 创建 Function Calling 守卫中间件(每次 CreateAgent 新建实例以重置调用计数) + var guardMiddleware = new TradingFunctionGuardMiddleware( + _loggerFactory.CreateLogger()); + guardMiddleware.ConfirmationCallback = TradeConfirmationCallback; + + // 通过 MAF Builder 模式附加中间件链:Token 追踪 + Function Calling 守卫 + var middlewareAgent = agent + .AsBuilder() + .Use( + runFunc: _tokenTracking.InvokeAsync, + runStreamingFunc: _tokenTracking.InvokeStreamingAsync) + .Use(guardMiddleware.InvokeAsync) + .Build(); + + _logger.LogInformation("成功创建 TradingAgent(已附加 Token 追踪 + 交易守卫中间件)"); + return middlewareAgent; + } + catch (Exception ex) + { + _logger.LogError(ex, "创建 TradingAgent 失败"); + throw; + } + } + + private IList ResolveToolParameters() + { + var toolAttributes = typeof(TradingAgent).GetCustomAttributes().ToList(); + var tools = new List(); + + foreach (var attr in toolAttributes) + { + var toolService = _serviceProvider.GetKeyedService(attr.ToolInterfaceType, MarketType.Crypto); + if (toolService is IToolsProvider provider) + { + tools.AddRange(provider.GetFunctions()); + } + else + { + throw new InvalidOperationException( + $"TradingAgent 所需的工具 {attr.ToolInterfaceType.Name} 未注册(MarketType.Crypto)"); + } + } + + return tools; + } +} diff --git a/src/Infrastructure/Factories/WebTextSearchFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/WebTextSearchFactory.cs similarity index 93% rename from src/Infrastructure/Factories/WebTextSearchFactory.cs rename to src/MarketAssistant.App.Services/Infrastructure/Factories/WebTextSearchFactory.cs index 7f7398a..a63d39c 100644 --- a/src/Infrastructure/Factories/WebTextSearchFactory.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/WebTextSearchFactory.cs @@ -6,11 +6,6 @@ namespace MarketAssistant.Infrastructure.Factories; -public interface IWebTextSearchFactory -{ - ITextSearch? Create(); -} - public class WebTextSearchFactory(IUserSettingService userSettingService) : IWebTextSearchFactory { public ITextSearch? Create() diff --git a/src/MarketAssistant.App.Services/Infrastructure/Http/CoinGeckoApiKeyHandler.cs b/src/MarketAssistant.App.Services/Infrastructure/Http/CoinGeckoApiKeyHandler.cs new file mode 100644 index 0000000..18f5e4b --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Http/CoinGeckoApiKeyHandler.cs @@ -0,0 +1,33 @@ +using MarketAssistant.Services.Settings; + +namespace MarketAssistant.Infrastructure.Http; + +/// +/// CoinGecko API Key 注入处理器 +/// 从用户设置读取 CoinGeckoApiKey,按密钥类型添加对应请求头: +/// - 以 "CG-Pro-" 开头:添加 x-cg-pro-api-key(付费版) +/// - 其他:添加 x-cg-demo-api-key(免费 Demo 版) +/// - 为空:不添加(兼容旧版公共端点,但 /coins/markets 可能返回 401) +/// +public sealed class CoinGeckoApiKeyHandler : DelegatingHandler +{ + private readonly IUserSettingService _userSettingService; + + public CoinGeckoApiKeyHandler(IUserSettingService userSettingService) + { + _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var apiKey = _userSettingService.CurrentSetting.CoinGeckoApiKey; + if (!string.IsNullOrWhiteSpace(apiKey)) + { + var headerName = apiKey.StartsWith("CG-Pro-", StringComparison.OrdinalIgnoreCase) + ? "x-cg-pro-api-key" + : "x-cg-demo-api-key"; + request.Headers.TryAddWithoutValidation(headerName, apiKey.Trim()); + } + return base.SendAsync(request, cancellationToken); + } +} diff --git a/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj b/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj new file mode 100644 index 0000000..1ff8af2 --- /dev/null +++ b/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj @@ -0,0 +1,54 @@ + + + net10.0 + enable + MarketAssistant + enable + $(NoWarn);MAAI001 + TestMarketAssistant + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj.lscache b/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj.lscache new file mode 100644 index 0000000..3aa798b --- /dev/null +++ b/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj.lscache @@ -0,0 +1,557 @@ +version=1 + +# This file caches language service data to improve the performance of C# Dev Kit. +# It is not intended for manual editing. It can safely be deleted and will be +# regenerated automatically. For more information, see https://aka.ms/lscache +# +# To control where cache files are stored, use the following VS Code setting: +# "dotnet.projectsystem.cacheInProjectFolder": true + +[project] +language=C# +primary +lastDtbSucceeded + +[properties] +AssemblyName=MarketAssistant.App.Services +CommandLineArgsForDesignTimeEvaluation=-langversion:14.0 -define:TRACE +CompilerGeneratedFilesOutputPath= +MaxSupportedLangVersion=14.0 +ProjectAssetsFile=obj/project.assets.json +RootNamespace=MarketAssistant +RunAnalyzers= +RunAnalyzersDuringLiveAnalysis= +SolutionPath=../../MarketAssistant.slnx +TargetFrameworkIdentifier=.NETCoreApp +TargetPath=bin/Debug/net10.0/MarketAssistant.App.Services.dll +TargetRefPath=obj/Debug/net10.0/ref/MarketAssistant.App.Services.dll +TemporaryDependencyNodeTargetIdentifier=net10.0 + +[commandLineArguments] +/noconfig +/unsafe- +/checked- +/nowarn:1701,1702,MAAI001,1701,1702,8002 +/fullpaths +/nostdlib+ +/errorreport:prompt +/warn:10 +/define:TRACE;DEBUG;NET;NET10_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NET10_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER +/highentropyva+ +/nullable:enable +/features:"InterceptorsNamespaces=;Microsoft.Extensions.Validation.Generated" +/debug+ +/debug:portable +/filealign:512 +/optimize- +/out:obj\Debug\net10.0\MarketAssistant.App.Services.dll +/refout:obj\Debug\net10.0\refint\MarketAssistant.App.Services.dll +/target:library +/warnaserror- +/utf8output +/deterministic+ +/langversion:14.0 +/warnaserror+:NU1605,SYSLIB0011 + +[sourceFiles] +Agents/Analysts/ + AnalystTypeRegistry.cs + CoordinatorAnalystAgent.cs + FinancialAnalystAgent.cs + FundamentalAnalystAgent.cs + MarketSentimentAnalystAgent.cs + NewsEventAnalystAgent.cs + TechnicalAnalystAgent.cs +Agents/ContextProviders/ + InvestmentPreferenceContextProvider.cs + LayeredMemoryContextProvider.cs +Agents/InvestmentSelection/Executors/ + AnalyzeAssetsExecutor.cs + GenerateCriteriaExecutor.cs + ScreenInvestmentTargetsExecutor.cs +Agents/InvestmentSelection/ + InvestmentSelectionWorkflow.cs + Models/WorkflowModels.cs + Strategies/ + CryptoCriteriaGenerationStrategy.cs + CryptoDataFormatter.cs + IAssetDataFormatter.cs + ICriteriaGenerationStrategy.cs + StockCriteriaGenerationStrategy.cs + StockDataFormatter.cs +Agents/MarketAnalysis/Executors/ + AnalysisAggregatorExecutor.cs + AnalysisDispatcherExecutor.cs + CoordinatorExecutor.cs +Agents/MarketAnalysis/ + MarketAnalysisWorkflow.cs + MarketSnapshotContextProvider.cs + WorkflowStateKeys.cs +Agents/ + MarketChatSession.cs + Tools/AShare/ + AShareBasicTools.cs + AShareFinancialTools.cs + AShareNewsTools.cs + AShareSentimentTools.cs + AShareTechnicalTools.cs + Tools/Crypto/ + CryptoBasicTools.cs + CryptoMetricsTools.cs + CryptoNewsTools.cs + CryptoSentimentTools.cs + CryptoStrategyTools.cs + CryptoTechnicalTools.cs + CryptoTradingExecutionTools.cs + Tools/ + GroundingSearchTools.cs + KnowledgeGraphTools.cs + MemoryManagementTools.cs + SessionSearchTools.cs + Trading/TradingAgent.cs +Applications/ + Analysis/AnalysisOrchestrationService.cs + Assets/ + AShareAssetInfoService.cs + CryptoAssetInfoService.cs + IAssetInfoService.cs + Models/ + AssetInfo.cs + AssetItem.cs + FavoriteAsset.cs + HotAsset.cs + AssetScreener/ + CryptoScreenerService.cs + IAssetScreenerService.cs + Models/ + AShareType.cs + CryptoCriteria.cs + IndustryType.cs + IScreeningCriteria.cs + ScreenerAssetInfo.cs + ScreenerCryptoInfo.cs + ScreenerStockInfo.cs + StockCriteria.cs + StockScreeningCriteria.cs + StockScreenerService.cs + Cache/ + AShareAssetCacheService.cs + CryptoAssetCacheService.cs + IAssetCacheService.cs + Charts/ + AShareKLineService.cs + CryptoKLineService.cs + IKLineService.cs + Models/ + KLineData.cs + KLineType.cs + Crypto/ + BinanceAccountService.cs + BinanceAuthService.cs + Favorites/ + AShareFavoriteService.cs + CryptoFavoriteService.cs + FavoriteServiceBase.cs + IFavoriteService.cs + History/ + AShareHistoryService.cs + AssetHistoryServiceBase.cs + CryptoHistoryService.cs + IAssetHistoryService.cs + Home/ + AShareHomeService.cs + CryptoHomeService.cs + HomeAssetServiceBase.cs + IHomeAssetService.cs + InvestmentSelection/ + InvestmentSelectionService.cs + Models/ + InvestmentRecommendation.cs + InvestmentRecommendationRequest.cs + InvestmentSelectionEnums.cs + InvestmentSelectionResult.cs + NewsBasedInvestmentRequest.cs + QuickSelectionStrategy.cs + News/ + INewsUpdateService.cs + NewsUpdateService.cs + PriceAlert/ + PriceAlertRule.cs + PriceAlertService.cs + Settings/ + AppInfo.cs + GitHubReleaseService.cs + InvestmentPreference.cs + IReleaseService.cs + MCPServerConfig.cs + MCPServerConfigService.cs + UpdateCheckResult.cs + UserSetting.cs + Telegrams/ + AShareTelegramService.cs + CryptoTelegramService.cs + ITelegramService.cs + Telegram.cs +GlobalUsing.cs +Infrastructure/ + Abstractions/IAnalysisService.cs + AdaptiveCards/Parsers/ + BaseAdaptiveCardParser.cs + CoordinatorCardParser.cs + FinancialCardParser.cs + FundamentalCardParser.cs + IAdaptiveCardParser.cs + NewsCardParser.cs + SentimentCardParser.cs + TechnicalCardParser.cs + Configuration/Preferences.cs + Core/FileSystem.cs + Factories/ + AnalystAgentFactory.cs + ChatClientFactory.cs + EmbeddingFactory.cs + MarketChatSessionFactory.cs + TradingAgentFactory.cs + WebTextSearchFactory.cs +obj/Debug/net10.0/ + .NETCoreApp,Version=v10.0.AssemblyAttributes.cs + MarketAssistant.App.Services.AssemblyInfo.cs + MarketAssistant.App.Services.GlobalUsings.g.cs +Services/ + Archive/ReportArchiveService.cs + Browser/ + BrowserService.cs + IBrowserService.cs + PlaywrightService.cs + Cache/ + AnalysisCacheService.cs + IAnalysisCacheService.cs + ChatSessionPersistenceService.cs + Dialog/IDialogService.cs + Export/MarkdownReportExporter.cs + Market/ + AShareMarketModule.cs + CryptoMarketModule.cs + IMarketCapability.cs + IMarketModule.cs + MarketContext.cs + Mcp/ + McpService.cs + McpToolAuditLogger.cs + McpToolContextProvider.cs + MemoryExtractionService.cs + Notification/INotificationService.cs + ServiceCollectionExtensions.cs + Settings/ + IUserSettingService.cs + UserSettingService.cs + UserKnowledgeGraphService.cs + UserMemoryService.cs + WorkflowVisualizationService.cs +Trading/ + CryptoPortfolioService.cs + Exchanges/BinanceExchangeClient.cs + MarketMonitor.cs + RiskManager.cs + StrategyEngine.cs + TradeExecutor.cs + TradingDataService.cs + +[metadataReferences] +../ + MarketAssistant.Agents/obj/Debug/net10.0/ref/MarketAssistant.Agents.dll + MarketAssistant.Core/obj/Debug/net10.0/ref/MarketAssistant.Core.dll + MarketAssistant.DataProviders/obj/Debug/net10.0/ref/MarketAssistant.DataProviders.dll + MarketAssistant.Rag/obj/Debug/net10.0/ref/MarketAssistant.Rag.dll + MarketAssistant.Trading/obj/Debug/net10.0/ref/MarketAssistant.Trading.dll +/packs/Microsoft.NETCore.App.Ref/10.0.5/ref/net10.0/ + Microsoft.CSharp.dll + Microsoft.VisualBasic.Core.dll + Microsoft.VisualBasic.dll + Microsoft.Win32.Primitives.dll + Microsoft.Win32.Registry.dll + mscorlib.dll + netstandard.dll + System.AppContext.dll + System.Buffers.dll + System.Collections.Concurrent.dll + System.Collections.dll + System.Collections.Immutable.dll + System.Collections.NonGeneric.dll + System.Collections.Specialized.dll + System.ComponentModel.Annotations.dll + System.ComponentModel.DataAnnotations.dll + System.ComponentModel.dll + System.ComponentModel.EventBasedAsync.dll + System.ComponentModel.Primitives.dll + System.ComponentModel.TypeConverter.dll + System.Configuration.dll + System.Console.dll + System.Core.dll + System.Data.Common.dll + System.Data.DataSetExtensions.dll + System.Data.dll + System.Diagnostics.Contracts.dll + System.Diagnostics.Debug.dll + System.Diagnostics.DiagnosticSource.dll + System.Diagnostics.FileVersionInfo.dll + System.Diagnostics.Process.dll + System.Diagnostics.StackTrace.dll + System.Diagnostics.TextWriterTraceListener.dll + System.Diagnostics.Tools.dll + System.Diagnostics.TraceSource.dll + System.Diagnostics.Tracing.dll + System.dll + System.Drawing.dll + System.Drawing.Primitives.dll + System.Dynamic.Runtime.dll + System.Formats.Asn1.dll + System.Formats.Tar.dll + System.Globalization.Calendars.dll + System.Globalization.dll + System.Globalization.Extensions.dll + System.IO.Compression.Brotli.dll + System.IO.Compression.dll + System.IO.Compression.FileSystem.dll + System.IO.Compression.ZipFile.dll + System.IO.dll + System.IO.FileSystem.AccessControl.dll + System.IO.FileSystem.dll + System.IO.FileSystem.DriveInfo.dll + System.IO.FileSystem.Primitives.dll + System.IO.FileSystem.Watcher.dll + System.IO.IsolatedStorage.dll + System.IO.MemoryMappedFiles.dll + System.IO.Pipelines.dll + System.IO.Pipes.AccessControl.dll + System.IO.Pipes.dll + System.IO.UnmanagedMemoryStream.dll + System.Linq.AsyncEnumerable.dll + System.Linq.dll + System.Linq.Expressions.dll + System.Linq.Parallel.dll + System.Linq.Queryable.dll + System.Memory.dll + System.Net.dll + System.Net.Http.dll + System.Net.Http.Json.dll + System.Net.HttpListener.dll + System.Net.Mail.dll + System.Net.NameResolution.dll + System.Net.NetworkInformation.dll + System.Net.Ping.dll + System.Net.Primitives.dll + System.Net.Quic.dll + System.Net.Requests.dll + System.Net.Security.dll + System.Net.ServerSentEvents.dll + System.Net.ServicePoint.dll + System.Net.Sockets.dll + System.Net.WebClient.dll + System.Net.WebHeaderCollection.dll + System.Net.WebProxy.dll + System.Net.WebSockets.Client.dll + System.Net.WebSockets.dll + System.Numerics.dll + System.Numerics.Vectors.dll + System.ObjectModel.dll + System.Reflection.DispatchProxy.dll + System.Reflection.dll + System.Reflection.Emit.dll + System.Reflection.Emit.ILGeneration.dll + System.Reflection.Emit.Lightweight.dll + System.Reflection.Extensions.dll + System.Reflection.Metadata.dll + System.Reflection.Primitives.dll + System.Reflection.TypeExtensions.dll + System.Resources.Reader.dll + System.Resources.ResourceManager.dll + System.Resources.Writer.dll + System.Runtime.CompilerServices.Unsafe.dll + System.Runtime.CompilerServices.VisualC.dll + System.Runtime.dll + System.Runtime.Extensions.dll + System.Runtime.Handles.dll + System.Runtime.InteropServices.dll + System.Runtime.InteropServices.JavaScript.dll + System.Runtime.InteropServices.RuntimeInformation.dll + System.Runtime.Intrinsics.dll + System.Runtime.Loader.dll + System.Runtime.Numerics.dll + System.Runtime.Serialization.dll + System.Runtime.Serialization.Formatters.dll + System.Runtime.Serialization.Json.dll + System.Runtime.Serialization.Primitives.dll + System.Runtime.Serialization.Xml.dll + System.Security.AccessControl.dll + System.Security.Claims.dll + System.Security.Cryptography.Algorithms.dll + System.Security.Cryptography.Cng.dll + System.Security.Cryptography.Csp.dll + System.Security.Cryptography.dll + System.Security.Cryptography.Encoding.dll + System.Security.Cryptography.OpenSsl.dll + System.Security.Cryptography.Primitives.dll + System.Security.Cryptography.X509Certificates.dll + System.Security.dll + System.Security.Principal.dll + System.Security.Principal.Windows.dll + System.Security.SecureString.dll + System.ServiceModel.Web.dll + System.ServiceProcess.dll + System.Text.Encoding.CodePages.dll + System.Text.Encoding.dll + System.Text.Encoding.Extensions.dll + System.Text.Encodings.Web.dll + System.Text.Json.dll + System.Text.RegularExpressions.dll + System.Threading.AccessControl.dll + System.Threading.Channels.dll + System.Threading.dll + System.Threading.Overlapped.dll + System.Threading.Tasks.Dataflow.dll + System.Threading.Tasks.dll + System.Threading.Tasks.Extensions.dll + System.Threading.Tasks.Parallel.dll + System.Threading.Thread.dll + System.Threading.ThreadPool.dll + System.Threading.Timer.dll + System.Transactions.dll + System.Transactions.Local.dll + System.ValueTuple.dll + System.Web.dll + System.Web.HttpUtility.dll + System.Windows.dll + System.Xml.dll + System.Xml.Linq.dll + System.Xml.ReaderWriter.dll + System.Xml.Serialization.dll + System.Xml.XDocument.dll + System.Xml.XmlDocument.dll + System.Xml.XmlSerializer.dll + System.Xml.XPath.dll + System.Xml.XPath.XDocument.dll + WindowsBase.dll +D:/NuGet/Packages/ + adaptivecards/3.1.0/lib/net6.0/AdaptiveCards.dll + anglesharp/1.4.0/lib/net10.0/AngleSharp.dll + communitytoolkit.mvvm/8.4.1/lib/net8.0/CommunityToolkit.Mvvm.dll + documentformat.openxml.framework/3.5.1/lib/net10.0/DocumentFormat.OpenXml.Framework.dll + documentformat.openxml/3.5.1/lib/net10.0/DocumentFormat.OpenXml.dll + excss/4.3.1/lib/net8.0/ExCSS.dll + google.apis.core/1.68.0/lib/net6.0/Google.Apis.Core.dll + google.apis.customsearchapi.v1/1.68.0.3520/lib/net6.0/Google.Apis.CustomSearchAPI.v1.dll + google.apis/1.68.0/lib/net6.0/Google.Apis.dll + google.protobuf/3.30.2/lib/net5.0/Google.Protobuf.dll + markdig/1.1.1/lib/net10.0/Markdig.dll + microsoft.agents.ai.abstractions/1.6.1/lib/net10.0/Microsoft.Agents.AI.Abstractions.dll + microsoft.agents.ai.workflows/1.6.1/lib/net10.0/Microsoft.Agents.AI.Workflows.dll + microsoft.agents.ai/1.6.1/lib/net10.0/Microsoft.Agents.AI.dll + microsoft.bcl.asyncinterfaces/6.0.0/lib/netstandard2.1/Microsoft.Bcl.AsyncInterfaces.dll + microsoft.bcl.hashcode/1.1.1/ref/netcoreapp2.1/Microsoft.Bcl.HashCode.dll + microsoft.data.sqlite.core/10.0.5/lib/net8.0/Microsoft.Data.Sqlite.dll + microsoft.extensions.ai.abstractions/10.5.1/lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + microsoft.extensions.ai.evaluation/10.4.0/lib/net10.0/Microsoft.Extensions.AI.Evaluation.dll + microsoft.extensions.ai.openai/10.5.1/lib/net10.0/Microsoft.Extensions.AI.OpenAI.dll + microsoft.extensions.ai/10.5.1/lib/net10.0/Microsoft.Extensions.AI.dll + microsoft.extensions.ambientmetadata.application/10.4.0/lib/net10.0/Microsoft.Extensions.AmbientMetadata.Application.dll + microsoft.extensions.caching.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.Caching.Abstractions.dll + microsoft.extensions.caching.memory/10.0.5/lib/net10.0/Microsoft.Extensions.Caching.Memory.dll + microsoft.extensions.compliance.abstractions/10.5.0/lib/net10.0/Microsoft.Extensions.Compliance.Abstractions.dll + microsoft.extensions.configuration.abstractions/10.0.5/lib/net10.0/Microsoft.Extensions.Configuration.Abstractions.dll + microsoft.extensions.configuration.binder/10.0.5/lib/net10.0/Microsoft.Extensions.Configuration.Binder.dll + microsoft.extensions.configuration/10.0.5/lib/net10.0/Microsoft.Extensions.Configuration.dll + microsoft.extensions.dependencyinjection.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll + microsoft.extensions.dependencyinjection.autoactivation/10.4.0/lib/net10.0/Microsoft.Extensions.DependencyInjection.AutoActivation.dll + microsoft.extensions.dependencyinjection/10.0.5/lib/net10.0/Microsoft.Extensions.DependencyInjection.dll + microsoft.extensions.diagnostics.abstractions/10.0.5/lib/net10.0/Microsoft.Extensions.Diagnostics.Abstractions.dll + microsoft.extensions.diagnostics.exceptionsummarization/10.4.0/lib/net10.0/Microsoft.Extensions.Diagnostics.ExceptionSummarization.dll + microsoft.extensions.diagnostics/10.0.5/lib/net10.0/Microsoft.Extensions.Diagnostics.dll + microsoft.extensions.fileproviders.abstractions/10.0.4/lib/net10.0/Microsoft.Extensions.FileProviders.Abstractions.dll + microsoft.extensions.filesystemglobbing/10.0.6/lib/net10.0/Microsoft.Extensions.FileSystemGlobbing.dll + microsoft.extensions.hosting.abstractions/10.0.4/lib/net10.0/Microsoft.Extensions.Hosting.Abstractions.dll + microsoft.extensions.http.diagnostics/10.4.0/lib/net10.0/Microsoft.Extensions.Http.Diagnostics.dll + microsoft.extensions.http.resilience/10.4.0/lib/net10.0/Microsoft.Extensions.Http.Resilience.dll + microsoft.extensions.http/10.0.5/lib/net10.0/Microsoft.Extensions.Http.dll + microsoft.extensions.logging.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.Logging.Abstractions.dll + microsoft.extensions.logging.configuration/10.0.4/lib/net10.0/Microsoft.Extensions.Logging.Configuration.dll + microsoft.extensions.logging/10.0.5/lib/net10.0/Microsoft.Extensions.Logging.dll + microsoft.extensions.objectpool/10.0.6/lib/net10.0/Microsoft.Extensions.ObjectPool.dll + microsoft.extensions.options.configurationextensions/10.0.5/lib/net10.0/Microsoft.Extensions.Options.ConfigurationExtensions.dll + microsoft.extensions.options/10.0.5/lib/net10.0/Microsoft.Extensions.Options.dll + microsoft.extensions.primitives/10.0.6/lib/net10.0/Microsoft.Extensions.Primitives.dll + microsoft.extensions.resilience/10.4.0/lib/net10.0/Microsoft.Extensions.Resilience.dll + microsoft.extensions.telemetry.abstractions/10.4.0/lib/net10.0/Microsoft.Extensions.Telemetry.Abstractions.dll + microsoft.extensions.telemetry/10.4.0/lib/net10.0/Microsoft.Extensions.Telemetry.dll + microsoft.extensions.vectordata.abstractions/10.0.1/lib/net10.0/Microsoft.Extensions.VectorData.Abstractions.dll + microsoft.ml.onnxruntime.managed/1.24.3/lib/net8.0/Microsoft.ML.OnnxRuntime.dll + microsoft.ml.tokenizers/2.0.0/lib/net8.0/Microsoft.ML.Tokenizers.dll + microsoft.playwright/1.58.0/lib/netstandard2.0/Microsoft.Playwright.dll + microsoft.semantickernel.abstractions/1.73.0/lib/net10.0/Microsoft.SemanticKernel.Abstractions.dll + microsoft.semantickernel.connectors.sqlitevec/1.73.0-preview/lib/net10.0/Microsoft.SemanticKernel.Connectors.SqliteVec.dll + microsoft.semantickernel.plugins.web/1.73.0-alpha/lib/net10.0/Microsoft.SemanticKernel.Plugins.Web.dll + modelcontextprotocol.core/1.1.0/lib/net10.0/ModelContextProtocol.Core.dll + modelcontextprotocol/1.1.0/lib/net10.0/ModelContextProtocol.dll + newtonsoft.json/13.0.3/lib/net6.0/Newtonsoft.Json.dll + openai/2.10.0/lib/net10.0/OpenAI.dll + opentelemetry.api/1.15.3/lib/net10.0/OpenTelemetry.Api.dll + pdfpig/0.1.13/lib/net8.0/ + UglyToad.PdfPig.Core.dll + UglyToad.PdfPig.dll + UglyToad.PdfPig.DocumentLayoutAnalysis.dll + UglyToad.PdfPig.Fonts.dll + UglyToad.PdfPig.Package.dll + UglyToad.PdfPig.Tokenization.dll + UglyToad.PdfPig.Tokens.dll + polly.core/8.4.2/lib/net8.0/Polly.Core.dll + polly.extensions/8.4.2/lib/net8.0/Polly.Extensions.dll + polly.ratelimiting/8.4.2/lib/net8.0/Polly.RateLimiting.dll + serilog.extensions.hosting/10.0.0/lib/net10.0/Serilog.Extensions.Hosting.dll + serilog.extensions.logging/10.0.0/lib/net10.0/Serilog.Extensions.Logging.dll + serilog.sinks.console/6.1.1/lib/net8.0/Serilog.Sinks.Console.dll + serilog.sinks.file/7.0.0/lib/net9.0/Serilog.Sinks.File.dll + serilog/4.3.0/lib/net9.0/Serilog.dll + shimskiasharp/3.6.0/lib/net10.0/ShimSkiaSharp.dll + skender.stock.indicators/2.7.1/lib/net10.0/Skender.Stock.Indicators.dll + skiasharp/3.119.2/ref/net8.0/SkiaSharp.dll + smartreader/0.11.0/lib/netstandard2.1/SmartReader.dll + sqlitepclraw.bundle_e_sqlite3/2.1.11/lib/netstandard2.0/SQLitePCLRaw.batteries_v2.dll + sqlitepclraw.core/2.1.11/lib/netstandard2.0/SQLitePCLRaw.core.dll + sqlitepclraw.provider.e_sqlite3/2.1.11/lib/net6.0/SQLitePCLRaw.provider.e_sqlite3.dll + svg.custom/3.6.0/lib/net10.0/Svg.Custom.dll + svg.model/3.6.0/lib/net10.0/Svg.Model.dll + svg.skia/3.6.0/lib/net10.0/Svg.Skia.dll + system.clientmodel/1.10.0/lib/net10.0/System.ClientModel.dll + system.io.packaging/10.0.2/lib/net10.0/System.IO.Packaging.dll + system.memory.data/10.0.3/lib/net10.0/System.Memory.Data.dll + system.numerics.tensors/10.0.6/lib/net10.0/System.Numerics.Tensors.dll + system.threading.ratelimiting/8.0.0/lib/net8.0/System.Threading.RateLimiting.dll + yamldotnet/16.3.0/lib/net8.0/YamlDotNet.dll + +[analyzerReferences] +/packs/Microsoft.NETCore.App.Ref/10.0.5/analyzers/dotnet/cs/ + Microsoft.Interop.ComInterfaceGenerator.dll + Microsoft.Interop.JavaScript.JSImportGenerator.dll + Microsoft.Interop.LibraryImportGenerator.dll + Microsoft.Interop.SourceGeneration.dll + System.Text.Json.SourceGeneration.dll + System.Text.RegularExpressions.Generator.dll +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/ + Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll + Microsoft.CodeAnalysis.NetAnalyzers.dll +D:/NuGet/Packages/communitytoolkit.mvvm/8.4.1/analyzers/dotnet/roslyn5.0/cs/ + CommunityToolkit.Mvvm.CodeFixers.dll + CommunityToolkit.Mvvm.SourceGenerators.dll +D:/NuGet/Packages/ + microsoft.agents.ai.workflows.generators/1.6.1/analyzers/dotnet/cs/Microsoft.Agents.AI.Workflows.Generators.dll + microsoft.extensions.options/10.0.5/analyzers/dotnet/roslyn4.4/cs/Microsoft.Extensions.Options.SourceGeneration.dll + microsoft.extensions.telemetry.abstractions/10.4.0/analyzers/dotnet/cs/ + Microsoft.Gen.Logging.dll + Microsoft.Gen.Metrics.dll + modelcontextprotocol.core/1.1.0/analyzers/dotnet/cs/ModelContextProtocol.Analyzers.dll + system.clientmodel/1.10.0/analyzers/dotnet/cs/System.ClientModel.SourceGeneration.dll + +[analyzerConfigFiles] +../../.editorconfig +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_10_default.globalconfig +obj/Debug/net10.0/MarketAssistant.App.Services.GeneratedMSBuildEditorConfig.editorconfig diff --git a/src/MarketAssistant.App.Services/Services/Archive/ReportArchiveService.cs b/src/MarketAssistant.App.Services/Services/Archive/ReportArchiveService.cs new file mode 100644 index 0000000..48e57ec --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/Archive/ReportArchiveService.cs @@ -0,0 +1,241 @@ +using System.Globalization; +using System.Text.Json; +using MarketAssistant.Agents.MarketAnalysis.Models; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Services.Archive; + +/// +/// 分析报告存档服务,使用 SQLite 持久化历史报告。 +/// +public class ReportArchiveService : SqliteServiceBase +{ + public ReportArchiveService(ILogger logger) + : base(logger) + { + } + + /// + /// 保存分析报告。失败时抛出异常,调用方可据此决定是否回滚缓存等后续操作。 + /// + public async Task SaveAsync(MarketAnalysisReport report, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(report); + + try + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO reports (asset_code, score, created_at, report_json) + VALUES (@code, @score, @time, @json) + """; + cmd.Parameters.AddWithValue("@code", report.AssetSymbol); + cmd.Parameters.AddWithValue("@score", report.CoordinatorResult.OverallScore); + cmd.Parameters.AddWithValue("@time", report.CreatedAt.ToString("O")); + cmd.Parameters.AddWithValue("@json", JsonSerializer.Serialize(report)); + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + catch (Exception ex) + { + // 抛出异常而非吞并,让调用方感知归档失败(避免幽灵报告) + throw new InvalidOperationException($"保存分析报告失败: {report.AssetSymbol}", ex); + } + } + + /// + /// 获取某资产的历史报告摘要(按时间倒序) + /// + public async Task> GetSummariesAsync( + string assetCode, + int limit = 20, + CancellationToken cancellationToken = default) + { + var results = new List(); + try + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT id, asset_code, score, created_at, report_json + FROM reports WHERE asset_code = @code + ORDER BY created_at DESC LIMIT @limit + """; + cmd.Parameters.AddWithValue("@code", assetCode); + cmd.Parameters.AddWithValue("@limit", limit); + + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + results.Add(new ReportSummary + { + Id = reader.GetInt64(0), + AssetCode = reader.GetString(1), + Score = reader.GetDouble(2), + CreatedAt = DateTime.Parse(reader.GetString(3), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) + }); + + if (!reader.IsDBNull(4)) + { + TryFillDimensionScores(results[^1], reader.GetString(4)); + } + } + + ApplyTimelineDeltas(results); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "查询报告摘要失败: {Asset}", assetCode); + } + return results; + } + + /// + /// 根据 ID 加载完整报告 + /// + public async Task LoadAsync(long id, CancellationToken cancellationToken = default) + { + try + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT report_json FROM reports WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", id); + + var json = await cmd.ExecuteScalarAsync(cancellationToken) as string; + return string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize(json); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "加载报告失败: id={Id}", id); + return null; + } + } + + /// + /// 删除指定报告 + /// + public async Task DeleteAsync(long id, CancellationToken cancellationToken = default) + { + try + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM reports WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", id); + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "删除报告失败: id={Id}", id); + } + } + + protected override async Task InitializeDatabaseAsync() + { + try + { + await using var conn = await OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + asset_code TEXT NOT NULL, + score REAL NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + report_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_reports_asset ON reports(asset_code); + CREATE INDEX IF NOT EXISTS idx_reports_time ON reports(created_at); + """; + await cmd.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + // 记录异常并重新抛出:让 _initializeTask 处于 Faulted 状态, + // 后续 await _initializeTask 会抛出异常,调用方能感知数据库不可用 + Logger.LogError(ex, "初始化报告数据库失败"); + throw; + } + } + + private static void TryFillDimensionScores(ReportSummary summary, string reportJson) + { + try + { + var report = JsonSerializer.Deserialize(reportJson); + var scores = report?.CoordinatorResult.DimensionScores; + if (scores == null) return; + + summary.FundamentalScore = scores.Fundamental; + summary.TechnicalScore = scores.Technical; + summary.FinancialScore = scores.Financial; + summary.SentimentScore = scores.Sentiment; + summary.NewsScore = scores.News; + } + catch (JsonException) + { + // 反序列化失败时维度分数保持默认值 0,不影响摘要展示 + } + } + + private static void ApplyTimelineDeltas(List summaries) + { + for (var i = 0; i < summaries.Count; i++) + { + if (i == summaries.Count - 1) + { + summaries[i].ScoreDelta = 0; + summaries[i].FundamentalDelta = 0; + summaries[i].TechnicalDelta = 0; + summaries[i].FinancialDelta = 0; + continue; + } + + var current = summaries[i]; + var previous = summaries[i + 1]; + + current.ScoreDelta = current.Score - previous.Score; + current.FundamentalDelta = current.FundamentalScore - previous.FundamentalScore; + current.TechnicalDelta = current.TechnicalScore - previous.TechnicalScore; + current.FinancialDelta = current.FinancialScore - previous.FinancialScore; + } + } + +} + +/// +/// 报告摘要(列表展示用,不含完整 JSON) +/// +public class ReportSummary +{ + public long Id { get; set; } + public string AssetCode { get; set; } = string.Empty; + public double Score { get; set; } + public DateTime CreatedAt { get; set; } + public double FundamentalScore { get; set; } + public double TechnicalScore { get; set; } + public double FinancialScore { get; set; } + public double SentimentScore { get; set; } + public double NewsScore { get; set; } + + public double ScoreDelta { get; set; } + public double FundamentalDelta { get; set; } + public double TechnicalDelta { get; set; } + public double FinancialDelta { get; set; } + + public string ScoreTrendText => $"总分 {Score:F1} ({FormatDelta(ScoreDelta)})"; + public string KeyScoreChangeText => + $"基{FundamentalScore:F1}({FormatDelta(FundamentalDelta)}) 技{TechnicalScore:F1}({FormatDelta(TechnicalDelta)}) 财{FinancialScore:F1}({FormatDelta(FinancialDelta)})"; + + private static string FormatDelta(double value) + { + return value > 0 ? $"+{value:F1}" : $"{value:F1}"; + } +} diff --git a/src/MarketAssistant.App.Services/Services/Cache/AnalysisCacheService.cs b/src/MarketAssistant.App.Services/Services/Cache/AnalysisCacheService.cs new file mode 100644 index 0000000..3924841 --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/Cache/AnalysisCacheService.cs @@ -0,0 +1,147 @@ +using MarketAssistant.Agents.MarketAnalysis.Models; +using MarketAssistant.Applications.Cache; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Market; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Services.Cache; + +/// +/// 分析结果缓存服务(彻底重构版) +/// 缓存完整的 MarketAnalysisReport,更符合业务逻辑 +/// +public class AnalysisCacheService : IAnalysisCacheService +{ + private readonly ILogger _logger; + private readonly IMemoryCache _memoryCache; + private readonly MarketContext _marketContext; + private readonly IUserSettingService _userSettingService; + private readonly TimeSpan _cacheExpiration = TimeSpan.FromHours(2); + + public AnalysisCacheService( + ILogger logger, + IMemoryCache memoryCache, + MarketContext marketContext, + IUserSettingService userSettingService) + { + _logger = logger; + _memoryCache = memoryCache; + _marketContext = marketContext; + _userSettingService = userSettingService; + } + + /// + /// 获取缓存的市场分析报告。 + /// 配置指纹(ModelId + 启用的分析师角色)变更后缓存自动失效。 + /// + public Task GetCachedAnalysisAsync(string assetSymbol) + { + if (string.IsNullOrWhiteSpace(assetSymbol)) + { + throw new ArgumentNullException(nameof(assetSymbol)); + } + + var cacheKey = GenerateCacheKey(assetSymbol); + var configFingerprint = GetConfigFingerprint(); + + if (_memoryCache.TryGetValue(cacheKey, out CachedEntry? entry)) + { + // 配置指纹不一致 → 视为缓存失效(用户修改了模型或启用的分析师) + if (entry != null && entry.ConfigFingerprint == configFingerprint) + { + _logger.LogInformation("从缓存获取分析报告: {AssetSymbol}, 分析师数量: {Count}", + assetSymbol, entry.Report?.AnalystMessages.Count ?? 0); + return Task.FromResult(entry.Report); + } + + // 配置已变更,清除过期缓存 + _memoryCache.Remove(cacheKey); + _logger.LogInformation("配置已变更,清除过期缓存: {AssetSymbol}", assetSymbol); + } + + _logger.LogInformation("缓存未命中: {AssetSymbol}", assetSymbol); + return Task.FromResult(null); + } + + /// + /// 缓存市场分析报告,同时记录当前配置指纹用于后续失效判断 + /// + public Task CacheAnalysisAsync(string assetSymbol, MarketAnalysisReport report) + { + if (string.IsNullOrWhiteSpace(assetSymbol)) + { + throw new ArgumentNullException(nameof(assetSymbol)); + } + + ArgumentNullException.ThrowIfNull(report); + + var cacheKey = GenerateCacheKey(assetSymbol); + var entry = new CachedEntry(report, GetConfigFingerprint()); + + _memoryCache.Set(cacheKey, entry, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = _cacheExpiration, + SlidingExpiration = TimeSpan.FromMinutes(30), + Priority = CacheItemPriority.Normal + }); + + _logger.LogInformation( + "已缓存分析报告: {AssetSymbol}, 分析师数量: {Count}, 过期时间: {Expiration}", + assetSymbol, + report.AnalystMessages.Count, + _cacheExpiration); + + return Task.CompletedTask; + } + + /// + /// 清除指定标的的缓存 + /// + public Task ClearCacheAsync(string assetSymbol) + { + if (string.IsNullOrWhiteSpace(assetSymbol)) + { + throw new ArgumentNullException(nameof(assetSymbol)); + } + + var cacheKey = GenerateCacheKey(assetSymbol); + _memoryCache.Remove(cacheKey); + _logger.LogInformation("已清除缓存: {AssetSymbol}", assetSymbol); + return Task.CompletedTask; + } + + /// + /// 生成缓存键(含市场类型,避免跨市场碰撞)。 + /// 键格式由 统一管理。 + /// + private string GenerateCacheKey(string assetSymbol) + { + return CacheKeys.GetAnalysisReportKey(_marketContext.CurrentMarket, assetSymbol); + } + + /// + /// 生成配置指纹:ModelId + 启用的分析师角色排序后的 JSON。 + /// 任一配置变更后指纹不同,缓存自动失效。 + /// + private string GetConfigFingerprint() + { + var setting = _userSettingService.CurrentSetting; + var enabledRoles = setting.EnabledAnalystRoles + .Where(kv => kv.Value) + .Select(kv => kv.Key) + .OrderBy(s => s); + return $"{setting.ModelId}|{string.Join(",", enabledRoles)}"; + } + + /// + /// 缓存条目:报告 + 生成时的配置指纹 + /// + private sealed record CachedEntry(MarketAnalysisReport Report, string ConfigFingerprint); + + public void Dispose() + { + // IMemoryCache 由 DI 容器管理,无需手动释放 + } +} diff --git a/src/Services/Cache/IAnalysisCacheService.cs b/src/MarketAssistant.App.Services/Services/Cache/IAnalysisCacheService.cs similarity index 60% rename from src/Services/Cache/IAnalysisCacheService.cs rename to src/MarketAssistant.App.Services/Services/Cache/IAnalysisCacheService.cs index 9fae47f..f4d3982 100644 --- a/src/Services/Cache/IAnalysisCacheService.cs +++ b/src/MarketAssistant.App.Services/Services/Cache/IAnalysisCacheService.cs @@ -11,20 +11,20 @@ public interface IAnalysisCacheService : IDisposable /// /// 获取缓存的市场分析报告 /// - /// 股票代码 + /// 分析标的代码 /// 缓存的分析报告,如果没有缓存则返回 null - Task GetCachedAnalysisAsync(string stockSymbol); + Task GetCachedAnalysisAsync(string assetSymbol); /// /// 缓存市场分析报告 /// - /// 股票代码 + /// 分析标的代码 /// 分析报告 - Task CacheAnalysisAsync(string stockSymbol, MarketAnalysisReport report); + Task CacheAnalysisAsync(string assetSymbol, MarketAnalysisReport report); /// - /// 清除指定股票的缓存 + /// 清除指定标的的缓存 /// - /// 股票代码 - Task ClearCacheAsync(string stockSymbol); + /// 分析标的代码 + Task ClearCacheAsync(string assetSymbol); } diff --git a/src/MarketAssistant.App.Services/Services/ChatSessionPersistenceService.cs b/src/MarketAssistant.App.Services/Services/ChatSessionPersistenceService.cs new file mode 100644 index 0000000..fb7a5c1 --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/ChatSessionPersistenceService.cs @@ -0,0 +1,297 @@ +using System.Text.Json; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Services; + +/// +/// 聊天会话持久化服务,支持将对话历史保存到 SQLite 并在应用重启后恢复。 +/// +public class ChatSessionPersistenceService : SqliteServiceBase +{ + public ChatSessionPersistenceService(ILogger logger) + : base(logger) + { + } + + /// + /// 保存聊天会话 + /// + public async Task SaveSessionAsync(ChatSessionSnapshot snapshot, 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 chat_sessions (id, stock_code, title, messages_json, analysis_context, created_at, updated_at) + VALUES (@id, @stockCode, @title, @messagesJson, @analysisContext, @createdAt, @updatedAt) + """; + cmd.Parameters.AddWithValue("@id", snapshot.Id); + cmd.Parameters.AddWithValue("@stockCode", snapshot.StockCode); + cmd.Parameters.AddWithValue("@title", snapshot.Title); + cmd.Parameters.AddWithValue("@messagesJson", JsonSerializer.Serialize(snapshot.Messages)); + cmd.Parameters.AddWithValue("@analysisContext", (object?)snapshot.AnalysisContext ?? DBNull.Value); + cmd.Parameters.AddWithValue("@createdAt", snapshot.CreatedAt.ToString("O")); + cmd.Parameters.AddWithValue("@updatedAt", DateTime.UtcNow.ToString("O")); + await cmd.ExecuteNonQueryAsync(ct); + + await UpdateFtsIndexAsync(conn, snapshot); + } + + /// + /// 加载指定会话 + /// + public async Task LoadSessionAsync(string sessionId, CancellationToken ct = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT * FROM chat_sessions WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", sessionId); + + await using var reader = await cmd.ExecuteReaderAsync(ct); + if (!await reader.ReadAsync(ct)) + return null; + + return ReadSnapshot(reader); + } + + /// + /// 获取所有会话摘要(不含消息体) + /// + public async Task> GetSessionSummariesAsync(CancellationToken ct = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT id, stock_code, title, created_at, updated_at + FROM chat_sessions + ORDER BY updated_at DESC + LIMIT 50 + """; + + var summaries = new List(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + summaries.Add(new ChatSessionSummary + { + Id = reader.GetString(0), + StockCode = reader.GetString(1), + Title = reader.GetString(2), + CreatedAt = DateTime.Parse(reader.GetString(3)), + UpdatedAt = DateTime.Parse(reader.GetString(4)) + }); + } + return summaries; + } + + /// + /// 删除指定会话 + /// + public async Task DeleteSessionAsync(string sessionId, CancellationToken ct = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(ct); + + await using var ftsCmd = conn.CreateCommand(); + ftsCmd.CommandText = "DELETE FROM chat_messages_fts WHERE session_id = @id"; + ftsCmd.Parameters.AddWithValue("@id", sessionId); + await ftsCmd.ExecuteNonQueryAsync(ct); + + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM chat_sessions WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", sessionId); + await cmd.ExecuteNonQueryAsync(ct); + } + + private static ChatSessionSnapshot ReadSnapshot(SqliteDataReader reader) + { + var messagesJson = reader.GetString(reader.GetOrdinal("messages_json")); + var messages = JsonSerializer.Deserialize>(messagesJson) ?? []; + + var contextOrd = reader.GetOrdinal("analysis_context"); + + return new ChatSessionSnapshot + { + Id = reader.GetString(reader.GetOrdinal("id")), + StockCode = reader.GetString(reader.GetOrdinal("stock_code")), + Title = reader.GetString(reader.GetOrdinal("title")), + Messages = messages, + AnalysisContext = reader.IsDBNull(contextOrd) ? null : reader.GetString(contextOrd), + CreatedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("created_at"))), + UpdatedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("updated_at"))) + }; + } + + /// + /// 全文搜索历史对话消息 + /// + public async Task> SearchSessionsAsync( + string query, int limit = 10, CancellationToken ct = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + if (string.IsNullOrWhiteSpace(query)) return []; + + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT f.session_id, f.role, f.content, f.author_name, f.message_index, + s.stock_code, s.title, s.updated_at + FROM chat_messages_fts f + JOIN chat_sessions s ON s.id = f.session_id + WHERE chat_messages_fts MATCH @query + ORDER BY rank + LIMIT @limit + """; + cmd.Parameters.AddWithValue("@query", query); + cmd.Parameters.AddWithValue("@limit", limit); + + var results = new List(); + try + { + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + results.Add(new SessionSearchResult + { + SessionId = reader.GetString(0), + Role = reader.GetString(1), + Content = reader.GetString(2), + AuthorName = reader.IsDBNull(3) ? null : reader.GetString(3), + StockCode = reader.GetString(5), + SessionTitle = reader.GetString(6), + UpdatedAt = DateTime.Parse(reader.GetString(7)) + }); + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "FTS5 搜索失败,查询: {Query}", query); + } + return results; + } + + /// + /// 保存会话时同步更新 FTS5 索引 + /// + private async Task UpdateFtsIndexAsync(SqliteConnection conn, ChatSessionSnapshot snapshot) + { + // 先删除旧索引 + await using (var delCmd = conn.CreateCommand()) + { + delCmd.CommandText = "DELETE FROM chat_messages_fts WHERE session_id = @id"; + delCmd.Parameters.AddWithValue("@id", snapshot.Id); + await delCmd.ExecuteNonQueryAsync(); + } + + // 逐条插入消息到 FTS 索引 + for (int i = 0; i < snapshot.Messages.Count; i++) + { + var msg = snapshot.Messages[i]; + if (string.IsNullOrWhiteSpace(msg.Content)) continue; + + await using var insCmd = conn.CreateCommand(); + insCmd.CommandText = """ + INSERT INTO chat_messages_fts (session_id, role, content, author_name, message_index) + VALUES (@sid, @role, @content, @author, @idx) + """; + insCmd.Parameters.AddWithValue("@sid", snapshot.Id); + insCmd.Parameters.AddWithValue("@role", msg.Role); + insCmd.Parameters.AddWithValue("@content", msg.Content); + insCmd.Parameters.AddWithValue("@author", (object?)msg.AuthorName ?? DBNull.Value); + insCmd.Parameters.AddWithValue("@idx", i); + await insCmd.ExecuteNonQueryAsync(); + } + } + + protected override async Task InitializeDatabaseAsync() + { + try + { + await using var conn = await OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS chat_sessions ( + id TEXT PRIMARY KEY, + stock_code TEXT NOT NULL, + title TEXT NOT NULL, + messages_json TEXT NOT NULL, + analysis_context TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_chat_sessions_stock ON chat_sessions(stock_code); + CREATE INDEX IF NOT EXISTS idx_chat_sessions_updated ON chat_sessions(updated_at); + + CREATE VIRTUAL TABLE IF NOT EXISTS chat_messages_fts USING fts5( + session_id UNINDEXED, + role UNINDEXED, + content, + author_name UNINDEXED, + message_index UNINDEXED, + tokenize='unicode61' + ); + """; + await cmd.ExecuteNonQueryAsync(); + Logger.LogInformation("聊天会话数据库初始化完成(含 FTS5 索引)"); + } + catch (Exception ex) + { + Logger.LogError(ex, "初始化聊天会话数据库失败"); + throw; + } + } +} + +/// +/// 聊天会话快照(用于持久化) +/// +public class ChatSessionSnapshot +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string StockCode { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public List Messages { get; set; } = []; + public string? AnalysisContext { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +/// +/// 聊天消息 DTO(用于序列化) +/// +public class ChatMessageDto +{ + public string Role { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public string? AuthorName { get; set; } +} + +/// +/// 聊天会话摘要(列表展示用) +/// +public class ChatSessionSummary +{ + public string Id { get; set; } = string.Empty; + public string StockCode { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} + +/// +/// 全文搜索结果条目 +/// +public class SessionSearchResult +{ + public string SessionId { get; set; } = string.Empty; + public string Role { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public string? AuthorName { get; set; } + public string StockCode { get; set; } = string.Empty; + public string SessionTitle { get; set; } = string.Empty; + public DateTime UpdatedAt { get; set; } +} diff --git a/src/Services/Dialog/IDialogService.cs b/src/MarketAssistant.App.Services/Services/Dialog/IDialogService.cs similarity index 100% rename from src/Services/Dialog/IDialogService.cs rename to src/MarketAssistant.App.Services/Services/Dialog/IDialogService.cs diff --git a/src/MarketAssistant.App.Services/Services/Export/MarkdownReportExporter.cs b/src/MarketAssistant.App.Services/Services/Export/MarkdownReportExporter.cs new file mode 100644 index 0000000..0750054 --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/Export/MarkdownReportExporter.cs @@ -0,0 +1,219 @@ +using System.Text; +using MarketAssistant.Agents.MarketAnalysis.Models; +using MarketAssistant.Infrastructure.Extensions; +using Microsoft.Extensions.AI; + +namespace MarketAssistant.Services.Export; + +/// +/// 将市场分析报告导出为 Markdown 格式 +/// +public static class MarkdownReportExporter +{ + /// + /// 将 MarketAnalysisReport 转换为 Markdown 文本 + /// + public static string Export(MarketAnalysisReport report) + { + var cr = report.CoordinatorResult; + var sb = new StringBuilder(); + + sb.AppendLine($"# {EscapeInline(report.AssetSymbol)} 投资分析报告"); + sb.AppendLine(); + sb.AppendLine($"> 生成时间:{report.CreatedAt.ToLocalTime():yyyy-MM-dd HH:mm}"); + sb.AppendLine(); + + sb.AppendLine("## 综合评级"); + sb.AppendLine(); + sb.AppendLine($"| 指标 | 值 |"); + sb.AppendLine($"|------|------|"); + sb.AppendLine($"| 综合评分 | **{cr.OverallScore:F1}** / 10 |"); + sb.AppendLine($"| 投资评级 | {EscapeTableCell(cr.InvestmentRating.GetDescription())} |"); + sb.AppendLine($"| 目标价格 | {EscapeTableCell(cr.TargetPrice)} |"); + sb.AppendLine($"| 价格预期 | {EscapeTableCell(cr.PriceChangeExpectation)} |"); + sb.AppendLine($"| 投资周期 | {EscapeTableCell(cr.TimeHorizon.GetDescription() + " " + cr.TimeHorizonDescription)} |"); + sb.AppendLine($"| 风险等级 | {EscapeTableCell(cr.RiskLevel.GetDescription())} |"); + sb.AppendLine($"| 置信度 | {cr.ConfidencePercentage:F0}% |"); + sb.AppendLine(); + + sb.AppendLine("## 各维度评分"); + sb.AppendLine(); + var ds = cr.DimensionScores; + sb.AppendLine($"| 维度 | 评分 |"); + sb.AppendLine($"|------|------|"); + sb.AppendLine($"| 基本面 | {ds.Fundamental:F1} |"); + sb.AppendLine($"| 技术面 | {ds.Technical:F1} |"); + sb.AppendLine($"| 财务面 | {ds.Financial:F1} |"); + sb.AppendLine($"| 市场情绪 | {ds.Sentiment:F1} |"); + sb.AppendLine($"| 新闻事件 | {ds.News:F1} |"); + sb.AppendLine(); + + AppendListSection(sb, "操作建议", cr.OperationSuggestions); + AppendListSection(sb, "投资亮点", cr.InvestmentHighlights); + AppendListSection(sb, "风险因素", cr.RiskFactors); + + if (!string.IsNullOrWhiteSpace(cr.ConsensusAnalysis)) + { + sb.AppendLine("## 核心共识"); + sb.AppendLine(); + sb.AppendLine(EscapeInline(cr.ConsensusAnalysis)); + sb.AppendLine(); + } + + if (!string.IsNullOrWhiteSpace(cr.DisagreementAnalysis)) + { + sb.AppendLine("## 主要分歧"); + sb.AppendLine(); + sb.AppendLine(EscapeInline(cr.DisagreementAnalysis)); + sb.AppendLine(); + } + + if (cr.KeyIndicators is { Count: > 0 }) + { + sb.AppendLine("## 关键指标"); + sb.AppendLine(); + sb.AppendLine("| 来源 | 类别 | 指标 | 值 | 信号 | 建议 |"); + sb.AppendLine("|------|------|------|------|------|------|"); + foreach (var ki in cr.KeyIndicators) + { + sb.AppendLine($"| {EscapeTableCell(ki.AnalystSource)} | {EscapeTableCell(ki.Category)} | {EscapeTableCell(ki.Name)} | {EscapeTableCell(ki.Value)} | {EscapeTableCell(ki.Signal)} | {EscapeTableCell(ki.Suggestion)} |"); + } + sb.AppendLine(); + } + + AppendQualityMetricsSection(sb, cr.QualityMetrics); + AppendAnalystMessagesSection(sb, report.AnalystMessages); + + if (!string.IsNullOrWhiteSpace(cr.Summary)) + { + sb.AppendLine("## 一句话总结"); + sb.AppendLine(); + sb.AppendLine($"**{EscapeInline(cr.Summary)}**"); + sb.AppendLine(); + } + + sb.AppendLine("---"); + sb.AppendLine("*本报告由 MarketAssistant AI 多 Agent 系统自动生成,仅供参考,不构成投资建议。*"); + + return sb.ToString(); + } + + private static void AppendListSection(StringBuilder sb, string title, List? items) + { + if (items is not { Count: > 0 }) return; + + sb.AppendLine($"## {title}"); + sb.AppendLine(); + foreach (var item in items) + { + sb.AppendLine($"- {EscapeInline(item)}"); + } + sb.AppendLine(); + } + + private static void AppendQualityMetricsSection(StringBuilder sb, AnalysisQualityMetrics qm) + { + if (qm is null) return; + if (qm.DataCompletenessPercent <= 0 && qm.AnalystConsensusPercent <= 0 + && string.IsNullOrWhiteSpace(qm.LimitationsNote) + && qm.MissingDataDimensions is not { Count: > 0 }) + { + return; + } + + sb.AppendLine("## 分析质量自评估"); + sb.AppendLine(); + sb.AppendLine("| 指标 | 值 |"); + sb.AppendLine("|------|------|"); + sb.AppendLine($"| 质量等级 | {EscapeTableCell(GetQualityLevelText(qm.OverallQualityLevel))} |"); + sb.AppendLine($"| 数据完整度 | {qm.DataCompletenessPercent}% |"); + sb.AppendLine($"| 分析师一致性 | {qm.AnalystConsensusPercent}% |"); + sb.AppendLine(); + + if (qm.MissingDataDimensions is { Count: > 0 }) + { + sb.AppendLine("**缺失的数据维度:**"); + sb.AppendLine(); + foreach (var dim in qm.MissingDataDimensions) + { + sb.AppendLine($"- {EscapeInline(dim)}"); + } + sb.AppendLine(); + } + + if (!string.IsNullOrWhiteSpace(qm.LimitationsNote)) + { + sb.AppendLine("**分析局限性:**"); + sb.AppendLine(); + sb.AppendLine(EscapeInline(qm.LimitationsNote)); + sb.AppendLine(); + } + } + + private static void AppendAnalystMessagesSection(StringBuilder sb, List? messages) + { + if (messages is not { Count: > 0 }) return; + + sb.AppendLine("## 各分析师详细分析"); + sb.AppendLine(); + foreach (var message in messages) + { + var author = string.IsNullOrWhiteSpace(message.AuthorName) + ? GetRoleDisplayName(message.Role) + : message.AuthorName; + var text = message.Text ?? string.Empty; + if (string.IsNullOrWhiteSpace(text)) continue; + + sb.AppendLine($"### {EscapeInline(author)}"); + sb.AppendLine(); + sb.AppendLine(EscapeInline(text)); + sb.AppendLine(); + } + } + + private static string GetQualityLevelText(AnalysisQualityLevel level) => level switch + { + AnalysisQualityLevel.High => "高(数据充分、分析师一致性高、置信度强)", + AnalysisQualityLevel.Medium => "中(部分数据缺失或分析师存在一定分歧)", + AnalysisQualityLevel.Low => "低(大量数据缺失或分析师严重分歧,结论仅供参考)", + _ => "未知" + }; + + private static string GetRoleDisplayName(ChatRole role) + { + if (role == ChatRole.Assistant) return "分析师"; + if (role == ChatRole.User) return "用户"; + if (role == ChatRole.System) return "系统"; + if (role == ChatRole.Tool) return "工具"; + return role.Value ?? "未知"; + } + + /// + /// 转义 Markdown 表格单元格中的特殊字符:管道符(会破坏表格结构)、反斜杠(转义符)、换行符。 + /// + private static string EscapeTableCell(string? text) + { + if (string.IsNullOrEmpty(text)) return string.Empty; + return text + .Replace("\\", "\\\\") + .Replace("|", "\\|") + .Replace("\r\n", " ") + .Replace("\r", " ") + .Replace("\n", " "); + } + + /// + /// 转义行内 Markdown 文本中的特殊字符:反斜杠、星号、下划线、反引号、管道符。 + /// 不转义换行符,以保留段落结构。 + /// + private static string EscapeInline(string? text) + { + if (string.IsNullOrEmpty(text)) return string.Empty; + return text + .Replace("\\", "\\\\") + .Replace("*", "\\*") + .Replace("_", "\\_") + .Replace("`", "\\`") + .Replace("|", "\\|"); + } +} diff --git a/src/MarketAssistant.App.Services/Services/Market/AShareMarketModule.cs b/src/MarketAssistant.App.Services/Services/Market/AShareMarketModule.cs new file mode 100644 index 0000000..5270b10 --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/Market/AShareMarketModule.cs @@ -0,0 +1,64 @@ +using MarketAssistant.Agents.InvestmentSelection.Executors; +using MarketAssistant.Agents.InvestmentSelection.Strategies; +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.AShare; +using MarketAssistant.Applications.AssetScreener; +using MarketAssistant.Applications.AssetScreener.Models; +using MarketAssistant.Applications.Assets; +using MarketAssistant.Applications.Cache; +using MarketAssistant.Applications.Charts; +using MarketAssistant.Applications.Favorites; +using MarketAssistant.Applications.History; +using MarketAssistant.Applications.Home; +using MarketAssistant.Applications.News; +using MarketAssistant.Applications.Telegrams; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Services.Market; + +/// +/// A 股市场模块——集中注册 A 股所有 Keyed 服务及工作流组件。 +/// +public sealed class AShareMarketModule : IMarketModule +{ + public MarketType MarketType => MarketType.AShare; + + 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); + + // 快讯 & 新闻 + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton( + MarketType.AShare, + (sp, _) => new NewsUpdateService( + sp.GetRequiredKeyedService(MarketType.AShare), + sp.GetRequiredService>())); + + // 资产服务 + 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); + services.AddSingleton, StockCriteriaGenerationStrategy>(); + services.AddSingleton>(); + } +} diff --git a/src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs b/src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs new file mode 100644 index 0000000..0cb23f9 --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs @@ -0,0 +1,71 @@ +using MarketAssistant.Agents.InvestmentSelection.Executors; +using MarketAssistant.Agents.InvestmentSelection.Strategies; +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.Crypto; +using MarketAssistant.Applications.AssetScreener; +using MarketAssistant.Applications.AssetScreener.Models; +using MarketAssistant.Applications.Assets; +using MarketAssistant.Applications.Cache; +using MarketAssistant.Applications.Charts; +using MarketAssistant.Applications.Favorites; +using MarketAssistant.Applications.History; +using MarketAssistant.Applications.Home; +using MarketAssistant.Applications.News; +using MarketAssistant.Applications.Telegrams; +using MarketAssistant.Trading.Abstractions; +using MarketAssistant.Trading.Exchanges; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Services.Market; + +/// +/// 虚拟币市场模块——集中注册虚拟币所有 Keyed 服务及工作流组件。 +/// +public sealed class CryptoMarketModule : IMarketModule +{ + public MarketType MarketType => MarketType.Crypto; + + 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); + + // 快讯 & 新闻 + services.AddKeyedSingleton(MarketType.Crypto); + services.AddKeyedSingleton( + MarketType.Crypto, + (sp, _) => new NewsUpdateService( + sp.GetRequiredKeyedService(MarketType.Crypto), + sp.GetRequiredService>())); + + // 资产服务 + 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.AddSingleton, CryptoCriteriaGenerationStrategy>(); + services.AddSingleton>(); + + // 交易所客户端 + services.AddKeyedSingleton(MarketType.Crypto); + } +} diff --git a/src/MarketAssistant.App.Services/Services/Market/IMarketCapability.cs b/src/MarketAssistant.App.Services/Services/Market/IMarketCapability.cs new file mode 100644 index 0000000..4664012 --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/Market/IMarketCapability.cs @@ -0,0 +1,39 @@ +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Services.Market; + +/// +/// 市场能力声明接口,各市场实现声明自身支持的功能 +/// +public interface IMarketCapability +{ + MarketType MarketType { get; } + bool SupportsAIAnalysis { get; } + bool SupportsKLine { get; } + bool SupportsScreener { get; } + bool SupportsRealtime { get; } + bool SupportsNews { get; } + bool SupportsTrading { get; } +} + +public class AShareMarketCapability : IMarketCapability +{ + public MarketType MarketType => MarketType.AShare; + public bool SupportsAIAnalysis => true; + public bool SupportsKLine => true; + public bool SupportsScreener => true; + public bool SupportsRealtime => false; + public bool SupportsNews => true; + public bool SupportsTrading => false; +} + +public class CryptoMarketCapability : IMarketCapability +{ + public MarketType MarketType => MarketType.Crypto; + public bool SupportsAIAnalysis => true; + public bool SupportsKLine => true; + public bool SupportsScreener => true; + public bool SupportsRealtime => true; + public bool SupportsNews => true; + public bool SupportsTrading => true; +} diff --git a/src/MarketAssistant.App.Services/Services/Market/IMarketModule.cs b/src/MarketAssistant.App.Services/Services/Market/IMarketModule.cs new file mode 100644 index 0000000..8690f35 --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/Market/IMarketModule.cs @@ -0,0 +1,14 @@ +namespace MarketAssistant.Services.Market; + +/// +/// 市场模块契约——每个市场实现一个模块类,集中管理该市场所有的 Keyed DI 注册。 +/// 新增市场时只需创建新的 实现并加入模块列表,无需修改分散的注册方法。 +/// +public interface IMarketModule +{ + /// 市场类型标识 + MarketType MarketType { get; } + + /// 注册该市场的所有依赖 + void Register(IServiceCollection services); +} diff --git a/src/MarketAssistant.App.Services/Services/Market/MarketContext.cs b/src/MarketAssistant.App.Services/Services/Market/MarketContext.cs new file mode 100644 index 0000000..0ac4b51 --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/Market/MarketContext.cs @@ -0,0 +1,97 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.DependencyInjection; + +namespace MarketAssistant.Services.Market; + +/// +/// 市场切换事件参数 +/// +public class MarketChangedEventArgs : EventArgs +{ + /// 切换前的市场 + public MarketType PreviousMarket { get; } + + /// 切换后的市场 + public MarketType NewMarket { get; } + + public MarketChangedEventArgs(MarketType previousMarket, MarketType newMarket) + { + PreviousMarket = previousMarket; + NewMarket = newMarket; + } +} + +/// +/// 市场上下文服务,管理当前激活的市场类型 +/// +public class MarketContext : INotifyPropertyChanged +{ + private readonly IUserSettingService _userSettingService; + private readonly IServiceProvider _serviceProvider; + private readonly object _marketLock = new(); + private MarketType _currentMarket; + + /// + /// 当前激活的市场类型(静态快照,供 UI Converter 等无法直接依赖注入的场景读取) + /// + public static MarketType CurrentMarketType { get; private set; } + + /// + /// 当前激活的市场类型 + /// + public MarketType CurrentMarket => _currentMarket; + + public MarketContext(IUserSettingService userSettingService, IServiceProvider serviceProvider) + { + _userSettingService = userSettingService; + _serviceProvider = serviceProvider; + _currentMarket = _userSettingService.CurrentSetting.CurrentMarketType; + CurrentMarketType = _currentMarket; + } + + /// + /// 获取当前市场的能力声明 + /// + public IMarketCapability CurrentCapability => + _serviceProvider.GetRequiredKeyedService(CurrentMarket); + + /// + /// 市场切换事件,供后端服务订阅以清理状态或暂停后台任务。 + /// 与 PropertyChanged 不同,此事件携带前后市场信息,且语义明确。 + /// + public event EventHandler? MarketChanged; + + /// + /// 切换市场 + /// + /// 新的市场类型 + public void SwitchMarket(MarketType newMarket) + { + MarketType previousMarket; + lock (_marketLock) + { + if (_currentMarket == newMarket) + return; + previousMarket = _currentMarket; + _currentMarket = newMarket; + CurrentMarketType = newMarket; + } + + _userSettingService.CurrentSetting.CurrentMarketType = newMarket; + _userSettingService.SaveSettings(); + OnPropertyChanged(nameof(CurrentMarket)); + MarketChanged?.Invoke(this, new MarketChangedEventArgs(previousMarket, newMarket)); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} + + diff --git a/src/Services/Mcp/McpService.cs b/src/MarketAssistant.App.Services/Services/Mcp/McpService.cs similarity index 52% rename from src/Services/Mcp/McpService.cs rename to src/MarketAssistant.App.Services/Services/Mcp/McpService.cs index 103b14e..eec5aaa 100644 --- a/src/Services/Mcp/McpService.cs +++ b/src/MarketAssistant.App.Services/Services/Mcp/McpService.cs @@ -11,17 +11,32 @@ namespace MarketAssistant.Services.Mcp; /// public class McpService : IAsyncDisposable { - private readonly ILogger? _logger; + private readonly ILogger _logger; + private readonly McpToolAuditLogger _auditLogger; + private readonly MCPServerConfigService _configService; private readonly List _mcpClients = new(); + private readonly object _clientsLock = new(); private bool _disposed; + /// + /// 已连接的 MCP 服务器数量 + /// + public int ActiveConnectionCount + { + get { lock (_clientsLock) return _mcpClients.Count; } + } + /// /// 创建 MCP 服务 /// - /// 日志记录器(可选) - public McpService(ILogger? logger = null) + public McpService( + ILogger logger, + McpToolAuditLogger auditLogger, + MCPServerConfigService configService) { - _logger = logger; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _auditLogger = auditLogger ?? throw new ArgumentNullException(nameof(auditLogger)); + _configService = configService ?? throw new ArgumentNullException(nameof(configService)); } /// @@ -46,17 +61,46 @@ public async Task> GetAIToolsAsync( var mcpClient = await McpClient.CreateAsync(clientTransport, options); - _mcpClients.Add(mcpClient); + lock (_clientsLock) + { + _mcpClients.Add(mcpClient); + } var mcpTools = await mcpClient.ListToolsAsync().ConfigureAwait(false); - tools.AddRange(mcpTools.Cast()); - _logger?.LogInformation("成功连接到 MCP 服务器 {Name},加载 {Count} 个工具", - config.Name, mcpTools.Count); + // 安全策略:AllowedTools 为空时允许所有工具加载,但发出安全警告, + // 提示用户显式配置白名单以实现最小暴露原则。 + if (config.AllowedTools.Count == 0) + { + _logger.LogWarning( + "MCP 服务器 {Name} 未配置 AllowedTools 白名单,将加载全部 {Count} 个工具。" + + "建议在配置中显式指定允许的工具以遵循最小暴露原则。", + config.Name, mcpTools.Count); + } + + foreach (var tool in mcpTools.Cast()) + { + var toolName = tool.Name; + + if (config.AllowedTools.Count > 0 && + !config.AllowedTools.Contains(toolName, StringComparer.OrdinalIgnoreCase)) + { + _auditLogger.LogToolFiltered(config.Name, toolName, + "不在允许列表中"); + continue; + } + + _auditLogger.LogToolLoaded(config.Name, toolName, config.Category); + tools.Add(tool); + } + + _logger.LogInformation( + "成功连接到 MCP 服务器 {Name} (分类: {Category}),加载 {Count}/{Total} 个工具", + config.Name, config.Category, tools.Count, mcpTools.Count); } catch (Exception ex) { - _logger?.LogWarning(ex, "连接到 MCP 服务器 {Name} 失败", config.Name); + _logger.LogWarning(ex, "连接到 MCP 服务器 {Name} 失败", config.Name); } } @@ -67,10 +111,9 @@ public async Task> GetAIToolsAsync( /// 获取所有启用的 MCP 服务器配置 /// /// 启用的配置列表 - public static List GetEnabledConfigs() + public List GetEnabledConfigs() { - var configService = MCPServerConfigService.Instance; - return configService.ServerConfigs.Where(c => c.IsEnabled).ToList(); + return _configService.ServerConfigs.Where(c => c.IsEnabled).ToList(); } /// @@ -134,6 +177,34 @@ private static IClientTransport CreateStreamableHttpTransport(MCPServerConfig co }); } + /// + /// 断开所有现有 MCP 连接,释放资源。 + /// 用于配置变更后重建连接。 + /// + public async Task ResetConnectionsAsync() + { + List clientsToDispose; + lock (_clientsLock) + { + clientsToDispose = [.. _mcpClients]; + _mcpClients.Clear(); + } + + foreach (var client in clientsToDispose) + { + try + { + await client.DisposeAsync(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "重置 MCP 连接时释放客户端出错"); + } + } + + _logger.LogInformation("已重置 {Count} 个 MCP 连接", clientsToDispose.Count); + } + /// /// 释放资源 /// @@ -142,7 +213,14 @@ public async ValueTask DisposeAsync() if (_disposed) return; - foreach (var mcpClient in _mcpClients) + List clientsToDispose; + lock (_clientsLock) + { + clientsToDispose = [.. _mcpClients]; + _mcpClients.Clear(); + } + + foreach (var mcpClient in clientsToDispose) { try { @@ -150,11 +228,10 @@ public async ValueTask DisposeAsync() } catch (Exception ex) { - _logger?.LogWarning(ex, "释放 MCP 客户端时发生错误"); + _logger.LogWarning(ex, "释放 MCP 客户端时发生错误"); } } - _mcpClients.Clear(); _disposed = true; GC.SuppressFinalize(this); } diff --git a/src/MarketAssistant.App.Services/Services/Mcp/McpToolAuditLogger.cs b/src/MarketAssistant.App.Services/Services/Mcp/McpToolAuditLogger.cs new file mode 100644 index 0000000..b876ce4 --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/Mcp/McpToolAuditLogger.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Services.Mcp; + +/// +/// MCP 工具调用审计日志 +/// 记录所有通过 MCP 加载和调用的工具,用于安全审计和排查 +/// +public class McpToolAuditLogger +{ + private readonly ILogger _logger; + private readonly List _entries = []; + private readonly object _lock = new(); + + public McpToolAuditLogger(ILogger logger) + { + _logger = logger; + } + + /// + /// 记录工具加载事件 + /// + public void LogToolLoaded(string serverName, string toolName, string category) + { + var entry = new McpToolAuditEntry + { + EventType = McpAuditEventType.ToolLoaded, + ServerName = serverName, + ToolName = toolName, + Category = category, + Timestamp = DateTime.UtcNow + }; + + lock (_lock) { _entries.Add(entry); } + _logger.LogInformation("[MCP 审计] 工具加载: {Server}/{Tool} (分类: {Category})", + serverName, toolName, category); + } + + /// + /// 记录工具被过滤(白名单外) + /// + public void LogToolFiltered(string serverName, string toolName, string reason) + { + _logger.LogWarning("[MCP 审计] 工具被过滤: {Server}/{Tool},原因: {Reason}", + serverName, toolName, reason); + } + + /// + /// 获取最近的审计记录 + /// + public IReadOnlyList GetRecentEntries(int count = 100) + { + lock (_lock) + { + return _entries.TakeLast(count).ToList().AsReadOnly(); + } + } +} + +public class McpToolAuditEntry +{ + public McpAuditEventType EventType { get; set; } + public string ServerName { get; set; } = string.Empty; + public string ToolName { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } +} + +public enum McpAuditEventType +{ + ToolLoaded, + ToolFiltered +} diff --git a/src/MarketAssistant.App.Services/Services/Mcp/McpToolContextProvider.cs b/src/MarketAssistant.App.Services/Services/Mcp/McpToolContextProvider.cs new file mode 100644 index 0000000..4787bd3 --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/Mcp/McpToolContextProvider.cs @@ -0,0 +1,102 @@ +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Services.Mcp; + +/// +/// MCP 工具上下文提供者,实现 MAF AIContextProvider 模式。 +/// 在每次 Agent 调用前自动将 MCP 服务器工具注入到 AIContext.Tools, +/// 无需调用方手动管理工具加载和传递。 +/// 支持缓存、按需刷新和连接状态监控。 +/// +public sealed class McpToolContextProvider : AIContextProvider +{ + private readonly McpService _mcpService; + private readonly ILogger _logger; + private readonly SemaphoreSlim _initLock = new(1, 1); + + private List? _cachedTools; + private bool _invalidated; + + public McpToolContextProvider( + McpService mcpService, + ILogger logger) + { + _mcpService = mcpService ?? throw new ArgumentNullException(nameof(mcpService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// 已加载的 MCP 工具数量 + /// + public int LoadedToolCount => _cachedTools?.Count ?? 0; + + /// + /// 标记工具缓存失效,下次 Agent 调用时重新加载。 + /// 配置变更(新增/删除/修改 MCP 服务器)后应调用此方法。 + /// + public void Invalidate() + { + _invalidated = true; + _logger.LogInformation("MCP 工具缓存已标记失效,将在下次调用时刷新"); + } + + protected override async ValueTask ProvideAIContextAsync( + InvokingContext context, + CancellationToken cancellationToken) + { + var tools = await GetOrLoadToolsAsync(cancellationToken); + + return new AIContext + { + Tools = tools + }; + } + + /// + /// 获取或加载 MCP 工具(线程安全,支持失效刷新) + /// + private async Task> GetOrLoadToolsAsync(CancellationToken cancellationToken) + { + if (_cachedTools != null && !_invalidated) + return _cachedTools; + + await _initLock.WaitAsync(cancellationToken); + try + { + // double-check + if (_cachedTools != null && !_invalidated) + return _cachedTools; + + if (_invalidated) + { + // 释放旧连接后重新加载 + await _mcpService.ResetConnectionsAsync(); + _invalidated = false; + } + + var enabledConfigs = _mcpService.GetEnabledConfigs(); + if (enabledConfigs.Count == 0) + { + _cachedTools = []; + return _cachedTools; + } + + _cachedTools = await _mcpService.GetAIToolsAsync(enabledConfigs); + _logger.LogInformation("McpToolContextProvider 加载了 {Count} 个 MCP 工具", _cachedTools.Count); + + return _cachedTools; + } + catch (Exception ex) + { + _logger.LogError(ex, "McpToolContextProvider 加载 MCP 工具失败"); + _cachedTools ??= []; + return _cachedTools; + } + finally + { + _initLock.Release(); + } + } +} diff --git a/src/MarketAssistant.App.Services/Services/MemoryExtractionService.cs b/src/MarketAssistant.App.Services/Services/MemoryExtractionService.cs new file mode 100644 index 0000000..b3dfc2a --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/MemoryExtractionService.cs @@ -0,0 +1,185 @@ +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Infrastructure.Factories; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.Text; +using System.Text.Json; + +namespace MarketAssistant.Services; + +/// +/// 自动记忆提取服务。 +/// 从对话历史中使用 LLM 提取结构化记忆(偏好、结论、纠正等), +/// 自动保存到 UserMemoryService 和 UserKnowledgeGraphService。 +/// 借鉴 Hermes 的主动保存 + MemPalace 的 auto-save hook。 +/// +public class MemoryExtractionService +{ + private readonly IChatClientFactory _chatClientFactory; + private readonly UserMemoryService _memoryService; + private readonly UserKnowledgeGraphService _kgService; + private readonly ILogger _logger; + + /// + /// 触发自动提取的对话轮次间隔 + /// + public int ExtractionInterval { get; set; } = 6; + + public MemoryExtractionService( + IChatClientFactory chatClientFactory, + UserMemoryService memoryService, + UserKnowledgeGraphService kgService, + ILogger logger) + { + _chatClientFactory = chatClientFactory; + _memoryService = memoryService; + _kgService = kgService; + _logger = logger; + } + + /// + /// 从对话历史中提取记忆并持久化。 + /// 在每 N 轮对话后自动调用,或在压缩前紧急调用。 + /// + public async Task ExtractAndSaveAsync( + IReadOnlyList conversationHistory, + bool isEmergency = false, + CancellationToken ct = default) + { + if (conversationHistory.Count < 2) + return; + + try + { + var extracted = await ExtractMemoriesAsync(conversationHistory, ct); + if (extracted is null) + return; + + await PersistExtractedMemoriesAsync(extracted, ct); + + _logger.LogInformation( + "自动记忆提取完成{Emergency}: {MemoryCount} 条记忆, {RelationCount} 条关系", + isEmergency ? "(紧急)" : "", + extracted.Memories?.Count ?? 0, + extracted.Relations?.Count ?? 0); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "自动记忆提取失败"); + } + } + + /// + /// 使用 LLM 从对话中提取结构化记忆 + /// + private async Task ExtractMemoriesAsync( + IReadOnlyList history, CancellationToken ct) + { + var chatClient = _chatClientFactory.CreateClient(); + + var conversationText = new StringBuilder(); + foreach (var msg in history.TakeLast(20)) + { + var role = msg.Role == ChatRole.User ? "用户" : "助手"; + var text = msg.Text; + if (string.IsNullOrWhiteSpace(text)) continue; + if (text.Length > 500) text = text[..500] + "..."; + conversationText.AppendLine($"【{role}】{text}"); + } + + var prompt = $$""" + 分析以下对话,提取值得长期记住的信息。只提取明确表达的内容,不要推测。 + + + {{conversationText}} + + + 请以 JSON 格式返回提取结果,仅包含确实值得记住的内容(不要生造): + ```json + { + "memories": [ + {"category": "preference|correction|conclusion|profile", "key": "简短唯一标识", "value": "简洁的记忆内容"} + ], + "relations": [ + {"subject": "主体", "predicate": "关注|持有|分析过|属于行业|影响", "object": "客体"} + ] + } + ``` + + 规则: + - memories: 只提取用户明确表达的偏好、被纠正的认知、重要结论、个人信息 + - relations: 只提取用户明确提到的实体关系(持有、关注、行业归属等) + - 如果没有值得提取的内容,返回空数组 + - key 和 value 用中文,保持简洁 + """; + + var response = await chatClient.GetResponseAsync( + [new ChatMessage(ChatRole.User, prompt)], + new ChatOptions { Temperature = 0.1f, MaxOutputTokens = 500 }, + ct); + + var text2 = response.Text; + if (string.IsNullOrWhiteSpace(text2)) + return null; + + return ParseExtractedMemories(text2); + } + + private ExtractedMemories? ParseExtractedMemories(string responseText) + { + try + { + return LlmJsonExtractor.Deserialize(responseText); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "解析 LLM 记忆提取响应失败"); + return null; + } + } + + private async Task PersistExtractedMemoriesAsync(ExtractedMemories extracted, CancellationToken ct) + { + if (extracted.Memories != null) + { + foreach (var m in extracted.Memories) + { + if (string.IsNullOrWhiteSpace(m.Category) || string.IsNullOrWhiteSpace(m.Key) || string.IsNullOrWhiteSpace(m.Value)) + continue; + var (success, error) = await _memoryService.SaveMemoryAsync(m.Category, m.Key, m.Value, ct); + if (!success) + _logger.LogWarning("自动提取记忆保存失败 [{Category}] {Key}: {Error}", m.Category, m.Key, error); + } + } + + if (extracted.Relations != null) + { + foreach (var r in extracted.Relations) + { + if (string.IsNullOrWhiteSpace(r.Subject) || string.IsNullOrWhiteSpace(r.Predicate) || string.IsNullOrWhiteSpace(r.Object)) + continue; + await _kgService.AddTripleAsync(r.Subject, r.Predicate, r.Object, ct: ct); + } + } + } +} + +internal class ExtractedMemories +{ + public List? Memories { get; set; } + public List? Relations { get; set; } +} + +internal class ExtractedMemoryItem +{ + public string Category { get; set; } = string.Empty; + public string Key { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; +} + +internal class ExtractedRelation +{ + public string Subject { get; set; } = string.Empty; + public string Predicate { get; set; } = string.Empty; + public string Object { get; set; } = string.Empty; +} diff --git a/src/Services/Notification/INotificationService.cs b/src/MarketAssistant.App.Services/Services/Notification/INotificationService.cs similarity index 100% rename from src/Services/Notification/INotificationService.cs rename to src/MarketAssistant.App.Services/Services/Notification/INotificationService.cs diff --git a/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..4327297 --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs @@ -0,0 +1,396 @@ +using MarketAssistant.Agents.ContextProviders; +using MarketAssistant.Agents.Middleware; +using MarketAssistant.Agents.PromptConfiguration; +using MarketAssistant.Agents.Trading; +using MarketAssistant.Agents.InvestmentSelection; +using MarketAssistant.Applications.Analysis; +using MarketAssistant.Agents.InvestmentSelection.Executors; +using MarketAssistant.Agents.InvestmentSelection.Strategies; +using MarketAssistant.Agents.MarketAnalysis; +using MarketAssistant.Agents.MarketAnalysis.Executors; +using MarketAssistant.Agents.Tools; +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.AShare; +using MarketAssistant.Agents.Tools.Crypto; +using MarketAssistant.Applications.Assets; +using MarketAssistant.Applications.AssetScreener; +using MarketAssistant.Applications.AssetScreener.Models; +using MarketAssistant.Applications.Cache; +using MarketAssistant.Applications.Charts; +using MarketAssistant.Applications.Favorites; +using MarketAssistant.Applications.History; +using MarketAssistant.Applications.Crypto; +using MarketAssistant.Applications.Home; +using MarketAssistant.Applications.InvestmentSelection; +using MarketAssistant.Applications.News; +using MarketAssistant.Applications.PriceAlert; +using MarketAssistant.Applications.Settings; +using MarketAssistant.Applications.Telegrams; +using MarketAssistant.Infrastructure.Factories; +using MarketAssistant.Infrastructure.Http; +using MarketAssistant.Rag.Extensions; +using MarketAssistant.Services.Archive; +using MarketAssistant.Services.Cache; +using MarketAssistant.Services.Data; +using MarketAssistant.Services.Market; +using MarketAssistant.Services.Mcp; +using MarketAssistant.Services.Settings; +using MarketAssistant.Trading; +using MarketAssistant.Trading.Abstractions; +using MarketAssistant.Trading.Exchanges; +using System.Net; +using Microsoft.Agents.AI; +using Microsoft.Extensions.Http.Resilience; +using Microsoft.Extensions.Logging; +using Polly.RateLimiting; +using Serilog; +using System.Threading.RateLimiting; + +namespace MarketAssistant.Services; + +/// +/// App.Services 业务服务注册扩展(非 UI 层) +/// +public static class BusinessServiceCollectionExtensions +{ + /// + /// 注册所有业务服务(不含 UI/Avalonia 特定服务) + /// + public static IServiceCollection AddBusinessServices(this IServiceCollection services) + { + services.AddMemoryCache(); + services.AddNamedMarketHttpClients(); + services.AddAgentTools(); + services.AddAgentInfrastructure(); + services.AddRagServices(); + services.AddMarketDataServices(); + services.AddTradingServices(); + services.AddWorkflowServices(); + services.AddMarketModules(); + services.AddSingleton(); + return services; + } + + // ───────────────────────────────────────────────────────────────────────── + // 命名 HttpClient(公开别名便于单元测试与外部宿主复用) + // ───────────────────────────────────────────────────────────────────────── + + /// + /// 注册 Binance / CoinGecko / Cls 等命名 HttpClient,含标准弹性策略。 + /// + public static IServiceCollection AddNamedMarketHttpClients(this IServiceCollection services) => + AddHttpClientsCore(services); + + internal static IServiceCollection AddHttpClients(this IServiceCollection services) => + AddHttpClientsCore(services); + + private static IServiceCollection AddHttpClientsCore(IServiceCollection services) + { + services.AddHttpClient("Binance", client => + { + client.BaseAddress = new Uri("https://api.binance.com"); + client.Timeout = TimeSpan.FromSeconds(30); + }).AddStandardResilienceHandler(options => + { + // 币安 REST API 限流:IP 维度 6000 weight/min,单连接并发过高易触发 429。 + // 桌面端场景下限制并发请求数即可,配合标准重试/熔断策略。 + ConfigureBinanceRateLimiter(options); + }); + + services.AddHttpClient("BinanceFutures", client => + { + client.BaseAddress = new Uri("https://fapi.binance.com"); + client.Timeout = TimeSpan.FromSeconds(30); + }).AddStandardResilienceHandler(options => + { + ConfigureBinanceRateLimiter(options); + }); + + // CoinGecko API Key 通过 DelegatingHandler 注入,避免 DataProviders 反向依赖 App.Services + services.AddTransient(); + + services.AddHttpClient("CoinGecko", client => + { + client.BaseAddress = new Uri("https://api.coingecko.com/api/v3/"); + client.Timeout = TimeSpan.FromSeconds(25); + client.DefaultRequestHeaders.UserAgent.ParseAdd("MarketAssistant/1.0"); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + }) + .AddHttpMessageHandler() + .AddStandardResilienceHandler(); + + services.AddHttpClient("ZhiTu", client => + { + client.BaseAddress = new Uri("https://api.zhituapi.com"); + client.Timeout = TimeSpan.FromSeconds(60); + }).AddStandardResilienceHandler(options => + { + options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(20); + options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(60); + options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(60); + }); + + services.AddHttpClient("Cls", client => + { + client.BaseAddress = new Uri("https://x-quote.cls.cn"); + client.Timeout = TimeSpan.FromSeconds(10); + }).AddStandardResilienceHandler(); + + // 东方财富搜索接口(新闻搜索等,返回 JSONP) + services.AddHttpClient("EastMoneySearch", client => + { + client.BaseAddress = new Uri("https://search-api-web.eastmoney.com/"); + client.Timeout = TimeSpan.FromSeconds(10); + }).AddStandardResilienceHandler(); + + // 新浪财经资金流接口(热门股票资金流排行) + // 原 push2.eastmoney.com 端点在部分网络环境下 TLS 重协商被中断,改用新浪接口 + services.AddHttpClient("SinaFinance", client => + { + client.BaseAddress = new Uri("https://vip.stock.finance.sina.com.cn"); + client.Timeout = TimeSpan.FromSeconds(10); + client.DefaultRequestHeaders.UserAgent.ParseAdd( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"); + client.DefaultRequestHeaders.Referrer = new Uri("https://vip.stock.finance.sina.com.cn/"); + }).AddStandardResilienceHandler(); + + // 雪球选股 API(支持全部 38 个筛选指标,Cookie 跨请求共享) + services.AddSingleton(); + services.AddHttpClient("Xueqiu", client => + { + client.BaseAddress = new Uri("https://xueqiu.com"); + client.Timeout = TimeSpan.FromSeconds(15); + client.DefaultRequestHeaders.UserAgent.ParseAdd( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + }) + .ConfigurePrimaryHttpMessageHandler(sp => new HttpClientHandler + { + UseCookies = true, + CookieContainer = sp.GetRequiredService() + }) + .AddStandardResilienceHandler(); + + // 虚拟币新闻 RSS 源(CoinTelegraph 等) + services.AddHttpClient("CryptoNewsRss", client => + { + client.Timeout = TimeSpan.FromSeconds(15); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"); + }).AddStandardResilienceHandler(); + + // 同花顺快讯接口 + services.AddHttpClient("AShareTelegram", client => + { + client.BaseAddress = new Uri("https://news.10jqka.com.cn"); + client.Timeout = TimeSpan.FromSeconds(10); + }).AddStandardResilienceHandler(); + + // 虚拟币快讯接口(PANews) + services.AddHttpClient("CryptoTelegram", client => + { + client.BaseAddress = new Uri("https://universal-api.panewslab.com"); + client.Timeout = TimeSpan.FromSeconds(10); + }).AddStandardResilienceHandler(); + + services.AddHttpClient("GitHub", client => + { + client.BaseAddress = new Uri(AppInfo.GitHubApiBaseUrl); + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.UserAgent.ParseAdd(AppInfo.UserAgent); + client.DefaultRequestHeaders.Accept.Add( + new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + }).AddStandardResilienceHandler(); + + // 专用于下载 Release 二进制文件的 HttpClient,不带 GitHub API 专用 Accept 头 + services.AddHttpClient("GitHubDownload", client => + { + client.Timeout = TimeSpan.FromMinutes(10); + client.DefaultRequestHeaders.UserAgent.ParseAdd(AppInfo.UserAgent); + }); + + return services; + } + + /// + /// 为币安 HttpClient 配置并发限流:最多 10 个并发请求,排队上限 50。 + /// 币安 REST API 限流为 IP 维度 6000 weight/min,桌面端控制并发即可避免触发 429。 + /// + private static void ConfigureBinanceRateLimiter(HttpStandardResilienceOptions options) + { + var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions + { + PermitLimit = 10, + QueueLimit = 50, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst + }); + + options.RateLimiter = new HttpRateLimiterStrategyOptions + { + RateLimiter = args => limiter.AcquireAsync(1, args.Context.CancellationToken) + }; + } + + // ───────────────────────────────────────────────────────────────────────── + // Agent Tools — Keyed Services(A股 + 虚拟币) + // ───────────────────────────────────────────────────────────────────────── + + private static IServiceCollection AddAgentTools(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + + // 通用工具 + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } + + // ───────────────────────────────────────────────────────────────────────── + // Agent 基础设施(工厂 / MAF / MCP / 向量存储) + // ───────────────────────────────────────────────────────────────────────── + + private static IServiceCollection AddAgentInfrastructure(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // MAF 中间件 + services.AddSingleton(); + services.AddSingleton(sp => + new ConversationCompressionMiddleware( + () => sp.GetRequiredService().CreateClient(), + sp.GetRequiredService>())); + + services.AddSingleton(sp => + new AgentSkillsProvider( + skillPath: Path.Combine(AppContext.BaseDirectory, "skills"))); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // AI Context Providers(Memory / RAG) + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + var store = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + AppInfo.AppName, + "vector.sqlite"); + services.AddSqliteVectorStore(_ => $"Data Source={store}"); + + return services; + } + + // ───────────────────────────────────────────────────────────────────────── + // 市场行情 / 数据 API 服务 + // ───────────────────────────────────────────────────────────────────────── + + private static IServiceCollection AddMarketDataServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } + + // ───────────────────────────────────────────────────────────────────────── + // 自主交易模块 + // ───────────────────────────────────────────────────────────────────────── + + private static IServiceCollection AddTradingServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } + + // ───────────────────────────────────────────────────────────────────────── + // 工作流服务(投资选择 + 市场分析) + // ───────────────────────────────────────────────────────────────────────── + + private static IServiceCollection AddWorkflowServices(this IServiceCollection services) + { + // 投资选择工作流 + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // 市场分析工作流 + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } + + // ───────────────────────────────────────────────────────────────────────── + // 市场模块——每个市场一个模块类,新增市场只需实现 IMarketModule 并加入列表 + // ───────────────────────────────────────────────────────────────────────── + + private static readonly IMarketModule[] _marketModules = + [ + new AShareMarketModule(), + new CryptoMarketModule(), + ]; + + private static IServiceCollection AddMarketModules(this IServiceCollection services) + { + foreach (var module in _marketModules) + module.Register(services); + return services; + } + + /// + /// 配置 Serilog 日志服务 + /// + public static ILoggingBuilder ConfigureLogging(this ILoggingBuilder logging, IUserSettingService userSettingService) + { + var logPath = userSettingService.CurrentSetting.LogPath; + try { Directory.CreateDirectory(logPath); } catch { } + + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.Console() + .WriteTo.File(Path.Combine(logPath, "log.txt"), + rollingInterval: RollingInterval.Day, + fileSizeLimitBytes: 10_000_000, + retainedFileCountLimit: 7) + .CreateLogger(); + + logging.ClearProviders(); + logging.AddSerilog(Log.Logger); + + logging.SetMinimumLevel(LogLevel.Information); + + return logging; + } +} diff --git a/src/Services/Settings/IUserSettingService.cs b/src/MarketAssistant.App.Services/Services/Settings/IUserSettingService.cs similarity index 100% rename from src/Services/Settings/IUserSettingService.cs rename to src/MarketAssistant.App.Services/Services/Settings/IUserSettingService.cs diff --git a/src/MarketAssistant.App.Services/Services/Settings/UserSettingService.cs b/src/MarketAssistant.App.Services/Services/Settings/UserSettingService.cs new file mode 100644 index 0000000..44f8098 --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/Settings/UserSettingService.cs @@ -0,0 +1,110 @@ +using MarketAssistant.Applications.Settings; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MarketAssistant.Services.Settings; + +/// +/// 用户设置服务,提供对UserSetting的统一访问和管理 +/// +public class UserSettingService : IUserSettingService +{ + private readonly ILogger _logger; + private readonly object _fileLock = new(); + private readonly string _settingsFilePath = Path.Combine(FileSystem.AppDataDirectory, AppInfo.UserSettingsFileName); + + private UserSetting _currentSetting = new(); + + /// + /// 当前用户设置 + /// + public UserSetting CurrentSetting => _currentSetting; + + public UserSettingService(ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + LoadSettings(); + } + + /// + /// 从文件加载设置 + /// + public void LoadSettings() + { + lock (_fileLock) + { + try + { + if (File.Exists(_settingsFilePath)) + { + string json = File.ReadAllText(_settingsFilePath); + _currentSetting = JsonSerializer.Deserialize(json) ?? new UserSetting(); + } + else + { + _currentSetting = new UserSetting(); + } + + // 如果日志路径为空,设置为默认日志目录(与启动阶段保持一致) + if (string.IsNullOrWhiteSpace(_currentSetting.LogPath)) + { + _currentSetting.LogPath = Path.Combine(FileSystem.AppDataDirectory, AppInfo.LogsDirectoryName); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "加载设置时出错"); + + // 如果加载失败,使用默认值 + _currentSetting = new UserSetting(); + } + } + } + + /// + /// 保存设置到文件 + /// + public void SaveSettings() + { + lock (_fileLock) + { + try + { + // 确保目录存在 + var directory = Path.GetDirectoryName(_settingsFilePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + // 序列化并保存 + var json = JsonSerializer.Serialize(_currentSetting, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(_settingsFilePath, json); + } + catch (Exception ex) + { + _logger.LogError(ex, "保存设置时出错"); + } + } + } + + /// + /// 更新设置并保存 + /// + /// 新的用户设置 + public void UpdateSettings(UserSetting setting) + { + _currentSetting = setting; + SaveSettings(); + } + + /// + /// 重置设置为默认值 + /// + public void ResetSettings() + { + _currentSetting = new UserSetting(); + SaveSettings(); + } +} + diff --git a/src/MarketAssistant.App.Services/Services/UserKnowledgeGraphService.cs b/src/MarketAssistant.App.Services/Services/UserKnowledgeGraphService.cs new file mode 100644 index 0000000..9c47b2b --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/UserKnowledgeGraphService.cs @@ -0,0 +1,183 @@ +using MarketAssistant.Infrastructure.Core; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Services; + +/// +/// 时序知识图谱服务(SQLite)。 +/// 存储实体-关系三元组(subject, predicate, object),每个三元组有有效时间窗口。 +/// 参考 MemPalace 的时序知识图谱设计,适配金融投资领域。 +/// +public class UserKnowledgeGraphService : SqliteServiceBase +{ + public UserKnowledgeGraphService(ILogger logger) + : base(logger) + { + } + + /// + /// 添加一条三元组(实体-关系-实体),带有效起始时间 + /// + public async Task AddTripleAsync( + string subject, string predicate, string obj, + string? validFrom = null, string? metadata = null, + CancellationToken ct = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO triples (subject, predicate, object, valid_from, metadata, created_at) + VALUES (@subject, @predicate, @object, @validFrom, @metadata, @createdAt) + """; + cmd.Parameters.AddWithValue("@subject", subject); + cmd.Parameters.AddWithValue("@predicate", predicate); + cmd.Parameters.AddWithValue("@object", obj); + cmd.Parameters.AddWithValue("@validFrom", validFrom ?? DateTime.UtcNow.ToString("yyyy-MM-dd")); + cmd.Parameters.AddWithValue("@metadata", (object?)metadata ?? DBNull.Value); + cmd.Parameters.AddWithValue("@createdAt", DateTime.UtcNow.ToString("O")); + await cmd.ExecuteNonQueryAsync(ct); + + Logger.LogDebug("知识图谱新增三元组: {S} --[{P}]--> {O}", subject, predicate, obj); + } + + /// + /// 查询实体的所有当前有效关系 + /// + public async Task> QueryEntityAsync( + string entity, string? asOf = null, CancellationToken ct = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + var dateFilter = asOf ?? DateTime.UtcNow.ToString("yyyy-MM-dd"); + + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT id, subject, predicate, object, valid_from, valid_to, metadata, created_at + FROM triples + WHERE (subject = @entity OR object = @entity) + AND valid_from <= @date + AND (valid_to IS NULL OR valid_to >= @date) + ORDER BY valid_from DESC + """; + cmd.Parameters.AddWithValue("@entity", entity); + cmd.Parameters.AddWithValue("@date", dateFilter); + + return await ReadTriplesAsync(cmd, ct); + } + + /// + /// 使三元组过期(设置 valid_to) + /// + public async Task InvalidateAsync( + string subject, string predicate, string obj, + string? ended = null, CancellationToken ct = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE triples SET valid_to = @ended + WHERE subject = @subject AND predicate = @predicate AND object = @object + AND valid_to IS NULL + """; + cmd.Parameters.AddWithValue("@subject", subject); + cmd.Parameters.AddWithValue("@predicate", predicate); + cmd.Parameters.AddWithValue("@object", obj); + cmd.Parameters.AddWithValue("@ended", ended ?? DateTime.UtcNow.ToString("yyyy-MM-dd")); + await cmd.ExecuteNonQueryAsync(ct); + + Logger.LogDebug("知识图谱三元组过期: {S} --[{P}]--> {O}", subject, predicate, obj); + } + + /// + /// 获取实体的时间线(所有历史关系,含已过期的) + /// + public async Task> TimelineAsync( + string entity, CancellationToken ct = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT id, subject, predicate, object, valid_from, valid_to, metadata, created_at + FROM triples + WHERE subject = @entity OR object = @entity + ORDER BY valid_from ASC + """; + cmd.Parameters.AddWithValue("@entity", entity); + + return await ReadTriplesAsync(cmd, ct); + } + + private static async Task> ReadTriplesAsync(SqliteCommand cmd, CancellationToken ct) + { + var result = new List(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + result.Add(new KnowledgeTriple + { + Id = reader.GetInt64(0), + Subject = reader.GetString(1), + Predicate = reader.GetString(2), + Object = reader.GetString(3), + ValidFrom = reader.GetString(4), + ValidTo = reader.IsDBNull(5) ? null : reader.GetString(5), + Metadata = reader.IsDBNull(6) ? null : reader.GetString(6), + CreatedAt = reader.GetString(7) + }); + } + return result; + } + + protected override async Task InitializeDatabaseAsync() + { + try + { + await using var conn = await OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS triples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subject TEXT NOT NULL, + predicate TEXT NOT NULL, + object TEXT NOT NULL, + valid_from TEXT NOT NULL, + valid_to TEXT, + metadata TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_triples_subject ON triples(subject); + CREATE INDEX IF NOT EXISTS idx_triples_object ON triples(object); + CREATE INDEX IF NOT EXISTS idx_triples_predicate ON triples(predicate); + CREATE INDEX IF NOT EXISTS idx_triples_validity ON triples(valid_from, valid_to); + """; + await cmd.ExecuteNonQueryAsync(); + Logger.LogInformation("知识图谱数据库初始化完成"); + } + catch (Exception ex) + { + Logger.LogError(ex, "初始化知识图谱数据库失败"); + throw; + } + } +} + +/// +/// 知识图谱三元组 +/// +public record KnowledgeTriple +{ + public long Id { get; init; } + public string Subject { get; init; } = string.Empty; + public string Predicate { get; init; } = string.Empty; + public string Object { get; init; } = string.Empty; + public string ValidFrom { get; init; } = string.Empty; + public string? ValidTo { get; init; } + public string? Metadata { get; init; } + public string CreatedAt { get; init; } = string.Empty; + + public bool IsActive => ValidTo is null; +} diff --git a/src/MarketAssistant.App.Services/Services/UserMemoryService.cs b/src/MarketAssistant.App.Services/Services/UserMemoryService.cs new file mode 100644 index 0000000..ef2b56f --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/UserMemoryService.cs @@ -0,0 +1,221 @@ +using MarketAssistant.Infrastructure.Core; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Services; + +/// +/// 用户长期记忆持久化服务(SQLite)。 +/// 存储用户的投资偏好、历史分析结论、自定义标签等,供 AI 上下文使用。 +/// 采用有界设计:条目数上限 ,总字符上限 。 +/// +public class UserMemoryService : SqliteServiceBase +{ + public const int MaxEntryCount = 50; + public const int MaxTotalChars = 5000; + + public UserMemoryService(ILogger logger) + : base(logger) + { + } + + /// + /// 保存一条记忆条目。如果容量已满,返回 false 并输出诊断信息。 + /// + public async Task<(bool Success, string? Error)> SaveMemoryAsync( + string category, string key, string value, CancellationToken ct = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + + var usage = await GetUsageAsync(ct); + bool isReplace = await ExistsAsync(category, key, ct); + + if (!isReplace) + { + if (usage.EntryCount >= MaxEntryCount) + return (false, $"记忆条目数已达上限 {usage.EntryCount}/{MaxEntryCount},请先删除旧条目再添加。"); + + if (usage.TotalChars + value.Length > MaxTotalChars) + return (false, $"记忆总字符数将超过上限(当前 {usage.TotalChars}/{MaxTotalChars}),请先精简或删除旧条目。"); + } + + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO memories (category, key, value, priority, updated_at) + VALUES (@category, @key, @value, 0, @updatedAt) + ON CONFLICT(category, key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at + """; + cmd.Parameters.AddWithValue("@category", category); + cmd.Parameters.AddWithValue("@key", key); + cmd.Parameters.AddWithValue("@value", value); + cmd.Parameters.AddWithValue("@updatedAt", DateTime.UtcNow.ToString("O")); + await cmd.ExecuteNonQueryAsync(ct); + return (true, null); + } + + /// + /// 获取指定类别的所有记忆条目 + /// + public async Task> GetMemoriesAsync(string category, CancellationToken ct = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT key, value FROM memories WHERE category = @category ORDER BY priority DESC, updated_at DESC"; + cmd.Parameters.AddWithValue("@category", category); + + var result = new Dictionary(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + result[reader.GetString(0)] = reader.GetString(1); + } + return result; + } + + /// + /// 获取所有记忆条目(用于注入 AI 上下文) + /// + public async Task> GetAllMemoriesAsync(CancellationToken ct = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT category, key, value FROM memories ORDER BY priority DESC, category, updated_at DESC"; + + var result = new List<(string, string, string)>(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + result.Add((reader.GetString(0), reader.GetString(1), reader.GetString(2))); + } + return result; + } + + /// + /// 获取高优先级记忆条目(用于 L1 层始终加载) + /// + public async Task> GetHighPriorityMemoriesAsync( + int minPriority = 1, CancellationToken ct = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT category, key, value FROM memories + WHERE priority >= @minPriority + ORDER BY priority DESC, updated_at DESC + """; + cmd.Parameters.AddWithValue("@minPriority", minPriority); + + var result = new List<(string, string, string)>(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + result.Add((reader.GetString(0), reader.GetString(1), reader.GetString(2))); + } + return result; + } + + /// + /// 设置记忆条目的优先级(0=普通,1+=高优先级,始终加载到上下文) + /// + public async Task SetPriorityAsync(string category, string key, int priority, CancellationToken ct = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE memories SET priority = @priority WHERE category = @category AND key = @key"; + cmd.Parameters.AddWithValue("@priority", priority); + cmd.Parameters.AddWithValue("@category", category); + cmd.Parameters.AddWithValue("@key", key); + await cmd.ExecuteNonQueryAsync(ct); + } + + /// + /// 删除一条记忆条目 + /// + public async Task DeleteMemoryAsync(string category, string key, CancellationToken ct = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM memories WHERE category = @category AND key = @key"; + cmd.Parameters.AddWithValue("@category", category); + cmd.Parameters.AddWithValue("@key", key); + await cmd.ExecuteNonQueryAsync(ct); + } + + /// + /// 获取当前记忆用量统计 + /// + public async Task GetUsageAsync(CancellationToken ct = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*), COALESCE(SUM(LENGTH(value)), 0) FROM memories"; + + await using var reader = await cmd.ExecuteReaderAsync(ct); + await reader.ReadAsync(ct); + return new MemoryUsage + { + EntryCount = reader.GetInt32(0), + TotalChars = reader.GetInt32(1), + MaxEntryCount = MaxEntryCount, + MaxTotalChars = MaxTotalChars + }; + } + + private async Task ExistsAsync(string category, string key, CancellationToken ct) + { + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT 1 FROM memories WHERE category = @category AND key = @key LIMIT 1"; + cmd.Parameters.AddWithValue("@category", category); + cmd.Parameters.AddWithValue("@key", key); + return await cmd.ExecuteScalarAsync(ct) is not null; + } + + protected override async Task InitializeDatabaseAsync() + { + try + { + await using var conn = await OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS memories ( + category TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + PRIMARY KEY (category, key) + ); + CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category); + CREATE INDEX IF NOT EXISTS idx_memories_priority ON memories(priority); + """; + await cmd.ExecuteNonQueryAsync(); + Logger.LogInformation("用户记忆数据库初始化完成"); + } + catch (Exception ex) + { + Logger.LogError(ex, "初始化用户记忆数据库失败"); + throw; + } + } +} + +/// +/// 记忆用量统计 +/// +public record MemoryUsage +{ + public int EntryCount { get; init; } + public int TotalChars { get; init; } + public int MaxEntryCount { get; init; } + public int MaxTotalChars { get; init; } +} diff --git a/src/MarketAssistant.App.Services/Services/WorkflowVisualizationService.cs b/src/MarketAssistant.App.Services/Services/WorkflowVisualizationService.cs new file mode 100644 index 0000000..d84bc72 --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/WorkflowVisualizationService.cs @@ -0,0 +1,48 @@ +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Services; + +/// +/// 工作流可视化服务,将 MAF Workflow 导出为 Mermaid 图表 +/// +public class WorkflowVisualizationService +{ + private readonly ILogger _logger; + + public WorkflowVisualizationService(ILogger logger) + { + _logger = logger; + } + + /// + /// 将工作流导出为 Mermaid 格式图表 + /// + public string ExportToMermaid(Workflow? workflow = null) + { + // MAF 1.0.0 暂未公开 ToMermaid(),使用预定义模板 + return GenerateFallbackMermaid(); + } + + /// + /// 静态回退 Mermaid 图(当 MAF API 不可用时) + /// + private static string GenerateFallbackMermaid() + { + return """ + graph TD + A[Dispatcher
分发分析任务] --> B[技术分析师] + A --> C[基本面分析师] + A --> D[情绪分析师] + A --> E[新闻分析师] + A --> F[财务分析师] + B --> G[Aggregator
聚合分析结果] + C --> G + D --> G + E --> G + F --> G + G --> H[Coordinator
生成综合报告] + H --> I[MarketAnalysisReport] + """; + } +} diff --git a/src/MarketAssistant.App.Services/Trading/AnalysisReportCache.cs b/src/MarketAssistant.App.Services/Trading/AnalysisReportCache.cs new file mode 100644 index 0000000..db30f87 --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/AnalysisReportCache.cs @@ -0,0 +1,97 @@ +using System.Collections.Concurrent; +using MarketAssistant.Agents.MarketAnalysis.Models; +using MarketAssistant.Applications.Cache; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Market; + +namespace MarketAssistant.Trading; + +/// +/// 分析报告内存缓存:线程安全地存储最近一次市场分析结果, +/// 供交易模块在 AI 信号策略决策时读取,打通分析-交易链路。 +/// 缓存键包含市场类型前缀,避免 A 股与虚拟币同代码(理论上)相互覆盖。 +/// +public sealed class AnalysisReportCache +{ + private const int MaxEntries = 50; + private static readonly TimeSpan Ttl = TimeSpan.FromHours(24); + + private readonly ConcurrentDictionary _reports = + new(StringComparer.OrdinalIgnoreCase); + + private readonly MarketContext _marketContext; + + public AnalysisReportCache(MarketContext marketContext) + { + _marketContext = marketContext; + } + + public void Set(string symbol, MarketAnalysisReport report) + { + // 惰性清理:写入前移除已过期条目,并在超限时淘汰最旧的条目 + EvictExpired(); + EnsureCapacity(); + + _reports[BuildKey(symbol)] = new CachedReport(report, DateTime.UtcNow); + } + + public CachedReport? Get(string symbol) + { + var key = BuildKey(symbol); + if (!_reports.TryGetValue(key, out var cached)) + return null; + + if (DateTime.UtcNow - cached.CachedAt > Ttl) + { + _reports.TryRemove(key, out _); + return null; + } + + return cached; + } + + /// + /// 构建包含市场类型的缓存键,避免跨市场冲突。 + /// 键格式由 统一管理。 + /// + private string BuildKey(string symbol) + => CacheKeys.GetTradingAnalysisReportKey(_marketContext.CurrentMarket, symbol); + + /// + /// 移除所有已过期条目 + /// + private void EvictExpired() + { + var threshold = DateTime.UtcNow - Ttl; + foreach (var kv in _reports) + { + if (kv.Value.CachedAt < threshold) + { + _reports.TryRemove(kv.Key, out _); + } + } + } + + /// + /// 容量超限时淘汰最旧的条目 + /// + private void EnsureCapacity() + { + if (_reports.Count < MaxEntries) return; + + // 按 CachedAt 升序,淘汰最旧的若干条目,留出少量余量避免频繁触发 + var overflow = _reports.Count - MaxEntries + 1; + var oldest = _reports + .OrderBy(kv => kv.Value.CachedAt) + .Take(overflow) + .Select(kv => kv.Key) + .ToList(); + + foreach (var key in oldest) + { + _reports.TryRemove(key, out _); + } + } + + public sealed record CachedReport(MarketAnalysisReport Report, DateTime CachedAt); +} diff --git a/src/MarketAssistant.App.Services/Trading/CryptoPortfolioService.cs b/src/MarketAssistant.App.Services/Trading/CryptoPortfolioService.cs new file mode 100644 index 0000000..b4d0398 --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/CryptoPortfolioService.cs @@ -0,0 +1,138 @@ +using MarketAssistant.Services.Data; +using MarketAssistant.Trading.Abstractions; +using MarketAssistant.Trading.Models; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Trading; + +/// +/// 统一封装虚拟币账户资产估值与持仓快照,避免在多个调用点重复拼装账户视图。 +/// +public class CryptoPortfolioService +{ + private readonly IExchangeClient _exchangeClient; + private readonly BinanceMarketDataService _marketDataService; + private readonly TradingDataService _tradingDataService; + private readonly ILogger _logger; + + public CryptoPortfolioService( + [FromKeyedServices(MarketType.Crypto)] IExchangeClient exchangeClient, + BinanceMarketDataService marketDataService, + TradingDataService tradingDataService, + ILogger logger) + { + _exchangeClient = exchangeClient; + _marketDataService = marketDataService; + _tradingDataService = tradingDataService; + _logger = logger; + } + + public async Task GetAccountBalanceSummaryAsync(CancellationToken ct = default) + { + var accountInfo = await _exchangeClient.GetAccountInfoAsync(ct); + return await BuildBalanceSummaryAsync(accountInfo, ct); + } + + public async Task> GetCurrentPositionsAsync(CancellationToken ct = default) + { + var accountInfo = await _exchangeClient.GetAccountInfoAsync(ct); + var positions = new List(); + + foreach (var balance in accountInfo.Balances) + { + var quantity = balance.Free + balance.Locked; + if (quantity <= 0 || balance.Asset.Equals("USDT", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var position = new PositionInfo + { + Symbol = $"{balance.Asset}USDT", + Quantity = quantity + }; + + try + { + var ticker = await _marketDataService.Get24hrTickerAsync(position.Symbol, ct); + if (ticker != null) + { + position.CurrentPrice = ticker.LastPrice; + + // 只按未平仓的 FIFO 持仓计算均价,避免已平仓记录污染浮盈显示 + var avgEntry = await _tradingDataService.GetOpenPositionAvgEntryPriceAsync(position.Symbol, ct); + position.EntryPrice = avgEntry > 0 ? avgEntry : ticker.LastPrice; + + if (position.EntryPrice > 0) + { + position.UnrealizedPnl = (position.CurrentPrice - position.EntryPrice) * position.Quantity; + position.UnrealizedPnlPercent = (position.CurrentPrice - position.EntryPrice) / position.EntryPrice * 100; + } + } + } + catch (HttpRequestException) + { + _logger.LogDebug("获取持仓价格失败,跳过: {Symbol}", position.Symbol); + } + + positions.Add(position); + } + + return positions; + } + + public static decimal GetUsdtBalance(AccountBalanceSummary summary) + { + return summary.Assets + .Where(asset => asset.Asset.Equals("USDT", StringComparison.OrdinalIgnoreCase)) + .Sum(asset => asset.Free + asset.Locked); + } + + private async Task BuildBalanceSummaryAsync( + ExchangeAccountInfo accountInfo, + CancellationToken ct) + { + var summary = new AccountBalanceSummary(); + + foreach (var balance in accountInfo.Balances) + { + var totalAmount = balance.Free + balance.Locked; + if (totalAmount <= 0) + { + continue; + } + + var assetBalance = new AssetBalance + { + Asset = balance.Asset, + Free = balance.Free, + Locked = balance.Locked + }; + + if (balance.Asset.Equals("USDT", StringComparison.OrdinalIgnoreCase)) + { + assetBalance.ValueUSDT = totalAmount; + } + else + { + try + { + var ticker = await _marketDataService.Get24hrTickerAsync($"{balance.Asset}USDT", ct); + if (ticker != null) + { + assetBalance.ValueUSDT = totalAmount * ticker.LastPrice; + } + } + catch (HttpRequestException) + { + _logger.LogDebug("跳过无 USDT 标的行情的资产估值: {Asset}", balance.Asset); + } + } + + summary.TotalValueUSDT += assetBalance.ValueUSDT; + summary.Assets.Add(assetBalance); + } + + return summary; + } +} diff --git a/src/MarketAssistant.App.Services/Trading/Exchanges/BinanceExchangeClient.cs b/src/MarketAssistant.App.Services/Trading/Exchanges/BinanceExchangeClient.cs new file mode 100644 index 0000000..50014ec --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/Exchanges/BinanceExchangeClient.cs @@ -0,0 +1,100 @@ +using MarketAssistant.Applications.Crypto; +using MarketAssistant.Trading.Abstractions; +using MarketAssistant.Trading.Models; +using System.Globalization; + +namespace MarketAssistant.Trading.Exchanges; + +/// +/// Binance 交易所适配器,将 BinanceAccountService 包装为统一的 IExchangeClient 接口 +/// +public class BinanceExchangeClient : IExchangeClient +{ + private readonly BinanceAccountService _accountService; + + public string ExchangeName => "Binance"; + + public BinanceExchangeClient(BinanceAccountService accountService) + { + _accountService = accountService; + } + + public async Task GetAccountInfoAsync(CancellationToken ct = default) + { + var info = await _accountService.GetAccountInfoAsync(ct); + return new ExchangeAccountInfo + { + CanTrade = info.CanTrade, + Balances = info.Balances.Select(b => new ExchangeBalance + { + Asset = b.Asset, + Free = decimal.TryParse(b.Free, NumberStyles.Number, CultureInfo.InvariantCulture, out var f) ? f : 0, + Locked = decimal.TryParse(b.Locked, NumberStyles.Number, CultureInfo.InvariantCulture, out var l) ? l : 0 + }).ToList() + }; + } + + public async Task PlaceOrderAsync( + string symbol, OrderSide side, OrderType type, + decimal quantity, decimal? price = null, + string? clientOrderId = null, + CancellationToken ct = default) + { + var response = await _accountService.PlaceOrderAsync( + symbol, side.ToString().ToUpper(), type.ToString().ToUpper(), + quantity, price, clientOrderId, ct); + + return MapOrderResponse(response); + } + + public async Task GetOrderAsync( + string symbol, string orderId, CancellationToken ct = default) + { + var response = await _accountService.GetOrderAsync(symbol, long.Parse(orderId), ct); + return MapOrderResponse(response); + } + + public async Task CancelOrderAsync( + string symbol, string orderId, CancellationToken ct = default) + { + var response = await _accountService.CancelOrderAsync(symbol, long.Parse(orderId), ct); + return MapOrderResponse(response); + } + + public async Task> GetOpenOrdersAsync( + string? symbol = null, CancellationToken ct = default) + { + var orders = await _accountService.GetOpenOrdersAsync(symbol, ct); + return orders.Select(MapOrderResponse).ToList(); + } + + private static ExchangeOrderResult MapOrderResponse(BinanceOrderResponse response) + { + // 汇总所有 fills 的手续费(仅下单响应包含 fills,查询/撤单响应不包含) + decimal totalCommission = 0; + string? commissionAsset = null; + if (response.Fills.Count > 0) + { + foreach (var fill in response.Fills) + { + if (decimal.TryParse(fill.Commission, NumberStyles.Number, CultureInfo.InvariantCulture, out var c)) + totalCommission += c; + commissionAsset ??= fill.CommissionAsset; + } + } + + return new ExchangeOrderResult + { + Symbol = response.Symbol, + OrderId = response.OrderId.ToString(), + Status = response.Status, + Side = response.Side, + Type = response.Type, + RequestedQty = decimal.TryParse(response.OrigQty, NumberStyles.Number, CultureInfo.InvariantCulture, out var rq) ? rq : 0, + ExecutedQty = decimal.TryParse(response.ExecutedQty, NumberStyles.Number, CultureInfo.InvariantCulture, out var eq) ? eq : 0, + Price = decimal.TryParse(response.Price, NumberStyles.Number, CultureInfo.InvariantCulture, out var p) ? p : 0, + FillCommission = totalCommission, + CommissionAsset = commissionAsset + }; + } +} diff --git a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs new file mode 100644 index 0000000..b81b007 --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs @@ -0,0 +1,550 @@ +using System.Collections.Concurrent; +using System.Text; +using System.Threading.Channels; +using MarketAssistant.Agents.Trading; +using MarketAssistant.Infrastructure.Factories; +using MarketAssistant.Services.Data; +using MarketAssistant.Trading.Models; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Trading; + +/// +/// 后台市场监控器,订阅实时价格并根据策略触发交易。 +/// 使用 Channel 缓冲价格更新,按顺序处理每个 tick。 +/// +public class MarketMonitor : IDisposable +{ + private readonly BinanceWebSocketService _webSocketService; + private readonly StrategyEngine _strategyEngine; + private readonly TradeExecutor _tradeExecutor; + private readonly ITradingAgentFactory _agentFactory; + private readonly TradingDataService _dataService; + private readonly CryptoPortfolioService _portfolioService; + private readonly AnalysisReportCache _reportCache; + private readonly ILogger _logger; + + private readonly SemaphoreSlim _lifecycleLock = new(1, 1); + private CancellationTokenSource? _cts; + private bool _isRunning; + private Task? _consumerTask; + + private readonly Channel<(string Symbol, decimal Price)> _priceChannel = + Channel.CreateBounded<(string, decimal)>(new BoundedChannelOptions(1000) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.DropOldest + }); + + private readonly ConcurrentDictionary _strategyLocks = new(); + + /// + /// 已取消的 Token,用于 _cts 为 null 时(未启动或已释放)确保异步操作立即返回而非不可取消地继续执行。 + /// 使用 CancellationToken.None 会导致操作不可取消,在 StopAsync/Dispose 后仍可能执行交易。 + /// + private static readonly CancellationToken StoppedToken = new(canceled: true); + + /// + /// 获取当前可用的 CancellationToken:运行中返回 _cts.Token,未运行或已释放返回已取消的 Token。 + /// + private CancellationToken MonitorToken => _cts?.Token ?? StoppedToken; + + /// + /// 正在执行的策略任务列表,用于 StopAsync 时等待全部完成,避免订单已发送但本地状态未更新 + /// + private readonly List _pendingStrategyTasks = []; + private readonly object _pendingTasksLock = new(); + + // 适配 BinanceWebSocketService.PriceUpdated 事件签名(Action) + // 到不含 changePercent 的 OnPriceUpdated 处理方法,需存储委托实例以支持 -= 取消订阅 + private readonly Action _priceUpdatedAdapter; + + public bool IsRunning => _isRunning; + + /// + /// 当交易执行时触发的事件(UI 可订阅来刷新) + /// + public event Action? TradeExecuted; + + /// + /// 当监控状态变化时触发 + /// + public event Action? StatusChanged; + + public MarketMonitor( + BinanceWebSocketService webSocketService, + StrategyEngine strategyEngine, + TradeExecutor tradeExecutor, + ITradingAgentFactory agentFactory, + TradingDataService dataService, + CryptoPortfolioService portfolioService, + AnalysisReportCache reportCache, + ILogger logger) + { + _webSocketService = webSocketService; + _strategyEngine = strategyEngine; + _tradeExecutor = tradeExecutor; + _agentFactory = agentFactory; + _dataService = dataService; + _portfolioService = portfolioService; + _reportCache = reportCache; + _logger = logger; + _priceUpdatedAdapter = (symbol, lastPrice, _) => OnPriceUpdated(symbol, lastPrice); + } + + /// + /// 启动后台监控 + /// + public async Task StartAsync() + { + await _lifecycleLock.WaitAsync(); + try + { + if (_isRunning) + return; + + _cts = new CancellationTokenSource(); + _isRunning = true; + + var activeStrategies = await _dataService.GetStrategiesByStatusAsync(StrategyStatus.Active); + var instrumentSymbols = activeStrategies + .Select(s => s.Symbol.ToLowerInvariant()) + .Distinct() + .ToList(); + + if (instrumentSymbols.Count > 0) + await _webSocketService.SubscribeAsync(instrumentSymbols); + + _webSocketService.PriceUpdated += _priceUpdatedAdapter; + _consumerTask = Task.Run(() => ConsumePriceUpdatesAsync(_cts.Token)); + + _logger.LogInformation("MarketMonitor 已启动,监控 {Count} 个交易标的", instrumentSymbols.Count); + StatusChanged?.Invoke(true); + } + finally + { + _lifecycleLock.Release(); + } + } + + /// + /// 停止后台监控 + /// + public async Task StopAsync() + { + await _lifecycleLock.WaitAsync(); + try + { + if (!_isRunning) + return; + + _webSocketService.PriceUpdated -= _priceUpdatedAdapter; + _cts?.Cancel(); + + if (_consumerTask != null) + { + try { await _consumerTask; } + catch (OperationCanceledException) { } + } + + // 等待所有正在执行的策略任务完成,避免订单已发送但本地状态未更新 + Task[] pendingTasks; + lock (_pendingTasksLock) + pendingTasks = _pendingStrategyTasks.ToArray(); + + if (pendingTasks.Length > 0) + { + _logger.LogInformation("等待 {Count} 个策略任务完成...", pendingTasks.Length); + try + { + await Task.WhenAll(pendingTasks).WaitAsync(TimeSpan.FromSeconds(10)); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "等待策略任务完成时超时或出错,部分状态可能未持久化"); + } + } + + await _webSocketService.UnsubscribeAllAsync(); + + _isRunning = false; + _logger.LogInformation("MarketMonitor 已停止"); + StatusChanged?.Invoke(false); + } + finally + { + _lifecycleLock.Release(); + } + } + + /// + /// 刷新监控的交易标的列表(策略增减后调用) + /// + public async Task RefreshSubscriptionsAsync() + { + if (!_isRunning) + return; + + var activeStrategies = await _dataService.GetStrategiesByStatusAsync(StrategyStatus.Active); + var newSymbols = activeStrategies + .Select(s => s.Symbol.ToLowerInvariant()) + .Distinct() + .ToHashSet(); + + await _webSocketService.UnsubscribeAllAsync(); + + if (newSymbols.Count > 0) + await _webSocketService.SubscribeAsync(newSymbols.ToList()); + + _logger.LogInformation("已刷新监控列表: {Count} 个交易标的", newSymbols.Count); + } + + private void OnPriceUpdated(string symbol, decimal lastPrice) + { + _priceChannel.Writer.TryWrite((symbol, lastPrice)); + } + + /// + /// Channel 消费者:顺序评估策略,异步执行触发的交易(每策略独立锁) + /// + private async Task ConsumePriceUpdatesAsync(CancellationToken ct) + { + try + { + await foreach (var (symbol, price) in _priceChannel.Reader.ReadAllAsync(ct)) + { + try + { + var triggered = await _strategyEngine.EvaluateAndUpdateStrategiesAsync(symbol, price, ct); + foreach (var strategy in triggered) + { + var task = ExecuteWithStrategyLockAsync(strategy, price, ct); + lock (_pendingTasksLock) + _pendingStrategyTasks.Add(task); + // 任务完成后自动从列表移除,避免无限增长 + _ = task.ContinueWith(t => + { + lock (_pendingTasksLock) + _pendingStrategyTasks.Remove(t); + }, TaskScheduler.Default); + } + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + _logger.LogError(ex, "价格更新处理异常: {Symbol}", symbol); + } + } + } + catch (OperationCanceledException) + { + _logger.LogDebug("价格消费者已取消"); + } + } + + /// + /// 带策略级锁的异步执行,防止同一策略并发触发 + /// + private async Task ExecuteWithStrategyLockAsync( + TradingStrategy strategy, decimal price, CancellationToken ct) + { + var strategyLock = _strategyLocks.GetOrAdd(strategy.Id, _ => new SemaphoreSlim(1, 1)); + if (!await strategyLock.WaitAsync(0, ct)) + { + _logger.LogDebug("策略 {Id} 正在执行中,跳过本次触发", strategy.Id); + return; + } + + try + { + await HandleTriggeredStrategyAsync(strategy, price); + } + catch (Exception ex) + { + _logger.LogError(ex, "策略执行异常: {StrategyId}", strategy.Id); + } + finally + { + strategyLock.Release(); + } + } + + private async Task HandleTriggeredStrategyAsync(TradingStrategy strategy, decimal currentPrice) + { + if (strategy.Type == StrategyType.AISignal) + { + // 硬性止损/止盈边界检查:存在持仓时无需 AI 决策,直接强制平仓 + if (TryHandleHardBoundary(strategy, currentPrice, out var boundaryReasoning)) + { + strategy.Side = strategy.Side == OrderSide.Buy ? OrderSide.Sell : OrderSide.Buy; + var boundaryResult = await _tradeExecutor.ExecuteTradeAsync( + strategy, currentPrice, boundaryReasoning, ct: MonitorToken); + if (boundaryResult.Success && boundaryResult.Record != null) + TradeExecuted?.Invoke(boundaryResult.Record); + await CheckStrategyCompletionAsync(strategy); + return; + } + + await HandleAISignalAsync(strategy, currentPrice); + } + else + { + // 网格交易:交易成功后原子地持久化更新后的网格参数,防止计数和参数不一致 + var pendingCustomParams = strategy.Type == StrategyType.GridTrading ? strategy.CustomParams : null; + var result = await _tradeExecutor.ExecuteTradeAsync( + strategy, currentPrice, pendingCustomParams: pendingCustomParams, ct: MonitorToken); + + if (result.Success && result.Record != null) + TradeExecuted?.Invoke(result.Record); + + await CheckStrategyCompletionAsync(strategy); + } + } + + private static bool TryHandleHardBoundary(TradingStrategy strategy, decimal currentPrice, out string reasoning) + { + reasoning = string.Empty; + + // 仅在已有成交(存在持仓)时执行硬性边界保护 + if (strategy.ExecutionCount == 0) + return false; + + if (strategy.StopLossPrice.HasValue) + { + bool stopTriggered = strategy.Side == OrderSide.Buy + ? currentPrice <= strategy.StopLossPrice.Value + : currentPrice >= strategy.StopLossPrice.Value; + if (stopTriggered) + { + reasoning = $"AISignal 硬性止损触发:当前价 {currentPrice} 已达止损位 {strategy.StopLossPrice.Value},系统强制平仓"; + return true; + } + } + + if (strategy.TakeProfitPrice.HasValue) + { + bool tpTriggered = strategy.Side == OrderSide.Buy + ? currentPrice >= strategy.TakeProfitPrice.Value + : currentPrice <= strategy.TakeProfitPrice.Value; + if (tpTriggered) + { + reasoning = $"AISignal 硬性止盈触发:当前价 {currentPrice} 已达止盈位 {strategy.TakeProfitPrice.Value},系统自动止盈"; + return true; + } + } + + return false; + } + + private async Task HandleAISignalAsync(TradingStrategy strategy, decimal currentPrice) + { + try + { + TradingContext.CurrentStrategyId = strategy.Id; + + var prompt = await BuildAIPromptAsync(strategy, currentPrice); + await InvokeAgentAsync(prompt); + await ProcessAgentResponseAsync(strategy); + } + catch (Exception ex) + { + _logger.LogError(ex, "AI 信号策略执行失败: {StrategyId}", strategy.Id); + } + finally + { + TradingContext.CurrentStrategyId = null; + } + } + + /// + /// 构建 AI 决策 prompt:聚合历史成交、仓位、分析报告与风险预算 + /// + private async Task BuildAIPromptAsync(TradingStrategy strategy, decimal currentPrice) + { + var priorRecords = await _dataService.GetRecordsByStrategyAsync(strategy.Id, MonitorToken) + .ConfigureAwait(false); + var recentSummary = priorRecords.Count == 0 + ? "(该策略尚无成交记录)" + : string.Join("\n", priorRecords.Take(5).Select(r => + $"{r.CreatedAt:u} {r.Side} 成交量:{r.ExecutedQty} 价:{r.ExecutedPrice} {r.Status}")); + + var positionSummary = await BuildPositionSummaryAsync(strategy.Symbol); + var analysisContext = BuildAnalysisContext(strategy.Symbol); + + var stopLossInfo = strategy.StopLossPrice.HasValue + ? $"止损价: {strategy.StopLossPrice.Value}" + : "未设置止损"; + var takeProfitInfo = strategy.TakeProfitPrice.HasValue + ? $"止盈价: {strategy.TakeProfitPrice.Value}" + : "未设置止盈"; + var maxPositionPercent = strategy.MaxPositionPercent ?? 20m; + var todayStats = await _dataService.GetTodayStatsAsync(MonitorToken); + var maxDailyTrades = (await _dataService.LoadRiskConfigAsync()).MaxDailyTrades; + var remainingTrades = Math.Max(0, maxDailyTrades - todayStats.TradeCount); + + return $""" + 分析交易标的 {strategy.Symbol},当前价格 {currentPrice}。 + + ## 风险预算(必须严格遵守) + - 本次交易后该 symbol 总仓位不得超过账户总值的 {maxPositionPercent:F1}% + - 今日已实现盈亏: {todayStats.TotalPnl:F2} USDT + - 今日剩余交易次数: {remainingTrades} + + ## 策略配置 + {strategy.CustomParams ?? "无"} + 风险边界: {stopLossInfo} | {takeProfitInfo} + + ## 当前仓位状态 + {positionSummary} + + ## 最新市场分析报告 + {analysisContext} + + 近期该策略成交摘要(最多 5 笔,按时间倒序): + {recentSummary} + + ## 决策要求 + 请输出结构化决策: + 1. 决策: BUY / SELL / HOLD + 2. 置信度: 0-100 + 3. 入场逻辑 + 4. 退出计划(止损/止盈具体价位) + 5. 主要风险因素 + + 如果置信度低于 60,建议 HOLD。 + 如果决定交易,请调用 PlaceOrder 工具执行 {strategy.Side} 操作,数量 {strategy.Quantity}。 + 如果决定不交易,请说明理由。 + """; + } + + /// + /// 调用 TradingAgent 执行决策 + /// + private async Task InvokeAgentAsync(string prompt) + { + var agent = _agentFactory.CreateAgent(); + var messages = new List + { + new(ChatRole.User, prompt) + }; + + var response = await agent.RunAsync(messages, session: null, options: null, + cancellationToken: MonitorToken); + _logger.LogDebug("TradingAgent 响应: {Content}", response.Text); + } + + /// + /// 处理 Agent 响应结果:检测是否产生新成交,若产生则更新触发计数 + /// + private async Task ProcessAgentResponseAsync(TradingStrategy strategy) + { + // 只在 Agent 实际执行了交易后才更新触发计数 + var recentRecords = await _dataService.GetRecordsByStrategyAsync(strategy.Id); + var hasNewTrade = recentRecords.Any(r => + r.CreatedAt > (strategy.LastTriggeredAt ?? DateTime.MinValue)); + + if (hasNewTrade) + { + await UpdateTriggerCountAsync(strategy); + } + } + + /// + /// 更新策略触发计数并检查是否达到最大执行次数 + /// + private async Task UpdateTriggerCountAsync(TradingStrategy strategy) + { + await _dataService.UpdateStrategyTriggeredAsync(strategy.Id); + await CheckStrategyCompletionAsync(strategy); + } + + private async Task BuildPositionSummaryAsync(string symbol) + { + try + { + var positions = await _portfolioService.GetCurrentPositionsAsync(MonitorToken); + var symbolPosition = positions.FirstOrDefault(p => + p.Symbol.Equals(symbol, StringComparison.OrdinalIgnoreCase)); + var positionLine = symbolPosition != null + ? $"持仓: 数量 {symbolPosition.Quantity} | 入场均价 {symbolPosition.EntryPrice} | 未实现盈亏 {symbolPosition.UnrealizedPnl:F2} USDT ({symbolPosition.UnrealizedPnlPercent:F1}%)" + : "当前无持仓"; + + var activeStrategies = await _dataService.GetStrategiesByStatusAsync(StrategyStatus.Active, MonitorToken); + var siblings = activeStrategies + .Where(s => s.Symbol.Equals(symbol, StringComparison.OrdinalIgnoreCase)) + .Select(s => $"{s.Type}(触发价:{s.TriggerPrice})") + .ToList(); + var siblingsLine = siblings.Count > 0 ? string.Join(", ", siblings) : "无"; + + return $"{positionLine}\n同标的活跃策略: {siblingsLine}"; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "获取持仓信息失败,略过仓位上下文: {Symbol}", symbol); + return "(获取持仓信息失败)"; + } + } + + private string BuildAnalysisContext(string symbol) + { + var cached = _reportCache.Get(symbol); + if (cached == null) + return "(暂无分析报告,建议先运行市场分析工作流)"; + + var ageMinutes = (int)(DateTime.UtcNow - cached.CachedAt).TotalMinutes; + var result = cached.Report.CoordinatorResult; + + var sb = new StringBuilder(); + sb.AppendLine($"报告时间: {ageMinutes} 分钟前"); + sb.AppendLine($"综合评级: {result.InvestmentRating} | 综合评分: {result.OverallScore:F1}/10 | 置信度: {result.ConfidencePercentage:F0}%"); + sb.AppendLine($"目标价区间: {result.TargetPrice} | 预期: {result.PriceChangeExpectation}"); + sb.AppendLine($"技术面: {result.DimensionScores.Technical:F1} | 情绪面: {result.DimensionScores.Sentiment:F1} | 风险等级: {result.RiskLevel}"); + sb.AppendLine($"结论: {result.Summary}"); + + if (result.OperationSuggestions.Count > 0) + { + sb.AppendLine("操作建议:"); + foreach (var suggestion in result.OperationSuggestions.Take(3)) + sb.AppendLine($" - {suggestion}"); + } + + if (result.RiskFactors.Count > 0) + sb.AppendLine($"主要风险: {string.Join("; ", result.RiskFactors.Take(2))}"); + + return sb.ToString().TrimEnd(); + } + + private async Task CheckStrategyCompletionAsync(TradingStrategy strategy) + { + if (!strategy.MaxExecutions.HasValue) + return; + + var updated = await _dataService.GetStrategyAsync(strategy.Id); + if (updated != null && updated.ExecutionCount >= updated.MaxExecutions!.Value) + { + await _dataService.UpdateStrategyStatusAsync(strategy.Id, StrategyStatus.Completed); + await _strategyEngine.ClearPeakPriceAsync(strategy.Id); + _strategyLocks.TryRemove(strategy.Id, out _); + } + } + + public void Dispose() + { + _cts?.Cancel(); + _cts?.Dispose(); + _priceChannel.Writer.TryComplete(); + foreach (var kvp in _strategyLocks) + kvp.Value.Dispose(); + _strategyLocks.Clear(); + _lifecycleLock.Dispose(); + + TradeExecuted = null; + StatusChanged = null; + _webSocketService.PriceUpdated -= _priceUpdatedAdapter; + + GC.SuppressFinalize(this); + } +} diff --git a/src/MarketAssistant.App.Services/Trading/RiskManager.cs b/src/MarketAssistant.App.Services/Trading/RiskManager.cs new file mode 100644 index 0000000..71f4441 --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/RiskManager.cs @@ -0,0 +1,184 @@ +using MarketAssistant.Services.Data; +using MarketAssistant.Trading.Models; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Trading; + +/// +/// 风控网关,所有交易指令必须经过风控检查 +/// +public class RiskManager +{ + private static readonly string[] QuoteAssets = { "USDT", "USDC", "BUSD", "BTC", "ETH", "BNB" }; + + private readonly TradingDataService _dataService; + private readonly CryptoPortfolioService _portfolioService; + private readonly ILogger _logger; + + public RiskManager( + TradingDataService dataService, + CryptoPortfolioService portfolioService, + ILogger logger) + { + _dataService = dataService; + _portfolioService = portfolioService; + _logger = logger; + } + + /// + /// 校验交易是否通过风控检查 + /// + public async Task ValidateOrderAsync( + string instrumentSymbol, OrderSide side, decimal quantity, decimal price, + OrderType orderType = OrderType.Market, + CancellationToken ct = default) + { + const decimal MarketOrderSlippageBuffer = 0.015m; // 市价单预留 1.5% 滑点缓冲 + + var config = await _dataService.LoadRiskConfigAsync(ct).ConfigureAwait(false); + + // 市价单使用保守的滑点缓冲价格进行风控计算,防止实际成交额超限 + var effectivePrice = orderType == OrderType.Market + ? price * (1 + MarketOrderSlippageBuffer) + : price; + + var orderValueUSDT = quantity * effectivePrice; + + if (orderValueUSDT < config.MinOrderAmount) + return RiskCheckResult.Reject($"订单金额 {orderValueUSDT:F2} USDT 低于最小限额 {config.MinOrderAmount} USDT"); + + var todayStats = await _dataService.GetTodayStatsAsync(ct).ConfigureAwait(false); + + if (todayStats.TradeCount >= config.MaxDailyTrades) + return RiskCheckResult.Reject($"今日交易次数 {todayStats.TradeCount} 已达上限 {config.MaxDailyTrades}"); + + AccountBalanceSummary portfolioSummary; + try + { + portfolioSummary = await _portfolioService.GetAccountBalanceSummaryAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "获取账户信息用于风控校验失败,拒绝交易(fail-closed)"); + return RiskCheckResult.Reject("无法获取账户信息,风控拒绝交易"); + } + + var totalUSDT = portfolioSummary.TotalValueUSDT; + + if (totalUSDT > 0) + { + var orderPercent = orderValueUSDT / totalUSDT * 100; + if (orderPercent > config.MaxSingleOrderPercent) + return RiskCheckResult.Reject( + $"单笔金额占比 {orderPercent:F1}% 超过限额 {config.MaxSingleOrderPercent}%"); + + if (config.MaxTotalPositionPercent > 0) + { + var usdtBalance = CryptoPortfolioService.GetUsdtBalance(portfolioSummary); + var nonUSDTValue = totalUSDT - usdtBalance; + var currentPositionPercent = nonUSDTValue / totalUSDT * 100; + var projectedPercent = side == OrderSide.Buy + ? currentPositionPercent + orderPercent + : Math.Max(0, currentPositionPercent - orderPercent); + if (projectedPercent > config.MaxTotalPositionPercent) + return RiskCheckResult.Reject( + $"总仓位占比将达 {projectedPercent:F1}%,超过限额 {config.MaxTotalPositionPercent}%"); + } + + // 单 symbol 仓位上限(仅买入时检查) + if (config.MaxSinglePositionPercent > 0 && side == OrderSide.Buy) + { + var baseAsset = ExtractBaseAsset(instrumentSymbol); + if (!string.IsNullOrEmpty(baseAsset)) + { + var symbolValue = portfolioSummary.Assets + .Where(a => a.Asset.Equals(baseAsset, StringComparison.OrdinalIgnoreCase)) + .Sum(a => a.ValueUSDT); + var symbolPercent = (symbolValue + orderValueUSDT) / totalUSDT * 100; + if (symbolPercent > config.MaxSinglePositionPercent) + return RiskCheckResult.Reject( + $"单标的 {baseAsset} 仓位将达 {symbolPercent:F1}%,超过限额 {config.MaxSinglePositionPercent}%"); + } + } + + // 卖出订单校验持仓充足性:本地 FIFO 持仓追踪不允许超卖, + // 否则会产生负持仓并导致 PnL 计算错误。 + if (side == OrderSide.Sell) + { + var baseAsset = ExtractBaseAsset(instrumentSymbol); + if (!string.IsNullOrEmpty(baseAsset)) + { + var positions = await _dataService.GetOpenPositionsAsync(instrumentSymbol, ct).ConfigureAwait(false); + var availableQty = positions + .Where(p => p.Symbol.Equals(instrumentSymbol, StringComparison.OrdinalIgnoreCase)) + .Sum(p => p.Quantity); + if (quantity > availableQty) + return RiskCheckResult.Reject( + $"卖出数量 {quantity} 超过可用持仓 {availableQty}(含部分成交未同步的偏差)"); + } + } + + // 最大回撤熔断 + if (config.MaxDrawdownPercent > 0) + { + var peakValue = await _dataService.GetPeakAccountValueAsync(ct).ConfigureAwait(false); + if (peakValue > 0) + { + var drawdownPercent = (peakValue - totalUSDT) / peakValue * 100; + if (drawdownPercent >= config.MaxDrawdownPercent) + return RiskCheckResult.Reject( + $"账户回撤 {drawdownPercent:F1}% 已达熔断阈值 {config.MaxDrawdownPercent}%,停止交易"); + } + } + + // 日亏损熔断:仅当账户总值达到有意义的最小阈值时才计算百分比, + // 避免极小余额(如 0.01 USDT)导致百分比爆炸误触发熔断 + const decimal MinMeaningfulTotalUsdt = 10m; + if (totalUSDT >= MinMeaningfulTotalUsdt) + { + var dailyLossPercent = Math.Abs(todayStats.TotalPnl) / totalUSDT * 100; + if (todayStats.TotalPnl < 0 && dailyLossPercent >= config.MaxDailyLossPercent) + return RiskCheckResult.Reject( + $"今日亏损 {dailyLossPercent:F1}% 已达上限 {config.MaxDailyLossPercent}%"); + } + } + + if (config.RequireConfirmation && orderValueUSDT >= config.ConfirmationThreshold) + return RiskCheckResult.RequireConfirmation( + $"订单金额 {orderValueUSDT:F2} USDT 超过确认阈值 {config.ConfirmationThreshold} USDT,需人工确认"); + + // 风控通过后顺带刷新账户快照,供最大回撤熔断使用。 + // 每笔交易必经风控,无需单独的定时器即可保证快照新鲜。 + if (totalUSDT > 0) + { + try + { + await _dataService.SaveAccountSnapshotAsync(totalUSDT, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + // 快照写入失败不应阻断交易本身,仅记录 + _logger.LogWarning(ex, "账户快照写入失败,回撤熔断可能滞后"); + } + } + + _logger.LogInformation("风控检查通过: {InstrumentSymbol} {Side} 数量:{Qty} 价格:{Price}", + instrumentSymbol, side, quantity, price); + return RiskCheckResult.Pass(); + } + + /// + /// 从交易对符号提取基础资产(如 BTCUSDT → BTC) + /// + private static string ExtractBaseAsset(string instrumentSymbol) + { + // 常见报价资产后缀 + foreach (var quote in QuoteAssets) + { + if (instrumentSymbol.EndsWith(quote, StringComparison.OrdinalIgnoreCase)) + return instrumentSymbol[..^quote.Length]; + } + return string.Empty; + } + +} diff --git a/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs b/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs new file mode 100644 index 0000000..306038a --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs @@ -0,0 +1,354 @@ +using System.Text.Json; +using MarketAssistant.Trading.Models; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Trading; + +/// +/// 策略引擎,管理用户策略并评估触发条件 +/// +public class StrategyEngine +{ + private readonly TradingDataService _dataService; + private readonly ILogger _logger; + + public StrategyEngine(TradingDataService dataService, ILogger logger) + { + _dataService = dataService; + _logger = logger; + } + + /// + /// 清理指定策略的追踪止损峰值(策略完成或删除时调用) + /// + public async Task ClearPeakPriceAsync(string strategyId, CancellationToken ct = default) + => await _dataService.UpdateStrategyTrailingPeakAsync(strategyId, null, ct); + + /// + /// 评估指定交易标的的所有活跃策略,返回触发的策略列表。 + /// 注意:此方法会修改返回列表中策略对象的 和 + /// 字段(用于反映触发时的有效方向和数量, + /// 如网格交易、DCA 等动态计算值),调用方依赖这些副作用将策略传递给交易执行器。 + /// + public async Task> EvaluateAndUpdateStrategiesAsync( + string symbol, decimal currentPrice, CancellationToken ct = default) + { + var activeStrategies = await _dataService.GetStrategiesByStatusAsync(StrategyStatus.Active, ct); + var triggered = new List(); + + foreach (var strategy in activeStrategies) + { + if (!strategy.Symbol.Equals(symbol, StringComparison.OrdinalIgnoreCase)) + continue; + + if (strategy.MaxExecutions.HasValue && strategy.ExecutionCount >= strategy.MaxExecutions.Value) + { + await _dataService.UpdateStrategyStatusAsync(strategy.Id, StrategyStatus.Completed, ct); + continue; + } + + var (triggeredFlag, effectiveSide, effectiveQty) = await IsTriggeredAsync(strategy, currentPrice, ct); + if (triggeredFlag) + { + strategy.Side = effectiveSide; + strategy.Quantity = effectiveQty; + _logger.LogInformation( + "策略触发: {StrategyId} {Type} {Symbol} 触发价:{TriggerPrice} 当前价:{CurrentPrice}", + strategy.Id, strategy.Type, symbol, strategy.TriggerPrice, currentPrice); + + triggered.Add(strategy); + } + } + + return triggered; + } + + private async Task<(bool Triggered, OrderSide Side, decimal Qty)> IsTriggeredAsync( + TradingStrategy strategy, decimal currentPrice, CancellationToken ct) + { + return strategy.Type switch + { + StrategyType.StopLoss => (EvaluateStopLoss(strategy, currentPrice), strategy.Side, strategy.Quantity), + StrategyType.TakeProfit => (EvaluateTakeProfit(strategy, currentPrice), strategy.Side, strategy.Quantity), + StrategyType.TrailingStop => (await EvaluateAndUpdateTrailingStopAsync(strategy, currentPrice, ct), strategy.Side, strategy.Quantity), + StrategyType.AISignal => (EvaluateAISignal(strategy), strategy.Side, strategy.Quantity), + StrategyType.GridTrading => EvaluateAndUpdateGridTrading(strategy, currentPrice, out var gs, out var gq) ? (true, gs, gq) : (false, strategy.Side, strategy.Quantity), + StrategyType.DCA => await EvaluateDCAAsync(strategy, currentPrice, ct), + _ => (false, strategy.Side, strategy.Quantity) + }; + } + + private static bool EvaluateStopLoss(TradingStrategy strategy, decimal currentPrice) + { + // Side 表示触发时要执行的操作方向 + // Sell 侧止损:持有多头仓位,价格跌破触发价时卖出止损 + if (strategy.Side == OrderSide.Sell) + return currentPrice <= strategy.TriggerPrice; + // Buy 侧止损:持有空头仓位,价格涨破触发价时买入止损 + return currentPrice >= strategy.TriggerPrice; + } + + private static bool EvaluateTakeProfit(TradingStrategy strategy, decimal currentPrice) + { + // Sell 侧止盈:持有多头仓位,价格涨至触发价时卖出止盈 + if (strategy.Side == OrderSide.Sell) + return currentPrice >= strategy.TriggerPrice; + // Buy 侧止盈:等待买入机会,价格跌至触发价时买入 + return currentPrice <= strategy.TriggerPrice; + } + + /// + /// 评估追踪止损触发条件。 + /// 注意:此方法会修改入参 + /// 字段以持久化追踪峰值/谷值状态,并同步写入数据存储,调用方依赖此副作用保持内存与持久化状态一致。 + /// + private async Task EvaluateAndUpdateTrailingStopAsync(TradingStrategy strategy, decimal currentPrice, CancellationToken ct) + { + if (string.IsNullOrEmpty(strategy.CustomParams)) + return false; + + try + { + using var doc = JsonDocument.Parse(strategy.CustomParams); + var root = doc.RootElement; + + if (!root.TryGetProperty("activationPrice", out var activationPriceEl)) + return false; + var activationPrice = activationPriceEl.GetDecimal(); + + if (!root.TryGetProperty("trailingPercent", out var trailingPercentEl)) + return false; + var trailingPercent = trailingPercentEl.GetDecimal(); + + if (strategy.Side == OrderSide.Sell) + { + // 未激活且价格未达到激活价:不触发 + if (!strategy.TrailingPeakPrice.HasValue && currentPrice < activationPrice) + return false; + + // 追踪最高价(从持久化字段恢复),从峰值回撤 trailingPercent% 时触发卖出 + var peak = Math.Max(strategy.TrailingPeakPrice ?? currentPrice, currentPrice); + var trailPrice = peak * (1 - trailingPercent / 100); + + // 持久化更新峰值 + if (!strategy.TrailingPeakPrice.HasValue || peak > strategy.TrailingPeakPrice.Value) + { + strategy.TrailingPeakPrice = peak; + await _dataService.UpdateStrategyTrailingPeakAsync(strategy.Id, peak, ct); + } + + return currentPrice <= trailPrice; + } + else + { + if (!strategy.TrailingPeakPrice.HasValue && currentPrice > activationPrice) + return false; + + // 追踪最低价(从持久化字段恢复),从谷值反弹 trailingPercent% 时触发买入 + var trough = Math.Min(strategy.TrailingPeakPrice ?? currentPrice, currentPrice); + var trailPrice = trough * (1 + trailingPercent / 100); + + if (!strategy.TrailingPeakPrice.HasValue || trough < strategy.TrailingPeakPrice.Value) + { + strategy.TrailingPeakPrice = trough; + await _dataService.UpdateStrategyTrailingPeakAsync(strategy.Id, trough, ct); + } + + return currentPrice >= trailPrice; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "解析 TrailingStop 参数失败: {StrategyId}", strategy.Id); + return false; + } + } + + // 未配置时的安全默认值,防止每个价格 tick 都触发 AI 调用 + private const int DefaultAISignalIntervalSeconds = 60; + + private bool EvaluateAISignal(TradingStrategy strategy) + { + var intervalSeconds = DefaultAISignalIntervalSeconds; + + if (!string.IsNullOrEmpty(strategy.CustomParams)) + { + try + { + using var doc = JsonDocument.Parse(strategy.CustomParams); + var root = doc.RootElement; + if (root.TryGetProperty("analysisInterval", out var intervalEl)) + intervalSeconds = intervalEl.GetInt32(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "解析 AISignal 参数失败,使用默认间隔 {DefaultInterval}s: {StrategyId}", + DefaultAISignalIntervalSeconds, strategy.Id); + } + } + + if (strategy.LastTriggeredAt.HasValue) + { + var elapsed = (DateTime.UtcNow - strategy.LastTriggeredAt.Value).TotalSeconds; + return elapsed >= intervalSeconds; + } + + return true; + } + + /// + /// 网格交易评估:价格穿越网格线时触发交易。 + /// 网格在 LowerPrice 和 UpperPrice 之间均匀分布。 + /// 价格下穿网格线时买入,上穿时卖出。 + /// 注意:此方法会修改入参 + /// 字段以更新网格的 LastTriggeredIndex 状态,调用方依赖此副作用在交易成功后原子持久化更新后的参数。 + /// + private bool EvaluateAndUpdateGridTrading(TradingStrategy strategy, decimal currentPrice, + out OrderSide effectiveSide, out decimal effectiveQty) + { + effectiveSide = strategy.Side; + effectiveQty = strategy.Quantity; + + if (string.IsNullOrEmpty(strategy.CustomParams)) + return false; + + try + { + var gridParams = JsonSerializer.Deserialize(strategy.CustomParams); + if (gridParams == null || gridParams.GridCount <= 1 || gridParams.UpperPrice <= gridParams.LowerPrice) + return false; + + if (currentPrice < gridParams.LowerPrice) + { + // 价格跌破网格下界:检查破网止损 + if (gridParams.StopLossPrice.HasValue && currentPrice <= gridParams.StopLossPrice.Value) + { + effectiveSide = OrderSide.Sell; + effectiveQty = gridParams.QuantityPerGrid * gridParams.GridCount; + _logger.LogWarning( + "网格破网止损触发: {StrategyId} 价格 {Price} <= 止损位 {StopLoss},清仓 {Qty}", + strategy.Id, currentPrice, gridParams.StopLossPrice, effectiveQty); + return true; + } + return false; + } + if (currentPrice > gridParams.UpperPrice) + { + // 价格涨破网格上界:检查破网止盈 + if (gridParams.TakeProfitPrice.HasValue && currentPrice >= gridParams.TakeProfitPrice.Value) + { + effectiveSide = OrderSide.Buy; + effectiveQty = gridParams.QuantityPerGrid * gridParams.GridCount; + _logger.LogWarning( + "网格破网止盈触发: {StrategyId} 价格 {Price} >= 止盈位 {TakeProfit},清仓 {Qty}", + strategy.Id, currentPrice, gridParams.TakeProfitPrice, effectiveQty); + return true; + } + return false; + } + + var spacing = gridParams.GridSpacing; + var currentIndex = (int)((currentPrice - gridParams.LowerPrice) / spacing); + currentIndex = Math.Clamp(currentIndex, 0, gridParams.GridCount); + + if (gridParams.LastTriggeredIndex < 0) + { + gridParams.LastTriggeredIndex = currentIndex; + strategy.CustomParams = JsonSerializer.Serialize(gridParams); + return false; + } + + if (currentIndex == gridParams.LastTriggeredIndex) + return false; + + effectiveSide = currentIndex < gridParams.LastTriggeredIndex ? OrderSide.Buy : OrderSide.Sell; + effectiveQty = gridParams.QuantityPerGrid; + + gridParams.LastTriggeredIndex = currentIndex; + strategy.CustomParams = JsonSerializer.Serialize(gridParams); + + _logger.LogInformation( + "网格交易触发: {StrategyId} 网格 {Index} → {Side},价格: {Price}", + strategy.Id, currentIndex, effectiveSide, currentPrice); + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "解析 GridTrading 参数失败: {StrategyId}", strategy.Id); + return false; + } + } + + /// + /// DCA(定投)评估:按时间间隔定期买入。 + /// 支持价格上限过滤和低价加倍买入。 + /// + private async Task<(bool Triggered, OrderSide Side, decimal Qty)> EvaluateDCAAsync( + TradingStrategy strategy, decimal currentPrice, CancellationToken ct) + { + if (string.IsNullOrEmpty(strategy.CustomParams)) + return (false, strategy.Side, strategy.Quantity); + + try + { + var dcaParams = JsonSerializer.Deserialize(strategy.CustomParams); + if (dcaParams == null || dcaParams.AmountPerInterval <= 0) + return (false, strategy.Side, strategy.Quantity); + + if (strategy.LastTriggeredAt.HasValue) + { + var elapsed = (DateTime.UtcNow - strategy.LastTriggeredAt.Value).TotalSeconds; + if (elapsed < dcaParams.IntervalSeconds) + return (false, strategy.Side, strategy.Quantity); + } + + if (dcaParams.MaxBuyPrice > 0 && currentPrice > dcaParams.MaxBuyPrice) + { + _logger.LogDebug("DCA 跳过: {StrategyId} 当前价 {Price} 超过上限 {MaxPrice}", + strategy.Id, currentPrice, dcaParams.MaxBuyPrice); + return (false, strategy.Side, strategy.Quantity); + } + + var amount = dcaParams.AmountPerInterval; + if (dcaParams.DoubleBuyBelowPrice > 0 && currentPrice < dcaParams.DoubleBuyBelowPrice) + { + // 检查加倍次数上限 + var maxCount = dcaParams.MaxDoubleBuyCount > 0 ? dcaParams.MaxDoubleBuyCount : 3; + if (dcaParams.DoubleBuyCount >= maxCount) + { + _logger.LogWarning("DCA 加倍次数已达上限 {Max}: {StrategyId},本次按常规金额买入", + maxCount, strategy.Id); + } + // 检查加倍冷却期 + else if (dcaParams.LastDoubleBuyAt != null + && DateTime.TryParse(dcaParams.LastDoubleBuyAt, out var lastDouble) + && (DateTime.UtcNow - lastDouble).TotalSeconds < dcaParams.DoubleBuyCooldownSeconds) + { + _logger.LogDebug("DCA 加倍冷却中: {StrategyId} 距上次加倍 {Elapsed:F0}s < 冷却 {Cooldown}s", + strategy.Id, (DateTime.UtcNow - lastDouble).TotalSeconds, dcaParams.DoubleBuyCooldownSeconds); + } + else + { + amount *= 2; + dcaParams.DoubleBuyCount++; + dcaParams.LastDoubleBuyAt = DateTime.UtcNow.ToString("O"); + strategy.CustomParams = JsonSerializer.Serialize(dcaParams); + // 立即持久化加倍计数,防止重启后上限失效 + await _dataService.UpdateStrategyCustomParamsAsync(strategy.Id, strategy.CustomParams, ct); + _logger.LogInformation("DCA 低价加倍: {StrategyId} 价格 {Price} < {Threshold},加倍买入(第 {Count} 次)", + strategy.Id, currentPrice, dcaParams.DoubleBuyBelowPrice, dcaParams.DoubleBuyCount); + } + } + + var effectiveSide = OrderSide.Buy; + var effectiveQty = currentPrice > 0 ? amount / currentPrice : 0; + + return (effectiveQty > 0, effectiveSide, effectiveQty); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "解析 DCA 参数失败: {StrategyId}", strategy.Id); + return (false, strategy.Side, strategy.Quantity); + } + } +} diff --git a/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs b/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs new file mode 100644 index 0000000..6da84c9 --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs @@ -0,0 +1,258 @@ +using System.Collections.Concurrent; +using MarketAssistant.Trading.Abstractions; +using MarketAssistant.Trading.Models; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Trading; + +/// +/// 交易执行器,统一的下单入口:风控 → 确认 → 下单 → 记录 → PnL 计算。 +/// 同一交易对在任意时刻仅允许一条下单路径进入交易所调用,避免并发重复下单。 +/// +public class TradeExecutor : IDisposable +{ + private readonly ConcurrentDictionary _symbolExecutionLocks = + new(StringComparer.OrdinalIgnoreCase); + + private readonly IExchangeClient _exchangeClient; + private readonly RiskManager _riskManager; + private readonly TradingDataService _dataService; + private readonly ILogger _logger; + + /// + /// Human-in-the-Loop 确认事件。 + /// 当风控返回 NeedsConfirmation 时触发,等待订阅者返回 true(放行)或 false(拒绝)。 + /// 使用事件模式而非单一回调属性,避免单例被多个 ViewModel 订阅时相互覆盖。 + /// 未订阅时保持现有行为(直接拒绝)。 + /// + public event Func>? ConfirmationRequested; + + public TradeExecutor( + [FromKeyedServices(MarketType.Crypto)] IExchangeClient exchangeClient, + RiskManager riskManager, + TradingDataService dataService, + ILogger logger) + { + _exchangeClient = exchangeClient; + _riskManager = riskManager; + _dataService = dataService; + _logger = logger; + } + + /// + /// 执行策略触发的交易(委托给通用下单方法) + /// + public async Task ExecuteTradeAsync( + TradingStrategy strategy, decimal currentPrice, string? aiReasoning = null, + string? pendingCustomParams = null, + CancellationToken ct = default) + { + _logger.LogInformation("开始执行交易: {StrategyId} {Symbol} {Side} 数量:{Qty}", + strategy.Id, strategy.Symbol, strategy.Side, strategy.Quantity); + + // 限价单基于当前价计算滑点保护价 + decimal? limitPrice = null; + var orderType = strategy.OrderType; + if (orderType == OrderType.Limit) + { + var slippage = strategy.SlippageTolerance > 0 ? strategy.SlippageTolerance : 0.003m; + limitPrice = strategy.Side == OrderSide.Buy + ? currentPrice * (1 + slippage) + : currentPrice * (1 - slippage); + } + + var result = await ExecuteOrderAsync( + strategy.Symbol, strategy.Side, orderType, strategy.Quantity, + currentPrice, limitPrice: limitPrice, strategyId: strategy.Id, + aiReasoning: aiReasoning, ct: ct); + + if (result.Success) + { + if (pendingCustomParams != null) + await _dataService.UpdateStrategyTriggeredWithParamsAsync(strategy.Id, pendingCustomParams, ct); + else + await _dataService.UpdateStrategyTriggeredAsync(strategy.Id, ct); + } + + return result; + } + + /// + /// 通用下单方法,所有交易路径(策略触发、AI Agent、手动)的统一入口。 + /// 风控检查和人工确认在 symbol 锁之外执行,避免等待用户输入时锁死后续交易。 + /// + public async Task ExecuteOrderAsync( + string instrumentSymbol, OrderSide side, OrderType type, decimal quantity, + decimal currentPrice, decimal? limitPrice = null, + string strategyId = "manual", string? aiReasoning = null, + CancellationToken ct = default) + { + // 风控校验和人工确认在 symbol 锁外完成,防止 ConfirmationCallback 等待期间 + // 持有 SemaphoreSlim,导致同一标的后续所有交易永久阻塞。 + var riskCheck = await _riskManager.ValidateOrderAsync(instrumentSymbol, side, quantity, currentPrice, type, ct); + + if (riskCheck.NeedsConfirmation) + { + _logger.LogWarning("交易需人工确认: {InstrumentSymbol} {Side} 金额:{Amount}", + instrumentSymbol, side, quantity * currentPrice); + + if (ConfirmationRequested != null) + { + var approved = await ConfirmationRequested.Invoke( + instrumentSymbol, side, currentPrice, quantity, riskCheck.Reason ?? "需人工确认"); + if (!approved) + return new TradeResult { Success = false, ErrorMessage = $"用户拒绝交易: {riskCheck.Reason}" }; + + _logger.LogInformation("用户已确认交易: {InstrumentSymbol} {Side}", instrumentSymbol, side); + } + else + { + return new TradeResult { Success = false, ErrorMessage = $"需人工确认: {riskCheck.Reason}" }; + } + } + else if (!riskCheck.Passed) + { + _logger.LogWarning("风控拒绝: {Reason}", riskCheck.Reason); + return new TradeResult { Success = false, ErrorMessage = $"风控拒绝: {riskCheck.Reason}" }; + } + + // 仅在实际调用交易所 API 时持有 symbol 锁,防止同一标的并发重复下单 + var gate = _symbolExecutionLocks.GetOrAdd( + instrumentSymbol.Trim(), static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(ct).ConfigureAwait(false); + try + { + return await ExecuteApprovedOrderAsync( + instrumentSymbol, side, type, quantity, currentPrice, limitPrice, + strategyId, aiReasoning, ct).ConfigureAwait(false); + } + finally + { + gate.Release(); + } + } + + private async Task ExecuteApprovedOrderAsync( + string instrumentSymbol, OrderSide side, OrderType type, decimal quantity, + decimal currentPrice, decimal? limitPrice, + string strategyId, string? aiReasoning, + CancellationToken ct) + { + try + { + // 生成幂等订单 ID:同一笔交易的所有重试使用相同 ID, + // 币安收到重复的 newClientOrderId 时返回已有订单而非新建,避免重复下单。 + // 币安限制 newClientOrderId 最长 36 字符,使用 Base36 编码压缩 GUID。 + var clientOrderId = "MA" + Convert.ToHexString(Guid.NewGuid().ToByteArray())[..16].ToLowerInvariant(); + + // 网络异常重试:最多 3 次,指数退避 1s/2s/4s。 + // 业务错误(如余额不足、风控拒绝)不重试,直接抛出。 + ExchangeOrderResult? response = null; + Exception? lastNetworkException = null; + const int maxRetries = 3; + for (int attempt = 1; attempt <= maxRetries; attempt++) + { + try + { + response = await _exchangeClient.PlaceOrderAsync( + instrumentSymbol, side, type, quantity, + type == OrderType.Limit ? limitPrice : null, + clientOrderId, ct); + break; + } + catch (HttpRequestException ex) + { + lastNetworkException = ex; + if (attempt >= maxRetries) + break; + + var delayMs = (int)Math.Pow(2, attempt - 1) * 1000; + _logger.LogWarning(ex, + "下单网络异常,{Attempt}/{Max} 次重试,{Delay}ms 后重试(幂等ID={ClientOrderId}): {Symbol} {Side}", + attempt, maxRetries, delayMs, clientOrderId, instrumentSymbol, side); + await Task.Delay(delayMs, ct); + } + } + + if (response == null) + throw new InvalidOperationException( + $"交易所下单响应为空(已重试 {maxRetries} 次)", lastNetworkException); + + var record = new TradeRecord + { + StrategyId = strategyId, + Symbol = instrumentSymbol, + Side = side, + OrderType = type, + RequestedQty = response.RequestedQty == 0 ? quantity : response.RequestedQty, + ExecutedQty = response.ExecutedQty, + RequestedPrice = limitPrice, + ExecutedPrice = response.Price == 0 ? currentPrice : response.Price, + Commission = response.FillCommission, + CommissionAsset = response.CommissionAsset ?? string.Empty, + Status = MapStatus(response.Status), + BinanceOrderId = long.TryParse(response.OrderId, out var orderId) ? orderId : 0, + AIReasoning = aiReasoning, + CompletedAt = response.Status == "FILLED" ? DateTime.UtcNow : null + }; + + await _dataService.SaveTradeRecordAsync(record, ct); + + // FIFO 持仓追踪:买入开仓,卖出按 FIFO 平仓计算已实现盈亏 + decimal pnl = 0; + if (record.ExecutedQty > 0) + { + if (side == OrderSide.Buy) + { + // 现货买入为开多仓,无已实现盈亏 + await _dataService.OpenPositionAsync(new Position + { + Symbol = instrumentSymbol, + Side = PositionSide.Long, + Quantity = record.ExecutedQty, + EntryPrice = record.ExecutedPrice, + StrategyId = strategyId, + OpenedAt = record.CreatedAt + }, ct); + } + else + { + // 卖出按 FIFO 匹配未平仓多头,计算已实现盈亏 + pnl = await _dataService.ClosePositionFifoAsync( + instrumentSymbol, record.ExecutedQty, record.ExecutedPrice, ct); + } + } + await _dataService.UpdateDailyStatsAsync(pnl, record.Commission, ct); + + _logger.LogInformation("交易执行成功: {StrategyId} 订单ID:{OrderId} 状态:{Status} PnL:{Pnl}", + strategyId, response.OrderId, response.Status, pnl); + + return new TradeResult { Success = true, Record = record }; + } + catch (Exception ex) + { + _logger.LogError(ex, "交易执行失败: {InstrumentSymbol} {Side}", instrumentSymbol, side); + return new TradeResult { Success = false, ErrorMessage = ex.Message }; + } + } + + private static TradeRecordStatus MapStatus(string status) => status switch + { + "FILLED" => TradeRecordStatus.Filled, + "PARTIALLY_FILLED" => TradeRecordStatus.PartiallyFilled, + "CANCELED" or "CANCELLED" => TradeRecordStatus.Cancelled, + "REJECTED" or "EXPIRED" => TradeRecordStatus.Failed, + _ => TradeRecordStatus.Pending + }; + + /// + /// 释放所有 symbol 执行锁资源,避免长期运行后内存泄漏。 + /// + public void Dispose() + { + foreach (var kvp in _symbolExecutionLocks) + kvp.Value.Dispose(); + _symbolExecutionLocks.Clear(); + GC.SuppressFinalize(this); + } +} diff --git a/src/MarketAssistant.App.Services/Trading/TradingDataService.cs b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs new file mode 100644 index 0000000..9b33420 --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs @@ -0,0 +1,813 @@ +using System.Globalization; +using System.Text.Json; +using MarketAssistant.Applications.Settings; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Trading.Models; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Trading; + +/// +/// 交易数据持久化服务,管理策略、交易记录和日统计的 SQLite 存储。 +/// +public class TradingDataService : SqliteServiceBase +{ + public TradingDataService(ILogger logger) + : base(logger) + { + } + + #region 策略 CRUD + + 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, symbol, type, status, side, 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, @symbol, @type, @status, @side, @triggerPrice, @slPrice, @tpPrice, + @qty, @maxPos, @customParams, @createdAt, @lastTriggered, + @execCount, @maxExec, @trailingPeak) + """; + cmd.Parameters.AddWithValue("@id", strategy.Id); + 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("@triggerPrice", (double)strategy.TriggerPrice); + cmd.Parameters.AddWithValue("@slPrice", strategy.StopLossPrice.HasValue ? (object)(double)strategy.StopLossPrice.Value : DBNull.Value); + cmd.Parameters.AddWithValue("@tpPrice", strategy.TakeProfitPrice.HasValue ? (object)(double)strategy.TakeProfitPrice.Value : DBNull.Value); + cmd.Parameters.AddWithValue("@qty", (double)strategy.Quantity); + cmd.Parameters.AddWithValue("@maxPos", strategy.MaxPositionPercent.HasValue ? (object)(double)strategy.MaxPositionPercent.Value : DBNull.Value); + 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", strategy.TrailingPeakPrice.HasValue ? (object)(double)strategy.TrailingPeakPrice.Value : DBNull.Value); + 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"; + cmd.Parameters.AddWithValue("@id", id); + 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 status = @status ORDER BY created_at DESC"; + 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 ORDER BY created_at DESC"; + 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"; + cmd.Parameters.AddWithValue("@id", id); + 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"; + cmd.Parameters.AddWithValue("@id", id); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + 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 + """; + cmd.Parameters.AddWithValue("@id", id); + 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(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 + """; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@time", DateTime.UtcNow.ToString("O")); + cmd.Parameters.AddWithValue("@customParams", (object?)customParams ?? DBNull.Value); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + await tx.CommitAsync(ct); + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + } + + public async Task UpdateStrategyCustomParamsAsync(string id, string? customParams, CancellationToken ct = default) + { + await 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"; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@customParams", (object?)customParams ?? DBNull.Value); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + /// + /// 更新追踪止损的峰值/谷值价格(持久化,防止重启丢失) + /// + public 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"; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@peak", trailingPeakPrice.HasValue ? (object)(double)trailingPeakPrice.Value : DBNull.Value); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + #endregion + + #region 交易记录 + + public 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, 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, @stratId, @symbol, @side, @orderType, @reqQty, @execQty, + @reqPrice, @execPrice, @commission, @commAsset, @status, + @binanceId, @aiReasoning, @createdAt, @completedAt) + """; + cmd.Parameters.AddWithValue("@id", record.Id); + 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", (double)record.RequestedQty); + cmd.Parameters.AddWithValue("@execQty", (double)record.ExecutedQty); + cmd.Parameters.AddWithValue("@reqPrice", record.RequestedPrice.HasValue ? (object)(double)record.RequestedPrice.Value : DBNull.Value); + cmd.Parameters.AddWithValue("@execPrice", (double)record.ExecutedPrice); + cmd.Parameters.AddWithValue("@commission", (double)record.Commission); + cmd.Parameters.AddWithValue("@commAsset", (object?)record.CommissionAsset ?? DBNull.Value); + cmd.Parameters.AddWithValue("@status", (int)record.Status); + cmd.Parameters.AddWithValue("@binanceId", record.BinanceOrderId); + 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(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + + var conditions = new List(); + 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 strategy_id = @stratId ORDER BY created_at DESC"; + 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; + } + + #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 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 date = @date"; + 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 = (decimal)reader.GetDouble(reader.GetOrdinal("total_pnl")), + TotalCommission = (decimal)reader.GetDouble(reader.GetOrdinal("total_commission")) + }; + } + + return new DailyStats { Date = today }; + } + + public async Task UpdateDailyStatsAsync(decimal pnl, decimal commission, 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 daily_stats (date, trade_count, total_pnl, total_commission) + VALUES (@date, 1, @pnl, @comm) + ON CONFLICT(date) DO UPDATE SET + trade_count = trade_count + 1, + total_pnl = total_pnl + @pnl, + total_commission = total_commission + @comm + """; + cmd.Parameters.AddWithValue("@date", today); + cmd.Parameters.AddWithValue("@pnl", (double)pnl); + cmd.Parameters.AddWithValue("@comm", (double)commission); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + /// + /// 计算指定交易标的的加权平均买入价(用于多头 PnL 估算) + /// + public async Task GetAverageEntryPriceAsync(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(executed_qty) > 0 + THEN SUM(executed_qty * executed_price) / SUM(executed_qty) + ELSE 0 END + FROM trade_records + WHERE symbol = @symbol AND side = @side AND executed_qty > 0 AND status = @status + """; + cmd.Parameters.AddWithValue("@symbol", symbol); + cmd.Parameters.AddWithValue("@side", (int)OrderSide.Buy); + cmd.Parameters.AddWithValue("@status", (int)TradeRecordStatus.Filled); + + var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); + if (result is double d) + return (decimal)d; + return 0; + } + + /// + /// 计算指定交易标的的加权平均卖出价(用于空头平仓 PnL 估算) + /// + public async Task GetAverageSellPriceAsync(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(executed_qty) > 0 + THEN SUM(executed_qty * executed_price) / SUM(executed_qty) + ELSE 0 END + FROM trade_records + WHERE symbol = @symbol AND side = @side AND executed_qty > 0 AND status = @status + """; + cmd.Parameters.AddWithValue("@symbol", symbol); + cmd.Parameters.AddWithValue("@side", (int)OrderSide.Sell); + cmd.Parameters.AddWithValue("@status", (int)TradeRecordStatus.Filled); + + var result = await cmd.ExecuteScalarAsync(ct); + if (result is double d) + return (decimal)d; + return 0; + } + + #endregion + + #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, symbol, side, quantity, entry_price, closed_quantity, strategy_id, opened_at) + VALUES (@id, @symbol, @side, @qty, @entry, 0, @stratId, @openedAt) + """; + cmd.Parameters.AddWithValue("@id", position.Id); + cmd.Parameters.AddWithValue("@symbol", position.Symbol); + cmd.Parameters.AddWithValue("@side", (int)position.Side); + cmd.Parameters.AddWithValue("@qty", (double)position.Quantity); + cmd.Parameters.AddWithValue("@entry", (double)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) + { + 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 symbol = @symbol AND side = @side AND (quantity - closed_quantity) > 0 + ORDER BY opened_at ASC + """; + cmd.Parameters.AddWithValue("@symbol", symbol); + cmd.Parameters.AddWithValue("@side", (int)PositionSide.Long); + + 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 = (decimal)reader.GetDouble(1); + var closed = (decimal)reader.GetDouble(2); + var entry = (decimal)reader.GetDouble(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 += (closePrice - entry) * closeThis; + + 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", (double)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(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 (quantity - closed_quantity) > 0 + ORDER BY opened_at ASC + """; + } + else + { + cmd.CommandText = """ + SELECT id, symbol, side, quantity, entry_price, closed_quantity, strategy_id, opened_at + FROM positions + WHERE symbol = @symbol AND (quantity - closed_quantity) > 0 + ORDER BY opened_at ASC + """; + 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(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 symbol = @symbol AND side = @side AND (quantity - closed_quantity) > 0 + """; + 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 = (decimal)reader.GetDouble(reader.GetOrdinal("quantity")), + EntryPrice = (decimal)reader.GetDouble(reader.GetOrdinal("entry_price")), + ClosedQuantity = (decimal)reader.GetDouble(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; + } + + #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 (date, total_value_usdt, snapshot_at) + VALUES (@date, @value, @snapshotAt) + ON CONFLICT(date) DO UPDATE SET + total_value_usdt = @value, + snapshot_at = @snapshotAt + """; + cmd.Parameters.AddWithValue("@date", today); + cmd.Parameters.AddWithValue("@value", (double)totalValueUsdt); + cmd.Parameters.AddWithValue("@snapshotAt", DateTime.UtcNow.ToString("O")); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + /// + /// 获取历史最高账户价值(用于计算回撤) + /// + public async Task GetPeakAccountValueAsync(CancellationToken ct = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT MAX(total_value_usdt) FROM account_snapshots"; + var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); + if (result is double d) + return (decimal)d; + return 0; + } + + public 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 market_type = @marketType"; + 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 (market_type, config_json, updated_at) + VALUES (@marketType, @configJson, @updatedAt) + ON CONFLICT(market_type) DO UPDATE SET config_json = @configJson, updated_at = @updatedAt + """; + 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 using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS strategies ( + id TEXT PRIMARY KEY, + symbol TEXT NOT NULL, + type INTEGER NOT NULL, + status INTEGER NOT NULL, + side INTEGER NOT NULL, + trigger_price REAL NOT NULL, + stop_loss_price REAL, + take_profit_price REAL, + quantity REAL NOT NULL, + max_position_percent REAL, + custom_params TEXT, + created_at TEXT NOT NULL, + last_triggered_at TEXT, + execution_count INTEGER DEFAULT 0, + max_executions INTEGER, + trailing_peak_price REAL + ); + CREATE INDEX IF NOT EXISTS idx_strategies_symbol ON strategies(symbol); + CREATE INDEX IF NOT EXISTS idx_strategies_status ON strategies(status); + + CREATE TABLE IF NOT EXISTS trade_records ( + id TEXT PRIMARY KEY, + strategy_id TEXT NOT NULL, + symbol TEXT NOT NULL, + side INTEGER NOT NULL, + order_type INTEGER NOT NULL, + requested_qty REAL NOT NULL, + executed_qty REAL NOT NULL, + requested_price REAL, + executed_price REAL NOT NULL, + commission REAL 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) + ); + 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 TABLE IF NOT EXISTS daily_stats ( + date TEXT PRIMARY KEY, + trade_count INTEGER DEFAULT 0, + total_pnl REAL DEFAULT 0, + total_commission REAL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS positions ( + id TEXT PRIMARY KEY, + symbol TEXT NOT NULL, + side INTEGER NOT NULL, + quantity REAL NOT NULL, + entry_price REAL NOT NULL, + closed_quantity REAL DEFAULT 0, + strategy_id TEXT, + opened_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_positions_symbol ON positions(symbol); + CREATE INDEX IF NOT EXISTS idx_positions_side ON positions(symbol, side); + + CREATE TABLE IF NOT EXISTS account_snapshots ( + date TEXT PRIMARY KEY, + total_value_usdt REAL NOT NULL, + snapshot_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS risk_config ( + market_type INTEGER PRIMARY KEY, + config_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + """; + await cmd.ExecuteNonQueryAsync(); + Logger.LogInformation("交易数据库初始化完成"); + } + catch (Exception ex) + { + Logger.LogError(ex, "初始化交易数据库失败"); + throw new InvalidOperationException("交易数据库初始化失败,应用无法继续运行", ex); + } + } + + 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")), + TriggerPrice = (decimal)reader.GetDouble(reader.GetOrdinal("trigger_price")), + Quantity = (decimal)reader.GetDouble(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 = (decimal)reader.GetDouble(slOrd); + + var tpOrd = reader.GetOrdinal("take_profit_price"); + if (!reader.IsDBNull(tpOrd)) strategy.TakeProfitPrice = (decimal)reader.GetDouble(tpOrd); + + var mpOrd = reader.GetOrdinal("max_position_percent"); + if (!reader.IsDBNull(mpOrd)) strategy.MaxPositionPercent = (decimal)reader.GetDouble(mpOrd); + + 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 = (decimal)reader.GetDouble(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 = (decimal)reader.GetDouble(reader.GetOrdinal("requested_qty")), + ExecutedQty = (decimal)reader.GetDouble(reader.GetOrdinal("executed_qty")), + ExecutedPrice = (decimal)reader.GetDouble(reader.GetOrdinal("executed_price")), + Commission = (decimal)reader.GetDouble(reader.GetOrdinal("commission")), + Status = (TradeRecordStatus)reader.GetInt32(reader.GetOrdinal("status")), + BinanceOrderId = 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 = (decimal)reader.GetDouble(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 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 +} diff --git a/src/MarketAssistant.App/AGENTS.md b/src/MarketAssistant.App/AGENTS.md new file mode 100644 index 0000000..b946f4c --- /dev/null +++ b/src/MarketAssistant.App/AGENTS.md @@ -0,0 +1,106 @@ +# MarketAssistant.App — AGENTS.md + +主应用入口项目(Avalonia 桌面应用)。**职责限定为 UI 宿主**:包含 View、ViewModel、导航、通知、对话框、样式资源和内容文件输出;业务逻辑、Agent 实现、Workflow、Tool、MCP、交易引擎收敛在 `MarketAssistant.App.Services`,RAG 基础能力收敛在 `MarketAssistant.Rag`。 + +--- + +## 目录结构 + +``` +MarketAssistant.App/ +├── config/ +│ ├── models.yaml ← AI 模型与供应商配置 +│ └── prompts/ ← 分析师提示词配置(每个分析师一个 yaml 文件) +│ ├── FundamentalAnalyst.yaml +│ ├── FinancialAnalyst.yaml +│ ├── TechnicalAnalyst.yaml +│ ├── MarketSentimentAnalyst.yaml +│ ├── NewsEventAnalyst.yaml +│ └── CoordinatorAnalyst.yaml +├── Converts/ ← AXAML 值转换器 +├── Infrastructure/ ← ViewLocator 等 UI 宿主基础设施 +├── Resources/Styles/ ← Avalonia 样式资源字典 +├── Services/ ← UI 适配服务 +│ ├── Dialog/ ← Avalonia 对话框实现 +│ ├── Navigation/ ← 页面导航 +│ └── Notification/ ← UI 通知 +├── ViewModels/ ← MVVM ViewModel +├── Views/ ← Avalonia AXAML 视图 +│ ├── Controls/ ← 自定义控件 +│ ├── Components/ ← 复合组件 +│ ├── Pages/ ← 页面视图 +│ └── Windows/ ← 窗口 +├── skills/ ← 输出到运行目录的 Skill/参考资源 +└── Assets/ ← 图片、图标 +``` + +--- + +## UI 与样式约定(Avalonia AXAML) + +- **文件格式**:视图文件使用 `.axaml` 扩展名。 +- **布局约束**: + - `Padding`/`Margin`/间距使用 **4 的倍数**(4/8/12/16),原则上不超过 16。 + - **禁止硬编码数值**:使用 `Resources/Styles/Spacing.axaml` 中的资源(如 `{StaticResource SmallMargin}`),或在 `UserControl.Resources` 中定义局部资源。 +- **样式管理**:统一遵循 `Resources/Styles/` 中的集中式样式资源,避免硬编码颜色与字体。 +- **资源引用**: + - `Colors.axaml` 中 `Primary`、`PrimaryLight` 等是 **Color** 类型,`Background`/`Foreground` 必须使用对应 **Brush**(`PrimaryBrush`、`PrimaryLightBrush`)。 + - 主题感知资源 → `{DynamicResource}`;固定资源 → `{StaticResource}`。 +- **控件**:优先使用 Avalonia 内置控件。 +- **资产**:非必要不改动图片与资产文件。 + +--- + +## ViewModel 编码规范 + +- 使用 `CommunityToolkit.Mvvm` 源生成器:`[ObservableProperty]`、`[RelayCommand]`。 +- `ICommand` 属性禁止在 getter 中每次创建新实例。 +- 订阅外部事件的 ViewModel 实现 `IDisposable`,在 `Dispose` 中取消订阅。 +- `ObservableCollection` 在构造函数中初始化一次。 +- 异常处理:使用 `SafeExecuteAsync` 或 `ErrorMessageMapper`,**不要吞并异常**。 +- 通过 DI 容器获取依赖,不使用 `ServiceLocator`。 +- ViewModel 可以消费 `MarketAssistant.App.Services` 暴露的业务服务,但不要在 UI 层新增 Agent、Tool、Workflow 实现。 + +--- + +## 边界约定 + +- `MarketAssistant.App` 不再承载 Agent Tool、Workflow、RAG、交易引擎等运行时代码。 +- 新增 Agent/Tool/Workflow/业务服务时,放到 `MarketAssistant.App.Services`;新增文档解析、向量化、检索、重排等 RAG 基础能力时,放到 `MarketAssistant.Rag`;本项目只保留 UI 相关适配。 +- `skills/` 作为内容文件随 App 输出,由运行时从输出目录加载;不要在 UI 层复制第二套 Skill 加载逻辑。 +- 版本号定义在 `.csproj` 的 `` 属性中,运行时通过 `AppInfo.Version` 获取。 + +--- + +## DI 注册 + +- 入口:`Program.ConfigureServices()`。 +- UI 注册入口:`Services/ServiceCollectionExtensions.cs` 中的 `AddApplicationServices()` 和 `AddViewModels()`。 +- `AddApplicationServices()` 会先调用 `AddBusinessServices()`,后者定义在 `MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs`。 +- 新增 UI 服务在本项目注册;新增业务/运行时服务在 `MarketAssistant.App.Services` 注册。 + +--- + +## 安全 + +- API 密钥(Binance、AI 模型)通过 `IUserSettingService` 管理,持久化在用户本地目录,**禁止提交到仓库**。 +- 涉及 Binance 等交易所签名逻辑的代码修改时,需确保 HMAC 密钥不被日志记录。 + +--- + +## 配置文件 + +- `config/prompts/*.yaml`:分析师提示词配置,每个分析师一个独立文件(如 `FundamentalAnalyst.yaml`),含 `name`、`displayName`、`temperature`、`topP`、`topK`、`instructions` 字段,运行时热加载。 +- `config/models.yaml`:AI 模型与供应商配置。 + +--- + +## 构建与运行 + +```bash +# 构建 +dotnet build src/MarketAssistant.App/MarketAssistant.App.csproj -c Debug + +# 运行 +dotnet run --project src/MarketAssistant.App/MarketAssistant.App.csproj -c Debug +``` diff --git a/src/App.axaml b/src/MarketAssistant.App/App.axaml similarity index 93% rename from src/App.axaml rename to src/MarketAssistant.App/App.axaml index d9ab7d2..2876492 100644 --- a/src/App.axaml +++ b/src/MarketAssistant.App/App.axaml @@ -25,6 +25,8 @@ + + diff --git a/src/App.axaml.cs b/src/MarketAssistant.App/App.axaml.cs similarity index 71% rename from src/App.axaml.cs rename to src/MarketAssistant.App/App.axaml.cs index 34dd0ed..95c018b 100644 --- a/src/App.axaml.cs +++ b/src/MarketAssistant.App/App.axaml.cs @@ -1,25 +1,28 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Data.Core.Plugins; using Avalonia.Markup.Xaml; +using Avalonia.Styling; +using MarketAssistant.Applications.PriceAlert; +using MarketAssistant.Services.Dialog; +using MarketAssistant.Services.Settings; using MarketAssistant.ViewModels; using MarketAssistant.Views.Windows; +using Microsoft.Extensions.Logging; using Serilog; namespace MarketAssistant; public partial class App : Application { + // 应用启动期间用于解析少量根服务(异常处理器、主题设置、主窗口 ViewModel), + // 业务层应通过构造函数注入获取依赖,不应直接访问此属性。 public static IServiceProvider? ServiceProvider { get; private set; } private Window? _mainWindow; public override void Initialize() { AvaloniaXamlLoader.Load(this); -#if DEBUG - this.AttachDeveloperTools(); -#endif } public override void OnFrameworkInitializationCompleted() @@ -27,15 +30,25 @@ public override void OnFrameworkInitializationCompleted() // 配置依赖注入 ServiceProvider = Program.ConfigureServices(); - // 初始化全局异常处理器 - Infrastructure.Core.GlobalExceptionHandler.Initialize(ServiceProvider); + // 初始化全局异常处理器(由 DI 创建实例,避免静态工厂自行 new) + GlobalExceptionHandler.Initialize( + ServiceProvider.GetRequiredService()); - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + // 激活价格预警服务:异步加载规则 + 启动后台监控 + var priceAlertService = ServiceProvider.GetRequiredService(); + _ = priceAlertService.InitializeAsync(); + + // 应用保存的主题 + var settingService = ServiceProvider.GetRequiredService(); + RequestedThemeVariant = settingService.CurrentSetting.ThemeMode switch { - // Avoid duplicate validations from both Avalonia and the CommunityToolkit. - // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins - DisableAvaloniaDataAnnotationValidation(); + "Light" => ThemeVariant.Light, + "Dark" => ThemeVariant.Dark, + _ => ThemeVariant.Default + }; + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { // 使用DI容器创建MainWindowViewModel var mainWindowViewModel = ServiceProvider.GetRequiredService(); _mainWindow = new MainWindow @@ -113,17 +126,4 @@ private void ShowMainWindow() _mainWindow.Activate(); } } - - private void DisableAvaloniaDataAnnotationValidation() - { - // Get an array of plugins to remove - var dataValidationPluginsToRemove = - BindingPlugins.DataValidators.OfType().ToArray(); - - // remove each entry found - foreach (var plugin in dataValidationPluginsToRemove) - { - BindingPlugins.DataValidators.Remove(plugin); - } - } } \ No newline at end of file diff --git a/src/Assets/Images/email.svg b/src/MarketAssistant.App/Assets/Images/email.svg similarity index 100% rename from src/Assets/Images/email.svg rename to src/MarketAssistant.App/Assets/Images/email.svg diff --git a/src/Assets/Images/empty_data.svg b/src/MarketAssistant.App/Assets/Images/empty_data.svg similarity index 100% rename from src/Assets/Images/empty_data.svg rename to src/MarketAssistant.App/Assets/Images/empty_data.svg diff --git a/src/Assets/Images/feedback.svg b/src/MarketAssistant.App/Assets/Images/feedback.svg similarity index 100% rename from src/Assets/Images/feedback.svg rename to src/MarketAssistant.App/Assets/Images/feedback.svg diff --git a/src/Assets/Images/github.svg b/src/MarketAssistant.App/Assets/Images/github.svg similarity index 100% rename from src/Assets/Images/github.svg rename to src/MarketAssistant.App/Assets/Images/github.svg diff --git a/src/Assets/Images/globe.svg b/src/MarketAssistant.App/Assets/Images/globe.svg similarity index 100% rename from src/Assets/Images/globe.svg rename to src/MarketAssistant.App/Assets/Images/globe.svg diff --git a/src/Assets/Images/license.svg b/src/MarketAssistant.App/Assets/Images/license.svg similarity index 100% rename from src/Assets/Images/license.svg rename to src/MarketAssistant.App/Assets/Images/license.svg diff --git a/src/Assets/Images/qq.svg b/src/MarketAssistant.App/Assets/Images/qq.svg similarity index 100% rename from src/Assets/Images/qq.svg rename to src/MarketAssistant.App/Assets/Images/qq.svg diff --git a/src/Assets/Images/refresh.svg b/src/MarketAssistant.App/Assets/Images/refresh.svg similarity index 100% rename from src/Assets/Images/refresh.svg rename to src/MarketAssistant.App/Assets/Images/refresh.svg diff --git a/src/Assets/Images/search_empty.svg b/src/MarketAssistant.App/Assets/Images/search_empty.svg similarity index 100% rename from src/Assets/Images/search_empty.svg rename to src/MarketAssistant.App/Assets/Images/search_empty.svg diff --git a/src/Assets/Images/search_icon.svg b/src/MarketAssistant.App/Assets/Images/search_icon.svg similarity index 100% rename from src/Assets/Images/search_icon.svg rename to src/MarketAssistant.App/Assets/Images/search_icon.svg diff --git a/src/Assets/Images/tab_about.svg b/src/MarketAssistant.App/Assets/Images/tab_about.svg similarity index 100% rename from src/Assets/Images/tab_about.svg rename to src/MarketAssistant.App/Assets/Images/tab_about.svg diff --git a/src/Assets/Images/tab_about_on.svg b/src/MarketAssistant.App/Assets/Images/tab_about_on.svg similarity index 100% rename from src/Assets/Images/tab_about_on.svg rename to src/MarketAssistant.App/Assets/Images/tab_about_on.svg diff --git a/src/Assets/Images/tab_analysis.svg b/src/MarketAssistant.App/Assets/Images/tab_analysis.svg similarity index 100% rename from src/Assets/Images/tab_analysis.svg rename to src/MarketAssistant.App/Assets/Images/tab_analysis.svg diff --git a/src/Assets/Images/tab_analysis_on.svg b/src/MarketAssistant.App/Assets/Images/tab_analysis_on.svg similarity index 100% rename from src/Assets/Images/tab_analysis_on.svg rename to src/MarketAssistant.App/Assets/Images/tab_analysis_on.svg diff --git a/src/Assets/Images/tab_favorites.svg b/src/MarketAssistant.App/Assets/Images/tab_favorites.svg similarity index 100% rename from src/Assets/Images/tab_favorites.svg rename to src/MarketAssistant.App/Assets/Images/tab_favorites.svg diff --git a/src/Assets/Images/tab_favorites_on.svg b/src/MarketAssistant.App/Assets/Images/tab_favorites_on.svg similarity index 100% rename from src/Assets/Images/tab_favorites_on.svg rename to src/MarketAssistant.App/Assets/Images/tab_favorites_on.svg diff --git a/src/Assets/Images/tab_home.svg b/src/MarketAssistant.App/Assets/Images/tab_home.svg similarity index 100% rename from src/Assets/Images/tab_home.svg rename to src/MarketAssistant.App/Assets/Images/tab_home.svg diff --git a/src/Assets/Images/tab_home_on.svg b/src/MarketAssistant.App/Assets/Images/tab_home_on.svg similarity index 100% rename from src/Assets/Images/tab_home_on.svg rename to src/MarketAssistant.App/Assets/Images/tab_home_on.svg diff --git a/src/Assets/Images/tab_settings.svg b/src/MarketAssistant.App/Assets/Images/tab_settings.svg similarity index 100% rename from src/Assets/Images/tab_settings.svg rename to src/MarketAssistant.App/Assets/Images/tab_settings.svg diff --git a/src/Assets/Images/tab_settings_on.svg b/src/MarketAssistant.App/Assets/Images/tab_settings_on.svg similarity index 100% rename from src/Assets/Images/tab_settings_on.svg rename to src/MarketAssistant.App/Assets/Images/tab_settings_on.svg diff --git a/src/MarketAssistant.App/Assets/Images/tab_trading.svg b/src/MarketAssistant.App/Assets/Images/tab_trading.svg new file mode 100644 index 0000000..642802e --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/tab_trading.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/MarketAssistant.App/Assets/Images/tab_trading_on.svg b/src/MarketAssistant.App/Assets/Images/tab_trading_on.svg new file mode 100644 index 0000000..65d4fa0 --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/tab_trading_on.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/Assets/Images/tray_logo.ico b/src/MarketAssistant.App/Assets/Images/tray_logo.ico similarity index 100% rename from src/Assets/Images/tray_logo.ico rename to src/MarketAssistant.App/Assets/Images/tray_logo.ico diff --git a/src/Assets/Images/trending_icon.svg b/src/MarketAssistant.App/Assets/Images/trending_icon.svg similarity index 100% rename from src/Assets/Images/trending_icon.svg rename to src/MarketAssistant.App/Assets/Images/trending_icon.svg diff --git a/src/Assets/Raw/kline_chart.html b/src/MarketAssistant.App/Assets/Raw/kline_chart.html similarity index 100% rename from src/Assets/Raw/kline_chart.html rename to src/MarketAssistant.App/Assets/Raw/kline_chart.html diff --git a/src/Assets/logo.ico b/src/MarketAssistant.App/Assets/logo.ico similarity index 100% rename from src/Assets/logo.ico rename to src/MarketAssistant.App/Assets/logo.ico diff --git a/src/Assets/logo.png b/src/MarketAssistant.App/Assets/logo.png similarity index 100% rename from src/Assets/logo.png rename to src/MarketAssistant.App/Assets/logo.png diff --git a/src/Assets/trans_bg.ico b/src/MarketAssistant.App/Assets/trans_bg.ico similarity index 100% rename from src/Assets/trans_bg.ico rename to src/MarketAssistant.App/Assets/trans_bg.ico diff --git a/src/Converts/EnumDescriptionConverter.cs b/src/MarketAssistant.App/Converters/EnumDescriptionConverter.cs similarity index 93% rename from src/Converts/EnumDescriptionConverter.cs rename to src/MarketAssistant.App/Converters/EnumDescriptionConverter.cs index 0f724ef..69a7fc2 100644 --- a/src/Converts/EnumDescriptionConverter.cs +++ b/src/MarketAssistant.App/Converters/EnumDescriptionConverter.cs @@ -2,7 +2,7 @@ using MarketAssistant.Infrastructure.Extensions; using System.Globalization; -namespace MarketAssistant.Converts; +namespace MarketAssistant.Converters; public class EnumDescriptionConverter : IValueConverter { diff --git a/src/MarketAssistant.App/Converters/NavigationIconConverter.cs b/src/MarketAssistant.App/Converters/NavigationIconConverter.cs new file mode 100644 index 0000000..4c0306e --- /dev/null +++ b/src/MarketAssistant.App/Converters/NavigationIconConverter.cs @@ -0,0 +1,24 @@ +using Avalonia.Data.Converters; +using MarketAssistant.ViewModels; +using System.Globalization; + +namespace MarketAssistant.Converters; + +/// +/// 导航图标转换器,根据选中状态返回对应的 SVG 路径 +/// +public class NavigationIconConverter : IMultiValueConverter +{ + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + if (values.Count != 2 || + values[0] is not NavigationItemViewModel navigationItem || + values[1] is not bool isSelected) + { + return null; + } + + // 直接返回 SVG 路径,让 Svg 控件处理 + return isSelected ? navigationItem.SelectedIconPath : navigationItem.IconPath; + } +} diff --git a/src/Converts/NullableValueConverter.cs b/src/MarketAssistant.App/Converters/NullableValueConverter.cs similarity index 77% rename from src/Converts/NullableValueConverter.cs rename to src/MarketAssistant.App/Converters/NullableValueConverter.cs index 8afc27c..b584729 100644 --- a/src/Converts/NullableValueConverter.cs +++ b/src/MarketAssistant.App/Converters/NullableValueConverter.cs @@ -1,17 +1,17 @@ using System.Globalization; using Avalonia.Data.Converters; -namespace MarketAssistant.Converts; +namespace MarketAssistant.Converters; /// -/// ɿֵת - ڸʽΪ null ֵ +/// �ɿ�ֵת���� - ���ڸ�ʽ������Ϊ null ����ֵ /// public class NullableValueConverter : IValueConverter { public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { - // ʽformat|fallback - // 磺"{0:F2}Ԫ|" "+{0:F1}%|--" + // ������ʽ��format|fallback + // ���磺"{0:F2}Ԫ|����" �� "+{0:F1}%|--" var parameterStr = parameter as string ?? "{0}|--"; var parts = parameterStr.Split('|'); var format = parts.Length > 0 ? parts[0] : "{0}"; @@ -20,19 +20,19 @@ public class NullableValueConverter : IValueConverter if (value == null) return fallback; - // decimal? + // ���� decimal? if (value is decimal decimalValue) { return string.Format(culture, format, decimalValue); } - // float? + // ���� float? if (value is float floatValue) { return string.Format(culture, format, floatValue); } - // int? + // ���� int? if (value is int intValue) { return string.Format(culture, format, intValue); diff --git a/src/Converts/NullableVisibilityConverter.cs b/src/MarketAssistant.App/Converters/NullableVisibilityConverter.cs similarity index 74% rename from src/Converts/NullableVisibilityConverter.cs rename to src/MarketAssistant.App/Converters/NullableVisibilityConverter.cs index b2dfa5b..f4f31f3 100644 --- a/src/Converts/NullableVisibilityConverter.cs +++ b/src/MarketAssistant.App/Converters/NullableVisibilityConverter.cs @@ -1,27 +1,27 @@ using System.Globalization; using Avalonia.Data.Converters; -namespace MarketAssistant.Converts; +namespace MarketAssistant.Converters; /// -/// ɿֵɼת - ֵΪ null ʱ false IsVisible +/// �ɿ�ֵ�ɼ���ת���� - ��ֵΪ null ʱ���� false������ IsVisible �� /// public class NullableVisibilityConverter : IValueConverter { public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { - // ǷΪ null + // ����Ƿ�Ϊ null if (value == null) return false; - // ַǷΪ + // ����ַ����Ƿ�Ϊ�� if (value is string str && string.IsNullOrWhiteSpace(str)) return false; - // ֵͣǷֵ + // ����ֵ���ͣ�����Ƿ���ֵ var type = value.GetType(); - - // Nullable + + // ����� Nullable ���� if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) { var hasValueProperty = type.GetProperty("HasValue"); diff --git a/src/Converts/NumberFormatConverter.cs b/src/MarketAssistant.App/Converters/NumberFormatConverter.cs similarity index 96% rename from src/Converts/NumberFormatConverter.cs rename to src/MarketAssistant.App/Converters/NumberFormatConverter.cs index 64ad05e..609aefc 100644 --- a/src/Converts/NumberFormatConverter.cs +++ b/src/MarketAssistant.App/Converters/NumberFormatConverter.cs @@ -2,7 +2,7 @@ using System.Globalization; using Avalonia.Data.Converters; -namespace MarketAssistant.Converts; +namespace MarketAssistant.Converters; public class NumberFormatConverter : IValueConverter { diff --git a/src/MarketAssistant.App/Converters/PriceChangeColorConverter.cs b/src/MarketAssistant.App/Converters/PriceChangeColorConverter.cs new file mode 100644 index 0000000..68e0f44 --- /dev/null +++ b/src/MarketAssistant.App/Converters/PriceChangeColorConverter.cs @@ -0,0 +1,49 @@ +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace MarketAssistant.Converters; + +/// +/// 价格变化颜色转换器 +/// +public class PriceChangeColorConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + // 处理decimal类型输入 + if (value is decimal priceChange) + { + // 价格上涨显示红色,下跌显示绿色 + if (priceChange > 0) + return new SolidColorBrush(Color.FromRgb(231, 76, 60)); // 红色 #e74c3c + else if (priceChange < 0) + return new SolidColorBrush(Color.FromRgb(46, 204, 113)); // 绿色 #2ecc71 + else + return new SolidColorBrush(Color.FromRgb(108, 117, 125)); // 灰色 #6c757d + } + + // 处理字符串类型输入(如:"+1.26%"或"-0.83%") + if (value is string percentageStr && !string.IsNullOrEmpty(percentageStr)) + { + // 移除百分号和其他非数字字符,保留正负号 + string numStr = percentageStr.Replace("%", "").Trim(); + if (decimal.TryParse(numStr, out decimal percentage)) + { + if (percentage > 0) + return new SolidColorBrush(Color.FromRgb(231, 76, 60)); // 红色 #e74c3c + else if (percentage < 0) + return new SolidColorBrush(Color.FromRgb(46, 204, 113)); // 绿色 #2ecc71 + else + return new SolidColorBrush(Color.FromRgb(108, 117, 125)); // 灰色 #6c757d + } + } + + return Brushes.Gray; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/src/Converts/README.md b/src/MarketAssistant.App/Converters/README.md similarity index 100% rename from src/Converts/README.md rename to src/MarketAssistant.App/Converters/README.md diff --git a/src/MarketAssistant.App/Converters/RadioButtonEqualityConverter.cs b/src/MarketAssistant.App/Converters/RadioButtonEqualityConverter.cs new file mode 100644 index 0000000..8e7b0b9 --- /dev/null +++ b/src/MarketAssistant.App/Converters/RadioButtonEqualityConverter.cs @@ -0,0 +1,48 @@ +using Avalonia.Data; +using Avalonia.Data.Converters; +using System.Globalization; + +namespace MarketAssistant.Converters; + +/// +/// 双向转换器,用于RadioButton的IsChecked属性与字符串值的双向绑定 +/// +public class RadioButtonEqualityConverter : IValueConverter +{ + /// + /// 将字符串值转换为布尔值,用于RadioButton的IsChecked属性 + /// + /// 源值(字符串) + /// 目标类型 + /// 参数(要比较的值) + /// 文化信息 + /// 如果源值等于参数,则返回true,否则返回false + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value == null || parameter == null) + return false; + + return value.ToString() == parameter.ToString(); + } + + /// + /// 将布尔值转换回字符串值,用于更新源属性 + /// + /// 目标值(布尔值) + /// 目标类型 + /// 参数(要设置的值) + /// 文化信息 + /// 如果布尔值为true,则返回参数值,否则返回BindingOperations.DoNothing + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool isChecked) + { + if (isChecked && parameter != null) + { + return parameter.ToString(); + } + } + + return BindingOperations.DoNothing; + } +} diff --git a/src/Converts/ScoreToColorConverter.cs b/src/MarketAssistant.App/Converters/ScoreToColorConverter.cs similarity index 96% rename from src/Converts/ScoreToColorConverter.cs rename to src/MarketAssistant.App/Converters/ScoreToColorConverter.cs index aa95c0f..f00f89a 100644 --- a/src/Converts/ScoreToColorConverter.cs +++ b/src/MarketAssistant.App/Converters/ScoreToColorConverter.cs @@ -2,7 +2,7 @@ using Avalonia.Media; using System.Globalization; -namespace MarketAssistant.Converts; +namespace MarketAssistant.Converters; /// /// 根据分数返回颜色的转换器 diff --git a/src/GlobalUsing.cs b/src/MarketAssistant.App/GlobalUsing.cs similarity index 88% rename from src/GlobalUsing.cs rename to src/MarketAssistant.App/GlobalUsing.cs index 5f9fbd2..dec89b1 100644 --- a/src/GlobalUsing.cs +++ b/src/MarketAssistant.App/GlobalUsing.cs @@ -1,6 +1,5 @@ global using Avalonia.Threading; global using MarketAssistant.Infrastructure; -global using MarketAssistant.Infrastructure.Configuration; global using MarketAssistant.Infrastructure.Core; global using Microsoft.Extensions.DependencyInjection; global using System; diff --git a/src/Infrastructure/AdaptiveCards/AdaptiveCardConverter.cs b/src/MarketAssistant.App/Infrastructure/AdaptiveCards/AdaptiveCardConverter.cs similarity index 95% rename from src/Infrastructure/AdaptiveCards/AdaptiveCardConverter.cs rename to src/MarketAssistant.App/Infrastructure/AdaptiveCards/AdaptiveCardConverter.cs index ce14aca..a78a171 100644 --- a/src/Infrastructure/AdaptiveCards/AdaptiveCardConverter.cs +++ b/src/MarketAssistant.App/Infrastructure/AdaptiveCards/AdaptiveCardConverter.cs @@ -1,4 +1,5 @@ using AdaptiveCards; +using Avalonia.Data; using Avalonia.Data.Converters; using MarketAssistant.Infrastructure.AdaptiveCards.Parsers; using System; @@ -54,6 +55,6 @@ public AdaptiveCardConverter() public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) { - throw new NotImplementedException(); + return BindingOperations.DoNothing; } } diff --git a/src/Infrastructure/Core/GlobalExceptionHandler.cs b/src/MarketAssistant.App/Infrastructure/Core/GlobalExceptionHandler.cs similarity index 63% rename from src/Infrastructure/Core/GlobalExceptionHandler.cs rename to src/MarketAssistant.App/Infrastructure/Core/GlobalExceptionHandler.cs index ee571f3..6d33278 100644 --- a/src/Infrastructure/Core/GlobalExceptionHandler.cs +++ b/src/MarketAssistant.App/Infrastructure/Core/GlobalExceptionHandler.cs @@ -4,7 +4,8 @@ namespace MarketAssistant.Infrastructure.Core; /// -/// 全局异常处理器,提供应用级异常捕获和处理 +/// 全局异常处理器,提供应用级异常捕获和处理。 +/// 通过 DI 注册为 Singleton,由 激活事件钩子。 /// public sealed class GlobalExceptionHandler { @@ -13,26 +14,25 @@ public sealed class GlobalExceptionHandler private static GlobalExceptionHandler? _instance; private static readonly object _lock = new(); - private GlobalExceptionHandler(ILogger logger, IDialogService dialogService) + // 通过 DI 注入具体服务 + public GlobalExceptionHandler(ILogger logger, IDialogService dialogService) { _logger = logger; _dialogService = dialogService; } /// - /// 初始化全局异常处理 + /// 注册全局异常处理钩子。应在 DI 容器构建完毕后调用一次, + /// 传入由 DI 容器创建的实例,避免静态工厂自行 new。 /// - public static void Initialize(IServiceProvider serviceProvider) + public static void Initialize(GlobalExceptionHandler instance) { if (_instance != null) return; lock (_lock) { if (_instance != null) return; - - var logger = serviceProvider.GetRequiredService>(); - var dialogService = serviceProvider.GetRequiredService(); - _instance = new GlobalExceptionHandler(logger, dialogService); + _instance = instance; _instance.RegisterHandlers(); } } @@ -95,6 +95,14 @@ private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEv /// private void OnDispatcherUnhandledException(object sender, Avalonia.Threading.DispatcherUnhandledExceptionEventArgs e) { + // OutOfMemoryException / StackOverflowException 之类的致命错误不应被吞并,让进程终止并写入崩溃日志 + if (IsFatalException(e.Exception)) + { + _logger.LogCritical(e.Exception, "UI 线程发生致命异常,不处理,允许进程终止"); + WriteCrashLog(e.Exception); + return; // e.Handled 保持默认 false,进程终止 + } + _logger.LogError(e.Exception, "UI 线程发生未处理的异常"); var message = ErrorMessageMapper.GetUserFriendlyMessage(e.Exception); @@ -109,6 +117,15 @@ private void OnDispatcherUnhandledException(object sender, Avalonia.Threading.Di }); } + /// + /// 判断是否是不可恢复的致命异常 + /// + private static bool IsFatalException(Exception ex) => + ex is OutOfMemoryException + or StackOverflowException + or AccessViolationException + or AppDomainUnloadedException; + /// /// 写入崩溃日志 /// @@ -171,26 +188,35 @@ public static async Task SafeExecuteAsync( string? operationName = null, ILogger? logger = null) { - if (_instance == null) - { - throw new InvalidOperationException("GlobalExceptionHandler 未初始化"); - } - setBusy?.Invoke(true); try { await operation(); } + catch (OperationCanceledException ex) when (ex.CancellationToken.IsCancellationRequested) + { + // 用户主动取消,不显示错误对话框,仅记录信息日志 + logger?.LogInformation("'{Operation}' 被用户取消", operationName ?? "未知操作"); + } catch (Exception ex) { - var message = ErrorMessageMapper.GetUserFriendlyMessageWithContext(ex, operationName ?? "操作"); - (logger ?? _instance._logger).LogError(ex, "执行 '{Operation}' 时发生错误", operationName ?? "未知操作"); + if (_instance != null) + { + var message = ErrorMessageMapper.GetUserFriendlyMessageWithContext(ex, operationName ?? "操作"); + (logger ?? _instance._logger).LogError(ex, "执行 '{Operation}' 时发生错误", operationName ?? "未知操作"); - await Dispatcher.UIThread.InvokeAsync(async () => + await Dispatcher.UIThread.InvokeAsync(async () => + { + await _instance.ShowErrorAsync("操作失败", message); + }); + } + else { - await _instance.ShowErrorAsync("操作失败", message); - }); + // 处理器尚未初始化(应用启动早期):将异常传递给链上扫调程序 + logger?.LogError(ex, "执行 '{Operation}' 时发生错误(全局处理器未就绪)", operationName ?? "未知操作"); + throw; + } } finally { @@ -207,28 +233,37 @@ await Dispatcher.UIThread.InvokeAsync(async () => string? operationName = null, ILogger? logger = null) { - if (_instance == null) - { - throw new InvalidOperationException("GlobalExceptionHandler 未初始化"); - } - setBusy?.Invoke(true); try { return await operation(); } + catch (OperationCanceledException ex) when (ex.CancellationToken.IsCancellationRequested) + { + // 用户主动取消,不显示错误对话框,仅记录信息日志 + logger?.LogInformation("'{Operation}' 被用户取消", operationName ?? "未知操作"); + return default; + } catch (Exception ex) { - var message = ErrorMessageMapper.GetUserFriendlyMessageWithContext(ex, operationName ?? "操作"); - (logger ?? _instance._logger).LogError(ex, "执行 '{Operation}' 时发生错误", operationName ?? "未知操作"); - - await Dispatcher.UIThread.InvokeAsync(async () => + if (_instance != null) { - await _instance.ShowErrorAsync("操作失败", message); - }); + var message = ErrorMessageMapper.GetUserFriendlyMessageWithContext(ex, operationName ?? "操作"); + (logger ?? _instance._logger).LogError(ex, "执行 '{Operation}' 时发生错误", operationName ?? "未知操作"); - return default; + await Dispatcher.UIThread.InvokeAsync(async () => + { + await _instance.ShowErrorAsync("操作失败", message); + }); + + return default; + } + else + { + logger?.LogError(ex, "执行 '{Operation}' 时发生错误(全局处理器未就绪)", operationName ?? "未知操作"); + throw; + } } finally { @@ -245,11 +280,6 @@ public static void SafeExecute( string? operationName = null, ILogger? logger = null) { - if (_instance == null) - { - throw new InvalidOperationException("GlobalExceptionHandler 未初始化"); - } - setBusy?.Invoke(true); try @@ -258,14 +288,22 @@ public static void SafeExecute( } catch (Exception ex) { - var message = ErrorMessageMapper.GetUserFriendlyMessageWithContext(ex, operationName ?? "操作"); - (logger ?? _instance._logger).LogError(ex, "执行 '{Operation}' 时发生错误", operationName ?? "未知操作"); - - // 使用 Post 而不是 InvokeAsync,避免阻塞当前线程 - Dispatcher.UIThread.Post(async () => + if (_instance != null) { - await _instance.ShowErrorAsync("操作失败", message); - }); + var message = ErrorMessageMapper.GetUserFriendlyMessageWithContext(ex, operationName ?? "操作"); + (logger ?? _instance._logger).LogError(ex, "执行 '{Operation}' 时发生错误", operationName ?? "未知操作"); + + // 使用 Post 而不是 InvokeAsync,避免阻塞当前线程 + Dispatcher.UIThread.Post(async () => + { + await _instance.ShowErrorAsync("操作失败", message); + }); + } + else + { + logger?.LogError(ex, "执行 '{Operation}' 时发生错误(全局处理器未就绪)", operationName ?? "未知操作"); + throw; + } } finally { diff --git a/src/Infrastructure/Core/ViewLocator.cs b/src/MarketAssistant.App/Infrastructure/Core/ViewLocator.cs similarity index 63% rename from src/Infrastructure/Core/ViewLocator.cs rename to src/MarketAssistant.App/Infrastructure/Core/ViewLocator.cs index 07f3dbd..29e7744 100644 --- a/src/Infrastructure/Core/ViewLocator.cs +++ b/src/MarketAssistant.App/Infrastructure/Core/ViewLocator.cs @@ -2,8 +2,10 @@ using Avalonia.Controls.Templates; using MarketAssistant.ViewModels; using MarketAssistant.ViewModels.Demo; +using MarketAssistant.ViewModels.Trading; using MarketAssistant.Views.Demo; using MarketAssistant.Views.Pages; +using MarketAssistant.Views.Pages.Trading; namespace MarketAssistant.Infrastructure.Core; @@ -17,33 +19,19 @@ public class ViewLocator : IDataTemplate if (data is null) return null; - var viewModelType = data.GetType(); - var viewTypeName = viewModelType.FullName!.Replace("ViewModel", "View"); - var viewType = Type.GetType(viewTypeName); - - if (viewType != null) - { - var control = (Control?)Activator.CreateInstance(viewType); - if (control != null) - { - control.DataContext = data; - return control; - } - } - - // 如果找不到对应的View,使用具体的映射 return data switch { HomePageViewModel => new HomePageView { DataContext = data }, FavoritesPageViewModel => new FavoritesPageView { DataContext = data }, - StockSelectionPageViewModel => new StockSelectionPageView { DataContext = data }, + AssetSelectionPageViewModel => new AssetSelectionPageView { DataContext = data }, SettingsPageViewModel => new SettingsPageView { DataContext = data }, AboutPageViewModel => new AboutPageView { DataContext = data }, MCPConfigPageViewModel => new MCPConfigPageView { DataContext = data }, - StockPageViewModel => new StockPageView { DataContext = data }, + AssetPageViewModel => new AssetPageView { DataContext = data }, AgentAnalysisViewModel => new AgentAnalysisPageView { DataContext = data }, + TradingPageViewModel => new TradingPageView { DataContext = data }, ChatSidebarDemoViewModel => new ChatSidebarDemoView { DataContext = data }, - _ => new TextBlock { Text = $"未找到视图: {viewModelType.Name}" } + _ => new TextBlock { Text = $"未找到视图: {data.GetType().Name}" } }; } diff --git a/src/MarketAssistant.App/MarketAssistant.App.csproj b/src/MarketAssistant.App/MarketAssistant.App.csproj new file mode 100644 index 0000000..c994b79 --- /dev/null +++ b/src/MarketAssistant.App/MarketAssistant.App.csproj @@ -0,0 +1,95 @@ + + + WinExe + net10.0 + enable + MarketAssistant + true + app.manifest + true + + + win-x64;osx-x64;osx-arm64;linux-x64 + true + + + MarketAssistant + 1.0.0.0 + Assets\logo.ico + 1.0.0-beta1 + X2Agent + Market Assistant + Market Assistant + Copyright © 2026 + AI 智能市场分析助手 + + + MarketAssistant + Market Assistant + xyz.haoai.market + + $(Version) + $(Version) + APPL + MKAS + MarketAssistant + MarketAssistant.icns + true + NSApplication + + + + + + + + + + + + + + + + + + Always + + + Always + + + PreserveNewest + skills\%(RecursiveDir)%(Filename)%(Extension) + + + + + + + + + + + + + + + + + + + + + + + false + true + + + + + + + + diff --git a/src/MarketAssistant.App/MarketAssistant.App.csproj.lscache b/src/MarketAssistant.App/MarketAssistant.App.csproj.lscache new file mode 100644 index 0000000..f00c308 --- /dev/null +++ b/src/MarketAssistant.App/MarketAssistant.App.csproj.lscache @@ -0,0 +1,528 @@ +version=1 + +# This file caches language service data to improve the performance of C# Dev Kit. +# It is not intended for manual editing. It can safely be deleted and will be +# regenerated automatically. For more information, see https://aka.ms/lscache +# +# To control where cache files are stored, use the following VS Code setting: +# "dotnet.projectsystem.cacheInProjectFolder": true + +[project] +language=C# +primary +lastDtbSucceeded + +[properties] +AssemblyName=MarketAssistant +CommandLineArgsForDesignTimeEvaluation=-langversion:14.0 -define:TRACE +CompilerGeneratedFilesOutputPath= +MaxSupportedLangVersion=14.0 +ProjectAssetsFile=obj/project.assets.json +RootNamespace=MarketAssistant +RunAnalyzers= +RunAnalyzersDuringLiveAnalysis= +SolutionPath=../../MarketAssistant.slnx +TargetFrameworkIdentifier=.NETCoreApp +TargetPath=bin/Debug/net10.0/MarketAssistant.dll +TargetRefPath=obj/Debug/net10.0/ref/MarketAssistant.dll +TemporaryDependencyNodeTargetIdentifier=net10.0 + +[commandLineArguments] +/noconfig +/unsafe- +/checked- +/nowarn:1701,1702,1701,1702,8002 +/fullpaths +/nostdlib+ +/errorreport:prompt +/warn:10 +/define:TRACE;DEBUG;NET;NET10_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NET10_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER +/highentropyva+ +/nullable:enable +/features:"InterceptorsNamespaces=;Microsoft.Extensions.Validation.Generated" +/debug+ +/debug:portable +/filealign:512 +/optimize- +/out:obj\Debug\net10.0\MarketAssistant.dll +/refout:obj\Debug\net10.0\refint\MarketAssistant.dll +/target:winexe +/warnaserror- +/utf8output +/win32icon:Assets\logo.ico +/win32manifest:app.manifest +/deterministic+ +/langversion:14.0 +/warnaserror+:NU1605,SYSLIB0011 + +[sourceFiles] +App.axaml.cs +Converts/ + EnumDescriptionConverter.cs + NavigationIconConverter.cs + NullableValueConverter.cs + NullableVisibilityConverter.cs + NumberFormatConverter.cs + PriceChangeColorConverter.cs + RadioButtonEqualityConverter.cs + ScoreToColorConverter.cs +GlobalUsing.cs +Infrastructure/ + AdaptiveCards/AdaptiveCardConverter.cs + Core/ + GlobalExceptionHandler.cs + ViewLocator.cs +obj/Debug/net10.0/ + .NETCoreApp,Version=v10.0.AssemblyAttributes.cs + MarketAssistant.App.AssemblyInfo.cs +Program.cs +Services/ + Dialog/DialogService.cs + Navigation/ + INavigationAware.cs + NavigationService.cs + Notification/NotificationService.cs + ServiceCollectionExtensions.cs +ViewModels/ + AboutPageViewModel.cs + AgentAnalysisViewModel.cs + AnalysisReportViewModel.cs + AnalystRoleViewModel.cs + AssetNavigationParameter.cs + AssetPageViewModel.cs + AssetSelectionPageViewModel.cs + ChatMessageAdapter.cs + ChatSidebarViewModel.cs + Demo/ + ChatSidebarDemoViewModel.cs + MockChatClientFactory.cs + FavoritesPageViewModel.cs + Home/ + HomeSearchViewModel.cs + HotAssetsViewModel.cs + RecentAssetsViewModel.cs + TelegraphNewsViewModel.cs + HomePageViewModel.cs + MainWindowViewModel.cs + MCPConfigPageViewModel.cs + SettingsPageViewModel.cs + Trading/ + StrategyConfigViewModel.cs + TradeHistoryViewModel.cs + TradeMonitorViewModel.cs + TradingPageViewModel.cs + ViewModelBase.cs +Views/Components/ + AdaptiveCardView.axaml.cs + AnalysisReportView.axaml.cs + ChatSidebarView.axaml.cs + KLineChartView.cs + ProgressDisplayView.axaml.cs +Views/Controls/ + CardView.axaml.cs + LoadingDots.axaml.cs + RichTextBlock.cs + WatermarkView.cs +Views/ + Demo/ChatSidebarDemoView.axaml.cs + Pages/ + AboutPageView.axaml.cs + AgentAnalysisPageView.axaml.cs + AssetPageView.axaml.cs + AssetSelectionPageView.axaml.cs + FavoritesPageView.axaml.cs + HomePageView.axaml.cs + MCPConfigPageView.axaml.cs + SettingsPageView.axaml.cs + Trading/ + StrategyConfigView.axaml.cs + TradeHistoryView.axaml.cs + TradeMonitorView.axaml.cs + TradingPageView.axaml.cs + Windows/ + MainWindow.axaml.cs + NotificationWindow.axaml.cs + +[metadataReferences] +../ + MarketAssistant.Agents/obj/Debug/net10.0/ref/MarketAssistant.Agents.dll + MarketAssistant.App.Services/obj/Debug/net10.0/ref/MarketAssistant.App.Services.dll + MarketAssistant.Core/obj/Debug/net10.0/ref/MarketAssistant.Core.dll + MarketAssistant.DataProviders/obj/Debug/net10.0/ref/MarketAssistant.DataProviders.dll + MarketAssistant.Rag/obj/Debug/net10.0/ref/MarketAssistant.Rag.dll + MarketAssistant.Trading/obj/Debug/net10.0/ref/MarketAssistant.Trading.dll +/packs/Microsoft.NETCore.App.Ref/10.0.5/ref/net10.0/ + Microsoft.CSharp.dll + Microsoft.VisualBasic.Core.dll + Microsoft.VisualBasic.dll + Microsoft.Win32.Primitives.dll + Microsoft.Win32.Registry.dll + mscorlib.dll + netstandard.dll + System.AppContext.dll + System.Buffers.dll + System.Collections.Concurrent.dll + System.Collections.dll + System.Collections.Immutable.dll + System.Collections.NonGeneric.dll + System.Collections.Specialized.dll + System.ComponentModel.Annotations.dll + System.ComponentModel.DataAnnotations.dll + System.ComponentModel.dll + System.ComponentModel.EventBasedAsync.dll + System.ComponentModel.Primitives.dll + System.ComponentModel.TypeConverter.dll + System.Configuration.dll + System.Console.dll + System.Core.dll + System.Data.Common.dll + System.Data.DataSetExtensions.dll + System.Data.dll + System.Diagnostics.Contracts.dll + System.Diagnostics.Debug.dll + System.Diagnostics.DiagnosticSource.dll + System.Diagnostics.FileVersionInfo.dll + System.Diagnostics.Process.dll + System.Diagnostics.StackTrace.dll + System.Diagnostics.TextWriterTraceListener.dll + System.Diagnostics.Tools.dll + System.Diagnostics.TraceSource.dll + System.Diagnostics.Tracing.dll + System.dll + System.Drawing.dll + System.Drawing.Primitives.dll + System.Dynamic.Runtime.dll + System.Formats.Asn1.dll + System.Formats.Tar.dll + System.Globalization.Calendars.dll + System.Globalization.dll + System.Globalization.Extensions.dll + System.IO.Compression.Brotli.dll + System.IO.Compression.dll + System.IO.Compression.FileSystem.dll + System.IO.Compression.ZipFile.dll + System.IO.dll + System.IO.FileSystem.AccessControl.dll + System.IO.FileSystem.dll + System.IO.FileSystem.DriveInfo.dll + System.IO.FileSystem.Primitives.dll + System.IO.FileSystem.Watcher.dll + System.IO.IsolatedStorage.dll + System.IO.MemoryMappedFiles.dll + System.IO.Pipelines.dll + System.IO.Pipes.AccessControl.dll + System.IO.Pipes.dll + System.IO.UnmanagedMemoryStream.dll + System.Linq.AsyncEnumerable.dll + System.Linq.dll + System.Linq.Expressions.dll + System.Linq.Parallel.dll + System.Linq.Queryable.dll + System.Memory.dll + System.Net.dll + System.Net.Http.dll + System.Net.Http.Json.dll + System.Net.HttpListener.dll + System.Net.Mail.dll + System.Net.NameResolution.dll + System.Net.NetworkInformation.dll + System.Net.Ping.dll + System.Net.Primitives.dll + System.Net.Quic.dll + System.Net.Requests.dll + System.Net.Security.dll + System.Net.ServerSentEvents.dll + System.Net.ServicePoint.dll + System.Net.Sockets.dll + System.Net.WebClient.dll + System.Net.WebHeaderCollection.dll + System.Net.WebProxy.dll + System.Net.WebSockets.Client.dll + System.Net.WebSockets.dll + System.Numerics.dll + System.Numerics.Vectors.dll + System.ObjectModel.dll + System.Reflection.DispatchProxy.dll + System.Reflection.dll + System.Reflection.Emit.dll + System.Reflection.Emit.ILGeneration.dll + System.Reflection.Emit.Lightweight.dll + System.Reflection.Extensions.dll + System.Reflection.Metadata.dll + System.Reflection.Primitives.dll + System.Reflection.TypeExtensions.dll + System.Resources.Reader.dll + System.Resources.ResourceManager.dll + System.Resources.Writer.dll + System.Runtime.CompilerServices.Unsafe.dll + System.Runtime.CompilerServices.VisualC.dll + System.Runtime.dll + System.Runtime.Extensions.dll + System.Runtime.Handles.dll + System.Runtime.InteropServices.dll + System.Runtime.InteropServices.JavaScript.dll + System.Runtime.InteropServices.RuntimeInformation.dll + System.Runtime.Intrinsics.dll + System.Runtime.Loader.dll + System.Runtime.Numerics.dll + System.Runtime.Serialization.dll + System.Runtime.Serialization.Formatters.dll + System.Runtime.Serialization.Json.dll + System.Runtime.Serialization.Primitives.dll + System.Runtime.Serialization.Xml.dll + System.Security.AccessControl.dll + System.Security.Claims.dll + System.Security.Cryptography.Algorithms.dll + System.Security.Cryptography.Cng.dll + System.Security.Cryptography.Csp.dll + System.Security.Cryptography.dll + System.Security.Cryptography.Encoding.dll + System.Security.Cryptography.OpenSsl.dll + System.Security.Cryptography.Primitives.dll + System.Security.Cryptography.X509Certificates.dll + System.Security.dll + System.Security.Principal.dll + System.Security.Principal.Windows.dll + System.Security.SecureString.dll + System.ServiceModel.Web.dll + System.ServiceProcess.dll + System.Text.Encoding.CodePages.dll + System.Text.Encoding.dll + System.Text.Encoding.Extensions.dll + System.Text.Encodings.Web.dll + System.Text.Json.dll + System.Text.RegularExpressions.dll + System.Threading.AccessControl.dll + System.Threading.Channels.dll + System.Threading.dll + System.Threading.Overlapped.dll + System.Threading.Tasks.Dataflow.dll + System.Threading.Tasks.dll + System.Threading.Tasks.Extensions.dll + System.Threading.Tasks.Parallel.dll + System.Threading.Thread.dll + System.Threading.ThreadPool.dll + System.Threading.Timer.dll + System.Transactions.dll + System.Transactions.Local.dll + System.ValueTuple.dll + System.Web.dll + System.Web.HttpUtility.dll + System.Windows.dll + System.Xml.dll + System.Xml.Linq.dll + System.Xml.ReaderWriter.dll + System.Xml.Serialization.dll + System.Xml.XDocument.dll + System.Xml.XmlDocument.dll + System.Xml.XmlSerializer.dll + System.Xml.XPath.dll + System.Xml.XPath.XDocument.dll + WindowsBase.dll +D:/NuGet/Packages/ + adaptivecards/3.1.0/lib/net6.0/AdaptiveCards.dll + anglesharp/1.4.0/lib/net10.0/AngleSharp.dll + avalonia.avaloniaedit/11.0.0/lib/netstandard2.0/AvaloniaEdit.dll + avalonia.controls.webview/12.0.0-preview2/lib/net10.0/Avalonia.Controls.WebView.dll + avalonia.desktop/12.0.0-preview2/lib/net10.0/Avalonia.Desktop.dll + avalonia.fonts.inter/12.0.0-preview2/lib/net10.0/Avalonia.Fonts.Inter.dll + avalonia.freedesktop.atspi/12.0.0-preview2/lib/net10.0/Avalonia.FreeDesktop.AtSpi.dll + avalonia.freedesktop/12.0.0-preview2/lib/net10.0/Avalonia.FreeDesktop.dll + avalonia.harfbuzz/12.0.0-preview2/lib/net10.0/Avalonia.HarfBuzz.dll + avalonia.native/12.0.0-preview2/lib/net10.0/Avalonia.Native.dll + avalonia.remote.protocol/12.0.0-preview2/lib/net10.0/Avalonia.Remote.Protocol.dll + avalonia.skia/12.0.0-preview2/lib/net10.0/Avalonia.Skia.dll + avalonia.svg/11.0.0/lib/net6.0/Avalonia.Svg.dll + avalonia.themes.fluent/12.0.0-preview2/lib/net10.0/Avalonia.Themes.Fluent.dll + avalonia.win32/12.0.0-preview2/lib/net10.0/ + Avalonia.Win32.Automation.dll + Avalonia.Win32.dll + avalonia.x11/12.0.0-preview2/lib/net10.0/Avalonia.X11.dll + avalonia/12.0.0-preview2/ref/net10.0/ + Avalonia.Base.dll + Avalonia.Controls.dll + Avalonia.DesignerSupport.dll + Avalonia.Dialogs.dll + Avalonia.dll + Avalonia.Markup.dll + Avalonia.Markup.Xaml.dll + Avalonia.Metal.dll + Avalonia.MicroCom.dll + Avalonia.OpenGL.dll + Avalonia.Vulkan.dll + colordocument.avalonia/11.0.3-a1/lib/net6.0/ColorDocument.Avalonia.dll + colortextblock.avalonia/11.0.3-a1/lib/net6.0/ColorTextBlock.Avalonia.dll + communitytoolkit.mvvm/8.4.1/lib/net8.0/CommunityToolkit.Mvvm.dll + documentformat.openxml.framework/3.5.1/lib/net10.0/DocumentFormat.OpenXml.Framework.dll + documentformat.openxml/3.5.1/lib/net10.0/DocumentFormat.OpenXml.dll + excss/4.3.1/lib/net8.0/ExCSS.dll + google.apis.core/1.68.0/lib/net6.0/Google.Apis.Core.dll + google.apis.customsearchapi.v1/1.68.0.3520/lib/net6.0/Google.Apis.CustomSearchAPI.v1.dll + google.apis/1.68.0/lib/net6.0/Google.Apis.dll + google.protobuf/3.30.2/lib/net5.0/Google.Protobuf.dll + harfbuzzsharp/8.3.1.3/lib/net8.0/HarfBuzzSharp.dll + htmlagilitypack/1.11.42/lib/netstandard2.0/HtmlAgilityPack.dll + markdig/1.1.1/lib/net10.0/Markdig.dll + markdown.avalonia.html/11.0.3-a1/lib/net6.0/Markdown.Avalonia.Html.dll + markdown.avalonia.svg/11.0.3-a1/lib/net6.0/Markdown.Avalonia.Svg.dll + markdown.avalonia.syntaxhigh/11.0.3-a1/lib/net6.0/Markdown.Avalonia.SyntaxHigh.dll + markdown.avalonia.tight/11.0.3-a1/lib/net6.0/Markdown.Avalonia.dll + markdown.avalonia/11.0.3-a1/lib/net6.0/Markdown.Avalonia.Full.dll + microcom.runtime/0.11.0/lib/net5.0/MicroCom.Runtime.dll + microsoft.agents.ai.abstractions/1.6.1/lib/net10.0/Microsoft.Agents.AI.Abstractions.dll + microsoft.agents.ai.workflows/1.6.1/lib/net10.0/Microsoft.Agents.AI.Workflows.dll + microsoft.agents.ai/1.6.1/lib/net10.0/Microsoft.Agents.AI.dll + microsoft.bcl.asyncinterfaces/6.0.0/lib/netstandard2.1/Microsoft.Bcl.AsyncInterfaces.dll + microsoft.bcl.hashcode/1.1.1/ref/netcoreapp2.1/Microsoft.Bcl.HashCode.dll + microsoft.data.sqlite.core/10.0.5/lib/net8.0/Microsoft.Data.Sqlite.dll + microsoft.extensions.ai.abstractions/10.5.1/lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + microsoft.extensions.ai.evaluation/10.4.0/lib/net10.0/Microsoft.Extensions.AI.Evaluation.dll + microsoft.extensions.ai.openai/10.5.1/lib/net10.0/Microsoft.Extensions.AI.OpenAI.dll + microsoft.extensions.ai/10.5.1/lib/net10.0/Microsoft.Extensions.AI.dll + microsoft.extensions.ambientmetadata.application/10.4.0/lib/net10.0/Microsoft.Extensions.AmbientMetadata.Application.dll + microsoft.extensions.caching.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.Caching.Abstractions.dll + microsoft.extensions.caching.memory/10.0.5/lib/net10.0/Microsoft.Extensions.Caching.Memory.dll + microsoft.extensions.compliance.abstractions/10.5.0/lib/net10.0/Microsoft.Extensions.Compliance.Abstractions.dll + microsoft.extensions.configuration.abstractions/10.0.5/lib/net10.0/Microsoft.Extensions.Configuration.Abstractions.dll + microsoft.extensions.configuration.binder/10.0.5/lib/net10.0/Microsoft.Extensions.Configuration.Binder.dll + microsoft.extensions.configuration/10.0.5/lib/net10.0/Microsoft.Extensions.Configuration.dll + microsoft.extensions.dependencyinjection.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll + microsoft.extensions.dependencyinjection.autoactivation/10.4.0/lib/net10.0/Microsoft.Extensions.DependencyInjection.AutoActivation.dll + microsoft.extensions.dependencyinjection/10.0.5/lib/net10.0/Microsoft.Extensions.DependencyInjection.dll + microsoft.extensions.diagnostics.abstractions/10.0.5/lib/net10.0/Microsoft.Extensions.Diagnostics.Abstractions.dll + microsoft.extensions.diagnostics.exceptionsummarization/10.4.0/lib/net10.0/Microsoft.Extensions.Diagnostics.ExceptionSummarization.dll + microsoft.extensions.diagnostics/10.0.5/lib/net10.0/Microsoft.Extensions.Diagnostics.dll + microsoft.extensions.fileproviders.abstractions/10.0.4/lib/net10.0/Microsoft.Extensions.FileProviders.Abstractions.dll + microsoft.extensions.filesystemglobbing/10.0.6/lib/net10.0/Microsoft.Extensions.FileSystemGlobbing.dll + microsoft.extensions.hosting.abstractions/10.0.4/lib/net10.0/Microsoft.Extensions.Hosting.Abstractions.dll + microsoft.extensions.http.diagnostics/10.4.0/lib/net10.0/Microsoft.Extensions.Http.Diagnostics.dll + microsoft.extensions.http.resilience/10.4.0/lib/net10.0/Microsoft.Extensions.Http.Resilience.dll + microsoft.extensions.http/10.0.5/lib/net10.0/Microsoft.Extensions.Http.dll + microsoft.extensions.logging.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.Logging.Abstractions.dll + microsoft.extensions.logging.configuration/10.0.4/lib/net10.0/Microsoft.Extensions.Logging.Configuration.dll + microsoft.extensions.logging/10.0.5/lib/net10.0/Microsoft.Extensions.Logging.dll + microsoft.extensions.objectpool/10.0.6/lib/net10.0/Microsoft.Extensions.ObjectPool.dll + microsoft.extensions.options.configurationextensions/10.0.5/lib/net10.0/Microsoft.Extensions.Options.ConfigurationExtensions.dll + microsoft.extensions.options/10.0.5/lib/net10.0/Microsoft.Extensions.Options.dll + microsoft.extensions.primitives/10.0.6/lib/net10.0/Microsoft.Extensions.Primitives.dll + microsoft.extensions.resilience/10.4.0/lib/net10.0/Microsoft.Extensions.Resilience.dll + microsoft.extensions.telemetry.abstractions/10.4.0/lib/net10.0/Microsoft.Extensions.Telemetry.Abstractions.dll + microsoft.extensions.telemetry/10.4.0/lib/net10.0/Microsoft.Extensions.Telemetry.dll + microsoft.extensions.vectordata.abstractions/10.0.1/lib/net10.0/Microsoft.Extensions.VectorData.Abstractions.dll + microsoft.ml.onnxruntime.managed/1.24.3/lib/net8.0/Microsoft.ML.OnnxRuntime.dll + microsoft.ml.tokenizers/2.0.0/lib/net8.0/Microsoft.ML.Tokenizers.dll + microsoft.playwright/1.58.0/lib/netstandard2.0/Microsoft.Playwright.dll + microsoft.semantickernel.abstractions/1.73.0/lib/net10.0/Microsoft.SemanticKernel.Abstractions.dll + microsoft.semantickernel.connectors.sqlitevec/1.73.0-preview/lib/net10.0/Microsoft.SemanticKernel.Connectors.SqliteVec.dll + microsoft.semantickernel.plugins.web/1.73.0-alpha/lib/net10.0/Microsoft.SemanticKernel.Plugins.Web.dll + modelcontextprotocol.core/1.1.0/lib/net10.0/ModelContextProtocol.Core.dll + modelcontextprotocol/1.1.0/lib/net10.0/ModelContextProtocol.dll + newtonsoft.json/13.0.3/lib/net6.0/Newtonsoft.Json.dll + openai/2.10.0/lib/net10.0/OpenAI.dll + opentelemetry.api/1.15.3/lib/net10.0/OpenTelemetry.Api.dll + pdfpig/0.1.13/lib/net8.0/ + UglyToad.PdfPig.Core.dll + UglyToad.PdfPig.dll + UglyToad.PdfPig.DocumentLayoutAnalysis.dll + UglyToad.PdfPig.Fonts.dll + UglyToad.PdfPig.Package.dll + UglyToad.PdfPig.Tokenization.dll + UglyToad.PdfPig.Tokens.dll + polly.core/8.4.2/lib/net8.0/Polly.Core.dll + polly.extensions/8.4.2/lib/net8.0/Polly.Extensions.dll + polly.ratelimiting/8.4.2/lib/net8.0/Polly.RateLimiting.dll + serilog.extensions.hosting/10.0.0/lib/net10.0/Serilog.Extensions.Hosting.dll + serilog.extensions.logging/10.0.0/lib/net10.0/Serilog.Extensions.Logging.dll + serilog.sinks.console/6.1.1/lib/net8.0/Serilog.Sinks.Console.dll + serilog.sinks.file/7.0.0/lib/net9.0/Serilog.Sinks.File.dll + serilog/4.3.0/lib/net9.0/Serilog.dll + shimskiasharp/3.6.0/lib/net10.0/ShimSkiaSharp.dll + skender.stock.indicators/2.7.1/lib/net10.0/Skender.Stock.Indicators.dll + skiasharp/3.119.3-preview.1.1/ref/net8.0/SkiaSharp.dll + smartreader/0.11.0/lib/netstandard2.1/SmartReader.dll + sqlitepclraw.bundle_e_sqlite3/2.1.11/lib/netstandard2.0/SQLitePCLRaw.batteries_v2.dll + sqlitepclraw.core/2.1.11/lib/netstandard2.0/SQLitePCLRaw.core.dll + sqlitepclraw.provider.e_sqlite3/2.1.11/lib/net6.0/SQLitePCLRaw.provider.e_sqlite3.dll + svg.controls.skia.avalonia/11.3.9.4/lib/net10.0/Svg.Controls.Skia.Avalonia.dll + svg.custom/3.6.0/lib/net10.0/Svg.Custom.dll + svg.model/3.6.0/lib/net10.0/Svg.Model.dll + svg.skia/3.6.0/lib/net10.0/Svg.Skia.dll + system.clientmodel/1.10.0/lib/net10.0/System.ClientModel.dll + system.io.packaging/10.0.2/lib/net10.0/System.IO.Packaging.dll + system.memory.data/10.0.3/lib/net10.0/System.Memory.Data.dll + system.numerics.tensors/10.0.6/lib/net10.0/System.Numerics.Tensors.dll + system.threading.ratelimiting/8.0.0/lib/net8.0/System.Threading.RateLimiting.dll + tmds.dbus.protocol/0.90.3/lib/net9.0/Tmds.DBus.Protocol.dll + yamldotnet/16.3.0/lib/net8.0/YamlDotNet.dll + +[analyzerReferences] +/packs/Microsoft.NETCore.App.Ref/10.0.5/analyzers/dotnet/cs/ + Microsoft.Interop.ComInterfaceGenerator.dll + Microsoft.Interop.JavaScript.JSImportGenerator.dll + Microsoft.Interop.LibraryImportGenerator.dll + Microsoft.Interop.SourceGeneration.dll + System.Text.Json.SourceGeneration.dll + System.Text.RegularExpressions.Generator.dll +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/ + Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll + Microsoft.CodeAnalysis.NetAnalyzers.dll +D:/NuGet/Packages/avalonia/12.0.0-preview2/analyzers/dotnet/cs/ + Avalonia.Analyzers.CodeFixes.CSharp.dll + Avalonia.Analyzers.CSharp.dll + Avalonia.Analyzers.VisualBasic.dll + Avalonia.Generators.dll +D:/NuGet/Packages/communitytoolkit.mvvm/8.4.1/analyzers/dotnet/roslyn5.0/cs/ + CommunityToolkit.Mvvm.CodeFixers.dll + CommunityToolkit.Mvvm.SourceGenerators.dll +D:/NuGet/Packages/ + microsoft.agents.ai.workflows.generators/1.6.1/analyzers/dotnet/cs/Microsoft.Agents.AI.Workflows.Generators.dll + microsoft.extensions.options/10.0.5/analyzers/dotnet/roslyn4.4/cs/Microsoft.Extensions.Options.SourceGeneration.dll + microsoft.extensions.telemetry.abstractions/10.4.0/analyzers/dotnet/cs/ + Microsoft.Gen.Logging.dll + Microsoft.Gen.Metrics.dll + modelcontextprotocol.core/1.1.0/analyzers/dotnet/cs/ModelContextProtocol.Analyzers.dll + system.clientmodel/1.10.0/analyzers/dotnet/cs/System.ClientModel.SourceGeneration.dll + +[analyzerConfigFiles] +../../.editorconfig +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_10_default.globalconfig +obj/Debug/net10.0/MarketAssistant.App.GeneratedMSBuildEditorConfig.editorconfig + +[additionalFiles] +App.axaml +Resources/Styles/ + ButtonStyles.axaml + CardStyles.axaml + Colors.axaml + Geometries.axaml + ListStyles.axaml + SkeletonStyles.axaml + Spacing.axaml + TextStyles.axaml +Views/Components/ + AdaptiveCardView.axaml + AnalysisReportView.axaml + ChatSidebarView.axaml + ProgressDisplayView.axaml +Views/Controls/ + CardView.axaml + LoadingDots.axaml +Views/ + Demo/ChatSidebarDemoView.axaml + Pages/ + AboutPageView.axaml + AgentAnalysisPageView.axaml + AssetPageView.axaml + AssetSelectionPageView.axaml + FavoritesPageView.axaml + HomePageView.axaml + MCPConfigPageView.axaml + SettingsPageView.axaml + Trading/ + StrategyConfigView.axaml + TradeHistoryView.axaml + TradeMonitorView.axaml + TradingPageView.axaml + Windows/ + MainWindow.axaml + NotificationWindow.axaml diff --git a/src/Program.cs b/src/MarketAssistant.App/Program.cs similarity index 65% rename from src/Program.cs rename to src/MarketAssistant.App/Program.cs index 070f725..98c6950 100644 --- a/src/Program.cs +++ b/src/MarketAssistant.App/Program.cs @@ -29,20 +29,10 @@ public static IServiceProvider ConfigureServices() { var services = new ServiceCollection(); - // 注册用户设置服务为单例(需要先注册以便获取日志路径) - services.AddSingleton(); - - // 构建一个临时 ServiceProvider 以便在配置日志之前获取用户设置 - using (var tempProvider = services.BuildServiceProvider()) - { - var userSettingService = tempProvider.GetRequiredService(); - - // 配置日志 - services.AddLogging(builder => builder.ConfigureLogging(userSettingService)); - } - - // 注册基础服务(RAG、向量化等) - services.AddRagServices(); + // ConfigureLogging 只需要在启动时读取一次日志路径,直接实例化即可, + // 避免构建临时容器(捕获依赖问题)。 + // IUserSettingService 的正式 Singleton 由 AddAgentTools() 内部负责注册。 + services.AddLogging(builder => builder.ConfigureLogging(new UserSettingService())); // 注册应用程序业务服务 services.AddApplicationServices(); diff --git a/src/MarketAssistant.App/Resources/Styles/ButtonStyles.axaml b/src/MarketAssistant.App/Resources/Styles/ButtonStyles.axaml new file mode 100644 index 0000000..8d5b2fa --- /dev/null +++ b/src/MarketAssistant.App/Resources/Styles/ButtonStyles.axaml @@ -0,0 +1,245 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/Styles/CardStyles.axaml b/src/MarketAssistant.App/Resources/Styles/CardStyles.axaml similarity index 100% rename from src/Resources/Styles/CardStyles.axaml rename to src/MarketAssistant.App/Resources/Styles/CardStyles.axaml diff --git a/src/Resources/Styles/Colors.axaml b/src/MarketAssistant.App/Resources/Styles/Colors.axaml similarity index 91% rename from src/Resources/Styles/Colors.axaml rename to src/MarketAssistant.App/Resources/Styles/Colors.axaml index dc914d4..c5417a8 100644 --- a/src/Resources/Styles/Colors.axaml +++ b/src/MarketAssistant.App/Resources/Styles/Colors.axaml @@ -14,6 +14,14 @@ #FF9800 #F44336 #2196F3 + + + #66BB6A + #388E3C + #FFB74D + #F57C00 + #64B5F6 + #1976D2 #F44336 @@ -48,6 +56,9 @@ #40000000 + + + #FEF2F2 @@ -74,6 +85,9 @@ #80000000 + + + #2D1B1B @@ -89,6 +103,14 @@ + + + + + + + + diff --git a/src/MarketAssistant.App/Resources/Styles/FormStyles.axaml b/src/MarketAssistant.App/Resources/Styles/FormStyles.axaml new file mode 100644 index 0000000..c0dab6e --- /dev/null +++ b/src/MarketAssistant.App/Resources/Styles/FormStyles.axaml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/Styles/Geometries.axaml b/src/MarketAssistant.App/Resources/Styles/Geometries.axaml similarity index 100% rename from src/Resources/Styles/Geometries.axaml rename to src/MarketAssistant.App/Resources/Styles/Geometries.axaml diff --git a/src/MarketAssistant.App/Resources/Styles/ListStyles.axaml b/src/MarketAssistant.App/Resources/Styles/ListStyles.axaml new file mode 100644 index 0000000..0785ebf --- /dev/null +++ b/src/MarketAssistant.App/Resources/Styles/ListStyles.axaml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MarketAssistant.App/Resources/Styles/SkeletonStyles.axaml b/src/MarketAssistant.App/Resources/Styles/SkeletonStyles.axaml new file mode 100644 index 0000000..28d2a08 --- /dev/null +++ b/src/MarketAssistant.App/Resources/Styles/SkeletonStyles.axaml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Resources/Styles/Spacing.axaml b/src/MarketAssistant.App/Resources/Styles/Spacing.axaml similarity index 96% rename from src/Resources/Styles/Spacing.axaml rename to src/MarketAssistant.App/Resources/Styles/Spacing.axaml index a1c890a..37615d4 100644 --- a/src/Resources/Styles/Spacing.axaml +++ b/src/MarketAssistant.App/Resources/Styles/Spacing.axaml @@ -8,6 +8,8 @@ 16 12 8 + 8 + 4,2 4 @@ -119,8 +121,9 @@ 120 - 600 - 300 + 480 + 360 + 720 320 72 400 diff --git a/src/MarketAssistant.App/Resources/Styles/TextStyles.axaml b/src/MarketAssistant.App/Resources/Styles/TextStyles.axaml new file mode 100644 index 0000000..5889603 --- /dev/null +++ b/src/MarketAssistant.App/Resources/Styles/TextStyles.axaml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Services/Dialog/DialogService.cs b/src/MarketAssistant.App/Services/Dialog/DialogService.cs similarity index 99% rename from src/Services/Dialog/DialogService.cs rename to src/MarketAssistant.App/Services/Dialog/DialogService.cs index 602d3f0..8652501 100644 --- a/src/Services/Dialog/DialogService.cs +++ b/src/MarketAssistant.App/Services/Dialog/DialogService.cs @@ -187,7 +187,7 @@ public async Task ShowConfirmationAsync(string title, string message, stri { Text = defaultValue ?? string.Empty, Width = 300, - Watermark = "请输入...", + PlaceholderText = "请输入...", Margin = new Thickness(0, 8, 0, 0) }; diff --git a/src/MarketAssistant.App/Services/Navigation/INavigationAware.cs b/src/MarketAssistant.App/Services/Navigation/INavigationAware.cs new file mode 100644 index 0000000..0e097ba --- /dev/null +++ b/src/MarketAssistant.App/Services/Navigation/INavigationAware.cs @@ -0,0 +1,64 @@ +namespace MarketAssistant.Services.Navigation; + +/// +/// 导航感知接口 +/// 实现此接口的 ViewModel 可以在导航发生时接收通知和参数 +/// +public interface INavigationAware +{ + /// + /// 当导航到此页面时调用(首次进入或 GoBack 重新激活均会触发)。 + /// 默认实现转发到 。 + /// + /// 导航参数 + void OnNavigatedTo(object? parameter) => OnNavigatedTo(parameter, isReactivation: false); + + /// + /// 当导航到此页面时调用,可区分首次进入与 GoBack 重新激活。 + /// 实现者可据此决定是否重新执行副作用(如重新订阅、重新加载)。 + /// + /// 导航参数 + /// true 表示从子页面 GoBack 重新激活;false 表示首次进入 + void OnNavigatedTo(object? parameter, bool isReactivation); + + /// + /// 当从此页面离开时调用 + /// + void OnNavigatedFrom(); +} + +/// +/// 泛型导航感知接口,提供强类型参数支持 +/// +/// 参数类型 +public interface INavigationAware : INavigationAware +{ + /// + /// 当导航到此页面时调用(强类型,首次进入或 GoBack 重新激活均会触发) + /// 默认实现转发到 (isReactivation = false)。 + /// + /// 强类型参数 + void OnNavigatedTo(T parameter) => OnNavigatedTo(parameter, isReactivation: false); + + /// + /// 当导航到此页面时调用(强类型,可区分首次进入与重新激活) + /// + /// 强类型参数 + /// true 表示从子页面 GoBack 重新激活;false 表示首次进入 + void OnNavigatedTo(T parameter, bool isReactivation) => OnNavigatedTo(parameter); + + // 显式实现基接口方法,进行类型转换 + void INavigationAware.OnNavigatedTo(object? parameter, bool isReactivation) + { + if (parameter is T t) + { + OnNavigatedTo(t, isReactivation); + } + // 如果参数为null且T允许null,也调用 + else if (parameter is null && default(T) is null) + { + OnNavigatedTo(default!, isReactivation); + } + // 类型不匹配时忽略 + } +} diff --git a/src/Services/Navigation/NavigationService.cs b/src/MarketAssistant.App/Services/Navigation/NavigationService.cs similarity index 83% rename from src/Services/Navigation/NavigationService.cs rename to src/MarketAssistant.App/Services/Navigation/NavigationService.cs index e600db5..84297ef 100644 --- a/src/Services/Navigation/NavigationService.cs +++ b/src/MarketAssistant.App/Services/Navigation/NavigationService.cs @@ -52,7 +52,7 @@ public NavigationService(IServiceProvider serviceProvider, ILogger("MCPConfig"); - RegisterRoute("Stock"); + RegisterRoute("Asset"); RegisterRoute("Analysis"); // 注册导航消息监听 @@ -152,18 +152,7 @@ public bool GoBack() } // 释放旧页面资源 - if (poppedItem.ViewModel is IDisposable disposable) - { - try - { - disposable.Dispose(); - _logger?.LogDebug("已释放 ViewModel 资源: {Type}", poppedItem.ViewModel.GetType().Name); - } - catch (Exception ex) - { - _logger?.LogError(ex, "释放 ViewModel 资源时发生错误: {Type}", poppedItem.ViewModel.GetType().Name); - } - } + DisposeViewModel(poppedItem.ViewModel); if (_navigationStack.Count > 0) { @@ -172,10 +161,11 @@ public bool GoBack() _logger?.LogInformation("返回到页面: {PageType}", currentItem.ViewModel.GetType().Name); // 2. 通知重新显示的页面(Re-activation) - // 我们传递它原始的参数,以便它决定是否需要刷新 + // 传递 isReactivation: true,让页面区分首次进入与 GoBack 重新激活, + // 避免重复执行订阅、加载等副作用 if (currentItem.ViewModel is INavigationAware currentAware) { - currentAware.OnNavigatedTo(currentItem.Parameter); + currentAware.OnNavigatedTo(currentItem.Parameter, isReactivation: true); } // 3. 更新状态(触发UI变更) @@ -197,21 +187,13 @@ public bool GoBack() ///
public void NavigateToRoot(ViewModelBase viewModel, string rootNavigationItemTitle) { - // 清空并释放所有页面 + // 清空并释放所有页面:先通知 OnNavigatedFrom 再 Dispose,确保资源正确清理 while (_navigationStack.Count > 0) { var item = _navigationStack.Pop(); - if (item.ViewModel is IDisposable disposable) - { - try - { - disposable.Dispose(); - } - catch (Exception ex) - { - _logger?.LogError(ex, "释放 ViewModel 资源时发生错误: {Type}", item.ViewModel.GetType().Name); - } - } + if (item.ViewModel is INavigationAware aware) + aware.OnNavigatedFrom(); + DisposeViewModel(item.ViewModel); } NavigateToInternal(viewModel, null, rootNavigationItemTitle); @@ -230,17 +212,9 @@ public void Clear() while (_navigationStack.Count > 0) { var item = _navigationStack.Pop(); - if (item.ViewModel is IDisposable disposable) - { - try - { - disposable.Dispose(); - } - catch (Exception ex) - { - _logger?.LogError(ex, "释放 ViewModel 资源时发生错误: {Type}", item.ViewModel.GetType().Name); - } - } + if (item.ViewModel is INavigationAware aware) + aware.OnNavigatedFrom(); + DisposeViewModel(item.ViewModel); } UpdateState(); @@ -262,5 +236,24 @@ private void UpdateState() CurrentRootNavigationItemTitle = null; } } + + /// + /// 释放 ViewModel 资源(如果实现了 IDisposable) + /// + private void DisposeViewModel(ViewModelBase? viewModel) + { + if (viewModel is not IDisposable disposable) + return; + + try + { + disposable.Dispose(); + _logger?.LogDebug("已释放 ViewModel 资源: {Type}", viewModel.GetType().Name); + } + catch (Exception ex) + { + _logger?.LogError(ex, "释放 ViewModel 资源时发生错误: {Type}", viewModel.GetType().Name); + } + } } diff --git a/src/Services/Notification/NotificationService.cs b/src/MarketAssistant.App/Services/Notification/NotificationService.cs similarity index 92% rename from src/Services/Notification/NotificationService.cs rename to src/MarketAssistant.App/Services/Notification/NotificationService.cs index 6a28475..20fb298 100644 --- a/src/Services/Notification/NotificationService.cs +++ b/src/MarketAssistant.App/Services/Notification/NotificationService.cs @@ -42,9 +42,9 @@ private void ShowNotification(string message, NotificationType type, int duratio notification.SetMessage(message, type); await notification.ShowNotificationAsync(durationMs); } - catch (Exception) + catch (Exception ex) { - // 忽略通知显示错误 + System.Diagnostics.Debug.WriteLine($"显示通知失败: {ex.Message}"); } }); } diff --git a/src/MarketAssistant.App/Services/ServiceCollectionExtensions.cs b/src/MarketAssistant.App/Services/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..b6ff0db --- /dev/null +++ b/src/MarketAssistant.App/Services/ServiceCollectionExtensions.cs @@ -0,0 +1,73 @@ +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Dialog; +using MarketAssistant.Services.Navigation; +using MarketAssistant.Services.Notification; +using MarketAssistant.Services.Settings; +using MarketAssistant.ViewModels; +using MarketAssistant.ViewModels.Home; +using MarketAssistant.ViewModels.Trading; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Services; + +/// +/// 服务注册扩展 +/// +public static class ServiceCollectionExtensions +{ + /// + /// 注册应用程序所有服务 + /// + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + // 注册所有业务服务(非 UI 层,来自 App.Services) + services.AddBusinessServices(); + + // 注册 Avalonia 平台特定服务 + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // 注册全局异常处理器(Singleton,由 DI 创建实例) + services.AddSingleton(); + + return services; + } + + /// + /// 注册所有ViewModels + /// + public static IServiceCollection AddViewModels(this IServiceCollection services) + { + // 注册主窗口 ViewModel + services.AddTransient(); + + // 注册主要页面 ViewModels + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // 注册 Home 子 ViewModels + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // 注册 AI 分析相关 ViewModels + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // 注册交易模块 ViewModels + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + return services; + } +} diff --git a/src/MarketAssistant.App/ViewModels/AboutPageViewModel.cs b/src/MarketAssistant.App/ViewModels/AboutPageViewModel.cs new file mode 100644 index 0000000..72bcc32 --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/AboutPageViewModel.cs @@ -0,0 +1,361 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using MarketAssistant.Applications.Settings; +using MarketAssistant.Services.Notification; +using Microsoft.Extensions.Logging; +using System.Collections.ObjectModel; +using System.Diagnostics; + +namespace MarketAssistant.ViewModels; +/// +/// 关于页ViewModel +/// +public partial class AboutPageViewModel : ViewModelBase +{ + private readonly IReleaseService _releaseService; + private readonly INotificationService _notificationService; + private ReleaseInfo? _latestRelease; + + [ObservableProperty] + private bool _isCheckingUpdate; + + [ObservableProperty] + private string _updateStatus = "点击检查更新"; + + [ObservableProperty] + private bool _hasNewVersion; + + [ObservableProperty] + private bool _isDownloading; + + [ObservableProperty] + private double _downloadProgress; + + [ObservableProperty] + private string _latestVersion = ""; + + [ObservableProperty] + private string _releaseNotes = ""; + + public string AppName => AppInfo.Title; + public string Version => $"v {AppInfo.Version}"; + public string Description => AppInfo.Description; + public string Company => AppInfo.Company; + public string Copyright => AppInfo.Copyright; + + public ObservableCollection FeatureItems { get; } = new ObservableCollection(); + + public IAsyncRelayCommand CheckUpdateCommand { get; } + public IAsyncRelayCommand DownloadUpdateCommand { get; } + public IRelayCommand OpenGitHubCommand { get; } + + /// + /// 构造函数(使用依赖注入) + /// + public AboutPageViewModel( + IReleaseService releaseService, + INotificationService notificationService, + ILogger logger) : base(logger) + { + _releaseService = releaseService; + _notificationService = notificationService; + + CheckUpdateCommand = new AsyncRelayCommand(CheckForUpdateAsync); + DownloadUpdateCommand = new AsyncRelayCommand(DownloadUpdateAsync, () => HasNewVersion && !IsDownloading); + OpenGitHubCommand = new RelayCommand(OpenGitHub); + + // 初始化功能项列表 + InitializeFeatureItems(); + } + + private async Task CheckForUpdateAsync() + { + await SafeExecuteAsync(async () => + { + IsCheckingUpdate = true; + UpdateStatus = "正在检查更新..."; + HasNewVersion = false; + _latestRelease = null; + + try + { + Logger?.LogInformation("开始检查更新,当前版本: {Version}", AppInfo.Version); + + // 当前版本为正式版时,不检查预发布版本,避免正式版用户被提示升级到 beta + var includePrerelease = IsPrerelease(AppInfo.Version); + var result = await _releaseService.CheckForUpdateAsync(AppInfo.Version, includePrerelease: includePrerelease); + + if (result.HasNewVersion && result.LatestRelease != null) + { + _latestRelease = result.LatestRelease; + LatestVersion = result.LatestRelease.TagName; + ReleaseNotes = result.LatestRelease.Body ?? "无更新说明"; + HasNewVersion = true; + UpdateStatus = $"发现新版本:{result.LatestRelease.TagName}"; + + _notificationService.ShowInfo($"发现新版本 {result.LatestRelease.TagName}!\n点击下载按钮进行更新"); + Logger?.LogInformation("发现新版本: {Version}", result.LatestRelease.TagName); + + // 更新下载命令的可执行状态 + DownloadUpdateCommand.NotifyCanExecuteChanged(); + } + else + { + UpdateStatus = "已是最新版本 ✓"; + _notificationService.ShowSuccess("当前已是最新版本!"); + Logger?.LogInformation("当前已是最新版本"); + } + } + catch (FriendlyException ex) + { + UpdateStatus = $"检查更新失败:{ex.Message}"; + _notificationService.ShowError($"检查更新失败:{ex.Message}"); + Logger?.LogError(ex, "检查更新失败"); + } + catch (Exception ex) + { + UpdateStatus = "检查更新失败"; + _notificationService.ShowError("检查更新失败,请稍后重试"); + Logger?.LogError(ex, "检查更新时发生未知错误"); + } + finally + { + IsCheckingUpdate = false; + } + }, "检查更新"); + } + + private async Task DownloadUpdateAsync() + { + if (!HasNewVersion || _latestRelease == null) return; + + await SafeExecuteAsync(async () => + { + IsDownloading = true; + DownloadProgress = 0; + + try + { + // 获取下载URL(优先匹配当前操作系统的安装包) + var asset = SelectAssetForCurrentOs(_latestRelease) + ?? _latestRelease.Assets?.FirstOrDefault(); + + if (asset == null || string.IsNullOrEmpty(asset.DownloadUrl)) + { + // 没有找到资产文件,打开 GitHub Release 页面 + _notificationService.ShowInfo("将打开 GitHub Release 页面手动下载"); + OpenUrl(_latestRelease.HtmlUrl); + return; + } + + // 确定保存路径 + var downloadsPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + downloadsPath = Path.Combine(downloadsPath, "Downloads"); + if (!Directory.Exists(downloadsPath)) + { + downloadsPath = Path.GetTempPath(); + } + + var savePath = Path.Combine(downloadsPath, asset.Name); + Logger?.LogInformation("准备下载更新:{Url} -> {Path}", asset.DownloadUrl, savePath); + + _notificationService.ShowInfo($"开始下载 {asset.Name}..."); + + // 创建进度报告器 + var progress = new Progress(p => + { + DownloadProgress = p * 100; + UpdateStatus = $"下载中... {DownloadProgress:F0}%"; + }); + + // 下载更新文件 + var downloadedPath = await _releaseService.DownloadUpdateAsync( + asset.DownloadUrl, + savePath, + progress); + + Logger?.LogInformation("更新文件下载完成: {Path}", downloadedPath); + + // 下载完成 + UpdateStatus = "下载完成!"; + _notificationService.ShowSuccess($"更新文件已下载到:\n{downloadedPath}\n\n请手动运行安装程序进行更新"); + + // 打开下载目录 + Process.Start(new ProcessStartInfo + { + FileName = downloadsPath, + UseShellExecute = true, + Verb = "open" + }); + } + catch (OperationCanceledException) + { + UpdateStatus = "下载已取消"; + _notificationService.ShowWarning("下载已取消"); + Logger?.LogWarning("下载已取消"); + } + catch (FriendlyException ex) + { + UpdateStatus = $"下载失败:{ex.Message}"; + _notificationService.ShowError($"下载失败:{ex.Message}"); + Logger?.LogError(ex, "下载更新失败"); + } + catch (Exception ex) + { + UpdateStatus = "下载失败"; + _notificationService.ShowError("下载失败,请稍后重试或手动访问 GitHub 下载"); + Logger?.LogError(ex, "下载更新时发生未知错误"); + } + finally + { + IsDownloading = false; + DownloadProgress = 0; + DownloadUpdateCommand.NotifyCanExecuteChanged(); + } + }, "下载更新"); + } + + private void OpenGitHub() + { + try + { + Process.Start(new ProcessStartInfo(AppInfo.GitHubRepoUrl) { UseShellExecute = true }); + } + catch (Exception) + { + // 处理异常 + } + } + + /// + /// 根据当前操作系统选择合适的下载资产,避免向 macOS/Linux 用户推荐 Windows 安装包。 + /// 命名约定见 release.yml:Windows 优先 .exe/.msi,macOS 优先 .dmg,Linux 优先 .deb/.rpm。 + /// + private static ReleaseAsset? SelectAssetForCurrentOs(ReleaseInfo release) + { + var assets = release.Assets; + if (assets == null || assets.Count == 0) + { + return null; + } + + if (OperatingSystem.IsWindows()) + { + return assets.FirstOrDefault(a => a.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + ?? assets.FirstOrDefault(a => a.Name.EndsWith(".msi", StringComparison.OrdinalIgnoreCase)) + ?? assets.FirstOrDefault(a => a.Name.Contains("Windows", StringComparison.OrdinalIgnoreCase)); + } + + if (OperatingSystem.IsMacOS()) + { + return assets.FirstOrDefault(a => a.Name.EndsWith(".dmg", StringComparison.OrdinalIgnoreCase)) + ?? assets.FirstOrDefault(a => a.Name.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase)) + ?? assets.FirstOrDefault(a => a.Name.Contains("macOS", StringComparison.OrdinalIgnoreCase) + || a.Name.Contains("macos", StringComparison.OrdinalIgnoreCase) + || a.Name.Contains("osx", StringComparison.OrdinalIgnoreCase)); + } + + if (OperatingSystem.IsLinux()) + { + return assets.FirstOrDefault(a => a.Name.EndsWith(".deb", StringComparison.OrdinalIgnoreCase)) + ?? assets.FirstOrDefault(a => a.Name.EndsWith(".rpm", StringComparison.OrdinalIgnoreCase)) + ?? assets.FirstOrDefault(a => a.Name.Contains("Linux", StringComparison.OrdinalIgnoreCase) + || a.Name.Contains("linux", StringComparison.OrdinalIgnoreCase)); + } + + return null; + } + + /// + /// 判断版本号是否为预发布版本(包含 '-' 分隔符,如 1.0.0-beta1)。 + /// 正式版(如 1.0.0)不检查预发布更新,避免被提示升级到 beta。 + /// + private static bool IsPrerelease(string version) + { + if (string.IsNullOrWhiteSpace(version)) + { + return false; + } + + var trimmed = version.TrimStart('v'); + return trimmed.Contains('-'); + } + + private void OpenUrl(string url) + { + try + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + catch (Exception) + { + // 处理异常 + } + } + + private void InitializeFeatureItems() + { + FeatureItems.Add(new FeatureItem + { + IconSource = "/Assets/Images/refresh.svg", + Title = "更新日志", + ButtonText = "查看", + Command = new RelayCommand(() => OpenUrl(AppInfo.ChangelogUrl)) + }); + + FeatureItems.Add(new FeatureItem + { + IconSource = "/Assets/Images/globe.svg", + Title = "官方网站", + ButtonText = "查看", + Command = new RelayCommand(() => OpenUrl(AppInfo.OfficialWebsite)) + }); + + FeatureItems.Add(new FeatureItem + { + IconSource = "/Assets/Images/feedback.svg", + Title = "意见反馈", + ButtonText = "反馈", + Command = new RelayCommand(() => OpenUrl(AppInfo.FeedbackUrl)) + }); + + FeatureItems.Add(new FeatureItem + { + IconSource = "/Assets/Images/license.svg", + Title = "许可证", + ButtonText = "查看", + Command = new RelayCommand(() => OpenUrl(AppInfo.LicenseUrl)) + }); + + FeatureItems.Add(new FeatureItem + { + IconSource = "/Assets/Images/qq.svg", + Title = $"官方QQ群: {AppInfo.QQGroupNumber}", + ButtonText = "加入", + Command = new RelayCommand(() => OpenUrl(AppInfo.QQGroupUrl)) + }); + } +} + +public class FeatureItem +{ + /// + /// 功能项图标 + /// + public string IconSource { get; set; } = ""; + + /// + /// 功能项名称 + /// + public string Title { get; set; } = ""; + + /// + /// 按钮文本 + /// + public string ButtonText { get; set; } = ""; + + /// + /// 功能项命令 + /// + public IRelayCommand Command { get; set; } = null!; +} diff --git a/src/MarketAssistant.App/ViewModels/AgentAnalysisViewModel.cs b/src/MarketAssistant.App/ViewModels/AgentAnalysisViewModel.cs new file mode 100644 index 0000000..2804759 --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/AgentAnalysisViewModel.cs @@ -0,0 +1,359 @@ +using Avalonia.Platform.Storage; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using MarketAssistant.Agents.MarketAnalysis; +using MarketAssistant.Agents.MarketAnalysis.Models; +using MarketAssistant.Applications.Analysis; +using MarketAssistant.Services.Archive; +using MarketAssistant.Services.Export; +using MarketAssistant.Services.Market; +using MarketAssistant.Services.Navigation; +using Microsoft.Extensions.Logging; +using System.Collections.ObjectModel; +using System.Windows.Input; + +namespace MarketAssistant.ViewModels; + +/// +/// 代理分析页面视图模型 +/// +public partial class AgentAnalysisViewModel : ViewModelBase, INavigationAware, IDisposable +{ + public override string Title => MarketContext?.CurrentMarket switch + { + MarketType.Crypto => "AI虚拟币分析", + _ => "AI股票分析" + }; + + private readonly AnalysisOrchestrationService _orchestrationService; + + [ObservableProperty] + private string _stockCode = ""; + + [ObservableProperty] + private bool _isAnalysisInProgress; + + [ObservableProperty] + private string _analysisStage = "等待开始分析"; + + [ObservableProperty] + private int _analysisProgressPercent; + + [ObservableProperty] + private string _failedAnalystsInfo = string.Empty; + + [ObservableProperty] + private AnalysisReportViewModel _analysisReportViewModel; + + [ObservableProperty] + private bool _isChatSidebarVisible; + + public ICommand ToggleChatSidebarCommand { get; private set; } + public ICommand CancelAnalysisCommand { get; private set; } + + private MarketAnalysisReport? _lastReport; + private IStorageProvider? _storageProvider; + private CancellationTokenSource? _analysisCts; + + /// + /// 供 View 在 AttachedToVisualTree 时注入 + /// + public void SetStorageProvider(IStorageProvider? storageProvider) => _storageProvider = storageProvider; + + /// + /// 报告是否可导出 + /// + [ObservableProperty] + private bool _canExportReport; + + public ObservableCollection ReportHistory { get; } = []; + + [ObservableProperty] + private bool _hasReportHistory; + + private ChatSidebarViewModel? _chatSidebarViewModel; + /// + /// 聊天侧边栏 ViewModel 引用(用于数据同步) + /// + public ChatSidebarViewModel? ChatSidebarViewModel + { + get => _chatSidebarViewModel; + set + { + if (_chatSidebarViewModel != null) + { + _chatSidebarViewModel.PropertyChanged -= OnChatSidebarPropertyChanged; + } + + SetProperty(ref _chatSidebarViewModel, value); + + if (_chatSidebarViewModel != null) + { + _chatSidebarViewModel.PropertyChanged += OnChatSidebarPropertyChanged; + } + + OnPropertyChanged(nameof(ChatMessages)); + OnPropertyChanged(nameof(UserInput)); + OnPropertyChanged(nameof(SendMessageCommand)); + } + } + + private readonly ObservableCollection _emptyChatMessages = new(); + public ObservableCollection ChatMessages => ChatSidebarViewModel?.ChatMessages ?? _emptyChatMessages; + + public string UserInput + { + get => ChatSidebarViewModel?.UserInput ?? string.Empty; + set + { + if (ChatSidebarViewModel != null) + { + ChatSidebarViewModel.UserInput = value; + OnPropertyChanged(); + } + } + } + + private static readonly ICommand _noopCommand = new RelayCommand(() => { }); + public ICommand SendMessageCommand => ChatSidebarViewModel?.SendMessageCommand ?? _noopCommand; + + public AgentAnalysisViewModel( + AnalysisOrchestrationService orchestrationService, + AnalysisReportViewModel analysisReportViewModel, + ChatSidebarViewModel chatSidebarViewModel, + MarketContext marketContext, + ILogger logger) : base(logger) + { + _orchestrationService = orchestrationService; + _analysisReportViewModel = analysisReportViewModel; + + SubscribeToMarketChanges(marketContext); + + ChatSidebarViewModel = chatSidebarViewModel; + ChatSidebarViewModel.InitializeEmpty(); + + SubscribeToEvents(); + ToggleChatSidebarCommand = new RelayCommand(ToggleChatSidebar); + CancelAnalysisCommand = new RelayCommand(CancelAnalysis); + ExportReportCommand = new AsyncRelayCommand(ExportReportAsync); + LoadHistoryReportCommand = new AsyncRelayCommand(LoadHistoryReportAsync); + } + + public ICommand ExportReportCommand { get; } + public ICommand LoadHistoryReportCommand { get; } + + private void SubscribeToEvents() + { + _orchestrationService.ProgressChanged += OnAnalysisProgressChanged; + } + + /// + /// 处理 ChatSidebarViewModel 的属性变更 + /// + private void OnChatSidebarPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(ChatSidebarViewModel.UserInput): + OnPropertyChanged(nameof(UserInput)); + break; + case nameof(ChatSidebarViewModel.ChatMessages): + OnPropertyChanged(nameof(ChatMessages)); + break; + } + } + + private void OnAnalysisProgressChanged(object? sender, AnalysisProgressEventArgs e) + { + Dispatcher.UIThread.InvokeAsync(() => + { + IsAnalysisInProgress = e.IsInProgress; + AnalysisStage = e.StageDescription; + AnalysisProgressPercent = e.ProgressPercent; + + if (e.FailedAnalysts.Count > 0) + { + FailedAnalystsInfo = $"部分分析师失败: {string.Join(", ", e.FailedAnalysts)}"; + } + }); + } + + private void CancelAnalysis() + { + _analysisCts?.Cancel(); + Logger?.LogInformation("用户取消了分析任务"); + } + + /// + /// 加载分析数据 + /// + public async Task LoadAnalysisDataAsync() + { + if (string.IsNullOrEmpty(StockCode)) + return; + + await SafeExecuteAsync(async () => + { + await Dispatcher.UIThread.InvokeAsync(() => + { + AnalysisStage = "准备开始..."; + FailedAnalystsInfo = string.Empty; + AnalysisProgressPercent = 0; + }); + + await RefreshHistoryAsync(StockCode); + + _analysisCts?.Cancel(); + _analysisCts = new CancellationTokenSource(); + + var result = await _orchestrationService.AnalyzeAsync(StockCode, _analysisCts.Token); + var report = result.Report; + + _lastReport = report; + CanExportReport = true; + + if (!result.FromCache) + await RefreshHistoryAsync(StockCode); + + await Dispatcher.UIThread.InvokeAsync(async () => + { + AnalysisReportViewModel.UpdateWithReport(report); + if (ChatSidebarViewModel != null) + { + await ChatSidebarViewModel.InitializeWithAnalysisHistory(StockCode, report.AnalystMessages); + } + }); + + }, "资产分析"); + } + + /// + /// 切换聊天侧边栏显示状态 + /// + private void ToggleChatSidebar() + { + IsChatSidebarVisible = !IsChatSidebarVisible; + } + + /// + /// 导出分析报告为 Markdown 文件 + /// + private async Task ExportReportAsync() + { + if (_lastReport == null || _storageProvider == null) + return; + + var suggestedName = $"{_lastReport.AssetSymbol}_分析报告_{_lastReport.CreatedAt.ToLocalTime():yyyyMMdd}"; + var file = await _storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "导出分析报告", + SuggestedFileName = suggestedName, + FileTypeChoices = + [ + new FilePickerFileType("Markdown") { Patterns = ["*.md"] }, + ], + DefaultExtension = "md" + }); + + if (file == null) return; + + var markdown = MarkdownReportExporter.Export(_lastReport); + await using var stream = await file.OpenWriteAsync(); + await using var writer = new System.IO.StreamWriter(stream, System.Text.Encoding.UTF8); + await writer.WriteAsync(markdown); + + Logger?.LogInformation("分析报告已导出: {Path}", file.Name); + } + + public void OnNavigatedTo(AssetNavigationParameter parameter, bool isReactivation) + { + if (!string.IsNullOrEmpty(parameter.Code)) + { + StockCode = parameter.Code; + Logger?.LogInformation("导航到 AI 分析页面,资产代码: {Code},重新激活: {IsReactivation}", StockCode, isReactivation); + // GoBack 重新激活时不重复启动分析,避免重复请求 + if (!isReactivation) + { + _ = LoadAnalysisDataAsync(); + } + } + else + { + Logger?.LogInformation("导航到 AI 分析页面,但未提供资产代码"); + } + } + + public void OnNavigatedFrom() + { + } + + private async Task RefreshHistoryAsync(string assetCode) + { + var summaries = await _orchestrationService.GetReportHistoryAsync(assetCode); + await Dispatcher.UIThread.InvokeAsync(() => + { + ReportHistory.Clear(); + foreach (var s in summaries) + ReportHistory.Add(s); + HasReportHistory = ReportHistory.Count > 0; + }); + } + + private async Task LoadHistoryReportAsync(ReportSummary? summary) + { + if (summary == null) return; + + await SafeExecuteAsync(async () => + { + var report = await _orchestrationService.LoadHistoryReportAsync(summary.Id); + if (report == null) return; + + _lastReport = report; + CanExportReport = true; + + await Dispatcher.UIThread.InvokeAsync(async () => + { + AnalysisReportViewModel.UpdateWithReport(report); + if (ChatSidebarViewModel != null) + await ChatSidebarViewModel.InitializeWithAnalysisHistory(report.AssetSymbol, report.AnalystMessages); + }); + }, "加载历史报告"); + } + + protected override void OnMarketChanged(MarketType newMarket) + { + // 取消进行中的分析任务,避免旧市场的分析结果污染新市场 + _analysisCts?.Cancel(); + _analysisCts?.Dispose(); + _analysisCts = null; + _lastReport = null; + + // 重置分析状态 + IsAnalysisInProgress = false; + AnalysisStage = "等待开始分析"; + AnalysisProgressPercent = 0; + FailedAnalystsInfo = string.Empty; + CanExportReport = false; + + OnPropertyChanged(nameof(Title)); + } + + public void Dispose() + { + _orchestrationService.ProgressChanged -= OnAnalysisProgressChanged; + _analysisCts?.Cancel(); + _analysisCts?.Dispose(); + + if (MarketContext != null) + UnsubscribeFromMarketChanges(MarketContext); + + if (_chatSidebarViewModel != null) + { + _chatSidebarViewModel.PropertyChanged -= OnChatSidebarPropertyChanged; + _chatSidebarViewModel.Dispose(); + } + + GC.SuppressFinalize(this); + } +} + diff --git a/src/ViewModels/AnalysisReportViewModel.cs b/src/MarketAssistant.App/ViewModels/AnalysisReportViewModel.cs similarity index 77% rename from src/ViewModels/AnalysisReportViewModel.cs rename to src/MarketAssistant.App/ViewModels/AnalysisReportViewModel.cs index 9ef4feb..93df374 100644 --- a/src/ViewModels/AnalysisReportViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/AnalysisReportViewModel.cs @@ -70,6 +70,28 @@ public partial class AnalysisReportViewModel : ViewModelBase [ObservableProperty] private bool _hasDisagreementAnalysis; + // === 质量评估指标 === + + [ObservableProperty] + private int _dataCompletenessPercent; + + [ObservableProperty] + private int _analystConsensusPercent; + + [ObservableProperty] + private string _limitationsNote = string.Empty; + + [ObservableProperty] + private string _qualityLevelText = string.Empty; + + [ObservableProperty] + private string _qualityLevelColor = string.Empty; + + [ObservableProperty] + private bool _hasQualityMetrics; + + public ObservableCollection MissingDataDimensions { get; } = new(); + // === 各分析师的消息 === public ObservableCollection AnalystMessages { get; } = new(); @@ -98,7 +120,7 @@ public void UpdateWithReport(MarketAnalysisReport report) try { - StockSymbol = report.StockSymbol; + StockSymbol = report.AssetSymbol; var coordinatorResult = report.CoordinatorResult; @@ -142,6 +164,28 @@ public void UpdateWithReport(MarketAnalysisReport report) CoordinatorSummary = coordinatorResult.Summary; + var qm = coordinatorResult.QualityMetrics; + DataCompletenessPercent = qm.DataCompletenessPercent; + AnalystConsensusPercent = qm.AnalystConsensusPercent; + LimitationsNote = qm.LimitationsNote; + QualityLevelText = qm.OverallQualityLevel switch + { + Agents.MarketAnalysis.Models.AnalysisQualityLevel.High => "高", + Agents.MarketAnalysis.Models.AnalysisQualityLevel.Medium => "中", + Agents.MarketAnalysis.Models.AnalysisQualityLevel.Low => "低", + _ => "未知" + }; + QualityLevelColor = qm.OverallQualityLevel switch + { + Agents.MarketAnalysis.Models.AnalysisQualityLevel.High => "Success", + Agents.MarketAnalysis.Models.AnalysisQualityLevel.Medium => "Warning", + _ => "Danger" + }; + MissingDataDimensions.Clear(); + foreach (var dim in qm.MissingDataDimensions) + MissingDataDimensions.Add(dim); + HasQualityMetrics = qm.DataCompletenessPercent > 0 || qm.AnalystConsensusPercent > 0; + // 添加各专业分析师的自然语言分析(无结构化数据) foreach (var message in report.AnalystMessages) { @@ -168,6 +212,7 @@ private void ClearAllData() RiskFactors.Clear(); OperationSuggestions.Clear(); AnalystMessages.Clear(); + MissingDataDimensions.Clear(); StockSymbol = string.Empty; CoordinatorSummary = string.Empty; @@ -182,6 +227,12 @@ private void ClearAllData() DisagreementAnalysis = string.Empty; HasConsensusAnalysis = false; HasDisagreementAnalysis = false; + DataCompletenessPercent = 0; + AnalystConsensusPercent = 0; + LimitationsNote = string.Empty; + QualityLevelText = string.Empty; + QualityLevelColor = string.Empty; + HasQualityMetrics = false; } } diff --git a/src/ViewModels/AnalystRoleViewModel.cs b/src/MarketAssistant.App/ViewModels/AnalystRoleViewModel.cs similarity index 100% rename from src/ViewModels/AnalystRoleViewModel.cs rename to src/MarketAssistant.App/ViewModels/AnalystRoleViewModel.cs diff --git a/src/MarketAssistant.App/ViewModels/AssetNavigationParameter.cs b/src/MarketAssistant.App/ViewModels/AssetNavigationParameter.cs new file mode 100644 index 0000000..1351e30 --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/AssetNavigationParameter.cs @@ -0,0 +1,34 @@ +namespace MarketAssistant.ViewModels; + +/// +/// 资产导航参数 +/// +public class AssetNavigationParameter +{ + public string Code { get; set; } + public string Name { get; set; } + + /// + /// 当前价格(可选)- 用于立即显示基本信息 + /// + public decimal? CurrentPrice { get; set; } + + /// + /// 涨跌幅(可选)- 用于立即显示基本信息 + /// + public decimal? ChangePercent { get; set; } + + public AssetNavigationParameter(string code, string name, decimal? currentPrice = null, decimal? changePercent = null) + { + Code = code; + Name = name; + CurrentPrice = currentPrice; + ChangePercent = changePercent; + } +} + + + + + + diff --git a/src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs b/src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs new file mode 100644 index 0000000..9ea17ac --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs @@ -0,0 +1,305 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using MarketAssistant.Applications.Charts; +using MarketAssistant.Applications.Charts.Models; +using MarketAssistant.Infrastructure; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Data; +using MarketAssistant.Services.Market; +using MarketAssistant.Services.Navigation; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Collections.ObjectModel; +using static MarketAssistant.Infrastructure.Core.CryptoSymbolConverter; + +namespace MarketAssistant.ViewModels; + +/// +/// 资产详情页ViewModel +/// +public partial class AssetPageViewModel : ViewModelBase, INavigationAware, IDisposable +{ + public override string Title => "资产详情"; + + private readonly IServiceProvider _serviceProvider; + private readonly MarketContext _marketContext; + private readonly BinanceWebSocketService _wsService; + private CancellationTokenSource? _loadingCancellationTokenSource; + + [ObservableProperty] + private KLineType _currentKLineType = KLineType.Daily; + + [ObservableProperty] + private string _assetCode = ""; + + [ObservableProperty] + private string _assetName = string.Empty; + + [ObservableProperty] + private string _errorMessage = string.Empty; + + [ObservableProperty] + private bool _hasError; + + [ObservableProperty] + private ObservableCollection _kLineData = new(); + + [ObservableProperty] + private decimal _currentPrice; + + [ObservableProperty] + private decimal _priceChangePercent; + + [ObservableProperty] + private decimal _priceChange; + + /// + /// 计算属性用于UI绑定 + /// + public bool IsMinuteSelected => CurrentKLineType == KLineType.Minute15; + public bool IsDailySelected => CurrentKLineType == KLineType.Daily; + public bool IsWeeklySelected => CurrentKLineType == KLineType.Weekly; + public bool IsMonthlySelected => CurrentKLineType == KLineType.Monthly; + + public IRelayCommand ChangeKLineTypeCommand { get; private set; } + public IRelayCommand NavigateToAnalysisCommand { get; private set; } + + public AssetPageViewModel( + ILogger logger, + IServiceProvider serviceProvider, + MarketContext marketContext, + BinanceWebSocketService wsService) : base(logger) + { + _serviceProvider = serviceProvider; + _marketContext = marketContext; + _wsService = wsService; + + ChangeKLineTypeCommand = new RelayCommand(ChangeKLineTypeAsync); + NavigateToAnalysisCommand = new RelayCommand(NavigateToAnalysisAsync); + } + + /// + /// 设置资产代码(异步加载数据,避免阻塞UI) + /// + private void SetAssetCode(string code) + { + AssetCode = code; + if (!string.IsNullOrEmpty(code)) + { + _ = LoadAssetDataAsync(code); + } + } + + /// + /// 当K线类型变化时通知相关UI属性 + /// + partial void OnCurrentKLineTypeChanged(KLineType value) + { + OnPropertyChanged(nameof(IsMinuteSelected)); + OnPropertyChanged(nameof(IsDailySelected)); + OnPropertyChanged(nameof(IsWeeklySelected)); + OnPropertyChanged(nameof(IsMonthlySelected)); + + if (!string.IsNullOrEmpty(AssetCode)) + { + _ = LoadAssetDataAsync(AssetCode); + } + } + + /// + /// 刷新资产数据 + /// + [RelayCommand] + private async Task RefreshDataAsync() + { + if (!string.IsNullOrEmpty(AssetCode)) + { + await SafeExecuteAsync(async () => await LoadAssetDataAsync(AssetCode), "刷新数据"); + } + } + + /// + /// 导航到资产分析页面 + /// + private void NavigateToAnalysisAsync() + { + if (string.IsNullOrEmpty(AssetCode)) + return; + + WeakReferenceMessenger.Default.Send(new NavigationMessage("Analysis", new AssetNavigationParameter(AssetCode, AssetName))); + } + + /// + /// 改变K线类型 + /// + private void ChangeKLineTypeAsync(string? type) + { + if (string.IsNullOrEmpty(type)) + return; + + var newKLineType = type.ToLower() switch + { + "minute" => KLineType.Minute15, + "daily" => KLineType.Daily, + "weekly" => KLineType.Weekly, + "monthly" => KLineType.Monthly, + _ => CurrentKLineType + }; + + if (newKLineType != CurrentKLineType) + { + CurrentKLineType = newKLineType; + } + } + + /// + /// 加载资产K线数据 + /// + private async Task LoadAssetDataAsync(string assetCode) + { + if (string.IsNullOrEmpty(assetCode)) + return; + + _loadingCancellationTokenSource?.Cancel(); + _loadingCancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = _loadingCancellationTokenSource.Token; + + IsBusy = true; + HasError = false; + ErrorMessage = string.Empty; + + try + { + var klineService = _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + var kLineDataList = await klineService.GetKLineDataAsync(assetCode, CurrentKLineType); + + cancellationToken.ThrowIfCancellationRequested(); + + // 在 UI 线程上更新 ObservableCollection,避免后台线程修改绑定属性 + await Dispatcher.UIThread.InvokeAsync(() => + { + KLineData = new ObservableCollection(kLineDataList); + CalculatePriceInfo(kLineDataList); + }); + } + catch (OperationCanceledException) + { + Logger?.LogInformation("资产 {AssetCode} 的K线数据加载已取消", assetCode); + } + catch (Exception ex) + { + HasError = true; + ErrorMessage = ErrorMessageMapper.GetUserFriendlyMessageWithContext(ex, "加载K线数据"); + Logger?.LogError(ex, "加载资产 {AssetCode} 的K线数据时发生错误", assetCode); + } + finally + { + IsBusy = false; + } + } + + /// + /// 计算价格相关信息 + /// + private void CalculatePriceInfo(List data) + { + if (data.Count == 0) + return; + + var latestData = data.Last(); + CurrentPrice = latestData.Close; + + if (data.Count > 1) + { + var previousData = data[data.Count - 2]; + PriceChange = latestData.Close - previousData.Close; + PriceChangePercent = previousData.Close != 0 ? + Math.Round((latestData.Close - previousData.Close) / previousData.Close * 100, 2) : 0; + } + else + { + PriceChange = 0; + PriceChangePercent = 0; + } + } + + public void OnNavigatedTo(AssetNavigationParameter parameter, bool isReactivation) + { + if (!string.IsNullOrEmpty(parameter.Code)) + { + // 1. 立即设置加载状态,确保骨架屏显示 + IsBusy = true; + HasError = false; + + // 2. 设置基本信息(立即显示) + AssetName = !string.IsNullOrEmpty(parameter.Name) ? parameter.Name : parameter.Code; + AssetCode = parameter.Code; + + // 3. 如果导航参数中包含价格信息,立即显示 + if (parameter.CurrentPrice.HasValue) + { + CurrentPrice = parameter.CurrentPrice.Value; + + if (parameter.ChangePercent.HasValue) + { + PriceChangePercent = parameter.ChangePercent.Value; + PriceChange = CurrentPrice * PriceChangePercent / 100; + } + } + else + { + // 清空旧数据 + CurrentPrice = 0; + PriceChangePercent = 0; + PriceChange = 0; + } + + // 4. 在后台线程加载完整数据(不阻塞导航) + // GoBack 重新激活时不重复加载,避免重复订阅 WebSocket 和重复请求 + if (!isReactivation) + { + _ = Task.Run(async () => await LoadAssetDataAsync(parameter.Code)); + + // 5. 虚拟币市场订阅 WebSocket 实时价格 + if (_marketContext.CurrentMarket == MarketType.Crypto) + { + // 订阅前先取消订阅,防止重复 + _wsService.PriceUpdated -= OnDetailPriceUpdated; + _wsService.PriceUpdated += OnDetailPriceUpdated; + _ = _wsService.SubscribeAsync([ToBinanceFormat(parameter.Code)]); + } + } + } + } + + private void OnDetailPriceUpdated(string symbol, decimal lastPrice, decimal changePercent) + { + if (!ToBinanceFormat(AssetCode).Equals(symbol, StringComparison.OrdinalIgnoreCase)) + return; + + Dispatcher.UIThread.InvokeAsync(() => + { + CurrentPrice = lastPrice; + PriceChangePercent = changePercent; + PriceChange = CurrentPrice * PriceChangePercent / 100; + }); + } + + public void OnNavigatedFrom() + { + _loadingCancellationTokenSource?.Cancel(); + _wsService.PriceUpdated -= OnDetailPriceUpdated; + } + + public void Dispose() + { + _loadingCancellationTokenSource?.Cancel(); + _loadingCancellationTokenSource?.Dispose(); + _wsService.PriceUpdated -= OnDetailPriceUpdated; + GC.SuppressFinalize(this); + } +} + + + diff --git a/src/ViewModels/StockSelectionPageViewModel.cs b/src/MarketAssistant.App/ViewModels/AssetSelectionPageViewModel.cs similarity index 68% rename from src/ViewModels/StockSelectionPageViewModel.cs rename to src/MarketAssistant.App/ViewModels/AssetSelectionPageViewModel.cs index 0da0b1d..560a7d5 100644 --- a/src/ViewModels/StockSelectionPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/AssetSelectionPageViewModel.cs @@ -1,12 +1,16 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; -using MarketAssistant.Applications.Stocks; -using MarketAssistant.Applications.StockSelection; -using MarketAssistant.Applications.StockSelection.Models; +using MarketAssistant.Applications.Favorites; +using MarketAssistant.Applications.InvestmentSelection; +using MarketAssistant.Applications.InvestmentSelection.Models; +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Services.Dialog; +using MarketAssistant.Services.Market; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System.Collections.ObjectModel; +using System.ComponentModel; namespace MarketAssistant.ViewModels; @@ -44,10 +48,11 @@ public enum SelectionModeType /// /// AI选股功能的ViewModel /// -public partial class StockSelectionPageViewModel : ViewModelBase +public partial class AssetSelectionPageViewModel : ViewModelBase, IDisposable { - private readonly StockSelectionService _stockSelectionService; - private readonly StockFavoriteService _stockFavoriteService; + private readonly InvestmentSelectionService _investmentSelectionService; + private readonly IServiceProvider _serviceProvider; + private readonly MarketContext _marketContext; private readonly IDialogService _dialogService; [ObservableProperty] @@ -63,7 +68,7 @@ public partial class StockSelectionPageViewModel : ViewModelBase private SelectionModeItem? _selectedMode; [ObservableProperty] - private StockSelectionResult? _selectionResult; + private InvestmentSelectionResult? _selectionResult; [ObservableProperty] private bool _hasResult; @@ -123,11 +128,13 @@ public string CurrentInputContent public bool IsQuickStrategyAreaVisible => SelectedMode?.ModeType == SelectionModeType.QuickStrategy; /// - /// 当前占位符文本 + /// 当前占位符文本,根据市场类型动态调整 /// public string CurrentPlaceholder => SelectedMode?.ModeType switch { - SelectionModeType.UserRequirement => "请描述您的选股需求,例如:寻找市值在100-500亿之间,PE低于20倍,近期涨幅不超过10%的价值股", + SelectionModeType.UserRequirement => _marketContext.CurrentMarket == MarketType.Crypto + ? "请描述您的选币需求,例如:寻找市值前50、24h涨幅超过5%、交易量活跃的虚拟币" + : "请描述您的选股需求,例如:寻找市值在100-500亿之间,PE低于20倍,近期涨幅不超过10%的价值股", SelectionModeType.NewsAnalysis => "请输入新闻内容或热点信息,例如:央行降准利好银行股,新能源汽车销量创新高等", _ => "请输入内容" }; @@ -143,12 +150,12 @@ public string CurrentInputContent }; /// - /// 推荐股票列表(用于UI绑定) + /// 推荐投资标的列表(用于UI绑定) /// - public ObservableCollection RecommendedStocks => + public ObservableCollection RecommendedStocks => SelectionResult?.Recommendations != null - ? new ObservableCollection(SelectionResult.Recommendations) - : new ObservableCollection(); + ? new ObservableCollection(SelectionResult.Recommendations) + : new ObservableCollection(); /// /// 格式化的风险提示文本(用于UI绑定) @@ -164,17 +171,63 @@ public string CurrentInputContent public bool HasRiskWarnings => SelectionResult?.RiskWarnings != null && SelectionResult.RiskWarnings.Count > 0; + /// + /// 加载提示文字,根据当前市场类型动态调整 + /// + public string LoadingText => _marketContext.CurrentMarket == MarketType.Crypto + ? "AI 正在为您精选优质虚拟币,请稍候..." + : "AI 正在为您精选优质股票,请稍候..."; + + /// + /// 页面标题,根据当前市场类型动态调整 + /// + public string PageTitle => _marketContext.CurrentMarket == MarketType.Crypto + ? "AI智能选币" + : "AI智能选股"; + + /// + /// 模式标签标题 + /// + public string ModeLabel => _marketContext.CurrentMarket == MarketType.Crypto ? "选币模式" : "选股模式"; + + /// + /// 快速策略标题 + /// + public string QuickStrategyTitle => _marketContext.CurrentMarket == MarketType.Crypto ? "快速选币策略" : "快速选股策略"; + + /// + /// 快速策略描述 + /// + public string QuickStrategyDescription => _marketContext.CurrentMarket == MarketType.Crypto + ? "选择预设的选币策略,快速获得投资建议" + : "选择预设的选股策略,快速获得投资建议"; + + /// + /// 结果区域标题 + /// + public string ResultTitle => _marketContext.CurrentMarket == MarketType.Crypto ? "选币结果" : "选股结果"; + + /// + /// 推荐列表标题 + /// + public string RecommendationTitle => _marketContext.CurrentMarket == MarketType.Crypto + ? "📈 推荐虚拟币" + : "📈 推荐股票"; + /// /// 构造函数(使用依赖注入) /// - public StockSelectionPageViewModel( - ILogger logger, - StockSelectionService stockSelectionService, - StockFavoriteService stockFavoriteService, + public AssetSelectionPageViewModel( + ILogger logger, + InvestmentSelectionService investmentSelectionService, + IServiceProvider serviceProvider, + MarketContext marketContext, IDialogService dialogService) : base(logger) { - _stockSelectionService = stockSelectionService; - _stockFavoriteService = stockFavoriteService; + _investmentSelectionService = investmentSelectionService; + _serviceProvider = serviceProvider; + _marketContext = marketContext; + SubscribeToMarketChanges(_marketContext); _dialogService = dialogService; _ = LoadQuickStrategiesAsync(); _ = LoadSelectionModesAsync(); @@ -192,24 +245,37 @@ partial void OnSelectedModeChanged(SelectionModeItem? value) } } - partial void OnSelectionResultChanged(StockSelectionResult? value) + partial void OnSelectionResultChanged(InvestmentSelectionResult? value) { OnPropertyChanged(nameof(RecommendedStocks)); OnPropertyChanged(nameof(FormattedRiskWarnings)); OnPropertyChanged(nameof(HasRiskWarnings)); } + protected override void OnMarketChanged(MarketType newMarket) + { + OnPropertyChanged(nameof(LoadingText)); + OnPropertyChanged(nameof(PageTitle)); + OnPropertyChanged(nameof(ModeLabel)); + OnPropertyChanged(nameof(QuickStrategyTitle)); + OnPropertyChanged(nameof(QuickStrategyDescription)); + OnPropertyChanged(nameof(ResultTitle)); + OnPropertyChanged(nameof(RecommendationTitle)); + OnPropertyChanged(nameof(CurrentPlaceholder)); + _ = LoadQuickStrategiesAsync(); + } + [RelayCommand] - private void ViewStockDetail(StockRecommendation? stock) + private void ViewStockDetail(InvestmentRecommendation? stock) { if (stock == null) return; WeakReferenceMessenger.Default.Send( - new NavigationMessage("Stock", new StockNavigationParameter(stock.Symbol, stock.Name))); + new NavigationMessage("Asset", new AssetNavigationParameter(stock.Symbol, stock.Name))); } [RelayCommand] - private async Task AddToFavorites(StockRecommendation? stock) + private async Task AddToFavorites(InvestmentRecommendation? stock) { if (stock == null) return; @@ -223,13 +289,14 @@ private async Task AddToFavorites(StockRecommendation? stock) code = stock.Symbol.Substring(2); } - if (_stockFavoriteService.IsFavorite(code, market)) + var favoriteService = _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + if (await favoriteService.IsFavoriteAsync(code, market)) { await _dialogService.ShowMessageAsync("提示", $"{stock.Name} ({stock.Symbol}) 已在自选列表中"); return; } - _stockFavoriteService.AddFavorite(code, market); + await favoriteService.AddFavoriteAsync(code, market); await _dialogService.ShowMessageAsync("成功", $"已将 {stock.Name} ({stock.Symbol}) 加入自选"); } @@ -284,7 +351,7 @@ private async Task ExecuteQuickSelectionAsync(QuickSelectionStrategyInfo? strate await SafeExecuteAsync(async () => { - var result = await _stockSelectionService.QuickSelectAsync(strategy.Strategy); + var result = await _investmentSelectionService.QuickSelectAsync(strategy.Strategy, _marketContext.CurrentMarket); SelectionResult = result; HasResult = result != null && ( (result.Recommendations?.Count ?? 0) > 0 || @@ -312,12 +379,13 @@ private async Task ExecuteSelectionAsync() ClearValidationMessage(); await SafeExecuteAsync(async () => { - var request = new StockRecommendationRequest + var request = new InvestmentRecommendationRequest { + MarketType = _marketContext.CurrentMarket, UserRequirements = UserRequirements }; - var result = await _stockSelectionService.RecommendStocksByUserRequirementAsync(request); + var result = await _investmentSelectionService.RecommendByUserRequirementAsync(request); SelectionResult = result; HasResult = result != null && ( (result.Recommendations?.Count ?? 0) > 0 || @@ -336,13 +404,14 @@ private async Task ExecuteNewsSelectionAsync() ClearValidationMessage(); await SafeExecuteAsync(async () => { - var request = new NewsBasedSelectionRequest + var request = new NewsBasedInvestmentRequest { + MarketType = _marketContext.CurrentMarket, NewsContent = NewsContent, MaxRecommendations = 5 }; - var result = await _stockSelectionService.RecommendStocksByNewsAsync(request); + var result = await _investmentSelectionService.RecommendByNewsAsync(request); SelectionResult = result; HasResult = result != null && ( (result.Recommendations?.Count ?? 0) > 0 || @@ -375,7 +444,7 @@ private async Task LoadQuickStrategiesAsync() { await SafeExecuteAsync(() => { - var strategies = _stockSelectionService.GetQuickSelectionStrategies(); + var strategies = _investmentSelectionService.GetQuickSelectionStrategies(_marketContext.CurrentMarket); Dispatcher.UIThread.InvokeAsync(() => { QuickStrategies.Clear(); @@ -411,4 +480,13 @@ await SafeExecuteAsync(() => return Task.CompletedTask; }, "加载选股模式"); } + + public void Dispose() + { + UnsubscribeFromMarketChanges(_marketContext); + GC.SuppressFinalize(this); + } } + + + diff --git a/src/ViewModels/ChatMessageAdapter.cs b/src/MarketAssistant.App/ViewModels/ChatMessageAdapter.cs similarity index 100% rename from src/ViewModels/ChatMessageAdapter.cs rename to src/MarketAssistant.App/ViewModels/ChatMessageAdapter.cs diff --git a/src/ViewModels/ChatSidebarViewModel.cs b/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs similarity index 61% rename from src/ViewModels/ChatSidebarViewModel.cs rename to src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs index 55ecf09..3fc3ef5 100644 --- a/src/ViewModels/ChatSidebarViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs @@ -2,7 +2,6 @@ using CommunityToolkit.Mvvm.Input; using MarketAssistant.Agents; using MarketAssistant.Infrastructure.Factories; -using MarketAssistant.Services.Mcp; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using System.Collections.ObjectModel; @@ -12,61 +11,38 @@ namespace MarketAssistant.ViewModels; /// /// 聊天侧边栏视图模型 /// -public partial class ChatSidebarViewModel : ViewModelBase +public partial class ChatSidebarViewModel : ViewModelBase, IDisposable { private readonly MarketChatSession _chatSession; - /// - /// 聊天消息集合 - /// - public ObservableCollection ChatMessages { get; } = new ObservableCollection(); + public ObservableCollection ChatMessages { get; } = []; - /// - /// 用户输入内容 - /// [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SendMessageCommand))] private string _userInput = string.Empty; - /// - /// 当前股票代码(用于上下文) - /// [ObservableProperty] private string _stockCode = string.Empty; - /// - /// 是否正在处理请求 - /// [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SendMessageCommand))] private bool _isProcessing = false; - /// - /// 发送按钮文本 - /// [ObservableProperty] private string _sendButtonText = "➤"; - /// - /// 当前取消令牌源 - /// private CancellationTokenSource? _currentCancellationTokenSource; - public IRelayCommand SendMessageCommand { get; } + public IAsyncRelayCommand SendMessageCommand { get; } public ChatSidebarViewModel( ILogger logger, - IChatClientFactory chatClientFactory, - ILoggerFactory loggerFactory, - McpService mcpService) + IMarketChatSessionFactory chatSessionFactory) : base(logger) { - // 创建新的聊天会话 - var chatClient = chatClientFactory.CreateClient(); - var sessionLogger = loggerFactory.CreateLogger(); - _chatSession = new MarketChatSession(chatClient, sessionLogger, mcpService); + _chatSession = chatSessionFactory.Create(); - SendMessageCommand = new RelayCommand(SendMessage, CanSendMessage); + SendMessageCommand = new AsyncRelayCommand(SendMessageAsync, CanSendMessage); } /// @@ -80,7 +56,7 @@ private bool CanSendMessage() /// /// 发送消息 /// - private async void SendMessage() + private async Task SendMessageAsync() { if (IsProcessing) { @@ -114,15 +90,15 @@ private async void SendMessage() await foreach (var chunk in _chatSession.SendMessageStreamAsync(currentInput, _currentCancellationTokenSource.Token)) { - if (!string.IsNullOrEmpty(chunk.Content)) + if (!string.IsNullOrEmpty(chunk)) { - contentBuilder.Append(chunk.Content); + contentBuilder.Append(chunk); if (!hasReceivedContent) { hasReceivedContent = true; aiMessage.Status = MessageStatus.Streaming; - aiMessage.Content = chunk.Content; + aiMessage.Content = chunk; } else { @@ -158,6 +134,7 @@ private async void SendMessage() { IsProcessing = false; SendButtonText = "➤"; + _currentCancellationTokenSource?.Dispose(); _currentCancellationTokenSource = null; } } @@ -176,28 +153,25 @@ private void AddWelcomeMessage() } /// - /// 初始化分析历史记录 + /// 使用分析结果初始化对话上下文。 + /// 分析结果会注入到 MAF 会话的系统指令中,同时在 UI 上展示。 /// - public async Task InitializeWithAnalysisHistory(string stockCode, IEnumerable analysisMessages) + public Task InitializeWithAnalysisHistory(string stockCode, IEnumerable analysisMessages) { StockCode = stockCode; - // 设置股票代码(不需要异步操作) - _chatSession.SetStockCode(stockCode); + var messages = analysisMessages.ToList(); + _chatSession.InjectAnalysisContext(stockCode, messages); ChatMessages.Clear(); bool hasVisibleMessages = false; - foreach (var message in analysisMessages) + foreach (var message in messages) { - if (!string.IsNullOrWhiteSpace(message.Text)) - { - _chatSession.AddAssistantMessage($"分析师观点:{message.Text}"); + if (string.IsNullOrWhiteSpace(message.Text)) continue; - var displayMessage = new ChatMessageAdapter(message); - ChatMessages.Add(displayMessage); - hasVisibleMessages = true; - } + ChatMessages.Add(new ChatMessageAdapter(message)); + hasVisibleMessages = true; } if (!hasVisibleMessages) @@ -206,36 +180,13 @@ public async Task InitializeWithAnalysisHistory(string stockCode, IEnumerable - /// 添加单条分析消息 - /// - public void AddAnalysisMessage(Microsoft.Extensions.AI.ChatMessage message) - { - if (string.IsNullOrWhiteSpace(message.Text)) - return; - - // 添加到会话上下文 - _chatSession.AddAssistantMessage($"分析师观点:{message.Text}"); - - // 添加到 UI 列表 - var displayMessage = new ChatMessageAdapter(message); - ChatMessages.Add(displayMessage); - } - - /// - /// 添加系统消息 - /// - public void AddSystemMessage(string content) - { - var systemMessage = new ChatMessageAdapter(new ChatMessage(ChatRole.System, content) { AuthorName = "系统" }); - ChatMessages.Add(systemMessage); - - _chatSession.AddAssistantMessage(content); + return Task.CompletedTask; } /// @@ -248,13 +199,15 @@ public void InitializeEmpty() } /// - /// 清空聊天历史 + /// 释放资源 /// - public void ClearChatHistory() + public void Dispose() { - ChatMessages.Clear(); - _chatSession.ClearHistory(); - AddWelcomeMessage(); + _chatSession.Dispose(); + _currentCancellationTokenSource?.Cancel(); + _currentCancellationTokenSource?.Dispose(); + _currentCancellationTokenSource = null; + GC.SuppressFinalize(this); } } diff --git a/src/MarketAssistant.App/ViewModels/Demo/ChatSidebarDemoViewModel.cs b/src/MarketAssistant.App/ViewModels/Demo/ChatSidebarDemoViewModel.cs new file mode 100644 index 0000000..d3a68c5 --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/Demo/ChatSidebarDemoViewModel.cs @@ -0,0 +1,338 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using MarketAssistant.Agents; +using MarketAssistant.Agents.MarketAnalysis.Models; +using MarketAssistant.Infrastructure.Factories; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using System.Text.Json.Serialization; + +namespace MarketAssistant.ViewModels.Demo; + +public partial class ChatSidebarDemoViewModel : ViewModelBase +{ + public override string Title => "Chat Demo"; + + [ObservableProperty] + private ChatSidebarViewModel _targetViewModel; + + private readonly JsonSerializerOptions _jsonOptions; + + public ChatSidebarDemoViewModel() : base(NullLogger.Instance) + { + TargetViewModel = new ChatSidebarViewModel( + NullLogger.Instance, + new MockMarketChatSessionFactory()); + + _jsonOptions = new JsonSerializerOptions + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter() } + }; + } + + [RelayCommand] + private void AddTextMessage(string content) + { + TargetViewModel.ChatMessages.Add(new ChatMessageAdapter(new ChatMessage(ChatRole.Assistant, content) { AuthorName = "AI Assistant" })); + } + + [RelayCommand] + private void AddUserMessage(string content) + { + TargetViewModel.ChatMessages.Add(new ChatMessageAdapter(new ChatMessage(ChatRole.User, content) { AuthorName = "User" })); + } + + [RelayCommand] + private void AddMarkdownMessage() + { + var markdown = @"# Markdown 测试 + +这是一个 **Markdown** 消息测试。 + +- 列表项 1 +- 列表项 2 + +```csharp +public void Hello() +{ + Console.WriteLine(""Hello World""); +} +``` + +[链接](https://www.google.com) +"; + TargetViewModel.ChatMessages.Add(new ChatMessageAdapter(new ChatMessage(ChatRole.Assistant, markdown) { AuthorName = "AI Assistant" })); + } + + [RelayCommand] + private void AddAdaptiveCard(string json) + { + TargetViewModel.ChatMessages.Add(new ChatMessageAdapter(new ChatMessage(ChatRole.Assistant, json) { AuthorName = "AI Assistant" })); + } + + [RelayCommand] + private void ClearMessages() + { + TargetViewModel.ChatMessages.Clear(); + } + + // Coordinator + public string CoordinatorCardJson => JsonSerializer.Serialize(new CoordinatorResult + { + OverallScore = 8.5f, + InvestmentRating = InvestmentRating.Buy, + TargetPrice = "55.00 - 60.00 元", + PriceChangeExpectation = "综合判断预计上涨 15-20%", + TimeHorizon = Duration.MediumTerm, + TimeHorizonDescription = "中期 6-12 个月", + RiskLevel = Level.Medium, + ConfidencePercentage = 85, + DimensionScores = new AnalysisDimensionScores + { + Fundamental = 8.0f, + Technical = 7.5f, + Financial = 8.0f, // Added Financial to be complete + Sentiment = 9.0f, + News = 8.5f + }, + InvestmentHighlights = new List { "业绩超预期", "技术面突破", "行业政策利好" }, + RiskFactors = new List { "宏观经济波动", "原材料价格上涨" }, + OperationSuggestions = new List { "建议在 50 元附近建仓", "止损位设在 45 元", "目标价 60 元分批止盈" }, + ConsensusAnalysis = "所有分析师均认为该公司基本面稳健,且近期有重大利好消息驱动,市场情绪高涨。", + DisagreementAnalysis = "技术分析师认为短期有回调风险,而基本面分析师认为长期价值被低估。综合来看,短期回调是买入机会。", + Summary = "基本面优秀,技术面配合,建议逢低买入。", + KeyIndicators = new List + { + new KeyIndicator { AnalystSource = "基本面分析师", Category = "财务数据", Name = "ROE", Value = "15.2%", Signal = "健康", Suggestion = "持续关注" }, + new KeyIndicator { AnalystSource = "技术分析师", Category = "技术指标", Name = "MACD", Value = "金叉", Signal = "买入", Suggestion = "右侧交易" } + } + }, _jsonOptions); + + // Financial + public string FinancialCardJson => JsonSerializer.Serialize(new FinancialAnalysisResult + { + HealthAssessment = new FinancialHealth + { + SolvencyScore = 8, + CurrentRatio = 2.5f, + QuickRatio = 1.8f, + SolvencyAssessment = "偿债能力强,流动资产充足。", + DebtRatio = 45.5f, + DebtRatioTrend = TrendChange.Stable, + DebtStructureAssessment = DebtStructureAssessment.Healthy, + OverallStability = FinancialStability.Strong, + StabilityScore = 9, + CoreInsight = "财务结构稳健,无重大债务风险。" + }, + ProfitQuality = new ProfitabilityQuality + { + GrossMargin = 30.5f, + NetMargin = 12.8f, + NetMarginTrend = TrendChange.Rising, + ProfitTrendAssessment = ProfitTrendAssessment.SteadyGrowth, + ROE = 15.2f, + ROA = 8.5f, + IndustryComparison = Level.High, + ProfitQualityLevel = Level.High, + ProfitQualityScore = 9, + ProfitSustainability = "核心业务盈利能力强,具有可持续性。" + }, + CashFlow = new CashFlowAssessment + { + OperatingCashFlow = 100000000, + CashFlowToNetIncomeRatio = 1.2f, + CashFlowQualityScore = 9, + FreeCashFlowStatus = FreeCashFlowStatus.Positive, + FreeCashFlowTrend = FreeCashFlowTrend.Improving, + FreeCashFlowSustainabilityScore = 8, + CashConversionCycle = 45, + CashConversionCycleTrend = TrendChange.Falling, + EfficiencyDescription = "营运资本管理效率高。" + }, + RiskWarning = new FinancialRiskWarning + { + KeyRiskIndicators = new List { "应收账款周转天数略有增加" }, + FraudRiskLevel = Level.Low, + FraudRiskScore = 2, + FraudRiskRationale = "财务报表逻辑自洽,无明显造假迹象。", + MonitoringPoints = new List { "关注原材料价格波动对毛利率的影响" } + } + }, _jsonOptions); + + // Fundamental + public string FundamentalCardJson => JsonSerializer.Serialize(new FundamentalAnalysisResult + { + BasicInfo = new StockBasicInfo { Symbol = "SH600000", Name = "浦发银行", CurrentPrice = 10.5m, DailyChangePercent = 1.2f, DailyChangeAmount = 0.12m }, + Fundamentals = new CompanyFundamentals + { + Industry = "银行业", + IndustryGrowthScore = 6, + CoreBusiness = "商业银行业务", + BusinessQualityScore = 8, + ProfitabilityOverview = "盈利能力稳定,分红率高。", + ProfitabilityTrend = ProfitabilityTrend.Average, + FinancialHealthOverview = "资本充足率达标,资产质量改善。", + CashFlowStatus = CashFlowStatus.Healthy + }, + Competition = new IndustryCompetitiveness + { + IndustryLifecycle = IndustryLifecycle.Maturity, + LifecycleConfidenceScore = 9, + MarketPosition = MarketPosition.SecondTier, + MarketShareDescription = "股份制银行前列。", + CoreCompetence = "长三角区域优势。", + CompetenceStrengthScore = 7, + BarrierLevel = Level.High, + BarrierDescription = "牌照壁垒和资金壁垒。" + }, + GrowthValue = new GrowthAndValue + { + GrowthDrivers = "零售转型和数字化赋能。", + GrowthSustainabilityScore = 7, + ValuationDescription = "PB 0.5倍,处于历史低位。", + InvestmentRating = InvestmentRating.Buy, + ValuationTarget = "合理估值修复至 0.7倍 PB。", + InvestmentHighlights = new List { "低估值高股息", "资产质量拐点" }, + KeyRisk = "宏观经济下行导致坏账增加。" + } + }, _jsonOptions); + + // News + public string NewsCardJson => JsonSerializer.Serialize(new NewsEventAnalysisResult + { + EventAnalysis = new EventInterpretation + { + EventType = EventType.Earnings, + EventSummary = "发布2024年三季度财报,净利润同比增长15%。", + InformationSource = InformationSource.Official, + CredibilityScore = 10, + EventNature = EventNature.Positive, + ImportanceScore = 8 + }, + ImpactEvaluation = new ImpactAssessment + { + FundamentalImpact = ImpactDirection.Positive, + FundamentalImpactScore = 8, + FundamentalImpactLogic = "业绩超预期验证了公司经营改善。", + SentimentImpact = ImpactDirection.Positive, + SentimentIntensityScore = 7, + SentimentChangeExpectation = "短期提振市场信心。", + ImpactScope = ImpactScope.CompanySpecific, + ImpactDuration = Duration.MediumTerm, + ExpectedTimeframe = "未来1-2个季度", + MarketExpectedReaction = MarketReactionExpectation.RationalReaction, + PriceChangeExpectation = PriceChangeExpectation.Rise, + CapitalFlowExpectation = CapitalFlowDirection.NetInflow, + CapitalScaleEstimate = "预计吸引中长期资金配置。" + }, + InvestmentGuidance = new InvestmentInsight + { + InvestmentImpactAssessment = InvestmentImpactAssessment.Opportunity, + CoreInvestmentLogic = "业绩拐点确认,估值有望修复。", + ResponseStrategy = OperationRecommendation.Buy, + SpecificActionAdvice = "建议逢低吸纳。", + FocusPoints = new List { "后续季度业绩持续性" }, + KeyRiskAlert = "宏观经济不及预期。" + } + }, _jsonOptions); + + // Sentiment + public string SentimentCardJson => JsonSerializer.Serialize(new MarketSentimentAnalysisResult + { + SentimentAssessment = new MarketSentiment + { + DominantEmotion = DominantEmotion.Greed, + EmotionIntensityScore = 7, + VIXLevel = "15.5", + InvestorConfidenceLevel = Level.High, + ConfidenceTrendDescription = "信心逐步回升。", + OverallAtmosphere = MarketAtmosphere.Optimistic, + AtmosphereIntensityScore = 8 + }, + CapitalFlowAnalysis = new CapitalFlow + { + MainCapitalFlow = CapitalFlowDirection.NetInflow, + MainCapitalAmount = 500000000, + MainCapitalConsecutiveDays = 3, + InstitutionTrend = InstitutionTrend.Increasing, + InstitutionPositionChange = "机构仓位小幅提升。", + NorthboundCapitalFlow = CapitalFlowDirection.NetInflow, + NorthboundCapitalAmount = 200000000, + NorthboundCapitalPercentage = 15.5f, + MarginFinancingChange = "融资余额增加。", + MarginTradingChange = "融券余额减少。", + LeverageDescription = "杠杆资金情绪回暖。" + }, + BehaviorAnalysis = new InvestorBehavior + { + MainBehaviorBias = BehaviorBias.HerdMentality, + BiasSeverityScore = 6, + RetailInvestorCharacteristics = RetailInvestorCharacteristics.ChasingRally, + RetailActivityScore = 8, + InstitutionBehaviorConsistency = BehaviorConsistency.Consistent, + InstitutionMainTrend = "一致看多。", + RiskPreference = RiskPreference.HighRisk, + RiskPreferenceChange = "风险偏好提升。" + }, + ShortTermStrategy = new ShortTermInsight + { + MarketRhythm = MarketRhythm.OneSidedTrend, + MarketRhythmRationale = "量价齐升,趋势向好。", + HotSectors = "科技、新能源。", + HotnessSustainabilityAssessment = "热点具有持续性。", + ShortTermOpportunities = "关注板块轮动机会。", + OperationRecommendation = OperationRecommendation.Buy, + PositionRecommendation = PositionRecommendation.Aggressive, + BestTiming = "回调即买入。", + TargetPriceRange = "短期看高一线。" + } + }, _jsonOptions); + + // Technical + public string TechnicalCardJson => JsonSerializer.Serialize(new TechnicalAnalysisResult + { + PatternTrend = new ChartPatternTrend + { + CurrentTrend = TrendDirection.Uptrend, + TrendStrengthScore = 8, + KeyPatterns = "突破箱体震荡。", + PatternReliabilityScore = 9, + TimeFrame = TimeFrame.Daily, + TimeFrameConsistencyScore = 8 + }, + PriceLevels = new KeyPriceLevels + { + CurrentPrice = 52.5m, + SupportLevels = new List { 50.0m, 48.5m }, + SupportStrengthScore = 8, + ResistanceLevels = new List { 55.0m, 58.0m }, + ResistanceStrengthScore = 7, + BreakoutDirection = BreakoutDirection.UpwardBreakout, + BreakoutProbabilityScore = 8 + }, + Indicators = new TechnicalIndicators + { + TrendIndicatorSignals = "均线多头排列。", + TrendIndicatorReliabilityScore = 9, + MomentumIndicatorSignals = "MACD金叉向上。", + MomentumIndicatorReliabilityScore = 8, + VolumeStatus = VolumeStatus.Expanding, + PriceVolumeRelationship = PriceVolumeRelationship.Healthy, + IndicatorConsistency = Level.High, + IndicatorSynergyDescription = "量价配合理想。" + }, + Strategy = new TradingStrategy + { + TechnicalRating = InvestmentRating.Buy, + OperationDirection = OperationRecommendation.Buy, + TargetPriceLow = 55.0m, + TargetPriceHigh = 58.0m, + StopLossPrice = 49.5m, + HoldingPeriod = Duration.ShortTerm, + RiskLevel = Level.Medium + } + }, _jsonOptions); + + +} diff --git a/src/MarketAssistant.App/ViewModels/Demo/MockChatClientFactory.cs b/src/MarketAssistant.App/ViewModels/Demo/MockChatClientFactory.cs new file mode 100644 index 0000000..bc3a0db --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/Demo/MockChatClientFactory.cs @@ -0,0 +1,61 @@ +using MarketAssistant.Agents; +using MarketAssistant.Infrastructure.Factories; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MarketAssistant.ViewModels.Demo; + +/// +/// 演示用的 MarketChatSession 工厂,使用 MockChatClient 生成模拟回复 +/// +internal class MockMarketChatSessionFactory : IMarketChatSessionFactory +{ + public MarketChatSession Create(string? initialStockCode = null) + { + var mockClient = new MockChatClient(); + return new MarketChatSession( + mockClient, + NullLogger.Instance, + initialStockCode: initialStockCode); + } +} + +/// +/// 模拟聊天客户端,用于演示场景返回固定回复 +/// +internal class MockChatClient : IChatClient +{ + public string Name => nameof(MockChatClient); + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "这是演示回复。")); + return Task.FromResult(response); + } + + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + return GetStreamingResponseCoreAsync(cancellationToken); + } + + private static async IAsyncEnumerable GetStreamingResponseCoreAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, "这是演示回复。"); + await Task.CompletedTask; + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return serviceType == typeof(IChatClient) ? this : null; + } + + public void Dispose() { } +} diff --git a/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs b/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs new file mode 100644 index 0000000..f06e8e9 --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs @@ -0,0 +1,253 @@ +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using MarketAssistant.Applications.Assets; +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Applications.Cache; +using MarketAssistant.Applications.Favorites; +using MarketAssistant.Infrastructure; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Data; +using MarketAssistant.Services.Dialog; +using MarketAssistant.Services.Market; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Collections.ObjectModel; +using System.ComponentModel; +using static MarketAssistant.Infrastructure.Core.CryptoSymbolConverter; + +namespace MarketAssistant.ViewModels; + +/// +/// 收藏页ViewModel +/// +public partial class FavoritesPageViewModel : ViewModelBase, IRecipient, IDisposable +{ + private readonly IServiceProvider _serviceProvider; + private readonly MarketContext _marketContext; + private readonly IDialogService _dialogService; + private readonly BinanceWebSocketService _wsService; + + /// + /// 用于取消上一次加载任务的 CTS,防止并发加载导致列表闪烁或重复项。 + /// 构造函数、市场切换、收藏变更消息都可能触发加载,需串行化。 + /// + private CancellationTokenSource? _loadCts; + + private IFavoriteService FavoriteService => + _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + + private IAssetInfoService AssetInfoService => + _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + + private IAssetCacheService CacheService => + _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + + public ObservableCollection Assets { get; set; } = new ObservableCollection(); + + /// + /// 构造函数 + /// + public FavoritesPageViewModel( + IServiceProvider serviceProvider, + MarketContext marketContext, + IDialogService dialogService, + BinanceWebSocketService wsService, + ILogger logger) + : base(logger) + { + _serviceProvider = serviceProvider; + _marketContext = marketContext; + _dialogService = dialogService; + _wsService = wsService; + _wsService.PriceUpdated += OnWebSocketPriceUpdated; + SubscribeToMarketChanges(_marketContext); + _ = LoadFavoriteAssetsAsync(); + WeakReferenceMessenger.Default.Register(this); + } + + /// + /// 市场切换时重新加载收藏列表 + /// + protected override void OnMarketChanged(MarketType newMarket) + { + _ = _wsService.UnsubscribeAllAsync(); + _ = LoadFavoriteAssetsAsync(); + } + + /// + /// 加载收藏资产列表 + /// + private async Task LoadFavoriteAssetsAsync() + { + // 取消上一次加载任务,避免并发加载导致列表闪烁或重复项 + _loadCts?.Cancel(); + _loadCts?.Dispose(); + _loadCts = new CancellationTokenSource(); + var ct = _loadCts.Token; + + await SafeExecuteAsync(async () => + { + var favoritesCodes = await FavoriteService.GetFavoritesCodesAsync(); + Assets.Clear(); + await UpdateAssetDataProgressivelyAsync(favoritesCodes, ct); + + // 虚拟币市场启用 WebSocket 实时推送 + if (_marketContext.CurrentMarket == MarketType.Crypto && Assets.Count > 0) + { + var symbols = Assets.Select(a => ToBinanceFormat(a.Code)).ToList(); + _ = _wsService.SubscribeAsync(symbols); + } + }, "加载收藏列表"); + } + + /// + /// 渐进式加载资产实时数据(限制并发数,避免同时打开过多浏览器页面) + /// + private async Task UpdateAssetDataProgressivelyAsync(List favorites, CancellationToken ct) + { + const int maxConcurrency = 3; // 最多同时请求3个资产数据 + using var semaphore = new SemaphoreSlim(maxConcurrency); + + var tasks = favorites.Select(async favorite => + { + ct.ThrowIfCancellationRequested(); + await semaphore.WaitAsync(ct); + try + { + // 先尝试从缓存获取 + var assetInfo = await CacheService.GetCachedAssetInfoAsync(favorite.Code); + + // 如果缓存中没有,则从网络获取 + if (assetInfo == null) + { + assetInfo = await AssetInfoService.GetAssetInfoAsync(favorite.Code, favorite.Market); + // 缓存获取到的数据 + if (assetInfo != null) + { + CacheService.CacheAssetInfo(favorite.Code, assetInfo); + } + } + + return assetInfo; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Logger?.LogError(ex, $"加载资产 {favorite.Code} 数据时出错"); + return null; + } + finally + { + semaphore.Release(); + } + }); + + var results = await Task.WhenAll(tasks); + + // 在UI线程上批量添加结果 + foreach (var assetInfo in results) + { + if (assetInfo != null) + { + Assets.Add(assetInfo); + } + } + } + + /// + /// 选择收藏资产 + /// + [RelayCommand] + private void SelectFavoriteAsset(AssetInfo? asset) + { + if (asset == null) return; + + // 解析价格信息 + decimal? currentPrice = decimal.TryParse(asset.CurrentPrice, out var price) ? price : null; + decimal? changePercent = decimal.TryParse(asset.ChangePercentage?.TrimEnd('%'), out var percent) ? percent : null; + + // 传递完整的基本信息,加速详情页显示 + WeakReferenceMessenger.Default.Send( + new NavigationMessage("Asset", new AssetNavigationParameter( + asset.Code, + asset.Name, + currentPrice, + changePercent))); + } + + /// + /// 移除收藏资产 + /// + [RelayCommand] + private async Task RemoveFavorite(AssetInfo? asset) + { + if (asset == null) return; + + // 显示确认对话框 + var confirmed = await _dialogService.ShowConfirmationAsync( + "取消收藏", + $"确定要取消收藏 {asset.Name}({asset.Code}) 吗?", + "确定", + "取消" + ); + + // 用户确认后才执行删除 + if (confirmed) + { + await SafeExecuteAsync(async () => + { + // 先从UI集合中移除(避免因消息触发重新加载导致的竞态条件) + var assetToRemove = Assets.FirstOrDefault(s => s.Code == asset.Code && s.Market == asset.Market); + if (assetToRemove != null) + { + Assets.Remove(assetToRemove); + } + + // 再从持久化存储中移除 + await FavoriteService.RemoveFavoriteAsync(asset.Code, asset.Market); + + Logger?.LogInformation($"已取消收藏资产: {asset.Name}({asset.Code})"); + await Task.CompletedTask; + }, "取消收藏"); + } + } + + /// + /// WebSocket 实时价格更新回调 + /// + private void OnWebSocketPriceUpdated(string symbol, decimal lastPrice, decimal changePercent) + { + Dispatcher.UIThread.InvokeAsync(() => + { + var asset = Assets.FirstOrDefault(a => + ToBinanceFormat(a.Code).Equals(symbol, StringComparison.OrdinalIgnoreCase)); + + if (asset == null) return; + + asset.CurrentPrice = lastPrice.ToString("G"); + asset.ChangePercentage = $"{changePercent:F2}%"; + }); + } + + /// + /// 接收收藏变更消息 + /// + public void Receive(AssetFavoritesChanged message) + { + _ = LoadFavoriteAssetsAsync(); + } + + public void Dispose() + { + _loadCts?.Cancel(); + _loadCts?.Dispose(); + UnsubscribeFromMarketChanges(_marketContext); + _wsService.PriceUpdated -= OnWebSocketPriceUpdated; + _ = _wsService.UnsubscribeAllAsync(); + WeakReferenceMessenger.Default.UnregisterAll(this); + GC.SuppressFinalize(this); + } +} diff --git a/src/ViewModels/Home/HomeSearchViewModel.cs b/src/MarketAssistant.App/ViewModels/Home/HomeSearchViewModel.cs similarity index 67% rename from src/ViewModels/Home/HomeSearchViewModel.cs rename to src/MarketAssistant.App/ViewModels/Home/HomeSearchViewModel.cs index 950c8b7..f87b864 100644 --- a/src/ViewModels/Home/HomeSearchViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Home/HomeSearchViewModel.cs @@ -1,7 +1,9 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using MarketAssistant.Applications.Stocks; -using MarketAssistant.Applications.Stocks.Models; +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Applications.Home; +using MarketAssistant.Services.Market; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System.Collections.ObjectModel; @@ -10,12 +12,16 @@ namespace MarketAssistant.ViewModels.Home; /// /// 主页搜索功能ViewModel /// -public partial class HomeSearchViewModel : ViewModelBase +public partial class HomeSearchViewModel : ViewModelBase, IDisposable { - private readonly IHomeStockService _homeStockService; + private readonly IServiceProvider _serviceProvider; + private readonly MarketContext _marketContext; private CancellationTokenSource? _debounceCts; private const int DebounceDelayMs = 200; + private IHomeAssetService HomeAssetService => + _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + [ObservableProperty] private string _searchQuery = string.Empty; @@ -28,36 +34,25 @@ public partial class HomeSearchViewModel : ViewModelBase /// /// 搜索结果集合 /// - public ObservableCollection SearchResults { get; } = new(); + public ObservableCollection SearchResults { get; } = new(); /// - /// 搜索命令 + /// 资产选择事件 /// - public IAsyncRelayCommand SearchCommand { get; } - - /// - /// 选择股票命令 - /// - public IRelayCommand SelectStockCommand { get; } - - /// - /// 股票选择事件 - /// - public event EventHandler? StockSelected; + public event EventHandler? AssetSelected; public HomeSearchViewModel( - IHomeStockService homeStockService, + IServiceProvider serviceProvider, + MarketContext marketContext, ILogger logger) : base(logger) { - _homeStockService = homeStockService; - - SearchCommand = new AsyncRelayCommand(OnSearchAsync); - SelectStockCommand = new RelayCommand(OnSelectStock); + _serviceProvider = serviceProvider; + _marketContext = marketContext; } /// - /// 当 SearchQuery 变化时自动触发搜索(带500毫秒防抖) + /// 当 SearchQuery 变化时自动触发搜索(带200毫秒防抖) /// partial void OnSearchQueryChanged(string value) { @@ -89,7 +84,7 @@ partial void OnSearchQueryChanged(string value) // 如果没有被取消,执行搜索 if (!cancellationToken.IsCancellationRequested) { - await OnSearchAsync(value); + await SearchAsync(value); } } catch (TaskCanceledException) @@ -99,7 +94,7 @@ partial void OnSearchQueryChanged(string value) } catch (Exception ex) { - Logger?.LogError(ex, "搜索股票时发生错误,查询:{Query}", value); + Logger?.LogError(ex, "搜索资产时发生错误,查询:{Query}", value); } }, cancellationToken); } @@ -107,7 +102,8 @@ partial void OnSearchQueryChanged(string value) /// /// 执行搜索 /// - private async Task OnSearchAsync(string? query) + [RelayCommand] + private async Task SearchAsync(string? query) { if (string.IsNullOrWhiteSpace(query)) { @@ -118,12 +114,12 @@ private async Task OnSearchAsync(string? query) return; } - Logger?.LogInformation("开始搜索股票,查询:{Query}", query); + Logger?.LogInformation("开始搜索资产,查询:{Query}", query); IsSearching = true; await SafeExecuteAsync(async () => { - var results = await _homeStockService.SearchStockAsync(query, CancellationToken.None); + var results = await HomeAssetService.SearchAssetAsync(query, CancellationToken.None); Logger?.LogInformation("搜索完成,找到 {Count} 个结果", results.Count); @@ -131,36 +127,37 @@ await SafeExecuteAsync(async () => await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => { SearchResults.Clear(); - foreach (var stock in results) + foreach (var asset in results) { - SearchResults.Add(stock); - Logger?.LogDebug("添加搜索结果:{Name} ({Code})", stock.Name, stock.Code); + SearchResults.Add(asset); + Logger?.LogDebug("添加搜索结果:{Name} ({Code})", asset.Name, asset.Code); } IsSearchResultVisible = SearchResults.Count > 0; }); - + if (results.Count == 0) { - Logger?.LogWarning("未找到匹配的股票,查询:{Query}", query); + Logger?.LogWarning("未找到匹配的资产,查询:{Query}", query); } - }, "搜索股票"); + }, "搜索资产"); IsSearching = false; } /// - /// 选择股票 + /// 选择资产 /// - private void OnSelectStock(StockItem? stock) + [RelayCommand] + private void NavigateToAsset(AssetItem? asset) { - if (stock == null) return; + if (asset == null) return; // 隐藏搜索结果 IsSearchResultVisible = false; // 通知父ViewModel - StockSelected?.Invoke(this, stock); + AssetSelected?.Invoke(this, asset); } /// @@ -178,4 +175,15 @@ public void ClearSearch() IsSearchResultVisible = false; IsSearching = false; } + + /// + /// 释放资源 + /// + public void Dispose() + { + _debounceCts?.Cancel(); + _debounceCts?.Dispose(); + _debounceCts = null; + GC.SuppressFinalize(this); + } } diff --git a/src/MarketAssistant.App/ViewModels/Home/HotAssetsViewModel.cs b/src/MarketAssistant.App/ViewModels/Home/HotAssetsViewModel.cs new file mode 100644 index 0000000..06fc45d --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/Home/HotAssetsViewModel.cs @@ -0,0 +1,115 @@ +using CommunityToolkit.Mvvm.Input; +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Applications.Home; +using MarketAssistant.Services.Market; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Collections.ObjectModel; +using System.ComponentModel; + +namespace MarketAssistant.ViewModels.Home; + +/// +/// 热门资产ViewModel +/// +public partial class HotAssetsViewModel : ViewModelBase, IDisposable +{ + private readonly IServiceProvider _serviceProvider; + private readonly MarketContext _marketContext; + + private IHomeAssetService HomeAssetService => + _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + + /// + /// 热门资产集合 + /// + public ObservableCollection HotAssets { get; } = new(); + + /// + /// 热门资产选择事件 + /// + public event EventHandler? HotAssetSelected; + + public HotAssetsViewModel( + IServiceProvider serviceProvider, + MarketContext marketContext, + ILogger logger) + : base(logger) + { + _serviceProvider = serviceProvider; + _marketContext = marketContext; + + // 订阅市场切换事件 + SubscribeToMarketChanges(_marketContext); + + // 自动加载热门资产 + _ = LoadHotAssetsAsync(); + } + + /// + /// 市场切换时重新加载热门资产 + /// + protected override void OnMarketChanged(MarketType newMarket) + { + _ = LoadHotAssetsAsync(); + } + + /// + /// 加载热门资产 + /// + [RelayCommand] + private async Task LoadHotAssetsAsync() + { + await SafeExecuteAsync(async () => + { + var hotAssets = await HomeAssetService.GetHotAssetsAsync(); + + HotAssets.Clear(); + foreach (var asset in hotAssets) + { + HotAssets.Add(asset); + } + }, "加载热门资产"); + } + + /// + /// 选择热门资产 + /// + [RelayCommand] + private void SelectHotAsset(HotAsset? asset) + { + if (asset == null) return; + + // 通知父ViewModel + HotAssetSelected?.Invoke(this, asset); + } + + /// + /// 添加到收藏 + /// + [RelayCommand] + private async Task AddToFavoriteAsync(HotAsset? asset) + { + if (asset == null) return; + + await SafeExecuteAsync(async () => + { + await HomeAssetService.AddToFavoriteAsync(asset); + }, "添加收藏"); + } + + /// + /// 释放资源 + /// + public void Dispose() + { + UnsubscribeFromMarketChanges(_marketContext); + GC.SuppressFinalize(this); + } +} + + + + + + diff --git a/src/MarketAssistant.App/ViewModels/Home/RecentAssetsViewModel.cs b/src/MarketAssistant.App/ViewModels/Home/RecentAssetsViewModel.cs new file mode 100644 index 0000000..8a601cd --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/Home/RecentAssetsViewModel.cs @@ -0,0 +1,131 @@ +using CommunityToolkit.Mvvm.Input; +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Applications.History; +using MarketAssistant.Applications.Home; +using MarketAssistant.Services.Market; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Collections.ObjectModel; +using System.ComponentModel; + +namespace MarketAssistant.ViewModels.Home; + +/// +/// 最近查看资产ViewModel +/// +public partial class RecentAssetsViewModel : ViewModelBase, IDisposable +{ + private readonly IServiceProvider _serviceProvider; + private readonly MarketContext _marketContext; + + private IAssetHistoryService HistoryService => + _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + + private IHomeAssetService HomeAssetService => + _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + + /// + /// 最近查看资产集合 + /// + public ObservableCollection RecentAssets { get; } = new(); + + /// + /// 最近资产选择事件 + /// + public event EventHandler? RecentAssetSelected; + + public RecentAssetsViewModel( + IServiceProvider serviceProvider, + MarketContext marketContext, + ILogger logger) + : base(logger) + { + _serviceProvider = serviceProvider; + _marketContext = marketContext; + + // 订阅市场切换事件 + SubscribeToMarketChanges(_marketContext); + + // 自动加载最近资产 + _ = LoadRecentAssetsAsync(); + } + + /// + /// 市场切换时重新加载最近查看资产 + /// + protected override void OnMarketChanged(MarketType newMarket) + { + _ = LoadRecentAssetsAsync(); + } + + /// + /// 加载最近查看资产 + /// + [RelayCommand] + private async Task LoadRecentAssetsAsync() + { + await SafeExecuteAsync(async () => + { + var recentAssets = await HistoryService.GetHistoryAsync(); + + RecentAssets.Clear(); + foreach (var asset in recentAssets) + { + RecentAssets.Add(asset); + } + }, "加载最近查看资产"); + } + + /// + /// 添加资产到最近查看 + /// + public async Task AddToRecentAssetsAsync(AssetItem asset) + { + await SafeExecuteAsync(async () => + { + await HistoryService.AddHistoryAsync(asset); + await LoadRecentAssetsAsync(); + }, "添加到最近查看"); + } + + /// + /// 选择最近资产 + /// + [RelayCommand] + private void SelectRecentAsset(AssetItem? asset) + { + if (asset == null) return; + + // 通知父ViewModel + RecentAssetSelected?.Invoke(this, asset); + } + + /// + /// 添加到收藏 + /// + [RelayCommand] + private async Task AddToFavoriteAsync(AssetItem? asset) + { + if (asset == null) return; + + await SafeExecuteAsync(async () => + { + await HomeAssetService.AddToFavoriteAsync(asset); + }, "添加收藏"); + } + + /// + /// 释放资源 + /// + public void Dispose() + { + UnsubscribeFromMarketChanges(_marketContext); + GC.SuppressFinalize(this); + } +} + + + + + + diff --git a/src/ViewModels/Home/TelegraphNewsViewModel.cs b/src/MarketAssistant.App/ViewModels/Home/TelegraphNewsViewModel.cs similarity index 60% rename from src/ViewModels/Home/TelegraphNewsViewModel.cs rename to src/MarketAssistant.App/ViewModels/Home/TelegraphNewsViewModel.cs index 48b41dd..9a8d8d1 100644 --- a/src/ViewModels/Home/TelegraphNewsViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Home/TelegraphNewsViewModel.cs @@ -2,8 +2,10 @@ using CommunityToolkit.Mvvm.Input; using MarketAssistant.Applications.News; using MarketAssistant.Applications.Telegrams; +using MarketAssistant.Services.Market; using Microsoft.Extensions.Logging; using System.Collections.ObjectModel; +using System.ComponentModel; using System.Diagnostics; namespace MarketAssistant.ViewModels.Home; @@ -13,7 +15,9 @@ namespace MarketAssistant.ViewModels.Home; /// public partial class TelegraphNewsViewModel : ViewModelBase, IDisposable { - private readonly INewsUpdateService _newsUpdateService; + private readonly IServiceProvider _serviceProvider; + private readonly MarketContext _marketContext; + private INewsUpdateService _newsUpdateService; private bool _disposed; [ObservableProperty] @@ -29,39 +33,61 @@ public partial class TelegraphNewsViewModel : ViewModelBase, IDisposable /// public IAsyncRelayCommand OpenNewsCommand { get; } - /// - /// 刷新新闻命令 - /// - public IRelayCommand RefreshCommand { get; } - - /// - /// 启动更新命令 - /// - public IRelayCommand StartUpdatesCommand { get; } - - /// - /// 停止更新命令 - /// - public IRelayCommand StopUpdatesCommand { get; } - - public TelegraphNewsViewModel(INewsUpdateService newsUpdateService, ILogger logger) + public TelegraphNewsViewModel( + IServiceProvider serviceProvider, + MarketContext marketContext, + ILogger logger) : base(logger) { - _newsUpdateService = newsUpdateService; + _serviceProvider = serviceProvider; + _marketContext = marketContext; + + // 根据当前市场类型获取对应的 NewsUpdateService + _newsUpdateService = _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); OpenNewsCommand = new AsyncRelayCommand(OnOpenNewsAsync); - RefreshCommand = new RelayCommand(() => _newsUpdateService.StartUpdates()); - StartUpdatesCommand = new RelayCommand(_newsUpdateService.StartUpdates); - StopUpdatesCommand = new RelayCommand(_newsUpdateService.StopUpdates); // 订阅新闻更新服务事件 _newsUpdateService.NewsUpdated += OnNewsUpdated; _newsUpdateService.CountdownUpdated += OnCountdownUpdated; + // 订阅市场切换事件 + SubscribeToMarketChanges(_marketContext); + // 自动启动新闻更新 _newsUpdateService.StartUpdates(); } + /// + /// 市场切换时更换新闻服务 + /// + protected override void OnMarketChanged(MarketType newMarket) + { + Dispatcher.UIThread.InvokeAsync(() => + { + // 停止旧服务并取消事件订阅 + _newsUpdateService.StopUpdates(); + _newsUpdateService.NewsUpdated -= OnNewsUpdated; + _newsUpdateService.CountdownUpdated -= OnCountdownUpdated; + + // 获取新市场的服务 + _newsUpdateService = _serviceProvider.GetRequiredKeyedService(newMarket); + + // 订阅新服务事件 + _newsUpdateService.NewsUpdated += OnNewsUpdated; + _newsUpdateService.CountdownUpdated += OnCountdownUpdated; + + // 清空旧新闻 + Telegraphs.Clear(); + TelegraphRefreshCountdown = ""; + + // 始终启动新闻更新 + _newsUpdateService.StartUpdates(); + + Logger?.LogInformation("已切换到 {Market} 市场新闻源", newMarket); + }); + } + /// /// 处理新闻更新事件 /// @@ -111,29 +137,6 @@ await SafeExecuteAsync(async () => }, "打开新闻"); } - /// - /// 启动新闻更新 - /// - public void StartUpdates() - { - _newsUpdateService.StartUpdates(); - Logger?.LogInformation("新闻更新服务已启动"); - } - - /// - /// 停止新闻更新 - /// - public void StopUpdates() - { - _newsUpdateService.StopUpdates(); - Logger?.LogInformation("新闻更新服务已停止"); - } - - /// - /// 是否正在更新 - /// - public bool IsUpdating => _newsUpdateService.IsRunning; - /// /// 释放资源 /// @@ -141,7 +144,10 @@ public void Dispose() { if (!_disposed) { - // 取消事件订阅 + // 取消市场切换事件订阅 + UnsubscribeFromMarketChanges(_marketContext); + + // 取消新闻服务事件订阅 _newsUpdateService.NewsUpdated -= OnNewsUpdated; _newsUpdateService.CountdownUpdated -= OnCountdownUpdated; diff --git a/src/MarketAssistant.App/ViewModels/HomePageViewModel.cs b/src/MarketAssistant.App/ViewModels/HomePageViewModel.cs new file mode 100644 index 0000000..03af16f --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/HomePageViewModel.cs @@ -0,0 +1,137 @@ +using CommunityToolkit.Mvvm.Messaging; +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.ViewModels.Home; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.ViewModels; + +/// +/// 首页ViewModel +/// +public partial class HomePageViewModel : ViewModelBase, IDisposable +{ + /// + /// 搜索功能ViewModel + /// + public HomeSearchViewModel Search { get; } + + /// + /// 热门资产ViewModel + /// + public HotAssetsViewModel HotAssets { get; } + + /// + /// 最近查看ViewModel + /// + public RecentAssetsViewModel RecentAssets { get; } + + /// + /// 新闻快讯ViewModel + /// + public TelegraphNewsViewModel News { get; } + + /// + /// 构造函数(使用依赖注入) + /// + public HomePageViewModel( + HomeSearchViewModel searchViewModel, + HotAssetsViewModel hotAssetsViewModel, + RecentAssetsViewModel recentAssetsViewModel, + TelegraphNewsViewModel newsViewModel, + ILogger logger) : base(logger) + { + Search = searchViewModel; + HotAssets = hotAssetsViewModel; + RecentAssets = recentAssetsViewModel; + News = newsViewModel; + + // 订阅子ViewModel事件 + Search.AssetSelected += OnAssetSelected; + HotAssets.HotAssetSelected += OnHotAssetSelected; + RecentAssets.RecentAssetSelected += OnRecentAssetSelected; + } + + /// + /// 处理搜索资产选择事件 + /// + private void OnAssetSelected(object? sender, AssetItem asset) + { + NavigateToAsset(asset); + } + + /// + /// 处理热门资产选择事件 + /// + private void OnHotAssetSelected(object? sender, HotAsset asset) + { + // 根据市场类型决定是否拼接市场代码 + var assetCode = asset.MarketType == Infrastructure.Core.MarketType.Crypto + ? asset.Code.ToLower() + : $"{asset.Market}{asset.Code}".ToLower(); + + // 解析价格信息 + decimal? currentPrice = decimal.TryParse(asset.CurrentPrice, out var price) ? price : null; + decimal? changePercent = decimal.TryParse(asset.ChangePercentage?.TrimEnd('%'), out var percent) ? percent : null; + + // 传递完整的基本信息,避免详情页等待 + var parameter = new AssetNavigationParameter( + assetCode, + asset.Name, + currentPrice, + changePercent + ); + + WeakReferenceMessenger.Default.Send(new NavigationMessage("Asset", parameter)); + Logger?.LogInformation($"导航到资产详情页: {assetCode}"); + + // 异步添加到最近查看 + var assetItem = new AssetItem { Name = asset.Name, Code = assetCode }; + _ = RecentAssets.AddToRecentAssetsAsync(assetItem); + } + + /// + /// 处理最近资产选择事件 + /// + private void OnRecentAssetSelected(object? sender, AssetItem asset) + { + NavigateToAsset(asset); + } + + /// + /// 导航到资产详情页 + /// + private void NavigateToAsset(AssetItem assetItem, decimal? currentPrice = null, decimal? changePercent = null) + { + // 立即发送导航消息,不阻塞UI + WeakReferenceMessenger.Default.Send( + new NavigationMessage("Asset", new AssetNavigationParameter( + assetItem.Code, + assetItem.Name, + currentPrice, + changePercent))); + + Logger?.LogInformation($"导航到资产详情页: {assetItem.Code}"); + + // 异步添加到最近查看,不阻塞导航 + _ = RecentAssets.AddToRecentAssetsAsync(assetItem); + } + + /// + /// 释放资源 + /// + public void Dispose() + { + // 取消事件订阅 + Search.AssetSelected -= OnAssetSelected; + HotAssets.HotAssetSelected -= OnHotAssetSelected; + RecentAssets.RecentAssetSelected -= OnRecentAssetSelected; + + // 释放子ViewModel资源 + Search.Dispose(); + HotAssets.Dispose(); + RecentAssets.Dispose(); + News.Dispose(); + + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/src/ViewModels/MCPConfigPageViewModel.cs b/src/MarketAssistant.App/ViewModels/MCPConfigPageViewModel.cs similarity index 87% rename from src/ViewModels/MCPConfigPageViewModel.cs rename to src/MarketAssistant.App/ViewModels/MCPConfigPageViewModel.cs index 499686d..7c2706a 100644 --- a/src/ViewModels/MCPConfigPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/MCPConfigPageViewModel.cs @@ -1,6 +1,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using MarketAssistant.Applications.Settings; +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Services.Dialog; using MarketAssistant.Services.Mcp; using MarketAssistant.Services.Navigation; @@ -21,6 +22,7 @@ public partial class MCPConfigPageViewModel : ViewModelBase, INavigationAware private readonly INotificationService _notificationService; private readonly IDialogService _dialogService; private readonly McpService _mcpService; + private readonly McpToolContextProvider _mcpToolProvider; [ObservableProperty] private ObservableCollection _serverConfigs = new(); @@ -62,16 +64,19 @@ public partial class MCPConfigPageViewModel : ViewModelBase, INavigationAware private MCPServerConfig? _editingConfig; public MCPConfigPageViewModel( + MCPServerConfigService configService, INotificationService notificationService, IDialogService dialogService, McpService mcpService, + McpToolContextProvider mcpToolProvider, ILogger? logger) : base(logger) { - _configService = MCPServerConfigService.Instance; + _configService = configService; _notificationService = notificationService; _dialogService = dialogService; _mcpService = mcpService; + _mcpToolProvider = mcpToolProvider; LoadServerConfigs(); } @@ -92,7 +97,7 @@ private void LoadServerConfigs() catch (Exception ex) { Logger?.LogError(ex, "加载MCP服务器配置失败"); - _notificationService?.ShowError($"加载配置失败: {ex.Message}"); + _notificationService?.ShowError(ErrorMessageMapper.GetUserFriendlyMessageWithContext(ex, "加载配置")); } } @@ -134,7 +139,9 @@ private void EditServer() Command = SelectedConfig.Command, Arguments = SelectedConfig.Arguments, IsEnabled = SelectedConfig.IsEnabled, - EnvironmentVariables = SelectedConfig.EnvironmentVariables + EnvironmentVariables = new Dictionary(SelectedConfig.EnvironmentVariables), + Category = SelectedConfig.Category, + AllowedTools = [.. SelectedConfig.AllowedTools] }; LoadConfigToUI(_editingConfig); IsEditing = true; @@ -169,8 +176,9 @@ private void SaveServer() // 保存到服务 _configService.AddOrUpdateConfig(_editingConfig); - // 刷新列表 + // 刷新列表并使工具缓存失效 LoadServerConfigs(); + _mcpToolProvider.Invalidate(); IsEditing = false; _editingConfig = null; @@ -180,7 +188,7 @@ private void SaveServer() catch (Exception ex) { Logger?.LogError(ex, "保存MCP服务器配置失败"); - _notificationService?.ShowError($"保存失败: {ex.Message}"); + _notificationService?.ShowError(ErrorMessageMapper.GetUserFriendlyMessageWithContext(ex, "保存配置")); } } @@ -231,26 +239,17 @@ private async Task TestConnection() Command = Command, Arguments = Arguments, IsEnabled = true, - EnvironmentVariables = ParseEnvironmentVariables() + EnvironmentVariables = ParseEnvironmentVariables(), + Category = _editingConfig.Category, + AllowedTools = [.. _editingConfig.AllowedTools] }; // 设置超时 using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - // 尝试连接并获取工具列表 - // 使用注入的服务或创建临时实例(仅用于测试) - var service = _mcpService; - var shouldDispose = false; - - if (service == null) - { - service = new McpService(); - shouldDispose = true; - } - try { - var tools = await service.GetAIToolsAsync([testConfig]); + var tools = await _mcpService.GetAIToolsAsync([testConfig]).WaitAsync(cts.Token); var toolCount = tools.Count; if (toolCount > 0) @@ -274,18 +273,10 @@ private async Task TestConnection() } catch (Exception ex) { - TestStatus = $"连接失败: {ex.Message}"; - _notificationService?.ShowError($"连接失败: {ex.Message}"); + TestStatus = "连接失败"; + _notificationService?.ShowError(ErrorMessageMapper.GetUserFriendlyMessageWithContext(ex, "连接")); Logger?.LogError(ex, "MCP服务器测试连接失败: {Name}", Name); } - finally - { - // 如果是临时创建的服务,需要释放 - if (shouldDispose && service != null) - { - await service.DisposeAsync(); - } - } } finally { @@ -314,6 +305,7 @@ private async Task DeleteServer() { _configService.DeleteConfig(SelectedConfig.Id); LoadServerConfigs(); + _mcpToolProvider.Invalidate(); IsEditing = false; _notificationService?.ShowSuccess("删除成功"); } @@ -399,7 +391,13 @@ public void OnNavigatedFrom() public void OnNavigatedTo(object? parameter) { - // 每次进入页面时刷新配置列表,确保数据最新 + // 默认调用:首次进入 + OnNavigatedTo(parameter, isReactivation: false); + } + + public void OnNavigatedTo(object? parameter, bool isReactivation) + { + // 首次进入时加载配置;GoBack 重新激活时也刷新,确保数据最新 LoadServerConfigs(); } } diff --git a/src/MarketAssistant.App/ViewModels/MainWindowViewModel.cs b/src/MarketAssistant.App/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..982f9f0 --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,177 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using MarketAssistant.Services.Market; +using MarketAssistant.Services.Navigation; +using MarketAssistant.Services.Notification; +using MarketAssistant.ViewModels.Demo; +using MarketAssistant.ViewModels.Trading; +using Microsoft.Extensions.Logging; +using System.Collections.ObjectModel; +using System.ComponentModel; + +namespace MarketAssistant.ViewModels; + +public partial class MainWindowViewModel : ViewModelBase +{ + private readonly IServiceProvider _serviceProvider; + private readonly NavigationService _navigationService; + private readonly MarketContext _marketContext; + private readonly INotificationService _notificationService; + + [ObservableProperty] + private NavigationItemViewModel? _selectedNavigationItem; + + public ViewModelBase? CurrentPage => _navigationService.CurrentPage; + public bool CanGoBack => _navigationService.CanGoBack; + public string CurrentPageTitle => _navigationService.CurrentPage?.Title ?? string.Empty; + + public ObservableCollection NavigationItems { get; } + + /// + /// 当前市场类型显示文本 + /// + public string CurrentMarketText => _marketContext.CurrentMarket == MarketType.AShare ? "A股市场" : "虚拟币市场"; + + public MainWindowViewModel( + IServiceProvider serviceProvider, + NavigationService navigationService, + MarketContext marketContext, + INotificationService notificationService, + ILogger? logger = null) + : base(logger) + { + _serviceProvider = serviceProvider; + _navigationService = navigationService; + _marketContext = marketContext; + _notificationService = notificationService; + + NavigationItems = new ObservableCollection(); + RebuildNavigationItems(); + + // 监听导航服务属性变更 + _navigationService.PropertyChanged += OnNavigationServicePropertyChanged; + + // 监听市场切换事件 + SubscribeToMarketChanges(_marketContext); + + // 默认导航到首页 + SelectedNavigationItem = NavigationItems[0]; + var homeViewModel = SelectedNavigationItem.CreateViewModel(); + _navigationService.NavigateToRoot(homeViewModel, SelectedNavigationItem.Title); + } + + protected override void OnMarketChanged(MarketType newMarket) + { + OnPropertyChanged(nameof(CurrentMarketText)); + RebuildNavigationItems(); + } + + private void RebuildNavigationItems() + { + NavigationItems.Clear(); + +#if DEBUG + NavigationItems.Add(new NavigationItemViewModel("Chat Demo", "avares://MarketAssistant/Assets/Images/tab_analysis.svg", "avares://MarketAssistant/Assets/Images/tab_analysis_on.svg", () => new ChatSidebarDemoViewModel())); +#endif + NavigationItems.Add(new NavigationItemViewModel("首页", "avares://MarketAssistant/Assets/Images/tab_home.svg", "avares://MarketAssistant/Assets/Images/tab_home_on.svg", () => _serviceProvider.GetRequiredService())); + NavigationItems.Add(new NavigationItemViewModel("收藏", "avares://MarketAssistant/Assets/Images/tab_favorites.svg", "avares://MarketAssistant/Assets/Images/tab_favorites_on.svg", () => _serviceProvider.GetRequiredService())); + NavigationItems.Add(new NavigationItemViewModel("AI选股", "avares://MarketAssistant/Assets/Images/tab_analysis.svg", "avares://MarketAssistant/Assets/Images/tab_analysis_on.svg", () => _serviceProvider.GetRequiredService())); +#if DEBUG + if (_marketContext.CurrentCapability.SupportsTrading) + { + NavigationItems.Add(new NavigationItemViewModel("交易", "avares://MarketAssistant/Assets/Images/tab_trading.svg", "avares://MarketAssistant/Assets/Images/tab_trading_on.svg", () => _serviceProvider.GetRequiredService())); + } +#endif + NavigationItems.Add(new NavigationItemViewModel("设置", "avares://MarketAssistant/Assets/Images/tab_settings.svg", "avares://MarketAssistant/Assets/Images/tab_settings_on.svg", () => _serviceProvider.GetRequiredService())); + NavigationItems.Add(new NavigationItemViewModel("关于", "avares://MarketAssistant/Assets/Images/tab_about.svg", "avares://MarketAssistant/Assets/Images/tab_about_on.svg", () => _serviceProvider.GetRequiredService())); + } + + private void OnNavigationServicePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(NavigationService.CurrentPage)) + { + OnPropertyChanged(nameof(CurrentPage)); + OnPropertyChanged(nameof(CurrentPageTitle)); + } + else if (e.PropertyName == nameof(NavigationService.CanGoBack)) + { + OnPropertyChanged(nameof(CanGoBack)); + } + else if (e.PropertyName == nameof(NavigationService.CurrentRootNavigationItemTitle)) + { + if (_navigationService.CurrentRootNavigationItemTitle != null) + { + SelectedNavigationItem = NavigationItems.FirstOrDefault( + item => item.Title == _navigationService.CurrentRootNavigationItemTitle); + } + } + } + + /// + /// 返回命令 + /// + [RelayCommand] + private void GoBack() + { + _navigationService.GoBack(); + } + + /// + /// 切换市场命令(Logo点击或Ctrl+M快捷键) + /// + [RelayCommand] + private void ToggleMarket() + { + // 切换市场类型 + var newMarket = _marketContext.CurrentMarket == MarketType.AShare + ? MarketType.Crypto + : MarketType.AShare; + + _marketContext.SwitchMarket(newMarket); + + // 显示切换提示 + var marketName = newMarket == MarketType.AShare ? "A股市场" : "虚拟币市场"; + _notificationService.ShowSuccess($"已切换到{marketName}"); + + Logger?.LogInformation("市场已切换到: {Market} ({MarketName})", newMarket, marketName); + + // 刷新当前页面(重新加载数据) + if (SelectedNavigationItem != null) + { + var currentTitle = SelectedNavigationItem.Title; + var viewModel = SelectedNavigationItem.CreateViewModel(); + _navigationService.NavigateToRoot(viewModel, currentTitle); + } + } + + partial void OnSelectedNavigationItemChanged(NavigationItemViewModel? value) + { + if (value != null) + { + // 避免重复导航 + if (_navigationService.CurrentRootNavigationItemTitle == value.Title) + { + return; + } + + var viewModel = value.CreateViewModel(); + _navigationService.NavigateToRoot(viewModel, value.Title); + } + } +} + +public class NavigationItemViewModel : ViewModelBase +{ + public override string Title { get; } + public string IconPath { get; } + public string SelectedIconPath { get; } + public Func CreateViewModel { get; } + + public NavigationItemViewModel(string title, string iconPath, string selectedIconPath, Func createViewModel) + { + Title = title; + IconPath = iconPath; + SelectedIconPath = selectedIconPath; + CreateViewModel = createViewModel; + } +} diff --git a/src/ViewModels/SettingsPageViewModel.cs b/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs similarity index 69% rename from src/ViewModels/SettingsPageViewModel.cs rename to src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs index c569668..d409a9c 100644 --- a/src/ViewModels/SettingsPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs @@ -5,6 +5,7 @@ using MarketAssistant.Agents.Analysts; using MarketAssistant.Agents.Analysts.Attributes; using MarketAssistant.Applications.Settings; +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Infrastructure.Factories; using MarketAssistant.Rag; using MarketAssistant.Rag.Interfaces; @@ -22,19 +23,56 @@ namespace MarketAssistant.ViewModels; /// /// 设置页ViewModel /// -public partial class SettingsPageViewModel : ViewModelBase +public partial class SettingsPageViewModel : ViewModelBase, IDisposable { private readonly IRagIngestionService _ragIngestionService; private readonly INotificationService _notificationService; private readonly IUserSettingService _userSettingService; private readonly IEmbeddingFactory _embeddingFactory; private readonly VectorStore _vectorStore; + private readonly Services.Market.MarketContext _marketContext; private IStorageProvider? _storageProvider; // UserSetting对象,包含所有用户设置 [ObservableProperty] private UserSetting _userSetting = new(); + /// + /// UserSetting 属性变更时,自动转发关联的计算属性通知 + /// + partial void OnUserSettingChanged(UserSetting? oldValue, UserSetting newValue) + { + if (oldValue is not null) + oldValue.PropertyChanged -= ForwardComputedProperties; + + if (newValue is not null) + newValue.PropertyChanged += ForwardComputedProperties; + } + + private void ForwardComputedProperties(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(UserSetting.ThemeMode): + OnPropertyChanged(nameof(IsThemeDefault)); + OnPropertyChanged(nameof(IsThemeLight)); + OnPropertyChanged(nameof(IsThemeDark)); + break; + case nameof(UserSetting.CurrentMarketType): + OnPropertyChanged(nameof(IsAShareMarket)); + OnPropertyChanged(nameof(IsCryptoMarket)); + break; + case nameof(UserSetting.WebSearchProvider): + OnPropertyChanged(nameof(IsBingProvider)); + OnPropertyChanged(nameof(IsBraveProvider)); + OnPropertyChanged(nameof(IsTavilyProvider)); + break; + case nameof(UserSetting.KnowledgeFileDirectory): + OnPropertyChanged(nameof(IsKnowledgeDirectoryValid)); + break; + } + } + // 模型列表 - ViewModel特有属性 [ObservableProperty] private ObservableCollection _models = []; @@ -70,6 +108,142 @@ public partial class SettingsPageViewModel : ViewModelBase // API密钥获取URL public string ModelApiUrl { get; } = "https://cloud.siliconflow.cn/i/z4lbHdBE"; public string ZhiTuApiUrl { get; } = "https://www.zhituapi.com/gettoken.html"; + public string CoinGeckoApiUrl { get; } = "https://www.coingecko.com/en/api"; + public string JinaApiUrl { get; } = "https://jina.ai/embeddings"; + + /// + /// 主题:跟随系统 + /// + public bool IsThemeDefault + { + get => UserSetting.ThemeMode == "Default"; + set + { + if (value) + { + UserSetting.ThemeMode = "Default"; + ApplyTheme("Default"); + } + } + } + + /// + /// 主题:浅色 + /// + public bool IsThemeLight + { + get => UserSetting.ThemeMode == "Light"; + set + { + if (value) + { + UserSetting.ThemeMode = "Light"; + ApplyTheme("Light"); + } + } + } + + /// + /// 主题:深色 + /// + public bool IsThemeDark + { + get => UserSetting.ThemeMode == "Dark"; + set + { + if (value) + { + UserSetting.ThemeMode = "Dark"; + ApplyTheme("Dark"); + } + } + } + + private static void ApplyTheme(string mode) + { + if (Avalonia.Application.Current == null) return; + Avalonia.Application.Current.RequestedThemeVariant = mode switch + { + "Light" => Avalonia.Styling.ThemeVariant.Light, + "Dark" => Avalonia.Styling.ThemeVariant.Dark, + _ => Avalonia.Styling.ThemeVariant.Default + }; + } + + /// + /// 是否为A股市场 + /// + public bool IsAShareMarket + { + get => UserSetting.CurrentMarketType == MarketType.AShare; + set + { + if (value && UserSetting.CurrentMarketType != MarketType.AShare) + { + // 仅修改本地 UserSetting,不立即调用 SwitchMarket + // 避免触发 MainWindowViewModel 重建导航,导致正在编辑的设置丢失 + // 实际市场切换统一在 Save() 中执行 + UserSetting.CurrentMarketType = MarketType.AShare; + Logger?.LogInformation("市场选择已改为: A股(保存后生效)"); + } + } + } + + /// + /// 是否为虚拟币市场 + /// + public bool IsCryptoMarket + { + get => UserSetting.CurrentMarketType == MarketType.Crypto; + set + { + if (value && UserSetting.CurrentMarketType != MarketType.Crypto) + { + // 仅修改本地 UserSetting,不立即调用 SwitchMarket + UserSetting.CurrentMarketType = MarketType.Crypto; + Logger?.LogInformation("市场选择已改为: 虚拟币(保存后生效)"); + } + } + } + + /// + /// 是否为Bing搜索平台 + /// + public bool IsBingProvider + { + get => UserSetting.WebSearchProvider == "Bing"; + set + { + if (value) + UserSetting.WebSearchProvider = "Bing"; + } + } + + /// + /// 是否为Brave搜索平台 + /// + public bool IsBraveProvider + { + get => UserSetting.WebSearchProvider == "Brave"; + set + { + if (value) + UserSetting.WebSearchProvider = "Brave"; + } + } + + /// + /// 是否为Tavily搜索平台 + /// + public bool IsTavilyProvider + { + get => UserSetting.WebSearchProvider == "Tavily"; + set + { + if (value) + UserSetting.WebSearchProvider = "Tavily"; + } + } /// /// 构造函数(使用依赖注入) @@ -80,6 +254,7 @@ public SettingsPageViewModel( IUserSettingService userSettingService, IEmbeddingFactory embeddingFactory, VectorStore vectorStore, + Services.Market.MarketContext marketContext, ILogger logger) : base(logger) { _ragIngestionService = ragIngestionService; @@ -87,7 +262,8 @@ public SettingsPageViewModel( _userSettingService = userSettingService; _embeddingFactory = embeddingFactory; _vectorStore = vectorStore; - _ = InitializeAsync(); + _marketContext = marketContext; + _ = SafeExecuteAsync(InitializeAsync, "初始化设置页"); } /// @@ -102,18 +278,20 @@ private async Task InitializeAsync() { // 先加载模型列表 await LoadModelsAsync(); - // 加载用户设置 + // 加载用户设置(OnUserSettingChanged 会自动订阅 PropertyChanged) UserSetting = _userSettingService.CurrentSetting; + // 同步市场类型到MarketContext + _marketContext.SwitchMarket(UserSetting.CurrentMarketType); // 加载分析师角色 LoadAnalystRoles(); + // 应用保存的主题 + ApplyTheme(UserSetting.ThemeMode); } private void LoadAnalystRoles() { AnalystRoles.Clear(); - var agentTypes = AppDomain.CurrentDomain.GetAssemblies() - .SelectMany(a => a.GetTypes()) - .Where(t => t.IsSubclassOf(typeof(AnalystAgentBase)) && !t.IsAbstract); + var agentTypes = AnalystTypeRegistry.GetConcreteAnalystTypes(); foreach (var type in agentTypes) { @@ -154,6 +332,12 @@ private void LoadAnalystRoles() [RelayCommand] private Task OpenZhiTuApiWebsite() => OpenUrlAsync(ZhiTuApiUrl); + [RelayCommand] + private Task OpenCoinGeckoApiWebsite() => OpenUrlAsync(CoinGeckoApiUrl); + + [RelayCommand] + private Task OpenEmbeddingApiWebsite() => OpenUrlAsync(JinaApiUrl); + /// /// 选择知识库目录 /// @@ -173,44 +357,10 @@ await SafeExecuteAsync(async () => if (folders.Count > 0) { UserSetting.KnowledgeFileDirectory = folders[0].Path.LocalPath; - OnPropertyChanged(nameof(UserSetting)); - OnPropertyChanged(nameof(IsKnowledgeDirectoryValid)); } }, "选择知识库目录"); } - /// - /// 选择浏览器路径 - /// - [RelayCommand] - private async Task SelectBrowserPath() - { - if (_storageProvider == null) return; - - await SafeExecuteAsync(async () => - { - // 定义可执行文件类型 - var executableFileType = new FilePickerFileType("可执行文件") - { - Patterns = new[] { "*.exe", "*" }, - MimeTypes = new[] { "application/octet-stream" } - }; - - var files = await _storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions - { - Title = "选择浏览器可执行文件", - AllowMultiple = false, - FileTypeFilter = new[] { executableFileType, FilePickerFileTypes.All } - }); - - if (files.Count > 0) - { - UserSetting.BrowserPath = files[0].Path.LocalPath; - OnPropertyChanged(nameof(UserSetting)); - } - }, "选择浏览器路径"); - } - /// /// 选择日志路径 /// @@ -230,7 +380,6 @@ await SafeExecuteAsync(async () => if (folders.Count > 0) { UserSetting.LogPath = Path.Combine(folders[0].Path.LocalPath, "logs"); - OnPropertyChanged(nameof(UserSetting)); } }, "选择日志路径"); } @@ -349,7 +498,7 @@ private async Task VectorizeDocuments() { VectorizingProgressText = "向量化失败"; Logger?.LogError(ex, "向量化过程发生严重错误"); - _notificationService.ShowError($"向量化失败:{ex.Message}"); + _notificationService.ShowError(ErrorMessageMapper.GetUserFriendlyMessageWithContext(ex, "向量化")); } finally { @@ -371,9 +520,12 @@ private void Save() UserSetting.EnabledAnalystRoles[role.Id] = role.IsEnabled; } + // 同步市场类型到MarketContext + _marketContext.SwitchMarket(UserSetting.CurrentMarketType); + _userSettingService.UpdateSettings(UserSetting); _notificationService.ShowSuccess("设置已保存"); - Logger?.LogInformation("保存设置"); + Logger?.LogInformation("保存设置,市场类型:{MarketType}", UserSetting.CurrentMarketType); }, "保存设置"); } @@ -388,7 +540,6 @@ private void Reset() _userSettingService.ResetSettings(); UserSetting = _userSettingService.CurrentSetting; LoadAnalystRoles(); // 重新加载角色 - OnPropertyChanged(nameof(UserSetting)); _notificationService.ShowSuccess("设置已重置为默认值"); Logger?.LogInformation("重置设置为默认值"); }, "重置设置"); @@ -457,8 +608,9 @@ private async Task> LoadModelsFromYamlAsync() return models; } - catch + catch (Exception ex) { + Logger?.LogWarning(ex, "从 YAML 加载模型列表失败"); return new List(); } } @@ -479,4 +631,18 @@ await SafeExecuteAsync(async () => await Task.CompletedTask; }, "打开链接"); } + + private bool _disposed; + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + // 取消 UserSetting.PropertyChanged 订阅,避免 Singleton 持有已释放 ViewModel 的引用 + if (UserSetting is not null) + UserSetting.PropertyChanged -= ForwardComputedProperties; + + GC.SuppressFinalize(this); + } } \ No newline at end of file diff --git a/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs b/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs new file mode 100644 index 0000000..da314f6 --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs @@ -0,0 +1,269 @@ +using System.Collections.ObjectModel; +using System.Text.Json; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using MarketAssistant.Trading; +using MarketAssistant.Trading.Models; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.ViewModels.Trading; + +public partial class StrategyConfigViewModel : ViewModelBase +{ + private readonly TradingDataService _dataService; + private readonly MarketMonitor _marketMonitor; + + public ObservableCollection Strategies { get; } = []; + + [ObservableProperty] private string _newSymbol = string.Empty; + [ObservableProperty] private StrategyType _newStrategyType; + + partial void OnNewStrategyTypeChanged(StrategyType value) + { + OnPropertyChanged(nameof(IsGridTrading)); + OnPropertyChanged(nameof(IsDCA)); + OnPropertyChanged(nameof(IsBasicStrategy)); + OnPropertyChanged(nameof(SideHintText)); + } + + [ObservableProperty] private OrderSide _newSide = OrderSide.Buy; + + partial void OnNewSideChanged(OrderSide value) + { + OnPropertyChanged(nameof(SideHintText)); + } + + [ObservableProperty] private string _newTriggerPrice = string.Empty; + [ObservableProperty] private string _newQuantity = string.Empty; + [ObservableProperty] private string _newStopLossPrice = string.Empty; + [ObservableProperty] private string _newTakeProfitPrice = string.Empty; + [ObservableProperty] private bool _isCreating; + + /// + /// 表单校验错误信息。非空时显示在创建按钮旁。 + /// + [ObservableProperty] private string _validationError = string.Empty; + + public StrategyType[] StrategyTypes { get; } = Enum.GetValues(); + public OrderSide[] OrderSides => Enum.GetValues(); + + // Grid Trading 参数 + [ObservableProperty] private string _gridUpperPrice = string.Empty; + [ObservableProperty] private string _gridLowerPrice = string.Empty; + [ObservableProperty] private string _gridCount = "10"; + [ObservableProperty] private string _gridQuantityPerGrid = string.Empty; + + // DCA 参数 + [ObservableProperty] private string _dcaIntervalSeconds = "86400"; + [ObservableProperty] private string _dcaAmountPerInterval = string.Empty; + [ObservableProperty] private string _dcaMaxBuyPrice = string.Empty; + [ObservableProperty] private string _dcaDoubleBuyBelowPrice = string.Empty; + + /// + /// 当前选择的策略类型是否为网格交易 + /// + public bool IsGridTrading => NewStrategyType == StrategyType.GridTrading; + + /// + /// 当前选择的策略类型是否为定投 + /// + public bool IsDCA => NewStrategyType == StrategyType.DCA; + + /// + /// 当前选择的策略类型是否为基础策略(非 Grid/DCA) + /// + public bool IsBasicStrategy => !IsGridTrading && !IsDCA; + + /// + /// 针对现货交易者的方向提示:买入止损/止盈通常用于空头对冲,现货做多应选卖出。 + /// + public string SideHintText => + NewSide == OrderSide.Buy && + (NewStrategyType == StrategyType.StopLoss || NewStrategyType == StrategyType.TakeProfit) + ? "⚠️ 买入方向的止损/止盈通常用于空头对冲(期货);现货多头持仓请选「卖出」方向" + : string.Empty; + + // 风控配置 + [ObservableProperty] private RiskConfig _riskConfig = new(); + + public StrategyConfigViewModel( + TradingDataService dataService, + MarketMonitor marketMonitor, + ILogger logger) + : base(logger) + { + _dataService = dataService; + _marketMonitor = marketMonitor; + _ = InitializeAsync(); + } + + private async Task InitializeAsync() + { + await SafeExecuteAsync(async () => + { + RiskConfig = await _dataService.LoadRiskConfigAsync(); + var strategies = await _dataService.GetAllStrategiesAsync(); + Strategies.Clear(); + foreach (var s in strategies) + Strategies.Add(s); + }, "加载策略列表"); + } + + [RelayCommand] + private async Task CreateStrategyAsync() + { + if (string.IsNullOrWhiteSpace(NewSymbol)) + { + ValidationError = "请填写交易对(如 BTCUSDT)"; + return; + } + + await SafeExecuteAsync(async () => + { + ValidationError = string.Empty; + + var strategy = new TradingStrategy + { + Symbol = NewSymbol.ToUpper().Trim(), + Type = NewStrategyType, + Status = StrategyStatus.Active, + Side = NewSide + }; + + switch (NewStrategyType) + { + case StrategyType.GridTrading: + if (!decimal.TryParse(GridUpperPrice, out var upper) || + !decimal.TryParse(GridLowerPrice, out var lower) || + !int.TryParse(GridCount, out var gridCount) || + !decimal.TryParse(GridQuantityPerGrid, out var qtyPerGrid)) + { + ValidationError = "请填写完整的网格交易参数(上界价格、下界价格、网格数量、每格数量)"; + return; + } + + var gridParams = new GridTradingParams + { + UpperPrice = upper, + LowerPrice = lower, + GridCount = gridCount, + QuantityPerGrid = qtyPerGrid + }; + strategy.CustomParams = JsonSerializer.Serialize(gridParams); + strategy.TriggerPrice = lower; // 用下界作为参考触发价 + strategy.Quantity = qtyPerGrid; + break; + + case StrategyType.DCA: + if (!decimal.TryParse(DcaAmountPerInterval, out var amount)) + { + ValidationError = "请填写有效的定投金额(USDT)"; + return; + } + + var dcaParams = new DCAParams { AmountPerInterval = amount }; + if (int.TryParse(DcaIntervalSeconds, out var interval)) + dcaParams.IntervalSeconds = interval; + if (decimal.TryParse(DcaMaxBuyPrice, out var maxPrice)) + dcaParams.MaxBuyPrice = maxPrice; + if (decimal.TryParse(DcaDoubleBuyBelowPrice, out var doublePrice)) + dcaParams.DoubleBuyBelowPrice = doublePrice; + + strategy.CustomParams = JsonSerializer.Serialize(dcaParams); + strategy.TriggerPrice = maxPrice > 0 ? maxPrice : 0; + // DCA 的 Quantity 存储每次定投的 USDT 金额(代币数量在执行时按实时价格换算) + strategy.Quantity = amount; + break; + + default: + if (!decimal.TryParse(NewTriggerPrice, out var triggerPrice) || + !decimal.TryParse(NewQuantity, out var quantity)) + { + ValidationError = "请填写有效的触发价格和交易数量"; + return; + } + + strategy.TriggerPrice = triggerPrice; + strategy.Quantity = quantity; + + if (decimal.TryParse(NewStopLossPrice, out var sl)) + strategy.StopLossPrice = sl; + if (decimal.TryParse(NewTakeProfitPrice, out var tp)) + strategy.TakeProfitPrice = tp; + break; + } + + await _dataService.SaveStrategyAsync(strategy); + Strategies.Insert(0, strategy); + + ClearForm(); + await _marketMonitor.RefreshSubscriptionsAsync(); + }, "创建策略"); + } + + [RelayCommand] + private async Task ToggleStrategyAsync(TradingStrategy strategy) + { + await SafeExecuteAsync(async () => + { + var newStatus = strategy.Status == StrategyStatus.Active + ? StrategyStatus.Paused + : StrategyStatus.Active; + + await _dataService.UpdateStrategyStatusAsync(strategy.Id, newStatus); + strategy.Status = newStatus; + + var index = Strategies.IndexOf(strategy); + if (index >= 0) + { + Strategies.RemoveAt(index); + Strategies.Insert(index, strategy); + } + + await _marketMonitor.RefreshSubscriptionsAsync(); + }, "切换策略状态"); + } + + [RelayCommand] + private async Task DeleteStrategyAsync(TradingStrategy strategy) + { + await SafeExecuteAsync(async () => + { + await _dataService.DeleteStrategyAsync(strategy.Id); + Strategies.Remove(strategy); + await _marketMonitor.RefreshSubscriptionsAsync(); + }, "删除策略"); + } + + [RelayCommand] + private async Task SaveRiskConfigAsync() + { + await _dataService.SaveRiskConfigAsync(RiskConfig); + } + + [RelayCommand] + private void ToggleCreateForm() + { + IsCreating = !IsCreating; + if (!IsCreating) ClearForm(); + } + + private void ClearForm() + { + NewSymbol = string.Empty; + NewTriggerPrice = string.Empty; + NewQuantity = string.Empty; + NewStopLossPrice = string.Empty; + NewTakeProfitPrice = string.Empty; + GridUpperPrice = string.Empty; + GridLowerPrice = string.Empty; + GridCount = "10"; + GridQuantityPerGrid = string.Empty; + DcaIntervalSeconds = "86400"; + DcaAmountPerInterval = string.Empty; + DcaMaxBuyPrice = string.Empty; + DcaDoubleBuyBelowPrice = string.Empty; + ValidationError = string.Empty; + IsCreating = false; + } +} diff --git a/src/MarketAssistant.App/ViewModels/Trading/TradeHistoryViewModel.cs b/src/MarketAssistant.App/ViewModels/Trading/TradeHistoryViewModel.cs new file mode 100644 index 0000000..7d68e17 --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/Trading/TradeHistoryViewModel.cs @@ -0,0 +1,55 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using MarketAssistant.Trading; +using MarketAssistant.Trading.Models; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.ViewModels.Trading; + +public partial class TradeHistoryViewModel : ViewModelBase +{ + private readonly TradingDataService _dataService; + + public ObservableCollection Records { get; } = []; + + [ObservableProperty] private string _filterSymbol = string.Empty; + [ObservableProperty] private DailyStats _todayStats = new(); + + public TradeHistoryViewModel( + TradingDataService dataService, + ILogger logger) + : base(logger) + { + _dataService = dataService; + } + + [RelayCommand] + private async Task RefreshAsync() + { + await SafeExecuteAsync(async () => + { + var symbol = string.IsNullOrWhiteSpace(FilterSymbol) ? null : FilterSymbol.Trim().ToUpper(); + var records = await _dataService.GetTradeRecordsAsync(symbol: symbol, limit: 100); + + Records.Clear(); + foreach (var r in records) + Records.Add(r); + + TodayStats = await _dataService.GetTodayStatsAsync(); + }, "加载交易历史"); + } + + [RelayCommand] + private async Task FilterAsync() + { + await RefreshAsync(); + } + + [RelayCommand] + private async Task ClearFilterAsync() + { + FilterSymbol = string.Empty; + await RefreshAsync(); + } +} diff --git a/src/MarketAssistant.App/ViewModels/Trading/TradeMonitorViewModel.cs b/src/MarketAssistant.App/ViewModels/Trading/TradeMonitorViewModel.cs new file mode 100644 index 0000000..4f6dc45 --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/Trading/TradeMonitorViewModel.cs @@ -0,0 +1,224 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Trading; +using MarketAssistant.Trading.Abstractions; +using MarketAssistant.Trading.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.ViewModels.Trading; + +public partial class TradeMonitorViewModel : ViewModelBase, IDisposable +{ + private const decimal RiskWarningThreshold = 0.8m; + private const int ConfirmationTimeoutSeconds = 60; + + private readonly MarketMonitor _marketMonitor; + private readonly CryptoPortfolioService _portfolioService; + private readonly IExchangeClient _exchangeClient; + private readonly TradingDataService _dataService; + private readonly TradeExecutor _tradeExecutor; + + public ObservableCollection Balances { get; } = []; + public ObservableCollection OpenOrders { get; } = []; + public ObservableCollection Positions { get; } = []; + public ObservableCollection ActiveStrategies { get; } = []; + + [ObservableProperty] private decimal _totalValueUSDT; + [ObservableProperty] private bool _isMonitorRunning; + [ObservableProperty] private DailyStats _todayStats = new(); + + // 风控指标 + [ObservableProperty] private decimal _dailyLossPercent; + [ObservableProperty] private decimal _totalPositionPercent; + [ObservableProperty] private int _remainingDailyTrades; + [ObservableProperty] private RiskConfig _riskConfig = new(); + + // 派生展示属性(由 RefreshAsync 计算) + [ObservableProperty] private decimal _todayPnlPercent; + [ObservableProperty] private bool _isTodayProfitable; + [ObservableProperty] private bool _isDailyLossHigh; + [ObservableProperty] private bool _isPositionHigh; + + // Human-in-the-Loop 确认 + [ObservableProperty] private bool _hasPendingConfirmation; + [ObservableProperty] private string _confirmationSymbol = string.Empty; + [ObservableProperty] private string _confirmationSide = string.Empty; + [ObservableProperty] private string _confirmationPrice = string.Empty; + [ObservableProperty] private string _confirmationQuantity = string.Empty; + [ObservableProperty] private string _confirmationReason = string.Empty; + + private TaskCompletionSource? _confirmationTcs; + private CancellationTokenSource? _confirmationCts; + + public TradeMonitorViewModel( + MarketMonitor marketMonitor, + CryptoPortfolioService portfolioService, + [FromKeyedServices(MarketType.Crypto)] IExchangeClient exchangeClient, + TradingDataService dataService, + TradeExecutor tradeExecutor, + ILogger logger) + : base(logger) + { + _marketMonitor = marketMonitor; + _portfolioService = portfolioService; + _exchangeClient = exchangeClient; + _dataService = dataService; + _tradeExecutor = tradeExecutor; + + _isMonitorRunning = _marketMonitor.IsRunning; + _marketMonitor.StatusChanged += OnMonitorStatusChanged; + + // 接管 TradeExecutor 的确认事件(使用事件模式,Dispose 时取消订阅) + _tradeExecutor.ConfirmationRequested += OnTradeConfirmationRequestedAsync; + } + + [RelayCommand] + private async Task RefreshAsync() + { + await SafeExecuteAsync(async () => + { + TodayStats = await _dataService.GetTodayStatsAsync(); + RiskConfig = await _dataService.LoadRiskConfigAsync(); + + // 计算风控指标 + RemainingDailyTrades = Math.Max(0, RiskConfig.MaxDailyTrades - TodayStats.TradeCount); + + try + { + var summary = await _portfolioService.GetAccountBalanceSummaryAsync(); + Balances.Clear(); + foreach (var balance in summary.Assets) + { + Balances.Add(balance); + } + + TotalValueUSDT = summary.TotalValueUSDT; + + // 计算风控百分比指标 + if (TotalValueUSDT > 0) + { + DailyLossPercent = TodayStats.TotalPnl < 0 + ? Math.Abs(TodayStats.TotalPnl) / TotalValueUSDT * 100 + : 0; + + var usdtBalance = CryptoPortfolioService.GetUsdtBalance(summary); + var nonUsdtValue = TotalValueUSDT - usdtBalance; + TotalPositionPercent = nonUsdtValue / TotalValueUSDT * 100; + + // 派生展示属性 + TodayPnlPercent = TodayStats.TotalPnl / TotalValueUSDT * 100; + IsTodayProfitable = TodayStats.TotalPnl >= 0; + } + else + { + TodayPnlPercent = 0; + IsTodayProfitable = true; + } + + // 风控阈值预警(达到限额 80% 视为高位) + IsDailyLossHigh = RiskConfig.MaxDailyLossPercent > 0 + && DailyLossPercent / RiskConfig.MaxDailyLossPercent >= RiskWarningThreshold; + IsPositionHigh = RiskConfig.MaxTotalPositionPercent > 0 + && TotalPositionPercent / RiskConfig.MaxTotalPositionPercent >= RiskWarningThreshold; + + // 加载 FIFO 持仓 + var positions = await _dataService.GetOpenPositionsAsync(); + Positions.Clear(); + foreach (var p in positions) + { + Positions.Add(p); + } + + var orders = await _exchangeClient.GetOpenOrdersAsync(); + OpenOrders.Clear(); + foreach (var o in orders) + OpenOrders.Add(o); + } + catch (InvalidOperationException) + { + Logger?.LogWarning("Binance API 未配置,跳过账户数据加载"); + } + + // 加载活跃策略 + var activeStrategies = await _dataService.GetStrategiesByStatusAsync(StrategyStatus.Active); + ActiveStrategies.Clear(); + foreach (var s in activeStrategies) + { + ActiveStrategies.Add(s); + } + }, "刷新交易监控"); + } + + [RelayCommand] + private async Task ToggleMonitorAsync() + { + await SafeExecuteAsync(async () => + { + if (_marketMonitor.IsRunning) + await _marketMonitor.StopAsync(); + else + await _marketMonitor.StartAsync(); + }, "切换监控状态"); + } + + private void OnMonitorStatusChanged(bool isRunning) + { + IsMonitorRunning = isRunning; + } + + private Task OnTradeConfirmationRequestedAsync( + string symbol, OrderSide side, decimal price, decimal quantity, string reason) + { + ConfirmationSymbol = symbol; + ConfirmationSide = side.ToString(); + ConfirmationPrice = price.ToString("F2"); + ConfirmationQuantity = quantity.ToString("F6"); + ConfirmationReason = reason; + HasPendingConfirmation = true; + + _confirmationTcs = new TaskCompletionSource(); + + // 60 秒超时自动拒绝,避免用户离开后交易长时间挂起 + _confirmationCts?.Dispose(); + _confirmationCts = new CancellationTokenSource(TimeSpan.FromSeconds(ConfirmationTimeoutSeconds)); + _confirmationCts.Token.Register(() => _confirmationTcs.TrySetResult(false)); + + return _confirmationTcs.Task; + } + + [RelayCommand] + private void ApproveConfirmation() + { + HasPendingConfirmation = false; + // 注意:必须 Dispose 而非 Cancel。 + // Cancel 会同步触发 Token.Register 的回调(TrySetResult(false)), + // 导致随后的 TrySetResult(true) 被忽略,用户批准反而变成拒绝。 + _confirmationCts?.Dispose(); + _confirmationCts = null; + _confirmationTcs?.TrySetResult(true); + } + + [RelayCommand] + private void RejectConfirmation() + { + HasPendingConfirmation = false; + _confirmationCts?.Dispose(); + _confirmationCts = null; + _confirmationTcs?.TrySetResult(false); + } + + public void Dispose() + { + _marketMonitor.StatusChanged -= OnMonitorStatusChanged; + // 取消订阅事件,避免单例 TradeExecutor 持有已 Dispose 的 ViewModel 引用 + _tradeExecutor.ConfirmationRequested -= OnTradeConfirmationRequestedAsync; + // 释放前若仍有待确认请求,按拒绝处理,避免调用方永久挂起 + _confirmationTcs?.TrySetResult(false); + _confirmationCts?.Dispose(); + _confirmationCts = null; + GC.SuppressFinalize(this); + } +} diff --git a/src/MarketAssistant.App/ViewModels/Trading/TradingPageViewModel.cs b/src/MarketAssistant.App/ViewModels/Trading/TradingPageViewModel.cs new file mode 100644 index 0000000..9f639bc --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/Trading/TradingPageViewModel.cs @@ -0,0 +1,58 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.ViewModels.Trading; + +public partial class TradingPageViewModel : ViewModelBase, IDisposable +{ + public override string Title => "交易"; + + public StrategyConfigViewModel StrategyConfig { get; } + public TradeMonitorViewModel TradeMonitor { get; } + public TradeHistoryViewModel TradeHistory { get; } + + [ObservableProperty] + private int _selectedTabIndex; + + public bool IsStrategyTabSelected => SelectedTabIndex == 0; + public bool IsMonitorTabSelected => SelectedTabIndex == 1; + public bool IsHistoryTabSelected => SelectedTabIndex == 2; + + public TradingPageViewModel( + StrategyConfigViewModel strategyConfig, + TradeMonitorViewModel tradeMonitor, + TradeHistoryViewModel tradeHistory, + ILogger logger) + : base(logger) + { + StrategyConfig = strategyConfig; + TradeMonitor = tradeMonitor; + TradeHistory = tradeHistory; + } + + [RelayCommand] + private void SelectTab(string tabIndex) + { + if (int.TryParse(tabIndex, out var index)) + SelectedTabIndex = index; + } + + partial void OnSelectedTabIndexChanged(int value) + { + OnPropertyChanged(nameof(IsStrategyTabSelected)); + OnPropertyChanged(nameof(IsMonitorTabSelected)); + OnPropertyChanged(nameof(IsHistoryTabSelected)); + + if (value == 1) + TradeMonitor.RefreshCommand.Execute(null); + else if (value == 2) + TradeHistory.RefreshCommand.Execute(null); + } + + public void Dispose() + { + TradeMonitor.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/ViewModels/ViewModelBase.cs b/src/MarketAssistant.App/ViewModels/ViewModelBase.cs similarity index 57% rename from src/ViewModels/ViewModelBase.cs rename to src/MarketAssistant.App/ViewModels/ViewModelBase.cs index 799ae95..09e0fac 100644 --- a/src/ViewModels/ViewModelBase.cs +++ b/src/MarketAssistant.App/ViewModels/ViewModelBase.cs @@ -1,5 +1,7 @@ using CommunityToolkit.Mvvm.ComponentModel; +using MarketAssistant.Services.Market; using Microsoft.Extensions.Logging; +using System.ComponentModel; namespace MarketAssistant.ViewModels; @@ -8,6 +10,11 @@ public abstract class ViewModelBase : ObservableObject private bool _isBusy; protected readonly ILogger? Logger; + /// + /// 当前市场上下文(由 SubscribeToMarketChanges 设置) + /// + protected MarketContext? MarketContext { get; private set; } + /// /// Gets or sets a value indicating whether the view model is busy performing an operation. /// @@ -65,4 +72,37 @@ protected void SafeExecute(Action operation, string? operationName = null) Logger ); } + + /// + /// 市场切换时的回调方法,派生类可重写以响应市场变化 + /// + protected virtual void OnMarketChanged(MarketType newMarket) + { + // 默认空实现,派生类可按需重写 + } + + /// + /// 订阅市场上下文的 PropertyChanged 事件,在 CurrentMarket 变化时调用 OnMarketChanged + /// + protected void SubscribeToMarketChanges(MarketContext marketContext) + { + MarketContext = marketContext; + marketContext.PropertyChanged += OnMarketContextPropertyChanged; + } + + /// + /// 取消订阅市场上下文的 PropertyChanged 事件 + /// + protected void UnsubscribeFromMarketChanges(MarketContext marketContext) + { + marketContext.PropertyChanged -= OnMarketContextPropertyChanged; + } + + private void OnMarketContextPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(MarketContext.CurrentMarket) && MarketContext != null) + { + OnMarketChanged(MarketContext.CurrentMarket); + } + } } diff --git a/src/Views/Components/AdaptiveCardView.axaml b/src/MarketAssistant.App/Views/Components/AdaptiveCardView.axaml similarity index 100% rename from src/Views/Components/AdaptiveCardView.axaml rename to src/MarketAssistant.App/Views/Components/AdaptiveCardView.axaml diff --git a/src/Views/Components/AdaptiveCardView.axaml.cs b/src/MarketAssistant.App/Views/Components/AdaptiveCardView.axaml.cs similarity index 67% rename from src/Views/Components/AdaptiveCardView.axaml.cs rename to src/MarketAssistant.App/Views/Components/AdaptiveCardView.axaml.cs index 202a436..f2d6ec4 100644 --- a/src/Views/Components/AdaptiveCardView.axaml.cs +++ b/src/MarketAssistant.App/Views/Components/AdaptiveCardView.axaml.cs @@ -3,13 +3,22 @@ using Avalonia.Controls; using Avalonia.Layout; using Avalonia.Media; -using Avalonia.Controls.Shapes; + using Avalonia.Markup.Xaml.MarkupExtensions; namespace MarketAssistant.Views.Components; public partial class AdaptiveCardView : UserControl { + private const int SmallFontSize = 12; + private const int DefaultFontSize = 14; + private const int MediumFontSize = 16; + private const int LargeFontSize = 20; + private const int ExtraLargeFontSize = 24; + private const int ContainerSpacing = 8; + private const int ContainerPadding = 12; + private const int ContainerCornerRadius = 6; + public static readonly StyledProperty CardProperty = AvaloniaProperty.Register(nameof(Card)); @@ -72,12 +81,12 @@ private Control RenderTextBlock(AdaptiveTextBlock textBlock) // Size tb.FontSize = textBlock.Size switch { - AdaptiveTextSize.Small => 12, - AdaptiveTextSize.Default => 14, - AdaptiveTextSize.Medium => 16, - AdaptiveTextSize.Large => 20, - AdaptiveTextSize.ExtraLarge => 24, - _ => 14 + AdaptiveTextSize.Small => SmallFontSize, + AdaptiveTextSize.Default => DefaultFontSize, + AdaptiveTextSize.Medium => MediumFontSize, + AdaptiveTextSize.Large => LargeFontSize, + AdaptiveTextSize.ExtraLarge => ExtraLargeFontSize, + _ => DefaultFontSize }; // Weight @@ -89,21 +98,17 @@ private Control RenderTextBlock(AdaptiveTextBlock textBlock) _ => FontWeight.Normal }; - // Color with Theme Support - if (textBlock.Color == AdaptiveTextColor.Accent) - tb[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlForegroundAccentBrush"); - else if (textBlock.Color == AdaptiveTextColor.Good) - tb[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlForegroundSuccessBrush"); // Or a custom resource - else if (textBlock.Color == AdaptiveTextColor.Warning) - tb[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlForegroundCautionBrush"); // Or a custom resource - else if (textBlock.Color == AdaptiveTextColor.Attention) - tb[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlErrorTextForegroundBrush"); - else if (textBlock.IsSubtle) - tb[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlForegroundBaseMediumBrush"); - else - tb[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlForegroundBaseHighBrush"); - - // Fallback for custom brushes if system ones aren't perfect, but for now system ones are safer for light/dark + // Color — 对齐到项目真实主题资源(不再依赖幽灵 SystemControl* 画刷) + tb[!TextBlock.ForegroundProperty] = textBlock.Color switch + { + AdaptiveTextColor.Accent => new DynamicResourceExtension("AccentBrush"), + AdaptiveTextColor.Good => new DynamicResourceExtension("SuccessDeepTextBrush"), + AdaptiveTextColor.Warning => new DynamicResourceExtension("WarningDarkTextBrush"), + AdaptiveTextColor.Attention => new DynamicResourceExtension("ErrorDarkTextBrush"), + _ => textBlock.IsSubtle + ? new DynamicResourceExtension("TextSecondaryBrush") + : new DynamicResourceExtension("TextPrimaryBrush") + }; // Alignment tb.TextAlignment = textBlock.HorizontalAlignment switch @@ -121,7 +126,7 @@ private Control RenderContainer(AdaptiveContainer container) { var stackPanel = new StackPanel { - Spacing = 8 + Spacing = ContainerSpacing }; foreach (var item in container.Items) @@ -135,23 +140,22 @@ private Control RenderContainer(AdaptiveContainer container) var border = new Border { - Child = stackPanel + Child = stackPanel, + Padding = new Thickness(ContainerPadding), + CornerRadius = new CornerRadius(ContainerCornerRadius) }; - // Style based on container style + // Style based on container style —— 两种容器风格一致,仅靠颜色区分语义 if (container.Style == AdaptiveContainerStyle.Emphasis) { - // Use a dynamic resource for background to support dark mode - border[!Border.BackgroundProperty] = new DynamicResourceExtension("SystemControlBackgroundChromeMediumLowBrush"); - border.Padding = new Thickness(8); - border.CornerRadius = new CornerRadius(4); + border[!Border.BackgroundProperty] = new DynamicResourceExtension("SurfaceVariantBrush"); } else if (container.Style == AdaptiveContainerStyle.Attention) { - // Light red background for attention - border.Background = new SolidColorBrush(Color.Parse("#20FF0000")); - border.Padding = new Thickness(8); - border.CornerRadius = new CornerRadius(4); + border[!Border.BackgroundProperty] = new DynamicResourceExtension("DangerBackgroundBrush"); + border[!Border.BorderBrushProperty] = new DynamicResourceExtension("ErrorDarkTextBrush"); + border.BorderThickness = new Thickness(3, 0, 0, 0); + border.CornerRadius = new CornerRadius(ContainerCornerRadius, 0, 0, ContainerCornerRadius); } return border; @@ -214,10 +218,10 @@ private Control RenderColumnSet(AdaptiveColumnSet columnSet) private Control RenderFactSet(AdaptiveFactSet factSet) { - var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition(1, GridUnitType.Auto)); // Title - grid.ColumnDefinitions.Add(new ColumnDefinition(16, GridUnitType.Pixel)); // Spacing - grid.ColumnDefinitions.Add(new ColumnDefinition(1, GridUnitType.Star)); // Value + var grid = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto, 20, *") + }; for (int i = 0; i < factSet.Facts.Count; i++) { @@ -228,12 +232,11 @@ private Control RenderFactSet(AdaptiveFactSet factSet) var title = new TextBlock { Text = fact.Title, - FontWeight = FontWeight.Bold, - Margin = new Thickness(0, 0, 0, 4) + FontWeight = FontWeight.Medium, + Margin = new Thickness(0, 0, 0, 6) }; - // Use dynamic resource for text color - title[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlForegroundBaseHighBrush"); - + title[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("TextSecondaryBrush"); + Grid.SetRow(title, i); Grid.SetColumn(title, 0); @@ -241,10 +244,9 @@ private Control RenderFactSet(AdaptiveFactSet factSet) { Text = fact.Value, TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(0, 0, 0, 4) + Margin = new Thickness(0, 0, 0, 6) }; - // Use dynamic resource for text color (slightly subtler if desired, or same) - value[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlForegroundBaseMediumBrush"); + value[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("TextPrimaryBrush"); Grid.SetRow(value, i); Grid.SetColumn(value, 2); @@ -258,20 +260,22 @@ private Control RenderFactSet(AdaptiveFactSet factSet) private Control RenderImage(AdaptiveImage image) { - // Placeholder for image rendering + var textBlock = new TextBlock + { + Text = "Image: " + image.Url, + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center, + FontSize = 10 + }; + textBlock[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("TextSecondaryBrush"); + var border = new Border { - Background = Brushes.LightGray, Height = 100, CornerRadius = new CornerRadius(4), - Child = new TextBlock - { - Text = "Image: " + image.Url, - VerticalAlignment = VerticalAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center, - FontSize = 10 - } + Child = textBlock }; + border[!Border.BackgroundProperty] = new DynamicResourceExtension("SurfaceVariantBrush"); if (image.PixelHeight > 0) border.Height = image.PixelHeight; diff --git a/src/Views/Components/AnalysisReportView.axaml b/src/MarketAssistant.App/Views/Components/AnalysisReportView.axaml similarity index 75% rename from src/Views/Components/AnalysisReportView.axaml rename to src/MarketAssistant.App/Views/Components/AnalysisReportView.axaml index 237780d..52c63bc 100644 --- a/src/Views/Components/AnalysisReportView.axaml +++ b/src/MarketAssistant.App/Views/Components/AnalysisReportView.axaml @@ -5,7 +5,7 @@ xmlns:vm="using:MarketAssistant.ViewModels" xmlns:models="using:MarketAssistant.Models" xmlns:controls="using:MarketAssistant.Views.Controls" - xmlns:converts="using:MarketAssistant.Converts" + xmlns:converts="using:MarketAssistant.Converters" mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="800" x:Class="MarketAssistant.Views.Components.AnalysisReportView" x:DataType="vm:AnalysisReportViewModel"> @@ -97,20 +97,23 @@ - + - + - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Components/AnalysisReportView.axaml.cs b/src/MarketAssistant.App/Views/Components/AnalysisReportView.axaml.cs similarity index 100% rename from src/Views/Components/AnalysisReportView.axaml.cs rename to src/MarketAssistant.App/Views/Components/AnalysisReportView.axaml.cs diff --git a/src/Views/Components/ChatSidebarView.axaml b/src/MarketAssistant.App/Views/Components/ChatSidebarView.axaml similarity index 98% rename from src/Views/Components/ChatSidebarView.axaml rename to src/MarketAssistant.App/Views/Components/ChatSidebarView.axaml index a302a49..d33a6cd 100644 --- a/src/Views/Components/ChatSidebarView.axaml +++ b/src/MarketAssistant.App/Views/Components/ChatSidebarView.axaml @@ -3,7 +3,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:MarketAssistant.ViewModels" - xmlns:converts="using:MarketAssistant.Converts" + xmlns:converts="using:MarketAssistant.Converters" xmlns:controls="using:MarketAssistant.Views.Controls" xmlns:components="using:MarketAssistant.Views.Components" mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="800" @@ -73,7 +73,7 @@ @@ -153,10 +153,10 @@ Margin="{StaticResource SendIconMargin}"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MarketAssistant.App/Views/Pages/AgentAnalysisPageView.axaml.cs b/src/MarketAssistant.App/Views/Pages/AgentAnalysisPageView.axaml.cs new file mode 100644 index 0000000..87cdb68 --- /dev/null +++ b/src/MarketAssistant.App/Views/Pages/AgentAnalysisPageView.axaml.cs @@ -0,0 +1,36 @@ +using Avalonia.Controls; +using Avalonia.Input; +using MarketAssistant.ViewModels; + +namespace MarketAssistant.Views.Pages; + +/// +/// 代理分析页面视图 +/// +public partial class AgentAnalysisPageView : UserControl +{ + public AgentAnalysisPageView() + { + InitializeComponent(); + + AttachedToVisualTree += (_, _) => + { + if (DataContext is AgentAnalysisViewModel vm) + { + var topLevel = TopLevel.GetTopLevel(this); + vm.SetStorageProvider(topLevel?.StorageProvider); + } + }; + } + + /// + /// 点击遮罩层关闭侧边栏 + /// + private void OnOverlayTapped(object? sender, TappedEventArgs e) + { + if (DataContext is AgentAnalysisViewModel vm) + { + vm.ToggleChatSidebarCommand.Execute(null); + } + } +} diff --git a/src/Views/Pages/StockPageView.axaml b/src/MarketAssistant.App/Views/Pages/AssetPageView.axaml similarity index 79% rename from src/Views/Pages/StockPageView.axaml rename to src/MarketAssistant.App/Views/Pages/AssetPageView.axaml index ce9775a..222eb46 100644 --- a/src/Views/Pages/StockPageView.axaml +++ b/src/MarketAssistant.App/Views/Pages/AssetPageView.axaml @@ -5,13 +5,13 @@ xmlns:vm="using:MarketAssistant.ViewModels" xmlns:controls="using:MarketAssistant.Views.Controls" xmlns:components="using:MarketAssistant.Views.Components" - xmlns:converts="using:MarketAssistant.Converts" + xmlns:converts="using:MarketAssistant.Converters" mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="800" - x:Class="MarketAssistant.Views.Pages.StockPageView" - x:DataType="vm:StockPageViewModel"> + x:Class="MarketAssistant.Views.Pages.AssetPageView" + x:DataType="vm:AssetPageViewModel"> - + @@ -20,17 +20,17 @@ - + - + - @@ -39,7 +39,7 @@ CornerRadius="{StaticResource TinyCornerRadius}" Padding="{StaticResource TinyPadding}" VerticalAlignment="Center"> - @@ -178,20 +178,45 @@ - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - + @@ -224,3 +249,6 @@ + + + diff --git a/src/Views/Pages/StockPageView.axaml.cs b/src/MarketAssistant.App/Views/Pages/AssetPageView.axaml.cs similarity index 69% rename from src/Views/Pages/StockPageView.axaml.cs rename to src/MarketAssistant.App/Views/Pages/AssetPageView.axaml.cs index 38f3fc0..d2a972d 100644 --- a/src/Views/Pages/StockPageView.axaml.cs +++ b/src/MarketAssistant.App/Views/Pages/AssetPageView.axaml.cs @@ -6,12 +6,15 @@ namespace MarketAssistant.Views.Pages; /// -/// 股票详情页视图 +/// 资产详情页视图 /// -public partial class StockPageView : UserControl +public partial class AssetPageView : UserControl { - public StockPageView() + public AssetPageView() { InitializeComponent(); } } + + + diff --git a/src/Views/Pages/StockSelectionPageView.axaml b/src/MarketAssistant.App/Views/Pages/AssetSelectionPageView.axaml similarity index 72% rename from src/Views/Pages/StockSelectionPageView.axaml rename to src/MarketAssistant.App/Views/Pages/AssetSelectionPageView.axaml index f382ef7..9d58b46 100644 --- a/src/Views/Pages/StockSelectionPageView.axaml +++ b/src/MarketAssistant.App/Views/Pages/AssetSelectionPageView.axaml @@ -4,15 +4,15 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:MarketAssistant.ViewModels" xmlns:controls="using:MarketAssistant.Views.Controls" - xmlns:models="using:MarketAssistant.Applications.StockSelection.Models" - xmlns:converts="using:MarketAssistant.Converts" + xmlns:models="using:MarketAssistant.Applications.InvestmentSelection.Models" + xmlns:converts="using:MarketAssistant.Converters" mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="800" - x:Class="MarketAssistant.Views.Pages.StockSelectionPageView" - x:DataType="vm:StockSelectionPageViewModel" + x:Class="MarketAssistant.Views.Pages.AssetSelectionPageView" + x:DataType="vm:AssetSelectionPageViewModel" x:Name="Root"> - + @@ -109,7 +109,7 @@ FontSize="{StaticResource LargeFontSize}" FontWeight="SemiBold" HorizontalAlignment="Center"/> - - - @@ -186,7 +186,7 @@ - - @@ -266,8 +266,8 @@ - - + @@ -357,7 +357,7 @@ - @@ -365,58 +365,126 @@ - + - + - - - - - + + + + + + + + + + + + + + + + + private void OnStockItemTapped(object? sender, TappedEventArgs e) { if (sender is Border border && - border.Tag is StockInfo stock && + border.Tag is AssetInfo asset && DataContext is FavoritesPageViewModel viewModel) { - viewModel.SelectFavoriteStockCommand?.Execute(stock); + viewModel.SelectFavoriteAssetCommand?.Execute(asset); } } } \ No newline at end of file diff --git a/src/Views/Pages/HomePageView.axaml b/src/MarketAssistant.App/Views/Pages/HomePageView.axaml similarity index 89% rename from src/Views/Pages/HomePageView.axaml rename to src/MarketAssistant.App/Views/Pages/HomePageView.axaml index 9d27c0a..8c65a58 100644 --- a/src/Views/Pages/HomePageView.axaml +++ b/src/MarketAssistant.App/Views/Pages/HomePageView.axaml @@ -6,8 +6,8 @@ xmlns:controls="using:MarketAssistant.Views.Controls" xmlns:svg="clr-namespace:Avalonia.Svg.Skia;assembly=Svg.Controls.Skia.Avalonia" xmlns:telegrams="using:MarketAssistant.Applications.Telegrams" - xmlns:stocks="using:MarketAssistant.Applications.Stocks.Models" - xmlns:converts="using:MarketAssistant.Converts" + xmlns:assets="using:MarketAssistant.Applications.Assets.Models" + xmlns:converts="using:MarketAssistant.Converters" mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="800" x:Class="MarketAssistant.Views.Pages.HomePageView" x:DataType="vm:HomePageViewModel"> @@ -24,7 +24,7 @@ - - - - - - - + + + + + @@ -124,8 +121,8 @@ TextWrapping="Wrap" MaxLines="3"/> - - + + @@ -133,7 +130,7 @@ - - + - + - + - + @@ -270,7 +267,7 @@ - + @@ -290,8 +287,6 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -/// 协调分析师代理 -/// 整合多维度分析师结论并提供投资建议 -/// -[DisplayName("协调分析师")] -[Description("整合多维度分析师结论并提供投资建议")] -[RequiredAnalyst] - -public class CoordinatorAnalystAgent : AnalystAgentBase -{ - private static readonly ChatResponseFormat ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema: AIJsonUtilities.CreateJsonSchema(typeof(CoordinatorResult)), - schemaName: nameof(CoordinatorResult), - schemaDescription: "协调分析师的综合分析结果,包含投资建议、评分、风险评估等结构化数据" - ); - - public CoordinatorAnalystAgent( - IChatClient chatClient, - GroundingSearchTools searchTools, - IUserSettingService userSettingService, - ILoggerFactory loggerFactory) - : base( - chatClient, - instructions: GetInstructions(), - name: "CoordinatorAnalyst", - description: "市场分析协调专家,整合多维度分析师结论并提供投资建议。", - temperature: 0.2f, - topP: 0.7f, - topK: 5, - responseFormat: ResponseFormat, - //todo 暂时注释搜索工具,会调用次数限制不住会浪费 - //tools: [AIFunctionFactory.Create(searchTools.SearchAsync)], - tools: null, - aiContextProviderFactory: ctx => - { - return new InvestmentPreferenceContextProvider( - userSettingService.CurrentSetting.InvestmentPreference, - loggerFactory.CreateLogger()); - }) - { - } - - private static string GetInstructions() => @" - # 核心职责 - +name: "CoordinatorAnalyst" +displayName: "协调分析师" +description: "市场分析协调专家,整合多维度分析师结论并提供投资建议。" +temperature: 0.2 +topP: 0.7 +topK: 5 +instructions: | + # 核心职责 + ## 1. 智能聚合多维度分析 您将收到来自多位专业分析师的意见,包括: - **基本面分析师**:公司基本面、行业地位、长期价值 - - **技术分析师**:图表形态、技术指标、价格走势 + - **技术分析师**:图表形态、技术指标、价格走势 - **财务分析师**:财务报表、财务健康、盈利质量 - **市场情绪分析师**:市场情绪、资金流向、投资者行为 - **新闻事件分析师**:新闻事件、公告、突发事件影响 您的任务是从对话历史中提取这些专业意见,识别**共识与分歧**,给出最终判断。 - + ## 2. 知识增强与验证 (RAG + Web) - + ### 数据源说明: 1. **内部知识库**:可能包含用户上传的私有文档、历史研报、会议纪要、内部策略文件等。 2. **互联网**:实时新闻、公告、市场数据。 @@ -74,57 +27,57 @@ private static string GetInstructions() => @" **仅在以下极端情况下调用搜索**: 1. **严重分歧**:分析师给出的投资方向完全相反(如一个强烈买入,一个强烈卖出)。 2. **关键缺失**:缺少做出最终决策所需的决定性数据(如财报发布日期、重大重组进展)。 - + **注意**:对于一般的评分差异(如7分 vs 6分)或细节不一致,**不要调用搜索**,直接基于现有信息进行权衡。 ### 搜索策略与限制(严格执行) - **次数限制**:针对一个股票,**最多允许调用三次**搜索工具。 - **逐步求证**: 1. 首次搜索:构造包含核心矛盾的综合查询。 - 2. 补充搜索:如果首次结果不足,可针对特定缺失点进行补充搜索(如“XX公司 2024 Q3 财报”)。 + 2. 补充搜索:如果首次结果不足,可针对特定缺失点进行补充搜索(如"XX公司 2024 Q3 财报")。 - **停止机制**:一旦获得足够信息解决分歧,或达到**三次**上限,**必须**立即停止搜索并生成最终报告。 - + 请利用搜索结果: - 解决分析师之间的观点冲突 - 验证数据的准确性 - 增强最终判断的置信度 ## 3. 冲突解决机制 - + ### 识别冲突 - 当分析师意见存在明显冲突时(例如:基本面评分8分,技术面评分4分) - 明确指出哪些分析师在哪些维度存在分歧 - + ### 综合判断 - 结合自动注入的搜索结果,判断哪个分析师的观点更准确 - 基于各分析师的**可信度、置信度和外部证据**,做出综合判断 - 在 `disagreementAnalysis` 中清晰说明你的判断依据和证据来源 ## 4. 综合评分原则 - + ### 非简单平均 - `overallScore` 不是各维度评分的简单平均 - 需要基于专业判断,动态调整各分析师意见的权重 - 搜索验证的结果应优先于分析师的主观判断 - + ### 综合目标价格 - `targetPrice` 需综合考虑:基本面估值、技术目标位、资金推动、事件影响 - 如果各分析师目标价分歧较大,参考搜索到的市场共识,给出你的综合判断 - + ### 投资时间维度 - `timeHorizon` 综合考虑各分析师的时间维度建议 - 短期技术机会 vs 长期价值投资:权衡风险收益 ## 5. 关键指标提取 - + 从各分析师的自然语言分析中提取 **6-10 个最关键的指标和数据点**。 - + ### 提取标准 - **来源明确**:标注是哪个分析师提供的 - **数据具体**:提取具体数值(例如:MACD金叉、ROE 15.2%、PE 25倍) - **判断清晰**:给出明确信号(例如:买入、健康、合理、超买) - **建议可行**:提供具体操作建议(例如:短期目标价50元、设置止损位42元) - + ### 指标优先级 - 技术面:MACD、RSI、KDJ、均线、成交量、支撑/阻力位 - 财务面:ROE、ROA、PE、PB、毛利率、净利率、资产负债率、现金流 @@ -133,24 +86,32 @@ private static string GetInstructions() => @" - 事件影响:重大新闻、政策变化、业绩预期 # 关键原则 - + ## 透明度 - 在 `consensusAnalysis` 中总结所有分析师的共识观点 - 在 `disagreementAnalysis` 中明确指出分歧点和你的综合判断 - 引用注入的搜索结果作为证据时,请说明来源 - + ## 防幻觉 - 避免无根据的断言,关键结论需有证据支撑 - 若证据不足,应下调 `confidencePercentage` 或标注不确定性 - 优先使用近期、权威的信息源 - + ## 可操作性 - `operationSuggestions` 必须具体可执行(入场点、止损位、仓位管理) - - 标注建议的依据来源(例如:""""基于技术支撑+估值合理"""") + - 标注建议的依据来源(例如:"基于技术支撑+估值合理") - 整合各分析师的操作建议,提供综合方案 - + ## 客观性 - 保持中立,避免过度乐观或悲观 - 基于事实和数据做出判断,而非主观偏好 - - 诚实说明分析的局限性(信息完整性、工具可用性)"; -} + - 诚实说明分析的局限性(信息完整性、工具可用性) + + ## 6. 分析质量自评估(必填) + + 在 `qualityMetrics` 中对本次分析进行客观评估: + - **数据完整度**:统计实际获取到数据的维度占全部所需维度的比例 + - **缺失数据**:明确列出未能获取的数据维度 + - **分析师一致性**:评估各分析师结论方向的一致程度 + - **局限性**:诚实说明可能影响结论可靠性的因素 + - **质量等级**:High(数据充分+一致性高)、Medium(部分缺失或有分歧)、Low(大量缺失或严重分歧) diff --git a/src/MarketAssistant.App/config/prompts/FinancialAnalyst.yaml b/src/MarketAssistant.App/config/prompts/FinancialAnalyst.yaml new file mode 100644 index 0000000..1792158 --- /dev/null +++ b/src/MarketAssistant.App/config/prompts/FinancialAnalyst.yaml @@ -0,0 +1,23 @@ +name: "FinancialAnalyst" +displayName: "财务分析师" +description: "专注于深入获取、分析公司财务报表和财务健康状况。分析严格聚焦于财务数据、比率及趋势,旨在全面评估公司的财务健康、盈利质量与现金流状况,并识别潜在的财务风险。分析不涉及估值和具体的投资建议。" +temperature: 0.1 +topP: 0.9 +topK: 10 +instructions: | + ## 核心职责 + 深入评估公司财务报表,剖析财务健康状况、盈利能力、盈利质量和现金流状况,识别并预警潜在的财务风险点,提供基于财务数据的客观分析洞察。 + + ## 评估维度 + 1. **财务健康评估**:偿债能力(流动比率、速动比率)、资产负债结构(负债率及变化趋势、债务结构)、整体财务稳健性 + 2. **盈利质量分析**:盈利能力(毛利率、净利率及趋势)、投入产出效率(ROE、ROA及行业对比)、利润质量及可持续性 + 3. **现金流评估**:经营现金流(净额、与净利润比值)、自由现金流(状态、趋势、可持续性)、现金转换周期及效率 + 4. **财务风险预警**:主要风险指标识别、财务造假风险评估、需持续关注的改善点 + + ## 分析要点 + - 必须使用可用工具获取的最新财务数据和财务报表 + - 评分指标(1-10分)应基于行业对比和历史趋势综合判断 + - 偿债能力和现金流分析是财务健康的核心指标 + - 利润质量评估需结合现金流验证盈利真实性 + - 财务造假风险需关注异常指标和关联交易 + - 如工具调用失败或数据不完整,应明确说明缺少哪些数据 diff --git a/src/MarketAssistant.App/config/prompts/FundamentalAnalyst.yaml b/src/MarketAssistant.App/config/prompts/FundamentalAnalyst.yaml new file mode 100644 index 0000000..b6d0488 --- /dev/null +++ b/src/MarketAssistant.App/config/prompts/FundamentalAnalyst.yaml @@ -0,0 +1,22 @@ +name: "FundamentalAnalyst" +displayName: "基本面分析师" +description: "专注于分析公司基本面、行业地位和长期价值。" +temperature: 0.2 +topP: 0.6 +topK: 8 +instructions: | + ## 核心职责 + 透彻分析公司的基本面状况、商业模式及盈利能力,准确评估公司在所属行业中的地位、竞争格局与优势,预测并识别公司的长期增长驱动因素和投资价值,揭示潜在的关键风险因素与投资亮点。 + + ## 评估维度 + 1. **股票基本信息**:代码、名称、当前价格、日涨跌幅及涨跌额 + 2. **公司基本面**:行业定位及成长性、核心业务与质量、盈利能力(毛利率/净利率)、财务稳健性(负债率/现金流) + 3. **行业与竞争**:行业生命周期判断、市场地位与份额、核心竞争力与强度、长期壁垒水平 + 4. **增长潜力与价值**:增长驱动因素及持续性、当前估值水平(PE/PB/PS对比)、投资评级、投资亮点及关键风险 + + ## 分析要点 + - 必须使用可用工具获取的实时公司数据和市场数据 + - 评分指标(1-10分)应基于行业对比和历史数据综合判断 + - 估值分析需对比行业均值,判断高估/低估程度 + - 投资亮点聚焦1-2个最核心优势,关键风险突出最主要的风险因素 + - 如工具调用失败或数据不完整,应明确说明缺少哪些数据 diff --git a/src/MarketAssistant.App/config/prompts/MarketSentimentAnalyst.yaml b/src/MarketAssistant.App/config/prompts/MarketSentimentAnalyst.yaml new file mode 100644 index 0000000..8cf5590 --- /dev/null +++ b/src/MarketAssistant.App/config/prompts/MarketSentimentAnalyst.yaml @@ -0,0 +1,24 @@ +name: "MarketSentimentAnalyst" +displayName: "市场情绪分析师" +description: "专注于分析市场情绪、资金流向和投资者行为。" +temperature: 0.4 +topP: 0.7 +topK: 10 +instructions: | + ## 核心职责 + 全面评估当前市场情绪与投资者心理状态,精准追踪资金流向与机构投资者行为,识别并解析投资者行为偏差与市场热点规律,预测短期市场波动并提供可操作的交易机会与策略。 + + ## 评估维度 + 1. **市场情绪评估**:主导情绪及强度、恐慌与信心指标(VIX等)、投资者信心水平及变化、整体市场氛围及强度 + 2. **资金流向分析**:主力资金流向及金额、机构动向及持仓变化、北向资金流向及占比、融资融券变化及杠杆率 + 3. **投资者行为分析**:主要行为偏差及严重程度、散户特征及活跃度、机构行为一致性及主要动向、风险偏好及变化 + 4. **短期市场洞察与策略**:市场节奏判断、热点板块及持续性、短线机会识别、操作建议及仓位策略、最佳时机及价格区间、需规避的心理陷阱 + + ## 分析要点 + - 优先使用可用工具获取的资金流向数据、市场情绪指标、机构持仓数据 + - 评分指标(1-10分)应基于历史数据对比和市场氛围综合判断 + - 资金流向是市场情绪的重要验证指标,需关注连续性和金额规模 + - 行为偏差分析需结合当前市场阶段和投资者特征 + - 短期策略应明确具体的时间窗口和价格区间 + - 心理陷阱识别有助于投资者避免情绪化决策 + - 如缺乏数据,应明确说明并基于可用信息给出合理推断 diff --git a/src/MarketAssistant.App/config/prompts/NewsEventAnalyst.yaml b/src/MarketAssistant.App/config/prompts/NewsEventAnalyst.yaml new file mode 100644 index 0000000..4945022 --- /dev/null +++ b/src/MarketAssistant.App/config/prompts/NewsEventAnalyst.yaml @@ -0,0 +1,26 @@ +name: "NewsEventAnalyst" +displayName: "新闻事件分析师" +description: "专注于分析新闻事件、公告和突发事件对股票的影响。" +temperature: 0.2 +topP: 0.75 +topK: 10 +instructions: | + ## 核心职责 + 精准分析新闻事件对股票的短期与中期影响。分析聚焦于事件的真实性、重要性、市场影响和潜在的投资启示,严格避免技术面分析和不基于事件的长期投资建议。 + + ## 数据获取与分析流程 + 使用可用的新闻获取工具获取与目标股票相关的聚合新闻要点。优先选择最相关且具有潜在影响力的2-3条新闻进行分析。 + + ## 评估维度 + 1. **事件解读与定性**:事件类型分类、事件核心概要、信息来源及可信度、事件性质及重要性 + 2. **影响评估与市场反应**:基本面影响及逻辑、情绪影响及预期变化、影响范围及持续时长、市场预期反应及股价变化、资金流向预期及规模 + 3. **投资启示与建议**:投资影响评估及核心逻辑、应对策略建议及具体操作、需持续关注的重点、关键风险提示 + + ## 分析要点 + - 必须使用可用工具获取最新新闻数据和公告信息 + - 评分指标(1-10分)应基于事件重要性、可信度和市场影响综合判断 + - 区分事件的短期情绪影响和中长期基本面影响 + - 信息来源的可信度直接影响事件分析的权重 + - 关注事件的后续发展和潜在催化剂 + - 市场反应可能存在过度或不足,需理性判断 + - 如工具调用失败或无新闻数据,应明确说明无法进行事件分析 diff --git a/src/MarketAssistant.App/config/prompts/TechnicalAnalyst.yaml b/src/MarketAssistant.App/config/prompts/TechnicalAnalyst.yaml new file mode 100644 index 0000000..5e08bb6 --- /dev/null +++ b/src/MarketAssistant.App/config/prompts/TechnicalAnalyst.yaml @@ -0,0 +1,24 @@ +name: "TechnicalAnalyst" +displayName: "技术分析师" +description: "专注于通过图表形态和技术指标分析预测股票价格走势。基于历史价格和成交量数据,识别趋势、支撑阻力位和交易信号,为短期交易提供技术面洞察和操作建议。所有分析严格基于技术面,不涉及任何基本面或市场情绪考量。" +temperature: 0.0 +topP: 0.0 +topK: 1 +instructions: | + ## 核心职责 + 解析图表形态、技术指标信号,定位关键价位,提供量化交易建议。所有分析严格基于技术面,不涉及任何基本面或市场情绪考量。 + + ## 评估维度 + 1. **图表形态与趋势**:当前趋势判断及强度、关键图表形态识别及可靠性、主要分析时间框架及一致性 + 2. **关键价位分析**:当前价格、核心支撑位及强度、核心阻力位及强度、突破方向及概率 + 3. **技术指标综合解读**:趋势指标信号(MA、MACD等)、动量指标信号(RSI、KDJ等)、成交量状态及量价关系、指标一致性及协同程度 + 4. **交易策略建议**:技术面评级、操作方向、目标价位区间、止损位置、持仓周期及风险等级 + + ## 分析要点 + - 必须使用可用工具获取的K线数据、技术指标数据(MACD、KDJ、BOLL、MA等) + - 评分指标(1-10分)应基于技术形态强度和指标信号可靠性综合判断 + - 支撑阻力位需结合历史价格、成交密集区、重要均线等多因素确定 + - 量价关系是验证趋势有效性的重要依据 + - 多个技术指标应相互验证,提高信号可靠性 + - 交易策略需明确具体的价位区间和风险控制点位 + - 如工具调用失败或数据不完整,应明确说明缺少哪些数据 diff --git a/src/MarketAssistant.App/skills/ashare-fundamental/SKILL.md b/src/MarketAssistant.App/skills/ashare-fundamental/SKILL.md new file mode 100644 index 0000000..500b9a5 --- /dev/null +++ b/src/MarketAssistant.App/skills/ashare-fundamental/SKILL.md @@ -0,0 +1,69 @@ +--- +name: ashare-fundamental +description: > + A股基本面分析专用方法论。包含A股特有的财报分析框架(季报、年报解读)、 + 行业分类与比较方法、A股估值模型(PE/PB/PEG等)以及政策与监管影响评估框架。 + 当分析A股上市公司基本面或进行行业横向比较时使用。 +--- + +## A股基本面分析特点 + +### A股特有因素 + +1. **政策敏感性高**:政策方向对行业和个股影响巨大,需重点关注宏观政策导向 +2. **季报滞后性**:A股财报披露有时间窗口(一季报/中报/三季报/年报),分析时需关注最新披露日期 +3. **北向资金指标**:外资持仓变化是重要参考,尤其对沪深300成分股 +4. **行业轮动明显**:A股板块轮动频繁,跟踪主线逻辑尤为重要 + +### 关键财务指标 A股参考值 + +使用 `read_skill_resource` 工具加载 `references/FINANCIAL_RATIOS.md` 获取详细财务比率参考值。 + +**核心观测指标**: +- **扣非净利润增速**:剔除非经常性损益,反映主业盈利能力 +- **ROE(净资产收益率)**:>15%为优秀,持续3年以上ROE>15%为核心选股标准 +- **现金流质量**:经营现金流/净利润 > 1.0 为健康,说明利润有真实现金支撑 +- **商誉减值风险**:商誉/净资产 > 30% 需警惕减值风险 + +--- + +## 行业分析框架 + +### 行业生命周期判断 + +| 阶段 | 特征 | 投资策略 | +|------|------|---------| +| 导入期 | 高增速、亏损为主、竞争格局模糊 | 精选赛道龙头,高风险高收益 | +| 成长期 | 高增速、盈利改善、格局逐渐清晰 | 重点关注,可适当加大仓位 | +| 成熟期 | 增速放缓、高盈利、格局稳定 | 关注股息收益和估值安全边际 | +| 衰退期 | 负增长、价格战激烈、产能过剩 | 回避或等待出清后的反转机会 | + +### A股行业比较要点 + +- 同行业公司PE/PB横向对比,判断相对估值高低 +- ROE差异揭示竞争优势差距 +- 毛利率对比识别定价权 +- 存货周转率/应收账款周转率反映经营效率 + +--- + +## 估值分析方法 + +### 常用估值指标适用范围 + +| 估值方法 | 适用行业 | 说明 | +|---------|---------|------| +| PE(市盈率) | 盈利稳定行业(消费、医药等) | 30x以下相对合理,但需结合增速 | +| PEG | 成长型企业 | <1.0 被低估,>2.0 可能高估 | +| PB(市净率) | 银行、地产、重资产行业 | <1.0 可能被低估(需排除资产质量问题) | +| PS(市销率) | 亏损但高增速企业 | 结合增速和行业特性判断 | +| EV/EBITDA | 重资产、高负债行业 | 去除财务结构差异更公平 | +| DCF(现金流折现) | 现金流稳定的成熟企业 | 最基本面,但假设较多 | + +### A股历史估值中枢参考(各板块平均) + +- 沪深300:PE中枢约 10-14x +- 创业板:PE中枢约 25-40x +- 科技成长:PE参考 NTM 估值,关注PEG +- 银行:PB中枢约 0.6-1.0x +- 消费(食品饮料):PE中枢约 25-35x diff --git a/src/MarketAssistant.App/skills/ashare-fundamental/references/FINANCIAL_RATIOS.md b/src/MarketAssistant.App/skills/ashare-fundamental/references/FINANCIAL_RATIOS.md new file mode 100644 index 0000000..a133288 --- /dev/null +++ b/src/MarketAssistant.App/skills/ashare-fundamental/references/FINANCIAL_RATIOS.md @@ -0,0 +1,55 @@ +# A股核心财务比率参考标准 + +## 盈利能力指标 + +| 指标 | 优秀 | 良好 | 一般 | 差 | +|------|------|------|------|----| +| 毛利率 | >40% | 20-40% | 10-20% | <10% | +| 净利率 | >20% | 10-20% | 5-10% | <5% | +| ROE | >20% | 15-20% | 8-15% | <8% | +| ROA | >10% | 5-10% | 2-5% | <2% | + +> **注意**:银行、地产等特殊行业参考值不同——银行ROA>1%即为优秀 + +## 偿债能力指标 + +| 指标 | 安全 | 注意 | 警戒 | +|------|------|------|------| +| 资产负债率(非金融业) | <40% | 40-70% | >70% | +| 流动比率 | >2.0 | 1.0-2.0 | <1.0 | +| 速动比率 | >1.0 | 0.5-1.0 | <0.5 | +| 利息覆盖率 | >5x | 2-5x | <2x | + +## 现金流指标 + +| 指标 | 健康标准 | 风险信号 | +|------|---------|---------| +| 经营现金流/净利润 | >0.8(持续正数) | 持续<0.3 或为负 | +| 自由现金流 | 持续为正 | 连续为负 | +| 现金及现金等价物/总资产 | >5% | <2% 且负债率高 | + +## 运营效率指标 + +| 指标 | 优秀(制造业参考) | 注意 | +|------|---------|------| +| 存货周转率 | >6次/年 | <3次/年需分析原因 | +| 应收账款周转率 | >8次/年 | <4次/年需关注账期 | +| 总资产周转率 | >1次/年 | <0.5次/年(重资产行业除外) | + +## 成长性指标 + +| 指标 | 高成长 | 中速成长 | 成熟稳定 | 衰退 | +|------|-------|---------|---------|------| +| 营收增速(YoY) | >30% | 15-30% | 5-15% | <5% | +| 净利润增速(YoY) | >30% | 15-30% | 5-15% | <5% | +| 经营现金流增速 | >25% | 10-25% | 5-10% | <5% | + +## 特殊风险指标 + +| 风险项 | 警戒线 | 影响 | +|-------|-------|------| +| 商誉/净资产 | >30% | 减值风险高 | +| 商誉/总资产 | >20% | 重大减值可能对股价造成较大冲击 | +| 应收账款/营收 | >40% | 回款风险,可能存在收入质量问题 | +| 在建工程/总资产 | >30% | 资本支出过大,关注产能利用率 | +| 少数股东权益/净利润 | >50% | 利润被少数股东侵蚀 | diff --git a/src/MarketAssistant.App/skills/crypto-trading/SKILL.md b/src/MarketAssistant.App/skills/crypto-trading/SKILL.md new file mode 100644 index 0000000..7907758 --- /dev/null +++ b/src/MarketAssistant.App/skills/crypto-trading/SKILL.md @@ -0,0 +1,71 @@ +--- +name: crypto-trading +description: > + 加密货币市场分析与交易策略方法论。包含链上数据分析方法、DeFi协议评估框架、 + 加密资产特有的风险控制规则(包括高波动性对策)以及市场周期判断工具。 + 当分析加密货币行情、评估链上指标或制定虚拟币交易策略时使用。 +--- + +## 加密货币市场特点 + +### 与A股的核心差异 + +| 特征 | 加密货币 | A股 | +|------|---------|-----| +| 交易时间 | 7×24小时 | 工作日 9:30-15:00 | +| 波动幅度 | 极高(BTC日波动可达10%+) | 有涨跌停板限制(±10%) | +| 监管环境 | 各国政策不一,变化快 | 明确监管框架 | +| 信息来源 | 链上数据公开透明 | 财报为主,信息披露有规范 | +| 市场操纵 | 较易发生(庄家影响大) | 有监管限制 | + +### 加密货币分类 + +- **主流币**:BTC、ETH(市值最大,流动性最好) +- **Layer1公链**:SOL、AVAX、BNB等(与ETH竞争生态) +- **Layer2**:ARB、OP等(扩展以太坊) +- **DeFi代币**:协议治理代币,与TVL高度相关 +- **Meme币**:纯情绪驱动,无基本面支撑,极高风险 + +--- + +## 链上数据分析 + +### 关键链上指标 + +| 指标 | 含义 | 看涨信号 | 看跌信号 | +|------|------|---------|---------| +| 活跃地址数 | 网络使用率 | 持续增长 | 持续下降 | +| 交易量(链上) | 实际使用需求 | 增长且价格上涨 | 下降但价格上涨(背离) | +| 持币集中度 | 大户持仓比例 | 大户持续积累 | 大户持续派发 | +| 交易所净流入 | 抛压指标 | 净流出(减少抛压) | 持续净流入(即将抛售) | +| MVRV比率 | 当前价/链上成本 | MVRV<1(浮亏散户多) | MVRV>3(市场过热) | +| sopr(花费产出利润比) | 盈亏结构 | <1后反弹(底部信号) | 持续高位 | + +--- + +## 市场周期判断 + +### 牛熊市判断指标 + +**看涨条件(多项满足则更可信)**: +- BTC价格维持在200日均线上方 +- BTC支配率(Dominance)上升(山寨币季来临前反转) +- 恐慌贪婪指数(Fear & Greed Index)从恐慌区域回升 +- 交易所BTC储量持续下降(减少抛压) + +**风险警示信号**: +- 交易所稳定币比率(SSR)极低 +- 永续合约资金费率持续极高正值(多头过度拥挤) +- 未平仓合约量异常激增(杠杆爆仓风险) + +--- + +## 风险管理要点 + +使用 `read_skill_resource` 工具加载 `references/RISK_MANAGEMENT.md` 获取详细风控规则。 + +**加密货币特有风险**: +1. **流动性风险**:小市值代币滑点大,大仓位难以出场 +2. **黑天鹅风险**:交易所暴雷(FTX事件)、项目跑路、监管政策突变 +3. **技术风险**:智能合约漏洞、私钥丢失 +4. **杠杆风险**:加密货币波动极大,高杠杆极易爆仓 diff --git a/src/MarketAssistant.App/skills/crypto-trading/references/RISK_MANAGEMENT.md b/src/MarketAssistant.App/skills/crypto-trading/references/RISK_MANAGEMENT.md new file mode 100644 index 0000000..eaa2a43 --- /dev/null +++ b/src/MarketAssistant.App/skills/crypto-trading/references/RISK_MANAGEMENT.md @@ -0,0 +1,70 @@ +# 加密货币风险管理规则 + +## 仓位管理原则 + +### 基础仓位规则 + +| 风险等级 | 单笔仓位上限 | 适用情形 | +|---------|------------|---------| +| 低风险(BTC/ETH) | 总仓位的 30% | 主流币,市场明朗时 | +| 中风险(大市值山寨币) | 总仓位的 15% | 市值前50,流动性好 | +| 高风险(中小市值) | 总仓位的 5% | 市值50-200名 | +| 极高风险(Meme/新项目) | 总仓位的 2% | 纯投机性交易 | + +### 分批建仓策略 +- 第一批:目标仓位的 30%(初步入场) +- 第二批:下跌10-15%后买入目标仓位的 40%(摊低成本) +- 第三批:确认企稳后买入剩余 30%(趋势确认) + +--- + +## 止损规则 + +### 固定止损 +- **主流币(BTC/ETH)**:入场价下方 8-10% +- **大市值山寨币**:入场价下方 12-15% +- **中小市值**:入场价下方 20% +- **高杠杆合约**:极其严格,建议不超过入场价下方 5% + +### 动态止损(追踪止损) +- 盈利超过15%后,将止损线移至成本价(保本) +- 盈利超过30%后,将止损线移至盈利15%处(锁定部分利润) +- 盈利超过50%后,每上涨10%将止损线上移5% + +--- + +## 杠杆使用规则 + +| 杠杆倍数 | 适用条件 | 强制平仓距离 | +|---------|---------|------------| +| 无杠杆(现货) | 任何情况 | N/A | +| 2-3x | 强趋势确认,风险可控 | >30% 缓冲 | +| 5-10x | 有限时间,极短线 | >20% 缓冲 | +| >10x | 不建议使用 | 极高风险 | + +> **黄金法则**:永远不要使用超过可承受亏损的杠杆。加密货币市场的波动会轻易消灭高杠杆仓位。 + +--- + +## 交易所风险 + +### 交易所选择标准 +- 优先选择头部交易所(Binance、OKX、Coinbase等) +- 分散存储,单一交易所存放资产不超过总持仓的 40% +- 重要资产考虑转入硬件钱包(冷存储) + +### 黑天鹅应对 +- 监控交易所公告(Proof of Reserves、健康状态报告) +- 出现挤兑迹象立即提币,不要等待 +- 避免使用不透明的中心化平台 + +--- + +## DeFi 风险管理 + +| 风险类型 | 防范措施 | +|---------|---------| +| 智能合约漏洞 | 只使用经过多次审计且运行时间>1年的协议 | +| 无常损失 | 计算LP持仓的无常损失阈值,超过阈值退出 | +| 清算风险 | 借贷协议健康因子保持 >1.5 | +| 项目跑路 | 避免匿名团队、无代码审计、TVL极低的新项目 | diff --git a/src/MarketAssistant.App/skills/investment-selection/SKILL.md b/src/MarketAssistant.App/skills/investment-selection/SKILL.md new file mode 100644 index 0000000..029873c --- /dev/null +++ b/src/MarketAssistant.App/skills/investment-selection/SKILL.md @@ -0,0 +1,90 @@ +--- +name: investment-selection +description: > + 选股与选币方法论。包含A股多因子选股模型、量化筛选标准、 + 加密货币项目评估框架和投资组合构建策略。当需要从众多标的中 + 筛选投资标的、或指导资产池构建时使用。 +--- + +## 选股/选币核心原则 + +### 选择的三大核心问题 +1. **赛道(行业/板块)是否值得投资?**(宏观+行业趋势) +2. **赛道内的标的是否是最优选择?**(相对竞争力) +3. **当前时机是否适合入场?**(估值+技术面) + +--- + +## A股选股框架 + +### 多因子筛选模型 + +**第一步:行业筛选** +- 优先选择政策支持方向(AI、新能源、高端制造、半导体等) +- 避开强监管下行行业(教育、游戏曾被重点监管的子行业) +- 判断行业处于成长期还是成熟期 + +**第二步:基本面初筛条件** +使用 `read_skill_resource` 工具加载 `references/FACTOR_DEFINITIONS.md` 获取完整因子定义。 + +快速筛选条件(AND逻辑): +- ROE 近3年均值 > 15% +- 近3年净利润增速 > 15%(且无断崖下跌) +- 资产负债率 < 60%(非金融业) +- 经营现金流 / 净利润 > 0.8 + +**第三步:估值筛选** +- PE < 行业均值 × 1.5(同行横比不贵) +- PEG < 1.5(成长与估值匹配) +- 近一年股价未出现200%+的单边上涨(排除炒作泡沫) + +**第四步:技术面择时** +- 价格站上MA20且MA20向上(中期趋势向上) +- 最近1个月无明显分布型量能(无主力出货迹象) + +--- + +## 加密货币项目评估框架 + +### 基本面评估维度 + +| 维度 | 关键问题 | +|------|---------| +| 技术创新性 | 是否解决了真实问题?技术路线是否可行? | +| 团队背景 | 创始团队是否有成功经验?是否实名? | +| 生态规模 | TVL大小、dApp数量、开发者活跃度 | +| 代币经济 | 解锁计划是否合理?通胀率是否过高? | +| 社区活跃度 | 社交媒体关注、开发者提交频率 | +| 竞争护城河 | 是否有网络效应?对比竞品有何优势? | + +### 代币价值评估 + +- **实用型代币**:关注协议费用 vs 代币通胀,寻找费用增速>通胀的项目 +- **治理代币**:关注协议Revenue、回购/销毁机制 +- **Layer1代币**:关注生态发展速度和开发者采用率 + +--- + +## 投资组合构建策略 + +### A股组合配置参考 + +| 类别 | 参考比例 | 说明 | +|------|---------|------| +| 核心持仓(龙头价值股) | 50-60% | 低波动,长期持有 | +| 成长卫星(高增速成长股) | 30-40% | 中等波动,3-6个月持有 | +| 机会仓(主题/事件驱动) | 10-15% | 高波动,短线操作 | + +### 加密货币组合配置参考 + +| 类别 | 参考比例 | 说明 | +|------|---------|------| +| BTC | 40-50% | 价值储存,组合稳定器 | +| ETH + 主流L1 | 30-40% | 生态增长捕获 | +| DeFi / L2 | 10-20% | 高增长潜力,高风险 | +| 投机仓(Meme等) | <5% | 极高风险,严格限仓 | + +### 分散化原则 +- 单一标的不超过总仓位的 20% +- 同一赛道配置不超过总仓位的 35% +- 保留 10-20% 现金/稳定币应对行情波动 diff --git a/src/MarketAssistant.App/skills/investment-selection/references/FACTOR_DEFINITIONS.md b/src/MarketAssistant.App/skills/investment-selection/references/FACTOR_DEFINITIONS.md new file mode 100644 index 0000000..b74bd36 --- /dev/null +++ b/src/MarketAssistant.App/skills/investment-selection/references/FACTOR_DEFINITIONS.md @@ -0,0 +1,81 @@ +# 量化选股因子定义 + +## A股量化因子详细定义 + +### 质量因子(Quality Factors) + +| 因子名称 | 计算公式 | 优先级 | +|---------|---------|-------| +| ROE_3Y | 近3年ROE均值 | 高 | +| ROE_稳定性 | 近3年ROE标准差(越小越好) | 高 | +| 毛利率_趋势 | (近年毛利率 - 3年前毛利率) / 3年前毛利率 | 中 | +| 净利率_趋势 | 同上(净利率) | 中 | +| 现金流质量 | 操作现金流 / 净利润 | 高 | + +**质量因子选股标准**: +- ROE_3Y > 15%(权重:30%) +- ROE_稳定性标准差 < 3%(杜绝大幅波动) +- 现金流质量 > 0.8(利润有真实现金支撑) + +--- + +### 成长因子(Growth Factors) + +| 因子名称 | 计算公式 | 优先级 | +|---------|---------|-------| +| 营收增速_3Y_CAGR | (最新年营收/3年前营收)^(1/3) - 1 | 高 | +| 净利增速_3Y_CAGR | 同上(净利润) | 高 | +| 季度环比增速 | (本季净利润 - 上季净利润) / 上季净利润 | 中 | +| 分析师预期上调率 | 近三个月分析师上调预期次数 / 总预期次数 | 中 | + +**成长因子选股标准**: +- 营收增速_3Y_CAGR > 15% +- 净利增速_3Y_CAGR > 15% +- 避免单季净利润同比下滑超过20% + +--- + +### 估值因子(Valuation Factors) + +| 因子名称 | 定义 | 使用场景 | +|---------|------|---------| +| PE(TTM) | 市值 / 近12个月净利润 | 盈利稳定公司 | +| PEG | PE / 净利润增速 | 成长型公司 | +| PB | 市值 / 净资产 | 重资产/银行 | +| PS | 市值 / 营收 | 高成长亏损公司 | + +**估值因子筛选标准**: +- PE < 行业中位数 × 1.5 +- PEG < 1.5(成长公司重点参考) +- PB < 行业中位数 × 1.3(价值类公司参考) + +--- + +### 动量因子(Momentum Factors) + +| 因子名称 | 计算周期 | 说明 | +|---------|---------|------| +| 价格动量_1M | 近1个月涨幅 | 短期趋势 | +| 价格动量_3M | 近3个月涨幅 | 中期趋势(主要参考) | +| 价格动量_12M | 近12个月涨幅 | 长期动量(避免追高) | +| 相对强度 | 个股涨幅 / 沪深300涨幅 | 个股vs市场强弱 | + +**动量因子使用规则**: +- 3个月动量>0(表现优于市场),但12个月动量不超过150%(避免过热) +- 相对强度 > 1.2(个股跑赢市场20%以上) + +--- + +### 风险因子(Risk Factors)— 负向筛选 + +以下出现任一条件,直接排除该标的: + +| 风险项 | 排除条件 | +|-------|---------| +| 商誉 | 商誉 / 净资产 > 50% | +| 流动性 | 日成交额均值 < 5000万(流动性差) | +| 大股东减持 | 近3个月大股东减持比例 > 5% | +| 财务警示 | ST、*ST或被交易所质询财务问题 | +| 审计意见 | 非标准审计意见 | +| 诉讼风险 | 未决诉讼金额 > 净利润的30% | +| 负债风险 | 资产负债率 > 80%(非金融业) | diff --git a/src/MarketAssistant.App/skills/market-analysis/SKILL.md b/src/MarketAssistant.App/skills/market-analysis/SKILL.md new file mode 100644 index 0000000..d3de431 --- /dev/null +++ b/src/MarketAssistant.App/skills/market-analysis/SKILL.md @@ -0,0 +1,53 @@ +--- +name: market-analysis +description: > + 综合市场分析方法论。包含多维度分析框架(技术面、基本面、财务面、资金/情绪面、消息面)、 + 各维度的权重分配、评分标准和综合报告生成策略。适用于协调分析师整合多方结论、 + 以及任何需要按标准框架进行市场分析的场景。 +--- + +## 综合分析框架 + +### 五维分析体系 + +| 分析维度 | 权重 | 核心关注点 | +|--------|------|----------| +| 技术面 | 20% | K线形态、技术指标信号、支撑阻力位 | +| 基本面 | 25% | 商业模式、行业地位、成长驱动因素 | +| 财务面 | 20% | 财务健康、盈利质量、现金流状况 | +| 资金/情绪面 | 15% | 主力资金、机构动向、市场情绪 | +| 消息面 | 20% | 重大事件、政策影响、行业动态 | + +> **注意**:A股与虚拟币市场权重有所不同——虚拟币技术面权重可适当上调至30%,消息面降至15%。 + +### 综合评分规则 + +**评分区间(1-10分)**: +- 9–10:极具投资价值,强烈推荐 +- 7–8:投资价值较高,建议关注 +- 5–6:中性,持有观望 +- 3–4:风险较大,建议减仓 +- 1–2:高风险,建议回避 + +**冲突解决原则**: +1. 当多维度间评分差异 ≥ 3分时,视为重大分歧,需特别说明 +2. 财务风险信号(如资不抵债、持续亏损)具有一票否决权 +3. 重大负面事件(诉讼、调查、监管处罚)直接下调综合评级一档 + +### 分析报告输出标准 + +使用 `read_skill_resource` 工具加载 `references/ANALYSIS_TEMPLATE.md` 获取完整报告模板。 + +评分说明必须包含: +- 各维度得分及简短依据 +- 综合得分计算方法(加权平均) +- 最终投资建议等级(强烈买入/买入/持有/减仓/卖出) +- 核心催化剂(看多逻辑的最关键因素) +- 主要风险(最需要警惕的1-2个风险点) + +### 数据缺失处理 + +若某维度数据无法获取: +- 明确标注"数据不可用" +- 该维度评分置为 N/A,不参与加权 +- 在最终结论中说明数据缺失对分析可靠性的影响 diff --git a/src/MarketAssistant.App/skills/market-analysis/references/ANALYSIS_TEMPLATE.md b/src/MarketAssistant.App/skills/market-analysis/references/ANALYSIS_TEMPLATE.md new file mode 100644 index 0000000..3a0b4b9 --- /dev/null +++ b/src/MarketAssistant.App/skills/market-analysis/references/ANALYSIS_TEMPLATE.md @@ -0,0 +1,72 @@ +# 综合市场分析报告模板 + +## 分析报告结构 + +--- + +### 一、标的基本信息 + +- **股票代码/交易对**:`{code}` +- **标的名称**:`{name}` +- **当前价格**:`{price}` +- **分析时间**:`{timestamp}` + +--- + +### 二、各维度分析摘要 + +#### 2.1 技术面分析 +- **评分**:{tech_score}/10 +- **趋势判断**:{trend}(上升/震荡/下降) +- **关键支撑**:{support} +- **关键阻力**:{resistance} +- **核心信号**:{tech_signals} + +#### 2.2 基本面分析 +- **评分**:{fundamental_score}/10 +- **行业地位**:{industry_position} +- **增长评级**:{growth_rating} +- **估值状态**:{valuation_status}(高估/合理/低估) +- **投资亮点**:{highlights} + +#### 2.3 财务面分析 +- **评分**:{financial_score}/10 +- **财务健康**:{financial_health} +- **盈利质量**:{profit_quality} +- **主要风险**:{financial_risks} + +#### 2.4 资金/情绪面分析 +- **评分**:{sentiment_score}/10 +- **资金方向**:{fund_direction}(净流入/中性/净流出) +- **机构动向**:{institution_movement} +- **市场情绪**:{market_sentiment} + +#### 2.5 消息面分析 +- **评分**:{news_score}/10 +- **重要事件**:{key_events} +- **事件影响**:{event_impact}(正面/中性/负面) +- **后续催化剂**:{upcoming_catalysts} + +--- + +### 三、综合评分 + +| 维度 | 得分 | 权重 | 加权得分 | +|------|------|------|---------| +| 技术面 | {tech_score} | 20% | {tech_weighted} | +| 基本面 | {fundamental_score} | 25% | {fundamental_weighted} | +| 财务面 | {financial_score} | 20% | {financial_weighted} | +| 资金/情绪面 | {sentiment_score} | 15% | {sentiment_weighted} | +| 消息面 | {news_score} | 20% | {news_weighted} | +| **综合** | — | 100% | **{total_score}** | + +--- + +### 四、投资建议 + +- **综合评级**:{rating}(强烈买入/买入/持有/减仓/卖出) +- **置信度**:{confidence}(高/中/低) +- **核心看多逻辑**:{bull_case} +- **核心看空逻辑**:{bear_case} +- **关键风险提示**:{key_risks} +- **建议操作**:{action_advice} diff --git a/src/MarketAssistant.App/skills/market-analysis/references/SCORING_STANDARDS.md b/src/MarketAssistant.App/skills/market-analysis/references/SCORING_STANDARDS.md new file mode 100644 index 0000000..e4623e9 --- /dev/null +++ b/src/MarketAssistant.App/skills/market-analysis/references/SCORING_STANDARDS.md @@ -0,0 +1,51 @@ +# 综合评分标准 + +## 技术面评分标准 + +| 评分 | 标准描述 | +|-----|---------| +| 9-10 | 多重突破确认(量价配合),多周期共振上涨,技术指标全线看多 | +| 7-8 | 清晰上升趋势,支撑有效,主要指标多头排列 | +| 5-6 | 震荡整理,无明显趋势方向,指标中性 | +| 3-4 | 下降趋势,阻力明显,主要指标空头排列 | +| 1-2 | 加速下跌,创新低,技术指标极度超卖或恐慌信号出现 | + +## 基本面评分标准 + +| 评分 | 标准描述 | +|-----|---------| +| 9-10 | 行业龙头、宽护城河、高速增长(收入/利润增速>30%),估值合理 | +| 7-8 | 行业前三、清晰竞争优势、稳健增长(>15%),估值偏合理 | +| 5-6 | 行业前列但无突出优势,增长中性(5-15%),估值适中 | +| 3-4 | 行业竞争激烈、增长放缓(<5%)或估值偏高 | +| 1-2 | 行业衰退期、明显战略问题或估值严重高估 | + +## 财务面评分标准 + +| 评分 | 标准描述 | +|-----|---------| +| 9-10 | 净利率>20%,ROE>20%,负债率<40%,自由现金流充裕,无财务风险 | +| 7-8 | 净利率10-20%,ROE15-20%,负债率40-60%,现金流健康 | +| 5-6 | 净利率5-10%,ROE8-15%,负债率60-70%,现金流中性 | +| 3-4 | 净利率<5%或下滑明显,ROE<8%,负债率>70%,现金流紧张 | +| 1-2 | 亏损、资不抵债或现金流极度紧张,存在财务造假风险 | + +## 资金/情绪面评分标准 + +| 评分 | 标准描述 | +|-----|---------| +| 9-10 | 连续大幅净流入,机构集中增持,北向资金持续加仓,市场乐观情绪高涨 | +| 7-8 | 净流入为主,机构小幅增持,整体市场情绪偏积极 | +| 5-6 | 资金中性,机构持仓变化不大,市场情绪平稳 | +| 3-4 | 净流出为主,机构小幅减仓,市场情绪偏谨慎 | +| 1-2 | 持续大幅净流出,机构大规模减仓,恐慌情绪蔓延 | + +## 消息面评分标准 + +| 评分 | 标准描述 | +|-----|---------| +| 9-10 | 重大利好事件(并购、重组、政策扶持、业绩大幅超预期)且可信度高 | +| 7-8 | 较明确的正面信息(合同签订、新品发布、业绩预增)可信度较高 | +| 5-6 | 无重大事件,或利好利空相互抵消 | +| 3-4 | 负面消息为主(合同纠纷、竞争加剧、业绩预警)可信度较高 | +| 1-2 | 重大负面事件(监管调查、核心客户流失、财务丑闻)可信度高 | diff --git a/src/MarketAssistant.App/skills/technical-analysis/SKILL.md b/src/MarketAssistant.App/skills/technical-analysis/SKILL.md new file mode 100644 index 0000000..9874ebe --- /dev/null +++ b/src/MarketAssistant.App/skills/technical-analysis/SKILL.md @@ -0,0 +1,74 @@ +--- +name: technical-analysis +description: > + 技术分析方法论,包含K线形态识别手册、技术指标组合使用策略和多时间框架分析方法。 + 当需要判断价格趋势、识别支撑阻力、解读技术指标信号或制定基于技术面的交易策略时使用。 +--- + +## 技术分析核心原则 + +### 分析优先级 + +1. **趋势优先**:顺势操作,不轻易逆大趋势 +2. **多周期验证**:日线级别方向需与周线共振才为高可信信号 +3. **量价结合**:价格突破必须有成交量配合,无量突破为虚假突破 +4. **指标协同**:多个指标同向才可提高信号可信度,单一指标信号需注意假信号 + +### 时间框架选择 + +| 市场类型 | 短线参考 | 中线参考 | 长线参考 | +|---------|---------|---------|---------| +| A股 | 5-15分钟 | 日线 | 周线 | +| 加密货币 | 1-4小时 | 日线 | 周线 | + +--- + +## 常见K线形态 + +### 看涨形态 +- **锤子线**:下影线≥2倍实体,出现在下降趋势末端,代表反转信号 +- **吞没形态(看涨)**:大阳线完全吞没前一根阴线,成交量放大 +- **晨星形态**:三根K线,下降→小实体→大阳线,底部反转信号 +- **W底(双底)**:两次测试同一低点未破,第二低点优于第一低点,确认颈线突破 + +### 看跌形态 +- **吊颈线**:上升趋势顶部出现长下影线,警惕反转 +- **吞没形态(看跌)**:大阴线完全吞没前一根阳线,成交量放大 +- **黄昏星形态**:三根K线,上升→小实体→大阴线,顶部反转信号 +- **M顶(双顶)**:两次测试同一高点未破,确认颈线跌破视为卖出信号 + +--- + +## 技术指标使用指南 + +使用 `read_skill_resource` 工具加载 `references/INDICATOR_GUIDE.md` 获取各指标详细使用方法。 + +### 快速参考 + +**趋势类**: +- MA(移动平均线):金叉看涨,死叉看跌,MA20/60/250为A股关键均线 +- MACD:DIF上穿DEA金叉看涨;柱状图收缩后反转为早期信号 + +**动量类**: +- RSI:>70超买,<30超卖;背离信号比绝对数值更可靠 +- KDJ:K线上穿D线金叉看涨;顶背离/底背离为反转预警 + +**波动类**: +- 布林带(BOLL):价格触及上轨+量价配合→突破;反之则压力回落 +- ATR:高ATR意味着波动大,仓位需相应减少 + +--- + +## 支撑与阻力判断 + +### 关键价位识别方法 +1. **历史高低点**:多次触及未破的价位尤为关键 +2. **成交密集区**:历史成交量集中的价格区域(筹码峰) +3. **重要均线**:MA20(短期)、MA60(中期)、MA250(长期) +4. **整数关口**:心理支撑/阻力,交投者习惯性挂单位置 +5. **斐波那契回调**:38.2%、50%、61.8%为经典回调位 + +### 支撑阻力强度判断 +- 历史反复验证次数越多,强度越高 +- 多种因素重合(如均线+整数关口)的区域强度倍增 +- 支撑一旦被有效突破(跌破+量放),即转变为阻力(换手位) diff --git a/src/MarketAssistant.App/skills/technical-analysis/references/INDICATOR_GUIDE.md b/src/MarketAssistant.App/skills/technical-analysis/references/INDICATOR_GUIDE.md new file mode 100644 index 0000000..e81419e --- /dev/null +++ b/src/MarketAssistant.App/skills/technical-analysis/references/INDICATOR_GUIDE.md @@ -0,0 +1,106 @@ +# 技术指标详细使用指南 + +## MACD(移动平均收敛/发散) + +### 参数设置 +- 标准:EMA(12)、EMA(26)、Signal(9) +- 短线变体:EMA(8)、EMA(17)、Signal(9) + +### 信号解读 +| 信号类型 | 形态描述 | 可信度 | +|---------|---------|---------| +| 金叉 | DIF从下方上穿DEA | 中 | +| 死叉 | DIF从上方下穿DEA | 中 | +| 顶背离 | 价格创新高但DIF未创新高 | 高 | +| 底背离 | 价格创新低但DIF未创新低 | 高 | +| 零轴上方金叉 | 多头市场,信号更可靠 | 高 | +| 零轴下方死叉 | 空头市场,信号更可靠 | 高 | + +--- + +## RSI(相对强弱指标) + +### 参数设置 +- 标准:RSI(14) +- 短线:RSI(6),长线:RSI(25) + +### 信号解读 +| RSI 值域 | 状态 | 操作参考 | +|---------|------|---------| +| >80 | 严重超买 | 考虑减仓或止盈 | +| 70-80 | 超买 | 警惕回调 | +| 50-70 | 多头区域 | 顺势持有 | +| 30-50 | 空头区域 | 谨慎操作 | +| 20-30 | 超卖 | 关注反弹机会 | +| <20 | 严重超卖 | 考虑逢低布局 | + +**背离信号(优先于绝对值)**: +- 顶背离:价格更高,RSI更低 → 预警调整 +- 底背离:价格更低,RSI更高 → 预警反弹 + +--- + +## KDJ(随机指标) + +### 参数设置 +- 标准:K(9)、D(3)、J(3) + +### 信号解读 +| 信号 | 条件 | 市场意义 | +|-----|------|---------| +| 超买 | K、D 同时 >80 | 短期注意回调 | +| 超卖 | K、D 同时 <20 | 短期注意反弹 | +| 金叉 | K线上穿D线,在20以下区域 | 超卖金叉,可靠看涨信号 | +| 死叉 | K线下穿D线,在80以上区域 | 超买死叉,可靠看跌信号 | +| J线极端 | J > 100 或 J < 0 | 短期极端信号,可能反转 | + +--- + +## 布林带(BOLL / Bollinger Bands) + +### 参数设置 +- 标准:MA(20),±2个标准差 +- 窄带期(BB Squeeze)提示即将大幅波动 + +### 信号解读 +| 价位 | 意义 | +|-----|------| +| 触及上轨 | 短期超买,若量缩则可能回落;若量增说明强势突破 | +| 触及下轨 | 短期超卖,若量缩则可能反弹;若量增说明加速下行 | +| 中轨(MA20) | 多头:中轨为支撑;空头:中轨为阻力 | +| 带宽极窄 | 盘整末期,即将发生方向性突破,等待信号确认 | + +--- + +## 移动平均线(MA / EMA) + +### A股关键均线 + +| 均线 | 意义 | 参考作用 | +|-----|------|---------| +| MA5/MA10 | 短期趋势 | 日内及短线方向 | +| MA20 | 月线趋势 | 短中期方向 | +| MA60 | 季线趋势 | 中线方向,反转信号 | +| MA250 | 年线趋势 | 牛熊分界线,长线方向 | + +### 均线多头排列 +MA5 > MA10 > MA20 > MA60 → 强多头趋势 +均线空头排列:MA5 < MA10 < MA20 < MA60 → 强空头趋势 + +--- + +## 成交量分析 + +### 量价关系规则 +| 价格方向 | 成交量 | 解读 | +|---------|-------|------| +| 上涨 | 放量 | 有效突破,趋势可信 | +| 上涨 | 缩量 | 上涨乏力,谨慎追高 | +| 下跌 | 放量 | 恐慌下行,关注止跌信号 | +| 下跌 | 缩量 | 正常回调,关注是否企稳 | +| 震荡 | 持续缩量 | 盘整积累,等待方向突破 | + +### 异动量信号 +- **天量**:成交量超过近20日均量的3倍,高度警惕主力出货 +- **久缩后暴量上涨**:主力拉升启动信号 +- **高位放量长上影线**:主力出货特征,看跌信号 diff --git a/src/MarketAssistant.Core/AGENTS.md b/src/MarketAssistant.Core/AGENTS.md new file mode 100644 index 0000000..acd903f --- /dev/null +++ b/src/MarketAssistant.Core/AGENTS.md @@ -0,0 +1,51 @@ +# MarketAssistant.Core — AGENTS.md + +共享基础层,提供全局枚举、工具类、异常处理和基础扩展,被所有其他项目引用。**零项目依赖**——仅依赖 `Microsoft.Extensions.Logging.Abstractions` 和 `Microsoft.Data.Sqlite`。 + +--- + +## 目录结构 + +``` +MarketAssistant.Core/ +├── Infrastructure/ +│ ├── Core/ +│ │ ├── MarketType.cs ← 市场类型枚举(AShare / Crypto) +│ │ ├── FriendlyException.cs ← 用户友好异常(消息直接展示在 UI) +│ │ ├── ErrorMessageMapper.cs ← 技术异常 → 用户友好消息映射 +│ │ ├── StockSymbolConverter.cs ← A 股代码格式转换 +│ │ ├── CryptoSymbolConverter.cs ← 加密货币符号转换 +│ │ └── LlmJsonExtractor.cs ← LLM 返回文本 JSON 稳健提取/反序列化 +│ ├── Extensions/ +│ │ └── EnumExtensions.cs ← GetDescription() 扩展 +│ └── NavigationParameters.cs ← 导航参数(StockNavigationParameter) +``` + +--- + +## 关键类型 + +| 类型 | 用途 | +|------|------| +| `MarketType` | 多市场 Keyed Services 的 key,贯穿整个架构 | +| `FriendlyException` | 抛出后消息直接展示给用户,不要用于非用户可见的内部错误 | +| `ErrorMessageMapper` | 静态方法 `GetUserFriendlyMessage(Exception)` | +| `StockSymbolConverter` / `CryptoSymbolConverter` | 不同 API 间的代码格式转换 | +| `LlmJsonExtractor` | LLM 返回文本的 JSON 稳健提取与反序列化(多层兜底:直接解析→剥离 markdown→Utf8JsonReader 精确定位) | + +--- + +## 编码约定 + +- 此项目是**共享基础层**,可包含工具类和基础模型,但不应包含任何 UI 框架、AI 框架、消息总线消息类或具体 API 调用逻辑。 +- 新增类型应放入对应的命名空间目录(`Infrastructure/Core/`、`Infrastructure/Extensions/` 等)。 +- `MarketType` 枚举新增值时,需同步在所有消费项目中注册对应的 Keyed Service 实现。 +- 避免引入重量级 NuGet 包——Core 应保持轻量。 + +--- + +## 构建 + +```bash +dotnet build src/MarketAssistant.Core/MarketAssistant.Core.csproj -c Debug +``` diff --git a/src/MarketAssistant.Core/Infrastructure/Core/AssetFavoritesChanged.cs b/src/MarketAssistant.Core/Infrastructure/Core/AssetFavoritesChanged.cs new file mode 100644 index 0000000..476dc2e --- /dev/null +++ b/src/MarketAssistant.Core/Infrastructure/Core/AssetFavoritesChanged.cs @@ -0,0 +1,14 @@ +namespace MarketAssistant.Infrastructure.Core; + +/// +/// 资产收藏变更消息 +/// +public class AssetFavoritesChanged +{ +} + + + + + + diff --git a/src/MarketAssistant.Core/Infrastructure/Core/CryptoSymbolConverter.cs b/src/MarketAssistant.Core/Infrastructure/Core/CryptoSymbolConverter.cs new file mode 100644 index 0000000..e8e3654 --- /dev/null +++ b/src/MarketAssistant.Core/Infrastructure/Core/CryptoSymbolConverter.cs @@ -0,0 +1,131 @@ +namespace MarketAssistant.Infrastructure.Core; + +/// +/// 虚拟币交易对符号格式转换工具类 +/// +public static class CryptoSymbolConverter +{ + /// + /// 将币种代码转换为币安交易对格式(如 BTC → BTCUSDT) + /// + /// 币种代码,支持格式:BTC、BTCUSDT、BTC/USDT、BTC-USDT + /// 计价货币,默认为 USDT + /// 币安格式交易对(如 BTCUSDT) + public static string ToBinanceFormat(string symbol, string quoteCurrency = "USDT") + { + if (string.IsNullOrWhiteSpace(symbol)) + { + throw new ArgumentException("币种代码不能为空", nameof(symbol)); + } + + symbol = symbol.Replace("crypto.", "", StringComparison.OrdinalIgnoreCase) + .Replace("/", "") + .Replace("-", "") + .Replace(" ", "") + .ToUpperInvariant(); + + quoteCurrency = quoteCurrency.ToUpperInvariant(); + + // 如果已经包含计价货币后缀,直接返回 + var quoteCurrencies = new[] { "USDT", "BUSD", "USDC", "BTC", "ETH", "BNB", "DAI" }; + foreach (var quote in quoteCurrencies) + { + if (symbol.EndsWith(quote) && symbol.Length > quote.Length) + { + return symbol; + } + } + + // 添加计价货币 + return $"{symbol}{quoteCurrency}"; + } + + /// + /// 提取基础币种名称(移除计价货币后缀) + /// + /// 交易对,如 BTCUSDT + /// 基础币种,如 BTC + public static string ExtractBaseCurrency(string tradingPair) + { + if (string.IsNullOrWhiteSpace(tradingPair)) + { + return string.Empty; + } + + tradingPair = tradingPair.ToUpperInvariant(); + + // 移除常见计价货币后缀(按长度倒序,避免误匹配) + var quoteCurrencies = new[] { "USDT", "BUSD", "USDC", "DAI", "BTC", "ETH", "BNB" }; + foreach (var quote in quoteCurrencies) + { + if (tradingPair.EndsWith(quote) && tradingPair.Length > quote.Length) + { + return tradingPair.Substring(0, tradingPair.Length - quote.Length); + } + } + + // 如果没有匹配的后缀,返回原字符串(可能本身就是基础币种) + return tradingPair; + } + + /// + /// 格式化为CoinGecko ID(用于调用CoinGecko API) + /// + /// 币种代码,如 BTC、BTCUSDT + /// CoinGecko ID,如 bitcoin + public static string ToCoinGeckoId(string symbol) + { + if (string.IsNullOrWhiteSpace(symbol)) + { + return string.Empty; + } + + // 标准化输入(移除前缀、转大写) + symbol = symbol.Replace("crypto.", "", StringComparison.OrdinalIgnoreCase) + .Replace("/", "") + .Replace("-", "") + .Replace(" ", "") + .ToUpperInvariant(); + + // 提取基础币种 + var baseCurrency = ExtractBaseCurrency(symbol); + + // 如果提取失败(说明没有已知后缀),使用原始symbol + if (string.IsNullOrEmpty(baseCurrency)) + { + baseCurrency = symbol; + } + + baseCurrency = baseCurrency.ToLower(); + + // 常见币种映射表 + var mapping = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "btc", "bitcoin" }, + { "eth", "ethereum" }, + { "bnb", "binancecoin" }, + { "xrp", "ripple" }, + { "ada", "cardano" }, + { "doge", "dogecoin" }, + { "sol", "solana" }, + { "dot", "polkadot" }, + { "matic", "matic-network" }, + { "avax", "avalanche-2" }, + { "link", "chainlink" }, + { "uni", "uniswap" }, + { "ltc", "litecoin" }, + { "etc", "ethereum-classic" }, + { "xlm", "stellar" }, + { "bch", "bitcoin-cash" }, + { "atom", "cosmos" }, + { "trx", "tron" }, + { "shib", "shiba-inu" }, + { "usdc", "usd-coin" }, + { "usdt", "tether" }, + { "dai", "dai" }, + { "busd", "binance-usd" } + }; + + return mapping.TryGetValue(baseCurrency, out var coinId) ? coinId : baseCurrency; + } +} diff --git a/src/Infrastructure/Core/ErrorMessageMapper.cs b/src/MarketAssistant.Core/Infrastructure/Core/ErrorMessageMapper.cs similarity index 84% rename from src/Infrastructure/Core/ErrorMessageMapper.cs rename to src/MarketAssistant.Core/Infrastructure/Core/ErrorMessageMapper.cs index 8b670fb..0f7472f 100644 --- a/src/Infrastructure/Core/ErrorMessageMapper.cs +++ b/src/MarketAssistant.Core/Infrastructure/Core/ErrorMessageMapper.cs @@ -1,4 +1,5 @@ using System.Net.Sockets; +using System.Text.Json; namespace MarketAssistant.Infrastructure.Core; @@ -16,50 +17,54 @@ public static string GetUserFriendlyMessage(Exception exception) { // 友好异常直接返回消息 FriendlyException => exception.Message, - - // 取消相关异常 + + // 取消相关异常:区分网络超时与用户主动取消 + TaskCanceledException tce when tce.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase) + => "AI 模型响应超时,可能是模型服务繁忙或网络不稳定,请稍后重试", + TaskCanceledException tce when !tce.CancellationToken.IsCancellationRequested + => "AI 模型响应超时,可能是模型服务繁忙或网络不稳定,请稍后重试", TaskCanceledException => "请求超时,请稍后重试", OperationCanceledException => "操作已取消", - + // 网络相关异常 HttpRequestException => "网络连接失败,请检查网络设置", SocketException => "网络连接失败,请检查网络设置", TimeoutException => "操作超时,请稍后重试", - + // 权限相关异常 UnauthorizedAccessException => "权限不足,请以管理员身份运行或检查文件权限", - + // 参数相关异常(子类在前) ArgumentNullException => "参数错误,请检查输入", ArgumentOutOfRangeException => "参数超出有效范围", ArgumentException => "参数错误,请检查输入", - + // 操作相关异常(子类在前) ObjectDisposedException => "资源已释放,请重新打开", InvalidOperationException => "操作无效,请刷新后重试", NotSupportedException => "当前操作不受支持", NotImplementedException => "该功能尚未实现", - + // 文件IO相关异常(子类在前) FileNotFoundException => "文件未找到", DirectoryNotFoundException => "目录未找到", PathTooLongException => "文件路径过长", IOException => "文件读写失败,请检查权限或磁盘空间", - + // 数据相关异常 FormatException => "数据格式错误", JsonException => "数据解析失败,请检查数据格式", - + // 系统资源相关异常 OutOfMemoryException => "内存不足,请关闭其他应用后重试", StackOverflowException => "程序错误,请重启应用", - + // 数据库相关异常 Microsoft.Data.Sqlite.SqliteException => "数据库错误,数据可能已损坏", - + InvalidCastException => "数据类型转换失败", NullReferenceException => "数据访问错误,请刷新后重试", - + _ => "操作失败,请稍后重试" }; } diff --git a/src/Infrastructure/Core/FriendlyException.cs b/src/MarketAssistant.Core/Infrastructure/Core/FriendlyException.cs similarity index 99% rename from src/Infrastructure/Core/FriendlyException.cs rename to src/MarketAssistant.Core/Infrastructure/Core/FriendlyException.cs index 4d94687..bb2ac23 100644 --- a/src/Infrastructure/Core/FriendlyException.cs +++ b/src/MarketAssistant.Core/Infrastructure/Core/FriendlyException.cs @@ -13,4 +13,3 @@ public FriendlyException(string message, Exception innerException) : base(messag { } } - diff --git a/src/MarketAssistant.Core/Infrastructure/Core/LlmJsonExtractor.cs b/src/MarketAssistant.Core/Infrastructure/Core/LlmJsonExtractor.cs new file mode 100644 index 0000000..ae291f4 --- /dev/null +++ b/src/MarketAssistant.Core/Infrastructure/Core/LlmJsonExtractor.cs @@ -0,0 +1,202 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MarketAssistant.Infrastructure.Core; + +/// +/// 从大模型返回文本中稳健提取并反序列化 JSON 的通用工具。 +/// +/// 某些 LLM 提供商即使指定了 ChatResponseFormat.ForJsonSchema,仍可能在 JSON 前后 +/// 输出多余文本(前缀词、思考过程、markdown 代码块标记等)。本工具通过多层兜底策略 +/// 定位并解析真正的 JSON 片段,避免因首字符非 {/[ 而解析失败。 +/// +public static class LlmJsonExtractor +{ + private static readonly JsonReaderOptions ReaderOptions = new() + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip + }; + + /// + /// 从 LLM 返回文本中提取并反序列化为指定类型。 + /// 解析顺序:直接解析 → 剥离 markdown 代码块 → 用 精确定位 JSON 边界。 + /// + /// 目标类型 + /// LLM 返回的原始文本 + /// JSON 序列化选项,为 null 时使用默认 Web 选项 + /// 反序列化后的对象 + /// 所有兜底策略均失败时抛出,包含原始文本预览与内部异常 + public static T? Deserialize(string? rawText, JsonSerializerOptions? options = null) + { + if (string.IsNullOrWhiteSpace(rawText)) + return default; + + var jsonOptions = options ?? JsonSerializerOptions.Web; + Exception? lastException = null; + + // 第一层:直接解析(最理想情况,LLM 严格遵守了 JSON Schema 输出) + if (TryDeserialize(rawText, jsonOptions, out var result1, out var ex1)) + return result1; + lastException = ex1; + + // 第二层:剥离 markdown 代码块(```json ... ``` 或 ``` ... ```) + var stripped = StripMarkdownCodeFence(rawText); + if (!ReferenceEquals(stripped, rawText)) + { + if (TryDeserialize(stripped, jsonOptions, out var result2, out var ex2)) + return result2; + if (ex2 != null) lastException = ex2; + } + + // 第三层:用 Utf8JsonReader 精确定位首个完整 JSON 值的边界 + // 相比 IndexOf('{') 启发式,能正确处理字符串内的 {、嵌套结构、转义字符等 + Exception? ex3 = null; + if (TryExtractJsonWithReader(rawText, out var jsonText) && + TryDeserialize(jsonText, jsonOptions, out var result3, out ex3)) + return result3; + if (ex3 != null) lastException = ex3; + + // 所有策略失败:抛出带原始文本预览与最后一次异常的异常,便于排查 + var preview = rawText.Length > 500 ? rawText[..500] : rawText; + throw new JsonException( + $"LLM 返回文本无法解析为 JSON。原始文本前 500 字符: {preview}", lastException); + } + + /// + /// 仅提取 JSON 文本片段(不反序列化)。供需要自行反序列化的调用方使用。 + /// + /// 提取到的 JSON 文本;若所有策略均失败则返回 null。 + public static string? ExtractJsonString(string? rawText) + { + if (string.IsNullOrWhiteSpace(rawText)) + return rawText; + + if (IsValidJson(rawText)) + return rawText; + + var stripped = StripMarkdownCodeFence(rawText); + if (!ReferenceEquals(stripped, rawText) && IsValidJson(stripped)) + return stripped; + + if (TryExtractJsonWithReader(rawText, out var jsonText)) + return jsonText; + + return null; + } + + private static bool TryDeserialize( + string text, JsonSerializerOptions options, + out T? result, out Exception? exception) + { + try + { + result = JsonSerializer.Deserialize(text, options); + exception = null; + return true; + } + catch (Exception ex) when (ex is JsonException or NotSupportedException) + { + result = default; + exception = ex; + return false; + } + } + + /// + /// 剥离 markdown 代码块标记。若文本不包含代码块则原样返回(引用相等)。 + /// + private static string StripMarkdownCodeFence(string text) + { + var span = text.AsSpan().Trim(); + if (!span.StartsWith("```")) + return text; + + var firstNewline = span.IndexOf('\n'); + if (firstNewline < 0) + return text; + + var contentStart = firstNewline + 1; + var fenceEnd = span.LastIndexOf("```"); + if (fenceEnd <= contentStart) + return text; + + return span[contentStart..fenceEnd].Trim().ToString(); + } + + /// + /// 使用 逐 token 扫描,精确定位首个完整 JSON 值的边界。 + /// 相比 IndexOf('{') 启发式,能正确处理: + /// - JSON 字符串值内出现的 { } [ ] + /// - 嵌套对象/数组 + /// - 转义字符 + /// + private static bool TryExtractJsonWithReader(string text, out string json) + { + json = text; + + // 找到首个 { 或 [ 作为 JSON 起始 + var startIndex = text.AsSpan().IndexOfAny('{', '['); + if (startIndex < 0) + return false; + + // 只需将子串转为 UTF-8 字节一次,reader 直接在这些字节上工作 + var jsonBytes = Encoding.UTF8.GetBytes(text[startIndex..]); + var reader = new Utf8JsonReader(jsonBytes, ReaderOptions); + + try + { + // 读取首个 token(必须是 StartObject 或 StartArray) + if (!reader.Read()) + return false; + + if (reader.TokenType is not (JsonTokenType.StartObject or JsonTokenType.StartArray)) + return false; + + // 持续读取直到回到初始深度(即读完首个完整 JSON 值) + var depth = 0; + do + { + switch (reader.TokenType) + { + case JsonTokenType.StartObject: + case JsonTokenType.StartArray: + depth++; + break; + case JsonTokenType.EndObject: + case JsonTokenType.EndArray: + depth--; + break; + } + + if (depth == 0) + break; + + if (!reader.Read()) + return false; + } while (true); + + // 直接从已转换的子串字节中截取 JSON 部分,无需再次编码完整文本 + json = Encoding.UTF8.GetString(jsonBytes, 0, (int)reader.BytesConsumed); + return true; + } + catch (JsonException) + { + return false; + } + } + + private static bool IsValidJson(string text) + { + try + { + using var doc = JsonDocument.Parse(text); + return true; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/src/MarketAssistant.Core/Infrastructure/Core/MarketType.cs b/src/MarketAssistant.Core/Infrastructure/Core/MarketType.cs new file mode 100644 index 0000000..e4c3cc3 --- /dev/null +++ b/src/MarketAssistant.Core/Infrastructure/Core/MarketType.cs @@ -0,0 +1,23 @@ +namespace MarketAssistant.Infrastructure.Core; + +/// +/// 顶层市场类型枚举 +/// +public enum MarketType +{ + /// + /// A股市场 + /// + AShare, + + /// + /// 虚拟币市场 + /// + Crypto +} + + + + + + diff --git a/src/Infrastructure/Core/NavigationMessage.cs b/src/MarketAssistant.Core/Infrastructure/Core/NavigationMessage.cs similarity index 100% rename from src/Infrastructure/Core/NavigationMessage.cs rename to src/MarketAssistant.Core/Infrastructure/Core/NavigationMessage.cs diff --git a/src/Infrastructure/Core/StockSymbolConverter.cs b/src/MarketAssistant.Core/Infrastructure/Core/StockSymbolConverter.cs similarity index 55% rename from src/Infrastructure/Core/StockSymbolConverter.cs rename to src/MarketAssistant.Core/Infrastructure/Core/StockSymbolConverter.cs index e16284f..8d977ef 100644 --- a/src/Infrastructure/Core/StockSymbolConverter.cs +++ b/src/MarketAssistant.Core/Infrastructure/Core/StockSymbolConverter.cs @@ -6,13 +6,28 @@ namespace MarketAssistant.Infrastructure.Core; public static class StockSymbolConverter { /// - /// 将股票代码转换为财联社格式(如 SH600000、SZ000001) + /// 将股票代码转换为财联社格式(如 sh600000、sz000001,均为小写) /// public static string ToClsFormat(string stockCode) { + if (string.IsNullOrWhiteSpace(stockCode)) return string.Empty; + + // 预处理:仅规范化大小写,保留数字和字母供后续判断 + string normalized = stockCode.ToUpperInvariant().Replace("/", "").Replace("-", "").Replace(" ", ""); + + // 如果已经是 SHxxxxxx 或 SZxxxxxx 格式(且后续全是数字) + if ((normalized.StartsWith("SH") || normalized.StartsWith("SZ")) && + normalized.Length > 2 && + normalized.Skip(2).All(char.IsDigit)) + { + return normalized.ToLowerInvariant(); + } + string digits = ExtractDigits(stockCode); + if (string.IsNullOrEmpty(digits)) return stockCode; // 无法提取数字则返回原值 + string prefix = GetExchangePrefix(digits); - return $"{prefix}{digits}"; + return $"{prefix}{digits}".ToLowerInvariant(); } /// @@ -20,27 +35,54 @@ public static string ToClsFormat(string stockCode) /// public static string ToZhiTuFormat(string stockCode) { - if (stockCode.Contains(".")) + if (string.IsNullOrWhiteSpace(stockCode)) return string.Empty; + + // 移除常见分隔符 + string cleanCode = stockCode.Replace("/", "").Replace("-", "").Replace(" ", "").ToUpperInvariant(); + + // 1. 处理如 600519.SH 的标准格式 + if (cleanCode.Contains('.')) + { + return cleanCode; + } + + // 2. 处理 SH600519 / SZ000001 前缀格式 + if (cleanCode.StartsWith("SH") || cleanCode.StartsWith("SZ")) { - return stockCode.ToUpper(); + string code = cleanCode.Substring(2); + string market = cleanCode.StartsWith("SZ") ? "SZ" : "SH"; + // 确保剩余部分是纯数字才转换 + if (code.All(char.IsDigit)) + { + return $"{code}.{market}"; + } } - if (stockCode.StartsWith("sz", StringComparison.OrdinalIgnoreCase) || - stockCode.StartsWith("sh", StringComparison.OrdinalIgnoreCase)) + // 3. 处理 600519SH / 000001SZ 后缀格式 + if (cleanCode.EndsWith("SH") || cleanCode.EndsWith("SZ")) { - string code = stockCode.Substring(2); - string market = stockCode.StartsWith("sz", StringComparison.OrdinalIgnoreCase) ? "SZ" : "SH"; - return $"{code}.{market}"; + // 长度检查,避免 SH / SZ 本身 + if (cleanCode.Length > 2) + { + string code = cleanCode.Substring(0, cleanCode.Length - 2); + string market = cleanCode.EndsWith("SZ") ? "SZ" : "SH"; + if (code.All(char.IsDigit)) + { + return $"{code}.{market}"; + } + } } - if (stockCode.All(char.IsDigit)) + // 4. 处理纯数字 600519 + if (cleanCode.All(char.IsDigit)) { - string digits = stockCode; + string digits = cleanCode; string suffix = GetExchangeSuffix(digits); return $"{digits}.{suffix}"; } - return stockCode.ToUpper(); + // 5. 无法识别,返回大写原值 + return cleanCode; } /// diff --git a/src/Infrastructure/Extensions/EnumExtensions.cs b/src/MarketAssistant.Core/Infrastructure/Extensions/EnumExtensions.cs similarity index 100% rename from src/Infrastructure/Extensions/EnumExtensions.cs rename to src/MarketAssistant.Core/Infrastructure/Extensions/EnumExtensions.cs diff --git a/src/MarketAssistant.Core/MarketAssistant.Core.csproj b/src/MarketAssistant.Core/MarketAssistant.Core.csproj new file mode 100644 index 0000000..1cb0de6 --- /dev/null +++ b/src/MarketAssistant.Core/MarketAssistant.Core.csproj @@ -0,0 +1,13 @@ + + + net10.0 + enable + MarketAssistant + enable + + + + + + + diff --git a/src/MarketAssistant.Core/MarketAssistant.Core.csproj.lscache b/src/MarketAssistant.Core/MarketAssistant.Core.csproj.lscache new file mode 100644 index 0000000..be961d4 --- /dev/null +++ b/src/MarketAssistant.Core/MarketAssistant.Core.csproj.lscache @@ -0,0 +1,264 @@ +version=1 + +# This file caches language service data to improve the performance of C# Dev Kit. +# It is not intended for manual editing. It can safely be deleted and will be +# regenerated automatically. For more information, see https://aka.ms/lscache +# +# To control where cache files are stored, use the following VS Code setting: +# "dotnet.projectsystem.cacheInProjectFolder": true + +[project] +language=C# +primary +lastDtbSucceeded + +[properties] +AssemblyName=MarketAssistant.Core +CommandLineArgsForDesignTimeEvaluation=-langversion:14.0 -define:TRACE +CompilerGeneratedFilesOutputPath= +MaxSupportedLangVersion=14.0 +ProjectAssetsFile=obj/project.assets.json +RootNamespace=MarketAssistant +RunAnalyzers= +RunAnalyzersDuringLiveAnalysis= +SolutionPath=../../MarketAssistant.slnx +TargetFrameworkIdentifier=.NETCoreApp +TargetPath=bin/Debug/net10.0/MarketAssistant.Core.dll +TargetRefPath=obj/Debug/net10.0/ref/MarketAssistant.Core.dll +TemporaryDependencyNodeTargetIdentifier=net10.0 + +[commandLineArguments] +/noconfig +/unsafe- +/checked- +/nowarn:1701,1702,1701,1702,8002 +/fullpaths +/nostdlib+ +/errorreport:prompt +/warn:10 +/define:TRACE;DEBUG;NET;NET10_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NET10_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER +/highentropyva+ +/nullable:enable +/features:"InterceptorsNamespaces=;Microsoft.Extensions.Validation.Generated" +/debug+ +/debug:portable +/filealign:512 +/optimize- +/out:obj\Debug\net10.0\MarketAssistant.Core.dll +/refout:obj\Debug\net10.0\refint\MarketAssistant.Core.dll +/target:library +/warnaserror- +/utf8output +/deterministic+ +/langversion:14.0 +/warnaserror+:NU1605,SYSLIB0011 + +[sourceFiles] +Infrastructure/Core/ + AssetFavoritesChanged.cs + CryptoSymbolConverter.cs + ErrorMessageMapper.cs + FriendlyException.cs + MarketType.cs + NavigationMessage.cs + StockSymbolConverter.cs +Infrastructure/Extensions/EnumExtensions.cs +obj/Debug/net10.0/ + .NETCoreApp,Version=v10.0.AssemblyAttributes.cs + MarketAssistant.Core.AssemblyInfo.cs + MarketAssistant.Core.GlobalUsings.g.cs + +[metadataReferences] +/packs/Microsoft.NETCore.App.Ref/10.0.5/ref/net10.0/ + Microsoft.CSharp.dll + Microsoft.VisualBasic.Core.dll + Microsoft.VisualBasic.dll + Microsoft.Win32.Primitives.dll + Microsoft.Win32.Registry.dll + mscorlib.dll + netstandard.dll + System.AppContext.dll + System.Buffers.dll + System.Collections.Concurrent.dll + System.Collections.dll + System.Collections.Immutable.dll + System.Collections.NonGeneric.dll + System.Collections.Specialized.dll + System.ComponentModel.Annotations.dll + System.ComponentModel.DataAnnotations.dll + System.ComponentModel.dll + System.ComponentModel.EventBasedAsync.dll + System.ComponentModel.Primitives.dll + System.ComponentModel.TypeConverter.dll + System.Configuration.dll + System.Console.dll + System.Core.dll + System.Data.Common.dll + System.Data.DataSetExtensions.dll + System.Data.dll + System.Diagnostics.Contracts.dll + System.Diagnostics.Debug.dll + System.Diagnostics.DiagnosticSource.dll + System.Diagnostics.FileVersionInfo.dll + System.Diagnostics.Process.dll + System.Diagnostics.StackTrace.dll + System.Diagnostics.TextWriterTraceListener.dll + System.Diagnostics.Tools.dll + System.Diagnostics.TraceSource.dll + System.Diagnostics.Tracing.dll + System.dll + System.Drawing.dll + System.Drawing.Primitives.dll + System.Dynamic.Runtime.dll + System.Formats.Asn1.dll + System.Formats.Tar.dll + System.Globalization.Calendars.dll + System.Globalization.dll + System.Globalization.Extensions.dll + System.IO.Compression.Brotli.dll + System.IO.Compression.dll + System.IO.Compression.FileSystem.dll + System.IO.Compression.ZipFile.dll + System.IO.dll + System.IO.FileSystem.AccessControl.dll + System.IO.FileSystem.dll + System.IO.FileSystem.DriveInfo.dll + System.IO.FileSystem.Primitives.dll + System.IO.FileSystem.Watcher.dll + System.IO.IsolatedStorage.dll + System.IO.MemoryMappedFiles.dll + System.IO.Pipelines.dll + System.IO.Pipes.AccessControl.dll + System.IO.Pipes.dll + System.IO.UnmanagedMemoryStream.dll + System.Linq.AsyncEnumerable.dll + System.Linq.dll + System.Linq.Expressions.dll + System.Linq.Parallel.dll + System.Linq.Queryable.dll + System.Memory.dll + System.Net.dll + System.Net.Http.dll + System.Net.Http.Json.dll + System.Net.HttpListener.dll + System.Net.Mail.dll + System.Net.NameResolution.dll + System.Net.NetworkInformation.dll + System.Net.Ping.dll + System.Net.Primitives.dll + System.Net.Quic.dll + System.Net.Requests.dll + System.Net.Security.dll + System.Net.ServerSentEvents.dll + System.Net.ServicePoint.dll + System.Net.Sockets.dll + System.Net.WebClient.dll + System.Net.WebHeaderCollection.dll + System.Net.WebProxy.dll + System.Net.WebSockets.Client.dll + System.Net.WebSockets.dll + System.Numerics.dll + System.Numerics.Vectors.dll + System.ObjectModel.dll + System.Reflection.DispatchProxy.dll + System.Reflection.dll + System.Reflection.Emit.dll + System.Reflection.Emit.ILGeneration.dll + System.Reflection.Emit.Lightweight.dll + System.Reflection.Extensions.dll + System.Reflection.Metadata.dll + System.Reflection.Primitives.dll + System.Reflection.TypeExtensions.dll + System.Resources.Reader.dll + System.Resources.ResourceManager.dll + System.Resources.Writer.dll + System.Runtime.CompilerServices.Unsafe.dll + System.Runtime.CompilerServices.VisualC.dll + System.Runtime.dll + System.Runtime.Extensions.dll + System.Runtime.Handles.dll + System.Runtime.InteropServices.dll + System.Runtime.InteropServices.JavaScript.dll + System.Runtime.InteropServices.RuntimeInformation.dll + System.Runtime.Intrinsics.dll + System.Runtime.Loader.dll + System.Runtime.Numerics.dll + System.Runtime.Serialization.dll + System.Runtime.Serialization.Formatters.dll + System.Runtime.Serialization.Json.dll + System.Runtime.Serialization.Primitives.dll + System.Runtime.Serialization.Xml.dll + System.Security.AccessControl.dll + System.Security.Claims.dll + System.Security.Cryptography.Algorithms.dll + System.Security.Cryptography.Cng.dll + System.Security.Cryptography.Csp.dll + System.Security.Cryptography.dll + System.Security.Cryptography.Encoding.dll + System.Security.Cryptography.OpenSsl.dll + System.Security.Cryptography.Primitives.dll + System.Security.Cryptography.X509Certificates.dll + System.Security.dll + System.Security.Principal.dll + System.Security.Principal.Windows.dll + System.Security.SecureString.dll + System.ServiceModel.Web.dll + System.ServiceProcess.dll + System.Text.Encoding.CodePages.dll + System.Text.Encoding.dll + System.Text.Encoding.Extensions.dll + System.Text.Encodings.Web.dll + System.Text.Json.dll + System.Text.RegularExpressions.dll + System.Threading.AccessControl.dll + System.Threading.Channels.dll + System.Threading.dll + System.Threading.Overlapped.dll + System.Threading.Tasks.Dataflow.dll + System.Threading.Tasks.dll + System.Threading.Tasks.Extensions.dll + System.Threading.Tasks.Parallel.dll + System.Threading.Thread.dll + System.Threading.ThreadPool.dll + System.Threading.Timer.dll + System.Transactions.dll + System.Transactions.Local.dll + System.ValueTuple.dll + System.Web.dll + System.Web.HttpUtility.dll + System.Windows.dll + System.Xml.dll + System.Xml.Linq.dll + System.Xml.ReaderWriter.dll + System.Xml.Serialization.dll + System.Xml.XDocument.dll + System.Xml.XmlDocument.dll + System.Xml.XmlSerializer.dll + System.Xml.XPath.dll + System.Xml.XPath.XDocument.dll + WindowsBase.dll +D:/NuGet/Packages/ + microsoft.data.sqlite.core/10.0.5/lib/net8.0/Microsoft.Data.Sqlite.dll + microsoft.extensions.dependencyinjection.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll + microsoft.extensions.logging.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.Logging.Abstractions.dll + sqlitepclraw.bundle_e_sqlite3/2.1.11/lib/netstandard2.0/SQLitePCLRaw.batteries_v2.dll + sqlitepclraw.core/2.1.11/lib/netstandard2.0/SQLitePCLRaw.core.dll + sqlitepclraw.provider.e_sqlite3/2.1.11/lib/net6.0/SQLitePCLRaw.provider.e_sqlite3.dll + +[analyzerReferences] +/packs/Microsoft.NETCore.App.Ref/10.0.5/analyzers/dotnet/cs/ + Microsoft.Interop.ComInterfaceGenerator.dll + Microsoft.Interop.JavaScript.JSImportGenerator.dll + Microsoft.Interop.LibraryImportGenerator.dll + Microsoft.Interop.SourceGeneration.dll + System.Text.Json.SourceGeneration.dll + System.Text.RegularExpressions.Generator.dll +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/ + Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll + Microsoft.CodeAnalysis.NetAnalyzers.dll +D:/NuGet/Packages/microsoft.extensions.logging.abstractions/10.0.6/analyzers/dotnet/roslyn4.4/cs/Microsoft.Extensions.Logging.Generators.dll + +[analyzerConfigFiles] +../../.editorconfig +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_10_default.globalconfig +obj/Debug/net10.0/MarketAssistant.Core.GeneratedMSBuildEditorConfig.editorconfig diff --git a/src/MarketAssistant.DataProviders/AGENTS.md b/src/MarketAssistant.DataProviders/AGENTS.md new file mode 100644 index 0000000..152f4a3 --- /dev/null +++ b/src/MarketAssistant.DataProviders/AGENTS.md @@ -0,0 +1,56 @@ +# MarketAssistant.DataProviders — AGENTS.md + +数据提供者层,封装所有加密货币外部 API 调用。当前仅依赖 `MarketAssistant.Core`;不直接依赖 `MarketAssistant.Agents`。 + +--- + +## 目录结构 + +``` +MarketAssistant.DataProviders/ +├── BinanceMarketDataService.cs ← Binance REST API(现货 + 合约) +├── BinanceMarketDataModels.cs ← Binance 本地模型(24hrTicker、ExchangeInfo) +├── BinanceWebSocketService.cs ← Binance WebSocket 实时价格推送 +├── CoinGeckoApiService.cs ← CoinGecko REST API(市值、排名、涨跌幅) +├── CoinDeskApiService.cs ← CoinDesk REST API(项目元数据、新闻) +├── StringToDecimalConverter.cs ← JSON 字符串 → decimal 转换器 +└── GlobalUsing.cs +``` + +--- + +## 外部 API 说明 + +| 服务 | 域名 | 注意事项 | +|------|------|---------| +| **Binance** | `api.binance.com` / `fapi.binance.com` | 部分地区受限,需 VPN/代理 | +| **CoinGecko** | `api.coingecko.com` | 免费版有频率限制(~24 req/min),`CoinGeckoApiService` 内置限流 | +| **CoinDesk** | `data-api.coindesk.com` | 无特殊限制 | + +--- + +## 编码约定 + +- 所有外部 HTTP 调用通过命名 HttpClient(如 `CreateClient("Binance")`)自动获得弹性策略(重试、超时、熔断),无需手动包裹。 +- 网络/API 错误应包装为 `FriendlyException` 抛出,消息面向用户可读。 +- API 响应的反序列化模型: + - 本地模型(如 `Binance24hrTicker`)定义在本项目。 + - 若某个响应模型仅服务于数据提供者内部调用,继续保留在本项目;若它已成为跨模块工具契约的一部分,再评估是否上移到契约层。 +- 不要在代码中硬编码 API 密钥。密钥通过 App 项目的设置服务注入。 +- WebSocket 服务需处理断线重连。 +- Binance API 在部分地区需配置 HTTP 代理,代理设置通过 `IUserSettingService` 管理,由 `HttpClient` 构造时注入。 + +--- + +## 测试 + +- 外部 API 调用应通过 mock `HttpMessageHandler` 进行单元测试,避免依赖真实网络。 +- 测试位于 `tests/TestMarketAssistant.csproj`。 + +--- + +## 构建 + +```bash +dotnet build src/MarketAssistant.DataProviders/MarketAssistant.DataProviders.csproj -c Debug +``` diff --git a/src/MarketAssistant.DataProviders/BinanceFundingRateResponse.cs b/src/MarketAssistant.DataProviders/BinanceFundingRateResponse.cs new file mode 100644 index 0000000..0cc1a19 --- /dev/null +++ b/src/MarketAssistant.DataProviders/BinanceFundingRateResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace MarketAssistant.Services.Data; + +/// +/// 币安资金费率 API 响应模型 +/// +public class BinanceFundingRateResponse +{ + [JsonPropertyName("symbol")] + public string Symbol { get; set; } = string.Empty; + + [JsonPropertyName("fundingRate")] + public string FundingRate { get; set; } = string.Empty; + + [JsonPropertyName("fundingTime")] + public long FundingTime { get; set; } +} diff --git a/src/MarketAssistant.DataProviders/BinanceLongShortRatioResponse.cs b/src/MarketAssistant.DataProviders/BinanceLongShortRatioResponse.cs new file mode 100644 index 0000000..48f68f3 --- /dev/null +++ b/src/MarketAssistant.DataProviders/BinanceLongShortRatioResponse.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace MarketAssistant.Services.Data; + +/// +/// 币安多空比 API 响应模型 +/// +/// +/// 通用于三种 API: +/// - globalLongShortAccountRatio (longAccount/shortAccount 表示账户占比) +/// - topLongShortAccountRatio (longAccount/shortAccount 表示大户账户占比) +/// - topLongShortPositionRatio (longAccount/shortAccount 表示大户持仓占比) +/// +public class BinanceLongShortRatioResponse +{ + [JsonPropertyName("symbol")] + public string Symbol { get; set; } = string.Empty; + + [JsonPropertyName("longShortRatio")] + public string LongShortRatio { get; set; } = string.Empty; + + [JsonPropertyName("longAccount")] + public string LongAccount { get; set; } = string.Empty; + + [JsonPropertyName("shortAccount")] + public string ShortAccount { get; set; } = string.Empty; + + [JsonPropertyName("timestamp")] + public long Timestamp { get; set; } +} diff --git a/src/MarketAssistant.DataProviders/BinanceMarketDataModels.cs b/src/MarketAssistant.DataProviders/BinanceMarketDataModels.cs new file mode 100644 index 0000000..2edeacb --- /dev/null +++ b/src/MarketAssistant.DataProviders/BinanceMarketDataModels.cs @@ -0,0 +1,51 @@ +namespace MarketAssistant.Services.Data; + +/// +/// 币安24小时价格变动统计数据模型(用于 BinanceMarketDataService) +/// 注意:这是专门为 /api/v3/ticker/24hr 接口定义的模型,字段为 decimal 类型 +/// +public class Binance24hrTicker +{ + public string Symbol { get; set; } = string.Empty; + public decimal OpenPrice { get; set; } + public decimal HighPrice { get; set; } + public decimal LowPrice { get; set; } + public decimal LastPrice { get; set; } + public decimal Volume { get; set; } + public decimal QuoteVolume { get; set; } + public long OpenTime { get; set; } + public long CloseTime { get; set; } + public long FirstId { get; set; } + public long LastId { get; set; } + public long Count { get; set; } + + // FULL类型才有的字段(MINI类型不包含) + public decimal? PriceChange { get; set; } + public decimal? PriceChangePercent { get; set; } + public decimal? WeightedAvgPrice { get; set; } + public decimal? PrevClosePrice { get; set; } + public decimal? LastQty { get; set; } + public decimal? BidPrice { get; set; } + public decimal? BidQty { get; set; } + public decimal? AskPrice { get; set; } + public decimal? AskQty { get; set; } +} + +/// +/// 币安交易所信息响应 +/// +public class BinanceExchangeInfo +{ + public List? Symbols { get; set; } +} + +/// +/// 币安交易对信息 +/// +public class BinanceSymbolInfo +{ + public string Symbol { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string BaseAsset { get; set; } = string.Empty; + public string QuoteAsset { get; set; } = string.Empty; +} diff --git a/src/MarketAssistant.DataProviders/BinanceMarketDataService.cs b/src/MarketAssistant.DataProviders/BinanceMarketDataService.cs new file mode 100644 index 0000000..9e08cbc --- /dev/null +++ b/src/MarketAssistant.DataProviders/BinanceMarketDataService.cs @@ -0,0 +1,335 @@ +using MarketAssistant.Infrastructure.Core; +using Microsoft.Extensions.Logging; +using System.Text.Json.Nodes; +using System.Web; + +namespace MarketAssistant.Services.Data; + +/// +/// 币安市场数据API服务(包含现货和期货公开端点) +/// +public sealed class BinanceMarketDataService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions BinanceJsonSerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new StringToDecimalConverter() } + }; + + public BinanceMarketDataService( + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// 检查响应内容是否包含地区限制错误,并返回内容字符串 + /// + private async Task CheckAndReadResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken = default) + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + if (content.Contains("restricted location", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError("币安服务在当前地区受限: {Content}", content); + throw new FriendlyException( + "币安服务在当前地区不可用\n\n" + + "根据币安服务条款,您所在的地区无法访问此服务。\n\n" + + "建议解决方案:\n" + + "• 使用 VPN 或代理服务器\n" + + "• 在系统网络设置中配置代理\n" + + "• 联系币安客服确认您的地区是否被支持\n\n" + + "如果您认为这是错误提示,请联系客服处理。"); + } + } + + response.EnsureSuccessStatusCode(); + return content; + } + + #region 24小时价格统计 + + /// + /// 获取单个交易对的24小时价格变动统计 + /// + public async Task Get24hrTickerAsync( + string symbol, + CancellationToken cancellationToken = default) + { + var url = $"/api/v3/ticker/24hr?symbol={symbol.ToUpperInvariant()}"; + _logger.LogDebug("调用币安API: {Url}", url); + + using var httpClient = _httpClientFactory.CreateClient("Binance"); + var response = await httpClient.GetAsync(url, cancellationToken); + var content = await CheckAndReadResponseAsync(response, cancellationToken); + + return JsonSerializer.Deserialize(content, BinanceJsonSerializerOptions); + } + + /// + /// 批量获取多个交易对的24小时价格变动统计(最多100个) + /// + public async Task> Get24hrTickersAsync( + List symbols, + CancellationToken cancellationToken = default) + { + if (symbols == null || symbols.Count == 0) + return new List(); + + if (symbols.Count > 100) + { + _logger.LogWarning("币安API批量查询最多支持100个交易对,已截取前100个"); + symbols = symbols.Take(100).ToList(); + } + + var symbolsArray = string.Join(",", symbols.Select(s => $"\"{s.ToUpperInvariant()}\"")); + var symbolsParam = $"[{symbolsArray}]"; + var encodedSymbols = HttpUtility.UrlEncode(symbolsParam); + + var url = $"/api/v3/ticker/24hr?symbols={encodedSymbols}&type=MINI"; + + _logger.LogDebug("批量调用币安API: {Count}个交易对", symbols.Count); + + using var httpClient = _httpClientFactory.CreateClient("Binance"); + var response = await httpClient.GetAsync(url, cancellationToken); + var content = await CheckAndReadResponseAsync(response, cancellationToken); + + var tickers = JsonSerializer.Deserialize>(content, BinanceJsonSerializerOptions); + _logger.LogInformation("成功获取币安24h行情数据,交易对数量: {Count}", tickers?.Count ?? 0); + return tickers ?? new List(); + } + + /// + /// 获取所有交易对的24小时价格变动统计(MINI格式,权重80) + /// + public async Task> GetAll24hrTickersAsync( + CancellationToken cancellationToken = default) + { + var url = "/api/v3/ticker/24hr?type=MINI&symbolStatus=TRADING"; + _logger.LogDebug("调用币安API获取所有交易对24h行情"); + + using var httpClient = _httpClientFactory.CreateClient("Binance"); + var response = await httpClient.GetAsync(url, cancellationToken); + var content = await CheckAndReadResponseAsync(response, cancellationToken); + + var tickers = JsonSerializer.Deserialize>(content, BinanceJsonSerializerOptions); + _logger.LogInformation("成功获取所有币安24h行情数据,交易对数量: {Count}", tickers?.Count ?? 0); + return tickers ?? new List(); + } + + /// + /// 获取所有交易对的24小时价格变动统计(FULL格式,含 PriceChangePercent,权重80) + /// 用于 CoinGecko 不可用时的兜底数据源 + /// + public async Task> GetAll24hrTickersFullAsync( + CancellationToken cancellationToken = default) + { + var url = "/api/v3/ticker/24hr?symbolStatus=TRADING"; + _logger.LogDebug("调用币安API获取所有交易对24h行情(FULL)"); + + using var httpClient = _httpClientFactory.CreateClient("Binance"); + var response = await httpClient.GetAsync(url, cancellationToken); + var content = await CheckAndReadResponseAsync(response, cancellationToken); + + var tickers = JsonSerializer.Deserialize>(content, BinanceJsonSerializerOptions); + _logger.LogInformation("成功获取所有币安24h行情数据(FULL),交易对数量: {Count}", tickers?.Count ?? 0); + return tickers ?? new List(); + } + + #endregion + + #region 交易所信息 + + /// + /// 获取交易所信息(仅返回 TRADING 状态的交易对) + /// + public async Task GetExchangeInfoAsync( + CancellationToken cancellationToken = default) + { + var url = "/api/v3/exchangeInfo?symbolStatus=TRADING&showPermissionSets=false"; + _logger.LogDebug("调用币安交易所信息API"); + + using var httpClient = _httpClientFactory.CreateClient("Binance"); + var response = await httpClient.GetAsync(url, cancellationToken); + var content = await CheckAndReadResponseAsync(response, cancellationToken); + + var exchangeInfo = JsonSerializer.Deserialize(content, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + _logger.LogInformation("成功获取交易所信息,交易对数量: {Count}", exchangeInfo?.Symbols?.Count ?? 0); + return exchangeInfo; + } + + #endregion + + #region K线数据 + + /// + /// 获取K线数据(OHLCV) + /// + public async Task GetKlinesAsync( + string symbol, + string interval = "1d", + int limit = 500, + long? startTime = null, + long? endTime = null, + CancellationToken cancellationToken = default) + { + var url = $"/api/v3/klines?symbol={symbol.ToUpperInvariant()}&interval={interval}&limit={limit}"; + + if (startTime.HasValue) + url += $"&startTime={startTime.Value}"; + if (endTime.HasValue) + url += $"&endTime={endTime.Value}"; + + _logger.LogDebug("调用币安K线API: {Symbol} {Interval}", symbol, interval); + + using var httpClient = _httpClientFactory.CreateClient("Binance"); + var response = await httpClient.GetAsync(url, cancellationToken); + var content = await CheckAndReadResponseAsync(response, cancellationToken); + + var klines = JsonSerializer.Deserialize(content); + _logger.LogInformation("成功获取K线数据: {Symbol}, 数量: {Count}", symbol, klines?.Count ?? 0); + return klines; + } + + #endregion + + #region 订单簿深度 + + /// + /// 获取订单簿深度数据 + /// + public async Task GetDepthAsync( + string symbol, + int limit = 100, + CancellationToken cancellationToken = default) + { + var url = $"/api/v3/depth?symbol={symbol.ToUpperInvariant()}&limit={limit}"; + + _logger.LogDebug("调用币安深度API: {Symbol}", symbol); + + using var httpClient = _httpClientFactory.CreateClient("Binance"); + var response = await httpClient.GetAsync(url, cancellationToken); + var content = await CheckAndReadResponseAsync(response, cancellationToken); + return JsonSerializer.Deserialize(content); + } + + #endregion + + #region 最近交易 + + /// + /// 获取最近交易记录 + /// + public async Task GetRecentTradesAsync( + string symbol, + int limit = 500, + CancellationToken cancellationToken = default) + { + var url = $"/api/v3/trades?symbol={symbol.ToUpperInvariant()}&limit={limit}"; + + _logger.LogDebug("调用币安交易记录API: {Symbol}", symbol); + + using var httpClient = _httpClientFactory.CreateClient("Binance"); + var response = await httpClient.GetAsync(url, cancellationToken); + var content = await CheckAndReadResponseAsync(response, cancellationToken); + return JsonSerializer.Deserialize(content); + } + + #endregion + + #region 期货API + + /// + /// 获取资金费率 + /// + public async Task GetPremiumIndexAsync( + string symbol, + CancellationToken cancellationToken = default) + { + var url = $"/fapi/v1/premiumIndex?symbol={symbol.ToUpperInvariant()}"; + _logger.LogDebug("调用币安期货资金费率API: {Symbol}", symbol); + + using var httpClient = _httpClientFactory.CreateClient("BinanceFutures"); + var response = await httpClient.GetAsync(url, cancellationToken); + var content = await CheckAndReadResponseAsync(response, cancellationToken); + + return JsonSerializer.Deserialize(content, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + + /// + /// 获取历史资金费率 + /// + public async Task> GetFundingRateHistoryAsync( + string symbol, + int limit = 30, + long? startTime = null, + long? endTime = null, + CancellationToken cancellationToken = default) + { + var url = $"/fapi/v1/fundingRate?symbol={symbol.ToUpperInvariant()}&limit={limit}"; + + if (startTime.HasValue) + url += $"&startTime={startTime.Value}"; + if (endTime.HasValue) + url += $"&endTime={endTime.Value}"; + + _logger.LogDebug("调用币安期货历史资金费率API: {Symbol}", symbol); + + using var httpClient = _httpClientFactory.CreateClient("BinanceFutures"); + var response = await httpClient.GetAsync(url, cancellationToken); + var content = await CheckAndReadResponseAsync(response, cancellationToken); + + return JsonSerializer.Deserialize>(content, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? new List(); + } + + /// + /// 获取多空比数据 + /// + public async Task> GetLongShortRatioAsync( + string endpoint, + string symbol, + string period = "5m", + int limit = 30, + CancellationToken cancellationToken = default) + { + var url = $"/futures/data/{endpoint}?symbol={symbol.ToUpperInvariant()}&period={period}&limit={limit}"; + _logger.LogDebug("调用币安期货多空比API: {Symbol} {Endpoint}", symbol, endpoint); + + using var httpClient = _httpClientFactory.CreateClient("BinanceFutures"); + var response = await httpClient.GetAsync(url, cancellationToken); + var content = await CheckAndReadResponseAsync(response, cancellationToken); + + return JsonSerializer.Deserialize>(content, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? new List(); + } + + /// + /// 获取持仓量历史数据 + /// + public async Task> GetOpenInterestHistAsync( + string symbol, + string period = "5m", + int limit = 30, + CancellationToken cancellationToken = default) + { + var url = $"/futures/data/openInterestHist?symbol={symbol.ToUpperInvariant()}&period={period}&limit={limit}"; + _logger.LogDebug("调用币安期货持仓量API: {Symbol}", symbol); + + using var httpClient = _httpClientFactory.CreateClient("BinanceFutures"); + var response = await httpClient.GetAsync(url, cancellationToken); + var content = await CheckAndReadResponseAsync(response, cancellationToken); + + return JsonSerializer.Deserialize>(content, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? new List(); + } + + #endregion +} diff --git a/src/MarketAssistant.DataProviders/BinanceOpenInterestResponse.cs b/src/MarketAssistant.DataProviders/BinanceOpenInterestResponse.cs new file mode 100644 index 0000000..30edfcc --- /dev/null +++ b/src/MarketAssistant.DataProviders/BinanceOpenInterestResponse.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace MarketAssistant.Services.Data; + +/// +/// 币安持仓量 API 响应模型 +/// +public class BinanceOpenInterestResponse +{ + [JsonPropertyName("symbol")] + public string Symbol { get; set; } = string.Empty; + + [JsonPropertyName("sumOpenInterest")] + public string SumOpenInterest { get; set; } = string.Empty; + + [JsonPropertyName("sumOpenInterestValue")] + public string SumOpenInterestValue { get; set; } = string.Empty; + + [JsonPropertyName("timestamp")] + public long Timestamp { get; set; } +} diff --git a/src/MarketAssistant.DataProviders/BinancePremiumIndexResponse.cs b/src/MarketAssistant.DataProviders/BinancePremiumIndexResponse.cs new file mode 100644 index 0000000..3ea4589 --- /dev/null +++ b/src/MarketAssistant.DataProviders/BinancePremiumIndexResponse.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace MarketAssistant.Services.Data; + +/// +/// 币安溢价指数 API 响应模型 +/// +public class BinancePremiumIndexResponse +{ + [JsonPropertyName("symbol")] + public string Symbol { get; set; } = string.Empty; + + [JsonPropertyName("markPrice")] + public string MarkPrice { get; set; } = string.Empty; + + [JsonPropertyName("lastFundingRate")] + public string LastFundingRate { get; set; } = string.Empty; + + [JsonPropertyName("nextFundingTime")] + public long NextFundingTime { get; set; } +} diff --git a/src/MarketAssistant.DataProviders/BinanceWebSocketService.cs b/src/MarketAssistant.DataProviders/BinanceWebSocketService.cs new file mode 100644 index 0000000..3915d21 --- /dev/null +++ b/src/MarketAssistant.DataProviders/BinanceWebSocketService.cs @@ -0,0 +1,228 @@ +using System.Globalization; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Services.Data; + +/// +/// Binance WebSocket 实时行情服务,通过 mini-ticker 推送价格更新 +/// +public sealed class BinanceWebSocketService : IAsyncDisposable, IDisposable +{ + private const string WsBaseUrl = "wss://stream.binance.com:9443/stream?streams="; + private readonly ILogger _logger; + private ClientWebSocket? _ws; + private CancellationTokenSource? _cts; + private readonly HashSet _subscribedSymbols = new(StringComparer.OrdinalIgnoreCase); + private readonly Lock _lock = new(); + + /// + /// 收到价格更新时触发,参数为 (symbol, lastPrice, priceChangePercent) + /// + public event Action? PriceUpdated; + + public BinanceWebSocketService(ILogger logger) + { + _logger = logger; + } + + /// + /// 订阅指定交易对的实时行情 + /// + /// Binance 格式的交易对列表,如 ["BTCUSDT","ETHUSDT"] + public async Task SubscribeAsync(IEnumerable symbols) + { + var symbolList = symbols.Select(s => s.ToLowerInvariant()).Distinct().ToList(); + if (symbolList.Count == 0) return; + + lock (_lock) + { + foreach (var s in symbolList) + _subscribedSymbols.Add(s); + } + + await ReconnectAsync(); + } + + /// + /// 取消订阅并断开连接 + /// + public async Task UnsubscribeAllAsync() + { + lock (_lock) + { + _subscribedSymbols.Clear(); + } + + await DisconnectAsync(); + } + + private async Task ReconnectAsync() + { + await DisconnectAsync(); + + List symbols; + lock (_lock) + { + symbols = [.. _subscribedSymbols]; + } + + if (symbols.Count == 0) return; + + var streams = string.Join("/", symbols.Select(s => $"{s}@miniTicker")); + var url = WsBaseUrl + streams; + + _cts = new CancellationTokenSource(); + _ws = new ClientWebSocket(); + + try + { + _logger.LogInformation("连接 Binance WebSocket,订阅 {Count} 个交易对", symbols.Count); + await _ws.ConnectAsync(new Uri(url), _cts.Token); + _ = ReceiveLoopAsync(_ws, _cts.Token); + } + catch (Exception ex) + { + _logger.LogError(ex, "Binance WebSocket 连接失败"); + } + } + + private async Task ReceiveLoopAsync(ClientWebSocket ws, CancellationToken ct) + { + // Binance 组合流消息可能分片到达,使用动态缓冲区拼接完整消息 + var buffer = new byte[8192]; + using var messageStream = new MemoryStream(); + + try + { + while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested) + { + WebSocketReceiveResult result; + messageStream.SetLength(0); + + do + { + result = await ws.ReceiveAsync(buffer, ct); + if (result.MessageType == WebSocketMessageType.Close) + { + _logger.LogInformation("Binance WebSocket 服务端关闭连接"); + return; + } + messageStream.Write(buffer, 0, result.Count); + } + while (!result.EndOfMessage); + + var json = Encoding.UTF8.GetString(messageStream.GetBuffer(), 0, (int)messageStream.Length); + ProcessMessage(json); + } + } + catch (OperationCanceledException) + { + // 正常取消 + } + catch (WebSocketException ex) + { + _logger.LogWarning(ex, "Binance WebSocket 断开,将在 5 秒后重连"); + // 使用 ct 而非 CancellationToken.None,确保应用关闭时重连延迟可被取消 + try + { + await Task.Delay(5000, ct); + } + catch (OperationCanceledException) + { + return; + } + if (!ct.IsCancellationRequested) + _ = ReconnectAsync(); + } + } + + private void ProcessMessage(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (!root.TryGetProperty("data", out var data)) return; + + var symbol = data.GetProperty("s").GetString(); + var lastPriceStr = data.GetProperty("c").GetString(); + var openPriceStr = data.GetProperty("o").GetString(); + + if (symbol == null || lastPriceStr == null || openPriceStr == null) return; + + var lastPrice = decimal.Parse(lastPriceStr, CultureInfo.InvariantCulture); + var openPrice = decimal.Parse(openPriceStr, CultureInfo.InvariantCulture); + var changePercent = openPrice > 0 + ? Math.Round((lastPrice - openPrice) / openPrice * 100, 2) + : 0m; + + PriceUpdated?.Invoke(symbol.ToUpperInvariant(), lastPrice, changePercent); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "解析 WebSocket 消息失败"); + } + } + + private async Task DisconnectAsync() + { + if (_cts != null) + { + await _cts.CancelAsync(); + _cts.Dispose(); + _cts = null; + } + + if (_ws != null) + { + if (_ws.State == WebSocketState.Open) + { + try + { + await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "unsubscribe", CancellationToken.None); + } + catch + { + // 忽略关闭异常 + } + } + _ws.Dispose(); + _ws = null; + } + } + + /// + /// 异步释放资源,避免在 UI 线程上同步等待异步关闭操作造成死锁 + /// + public async ValueTask DisposeAsync() + { + await DisconnectAsync().ConfigureAwait(false); + GC.SuppressFinalize(this); + } + + public void Dispose() + { + _cts?.Cancel(); + _cts?.Dispose(); + _cts = null; + + if (_ws != null) + { + try + { + if (_ws.State == WebSocketState.Open || _ws.State == WebSocketState.CloseReceived) + _ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "dispose", CancellationToken.None) + .Wait(TimeSpan.FromSeconds(3)); + } + catch { } + _ws.Dispose(); + _ws = null; + } + + GC.SuppressFinalize(this); + } +} diff --git a/src/MarketAssistant.DataProviders/CoinDeskApiService.cs b/src/MarketAssistant.DataProviders/CoinDeskApiService.cs new file mode 100644 index 0000000..d1cc77d --- /dev/null +++ b/src/MarketAssistant.DataProviders/CoinDeskApiService.cs @@ -0,0 +1,82 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Logging; + + +namespace MarketAssistant.Services.Data; + +/// +/// CoinDesk API服务 +/// +public sealed class CoinDeskApiService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + // 支持 API 返回的字符串数值/null 自动容错转换为 decimal? + private static readonly JsonSerializerOptions CoinDeskJsonOptions = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new StringToDecimalConverter() } + }; + + public CoinDeskApiService( + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// 获取资产元数据(项目基本面信息) + /// + public async Task GetAssetMetadataAsync( + string symbol, + string quoteAsset = "USD", + string language = "en-US", + CancellationToken cancellationToken = default) + { + var upperSymbol = symbol.ToUpper(); + var url = $"/asset/v2/metadata?assets={upperSymbol}&asset_lookup_priority=SYMBOL"e_asset={quoteAsset}&asset_language={language}"; + + _logger.LogDebug("调用CoinDesk Metadata API: {Symbol}", upperSymbol); + + using var httpClient = _httpClientFactory.CreateClient("CoinDesk"); + var response = await httpClient.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + var metadata = await response.Content.ReadFromJsonAsync( + CoinDeskJsonOptions, + cancellationToken); + + _logger.LogInformation("成功获取CoinDesk元数据: {Symbol}", upperSymbol); + return metadata; + } + + /// + /// 搜索新闻 + /// + public async Task SearchNewsAsync( + string searchString, + int limit = 10, + string lang = "EN", + string sourceKey = "coindesk", + CancellationToken cancellationToken = default) + { + var url = $"/news/v1/search?search_string={Uri.EscapeDataString(searchString)}&limit={limit}&lang={lang}&source_key={sourceKey}"; + + _logger.LogDebug("调用CoinDesk News API: {SearchString}", searchString); + + using var httpClient = _httpClientFactory.CreateClient("CoinDesk"); + var response = await httpClient.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + var newsResponse = await response.Content.ReadFromJsonAsync( + CoinDeskJsonOptions, + cancellationToken); + + _logger.LogInformation("成功获取CoinDesk新闻,结果数: {Count}", newsResponse?.Data?.Count ?? 0); + return newsResponse; + } +} diff --git a/src/MarketAssistant.DataProviders/CoinDeskMetadataResponse.cs b/src/MarketAssistant.DataProviders/CoinDeskMetadataResponse.cs new file mode 100644 index 0000000..30e4cc9 --- /dev/null +++ b/src/MarketAssistant.DataProviders/CoinDeskMetadataResponse.cs @@ -0,0 +1,147 @@ +using System.Text.Json.Serialization; + +namespace MarketAssistant.Services.Data; + +/// +/// CoinDesk 元数据响应根对象 +/// +public class CoinDeskMetadataResponse +{ + [JsonPropertyName("Data")] + public Dictionary? Data { get; set; } +} + +/// +/// CoinDesk 资产数据 +/// +public class CoinDeskAssetData +{ + [JsonPropertyName("SYMBOL")] + public string? Symbol { get; set; } + + [JsonPropertyName("NAME")] + public string? Name { get; set; } + + [JsonPropertyName("URI")] + public string? Uri { get; set; } + + [JsonPropertyName("ASSET_TYPE")] + public string? AssetType { get; set; } + + [JsonPropertyName("ASSET_ISSUER_NAME")] + public string? AssetIssuerName { get; set; } + + [JsonPropertyName("ASSET_ALTERNATIVE_IDS")] + public List? AssetAlternativeIds { get; set; } + + [JsonPropertyName("ASSET_DESCRIPTION_SNIPPET")] + public string? AssetDescriptionSnippet { get; set; } + + [JsonPropertyName("ASSET_DESCRIPTION")] + public string? AssetDescription { get; set; } + + [JsonPropertyName("ASSET_SECURITY_METRICS")] + public List? AssetSecurityMetrics { get; set; } + + [JsonPropertyName("SUPPLY_MAX")] + public decimal? SupplyMax { get; set; } + + [JsonPropertyName("SUPPLY_ISSUED")] + public decimal? SupplyIssued { get; set; } + + [JsonPropertyName("SUPPLY_TOTAL")] + public decimal? SupplyTotal { get; set; } + + [JsonPropertyName("SUPPLY_CIRCULATING")] + public decimal? SupplyCirculating { get; set; } + + [JsonPropertyName("SUPPLY_BURNT")] + public decimal? SupplyBurnt { get; set; } + + [JsonPropertyName("PRICE_USD")] + public decimal? PriceUsd { get; set; } + + [JsonPropertyName("PRICE_USD_SOURCE")] + public string? PriceUsdSource { get; set; } + + [JsonPropertyName("TOTAL_MKT_CAP_USD")] + public decimal? TotalMktCapUsd { get; set; } + + [JsonPropertyName("CIRCULATING_MKT_CAP_USD")] + public decimal? CirculatingMktCapUsd { get; set; } + + [JsonPropertyName("SPOT_MOVING_24_HOUR_QUOTE_VOLUME_USD")] + public decimal? SpotMoving24HourQuoteVolumeUsd { get; set; } + + [JsonPropertyName("SPOT_MOVING_7_DAY_QUOTE_VOLUME_USD")] + public decimal? SpotMoving7DayQuoteVolumeUsd { get; set; } + + [JsonPropertyName("SPOT_MOVING_30_DAY_QUOTE_VOLUME_USD")] + public decimal? SpotMoving30DayQuoteVolumeUsd { get; set; } + + [JsonPropertyName("SPOT_MOVING_24_HOUR_CHANGE_PERCENTAGE_USD")] + public decimal? SpotMoving24HourChangePercentageUsd { get; set; } + + [JsonPropertyName("SPOT_MOVING_7_DAY_CHANGE_PERCENTAGE_USD")] + public decimal? SpotMoving7DayChangePercentageUsd { get; set; } + + [JsonPropertyName("SPOT_MOVING_30_DAY_CHANGE_PERCENTAGE_USD")] + public decimal? SpotMoving30DayChangePercentageUsd { get; set; } + + [JsonPropertyName("TOPLIST_BASE_RANK")] + public CoinDeskToplistRank? ToplistBaseRank { get; set; } + + [JsonPropertyName("ASSET_INDUSTRIES")] + public List? AssetIndustries { get; set; } +} + +/// +/// CoinDesk 其他平台 ID +/// +public class CoinDeskAlternativeId +{ + [JsonPropertyName("NAME")] + public string? Name { get; set; } + + [JsonPropertyName("ID")] + public string? Id { get; set; } +} + +/// +/// CoinDesk 安全审计指标 +/// +public class CoinDeskSecurityMetric +{ + [JsonPropertyName("NAME")] + public string? Name { get; set; } + + [JsonPropertyName("OVERALL_SCORE")] + public decimal? OverallScore { get; set; } + + [JsonPropertyName("OVERALL_RANK")] + public int? OverallRank { get; set; } +} + +/// +/// CoinDesk 排名信息 +/// +public class CoinDeskToplistRank +{ + [JsonPropertyName("CIRCULATING_MKT_CAP_USD")] + public int? CirculatingMktCapUsd { get; set; } + + [JsonPropertyName("SPOT_MOVING_24_HOUR_QUOTE_VOLUME_USD")] + public int? SpotMoving24HourQuoteVolumeUsd { get; set; } + + [JsonPropertyName("SPOT_MOVING_30_DAY_QUOTE_VOLUME_USD")] + public int? SpotMoving30DayQuoteVolumeUsd { get; set; } +} + +/// +/// CoinDesk 行业分类 +/// +public class CoinDeskIndustry +{ + [JsonPropertyName("ASSET_INDUSTRY")] + public string? AssetIndustry { get; set; } +} diff --git a/src/MarketAssistant.DataProviders/CoinDeskNewsResponse.cs b/src/MarketAssistant.DataProviders/CoinDeskNewsResponse.cs new file mode 100644 index 0000000..13079d0 --- /dev/null +++ b/src/MarketAssistant.DataProviders/CoinDeskNewsResponse.cs @@ -0,0 +1,51 @@ +using System.Text.Json.Serialization; + +namespace MarketAssistant.Services.Data; + +/// +/// CoinDesk 新闻 API 响应模型 +/// +public class CoinDeskNewsResponse +{ + [JsonPropertyName("DATA")] + public List? Data { get; set; } +} + +/// +/// 新闻文章模型 +/// +public class NewsArticle +{ + [JsonPropertyName("ID")] + public int Id { get; set; } + + [JsonPropertyName("TITLE")] + public string Title { get; set; } = ""; + + [JsonPropertyName("BODY")] + public string Body { get; set; } = ""; + + [JsonPropertyName("URL")] + public string Url { get; set; } = ""; + + [JsonPropertyName("KEYWORDS")] + public string Keywords { get; set; } = ""; + + [JsonPropertyName("CREATED_ON")] + public int CreatedOn { get; set; } + + [JsonPropertyName("AUTHORS")] + public string Authors { get; set; } = ""; + + [JsonPropertyName("SOURCE_DATA")] + public NewsSource? Source { get; set; } +} + +/// +/// 新闻来源模型 +/// +public class NewsSource +{ + [JsonPropertyName("NAME")] + public string Name { get; set; } = ""; +} diff --git a/src/MarketAssistant.DataProviders/CoinGeckoApiService.cs b/src/MarketAssistant.DataProviders/CoinGeckoApiService.cs new file mode 100644 index 0000000..5692d26 --- /dev/null +++ b/src/MarketAssistant.DataProviders/CoinGeckoApiService.cs @@ -0,0 +1,374 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + + +namespace MarketAssistant.Services.Data; + +/// +/// CoinGecko API服务(含客户端限流,免费版 30 次/分钟) +/// +public sealed class CoinGeckoApiService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + private static readonly SemaphoreSlim Throttle = new(1, 1); + private static DateTime _lastRequestTime = DateTime.MinValue; + private const int MinRequestIntervalMs = 2500; + + // 支持 API 返回的字符串数值/null 自动容错转换为 decimal?(CoinGeckoMarket 全为 decimal?) + private static readonly JsonSerializerOptions CoinGeckoJsonOptions = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new StringToDecimalConverter() } + }; + + public CoinGeckoApiService( + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// 限流执行:确保请求间隔不低于 MinRequestIntervalMs(约 24 次/分钟) + /// + private async Task ThrottledExecuteAsync(Func> action, CancellationToken cancellationToken) + { + await Throttle.WaitAsync(cancellationToken); + try + { + var elapsed = (DateTime.UtcNow - _lastRequestTime).TotalMilliseconds; + if (elapsed < MinRequestIntervalMs) + await Task.Delay((int)(MinRequestIntervalMs - elapsed), cancellationToken); + + using var httpClient = _httpClientFactory.CreateClient("CoinGecko"); + var result = await action(httpClient); + _lastRequestTime = DateTime.UtcNow; + return result; + } + finally + { + Throttle.Release(); + } + } + + /// + /// 获取市场数据(支持筛选) + /// + public async Task> GetCoinsMarketsAsync( + string vsCurrency = "usd", + string? category = null, + string? order = "market_cap_desc", + int perPage = 100, + int page = 1, + string? priceChangePercentage = null, + CancellationToken cancellationToken = default) + { + var queryParams = new List + { + $"vs_currency={vsCurrency}", + $"order={order}", + $"per_page={perPage}", + $"page={page}", + "sparkline=false" + }; + + if (!string.IsNullOrWhiteSpace(category)) + queryParams.Add($"category={category}"); + if (!string.IsNullOrWhiteSpace(priceChangePercentage)) + queryParams.Add($"price_change_percentage={priceChangePercentage}"); + + var url = $"coins/markets?{string.Join("&", queryParams)}"; + _logger.LogDebug("调用CoinGecko API: {Url}", url); + + return await ThrottledExecuteAsync(async httpClient => + { + var response = await httpClient.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + var markets = await response.Content.ReadFromJsonAsync>( + CoinGeckoJsonOptions, + cancellationToken); + + _logger.LogInformation("成功获取CoinGecko市场数据,币种数量: {Count}", markets?.Count ?? 0); + return markets ?? new List(); + }, cancellationToken); + } + + /// + /// 获取单币种市场数据(含价格变化百分比) + /// + public async Task GetCoinMarketDataAsync( + string coinId, + string vsCurrency = "usd", + string priceChangePercentage = "24h,7d,30d", + CancellationToken cancellationToken = default) + { + var url = $"coins/markets?vs_currency={vsCurrency}&ids={coinId}&order=market_cap_desc&sparkline=false&price_change_percentage={priceChangePercentage}"; + _logger.LogDebug("调用CoinGecko单币种API: {CoinId}", coinId); + + return await ThrottledExecuteAsync(async httpClient => + await httpClient.GetFromJsonAsync(url, cancellationToken), + cancellationToken); + } + + /// + /// 获取币种在各交易所的交易对数据 + /// + public async Task GetCoinTickersAsync( + string coinId, + CancellationToken cancellationToken = default) + { + var url = $"coins/{coinId}/tickers?include_exchange_logo=false"; + _logger.LogDebug("调用CoinGecko Tickers API: {CoinId}", coinId); + + return await ThrottledExecuteAsync(async httpClient => + await httpClient.GetFromJsonAsync( + url, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, + cancellationToken), + cancellationToken); + } + + /// + /// 搜索币种 + /// + public async Task SearchCoinsAsync( + string query, + CancellationToken cancellationToken = default) + { + var url = $"search?query={Uri.EscapeDataString(query)}"; + _logger.LogDebug("调用CoinGecko Search API: {Query}", query); + + return await ThrottledExecuteAsync(async httpClient => + { + var response = await httpClient.GetFromJsonAsync( + url, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, + cancellationToken); + + _logger.LogInformation("CoinGecko搜索结果: {Count}个币种", response?.Coins?.Count ?? 0); + return response; + }, cancellationToken); + } + + /// + /// 根据币种符号获取完整项目数据(含描述、分类、市场数据、社区数据、开发者数据) + /// 内部通过 /coins/list 建立 symbol→id 映射,再调用 /coins/{id} 获取详情 + /// + public async Task GetCoinBySymbolAsync( + string symbol, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(symbol)) + throw new ArgumentException("币种符号不能为空", nameof(symbol)); + + // 提取基础币种(BTCUSDT → BTC) + var baseSymbol = MarketAssistant.Infrastructure.Core.CryptoSymbolConverter.ExtractBaseCurrency(symbol); + if (string.IsNullOrEmpty(baseSymbol)) + baseSymbol = symbol.ToUpperInvariant(); + + var coinId = await ResolveCoinIdAsync(baseSymbol, cancellationToken); + if (string.IsNullOrEmpty(coinId)) + { + _logger.LogWarning("无法解析 CoinGecko coinId: {Symbol}", baseSymbol); + return null; + } + + var url = $"coins/{coinId}?localization=true&tickers=false&market_data=true&community_data=true&developer_data=true&sparkline=false"; + _logger.LogDebug("调用CoinGecko /coins/{{id}}: {CoinId}", coinId); + + return await ThrottledExecuteAsync(async httpClient => + { + var response = await httpClient.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + var detail = await response.Content.ReadFromJsonAsync( + CoinGeckoJsonOptions, cancellationToken); + _logger.LogInformation("成功获取CoinGecko项目详情: {Name} ({Id})", detail?.Name, coinId); + return detail; + }, cancellationToken); + } + + /// + /// 解析 symbol → coinId(先查内置映射表,未命中则调用 /coins/list) + /// + private async Task ResolveCoinIdAsync(string baseSymbol, CancellationToken cancellationToken) + { + // 优先使用内置映射表(覆盖主流币种,避免一次 HTTP 请求) + var mappedId = MarketAssistant.Infrastructure.Core.CryptoSymbolConverter.ToCoinGeckoId(baseSymbol); + if (!string.IsNullOrEmpty(mappedId) && !string.Equals(mappedId, baseSymbol, StringComparison.OrdinalIgnoreCase)) + { + return mappedId; + } + + // 未命中映射表,调用 /coins/list 查询 + try + { + return await ThrottledExecuteAsync(async httpClient => + { + var response = await httpClient.GetAsync("coins/list", cancellationToken); + response.EnsureSuccessStatusCode(); + + var coins = await response.Content.ReadFromJsonAsync>( + CoinGeckoJsonOptions, cancellationToken); + + var hit = coins?.FirstOrDefault(c => + string.Equals(c.Symbol, baseSymbol, StringComparison.OrdinalIgnoreCase)); + return hit?.Id; + }, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "调用 /coins/list 解析 coinId 失败: {Symbol}", baseSymbol); + return null; + } + } +} + +/// +/// CoinGecko /coins/{id} 端点返回的完整币种详情 +/// +public class CoinGeckoCoinDetail +{ + public string Id { get; set; } = string.Empty; + public string Symbol { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public List Categories { get; set; } = []; + public CoinGeckoCoinDescription? Description { get; set; } + [JsonPropertyName("links")] + public CoinGeckoCoinLinks? Links { get; set; } + [JsonPropertyName("market_data")] + public CoinGeckoCoinMarketData? MarketData { get; set; } + [JsonPropertyName("community_data")] + public CoinGeckoCoinCommunityData? CommunityData { get; set; } + [JsonPropertyName("developer_data")] + public CoinGeckoCoinDeveloperData? DeveloperData { get; set; } + [JsonPropertyName("market_cap_rank")] + public int? MarketCapRank { get; set; } + [JsonPropertyName("coingecko_rank")] + public int? CoingeckoRank { get; set; } + [JsonPropertyName("coingecko_score")] + public decimal? CoingeckoScore { get; set; } + [JsonPropertyName("sentiment_votes_up_percentage")] + public decimal? SentimentVotesUpPercentage { get; set; } + [JsonPropertyName("sentiment_votes_down_percentage")] + public decimal? SentimentVotesDownPercentage { get; set; } +} + +public class CoinGeckoCoinDescription +{ + public string En { get; set; } = string.Empty; +} + +public class CoinGeckoCoinLinks +{ + public List Homepage { get; set; } = []; + [JsonPropertyName("twitter_screen_name")] + public string TwitterScreenName { get; set; } = string.Empty; + [JsonPropertyName("subreddit_url")] + public string SubredditUrl { get; set; } = string.Empty; + [JsonPropertyName("repos_url")] + public CoinGeckoCoinReposUrl? ReposUrl { get; set; } +} + +public class CoinGeckoCoinReposUrl +{ + public List Github { get; set; } = []; + public List Bitbucket { get; set; } = []; +} + +public class CoinGeckoCoinMarketData +{ + [JsonPropertyName("current_price")] + public Dictionary CurrentPrice { get; set; } = []; + [JsonPropertyName("market_cap")] + public Dictionary MarketCap { get; set; } = []; + [JsonPropertyName("total_volume")] + public Dictionary TotalVolume { get; set; } = []; + [JsonPropertyName("circulating_supply")] + public decimal? CirculatingSupply { get; set; } + [JsonPropertyName("total_supply")] + public decimal? TotalSupply { get; set; } + [JsonPropertyName("max_supply")] + public decimal? MaxSupply { get; set; } + [JsonPropertyName("price_change_percentage_24h_in_currency")] + public Dictionary PriceChangePercentage24hInCurrency { get; set; } = []; + [JsonPropertyName("price_change_percentage_7d_in_currency")] + public Dictionary PriceChangePercentage7dInCurrency { get; set; } = []; + [JsonPropertyName("price_change_percentage_30d_in_currency")] + public Dictionary PriceChangePercentage30dInCurrency { get; set; } = []; +} + +public class CoinGeckoCoinCommunityData +{ + [JsonPropertyName("twitter_followers")] + public long? TwitterFollowers { get; set; } + [JsonPropertyName("reddit_subscribers")] + public long? RedditSubscribers { get; set; } + [JsonPropertyName("reddit_average_posts_24h")] + public decimal? RedditAveragePosts24h { get; set; } + [JsonPropertyName("reddit_average_comments_24h")] + public decimal? RedditAverageComments24h { get; set; } + [JsonPropertyName("reddit_accounts_active_48h")] + public decimal? RedditAccountsActive48h { get; set; } +} + +public class CoinGeckoCoinDeveloperData +{ + public int? Forks { get; set; } + public int? Stars { get; set; } + public int? Subscribers { get; set; } + [JsonPropertyName("total_issues")] + public int? TotalIssues { get; set; } + [JsonPropertyName("pull_requests_contributors")] + public int? PullRequestsContributors { get; set; } + [JsonPropertyName("commit_count_4_weeks")] + public int? CommitCount4Weeks { get; set; } +} + +public class CoinGeckoCoinListItem +{ + public string Id { get; set; } = string.Empty; + public string Symbol { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; +} + +/// +/// CoinGecko市场数据模型 +/// +public class CoinGeckoMarket +{ + public string Id { get; set; } = string.Empty; + public string Symbol { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Image { get; set; } = string.Empty; + public decimal? Current_Price { get; set; } + public decimal? Market_Cap { get; set; } + public int? Market_Cap_Rank { get; set; } + public decimal? Fully_Diluted_Valuation { get; set; } + public decimal? Total_Volume { get; set; } + public decimal? High_24h { get; set; } + public decimal? Low_24h { get; set; } + public decimal? Price_Change_24h { get; set; } + public decimal? Price_Change_Percentage_24h { get; set; } + public decimal? Market_Cap_Change_24h { get; set; } + public decimal? Market_Cap_Change_Percentage_24h { get; set; } + public decimal? Circulating_Supply { get; set; } + public decimal? Total_Supply { get; set; } + public decimal? Max_Supply { get; set; } + public decimal? Ath { get; set; } + public decimal? Ath_Change_Percentage { get; set; } + public DateTime? Ath_Date { get; set; } + public decimal? Atl { get; set; } + public decimal? Atl_Change_Percentage { get; set; } + public DateTime? Atl_Date { get; set; } + public DateTime? Last_Updated { get; set; } + + public decimal? Price_Change_Percentage_7d_In_Currency { get; set; } + public decimal? Price_Change_Percentage_30d_In_Currency { get; set; } +} diff --git a/src/MarketAssistant.DataProviders/CoinGeckoSearchResponse.cs b/src/MarketAssistant.DataProviders/CoinGeckoSearchResponse.cs new file mode 100644 index 0000000..9624a09 --- /dev/null +++ b/src/MarketAssistant.DataProviders/CoinGeckoSearchResponse.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace MarketAssistant.Services.Data; + +public class CoinGeckoSearchResponse +{ + [JsonPropertyName("coins")] + public List Coins { get; set; } = []; +} + +public class CoinSearchResult +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("symbol")] + public string Symbol { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("market_cap_rank")] + public int? MarketCapRank { get; set; } +} diff --git a/src/MarketAssistant.DataProviders/CoinGeckoTickersResponse.cs b/src/MarketAssistant.DataProviders/CoinGeckoTickersResponse.cs new file mode 100644 index 0000000..f04d54b --- /dev/null +++ b/src/MarketAssistant.DataProviders/CoinGeckoTickersResponse.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; + +namespace MarketAssistant.Services.Data; + +public class CoinGeckoTickersResponse +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("tickers")] + public List Tickers { get; set; } = []; +} + +public class TickerData +{ + [JsonPropertyName("base")] + public string Base { get; set; } = string.Empty; + + [JsonPropertyName("target")] + public string Target { get; set; } = string.Empty; + + [JsonPropertyName("market")] + public MarketInfo? Market { get; set; } + + [JsonPropertyName("converted_volume")] + public ConvertedVolume? ConvertedVolume { get; set; } +} + +public class MarketInfo +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("identifier")] + public string Identifier { get; set; } = string.Empty; +} + +public class ConvertedVolume +{ + [JsonPropertyName("usd")] + public double Usd { get; set; } +} diff --git a/src/MarketAssistant.DataProviders/GlobalUsing.cs b/src/MarketAssistant.DataProviders/GlobalUsing.cs new file mode 100644 index 0000000..7ec6f9d --- /dev/null +++ b/src/MarketAssistant.DataProviders/GlobalUsing.cs @@ -0,0 +1,4 @@ +global using System.Net.Http; +global using System.Text; +global using System.Text.Json; +global using MarketAssistant.Infrastructure.Core; diff --git a/src/MarketAssistant.DataProviders/MarketAssistant.DataProviders.csproj b/src/MarketAssistant.DataProviders/MarketAssistant.DataProviders.csproj new file mode 100644 index 0000000..4ac30f7 --- /dev/null +++ b/src/MarketAssistant.DataProviders/MarketAssistant.DataProviders.csproj @@ -0,0 +1,17 @@ + + + net10.0 + enable + MarketAssistant + enable + + + + + + + + + + + diff --git a/src/MarketAssistant.DataProviders/MarketAssistant.DataProviders.csproj.lscache b/src/MarketAssistant.DataProviders/MarketAssistant.DataProviders.csproj.lscache new file mode 100644 index 0000000..61ead36 --- /dev/null +++ b/src/MarketAssistant.DataProviders/MarketAssistant.DataProviders.csproj.lscache @@ -0,0 +1,284 @@ +version=1 + +# This file caches language service data to improve the performance of C# Dev Kit. +# It is not intended for manual editing. It can safely be deleted and will be +# regenerated automatically. For more information, see https://aka.ms/lscache +# +# To control where cache files are stored, use the following VS Code setting: +# "dotnet.projectsystem.cacheInProjectFolder": true + +[project] +language=C# +primary +lastDtbSucceeded + +[properties] +AssemblyName=MarketAssistant.DataProviders +CommandLineArgsForDesignTimeEvaluation=-langversion:14.0 -define:TRACE +CompilerGeneratedFilesOutputPath= +MaxSupportedLangVersion=14.0 +ProjectAssetsFile=obj/project.assets.json +RootNamespace=MarketAssistant +RunAnalyzers= +RunAnalyzersDuringLiveAnalysis= +SolutionPath=../../MarketAssistant.slnx +TargetFrameworkIdentifier=.NETCoreApp +TargetPath=bin/Debug/net10.0/MarketAssistant.DataProviders.dll +TargetRefPath=obj/Debug/net10.0/ref/MarketAssistant.DataProviders.dll +TemporaryDependencyNodeTargetIdentifier=net10.0 + +[commandLineArguments] +/noconfig +/unsafe- +/checked- +/nowarn:1701,1702,1701,1702,8002 +/fullpaths +/nostdlib+ +/errorreport:prompt +/warn:10 +/define:TRACE;DEBUG;NET;NET10_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NET10_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER +/highentropyva+ +/nullable:enable +/features:"InterceptorsNamespaces=;Microsoft.Extensions.Validation.Generated" +/debug+ +/debug:portable +/filealign:512 +/optimize- +/out:obj\Debug\net10.0\MarketAssistant.DataProviders.dll +/refout:obj\Debug\net10.0\refint\MarketAssistant.DataProviders.dll +/target:library +/warnaserror- +/utf8output +/deterministic+ +/langversion:14.0 +/warnaserror+:NU1605,SYSLIB0011 + +[sourceFiles] +BinanceFundingRateResponse.cs +BinanceLongShortRatioResponse.cs +BinanceMarketDataModels.cs +BinanceMarketDataService.cs +BinanceOpenInterestResponse.cs +BinancePremiumIndexResponse.cs +BinanceWebSocketService.cs +CoinDeskApiService.cs +CoinDeskMetadataResponse.cs +CoinDeskNewsResponse.cs +CoinGeckoApiService.cs +CoinGeckoSearchResponse.cs +CoinGeckoTickersResponse.cs +GlobalUsing.cs +obj/Debug/net10.0/ + .NETCoreApp,Version=v10.0.AssemblyAttributes.cs + MarketAssistant.DataProviders.AssemblyInfo.cs + MarketAssistant.DataProviders.GlobalUsings.g.cs +StringToDecimalConverter.cs + +[metadataReferences] +../MarketAssistant.Core/obj/Debug/net10.0/ref/MarketAssistant.Core.dll +/packs/Microsoft.NETCore.App.Ref/10.0.5/ref/net10.0/ + Microsoft.CSharp.dll + Microsoft.VisualBasic.Core.dll + Microsoft.VisualBasic.dll + Microsoft.Win32.Primitives.dll + Microsoft.Win32.Registry.dll + mscorlib.dll + netstandard.dll + System.AppContext.dll + System.Buffers.dll + System.Collections.Concurrent.dll + System.Collections.dll + System.Collections.Immutable.dll + System.Collections.NonGeneric.dll + System.Collections.Specialized.dll + System.ComponentModel.Annotations.dll + System.ComponentModel.DataAnnotations.dll + System.ComponentModel.dll + System.ComponentModel.EventBasedAsync.dll + System.ComponentModel.Primitives.dll + System.ComponentModel.TypeConverter.dll + System.Configuration.dll + System.Console.dll + System.Core.dll + System.Data.Common.dll + System.Data.DataSetExtensions.dll + System.Data.dll + System.Diagnostics.Contracts.dll + System.Diagnostics.Debug.dll + System.Diagnostics.DiagnosticSource.dll + System.Diagnostics.FileVersionInfo.dll + System.Diagnostics.Process.dll + System.Diagnostics.StackTrace.dll + System.Diagnostics.TextWriterTraceListener.dll + System.Diagnostics.Tools.dll + System.Diagnostics.TraceSource.dll + System.Diagnostics.Tracing.dll + System.dll + System.Drawing.dll + System.Drawing.Primitives.dll + System.Dynamic.Runtime.dll + System.Formats.Asn1.dll + System.Formats.Tar.dll + System.Globalization.Calendars.dll + System.Globalization.dll + System.Globalization.Extensions.dll + System.IO.Compression.Brotli.dll + System.IO.Compression.dll + System.IO.Compression.FileSystem.dll + System.IO.Compression.ZipFile.dll + System.IO.dll + System.IO.FileSystem.AccessControl.dll + System.IO.FileSystem.dll + System.IO.FileSystem.DriveInfo.dll + System.IO.FileSystem.Primitives.dll + System.IO.FileSystem.Watcher.dll + System.IO.IsolatedStorage.dll + System.IO.MemoryMappedFiles.dll + System.IO.Pipelines.dll + System.IO.Pipes.AccessControl.dll + System.IO.Pipes.dll + System.IO.UnmanagedMemoryStream.dll + System.Linq.AsyncEnumerable.dll + System.Linq.dll + System.Linq.Expressions.dll + System.Linq.Parallel.dll + System.Linq.Queryable.dll + System.Memory.dll + System.Net.dll + System.Net.Http.dll + System.Net.Http.Json.dll + System.Net.HttpListener.dll + System.Net.Mail.dll + System.Net.NameResolution.dll + System.Net.NetworkInformation.dll + System.Net.Ping.dll + System.Net.Primitives.dll + System.Net.Quic.dll + System.Net.Requests.dll + System.Net.Security.dll + System.Net.ServerSentEvents.dll + System.Net.ServicePoint.dll + System.Net.Sockets.dll + System.Net.WebClient.dll + System.Net.WebHeaderCollection.dll + System.Net.WebProxy.dll + System.Net.WebSockets.Client.dll + System.Net.WebSockets.dll + System.Numerics.dll + System.Numerics.Vectors.dll + System.ObjectModel.dll + System.Reflection.DispatchProxy.dll + System.Reflection.dll + System.Reflection.Emit.dll + System.Reflection.Emit.ILGeneration.dll + System.Reflection.Emit.Lightweight.dll + System.Reflection.Extensions.dll + System.Reflection.Metadata.dll + System.Reflection.Primitives.dll + System.Reflection.TypeExtensions.dll + System.Resources.Reader.dll + System.Resources.ResourceManager.dll + System.Resources.Writer.dll + System.Runtime.CompilerServices.Unsafe.dll + System.Runtime.CompilerServices.VisualC.dll + System.Runtime.dll + System.Runtime.Extensions.dll + System.Runtime.Handles.dll + System.Runtime.InteropServices.dll + System.Runtime.InteropServices.JavaScript.dll + System.Runtime.InteropServices.RuntimeInformation.dll + System.Runtime.Intrinsics.dll + System.Runtime.Loader.dll + System.Runtime.Numerics.dll + System.Runtime.Serialization.dll + System.Runtime.Serialization.Formatters.dll + System.Runtime.Serialization.Json.dll + System.Runtime.Serialization.Primitives.dll + System.Runtime.Serialization.Xml.dll + System.Security.AccessControl.dll + System.Security.Claims.dll + System.Security.Cryptography.Algorithms.dll + System.Security.Cryptography.Cng.dll + System.Security.Cryptography.Csp.dll + System.Security.Cryptography.dll + System.Security.Cryptography.Encoding.dll + System.Security.Cryptography.OpenSsl.dll + System.Security.Cryptography.Primitives.dll + System.Security.Cryptography.X509Certificates.dll + System.Security.dll + System.Security.Principal.dll + System.Security.Principal.Windows.dll + System.Security.SecureString.dll + System.ServiceModel.Web.dll + System.ServiceProcess.dll + System.Text.Encoding.CodePages.dll + System.Text.Encoding.dll + System.Text.Encoding.Extensions.dll + System.Text.Encodings.Web.dll + System.Text.Json.dll + System.Text.RegularExpressions.dll + System.Threading.AccessControl.dll + System.Threading.Channels.dll + System.Threading.dll + System.Threading.Overlapped.dll + System.Threading.Tasks.Dataflow.dll + System.Threading.Tasks.dll + System.Threading.Tasks.Extensions.dll + System.Threading.Tasks.Parallel.dll + System.Threading.Thread.dll + System.Threading.ThreadPool.dll + System.Threading.Timer.dll + System.Transactions.dll + System.Transactions.Local.dll + System.ValueTuple.dll + System.Web.dll + System.Web.HttpUtility.dll + System.Windows.dll + System.Xml.dll + System.Xml.Linq.dll + System.Xml.ReaderWriter.dll + System.Xml.Serialization.dll + System.Xml.XDocument.dll + System.Xml.XmlDocument.dll + System.Xml.XmlSerializer.dll + System.Xml.XPath.dll + System.Xml.XPath.XDocument.dll + WindowsBase.dll +D:/NuGet/Packages/ + microsoft.data.sqlite.core/10.0.5/lib/net8.0/Microsoft.Data.Sqlite.dll + microsoft.extensions.configuration.abstractions/10.0.5/lib/net10.0/Microsoft.Extensions.Configuration.Abstractions.dll + microsoft.extensions.configuration.binder/10.0.5/lib/net10.0/Microsoft.Extensions.Configuration.Binder.dll + microsoft.extensions.configuration/10.0.5/lib/net10.0/Microsoft.Extensions.Configuration.dll + microsoft.extensions.dependencyinjection.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll + microsoft.extensions.dependencyinjection/10.0.5/lib/net10.0/Microsoft.Extensions.DependencyInjection.dll + microsoft.extensions.diagnostics.abstractions/10.0.5/lib/net10.0/Microsoft.Extensions.Diagnostics.Abstractions.dll + microsoft.extensions.diagnostics/10.0.5/lib/net10.0/Microsoft.Extensions.Diagnostics.dll + microsoft.extensions.http/10.0.5/lib/net10.0/Microsoft.Extensions.Http.dll + microsoft.extensions.logging.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.Logging.Abstractions.dll + microsoft.extensions.logging/10.0.5/lib/net10.0/Microsoft.Extensions.Logging.dll + microsoft.extensions.options.configurationextensions/10.0.5/lib/net10.0/Microsoft.Extensions.Options.ConfigurationExtensions.dll + microsoft.extensions.options/10.0.5/lib/net10.0/Microsoft.Extensions.Options.dll + microsoft.extensions.primitives/10.0.5/lib/net10.0/Microsoft.Extensions.Primitives.dll + sqlitepclraw.bundle_e_sqlite3/2.1.11/lib/netstandard2.0/SQLitePCLRaw.batteries_v2.dll + sqlitepclraw.core/2.1.11/lib/netstandard2.0/SQLitePCLRaw.core.dll + sqlitepclraw.provider.e_sqlite3/2.1.11/lib/net6.0/SQLitePCLRaw.provider.e_sqlite3.dll + +[analyzerReferences] +/packs/Microsoft.NETCore.App.Ref/10.0.5/analyzers/dotnet/cs/ + Microsoft.Interop.ComInterfaceGenerator.dll + Microsoft.Interop.JavaScript.JSImportGenerator.dll + Microsoft.Interop.LibraryImportGenerator.dll + Microsoft.Interop.SourceGeneration.dll + System.Text.Json.SourceGeneration.dll + System.Text.RegularExpressions.Generator.dll +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/ + Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll + Microsoft.CodeAnalysis.NetAnalyzers.dll +D:/NuGet/Packages/ + microsoft.extensions.logging.abstractions/10.0.6/analyzers/dotnet/roslyn4.4/cs/Microsoft.Extensions.Logging.Generators.dll + microsoft.extensions.options/10.0.5/analyzers/dotnet/roslyn4.4/cs/Microsoft.Extensions.Options.SourceGeneration.dll + +[analyzerConfigFiles] +../../.editorconfig +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_10_default.globalconfig +obj/Debug/net10.0/MarketAssistant.DataProviders.GeneratedMSBuildEditorConfig.editorconfig diff --git a/src/MarketAssistant.DataProviders/StringToDecimalConverter.cs b/src/MarketAssistant.DataProviders/StringToDecimalConverter.cs new file mode 100644 index 0000000..dc9a58d --- /dev/null +++ b/src/MarketAssistant.DataProviders/StringToDecimalConverter.cs @@ -0,0 +1,76 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MarketAssistant.Services.Data; + +/// +/// 将字符串格式的数字转换为 decimal(币安等加密货币 API 返回的价格字段通常是字符串)。 +/// 使用 JsonConverterFactory 同时支持 decimal 和 decimal? 两种字段类型。 +/// +public sealed class StringToDecimalConverter : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) + => typeToConvert == typeof(decimal) || typeToConvert == typeof(decimal?); + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + => typeToConvert == typeof(decimal?) + ? new NullableDecimalConverter() + : new NonNullableDecimalConverter(); + + private sealed class NonNullableDecimalConverter : JsonConverter + { + public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + var stringValue = reader.GetString(); + if (decimal.TryParse(stringValue, out var value)) + return value; + } + else if (reader.TokenType == JsonTokenType.Number) + { + return reader.GetDecimal(); + } + + return 0m; + } + + public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } + } + + private sealed class NullableDecimalConverter : JsonConverter + { + public override decimal? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return null; + + if (reader.TokenType == JsonTokenType.String) + { + var stringValue = reader.GetString(); + if (string.IsNullOrEmpty(stringValue)) + return null; + if (decimal.TryParse(stringValue, NumberStyles.Number, CultureInfo.InvariantCulture, out var value)) + return value; + return null; + } + + if (reader.TokenType == JsonTokenType.Number) + return reader.GetDecimal(); + + return null; + } + + public override void Write(Utf8JsonWriter writer, decimal? value, JsonSerializerOptions options) + { + if (value.HasValue) + writer.WriteStringValue(value.Value.ToString()); + else + writer.WriteNullValue(); + } + } +} diff --git a/src/MarketAssistant.Rag/AGENTS.md b/src/MarketAssistant.Rag/AGENTS.md new file mode 100644 index 0000000..39b166d --- /dev/null +++ b/src/MarketAssistant.Rag/AGENTS.md @@ -0,0 +1,90 @@ +# MarketAssistant.Rag — AGENTS.md + +独立的 RAG 基础能力项目,负责文档解析、文本清洗、分块、嵌入、向量检索、查询改写与重排;**不承载 UI、Agent 工作流、业务编排或市场服务**。 + +--- + +## 目录结构 + +``` +MarketAssistant.Rag/ +├── Extensions/ +│ └── ServiceCollectionExtensions.cs ← AddRagServices() 注册入口 +├── Infrastructure/ +│ └── Factories/ +│ ├── IEmbeddingFactory.cs ← 文本嵌入生成器工厂契约 +│ └── IWebTextSearchFactory.cs ← Web 搜索工厂契约 +├── Interfaces/ +│ ├── IRagIngestionService.cs ← 文档摄取接口 +│ ├── IRetrievalOrchestrator.cs ← 检索编排接口 +│ ├── IRerankerService.cs ← 重排接口 +│ ├── IQueryRewriteService.cs ← 查询改写接口 +│ ├── ITextCleaningService.cs ← 文本清洗接口 +│ ├── ITextChunkingService.cs ← 文本分块接口 +│ ├── IDocumentBlockReader.cs ← 文档块读取接口 +│ ├── IMarkdownConverter.cs ← Markdown 转换接口 +│ ├── IImageEmbeddingService.cs ← 图像嵌入接口 +│ └── IImageStorageService.cs ← 图像存储接口 +├── Services/ +│ ├── RagIngestionService.cs ← 文档清洗/分块/向量化/写入 +│ ├── RetrievalOrchestrator.cs ← 查询改写 + 检索 + 去重 + 重排 +│ ├── RerankerService.cs ← 启发式重排 +│ ├── QueryRewriteService.cs ← 查询重写 +│ ├── TextCleaningService.cs ← 文本清洗 +│ ├── TextChunkingService.cs ← 文本分块 +│ ├── DocxMarkdownConverter.cs ← DOCX → Markdown +│ ├── PdfMarkdownConverter.cs ← PDF → Markdown +│ ├── DocxBlockReader.cs ← DOCX 文档块读取 +│ ├── PdfBlockReader.cs ← PDF 文档块读取 +│ ├── MarkdownDocumentBlockReader.cs ← Markdown 文档块读取 +│ ├── DocumentBlockReaderFactory.cs ← 文档读取器工厂 +│ ├── MarkdownConverterFactory.cs ← 转换器工厂 +│ ├── ClipImageEmbeddingService.cs ← CLIP 图像嵌入与 caption 降级策略 +│ └── LocalImageStorageService.cs ← 本地图像存储 +├── TextParagraph.cs ← 向量检索的段落实体 +└── GlobalUsing.cs +``` + +--- + +## 边界约定 + +- 本项目只放 **RAG 基础能力**:文档解析、清洗、分块、嵌入、存储映射、检索、重排。 +- Agent、Workflow、Tool 实现放在 `MarketAssistant.App.Services` 或 `MarketAssistant.Agents`,不要回流到这里。 +- Avalonia 视图、导航、通知、页面状态属于 `MarketAssistant.App`。 +- 市场业务服务、首页/收藏/分析等应用服务属于 `MarketAssistant.App.Services`。 +- 仅保留与 RAG 能力直接相关的接口和服务;不要在这里堆放通用业务逻辑。 + +--- + +## 编码约定 + +- 优先复用现有接口:新增摄取、检索或转换能力时,先检查 `Interfaces/` 和现有工厂是否已覆盖扩展点。 +- 文档解析流程优先走统一抽象:`IMarkdownConverter`、`IDocumentBlockReader`、对应工厂;不要在上层服务里按文件类型手写分支。 +- 检索流程保持分层:查询改写、召回、去重、重排分别在各自服务中完成,避免在单个服务中混合实现。 +- 向量或模型调用失败时优先保留降级路径,避免因单个模型或外部依赖故障导致整条链路不可用。 +- 只有与复杂算法、模型 I/O 或降级策略直接相关的代码才添加简短注释。 + +--- + +## DI 注册 + +- 注册入口:`Extensions/ServiceCollectionExtensions.cs` 中的 `AddRagServices()`。 +- 若新增 RAG 服务,优先在该扩展方法统一注册,不要把注册逻辑散落到 UI 或业务层。 +- 本项目依赖的 `VectorStore`、`IEmbeddingFactory` 等宿主级依赖,应由上层项目提供。 + +--- + +## 模型与文件处理 + +- `ClipImageEmbeddingService` 支持 CLIP ONNX 模型,默认从 `models/clip-image.onnx` 加载,也可通过环境变量 `CLIP_IMAGE_ONNX` 指定路径。 +- PDF/DOCX 解析依赖 `PdfPig`、`DocumentFormat.OpenXml`;修改解析逻辑时优先保证结构化输出稳定,不要为单个样例过拟合。 +- 重排服务当前为启发式实现,若引入模型化重排,需要明确成本、延迟与降级策略。 + +--- + +## 构建 + +```bash +dotnet build src/MarketAssistant.Rag/MarketAssistant.Rag.csproj -c Debug +``` \ No newline at end of file diff --git a/src/Rag/Extensions/ServiceCollectionExtensions.cs b/src/MarketAssistant.Rag/Extensions/ServiceCollectionExtensions.cs similarity index 94% rename from src/Rag/Extensions/ServiceCollectionExtensions.cs rename to src/MarketAssistant.Rag/Extensions/ServiceCollectionExtensions.cs index 3eb298b..6da309d 100644 --- a/src/Rag/Extensions/ServiceCollectionExtensions.cs +++ b/src/MarketAssistant.Rag/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,3 @@ -using MarketAssistant.Infrastructure.Factories; using MarketAssistant.Rag.Interfaces; using MarketAssistant.Rag.Services; @@ -47,7 +46,6 @@ public static IServiceCollection AddRagServices(this IServiceCollection services services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); diff --git a/src/MarketAssistant.Rag/GlobalUsing.cs b/src/MarketAssistant.Rag/GlobalUsing.cs new file mode 100644 index 0000000..085323d --- /dev/null +++ b/src/MarketAssistant.Rag/GlobalUsing.cs @@ -0,0 +1,8 @@ +global using Microsoft.Extensions.DependencyInjection; +global using System; +global using System.Collections.Generic; +global using System.IO; +global using System.Linq; +global using System.Text; +global using System.Threading; +global using System.Threading.Tasks; diff --git a/src/MarketAssistant.Rag/Infrastructure/Factories/IEmbeddingFactory.cs b/src/MarketAssistant.Rag/Infrastructure/Factories/IEmbeddingFactory.cs new file mode 100644 index 0000000..d19f045 --- /dev/null +++ b/src/MarketAssistant.Rag/Infrastructure/Factories/IEmbeddingFactory.cs @@ -0,0 +1,8 @@ +using Microsoft.Extensions.AI; + +namespace MarketAssistant.Infrastructure.Factories; + +public interface IEmbeddingFactory +{ + IEmbeddingGenerator> Create(); +} diff --git a/src/MarketAssistant.Rag/Infrastructure/Factories/IWebTextSearchFactory.cs b/src/MarketAssistant.Rag/Infrastructure/Factories/IWebTextSearchFactory.cs new file mode 100644 index 0000000..e4d992c --- /dev/null +++ b/src/MarketAssistant.Rag/Infrastructure/Factories/IWebTextSearchFactory.cs @@ -0,0 +1,8 @@ +using Microsoft.SemanticKernel.Data; + +namespace MarketAssistant.Infrastructure.Factories; + +public interface IWebTextSearchFactory +{ + ITextSearch? Create(); +} diff --git a/src/Rag/Interfaces/IDocumentBlockReader.cs b/src/MarketAssistant.Rag/Interfaces/IDocumentBlockReader.cs similarity index 100% rename from src/Rag/Interfaces/IDocumentBlockReader.cs rename to src/MarketAssistant.Rag/Interfaces/IDocumentBlockReader.cs diff --git a/src/Rag/Interfaces/IImageEmbeddingService.cs b/src/MarketAssistant.Rag/Interfaces/IImageEmbeddingService.cs similarity index 100% rename from src/Rag/Interfaces/IImageEmbeddingService.cs rename to src/MarketAssistant.Rag/Interfaces/IImageEmbeddingService.cs diff --git a/src/Rag/Interfaces/IImageStorageService.cs b/src/MarketAssistant.Rag/Interfaces/IImageStorageService.cs similarity index 100% rename from src/Rag/Interfaces/IImageStorageService.cs rename to src/MarketAssistant.Rag/Interfaces/IImageStorageService.cs diff --git a/src/Rag/Interfaces/IMarkdownConverter.cs b/src/MarketAssistant.Rag/Interfaces/IMarkdownConverter.cs similarity index 100% rename from src/Rag/Interfaces/IMarkdownConverter.cs rename to src/MarketAssistant.Rag/Interfaces/IMarkdownConverter.cs diff --git a/src/Rag/Interfaces/IQueryRewriteService.cs b/src/MarketAssistant.Rag/Interfaces/IQueryRewriteService.cs similarity index 100% rename from src/Rag/Interfaces/IQueryRewriteService.cs rename to src/MarketAssistant.Rag/Interfaces/IQueryRewriteService.cs diff --git a/src/Rag/Interfaces/IRagIngestionService.cs b/src/MarketAssistant.Rag/Interfaces/IRagIngestionService.cs similarity index 100% rename from src/Rag/Interfaces/IRagIngestionService.cs rename to src/MarketAssistant.Rag/Interfaces/IRagIngestionService.cs diff --git a/src/MarketAssistant.Rag/Interfaces/IRerankerService.cs b/src/MarketAssistant.Rag/Interfaces/IRerankerService.cs new file mode 100644 index 0000000..70767e8 --- /dev/null +++ b/src/MarketAssistant.Rag/Interfaces/IRerankerService.cs @@ -0,0 +1,22 @@ +using Microsoft.SemanticKernel.Data; + +namespace MarketAssistant.Rag.Interfaces; + +/// +/// 携带向量相似度分数的检索结果,用于重排阶段融合向量分数与启发式评分。 +/// +public record ScoredSearchResult(TextSearchResult Item, float VectorScore); + +/// +/// 检索结果重排序的重排接口 +/// +public interface IRerankerService +{ + /// + /// 对携带向量相似度分数的检索结果进行重排。 + /// + /// 原始查询文本。 + /// 携带向量分数的检索结果集。 + /// 按综合相关性重排后的检索结果。 + IReadOnlyList Rerank(string query, IEnumerable items); +} diff --git a/src/Rag/Interfaces/IRetrievalOrchestrator.cs b/src/MarketAssistant.Rag/Interfaces/IRetrievalOrchestrator.cs similarity index 100% rename from src/Rag/Interfaces/IRetrievalOrchestrator.cs rename to src/MarketAssistant.Rag/Interfaces/IRetrievalOrchestrator.cs diff --git a/src/Rag/Interfaces/ITextChunkingService.cs b/src/MarketAssistant.Rag/Interfaces/ITextChunkingService.cs similarity index 100% rename from src/Rag/Interfaces/ITextChunkingService.cs rename to src/MarketAssistant.Rag/Interfaces/ITextChunkingService.cs diff --git a/src/Rag/Interfaces/ITextCleaningService.cs b/src/MarketAssistant.Rag/Interfaces/ITextCleaningService.cs similarity index 100% rename from src/Rag/Interfaces/ITextCleaningService.cs rename to src/MarketAssistant.Rag/Interfaces/ITextCleaningService.cs diff --git a/src/MarketAssistant.Rag/MarketAssistant.Rag.csproj b/src/MarketAssistant.Rag/MarketAssistant.Rag.csproj new file mode 100644 index 0000000..1b12290 --- /dev/null +++ b/src/MarketAssistant.Rag/MarketAssistant.Rag.csproj @@ -0,0 +1,25 @@ + + + net10.0 + enable + MarketAssistant + enable + $(NoWarn);MAAI001 + + + + + + + + + + + + + + + + + + diff --git a/src/MarketAssistant.Rag/MarketAssistant.Rag.csproj.lscache b/src/MarketAssistant.Rag/MarketAssistant.Rag.csproj.lscache new file mode 100644 index 0000000..e7b3f58 --- /dev/null +++ b/src/MarketAssistant.Rag/MarketAssistant.Rag.csproj.lscache @@ -0,0 +1,333 @@ +version=1 + +# This file caches language service data to improve the performance of C# Dev Kit. +# It is not intended for manual editing. It can safely be deleted and will be +# regenerated automatically. For more information, see https://aka.ms/lscache +# +# To control where cache files are stored, use the following VS Code setting: +# "dotnet.projectsystem.cacheInProjectFolder": true + +[project] +language=C# +primary +lastDtbSucceeded + +[properties] +AssemblyName=MarketAssistant.Rag +CommandLineArgsForDesignTimeEvaluation=-langversion:14.0 -define:TRACE +CompilerGeneratedFilesOutputPath= +MaxSupportedLangVersion=14.0 +ProjectAssetsFile=obj/project.assets.json +RootNamespace=MarketAssistant +RunAnalyzers= +RunAnalyzersDuringLiveAnalysis= +SolutionPath=../../MarketAssistant.slnx +TargetFrameworkIdentifier=.NETCoreApp +TargetPath=bin/Debug/net10.0/MarketAssistant.Rag.dll +TargetRefPath=obj/Debug/net10.0/ref/MarketAssistant.Rag.dll +TemporaryDependencyNodeTargetIdentifier=net10.0 + +[commandLineArguments] +/noconfig +/unsafe- +/checked- +/nowarn:1701,1702,MAAI001,1701,1702,8002 +/fullpaths +/nostdlib+ +/errorreport:prompt +/warn:10 +/define:TRACE;DEBUG;NET;NET10_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NET10_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER +/highentropyva+ +/nullable:enable +/features:"InterceptorsNamespaces=;Microsoft.Extensions.Validation.Generated" +/debug+ +/debug:portable +/filealign:512 +/optimize- +/out:obj\Debug\net10.0\MarketAssistant.Rag.dll +/refout:obj\Debug\net10.0\refint\MarketAssistant.Rag.dll +/target:library +/warnaserror- +/utf8output +/deterministic+ +/langversion:14.0 +/warnaserror+:NU1605,SYSLIB0011 + +[sourceFiles] +Extensions/ServiceCollectionExtensions.cs +GlobalUsing.cs +Infrastructure/Factories/ + IEmbeddingFactory.cs + IWebTextSearchFactory.cs +Interfaces/ + IDocumentBlockReader.cs + IImageEmbeddingService.cs + IImageStorageService.cs + IMarkdownConverter.cs + IQueryRewriteService.cs + IRagIngestionService.cs + IRerankerService.cs + IRetrievalOrchestrator.cs + ITextChunkingService.cs + ITextCleaningService.cs +obj/Debug/net10.0/ + .NETCoreApp,Version=v10.0.AssemblyAttributes.cs + MarketAssistant.Rag.AssemblyInfo.cs + MarketAssistant.Rag.GlobalUsings.g.cs +RagConstants.cs +Services/ + ClipImageEmbeddingService.cs + DocumentBlockMapper.cs + DocumentBlockReaderFactory.cs + DocxBlockReader.cs + DocxMarkdownConverter.cs + LocalImageStorageService.cs + MarkdownConverterFactory.cs + MarkdownDocumentBlockReader.cs + PdfBlockReader.cs + PdfMarkdownConverter.cs + QueryRewriteService.cs + RagIngestionService.cs + RerankerService.cs + RetrievalOrchestrator.cs + TextChunkingService.cs + TextCleaningService.cs +TextParagraph.cs + +[metadataReferences] +../MarketAssistant.Core/obj/Debug/net10.0/ref/MarketAssistant.Core.dll +/packs/Microsoft.NETCore.App.Ref/10.0.5/ref/net10.0/ + Microsoft.CSharp.dll + Microsoft.VisualBasic.Core.dll + Microsoft.VisualBasic.dll + Microsoft.Win32.Primitives.dll + Microsoft.Win32.Registry.dll + mscorlib.dll + netstandard.dll + System.AppContext.dll + System.Buffers.dll + System.Collections.Concurrent.dll + System.Collections.dll + System.Collections.Immutable.dll + System.Collections.NonGeneric.dll + System.Collections.Specialized.dll + System.ComponentModel.Annotations.dll + System.ComponentModel.DataAnnotations.dll + System.ComponentModel.dll + System.ComponentModel.EventBasedAsync.dll + System.ComponentModel.Primitives.dll + System.ComponentModel.TypeConverter.dll + System.Configuration.dll + System.Console.dll + System.Core.dll + System.Data.Common.dll + System.Data.DataSetExtensions.dll + System.Data.dll + System.Diagnostics.Contracts.dll + System.Diagnostics.Debug.dll + System.Diagnostics.DiagnosticSource.dll + System.Diagnostics.FileVersionInfo.dll + System.Diagnostics.Process.dll + System.Diagnostics.StackTrace.dll + System.Diagnostics.TextWriterTraceListener.dll + System.Diagnostics.Tools.dll + System.Diagnostics.TraceSource.dll + System.Diagnostics.Tracing.dll + System.dll + System.Drawing.dll + System.Drawing.Primitives.dll + System.Dynamic.Runtime.dll + System.Formats.Asn1.dll + System.Formats.Tar.dll + System.Globalization.Calendars.dll + System.Globalization.dll + System.Globalization.Extensions.dll + System.IO.Compression.Brotli.dll + System.IO.Compression.dll + System.IO.Compression.FileSystem.dll + System.IO.Compression.ZipFile.dll + System.IO.dll + System.IO.FileSystem.AccessControl.dll + System.IO.FileSystem.dll + System.IO.FileSystem.DriveInfo.dll + System.IO.FileSystem.Primitives.dll + System.IO.FileSystem.Watcher.dll + System.IO.IsolatedStorage.dll + System.IO.MemoryMappedFiles.dll + System.IO.Pipelines.dll + System.IO.Pipes.AccessControl.dll + System.IO.Pipes.dll + System.IO.UnmanagedMemoryStream.dll + System.Linq.AsyncEnumerable.dll + System.Linq.dll + System.Linq.Expressions.dll + System.Linq.Parallel.dll + System.Linq.Queryable.dll + System.Memory.dll + System.Net.dll + System.Net.Http.dll + System.Net.Http.Json.dll + System.Net.HttpListener.dll + System.Net.Mail.dll + System.Net.NameResolution.dll + System.Net.NetworkInformation.dll + System.Net.Ping.dll + System.Net.Primitives.dll + System.Net.Quic.dll + System.Net.Requests.dll + System.Net.Security.dll + System.Net.ServerSentEvents.dll + System.Net.ServicePoint.dll + System.Net.Sockets.dll + System.Net.WebClient.dll + System.Net.WebHeaderCollection.dll + System.Net.WebProxy.dll + System.Net.WebSockets.Client.dll + System.Net.WebSockets.dll + System.Numerics.dll + System.Numerics.Vectors.dll + System.ObjectModel.dll + System.Reflection.DispatchProxy.dll + System.Reflection.dll + System.Reflection.Emit.dll + System.Reflection.Emit.ILGeneration.dll + System.Reflection.Emit.Lightweight.dll + System.Reflection.Extensions.dll + System.Reflection.Metadata.dll + System.Reflection.Primitives.dll + System.Reflection.TypeExtensions.dll + System.Resources.Reader.dll + System.Resources.ResourceManager.dll + System.Resources.Writer.dll + System.Runtime.CompilerServices.Unsafe.dll + System.Runtime.CompilerServices.VisualC.dll + System.Runtime.dll + System.Runtime.Extensions.dll + System.Runtime.Handles.dll + System.Runtime.InteropServices.dll + System.Runtime.InteropServices.JavaScript.dll + System.Runtime.InteropServices.RuntimeInformation.dll + System.Runtime.Intrinsics.dll + System.Runtime.Loader.dll + System.Runtime.Numerics.dll + System.Runtime.Serialization.dll + System.Runtime.Serialization.Formatters.dll + System.Runtime.Serialization.Json.dll + System.Runtime.Serialization.Primitives.dll + System.Runtime.Serialization.Xml.dll + System.Security.AccessControl.dll + System.Security.Claims.dll + System.Security.Cryptography.Algorithms.dll + System.Security.Cryptography.Cng.dll + System.Security.Cryptography.Csp.dll + System.Security.Cryptography.dll + System.Security.Cryptography.Encoding.dll + System.Security.Cryptography.OpenSsl.dll + System.Security.Cryptography.Primitives.dll + System.Security.Cryptography.X509Certificates.dll + System.Security.dll + System.Security.Principal.dll + System.Security.Principal.Windows.dll + System.Security.SecureString.dll + System.ServiceModel.Web.dll + System.ServiceProcess.dll + System.Text.Encoding.CodePages.dll + System.Text.Encoding.dll + System.Text.Encoding.Extensions.dll + System.Text.Encodings.Web.dll + System.Text.Json.dll + System.Text.RegularExpressions.dll + System.Threading.AccessControl.dll + System.Threading.Channels.dll + System.Threading.dll + System.Threading.Overlapped.dll + System.Threading.Tasks.Dataflow.dll + System.Threading.Tasks.dll + System.Threading.Tasks.Extensions.dll + System.Threading.Tasks.Parallel.dll + System.Threading.Thread.dll + System.Threading.ThreadPool.dll + System.Threading.Timer.dll + System.Transactions.dll + System.Transactions.Local.dll + System.ValueTuple.dll + System.Web.dll + System.Web.HttpUtility.dll + System.Windows.dll + System.Xml.dll + System.Xml.Linq.dll + System.Xml.ReaderWriter.dll + System.Xml.Serialization.dll + System.Xml.XDocument.dll + System.Xml.XmlDocument.dll + System.Xml.XmlSerializer.dll + System.Xml.XPath.dll + System.Xml.XPath.XDocument.dll + WindowsBase.dll +D:/NuGet/Packages/ + documentformat.openxml.framework/3.5.1/lib/net10.0/DocumentFormat.OpenXml.Framework.dll + documentformat.openxml/3.5.1/lib/net10.0/DocumentFormat.OpenXml.dll + google.apis.core/1.68.0/lib/net6.0/Google.Apis.Core.dll + google.apis.customsearchapi.v1/1.68.0.3520/lib/net6.0/Google.Apis.CustomSearchAPI.v1.dll + google.apis/1.68.0/lib/net6.0/Google.Apis.dll + google.protobuf/3.30.2/lib/net5.0/Google.Protobuf.dll + markdig/1.1.1/lib/net10.0/Markdig.dll + microsoft.bcl.hashcode/1.1.1/ref/netcoreapp2.1/Microsoft.Bcl.HashCode.dll + microsoft.data.sqlite.core/10.0.5/lib/net8.0/Microsoft.Data.Sqlite.dll + microsoft.extensions.ai.abstractions/10.5.1/lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + microsoft.extensions.ai.openai/10.5.1/lib/net10.0/Microsoft.Extensions.AI.OpenAI.dll + microsoft.extensions.ai/10.3.0/lib/net10.0/Microsoft.Extensions.AI.dll + microsoft.extensions.caching.abstractions/10.0.3/lib/net10.0/Microsoft.Extensions.Caching.Abstractions.dll + microsoft.extensions.configuration.abstractions/10.0.3/lib/net10.0/Microsoft.Extensions.Configuration.Abstractions.dll + microsoft.extensions.dependencyinjection.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll + microsoft.extensions.diagnostics.abstractions/10.0.3/lib/net10.0/Microsoft.Extensions.Diagnostics.Abstractions.dll + microsoft.extensions.fileproviders.abstractions/10.0.3/lib/net10.0/Microsoft.Extensions.FileProviders.Abstractions.dll + microsoft.extensions.hosting.abstractions/10.0.3/lib/net10.0/Microsoft.Extensions.Hosting.Abstractions.dll + microsoft.extensions.logging.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.Logging.Abstractions.dll + microsoft.extensions.options/10.0.3/lib/net10.0/Microsoft.Extensions.Options.dll + microsoft.extensions.primitives/10.0.3/lib/net10.0/Microsoft.Extensions.Primitives.dll + microsoft.extensions.vectordata.abstractions/10.0.1/lib/net10.0/Microsoft.Extensions.VectorData.Abstractions.dll + microsoft.ml.onnxruntime.managed/1.24.3/lib/net8.0/Microsoft.ML.OnnxRuntime.dll + microsoft.ml.tokenizers/2.0.0/lib/net8.0/Microsoft.ML.Tokenizers.dll + microsoft.semantickernel.abstractions/1.73.0/lib/net10.0/Microsoft.SemanticKernel.Abstractions.dll + microsoft.semantickernel.connectors.sqlitevec/1.73.0-preview/lib/net10.0/Microsoft.SemanticKernel.Connectors.SqliteVec.dll + microsoft.semantickernel.plugins.web/1.73.0-alpha/lib/net10.0/Microsoft.SemanticKernel.Plugins.Web.dll + newtonsoft.json/13.0.3/lib/net6.0/Newtonsoft.Json.dll + openai/2.10.0/lib/net10.0/OpenAI.dll + pdfpig/0.1.13/lib/net8.0/ + UglyToad.PdfPig.Core.dll + UglyToad.PdfPig.dll + UglyToad.PdfPig.DocumentLayoutAnalysis.dll + UglyToad.PdfPig.Fonts.dll + UglyToad.PdfPig.Package.dll + UglyToad.PdfPig.Tokenization.dll + UglyToad.PdfPig.Tokens.dll + skiasharp/3.119.2/ref/net8.0/SkiaSharp.dll + sqlitepclraw.bundle_e_sqlite3/2.1.11/lib/netstandard2.0/SQLitePCLRaw.batteries_v2.dll + sqlitepclraw.core/2.1.11/lib/netstandard2.0/SQLitePCLRaw.core.dll + sqlitepclraw.provider.e_sqlite3/2.1.11/lib/net6.0/SQLitePCLRaw.provider.e_sqlite3.dll + system.clientmodel/1.10.0/lib/net10.0/System.ClientModel.dll + system.io.packaging/10.0.2/lib/net10.0/System.IO.Packaging.dll + system.memory.data/10.0.3/lib/net10.0/System.Memory.Data.dll + system.numerics.tensors/10.0.3/lib/net10.0/System.Numerics.Tensors.dll + +[analyzerReferences] +/packs/Microsoft.NETCore.App.Ref/10.0.5/analyzers/dotnet/cs/ + Microsoft.Interop.ComInterfaceGenerator.dll + Microsoft.Interop.JavaScript.JSImportGenerator.dll + Microsoft.Interop.LibraryImportGenerator.dll + Microsoft.Interop.SourceGeneration.dll + System.Text.Json.SourceGeneration.dll + System.Text.RegularExpressions.Generator.dll +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/ + Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll + Microsoft.CodeAnalysis.NetAnalyzers.dll +D:/NuGet/Packages/ + microsoft.extensions.logging.abstractions/10.0.6/analyzers/dotnet/roslyn4.4/cs/Microsoft.Extensions.Logging.Generators.dll + microsoft.extensions.options/10.0.3/analyzers/dotnet/roslyn4.4/cs/Microsoft.Extensions.Options.SourceGeneration.dll + system.clientmodel/1.10.0/analyzers/dotnet/cs/System.ClientModel.SourceGeneration.dll + +[analyzerConfigFiles] +../../.editorconfig +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_10_default.globalconfig +obj/Debug/net10.0/MarketAssistant.Rag.GeneratedMSBuildEditorConfig.editorconfig diff --git a/src/MarketAssistant.Rag/RagConstants.cs b/src/MarketAssistant.Rag/RagConstants.cs new file mode 100644 index 0000000..d188226 --- /dev/null +++ b/src/MarketAssistant.Rag/RagConstants.cs @@ -0,0 +1,13 @@ +namespace MarketAssistant.Rag; + +/// +/// RAG 模块共享常量,避免魔法数字分散在各服务中。 +/// +public static class RagConstants +{ + /// + /// 向量嵌入维度(文本与图像统一维度,与 注解、CLIP 服务一致)。 + /// 更换嵌入模型时只需修改此值并重建向量库。 + /// + public const int EmbeddingDimension = 1024; +} diff --git a/src/Rag/Services/ClipImageEmbeddingService.cs b/src/MarketAssistant.Rag/Services/ClipImageEmbeddingService.cs similarity index 93% rename from src/Rag/Services/ClipImageEmbeddingService.cs rename to src/MarketAssistant.Rag/Services/ClipImageEmbeddingService.cs index 53fadc4..e00959f 100644 --- a/src/Rag/Services/ClipImageEmbeddingService.cs +++ b/src/MarketAssistant.Rag/Services/ClipImageEmbeddingService.cs @@ -33,8 +33,12 @@ namespace MarketAssistant.Rag.Services; /// public class ClipImageEmbeddingService : IImageEmbeddingService, IDisposable { - // 目标嵌入向量维度(文本嵌入保持一致以便比较) - private const int TargetDim = 1024; + // 目标嵌入向量维度,引用统一常量 + private const int TargetDim = RagConstants.EmbeddingDimension; + + // ImageNet 标准化参数(CLIP 模型训练时使用) + private static readonly float[] ImageNetMean = { 0.485f, 0.456f, 0.406f }; + private static readonly float[] ImageNetStd = { 0.229f, 0.224f, 0.225f }; // 【依赖注入】:服务的依赖项 private readonly ILogger _logger; // 结构化日志记录 @@ -344,10 +348,11 @@ private void LogModelInfo() } /// - /// 图像预处理:将原始字节转换为CLIP模型所需的标准张量(简化版) + /// 图像预处理:将原始字节转换为CLIP模型所需的标准张量 /// - /// 【处理流程】:图像解码 -> 缩放到224x224 -> 转换为CHW张量格式 - /// 【简化】:未移除均值和方差(ImageNet标准),此处使用更简单的归一化 + /// 【处理流程】:图像解码 -> 缩放到224x224 -> ImageNet标准化 -> CHW张量格式 + /// CLIP 模型训练时使用 ImageNet 均值和方差标准化,不做标准化会导致 + /// 图像嵌入与文本嵌入向量空间错位,跨模态检索失效。 /// private static DenseTensor PreprocessToTensor(byte[] bytes) { @@ -360,13 +365,14 @@ private static DenseTensor PreprocessToTensor(byte[] bytes) if (original == null) throw new InvalidOperationException("Failed to decode image"); using var resized = new SKBitmap(size, size); - original.ScalePixels(resized, SKFilterQuality.Medium); + original.ScalePixels(resized, new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear)); // 准备数据数组(注意batch维度) var tensorData = new float[1 * channels * size * size]; var pixels = resized.Pixels; // CHW格式:Channel-Height-Width + // 先归一化到 [0,1],再应用 ImageNet 标准化:(x - mean) / std for (int y = 0; y < size; y++) { for (int x = 0; x < size; x++) @@ -377,10 +383,10 @@ private static DenseTensor PreprocessToTensor(byte[] bytes) var b = pixel.Blue / 255f; var baseIndex = y * size + x; - // CHW格式:[batch, channel, height, width] - tensorData[0 * size * size + baseIndex] = r; // R通道 - tensorData[1 * size * size + baseIndex] = g; // G通道 - tensorData[2 * size * size + baseIndex] = b; // B通道 + // CHW格式:[batch, channel, height, width],应用 ImageNet 标准化 + tensorData[0 * size * size + baseIndex] = (r - ImageNetMean[0]) / ImageNetStd[0]; + tensorData[1 * size * size + baseIndex] = (g - ImageNetMean[1]) / ImageNetStd[1]; + tensorData[2 * size * size + baseIndex] = (b - ImageNetMean[2]) / ImageNetStd[2]; } } @@ -400,7 +406,7 @@ private static DenseTensor PreprocessToTensor(byte[] bytes) /// - L2归一化:||v|| = 1,确保余弦相似度计算准确 /// - 维度对齐:不同模型输出维度可能不同,需要统一 /// - 鲁棒性:处理零向量和维度不匹配情况 - /// - 循环填充:简单有效的维度扩展策略 + /// - 零填充/截断:避免循环填充引入周期性模式破坏余弦相似度 /// /// 【数学原理】: /// - L2范数:||v|| = sqrt(v1² + v2² + ... + vn²) @@ -425,10 +431,11 @@ private static float[] NormalizeAndResize(float[] src, int dim) // 维度匹配:若维度已正确则直接返回 if (normalized.Length == dim) return normalized; - // 维度调整:循环填充到目标维度 + // 维度调整:源向量短则零填充,长则截断 + // 零填充不会引入伪周期模式,保持向量语义完整性 var dst = new float[dim]; - for (int i = 0; i < dim; i++) - dst[i] = normalized[i % normalized.Length]; // 循环复制使用原向量元素 + var copyLen = Math.Min(normalized.Length, dim); + Array.Copy(normalized, dst, copyLen); return dst; } diff --git a/src/Rag/Services/DocumentBlockMapper.cs b/src/MarketAssistant.Rag/Services/DocumentBlockMapper.cs similarity index 94% rename from src/Rag/Services/DocumentBlockMapper.cs rename to src/MarketAssistant.Rag/Services/DocumentBlockMapper.cs index f6ab454..faaf293 100644 --- a/src/Rag/Services/DocumentBlockMapper.cs +++ b/src/MarketAssistant.Rag/Services/DocumentBlockMapper.cs @@ -34,11 +34,11 @@ public DocumentBlockMapper(ITextCleaningService cleaning, ITextChunkingService c /// 当前章节 /// 图片元数据(用于图片块) /// 转换后的段落集合和下一个序号 - public (IEnumerable Paragraphs, int NextOrder, string? UpdatedSection) + public (IEnumerable Paragraphs, int NextOrder, string? UpdatedSection) MapBlock( - DocumentBlock block, - string filePath, - int baseOrder, + DocumentBlock block, + string filePath, + int baseOrder, string? currentSection, ImageMetadata? imageMetadata = null) { @@ -64,7 +64,7 @@ public DocumentBlockMapper(ITextCleaningService cleaning, ITextChunkingService c Text = chunk.Text, TextEmbedding = chunk.TextEmbedding, ImageUri = chunk.ImageUri, - ImageEmbedding = chunk.ImageEmbedding ?? new Embedding(new float[1024]), // 确保不为null + ImageEmbedding = chunk.ImageEmbedding ?? new Embedding(new float[RagConstants.EmbeddingDimension]), // 确保不为null Order = nextOrder++, Section = currentSection, SourceType = chunk.SourceType, @@ -96,9 +96,9 @@ public DocumentBlockMapper(ITextCleaningService cleaning, ITextChunkingService c PublishedAt = null, BlockKind = 1, // Heading HeadingLevel = headingBlock.Level, - ImageEmbedding = new Embedding(new float[1024]) // 空的图像嵌入 + ImageEmbedding = new Embedding(new float[RagConstants.EmbeddingDimension]) // 空的图像嵌入 }); - + // 更新当前章节:高级别标题会重置章节上下文 if (headingBlock.Level <= 3) // H1-H3 作为主要章节分割 { @@ -126,7 +126,7 @@ public DocumentBlockMapper(ITextCleaningService cleaning, ITextChunkingService c PublishedAt = null, BlockKind = 2, // List ListType = (int)listBlock.ListType, - ImageEmbedding = new Embedding(new float[1024]) // 空的图像嵌入 + ImageEmbedding = new Embedding(new float[RagConstants.EmbeddingDimension]) // 空的图像嵌入 }); } break; @@ -145,7 +145,7 @@ public DocumentBlockMapper(ITextCleaningService cleaning, ITextChunkingService c ContentHash = tableBlock.Hash, PublishedAt = null, BlockKind = 3, // Table - ImageEmbedding = new Embedding(new float[1024]) // 空的图像嵌入 + ImageEmbedding = new Embedding(new float[RagConstants.EmbeddingDimension]) // 空的图像嵌入 }); break; @@ -164,7 +164,7 @@ public DocumentBlockMapper(ITextCleaningService cleaning, ITextChunkingService c PublishedAt = null, BlockKind = 4, // Image ImageUri = imageMetadata?.StoredPath, - ImageEmbedding = imageMetadata?.ImageEmbedding ?? new Embedding(new float[1024]) // 确保不为null + ImageEmbedding = imageMetadata?.ImageEmbedding ?? new Embedding(new float[RagConstants.EmbeddingDimension]) // 确保不为null }; paragraphs.Add(imageParagraph); break; diff --git a/src/Rag/Services/DocumentBlockReaderFactory.cs b/src/MarketAssistant.Rag/Services/DocumentBlockReaderFactory.cs similarity index 100% rename from src/Rag/Services/DocumentBlockReaderFactory.cs rename to src/MarketAssistant.Rag/Services/DocumentBlockReaderFactory.cs diff --git a/src/Rag/Services/DocxBlockReader.cs b/src/MarketAssistant.Rag/Services/DocxBlockReader.cs similarity index 100% rename from src/Rag/Services/DocxBlockReader.cs rename to src/MarketAssistant.Rag/Services/DocxBlockReader.cs diff --git a/src/Rag/Services/DocxMarkdownConverter.cs b/src/MarketAssistant.Rag/Services/DocxMarkdownConverter.cs similarity index 96% rename from src/Rag/Services/DocxMarkdownConverter.cs rename to src/MarketAssistant.Rag/Services/DocxMarkdownConverter.cs index 595dc12..a36270b 100644 --- a/src/Rag/Services/DocxMarkdownConverter.cs +++ b/src/MarketAssistant.Rag/Services/DocxMarkdownConverter.cs @@ -28,6 +28,12 @@ public class DocxMarkdownConverter : IMarkdownConverter private readonly Dictionary _listItemCounters = new(); private int _imageCounter = 0; + /// + /// 转换过程串行化锁:实例字段在并发调用时非线程安全, + /// 同一 Singleton 实例的并发转换必须串行执行。 + /// + private readonly SemaphoreSlim _convertLock = new(1, 1); + public DocxMarkdownConverter(IImageStorageService imageStorageService) { _imageStorageService = imageStorageService ?? throw new ArgumentNullException(nameof(imageStorageService)); @@ -37,6 +43,20 @@ public bool CanConvert(string filePath) => filePath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase); public async Task ConvertToMarkdownAsync(string filePath) + { + // 串行化转换:实例字段(_numberingFormats 等)在并发调用时非线程安全 + await _convertLock.WaitAsync(); + try + { + return await ConvertCoreAsync(filePath); + } + finally + { + _convertLock.Release(); + } + } + + private async Task ConvertCoreAsync(string filePath) { try { diff --git a/src/Rag/Services/LocalImageStorageService.cs b/src/MarketAssistant.Rag/Services/LocalImageStorageService.cs similarity index 96% rename from src/Rag/Services/LocalImageStorageService.cs rename to src/MarketAssistant.Rag/Services/LocalImageStorageService.cs index c96b28b..b15c91c 100644 --- a/src/Rag/Services/LocalImageStorageService.cs +++ b/src/MarketAssistant.Rag/Services/LocalImageStorageService.cs @@ -11,11 +11,11 @@ namespace MarketAssistant.Rag.Services; public sealed class LocalImageStorageService : IImageStorageService { private readonly ILogger _logger; - + // Configuration constants private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10MB private const string DefaultExtension = ".png"; - + // Pre-compiled regex for filename cleanup private static readonly Regex FileNameCleanupRegex = new(@"[<>:""/\\|?*\x00-\x1f]", RegexOptions.Compiled); private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) @@ -31,16 +31,16 @@ public LocalImageStorageService(ILogger? logger = null public async Task SaveImageAsync(byte[] imageBytes, string? fileName, string documentPath, CancellationToken cancellationToken = default) { ValidateInputs(imageBytes, fileName ?? string.Empty, documentPath); - + // Generate GUID-based filename if not provided if (string.IsNullOrWhiteSpace(fileName)) { fileName = $"{Guid.NewGuid()}.png"; } - + // Ensure document's image directory exists EnsureDocumentStorageAvailable(documentPath); - + var imageDir = GetDocumentImageDirectory(documentPath); var safeFileName = GenerateSafeFileName(fileName); var fullPath = Path.Combine(imageDir, safeFileName); @@ -48,18 +48,18 @@ public async Task SaveImageAsync(byte[] imageBytes, string? fileName, st try { _logger.LogDebug("Saving image to: {FullPath}, Size: {Size} bytes", fullPath, imageBytes.Length); - + // Directly overwrite if file exists await File.WriteAllBytesAsync(fullPath, imageBytes, cancellationToken); - - _logger.LogInformation("Successfully saved image: {FileName} for document: {DocumentPath}", + + _logger.LogInformation("Successfully saved image: {FileName} for document: {DocumentPath}", safeFileName, documentPath); - + return fullPath; } catch (Exception ex) when (ex is not OperationCanceledException) { - _logger.LogError(ex, "Failed to save image {FileName} for document {DocumentPath}", + _logger.LogError(ex, "Failed to save image {FileName} for document {DocumentPath}", safeFileName, documentPath); throw new InvalidOperationException($"Failed to save image: {ex.Message}", ex); } @@ -85,7 +85,7 @@ public string ResolveImagePath(string imagePath, string documentPath) // Handle relative paths with multiple resolution strategies var documentDir = Path.GetDirectoryName(documentPath) ?? throw new ArgumentException("Invalid document path", nameof(documentPath)); - + // 1. Check relative to document directory var relativeToDoc = Path.Combine(documentDir, imagePath); if (File.Exists(relativeToDoc)) @@ -107,17 +107,17 @@ public string ResolveImagePath(string imagePath, string documentPath) private string GetDocumentImageDirectory(string documentPath) { - var documentDir = Path.GetDirectoryName(documentPath) + var documentDir = Path.GetDirectoryName(documentPath) ?? throw new ArgumentException("Invalid document path", nameof(documentPath)); var documentName = Path.GetFileNameWithoutExtension(documentPath); - + // Clean document name to ensure it's a valid directory name var cleanDocumentName = FileNameCleanupRegex.Replace(documentName, "_"); if (string.IsNullOrWhiteSpace(cleanDocumentName)) { cleanDocumentName = "document"; } - + return Path.Combine(documentDir, cleanDocumentName); } @@ -155,10 +155,10 @@ private static string GenerateSafeFileName(string fileName) { // Clean illegal characters from filename var cleanName = FileNameCleanupRegex.Replace(fileName.Trim(), "_"); - + // Prevent path traversal attacks cleanName = Path.GetFileName(cleanName); - + if (string.IsNullOrWhiteSpace(cleanName)) { return Guid.NewGuid().ToString("N") + DefaultExtension; @@ -166,13 +166,13 @@ private static string GenerateSafeFileName(string fileName) var extension = Path.GetExtension(cleanName); var baseName = Path.GetFileNameWithoutExtension(cleanName); - + // Validate extension if (string.IsNullOrEmpty(extension) || !AllowedExtensions.Contains(extension)) { extension = DefaultExtension; } - + // Ensure base name is not empty if (string.IsNullOrWhiteSpace(baseName)) { diff --git a/src/Rag/Services/MarkdownConverterFactory.cs b/src/MarketAssistant.Rag/Services/MarkdownConverterFactory.cs similarity index 100% rename from src/Rag/Services/MarkdownConverterFactory.cs rename to src/MarketAssistant.Rag/Services/MarkdownConverterFactory.cs diff --git a/src/Rag/Services/MarkdownDocumentBlockReader.cs b/src/MarketAssistant.Rag/Services/MarkdownDocumentBlockReader.cs similarity index 100% rename from src/Rag/Services/MarkdownDocumentBlockReader.cs rename to src/MarketAssistant.Rag/Services/MarkdownDocumentBlockReader.cs diff --git a/src/Rag/Services/PdfBlockReader.cs b/src/MarketAssistant.Rag/Services/PdfBlockReader.cs similarity index 100% rename from src/Rag/Services/PdfBlockReader.cs rename to src/MarketAssistant.Rag/Services/PdfBlockReader.cs diff --git a/src/Rag/Services/PdfMarkdownConverter.cs b/src/MarketAssistant.Rag/Services/PdfMarkdownConverter.cs similarity index 99% rename from src/Rag/Services/PdfMarkdownConverter.cs rename to src/MarketAssistant.Rag/Services/PdfMarkdownConverter.cs index df12e33..f002e24 100644 --- a/src/Rag/Services/PdfMarkdownConverter.cs +++ b/src/MarketAssistant.Rag/Services/PdfMarkdownConverter.cs @@ -614,7 +614,7 @@ private async Task ProcessPageImages(PdfPage page, StringBuilder markdown, int p // 保存图片 var imagePath = await _imageStorageService.SaveImageAsync(imageBytes, imageFileName, filePath); var relativeImagePath = Path.GetRelativePath(Path.GetDirectoryName(filePath)!, imagePath); - + markdown.AppendLine(); markdown.AppendLine($"![{altText}]({relativeImagePath})"); markdown.AppendLine(); diff --git a/src/Rag/Services/QueryRewriteService.cs b/src/MarketAssistant.Rag/Services/QueryRewriteService.cs similarity index 71% rename from src/Rag/Services/QueryRewriteService.cs rename to src/MarketAssistant.Rag/Services/QueryRewriteService.cs index d2dc5df..3c08293 100644 --- a/src/Rag/Services/QueryRewriteService.cs +++ b/src/MarketAssistant.Rag/Services/QueryRewriteService.cs @@ -5,14 +5,14 @@ namespace MarketAssistant.Rag.Services; /// -/// 优化的查询改写服务,融合算法和启发式规则,不依赖大模型。 -/// 针对金融投资场景深度优化,支持多维度智能扩展。 +/// 查询改写服务,融合同义词扩展和关键词重组,不依赖大模型。 +/// 针对金融投资场景优化,只保留高价值变体以减少无效 embedding 调用。 /// public class QueryRewriteService : IQueryRewriteService { private readonly ILogger _logger; - // 合并的同义词词典(覆盖基础+热门词汇) + // 同义词词典(覆盖基础+热门词汇) private static readonly Dictionary SynonymMap = new(StringComparer.OrdinalIgnoreCase) { // 基础金融词汇 @@ -43,31 +43,13 @@ public class QueryRewriteService : IQueryRewriteService ["GDP"] = ["经济增长", "国内生产总值"] }; - // 投资分析维度 - private static readonly string[] AnalysisDimensions = - { - "基本面", "技术面", "消息面", "估值", "风险", "催化剂", "政策面" - }; - - // 时间范围限定词 - private static readonly string[] TimeFrames = - { - "最新", "近一个月", "近三个月", "近半年", "近一年", "历史" - }; - - // 信息类型限定词 - private static readonly string[] InfoTypes = - { - "数据", "指标", "新闻", "公告", "研报", "财报", "分析" - }; - - // 停用词(简化版) + // 停用词 private static readonly HashSet StopWords = new(StringComparer.OrdinalIgnoreCase) { "的", "了", "呢", "吗", "啊", "吧", "和", "与", "或", "但", "是", "在" }; - // 预编译正则表达式(简化版本) + // 预编译正则表达式 private static readonly Regex ChineseWordRegex = new(@"[\u4e00-\u9fa5]{2,}", RegexOptions.Compiled); public QueryRewriteService(ILogger logger) @@ -90,7 +72,7 @@ public IReadOnlyList Rewrite(string query, int maxCandidates = 3) var normalized = NormalizeQuery(query); var candidates = new List(); - // 1. 同义词替换(优先级最高) + // 1. 同义词替换(最高价值:扩展语义覆盖) foreach (var synonym in GenerateSynonymVariants(normalized)) { candidates.Add(synonym); @@ -98,31 +80,7 @@ public IReadOnlyList Rewrite(string query, int maxCandidates = 3) return DistinctKeepOrder(candidates); } - // 2. 投资维度扩展 - foreach (var dimension in GenerateAnalysisDimensionVariants(normalized)) - { - candidates.Add(dimension); - if (candidates.Count >= maxCandidates) - return DistinctKeepOrder(candidates); - } - - // 3. 时间范围扩展 - foreach (var timeframe in GenerateTimeFrameVariants(normalized)) - { - candidates.Add(timeframe); - if (candidates.Count >= maxCandidates) - return DistinctKeepOrder(candidates); - } - - // 4. 信息类型扩展 - foreach (var infoType in GenerateInfoTypeVariants(normalized)) - { - candidates.Add(infoType); - if (candidates.Count >= maxCandidates) - return DistinctKeepOrder(candidates); - } - - // 5. 关键词提取和重组(适度简化) + // 2. 关键词重组(提取核心词重新组合) foreach (var keywordVariant in GenerateKeywordVariants(normalized)) { candidates.Add(keywordVariant); @@ -130,7 +88,7 @@ public IReadOnlyList Rewrite(string query, int maxCandidates = 3) return DistinctKeepOrder(candidates); } - // 6. 停用词移除的简化版本 + // 3. 停用词移除(精简查询) var compactQuery = RemoveStopWords(normalized); if (!string.Equals(compactQuery, normalized, StringComparison.Ordinal)) { @@ -155,9 +113,7 @@ public IReadOnlyList Rewrite(string query, int maxCandidates = 3) private static string NormalizeQuery(string query) { var s = query.Trim(); - // 清理常见的控制字符 s = s.Replace("\r", "").Replace("\n", "").Replace("\t", " "); - // 压缩多个空格为单个空格 s = Regex.Replace(s, @"\s+", " "); return s; } @@ -180,40 +136,7 @@ private static IEnumerable GenerateSynonymVariants(string query) } /// - /// 生成分析维度变体 - /// - private static IEnumerable GenerateAnalysisDimensionVariants(string query) - { - foreach (var dimension in AnalysisDimensions) - { - yield return $"{query} {dimension}"; - } - } - - /// - /// 生成时间范围变体 - /// - private static IEnumerable GenerateTimeFrameVariants(string query) - { - foreach (var timeFrame in TimeFrames) - { - yield return $"{query} {timeFrame}"; - } - } - - /// - /// 生成信息类型变体 - /// - private static IEnumerable GenerateInfoTypeVariants(string query) - { - foreach (var infoType in InfoTypes) - { - yield return $"{query} {infoType}"; - } - } - - /// - /// 生成关键词变体(简化版本) + /// 生成关键词变体(提取核心词重新组合) /// private static IEnumerable GenerateKeywordVariants(string query) { @@ -231,7 +154,7 @@ private static IEnumerable GenerateKeywordVariants(string query) } /// - /// 提取关键词(简化版本) + /// 提取关键词 /// private static List ExtractKeywords(string query) { @@ -260,7 +183,7 @@ private static List ExtractKeywords(string query) // 按长度排序,长词优先 return keywords.Distinct(StringComparer.OrdinalIgnoreCase) .OrderByDescending(k => k.Length) - .Take(5) // 最多5个关键词,避免过度复杂 + .Take(5) .ToList(); } diff --git a/src/Rag/Services/RagIngestionService.cs b/src/MarketAssistant.Rag/Services/RagIngestionService.cs similarity index 90% rename from src/Rag/Services/RagIngestionService.cs rename to src/MarketAssistant.Rag/Services/RagIngestionService.cs index e1ea108..e57178a 100644 --- a/src/Rag/Services/RagIngestionService.cs +++ b/src/MarketAssistant.Rag/Services/RagIngestionService.cs @@ -110,11 +110,19 @@ private async Task IngestWithBlocksAsync( currentOrder = nextOrder; currentSection = updatedSection; - // 为所有段落生成文本嵌入并存储 - foreach (var paragraph in paragraphs) + // 批量生成文本嵌入:收集所有段落文本后一次性调用嵌入 API, + // 避免逐条 HTTP 往返触发限流,大幅提升大文档摄取性能 + var paragraphList = paragraphs.ToList(); + if (paragraphList.Count > 0) { - paragraph.TextEmbedding = await embeddingGenerator.GenerateAsync(paragraph.Text); - await collection.UpsertAsync(paragraph); + var texts = paragraphList.Select(p => p.Text).ToList(); + var embeddings = await embeddingGenerator.GenerateAsync(texts); + + for (int i = 0; i < paragraphList.Count; i++) + { + paragraphList[i].TextEmbedding = embeddings[i]; + await collection.UpsertAsync(paragraphList[i]); + } } } catch (Exception ex) diff --git a/src/Rag/Services/RerankerService.cs b/src/MarketAssistant.Rag/Services/RerankerService.cs similarity index 84% rename from src/Rag/Services/RerankerService.cs rename to src/MarketAssistant.Rag/Services/RerankerService.cs index bf23f55..249c330 100644 --- a/src/Rag/Services/RerankerService.cs +++ b/src/MarketAssistant.Rag/Services/RerankerService.cs @@ -12,12 +12,14 @@ public record TokenInfo(string Token, double Bonus, int Frequency = 1); /// /// 评分权重配置 +/// 向量分数占主导(语义检索的核心信号),启发式特征作为辅助补充。 /// public record ScoringWeights { - public double Relevance { get; init; } = 0.55; - public double Freshness { get; init; } = 0.25; - public double Length { get; init; } = 0.20; + public double Vector { get; init; } = 0.6; + public double Relevance { get; init; } = 0.2; + public double Freshness { get; init; } = 0.1; + public double Length { get; init; } = 0.1; } /// @@ -39,21 +41,22 @@ public record ScoringConstants /// public class ScoredResult { - public TextSearchResult Item { get; init; } = null!; + public ScoredSearchResult Item { get; init; } = null!; + public double NormalizedVectorScore { get; init; } public double RelevanceScore { get; init; } public double FreshnessScore { get; init; } public double LengthScore { get; init; } public double TotalScore { get; set; } - public ScoredResult(TextSearchResult item) + public ScoredResult(ScoredSearchResult item) { Item = item; } } /// -/// 重排序服务 - 基于启发式算法,专为金融场景优化 -/// 多维度评分:文本相关性 + 时效性 + 长度优化 + 多样性 +/// 重排序服务 - 融合向量相似度分数与启发式评分 +/// 多维度评分:向量相似度(主导) + 文本相关性 + 时效性 + 长度优化 + 多样性 /// public class RerankerService : IRerankerService { @@ -68,7 +71,7 @@ public RerankerService(ILogger logger) public IReadOnlyList Rerank( string query, - IEnumerable items) + IEnumerable items) { ArgumentNullException.ThrowIfNull(items); @@ -76,19 +79,30 @@ public IReadOnlyList Rerank( if (itemList.Count == 0) { _logger.LogDebug("重排序输入为空,直接返回"); - return itemList; + return new List(); } var safeQuery = query?.Trim() ?? string.Empty; - _logger.LogDebug("开始启发式重排序,结果数量: {Count}, 查询: '{Query}'", itemList.Count, safeQuery); + _logger.LogDebug("开始重排序,结果数量: {Count}, 查询: '{Query}'", itemList.Count, safeQuery); try { + // 向量分数归一化(min-max),消除不同查询间分数尺度差异 + var vectorScores = itemList.Select(i => (double)i.VectorScore).ToList(); + var minScore = vectorScores.Min(); + var maxScore = vectorScores.Max(); + var range = maxScore - minScore; + // 预处理查询词元 var queryTokens = TokenizeWithBonus(safeQuery); // 计算各项评分 - var scoredResults = CalculateScores(itemList, queryTokens, safeQuery); + var scoredResults = itemList.Select(item => + { + // 所有分数相同时给满分,避免单一维度失效 + var normalizedVector = range < 1e-9 ? 1.0 : (item.VectorScore - minScore) / range; + return CalculateItemScore(item, queryTokens, safeQuery, normalizedVector); + }).ToList(); // 应用多样性优化 ApplyDiversityOptimization(scoredResults); @@ -96,7 +110,7 @@ public IReadOnlyList Rerank( // 排序并返回结果 var rankedResults = scoredResults .OrderByDescending(r => r.TotalScore) - .Select(r => r.Item) + .Select(r => r.Item.Item) .ToList(); LogTopResults(scoredResults); @@ -105,36 +119,37 @@ public IReadOnlyList Rerank( catch (Exception ex) { _logger.LogError(ex, "重排序过程中发生错误,查询: '{Query}'", safeQuery); - // 发生错误时返回原始排序 - return itemList; + // 发生错误时返回原始排序(按向量分数降序) + return itemList + .OrderByDescending(x => x.VectorScore) + .Select(x => x.Item) + .ToList(); } } #region 评分计算核心方法 - private List CalculateScores(List items, IReadOnlyList queryTokens, string query) - { - return items.Select(item => CalculateItemScore(item, queryTokens, query)).ToList(); - } - - private ScoredResult CalculateItemScore(TextSearchResult item, IReadOnlyList queryTokens, string query) + private ScoredResult CalculateItemScore(ScoredSearchResult item, IReadOnlyList queryTokens, string query, double normalizedVectorScore) { - var text = GetFullText(item); + var text = GetFullText(item.Item); var itemTokens = TokenizeWithBonus(text); var scores = new { + Vector = normalizedVectorScore, Relevance = CalculateRelevanceScore(queryTokens, itemTokens, query, text), - Freshness = CalculateFreshnessScore(item), + Freshness = CalculateFreshnessScore(item.Item), Length = CalculateLengthScore(text) }; - var totalScore = Weights.Relevance * scores.Relevance + + var totalScore = Weights.Vector * scores.Vector + + Weights.Relevance * scores.Relevance + Weights.Freshness * scores.Freshness + Weights.Length * scores.Length; return new ScoredResult(item) { + NormalizedVectorScore = scores.Vector, RelevanceScore = scores.Relevance, FreshnessScore = scores.Freshness, LengthScore = scores.Length, @@ -147,7 +162,7 @@ private ScoredResult CalculateItemScore(TextSearchResult item, IReadOnlyList - /// 计算相关性评分 + /// 计算相关性评分(关键词匹配,作为向量分数的补充信号) /// private static double CalculateRelevanceScore(IReadOnlyList queryTokens, IReadOnlyList itemTokens, string query, string text) { @@ -222,7 +237,7 @@ private static void ApplyDiversityOptimization(List results) var tokenSetCache = new Dictionary>(); foreach (var result in results) { - var text = GetFullText(result.Item); + var text = GetFullText(result.Item.Item); var tokens = TokenizeWithBonus(text) .Select(t => t.Token) .ToHashSet(StringComparer.OrdinalIgnoreCase); @@ -409,17 +424,17 @@ private static string GetFullText(TextSearchResult item) /// private void LogTopResults(List scoredResults) { - _logger.LogDebug("启发式重排序完成,前3个结果得分情况:"); + _logger.LogDebug("重排序完成,前3个结果得分情况:"); var topResults = scoredResults.OrderByDescending(r => r.TotalScore).Take(3); int index = 1; foreach (var scored in topResults) { - var snippet = GetFullText(scored.Item); + var snippet = GetFullText(scored.Item.Item); if (snippet.Length > 50) snippet = snippet[..50] + "..."; - _logger.LogDebug(" {Index}. 总分: {Score:F2} (相关: {Rel:F2}, 时效: {Fresh:F2}, 长度: {Len:F2}) - {Title}", - index++, scored.TotalScore, scored.RelevanceScore, scored.FreshnessScore, scored.LengthScore, snippet); + _logger.LogDebug(" {Index}. 总分: {Score:F2} (向量: {Vec:F2}, 相关: {Rel:F2}, 时效: {Fresh:F2}, 长度: {Len:F2}) - {Title}", + index++, scored.TotalScore, scored.NormalizedVectorScore, scored.RelevanceScore, scored.FreshnessScore, scored.LengthScore, snippet); } } diff --git a/src/Rag/Services/RetrievalOrchestrator.cs b/src/MarketAssistant.Rag/Services/RetrievalOrchestrator.cs similarity index 68% rename from src/Rag/Services/RetrievalOrchestrator.cs rename to src/MarketAssistant.Rag/Services/RetrievalOrchestrator.cs index bce6692..86241f5 100644 --- a/src/Rag/Services/RetrievalOrchestrator.cs +++ b/src/MarketAssistant.Rag/Services/RetrievalOrchestrator.cs @@ -11,8 +11,8 @@ namespace MarketAssistant.Rag.Services; /// RAG 检索编排器,专注于知识库检索的核心管理 /// 包含经典的检索优化: /// 1) 查询重写——将原始查询生成多个候选,提高召回。 -/// 2) 向量检索——对每个候选在内部向量集合中检索。 -/// 3) 去重与重排:去重相似条目,重排获得优质结果。 +/// 2) 向量检索——对每个候选在内部向量集合中检索,保留向量相似度分数。 +/// 3) 去重与重排:去重相似条目,融合向量分数与启发式评分重排。 /// public class RetrievalOrchestrator : IRetrievalOrchestrator { @@ -61,11 +61,19 @@ public async Task> RetrieveAsync( var queries = new List { query }; queries.AddRange(rewrites); - // 2) 向量检索——对每个查询在向量集合中检索,合并结果。 - var merged = new List(); + var distinctQueries = queries.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); - // VectorStoreTextSearch 无法自动推断向量字段的类型问题,推荐 SearchAsync 时指定 VectorProperty。 - //var vectorTextSearch = new VectorStoreTextSearch(collection, _embeddingGenerator); + // 2) 批量生成查询向量,减少 HTTP 往返 + GeneratedEmbeddings>? queryEmbeddings = null; + try + { + queryEmbeddings = await embeddingGenerator.GenerateAsync(distinctQueries, cancellationToken: cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "批量生成查询向量失败"); + return Array.Empty(); + } // 指定使用TextEmbedding字段进行向量搜索(避免向量字段推断问题) var vectorSearchOptions = new VectorSearchOptions @@ -73,18 +81,19 @@ public async Task> RetrieveAsync( VectorProperty = r => r.TextEmbedding }; - // 对于每个查询的检索量,确保获得足够候选项供后续去重和重排 - // 多查询策略 + 向量搜索,能让查询获得更低的结果遗漏 - var perQueryLimit = Math.Max(top / 2, 3); // 至少3个,处理top很小时的边界情况 + // 确保重排候选池足够大:每个查询召回 top*3,至少 10 条 + var perQueryLimit = Math.Max(top * 3, 10); + + // 3) 向量检索——对每个查询在向量集合中检索,合并结果并保留向量分数 + var merged = new List(); - foreach (var q in queries.Distinct(StringComparer.OrdinalIgnoreCase)) + for (int qi = 0; qi < distinctQueries.Count; qi++) { + var q = distinctQueries[qi]; try { - // 生成查询向量 - var queryVector = await embeddingGenerator.GenerateAsync(q); + var queryVector = queryEmbeddings[qi]; - // 使用SearchAsync方法,显式指定使用TextEmbedding向量字段 var searchResults = collection.SearchAsync( queryVector.Vector, perQueryLimit, @@ -93,14 +102,14 @@ public async Task> RetrieveAsync( await foreach (var searchResult in searchResults) { - // Microsoft.SemanticKernel.Data.TextSearchResult 构造函数接收 string value - // Name 和 Link 是它的属性,不是构造函数参数 var textResult = new TextSearchResult(value: searchResult.Record.Text) { Name = searchResult.Record.ParagraphId, Link = searchResult.Record.DocumentUri }; - merged.Add(textResult); + // 保留向量相似度分数,用于后续重排融合 + var score = (float)(searchResult.Score ?? 0f); + merged.Add(new ScoredSearchResult(textResult, score)); } } catch (Exception ex) @@ -109,19 +118,19 @@ public async Task> RetrieveAsync( } } - // 3) 如果检索为空的兜底提示。 + // 4) 如果检索为空的兜底提示。 if (merged.Count == 0) { return Array.Empty(); } - // 4) 标准去重:通过文本内容合并重复项 + // 5) 标准去重:通过文本内容合并重复项,保留向量分数最高的 var dedup = merged - .GroupBy(r => $"{r.Link}|{r.Name}|{r.Value}", StringComparer.Ordinal) - .Select(g => g.First()) + .GroupBy(r => $"{r.Item.Link}|{r.Item.Name}|{r.Item.Value}", StringComparer.Ordinal) + .Select(g => g.OrderByDescending(x => x.VectorScore).First()) .ToList(); - // 5) 重排(支持RankGPT/启发式模型进一步优化重排) + // 6) 重排:融合向量相似度分数与启发式评分 var reranked = _reranker.Rerank(query, dedup); return reranked.Take(top).ToList(); } diff --git a/src/Rag/Services/TextChunkingService.cs b/src/MarketAssistant.Rag/Services/TextChunkingService.cs similarity index 88% rename from src/Rag/Services/TextChunkingService.cs rename to src/MarketAssistant.Rag/Services/TextChunkingService.cs index 1ac67d5..f212b3a 100644 --- a/src/Rag/Services/TextChunkingService.cs +++ b/src/MarketAssistant.Rag/Services/TextChunkingService.cs @@ -1,4 +1,5 @@ using MarketAssistant.Rag.Interfaces; +using Microsoft.ML.Tokenizers; using System.Security.Cryptography; namespace MarketAssistant.Rag.Services; @@ -294,10 +295,45 @@ private static List ProcessParagraphs( return processedParagraphs; } + private static readonly Tokenizer? s_tokenizer; + + static TextChunkerHelper() + { + try + { + // 使用 cl100k_base 编码而非绑定特定模型名。 + // cl100k_base 对中文分词偏保守(token 数更多),用于分块更安全; + // 且与具体 LLM 提供商无关,适用于 DeepSeek/Qwen 等非 OpenAI 模型。 + s_tokenizer = TiktokenTokenizer.CreateForEncoding("cl100k_base"); + } + catch + { + // 离线环境或编码数据不可用时回退到启发式估算 + s_tokenizer = null; + } + } + private static int GetTokenCount(string input) { - // Default approximation: length / 4 - return input.Length / 4; + if (string.IsNullOrEmpty(input)) return 0; + + if (s_tokenizer != null) + return s_tokenizer.CountTokens(input); + + // 回退:区分中文与其他字符 + int chineseCount = 0; + int otherCount = 0; + foreach (var ch in input) + { + if (ch is >= '\u4E00' and <= '\u9FFF' or + >= '\u3400' and <= '\u4DBF' or + >= '\u3000' and <= '\u303F' or + >= '\uFF00' and <= '\uFFEF') + chineseCount++; + else + otherCount++; + } + return Math.Max((int)(chineseCount / 1.5 + otherCount / 4.0), 1); } } } diff --git a/src/Rag/Services/TextCleaningService.cs b/src/MarketAssistant.Rag/Services/TextCleaningService.cs similarity index 100% rename from src/Rag/Services/TextCleaningService.cs rename to src/MarketAssistant.Rag/Services/TextCleaningService.cs diff --git a/src/Rag/TextParagraph.cs b/src/MarketAssistant.Rag/TextParagraph.cs similarity index 91% rename from src/Rag/TextParagraph.cs rename to src/MarketAssistant.Rag/TextParagraph.cs index b73dd32..62e1abc 100644 --- a/src/Rag/TextParagraph.cs +++ b/src/MarketAssistant.Rag/TextParagraph.cs @@ -26,7 +26,7 @@ public class TextParagraph public required string Text { get; init; } /// The embedding generated from the Text. - [VectorStoreVector(1024, DistanceFunction = DistanceFunction.CosineDistance, IndexKind = IndexKind.Hnsw)] + [VectorStoreVector(RagConstants.EmbeddingDimension, DistanceFunction = DistanceFunction.CosineDistance, IndexKind = IndexKind.Hnsw)] public Embedding TextEmbedding { get; set; } = default!; /// @@ -38,7 +38,7 @@ public class TextParagraph /// /// Optional image embedding for cross-modal search (same dimension as text for late fusion use-cases). /// - [VectorStoreVector(1024, DistanceFunction = DistanceFunction.CosineDistance, IndexKind = IndexKind.Hnsw)] + [VectorStoreVector(RagConstants.EmbeddingDimension, DistanceFunction = DistanceFunction.CosineDistance, IndexKind = IndexKind.Hnsw)] public Embedding? ImageEmbedding { get; set; } /// diff --git a/src/MarketAssistant.Trading/AGENTS.md b/src/MarketAssistant.Trading/AGENTS.md new file mode 100644 index 0000000..bd8955f --- /dev/null +++ b/src/MarketAssistant.Trading/AGENTS.md @@ -0,0 +1,42 @@ +# MarketAssistant.Trading — AGENTS.md + +遗留交易抽象项目,定义交易所客户端统一接口。**当前目录仍存在,但已不在主解决方案引用链中**;活跃的交易编排与执行实现位于 `MarketAssistant.App.Services`。 + +--- + +## 目录结构 + +``` +MarketAssistant.Trading/ +├── TradingModels.cs ← 遗留交易模型 +└── Abstractions/ + └── IExchangeClient.cs ← 交易所客户端接口 + 相关 DTO +``` + +--- + +## 关键类型 + +| 类型 | 用途 | +|------|------| +| `IExchangeClient` | 统一交易所客户端接口:获取账户信息、下单、查单、撤单、获取持仓 | +| `ExchangeAccountInfo` | 账户信息:`CanTrade`、`Balances` | +| `ExchangeBalance` | 资产余额:`Asset`、`Free`、`Locked` | +| `ExchangeOrderResult` | 订单结果:`OrderId`、`Status`、`ExecutedQty`、`Price` | + +--- + +## 当前状态 + +- 本项目不是当前主线开发入口;不要继续在这里新增交易实现。 +- 新增交易 Agent、策略工具、执行工具、风控与持久化实现,放在 `MarketAssistant.App.Services/Trading/` 或 `MarketAssistant.App.Services/Agents/Tools/Crypto/`。 +- 若未来决定恢复独立交易抽象层,应先同步更新根目录 `AGENTS.md`、解决方案文件和项目引用,再恢复在本项目扩展。 +- 在未恢复主线引用前,这里的文件仅视为遗留代码,不作为架构边界依据。 + +--- + +## 构建 + +```bash +dotnet build src/MarketAssistant.Trading/MarketAssistant.Trading.csproj -c Debug +``` diff --git a/src/MarketAssistant.Trading/Abstractions/IExchangeClient.cs b/src/MarketAssistant.Trading/Abstractions/IExchangeClient.cs new file mode 100644 index 0000000..4b32bb2 --- /dev/null +++ b/src/MarketAssistant.Trading/Abstractions/IExchangeClient.cs @@ -0,0 +1,93 @@ +using MarketAssistant.Trading.Models; + +namespace MarketAssistant.Trading.Abstractions; + +/// +/// 交易所客户端抽象接口 +/// 解耦交易模块与具体交易所实现,支持未来接入 OKX、Bybit 等 +/// +public interface IExchangeClient +{ + /// + /// 交易所名称标识 + /// + string ExchangeName { get; } + + /// + /// 获取账户余额 + /// + Task GetAccountInfoAsync(CancellationToken ct = default); + + /// + /// 对交易标的下单 + /// + /// 客户端自定义订单 ID,用于网络重试时实现幂等性,避免重复下单 + Task PlaceOrderAsync( + string instrumentSymbol, OrderSide side, OrderType type, + decimal quantity, decimal? price = null, + string? clientOrderId = null, + CancellationToken ct = default); + + /// + /// 查询订单状态 + /// + Task GetOrderAsync( + string instrumentSymbol, string orderId, CancellationToken ct = default); + + /// + /// 取消订单 + /// + Task CancelOrderAsync( + string instrumentSymbol, string orderId, CancellationToken ct = default); + + /// + /// 查询指定交易标的或全部挂单 + /// + Task> GetOpenOrdersAsync( + string? instrumentSymbol = null, CancellationToken ct = default); +} + +/// +/// 交易所账户信息(统一模型) +/// +public class ExchangeAccountInfo +{ + public bool CanTrade { get; set; } + public List Balances { get; set; } = []; +} + +/// +/// 交易所余额(统一模型) +/// +public class ExchangeBalance +{ + public string Asset { get; set; } = string.Empty; + public decimal Free { get; set; } + public decimal Locked { get; set; } +} + +/// +/// 交易所订单结果(统一模型) +/// +public class ExchangeOrderResult +{ + public string Symbol { get; set; } = string.Empty; + public string OrderId { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string Side { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public decimal RequestedQty { get; set; } + public decimal ExecutedQty { get; set; } + public decimal Price { get; set; } + + /// + /// 成交手续费(以 计价)。 + /// 交易所未返回时为 0。 + /// + public decimal FillCommission { get; set; } + + /// + /// 手续费币种(如 BNB、USDT、BTC)。交易所未返回时为空。 + /// + public string? CommissionAsset { get; set; } +} diff --git a/src/MarketAssistant.Trading/MarketAssistant.Trading.csproj b/src/MarketAssistant.Trading/MarketAssistant.Trading.csproj new file mode 100644 index 0000000..15a12c3 --- /dev/null +++ b/src/MarketAssistant.Trading/MarketAssistant.Trading.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + MarketAssistant + enable + + + + + + + + + + diff --git a/src/MarketAssistant.Trading/MarketAssistant.Trading.csproj.lscache b/src/MarketAssistant.Trading/MarketAssistant.Trading.csproj.lscache new file mode 100644 index 0000000..db3f750 --- /dev/null +++ b/src/MarketAssistant.Trading/MarketAssistant.Trading.csproj.lscache @@ -0,0 +1,258 @@ +version=1 + +# This file caches language service data to improve the performance of C# Dev Kit. +# It is not intended for manual editing. It can safely be deleted and will be +# regenerated automatically. For more information, see https://aka.ms/lscache +# +# To control where cache files are stored, use the following VS Code setting: +# "dotnet.projectsystem.cacheInProjectFolder": true + +[project] +language=C# +primary +lastDtbSucceeded + +[properties] +AssemblyName=MarketAssistant.Trading +CommandLineArgsForDesignTimeEvaluation=-langversion:14.0 -define:TRACE +CompilerGeneratedFilesOutputPath= +MaxSupportedLangVersion=14.0 +ProjectAssetsFile=obj/project.assets.json +RootNamespace=MarketAssistant +RunAnalyzers= +RunAnalyzersDuringLiveAnalysis= +SolutionPath=../../MarketAssistant.slnx +TargetFrameworkIdentifier=.NETCoreApp +TargetPath=bin/Debug/net10.0/MarketAssistant.Trading.dll +TargetRefPath=obj/Debug/net10.0/ref/MarketAssistant.Trading.dll +TemporaryDependencyNodeTargetIdentifier=net10.0 + +[commandLineArguments] +/noconfig +/unsafe- +/checked- +/nowarn:1701,1702,1701,1702,8002 +/fullpaths +/nostdlib+ +/errorreport:prompt +/warn:10 +/define:TRACE;DEBUG;NET;NET10_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NET10_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER +/highentropyva+ +/nullable:enable +/features:"InterceptorsNamespaces=;Microsoft.Extensions.Validation.Generated" +/debug+ +/debug:portable +/filealign:512 +/optimize- +/out:obj\Debug\net10.0\MarketAssistant.Trading.dll +/refout:obj\Debug\net10.0\refint\MarketAssistant.Trading.dll +/target:library +/warnaserror- +/utf8output +/deterministic+ +/langversion:14.0 +/warnaserror+:NU1605,SYSLIB0011 + +[sourceFiles] +Abstractions/IExchangeClient.cs +obj/Debug/net10.0/ + .NETCoreApp,Version=v10.0.AssemblyAttributes.cs + MarketAssistant.Trading.AssemblyInfo.cs + MarketAssistant.Trading.GlobalUsings.g.cs +TradingModels.cs + +[metadataReferences] +../MarketAssistant.Core/obj/Debug/net10.0/ref/MarketAssistant.Core.dll +/packs/Microsoft.NETCore.App.Ref/10.0.5/ref/net10.0/ + Microsoft.CSharp.dll + Microsoft.VisualBasic.Core.dll + Microsoft.VisualBasic.dll + Microsoft.Win32.Primitives.dll + Microsoft.Win32.Registry.dll + mscorlib.dll + netstandard.dll + System.AppContext.dll + System.Buffers.dll + System.Collections.Concurrent.dll + System.Collections.dll + System.Collections.Immutable.dll + System.Collections.NonGeneric.dll + System.Collections.Specialized.dll + System.ComponentModel.Annotations.dll + System.ComponentModel.DataAnnotations.dll + System.ComponentModel.dll + System.ComponentModel.EventBasedAsync.dll + System.ComponentModel.Primitives.dll + System.ComponentModel.TypeConverter.dll + System.Configuration.dll + System.Console.dll + System.Core.dll + System.Data.Common.dll + System.Data.DataSetExtensions.dll + System.Data.dll + System.Diagnostics.Contracts.dll + System.Diagnostics.Debug.dll + System.Diagnostics.DiagnosticSource.dll + System.Diagnostics.FileVersionInfo.dll + System.Diagnostics.Process.dll + System.Diagnostics.StackTrace.dll + System.Diagnostics.TextWriterTraceListener.dll + System.Diagnostics.Tools.dll + System.Diagnostics.TraceSource.dll + System.Diagnostics.Tracing.dll + System.dll + System.Drawing.dll + System.Drawing.Primitives.dll + System.Dynamic.Runtime.dll + System.Formats.Asn1.dll + System.Formats.Tar.dll + System.Globalization.Calendars.dll + System.Globalization.dll + System.Globalization.Extensions.dll + System.IO.Compression.Brotli.dll + System.IO.Compression.dll + System.IO.Compression.FileSystem.dll + System.IO.Compression.ZipFile.dll + System.IO.dll + System.IO.FileSystem.AccessControl.dll + System.IO.FileSystem.dll + System.IO.FileSystem.DriveInfo.dll + System.IO.FileSystem.Primitives.dll + System.IO.FileSystem.Watcher.dll + System.IO.IsolatedStorage.dll + System.IO.MemoryMappedFiles.dll + System.IO.Pipelines.dll + System.IO.Pipes.AccessControl.dll + System.IO.Pipes.dll + System.IO.UnmanagedMemoryStream.dll + System.Linq.AsyncEnumerable.dll + System.Linq.dll + System.Linq.Expressions.dll + System.Linq.Parallel.dll + System.Linq.Queryable.dll + System.Memory.dll + System.Net.dll + System.Net.Http.dll + System.Net.Http.Json.dll + System.Net.HttpListener.dll + System.Net.Mail.dll + System.Net.NameResolution.dll + System.Net.NetworkInformation.dll + System.Net.Ping.dll + System.Net.Primitives.dll + System.Net.Quic.dll + System.Net.Requests.dll + System.Net.Security.dll + System.Net.ServerSentEvents.dll + System.Net.ServicePoint.dll + System.Net.Sockets.dll + System.Net.WebClient.dll + System.Net.WebHeaderCollection.dll + System.Net.WebProxy.dll + System.Net.WebSockets.Client.dll + System.Net.WebSockets.dll + System.Numerics.dll + System.Numerics.Vectors.dll + System.ObjectModel.dll + System.Reflection.DispatchProxy.dll + System.Reflection.dll + System.Reflection.Emit.dll + System.Reflection.Emit.ILGeneration.dll + System.Reflection.Emit.Lightweight.dll + System.Reflection.Extensions.dll + System.Reflection.Metadata.dll + System.Reflection.Primitives.dll + System.Reflection.TypeExtensions.dll + System.Resources.Reader.dll + System.Resources.ResourceManager.dll + System.Resources.Writer.dll + System.Runtime.CompilerServices.Unsafe.dll + System.Runtime.CompilerServices.VisualC.dll + System.Runtime.dll + System.Runtime.Extensions.dll + System.Runtime.Handles.dll + System.Runtime.InteropServices.dll + System.Runtime.InteropServices.JavaScript.dll + System.Runtime.InteropServices.RuntimeInformation.dll + System.Runtime.Intrinsics.dll + System.Runtime.Loader.dll + System.Runtime.Numerics.dll + System.Runtime.Serialization.dll + System.Runtime.Serialization.Formatters.dll + System.Runtime.Serialization.Json.dll + System.Runtime.Serialization.Primitives.dll + System.Runtime.Serialization.Xml.dll + System.Security.AccessControl.dll + System.Security.Claims.dll + System.Security.Cryptography.Algorithms.dll + System.Security.Cryptography.Cng.dll + System.Security.Cryptography.Csp.dll + System.Security.Cryptography.dll + System.Security.Cryptography.Encoding.dll + System.Security.Cryptography.OpenSsl.dll + System.Security.Cryptography.Primitives.dll + System.Security.Cryptography.X509Certificates.dll + System.Security.dll + System.Security.Principal.dll + System.Security.Principal.Windows.dll + System.Security.SecureString.dll + System.ServiceModel.Web.dll + System.ServiceProcess.dll + System.Text.Encoding.CodePages.dll + System.Text.Encoding.dll + System.Text.Encoding.Extensions.dll + System.Text.Encodings.Web.dll + System.Text.Json.dll + System.Text.RegularExpressions.dll + System.Threading.AccessControl.dll + System.Threading.Channels.dll + System.Threading.dll + System.Threading.Overlapped.dll + System.Threading.Tasks.Dataflow.dll + System.Threading.Tasks.dll + System.Threading.Tasks.Extensions.dll + System.Threading.Tasks.Parallel.dll + System.Threading.Thread.dll + System.Threading.ThreadPool.dll + System.Threading.Timer.dll + System.Transactions.dll + System.Transactions.Local.dll + System.ValueTuple.dll + System.Web.dll + System.Web.HttpUtility.dll + System.Windows.dll + System.Xml.dll + System.Xml.Linq.dll + System.Xml.ReaderWriter.dll + System.Xml.Serialization.dll + System.Xml.XDocument.dll + System.Xml.XmlDocument.dll + System.Xml.XmlSerializer.dll + System.Xml.XPath.dll + System.Xml.XPath.XDocument.dll + WindowsBase.dll +D:/NuGet/Packages/ + microsoft.data.sqlite.core/10.0.5/lib/net8.0/Microsoft.Data.Sqlite.dll + microsoft.extensions.dependencyinjection.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll + microsoft.extensions.logging.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.Logging.Abstractions.dll + sqlitepclraw.bundle_e_sqlite3/2.1.11/lib/netstandard2.0/SQLitePCLRaw.batteries_v2.dll + sqlitepclraw.core/2.1.11/lib/netstandard2.0/SQLitePCLRaw.core.dll + sqlitepclraw.provider.e_sqlite3/2.1.11/lib/net6.0/SQLitePCLRaw.provider.e_sqlite3.dll + +[analyzerReferences] +/packs/Microsoft.NETCore.App.Ref/10.0.5/analyzers/dotnet/cs/ + Microsoft.Interop.ComInterfaceGenerator.dll + Microsoft.Interop.JavaScript.JSImportGenerator.dll + Microsoft.Interop.LibraryImportGenerator.dll + Microsoft.Interop.SourceGeneration.dll + System.Text.Json.SourceGeneration.dll + System.Text.RegularExpressions.Generator.dll +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/ + Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll + Microsoft.CodeAnalysis.NetAnalyzers.dll +D:/NuGet/Packages/microsoft.extensions.logging.abstractions/10.0.6/analyzers/dotnet/roslyn4.4/cs/Microsoft.Extensions.Logging.Generators.dll + +[analyzerConfigFiles] +../../.editorconfig +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_10_default.globalconfig +obj/Debug/net10.0/MarketAssistant.Trading.GeneratedMSBuildEditorConfig.editorconfig diff --git a/src/MarketAssistant.Trading/TradingModels.cs b/src/MarketAssistant.Trading/TradingModels.cs new file mode 100644 index 0000000..4a54a03 --- /dev/null +++ b/src/MarketAssistant.Trading/TradingModels.cs @@ -0,0 +1,386 @@ +using System.ComponentModel; + +namespace MarketAssistant.Trading.Models; + +#region 枚举类型 + +public enum StrategyType +{ + StopLoss, + TakeProfit, + TrailingStop, + GridTrading, + DCA, + AISignal +} + +public enum StrategyStatus { Active, Paused, Completed, Failed } + +public enum OrderSide { Buy, Sell } + +public enum OrderType { Market, Limit } + +public enum TradeRecordStatus { Pending, Filled, PartiallyFilled, Cancelled, Failed } + +/// +/// 持仓方向(与 OrderSide 区分,用于 positions 表) +/// +public enum PositionSide { Long, Short } + +#endregion + +#region 交易策略 + +[Description("交易策略配置")] +public class TradingStrategy +{ + [Description("策略唯一ID")] + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + [Description("交易对符号(如BTCUSDT)")] + public string Symbol { get; set; } = string.Empty; + [Description("策略类型:StopLoss/TakeProfit/TrailingStop/GridTrading/DCA/AISignal")] + public StrategyType Type { get; set; } + [Description("策略状态:Active/Paused/Completed/Failed")] + public StrategyStatus Status { get; set; } + [Description("交易方向:Buy或Sell")] + public OrderSide Side { get; set; } + [Description("触发价格")] + public decimal TriggerPrice { get; set; } + [Description("止损价格")] + public decimal? StopLossPrice { get; set; } + [Description("止盈价格")] + public decimal? TakeProfitPrice { get; set; } + [Description("交易数量(DCA策略为USDT金额)")] + public decimal Quantity { get; set; } + + [Description("下单类型:Market(市价)或Limit(限价)")] + public OrderType OrderType { get; set; } = OrderType.Market; + + [Description("滑点容忍度(0-1),仅限价单生效")] + public decimal SlippageTolerance { get; set; } = 0.003m; + + public string QuantityLabel => Type == StrategyType.DCA + ? $"定投: {Quantity:F2} USDT" + : $"数量: {Quantity:F6}"; + [Description("最大仓位占比限制(%)")] + public decimal? MaxPositionPercent { get; set; } + [Description("自定义策略参数(JSON)")] + public string? CustomParams { get; set; } + [Description("策略创建时间")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + [Description("上次触发时间")] + public DateTime? LastTriggeredAt { get; set; } + [Description("已执行次数")] + public int ExecutionCount { get; set; } + [Description("最大执行次数限制")] + public int? MaxExecutions { get; set; } + + [Description("追踪止损的峰值/谷值价格")] + public decimal? TrailingPeakPrice { get; set; } +} + +#endregion + +#region 交易记录 + +[Description("交易执行记录")] +public class TradeRecord +{ + [Description("记录唯一ID")] + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + [Description("关联策略ID")] + public string StrategyId { get; set; } = string.Empty; + [Description("交易对符号")] + public string Symbol { get; set; } = string.Empty; + [Description("交易方向")] + public OrderSide Side { get; set; } + [Description("订单类型")] + public OrderType OrderType { get; set; } + [Description("请求交易数量")] + public decimal RequestedQty { get; set; } + [Description("实际成交数量")] + public decimal ExecutedQty { get; set; } + [Description("请求价格(限价单)")] + public decimal? RequestedPrice { get; set; } + [Description("实际成交价格")] + public decimal ExecutedPrice { get; set; } + [Description("手续费")] + public decimal Commission { get; set; } + [Description("手续费币种")] + public string CommissionAsset { get; set; } = string.Empty; + [Description("订单状态:Pending/Filled/PartiallyFilled/Cancelled/Failed")] + public TradeRecordStatus Status { get; set; } + [Description("交易所订单ID")] + public long BinanceOrderId { get; set; } + [Description("AI下单理由")] + public string? AIReasoning { get; set; } + [Description("创建时间")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + [Description("完成时间")] + public DateTime? CompletedAt { get; set; } +} + +#endregion + +#region 风控配置 + +public class RiskConfig +{ + public decimal MaxSingleOrderPercent { get; set; } = 3; + public decimal MaxDailyLossPercent { get; set; } = 5; + public decimal MaxTotalPositionPercent { get; set; } = 70; + public int MaxDailyTrades { get; set; } = 15; + public decimal MinOrderAmount { get; set; } = 10; + public bool RequireConfirmation { get; set; } = true; + public decimal ConfirmationThreshold { get; set; } = 500; + + /// + /// 最大回撤熔断。累计回撤超过此百分比时停止所有交易。 + /// + public decimal MaxDrawdownPercent { get; set; } = 20; + + /// + /// 单 symbol 最大仓位占比(0 表示不限制)。 + /// + public decimal MaxSinglePositionPercent { get; set; } = 30; +} + +#endregion + +#region 日统计 + +public class DailyStats +{ + public string Date { get; set; } = string.Empty; + public int TradeCount { get; set; } + public decimal TotalPnl { get; set; } + public decimal TotalCommission { get; set; } +} + +#endregion + +#region Agent 辅助模型 + +[Description("账户余额汇总")] +public class AccountBalanceSummary +{ + [Description("账户总资产价值(USDT)")] + public decimal TotalValueUSDT { get; set; } + [Description("各币种余额明细")] + public List Assets { get; set; } = []; +} + +[Description("单币种资产余额")] +public class AssetBalance +{ + [Description("币种名称(如BTC、ETH、USDT)")] + public string Asset { get; set; } = string.Empty; + [Description("可用余额")] + public decimal Free { get; set; } + [Description("冻结余额(挂单中)")] + public decimal Locked { get; set; } + [Description("折合USDT价值")] + public decimal ValueUSDT { get; set; } +} + +[Description("持仓信息")] +public class PositionInfo +{ + [Description("交易对符号")] + public string Symbol { get; set; } = string.Empty; + [Description("持仓数量")] + public decimal Quantity { get; set; } + [Description("开仓均价")] + public decimal EntryPrice { get; set; } + [Description("当前价格")] + public decimal CurrentPrice { get; set; } + [Description("未实现盈亏(USDT)")] + public decimal UnrealizedPnl { get; set; } + [Description("未实现盈亏百分比(%)")] + public decimal UnrealizedPnlPercent { get; set; } +} + +[Description("交易执行结果")] +public class TradeResult +{ + [Description("是否成功")] + public bool Success { get; set; } + [Description("失败时的错误信息")] + public string? ErrorMessage { get; set; } + [Description("成功时的交易记录")] + public TradeRecord? Record { get; set; } +} + +/// +/// 持仓记录(FIFO 匹配追踪)。每笔开仓对应一行,平仓时按时间顺序消耗。 +/// +public class Position +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string Symbol { get; set; } = string.Empty; + public PositionSide Side { get; set; } + /// + /// 原始开仓数量 + /// + public decimal Quantity { get; set; } + /// + /// 开仓价 + /// + public decimal EntryPrice { get; set; } + /// + /// 已平仓数量 + /// + public decimal ClosedQuantity { get; set; } + public string? StrategyId { get; set; } + public DateTime OpenedAt { get; set; } = DateTime.UtcNow; + + /// + /// 剩余未平仓数量 + /// + public decimal RemainingQuantity => Quantity - ClosedQuantity; +} + +[Description("订单状态查询结果")] +public class OrderStatusInfo +{ + [Description("订单ID")] + public long OrderId { get; set; } + [Description("交易对符号")] + public string Symbol { get; set; } = string.Empty; + [Description("订单状态")] + public string Status { get; set; } = string.Empty; + [Description("已成交数量")] + public decimal ExecutedQty { get; set; } + [Description("成交价格")] + public decimal ExecutedPrice { get; set; } +} + +public class RiskCheckResult +{ + public bool Passed { get; set; } + public bool NeedsConfirmation { get; set; } + public string? Reason { get; set; } + + public static RiskCheckResult Pass() => new() { Passed = true }; + public static RiskCheckResult Reject(string reason) => new() { Passed = false, Reason = reason }; + public static RiskCheckResult RequireConfirmation(string reason) => + new() { Passed = false, NeedsConfirmation = true, Reason = reason }; +} + +/// +/// 交易上下文,用于在 Agent 工具调用链中传递当前策略 ID +/// +public static class TradingContext +{ + private static readonly AsyncLocal _strategyId = new(); + + public static string? CurrentStrategyId + { + get => _strategyId.Value; + set => _strategyId.Value = value; + } +} + +#endregion + +#region 策略参数模型 + +/// +/// 网格交易策略参数 +/// +public class GridTradingParams +{ + /// + /// 网格上界价格 + /// + public decimal UpperPrice { get; set; } + + /// + /// 网格下界价格 + /// + public decimal LowerPrice { get; set; } + + /// + /// 网格数量(将在上界与下界之间均匀分布) + /// + public int GridCount { get; set; } = 5; + + /// + /// 每格交易数量 + /// + public decimal QuantityPerGrid { get; set; } + + /// + /// 上次触发的网格价格索引(用于追踪状态) + /// + public int LastTriggeredIndex { get; set; } = -1; + + /// + /// 破网止损价(可选)。价格跌破此值时清仓所有网格多头仓位。 + /// + public decimal? StopLossPrice { get; set; } + + /// + /// 破网止盈价(可选)。价格涨破此值时清仓所有网格空头仓位。 + /// + public decimal? TakeProfitPrice { get; set; } + + /// + /// 计算网格间距 + /// + public decimal GridSpacing => GridCount > 1 ? (UpperPrice - LowerPrice) / GridCount : 0; + + /// + /// 获取指定索引处的网格价格 + /// + public decimal GetGridPrice(int index) => LowerPrice + GridSpacing * index; +} + +/// +/// 定投(DCA)策略参数 +/// +public class DCAParams +{ + /// + /// 定投间隔(秒) + /// + public int IntervalSeconds { get; set; } = 86400; // 默认每天 + + /// + /// 每次定投数量 + /// + public decimal AmountPerInterval { get; set; } + + /// + /// 价格上限(高于此价不买入,0 表示无限制) + /// + public decimal MaxBuyPrice { get; set; } + + /// + /// 价格下限触发加倍(低于此价双倍买入,0 表示不启用) + /// + public decimal DoubleBuyBelowPrice { get; set; } + + /// + /// 加倍冷却期(秒)。两次加倍之间至少间隔此时间,默认 24 小时。 + /// 防止瀑布式下跌中连续加倍耗尽资金。 + /// + public int DoubleBuyCooldownSeconds { get; set; } = 86400; + + /// + /// 加倍次数上限(0 表示不限制)。防止无限制加倍。 + /// + public int MaxDoubleBuyCount { get; set; } = 3; + + /// + /// 上次加倍时间(ISO 8601)。用于冷却期判断,持久化在 CustomParams 中。 + /// + public string? LastDoubleBuyAt { get; set; } + + /// + /// 已加倍次数。用于上限判断,持久化在 CustomParams 中。 + /// + public int DoubleBuyCount { get; set; } +} + +#endregion diff --git a/src/MarketAssistant.csproj b/src/MarketAssistant.csproj deleted file mode 100644 index a520ba8..0000000 --- a/src/MarketAssistant.csproj +++ /dev/null @@ -1,99 +0,0 @@ - - - WinExe - net10.0 - enable - true - app.manifest - true - - - win-x64;osx-x64;osx-arm64;linux-x64 - true - - - MarketAssistant - 1.0.0.0 - Assets\logo.ico - 1.0.0-beta1 - X2Agent - Market Assistant - Market Assistant - Copyright © 2025 - AI-powered market analysis assistant - - - MarketAssistant - Market Assistant - xyz.haoai.market - 1.0.0 - 1.0.0 - APPL - MKAS - MarketAssistant - MarketAssistant.icns - true - NSApplication - - - - - - - - - Always - - - - - - - - - - - - None - All - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - false - true - - - - - - - - diff --git a/src/Rag/Interfaces/IRerankerService.cs b/src/Rag/Interfaces/IRerankerService.cs deleted file mode 100644 index 508b918..0000000 --- a/src/Rag/Interfaces/IRerankerService.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.SemanticKernel.Data; - -namespace MarketAssistant.Rag.Interfaces; - -/// -/// 检索结果重排序的重排接口 -/// -public interface IRerankerService -{ - IReadOnlyList Rerank(string query, IEnumerable items); -} diff --git a/src/Resources/Styles/ButtonStyles.axaml b/src/Resources/Styles/ButtonStyles.axaml deleted file mode 100644 index 2f15253..0000000 --- a/src/Resources/Styles/ButtonStyles.axaml +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Resources/Styles/ListStyles.axaml b/src/Resources/Styles/ListStyles.axaml deleted file mode 100644 index 4e56d8d..0000000 --- a/src/Resources/Styles/ListStyles.axaml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/src/Resources/Styles/TextStyles.axaml b/src/Resources/Styles/TextStyles.axaml deleted file mode 100644 index d2e52a7..0000000 --- a/src/Resources/Styles/TextStyles.axaml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - diff --git a/src/Services/Browser/BrowserService.cs b/src/Services/Browser/BrowserService.cs deleted file mode 100644 index ab5a5c8..0000000 --- a/src/Services/Browser/BrowserService.cs +++ /dev/null @@ -1,236 +0,0 @@ -using System.Runtime.InteropServices; - -namespace MarketAssistant.Services.Browser; - -/// -/// 跨平台浏览器服务实现 -/// -public class BrowserService : IBrowserService -{ - /// - /// 检查系统上安装的浏览器 - /// - public string CheckBrowser() - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return CheckBrowserWindows(); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - return CheckBrowserMac(); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - return CheckBrowserLinux(); - } - - return string.Empty; - } - - /// - /// 检查Windows系统上安装的浏览器 - /// - private static string CheckBrowserWindows() - { - // 首先检查Edge浏览器 - var edgePath = CheckEdge(); - if (!string.IsNullOrEmpty(edgePath)) - { - return edgePath; - } - - // 然后检查Chrome浏览器 - var chromePath = CheckChrome(); - if (!string.IsNullOrEmpty(chromePath)) - { - return chromePath; - } - - return string.Empty; - } - - /// - /// 检查Mac系统上安装的浏览器 - /// - private static string CheckBrowserMac() - { - // 首先检查Chrome浏览器 - var chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; - if (File.Exists(chromePath)) - { - return chromePath; - } - - // 然后检查Safari浏览器 - var safariPath = "/Applications/Safari.app/Contents/MacOS/Safari"; - if (File.Exists(safariPath)) - { - return safariPath; - } - - // 检查Firefox - var firefoxPath = "/Applications/Firefox.app/Contents/MacOS/firefox"; - if (File.Exists(firefoxPath)) - { - return firefoxPath; - } - - return string.Empty; - } - - /// - /// 检查Linux系统上安装的浏览器 - /// - private static string CheckBrowserLinux() - { - // 按优先级检查常见浏览器 - string[] browsers = { - "/usr/bin/google-chrome", - "/usr/bin/chromium", - "/usr/bin/chromium-browser", - "/usr/bin/firefox", - "/snap/bin/chromium", - "/snap/bin/firefox" - }; - - foreach (var browserPath in browsers) - { - if (File.Exists(browserPath)) - { - return browserPath; - } - } - - return string.Empty; - } - - /// - /// 检查Windows系统上安装的Edge浏览器 - /// - private static string CheckEdge() - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return string.Empty; - } - - try - { - // 尝试在注册表中查找Edge - using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe"); - if (key != null) - { - var path = key.GetValue("Path") as string; - if (!string.IsNullOrEmpty(path)) - { - var edgePath = Path.Combine(path, "msedge.exe"); - if (File.Exists(edgePath)) - { - return edgePath; - } - } - } - - // 尝试在WOW6432Node中查找(适用于64位系统上的32位程序) - using var keyWow = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe"); - if (keyWow != null) - { - var path = keyWow.GetValue("Path") as string; - if (!string.IsNullOrEmpty(path)) - { - var edgePath = Path.Combine(path, "msedge.exe"); - if (File.Exists(edgePath)) - { - return edgePath; - } - } - } - - // 尝试在用户目录中查找Edge - var userEdgePath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Microsoft", "Edge", "Application", "msedge.exe"); - if (File.Exists(userEdgePath)) - { - return userEdgePath; - } - - // 尝试在Program Files中查找 - var programFilesEdge = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), - "Microsoft", "Edge", "Application", "msedge.exe"); - if (File.Exists(programFilesEdge)) - { - return programFilesEdge; - } - } - catch - { - // 忽略注册表访问错误 - } - - return string.Empty; - } - - /// - /// 检查Windows系统上安装的Chrome浏览器 - /// - private static string CheckChrome() - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return string.Empty; - } - - try - { - // 尝试在注册表中查找Chrome - using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe"); - if (key != null) - { - var path = key.GetValue(null) as string; - if (!string.IsNullOrEmpty(path) && File.Exists(path)) - { - return path; - } - } - - // 尝试在WOW6432Node中查找(适用于64位系统上的32位程序) - using var keyWow = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe"); - if (keyWow != null) - { - var path = keyWow.GetValue(null) as string; - if (!string.IsNullOrEmpty(path) && File.Exists(path)) - { - return path; - } - } - - // 尝试在用户目录中查找Chrome - var userChromePath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Google", "Chrome", "Application", "chrome.exe"); - if (File.Exists(userChromePath)) - { - return userChromePath; - } - - // 尝试在Program Files中查找 - var programFilesChrome = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), - "Google", "Chrome", "Application", "chrome.exe"); - if (File.Exists(programFilesChrome)) - { - return programFilesChrome; - } - } - catch - { - // 忽略注册表访问错误 - } - - return string.Empty; - } -} - diff --git a/src/Services/Browser/IBrowserService.cs b/src/Services/Browser/IBrowserService.cs deleted file mode 100644 index f385b67..0000000 --- a/src/Services/Browser/IBrowserService.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace MarketAssistant.Services.Browser; - -/// -/// 浏览器服务接口 -/// -public interface IBrowserService -{ - /// - /// 检查系统上安装的浏览器 - /// - /// 浏览器路径,如果未找到则返回空字符串 - string CheckBrowser(); -} diff --git a/src/Services/Browser/PlaywrightService.cs b/src/Services/Browser/PlaywrightService.cs deleted file mode 100644 index 4346811..0000000 --- a/src/Services/Browser/PlaywrightService.cs +++ /dev/null @@ -1,273 +0,0 @@ -using MarketAssistant.Services.Settings; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace MarketAssistant.Services.Browser; - -/// -/// Playwright服务,用于管理Playwright和Browser实例 -/// -public class PlaywrightService : IAsyncDisposable -{ - private const int MaxConcurrentPages = 5; - private const int DefaultTimeoutSeconds = 30; - private const string DefaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; - - private static readonly string[] BrowserArgs = [ - "--disable-gpu", - "--disable-extensions", - "--disable-dev-shm-usage", - "--no-first-run", - "--no-default-browser-check" - ]; - - private static readonly string[] BlockedResourceTypes = ["image", "media", "font"]; - - private readonly IUserSettingService _userSettingService; - private readonly ILogger? _logger; - private readonly SemaphoreSlim _initLock = new(1, 1); - private readonly SemaphoreSlim _pageLock = new(MaxConcurrentPages, MaxConcurrentPages); - - private IPlaywright? _playwright; - private IBrowser? _browser; - private string? _cachedBrowserPath; - private bool _disposed; - - public PlaywrightService(IUserSettingService userSettingService, ILogger? logger) - { - _userSettingService = userSettingService; - _logger = logger; - } - - /// - /// 获取Browser实例,如果尚未初始化则进行初始化 - /// - public async Task GetBrowserAsync() - { - var currentBrowserPath = _userSettingService.CurrentSetting.BrowserPath; - - // 检查浏览器是否连接且路径未变更 - if (_browser?.IsConnected == true && _cachedBrowserPath == currentBrowserPath) - { - return _browser; - } - - await _initLock.WaitAsync(); - try - { - // 双重检查 - if (_browser?.IsConnected == true && _cachedBrowserPath == currentBrowserPath) - { - return _browser; - } - - // 如果路径变更或浏览器未连接,重新初始化 - if (_browser != null) - { - await _browser.CloseAsync(); - await _browser.DisposeAsync(); - _browser = null; - } - - await InitializeBrowserAsync(); - _cachedBrowserPath = currentBrowserPath; - return _browser!; - } - finally - { - _initLock.Release(); - } - } - - /// - /// 执行需要Page的操作,自动管理Page生命周期和并发控制 - /// - public async Task ExecuteWithPageAsync(Func> action, TimeSpan? timeout = null, CancellationToken cancellationToken = default) - { - ObjectDisposedException.ThrowIf(_disposed, nameof(PlaywrightService)); - - var actualTimeout = timeout ?? TimeSpan.FromSeconds(DefaultTimeoutSeconds); - - await _pageLock.WaitAsync(cancellationToken); - try - { - ObjectDisposedException.ThrowIf(_disposed, nameof(PlaywrightService)); - - var browser = await GetBrowserAsync(); - - await using var context = await CreateBrowserContextAsync(browser); - var page = await context.NewPageAsync(); - page.SetDefaultTimeout((float)actualTimeout.TotalMilliseconds); - - return await action(page); - } - finally - { - if (!_disposed) - { - _pageLock.Release(); - } - } - } - - /// - /// 执行需要Page的操作(无返回值版本) - /// - public Task ExecuteWithPageAsync(Func action, TimeSpan? timeout = null, CancellationToken cancellationToken = default) - { - return ExecuteWithPageAsync(async page => - { - await action(page); - return true; - }, timeout, cancellationToken); - } - - /// - /// 创建浏览器上下文并设置资源阻止策略 - /// - private async Task CreateBrowserContextAsync(IBrowser browser) - { - var context = await browser.NewContextAsync(new BrowserNewContextOptions - { - BypassCSP = true, - UserAgent = DefaultUserAgent - }); - - await context.RouteAsync("**/*", route => - { - try - { - var resourceType = route.Request.ResourceType; - if (BlockedResourceTypes.Contains(resourceType)) - { - return route.AbortAsync(); - } - else - { - return route.ContinueAsync(); - } - } - catch (Exception ex) - { - _logger?.LogWarning(ex, "路由处理失败: {Url}", route.Request.Url); - return route.ContinueAsync(); - } - }); - - return context; - } - - /// - /// 初始化Playwright和Browser实例 - /// - private async Task InitializeBrowserAsync() - { - if (_browser?.IsConnected == true) - { - return; - } - - try - { - _logger?.LogInformation("初始化 Playwright"); - - _playwright ??= await Playwright.CreateAsync(); - - var options = new BrowserTypeLaunchOptions - { - Headless = true, - Args = BrowserArgs - }; - - var browserPath = _userSettingService.CurrentSetting.BrowserPath; - if (!string.IsNullOrWhiteSpace(browserPath) && File.Exists(browserPath)) - { - options.ExecutablePath = browserPath; - _logger?.LogInformation("使用自定义浏览器: {Path}", browserPath); - } - else - { - _logger?.LogInformation("使用内置 Chromium"); - await Task.Run(() => Microsoft.Playwright.Program.Main(["install", "chromium"])); - } - - _browser = await _playwright.Chromium.LaunchAsync(options); - _browser.Disconnected += (_, _) => - { - _logger?.LogWarning("浏览器连接断开"); - _browser = null; - }; - - _logger?.LogInformation("Playwright 初始化完成"); - } - catch (Exception ex) - { - _logger?.LogError(ex, "Playwright 初始化失败"); - await CleanupAsync(); - throw; - } - } - - /// - /// 清理浏览器资源 - /// - private async Task CleanupAsync() - { - if (_browser != null) - { - try - { - await _browser.CloseAsync(); - } - catch (Exception ex) - { - _logger?.LogWarning(ex, "关闭浏览器时出错"); - } - finally - { - _browser = null; - } - } - - _playwright?.Dispose(); - _playwright = null; - } - - /// - /// 释放资源 - /// - public async ValueTask DisposeAsync() - { - // 第一次检查:无锁快速路径,避免已释放时获取锁的开销 - if (_disposed) - { - return; - } - - await _initLock.WaitAsync(); - try - { - // 第二次检查:持锁后再次验证,防止多线程竞态条件 - // 场景:多个线程同时通过第一次检查,但只有第一个线程应该执行释放 - if (_disposed) - { - return; - } - - _disposed = true; - - // 清理浏览器资源 - await CleanupAsync(); - - // 在持锁状态下释放 SemaphoreSlim,确保没有其他线程在等待 - _pageLock.Dispose(); - } - finally - { - _initLock.Release(); - _initLock.Dispose(); - } - - GC.SuppressFinalize(this); - } -} \ No newline at end of file diff --git a/src/Services/Cache/AnalysisCacheService.cs b/src/Services/Cache/AnalysisCacheService.cs deleted file mode 100644 index a0bf3fb..0000000 --- a/src/Services/Cache/AnalysisCacheService.cs +++ /dev/null @@ -1,104 +0,0 @@ -using MarketAssistant.Agents.MarketAnalysis.Models; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; - -namespace MarketAssistant.Services.Cache; - -/// -/// 分析结果缓存服务(彻底重构版) -/// 缓存完整的 MarketAnalysisReport,更符合业务逻辑 -/// -public class AnalysisCacheService : IAnalysisCacheService -{ - private readonly ILogger _logger; - private readonly IMemoryCache _memoryCache; - private readonly TimeSpan _cacheExpiration = TimeSpan.FromHours(2); - - public AnalysisCacheService(ILogger logger, IMemoryCache memoryCache) - { - _logger = logger; - _memoryCache = memoryCache; - } - - /// - /// 获取缓存的市场分析报告 - /// - public Task GetCachedAnalysisAsync(string stockSymbol) - { - if (string.IsNullOrWhiteSpace(stockSymbol)) - { - throw new ArgumentNullException(nameof(stockSymbol)); - } - - var cacheKey = GenerateCacheKey(stockSymbol); - - if (_memoryCache.TryGetValue(cacheKey, out MarketAnalysisReport? cachedReport)) - { - _logger.LogInformation("从缓存获取分析报告: {StockSymbol}, 分析师数量: {Count}", - stockSymbol, cachedReport?.AnalystMessages.Count ?? 0); - return Task.FromResult(cachedReport); - } - - _logger.LogInformation("缓存未命中: {StockSymbol}", stockSymbol); - return Task.FromResult(null); - } - - /// - /// 缓存市场分析报告 - /// - public Task CacheAnalysisAsync(string stockSymbol, MarketAnalysisReport report) - { - if (string.IsNullOrWhiteSpace(stockSymbol)) - { - throw new ArgumentNullException(nameof(stockSymbol)); - } - - ArgumentNullException.ThrowIfNull(report); - - var cacheKey = GenerateCacheKey(stockSymbol); - - _memoryCache.Set(cacheKey, report, new MemoryCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = _cacheExpiration, - SlidingExpiration = TimeSpan.FromMinutes(30), - Priority = CacheItemPriority.Normal - }); - - _logger.LogInformation( - "已缓存分析报告: {StockSymbol}, 分析师数量: {Count}, 过期时间: {Expiration}", - stockSymbol, - report.AnalystMessages.Count, - _cacheExpiration); - - return Task.CompletedTask; - } - - /// - /// 清除指定股票的缓存 - /// - public Task ClearCacheAsync(string stockSymbol) - { - if (string.IsNullOrWhiteSpace(stockSymbol)) - { - throw new ArgumentNullException(nameof(stockSymbol)); - } - - var cacheKey = GenerateCacheKey(stockSymbol); - _memoryCache.Remove(cacheKey); - _logger.LogInformation("已清除缓存: {StockSymbol}", stockSymbol); - return Task.CompletedTask; - } - - /// - /// 生成缓存键 - /// - private string GenerateCacheKey(string stockSymbol) - { - return $"MarketAnalysisReport_{stockSymbol}"; - } - - public void Dispose() - { - // IMemoryCache 由 DI 容器管理,无需手动释放 - } -} diff --git a/src/Services/Navigation/INavigationAware.cs b/src/Services/Navigation/INavigationAware.cs deleted file mode 100644 index 089500a..0000000 --- a/src/Services/Navigation/INavigationAware.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace MarketAssistant.Services.Navigation; - -/// -/// 导航感知接口 -/// 实现此接口的 ViewModel 可以在导航发生时接收通知和参数 -/// -public interface INavigationAware -{ - /// - /// 当导航到此页面时调用 - /// - /// 导航参数 - void OnNavigatedTo(object? parameter); - - /// - /// 当从此页面离开时调用 - /// - void OnNavigatedFrom(); -} - -/// -/// 泛型导航感知接口,提供强类型参数支持 -/// -/// 参数类型 -public interface INavigationAware : INavigationAware -{ - /// - /// 当导航到此页面时调用(强类型) - /// - /// 强类型参数 - void OnNavigatedTo(T parameter); - - // 显式实现基接口方法,进行类型转换 - void INavigationAware.OnNavigatedTo(object? parameter) - { - if (parameter is T t) - { - OnNavigatedTo(t); - } - // 如果参数为null且T允许null,也调用 - else if (parameter is null && default(T) is null) - { - OnNavigatedTo(default!); - } - // 类型不匹配时忽略 - } -} diff --git a/src/Services/ServiceCollectionExtensions.cs b/src/Services/ServiceCollectionExtensions.cs deleted file mode 100644 index 60d74f0..0000000 --- a/src/Services/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,173 +0,0 @@ -using MarketAssistant.Agents.MarketAnalysis; -using MarketAssistant.Agents.MarketAnalysis.Executors; -using MarketAssistant.Agents.StockSelection; -using MarketAssistant.Agents.StockSelection.Executors; -using MarketAssistant.Agents.Tools; -using MarketAssistant.Applications.News; -using MarketAssistant.Applications.Settings; -using MarketAssistant.Applications.Stocks; -using MarketAssistant.Applications.StockSelection; -using MarketAssistant.Applications.Telegrams; -using MarketAssistant.Infrastructure.Factories; -using MarketAssistant.Rag.Extensions; -using MarketAssistant.Services.Browser; -using MarketAssistant.Services.Cache; -using MarketAssistant.Services.Dialog; -using MarketAssistant.Services.Mcp; -using MarketAssistant.Services.Navigation; -using MarketAssistant.Services.Notification; -using MarketAssistant.Services.Settings; -using MarketAssistant.Services.StockScreener; -using MarketAssistant.ViewModels; -using MarketAssistant.ViewModels.Home; -using Microsoft.Extensions.Logging; -using Serilog; - -namespace MarketAssistant.Services; - -/// -/// 服务注册扩展 -/// -public static class ServiceCollectionExtensions -{ - /// - /// 注册应用程序所有服务 - /// - public static IServiceCollection AddApplicationServices(this IServiceCollection services) - { - // 注册基础服务 - services.AddHttpClient(); - services.AddMemoryCache(); - - // 注册用户设置服务为单例 - services.AddSingleton(); - - // 注册 Agent Tool 类 - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // 注册 Kernel 和嵌入服务(保留用于 RAG 和提示词模板) - services.AddSingleton(); - - // 注册 Agent Framework 服务 - services.AddSingleton(); - services.AddSingleton(); - - // 注册 MCP 服务(Model Context Protocol) - services.AddSingleton(); - - // 注册向量存储 - var store = Directory.GetCurrentDirectory() + "/vector.sqlite"; - services.AddSqliteVectorStore(_ => $"Data Source={store}"); - - // 注册 RAG 和分析服务 - services.AddRagServices(); - services.AddSingleton(); - services.AddSingleton(); - - // 注册分析缓存服务 - services.AddSingleton(); - - // 注册股票相关服务 - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // 注册主页相关服务 - services.AddSingleton(); - services.AddSingleton(); - - // 注册AI选股相关服务(使用 Agent Framework Workflows) - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // 注册市场分析相关服务(使用 Agent Framework Workflows - 最佳实践) - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // 注册 MarketAnalysis Workflow Executors 的 Logger - // (通过 DI 自动注入,无需额外配置) - - // 注册版本更新服务 - services.AddSingleton(); - - // 注册 Avalonia 平台特定服务 - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // 注意:AI解析器已移除,分析师直接返回结构化 JSON - - return services; - } - - /// - /// 注册所有ViewModels - /// - public static IServiceCollection AddViewModels(this IServiceCollection services) - { - // 注册主窗口 ViewModel - services.AddTransient(); - - // 注册主要页面 ViewModels - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - - // 注册 Home 子 ViewModels - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - - // 注册 AI 分析相关 ViewModels - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - - return services; - } - - /// - /// 配置 Serilog 日志服务 - /// - public static ILoggingBuilder ConfigureLogging(this ILoggingBuilder logging, IUserSettingService userSettingService) - { - var logPath = userSettingService.CurrentSetting.LogPath; - try { Directory.CreateDirectory(logPath); } catch { } - - Log.Logger = new LoggerConfiguration() - .MinimumLevel.Information() - .WriteTo.Console() - .WriteTo.File(Path.Combine(logPath, "log.txt"), - rollingInterval: RollingInterval.Day, - fileSizeLimitBytes: 10_000_000, - retainedFileCountLimit: 7) - .CreateLogger(); - - logging.ClearProviders(); - logging.AddSerilog(Log.Logger); - - logging.SetMinimumLevel(LogLevel.Information); - - return logging; - } -} diff --git a/src/Services/Settings/UserSettingService.cs b/src/Services/Settings/UserSettingService.cs deleted file mode 100644 index 68e1194..0000000 --- a/src/Services/Settings/UserSettingService.cs +++ /dev/null @@ -1,112 +0,0 @@ -using MarketAssistant.Applications.Settings; -using MarketAssistant.Services.Browser; - -namespace MarketAssistant.Services.Settings; - -/// -/// 用户设置服务,提供对UserSetting的统一访问和管理 -/// -public class UserSettingService : IUserSettingService -{ - private const string PreferenceKey = "UserSettings"; - private readonly IBrowserService? _browserService; - - private UserSetting _currentSetting = new(); - - /// - /// 当前用户设置 - /// - public UserSetting CurrentSetting => _currentSetting; - - public UserSettingService(IBrowserService? browserService = null) - { - // 从存储中加载设置 - _browserService = browserService; - LoadSettings(); - } - - /// - /// 从存储中加载设置 - /// - public void LoadSettings() - { - try - { - // 从Preferences加载设置 - string settingsJson = Preferences.Default.Get(PreferenceKey, string.Empty); - if (!string.IsNullOrEmpty(settingsJson)) - { - _currentSetting = JsonSerializer.Deserialize(settingsJson) ?? new UserSetting(); - } - else - { - _currentSetting = new UserSetting(); - } - - // 如果日志路径为空,设置为默认日志目录(与启动阶段保持一致) - if (string.IsNullOrWhiteSpace(_currentSetting.LogPath)) - { - _currentSetting.LogPath = Path.Combine(FileSystem.AppDataDirectory, AppInfo.LogsDirectoryName); - } - - // 如果浏览器路径为空,则使用IBrowserService自动检测 - if (string.IsNullOrEmpty(_currentSetting.BrowserPath) && _browserService != null) - { - var browserPath = _browserService.CheckBrowser(); - if (!string.IsNullOrEmpty(browserPath)) - { - _currentSetting.BrowserPath = browserPath; - } - } - } - catch (Exception ex) - { - // 处理异常 - System.Diagnostics.Debug.WriteLine($"加载设置时出错: {ex.Message}"); - Preferences.Default.Remove(PreferenceKey); - - // 如果加载失败,使用默认值 - _currentSetting = new UserSetting(); - } - } - - /// - /// 保存设置到存储 - /// - public void SaveSettings() - { - try - { - // 序列化设置对象 - string json = JsonSerializer.Serialize(_currentSetting); - - // 保存到Preferences - Preferences.Default.Set(PreferenceKey, json); - } - catch (Exception ex) - { - // 处理异常 - System.Diagnostics.Debug.WriteLine($"保存设置时出错: {ex.Message}"); - } - } - - /// - /// 更新设置并保存 - /// - /// 新的用户设置 - public void UpdateSettings(UserSetting setting) - { - _currentSetting = setting; - SaveSettings(); - } - - /// - /// 重置设置为默认值 - /// - public void ResetSettings() - { - _currentSetting = new UserSetting(); - SaveSettings(); - } -} - diff --git a/src/Services/StockScreener/Models/ScreenerStockInfo.cs b/src/Services/StockScreener/Models/ScreenerStockInfo.cs deleted file mode 100644 index 8b65438..0000000 --- a/src/Services/StockScreener/Models/ScreenerStockInfo.cs +++ /dev/null @@ -1,208 +0,0 @@ -namespace MarketAssistant.Services.StockScreener.Models; - -/// -/// 雪球网股票筛选结果实体 -/// -public class ScreenerStockInfo -{ - /// - /// 股票名称 - /// - public string Name { get; set; } = string.Empty; - - /// - /// 股票代码(如:SZ300316) - /// - public string Symbol { get; set; } = string.Empty; - - /// - /// 当前价 - /// - public decimal Current { get; set; } - - /// - /// 当日涨跌幅(%) - /// - public decimal Pct { get; set; } - - /// - /// 当日成交额 - /// - public decimal Amount { get; set; } - - /// - /// 总市值(元) - /// - public decimal Mc { get; set; } - - /// - /// 流通市值(元) - /// - public decimal Fmc { get; set; } - - /// - /// 本日成交量(万) - /// - public decimal Volume { get; set; } - - /// - /// 当日量比 - /// - public decimal VolumeRatio { get; set; } - - /// - /// 当日换手率(%) - /// - public decimal Tr { get; set; } - - /// - /// 市盈率TTM - /// - public decimal PeTtm { get; set; } - - /// - /// 市盈率LYR - /// - public decimal PeLyr { get; set; } - - /// - /// 市净率MRQ - /// - public decimal Pb { get; set; } - - /// - /// 市销率(倍) - /// - public decimal Psr { get; set; } - - /// - /// 净资产收益率(%) - /// - public decimal RoeDiluted { get; set; } - - /// - /// 每股净资产 - /// - public decimal Bps { get; set; } - - /// - /// 每股收益 - /// - public decimal Eps { get; set; } - - /// - /// 净利润(元) - /// - public decimal NetProfit { get; set; } - - /// - /// 营业收入(元) - /// - public decimal TotalRevenue { get; set; } - - /// - /// 股息收益率(%) - /// - public decimal DyL { get; set; } - - /// - /// 净利润同比增长(%) - /// - public decimal Npay { get; set; } - - /// - /// 营业收入同比增长(%) - /// - public decimal Oiy { get; set; } - - /// - /// 总资产报酬率(%) - /// - public decimal Niota { get; set; } - - /// - /// 累计关注人数 - /// - public decimal Follow { get; set; } - - /// - /// 累计讨论次数 - /// - public decimal Tweet { get; set; } - - /// - /// 累计交易分享数 - /// - public decimal Deal { get; set; } - - /// - /// 一周新增关注 - /// - public decimal Follow7d { get; set; } - - /// - /// 一周新增讨论数 - /// - public decimal Tweet7d { get; set; } - - /// - /// 一周新增交易分享数 - /// - public decimal Deal7d { get; set; } - - /// - /// 一周关注增长率(%) - /// - public decimal Follow7dPct { get; set; } - - /// - /// 一周讨论增长率(%) - /// - public decimal Tweet7dPct { get; set; } - - /// - /// 一周交易分享增长率(%) - /// - public decimal Deal7dPct { get; set; } - - /// - /// 近5日涨跌幅(%) - /// - public decimal Pct5 { get; set; } - - /// - /// 近10日涨跌幅(%) - /// - public decimal Pct10 { get; set; } - - /// - /// 近20日涨跌幅(%) - /// - public decimal Pct20 { get; set; } - - /// - /// 近60日涨跌幅(%) - /// - public decimal Pct60 { get; set; } - - /// - /// 近120日涨跌幅(%) - /// - public decimal Pct120 { get; set; } - - /// - /// 近250日涨跌幅(%) - /// - public decimal Pct250 { get; set; } - - /// - /// 年初至今涨跌幅(%) - /// - public decimal PctCurrentYear { get; set; } - - /// - /// 当日振幅(%) - /// - public decimal ChgPct { get; set; } -} - diff --git a/src/Services/StockScreener/StockScreenerService.cs b/src/Services/StockScreener/StockScreenerService.cs deleted file mode 100644 index a6dbcd5..0000000 --- a/src/Services/StockScreener/StockScreenerService.cs +++ /dev/null @@ -1,966 +0,0 @@ -using MarketAssistant.Infrastructure.Extensions; -using MarketAssistant.Services.Browser; -using MarketAssistant.Services.StockScreener.Models; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; -using System.Text.RegularExpressions; - -namespace MarketAssistant.Services.StockScreener; - -/// -/// 雪球网股票筛选服务,通过Playwright自动化操作xueqiu.com股票筛选器 -/// -public sealed class StockScreenerService -{ - private readonly PlaywrightService _playwrightService; - private readonly ILogger _logger; - private const string XUEQIU_SCREENER_URL = "https://xueqiu.com/stock/screener"; - - /// - /// 雪球网支持的所有筛选指标定义(根据实际HTML结构更新) - /// - private static readonly Dictionary SupportedCriteria = new() - { - // 基本指标 (15个) - { "pettm", new StockScreeningCriteria { Code = "pettm", DisplayName = "市盈率TTM" } }, - { "roediluted", new StockScreeningCriteria { Code = "roediluted", DisplayName = "净资产收益率" } }, - { "bps", new StockScreeningCriteria { Code = "bps", DisplayName = "每股净资产" } }, - { "pelyr", new StockScreeningCriteria { Code = "pelyr", DisplayName = "市盈率LYR" } }, - { "npay", new StockScreeningCriteria { Code = "npay", DisplayName = "净利润同比增长" } }, - { "eps", new StockScreeningCriteria { Code = "eps", DisplayName = "每股收益" } }, - { "netprofit", new StockScreeningCriteria { Code = "netprofit", DisplayName = "净利润" } }, - { "dy_l", new StockScreeningCriteria { Code = "dy_l", DisplayName = "股息收益率" } }, - { "psr", new StockScreeningCriteria { Code = "psr", DisplayName = "市销率(倍)" } }, - { "pb", new StockScreeningCriteria { Code = "pb", DisplayName = "市净率MRQ" } }, - { "total_revenue", new StockScreeningCriteria { Code = "total_revenue", DisplayName = "营业收入" } }, - { "mc", new StockScreeningCriteria { Code = "mc", DisplayName = "总市值" } }, - { "fmc", new StockScreeningCriteria { Code = "fmc", DisplayName = "流通市值" } }, - { "niota", new StockScreeningCriteria { Code = "niota", DisplayName = "总资产报酬率" } }, - { "oiy", new StockScreeningCriteria { Code = "oiy", DisplayName = "营业收入同比增长" } }, - - // 雪球指标 (9个) - { "deal", new StockScreeningCriteria { Code = "deal", DisplayName = "累计交易分享数" } }, - { "follow7d", new StockScreeningCriteria { Code = "follow7d", DisplayName = "一周新增关注" } }, - { "deal7dpct", new StockScreeningCriteria { Code = "deal7dpct", DisplayName = "一周交易分享增长率" } }, - { "deal7d", new StockScreeningCriteria { Code = "deal7d", DisplayName = "一周新增交易分享数" } }, - { "tweet7dpct", new StockScreeningCriteria { Code = "tweet7dpct", DisplayName = "一周讨论增长率" } }, - { "tweet", new StockScreeningCriteria { Code = "tweet", DisplayName = "累计讨论次数" } }, - { "follow7dpct", new StockScreeningCriteria { Code = "follow7dpct", DisplayName = "一周关注增长率" } }, - { "follow", new StockScreeningCriteria { Code = "follow", DisplayName = "累计关注人数" } }, - { "tweet7d", new StockScreeningCriteria { Code = "tweet7d", DisplayName = "一周新增讨论数" } }, - - // 行情指标 (14个) - { "pct", new StockScreeningCriteria { Code = "pct", DisplayName = "当日涨跌幅" } }, - { "pct5", new StockScreeningCriteria { Code = "pct5", DisplayName = "近5日涨跌幅" } }, - { "pct60", new StockScreeningCriteria { Code = "pct60", DisplayName = "近60日涨跌幅" } }, - { "amount", new StockScreeningCriteria { Code = "amount", DisplayName = "当日成交额" } }, - { "chgpct", new StockScreeningCriteria { Code = "chgpct", DisplayName = "当日振幅" } }, - { "pct20", new StockScreeningCriteria { Code = "pct20", DisplayName = "近20日涨跌幅" } }, - { "pct120", new StockScreeningCriteria { Code = "pct120", DisplayName = "近120日涨跌幅" } }, - { "pct250", new StockScreeningCriteria { Code = "pct250", DisplayName = "近250日涨跌幅" } }, - { "volume", new StockScreeningCriteria { Code = "volume", DisplayName = "本日成交量" } }, - { "current", new StockScreeningCriteria { Code = "current", DisplayName = "当前价" } }, - { "volume_ratio", new StockScreeningCriteria { Code = "volume_ratio", DisplayName = "当日量比" } }, - { "pct_current_year", new StockScreeningCriteria { Code = "pct_current_year", DisplayName = "年初至今涨跌幅" } }, - { "pct10", new StockScreeningCriteria { Code = "pct10", DisplayName = "近10日涨跌幅" } }, - { "tr", new StockScreeningCriteria { Code = "tr", DisplayName = "当日换手率" } } - }; - - public StockScreenerService( - PlaywrightService playwrightService, - ILogger logger) - { - _playwrightService = playwrightService ?? throw new ArgumentNullException(nameof(playwrightService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - /// 从雪球网筛选股票 - /// - public async Task> ScreenStocksAsync(StockCriteria criteria) - { - if (criteria == null) - throw new ArgumentNullException(nameof(criteria)); - - try - { - _logger.LogInformation("开始筛选股票,共 {Count} 个条件", criteria.Criteria.Count); - - return await _playwrightService.ExecuteWithPageAsync(async page => - { - // 访问雪球选股器 - await page.GotoAsync(XUEQIU_SCREENER_URL, new PageGotoOptions - { - WaitUntil = WaitUntilState.NetworkIdle, - Timeout = 30000 - }); - - // 等待页面加载完成 - await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded); - await Task.Delay(1000); - - // 设置市场和行业 - await SetMarketType(page, criteria.Market); - await SetIndustry(page, criteria.Industry); - - // 设置所有指标条件 - foreach (var criterion in criteria.Criteria) - { - if (SupportedCriteria.ContainsKey(criterion.Code)) - { - var supportedCriterion = SupportedCriteria[criterion.Code]; - await SetSpecificCriteria(page, criterion.Code, criterion.MinValue, criterion.MaxValue, supportedCriterion.DisplayName); - } - else - { - _logger.LogWarning("不支持的指标代码: {Code}", criterion.Code); - } - } - - // 开始选股 - await TriggerScreening(page); - - // 等待结果加载 - await Task.Delay(2000); - - // 获取股票列表 - var stocks = await ExtractXueqiuStockList(page, criteria.Limit); - - _logger.LogInformation("成功获取 {Count} 只股票", stocks.Count); - return stocks; - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "筛选股票时发生错误"); - throw new FriendlyException($"筛选股票失败: {ex.Message}", ex); - } - } - - #region 私有方法 - - /// - /// 设置市场类型 - /// - private async Task SetMarketType(IPage page, MarketType market) - { - try - { - // 根据实际HTML结构选择市场 - var marketSelect = await page.QuerySelectorAsync(".stockScreener-range-market select"); - if (marketSelect != null) - { - // 使用 GetDescription() 获取枚举的描述值(中文名称) - var marketStr = market.GetDescription(); - var marketValue = marketStr switch - { - "全部A股" => "sh_sz", - "沪市A股" => "sha", - "深市A股" => "sza", - _ => "sh_sz" // 默认全部A股 - }; - - await marketSelect.SelectOptionAsync([marketValue]); - await Task.Delay(1000); - _logger.LogInformation("已设置市场类型: {Market} -> {Value}", marketStr, marketValue); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "设置市场类型时发生错误: {Market}", market); - } - } - - /// - /// 设置行业 - /// - private async Task SetIndustry(IPage page, IndustryType industry) - { - try - { - if (industry == IndustryType.All) - { - _logger.LogDebug("使用默认行业筛选条件:全部"); - return; - } - - var industryStr = industry.GetDescription(); - - // 查找行业选择下拉框 - var industrySelect = await page.QuerySelectorAsync(".stockScreener-range-industry select"); - if (industrySelect == null) - { - _logger.LogWarning("未找到行业选择下拉框"); - return; - } - - // 根据行业名称获取对应的值 - var industryValue = GetIndustryValue(industryStr); - if (string.IsNullOrEmpty(industryValue)) - { - _logger.LogWarning("未找到行业 '{Industry}' 对应的值,使用模糊匹配", industryStr); - - // 尝试模糊匹配 - var options = await industrySelect.QuerySelectorAllAsync("option"); - foreach (var option in options) - { - var text = await option.InnerTextAsync(); - if (text.Contains(industryStr)) - { - industryValue = await option.GetAttributeAsync("value") ?? ""; - _logger.LogInformation("通过模糊匹配找到行业: {Industry} -> {Value}", text, industryValue); - break; - } - } - } - - if (!string.IsNullOrEmpty(industryValue)) - { - await industrySelect.SelectOptionAsync(industryValue); - _logger.LogInformation("已选择行业: {Industry} (值: {Value})", industryStr, industryValue); - - // 等待页面更新 - await page.WaitForTimeoutAsync(500); - } - else - { - _logger.LogWarning("无法找到匹配的行业: {Industry}", industryStr); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "设置行业时发生错误: {Industry}", industry); - } - } - - /// - /// 根据行业名称获取对应的雪球行业代码 - /// - private string GetIndustryValue(string industryName) - { - // 雪球行业代码映射表 - var industryMapping = new Dictionary - { - // 农林牧渔 - { "种植业", "S1101" }, - { "渔业", "S1102" }, - { "林业", "S1103" }, - { "饲料", "S1104" }, - { "农产品加工", "S1105" }, - { "养殖业", "S1107" }, - { "动物保健", "S1108" }, - { "农业综合", "S1109" }, - - // 化工 - { "化学原料", "S2202" }, - { "化学制品", "S2203" }, - { "化学纤维", "S2204" }, - { "塑料", "S2205" }, - { "橡胶", "S2206" }, - { "农化制品", "S2208" }, - { "非金属材料", "S2209" }, - - // 钢铁 - { "冶钢原料", "S2303" }, - { "普钢", "S2304" }, - { "特钢", "S2305" }, - - // 有色金属 - { "金属新材料", "S2402" }, - { "工业金属", "S2403" }, - { "贵金属", "S2404" }, - { "小金属", "S2405" }, - { "能源金属", "S2406" }, - - // 电子 - { "半导体", "S2701" }, - { "元件", "S2702" }, - { "光学光电子", "S2703" }, - { "其他电子", "S2704" }, - { "消费电子", "S2705" }, - { "电子化学品", "S2706" }, - - // 汽车 - { "汽车零部件", "S2802" }, - { "汽车服务", "S2803" }, - { "摩托车及其他", "S2804" }, - { "乘用车", "S2805" }, - { "商用车", "S2806" }, - - // 家用电器 - { "白色家电", "S3301" }, - { "黑色家电", "S3302" }, - { "小家电", "S3303" }, - { "厨卫电器", "S3304" }, - { "照明设备", "S3305" }, - { "家电零部件", "S3306" }, - { "其他家电", "S3307" }, - - // 食品饮料 - { "食品加工", "S3404" }, - { "白酒", "S3405" }, - { "非白酒", "S3406" }, - { "饮料乳品", "S3407" }, - { "休闲食品", "S3408" }, - { "调味发酵品", "S3409" }, - - // 纺织服装 - { "纺织制造", "S3501" }, - { "服装家纺", "S3502" }, - { "饰品", "S3503" }, - - // 轻工制造 - { "造纸", "S3601" }, - { "包装印刷", "S3602" }, - { "家居用品", "S3603" }, - { "文娱用品", "S3605" }, - - // 医药生物 - { "化学制药", "S3701" }, - { "中药", "S3702" }, - { "生物制品", "S3703" }, - { "医药商业", "S3704" }, - { "医疗器械", "S3705" }, - { "医疗服务", "S3706" }, - - // 公用事业 - { "电力", "S4101" }, - { "燃气", "S4103" }, - - // 交通运输 - { "物流", "S4208" }, - { "铁路公路", "S4209" }, - { "航空机场", "S4210" }, - { "航运港口", "S4211" }, - - // 房地产 - { "房地产开发", "S4301" }, - { "房地产服务", "S4303" }, - - // 商业贸易 - { "贸易", "S4502" }, - { "一般零售", "S4503" }, - { "专业连锁", "S4504" }, - { "互联网电商", "S4506" }, - { "旅游零售", "S4507" }, - - // 社会服务 - { "体育", "S4606" }, - { "本地生活服务", "S4607" }, - { "专业服务", "S4608" }, - { "酒店餐饮", "S4609" }, - { "旅游及景区", "S4610" }, - { "教育", "S4611" }, - - // 银行 - { "国有大型银行", "S4802" }, - { "股份制银行", "S4803" }, - { "城商行", "S4804" }, - { "农商行", "S4805" }, - { "其他银行", "S4806" }, - - // 非银金融 - { "证券", "S4901" }, - { "保险", "S4902" }, - { "多元金融", "S4903" }, - - // 综合 - { "综合", "S5101" }, - - // 建筑材料 - { "水泥", "S6101" }, - { "玻璃玻纤", "S6102" }, - { "装修建材", "S6103" }, - - // 建筑装饰 - { "房屋建设", "S6201" }, - { "装修装饰", "S6202" }, - { "基础建设", "S6203" }, - { "专业工程", "S6204" }, - { "工程咨询服务", "S6206" }, - - // 电力设备 - { "电机", "S6301" }, - { "其他电源设备", "S6303" }, - { "光伏设备", "S6305" }, - { "风电设备", "S6306" }, - { "电池", "S6307" }, - { "电网设备", "S6308" }, - - // 机械设备 - { "通用设备", "S6401" }, - { "专用设备", "S6402" }, - { "轨交设备", "S6405" }, - { "工程机械", "S6406" }, - { "自动化设备", "S6407" }, - - // 国防军工 - { "航天装备", "S6501" }, - { "航空装备", "S6502" }, - { "地面兵装", "S6503" }, - { "航海装备", "S6504" }, - { "军工电子", "S6505" }, - - // 计算机 - { "计算机设备", "S7101" }, - { "IT服务", "S7103" }, - { "软件开发", "S7104" }, - - // 传媒 - { "游戏", "S7204" }, - { "广告营销", "S7205" }, - { "影视院线", "S7206" }, - { "数字媒体", "S7207" }, - { "社交", "S7208" }, - { "出版", "S7209" }, - { "电视广播", "S7210" }, - - // 通信 - { "通信服务", "S7301" }, - { "通信设备", "S7302" }, - - // 煤炭 - { "煤炭开采", "S7401" }, - { "焦炭", "S7402" }, - - // 石油石化 - { "油气开采", "S7501" }, - { "油服工程", "S7502" }, - { "炼化及贸易", "S7503" }, - - // 环保 - { "环境治理", "S7601" }, - { "环保设备", "S7602" }, - - // 美容护理 - { "个护用品", "S7701" }, - { "化妆品", "S7702" }, - { "医疗美容", "S7703" } - }; - - return industryMapping.TryGetValue(industryName, out var value) ? value : ""; - } - - /// - /// 设置特定指标的筛选条件,支持隐藏元素的强制点击 - /// - private async Task SetSpecificCriteria(IPage page, string value, decimal? min, decimal? max, string displayName) - { - try - { - // 第1步:尝试多种选择器找到复选框 - var checkbox = await FindCheckboxElement(page, value, displayName); - if (checkbox == null) - { - _logger.LogWarning("未找到 {DisplayName} (value={Value}) 复选框", displayName, value); - return; - } - - // 第2步:勾选复选框 - await CheckElementWithFallback(checkbox, displayName); - await Task.Delay(1000); // 等待条件输入框出现 - - // 第3步:设置条件值 - await SetConditionValues(page, value, min, max, displayName); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "设置 {DisplayName} 筛选条件时发生错误", displayName); - } - } - - /// - /// 查找复选框元素 - /// - private async Task FindCheckboxElement(IPage page, string value, string displayName) - { - // 尝试精确选择器 - var checkbox = await page.QuerySelectorAsync($"label[title='{displayName}'] input[type='checkbox'][value='{value}']"); - - // 如果没找到,尝试通用选择器 - if (checkbox == null) - { - checkbox = await page.QuerySelectorAsync($"input[type='checkbox'][value='{value}']"); - } - - return checkbox; - } - - /// - /// 勾选元素,支持JavaScript fallback - /// - private async Task CheckElementWithFallback(IElementHandle checkbox, string displayName) - { - _logger.LogInformation("正在设置 {DisplayName} 指标", displayName); - - try - { - // 首先尝试普通点击 - await checkbox.CheckAsync(); - _logger.LogInformation("通过CheckAsync成功勾选 {DisplayName}", displayName); - } - catch - { - // 如果普通点击失败,使用JavaScript强制点击 - _logger.LogInformation("CheckAsync失败,尝试JavaScript强制点击 {DisplayName}", displayName); - await checkbox.EvaluateAsync("element => element.click()"); - _logger.LogInformation("通过JavaScript成功勾选 {DisplayName}", displayName); - } - } - - /// - /// 设置条件输入框的值(最小值和最大值) - /// - private async Task SetConditionValues(IPage page, string value, decimal? min, decimal? max, string displayName) - { - var conditionElement = await FindConditionElement(page, value, displayName); - if (conditionElement == null) - { - // 延迟重试一次 - await Task.Delay(1000); - conditionElement = await FindConditionElement(page, value, displayName); - if (conditionElement == null) - { - _logger.LogWarning("延迟后仍未找到 {DisplayName} 条件输入框", displayName); - return; - } - _logger.LogInformation("延迟后找到了 {DisplayName} 条件输入框", displayName); - } - - // 设置最小值和最大值 - await SetInputValue(conditionElement, "input.min", min, "最小值", displayName); - await SetInputValue(conditionElement, "input.max", max, "最大值", displayName); - } - - /// - /// 查找条件输入框元素 - /// - private async Task FindConditionElement(IPage page, string value, string displayName) - { - var conditionElement = await page.QuerySelectorAsync($".stockScreener-selected-condition[data-field='{value}']"); - if (conditionElement == null) - { - _logger.LogWarning("未找到 {DisplayName} 条件输入框,可能需要等待更长时间", displayName); - } - return conditionElement; - } - - /// - /// 设置输入框的值 - /// - private async Task SetInputValue(IElementHandle conditionElement, string inputSelector, decimal? value, string valueType, string displayName) - { - if (!value.HasValue) return; - - var input = await conditionElement.QuerySelectorAsync(inputSelector); - if (input != null) - { - await input.FillAsync(value.Value.ToString()); - _logger.LogInformation("已设置 {DisplayName} {ValueType}: {Value}", displayName, valueType, value.Value); - } - else - { - _logger.LogWarning("未找到 {DisplayName} 的 {ValueType} 输入框", displayName, valueType); - } - } - - /// - /// 触发选股 - /// - private async Task TriggerScreening(IPage page) - { - try - { - // 根据实际HTML结构点击开始选股按钮: - var startButton = await page.QuerySelectorAsync("input[type='button'][value='开始选股'].submit.search"); - if (startButton != null) - { - _logger.LogInformation("找到开始选股按钮,准备点击"); - await startButton.ClickAsync(); - } - else - { - _logger.LogWarning("未找到开始选股按钮"); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "触发选股时发生错误"); - } - } - - /// - /// 提取雪球股票列表 - 动态解析表头和数据 - /// - private async Task> ExtractXueqiuStockList(IPage page, int limit) - { - var stocks = new List(); - - try - { - // 等待雪球的筛选结果表格加载 - 使用更精确的选择器 - var resultTableSelector = ".stockScreener-search-result-table.mainTable.overflowx table.portfolio"; - await page.WaitForSelectorAsync(resultTableSelector, new PageWaitForSelectorOptions - { - Timeout = 10000 - }); - - // 首先获取表头信息,确定列的顺序 - 只从结果表格中获取 - var headers = await page.QuerySelectorAllAsync($"{resultTableSelector} thead tr th"); - var columnMap = new Dictionary(); - - for (int i = 0; i < headers.Count; i++) - { - var dataKey = await headers[i].GetAttributeAsync("data-key"); - if (!string.IsNullOrEmpty(dataKey)) - { - columnMap[i] = dataKey; - _logger.LogDebug("列 {Index}: {DataKey}", i, dataKey); - } - } - - _logger.LogInformation("检测到 {Count} 列数据", columnMap.Count); - - // 获取所有股票行 - 只从结果表格中获取 - var rows = await page.QuerySelectorAllAsync($"{resultTableSelector} tbody tr"); - - if (rows.Count == 0) - { - _logger.LogWarning("未找到股票数据行"); - return stocks; - } - - _logger.LogInformation("找到 {Count} 行股票数据", rows.Count); - - // 处理每一行,但限制数量 - var processedCount = 0; - foreach (var row in rows.Take(limit)) - { - try - { - var stock = await ExtractXueqiuStockInfo(row, columnMap); - if (stock != null) - { - stocks.Add(stock); - processedCount++; - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "提取第 {Index} 行股票信息时发生错误", processedCount + 1); - } - } - - _logger.LogInformation("成功提取 {Count} 只股票信息", stocks.Count); - } - catch (Exception ex) - { - _logger.LogError(ex, "提取雪球股票列表时发生错误"); - } - - return stocks; - } - - /// - /// 从表格行提取雪球股票信息 - 根据列映射动态解析 - /// - private async Task ExtractXueqiuStockInfo(IElementHandle row, Dictionary columnMap) - { - try - { - var cells = await row.QuerySelectorAllAsync("td"); - if (cells.Count == 0) return null; - - var stock = new ScreenerStockInfo(); - - // 根据列映射动态解析数据 - for (int i = 0; i < cells.Count && i < columnMap.Count; i++) - { - if (!columnMap.ContainsKey(i)) continue; - - var dataKey = columnMap[i]; - var cellText = await cells[i].InnerTextAsync(); - - try - { - await ParseCellValue(stock, dataKey, cells[i], cellText.Trim()); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "解析列 {DataKey} 时发生错误: {Value}", dataKey, cellText); - } - } - - // 验证基本字段是否存在 - if (string.IsNullOrEmpty(stock.Name)) - { - _logger.LogWarning("股票名称为空,跳过此行"); - return null; - } - - return stock; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "提取雪球股票信息时发生错误"); - return null; - } - } - - /// - /// 解析单元格数据并设置到股票对象相应属性 - /// - private async Task ParseCellValue(ScreenerStockInfo stock, string dataKey, IElementHandle cell, string cellText) - { - switch (dataKey) - { - case "symbol": - // 股票名称和代码 - var linkElement = await cell.QuerySelectorAsync("a"); - if (linkElement != null) - { - stock.Name = await linkElement.InnerTextAsync(); - - // 从title属性中提取股票代码,格式如:"泓淋电力 (SZ301439)" - var title = await linkElement.GetAttributeAsync("title"); - if (!string.IsNullOrEmpty(title)) - { - var codeMatch = Regex.Match(title, @"\(([^)]+)\)"); - if (codeMatch.Success) - { - stock.Symbol = codeMatch.Groups[1].Value; - } - } - } - break; - - case "current": - stock.Current = ParseDecimalValue(cellText); - break; - - case "pct": - stock.Pct = ParsePercentageValue(cellText); - break; - - case "amount": - stock.Amount = ParseChineseAmountValue(cellText); - break; - - case "mc": - stock.Mc = ParseChineseAmountValue(cellText); - break; - - case "fmc": - stock.Fmc = ParseChineseAmountValue(cellText); - break; - - case "volume": - stock.Volume = ParseChineseAmountValue(cellText); - break; - - case "volume_ratio": - stock.VolumeRatio = ParseDecimalValue(cellText); - break; - - case "tr": - stock.Tr = ParsePercentageValue(cellText); - break; - - case "pettm": - stock.PeTtm = ParseDecimalValue(cellText); - break; - - case "pelyr": - stock.PeLyr = ParseDecimalValue(cellText); - break; - - case "pb": - stock.Pb = ParseDecimalValue(cellText); - break; - - case "psr": - stock.Psr = ParseDecimalValue(cellText); - break; - - case "roediluted": - stock.RoeDiluted = ParsePercentageValue(cellText); - break; - - case "bps": - stock.Bps = ParseDecimalValue(cellText); - break; - - case "eps": - stock.Eps = ParseDecimalValue(cellText); - break; - - case "netprofit": - stock.NetProfit = ParseChineseAmountValue(cellText); - break; - - case "total_revenue": - stock.TotalRevenue = ParseChineseAmountValue(cellText); - break; - - case "dy_l": - stock.DyL = ParsePercentageValue(cellText); - break; - - case "npay": - stock.Npay = ParsePercentageValue(cellText); - break; - - case "oiy": - stock.Oiy = ParsePercentageValue(cellText); - break; - - case "niota": - stock.Niota = ParsePercentageValue(cellText); - break; - - case "follow": - stock.Follow = ParseDecimalValue(cellText); - break; - - case "tweet": - stock.Tweet = ParseDecimalValue(cellText); - break; - - case "deal": - stock.Deal = ParseDecimalValue(cellText); - break; - - case "follow7d": - stock.Follow7d = ParseDecimalValue(cellText); - break; - - case "tweet7d": - stock.Tweet7d = ParseDecimalValue(cellText); - break; - - case "deal7d": - stock.Deal7d = ParseDecimalValue(cellText); - break; - - case "follow7dpct": - stock.Follow7dPct = ParsePercentageValue(cellText); - break; - - case "tweet7dpct": - stock.Tweet7dPct = ParsePercentageValue(cellText); - break; - - case "deal7dpct": - stock.Deal7dPct = ParsePercentageValue(cellText); - break; - - case "pct5": - stock.Pct5 = ParsePercentageValue(cellText); - break; - - case "pct10": - stock.Pct10 = ParsePercentageValue(cellText); - break; - - case "pct20": - stock.Pct20 = ParsePercentageValue(cellText); - break; - - case "pct60": - stock.Pct60 = ParsePercentageValue(cellText); - break; - - case "pct120": - stock.Pct120 = ParsePercentageValue(cellText); - break; - - case "pct250": - stock.Pct250 = ParsePercentageValue(cellText); - break; - - case "pct_current_year": - stock.PctCurrentYear = ParsePercentageValue(cellText); - break; - - case "chgpct": - stock.ChgPct = ParsePercentageValue(cellText); - break; - - default: - // 忽略未定义的字段 - break; - } - } - - /// - /// 解析普通数值 - /// - private decimal ParseDecimalValue(string text) - { - if (string.IsNullOrEmpty(text) || text == "-") return 0; - - // 移除可能的颜色span标签中的内容 - var cleanText = Regex.Replace(text, @"<[^>]*>", ""); - cleanText = cleanText.Replace(",", "").Trim(); - - if (decimal.TryParse(cleanText, out var value)) - { - return value; - } - return 0; - } - - /// - /// 解析百分比值 - /// - private decimal ParsePercentageValue(string text) - { - if (string.IsNullOrEmpty(text) || text == "-") return 0; - - // 移除可能的颜色span标签中的内容 - var cleanText = Regex.Replace(text, @"<[^>]*>", ""); - var match = Regex.Match(cleanText, @"([+-]?\d+\.?\d*)%"); - - if (match.Success && decimal.TryParse(match.Groups[1].Value, out var value)) - { - return value; - } - return 0; - } - - /// - /// 解析中文金额(转换为不带单位的真实数值) - /// - private decimal ParseChineseAmountValue(string text) - { - if (string.IsNullOrEmpty(text) || text == "-") return 0; - - // 移除可能的颜色span标签中的内容 - var cleanText = Regex.Replace(text, @"<[^>]*>", ""); - - // 提取数字部分 - var numberMatch = Regex.Match(cleanText, @"([+-]?\d+\.?\d*)"); - if (!numberMatch.Success) return 0; - - if (!decimal.TryParse(numberMatch.Groups[1].Value, out var value)) return 0; - - // 根据单位进行转换为原始数值(不带单位) - if (cleanText.Contains("亿")) - { - return value * 100000000; // 亿转换为原始数值 - } - else if (cleanText.Contains("万")) - { - return value * 10000; // 万转换为原始数值 - } - else - { - return value; // 无单位,直接返回 - } - } - - - - #endregion -} - diff --git a/src/ViewModels/AboutPageViewModel.cs b/src/ViewModels/AboutPageViewModel.cs deleted file mode 100644 index fb4bd56..0000000 --- a/src/ViewModels/AboutPageViewModel.cs +++ /dev/null @@ -1,310 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using MarketAssistant.Applications.Settings; -using MarketAssistant.Services.Notification; -using Microsoft.Extensions.Logging; -using System.Collections.ObjectModel; -using System.Diagnostics; - -namespace MarketAssistant.ViewModels -{ - /// - /// 关于页ViewModel - /// - public partial class AboutPageViewModel : ViewModelBase - { - private readonly IReleaseService _releaseService; - private readonly INotificationService _notificationService; - private ReleaseInfo? _latestRelease; - - [ObservableProperty] - private bool _isCheckingUpdate; - - [ObservableProperty] - private string _updateStatus = "点击检查更新"; - - [ObservableProperty] - private bool _hasNewVersion; - - [ObservableProperty] - private bool _isDownloading; - - [ObservableProperty] - private double _downloadProgress; - - [ObservableProperty] - private string _latestVersion = ""; - - [ObservableProperty] - private string _releaseNotes = ""; - - public string AppName => AppInfo.Title; - public string Version => $"v {AppInfo.Version}"; - public string Description => AppInfo.Description; - public string Company => AppInfo.Company; - public string Copyright => AppInfo.Copyright; - - public ObservableCollection FeatureItems { get; } = new ObservableCollection(); - - public IAsyncRelayCommand CheckUpdateCommand { get; } - public IAsyncRelayCommand DownloadUpdateCommand { get; } - public IRelayCommand OpenGitHubCommand { get; } - - /// - /// 构造函数(使用依赖注入) - /// - public AboutPageViewModel( - IReleaseService releaseService, - INotificationService notificationService, - ILogger logger) : base(logger) - { - _releaseService = releaseService; - _notificationService = notificationService; - - CheckUpdateCommand = new AsyncRelayCommand(CheckForUpdateAsync); - DownloadUpdateCommand = new AsyncRelayCommand(DownloadUpdateAsync, () => HasNewVersion && !IsDownloading); - OpenGitHubCommand = new RelayCommand(OpenGitHub); - - // 初始化功能项列表 - InitializeFeatureItems(); - } - - private async Task CheckForUpdateAsync() - { - await SafeExecuteAsync(async () => - { - IsCheckingUpdate = true; - UpdateStatus = "正在检查更新..."; - HasNewVersion = false; - _latestRelease = null; - - try - { - Logger?.LogInformation("开始检查更新,当前版本: {Version}", AppInfo.Version); - - // 调用 IReleaseService 检查更新 - var result = await _releaseService.CheckForUpdateAsync(AppInfo.Version, includePrerelease: true); - - if (result.HasNewVersion && result.LatestRelease != null) - { - _latestRelease = result.LatestRelease; - LatestVersion = result.LatestRelease.TagName; - ReleaseNotes = result.LatestRelease.Body ?? "无更新说明"; - HasNewVersion = true; - UpdateStatus = $"发现新版本:{result.LatestRelease.TagName}"; - - _notificationService.ShowInfo($"发现新版本 {result.LatestRelease.TagName}!\n点击下载按钮进行更新"); - Logger?.LogInformation("发现新版本: {Version}", result.LatestRelease.TagName); - - // 更新下载命令的可执行状态 - DownloadUpdateCommand.NotifyCanExecuteChanged(); - } - else - { - UpdateStatus = "已是最新版本 ✓"; - _notificationService.ShowSuccess("当前已是最新版本!"); - Logger?.LogInformation("当前已是最新版本"); - } - } - catch (FriendlyException ex) - { - UpdateStatus = $"检查更新失败:{ex.Message}"; - _notificationService.ShowError($"检查更新失败:{ex.Message}"); - Logger?.LogError(ex, "检查更新失败"); - } - catch (Exception ex) - { - UpdateStatus = "检查更新失败"; - _notificationService.ShowError("检查更新失败,请稍后重试"); - Logger?.LogError(ex, "检查更新时发生未知错误"); - } - finally - { - IsCheckingUpdate = false; - } - }, "检查更新"); - } - - private async Task DownloadUpdateAsync() - { - if (!HasNewVersion || _latestRelease == null) return; - - await SafeExecuteAsync(async () => - { - IsDownloading = true; - DownloadProgress = 0; - - try - { - // 获取下载URL(优先 Windows 安装包) - var asset = _latestRelease.Assets - ?.FirstOrDefault(a => a.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) || - a.Name.EndsWith(".msi", StringComparison.OrdinalIgnoreCase)) - ?? _latestRelease.Assets?.FirstOrDefault(); - - if (asset == null || string.IsNullOrEmpty(asset.DownloadUrl)) - { - // 没有找到资产文件,打开 GitHub Release 页面 - _notificationService.ShowInfo("将打开 GitHub Release 页面手动下载"); - OpenUrl(_latestRelease.HtmlUrl); - return; - } - - // 确定保存路径 - var downloadsPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - downloadsPath = Path.Combine(downloadsPath, "Downloads"); - if (!Directory.Exists(downloadsPath)) - { - downloadsPath = Path.GetTempPath(); - } - - var savePath = Path.Combine(downloadsPath, asset.Name); - Logger?.LogInformation("准备下载更新:{Url} -> {Path}", asset.DownloadUrl, savePath); - - _notificationService.ShowInfo($"开始下载 {asset.Name}..."); - - // 创建进度报告器 - var progress = new Progress(p => - { - DownloadProgress = p * 100; - UpdateStatus = $"下载中... {DownloadProgress:F0}%"; - }); - - // 下载更新文件 - var downloadedPath = await _releaseService.DownloadUpdateAsync( - asset.DownloadUrl, - savePath, - progress); - - Logger?.LogInformation("更新文件下载完成: {Path}", downloadedPath); - - // 下载完成 - UpdateStatus = "下载完成!"; - _notificationService.ShowSuccess($"更新文件已下载到:\n{downloadedPath}\n\n请手动运行安装程序进行更新"); - - // 打开下载目录 - Process.Start(new ProcessStartInfo - { - FileName = downloadsPath, - UseShellExecute = true, - Verb = "open" - }); - } - catch (OperationCanceledException) - { - UpdateStatus = "下载已取消"; - _notificationService.ShowWarning("下载已取消"); - Logger?.LogWarning("下载已取消"); - } - catch (FriendlyException ex) - { - UpdateStatus = $"下载失败:{ex.Message}"; - _notificationService.ShowError($"下载失败:{ex.Message}"); - Logger?.LogError(ex, "下载更新失败"); - } - catch (Exception ex) - { - UpdateStatus = "下载失败"; - _notificationService.ShowError("下载失败,请稍后重试或手动访问 GitHub 下载"); - Logger?.LogError(ex, "下载更新时发生未知错误"); - } - finally - { - IsDownloading = false; - DownloadProgress = 0; - DownloadUpdateCommand.NotifyCanExecuteChanged(); - } - }, "下载更新"); - } - - private void OpenGitHub() - { - try - { - Process.Start(new ProcessStartInfo(AppInfo.GitHubRepoUrl) { UseShellExecute = true }); - } - catch (Exception) - { - // 处理异常 - } - } - - private void OpenUrl(string url) - { - try - { - Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); - } - catch (Exception) - { - // 处理异常 - } - } - - private void InitializeFeatureItems() - { - FeatureItems.Add(new FeatureItem - { - IconSource = "/Assets/Images/refresh.svg", - Title = "更新日志", - ButtonText = "查看", - Command = new RelayCommand(() => OpenUrl(AppInfo.ChangelogUrl)) - }); - - FeatureItems.Add(new FeatureItem - { - IconSource = "/Assets/Images/globe.svg", - Title = "官方网站", - ButtonText = "查看", - Command = new RelayCommand(() => OpenUrl(AppInfo.OfficialWebsite)) - }); - - FeatureItems.Add(new FeatureItem - { - IconSource = "/Assets/Images/feedback.svg", - Title = "意见反馈", - ButtonText = "反馈", - Command = new RelayCommand(() => OpenUrl(AppInfo.FeedbackUrl)) - }); - - FeatureItems.Add(new FeatureItem - { - IconSource = "/Assets/Images/license.svg", - Title = "许可证", - ButtonText = "查看", - Command = new RelayCommand(() => OpenUrl(AppInfo.LicenseUrl)) - }); - - FeatureItems.Add(new FeatureItem - { - IconSource = "/Assets/Images/qq.svg", - Title = $"官方QQ群: {AppInfo.QQGroupNumber}", - ButtonText = "加入", - Command = new RelayCommand(() => OpenUrl(AppInfo.QQGroupUrl)) - }); - } - } - - public class FeatureItem - { - /// - /// 功能项图标 - /// - public string IconSource { get; set; } = ""; - - /// - /// 功能项名称 - /// - public string Title { get; set; } = ""; - - /// - /// 按钮文本 - /// - public string ButtonText { get; set; } = ""; - - /// - /// 功能项命令 - /// - public IRelayCommand Command { get; set; } = null!; - } -} diff --git a/src/ViewModels/AgentAnalysisViewModel.cs b/src/ViewModels/AgentAnalysisViewModel.cs deleted file mode 100644 index b592172..0000000 --- a/src/ViewModels/AgentAnalysisViewModel.cs +++ /dev/null @@ -1,221 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using MarketAssistant.Agents.MarketAnalysis; -using MarketAssistant.Infrastructure; -using MarketAssistant.Services.Cache; -using MarketAssistant.Services.Navigation; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using System.Collections.ObjectModel; -using System.Windows.Input; - -namespace MarketAssistant.ViewModels; - -/// -/// 代理分析页面视图模型 -/// -public partial class AgentAnalysisViewModel : ViewModelBase, INavigationAware -{ - public override string Title => "AI股票分析"; - - private readonly MarketAnalysisWorkflow _marketAnalysisWorkflow; - private readonly IAnalysisCacheService _analysisCacheService; - - [ObservableProperty] - private string _stockCode = ""; - - [ObservableProperty] - private bool _isAnalysisInProgress; - - [ObservableProperty] - private string _analysisStage = "等待开始分析"; - - [ObservableProperty] - private AnalysisReportViewModel _analysisReportViewModel; - - [ObservableProperty] - private bool _isChatSidebarVisible; - - public ICommand ToggleChatSidebarCommand { get; private set; } - - private ChatSidebarViewModel? _chatSidebarViewModel; - /// - /// 聊天侧边栏 ViewModel 引用(用于数据同步) - /// - public ChatSidebarViewModel? ChatSidebarViewModel - { - get => _chatSidebarViewModel; - set - { - if (_chatSidebarViewModel != null) - { - _chatSidebarViewModel.PropertyChanged -= OnChatSidebarPropertyChanged; - } - - SetProperty(ref _chatSidebarViewModel, value); - - if (_chatSidebarViewModel != null) - { - _chatSidebarViewModel.PropertyChanged += OnChatSidebarPropertyChanged; - } - - OnPropertyChanged(nameof(ChatMessages)); - OnPropertyChanged(nameof(UserInput)); - OnPropertyChanged(nameof(SendMessageCommand)); - } - } - - private readonly ObservableCollection _emptyChatMessages = new(); - public ObservableCollection ChatMessages => ChatSidebarViewModel?.ChatMessages ?? _emptyChatMessages; - - public string UserInput - { - get => ChatSidebarViewModel?.UserInput ?? string.Empty; - set - { - if (ChatSidebarViewModel != null) - { - ChatSidebarViewModel.UserInput = value; - OnPropertyChanged(); - } - } - } - - public ICommand SendMessageCommand => ChatSidebarViewModel?.SendMessageCommand ?? new RelayCommand(() => { }); - - public AgentAnalysisViewModel( - MarketAnalysisWorkflow marketAnalysisWorkflow, - AnalysisReportViewModel analysisReportViewModel, - IAnalysisCacheService analysisCacheService, - ChatSidebarViewModel chatSidebarViewModel, - ILogger logger) : base(logger) - { - _marketAnalysisWorkflow = marketAnalysisWorkflow; - _analysisReportViewModel = analysisReportViewModel; - _analysisCacheService = analysisCacheService; - - // 通过构造函数注入 ChatSidebarViewModel - ChatSidebarViewModel = chatSidebarViewModel; - ChatSidebarViewModel.InitializeEmpty(); - - SubscribeToEvents(); - ToggleChatSidebarCommand = new RelayCommand(ToggleChatSidebar); - } - - private void SubscribeToEvents() - { - _marketAnalysisWorkflow.ProgressChanged += OnAnalysisProgressChanged; - } - - /// - /// 处理 ChatSidebarViewModel 的属性变更 - /// - private void OnChatSidebarPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) - { - switch (e.PropertyName) - { - case nameof(ChatSidebarViewModel.UserInput): - OnPropertyChanged(nameof(UserInput)); - break; - case nameof(ChatSidebarViewModel.ChatMessages): - OnPropertyChanged(nameof(ChatMessages)); - break; - } - } - - private void OnAnalysisProgressChanged(object? sender, AnalysisProgressEventArgs e) - { - Dispatcher.UIThread.InvokeAsync(() => - { - IsAnalysisInProgress = e.IsInProgress; - AnalysisStage = e.StageDescription; - }); - } - - /// - /// 加载分析数据 - /// - public async Task LoadAnalysisDataAsync() - { - if (string.IsNullOrEmpty(StockCode)) - return; - - await SafeExecuteAsync(async () => - { - // 重置进度状态 - await Dispatcher.UIThread.InvokeAsync(() => - { - AnalysisStage = "准备开始..."; - }); - - // 1. 尝试从缓存加载 - var cachedReport = await _analysisCacheService.GetCachedAnalysisAsync(StockCode); - if (cachedReport != null) - { - Logger?.LogInformation("从缓存加载分析结果: {StockCode}", StockCode); - - // 更新 UI(结构化报告 + 侧边栏历史) - await Dispatcher.UIThread.InvokeAsync(async () => - { - AnalysisReportViewModel.UpdateWithReport(cachedReport); - if (ChatSidebarViewModel != null) - { - await ChatSidebarViewModel.InitializeWithAnalysisHistory(StockCode, cachedReport.AnalystMessages); - } - }); - return; - } - - // 2. 缓存未命中,执行新分析 - Logger?.LogInformation("开始新的分析: {StockCode}", StockCode); - - // 清空侧边栏(准备接收实时消息) - await Dispatcher.UIThread.InvokeAsync(() => ChatSidebarViewModel?.InitializeEmpty()); - - // 执行工作流(耗时操作,OnAnalysisCompleted 会实时更新侧边栏) - var report = await _marketAnalysisWorkflow.AnalyzeAsync(StockCode); - - // 3. 分析完成,更新最终报告并缓存 - await Dispatcher.UIThread.InvokeAsync(async () => - { - AnalysisReportViewModel.UpdateWithReport(report); - if (ChatSidebarViewModel != null) - { - await ChatSidebarViewModel.InitializeWithAnalysisHistory(StockCode, report.AnalystMessages); - } - // 缓存操作不需要在 UI 线程等待,可以放飞或在后台等待 - _ = _analysisCacheService.CacheAnalysisAsync(StockCode, report); - }); - - }, "股票分析"); - } - - /// - /// 切换聊天侧边栏显示状态 - /// - private void ToggleChatSidebar() - { - IsChatSidebarVisible = !IsChatSidebarVisible; - } - - public void OnNavigatedTo(StockNavigationParameter parameter) - { - if (!string.IsNullOrEmpty(parameter.StockCode)) - { - StockCode = parameter.StockCode; - Logger?.LogInformation("导航到 AI 股票分析页面,股票代码: {Code}", StockCode); - // 异步加载数据 - _ = LoadAnalysisDataAsync(); - } - else - { - Logger?.LogInformation("导航到 AI 股票分析页面,但未提供股票代码"); - } - } - - public void OnNavigatedFrom() - { - // 离开页面时的清理工作 - } -} - diff --git a/src/ViewModels/Demo/ChatSidebarDemoViewModel.cs b/src/ViewModels/Demo/ChatSidebarDemoViewModel.cs deleted file mode 100644 index bcac031..0000000 --- a/src/ViewModels/Demo/ChatSidebarDemoViewModel.cs +++ /dev/null @@ -1,343 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using MarketAssistant.Agents.MarketAnalysis.Models; -using MarketAssistant.Services.Mcp; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging.Abstractions; -using System.Text.Json.Serialization; - -namespace MarketAssistant.ViewModels.Demo; - -public partial class ChatSidebarDemoViewModel : ViewModelBase -{ - public override string Title => "Chat Demo"; - - [ObservableProperty] - private ChatSidebarViewModel _targetViewModel; - - private readonly JsonSerializerOptions _jsonOptions; - - public ChatSidebarDemoViewModel() : base(NullLogger.Instance) - { - // 初始化 TargetViewModel - var mcpService = new McpService(NullLogger.Instance); - - TargetViewModel = new ChatSidebarViewModel( - NullLogger.Instance, - new MockChatClientFactory(), - NullLoggerFactory.Instance, - mcpService - ); - - _jsonOptions = new JsonSerializerOptions - { - WriteIndented = true, - Converters = { new JsonStringEnumConverter() } - }; - } - - [RelayCommand] - private void AddTextMessage(string content) - { - TargetViewModel.ChatMessages.Add(new ChatMessageAdapter(new ChatMessage(ChatRole.Assistant, content) { AuthorName = "AI Assistant" })); - } - - [RelayCommand] - private void AddUserMessage(string content) - { - TargetViewModel.ChatMessages.Add(new ChatMessageAdapter(new ChatMessage(ChatRole.User, content) { AuthorName = "User" })); - } - - [RelayCommand] - private void AddMarkdownMessage() - { - var markdown = @"# Markdown 测试 - -这是一个 **Markdown** 消息测试。 - -- 列表项 1 -- 列表项 2 - -```csharp -public void Hello() -{ - Console.WriteLine(""Hello World""); -} -``` - -[链接](https://www.google.com) -"; - TargetViewModel.ChatMessages.Add(new ChatMessageAdapter(new ChatMessage(ChatRole.Assistant, markdown) { AuthorName = "AI Assistant" })); - } - - [RelayCommand] - private void AddAdaptiveCard(string json) - { - TargetViewModel.ChatMessages.Add(new ChatMessageAdapter(new ChatMessage(ChatRole.Assistant, json) { AuthorName = "AI Assistant" })); - } - - [RelayCommand] - private void ClearMessages() - { - TargetViewModel.ChatMessages.Clear(); - } - - // Coordinator - public string CoordinatorCardJson => JsonSerializer.Serialize(new CoordinatorResult - { - OverallScore = 8.5f, - InvestmentRating = InvestmentRating.Buy, - TargetPrice = "55.00 - 60.00 元", - PriceChangeExpectation = "综合判断预计上涨 15-20%", - TimeHorizon = Duration.MediumTerm, - TimeHorizonDescription = "中期 6-12 个月", - RiskLevel = Level.Medium, - ConfidencePercentage = 85, - DimensionScores = new AnalysisDimensionScores - { - Fundamental = 8.0f, - Technical = 7.5f, - Financial = 8.0f, // Added Financial to be complete - Sentiment = 9.0f, - News = 8.5f - }, - InvestmentHighlights = new List { "业绩超预期", "技术面突破", "行业政策利好" }, - RiskFactors = new List { "宏观经济波动", "原材料价格上涨" }, - OperationSuggestions = new List { "建议在 50 元附近建仓", "止损位设在 45 元", "目标价 60 元分批止盈" }, - ConsensusAnalysis = "所有分析师均认为该公司基本面稳健,且近期有重大利好消息驱动,市场情绪高涨。", - DisagreementAnalysis = "技术分析师认为短期有回调风险,而基本面分析师认为长期价值被低估。综合来看,短期回调是买入机会。", - Summary = "基本面优秀,技术面配合,建议逢低买入。", - KeyIndicators = new List - { - new KeyIndicator { AnalystSource = "基本面分析师", Category = "财务数据", Name = "ROE", Value = "15.2%", Signal = "健康", Suggestion = "持续关注" }, - new KeyIndicator { AnalystSource = "技术分析师", Category = "技术指标", Name = "MACD", Value = "金叉", Signal = "买入", Suggestion = "右侧交易" } - } - }, _jsonOptions); - - // Financial - public string FinancialCardJson => JsonSerializer.Serialize(new FinancialAnalysisResult - { - HealthAssessment = new FinancialHealth - { - SolvencyScore = 8, - CurrentRatio = 2.5f, - QuickRatio = 1.8f, - SolvencyAssessment = "偿债能力强,流动资产充足。", - DebtRatio = 45.5f, - DebtRatioTrend = TrendChange.Stable, - DebtStructureAssessment = DebtStructureAssessment.Healthy, - OverallStability = FinancialStability.Strong, - StabilityScore = 9, - CoreInsight = "财务结构稳健,无重大债务风险。" - }, - ProfitQuality = new ProfitabilityQuality - { - GrossMargin = 30.5f, - NetMargin = 12.8f, - NetMarginTrend = TrendChange.Rising, - ProfitTrendAssessment = ProfitTrendAssessment.SteadyGrowth, - ROE = 15.2f, - ROA = 8.5f, - IndustryComparison = Level.High, - ProfitQualityLevel = Level.High, - ProfitQualityScore = 9, - ProfitSustainability = "核心业务盈利能力强,具有可持续性。" - }, - CashFlow = new CashFlowAssessment - { - OperatingCashFlow = 100000000, - CashFlowToNetIncomeRatio = 1.2f, - CashFlowQualityScore = 9, - FreeCashFlowStatus = FreeCashFlowStatus.Positive, - FreeCashFlowTrend = FreeCashFlowTrend.Improving, - FreeCashFlowSustainabilityScore = 8, - CashConversionCycle = 45, - CashConversionCycleTrend = TrendChange.Falling, - EfficiencyDescription = "营运资本管理效率高。" - }, - RiskWarning = new FinancialRiskWarning - { - KeyRiskIndicators = new List { "应收账款周转天数略有增加" }, - FraudRiskLevel = Level.Low, - FraudRiskScore = 2, - FraudRiskRationale = "财务报表逻辑自洽,无明显造假迹象。", - MonitoringPoints = new List { "关注原材料价格波动对毛利率的影响" } - } - }, _jsonOptions); - - // Fundamental - public string FundamentalCardJson => JsonSerializer.Serialize(new FundamentalAnalysisResult - { - BasicInfo = new StockBasicInfo { Symbol = "SH600000", Name = "浦发银行", CurrentPrice = 10.5m, DailyChangePercent = 1.2f, DailyChangeAmount = 0.12m }, - Fundamentals = new CompanyFundamentals - { - Industry = "银行业", - IndustryGrowthScore = 6, - CoreBusiness = "商业银行业务", - BusinessQualityScore = 8, - ProfitabilityOverview = "盈利能力稳定,分红率高。", - ProfitabilityTrend = ProfitabilityTrend.Average, - FinancialHealthOverview = "资本充足率达标,资产质量改善。", - CashFlowStatus = CashFlowStatus.Healthy - }, - Competition = new IndustryCompetitiveness - { - IndustryLifecycle = IndustryLifecycle.Maturity, - LifecycleConfidenceScore = 9, - MarketPosition = MarketPosition.SecondTier, - MarketShareDescription = "股份制银行前列。", - CoreCompetence = "长三角区域优势。", - CompetenceStrengthScore = 7, - BarrierLevel = Level.High, - BarrierDescription = "牌照壁垒和资金壁垒。" - }, - GrowthValue = new GrowthAndValue - { - GrowthDrivers = "零售转型和数字化赋能。", - GrowthSustainabilityScore = 7, - ValuationDescription = "PB 0.5倍,处于历史低位。", - InvestmentRating = InvestmentRating.Buy, - ValuationTarget = "合理估值修复至 0.7倍 PB。", - InvestmentHighlights = new List { "低估值高股息", "资产质量拐点" }, - KeyRisk = "宏观经济下行导致坏账增加。" - } - }, _jsonOptions); - - // News - public string NewsCardJson => JsonSerializer.Serialize(new NewsEventAnalysisResult - { - EventAnalysis = new EventInterpretation - { - EventType = EventType.Earnings, - EventSummary = "发布2024年三季度财报,净利润同比增长15%。", - InformationSource = InformationSource.Official, - CredibilityScore = 10, - EventNature = EventNature.Positive, - ImportanceScore = 8 - }, - ImpactEvaluation = new ImpactAssessment - { - FundamentalImpact = ImpactDirection.Positive, - FundamentalImpactScore = 8, - FundamentalImpactLogic = "业绩超预期验证了公司经营改善。", - SentimentImpact = ImpactDirection.Positive, - SentimentIntensityScore = 7, - SentimentChangeExpectation = "短期提振市场信心。", - ImpactScope = ImpactScope.CompanySpecific, - ImpactDuration = Duration.MediumTerm, - ExpectedTimeframe = "未来1-2个季度", - MarketExpectedReaction = MarketReactionExpectation.RationalReaction, - PriceChangeExpectation = PriceChangeExpectation.Rise, - CapitalFlowExpectation = CapitalFlowDirection.NetInflow, - CapitalScaleEstimate = "预计吸引中长期资金配置。" - }, - InvestmentGuidance = new InvestmentInsight - { - InvestmentImpactAssessment = InvestmentImpactAssessment.Opportunity, - CoreInvestmentLogic = "业绩拐点确认,估值有望修复。", - ResponseStrategy = OperationRecommendation.Buy, - SpecificActionAdvice = "建议逢低吸纳。", - FocusPoints = new List { "后续季度业绩持续性" }, - KeyRiskAlert = "宏观经济不及预期。" - } - }, _jsonOptions); - - // Sentiment - public string SentimentCardJson => JsonSerializer.Serialize(new MarketSentimentAnalysisResult - { - SentimentAssessment = new MarketSentiment - { - DominantEmotion = DominantEmotion.Greed, - EmotionIntensityScore = 7, - VIXLevel = "15.5", - InvestorConfidenceLevel = Level.High, - ConfidenceTrendDescription = "信心逐步回升。", - OverallAtmosphere = MarketAtmosphere.Optimistic, - AtmosphereIntensityScore = 8 - }, - CapitalFlowAnalysis = new CapitalFlow - { - MainCapitalFlow = CapitalFlowDirection.NetInflow, - MainCapitalAmount = 500000000, - MainCapitalConsecutiveDays = 3, - InstitutionTrend = InstitutionTrend.Increasing, - InstitutionPositionChange = "机构仓位小幅提升。", - NorthboundCapitalFlow = CapitalFlowDirection.NetInflow, - NorthboundCapitalAmount = 200000000, - NorthboundCapitalPercentage = 15.5f, - MarginFinancingChange = "融资余额增加。", - MarginTradingChange = "融券余额减少。", - LeverageDescription = "杠杆资金情绪回暖。" - }, - BehaviorAnalysis = new InvestorBehavior - { - MainBehaviorBias = BehaviorBias.HerdMentality, - BiasSeverityScore = 6, - RetailInvestorCharacteristics = RetailInvestorCharacteristics.ChasingRally, - RetailActivityScore = 8, - InstitutionBehaviorConsistency = BehaviorConsistency.Consistent, - InstitutionMainTrend = "一致看多。", - RiskPreference = RiskPreference.HighRisk, - RiskPreferenceChange = "风险偏好提升。" - }, - ShortTermStrategy = new ShortTermInsight - { - MarketRhythm = MarketRhythm.OneSidedTrend, - MarketRhythmRationale = "量价齐升,趋势向好。", - HotSectors = "科技、新能源。", - HotnessSustainabilityAssessment = "热点具有持续性。", - ShortTermOpportunities = "关注板块轮动机会。", - OperationRecommendation = OperationRecommendation.Buy, - PositionRecommendation = PositionRecommendation.Aggressive, - BestTiming = "回调即买入。", - TargetPriceRange = "短期看高一线。" - } - }, _jsonOptions); - - // Technical - public string TechnicalCardJson => JsonSerializer.Serialize(new TechnicalAnalysisResult - { - PatternTrend = new ChartPatternTrend - { - CurrentTrend = TrendDirection.Uptrend, - TrendStrengthScore = 8, - KeyPatterns = "突破箱体震荡。", - PatternReliabilityScore = 9, - TimeFrame = TimeFrame.Daily, - TimeFrameConsistencyScore = 8 - }, - PriceLevels = new KeyPriceLevels - { - CurrentPrice = 52.5m, - SupportLevels = new List { 50.0m, 48.5m }, - SupportStrengthScore = 8, - ResistanceLevels = new List { 55.0m, 58.0m }, - ResistanceStrengthScore = 7, - BreakoutDirection = BreakoutDirection.UpwardBreakout, - BreakoutProbabilityScore = 8 - }, - Indicators = new TechnicalIndicators - { - TrendIndicatorSignals = "均线多头排列。", - TrendIndicatorReliabilityScore = 9, - MomentumIndicatorSignals = "MACD金叉向上。", - MomentumIndicatorReliabilityScore = 8, - VolumeStatus = VolumeStatus.Expanding, - PriceVolumeRelationship = PriceVolumeRelationship.Healthy, - IndicatorConsistency = Level.High, - IndicatorSynergyDescription = "量价配合理想。" - }, - Strategy = new TradingStrategy - { - TechnicalRating = InvestmentRating.Buy, - OperationDirection = OperationRecommendation.Buy, - TargetPriceLow = 55.0m, - TargetPriceHigh = 58.0m, - StopLossPrice = 49.5m, - HoldingPeriod = Duration.ShortTerm, - RiskLevel = Level.Medium - } - }, _jsonOptions); - - -} diff --git a/src/ViewModels/Demo/MockChatClientFactory.cs b/src/ViewModels/Demo/MockChatClientFactory.cs deleted file mode 100644 index 1f898d2..0000000 --- a/src/ViewModels/Demo/MockChatClientFactory.cs +++ /dev/null @@ -1,40 +0,0 @@ -using MarketAssistant.Infrastructure.Factories; -using Microsoft.Extensions.AI; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace MarketAssistant.ViewModels.Demo; - -public class MockChatClientFactory : IChatClientFactory -{ - public IChatClient CreateClient() - { - return new MockChatClient(); - } -} - -public class MockChatClient : IChatClient -{ - public ChatClientMetadata Metadata => new ChatClientMetadata("Mock", new Uri("http://localhost")); - - public object? GetService(Type serviceType, object? serviceKey = null) - { - return null; - } - - public Task GetResponseAsync(IEnumerable chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(new ChatResponse(new[] { new ChatMessage(ChatRole.Assistant, "Mock Response") })); - } - - public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public void Dispose() - { - } -} diff --git a/src/ViewModels/FavoritesPageViewModel.cs b/src/ViewModels/FavoritesPageViewModel.cs deleted file mode 100644 index 84862b9..0000000 --- a/src/ViewModels/FavoritesPageViewModel.cs +++ /dev/null @@ -1,167 +0,0 @@ -using CommunityToolkit.Mvvm.Input; -using CommunityToolkit.Mvvm.Messaging; -using MarketAssistant.Applications.Stocks; -using MarketAssistant.Applications.Stocks.Models; -using MarketAssistant.Services.Dialog; -using Microsoft.Extensions.Logging; -using System.Collections.ObjectModel; - -namespace MarketAssistant.ViewModels; - -/// -/// 收藏页ViewModel - 对应 FavoritesViewModel -/// -public partial class FavoritesPageViewModel : ViewModelBase, IRecipient -{ - private readonly StockFavoriteService _favoriteService; - private readonly StockService _stockService; - private readonly StockInfoCache _stockInfoCache; - private readonly IDialogService _dialogService; - - public ObservableCollection Stocks { get; set; } = new ObservableCollection(); - - /// - /// 构造函数 - /// - public FavoritesPageViewModel( - StockFavoriteService favoriteService, - StockService stockService, - StockInfoCache stockInfoCache, - IDialogService dialogService, - ILogger logger) - : base(logger) - { - _favoriteService = favoriteService; - _stockService = stockService; - _stockInfoCache = stockInfoCache; - _dialogService = dialogService; - _ = LoadFavoriteStocksAsync(); - WeakReferenceMessenger.Default.Register(this); - } - - /// - /// 加载收藏股票列表 - /// - private async Task LoadFavoriteStocksAsync() - { - await SafeExecuteAsync(async () => - { - // 获取收藏列表 - var favoritesCodes = _favoriteService.GetFavoritesCodes(); - - Stocks.Clear(); - - // 使用并发加载所有股票数据 - await UpdateStockDataProgressivelyAsync(favoritesCodes); - }, "加载收藏列表"); - } - - /// - /// 渐进式加载股票实时数据(限制并发数,避免同时打开过多浏览器页面) - /// - private async Task UpdateStockDataProgressivelyAsync(List favorites) - { - const int maxConcurrency = 3; // 最多同时请求3个股票数据 - var semaphore = new SemaphoreSlim(maxConcurrency); - - var tasks = favorites.Select(async favorite => - { - await semaphore.WaitAsync(); - try - { - // 先尝试从缓存获取 - var stockInfo = _stockInfoCache.Get(favorite.Code, favorite.Market); - - // 如果缓存中没有,则从网络获取 - if (stockInfo == null) - { - stockInfo = await _stockService.GetStockInfoAsync(favorite.Code, favorite.Market); - // 缓存获取到的数据 - if (stockInfo != null) - { - _stockInfoCache.Set(stockInfo); - } - } - - return stockInfo; - } - catch (Exception ex) - { - Logger?.LogError(ex, $"加载股票 {favorite.Code} 数据时出错"); - return null; - } - finally - { - semaphore.Release(); - } - }); - - var results = await Task.WhenAll(tasks); - - // 在UI线程上批量添加结果 - foreach (var stockInfo in results) - { - if (stockInfo != null) - { - Stocks.Add(stockInfo); - } - } - } - - /// - /// 选择收藏股票 - /// - [RelayCommand] - private void SelectFavoriteStock(StockInfo? stock) - { - if (stock == null) return; - - WeakReferenceMessenger.Default.Send( - new NavigationMessage("Stock", new StockNavigationParameter(stock.FullCode, stock.Name))); - } - - /// - /// 移除收藏股票 - /// - [RelayCommand] - private async Task RemoveFavorite(StockInfo? stock) - { - if (stock == null) return; - - // 显示确认对话框 - var confirmed = await _dialogService.ShowConfirmationAsync( - "取消收藏", - $"确定要取消收藏 {stock.Name}({stock.Code}) 吗?", - "确定", - "取消" - ); - - // 用户确认后才执行删除 - if (confirmed) - { - await SafeExecuteAsync(async () => - { - // 先从UI集合中移除(避免因消息触发重新加载导致的竞态条件) - var stockToRemove = Stocks.FirstOrDefault(s => s.Code == stock.Code && s.Market == stock.Market); - if (stockToRemove != null) - { - Stocks.Remove(stockToRemove); - } - - // 再从持久化存储中移除 - _favoriteService.RemoveFavorite(stock.Code, stock.Market); - - Logger?.LogInformation($"已取消收藏股票: {stock.Name}({stock.Code})"); - await Task.CompletedTask; - }, "取消收藏"); - } - } - - /// - /// 接收收藏变更消息 - /// - public void Receive(StockFavoritesChanged message) - { - _ = LoadFavoriteStocksAsync(); - } -} diff --git a/src/ViewModels/Home/HotStocksViewModel.cs b/src/ViewModels/Home/HotStocksViewModel.cs deleted file mode 100644 index 723305e..0000000 --- a/src/ViewModels/Home/HotStocksViewModel.cs +++ /dev/null @@ -1,94 +0,0 @@ -using CommunityToolkit.Mvvm.Input; -using MarketAssistant.Applications.Stocks; -using MarketAssistant.Applications.Stocks.Models; -using Microsoft.Extensions.Logging; -using System.Collections.ObjectModel; - -namespace MarketAssistant.ViewModels.Home; - -/// -/// 热门股票ViewModel -/// -public partial class HotStocksViewModel : ViewModelBase -{ - private readonly IHomeStockService _homeStockService; - - /// - /// 热门股票集合 - /// - public ObservableCollection HotStocks { get; } = new(); - - /// - /// 选择热门股票命令 - /// - public IRelayCommand SelectHotStockCommand { get; } - - /// - /// 添加到收藏命令 - /// - public IAsyncRelayCommand AddToFavoriteCommand { get; } - - /// - /// 刷新热门股票命令 - /// - public IAsyncRelayCommand RefreshCommand { get; } - - /// - /// 热门股票选择事件 - /// - public event EventHandler? HotStockSelected; - - public HotStocksViewModel(IHomeStockService homeStockService, ILogger logger) - : base(logger) - { - _homeStockService = homeStockService; - - SelectHotStockCommand = new RelayCommand(OnSelectHotStock); - AddToFavoriteCommand = new AsyncRelayCommand(OnAddToFavoriteAsync); - RefreshCommand = new AsyncRelayCommand(LoadHotStocksAsync); - - // 自动加载热门股票 - _ = LoadHotStocksAsync(); - } - - /// - /// 加载热门股票 - /// - public async Task LoadHotStocksAsync() - { - await SafeExecuteAsync(async () => - { - var hotStocks = await _homeStockService.GetHotStocksAsync(); - - HotStocks.Clear(); - foreach (var stock in hotStocks) - { - HotStocks.Add(stock); - } - }, "加载热门股票"); - } - - /// - /// 选择热门股票 - /// - private void OnSelectHotStock(HotStock? stock) - { - if (stock == null) return; - - // 通知父ViewModel - HotStockSelected?.Invoke(this, stock); - } - - /// - /// 添加到收藏 - /// - private async Task OnAddToFavoriteAsync(HotStock? stock) - { - if (stock == null) return; - - await SafeExecuteAsync(async () => - { - await _homeStockService.AddToFavoriteAsync(stock); - }, "添加收藏"); - } -} diff --git a/src/ViewModels/Home/RecentStocksViewModel.cs b/src/ViewModels/Home/RecentStocksViewModel.cs deleted file mode 100644 index b21c666..0000000 --- a/src/ViewModels/Home/RecentStocksViewModel.cs +++ /dev/null @@ -1,106 +0,0 @@ -using CommunityToolkit.Mvvm.Input; -using MarketAssistant.Applications.Stocks; -using MarketAssistant.Applications.Stocks.Models; -using Microsoft.Extensions.Logging; -using System.Collections.ObjectModel; - -namespace MarketAssistant.ViewModels.Home; - -/// -/// 最近查看股票ViewModel -/// -public partial class RecentStocksViewModel : ViewModelBase -{ - private readonly IHomeStockService _homeStockService; - - /// - /// 最近查看股票集合 - /// - public ObservableCollection RecentStocks { get; } = new(); - - /// - /// 选择最近股票命令 - /// - public IRelayCommand SelectRecentStockCommand { get; } - - /// - /// 添加到收藏命令 - /// - public IAsyncRelayCommand AddToFavoriteCommand { get; } - - /// - /// 刷新最近股票命令 - /// - public IRelayCommand RefreshCommand { get; } - - /// - /// 最近股票选择事件 - /// - public event EventHandler? RecentStockSelected; - - public RecentStocksViewModel(IHomeStockService homeStockService, ILogger logger) - : base(logger) - { - _homeStockService = homeStockService; - - SelectRecentStockCommand = new RelayCommand(OnSelectRecentStock); - AddToFavoriteCommand = new AsyncRelayCommand(OnAddToFavoriteAsync); - RefreshCommand = new RelayCommand(LoadRecentStocks); - - // 自动加载最近股票 - LoadRecentStocks(); - } - - /// - /// 加载最近查看股票 - /// - public void LoadRecentStocks() - { - SafeExecute(() => - { - var recentStocks = _homeStockService.GetRecentStocks(); - - RecentStocks.Clear(); - foreach (var stock in recentStocks) - { - RecentStocks.Add(stock); - } - }, "加载最近查看股票"); - } - - /// - /// 添加股票到最近查看 - /// - public void AddToRecentStocks(StockItem stock) - { - SafeExecute(() => - { - _homeStockService.AddToRecentStocks(stock); - LoadRecentStocks(); // 刷新列表 - }, "添加到最近查看"); - } - - /// - /// 选择最近股票 - /// - private void OnSelectRecentStock(StockItem? stock) - { - if (stock == null) return; - - // 通知父ViewModel - RecentStockSelected?.Invoke(this, stock); - } - - /// - /// 添加到收藏 - /// - private async Task OnAddToFavoriteAsync(StockItem? stock) - { - if (stock == null) return; - - await SafeExecuteAsync(async () => - { - await _homeStockService.AddToFavoriteAsync(stock); - }, "添加收藏"); - } -} diff --git a/src/ViewModels/HomePageViewModel.cs b/src/ViewModels/HomePageViewModel.cs deleted file mode 100644 index 5f867a4..0000000 --- a/src/ViewModels/HomePageViewModel.cs +++ /dev/null @@ -1,144 +0,0 @@ -using CommunityToolkit.Mvvm.Messaging; -using MarketAssistant.Applications.Stocks.Models; -using MarketAssistant.ViewModels.Home; -using Microsoft.Extensions.Logging; - -namespace MarketAssistant.ViewModels; - -/// -/// 首页ViewModel -/// -public partial class HomePageViewModel : ViewModelBase, IDisposable -{ - /// - /// 搜索功能ViewModel - /// - public HomeSearchViewModel Search { get; } - - /// - /// 热门股票ViewModel - /// - public HotStocksViewModel HotStocks { get; } - - /// - /// 最近查看ViewModel - /// - public RecentStocksViewModel RecentStocks { get; } - - /// - /// 新闻快讯ViewModel - /// - public TelegraphNewsViewModel News { get; } - - /// - /// 构造函数(使用依赖注入) - /// - public HomePageViewModel( - HomeSearchViewModel searchViewModel, - HotStocksViewModel hotStocksViewModel, - RecentStocksViewModel recentStocksViewModel, - TelegraphNewsViewModel newsViewModel, - ILogger logger) : base(logger) - { - Search = searchViewModel; - HotStocks = hotStocksViewModel; - RecentStocks = recentStocksViewModel; - News = newsViewModel; - - // 订阅子ViewModel事件 - Search.StockSelected += OnStockSelected; - HotStocks.HotStockSelected += OnHotStockSelected; - RecentStocks.RecentStockSelected += OnRecentStockSelected; - } - - /// - /// 处理搜索股票选择事件 - /// - private void OnStockSelected(object? sender, StockItem stock) - { - NavigateToStock(stock); - } - - /// - /// 处理热门股票选择事件 - /// - private void OnHotStockSelected(object? sender, HotStock stock) - { - var stockCode = $"{stock.Market}{stock.Code}".ToLower(); - var stockItem = new StockItem { Name = stock.Name, Code = stockCode }; - NavigateToStock(stockItem); - } - - /// - /// 处理最近股票选择事件 - /// - private void OnRecentStockSelected(object? sender, StockItem stock) - { - NavigateToStock(stock); - } - - /// - /// 导航到股票详情页 - /// - private void NavigateToStock(StockItem stockItem) - { - // 立即发送导航消息,不阻塞UI - WeakReferenceMessenger.Default.Send( - new NavigationMessage("Stock", new StockNavigationParameter(stockItem.Code, stockItem.Name))); - - Logger?.LogInformation($"导航到股票详情页: {stockItem.Code}"); - - // 异步添加到最近查看,不阻塞导航 - _ = Task.Run(() => RecentStocks.AddToRecentStocks(stockItem)); - } - - /// - /// 启动定时器 - /// - public void StartTimer() - { - News.StartUpdates(); - Logger?.LogInformation("新闻更新服务已启动"); - } - - /// - /// 暂停定时器 - /// - public void StopTimer() - { - News.StopUpdates(); - Logger?.LogInformation("新闻更新服务已暂停"); - } - - /// - /// 刷新热门股票 - /// - public async Task RefreshHotStocksAsync() - { - await HotStocks.LoadHotStocksAsync(); - } - - /// - /// 刷新最近查看股票 - /// - public void RefreshRecentStocks() - { - RecentStocks.LoadRecentStocks(); - } - - /// - /// 释放资源 - /// - public void Dispose() - { - // 取消事件订阅 - Search.StockSelected -= OnStockSelected; - HotStocks.HotStockSelected -= OnHotStockSelected; - RecentStocks.RecentStockSelected -= OnRecentStockSelected; - - // 释放子ViewModel资源 - News.Dispose(); - - GC.SuppressFinalize(this); - } -} \ No newline at end of file diff --git a/src/ViewModels/MainWindowViewModel.cs b/src/ViewModels/MainWindowViewModel.cs deleted file mode 100644 index 655b08a..0000000 --- a/src/ViewModels/MainWindowViewModel.cs +++ /dev/null @@ -1,116 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using MarketAssistant.Services.Navigation; -using MarketAssistant.ViewModels.Demo; -using Microsoft.Extensions.Logging; -using System.Collections.ObjectModel; -using System.ComponentModel; - -namespace MarketAssistant.ViewModels -{ - public partial class MainWindowViewModel : ViewModelBase - { - private readonly IServiceProvider _serviceProvider; - private readonly NavigationService _navigationService; - - [ObservableProperty] - private NavigationItemViewModel? _selectedNavigationItem; - - public ViewModelBase? CurrentPage => _navigationService.CurrentPage; - public bool CanGoBack => _navigationService.CanGoBack; - public string CurrentPageTitle => _navigationService.CurrentPage?.Title ?? string.Empty; - - public ObservableCollection NavigationItems { get; } - - public MainWindowViewModel( - IServiceProvider serviceProvider, - NavigationService navigationService, - ILogger? logger = null) - : base(logger) - { - _serviceProvider = serviceProvider; - _navigationService = navigationService; - - NavigationItems = new ObservableCollection - { -#if DEBUG - new NavigationItemViewModel("Chat Demo", "avares://MarketAssistant/Assets/Images/tab_analysis.svg", "avares://MarketAssistant/Assets/Images/tab_analysis_on.svg", () => new ChatSidebarDemoViewModel()), -#endif - new NavigationItemViewModel("首页", "avares://MarketAssistant/Assets/Images/tab_home.svg", "avares://MarketAssistant/Assets/Images/tab_home_on.svg", () => _serviceProvider.GetRequiredService()), - new NavigationItemViewModel("收藏", "avares://MarketAssistant/Assets/Images/tab_favorites.svg", "avares://MarketAssistant/Assets/Images/tab_favorites_on.svg", () => _serviceProvider.GetRequiredService()), - new NavigationItemViewModel("AI选股", "avares://MarketAssistant/Assets/Images/tab_analysis.svg", "avares://MarketAssistant/Assets/Images/tab_analysis_on.svg", () => _serviceProvider.GetRequiredService()), - new NavigationItemViewModel("设置", "avares://MarketAssistant/Assets/Images/tab_settings.svg", "avares://MarketAssistant/Assets/Images/tab_settings_on.svg", () => _serviceProvider.GetRequiredService()), - new NavigationItemViewModel("关于", "avares://MarketAssistant/Assets/Images/tab_about.svg", "avares://MarketAssistant/Assets/Images/tab_about_on.svg", () => _serviceProvider.GetRequiredService()) - }; - - // 监听导航服务属性变更 - _navigationService.PropertyChanged += OnNavigationServicePropertyChanged; - - // 默认导航到首页 - SelectedNavigationItem = NavigationItems[0]; - var homeViewModel = SelectedNavigationItem.CreateViewModel(); - _navigationService.NavigateToRoot(homeViewModel, SelectedNavigationItem.Title); - } - - private void OnNavigationServicePropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(NavigationService.CurrentPage)) - { - OnPropertyChanged(nameof(CurrentPage)); - OnPropertyChanged(nameof(CurrentPageTitle)); - } - else if (e.PropertyName == nameof(NavigationService.CanGoBack)) - { - OnPropertyChanged(nameof(CanGoBack)); - } - else if (e.PropertyName == nameof(NavigationService.CurrentRootNavigationItemTitle)) - { - if (_navigationService.CurrentRootNavigationItemTitle != null) - { - SelectedNavigationItem = NavigationItems.FirstOrDefault( - item => item.Title == _navigationService.CurrentRootNavigationItemTitle); - } - } - } - - /// - /// 返回命令 - /// - [RelayCommand] - private void GoBack() - { - _navigationService.GoBack(); - } - - partial void OnSelectedNavigationItemChanged(NavigationItemViewModel? value) - { - if (value != null) - { - // 避免重复导航 - if (_navigationService.CurrentRootNavigationItemTitle == value.Title) - { - return; - } - - var viewModel = value.CreateViewModel(); - _navigationService.NavigateToRoot(viewModel, value.Title); - } - } - } - - public class NavigationItemViewModel : ViewModelBase - { - public override string Title { get; } - public string IconPath { get; } - public string SelectedIconPath { get; } - public Func CreateViewModel { get; } - - public NavigationItemViewModel(string title, string iconPath, string selectedIconPath, Func createViewModel) - { - Title = title; - IconPath = iconPath; - SelectedIconPath = selectedIconPath; - CreateViewModel = createViewModel; - } - } -} diff --git a/src/ViewModels/StockPageViewModel.cs b/src/ViewModels/StockPageViewModel.cs deleted file mode 100644 index 8ae3a1d..0000000 --- a/src/ViewModels/StockPageViewModel.cs +++ /dev/null @@ -1,252 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using CommunityToolkit.Mvvm.Messaging; -using MarketAssistant.Applications.Stocks; -using MarketAssistant.Applications.Stocks.Models; -using MarketAssistant.Infrastructure; -using MarketAssistant.Infrastructure.Core; -using MarketAssistant.Services.Navigation; -using Microsoft.Extensions.Logging; -using System.Collections.ObjectModel; - -namespace MarketAssistant.ViewModels; - -/// -/// 股票详情页ViewModel -/// -public partial class StockPageViewModel : ViewModelBase, INavigationAware -{ - public override string Title => "股票详情"; - - private readonly StockKLineService _stockKLineService; - private CancellationTokenSource? _loadingCancellationTokenSource; - - [ObservableProperty] - private KLineType _currentKLineType = KLineType.Daily; - - [ObservableProperty] - private string _stockCode = ""; - - [ObservableProperty] - private string _stockName = string.Empty; - - [ObservableProperty] - private string _errorMessage = string.Empty; - - [ObservableProperty] - private bool _hasError; - - [ObservableProperty] - private StockKLineDataSet? _kLineDataSet; - - [ObservableProperty] - private ObservableCollection _kLineData = new(); - - [ObservableProperty] - private decimal _currentPrice; - - [ObservableProperty] - private decimal _priceChangePercent; - - [ObservableProperty] - private decimal _priceChange; - - /// - /// 计算属性用于UI绑定 - /// - public bool IsMinuteSelected => CurrentKLineType == KLineType.Minute15; - public bool IsDailySelected => CurrentKLineType == KLineType.Daily; - public bool IsWeeklySelected => CurrentKLineType == KLineType.Weekly; - public bool IsMonthlySelected => CurrentKLineType == KLineType.Monthly; - - public IRelayCommand ChangeKLineTypeCommand { get; private set; } - public IRelayCommand NavigateToAnalysisCommand { get; private set; } - - public StockPageViewModel( - ILogger logger, - StockKLineService stockKLineService) : base(logger) - { - _stockKLineService = stockKLineService; - - ChangeKLineTypeCommand = new RelayCommand(ChangeKLineTypeAsync); - NavigateToAnalysisCommand = new RelayCommand(NavigateToAnalysisAsync); - } - - /// - /// 设置股票代码(异步加载数据,避免阻塞UI) - /// - private void SetStockCode(string code) - { - StockCode = code; - if (!string.IsNullOrEmpty(code)) - { - // 立即开始异步加载数据 - _ = LoadStockDataAsync(code); - } - } - - /// - /// 当K线类型变化时通知相关UI属性 - /// - partial void OnCurrentKLineTypeChanged(KLineType value) - { - OnPropertyChanged(nameof(IsMinuteSelected)); - OnPropertyChanged(nameof(IsDailySelected)); - OnPropertyChanged(nameof(IsWeeklySelected)); - OnPropertyChanged(nameof(IsMonthlySelected)); - - if (!string.IsNullOrEmpty(StockCode)) - { - _ = LoadStockDataAsync(StockCode); - } - } - - /// - /// 刷新股票数据 - /// - [RelayCommand] - private async Task RefreshDataAsync() - { - if (!string.IsNullOrEmpty(StockCode)) - { - await SafeExecuteAsync(async () => await LoadStockDataAsync(StockCode), "刷新数据"); - } - } - - /// - /// 导航到股票详情页 - /// - private void NavigateToAnalysisAsync() - { - if (string.IsNullOrEmpty(StockCode)) - return; - - // 发送导航消息到分析页面 - WeakReferenceMessenger.Default.Send(new NavigationMessage("Analysis", new StockNavigationParameter(StockCode, StockName))); - } - - /// - /// 改变K线类型 - /// - private void ChangeKLineTypeAsync(string? type) - { - if (string.IsNullOrEmpty(type)) - return; - - var newKLineType = type.ToLower() switch - { - "minute" => KLineType.Minute15, - "daily" => KLineType.Daily, - "weekly" => KLineType.Weekly, - "monthly" => KLineType.Monthly, - _ => CurrentKLineType - }; - - if (newKLineType != CurrentKLineType) - { - CurrentKLineType = newKLineType; - } - } - - /// - /// 加载股票K线数据 - /// - private async Task LoadStockDataAsync(string stockCode) - { - if (string.IsNullOrEmpty(stockCode)) - return; - - // 取消之前的加载操作 - _loadingCancellationTokenSource?.Cancel(); - _loadingCancellationTokenSource = new CancellationTokenSource(); - var cancellationToken = _loadingCancellationTokenSource.Token; - - IsBusy = true; - HasError = false; - ErrorMessage = string.Empty; - - try - { - var kLineDataSet = CurrentKLineType switch - { - KLineType.Minute15 => await _stockKLineService.GetMinuteKLineDataAsync(stockCode, "15"), - KLineType.Weekly => await _stockKLineService.GetWeeklyKLineDataAsync(stockCode), - KLineType.Monthly => await _stockKLineService.GetMonthlyKLineDataAsync(stockCode), - _ => await _stockKLineService.GetDailyKLineDataAsync(stockCode) - }; - - // 检查是否已被取消 - cancellationToken.ThrowIfCancellationRequested(); - - KLineDataSet = kLineDataSet; - KLineData = new ObservableCollection(kLineDataSet.Data); - - // 计算价格信息 - CalculatePriceInfo(kLineDataSet.Data); - } - catch (OperationCanceledException) - { - // 取消操作,不显示错误 - Logger?.LogInformation("股票 {StockCode} 的K线数据加载已取消", stockCode); - } - catch (Exception ex) - { - // 设置错误状态 - HasError = true; - ErrorMessage = ErrorMessageMapper.GetUserFriendlyMessageWithContext(ex, "加载K线数据"); - Logger?.LogError(ex, "加载股票 {StockCode} 的K线数据时发生错误", stockCode); - } - finally - { - IsBusy = false; - } - } - - /// - /// 计算价格相关信息 - /// - private void CalculatePriceInfo(List data) - { - if (data.Count == 0) - return; - - var latestData = data.Last(); - CurrentPrice = latestData.Close; - - if (data.Count > 1) - { - var previousData = data[data.Count - 2]; - PriceChange = latestData.Close - previousData.Close; - PriceChangePercent = previousData.Close != 0 ? - Math.Round((latestData.Close - previousData.Close) / previousData.Close * 100, 2) : 0; - } - else - { - PriceChange = 0; - PriceChangePercent = 0; - } - } - - public void OnNavigatedTo(StockNavigationParameter parameter) - { - if (!string.IsNullOrEmpty(parameter.StockCode)) - { - if (!string.IsNullOrEmpty(parameter.StockName)) - { - StockName = parameter.StockName; - } - else - { - // 如果没有传递名称,重置为空或保持代码 - StockName = parameter.StockCode; - } - SetStockCode(parameter.StockCode); - } - } - - public void OnNavigatedFrom() - { - // 离开页面时可以取消正在进行的加载 - _loadingCancellationTokenSource?.Cancel(); - } -} diff --git a/src/Views/Components/ProgressDisplayView.axaml.cs b/src/Views/Components/ProgressDisplayView.axaml.cs deleted file mode 100644 index f268c9a..0000000 --- a/src/Views/Components/ProgressDisplayView.axaml.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Avalonia; -using Avalonia.Controls; - -namespace MarketAssistant.Views.Components; - -/// -/// 进度显示视图 -/// -public partial class ProgressDisplayView : UserControl -{ - public static readonly StyledProperty IsAnalysisInProgressProperty = - AvaloniaProperty.Register(nameof(IsAnalysisInProgress), false); - - public static readonly StyledProperty AnalysisStageProperty = - AvaloniaProperty.Register(nameof(AnalysisStage), string.Empty); - - public bool IsAnalysisInProgress - { - get => GetValue(IsAnalysisInProgressProperty); - set => SetValue(IsAnalysisInProgressProperty, value); - } - - public string AnalysisStage - { - get => GetValue(AnalysisStageProperty); - set => SetValue(AnalysisStageProperty, value); - } - - public ProgressDisplayView() - { - InitializeComponent(); - } -} - diff --git a/src/Views/Pages/AgentAnalysisPageView.axaml b/src/Views/Pages/AgentAnalysisPageView.axaml deleted file mode 100644 index 6ed39cb..0000000 --- a/src/Views/Pages/AgentAnalysisPageView.axaml +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - [TestMethod] + [TestCategory("Unit")] public async Task CacheAnalysisAsync_ShouldSaveDataSuccessfully() { // Arrange - var stockSymbol = "AAPL"; - var analysisResult = CreateTestAnalysisReport(stockSymbol); + var assetSymbol = "AAPL"; + var analysisResult = CreateTestAnalysisReport(assetSymbol); // Act - await _cacheService.CacheAnalysisAsync(stockSymbol, analysisResult); + await _cacheService.CacheAnalysisAsync(assetSymbol, analysisResult); // Assert - var cachedResult = await _cacheService.GetCachedAnalysisAsync(stockSymbol); + var cachedResult = await _cacheService.GetCachedAnalysisAsync(assetSymbol); Assert.IsNotNull(cachedResult); - Assert.AreEqual(stockSymbol, cachedResult.StockSymbol); + Assert.AreEqual(assetSymbol, cachedResult.AssetSymbol); Assert.AreEqual(InvestmentRating.Buy, cachedResult.CoordinatorResult.InvestmentRating); } @@ -56,19 +68,20 @@ public async Task CacheAnalysisAsync_ShouldSaveDataSuccessfully() /// 测试缓存分析数据的读取功能 /// [TestMethod] + [TestCategory("Unit")] public async Task GetCachedAnalysisAsync_ShouldReturnCorrectData() { // Arrange - var stockSymbol = "MSFT"; - var analysisResult = CreateTestAnalysisReport(stockSymbol); - await _cacheService.CacheAnalysisAsync(stockSymbol, analysisResult); + var assetSymbol = "MSFT"; + var analysisResult = CreateTestAnalysisReport(assetSymbol); + await _cacheService.CacheAnalysisAsync(assetSymbol, analysisResult); // Act - var result = await _cacheService.GetCachedAnalysisAsync(stockSymbol); + var result = await _cacheService.GetCachedAnalysisAsync(assetSymbol); // Assert Assert.IsNotNull(result); - Assert.AreEqual(stockSymbol, result.StockSymbol); + Assert.AreEqual(assetSymbol, result.AssetSymbol); Assert.AreEqual(8.5f, result.CoordinatorResult.OverallScore); } @@ -76,6 +89,7 @@ public async Task GetCachedAnalysisAsync_ShouldReturnCorrectData() /// 测试读取不存在的缓存数据 /// [TestMethod] + [TestCategory("Unit")] public async Task GetCachedAnalysisAsync_WithNonExistentStock_ShouldReturnNull() { // Act @@ -89,30 +103,31 @@ public async Task GetCachedAnalysisAsync_WithNonExistentStock_ShouldReturnNull() /// 测试缓存数据的覆盖写入功能 /// [TestMethod] + [TestCategory("Unit")] public async Task CacheAnalysisAsync_ShouldOverwriteExistingData() { // Arrange - var stockSymbol = "GOOGL"; - var firstResult = CreateTestAnalysisReport(stockSymbol); + var assetSymbol = "GOOGL"; + var firstResult = CreateTestAnalysisReport(assetSymbol); firstResult.CoordinatorResult.InvestmentRating = InvestmentRating.Sell; - var secondResult = CreateTestAnalysisReport(stockSymbol); + var secondResult = CreateTestAnalysisReport(assetSymbol); secondResult.CoordinatorResult.InvestmentRating = InvestmentRating.Buy; // Act - await _cacheService.CacheAnalysisAsync(stockSymbol, firstResult); - await _cacheService.CacheAnalysisAsync(stockSymbol, secondResult); + await _cacheService.CacheAnalysisAsync(assetSymbol, firstResult); + await _cacheService.CacheAnalysisAsync(assetSymbol, secondResult); // Assert - var cachedResult = await _cacheService.GetCachedAnalysisAsync(stockSymbol); + var cachedResult = await _cacheService.GetCachedAnalysisAsync(assetSymbol); Assert.IsNotNull(cachedResult); Assert.AreEqual(InvestmentRating.Buy, cachedResult.CoordinatorResult.InvestmentRating); } - private MarketAnalysisReport CreateTestAnalysisReport(string stockSymbol) + private MarketAnalysisReport CreateTestAnalysisReport(string assetSymbol) { return new MarketAnalysisReport { - StockSymbol = stockSymbol, + AssetSymbol = assetSymbol, AnalystMessages = new List { new(ChatRole.Assistant, "Test content") { AuthorName = "TestAnalyst" } diff --git a/tests/AnalystAgentFactoryTest.cs b/tests/AnalystAgentFactoryTest.cs index 363a458..f33e1f0 100644 --- a/tests/AnalystAgentFactoryTest.cs +++ b/tests/AnalystAgentFactoryTest.cs @@ -1,5 +1,6 @@ using MarketAssistant.Agents.Analysts; using MarketAssistant.Infrastructure.Factories; +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -7,117 +8,74 @@ namespace TestMarketAssistant; /// /// AnalystAgentFactory 工具调用验证测试 -/// 验证创建的 Agent 是否正确配置了工具,以及工具调用是否符合预期 +/// 使用 MAF Evaluation 框架验证 Agent 工具调用与响应质量 +/// 需要 OPENAI_API_KEY 环境变量;缺失时自动跳过 /// [TestClass] public class AnalystAgentFactoryTest : BaseAgentTest { + private const string StockSymbol = "sz002594"; + [TestMethod] + [TestCategory("Agent")] public void TestAnalystAgentFactory_CreateFinancialAnalyst_ShouldSucceed() { + RequireLlm(); var agentFactory = _serviceProvider.GetRequiredService(); - - var agent = agentFactory.CreateAnalyst(); + var agent = agentFactory.CreateAnalyst(typeof(FinancialAnalystAgent)); Assert.IsNotNull(agent, "应该成功创建 FinancialAnalyst"); - Console.WriteLine("成功创建 FinancialAnalyst"); } [TestMethod] + [TestCategory("Agent")] public async Task TestNewsEventAnalyst_CallsNewsToolCorrectly() { - var agentFactory = _serviceProvider.GetRequiredService(); - var agent = agentFactory.CreateAnalyst(); - - // 模拟调用,提示明确要求使用工具 - var messages = new List - { - new(ChatRole.User, "请对股票 sz002594 进行专业分析,提供投资建议。") - }; + RequireLlm(); - try - { - var response = await agent.RunAsync(messages); - var result = response.Messages.LastOrDefault()?.Text ?? string.Empty; + var agent = _analystAgentFactory.CreateAnalyst(typeof(NewsEventAnalystAgent)); + Assert.IsNotNull(agent); - Assert.IsFalse(string.IsNullOrWhiteSpace(result), "Agent 应该返回分析结果"); - Console.WriteLine("分析结果:"); - Console.WriteLine(result); + var evaluator = CreateAnalystEvaluator( + EvalChecks.ToolCalledCheck(ToolCalledMode.Any, "GetNewsAsync"), + MeaningfulJsonResponseCheck()); - // 检查是否有工具调用的痕迹(通过日志中间件记录) - var functionCalls = response.Messages - .Where(m => m.Contents.Any(c => c is FunctionCallContent)) - .ToList(); + var results = await agent.EvaluateAsync( + ["请对股票 sz002594 进行专业分析,提供投资建议。"], + evaluator); - if (functionCalls.Any()) - { - Console.WriteLine($"\n检测到 {functionCalls.Count} 次工具调用"); - } - } - catch (Exception ex) - { - Console.WriteLine($"测试执行异常: {ex.Message}"); - // 即使工具调用失败,我们也可以验证 agent 配置是否正确 - } + results.AssertAllPassed(); } [TestMethod] + [TestCategory("Agent")] public async Task TestFundamentalAnalyst_CallsToolsCorrectly() { - var agentFactory = _serviceProvider.GetRequiredService(); - var agent = agentFactory.CreateAnalyst(); - - Assert.IsNotNull(agent, "应该成功创建 FundamentalAnalyst"); - Console.WriteLine("成功创建 FundamentalAnalyst"); - - // 模拟调用,提示明确要求使用工具进行基本面分析 - var messages = new List - { - new(ChatRole.User, "请对股票 sz002594 进行基本面分析,评估其投资价值、行业地位和增长潜力。") - }; + RequireLlm(); - try - { - var response = await agent.RunAsync(messages); - var result = response.Messages.LastOrDefault()?.Text ?? string.Empty; + var agent = _analystAgentFactory.CreateAnalyst(typeof(FundamentalAnalystAgent)); + Assert.IsNotNull(agent); - Assert.IsFalse(string.IsNullOrWhiteSpace(result), "FundamentalAnalyst 应该返回分析结果"); - Console.WriteLine("基本面分析结果:"); - Console.WriteLine(result); + var evaluator = CreateAnalystEvaluator( + EvalChecks.ToolCalledCheck(ToolCalledMode.Any, "GetAssetInfoAsync", "GetCompanyInfoAsync"), + MeaningfulJsonResponseCheck()); - // 检查是否有工具调用的痕迹(通过日志中间件记录) - var functionCalls = response.Messages - .Where(m => m.Contents.Any(c => c is FunctionCallContent)) - .ToList(); + var results = await agent.EvaluateAsync( + [$"请对股票 {StockSymbol} 进行基本面分析,评估其投资价值、行业地位和增长潜力。"], + evaluator); - if (functionCalls.Any()) - { - Console.WriteLine($"\n检测到 {functionCalls.Count} 次工具调用"); - foreach (var call in functionCalls) - { - var functionContent = call.Contents.OfType().FirstOrDefault(); - if (functionContent != null) - { - Console.WriteLine($"- 调用工具: {functionContent.Name}"); - } - } - } - } - catch (Exception ex) - { - Console.WriteLine($"测试执行异常: {ex.Message}"); - // 即使工具调用失败,我们也可以验证 agent 配置是否正确 - } + results.AssertAllPassed(); } [TestMethod] + [TestCategory("Agent")] public async Task TestCoordinatorAnalyst_HandlesMultipleAnalystInputs() { - var agentFactory = _serviceProvider.GetRequiredService(); - var agent = agentFactory.CreateAnalyst(); + RequireLlm(); + + var agent = _analystAgentFactory.CreateAnalyst(typeof(CoordinatorAnalystAgent)); + Assert.IsNotNull(agent); - // 模拟其他分析师的输出作为历史消息 - // 构造冲突场景:基本面看好,技术面看空 var fundamentalJson = """ { "BasicInfo": { "Symbol": "SH600519", "Name": "贵州茅台" }, @@ -141,137 +99,134 @@ public async Task TestCoordinatorAnalyst_HandlesMultipleAnalystInputs() } """; + const string finalQuery = "请对股票进行综合评估。注意基本面和技术面存在分歧,请分析原因并利用搜索工具验证市场共识,给出最终判断。"; + var messages = new List { new(ChatRole.System, "你将收到来自多位分析师的意见,请综合分析并给出投资建议。"), new(ChatRole.User, $"基本面分析师报告:\n{fundamentalJson}"), new(ChatRole.User, $"技术分析师报告:\n{technicalJson}"), new(ChatRole.User, $"财务分析师报告:\n{financialJson}"), - new(ChatRole.User, "请对股票进行综合评估。注意基本面和技术面存在分歧,请分析原因并利用搜索工具验证市场共识,给出最终判断。") + new(ChatRole.User, finalQuery) }; - try - { - var response = await agent.RunAsync(messages); - var result = response.Messages.LastOrDefault()?.Text ?? string.Empty; - - Assert.IsFalse(string.IsNullOrWhiteSpace(result), "Coordinator 应该返回综合分析结果"); - Console.WriteLine("综合分析结果:"); - Console.WriteLine(result); + var response = await agent.RunAsync(messages); + AssertFunctionCallsPresent(response.Messages); - // 检查是否识别了冲突并使用了搜索工具 - var functionCalls = response.Messages - .Where(m => m.Contents.Any(c => c is FunctionCallContent)) - .ToList(); + var evaluator = CreateAnalystEvaluator( + EvalChecks.ToolCalledCheck(ToolCalledMode.Any, "SearchAsync"), + EvalChecks.NonEmpty(100), + EvalChecks.KeywordCheck("分歧", "综合"), + FunctionEvaluator.Create( + "MeaningfulCoordinatorResponse", + responseText => !string.IsNullOrWhiteSpace(responseText) && responseText.Length >= 100)); - if (functionCalls.Any()) - { - Console.WriteLine($"\nCoordinator 进行了 {functionCalls.Count} 次工具调用以解决分歧"); - foreach (var msg in functionCalls) - { - foreach (var content in msg.Contents.OfType()) - { - Console.WriteLine($"- 调用工具: {content.Name}, 参数: {content.Arguments}"); - } - } - } - else - { - Console.WriteLine("\n警告: Coordinator 未进行工具调用。在理想情况下,面对明显分歧应调用搜索工具。"); - } - } - catch (Exception ex) - { - Console.WriteLine($"测试执行异常: {ex.Message}"); - throw; - } + var results = await agent.EvaluateAsync([response], [finalQuery], evaluator); + results.AssertAllPassed(); } [TestMethod] + [TestCategory("Agent")] public async Task TestFinancialAnalyst_CallsToolsCorrectly() { - var agentFactory = _serviceProvider.GetRequiredService(); - var agent = agentFactory.CreateAnalyst(); - - Assert.IsNotNull(agent, "应该成功创建 FinancialAnalyst"); - Console.WriteLine("成功创建 FinancialAnalyst"); - - var messages = new List - { - new(ChatRole.User, "请对股票 sz002594 进行财务分析,重点关注盈利能力和偿债能力。") - }; - - try - { - var response = await agent.RunAsync(messages); - var result = response.Messages.LastOrDefault()?.Text ?? string.Empty; - - Assert.IsFalse(string.IsNullOrWhiteSpace(result), "FinancialAnalyst 应该返回分析结果"); - Console.WriteLine("财务分析结果:"); - Console.WriteLine(result); - } - catch (Exception ex) - { - Console.WriteLine($"测试执行异常: {ex.Message}"); - } + RequireLlm(); + + var agent = _analystAgentFactory.CreateAnalyst(typeof(FinancialAnalystAgent)); + Assert.IsNotNull(agent); + + var evaluator = CreateAnalystEvaluator( + EvalChecks.ToolCalledCheck( + ToolCalledMode.Any, + "GetBalanceSheetAsync", + "GetIncomeStatementAsync", + "GetCashFlowStatementAsync", + "GetFinancialRatiosAsync", + "GetCapitalStructureAsync"), + MeaningfulJsonResponseCheck()); + + var results = await agent.EvaluateAsync( + [$"请对股票 {StockSymbol} 进行财务分析,重点关注盈利能力和偿债能力。"], + evaluator); + + results.AssertAllPassed(); } [TestMethod] + [TestCategory("Agent")] public async Task TestMarketSentimentAnalyst_CallsToolsCorrectly() { - var agentFactory = _serviceProvider.GetRequiredService(); - var agent = agentFactory.CreateAnalyst(); + RequireLlm(); - Assert.IsNotNull(agent, "应该成功创建 MarketSentimentAnalyst"); - Console.WriteLine("成功创建 MarketSentimentAnalyst"); + var agent = _analystAgentFactory.CreateAnalyst(typeof(MarketSentimentAnalystAgent)); + Assert.IsNotNull(agent); - var messages = new List - { - new(ChatRole.User, "请分析股票 sz002594 的市场情绪和资金流向。") - }; + var evaluator = CreateAnalystEvaluator( + EvalChecks.ToolCalledCheck( + ToolCalledMode.Any, + "GetFundFlowAsync", + "GetBalanceSheetAsync", + "GetIncomeStatementAsync", + "GetFinancialRatiosAsync"), + MeaningfulJsonResponseCheck()); - try - { - var response = await agent.RunAsync(messages); - var result = response.Messages.LastOrDefault()?.Text ?? string.Empty; - - Assert.IsFalse(string.IsNullOrWhiteSpace(result), "MarketSentimentAnalyst 应该返回分析结果"); - Console.WriteLine("市场情绪分析结果:"); - Console.WriteLine(result); - } - catch (Exception ex) - { - Console.WriteLine($"测试执行异常: {ex.Message}"); - } + var results = await agent.EvaluateAsync( + [$"请分析股票 {StockSymbol} 的市场情绪和资金流向。"], + evaluator); + + results.AssertAllPassed(); } [TestMethod] + [TestCategory("Agent")] public async Task TestTechnicalAnalyst_CallsToolsCorrectly() { - var agentFactory = _serviceProvider.GetRequiredService(); - var agent = agentFactory.CreateAnalyst(); - - Assert.IsNotNull(agent, "应该成功创建 TechnicalAnalyst"); - Console.WriteLine("成功创建 TechnicalAnalyst"); + RequireLlm(); + + var agent = _analystAgentFactory.CreateAnalyst(typeof(TechnicalAnalystAgent)); + Assert.IsNotNull(agent); + + var evaluator = CreateAnalystEvaluator( + EvalChecks.ToolCalledCheck( + ToolCalledMode.Any, + "GetKDJAsync", + "GetMACDAsync", + "GetBOLLAsync", + "GetMAAsync", + "GetKLinesAsync"), + MeaningfulJsonResponseCheck()); + + var results = await agent.EvaluateAsync( + [$"请对股票 {StockSymbol} 进行技术面分析,查看K线形态和技术指标。"], + evaluator); + + results.AssertAllPassed(); + } - var messages = new List + private static LocalEvaluator CreateAnalystEvaluator(params EvalCheck[] additionalChecks) + { + var checks = new List { - new(ChatRole.User, "请对股票 sz002594 进行技术面分析,查看K线形态和技术指标。") + EvalChecks.ToolCallsPresent(), + EvalChecks.NonEmpty(50), + FunctionEvaluator.Create( + "HasFunctionCallContent", + item => item.Conversation?.Any(m => m.Contents.Any(c => c is FunctionCallContent)) == true) }; + checks.AddRange(additionalChecks); + return new LocalEvaluator(checks.ToArray()); + } - try - { - var response = await agent.RunAsync(messages); - var result = response.Messages.LastOrDefault()?.Text ?? string.Empty; - - Assert.IsFalse(string.IsNullOrWhiteSpace(result), "TechnicalAnalyst 应该返回分析结果"); - Console.WriteLine("技术分析结果:"); - Console.WriteLine(result); - } - catch (Exception ex) - { - Console.WriteLine($"测试执行异常: {ex.Message}"); - } + private static EvalCheck MeaningfulJsonResponseCheck() => + FunctionEvaluator.Create( + "MeaningfulJsonResponse", + response => !string.IsNullOrWhiteSpace(response) + && response.TrimStart().StartsWith('{') + && response.Length >= 50); + + private static void AssertFunctionCallsPresent(IEnumerable messages) + { + Assert.IsTrue( + messages.Any(m => m.Contents.Any(c => c is FunctionCallContent)), + "Agent 响应应包含工具调用"); } } - diff --git a/tests/Application/AShareKLineServiceTest.cs b/tests/Application/AShareKLineServiceTest.cs new file mode 100644 index 0000000..c0be769 --- /dev/null +++ b/tests/Application/AShareKLineServiceTest.cs @@ -0,0 +1,277 @@ +using System.Net; +using System.Text.Json; +using MarketAssistant.Applications.Charts; +using MarketAssistant.Applications.Charts.Models; +using MarketAssistant.Applications.Settings; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Data; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using Moq.Protected; + +namespace TestMarketAssistant.Application; + +/// +/// AShareKLineService 单元测试(使用 Mock HttpMessageHandler,不依赖真实网络) +/// +[TestClass] +public class AShareKLineServiceTest +{ + private const string SampleKLineJson = """ + [ + {"t":"2025-06-20","o":"38.50","h":"39.20","l":"38.10","c":"39.00","v":"1234567","a":"48000000","pc":"38.40"}, + {"t":"2025-06-23","o":"39.10","h":"39.80","l":"38.90","c":"39.50","v":"987654","a":"39000000","pc":"39.00"}, + {"t":"2025-06-24","o":"39.60","h":"40.10","l":"39.30","c":"39.80","v":"1100000","a":"43800000","pc":"39.50"} + ] + """; + + private static IServiceProvider BuildServiceProvider( + HttpStatusCode statusCode, + string responseContent, + Action? requestInspector = null) + { + var handlerMock = new Mock(); + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync((HttpRequestMessage request, CancellationToken _) => + { + requestInspector?.Invoke(request); + return new HttpResponseMessage(statusCode) + { + Content = new StringContent(responseContent) + }; + }); + + var mockUserSettingService = new Mock(); + mockUserSettingService.Setup(s => s.CurrentSetting) + .Returns(new UserSetting { ZhiTuApiToken = "test-token-123" }); + + var services = new ServiceCollection(); + services.AddSingleton(mockUserSettingService.Object); + services.AddLogging(); + + services.AddHttpClient("ZhiTu", client => + { + client.BaseAddress = new Uri("https://api.zhituapi.com"); + }) + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + + services.AddSingleton(); + + return services.BuildServiceProvider(); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task GetKLineDataAsync_Daily_ShouldParseCorrectly() + { + // Arrange + var sp = BuildServiceProvider(HttpStatusCode.OK, SampleKLineJson); + var service = sp.GetRequiredService(); + + // Act + var result = await service.GetKLineDataAsync("sz002475", KLineType.Daily, 100); + + // Assert + Assert.AreEqual(3, result.Count); + Assert.AreEqual(new DateTime(2025, 6, 20), result[0].Timestamp); + Assert.AreEqual(38.50m, result[0].Open); + Assert.AreEqual(39.20m, result[0].High); + Assert.AreEqual(38.10m, result[0].Low); + Assert.AreEqual(39.00m, result[0].Close); + Assert.AreEqual(1234567m, result[0].Volume); + Assert.AreEqual(48000000m, result[0].Amount); + Assert.AreEqual(38.40m, result[0].PreClose); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task GetKLineDataAsync_Daily_ShouldCalculateChangeCorrectly() + { + // Arrange + var sp = BuildServiceProvider(HttpStatusCode.OK, SampleKLineJson); + var service = sp.GetRequiredService(); + + // Act + var result = await service.GetKLineDataAsync("sz002475", KLineType.Daily, 100); + + // Assert - 第一条: close=39.00, preClose=38.40 → change=0.60, pctChg≈1.5625% + Assert.AreEqual(0.60m, result[0].Change); + Assert.AreEqual(0.60m / 38.40m * 100, result[0].PctChg); + + // 第二条: close=39.50, preClose=39.00 → change=0.50 + Assert.AreEqual(0.50m, result[1].Change); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task GetKLineDataAsync_Daily_ShouldSortByTimestamp() + { + // Arrange - 故意乱序 + var json = """ + [ + {"t":"2025-06-24","o":"39.60","h":"40.10","l":"39.30","c":"39.80","v":"1100000","a":"43800000","pc":"39.50"}, + {"t":"2025-06-20","o":"38.50","h":"39.20","l":"38.10","c":"39.00","v":"1234567","a":"48000000","pc":"38.40"}, + {"t":"2025-06-23","o":"39.10","h":"39.80","l":"38.90","c":"39.50","v":"987654","a":"39000000","pc":"39.00"} + ] + """; + var sp = BuildServiceProvider(HttpStatusCode.OK, json); + var service = sp.GetRequiredService(); + + // Act + var result = await service.GetKLineDataAsync("002475", KLineType.Daily, 100); + + // Assert - 应按时间升序 + Assert.AreEqual(new DateTime(2025, 6, 20), result[0].Timestamp); + Assert.AreEqual(new DateTime(2025, 6, 23), result[1].Timestamp); + Assert.AreEqual(new DateTime(2025, 6, 24), result[2].Timestamp); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task GetKLineDataAsync_ShouldTruncateToCount() + { + // Arrange - 返回 3 条,请求 count=2,应取最后 2 条 + var sp = BuildServiceProvider(HttpStatusCode.OK, SampleKLineJson); + var service = sp.GetRequiredService(); + + // Act + var result = await service.GetKLineDataAsync("sz002475", KLineType.Daily, 2); + + // Assert + Assert.AreEqual(2, result.Count); + Assert.AreEqual(new DateTime(2025, 6, 23), result[0].Timestamp); + Assert.AreEqual(new DateTime(2025, 6, 24), result[1].Timestamp); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task GetKLineDataAsync_ShouldUseRelativeUrl_WithCorrectPath() + { + // Arrange + HttpRequestMessage? capturedRequest = null; + var sp = BuildServiceProvider(HttpStatusCode.OK, SampleKLineJson, req => capturedRequest = req); + var service = sp.GetRequiredService(); + + // Act + await service.GetKLineDataAsync("sz002475", KLineType.Daily, 100); + + // Assert - 验证请求 URL 格式正确 + Assert.IsNotNull(capturedRequest); + var uri = capturedRequest!.RequestUri!; + Assert.IsTrue(uri.IsAbsoluteUri); + Assert.AreEqual("api.zhituapi.com", uri.Host); + Assert.IsTrue(uri.AbsolutePath.StartsWith("/hs/history/002475.SZ/d/n")); + Assert.IsTrue(uri.Query.Contains("token=test-token-123")); + Assert.IsTrue(uri.Query.Contains("st=")); + Assert.IsTrue(uri.Query.Contains("et=")); + } + + [TestMethod] + [TestCategory("Unit")] + [DataRow("sz002475", "002475.SZ")] + [DataRow("SH600519", "600519.SH")] + [DataRow("688001", "688001.SH")] + [DataRow("300750", "300750.SZ")] + [DataRow("600519.SH", "600519.SH")] + public async Task GetKLineDataAsync_ShouldConvertSymbolCorrectly(string inputCode, string expectedInPath) + { + // Arrange + HttpRequestMessage? capturedRequest = null; + var sp = BuildServiceProvider(HttpStatusCode.OK, SampleKLineJson, req => capturedRequest = req); + var service = sp.GetRequiredService(); + + // Act + await service.GetKLineDataAsync(inputCode, KLineType.Daily, 100); + + // Assert + Assert.IsNotNull(capturedRequest); + var path = capturedRequest!.RequestUri!.AbsolutePath; + Assert.IsTrue(path.Contains(expectedInPath), $"URL路径 '{path}' 应包含 '{expectedInPath}'"); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task GetKLineDataAsync_ApiError400_ShouldThrowFriendlyExceptionWithBody() + { + // Arrange + var sp = BuildServiceProvider(HttpStatusCode.BadRequest, "{\"error\":\"invalid token\"}"); + var service = sp.GetRequiredService(); + + // Act & Assert + var ex = await Assert.ThrowsExactlyAsync( + () => service.GetKLineDataAsync("sz002475", KLineType.Daily, 100)); + Assert.IsTrue(ex.Message.Contains("400"), "异常消息应包含状态码"); + Assert.IsTrue(ex.Message.Contains("invalid token"), "异常消息应包含API返回的错误内容"); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task GetKLineDataAsync_ApiError400_EmptyBody_ShouldThrowFriendlyException() + { + // Arrange + var sp = BuildServiceProvider(HttpStatusCode.BadRequest, ""); + var service = sp.GetRequiredService(); + + // Act & Assert + var ex = await Assert.ThrowsExactlyAsync( + () => service.GetKLineDataAsync("sz002475", KLineType.Daily, 100)); + Assert.IsTrue(ex.Message.Contains("400")); + Assert.IsTrue(ex.Message.Contains("请稍后重试")); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task GetKLineDataAsync_EmptyResponse_ShouldThrowFriendlyException() + { + // Arrange + var sp = BuildServiceProvider(HttpStatusCode.OK, "[]"); + var service = sp.GetRequiredService(); + + // Act & Assert + await Assert.ThrowsExactlyAsync( + () => service.GetKLineDataAsync("sz002475", KLineType.Daily, 100)); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task GetKLineDataAsync_EmptyCode_ShouldThrowArgumentException() + { + // Arrange + var sp = BuildServiceProvider(HttpStatusCode.OK, SampleKLineJson); + var service = sp.GetRequiredService(); + + // Act & Assert + await Assert.ThrowsExactlyAsync( + () => service.GetKLineDataAsync("", KLineType.Daily, 100)); + } + + [TestMethod] + [TestCategory("Unit")] + [DataRow(KLineType.Daily, "/d/")] + [DataRow(KLineType.Weekly, "/w/")] + [DataRow(KLineType.Monthly, "/m/")] + [DataRow(KLineType.Minute5, "/5/")] + [DataRow(KLineType.Minute15, "/15/")] + public async Task GetKLineDataAsync_ShouldMapIntervalCorrectly(KLineType kLineType, string expectedInterval) + { + // Arrange + HttpRequestMessage? capturedRequest = null; + var sp = BuildServiceProvider(HttpStatusCode.OK, SampleKLineJson, req => capturedRequest = req); + var service = sp.GetRequiredService(); + + // Act + await service.GetKLineDataAsync("sz002475", kLineType, 100); + + // Assert + Assert.IsNotNull(capturedRequest); + var path = capturedRequest!.RequestUri!.AbsolutePath; + Assert.IsTrue(path.Contains(expectedInterval), $"URL路径 '{path}' 应包含 '{expectedInterval}'"); + } +} diff --git a/tests/Application/AssetCacheServiceTest.cs b/tests/Application/AssetCacheServiceTest.cs new file mode 100644 index 0000000..5fb5d1a --- /dev/null +++ b/tests/Application/AssetCacheServiceTest.cs @@ -0,0 +1,154 @@ +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Applications.Cache; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace TestMarketAssistant.Application; + +/// +/// IAssetCacheService 接口测试(覆盖 A股 和 虚拟币 实现) +/// 注意:因 Clear 操作影响整个 IMemoryCache,本类禁用方法级并行以避免测试间相互干扰 +/// +[TestClass] +[DoNotParallelize] +public class AssetCacheServiceTest +{ + private ServiceProvider? _serviceProvider; + + [TestInitialize] + public void Setup() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMemoryCache(); + + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.Crypto); + + _serviceProvider = services.BuildServiceProvider(); + } + + [TestCleanup] + public async Task Cleanup() + { + // 清理缓存 + var aShareService = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var cryptoService = _serviceProvider.GetRequiredKeyedService(MarketType.Crypto); + + aShareService.Clear(); + cryptoService.Clear(); + + await _serviceProvider.DisposeAsync(); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task CacheAssetInfo_AShare_ShouldStore() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var assetInfo = new AssetInfo + { + Code = "SH600519", + Name = "贵州茅台", + CurrentPrice = "1800.50" + }; + + // Act + service.CacheAssetInfo("SH600519", assetInfo); + var cached = await service.GetCachedAssetInfoAsync("SH600519"); + + // Assert + Assert.IsNotNull(cached); + Assert.AreEqual("SH600519", cached.Code); + Assert.AreEqual("1800.50", cached.CurrentPrice); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task GetCachedAssetInfoAsync_AShare_NotExist_ShouldReturnNull() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act + var cached = await service.GetCachedAssetInfoAsync("NOT_EXIST"); + + // Assert + Assert.IsNull(cached); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task Clear_AShare_ShouldRemoveAllCache() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var assetInfo = new AssetInfo { Code = "SH600519", Name = "贵州茅台" }; + service.CacheAssetInfo("SH600519", assetInfo); + + // Act + service.Clear(); + var cached = await service.GetCachedAssetInfoAsync("SH600519"); + + // Assert + Assert.IsNull(cached); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task CacheAssetInfo_Crypto_ShouldStore() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var assetInfo = new AssetInfo + { + Code = "BTCUSDT", + Name = "Bitcoin", + CurrentPrice = "45000.00" + }; + + // Act + service.CacheAssetInfo("BTCUSDT", assetInfo); + var cached = await service.GetCachedAssetInfoAsync("BTCUSDT"); + + // Assert + Assert.IsNotNull(cached); + Assert.AreEqual("BTCUSDT", cached.Code); + Assert.AreEqual("45000.00", cached.CurrentPrice); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task AShareAndCrypto_ShouldHaveSeparateCache() + { + // Arrange + var aShareService = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var cryptoService = _serviceProvider.GetRequiredKeyedService(MarketType.Crypto); + + var aShareAsset = new AssetInfo { Code = "SH600519", Name = "贵州茅台", CurrentPrice = "1800" }; + var cryptoAsset = new AssetInfo { Code = "BTCUSDT", Name = "Bitcoin", CurrentPrice = "45000" }; + + // Act + aShareService.CacheAssetInfo("SH600519", aShareAsset); + cryptoService.CacheAssetInfo("BTCUSDT", cryptoAsset); + + var aShareCached = await aShareService.GetCachedAssetInfoAsync("SH600519"); + var cryptoCached = await cryptoService.GetCachedAssetInfoAsync("BTCUSDT"); + + // Assert + Assert.IsNotNull(aShareCached); + Assert.IsNotNull(cryptoCached); + Assert.AreEqual("SH600519", aShareCached.Code); + Assert.AreEqual("BTCUSDT", cryptoCached.Code); + + // 验证隔离性 + var aShareNotExist = await aShareService.GetCachedAssetInfoAsync("BTCUSDT"); + var cryptoNotExist = await cryptoService.GetCachedAssetInfoAsync("SH600519"); + Assert.IsNull(aShareNotExist); + Assert.IsNull(cryptoNotExist); + } +} diff --git a/tests/Application/AssetHistoryServiceTest.cs b/tests/Application/AssetHistoryServiceTest.cs new file mode 100644 index 0000000..e5c660c --- /dev/null +++ b/tests/Application/AssetHistoryServiceTest.cs @@ -0,0 +1,152 @@ +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Applications.History; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace TestMarketAssistant.Application; + +/// +/// IAssetHistoryService 接口测试(覆盖 A股 和 虚拟币 实现) +/// +[TestClass] +[DoNotParallelize] +public class AssetHistoryServiceTest +{ + private ServiceProvider? _serviceProvider; + + [TestInitialize] + public void Setup() + { + var services = new ServiceCollection(); + services.AddLogging(); + + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.Crypto); + + _serviceProvider = services.BuildServiceProvider(); + } + + [TestCleanup] + public async Task Cleanup() + { + var aShareService = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var cryptoService = _serviceProvider.GetRequiredKeyedService(MarketType.Crypto); + + await aShareService.ClearHistoryAsync(); + await cryptoService.ClearHistoryAsync(); + + await _serviceProvider.DisposeAsync(); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task AddHistory_AShare_ShouldStoreAsset() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var asset = new AssetItem { Code = "SH600519", Name = "贵州茅台" }; + + // Act + await service.AddHistoryAsync(asset); + var history = await service.GetHistoryAsync(); + + // Assert + Assert.IsNotNull(history); + Assert.AreEqual(1, history.Count); + Assert.AreEqual("SH600519", history[0].Code); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task GetHistory_AShare_ShouldReturnRecentAssets() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + await service.AddHistoryAsync(new AssetItem { Code = "SH600519", Name = "贵州茅台" }); + await service.AddHistoryAsync(new AssetItem { Code = "SH600036", Name = "招商银行" }); + + // Act + var history = await service.GetHistoryAsync(); + + // Assert + Assert.IsNotNull(history); + Assert.AreEqual(2, history.Count); + Assert.AreEqual("SH600036", history[0].Code); // 最新的在前面 + } + + [TestMethod] + [TestCategory("Unit")] + public async Task ClearHistory_AShare_ShouldRemoveAllRecords() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + await service.AddHistoryAsync(new AssetItem { Code = "SH600519", Name = "贵州茅台" }); + + // Act + await service.ClearHistoryAsync(); + var history = await service.GetHistoryAsync(); + + // Assert + Assert.IsNotNull(history); + Assert.AreEqual(0, history.Count); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task AddHistory_Crypto_ShouldStoreAsset() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var asset = new AssetItem { Code = "BTCUSDT", Name = "Bitcoin" }; + + // Act + await service.AddHistoryAsync(asset); + var history = await service.GetHistoryAsync(); + + // Assert + Assert.IsNotNull(history); + Assert.AreEqual(1, history.Count); + Assert.AreEqual("BTCUSDT", history[0].Code); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task GetHistory_Crypto_ShouldReturnRecentAssets() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + await service.AddHistoryAsync(new AssetItem { Code = "BTCUSDT", Name = "Bitcoin" }); + await service.AddHistoryAsync(new AssetItem { Code = "ETHUSDT", Name = "Ethereum" }); + + // Act + var history = await service.GetHistoryAsync(); + + // Assert + Assert.IsNotNull(history); + Assert.AreEqual(2, history.Count); + Assert.AreEqual("ETHUSDT", history[0].Code); // 最新的在前面 + } + + [TestMethod] + [TestCategory("Unit")] + public async Task AShareAndCrypto_ShouldHaveSeparateStorage() + { + // Arrange + var aShareService = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var cryptoService = _serviceProvider.GetRequiredKeyedService(MarketType.Crypto); + + // Act + await aShareService.AddHistoryAsync(new AssetItem { Code = "SH600519", Name = "贵州茅台" }); + await cryptoService.AddHistoryAsync(new AssetItem { Code = "BTCUSDT", Name = "Bitcoin" }); + + var aShareHistory = await aShareService.GetHistoryAsync(); + var cryptoHistory = await cryptoService.GetHistoryAsync(); + + // Assert + Assert.AreEqual(1, aShareHistory.Count); + Assert.AreEqual(1, cryptoHistory.Count); + Assert.AreEqual("SH600519", aShareHistory[0].Code); + Assert.AreEqual("BTCUSDT", cryptoHistory[0].Code); + } +} diff --git a/tests/Application/AssetInfoServiceTest.cs b/tests/Application/AssetInfoServiceTest.cs new file mode 100644 index 0000000..11a502a --- /dev/null +++ b/tests/Application/AssetInfoServiceTest.cs @@ -0,0 +1,155 @@ +using MarketAssistant.Applications.Assets; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Data; +using MarketAssistant.Services.Market; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.DependencyInjection; + +namespace TestMarketAssistant.Application; + +/// +/// IAssetInfoService 接口测试(覆盖 A股 和 虚拟币 实现) +/// +[TestClass] +public class AssetInfoServiceTest +{ + private ServiceProvider? _serviceProvider; + private MarketContext? _marketContext; + + [TestInitialize] + public void Setup() + { + var services = new ServiceCollection(); + + // 注册依赖服务 + services.AddHttpClient(); + services.AddTestMarketDataHttpClients(); + services.AddMemoryCache(); + services.AddLogging(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // 注册被测试的服务 + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.Crypto); + + _serviceProvider = services.BuildServiceProvider(); + _marketContext = _serviceProvider.GetRequiredService(); + } + + [TestCleanup] + public async Task Cleanup() + { + if (_serviceProvider != null) + { + await _serviceProvider.DisposeAsync(); + } + } + + [TestMethod] + [TestCategory("Integration")] + public async Task SearchAsync_AShare_ShouldReturnResults() + { + // Arrange + _marketContext!.SwitchMarket(MarketType.AShare); + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act + var results = await service.SearchAsync("贵州茅台"); + + // Assert + Assert.IsNotNull(results); + Assert.IsTrue(results.Count > 0, "搜索'贵州茅台'应返回至少一条结果"); + Assert.IsTrue(results.All(r => !string.IsNullOrWhiteSpace(r.Code)), "所有结果应包含有效代码"); + Assert.IsTrue(results.Any(r => r.Name.Contains("茅台")), "结果中应包含茅台相关股票"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetAssetInfoAsync_AShare_ShouldReturnAssetDetails() + { + // Arrange + _marketContext!.SwitchMarket(MarketType.AShare); + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act + var assetInfo = await service.GetAssetInfoAsync("SH600519"); + + // Assert + Assert.IsNotNull(assetInfo); + Assert.AreEqual(MarketType.AShare, assetInfo.MarketType); + Assert.IsFalse(string.IsNullOrWhiteSpace(assetInfo.Code)); + Assert.IsFalse(string.IsNullOrWhiteSpace(assetInfo.Name)); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetHotAssetsAsync_AShare_ShouldReturnHotList() + { + // Arrange + _marketContext!.SwitchMarket(MarketType.AShare); + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act + var hotAssets = await service.GetHotAssetsAsync(); + + // Assert + Assert.IsNotNull(hotAssets); + Assert.IsTrue(hotAssets.Count > 0, "A股热门资产列表不应为空"); + Assert.IsTrue(hotAssets.All(h => !string.IsNullOrEmpty(h.Code)), "所有热门资产应包含有效代码"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task SearchAsync_Crypto_ShouldReturnResults() + { + // Arrange + _marketContext!.SwitchMarket(MarketType.Crypto); + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var results = await service.SearchAsync("BTC"); + + // Assert + Assert.IsNotNull(results); + Assert.IsTrue(results.Count > 0, "搜索'BTC'应返回至少一条结果"); + Assert.IsTrue(results.All(r => !string.IsNullOrWhiteSpace(r.Code)), "所有结果应包含有效代码"); + Assert.IsTrue(results.Any(r => r.Code.Contains("BTC")), "结果中应包含 BTC 相关资产"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetAssetInfoAsync_Crypto_ShouldReturnAssetDetails() + { + // Arrange + _marketContext!.SwitchMarket(MarketType.Crypto); + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var assetInfo = await service.GetAssetInfoAsync("BTCUSDT"); + + // Assert + Assert.IsNotNull(assetInfo); + Assert.IsTrue(assetInfo.Code.Contains("BTC"), "返回的代码应包含 BTC"); + Assert.IsFalse(string.IsNullOrEmpty(assetInfo.CurrentPrice), "应返回当前价格"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetHotAssetsAsync_Crypto_ShouldReturnHotList() + { + // Arrange + _marketContext!.SwitchMarket(MarketType.Crypto); + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var hotAssets = await service.GetHotAssetsAsync(); + + // Assert + Assert.IsNotNull(hotAssets); + Assert.IsTrue(hotAssets.Count > 0, "虚拟币热门资产列表不应为空"); + Assert.IsTrue(hotAssets.All(h => !string.IsNullOrEmpty(h.Code)), "所有热门资产应包含有效代码"); + } +} diff --git a/tests/Application/AssetScreenerServiceTest.cs b/tests/Application/AssetScreenerServiceTest.cs new file mode 100644 index 0000000..028b4a9 --- /dev/null +++ b/tests/Application/AssetScreenerServiceTest.cs @@ -0,0 +1,369 @@ +using MarketAssistant.Applications.AssetScreener; +using MarketAssistant.Applications.AssetScreener.Models; +using MarketAssistant.Applications.Settings; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services; +using MarketAssistant.Services.Data; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; + +namespace TestMarketAssistant.Application; + +/// +/// IAssetScreenerService 接口测试(覆盖 A股 和 虚拟币 实现) +/// +[TestClass] +public sealed class AssetScreenerServiceTest +{ + private ServiceProvider? _serviceProvider; + + [TestInitialize] + public void Setup() + { + var services = new ServiceCollection(); + + // 注册日志服务 + services.AddLogging(); + + // 注册 A股 筛选服务依赖 + var mockUserSettingService = new Mock(); + mockUserSettingService.Setup(s => s.CurrentSetting) + .Returns(new UserSetting + { + ModelId = "test-model", + Endpoint = "http://localhost", + ApiKey = "test-key" + }); + services.AddSingleton(mockUserSettingService.Object); + services.AddLogging(); + services.AddHttpClient(); + services.AddTestMarketDataHttpClients(); + services.AddMemoryCache(); + + // 注册虚拟币筛选服务依赖 + services.AddSingleton(); + services.AddSingleton(); + + // 注册被测试的服务 + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.Crypto); + + _serviceProvider = services.BuildServiceProvider(); + } + + [TestCleanup] + public async Task Cleanup() + { + if (_serviceProvider != null) + { + await _serviceProvider.DisposeAsync(); + } + } + + #region A股筛选测试 + + [TestMethod] + [TestCategory("Integration")] + public void Constructor_AShare_ShouldCreateInstance() + { + // Arrange & Act + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Assert + Assert.IsNotNull(service); + } + + + [TestMethod] + [TestCategory("Integration")] + public async Task ScreenAsync_AShare_WithDefaultCriteria_ShouldReturnStocks() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var criteria = new StockCriteria + { + Market = AShareType.AllAShares, + Industry = IndustryType.All, + Limit = 10, + Criteria = new List() + }; + + // Act + var result = await service.ScreenAsync(criteria); + + // Assert + Assert.IsNotNull(result); + Assert.IsTrue(result.Count > 0, "A股默认条件筛选应返回至少一只股票"); + Assert.IsTrue(result.Count <= criteria.Limit, "返回数量不应超过 Limit"); + Assert.IsTrue(result.All(r => !string.IsNullOrWhiteSpace(r.Symbol)), "所有结果应包含有效代码"); + + Console.WriteLine($"A股默认条件筛选 - 返回股票数量: {result.Count}"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task ScreenAsync_AShare_WithSingleCriteria_ShouldReturnFilteredStocks() + { + // Arrange - 测试单个条件筛选(市值) + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var criteria = new StockCriteria + { + Market = AShareType.AllAShares, + Industry = IndustryType.All, + Limit = 15, + Criteria = new List + { + new() + { + Code = "mc", + DisplayName = "总市值", + MinValue = 5000000000m, // 50亿元 + MaxValue = 25000000000m // 250亿元 + } + } + }; + + // Act + var result = await service.ScreenAsync(criteria); + + // Assert + Assert.IsNotNull(result); + Assert.IsTrue(result.Count > 0, "市值50-250亿筛选应返回至少一只股票"); + Assert.IsTrue(result.Count <= criteria.Limit, "返回数量不应超过 Limit"); + Assert.IsTrue(result.All(r => !string.IsNullOrWhiteSpace(r.Symbol)), "所有结果应包含有效代码"); + + Console.WriteLine($"A股单条件筛选(市值50-250亿) - 返回股票数量: {result.Count}"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task ScreenAsync_AShare_WithMultipleCriteria_ShouldReturnFilteredStocks() + { + // Arrange - 测试多条件组合筛选 + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var criteria = new StockCriteria + { + Market = AShareType.AllAShares, + Industry = IndustryType.All, + Limit = 20, + Criteria = new List + { + new() + { + Code = "mc", + DisplayName = "总市值", + MinValue = 50000000000m, // 500亿元 + MaxValue = 1000000000000m // 1万亿元 + }, + new() + { + Code = "pettm", + DisplayName = "市盈率TTM", + MinValue = 5m, + MaxValue = 50m + } + } + }; + + // Act + var result = await service.ScreenAsync(criteria); + + // Assert + Assert.IsNotNull(result); + Assert.IsTrue(result.Count > 0, "市值500亿-1万亿+PE 5-50 筛选应返回至少一只股票"); + Assert.IsTrue(result.Count <= criteria.Limit, "返回数量不应超过 Limit"); + Assert.IsTrue(result.All(r => !string.IsNullOrWhiteSpace(r.Symbol)), "所有结果应包含有效代码"); + + Console.WriteLine($"A股多条件筛选(市值+PE) - 返回股票数量: {result.Count}"); + } + + #endregion + + #region 虚拟币筛选测试 + + [TestMethod] + [TestCategory("Integration")] + public void Constructor_Crypto_ShouldCreateInstance() + { + // Arrange & Act + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Assert + Assert.IsNotNull(service); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task ScreenAsync_Crypto_WithDefaultCriteria_ShouldReturnCryptos() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var criteria = new CryptoCriteria + { + Limit = 10, + Criteria = new List() + }; + + // Act + var result = await service.ScreenAsync(criteria); + + // Assert + Assert.IsNotNull(result); + Assert.IsTrue(result.Count > 0, "虚拟币默认条件筛选应返回至少一条记录"); + Assert.IsTrue(result.Count <= criteria.Limit, "返回数量不应超过 Limit"); + Assert.IsTrue(result.All(r => !string.IsNullOrEmpty(r.Symbol)), "所有结果应包含有效代码"); + + Console.WriteLine($"虚拟币默认条件筛选 - 返回数量: {result.Count}"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task ScreenAsync_Crypto_WithMarketCapFilter_ShouldReturnFilteredCryptos() + { + // Arrange - 测试市值筛选 + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var criteria = new CryptoCriteria + { + Limit = 15, + Criteria = new List + { + new() + { + Code = "market_cap", + MinValue = 1_000_000_000m, // 10亿美元 + MaxValue = 50_000_000_000m // 500亿美元 + } + } + }; + + // Act + var result = await service.ScreenAsync(criteria); + + // Assert + Assert.IsNotNull(result); + Assert.IsTrue(result.Count > 0, "市值10-500亿美元筛选应返回至少一条记录"); + Assert.IsTrue(result.Count <= criteria.Limit, "返回数量不应超过 Limit"); + Assert.IsTrue(result.All(r => !string.IsNullOrEmpty(r.Symbol)), "所有结果应包含有效代码"); + + Console.WriteLine($"虚拟币市值筛选(10-500亿美元) - 返回数量: {result.Count}"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task ScreenAsync_Crypto_WithPriceChangeFilter_ShouldReturnFilteredCryptos() + { + // Arrange - 测试价格变化筛选 + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var criteria = new CryptoCriteria + { + Limit = 20, + Criteria = new List + { + new() + { + Code = "price_change_24h", + MinValue = -10m, // 跌幅不超过10% + MaxValue = 50m // 涨幅50%以下 + } + } + }; + + // Act + var result = await service.ScreenAsync(criteria); + + // Assert + Assert.IsNotNull(result); + Assert.IsTrue(result.Count > 0, "价格变化筛选应返回至少一条记录"); + Assert.IsTrue(result.Count <= criteria.Limit, "返回数量不应超过 Limit"); + Assert.IsTrue(result.All(r => !string.IsNullOrEmpty(r.Symbol)), "所有结果应包含有效代码"); + + Console.WriteLine($"虚拟币价格变化筛选(-10% ~ +50%) - 返回数量: {result.Count}"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task ScreenAsync_Crypto_WithMultipleCriteria_ShouldReturnFilteredCryptos() + { + // Arrange - 测试多条件组合筛选 + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var criteria = new CryptoCriteria + { + Limit = 15, + Criteria = new List + { + new() + { + Code = "market_cap", + MinValue = 5_000_000_000m, // 50亿美元 + MaxValue = 100_000_000_000m // 1000亿美元 + }, + new() + { + Code = "volume_24h", + MinValue = 100_000_000m // 24小时交易量1亿美元以上 + } + } + }; + + // Act + var result = await service.ScreenAsync(criteria); + + // Assert + Assert.IsNotNull(result); + Assert.IsTrue(result.Count > 0, "市值+交易量组合筛选应返回至少一条记录"); + Assert.IsTrue(result.Count <= criteria.Limit, "返回数量不应超过 Limit"); + Assert.IsTrue(result.All(r => !string.IsNullOrEmpty(r.Symbol)), "所有结果应包含有效代码"); + + Console.WriteLine($"虚拟币多条件筛选(市值+交易量) - 返回数量: {result.Count}"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task ScreenAsync_Crypto_WithMarketCapRankFilter_ShouldReturnTopCryptos() + { + // Arrange - 测试市值排名筛选 + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var criteria = new CryptoCriteria + { + Limit = 10, + Criteria = new List + { + new() + { + Code = "market_cap_rank", + MinValue = 1m, + MaxValue = 50m + } + } + }; + + // Act + var result = await service.ScreenAsync(criteria); + + // Assert + Assert.IsNotNull(result); + Assert.IsTrue(result.Count > 0, "市值排名前50筛选应返回至少一条记录"); + Assert.IsTrue(result.Count <= criteria.Limit, "返回数量不应超过 Limit"); + Assert.IsTrue(result.All(r => !string.IsNullOrEmpty(r.Symbol)), "所有结果应包含有效代码"); + + Console.WriteLine($"虚拟币市值排名筛选(前50名) - 返回数量: {result.Count}"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task ScreenAsync_Crypto_WithInvalidCriteriaType_ShouldThrowArgumentException() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var invalidCriteria = new StockCriteria(); // 错误的类型 + + // Act & Assert + await Assert.ThrowsExactlyAsync( + async () => await service.ScreenAsync(invalidCriteria)); + } + + #endregion +} + diff --git a/tests/Application/FavoriteServiceTest.cs b/tests/Application/FavoriteServiceTest.cs new file mode 100644 index 0000000..3180882 --- /dev/null +++ b/tests/Application/FavoriteServiceTest.cs @@ -0,0 +1,173 @@ +using MarketAssistant.Applications.Assets; +using MarketAssistant.Applications.Favorites; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Data; +using MarketAssistant.Services.Market; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace TestMarketAssistant.Application; + +/// +/// IFavoriteService 接口测试(覆盖 A股 和 虚拟币 实现) +/// +[TestClass] +public class FavoriteServiceTest +{ + private ServiceProvider? _serviceProvider; + + [TestInitialize] + public void Setup() + { + var services = new ServiceCollection(); + + // 注册依赖服务 + services.AddHttpClient(); + services.AddMemoryCache(); + services.AddLogging(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // 注册 AssetInfoService(FavoriteService 的依赖) + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.Crypto); + + // 注册被测试的服务 + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.Crypto); + + _serviceProvider = services.BuildServiceProvider(); + } + + [TestCleanup] + public async Task Cleanup() + { + // 清理收藏 + var aShareService = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var cryptoService = _serviceProvider.GetRequiredKeyedService(MarketType.Crypto); + + await aShareService.ClearFavoritesAsync(); + await cryptoService.ClearFavoritesAsync(); + + await _serviceProvider.DisposeAsync(); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task AddFavorite_AShare_ShouldStoreAsset() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act + await service.AddFavoriteAsync("SH600519", ""); + + // Assert + Assert.IsTrue(await service.IsFavoriteAsync("SH600519", "")); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task RemoveFavorite_AShare_ShouldRemoveAsset() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + await service.AddFavoriteAsync("SH600519", ""); + + // Act + await service.RemoveFavoriteAsync("SH600519", ""); + + // Assert + Assert.IsFalse(await service.IsFavoriteAsync("SH600519", "")); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetFavoritesCodes_AShare_ShouldReturnList() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + await service.AddFavoriteAsync("SH600519", ""); + await service.AddFavoriteAsync("SH600036", ""); + + // Act + var favorites = await service.GetFavoritesCodesAsync(); + + // Assert + Assert.IsNotNull(favorites); + Assert.AreEqual(2, favorites.Count); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetFavoritesWithLatestDataAsync_AShare_ShouldReturnAssetInfo() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + await service.AddFavoriteAsync("SH600519", ""); + + // Act + var favoritesWithData = await service.GetFavoritesWithLatestDataAsync(); + + // Assert + Assert.IsNotNull(favoritesWithData); + Assert.IsTrue(favoritesWithData.Count > 0); + Assert.IsTrue(favoritesWithData.Any(f => f.Code == "SH600519")); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task ClearFavorites_AShare_ShouldRemoveAll() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + await service.AddFavoriteAsync("SH600519", ""); + await service.AddFavoriteAsync("SH600036", ""); + + // Act + await service.ClearFavoritesAsync(); + var favorites = await service.GetFavoritesCodesAsync(); + + // Assert + Assert.IsNotNull(favorites); + Assert.AreEqual(0, favorites.Count); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task AddFavorite_Crypto_ShouldStoreAsset() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + await service.AddFavoriteAsync("BTCUSDT", ""); + + // Assert + Assert.IsTrue(await service.IsFavoriteAsync("BTCUSDT", "")); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task AShareAndCrypto_ShouldHaveSeparateStorage() + { + // Arrange + var aShareService = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var cryptoService = _serviceProvider.GetRequiredKeyedService(MarketType.Crypto); + + // Act + await aShareService.AddFavoriteAsync("SH600519", ""); + await cryptoService.AddFavoriteAsync("BTCUSDT", ""); + + var aShareFavorites = await aShareService.GetFavoritesCodesAsync(); + var cryptoFavorites = await cryptoService.GetFavoritesCodesAsync(); + + // Assert + Assert.AreEqual(1, aShareFavorites.Count); + Assert.AreEqual(1, cryptoFavorites.Count); + Assert.AreEqual("SH600519", aShareFavorites[0].Code); + Assert.AreEqual("BTCUSDT", cryptoFavorites[0].Code); + } +} diff --git a/tests/Application/HomeAssetServiceTest.cs b/tests/Application/HomeAssetServiceTest.cs new file mode 100644 index 0000000..eb1b6b3 --- /dev/null +++ b/tests/Application/HomeAssetServiceTest.cs @@ -0,0 +1,205 @@ +using MarketAssistant.Applications.Assets; +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Applications.Favorites; +using MarketAssistant.Applications.History; +using MarketAssistant.Applications.Home; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Data; +using MarketAssistant.Services.Dialog; +using MarketAssistant.Services.Market; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; + +namespace TestMarketAssistant.Application; + +/// +/// IHomeAssetService 接口测试(覆盖 A股 和 虚拟币 实现) +/// +[TestClass] +public class HomeAssetServiceTest +{ + private ServiceProvider? _serviceProvider; + private MarketContext? _marketContext; + + [TestInitialize] + public void Setup() + { + var services = new ServiceCollection(); + + // 注册依赖服务 + services.AddHttpClient(); + services.AddTestMarketDataHttpClients(); + services.AddMemoryCache(); + services.AddLogging(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(new Mock().Object); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // 注册依赖的服务 + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.Crypto); + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.Crypto); + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.Crypto); + + // 注册被测试的服务 + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.Crypto); + + _serviceProvider = services.BuildServiceProvider(); + _marketContext = _serviceProvider.GetRequiredService(); + } + + [TestCleanup] + public async Task Cleanup() + { + await _serviceProvider?.GetRequiredKeyedService(MarketType.AShare).ClearHistoryAsync(); + await _serviceProvider?.GetRequiredKeyedService(MarketType.Crypto).ClearHistoryAsync(); + + if (_serviceProvider != null) + { + await _serviceProvider.DisposeAsync(); + } + } + + [TestMethod] + [TestCategory("Integration")] + public async Task SearchAssetAsync_AShare_ShouldReturnResults() + { + // Arrange + _marketContext!.SwitchMarket(MarketType.AShare); + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act + var results = await service.SearchAssetAsync("贵州茅台"); + + // Assert + Assert.IsNotNull(results); + Assert.IsTrue(results.Count > 0, "搜索'贵州茅台'应返回至少一条结果"); + var first = results[0]; + Assert.IsFalse(string.IsNullOrWhiteSpace(first.Code), "返回结果应包含有效的股票代码"); + Assert.IsFalse(string.IsNullOrWhiteSpace(first.Name), "返回结果应包含有效的股票名称"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetHotAssetsAsync_AShare_ShouldReturnHotList() + { + // Arrange + _marketContext!.SwitchMarket(MarketType.AShare); + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act + var hotAssets = await service.GetHotAssetsAsync(); + + // Assert + Assert.IsNotNull(hotAssets); + Assert.IsTrue(hotAssets.Count > 0, "热门资产列表不应为空"); + Assert.IsTrue(hotAssets.All(h => !string.IsNullOrWhiteSpace(h.Code)), "所有热门资产应包含有效代码"); + Assert.IsTrue(hotAssets.All(h => !string.IsNullOrWhiteSpace(h.Name)), "所有热门资产应包含有效名称"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task AddToRecentAssets_AShare_ShouldStoreInHistory() + { + // Arrange + _marketContext!.SwitchMarket(MarketType.AShare); + await _serviceProvider!.GetRequiredKeyedService(MarketType.AShare).ClearHistoryAsync(); + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var asset = new AssetItem { Code = "SH600519", Name = "贵州茅台" }; + + // Act + await service.AddToRecentAssetsAsync(asset); + var recentAssets = await service.GetRecentAssetsAsync(); + + // Assert + Assert.IsNotNull(recentAssets); + Assert.IsTrue(recentAssets.Count > 0, "添加后最近资产列表不应为空"); + Assert.IsTrue(recentAssets.Any(a => a.Code == "SH600519"), "最近资产应包含刚添加的贵州茅台"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetRecentAssets_AShare_ShouldReturnHistory() + { + // Arrange + _marketContext!.SwitchMarket(MarketType.AShare); + await _serviceProvider!.GetRequiredKeyedService(MarketType.AShare).ClearHistoryAsync(); + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var asset1 = new AssetItem { Code = "SH600519", Name = "贵州茅台" }; + var asset2 = new AssetItem { Code = "SH600036", Name = "招商银行" }; + + // Act + await service.AddToRecentAssetsAsync(asset1); + await service.AddToRecentAssetsAsync(asset2); + var recentAssets = await service.GetRecentAssetsAsync(); + + // Assert + Assert.IsNotNull(recentAssets); + Assert.IsTrue(recentAssets.Count >= 2, "添加两个资产后最近列表应至少包含两条记录"); + Assert.IsTrue(recentAssets.Any(a => a.Code == "SH600519"), "应包含贵州茅台"); + Assert.IsTrue(recentAssets.Any(a => a.Code == "SH600036"), "应包含招商银行"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task SearchAssetAsync_Crypto_ShouldReturnResults() + { + // Arrange + _marketContext!.SwitchMarket(MarketType.Crypto); + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var results = await service.SearchAssetAsync("BTC"); + + // Assert + Assert.IsNotNull(results); + Assert.IsTrue(results.Count > 0, "搜索'BTC'应返回至少一条结果"); + Assert.IsTrue(results.All(r => !string.IsNullOrWhiteSpace(r.Code)), "所有结果应包含有效代码"); + Assert.IsTrue(results.Any(r => r.Code.Contains("BTC")), "结果中应包含 BTC 相关资产"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetHotAssetsAsync_Crypto_ShouldReturnHotList() + { + // Arrange + _marketContext!.SwitchMarket(MarketType.Crypto); + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var hotAssets = await service.GetHotAssetsAsync(); + + // Assert + Assert.IsNotNull(hotAssets); + Assert.IsTrue(hotAssets.Count > 0, "虚拟币热门资产列表不应为空"); + Assert.IsTrue(hotAssets.All(h => !string.IsNullOrWhiteSpace(h.Code)), "所有热门资产应包含有效代码"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task AddToRecentAssets_Crypto_ShouldStoreInHistory() + { + // Arrange + _marketContext!.SwitchMarket(MarketType.Crypto); + await _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto).ClearHistoryAsync(); + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var asset = new AssetItem { Code = "BTCUSDT", Name = "Bitcoin" }; + + // Act + await service.AddToRecentAssetsAsync(asset); + var recentAssets = await service.GetRecentAssetsAsync(); + + // Assert + Assert.IsNotNull(recentAssets); + Assert.IsTrue(recentAssets.Count > 0, "添加后最近资产列表不应为空"); + Assert.IsTrue(recentAssets.Any(a => a.Code == "BTCUSDT"), "最近资产应包含刚添加的 BTCUSDT"); + } +} diff --git a/tests/Application/KLineServiceTest.cs b/tests/Application/KLineServiceTest.cs new file mode 100644 index 0000000..dffc18a --- /dev/null +++ b/tests/Application/KLineServiceTest.cs @@ -0,0 +1,167 @@ +using MarketAssistant.Applications.Charts; +using MarketAssistant.Applications.Charts.Models; +using MarketAssistant.Applications.Settings; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services; +using MarketAssistant.Services.Data; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace TestMarketAssistant.Application; + +/// +/// IKLineService 接口测试(覆盖 A股 和 虚拟币 实现) +/// +[TestClass] +public class KLineServiceTest +{ + private ServiceProvider? _serviceProvider; + + [TestInitialize] + public void Setup() + { + var services = new ServiceCollection(); + + // 注册依赖服务 + var mockUserSettingService = new Mock(); + mockUserSettingService.Setup(s => s.CurrentSetting) + .Returns(new UserSetting + { + ZhiTuApiToken = "test-token" + }); + + services.AddSingleton(mockUserSettingService.Object); + services.AddHttpClient(); + services.AddTestMarketDataHttpClients(); + services.AddSingleton(); + services.AddLogging(); + + // 注册被测试的服务 + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.Crypto); + + _serviceProvider = services.BuildServiceProvider(); + } + + [TestCleanup] + public async Task Cleanup() + { + if (_serviceProvider != null) + { + await _serviceProvider.DisposeAsync(); + } + } + + /// + /// 验证 K 线数据的真实性:数量、OHLC 关系、收盘价为正 + /// + private static void AssertKLineDataValid(List kLineData, int expectedMinCount) + { + Assert.IsNotNull(kLineData, "K线数据不应为 null"); + Assert.IsTrue(kLineData.Count >= expectedMinCount, $"K线数据数量应至少为 {expectedMinCount},实际 {kLineData.Count}"); + Assert.IsTrue(kLineData.All(k => k.Close > 0), "所有K线的收盘价应大于 0"); + Assert.IsTrue(kLineData.All(k => k.High >= k.Close), "所有K线的最高价应不低于收盘价"); + Assert.IsTrue(kLineData.All(k => k.Low <= k.Close), "所有K线的最低价应不高于收盘价"); + Assert.IsTrue(kLineData.All(k => k.High >= k.Low), "所有K线的最高价应不低于最低价"); + Assert.IsTrue(kLineData.All(k => k.Open > 0), "所有K线的开盘价应大于 0"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetKLineDataAsync_AShare_Minute15_ShouldReturnData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act + var kLineData = await service.GetKLineDataAsync("SH600519", KLineType.Minute15, 50); + + // Assert + AssertKLineDataValid(kLineData, 1); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetKLineDataAsync_AShare_Daily_ShouldReturnData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act + var kLineData = await service.GetKLineDataAsync("SH600519", KLineType.Daily, 100); + + // Assert + AssertKLineDataValid(kLineData, 1); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetKLineDataAsync_AShare_Weekly_ShouldReturnData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act + var kLineData = await service.GetKLineDataAsync("SH600519", KLineType.Weekly, 50); + + // Assert + AssertKLineDataValid(kLineData, 1); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetKLineDataAsync_Crypto_Minute15_ShouldReturnData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var kLineData = await service.GetKLineDataAsync("BTCUSDT", KLineType.Minute15, 50); + + // Assert + AssertKLineDataValid(kLineData, 1); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetKLineDataAsync_Crypto_Daily_ShouldReturnData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var kLineData = await service.GetKLineDataAsync("BTCUSDT", KLineType.Daily, 100); + + // Assert + AssertKLineDataValid(kLineData, 1); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetKLineDataAsync_Crypto_Weekly_ShouldReturnData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var kLineData = await service.GetKLineDataAsync("BTCUSDT", KLineType.Weekly, 50); + + // Assert + AssertKLineDataValid(kLineData, 1); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetKLineDataAsync_Crypto_Monthly_ShouldReturnData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var kLineData = await service.GetKLineDataAsync("ETHUSDT", KLineType.Monthly, 30); + + // Assert + AssertKLineDataValid(kLineData, 1); + } +} diff --git a/tests/BaseAgentTest.cs b/tests/BaseAgentTest.cs index 3e3e0da..25b5490 100644 --- a/tests/BaseAgentTest.cs +++ b/tests/BaseAgentTest.cs @@ -1,15 +1,7 @@ -using MarketAssistant.Agents.MarketAnalysis; -using MarketAssistant.Agents.MarketAnalysis.Executors; -using MarketAssistant.Agents.StockSelection; -using MarketAssistant.Agents.StockSelection.Executors; -using MarketAssistant.Agents.Tools; using MarketAssistant.Applications.Settings; using MarketAssistant.Infrastructure.Factories; -using MarketAssistant.Rag.Extensions; -using MarketAssistant.Services.Browser; -using MarketAssistant.Services.Mcp; +using MarketAssistant.Services; using MarketAssistant.Services.Settings; -using MarketAssistant.Services.StockScreener; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -19,8 +11,8 @@ namespace TestMarketAssistant; /// /// Agent Framework 测试基类 +/// 环境变量必需:缺失时对应测试失败(不跳过),确保真实场景验证 /// -[TestClass] public class BaseAgentTest { protected ILogger? _logger; @@ -30,6 +22,11 @@ public class BaseAgentTest protected IHttpClientFactory _httpClientFactory = null!; protected IUserSettingService _userSettingService = null!; + /// + /// 环境中是否配置了真实 LLM API Key + /// + protected bool IsLlmAvailable { get; private set; } + [TestInitialize] public void BaseInitialize() { @@ -39,7 +36,6 @@ public void BaseInitialize() }); _logger = loggerFactory.CreateLogger(); - // 初始化测试所需的服务 _serviceProvider = CreateServiceProvider(); _chatClientFactory = _serviceProvider.GetRequiredService(); _analystAgentFactory = _serviceProvider.GetRequiredService(); @@ -47,98 +43,89 @@ public void BaseInitialize() _userSettingService = _serviceProvider.GetRequiredService(); } + /// + /// 断言真实 LLM API Key 已配置(缺失则测试失败,而非跳过) + /// + protected void RequireLlm() + { + if (!IsLlmAvailable) + { + Assert.Fail("OPENAI_API_KEY 环境变量未配置,无法调用真实 LLM 进行真实场景验证"); + } + } + protected IServiceProvider CreateServiceProvider() { var services = new ServiceCollection(); - // 配置日志 services.AddLogging(builder => { builder.SetMinimumLevel(LogLevel.Debug); }); - // 从环境变量获取ApiKey - var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY environment variable is not set"); - var zhiTuApiToken = Environment.GetEnvironmentVariable("ZHITU_API_TOKEN") ?? throw new InvalidOperationException("ZHITU_API_TOKEN environment variable is not set"); - var searchApiKey = Environment.GetEnvironmentVariable("WEB_SEARCH_API_KEY") ?? throw new InvalidOperationException("WEB_SEARCH_API_KEY environment variable is not set"); + var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? ""; + var zhiTuApiToken = Environment.GetEnvironmentVariable("ZHITU_API_TOKEN") ?? ""; + var searchApiKey = Environment.GetEnvironmentVariable("WEB_SEARCH_API_KEY") ?? ""; + var embeddingApiKey = Environment.GetEnvironmentVariable("JINA_API_KEY") ?? ""; + + IsLlmAvailable = !string.IsNullOrEmpty(apiKey); - // 硬编码ModelId和Endpoint var modelId = "deepseek-ai/DeepSeek-V3.2"; var endpoint = "https://api.siliconflow.cn"; - // 注册依赖服务 - services.AddSingleton(provider => + services.AddApplicationServices(); + + // 必须在 AddApplicationServices 之后注册,覆盖其内部的真实 UserSettingService + var testUserSetting = new UserSetting { - var testUserSetting = new UserSetting + ZhiTuApiToken = zhiTuApiToken, + ModelId = modelId, + EmbeddingModelId = "jina-embeddings-v5-text-small", + EmbeddingEndpoint = "https://api.jina.ai", + EmbeddingApiKey = embeddingApiKey, + Endpoint = endpoint, + ApiKey = apiKey, + EnabledAnalystRoles = new Dictionary { - ZhiTuApiToken = zhiTuApiToken, - ModelId = modelId, - EmbeddingModelId = "BAAI/bge-m3", - Endpoint = endpoint, - ApiKey = apiKey, - EnabledAnalystRoles = new Dictionary - { - { "FinancialAnalystAgent", true }, - { "MarketSentimentAnalystAgent", false }, - { "TechnicalAnalystAgent", false }, - { "NewsEventAnalystAgent", true } - }, - EnableWebSearch = true, - WebSearchApiKey = searchApiKey, - WebSearchProvider = "Tavily", - LoadKnowledge = true, - }; - var userSettingServiceMock = new Mock(); - userSettingServiceMock.Setup(x => x.CurrentSetting).Returns(testUserSetting); - return userSettingServiceMock.Object; - }); - - // 注册核心服务 - services.AddHttpClient(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // 注册 Agent Tool 类 - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // 注册 Agent Framework 服务 - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // 注册 StockSelection Workflow and Executors - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // 注册 MarketAnalysis Workflow and Executors - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // 注册 RAG 服务 - services.AddRagServices(); - - // 注册 Embedding Generator - services.AddSingleton(serviceProvider => + { "FinancialAnalystAgent", true }, + { "MarketSentimentAnalystAgent", false }, + { "TechnicalAnalystAgent", false }, + { "NewsEventAnalystAgent", true } + }, + EnableWebSearch = !string.IsNullOrEmpty(searchApiKey), + WebSearchApiKey = searchApiKey, + WebSearchProvider = "Tavily", + LoadKnowledge = true, + }; + var userSettingServiceMock = new Mock(); + userSettingServiceMock.Setup(x => x.CurrentSetting).Returns(testUserSetting); + services.AddSingleton(userSettingServiceMock.Object); + + // 注册 IEmbeddingGenerator(RAG 测试依赖),通过 IEmbeddingFactory 创建 + // 仅当配置了 Jina EmbeddingApiKey 时注册,避免无密钥场景下工厂构造抛异常 + if (!string.IsNullOrEmpty(embeddingApiKey)) { - var embeddingFactory = serviceProvider.GetRequiredService(); - var embeddingGenerator = embeddingFactory.Create(); - return embeddingGenerator; - }); - - // 注册 Vector Store - var store = Directory.GetCurrentDirectory() + "/vector.sqlite"; - services.AddSqliteVectorStore(_ => $"Data Source={store}"); + services.AddSingleton>>(sp => + { + var factory = sp.GetRequiredService(); + return factory.Create(); + }); + } return services.BuildServiceProvider(); } -} \ No newline at end of file + + [TestCleanup] + public async Task BaseCleanupAsync() + { + switch (_serviceProvider) + { + case IAsyncDisposable asyncDisposable: + await asyncDisposable.DisposeAsync(); + break; + case IDisposable disposable: + disposable.Dispose(); + break; + } + } +} diff --git a/tests/CrawlNewsTest.cs b/tests/CrawlNewsTest.cs deleted file mode 100644 index 2d679cb..0000000 --- a/tests/CrawlNewsTest.cs +++ /dev/null @@ -1,53 +0,0 @@ -using MarketAssistant.Applications.Telegrams; -using MarketAssistant.Applications.Settings; -using MarketAssistant.Infrastructure; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Moq; -using System.Net.Http; - -namespace TestMarketAssistant; - -[TestClass] -public class CrawlNewsTest -{ - private TelegramService _telegramService = null!; - private Mock> _loggerMock = null!; - private IHttpClientFactory _httpClientFactory = null!; - - [TestInitialize] - public void Initialize() - { - _loggerMock = new Mock>(); - - // 创建真实的 IHttpClientFactory 实例 - var services = new ServiceCollection(); - services.AddHttpClient(); - var provider = services.BuildServiceProvider(); - _httpClientFactory = provider.GetRequiredService(); - - _telegramService = new TelegramService(_loggerMock.Object, _httpClientFactory); - } - - [TestMethod] - public async Task TestGetTelegraphsAsync() - { - // 调用获取实时新闻的方法 - var newsList = await _telegramService.GetTelegraphsAsync(); - - // 验证结果 - Assert.IsNotNull(newsList, "新闻列表不应为空"); - Assert.IsTrue(newsList.Count > 0, "应至少获取到一条新闻"); - - // 输出获取到的新闻信息 - Console.WriteLine($"共获取到 {newsList.Count} 条新闻"); - foreach (var news in newsList.Take(5)) // 只显示前5条 - { - Console.WriteLine($"时间: {news.Time}"); - Console.WriteLine($"标题: {news.Title}"); - Console.WriteLine($"内容: {news.Content.Substring(0, Math.Min(100, news.Content.Length))}..."); - Console.WriteLine($"链接: {news.Url}"); - Console.WriteLine("-----------------------------------"); - } - } -} diff --git a/tests/Crypto/BinanceAuthServiceTest.cs b/tests/Crypto/BinanceAuthServiceTest.cs new file mode 100644 index 0000000..8a618b1 --- /dev/null +++ b/tests/Crypto/BinanceAuthServiceTest.cs @@ -0,0 +1,166 @@ +using MarketAssistant.Applications.Crypto; +using MarketAssistant.Applications.Settings; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace MarketAssistant.Tests.Crypto; + +/// +/// 币安API鉴权服务测试 +/// +[TestClass] +public class BinanceAuthServiceTest +{ + private ILogger _logger = null!; + + [TestInitialize] + public void Setup() + { + var loggerFactory = LoggerFactory.Create(builder => + { + // 不需要额外的日志提供程序 + }); + _logger = loggerFactory.CreateLogger(); + } + + /// + /// 测试HMAC签名生成 + /// 使用币安文档中的示例数据验证签名是否正确 + /// 文档示例:https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/rest-api/request-security#hmac-keys + /// + [TestMethod] + [TestCategory("Unit")] + public void TestHmacSignature_WithBinanceExample() + { + // Arrange - 使用币安文档中的示例密钥(仅用于测试) + var authService = new BinanceAuthService( + _logger, + CreateUserSettingService( + "vmPUZE6mv9SD5VNHk4HlWFsOr6aKE2zvsw0MuIgwCIPy6utIco14y7Ju91duEh8A", + "NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j")); + + // Act - 构建币安文档中的示例payload(不包含timestamp,手动测试签名算法) + // 文档示例:symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000×tamp=1499827319559 + var testPayload = "symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000×tamp=1499827319559"; + + // 使用反射调用私有方法进行测试(仅用于单元测试) + var generateSignatureMethod = typeof(BinanceAuthService) + .GetMethod("GenerateSignature", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + var signature = generateSignatureMethod?.Invoke( + null, + new object[] + { + testPayload, + "NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j" + }) as string; + + // Assert - 验证签名是否与文档示例一致 + var expectedSignature = "c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71"; + + Console.WriteLine($"生成的签名: {signature}"); + Console.WriteLine($"期望的签名: {expectedSignature}"); + + Assert.IsNotNull(signature); + Assert.AreEqual(expectedSignature, signature.ToLowerInvariant()); + } + + /// + /// 测试签名查询字符串生成(实际使用场景) + /// + [TestMethod] + [TestCategory("Unit")] + public void TestSignQueryString_ShouldAddTimestampAndSignature() + { + // Arrange + var authService = new BinanceAuthService( + _logger, + CreateUserSettingService("test-api-key", "test-secret-key")); + + // Act + var queryString = "symbol=BTCUSDT&side=BUY&type=MARKET&quantity=0.001"; + var signedQuery = authService.SignQueryString(queryString); + + // Assert + Console.WriteLine($"原始查询: {queryString}"); + Console.WriteLine($"签名后查询: {signedQuery}"); + + Assert.IsTrue(signedQuery.Contains("timestamp=")); + Assert.IsTrue(signedQuery.Contains("signature=")); + Assert.IsTrue(signedQuery.Contains(queryString)); + } + + /// + /// 测试配置验证 - 需要SecretKey + /// + [TestMethod] + [TestCategory("Unit")] + public void TestConfigValidation_RequiresSecretKey() + { + // Arrange + var authService = new BinanceAuthService( + _logger, + CreateUserSettingService("test-api-key", string.Empty)); + + // Act & Assert + var exception = Assert.ThrowsExactly(() => authService.SignQueryString("symbol=BTCUSDT")); + Console.WriteLine($"预期异常: {exception.Message}"); + Assert.IsTrue(exception.Message.Contains("Secret Key")); + } + + /// + /// 测试添加鉴权 Header + /// + [TestMethod] + [TestCategory("Unit")] + public void TestAddAuthHeaders_ShouldSetApiKeyHeader() + { + var authService = new BinanceAuthService( + _logger, + CreateUserSettingService("test-api-key", "test-secret-key")); + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com"); + + authService.AddAuthHeaders(request); + + Assert.IsTrue(request.Headers.TryGetValues("X-MBX-APIKEY", out var values)); + Assert.AreEqual("test-api-key", values.Single()); + } + + private static IUserSettingService CreateUserSettingService(string apiKey, string secretKey) + { + return new FakeUserSettingService(new UserSetting + { + BinanceApiKey = apiKey, + BinanceSecretKey = secretKey + }); + } + + private sealed class FakeUserSettingService : IUserSettingService + { + public FakeUserSettingService(UserSetting setting) + { + CurrentSetting = setting; + } + + public UserSetting CurrentSetting { get; private set; } + + public void LoadSettings() + { + } + + public void SaveSettings() + { + } + + public void UpdateSettings(UserSetting setting) + { + CurrentSetting = setting; + } + + public void ResetSettings() + { + CurrentSetting = new UserSetting(); + } + } +} diff --git a/tests/CryptoSymbolConverterTest.cs b/tests/CryptoSymbolConverterTest.cs new file mode 100644 index 0000000..f948e23 --- /dev/null +++ b/tests/CryptoSymbolConverterTest.cs @@ -0,0 +1,159 @@ +using MarketAssistant.Infrastructure.Core; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace TestMarketAssistant; + +/// +/// CryptoSymbolConverter 工具类单元测试 +/// +[TestClass] +public class CryptoSymbolConverterTest +{ + [TestMethod] + [TestCategory("Unit")] + public void ToBinanceFormat_SimpleCoin_ShouldAddUSDT() + { + // Arrange & Act + var result = CryptoSymbolConverter.ToBinanceFormat("BTC"); + + // Assert + Assert.AreEqual("BTCUSDT", result); + } + + [TestMethod] + [TestCategory("Unit")] + public void ToBinanceFormat_WithCryptoPrefix_ShouldRemovePrefix() + { + // Arrange & Act + var result = CryptoSymbolConverter.ToBinanceFormat("crypto.ETH"); + + // Assert + Assert.AreEqual("ETHUSDT", result); + } + + [TestMethod] + [TestCategory("Unit")] + public void ToBinanceFormat_AlreadyHasUSDT_ShouldReturnAsIs() + { + // Arrange & Act + var result = CryptoSymbolConverter.ToBinanceFormat("BTCUSDT"); + + // Assert + Assert.AreEqual("BTCUSDT", result); + } + + [TestMethod] + [TestCategory("Unit")] + public void ToBinanceFormat_WithSlash_ShouldRemoveSlash() + { + // Arrange & Act + var result = CryptoSymbolConverter.ToBinanceFormat("BTC/USDT"); + + // Assert + Assert.AreEqual("BTCUSDT", result); + } + + [TestMethod] + [TestCategory("Unit")] + public void ToBinanceFormat_WithCustomQuote_ShouldUseCustomQuote() + { + // Arrange & Act + var result = CryptoSymbolConverter.ToBinanceFormat("ETH", "BTC"); + + // Assert + Assert.AreEqual("ETHBTC", result); + } + + [TestMethod] + [TestCategory("Unit")] + public void ExtractBaseCurrency_FromBTCUSDT_ShouldReturnBTC() + { + // Arrange & Act + var result = CryptoSymbolConverter.ExtractBaseCurrency("BTCUSDT"); + + // Assert + Assert.AreEqual("BTC", result); + } + + [TestMethod] + [TestCategory("Unit")] + public void ExtractBaseCurrency_FromETHBTC_ShouldReturnETH() + { + // Arrange & Act + var result = CryptoSymbolConverter.ExtractBaseCurrency("ETHBTC"); + + // Assert + Assert.AreEqual("ETH", result); + } + + [TestMethod] + [TestCategory("Unit")] + public void ExtractBaseCurrency_FromBNBBUSD_ShouldReturnBNB() + { + // Arrange & Act + var result = CryptoSymbolConverter.ExtractBaseCurrency("BNBBUSD"); + + // Assert + Assert.AreEqual("BNB", result); + } + + [TestMethod] + [TestCategory("Unit")] + public void ToCoinGeckoId_BTC_ShouldReturnBitcoin() + { + // Arrange & Act + var result = CryptoSymbolConverter.ToCoinGeckoId("BTC"); + + // Assert + Assert.AreEqual("bitcoin", result); + } + + [TestMethod] + [TestCategory("Unit")] + public void ToCoinGeckoId_BTCUSDT_ShouldReturnBitcoin() + { + // Arrange & Act + var result = CryptoSymbolConverter.ToCoinGeckoId("BTCUSDT"); + + // Assert + Assert.AreEqual("bitcoin", result); + } + + [TestMethod] + [TestCategory("Unit")] + public void ToCoinGeckoId_ETH_ShouldReturnEthereum() + { + // Arrange & Act + var result = CryptoSymbolConverter.ToCoinGeckoId("ETH"); + + // Assert + Assert.AreEqual("ethereum", result); + } + + [TestMethod] + [TestCategory("Unit")] + public void ToCoinGeckoId_UnknownCoin_ShouldReturnLowercase() + { + // Arrange & Act + var result = CryptoSymbolConverter.ToCoinGeckoId("NEWCOIN"); + + // Assert + Assert.AreEqual("newcoin", result); + } + + [TestMethod] + [TestCategory("Unit")] + public void ToBinanceFormat_EmptyString_ShouldThrowException() + { + // Arrange & Act & Assert + Assert.ThrowsExactly(() => CryptoSymbolConverter.ToBinanceFormat("")); + } + + [TestMethod] + [TestCategory("Unit")] + public void ToBinanceFormat_NullString_ShouldThrowException() + { + // Arrange & Act & Assert + Assert.ThrowsExactly(() => CryptoSymbolConverter.ToBinanceFormat(null!)); + } +} diff --git a/tests/GenerateCriteriaExecutorTest.cs b/tests/GenerateCriteriaExecutorTest.cs deleted file mode 100644 index d50ba33..0000000 --- a/tests/GenerateCriteriaExecutorTest.cs +++ /dev/null @@ -1,80 +0,0 @@ -using MarketAssistant.Agents.StockSelection.Executors; -using MarketAssistant.Agents.StockSelection.Models; -using MarketAssistant.Services.StockScreener.Models; -using Microsoft.Extensions.DependencyInjection; - -namespace TestMarketAssistant; - -/// -/// GenerateCriteriaExecutor 单元测试 -/// -[TestClass] -public sealed class GenerateCriteriaExecutorTest : BaseAgentTest -{ - private GenerateCriteriaExecutor _executor = null!; - - [TestInitialize] - public void Initialize() - { - _executor = _serviceProvider.GetRequiredService(); - } - - [TestMethod] - public async Task GenerateCriteria_NoIndustryMentioned_ShouldReturnAll() - { - // Arrange - var request = new StockSelectionWorkflowRequest - { - Content = "寻找市值大于100亿的成长股,ROE要大于15%", - MaxRecommendations = 20, - IsNewsAnalysis = false - }; - - // Act - var result = await _executor.HandleAsync(request, null!, CancellationToken.None); - - // Assert - Assert.IsNotNull(result); - Assert.IsNotNull(result.Criteria); - - // 关键断言:没有提到行业时,应该是 All(全部) - Console.WriteLine($"解析的行业: {result.Criteria.Industry}"); - Console.WriteLine($"行业枚举值: {(int)result.Criteria.Industry}"); - Console.WriteLine($"筛选条件数量: {result.Criteria.Criteria?.Count ?? 0}"); - - foreach (var criterion in result.Criteria.Criteria ?? new()) - { - Console.WriteLine($" - {criterion.DisplayName}: {criterion.MinValue} ~ {criterion.MaxValue}"); - } - - Assert.AreEqual(IndustryType.All, result.Criteria.Industry, - $"用户需求中没有提到行业,应该解析为 All,但实际解析为 {result.Criteria.Industry}"); - } - - [TestMethod] - public async Task GenerateCriteria_TechnologyIndustry_ShouldReturnCorrectIndustry() - { - // Arrange - var request = new StockSelectionWorkflowRequest - { - Content = "从沪A中,寻找市值大于100亿的科技股,专注软件开发领域,ROE要大于15%", - MaxRecommendations = 20, - IsNewsAnalysis = false - }; - - // Act - var result = await _executor.HandleAsync(request, null!, CancellationToken.None); - - // Assert - Assert.IsNotNull(result); - Assert.IsNotNull(result.Criteria); - - Console.WriteLine($"解析的行业: {result.Criteria.Industry}"); - - // 应该解析为软件开发或计算机设备 - Assert.IsTrue( - result.Criteria.Industry == IndustryType.SoftwareDevelopment || - result.Criteria.Industry == IndustryType.ComputerEquipment, - $"应该解析为软件开发或计算机设备,但实际解析为 {result.Criteria.Industry}"); - } -} diff --git a/tests/GitHubReleaseServiceTest.cs b/tests/GitHubReleaseServiceTest.cs index 0fa3cff..7eda102 100644 --- a/tests/GitHubReleaseServiceTest.cs +++ b/tests/GitHubReleaseServiceTest.cs @@ -1,6 +1,7 @@ using MarketAssistant.Applications.Settings; using MarketAssistant.Infrastructure.Core; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Caching.Memory; using Moq; using Moq.Protected; using System.Net; @@ -25,10 +26,12 @@ public void Initialize() httpClientFactoryMock.Setup(x => x.CreateClient(It.IsAny())).Returns(httpClient); _loggerMock = new Mock>(); - _service = new GitHubReleaseService(httpClientFactoryMock.Object, _loggerMock.Object); + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + _service = new GitHubReleaseService(httpClientFactoryMock.Object, memoryCache, _loggerMock.Object); } [TestMethod] + [TestCategory("Unit")] public async Task CheckForUpdateAsync_HasNewVersion_ReturnsTrue() { // Arrange @@ -67,6 +70,7 @@ public async Task CheckForUpdateAsync_HasNewVersion_ReturnsTrue() } [TestMethod] + [TestCategory("Unit")] public async Task CheckForUpdateAsync_NoNewVersion_ReturnsFalse() { // Arrange @@ -92,6 +96,7 @@ public async Task CheckForUpdateAsync_NoNewVersion_ReturnsFalse() } [TestMethod] + [TestCategory("Unit")] public async Task CheckForUpdateAsync_WithFourPartVersion_WorksCorrectly() { // Arrange @@ -118,6 +123,7 @@ public async Task CheckForUpdateAsync_WithFourPartVersion_WorksCorrectly() } [TestMethod] + [TestCategory("Unit")] public async Task CheckForUpdateAsync_ExcludePrerelease_OnlyStableVersions() { // Arrange @@ -141,6 +147,7 @@ public async Task CheckForUpdateAsync_ExcludePrerelease_OnlyStableVersions() } [TestMethod] + [TestCategory("Unit")] public async Task CheckForUpdateAsync_NetworkError_ThrowsException() { // Arrange @@ -158,6 +165,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task CheckForUpdateAsync_RateLimitExceeded_ThrowsException() { // Arrange @@ -169,6 +177,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task CheckForUpdateAsync_EmptyVersion_ThrowsException() { // Act & Assert @@ -177,6 +186,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task DownloadUpdateAsync_Success_ReturnsFilePath() { // Arrange @@ -215,6 +225,7 @@ public async Task DownloadUpdateAsync_Success_ReturnsFilePath() } [TestMethod] + [TestCategory("Unit")] public async Task DownloadUpdateAsync_WithProgress_ReportsProgress() { // Arrange @@ -266,6 +277,7 @@ public async Task DownloadUpdateAsync_WithProgress_ReportsProgress() } [TestMethod] + [TestCategory("Unit")] public async Task DownloadUpdateAsync_InvalidUrl_ThrowsException() { // Act & Assert @@ -274,6 +286,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task DownloadUpdateAsync_HttpError_ThrowsException() { // Arrange @@ -293,6 +306,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public void ClearCache_ClearsSuccessfully() { // Act & Assert - 不抛出异常即可 @@ -315,4 +329,115 @@ private void SetupHttpResponse(HttpStatusCode statusCode, T content) ) .ReturnsAsync(response); } + + [TestMethod] + [TestCategory("Unit")] + public async Task CheckForUpdateAsync_BetaToStable_DetectsUpdate() + { + // 场景:当前是 beta 版本,GitHub 上发布了正式版 + var releases = new List + { + new ReleaseInfo + { + TagName = "v1.0.0", + Name = "Release 1.0.0", + PublishedAt = DateTime.UtcNow, + Prerelease = false, + Draft = false, + Assets = new List + { + new ReleaseAsset + { + Name = "MarketAssistant-Setup-1.0.0.exe", + DownloadUrl = "https://github.com/X2Agent/MarketAssistant/releases/download/v1.0.0/MarketAssistant-Setup-1.0.0.exe", + Size = 50_000_000 + }, + new ReleaseAsset + { + Name = "MarketAssistant-Windows-x64.zip", + DownloadUrl = "https://github.com/X2Agent/MarketAssistant/releases/download/v1.0.0/MarketAssistant-Windows-x64.zip", + Size = 45_000_000 + } + } + } + }; + + SetupHttpResponse(HttpStatusCode.OK, releases); + + // 当前版本是 beta + var result = await _service.CheckForUpdateAsync("1.0.0-beta1"); + + Assert.IsTrue(result.HasNewVersion, "beta1 → stable 应检测到新版本"); + Assert.AreEqual("v1.0.0", result.LatestRelease!.TagName); + + // 验证资产选择:应优先选 .exe + var asset = result.LatestRelease.Assets + .FirstOrDefault(a => a.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)); + Assert.IsNotNull(asset, "应找到 .exe 安装包"); + Assert.AreEqual("MarketAssistant-Setup-1.0.0.exe", asset!.Name); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task CheckForUpdateAsync_MultipleReleases_PicksHighestVersion() + { + // 场景:多个 release,hotfix 发布时间更晚,但 beta 版本号更高 + var releases = new List + { + new ReleaseInfo + { + TagName = "v1.0.1", + Name = "Hotfix 1.0.1", + PublishedAt = DateTime.UtcNow, // 最后发布 + Prerelease = false, + Draft = false + }, + new ReleaseInfo + { + TagName = "v1.1.0-beta1", + Name = "Beta 1.1.0", + PublishedAt = DateTime.UtcNow.AddDays(-1), // 更早发布 + Prerelease = true, + Draft = false + }, + new ReleaseInfo + { + TagName = "v1.0.0", + Name = "Release 1.0.0", + PublishedAt = DateTime.UtcNow.AddDays(-7), + Prerelease = false, + Draft = false + } + }; + + SetupHttpResponse(HttpStatusCode.OK, releases); + + // includePrerelease=true 时应取到 v1.1.0-beta1(版本号最高) + var result = await _service.CheckForUpdateAsync("1.0.0", includePrerelease: true); + Assert.IsTrue(result.HasNewVersion); + Assert.AreEqual("v1.1.0-beta1", result.LatestRelease!.TagName, + "应按版本号排序取最高,而非按发布时间"); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task CheckForUpdateAsync_SameBetaVersion_NoUpdate() + { + // 场景:当前版本和最新版本相同 + var releases = new List + { + new ReleaseInfo + { + TagName = "v1.0.0-beta1", + PublishedAt = DateTime.UtcNow, + Prerelease = true, + Draft = false + } + }; + + SetupHttpResponse(HttpStatusCode.OK, releases); + + var result = await _service.CheckForUpdateAsync("1.0.0-beta1"); + Assert.IsFalse(result.HasNewVersion, "相同版本不应提示更新"); + } } diff --git a/tests/InvestmentSelection/ScreenInvestmentTargetsExecutorTest.cs b/tests/InvestmentSelection/ScreenInvestmentTargetsExecutorTest.cs new file mode 100644 index 0000000..f89aee9 --- /dev/null +++ b/tests/InvestmentSelection/ScreenInvestmentTargetsExecutorTest.cs @@ -0,0 +1,66 @@ +using MarketAssistant.Agents.InvestmentSelection.Executors; +using MarketAssistant.Applications.AssetScreener; +using MarketAssistant.Applications.AssetScreener.Models; +using Microsoft.Extensions.Logging; +using Moq; + +namespace TestMarketAssistant.InvestmentSelection; + +/// +/// ScreenInvestmentTargetsExecutor 测试 +/// 验证构造函数参数校验和筛选执行逻辑 +/// +[TestClass] +public class ScreenInvestmentTargetsExecutorTest +{ + private Mock _mockServiceProvider = null!; + private Mock _mockScreenerService = null!; + private Mock> _mockLogger = null!; + private ScreenInvestmentTargetsExecutor _executor = null!; + + [TestInitialize] + public void Initialize() + { + _mockServiceProvider = new Mock(); + _mockScreenerService = new Mock(); + _mockLogger = new Mock>(); + + _mockServiceProvider + .Setup(sp => sp.GetService(typeof(IAssetScreenerService))) + .Returns(_mockScreenerService.Object); + + _executor = new ScreenInvestmentTargetsExecutor(_mockServiceProvider.Object, _mockLogger.Object); + } + + [TestMethod] + [TestCategory("Unit")] + public void Constructor_WithValidParameters_ShouldCreateInstance() + { + Assert.IsNotNull(_executor); + } + + [TestMethod] + [TestCategory("Unit")] + public void Constructor_WithNullServiceProvider_ShouldThrowException() + { + Assert.ThrowsExactly(() => + new ScreenInvestmentTargetsExecutor(null!, _mockLogger.Object)); + } + + [TestMethod] + [TestCategory("Unit")] + public void Constructor_WithNullLogger_ShouldThrowException() + { + Assert.ThrowsExactly(() => + new ScreenInvestmentTargetsExecutor(_mockServiceProvider.Object, null!)); + } + + [TestMethod] + [TestCategory("Unit")] + public void MockScreenerService_ShouldBeResolvableFromServiceProvider() + { + var resolved = _mockServiceProvider.Object.GetService(typeof(IAssetScreenerService)); + Assert.IsNotNull(resolved, "IAssetScreenerService 应可从 ServiceProvider 解析"); + Assert.AreSame(_mockScreenerService.Object, resolved); + } +} diff --git a/tests/JsonSchemaGenerationTest.cs b/tests/JsonSchemaGenerationTest.cs deleted file mode 100644 index a7ba8df..0000000 --- a/tests/JsonSchemaGenerationTest.cs +++ /dev/null @@ -1,62 +0,0 @@ -using MarketAssistant.Applications.StockSelection.Models; -using Microsoft.Extensions.AI; -using System.Text.Json; - -namespace TestMarketAssistant; - -/// -/// 测试 AIJsonUtilities.CreateJsonSchema 如何处理各种 DataAnnotations 特性 -/// 用于验证 JSON Schema 的生成逻辑 -/// -[TestClass] -public class JsonSchemaGenerationTest -{ - /// - /// 测试 StockSelectionResult 的 JSON Schema 生成 - /// - [TestMethod] - public void Test_StockSelectionResult_JsonSchema_Generation() - { - Console.WriteLine("========================================"); - Console.WriteLine("测试 StockSelectionResult 的 JSON Schema"); - Console.WriteLine("========================================\n"); - - // 生成 JSON Schema - var schema = AIJsonUtilities.CreateJsonSchema( - typeof(StockSelectionResult), - serializerOptions: new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true - }); - - var schemaJson = JsonSerializer.Serialize(schema, new JsonSerializerOptions - { - WriteIndented = true, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }); - - Console.WriteLine(schemaJson); - Console.WriteLine("\n========================================"); - Console.WriteLine("关键检查项:"); - Console.WriteLine("========================================"); - - // 检查是否包含关键字段 - var checks = new Dictionary - { - ["包含 $schema"] = schemaJson.Contains("\"$schema\""), - ["包含 description"] = schemaJson.Contains("\"description\""), - ["包含 minLength"] = schemaJson.Contains("\"minLength\""), - ["包含 maxLength"] = schemaJson.Contains("\"maxLength\""), - ["包含 minimum"] = schemaJson.Contains("\"minimum\""), - ["包含 maximum"] = schemaJson.Contains("\"maximum\""), - }; - - foreach (var check in checks) - { - Console.WriteLine($"✓ {check.Key}: {(check.Value ? "是" : "否")}"); - } - - Console.WriteLine("\n"); - } -} diff --git a/tests/LocalImageStorageServiceTest.cs b/tests/LocalImageStorageServiceTest.cs index 4328860..037fc1e 100644 --- a/tests/LocalImageStorageServiceTest.cs +++ b/tests/LocalImageStorageServiceTest.cs @@ -27,6 +27,7 @@ public void Cleanup() } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithValidImageAndHint_ShouldSaveSuccessfully() { // Arrange @@ -44,6 +45,7 @@ public async Task SaveImageAsync_WithValidImageAndHint_ShouldSaveSuccessfully() } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithEmptyHint_ShouldGenerateGuidName() { // Arrange @@ -59,6 +61,7 @@ public async Task SaveImageAsync_WithEmptyHint_ShouldGenerateGuidName() } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithNullHint_ShouldGenerateGuidName() { // Arrange @@ -74,6 +77,7 @@ public async Task SaveImageAsync_WithNullHint_ShouldGenerateGuidName() } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithDuplicateFileName_ShouldOverwriteExistingFile() { // Arrange @@ -98,6 +102,7 @@ public async Task SaveImageAsync_WithDuplicateFileName_ShouldOverwriteExistingFi } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithUnsafeFileName_ShouldCleanFileName() { // Arrange @@ -120,6 +125,7 @@ public async Task SaveImageAsync_WithUnsafeFileName_ShouldCleanFileName() } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithUnsupportedExtension_ShouldUsePngExtension() { // Arrange @@ -136,6 +142,7 @@ public async Task SaveImageAsync_WithUnsupportedExtension_ShouldUsePngExtension( } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithLongFileName_ShouldTruncate() { // Arrange @@ -153,6 +160,7 @@ public async Task SaveImageAsync_WithLongFileName_ShouldTruncate() } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithNullImageBytes_ShouldThrowArgumentException() { // Act & Assert @@ -161,6 +169,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithEmptyImageBytes_ShouldThrowArgumentException() { // Act & Assert @@ -169,6 +178,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithTooLargeImage_ShouldThrowArgumentException() { // Arrange @@ -180,6 +190,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithNullDocumentPath_ShouldThrowArgumentException() { // Arrange @@ -191,6 +202,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithEmptyDocumentPath_ShouldThrowArgumentException() { // Arrange @@ -202,6 +214,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithWhitespaceDocumentPath_ShouldThrowArgumentException() { // Arrange @@ -213,6 +226,7 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_CreatesDirectoryStructure() { // Arrange @@ -233,6 +247,7 @@ public async Task SaveImageAsync_CreatesDirectoryStructure() } [TestMethod] + [TestCategory("Unit")] public async Task SaveImageAsync_WithCancellationToken_ShouldRespectCancellation() { // Arrange diff --git a/tests/MarketAnalysisWorkflowTest.cs b/tests/MarketAnalysisWorkflowTest.cs index c5c6054..31e5fb1 100644 --- a/tests/MarketAnalysisWorkflowTest.cs +++ b/tests/MarketAnalysisWorkflowTest.cs @@ -4,7 +4,8 @@ namespace TestMarketAssistant; /// -/// 市场分析工作流测试(最小原则:验证核心工作流功能) +/// 市场分析工作流测试 +/// 验证核心工作流功能、报告结构完整性和质量指标 /// [TestClass] public sealed class MarketAnalysisWorkflowTest : BaseAgentTest @@ -14,40 +15,43 @@ public sealed class MarketAnalysisWorkflowTest : BaseAgentTest [TestInitialize] public void Initialize() { + RequireLlm(); _workflow = _serviceProvider.GetRequiredService(); } [TestCleanup] public void Cleanup() { - _workflow?.Dispose(); + // MarketAnalysisWorkflow 为 DI 单例,无需手动 Dispose } [TestMethod] + [TestCategory("E2E")] public async Task AnalyzeAsync_ShouldReturnValidReport() { - // Arrange - string stockSymbol = "000001"; + string assetSymbol = "000001"; - // Act - var report = await _workflow.AnalyzeAsync(stockSymbol); + var report = await _workflow.AnalyzeAsync(assetSymbol); - // Assert Assert.IsNotNull(report); - Assert.AreEqual(stockSymbol, report.StockSymbol); + Assert.AreEqual(assetSymbol, report.AssetSymbol); Assert.IsNotNull(report.AnalystMessages); Assert.IsTrue(report.AnalystMessages.Count > 0, "应该至少有一位分析师的结果"); + Assert.IsNotNull(report.CoordinatorResult, "协调分析师应该生成结果"); Assert.IsFalse(string.IsNullOrWhiteSpace(report.CoordinatorResult.Summary), "协调分析师应该生成总结报告"); + Assert.IsTrue(report.CoordinatorResult.Summary.Length >= 50, "总结报告应有足够的深度"); - Console.WriteLine($"股票 {stockSymbol} 分析完成 - 分析师数量: {report.AnalystMessages.Count}, 总结长度: {report.CoordinatorResult.Summary.Length} 字符"); + Assert.IsTrue( + report.CoordinatorResult.OverallScore is >= 1 and <= 10, + $"投资评分应在 1-10 范围内,实际值: {report.CoordinatorResult.OverallScore}"); } [TestMethod] + [TestCategory("E2E")] public async Task AnalyzeAsync_ShouldTriggerProgressEvents() { - // Arrange - string stockSymbol = "000001"; + string assetSymbol = "000001"; var progressEvents = new List(); _workflow.ProgressChanged += (sender, e) => @@ -55,19 +59,34 @@ public async Task AnalyzeAsync_ShouldTriggerProgressEvents() progressEvents.Add(e.StageDescription); }; - // Act - var report = await _workflow.AnalyzeAsync(stockSymbol); + var report = await _workflow.AnalyzeAsync(assetSymbol); - // Assert Assert.IsNotNull(report); Assert.IsTrue(progressEvents.Count > 0, "应该触发进度事件"); + Assert.IsTrue(progressEvents.Count >= 3, "至少应有分派、聚合、协调三个阶段的进度事件"); - // 验证进度事件内容合理性 foreach (var evt in progressEvents) { Assert.IsFalse(string.IsNullOrWhiteSpace(evt), "阶段描述不应为空"); } + } + + [TestMethod] + [TestCategory("E2E")] + public async Task AnalyzeAsync_ReportShouldHaveQualityMetrics() + { + string assetSymbol = "000001"; + + var report = await _workflow.AnalyzeAsync(assetSymbol); + + Assert.IsNotNull(report); + Assert.IsNotNull(report.CoordinatorResult); + + Assert.IsTrue(report.CoordinatorResult.RiskFactors.Count > 0, "完整报告应包含风险评估"); + + Assert.IsTrue(report.CoordinatorResult.KeyIndicators.Count > 0, "完整报告应包含关键指标"); - Console.WriteLine($"进度事件触发 {progressEvents.Count} 次 - 所有事件内容有效"); + Assert.IsTrue(report.AnalystMessages.Count >= 2, + "应至少包含财务分析师和新闻事件分析师两位的结果(基于默认启用配置)"); } } diff --git a/tests/MarketChatSessionTest.cs b/tests/MarketChatSessionTest.cs index 8c3134c..2486cb8 100644 --- a/tests/MarketChatSessionTest.cs +++ b/tests/MarketChatSessionTest.cs @@ -1,7 +1,5 @@ using MarketAssistant.Agents; -using MarketAssistant.Infrastructure; using MarketAssistant.Infrastructure.Factories; -using MarketAssistant.Services.Mcp; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -15,18 +13,11 @@ public class MarketChatSessionTest : BaseAgentTest [TestInitialize] public void Initialize() { - BaseInitialize(); - + RequireLlm(); var logger = new Microsoft.Extensions.Logging.Abstractions.NullLogger(); - - // 使用 ChatClientFactory 创建 ChatClient var chatClientFactory = _serviceProvider.GetRequiredService(); var chatClient = chatClientFactory.CreateClient(); - - // 从 DI 容器中获取 McpService - var mcpService = _serviceProvider.GetRequiredService(); - - _chatSession = new MarketChatSession(chatClient, logger, mcpService); + _chatSession = new MarketChatSession(chatClient, logger); } [TestCleanup] @@ -36,176 +27,185 @@ public void Cleanup() } [TestMethod] + [TestCategory("Agent")] public async Task TestBasicChatAsync() { - // 测试基础对话功能 - var response = await _chatSession.SendMessageAsync("你好,请介绍股票投资的基础知识"); - - Assert.IsNotNull(response); - Assert.IsNotNull(response.Text); - Assert.IsFalse(string.IsNullOrEmpty(response.Text)); + var responseBuilder = new System.Text.StringBuilder(); + await foreach (var update in _chatSession.SendMessageStreamAsync("你好,请介绍股票投资的基础知识")) + { + responseBuilder.Append(update); + } - Console.WriteLine($"AI回复: {response.Text}"); + var responseText = responseBuilder.ToString(); + Assert.IsFalse(string.IsNullOrWhiteSpace(responseText)); + Assert.IsTrue(responseText.Length > 20, "基础对话应返回有实质内容的回复"); } [TestMethod] + [TestCategory("Agent")] public async Task TestStockContextChatAsync() { - // 设置股票上下文 - _chatSession.SetStockCode("sz002594"); + _chatSession.SetCurrentStock("sz002594"); - // 测试带有股票上下文的对话 - var response = await _chatSession.SendMessageAsync("这只股票的基本面如何?"); - - Assert.IsNotNull(response); - Assert.IsNotNull(response.Text); - Assert.IsFalse(string.IsNullOrEmpty(response.Text)); + var responseBuilder = new System.Text.StringBuilder(); + await foreach (var update in _chatSession.SendMessageStreamAsync("这只股票的基本面如何?")) + { + responseBuilder.Append(update); + } - Console.WriteLine($"AI回复: {response.Text}"); + var responseText = responseBuilder.ToString(); + Assert.IsFalse(string.IsNullOrWhiteSpace(responseText)); + Assert.IsTrue( + ContainsAnyKeyword(responseText, "002594", "比亚迪", "股票", "基本面", "估值", "财务", "盈利"), + "回复应体现当前股票上下文"); } [TestMethod] + [TestCategory("Agent")] public async Task TestConversationHistoryAsync() { - // 测试多轮对话 - await _chatSession.SendMessageAsync("什么是市盈率?"); - await _chatSession.SendMessageAsync("有何意义?"); - var response = await _chatSession.SendMessageAsync("这两个指标有什么区别"); + await foreach (var _ in _chatSession.SendMessageStreamAsync("什么是市盈率?")) { } + await foreach (var _ in _chatSession.SendMessageStreamAsync("有何意义?")) { } - Assert.IsNotNull(response); - Assert.IsNotNull(response.Text); + var responseBuilder = new System.Text.StringBuilder(); + await foreach (var update in _chatSession.SendMessageStreamAsync("这两个指标有什么区别")) + { + responseBuilder.Append(update); + } - // 验证对话历史 - var history = _chatSession.ConversationHistory; - Assert.IsTrue(history.Count > 0); + var responseText = responseBuilder.ToString(); + Assert.IsFalse(string.IsNullOrWhiteSpace(responseText)); - Console.WriteLine($"对话历史条数: {history.Count}"); - Console.WriteLine($"最新回复: {response.Text}"); + var history = await _chatSession.GetConversationHistoryAsync(); + Assert.IsTrue(history.Count >= 6, "三轮对话应至少包含 3 条用户消息和 3 条助手回复"); + Assert.IsTrue( + ContainsAnyKeyword(responseText, "市盈率", "PE", "估值", "盈利", "市净率", "指标", "比率"), + "第三轮回复应延续前两轮讨论的估值指标话题"); } [TestMethod] - public void TestClearHistoryAsync() + [TestCategory("Agent")] + public async Task TestClearHistoryAsync() { - // 添加一些对话 - _chatSession.SendMessageAsync("测试消息").Wait(); + await foreach (var _ in _chatSession.SendMessageStreamAsync("测试消息")) { } - // 验证有历史记录 - Assert.IsTrue(_chatSession.ConversationHistory.Count > 0); + var historyBefore = await _chatSession.GetConversationHistoryAsync(); + Assert.IsTrue(historyBefore.Count > 0); - // 清除历史 _chatSession.ClearHistory(); - // 验证历史被清空(应该只剩系统消息) - Assert.IsTrue(_chatSession.ConversationHistory.Count <= 1); + var historyAfter = await _chatSession.GetConversationHistoryAsync(); + Assert.AreEqual(0, historyAfter.Count); } [TestMethod] + [TestCategory("Agent")] public async Task TestContextWindowManagementAsync() { - // 设置股票上下文 - _chatSession.SetStockCode("sz002594"); + _chatSession.SetCurrentStock("sz002594"); - // 添加大量消息来测试上下文窗口管理 - for (int i = 0; i < 50; i++) + for (int i = 0; i < 10; i++) { - await _chatSession.SendMessageAsync($"这是第{i}次测试消息,关于sz002594的股票分析。"); + await foreach (var _ in _chatSession.SendMessageStreamAsync($"这是第{i}次测试消息,关于sz002594的股票分析。")) { } } - // 测试对话历史是否被管理 - Assert.IsTrue(_chatSession.ConversationHistory.Count > 0); + var history = await _chatSession.GetConversationHistoryAsync(); + Assert.IsTrue(history.Count > 0); Assert.AreEqual("sz002594", _chatSession.CurrentStockCode); - - Console.WriteLine($"消息数: {_chatSession.ConversationHistory.Count}"); } [TestMethod] + [TestCategory("Agent")] public async Task TestTopicGuidanceAsync() { - // 设置股票上下文 - _chatSession.SetStockCode("sz002594"); - - // 询问与股票无关的消息,测试AI是否能自然地引导回相关话题 - var response = await _chatSession.SendMessageAsync("今天的天气怎么样"); + _chatSession.SetCurrentStock("sz002594"); - Assert.IsNotNull(response); - Assert.IsNotNull(response.Text); - // AI应该能够自然地回复用户或引导回股票话题 + var responseBuilder = new System.Text.StringBuilder(); + await foreach (var update in _chatSession.SendMessageStreamAsync("今天的天气怎么样")) + { + responseBuilder.Append(update); + } - Console.WriteLine($"AI的回复: {response.Text}"); + var responseText = responseBuilder.ToString(); + Assert.IsFalse(string.IsNullOrWhiteSpace(responseText)); + Assert.IsTrue( + ContainsAnyKeyword(responseText, "股票", "市场", "投资", "002594", "比亚迪", "标的", "分析", "金融市场"), + "偏离话题的提问应被引导回股票或市场相关讨论"); } [TestMethod] + [TestCategory("Agent")] public async Task TestStreamingResponseAsync() { - // 设置股票上下文 - _chatSession.SetStockCode("sz000001"); + _chatSession.SetCurrentStock("sz000001"); - var streamingContent = new List(); - bool streamingCompleted = false; - - _chatSession.StreamingResponse += (sender, args) => - { - if (!string.IsNullOrEmpty(args.Content)) - { - streamingContent.Add(args.Content); - } - if (args.IsComplete) - { - streamingCompleted = true; - } - }; - - // 测试流式响应 var allContent = new List(); await foreach (var update in _chatSession.SendMessageStreamAsync("分析sz000001的技术指标")) { - if (!string.IsNullOrEmpty(update.Content)) + if (!string.IsNullOrEmpty(update)) { - allContent.Add(update.Content); + allContent.Add(update); } } - Assert.IsTrue(allContent.Count > 0); - Assert.IsTrue(streamingCompleted); - - var fullResponse = string.Join("", allContent); - Console.WriteLine($"流式响应完整内容: {fullResponse}"); + Assert.IsTrue(allContent.Count > 1, "流式响应应产生多个内容块"); } [TestMethod] - public void TestCancellationAsync() + [TestCategory("Agent")] + public async Task TestCancellationAsync() { - var cts = new CancellationTokenSource(); + Assert.IsFalse(_chatSession.IsProcessing, "新会话初始不应处于处理中状态"); - // 启动一个可能耗时较长的请求 - var task = _chatSession.SendMessageAsync("请详细分析市场趋势", cts.Token); + var history = await _chatSession.GetConversationHistoryAsync(); + Assert.AreEqual(0, history.Count, "新会话初始对话历史应为空"); - // 立即取消 - _chatSession.CancelCurrentRequest(); - cts.Cancel(); + _chatSession.StopCurrentRequest(); + Assert.IsFalse(_chatSession.IsProcessing, "空闲会话调用 StopCurrentRequest 后仍不应处于处理中状态"); - // 验证取消状态 - Assert.IsTrue(_chatSession.IsProcessing == false); + _chatSession.SetCurrentStock("sz002594"); + _chatSession.StopCurrentRequest(); + Assert.AreEqual("sz002594", _chatSession.CurrentStockCode); + Assert.IsFalse(_chatSession.IsProcessing, "设置股票上下文不应改变处理状态"); } [TestMethod] + [TestCategory("Agent")] public async Task TestIntelligentAnalysisAsync() { - // 设置股票上下文 - _chatSession.SetStockCode("sz000858"); + _chatSession.SetCurrentStock("sz002594"); - // 测试AI能否智能调用可能的插件来回答深度 - var response1 = await _chatSession.SendMessageAsync("分析MACD和RSI指标"); - Assert.IsNotNull(response1.Text); + var response1Builder = new System.Text.StringBuilder(); + await foreach (var update in _chatSession.SendMessageStreamAsync("分析MACD和RSI指标")) + { + response1Builder.Append(update); + } + var response1 = response1Builder.ToString(); + Assert.IsTrue(response1.Length > 50, "技术分析回复应有足够深度"); - var response2 = await _chatSession.SendMessageAsync("这家公司的ROE和净利润如何?"); - Assert.IsNotNull(response2.Text); + var response2Builder = new System.Text.StringBuilder(); + await foreach (var update in _chatSession.SendMessageStreamAsync("这家公司的ROE和净利润如何?")) + { + response2Builder.Append(update); + } + var response2 = response2Builder.ToString(); + Assert.IsTrue(response2.Length > 50, "财务分析回复应有足够深度"); - var response3 = await _chatSession.SendMessageAsync("投资这只股票有什么风险?"); - Assert.IsNotNull(response3.Text); + var response3Builder = new System.Text.StringBuilder(); + await foreach (var update in _chatSession.SendMessageStreamAsync("投资这只股票有什么风险?")) + { + response3Builder.Append(update); + } + var response3 = response3Builder.ToString(); + Assert.IsTrue(response3.Length > 50, "风险分析回复应有足够深度"); + + var history = await _chatSession.GetConversationHistoryAsync(); + Assert.IsTrue(history.Count >= 6, "三轮分析对话应累积至少 6 条历史消息"); + } - Console.WriteLine($"技术分析回复: {response1.Text}"); - Console.WriteLine($"财务分析回复: {response2.Text}"); - Console.WriteLine($"风险分析回复: {response3.Text}"); + private static bool ContainsAnyKeyword(string text, params string[] keywords) + { + return keywords.Any(keyword => + text.Contains(keyword, StringComparison.OrdinalIgnoreCase)); } } diff --git a/tests/McpServiceTest.cs b/tests/McpServiceTest.cs index df1ba1d..69bac33 100644 --- a/tests/McpServiceTest.cs +++ b/tests/McpServiceTest.cs @@ -11,10 +11,10 @@ namespace TestMarketAssistant; public class McpServiceTest { [TestMethod] + [TestCategory("Integration")] [Timeout(120000)] public async Task GetAITools_Stdio_Success() { - // Arrange var config = new MCPServerConfig { Name = "mcp-server-filesystem", @@ -24,19 +24,20 @@ public async Task GetAITools_Stdio_Success() EnvironmentVariables = new Dictionary() }; - var service = new McpService(NullLogger.Instance); + var service = new McpService( + NullLogger.Instance, + new McpToolAuditLogger(NullLogger.Instance), + new MCPServerConfigService()); - // Act var tools = await service.GetAIToolsAsync([config]); - // Assert Assert.IsTrue(tools.Count > 0, "应该返回至少一个 AI 工具"); } [TestMethod] + [TestCategory("Unit")] public void CreateClientTransport_InvalidType_ThrowsException() { - // Arrange var config = new MCPServerConfig { Name = "invalid-server", @@ -44,7 +45,6 @@ public void CreateClientTransport_InvalidType_ThrowsException() Command = "test" }; - // Act & Assert Assert.ThrowsExactly(() => { McpService.CreateClientTransport(config); @@ -52,21 +52,33 @@ public void CreateClientTransport_InvalidType_ThrowsException() } [TestMethod] + [TestCategory("Unit")] public void GetEnabledConfigs_ReturnsOnlyEnabled() { - // Act - var configs = McpService.GetEnabledConfigs(); + var configService = new MCPServerConfigService(); + configService.ServerConfigs.Clear(); + configService.ServerConfigs.AddRange( + [ + new MCPServerConfig { Name = "enabled", Command = "test", IsEnabled = true }, + new MCPServerConfig { Name = "disabled", Command = "test", IsEnabled = false } + ]); + + var service = new McpService( + NullLogger.Instance, + new McpToolAuditLogger(NullLogger.Instance), + configService); + + var configs = service.GetEnabledConfigs(); - // Assert Assert.IsNotNull(configs); Assert.IsTrue(configs.All(c => c.IsEnabled), "应该只返回启用的配置"); } [TestMethod] + [TestCategory("Integration")] [Timeout(120000)] public async Task GetAITools_WithLifetimeManagement_Success() { - // Arrange var config = new MCPServerConfig { Name = "mcp-server-test", @@ -76,29 +88,28 @@ public async Task GetAITools_WithLifetimeManagement_Success() EnvironmentVariables = new Dictionary() }; - await using var service = new McpService(NullLogger.Instance); + await using var service = new McpService( + NullLogger.Instance, + new McpToolAuditLogger(NullLogger.Instance), + new MCPServerConfigService()); - // Act var tools = await service.GetAIToolsAsync([config]); - // Assert Assert.IsTrue(tools.Count > 0); - - // Cleanup - service 会自动释放客户端 } [TestMethod] + [TestCategory("Unit")] [Timeout(120000)] public async Task GetAITools_EmptyConfigs_ReturnsEmpty() { - // Arrange - var service = new McpService(NullLogger.Instance); + var service = new McpService( + NullLogger.Instance, + new McpToolAuditLogger(NullLogger.Instance), + new MCPServerConfigService()); - // Act var tools = await service.GetAIToolsAsync([]); - // Assert Assert.AreEqual(0, tools.Count, "空配置列表应该返回空工具列表"); } } - diff --git a/tests/Middleware/ConversationCompressionMiddlewareTest.cs b/tests/Middleware/ConversationCompressionMiddlewareTest.cs new file mode 100644 index 0000000..62471d1 --- /dev/null +++ b/tests/Middleware/ConversationCompressionMiddlewareTest.cs @@ -0,0 +1,61 @@ +using MarketAssistant.Agents.Middleware; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace TestMarketAssistant.Middleware; + +[TestClass] +public class ConversationCompressionMiddlewareTest +{ + [TestMethod] + [TestCategory("Unit")] + public void Constructor_ShouldSetDefaults() + { + var middleware = CreateMiddleware(); + + Assert.AreEqual(8000, middleware.MaxTokens); + Assert.AreEqual(4, middleware.ReserveRecentCount); + } + + [TestMethod] + [TestCategory("Unit")] + public void MaxTokens_CanBeChanged() + { + var middleware = CreateMiddleware(); + + middleware.MaxTokens = 4000; + + Assert.AreEqual(4000, middleware.MaxTokens); + } + + [TestMethod] + [TestCategory("Unit")] + public void ReserveRecentCount_CanBeChanged() + { + var middleware = CreateMiddleware(); + + middleware.ReserveRecentCount = 6; + + Assert.AreEqual(6, middleware.ReserveRecentCount); + } + + [TestMethod] + [TestCategory("Unit")] + public void PreCompressHook_DefaultNull() + { + var middleware = CreateMiddleware(); + + Assert.IsNull(middleware.PreCompressHook); + } + + private static ConversationCompressionMiddleware CreateMiddleware() + { + var chatClientFactory = new Mock>(); + chatClientFactory.Setup(f => f()).Returns(new Mock().Object); + + return new ConversationCompressionMiddleware( + chatClientFactory.Object, + NullLogger.Instance); + } +} diff --git a/tests/Middleware/TokenTrackingMiddlewareTest.cs b/tests/Middleware/TokenTrackingMiddlewareTest.cs new file mode 100644 index 0000000..de88f0a --- /dev/null +++ b/tests/Middleware/TokenTrackingMiddlewareTest.cs @@ -0,0 +1,52 @@ +using MarketAssistant.Agents.Middleware; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace TestMarketAssistant.Middleware; + +[TestClass] +public class TokenTrackingMiddlewareTest +{ + [TestMethod] + [TestCategory("Unit")] + public void GetCumulativeTokens_NullSession_ReturnsZeros() + { + var (input, output) = TokenTrackingMiddleware.GetCumulativeTokens(null); + + Assert.AreEqual(0, input); + Assert.AreEqual(0, output); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task GetCumulativeTokens_EmptyStateBag_ReturnsZeros() + { + var chatClient = new Mock().Object; + var agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions { Name = "TestAgent" }); + var session = await agent.CreateSessionAsync(); + + var (input, output) = TokenTrackingMiddleware.GetCumulativeTokens(session); + + Assert.AreEqual(0, input); + Assert.AreEqual(0, output); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task GetCumulativeTokens_WithValues_ReturnsCorrectly() + { + var chatClient = new Mock().Object; + var agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions { Name = "TestAgent" }); + var session = await agent.CreateSessionAsync(); + + session.StateBag.SetValue(TokenTrackingMiddleware.InputTokensKey, "150"); + session.StateBag.SetValue(TokenTrackingMiddleware.OutputTokensKey, "75"); + + var (input, output) = TokenTrackingMiddleware.GetCumulativeTokens(session); + + Assert.AreEqual(150, input); + Assert.AreEqual(75, output); + } +} diff --git a/tests/Middleware/TradingFunctionGuardMiddlewareTest.cs b/tests/Middleware/TradingFunctionGuardMiddlewareTest.cs new file mode 100644 index 0000000..633ea92 --- /dev/null +++ b/tests/Middleware/TradingFunctionGuardMiddlewareTest.cs @@ -0,0 +1,63 @@ +using MarketAssistant.Agents.Middleware; +using Microsoft.Extensions.Logging.Abstractions; +using System.Reflection; + +namespace TestMarketAssistant.Middleware; + +[TestClass] +public class TradingFunctionGuardMiddlewareTest +{ + [TestMethod] + [TestCategory("Unit")] + public void Constructor_DefaultMaxToolCalls_ShouldBe20() + { + var middleware = new TradingFunctionGuardMiddleware( + NullLogger.Instance); + + var maxToolCalls = GetPrivateField(middleware, "_maxToolCalls"); + + Assert.AreEqual(20, maxToolCalls); + } + + [TestMethod] + [TestCategory("Unit")] + public void Constructor_CustomMaxToolCalls_ShouldAccept() + { + var middleware = new TradingFunctionGuardMiddleware( + NullLogger.Instance, + maxToolCalls: 5); + + var maxToolCalls = GetPrivateField(middleware, "_maxToolCalls"); + + Assert.AreEqual(5, maxToolCalls); + } + + [TestMethod] + [TestCategory("Unit")] + public void ToolCallCount_ShouldStartAtZero() + { + var middleware = new TradingFunctionGuardMiddleware( + NullLogger.Instance); + + var count = GetPrivateField(middleware, "_toolCallCount"); + + Assert.AreEqual(0, count); + } + + [TestMethod] + [TestCategory("Unit")] + public void ConfirmationCallback_DefaultNull() + { + var middleware = new TradingFunctionGuardMiddleware( + NullLogger.Instance); + + Assert.IsNull(middleware.ConfirmationCallback); + } + + private static T GetPrivateField(object instance, string fieldName) + { + var field = instance.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance); + Assert.IsNotNull(field, $"Field '{fieldName}' not found."); + return (T)field!.GetValue(instance)!; + } +} diff --git a/tests/StockKLineServiceTest.cs b/tests/StockKLineServiceTest.cs deleted file mode 100644 index 0134c24..0000000 --- a/tests/StockKLineServiceTest.cs +++ /dev/null @@ -1,98 +0,0 @@ -using MarketAssistant.Applications.Settings; -using MarketAssistant.Applications.Stocks; -using MarketAssistant.Services.Settings; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; - -namespace TestMarketAssistant; - -[TestClass] -public class StockKLineServiceTest -{ - private StockKLineService _stockKLineService = null!; - private Mock _mockUserSettingService = null!; - - [TestInitialize] - public void Initialize() - { - var zhiTuApiToken = Environment.GetEnvironmentVariable("ZHITU_API_TOKEN") ?? throw new InvalidOperationException("ZHITU_API_TOKEN environment variable is not set"); - - // 创建模拟的用户设置服? - _mockUserSettingService = new Mock(); - var testUserSetting = new UserSetting - { - ZhiTuApiToken = zhiTuApiToken - }; - _mockUserSettingService.Setup(x => x.CurrentSetting).Returns(testUserSetting); - - // 使用NullLogger和模拟的用户设置服务创建StockKLineService实例 - _stockKLineService = new StockKLineService( - NullLogger.Instance, - _mockUserSettingService.Object); - } - - [TestMethod] - public async Task GetDailyKLineDataAsync_ValidSymbol_ReturnsCorrectData() - { - // Arrange - string symbol = "600000"; - - // Act - var result = await _stockKLineService.GetDailyKLineDataAsync(symbol); - - // Assert - Assert.IsNotNull(result); - Assert.AreEqual(symbol, result.Symbol); - Assert.AreEqual("daily", result.Interval); - Assert.IsTrue(result.Data.Count > 0); - } - - [TestMethod] - public async Task GetWeeklyKLineDataAsync_ValidSymbol_ReturnsCorrectData() - { - // Arrange - string symbol = "000001.SZ"; - - // Act - var result = await _stockKLineService.GetWeeklyKLineDataAsync(symbol); - - // Assert - Assert.IsNotNull(result); - Assert.AreEqual(symbol, result.Symbol); - Assert.AreEqual("weekly", result.Interval); - Assert.IsTrue(result.Data.Count > 0); - } - - [TestMethod] - public async Task GetMonthlyKLineDataAsync_ValidSymbol_ReturnsCorrectData() - { - // Arrange - string symbol = "601398.SH"; - - // Act - var result = await _stockKLineService.GetMonthlyKLineDataAsync(symbol); - - // Assert - Assert.IsNotNull(result); - Assert.AreEqual(symbol, result.Symbol); - Assert.AreEqual("monthly", result.Interval); - Assert.IsTrue(result.Data.Count > 0); - } - - [TestMethod] - public async Task GetMinuteKLineDataAsync_ValidSymbol_ReturnsCorrectData() - { - // Arrange - string symbol = "600000.SH"; - string interval = "5"; - - // Act - var result = await _stockKLineService.GetMinuteKLineDataAsync(symbol, interval); - - // Assert - Assert.IsNotNull(result); - Assert.AreEqual(symbol, result.Symbol); - Assert.AreEqual("5min", result.Interval); - Assert.IsTrue(result.Data.Count > 0); - } -} \ No newline at end of file diff --git a/tests/StockScreenerServiceTest.cs b/tests/StockScreenerServiceTest.cs deleted file mode 100644 index cec6251..0000000 --- a/tests/StockScreenerServiceTest.cs +++ /dev/null @@ -1,150 +0,0 @@ -using MarketAssistant.Applications.Settings; -using MarketAssistant.Services.Browser; -using MarketAssistant.Services.Settings; -using MarketAssistant.Services.StockScreener; -using MarketAssistant.Services.StockScreener.Models; -using Microsoft.Extensions.Logging; -using Moq; - -namespace TestMarketAssistant; - -/// -/// 股票筛选服务测试(最小原则:仅验证核心正常流程) -/// -[TestClass] -public sealed class StockScreenerServiceTest -{ - private StockScreenerService _service = null!; - private Mock> _mockLogger = null!; - private PlaywrightService _playwrightService = null!; - - [TestInitialize] - public void Initialize() - { - _mockLogger = new Mock>(); - - // 创建 Mock 的 IUserSettingService (不需要任何真实配置) - var mockUserSettingService = new Mock(); - mockUserSettingService.Setup(s => s.CurrentSetting) - .Returns(new UserSetting - { - ModelId = "test-model", - Endpoint = "http://localhost", - ApiKey = "test-key" - }); - - _playwrightService = new PlaywrightService(mockUserSettingService.Object, null); - _service = new StockScreenerService(_playwrightService, _mockLogger.Object); - } - - [TestCleanup] - public async Task Cleanup() - { - if (_playwrightService != null) - { - await _playwrightService.DisposeAsync(); - } - } - - [TestMethod] - public void Constructor_WithValidParameters_ShouldCreateInstance() - { - // Assert - Assert.IsNotNull(_service); - } - - [TestMethod] - public async Task ScreenStocksAsync_WithDefaultCriteria_ShouldReturnStocks() - { - // Arrange - var criteria = new StockCriteria - { - Market = MarketType.AllAShares, - Industry = IndustryType.All, - Limit = 10, - Criteria = new List() - }; - - // Act - var result = await _service.ScreenStocksAsync(criteria); - - // Assert - Assert.IsNotNull(result); - Assert.IsTrue(result.Count >= 0); - Assert.IsTrue(result.Count <= criteria.Limit); - - Console.WriteLine($"默认条件筛选 - 返回股票数量: {result.Count}"); - } - - [TestMethod] - public async Task ScreenStocksAsync_WithSingleCriteria_ShouldReturnFilteredStocks() - { - // Arrange - 测试单个条件筛选(市值) - var criteria = new StockCriteria - { - Market = MarketType.AllAShares, - Industry = IndustryType.All, - Limit = 15, - Criteria = new List - { - new() - { - Code = "mc", - DisplayName = "总市值", - MinValue = 5000000000m, // 50亿元 - MaxValue = 25000000000m // 250亿元 - } - } - }; - - // Act - var result = await _service.ScreenStocksAsync(criteria); - - // Assert - Assert.IsNotNull(result); - Assert.IsTrue(result.Count >= 0); - Assert.IsTrue(result.Count <= criteria.Limit); - - Console.WriteLine($"单条件筛选(市值50-250亿) - 返回股票数量: {result.Count}"); - } - - [TestMethod] - public async Task ScreenStocksAsync_WithMultipleCriteria_ShouldReturnFilteredStocks() - { - // Arrange - 测试多条件组合筛选 - var criteria = new StockCriteria - { - Market = MarketType.AllAShares, - Industry = IndustryType.All, - Limit = 20, - Criteria = new List - { - new() - { - Code = "mc", - DisplayName = "总市值", - MinValue = 50000000000m, // 500亿元 - MaxValue = 1000000000000m // 1万亿元 - }, - new() - { - Code = "pettm", - DisplayName = "市盈率TTM", - MinValue = 5m, - MaxValue = 50m - } - } - }; - - // Act - var result = await _service.ScreenStocksAsync(criteria); - - // Assert - Assert.IsNotNull(result); - Assert.IsTrue(result.Count >= 0); - Assert.IsTrue(result.Count <= criteria.Limit); - - Console.WriteLine($"多条件筛选(市值+PE) - 返回股票数量: {result.Count}"); - } -} - diff --git a/tests/StockSelectionServiceTest.cs b/tests/StockSelectionServiceTest.cs deleted file mode 100644 index 4237494..0000000 --- a/tests/StockSelectionServiceTest.cs +++ /dev/null @@ -1,127 +0,0 @@ -using MarketAssistant.Agents.StockSelection; -using MarketAssistant.Applications.StockSelection; -using MarketAssistant.Applications.StockSelection.Models; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Moq; - -namespace TestMarketAssistant; - -/// -/// 选股服务测试(最小原则:仅验证业务层独有逻辑) -/// 注:底层工作流逻辑由 StockSelectionWorkflowTest 覆盖 -/// -[TestClass] -public sealed class StockSelectionServiceTest : BaseAgentTest -{ - private StockSelectionService _stockSelectionService = null!; - private StockSelectionWorkflow _stockSelectionWorkflow = null!; - private Mock> _mockLogger = null!; - - [TestInitialize] - public void Initialize() - { - _stockSelectionWorkflow = _serviceProvider.GetRequiredService(); - _mockLogger = new Mock>(); - _stockSelectionService = new StockSelectionService(_stockSelectionWorkflow, _mockLogger.Object); - } - - [TestCleanup] - public void Cleanup() - { - _stockSelectionService?.Dispose(); - } - - [TestMethod] - public async Task RecommendStocksByUserRequirement_ShouldReturnValidResult() - { - // Arrange - var request = new StockRecommendationRequest - { - UserRequirements = "寻找价值股投资机会", - RiskPreference = "conservative", - InvestmentAmount = 100000m, - InvestmentHorizon = 365 - }; - - // Act - var result = await _stockSelectionService.RecommendStocksByUserRequirementAsync(request); - - // Assert - Assert.IsNotNull(result); - Assert.IsNotNull(result.Recommendations); - Assert.IsTrue(result.ConfidenceScore >= 0 && result.ConfidenceScore <= 100); - Assert.IsFalse(string.IsNullOrWhiteSpace(result.AnalysisSummary)); - - Console.WriteLine($"用户需求选股 - 推荐数量: {result.Recommendations.Count}, 置信度: {result.ConfidenceScore:F1}%"); - } - - [TestMethod] - public async Task RecommendStocksByNews_ShouldReturnResultWithNewsTag() - { - // Arrange - 测试业务层添加新闻标签的逻辑 - var request = new NewsBasedSelectionRequest - { - NewsContent = "人工智能技术取得重大突破,相关概念股受到市场追捧", - MaxRecommendations = 5 - }; - - // Act - var result = await _stockSelectionService.RecommendStocksByNewsAsync(request); - - // Assert - Assert.IsNotNull(result); - Assert.IsNotNull(result.Recommendations); - Assert.IsTrue(result.Recommendations.Count <= request.MaxRecommendations); - - // 验证业务层特有逻辑:新闻热点标识被添加 - if (result.Recommendations.Count > 0) - { - foreach (var recommendation in result.Recommendations) - { - Assert.IsTrue(recommendation.Reason.Contains("[新闻热点]"), - "业务层应该为新闻推荐添加 [新闻热点] 标签"); - } - } - - Console.WriteLine($"新闻推荐 - 推荐数量: {result.Recommendations.Count}, 置信度: {result.ConfidenceScore:F1}%"); - } - - [TestMethod] - public async Task QuickSelect_ShouldReturnValidResult() - { - // Arrange - 测试业务层策略转换逻辑 - var strategy = QuickSelectionStrategy.ValueStocks; - - // Act - var result = await _stockSelectionService.QuickSelectAsync(strategy); - - // Assert - Assert.IsNotNull(result); - Assert.IsNotNull(result.Recommendations); - Assert.IsFalse(string.IsNullOrWhiteSpace(result.AnalysisSummary)); - - Console.WriteLine($"快速选股({strategy}) - 推荐数量: {result.Recommendations.Count}, 置信度: {result.ConfidenceScore:F1}%"); - } - - [TestMethod] - public void GetQuickSelectionStrategies_ShouldReturnCompleteStrategies() - { - // Act - 测试业务层提供的策略元数据 - var strategies = _stockSelectionService.GetQuickSelectionStrategies(); - - // Assert - Assert.IsNotNull(strategies); - Assert.IsTrue(strategies.Count > 0, "应该返回至少一个策略"); - - foreach (var strategyInfo in strategies) - { - Assert.IsFalse(string.IsNullOrWhiteSpace(strategyInfo.Name), "策略名称不能为空"); - Assert.IsFalse(string.IsNullOrWhiteSpace(strategyInfo.Description), "策略描述不能为空"); - Assert.IsFalse(string.IsNullOrWhiteSpace(strategyInfo.Scenario), "适用场景不能为空"); - Assert.IsFalse(string.IsNullOrWhiteSpace(strategyInfo.RiskLevel), "风险等级不能为空"); - } - - Console.WriteLine($"策略列表 - 总数: {strategies.Count}"); - } -} diff --git a/tests/StockSelectionWorkflowTest.cs b/tests/StockSelectionWorkflowTest.cs deleted file mode 100644 index ef0b8fd..0000000 --- a/tests/StockSelectionWorkflowTest.cs +++ /dev/null @@ -1,72 +0,0 @@ -using MarketAssistant.Agents.StockSelection; -using MarketAssistant.Applications.StockSelection.Models; -using Microsoft.Extensions.DependencyInjection; - -namespace TestMarketAssistant; - -/// -/// 选股工作流测试(最小原则:验证核心工作流功能) -/// -[TestClass] -public sealed class StockSelectionWorkflowTest : BaseAgentTest -{ - private StockSelectionWorkflow _workflow = null!; - - [TestInitialize] - public void Initialize() - { - _workflow = _serviceProvider.GetRequiredService(); - } - - [TestCleanup] - public void Cleanup() - { - _workflow?.Dispose(); - } - - [TestMethod] - public async Task AnalyzeUserRequirement_ShouldReturnValidResult() - { - // Arrange - var request = new StockRecommendationRequest - { - UserRequirements = "寻找市值大于100亿的成长股,ROE要大于15%", - RiskPreference = "moderate", - InvestmentAmount = 500000m, - InvestmentHorizon = 180 - }; - - // Act - var result = await _workflow.AnalyzeUserRequirementAsync(request); - - // Assert - Assert.IsNotNull(result); - Assert.IsNotNull(result.Recommendations); - Assert.IsTrue(result.ConfidenceScore >= 0 && result.ConfidenceScore <= 100); - Assert.IsFalse(string.IsNullOrWhiteSpace(result.AnalysisSummary)); - - Console.WriteLine($"用户需求分析完成 - 推荐数量: {result.Recommendations.Count}, 置信度: {result.ConfidenceScore:F1}%"); - } - - [TestMethod] - public async Task AnalyzeNewsHotspot_ShouldReturnValidResult() - { - // Arrange - var request = new NewsBasedSelectionRequest - { - NewsContent = "人工智能技术取得重大突破,相关概念股受到市场追捧", - MaxRecommendations = 5 - }; - - // Act - var result = await _workflow.AnalyzeNewsHotspotAsync(request); - - // Assert - Assert.IsNotNull(result); - Assert.IsNotNull(result.Recommendations); - Assert.IsTrue(result.ConfidenceScore >= 0 && result.ConfidenceScore <= 100); - Assert.IsTrue(result.Recommendations.Count <= request.MaxRecommendations); - - Console.WriteLine($"新闻热点分析完成 - 推荐数量: {result.Recommendations.Count}, 置信度: {result.ConfidenceScore:F1}%"); - } -} diff --git a/tests/StockServiceTest.cs b/tests/StockServiceTest.cs deleted file mode 100644 index badcd1f..0000000 --- a/tests/StockServiceTest.cs +++ /dev/null @@ -1,32 +0,0 @@ -using MarketAssistant.Applications.Stocks; -using MarketAssistant.Services.Browser; -using Microsoft.Extensions.Logging; -using Moq; - -namespace TestMarketAssistant; - -[TestClass] -public class StockServiceTest -{ - private StockService _stockService; - private Mock> _loggerMock; - private Mock _playwrightServiceMock; - - [TestInitialize] - public void Initialize() - { - _loggerMock = new Mock>(); - _playwrightServiceMock = new Mock(); - _stockService = new StockService(_loggerMock.Object, _playwrightServiceMock.Object); - } - - [TestMethod] - public async Task TestGetHotStocksAsync() - { - // Act - var result = await _stockService.GetHotStocksAsync(); - // Assert - Assert.IsNotNull(result); - Assert.IsTrue(result.Count > 0); - } -} diff --git a/tests/StockSymbolConverterTest.cs b/tests/StockSymbolConverterTest.cs index 152178d..3b86722 100644 --- a/tests/StockSymbolConverterTest.cs +++ b/tests/StockSymbolConverterTest.cs @@ -7,6 +7,7 @@ namespace Tests; public class StockSymbolConverterTest { [TestMethod] + [TestCategory("Unit")] [DataRow("600000", "sh600000")] [DataRow("SH600000", "sh600000")] [DataRow("sh600000", "sh600000")] @@ -22,6 +23,7 @@ public void ToClsFormat_ShouldReturnCorrectFormat(string input, string expected) } [TestMethod] + [TestCategory("Unit")] [DataRow("600000", "600000.SH")] [DataRow("000001", "000001.SZ")] [DataRow("688001", "688001.SH")] @@ -43,6 +45,7 @@ public void ToZhiTuFormat_ShouldReturnCorrectFormat_ForNumericCode(string input, } [TestMethod] + [TestCategory("Unit")] [DataRow("sh600000", "600000.SH")] [DataRow("sz000001", "000001.SZ")] [DataRow("SH688001", "688001.SH")] @@ -54,6 +57,7 @@ public void ToZhiTuFormat_ShouldReturnCorrectFormat_ForPrefixedCode(string input } [TestMethod] + [TestCategory("Unit")] [DataRow("600000.SH", "600000.SH")] [DataRow("000001.sz", "000001.SZ")] [DataRow("688001.sh", "688001.SH")] diff --git a/tests/TestHttpClientServiceCollectionExtensions.cs b/tests/TestHttpClientServiceCollectionExtensions.cs new file mode 100644 index 0000000..6f7236e --- /dev/null +++ b/tests/TestHttpClientServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using MarketAssistant.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace TestMarketAssistant; + +/// +/// 测试用 HttpClient 注册,直接复用生产配置(含 Resilience Handler) +/// +internal static class TestHttpClientServiceCollectionExtensions +{ + public static IServiceCollection AddTestMarketDataHttpClients(this IServiceCollection services) + { + return services.AddNamedMarketHttpClients(); + } +} diff --git a/tests/TestMarketAssistant.csproj b/tests/TestMarketAssistant.csproj index d4980d8..fa1dd67 100644 --- a/tests/TestMarketAssistant.csproj +++ b/tests/TestMarketAssistant.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -8,12 +8,15 @@ - - - + + + + + + - + diff --git a/tests/TestMarketAssistant.csproj.lscache b/tests/TestMarketAssistant.csproj.lscache new file mode 100644 index 0000000..20a6a1c --- /dev/null +++ b/tests/TestMarketAssistant.csproj.lscache @@ -0,0 +1,490 @@ +version=1 + +# This file caches language service data to improve the performance of C# Dev Kit. +# It is not intended for manual editing. It can safely be deleted and will be +# regenerated automatically. For more information, see https://aka.ms/lscache +# +# To control where cache files are stored, use the following VS Code setting: +# "dotnet.projectsystem.cacheInProjectFolder": true + +[project] +language=C# +primary +lastDtbSucceeded + +[properties] +AssemblyName=TestMarketAssistant +CommandLineArgsForDesignTimeEvaluation=-langversion:latest -define:TRACE +CompilerGeneratedFilesOutputPath= +MaxSupportedLangVersion=14.0 +ProjectAssetsFile=obj/project.assets.json +RootNamespace=TestMarketAssistant +RunAnalyzers= +RunAnalyzersDuringLiveAnalysis= +SolutionPath=../MarketAssistant.slnx +TargetFrameworkIdentifier=.NETCoreApp +TargetPath=bin/Debug/net10.0/TestMarketAssistant.dll +TargetRefPath=obj/Debug/net10.0/ref/TestMarketAssistant.dll +TemporaryDependencyNodeTargetIdentifier=net10.0 + +[commandLineArguments] +/noconfig +/unsafe- +/checked- +/nowarn:1701,1702,1701,1702,8002 +/fullpaths +/nostdlib+ +/errorreport:prompt +/warn:10 +/define:TRACE;DEBUG;NET;NET10_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NET10_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER +/highentropyva+ +/nullable:enable +/features:"InterceptorsNamespaces=;Microsoft.Extensions.Validation.Generated" +/debug+ +/debug:portable +/filealign:512 +/optimize- +/out:obj\Debug\net10.0\TestMarketAssistant.dll +/refout:obj\Debug\net10.0\refint\TestMarketAssistant.dll +/target:exe +/warnaserror- +/utf8output +/deterministic+ +/langversion:latest +/warnaserror+:NU1605,SYSLIB0011 + +[sourceFiles] +AnalysisCacheServiceTest.cs +AnalystAgentFactoryTest.cs +Application/ + AssetCacheServiceTest.cs + AssetHistoryServiceTest.cs + AssetInfoServiceTest.cs + AssetScreenerServiceTest.cs + FavoriteServiceTest.cs + HomeAssetServiceTest.cs + KLineServiceTest.cs +BaseAgentTest.cs +Crypto/BinanceAuthServiceTest.cs +CryptoSymbolConverterTest.cs +D:/NuGet/Packages/microsoft.net.test.sdk/18.3.0/build/net8.0/Microsoft.NET.Test.Sdk.Program.cs + @folderNames= +GitHubReleaseServiceTest.cs +InvestmentSelection/ScreenInvestmentTargetsExecutorTest.cs +LocalImageStorageServiceTest.cs +MarketAnalysisWorkflowTest.cs +MarketChatSessionTest.cs +McpServiceTest.cs +MSTestSettings.cs +obj/Debug/net10.0/ + .NETCoreApp,Version=v10.0.AssemblyAttributes.cs + TestMarketAssistant.AssemblyInfo.cs + TestMarketAssistant.GlobalUsings.g.cs +StockSymbolConverterTest.cs +TestHttpClientServiceCollectionExtensions.cs +Tools/ + BasicDataToolsTest.cs + CryptoMetricsToolsTest.cs + CryptoTechnicalToolsTest.cs + FinancialDataToolsTest.cs + NewsDataToolsTest.cs + SentimentToolsTest.cs + TechnicalDataToolsTest.cs +Vectors/ + ClipImageEmbeddingServiceTest.cs + DocumentBlockMapperTest.cs + DocxFileTest.cs + MarkdownDocumentBlockReaderTest.cs + PdfFileTest.cs + QueryRewriteServiceTest.cs + RagIngestionServiceIntegrationTest.cs + RerankerServiceTest.cs + RetrievalOrchestratorIntegrationTest.cs + TextChunkingServiceTest.cs + TextCleaningServiceTest.cs + VectorStoreIntegrationTest.cs + +[metadataReferences] +../src/ + MarketAssistant.Agents/obj/Debug/net10.0/ref/MarketAssistant.Agents.dll + MarketAssistant.App.Services/obj/Debug/net10.0/ref/MarketAssistant.App.Services.dll + MarketAssistant.App/obj/Debug/net10.0/ref/MarketAssistant.dll + MarketAssistant.Core/obj/Debug/net10.0/ref/MarketAssistant.Core.dll + MarketAssistant.DataProviders/obj/Debug/net10.0/ref/MarketAssistant.DataProviders.dll + MarketAssistant.Rag/obj/Debug/net10.0/ref/MarketAssistant.Rag.dll + MarketAssistant.Trading/obj/Debug/net10.0/ref/MarketAssistant.Trading.dll +/packs/Microsoft.NETCore.App.Ref/10.0.5/ref/net10.0/ + Microsoft.CSharp.dll + Microsoft.VisualBasic.Core.dll + Microsoft.VisualBasic.dll + Microsoft.Win32.Primitives.dll + Microsoft.Win32.Registry.dll + mscorlib.dll + netstandard.dll + System.AppContext.dll + System.Buffers.dll + System.Collections.Concurrent.dll + System.Collections.dll + System.Collections.Immutable.dll + System.Collections.NonGeneric.dll + System.Collections.Specialized.dll + System.ComponentModel.Annotations.dll + System.ComponentModel.DataAnnotations.dll + System.ComponentModel.dll + System.ComponentModel.EventBasedAsync.dll + System.ComponentModel.Primitives.dll + System.ComponentModel.TypeConverter.dll + System.Configuration.dll + System.Console.dll + System.Core.dll + System.Data.Common.dll + System.Data.DataSetExtensions.dll + System.Data.dll + System.Diagnostics.Contracts.dll + System.Diagnostics.Debug.dll + System.Diagnostics.DiagnosticSource.dll + System.Diagnostics.FileVersionInfo.dll + System.Diagnostics.Process.dll + System.Diagnostics.StackTrace.dll + System.Diagnostics.TextWriterTraceListener.dll + System.Diagnostics.Tools.dll + System.Diagnostics.TraceSource.dll + System.Diagnostics.Tracing.dll + System.dll + System.Drawing.dll + System.Drawing.Primitives.dll + System.Dynamic.Runtime.dll + System.Formats.Asn1.dll + System.Formats.Tar.dll + System.Globalization.Calendars.dll + System.Globalization.dll + System.Globalization.Extensions.dll + System.IO.Compression.Brotli.dll + System.IO.Compression.dll + System.IO.Compression.FileSystem.dll + System.IO.Compression.ZipFile.dll + System.IO.dll + System.IO.FileSystem.AccessControl.dll + System.IO.FileSystem.dll + System.IO.FileSystem.DriveInfo.dll + System.IO.FileSystem.Primitives.dll + System.IO.FileSystem.Watcher.dll + System.IO.IsolatedStorage.dll + System.IO.MemoryMappedFiles.dll + System.IO.Pipelines.dll + System.IO.Pipes.AccessControl.dll + System.IO.Pipes.dll + System.IO.UnmanagedMemoryStream.dll + System.Linq.AsyncEnumerable.dll + System.Linq.dll + System.Linq.Expressions.dll + System.Linq.Parallel.dll + System.Linq.Queryable.dll + System.Memory.dll + System.Net.dll + System.Net.Http.dll + System.Net.Http.Json.dll + System.Net.HttpListener.dll + System.Net.Mail.dll + System.Net.NameResolution.dll + System.Net.NetworkInformation.dll + System.Net.Ping.dll + System.Net.Primitives.dll + System.Net.Quic.dll + System.Net.Requests.dll + System.Net.Security.dll + System.Net.ServerSentEvents.dll + System.Net.ServicePoint.dll + System.Net.Sockets.dll + System.Net.WebClient.dll + System.Net.WebHeaderCollection.dll + System.Net.WebProxy.dll + System.Net.WebSockets.Client.dll + System.Net.WebSockets.dll + System.Numerics.dll + System.Numerics.Vectors.dll + System.ObjectModel.dll + System.Reflection.DispatchProxy.dll + System.Reflection.dll + System.Reflection.Emit.dll + System.Reflection.Emit.ILGeneration.dll + System.Reflection.Emit.Lightweight.dll + System.Reflection.Extensions.dll + System.Reflection.Metadata.dll + System.Reflection.Primitives.dll + System.Reflection.TypeExtensions.dll + System.Resources.Reader.dll + System.Resources.ResourceManager.dll + System.Resources.Writer.dll + System.Runtime.CompilerServices.Unsafe.dll + System.Runtime.CompilerServices.VisualC.dll + System.Runtime.dll + System.Runtime.Extensions.dll + System.Runtime.Handles.dll + System.Runtime.InteropServices.dll + System.Runtime.InteropServices.JavaScript.dll + System.Runtime.InteropServices.RuntimeInformation.dll + System.Runtime.Intrinsics.dll + System.Runtime.Loader.dll + System.Runtime.Numerics.dll + System.Runtime.Serialization.dll + System.Runtime.Serialization.Formatters.dll + System.Runtime.Serialization.Json.dll + System.Runtime.Serialization.Primitives.dll + System.Runtime.Serialization.Xml.dll + System.Security.AccessControl.dll + System.Security.Claims.dll + System.Security.Cryptography.Algorithms.dll + System.Security.Cryptography.Cng.dll + System.Security.Cryptography.Csp.dll + System.Security.Cryptography.dll + System.Security.Cryptography.Encoding.dll + System.Security.Cryptography.OpenSsl.dll + System.Security.Cryptography.Primitives.dll + System.Security.Cryptography.X509Certificates.dll + System.Security.dll + System.Security.Principal.dll + System.Security.Principal.Windows.dll + System.Security.SecureString.dll + System.ServiceModel.Web.dll + System.ServiceProcess.dll + System.Text.Encoding.CodePages.dll + System.Text.Encoding.dll + System.Text.Encoding.Extensions.dll + System.Text.Encodings.Web.dll + System.Text.Json.dll + System.Text.RegularExpressions.dll + System.Threading.AccessControl.dll + System.Threading.Channels.dll + System.Threading.dll + System.Threading.Overlapped.dll + System.Threading.Tasks.Dataflow.dll + System.Threading.Tasks.dll + System.Threading.Tasks.Extensions.dll + System.Threading.Tasks.Parallel.dll + System.Threading.Thread.dll + System.Threading.ThreadPool.dll + System.Threading.Timer.dll + System.Transactions.dll + System.Transactions.Local.dll + System.ValueTuple.dll + System.Web.dll + System.Web.HttpUtility.dll + System.Windows.dll + System.Xml.dll + System.Xml.Linq.dll + System.Xml.ReaderWriter.dll + System.Xml.Serialization.dll + System.Xml.XDocument.dll + System.Xml.XmlDocument.dll + System.Xml.XmlSerializer.dll + System.Xml.XPath.dll + System.Xml.XPath.XDocument.dll + WindowsBase.dll +D:/NuGet/Packages/ + adaptivecards/3.1.0/lib/net6.0/AdaptiveCards.dll + anglesharp/1.4.0/lib/net10.0/AngleSharp.dll + avalonia.avaloniaedit/11.0.0/lib/netstandard2.0/AvaloniaEdit.dll + avalonia.controls.webview/12.0.0-preview2/lib/net10.0/Avalonia.Controls.WebView.dll + avalonia.desktop/12.0.0-preview2/lib/net10.0/Avalonia.Desktop.dll + avalonia.fonts.inter/12.0.0-preview2/lib/net10.0/Avalonia.Fonts.Inter.dll + avalonia.freedesktop.atspi/12.0.0-preview2/lib/net10.0/Avalonia.FreeDesktop.AtSpi.dll + avalonia.freedesktop/12.0.0-preview2/lib/net10.0/Avalonia.FreeDesktop.dll + avalonia.harfbuzz/12.0.0-preview2/lib/net10.0/Avalonia.HarfBuzz.dll + avalonia.native/12.0.0-preview2/lib/net10.0/Avalonia.Native.dll + avalonia.remote.protocol/12.0.0-preview2/lib/net10.0/Avalonia.Remote.Protocol.dll + avalonia.skia/12.0.0-preview2/lib/net10.0/Avalonia.Skia.dll + avalonia.svg/11.0.0/lib/net6.0/Avalonia.Svg.dll + avalonia.themes.fluent/12.0.0-preview2/lib/net10.0/Avalonia.Themes.Fluent.dll + avalonia.win32/12.0.0-preview2/lib/net10.0/ + Avalonia.Win32.Automation.dll + Avalonia.Win32.dll + avalonia.x11/12.0.0-preview2/lib/net10.0/Avalonia.X11.dll + avalonia/12.0.0-preview2/ref/net10.0/ + Avalonia.Base.dll + Avalonia.Controls.dll + Avalonia.DesignerSupport.dll + Avalonia.Dialogs.dll + Avalonia.dll + Avalonia.Markup.dll + Avalonia.Markup.Xaml.dll + Avalonia.Metal.dll + Avalonia.MicroCom.dll + Avalonia.OpenGL.dll + Avalonia.Vulkan.dll + castle.core/5.1.1/lib/net6.0/Castle.Core.dll + colordocument.avalonia/11.0.3-a1/lib/net6.0/ColorDocument.Avalonia.dll + colortextblock.avalonia/11.0.3-a1/lib/net6.0/ColorTextBlock.Avalonia.dll + communitytoolkit.mvvm/8.4.1/lib/net8.0/CommunityToolkit.Mvvm.dll + documentformat.openxml.framework/3.5.1/lib/net10.0/DocumentFormat.OpenXml.Framework.dll + documentformat.openxml/3.5.1/lib/net10.0/DocumentFormat.OpenXml.dll + excss/4.3.1/lib/net8.0/ExCSS.dll + google.apis.core/1.68.0/lib/net6.0/Google.Apis.Core.dll + google.apis.customsearchapi.v1/1.68.0.3520/lib/net6.0/Google.Apis.CustomSearchAPI.v1.dll + google.apis/1.68.0/lib/net6.0/Google.Apis.dll + google.protobuf/3.30.2/lib/net5.0/Google.Protobuf.dll + harfbuzzsharp/8.3.1.3/lib/net8.0/HarfBuzzSharp.dll + htmlagilitypack/1.11.42/lib/netstandard2.0/HtmlAgilityPack.dll + markdig/1.1.1/lib/net10.0/Markdig.dll + markdown.avalonia.html/11.0.3-a1/lib/net6.0/Markdown.Avalonia.Html.dll + markdown.avalonia.svg/11.0.3-a1/lib/net6.0/Markdown.Avalonia.Svg.dll + markdown.avalonia.syntaxhigh/11.0.3-a1/lib/net6.0/Markdown.Avalonia.SyntaxHigh.dll + markdown.avalonia.tight/11.0.3-a1/lib/net6.0/Markdown.Avalonia.dll + markdown.avalonia/11.0.3-a1/lib/net6.0/Markdown.Avalonia.Full.dll + microcom.runtime/0.11.0/lib/net5.0/MicroCom.Runtime.dll + microsoft.agents.ai.abstractions/1.6.1/lib/net10.0/Microsoft.Agents.AI.Abstractions.dll + microsoft.agents.ai.workflows/1.6.1/lib/net10.0/Microsoft.Agents.AI.Workflows.dll + microsoft.agents.ai/1.6.1/lib/net10.0/Microsoft.Agents.AI.dll + microsoft.applicationinsights/2.23.0/lib/netstandard2.0/Microsoft.ApplicationInsights.dll + microsoft.bcl.asyncinterfaces/6.0.0/lib/netstandard2.1/Microsoft.Bcl.AsyncInterfaces.dll + microsoft.bcl.hashcode/1.1.1/ref/netcoreapp2.1/Microsoft.Bcl.HashCode.dll + microsoft.data.sqlite.core/10.0.5/lib/net8.0/Microsoft.Data.Sqlite.dll + microsoft.diasymreader/2.0.0/lib/netstandard2.0/Microsoft.DiaSymReader.dll + microsoft.extensions.ai.abstractions/10.5.1/lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + microsoft.extensions.ai.evaluation/10.4.0/lib/net10.0/Microsoft.Extensions.AI.Evaluation.dll + microsoft.extensions.ai.openai/10.5.1/lib/net10.0/Microsoft.Extensions.AI.OpenAI.dll + microsoft.extensions.ai/10.5.1/lib/net10.0/Microsoft.Extensions.AI.dll + microsoft.extensions.ambientmetadata.application/10.4.0/lib/net10.0/Microsoft.Extensions.AmbientMetadata.Application.dll + microsoft.extensions.caching.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.Caching.Abstractions.dll + microsoft.extensions.caching.memory/10.0.5/lib/net10.0/Microsoft.Extensions.Caching.Memory.dll + microsoft.extensions.compliance.abstractions/10.5.0/lib/net10.0/Microsoft.Extensions.Compliance.Abstractions.dll + microsoft.extensions.configuration.abstractions/10.0.5/lib/net10.0/Microsoft.Extensions.Configuration.Abstractions.dll + microsoft.extensions.configuration.binder/10.0.5/lib/net10.0/Microsoft.Extensions.Configuration.Binder.dll + microsoft.extensions.configuration/10.0.5/lib/net10.0/Microsoft.Extensions.Configuration.dll + microsoft.extensions.dependencyinjection.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll + microsoft.extensions.dependencyinjection.autoactivation/10.4.0/lib/net10.0/Microsoft.Extensions.DependencyInjection.AutoActivation.dll + microsoft.extensions.dependencyinjection/10.0.5/lib/net10.0/Microsoft.Extensions.DependencyInjection.dll + microsoft.extensions.dependencymodel/8.0.2/lib/net8.0/Microsoft.Extensions.DependencyModel.dll + microsoft.extensions.diagnostics.abstractions/10.0.5/lib/net10.0/Microsoft.Extensions.Diagnostics.Abstractions.dll + microsoft.extensions.diagnostics.exceptionsummarization/10.4.0/lib/net10.0/Microsoft.Extensions.Diagnostics.ExceptionSummarization.dll + microsoft.extensions.diagnostics/10.0.5/lib/net10.0/Microsoft.Extensions.Diagnostics.dll + microsoft.extensions.fileproviders.abstractions/10.0.4/lib/net10.0/Microsoft.Extensions.FileProviders.Abstractions.dll + microsoft.extensions.filesystemglobbing/10.0.6/lib/net10.0/Microsoft.Extensions.FileSystemGlobbing.dll + microsoft.extensions.hosting.abstractions/10.0.4/lib/net10.0/Microsoft.Extensions.Hosting.Abstractions.dll + microsoft.extensions.http.diagnostics/10.4.0/lib/net10.0/Microsoft.Extensions.Http.Diagnostics.dll + microsoft.extensions.http.resilience/10.4.0/lib/net10.0/Microsoft.Extensions.Http.Resilience.dll + microsoft.extensions.http/10.0.5/lib/net10.0/Microsoft.Extensions.Http.dll + microsoft.extensions.logging.abstractions/10.0.6/lib/net10.0/Microsoft.Extensions.Logging.Abstractions.dll + microsoft.extensions.logging.configuration/10.0.4/lib/net10.0/Microsoft.Extensions.Logging.Configuration.dll + microsoft.extensions.logging/10.0.5/lib/net10.0/Microsoft.Extensions.Logging.dll + microsoft.extensions.objectpool/10.0.6/lib/net10.0/Microsoft.Extensions.ObjectPool.dll + microsoft.extensions.options.configurationextensions/10.0.5/lib/net10.0/Microsoft.Extensions.Options.ConfigurationExtensions.dll + microsoft.extensions.options/10.0.5/lib/net10.0/Microsoft.Extensions.Options.dll + microsoft.extensions.primitives/10.0.6/lib/net10.0/Microsoft.Extensions.Primitives.dll + microsoft.extensions.resilience/10.4.0/lib/net10.0/Microsoft.Extensions.Resilience.dll + microsoft.extensions.telemetry.abstractions/10.4.0/lib/net10.0/Microsoft.Extensions.Telemetry.Abstractions.dll + microsoft.extensions.telemetry/10.4.0/lib/net10.0/Microsoft.Extensions.Telemetry.dll + microsoft.extensions.vectordata.abstractions/10.0.1/lib/net10.0/Microsoft.Extensions.VectorData.Abstractions.dll + microsoft.ml.onnxruntime.managed/1.24.3/lib/net8.0/Microsoft.ML.OnnxRuntime.dll + microsoft.ml.tokenizers/2.0.0/lib/net8.0/Microsoft.ML.Tokenizers.dll + microsoft.playwright/1.58.0/lib/netstandard2.0/Microsoft.Playwright.dll + microsoft.semantickernel.abstractions/1.73.0/lib/net10.0/Microsoft.SemanticKernel.Abstractions.dll + microsoft.semantickernel.connectors.sqlitevec/1.73.0-preview/lib/net10.0/Microsoft.SemanticKernel.Connectors.SqliteVec.dll + microsoft.semantickernel.plugins.web/1.73.0-alpha/lib/net10.0/Microsoft.SemanticKernel.Plugins.Web.dll + microsoft.testing.extensions.codecoverage/18.4.1/lib/net8.0/ + Microsoft.CodeCoverage.Core.dll + Microsoft.CodeCoverage.Instrumentation.Core.dll + Microsoft.CodeCoverage.Instrumentation.dll + Microsoft.CodeCoverage.Interprocess.dll + Microsoft.Testing.Extensions.CodeCoverage.dll + Microsoft.VisualStudio.CodeCoverage.Shim.dll + Mono.Cecil.dll + Mono.Cecil.Mdb.dll + Mono.Cecil.Pdb.dll + Mono.Cecil.Rocks.dll + microsoft.testing.extensions.telemetry/2.1.0/lib/net9.0/Microsoft.Testing.Extensions.Telemetry.dll + microsoft.testing.extensions.trxreport.abstractions/2.1.0/lib/net9.0/Microsoft.Testing.Extensions.TrxReport.Abstractions.dll + microsoft.testing.extensions.trxreport/2.1.0/lib/net9.0/Microsoft.Testing.Extensions.TrxReport.dll + microsoft.testing.extensions.vstestbridge/2.1.0/lib/net9.0/Microsoft.Testing.Extensions.VSTestBridge.dll + microsoft.testing.platform.msbuild/2.1.0/lib/net9.0/Microsoft.Testing.Extensions.MSBuild.dll + microsoft.testing.platform/2.1.0/lib/net9.0/Microsoft.Testing.Platform.dll + microsoft.testplatform.testhost/18.3.0/lib/net8.0/ + Microsoft.TestPlatform.CommunicationUtilities.dll + Microsoft.TestPlatform.CoreUtilities.dll + Microsoft.TestPlatform.CrossPlatEngine.dll + Microsoft.TestPlatform.PlatformAbstractions.dll + Microsoft.TestPlatform.Utilities.dll + Microsoft.VisualStudio.TestPlatform.Common.dll + Microsoft.VisualStudio.TestPlatform.ObjectModel.dll + testhost.dll + modelcontextprotocol.core/1.1.0/lib/net10.0/ModelContextProtocol.Core.dll + modelcontextprotocol/1.1.0/lib/net10.0/ModelContextProtocol.dll + moq/4.20.72/lib/net6.0/Moq.dll + mstest.testframework/4.1.0/ + buildTransitive/net9.0/MSTest.TestFramework.Extensions.dll + lib/net9.0/MSTest.TestFramework.dll + newtonsoft.json/13.0.3/lib/net6.0/Newtonsoft.Json.dll + openai/2.10.0/lib/net10.0/OpenAI.dll + opentelemetry.api/1.15.3/lib/net10.0/OpenTelemetry.Api.dll + pdfpig/0.1.13/lib/net8.0/ + UglyToad.PdfPig.Core.dll + UglyToad.PdfPig.dll + UglyToad.PdfPig.DocumentLayoutAnalysis.dll + UglyToad.PdfPig.Fonts.dll + UglyToad.PdfPig.Package.dll + UglyToad.PdfPig.Tokenization.dll + UglyToad.PdfPig.Tokens.dll + polly.core/8.4.2/lib/net8.0/Polly.Core.dll + polly.extensions/8.4.2/lib/net8.0/Polly.Extensions.dll + polly.ratelimiting/8.4.2/lib/net8.0/Polly.RateLimiting.dll + serilog.extensions.hosting/10.0.0/lib/net10.0/Serilog.Extensions.Hosting.dll + serilog.extensions.logging/10.0.0/lib/net10.0/Serilog.Extensions.Logging.dll + serilog.sinks.console/6.1.1/lib/net8.0/Serilog.Sinks.Console.dll + serilog.sinks.file/7.0.0/lib/net9.0/Serilog.Sinks.File.dll + serilog/4.3.0/lib/net9.0/Serilog.dll + shimskiasharp/3.6.0/lib/net10.0/ShimSkiaSharp.dll + skender.stock.indicators/2.7.1/lib/net10.0/Skender.Stock.Indicators.dll + skiasharp/3.119.3-preview.1.1/ref/net8.0/SkiaSharp.dll + smartreader/0.11.0/lib/netstandard2.1/SmartReader.dll + sqlitepclraw.bundle_e_sqlite3/2.1.11/lib/netstandard2.0/SQLitePCLRaw.batteries_v2.dll + sqlitepclraw.core/2.1.11/lib/netstandard2.0/SQLitePCLRaw.core.dll + sqlitepclraw.provider.e_sqlite3/2.1.11/lib/net6.0/SQLitePCLRaw.provider.e_sqlite3.dll + svg.controls.skia.avalonia/11.3.9.4/lib/net10.0/Svg.Controls.Skia.Avalonia.dll + svg.custom/3.6.0/lib/net10.0/Svg.Custom.dll + svg.model/3.6.0/lib/net10.0/Svg.Model.dll + svg.skia/3.6.0/lib/net10.0/Svg.Skia.dll + system.clientmodel/1.10.0/lib/net10.0/System.ClientModel.dll + system.diagnostics.eventlog/6.0.0/lib/net6.0/System.Diagnostics.EventLog.dll + system.io.packaging/10.0.2/lib/net10.0/System.IO.Packaging.dll + system.memory.data/10.0.3/lib/net10.0/System.Memory.Data.dll + system.numerics.tensors/10.0.6/lib/net10.0/System.Numerics.Tensors.dll + system.threading.ratelimiting/8.0.0/lib/net8.0/System.Threading.RateLimiting.dll + tmds.dbus.protocol/0.90.3/lib/net9.0/Tmds.DBus.Protocol.dll + yamldotnet/16.3.0/lib/net8.0/YamlDotNet.dll + +[analyzerReferences] +/packs/Microsoft.NETCore.App.Ref/10.0.5/analyzers/dotnet/cs/ + Microsoft.Interop.ComInterfaceGenerator.dll + Microsoft.Interop.JavaScript.JSImportGenerator.dll + Microsoft.Interop.LibraryImportGenerator.dll + Microsoft.Interop.SourceGeneration.dll + System.Text.Json.SourceGeneration.dll + System.Text.RegularExpressions.Generator.dll +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/ + Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll + Microsoft.CodeAnalysis.NetAnalyzers.dll +D:/NuGet/Packages/avalonia/12.0.0-preview2/analyzers/dotnet/cs/ + Avalonia.Analyzers.CodeFixes.CSharp.dll + Avalonia.Analyzers.CSharp.dll + Avalonia.Analyzers.VisualBasic.dll + Avalonia.Generators.dll +D:/NuGet/Packages/communitytoolkit.mvvm/8.4.1/analyzers/dotnet/roslyn5.0/cs/ + CommunityToolkit.Mvvm.CodeFixers.dll + CommunityToolkit.Mvvm.SourceGenerators.dll +D:/NuGet/Packages/ + microsoft.agents.ai.workflows.generators/1.6.1/analyzers/dotnet/cs/Microsoft.Agents.AI.Workflows.Generators.dll + microsoft.extensions.options/10.0.5/analyzers/dotnet/roslyn4.4/cs/Microsoft.Extensions.Options.SourceGeneration.dll + microsoft.extensions.telemetry.abstractions/10.4.0/analyzers/dotnet/cs/ + Microsoft.Gen.Logging.dll + Microsoft.Gen.Metrics.dll + modelcontextprotocol.core/1.1.0/analyzers/dotnet/cs/ModelContextProtocol.Analyzers.dll + mstest.analyzers/4.1.0/analyzers/dotnet/cs/ + MSTest.Analyzers.CodeFixes.dll + MSTest.Analyzers.dll + system.clientmodel/1.10.0/analyzers/dotnet/cs/System.ClientModel.SourceGeneration.dll + +[analyzerConfigFiles] +../.editorconfig +/sdk/10.0.300-preview.0.26177.108/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_10_default.globalconfig +D:/NuGet/Packages/mstest.analyzers/4.1.0/globalconfigs/mstest-default.globalconfig +obj/Debug/net10.0/TestMarketAssistant.GeneratedMSBuildEditorConfig.editorconfig diff --git a/tests/Tools/BasicDataToolsTest.cs b/tests/Tools/BasicDataToolsTest.cs new file mode 100644 index 0000000..a2c01f8 --- /dev/null +++ b/tests/Tools/BasicDataToolsTest.cs @@ -0,0 +1,374 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.AShare; +using MarketAssistant.Agents.Tools.Crypto; +using MarketAssistant.Agents.Tools.Models.AShare; +using MarketAssistant.Agents.Tools.Models.Crypto; +using MarketAssistant.Applications.Settings; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services; +using MarketAssistant.Services.Data; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using System.Text.Json; + +namespace TestMarketAssistant.Tools; + +/// +/// IBasicDataTools 接口真实场景验证测试(覆盖 A股 和 虚拟币 实现) +/// 使用真实 API 调用验证工具实现的 authenticity: +/// - A股 GetAssetInfoAsync:调用财联社(cls.cn)行情接口 +/// - A股 GetCompanyInfoAsync:调用智兔 API(需 ZhiTuApiToken) +/// - 虚拟币 GetAssetInfoAsync:调用币安 24h 行情接口 +/// - 虚拟币 GetProjectInfoAsync:调用 CoinDesk 项目元数据接口 +/// +/// 环境变量(缺失时对应测试直接 Fail,不跳过): +/// - ZHITU_API_TOKEN:智兔 API 令牌(A 股公司基本面必需) +/// - JINA_API_KEY:Jina 嵌入密钥(写入 UserSetting,IBasicDataTools 不直接使用) +/// - OPENAI_API_KEY:SiliconFlow LLM 密钥(写入 UserSetting,IBasicDataTools 不直接使用) +/// +[TestClass] +public class BasicDataToolsTest +{ + private ServiceProvider? _serviceProvider; + private string? _zhiTuApiToken; + private string? _jinaApiKey; + private string? _siliconFlowApiKey; + + public TestContext? TestContext { get; set; } + + [TestInitialize] + public void Setup() + { + // 从环境变量读取 API 密钥(不在代码中硬编码,避免提交到仓库) + _zhiTuApiToken = Environment.GetEnvironmentVariable("ZHITU_API_TOKEN"); + _jinaApiKey = Environment.GetEnvironmentVariable("JINA_API_KEY"); + _siliconFlowApiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY"); + + var services = new ServiceCollection(); + + services.AddLogging(builder => + { + builder.SetMinimumLevel(LogLevel.Debug); + }); + + // 注册命名 HttpClient(含 BaseAddress 与弹性策略),与生产配置一致 + services.AddNamedMarketHttpClients(); + + // 注册虚拟币基础数据工具依赖的数据服务(CoinGecko 替代 CoinDesk) + services.AddSingleton(); + services.AddSingleton(); + + // 通过 Mock 注入带真实密钥的 UserSetting(避免依赖本地 Preferences 存储) + var userSetting = new UserSetting + { + ZhiTuApiToken = _zhiTuApiToken ?? "", + EmbeddingApiKey = _jinaApiKey ?? "", + EmbeddingEndpoint = "https://api.jina.ai", + EmbeddingModelId = "jina-embeddings-v5-text-small", + ApiKey = _siliconFlowApiKey ?? "", + Endpoint = "https://api.siliconflow.cn", + ModelId = "deepseek-ai/DeepSeek-V3.2" + }; + var userSettingServiceMock = new Mock(); + userSettingServiceMock.Setup(x => x.CurrentSetting).Returns(userSetting); + services.AddSingleton(userSettingServiceMock.Object); + + // 注册被测试的服务(A股 + 虚拟币,含基接口与子接口) + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.Crypto); + services.AddKeyedSingleton(MarketType.Crypto); + + _serviceProvider = services.BuildServiceProvider(); + } + + [TestCleanup] + public async Task Cleanup() + { + if (_serviceProvider != null) + { + await _serviceProvider.DisposeAsync(); + } + } + + /// + /// 断言智兔 API 令牌已配置(缺失则测试失败,而非跳过) + /// + private void RequireZhiTuToken() + { + if (string.IsNullOrEmpty(_zhiTuApiToken)) + { + Assert.Fail("ZHITU_API_TOKEN 环境变量未配置,无法调用智兔 API 进行真实场景验证"); + } + } + + /// + /// AIFunction 返回 JsonElement 的反序列化选项(MAF 默认 camelCase 序列化,需大小写不敏感反序列化)。 + /// + private static readonly JsonSerializerOptions AIFunctionJsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + #region A股基础数据测试 + + [TestMethod] + [TestCategory("Integration")] + public async Task GetAssetInfoAsync_AShare_ShouldReturnValidQuoteInfo() + { + // Arrange - 贵州茅台 SH600519,财联社公开行情接口 + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act - 验证工具能真实调用财联社 API 并返回结构化数据 + var quoteInfo = await service.GetAssetInfoAsync("SH600519"); + + // Assert - 验证真实行情数据(关键字段非空 + 数值合理性,证明 API 真实返回而非空对象) + Assert.IsNotNull(quoteInfo, "财联社 API 应返回行情数据对象"); + Assert.IsFalse(string.IsNullOrEmpty(quoteInfo.SecurityName), "股票名称不应为空"); + Assert.IsTrue(quoteInfo.SecurityName.Contains("茅台"), $"股票名称应包含'茅台',实际: {quoteInfo.SecurityName}"); + Assert.IsFalse(string.IsNullOrEmpty(quoteInfo.SecurityCode), "股票代码不应为空"); + Assert.IsTrue(quoteInfo.CurrentPrice > 0, $"当前价格应大于0,实际: {quoteInfo.CurrentPrice}"); + Assert.IsTrue(quoteInfo.MarketCapitalization > 0, $"总市值应大于0,实际: {quoteInfo.MarketCapitalization}"); + Assert.IsTrue(quoteInfo.HighPrice >= quoteInfo.LowPrice, $"最高价({quoteInfo.HighPrice})应大于等于最低价({quoteInfo.LowPrice})"); + Assert.IsTrue(quoteInfo.UpLimitPrice >= quoteInfo.CurrentPrice, $"涨停价({quoteInfo.UpLimitPrice})应大于等于当前价({quoteInfo.CurrentPrice})"); + Assert.IsTrue(quoteInfo.DownLimitPrice <= quoteInfo.CurrentPrice, $"跌停价({quoteInfo.DownLimitPrice})应小于等于当前价({quoteInfo.CurrentPrice})"); + Assert.IsTrue(quoteInfo.TotalShares > 0, $"总股本应大于0,实际: {quoteInfo.TotalShares}"); + TestContext?.WriteLine($"SH600519 名称: {quoteInfo.SecurityName}, 当前价: {quoteInfo.CurrentPrice}, 总市值: {quoteInfo.MarketCapitalization}亿, 涨跌幅: {quoteInfo.PercentageChange}%"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetCompanyInfoAsync_AShare_ShouldReturnValidCompanyInfo() + { + // 智兔 API 需要令牌 + RequireZhiTuToken(); + + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act - 真实调用智兔 API 获取公司基本面 + var companyInfo = await service.GetCompanyInfoAsync("SH600519"); + + // Assert - 验证真实公司基本面数据(关键字段非空 + 内容匹配,证明智兔 API 真实返回) + Assert.IsNotNull(companyInfo, "公司信息不应为空"); + Assert.IsFalse(string.IsNullOrEmpty(companyInfo.Name), "公司名称不应为空"); + Assert.IsTrue(companyInfo.Name.Contains("茅台"), $"公司名称应包含'茅台',实际: {companyInfo.Name}"); + Assert.IsFalse(string.IsNullOrEmpty(companyInfo.Market), "上市市场不应为空"); + Assert.IsFalse(string.IsNullOrEmpty(companyInfo.ListingDate), "上市日期不应为空"); + Assert.IsFalse(string.IsNullOrEmpty(companyInfo.BusinessScope), "经营范围不应为空"); + Assert.IsFalse(string.IsNullOrEmpty(companyInfo.Description), "公司简介不应为空"); + TestContext?.WriteLine($"{companyInfo.Name} 市场: {companyInfo.Market}, 上市日期: {companyInfo.ListingDate}, 简介: {companyInfo.Description.Substring(0, Math.Min(50, companyInfo.Description.Length))}..."); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetAssetInfoAsync_AShare_MultipleSymbols_ShouldAllReturnValidData() + { + // Arrange - 验证多只股票的真实行情 + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var symbols = new[] { "SH600519", "SZ000001", "SH600036" }; + var expectedNames = new[] { "茅台", "平安", "招商" }; + + for (var i = 0; i < symbols.Length; i++) + { + var symbol = symbols[i]; + var expectedName = expectedNames[i]; + + // Act - 验证每只股票都能真实调用 API 并返回数据对象 + var quoteInfo = await service.GetAssetInfoAsync(symbol); + + // Assert - 校验 API 连通性 + 真实数据内容(名称匹配 + 价格合理) + Assert.IsNotNull(quoteInfo, $"{symbol} 行情数据不应为空"); + Assert.IsFalse(string.IsNullOrEmpty(quoteInfo.SecurityName), $"{symbol} 股票名称不应为空"); + Assert.IsTrue(quoteInfo.SecurityName.Contains(expectedName), $"{symbol} 股票名称应包含'{expectedName}',实际: {quoteInfo.SecurityName}"); + Assert.IsFalse(string.IsNullOrEmpty(quoteInfo.SecurityCode), $"{symbol} 股票代码不应为空"); + Assert.IsTrue(quoteInfo.CurrentPrice > 0, $"{symbol} 当前价格应大于0,实际: {quoteInfo.CurrentPrice}"); + Assert.IsTrue(quoteInfo.MarketCapitalization > 0, $"{symbol} 总市值应大于0,实际: {quoteInfo.MarketCapitalization}"); + TestContext?.WriteLine($"{symbol} 名称: {quoteInfo.SecurityName}, 当前价: {quoteInfo.CurrentPrice}, 总市值: {quoteInfo.MarketCapitalization}亿"); + } + } + + #endregion + + #region 虚拟币基础数据测试 + + [TestMethod] + [TestCategory("Integration")] + public async Task GetAssetInfoAsync_Crypto_ShouldReturnValidQuoteInfo() + { + // Arrange - BTC 现货,币安 24h 行情接口 + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var quoteInfo = await service.GetAssetInfoAsync("BTC"); + + // Assert - 验证真实币安行情数据(数值合理性 + 字段一致性,证明币安 API 真实返回) + Assert.IsNotNull(quoteInfo); + Assert.AreEqual("BTC", quoteInfo.SecurityCode); + Assert.IsFalse(string.IsNullOrEmpty(quoteInfo.SecurityName), "币种名称不应为空"); + Assert.IsTrue(quoteInfo.CurrentPrice > 0, $"当前价格应大于0,实际: {quoteInfo.CurrentPrice}"); + Assert.IsTrue(quoteInfo.Volume > 0, $"24h 成交量应大于0,实际: {quoteInfo.Volume}"); + Assert.IsTrue(quoteInfo.Amount > 0, $"24h 成交额应大于0,实际: {quoteInfo.Amount}"); + Assert.IsTrue(quoteInfo.HighPrice >= quoteInfo.LowPrice, $"最高价({quoteInfo.HighPrice})应大于等于最低价({quoteInfo.LowPrice})"); + Assert.IsTrue(quoteInfo.HighPrice >= quoteInfo.CurrentPrice, $"最高价({quoteInfo.HighPrice})应大于等于当前价({quoteInfo.CurrentPrice})"); + Assert.IsTrue(quoteInfo.LowPrice <= quoteInfo.CurrentPrice, $"最低价({quoteInfo.LowPrice})应小于等于当前价({quoteInfo.CurrentPrice})"); + // BTC 价格量级校验(真实场景下 BTC 价格远高于 1000 USDT) + Assert.IsTrue(quoteInfo.CurrentPrice > 1000, $"BTC 当前价格应大于 1000 USDT,实际: {quoteInfo.CurrentPrice}"); + TestContext?.WriteLine($"BTC 当前价: {quoteInfo.CurrentPrice}, 24h成交额: {quoteInfo.Amount:N0}, 涨跌幅: {quoteInfo.PercentageChange}%"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetProjectInfoAsync_Crypto_ShouldReturnValidInfo() + { + // Arrange - CoinGecko /coins/{id} 项目详情接口(免费,无需密钥) + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var projectInfo = await service.GetProjectInfoAsync("BTC"); + + // Assert - 验证真实项目基本面数据(字段非空 + 数值合理 + 内容匹配,证明 CoinGecko API 真实返回) + Assert.IsNotNull(projectInfo); + Assert.IsFalse(string.IsNullOrEmpty(projectInfo.Symbol), "项目符号不应为空"); + Assert.AreEqual("BTC", projectInfo.Symbol, $"项目符号应为 BTC,实际: {projectInfo.Symbol}"); + Assert.IsFalse(string.IsNullOrEmpty(projectInfo.Name), "项目名称不应为空"); + Assert.IsTrue(projectInfo.CirculatingSupply > 0, $"流通供应量应大于0,实际: {projectInfo.CirculatingSupply}"); + Assert.IsTrue(projectInfo.PriceUsd > 0, $"美元价格应大于0,实际: {projectInfo.PriceUsd}"); + Assert.IsTrue(projectInfo.TotalMarketCapUsd > 0, $"总市值应大于0,实际: {projectInfo.TotalMarketCapUsd}"); + Assert.IsFalse(string.IsNullOrEmpty(projectInfo.Description), "项目描述不应为空"); + Assert.IsFalse(string.IsNullOrEmpty(projectInfo.AssetType), "资产类型不应为空"); + Assert.AreEqual("BLOCKCHAIN", projectInfo.AssetType, $"资产类型应为 BLOCKCHAIN,实际: {projectInfo.AssetType}"); + TestContext?.WriteLine($"{projectInfo.Name}({projectInfo.Symbol}) 价格: ${projectInfo.PriceUsd:N2}, 流通量: {projectInfo.CirculatingSupply:N0}, 市值: ${projectInfo.TotalMarketCapUsd:N0}"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetAssetInfoAsync_Crypto_MultipleSymbols_ShouldAllReturnValidData() + { + // Arrange - 验证多个币种的真实行情 + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var symbols = new[] { "BTC", "ETH", "BNB" }; + + foreach (var symbol in symbols) + { + // Act + var quoteInfo = await service.GetAssetInfoAsync(symbol); + + // Assert - 校验真实数据(代码匹配 + 价格合理 + 高低价关系) + Assert.IsNotNull(quoteInfo, $"{symbol} 行情数据不应为空"); + Assert.AreEqual(symbol.ToUpper(), quoteInfo.SecurityCode); + Assert.IsTrue(quoteInfo.CurrentPrice > 0, $"{symbol} 当前价格应大于0,实际: {quoteInfo.CurrentPrice}"); + Assert.IsTrue(quoteInfo.Volume > 0, $"{symbol} 24h成交量应大于0,实际: {quoteInfo.Volume}"); + Assert.IsTrue(quoteInfo.Amount > 0, $"{symbol} 24h成交额应大于0,实际: {quoteInfo.Amount}"); + Assert.IsTrue(quoteInfo.HighPrice >= quoteInfo.LowPrice, $"{symbol} 最高价({quoteInfo.HighPrice})应大于等于最低价({quoteInfo.LowPrice})"); + TestContext?.WriteLine($"{symbol} 当前价: {quoteInfo.CurrentPrice}, 24h涨跌: {quoteInfo.PercentageChange}%, 成交额: {quoteInfo.Amount:N0}"); + } + } + + #endregion + + #region GetFunctions 验证(MAF 工具函数契约) + + [TestMethod] + [TestCategory("Integration")] + public void GetFunctions_AShare_ShouldReturnTwoAIFunctions() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act + var functions = service.GetFunctions().ToList(); + var functionNames = functions.Select(f => f.Name).ToList(); + TestContext?.WriteLine($"AShare AIFunction 名称: {string.Join(", ", functionNames)}"); + + // Assert - AShareBasicTools 暴露 2 个 AIFunction + Assert.IsNotNull(functions); + Assert.AreEqual(2, functions.Count); + // 使用 Contains 模糊匹配,兼容 AIFunctionFactory 不同版本的命名约定 + Assert.IsTrue(functionNames.Any(n => n.Contains("GetAssetInfo")), "应包含 GetAssetInfo 函数"); + Assert.IsTrue(functionNames.Any(n => n.Contains("GetCompanyInfo")), "应包含 GetCompanyInfo 函数"); + } + + [TestMethod] + [TestCategory("Integration")] + public void GetFunctions_Crypto_ShouldReturnTwoAIFunctions() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var functions = service.GetFunctions().ToList(); + var functionNames = functions.Select(f => f.Name).ToList(); + TestContext?.WriteLine($"Crypto AIFunction 名称: {string.Join(", ", functionNames)}"); + + // Assert - CryptoBasicTools 暴露 2 个 AIFunction + Assert.IsNotNull(functions); + Assert.AreEqual(2, functions.Count); + Assert.IsTrue(functionNames.Any(n => n.Contains("GetAssetInfo")), "应包含 GetAssetInfo 函数"); + Assert.IsTrue(functionNames.Any(n => n.Contains("GetProjectInfo")), "应包含 GetProjectInfo 函数"); + } + + #endregion + + #region AIFunction 真实调用验证(通过 MAF 契约入口) + + [TestMethod] + [TestCategory("Integration")] + public async Task AIFunction_GetAssetInfoAsync_AShare_ShouldInvokeRealApi() + { + // Arrange - 通过 GetFunctions() 返回的 AIFunction 调用,验证 MAF 契约可真实触发 API + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + var getAssetInfoFunction = service.GetFunctions().First(f => f.Name.Contains("GetAssetInfo")); + + // Act - AIFunction.InvokeAsync 返回 JsonElement(MAF 序列化返回值),需反序列化为强类型 + var result = await getAssetInfoFunction.InvokeAsync(new AIFunctionArguments + { + ["assetSymbol"] = "SH600519" + }); + + // Assert - 验证 AIFunction 真实返回数据并反序列化为 StockQuoteInfo(非空对象 + 真实字段值) + Assert.IsNotNull(result, "AIFunction 返回值不应为空"); + Assert.IsInstanceOfType(result, typeof(JsonElement), $"AIFunction 返回值应为 JsonElement 类型,实际: {result.GetType().Name}"); + var jsonElement = (JsonElement)result; + var quoteInfo = JsonSerializer.Deserialize(jsonElement.GetRawText(), AIFunctionJsonOptions) + ?? throw new AssertFailedException("AIFunction 返回值反序列化为 StockQuoteInfo 失败"); + Assert.IsFalse(string.IsNullOrEmpty(quoteInfo.SecurityName), "股票名称不应为空"); + Assert.IsTrue(quoteInfo.SecurityName.Contains("茅台"), $"股票名称应包含'茅台',实际: {quoteInfo.SecurityName}"); + Assert.IsTrue(quoteInfo.CurrentPrice > 0, $"当前价格应大于0,实际: {quoteInfo.CurrentPrice}"); + Assert.IsTrue(quoteInfo.MarketCapitalization > 0, $"总市值应大于0,实际: {quoteInfo.MarketCapitalization}"); + TestContext?.WriteLine($"AIFunction 返回 {quoteInfo.SecurityName}({quoteInfo.SecurityCode}), 当前价: {quoteInfo.CurrentPrice}, 总市值: {quoteInfo.MarketCapitalization}亿"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task AIFunction_GetAssetInfoAsync_Crypto_ShouldInvokeRealApi() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var getAssetInfoFunction = service.GetFunctions().First(f => f.Name.Contains("GetAssetInfo")); + + // Act - AIFunction.InvokeAsync 返回 JsonElement(MAF 序列化返回值),需反序列化为强类型 + var result = await getAssetInfoFunction.InvokeAsync(new AIFunctionArguments + { + ["assetSymbol"] = "BTC" + }); + + // Assert - 验证 AIFunction 真实返回数据并反序列化为 CryptoQuoteInfo(非空对象 + 真实字段值) + Assert.IsNotNull(result, "AIFunction 返回值不应为空"); + Assert.IsInstanceOfType(result, typeof(JsonElement), $"AIFunction 返回值应为 JsonElement 类型,实际: {result.GetType().Name}"); + var jsonElement = (JsonElement)result; + var quoteInfo = JsonSerializer.Deserialize(jsonElement.GetRawText(), AIFunctionJsonOptions) + ?? throw new AssertFailedException("AIFunction 返回值反序列化为 CryptoQuoteInfo 失败"); + Assert.AreEqual("BTC", quoteInfo.SecurityCode, $"币种代码应为 BTC,实际: {quoteInfo.SecurityCode}"); + Assert.IsTrue(quoteInfo.CurrentPrice > 0, $"当前价格应大于0,实际: {quoteInfo.CurrentPrice}"); + Assert.IsTrue(quoteInfo.Volume > 0, $"24h成交量应大于0,实际: {quoteInfo.Volume}"); + Assert.IsTrue(quoteInfo.Amount > 0, $"24h成交额应大于0,实际: {quoteInfo.Amount}"); + Assert.IsTrue(quoteInfo.CurrentPrice > 1000, $"BTC 当前价格应大于 1000 USDT,实际: {quoteInfo.CurrentPrice}"); + TestContext?.WriteLine($"AIFunction 返回 {quoteInfo.SecurityName}({quoteInfo.SecurityCode}), 当前价: {quoteInfo.CurrentPrice}, 24h成交额: {quoteInfo.Amount:N0}"); + } + + #endregion +} diff --git a/tests/Tools/CryptoMetricsToolsTest.cs b/tests/Tools/CryptoMetricsToolsTest.cs new file mode 100644 index 0000000..08b1ed0 --- /dev/null +++ b/tests/Tools/CryptoMetricsToolsTest.cs @@ -0,0 +1,178 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.Crypto; +using MarketAssistant.Agents.Tools.Models; +using MarketAssistant.Agents.Tools.Models.Crypto; +using MarketAssistant.Applications.Charts; +using MarketAssistant.Applications.Settings; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services; +using MarketAssistant.Services.Data; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; + +namespace TestMarketAssistant.Tools; + +/// +/// ICryptoMetricsTools 接口测试(虚拟币市场数据) +/// +[TestClass] +public class CryptoMetricsToolsTest +{ + private ServiceProvider? _serviceProvider; + + [TestInitialize] + public void Setup() + { + var services = new ServiceCollection(); + + services.AddLogging(); + // 注册命名 HttpClient(含 BaseAddress 与弹性策略),与生产配置一致 + services.AddNamedMarketHttpClients(); + // 注册虚拟币指标工具依赖的数据服务(Binance 行情 + CoinGecko 市场指标) + services.AddSingleton(); + services.AddSingleton(); + // CryptoMetricsTools 依赖 IKLineService(虚拟币 K线服务,基于币安行情) + services.AddKeyedSingleton(MarketType.Crypto); + + // 通过 Mock 注入 UserSetting(CoinGeckoApiKey 可为空,使用免费 API) + var userSetting = new UserSetting(); + var userSettingServiceMock = new Mock(); + userSettingServiceMock.Setup(x => x.CurrentSetting).Returns(userSetting); + services.AddSingleton(userSettingServiceMock.Object); + + services.AddKeyedSingleton(MarketType.Crypto); + + _serviceProvider = services.BuildServiceProvider(); + } + + [TestCleanup] + public async Task Cleanup() + { + if (_serviceProvider != null) + { + await _serviceProvider.DisposeAsync(); + } + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetVolumeDistributionAsync_ShouldReturnValidData() + { + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + var result = await service.GetVolumeDistributionAsync("BTC"); + + Assert.IsNotNull(result); + Assert.IsTrue(result.Count > 0); + Assert.IsTrue(result[0].Volume > 0); + // 加强验证:交易所名称、占比、交易对数量等字段 + Assert.IsFalse(string.IsNullOrEmpty(result[0].Exchange), $"交易所名称不应为空,实际: '{result[0].Exchange}'"); + Assert.IsTrue(result[0].Percentage > 0, $"占比应大于0,实际: {result[0].Percentage}"); + Assert.IsTrue(result[0].PairCount > 0, $"交易对数量应大于0,实际: {result[0].PairCount}"); + // 所有条目的占比之和应接近 100(允许浮点精度误差) + var totalPercentage = result.Sum(x => x.Percentage); + Assert.IsTrue(totalPercentage > 98 && totalPercentage < 102, $"占比总和应接近100,实际: {totalPercentage}"); + } + + [TestMethod] + [TestCategory("Integration")] + public void GetFunctions_ShouldReturnValidAIFunctions() + { + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + var functions = service.GetFunctions().ToList(); + + Assert.IsNotNull(functions); + Assert.AreEqual(6, functions.Count); // 6个函数:OHLCV、深度、成交、市场指标、交易量分布、波动性 + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetOHLCVAsync_ShouldReturnValidData() + { + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + var result = await service.GetOHLCVAsync("BTCUSDT", interval: MarketInterval.OneDay, limit: 10); + + Assert.IsNotNull(result); + Assert.AreEqual("BTCUSDT", result.Symbol); + Assert.AreEqual("1d", result.Interval); + Assert.IsTrue(result.Candles.Count > 0); + Assert.IsTrue(result.Candles[0].Close > 0); + // 加强验证:OHLC 关系与成交量/时间戳 + Assert.IsTrue(result.Candles[0].Open > 0, $"开盘价应大于0,实际: {result.Candles[0].Open}"); + Assert.IsTrue(result.Candles[0].High >= result.Candles[0].Low, $"最高价({result.Candles[0].High})应大于等于最低价({result.Candles[0].Low})"); + Assert.IsTrue(result.Candles[0].High >= result.Candles[0].Open, $"最高价({result.Candles[0].High})应大于等于开盘价({result.Candles[0].Open})"); + Assert.IsTrue(result.Candles[0].High >= result.Candles[0].Close, $"最高价({result.Candles[0].High})应大于等于收盘价({result.Candles[0].Close})"); + Assert.IsTrue(result.Candles[0].Low <= result.Candles[0].Open, $"最低价({result.Candles[0].Low})应小于等于开盘价({result.Candles[0].Open})"); + Assert.IsTrue(result.Candles[0].Low <= result.Candles[0].Close, $"最低价({result.Candles[0].Low})应小于等于收盘价({result.Candles[0].Close})"); + Assert.IsTrue(result.Candles[0].Volume > 0, $"成交量应大于0,实际: {result.Candles[0].Volume}"); + Assert.IsTrue(result.Candles[0].OpenTime > 0, $"开盘时间戳应大于0,实际: {result.Candles[0].OpenTime}"); + Assert.IsTrue(result.Candles[0].CloseTime > result.Candles[0].OpenTime, $"收盘时间戳({result.Candles[0].CloseTime})应大于开盘时间戳({result.Candles[0].OpenTime})"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetOrderBookDepthAsync_ShouldReturnValidData() + { + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + var result = await service.GetOrderBookDepthAsync("BTCUSDT", limit: 10); + + Assert.IsNotNull(result); + Assert.AreEqual("BTCUSDT", result.Symbol); + Assert.IsTrue(result.Bids.Count > 0); + Assert.IsTrue(result.Asks.Count > 0); + Assert.IsTrue(result.BestBidPrice > 0); + Assert.IsTrue(result.BestAskPrice > 0); + Assert.IsTrue(result.Spread > 0); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetRecentTradesAsync_ShouldReturnValidData() + { + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + var result = await service.GetRecentTradesAsync("BTCUSDT", limit: 100); + + Assert.IsNotNull(result); + Assert.AreEqual("BTCUSDT", result.Symbol); + Assert.IsTrue(result.Trades.Count > 0); + Assert.IsTrue(result.TotalVolume > 0); + Assert.IsTrue(result.BuyerVolumePercent + result.SellerVolumePercent == 100); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetMarketMetricsAsync_ShouldReturnValidData() + { + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + var result = await service.GetMarketMetricsAsync("BTC"); + + Assert.IsNotNull(result); + Assert.AreEqual("BTC", result.Symbol); + Assert.IsTrue(result.CurrentPriceUsd > 0); + Assert.IsTrue(result.MarketCapUsd > 0); + Assert.IsTrue(result.CirculatingSupply > 0); + Assert.IsNotNull(result.MarketCapRank); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetVolatilityMetricsAsync_ShouldReturnValidData() + { + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + var result = await service.GetVolatilityMetricsAsync("BTCUSDT", days: 30); + + Assert.IsNotNull(result); + Assert.AreEqual("BTCUSDT", result.Symbol); + Assert.IsTrue(result.AnnualizedVolatility > 0); + Assert.IsTrue(result.DailyVolatility > 0); + Assert.IsTrue(result.PeriodDays == 30); + } +} diff --git a/tests/Tools/CryptoTechnicalToolsTest.cs b/tests/Tools/CryptoTechnicalToolsTest.cs new file mode 100644 index 0000000..dd1e905 --- /dev/null +++ b/tests/Tools/CryptoTechnicalToolsTest.cs @@ -0,0 +1,77 @@ +using MarketAssistant.Agents.Tools.Crypto; +using MarketAssistant.Applications.Charts; +using MarketAssistant.Applications.Charts.Models; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace TestMarketAssistant.Tools; + +[TestClass] +public class CryptoTechnicalToolsTest +{ + [TestMethod] + [TestCategory("Unit")] + public async Task TechnicalIndicators_ShouldReturnStructuredValues() + { + var services = new ServiceCollection(); + services.AddKeyedSingleton(MarketType.Crypto); + + using var serviceProvider = services.BuildServiceProvider(); + var tools = new CryptoTechnicalTools( + NullLogger.Instance, + serviceProvider); + + var kdj = await tools.GetKDJAsync("BTC"); + var macd = await tools.GetMACDAsync("BTC"); + var boll = await tools.GetBOLLAsync("BTC"); + var ma = await tools.GetMAAsync("BTC"); + + Assert.IsTrue(kdj.K.HasValue); + Assert.IsTrue(kdj.D.HasValue); + Assert.IsTrue(kdj.J.HasValue); + + Assert.AreNotEqual(0m, macd.Ema12); + Assert.AreNotEqual(0m, macd.Ema26); + + Assert.IsTrue(boll.U.HasValue); + Assert.IsTrue(boll.M.HasValue); + Assert.IsTrue(boll.D.HasValue); + Assert.IsTrue(boll.U > boll.M); + Assert.IsTrue(boll.M > boll.D); + + Assert.IsTrue(ma.MA5.HasValue); + Assert.IsTrue(ma.MA20.HasValue); + Assert.IsTrue(ma.MA250.HasValue); + Assert.IsTrue(ma.MA5 > ma.MA20); + } + + private sealed class FakeKLineService : IKLineService + { + public Task> GetKLineDataAsync(string code, KLineType kLineType, int count = 100) + { + var start = new DateTime(2024, 1, 1); + var data = Enumerable.Range(0, Math.Max(count, 260)) + .Select(index => + { + var basePrice = 100m + index; + return new KLineData + { + Timestamp = start.AddDays(index), + Open = basePrice, + High = basePrice + 2m, + Low = basePrice - 2m, + Close = basePrice + 1m, + Volume = 1_000m + index, + PreClose = basePrice - 1m, + Change = 2m, + PctChg = 1m, + Amount = (1_000m + index) * (basePrice + 1m) + }; + }) + .ToList(); + + return Task.FromResult(data); + } + } +} diff --git a/tests/Tools/FinancialDataToolsTest.cs b/tests/Tools/FinancialDataToolsTest.cs new file mode 100644 index 0000000..706b1fb --- /dev/null +++ b/tests/Tools/FinancialDataToolsTest.cs @@ -0,0 +1,262 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.AShare; +using MarketAssistant.Agents.Tools.Crypto; +using MarketAssistant.Agents.Tools.Models.AShare; +using MarketAssistant.Applications.Settings; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; + +namespace TestMarketAssistant.Tools; + +/// +/// IShareFinancialTools 接口测试(仅测试 A股 实现,虚拟币已改用 ICryptoMetricsTools) +/// +[TestClass] +public class FinancialDataToolsTest +{ + private ServiceProvider? _serviceProvider; + private string? _zhiTuApiToken; + + public TestContext? TestContext { get; set; } + + [TestInitialize] + public void Setup() + { + // 从环境变量读取智兔 API 令牌(不在代码中硬编码,避免提交到仓库) + _zhiTuApiToken = Environment.GetEnvironmentVariable("ZHITU_API_TOKEN"); + + var services = new ServiceCollection(); + + services.AddLogging(builder => + { + builder.SetMinimumLevel(LogLevel.Debug); + }); + + // 注册命名 HttpClient(含 ZhiTu、Cls 等 BaseAddress 与弹性策略),与生产配置一致 + services.AddNamedMarketHttpClients(); + + // 通过 Mock 注入带真实密钥的 UserSetting(避免依赖本地 Preferences 存储) + var userSetting = new UserSetting + { + ZhiTuApiToken = _zhiTuApiToken ?? "" + }; + var userSettingServiceMock = new Mock(); + userSettingServiceMock.Setup(x => x.CurrentSetting).Returns(userSetting); + services.AddSingleton(userSettingServiceMock.Object); + + // 注册被测试的服务(仅 A股) + services.AddKeyedSingleton(MarketType.AShare); + + _serviceProvider = services.BuildServiceProvider(); + } + + [TestCleanup] + public async Task Cleanup() + { + if (_serviceProvider != null) + { + await _serviceProvider.DisposeAsync(); + } + } + + /// + /// 断言智兔 API 令牌已配置(缺失则测试失败,而非跳过) + /// + private void RequireZhiTuToken() + { + if (string.IsNullOrEmpty(_zhiTuApiToken)) + { + Assert.Fail("ZHITU_API_TOKEN 环境变量未配置,无法调用智兔 API 进行真实场景验证"); + } + } + + #region A股财务数据测试 + + [TestMethod] + [TestCategory("Integration")] + public async Task GetBalanceSheetAsync_AShare_ShouldReturnValidData() + { + // 智兔 API 需要令牌 + RequireZhiTuToken(); + + // Arrange - 贵州茅台 SH600519 + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act - 真实调用智兔 API 获取资产负债表 + var balanceSheets = await service.GetBalanceSheetAsync("SH600519"); + + // Assert - 验证真实资产负债表数据(关键字段非空 + 数值合理性,证明 API 真实返回) + Assert.IsNotNull(balanceSheets); + Assert.IsTrue(balanceSheets.Count > 0, "应返回至少一条资产负债表数据"); + + var latest = balanceSheets[0]; + Assert.IsFalse(string.IsNullOrEmpty(latest.EndDate), $"截止日期不应为空,实际: {latest.EndDate}"); + Assert.IsTrue(latest.TotalAssets > 0, $"资产总计应大于0,实际: {latest.TotalAssets}"); + Assert.IsTrue(latest.TotalLiabilities > 0, $"负债合计应大于0,实际: {latest.TotalLiabilities}"); + Assert.IsTrue(latest.TotalEquity > 0, $"所有者权益合计应大于0,实际: {latest.TotalEquity}"); + Assert.IsTrue(latest.TotalCurrentAssets > 0, $"流动资产合计应大于0,实际: {latest.TotalCurrentAssets}"); + Assert.IsTrue(latest.TotalCurrentLiabilities >= 0, $"流动负债合计应非负,实际: {latest.TotalCurrentLiabilities}"); + Assert.IsTrue(latest.MonetaryFunds > 0, $"货币资金应大于0,实际: {latest.MonetaryFunds}"); + Assert.IsTrue(latest.PaidInCapital > 0, $"实收资本应大于0,实际: {latest.PaidInCapital}"); + // 会计恒等式校验:资产总计 = 负债合计 + 所有者权益合计 + Assert.IsTrue(latest.TotalAssets.HasValue && latest.TotalLiabilities.HasValue && latest.TotalEquity.HasValue, + "资产总计、负债合计、所有者权益合计均不应为空,无法校验会计恒等式"); + var equitySum = latest.TotalLiabilities.Value + latest.TotalEquity.Value; + Assert.AreEqual(latest.TotalAssets.Value, equitySum, 1m, + $"会计恒等式不成立: 资产总计({latest.TotalAssets}) ≠ 负债合计({latest.TotalLiabilities}) + 所有者权益合计({latest.TotalEquity})"); + + TestContext?.WriteLine($"SH600519 资产负债表 截止日期: {latest.EndDate}, 资产总计: {latest.TotalAssets}, 负债合计: {latest.TotalLiabilities}, 所有者权益: {latest.TotalEquity}"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetIncomeStatementAsync_AShare_ShouldReturnValidData() + { + // 智兔 API 需要令牌 + RequireZhiTuToken(); + + // Arrange - 贵州茅台 SH600519 + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act - 真实调用智兔 API 获取利润表 + var incomeStatements = await service.GetIncomeStatementAsync("SH600519"); + + // Assert - 验证真实利润表数据(关键字段非空 + 数值合理性,证明 API 真实返回) + Assert.IsNotNull(incomeStatements); + Assert.IsTrue(incomeStatements.Count > 0, "应返回至少一条利润表数据"); + + var latest = incomeStatements[0]; + Assert.IsFalse(string.IsNullOrEmpty(latest.EndDate), $"截止日期不应为空,实际: {latest.EndDate}"); + Assert.IsTrue(latest.OperatingRevenue > 0, $"营业收入应大于0,实际: {latest.OperatingRevenue}"); + Assert.IsTrue(latest.TotalOperatingRevenue > 0, $"营业总收入应大于0,实际: {latest.TotalOperatingRevenue}"); + Assert.IsTrue(latest.OperatingCost > 0, $"营业成本应大于0,实际: {latest.OperatingCost}"); + Assert.IsTrue(latest.OperatingProfit > 0, $"营业利润应大于0,实际: {latest.OperatingProfit}"); + Assert.IsTrue(latest.TotalProfit > 0, $"利润总额应大于0,实际: {latest.TotalProfit}"); + Assert.IsTrue(latest.NetProfit > 0, $"净利润应大于0,实际: {latest.NetProfit}"); + Assert.IsTrue(latest.NetProfitAttributableToParent > 0, $"归母净利润应大于0,实际: {latest.NetProfitAttributableToParent}"); + Assert.IsTrue(latest.BasicEarningsPerShare > 0, $"基本每股收益应大于0,实际: {latest.BasicEarningsPerShare}"); + // 利润逻辑校验:营业总收入 >= 营业收入 + Assert.IsTrue(latest.TotalOperatingRevenue >= latest.OperatingRevenue, + $"营业总收入({latest.TotalOperatingRevenue})应大于等于营业收入({latest.OperatingRevenue})"); + // 利润逻辑校验:利润总额 >= 营业利润(营业外收支净额通常较小,但利润总额应包含营业利润) + Assert.IsTrue(latest.TotalProfit >= latest.OperatingProfit, + $"利润总额({latest.TotalProfit})应大于等于营业利润({latest.OperatingProfit})"); + + TestContext?.WriteLine($"SH600519 利润表 截止日期: {latest.EndDate}, 营业收入: {latest.OperatingRevenue}, 净利润: {latest.NetProfit}, 基本每股收益: {latest.BasicEarningsPerShare}"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetCashFlowStatementAsync_AShare_ShouldReturnValidData() + { + // 智兔 API 需要令牌 + RequireZhiTuToken(); + + // Arrange - 贵州茅台 SH600519 + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act - 真实调用智兔 API 获取现金流量表 + var cashFlowStatements = await service.GetCashFlowStatementAsync("SH600519"); + + // Assert - 验证真实现金流量表数据(关键字段非空 + 数值合理性,证明 API 真实返回) + Assert.IsNotNull(cashFlowStatements); + Assert.IsTrue(cashFlowStatements.Count > 0, "应返回至少一条现金流量表数据"); + + // 季报可能未披露完整现金流量字段,取首个含经营活动净额的记录进行校验 + var latest = cashFlowStatements.FirstOrDefault(s => s.NetCashFlowFromOperating.HasValue) + ?? cashFlowStatements[0]; + Assert.IsFalse(string.IsNullOrEmpty(latest.EndDate), $"截止日期不应为空,实际: {latest.EndDate}"); + Assert.IsTrue(latest.NetCashFlowFromOperating > 0, $"经营活动现金流量净额应大于0,实际: {latest.NetCashFlowFromOperating}"); + Assert.IsTrue(latest.TotalCashInflowsFromOperating > 0, $"经营活动现金流入小计应大于0,实际: {latest.TotalCashInflowsFromOperating}"); + Assert.IsTrue(latest.TotalCashOutflowsFromOperating > 0, $"经营活动现金流出小计应大于0,实际: {latest.TotalCashOutflowsFromOperating}"); + Assert.IsTrue(latest.CashFromSalesAndServices > 0, $"销售商品提供劳务收到的现金应大于0,实际: {latest.CashFromSalesAndServices}"); + Assert.IsTrue(latest.EndingCashBalance > 0, $"期末现金余额应大于0,实际: {latest.EndingCashBalance}"); + Assert.IsTrue(latest.BeginningCashBalance > 0, $"期初现金余额应大于0,实际: {latest.BeginningCashBalance}"); + // 现金流逻辑校验:经营活动净额 = 流入小计 - 流出小计 + // 注:智兔 API 季报数据中"经营活动现金流入小计"偶尔小于"销售商品提供劳务收到的现金", + // 属于 API 端数据质量问题,此处不校验 total >= sales,仅校验勾稽关系。 + Assert.IsTrue(latest.TotalCashInflowsFromOperating.HasValue && latest.TotalCashOutflowsFromOperating.HasValue && latest.NetCashFlowFromOperating.HasValue, + "经营活动现金流入/流出/净额均不应为空,无法校验勾稽关系"); + var expectedNet = latest.TotalCashInflowsFromOperating.Value - latest.TotalCashOutflowsFromOperating.Value; + Assert.AreEqual(expectedNet, latest.NetCashFlowFromOperating.Value, 1m, + $"经营活动净额勾稽不成立: 流入({latest.TotalCashInflowsFromOperating}) - 流出({latest.TotalCashOutflowsFromOperating}) ≠ 净额({latest.NetCashFlowFromOperating})"); + + TestContext?.WriteLine($"SH600519 现金流量表 截止日期: {latest.EndDate}, 经营活动净额: {latest.NetCashFlowFromOperating}, 期末现金余额: {latest.EndingCashBalance}"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetFinancialRatiosAsync_AShare_ShouldReturnValidData() + { + // 智兔 API 需要令牌 + RequireZhiTuToken(); + + // Arrange - 贵州茅台 SH600519 + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act - 真实调用智兔 API 获取财务主要指标 + var ratios = await service.GetFinancialRatiosAsync("SH600519"); + + // Assert - 验证真实财务指标数据(关键字段非空 + 数值合理性,证明 API 真实返回) + Assert.IsNotNull(ratios); + Assert.IsTrue(ratios.Count > 0, "应返回至少一条财务指标数据"); + + var latest = ratios[0]; + Assert.IsFalse(string.IsNullOrEmpty(latest.EndDate), $"截止日期不应为空,实际: {latest.EndDate}"); + Assert.IsTrue(latest.BasicEarningsPerShare > 0, $"基本每股收益应大于0,实际: {latest.BasicEarningsPerShare}"); + Assert.IsTrue(latest.NetAssetsPerShare > 0, $"每股净资产应大于0,实际: {latest.NetAssetsPerShare}"); + Assert.IsTrue(latest.ReturnOnEquity > 0, $"净资产收益率应大于0,实际: {latest.ReturnOnEquity}"); + Assert.IsTrue(latest.GrossMargin > 0, $"销售毛利率应大于0,实际: {latest.GrossMargin}"); + Assert.IsTrue(latest.NetProfitMargin > 0, $"净利率应大于0,实际: {latest.NetProfitMargin}"); + Assert.IsTrue(latest.AssetLiabilityRatio >= 0, $"资产负债率应非负,实际: {latest.AssetLiabilityRatio}"); + // 茅台是高毛利白酒企业,毛利率应处于较高水平(> 50%) + Assert.IsTrue(latest.GrossMargin > 50, $"茅台销售毛利率应大于50%,实际: {latest.GrossMargin}"); + // 茅台资产负债率应处于较低水平(< 50%) + Assert.IsTrue(latest.AssetLiabilityRatio < 50, $"茅台资产负债率应小于50%,实际: {latest.AssetLiabilityRatio}"); + + TestContext?.WriteLine($"SH600519 财务指标 截止日期: {latest.EndDate}, ROE: {latest.ReturnOnEquity}%, 毛利率: {latest.GrossMargin}%, 净利率: {latest.NetProfitMargin}%, 资产负债率: {latest.AssetLiabilityRatio}%"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetCapitalStructureAsync_AShare_ShouldReturnValidData() + { + // 智兔 API 需要令牌 + RequireZhiTuToken(); + + // Arrange - 贵州茅台 SH600519 + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act - 真实调用智兔 API 获取股本结构 + var capitalStructure = await service.GetCapitalStructureAsync("SH600519"); + + // Assert - 验证真实股本结构数据(关键字段非空 + 数值合理性,证明 API 真实返回) + Assert.IsNotNull(capitalStructure); + Assert.IsTrue(capitalStructure.Count > 0, "应返回至少一条股本结构数据"); + + var latest = capitalStructure[0]; + Assert.IsFalse(string.IsNullOrEmpty(latest.ChangeDate), $"变动日期不应为空,实际: {latest.ChangeDate}"); + Assert.IsTrue(latest.TotalShares > 0, $"总股本应大于0,实际: {latest.TotalShares}"); + Assert.IsTrue(latest.CirculatingAShares > 0, $"已上市流通A股应大于0,实际: {latest.CirculatingAShares}"); + // 茅台总股本约 12.56 亿股,校验量级合理性 + Assert.IsTrue(latest.TotalShares > 100000000, $"茅台总股本应大于1亿股,实际: {latest.TotalShares}"); + // 已上市流通A股应小于等于总股本 + Assert.IsTrue(latest.CirculatingAShares <= latest.TotalShares, + $"已上市流通A股({latest.CirculatingAShares})应小于等于总股本({latest.TotalShares})"); + // 限售流通股 + 已上市流通A股 = 总股本(若限售流通股字段有值) + if (latest.RestrictedShares.HasValue) + { + var shareSum = latest.CirculatingAShares!.Value + latest.RestrictedShares!.Value; + Assert.IsTrue(Math.Abs(latest.TotalShares.Value - shareSum) <= 1m, + $"股本勾稽不成立: 流通A股({latest.CirculatingAShares}) + 限售流通股({latest.RestrictedShares}) ≠ 总股本({latest.TotalShares})"); + } + + TestContext?.WriteLine($"SH600519 股本结构 变动日期: {latest.ChangeDate}, 总股本: {latest.TotalShares}, 流通A股: {latest.CirculatingAShares}, 限售流通股: {latest.RestrictedShares}"); + } + + #endregion +} diff --git a/tests/Tools/NewsDataToolsTest.cs b/tests/Tools/NewsDataToolsTest.cs new file mode 100644 index 0000000..6f26706 --- /dev/null +++ b/tests/Tools/NewsDataToolsTest.cs @@ -0,0 +1,113 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.AShare; +using MarketAssistant.Agents.Tools.Crypto; +using MarketAssistant.Agents.Tools.Models; +using MarketAssistant.Applications.Assets; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Infrastructure.Factories; +using MarketAssistant.Services.Data; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.DependencyInjection; + +namespace TestMarketAssistant.Tools; + +/// +/// INewsDataTools 接口真实场景验证测试(覆盖 A股 和 虚拟币 实现) +/// +/// 真实性说明: +/// - A股 GetNewsAsync:调用东方财富搜索 API(search-api-web.eastmoney.com),公开免费、无需签名 +/// - 虚拟币 GetNewsAsync:调用 CoinTelegraph RSS(https://cointelegraph.com/rss),免费、无需密钥 +/// +[TestClass] +public class NewsDataToolsTest +{ + private ServiceProvider? _serviceProvider; + + public TestContext? TestContext { get; set; } + + [TestInitialize] + public void Setup() + { + var services = new ServiceCollection(); + + // 注册依赖服务 + services.AddSingleton(); + services.AddSingleton(); + services.AddHttpClient(); + services.AddTestMarketDataHttpClients(); + services.AddLogging(); + + // CryptoNewsTools 依赖 ICryptoAliasRegistry(基于 CoinGecko 币种别名) + services.AddMemoryCache(); + services.AddSingleton(); + services.AddSingleton(); + + // 注册被测试的服务 + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.Crypto); + + _serviceProvider = services.BuildServiceProvider(); + } + + [TestCleanup] + public async Task Cleanup() + { + if (_serviceProvider != null) + { + await _serviceProvider.DisposeAsync(); + } + } + + #region A股新闻数据测试 + + [TestMethod] + [TestCategory("Integration")] + public async Task GetNewsAsync_AShare_ShouldReturnValidData() + { + // Arrange - 贵州茅台 SH600519,东方财富搜索 API(公开免费) + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act - 真实调用东方财富搜索 API + var newsData = await service.GetNewsAsync("SH600519"); + + // Assert - 真实验证:东方财富应返回贵州茅台相关新闻(非空 + 内容校验) + Assert.IsNotNull(newsData, "新闻列表不应为空"); + Assert.IsTrue(newsData.Count > 0, "东方财富应返回至少 1 条新闻"); + var firstNews = newsData[0]; + Assert.IsFalse(string.IsNullOrEmpty(firstNews.Title), "新闻标题不应为空"); + Assert.IsFalse(string.IsNullOrEmpty(firstNews.Link), "新闻链接不应为空"); + Assert.IsTrue(firstNews.Title.Length > 4, $"新闻标题应有实质内容(长度>4),实际: {firstNews.Title}"); + Assert.IsTrue(firstNews.Link.StartsWith("http"), $"新闻链接应为合法 URL,实际: {firstNews.Link}"); + TestContext?.WriteLine($"A股新闻数量: {newsData.Count}, 首条标题: {firstNews.Title}"); + } + + #endregion + + #region 虚拟币新闻数据测试 + + /// + /// 虚拟币新闻测试 - 真实场景验证(CoinTelegraph RSS) + /// RSS 源免费可用,真实返回加密货币相关新闻 + /// + [TestMethod] + [TestCategory("Integration")] + public async Task GetNewsAsync_Crypto_ShouldReturnValidData() + { + // Arrange - BTC,CoinTelegraph RSS 免费源 + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act - 真实调用 CoinTelegraph RSS + var newsData = await service.GetNewsAsync("btc"); + + // Assert - 真实验证 RSS 返回的新闻数据(非空 + 内容校验) + Assert.IsNotNull(newsData, "新闻列表不应为空"); + Assert.IsTrue(newsData.Count > 0, "RSS 应返回至少 1 条新闻"); + var firstNews = newsData[0]; + Assert.IsFalse(string.IsNullOrEmpty(firstNews.Title), "新闻标题不应为空"); + Assert.IsFalse(string.IsNullOrEmpty(firstNews.Link), "新闻链接不应为空"); + Assert.IsTrue(firstNews.Link.StartsWith("http"), $"新闻链接应为合法 URL,实际: {firstNews.Link}"); + TestContext?.WriteLine($"虚拟币新闻数量: {newsData.Count}, 首条标题: {firstNews.Title}"); + } + + #endregion +} diff --git a/tests/Tools/SentimentToolsTest.cs b/tests/Tools/SentimentToolsTest.cs new file mode 100644 index 0000000..4399430 --- /dev/null +++ b/tests/Tools/SentimentToolsTest.cs @@ -0,0 +1,202 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.AShare; +using MarketAssistant.Agents.Tools.Crypto; +using MarketAssistant.Applications.Settings; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services; +using MarketAssistant.Services.Data; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; + +namespace TestMarketAssistant.Tools; + +/// +/// ISentimentTools 接口测试(覆盖 A股 和 虚拟币 实现) +/// +[TestClass] +public class SentimentToolsTest +{ + private ServiceProvider? _serviceProvider; + private ILogger? _logger; + private string? _zhiTuApiToken; + + public TestContext? TestContext { get; set; } + + [TestInitialize] + public void Setup() + { + // 从环境变量读取智兔 API 令牌(不在代码中硬编码,避免提交到仓库) + _zhiTuApiToken = Environment.GetEnvironmentVariable("ZHITU_API_TOKEN"); + + var services = new ServiceCollection(); + + services.AddLogging(builder => + { + builder.SetMinimumLevel(LogLevel.Debug); + }); + + // 注册命名 HttpClient(含 BaseAddress 与弹性策略),与生产配置一致 + // AShareSentimentTools 依赖 "Cls" 与 "ZhiTu" 命名 HttpClient + services.AddNamedMarketHttpClients(); + + // CryptoSentimentTools 依赖 BinanceMarketDataService + services.AddSingleton(); + + // 通过 Mock 注入带真实 ZhiTuApiToken 的 UserSetting(避免依赖本地 Preferences 存储) + var userSetting = new UserSetting + { + ZhiTuApiToken = _zhiTuApiToken ?? "" + }; + var userSettingServiceMock = new Mock(); + userSettingServiceMock.Setup(x => x.CurrentSetting).Returns(userSetting); + services.AddSingleton(userSettingServiceMock.Object); + + // 注册被测试的服务 + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.Crypto); + services.AddKeyedSingleton(MarketType.Crypto); + + _serviceProvider = services.BuildServiceProvider(); + _logger = _serviceProvider.GetRequiredService>(); + } + + [TestCleanup] + public async Task Cleanup() + { + if (_serviceProvider != null) + { + await _serviceProvider.DisposeAsync(); + } + } + + #region A股情绪数据测试 + + [TestMethod] + [TestCategory("Integration")] + public async Task GetFundFlowAsync_AShare_ShouldReturnValidData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act + var sentimentData = await service.GetFundFlowAsync("SH600519"); + + // Assert - 验证真实资金流向数据(关键字段非空 + 数值合理性,证明 API 真实返回而非空对象) + Assert.IsNotNull(sentimentData, "资金流向数据不应为空"); + Assert.IsTrue(sentimentData.Date > 0, $"日期应大于 0,实际: {sentimentData.Date}"); + Assert.IsTrue(sentimentData.MainFundIn > 0 || sentimentData.MainFundOut > 0, + $"主力流入({sentimentData.MainFundIn})或主力流出({sentimentData.MainFundOut})应至少有一个大于 0"); + Assert.IsTrue(sentimentData.MainFundDiff != 0 || sentimentData.SuperFundDiff != 0 || sentimentData.LargeFundDiff != 0, + $"主力净流入({sentimentData.MainFundDiff})、超大单净流入({sentimentData.SuperFundDiff})、大单净流入({sentimentData.LargeFundDiff})应至少有一个非零"); + // 主力 = 特大单 + 大单,勾稽关系:MainFundDiff = SuperFundDiff + LargeFundDiff + var expectedMainDiff = sentimentData.SuperFundDiff + sentimentData.LargeFundDiff; + Assert.IsTrue(Math.Abs(expectedMainDiff - sentimentData.MainFundDiff) < 1m, + $"主力净流入({sentimentData.MainFundDiff})应等于超大单({sentimentData.SuperFundDiff})+大单({sentimentData.LargeFundDiff})={expectedMainDiff}"); + + TestContext?.WriteLine($"SH600519 日期: {sentimentData.Date}, 主力净流入: {sentimentData.MainFundDiff}, " + + $"超大单: {sentimentData.SuperFundDiff}, 大单: {sentimentData.LargeFundDiff}, " + + $"中单: {sentimentData.MediumFundDiff}, 小单: {sentimentData.LittleFundDiff}, " + + $"3日主力: {sentimentData.MainFund3}, 5日主力: {sentimentData.MainFund5}"); + } + + #endregion + + #region 虚拟币情绪数据测试 + + [TestMethod] + [TestCategory("Integration")] + public async Task GetFundingRateAsync_Crypto_ShouldReturnValidData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var fundingRateHistory = await service.GetFundingRateAsync("BTC"); + + // Assert + Assert.IsNotNull(fundingRateHistory); + Assert.IsNotNull(fundingRateHistory.Symbol); + Assert.IsTrue(fundingRateHistory.Symbol.Contains("BTC"), $"期望符号包含 BTC,实际为 {fundingRateHistory.Symbol}"); + Assert.IsTrue(fundingRateHistory.CurrentFundingTime > 0, "当前费率时间应大于 0"); + Assert.IsTrue(fundingRateHistory.NextFundingTime > 0, "下次结算时间应大于 0"); + Assert.IsTrue(fundingRateHistory.NextFundingTime > fundingRateHistory.CurrentFundingTime, "下次结算时间应晚于当前时间"); + Assert.IsNotNull(fundingRateHistory.History); + Assert.IsTrue(fundingRateHistory.History.Count > 0, "历史数据应至少有 1 条记录"); + Assert.IsTrue(fundingRateHistory.History.Count <= 30, "历史数据不应超过请求的 limit"); + + _logger?.LogInformation( + "当前费率: {CurrentRate}%, 平均费率: {AverageRate}%, 历史记录数: {Count}", + fundingRateHistory.CurrentRate, + fundingRateHistory.AverageRate, + fundingRateHistory.History.Count); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetGlobalLongShortRatioAsync_Crypto_ShouldReturnValidData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var result = await service.GetGlobalLongShortRatioAsync("BTC"); + + // Assert + Assert.IsNotNull(result); + Assert.IsNotNull(result.Symbol); + Assert.IsTrue(result.History.Count > 0); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetTopTraderAccountRatioAsync_Crypto_ShouldReturnValidData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var result = await service.GetTopTraderAccountRatioAsync("BTC"); + + // Assert + Assert.IsNotNull(result); + Assert.IsNotNull(result.Symbol); + Assert.IsTrue(result.History.Count > 0); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetTopTraderPositionRatioAsync_Crypto_ShouldReturnValidData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var result = await service.GetTopTraderPositionRatioAsync("BTC"); + + // Assert + Assert.IsNotNull(result); + Assert.IsNotNull(result.Symbol); + Assert.IsTrue(result.History.Count > 0); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetOpenInterestAsync_Crypto_ShouldReturnValidData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var result = await service.GetOpenInterestAsync("BTC"); + + // Assert + Assert.IsNotNull(result); + Assert.IsNotNull(result.Symbol); + Assert.IsTrue(result.History.Count > 0); + } + + #endregion +} diff --git a/tests/Tools/TechnicalDataToolsTest.cs b/tests/Tools/TechnicalDataToolsTest.cs new file mode 100644 index 0000000..964bd54 --- /dev/null +++ b/tests/Tools/TechnicalDataToolsTest.cs @@ -0,0 +1,227 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.AShare; +using MarketAssistant.Agents.Tools.Crypto; +using MarketAssistant.Agents.Tools.Models.Technical; +using MarketAssistant.Applications.Charts; +using MarketAssistant.Applications.Charts.Models; +using MarketAssistant.Applications.Settings; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services; +using MarketAssistant.Services.Data; +using MarketAssistant.Services.Settings; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; + +namespace TestMarketAssistant.Tools; + +/// +/// ITechnicalDataTools 接口测试(覆盖 A股 和 虚拟币 实现) +/// +[TestClass] +public class TechnicalDataToolsTest +{ + private ServiceProvider? _serviceProvider; + + public TestContext? TestContext { get; set; } + + [TestInitialize] + public void Setup() + { + // 从环境变量读取 API 密钥(不在代码中硬编码,避免提交到仓库) + var zhiTuApiToken = Environment.GetEnvironmentVariable("ZHITU_API_TOKEN") ?? ""; + + var services = new ServiceCollection(); + + services.AddLogging(builder => + { + builder.SetMinimumLevel(LogLevel.Debug); + }); + + // 注册命名 HttpClient(含 BaseAddress 与弹性策略),与生产配置一致 + services.AddNamedMarketHttpClients(); + + // 注册虚拟币 KLine 服务依赖的数据服务 + services.AddSingleton(); + + // 通过 Mock 注入带真实密钥的 UserSetting(避免依赖本地 Preferences 存储) + var userSetting = new UserSetting + { + ZhiTuApiToken = zhiTuApiToken + }; + var userSettingServiceMock = new Mock(); + userSettingServiceMock.Setup(x => x.CurrentSetting).Returns(userSetting); + services.AddSingleton(userSettingServiceMock.Object); + + // 注册 KLine 服务(TechnicalTools 依赖) + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.Crypto); + + // 注册被测试的服务 + services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.Crypto); + + _serviceProvider = services.BuildServiceProvider(); + } + + [TestCleanup] + public async Task Cleanup() + { + if (_serviceProvider != null) + { + await _serviceProvider.DisposeAsync(); + } + } + + #region A股技术数据测试 + + [TestMethod] + [TestCategory("Integration")] + public async Task GetKDJAsync_AShare_ShouldReturnValidData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act + var indicator = await service.GetKDJAsync("SH600519"); + + // Assert - 验证真实 KDJ 数据(K、D、J 值非空,证明 API 真实返回而非空对象) + Assert.IsNotNull(indicator, "KDJ 指标不应为空"); + Assert.IsTrue(indicator.K.HasValue, "K 值不应为空"); + Assert.IsTrue(indicator.D.HasValue, "D 值不应为空"); + Assert.IsTrue(indicator.J.HasValue, "J 值不应为空"); + TestContext?.WriteLine($"SH600519 KDJ: K={indicator.K}, D={indicator.D}, J={indicator.J}, 日期={indicator.T}"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetMACDAsync_AShare_ShouldReturnValidData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act + var indicator = await service.GetMACDAsync("SH600519"); + + // Assert - 验证真实 MACD 数据(Ema12、Ema26 非 0,证明 API 真实返回而非默认值) + Assert.IsNotNull(indicator, "MACD 指标不应为空"); + Assert.AreNotEqual(0m, indicator.Ema12, $"Ema12 不应为 0,实际: {indicator.Ema12}"); + Assert.AreNotEqual(0m, indicator.Ema26, $"Ema26 不应为 0,实际: {indicator.Ema26}"); + TestContext?.WriteLine($"SH600519 MACD: Diff={indicator.Diff}, Dea={indicator.Dea}, Macd={indicator.Macd}, Ema12={indicator.Ema12}, Ema26={indicator.Ema26}, 日期={indicator.T}"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetBOLLAsync_AShare_ShouldReturnValidData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act + var indicator = await service.GetBOLLAsync("SH600519"); + + // Assert - 验证真实 BOLL 数据(U、M、D 非空且满足 U > M > D 的布林带几何关系) + Assert.IsNotNull(indicator, "BOLL 指标不应为空"); + Assert.IsTrue(indicator.U.HasValue, "U(上轨)不应为空"); + Assert.IsTrue(indicator.M.HasValue, "M(中轨)不应为空"); + Assert.IsTrue(indicator.D.HasValue, "D(下轨)不应为空"); + Assert.IsTrue(indicator.U > indicator.M, $"U({indicator.U}) 应大于 M({indicator.M})"); + Assert.IsTrue(indicator.M > indicator.D, $"M({indicator.M}) 应大于 D({indicator.D})"); + TestContext?.WriteLine($"SH600519 BOLL: U={indicator.U}, M={indicator.M}, D={indicator.D}, 日期={indicator.T}"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetMAAsync_AShare_ShouldReturnValidData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.AShare); + + // Act + var indicator = await service.GetMAAsync("SH600519"); + + // Assert - 验证真实 MA 数据(MA5、MA20 非空,证明 API 真实返回而非空对象) + Assert.IsNotNull(indicator, "MA 指标不应为空"); + Assert.IsTrue(indicator.MA5.HasValue, "MA5 不应为空"); + Assert.IsTrue(indicator.MA20.HasValue, "MA20 不应为空"); + TestContext?.WriteLine($"SH600519 MA: MA5={indicator.MA5}, MA10={indicator.MA10}, MA20={indicator.MA20}, MA30={indicator.MA30}, 日期={indicator.T}"); + } + + #endregion + + #region 虚拟币技术数据测试 + + [TestMethod] + [TestCategory("Integration")] + public async Task GetKDJAsync_Crypto_ShouldReturnValidData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var indicator = await service.GetKDJAsync("BTCUSDT"); + + // Assert - 验证真实 KDJ 数据(K、D、J 值非空,证明 K 线数据真实返回并完成指标计算) + Assert.IsNotNull(indicator, "KDJ 指标不应为空"); + Assert.IsTrue(indicator.K.HasValue, "K 值不应为空"); + Assert.IsTrue(indicator.D.HasValue, "D 值不应为空"); + Assert.IsTrue(indicator.J.HasValue, "J 值不应为空"); + TestContext?.WriteLine($"BTCUSDT KDJ: K={indicator.K}, D={indicator.D}, J={indicator.J}, 日期={indicator.T}"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetMACDAsync_Crypto_ShouldReturnValidData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var indicator = await service.GetMACDAsync("BTCUSDT"); + + // Assert - 验证真实 MACD 数据(Ema12、Ema26 非 0,证明 K 线数据真实返回并完成指标计算) + Assert.IsNotNull(indicator, "MACD 指标不应为空"); + Assert.AreNotEqual(0m, indicator.Ema12, $"Ema12 不应为 0,实际: {indicator.Ema12}"); + Assert.AreNotEqual(0m, indicator.Ema26, $"Ema26 不应为 0,实际: {indicator.Ema26}"); + TestContext?.WriteLine($"BTCUSDT MACD: Diff={indicator.Diff}, Dea={indicator.Dea}, Macd={indicator.Macd}, Ema12={indicator.Ema12}, Ema26={indicator.Ema26}, 日期={indicator.T}"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetBOLLAsync_Crypto_ShouldReturnValidData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var indicator = await service.GetBOLLAsync("BTCUSDT"); + + // Assert - 验证真实 BOLL 数据(U、M、D 非空且满足 U > M > D 的布林带几何关系) + Assert.IsNotNull(indicator, "BOLL 指标不应为空"); + Assert.IsTrue(indicator.U.HasValue, "U(上轨)不应为空"); + Assert.IsTrue(indicator.M.HasValue, "M(中轨)不应为空"); + Assert.IsTrue(indicator.D.HasValue, "D(下轨)不应为空"); + Assert.IsTrue(indicator.U > indicator.M, $"U({indicator.U}) 应大于 M({indicator.M})"); + Assert.IsTrue(indicator.M > indicator.D, $"M({indicator.M}) 应大于 D({indicator.D})"); + TestContext?.WriteLine($"BTCUSDT BOLL: U={indicator.U}, M={indicator.M}, D={indicator.D}, 日期={indicator.T}"); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetMAAsync_Crypto_ShouldReturnValidData() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + + // Act + var indicator = await service.GetMAAsync("BTCUSDT"); + + // Assert - 验证真实 MA 数据(MA5、MA20 非空,证明 K 线数据真实返回并完成指标计算) + Assert.IsNotNull(indicator, "MA 指标不应为空"); + Assert.IsTrue(indicator.MA5.HasValue, "MA5 不应为空"); + Assert.IsTrue(indicator.MA20.HasValue, "MA20 不应为空"); + TestContext?.WriteLine($"BTCUSDT MA: MA5={indicator.MA5}, MA10={indicator.MA10}, MA20={indicator.MA20}, MA30={indicator.MA30}, 日期={indicator.T}"); + } + + #endregion +} diff --git a/tests/ToolsIntegrationTest.cs b/tests/ToolsIntegrationTest.cs deleted file mode 100644 index 8d980c6..0000000 --- a/tests/ToolsIntegrationTest.cs +++ /dev/null @@ -1,418 +0,0 @@ -using MarketAssistant.Agents.Tools; -using MarketAssistant.Applications.Settings; -using MarketAssistant.Infrastructure.Factories; -using MarketAssistant.Rag.Interfaces; -using MarketAssistant.Services.Browser; -using MarketAssistant.Services.Settings; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Moq; - -namespace TestMarketAssistant; - -/// -/// 所有 Agent Tools 的集成测试 -/// 验证各个工具类的基本功能正常运行 -/// -[TestClass] -public sealed class ToolsIntegrationTest -{ - private IServiceProvider _serviceProvider = null!; - private Mock _mockUserSettingService = null!; - private string _zhiTuApiToken = null!; - - [TestInitialize] - public void Initialize() - { - _zhiTuApiToken = Environment.GetEnvironmentVariable("ZHITU_API_TOKEN") - ?? throw new InvalidOperationException("ZHITU_API_TOKEN environment variable is not set"); - - _mockUserSettingService = new Mock(); - _mockUserSettingService.Setup(x => x.CurrentSetting).Returns(new UserSetting - { - ZhiTuApiToken = _zhiTuApiToken, - EnableWebSearch = true, - WebSearchApiKey = Environment.GetEnvironmentVariable("WEB_SEARCH_API_KEY") ?? string.Empty - }); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddHttpClient(); - serviceCollection.AddLogging(); - serviceCollection.AddSingleton(_mockUserSettingService.Object); - _serviceProvider = serviceCollection.BuildServiceProvider(); - } - - #region StockBasicTools 测试 - - [TestMethod] - public async Task StockBasicTools_GetStockInfo_ShouldReturnData() - { - // Arrange - var httpClientFactory = _serviceProvider.GetRequiredService(); - var tools = new StockBasicTools(httpClientFactory, _mockUserSettingService.Object); - - // Act - var result = await tools.GetStockInfoAsync("sz002594"); - - // Assert - Assert.IsNotNull(result, "股票基本信息不应为空"); - Assert.IsFalse(string.IsNullOrEmpty(result.SecurityCode), "股票代码不应为空"); - Console.WriteLine($"股票名称: {result.SecurityName}, 当前价格: {result.CurrentPrice}"); - } - - [TestMethod] - public async Task StockBasicTools_GetCompanyInfo_ShouldReturnData() - { - // Arrange - var httpClientFactory = _serviceProvider.GetRequiredService(); - var tools = new StockBasicTools(httpClientFactory, _mockUserSettingService.Object); - - // Act - var result = await tools.GetStockCompanyInfoAsync("sz002594"); - - // Assert - Assert.IsNotNull(result, "公司信息不应为空"); - Console.WriteLine($"公司名称: {result.Name}"); - } - - #endregion - - #region StockFinancialTools 测试 - - [TestMethod] - public async Task StockFinancialTools_GetBalanceSheet_ShouldReturnData() - { - // Arrange - var httpClientFactory = _serviceProvider.GetRequiredService(); - var tools = new StockFinancialTools(httpClientFactory, _mockUserSettingService.Object); - - // Act - var result = await tools.GetBalanceSheetAsync("sz002594"); - - // Assert - Assert.IsNotNull(result, "资产负债表数据不应为空"); - Assert.IsTrue(result.Count > 0, "资产负债表应至少包含一条记录"); - Console.WriteLine($"资产负债表记录数: {result.Count}, 最近报告期: {result[0].EndDate}"); - if (result[0].TotalAssets.HasValue) - { - Console.WriteLine($"总资产: {result[0].TotalAssets.Value}"); - } - } - - [TestMethod] - public async Task StockFinancialTools_GetIncomeStatement_ShouldReturnData() - { - // Arrange - var httpClientFactory = _serviceProvider.GetRequiredService(); - var tools = new StockFinancialTools(httpClientFactory, _mockUserSettingService.Object); - - // Act - var result = await tools.GetIncomeStatementAsync("sz002594"); - - // Assert - Assert.IsNotNull(result, "利润表数据不应为空"); - Assert.IsTrue(result.Count > 0, "利润表应至少包含一条记录"); - Console.WriteLine($"利润表记录数: {result.Count}, 最近报告期: {result[0].EndDate}"); - if (result[0].OperatingRevenue.HasValue) - { - Console.WriteLine($"营业收入: {result[0].OperatingRevenue.Value}"); - } - if (result[0].NetProfit.HasValue) - { - Console.WriteLine($"净利润: {result[0].NetProfit.Value}"); - } - } - - [TestMethod] - public async Task StockFinancialTools_GetCashFlowStatement_ShouldReturnData() - { - // Arrange - var httpClientFactory = _serviceProvider.GetRequiredService(); - var tools = new StockFinancialTools(httpClientFactory, _mockUserSettingService.Object); - - // Act - var result = await tools.GetCashFlowStatementAsync("sz002594"); - - // Assert - Assert.IsNotNull(result, "现金流量表数据不应为空"); - Assert.IsTrue(result.Count > 0, "现金流量表应至少包含一条记录"); - Console.WriteLine($"现金流量表记录数: {result.Count}, 最近报告期: {result[0].EndDate}"); - if (result[0].NetCashFlowFromOperating.HasValue) - { - Console.WriteLine($"经营活动现金流量净额: {result[0].NetCashFlowFromOperating.Value}"); - } - } - - [TestMethod] - public async Task StockFinancialTools_GetFinancialRatios_ShouldReturnData() - { - // Arrange - var httpClientFactory = _serviceProvider.GetRequiredService(); - var tools = new StockFinancialTools(httpClientFactory, _mockUserSettingService.Object); - - // Act - var result = await tools.GetFinancialRatiosAsync("sz002594"); - - // Assert - Assert.IsNotNull(result, "财务主要指标数据不应为空"); - Assert.IsTrue(result.Count > 0, "财务主要指标应至少包含一条记录"); - Console.WriteLine($"财务主要指标记录数: {result.Count}, 最近报告期: {result[0].EndDate}"); - if (result[0].ReturnOnEquity.HasValue) - { - Console.WriteLine($"净资产收益率: {result[0].ReturnOnEquity.Value}%"); - } - if (result[0].GrossMargin.HasValue) - { - Console.WriteLine($"销售毛利率: {result[0].GrossMargin.Value}%"); - } - } - - [TestMethod] - public async Task StockFinancialTools_GetCapitalStructure_ShouldReturnData() - { - // Arrange - var httpClientFactory = _serviceProvider.GetRequiredService(); - var tools = new StockFinancialTools(httpClientFactory, _mockUserSettingService.Object); - - // Act - var result = await tools.GetCapitalStructureAsync("sz002594"); - - // Assert - Assert.IsNotNull(result, "公司股本结构数据不应为空"); - Assert.IsTrue(result.Count > 0, "股本结构应至少包含一条记录"); - Console.WriteLine($"股本结构记录数: {result.Count}, 最近变动日期: {result[0].ChangeDate}"); - if (result[0].TotalShares.HasValue) - { - Console.WriteLine($"总股本: {result[0].TotalShares.Value}"); - } - if (result[0].CirculatingAShares.HasValue) - { - Console.WriteLine($"流通A股: {result[0].CirculatingAShares.Value}"); - } - } - - #endregion - - #region StockTechnicalTools 测试 - - [TestMethod] - public async Task StockTechnicalTools_GetKDJ_ShouldReturnData() - { - // Arrange - var httpClientFactory = _serviceProvider.GetRequiredService(); - var tools = new StockTechnicalTools(httpClientFactory, _mockUserSettingService.Object); - - // Act - var result = await tools.GetStockKDJAsync("sz002594"); - - // Assert - Assert.IsNotNull(result, "KDJ指标数据不应为空"); - Console.WriteLine($"KDJ: K={result.K}, D={result.D}, J={result.J}"); - } - - [TestMethod] - public async Task StockTechnicalTools_GetMACD_ShouldReturnData() - { - // Arrange - var httpClientFactory = _serviceProvider.GetRequiredService(); - var tools = new StockTechnicalTools(httpClientFactory, _mockUserSettingService.Object); - - // Act - var result = await tools.GetStockMACDAsync("sz002594"); - - // Assert - Assert.IsNotNull(result, "MACD指标数据不应为空"); - Console.WriteLine($"MACD: Diff={result.Diff}, Dea={result.Dea}, Macd={result.Macd}"); - } - - [TestMethod] - public async Task StockTechnicalTools_GetBOLL_ShouldReturnData() - { - // Arrange - var httpClientFactory = _serviceProvider.GetRequiredService(); - var tools = new StockTechnicalTools(httpClientFactory, _mockUserSettingService.Object); - - // Act - var result = await tools.GetStockBOLLAsync("002594"); - - // Assert - Assert.IsNotNull(result, "BOLL指标数据不应为空"); - Console.WriteLine($"BOLL: 上轨={result.U}, 中轨={result.M}, 下轨={result.D}"); - } - - [TestMethod] - public async Task StockTechnicalTools_GetMA_ShouldReturnData() - { - // Arrange - var httpClientFactory = _serviceProvider.GetRequiredService(); - var tools = new StockTechnicalTools(httpClientFactory, _mockUserSettingService.Object); - - // Act - var result = await tools.GetStockMAAsync("sz002594"); - - // Assert - Assert.IsNotNull(result, "MA指标数据不应为空"); - Console.WriteLine($"MA: 5日={result.MA5}, 10日={result.MA10}, 20日={result.MA20}"); - } - - #endregion - - #region StockNewsTools 测试 - - [TestMethod] - [Timeout(60000)] // 新闻抓取可能较慢,设置60秒超时 - public async Task StockNewsTools_GetNewsContext_Concise_ShouldReturnData() - { - // Arrange - var serviceCollection = new ServiceCollection(); - serviceCollection.AddHttpClient(); - serviceCollection.AddLogging(); - serviceCollection.AddSingleton(_mockUserSettingService.Object); - serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); - var provider = serviceCollection.BuildServiceProvider(); - - var playwrightService = provider.GetRequiredService(); - var chatClientFactory = provider.GetRequiredService(); - var tools = new StockNewsTools(playwrightService, chatClientFactory); - - // Act - var result = await tools.GetStockNewsContextAsync("sz002594", topK: 3, responseFormat: "concise"); - - // Assert - Assert.IsNotNull(result, "新闻上下文不应为空"); - Console.WriteLine($"新闻摘要: {result}"); - } - - #endregion - - #region MarketSentimentTools 测试 - - [TestMethod] - public async Task MarketSentimentTools_GetFundFlow_ShouldReturnData() - { - // Arrange - var httpClientFactory = _serviceProvider.GetRequiredService(); - var tools = new MarketSentimentTools(httpClientFactory, _mockUserSettingService.Object); - - // Act - var result = await tools.GetFundFlowAsync("sz002594"); - - // Assert - Assert.IsNotNull(result, "资金流向数据不应为空"); - Console.WriteLine($"主力净流入: {result.MainFundDiff}"); - } - - [TestMethod] - public async Task MarketSentimentTools_GetTopShareholders_ShouldReturnData() - { - // Arrange - var httpClientFactory = _serviceProvider.GetRequiredService(); - var tools = new MarketSentimentTools(httpClientFactory, _mockUserSettingService.Object); - - // Act - var result = await tools.GetTopShareholdersAsync("sz002594"); - - // Assert - Assert.IsNotNull(result, "十大股东信息不应为空"); - Assert.IsTrue(result.Count > 0, "十大股东应至少包含一条记录"); - Console.WriteLine($"十大股东记录数: {result.Count}"); - if (result.Count > 0) - { - Console.WriteLine($"第一大股东: {result[0].ShareholderName}, 持股比例: {result[0].ShareholdingRatio}"); - } - } - - [TestMethod] - public async Task MarketSentimentTools_GetTopCirculatingShareholders_ShouldReturnData() - { - // Arrange - var httpClientFactory = _serviceProvider.GetRequiredService(); - var tools = new MarketSentimentTools(httpClientFactory, _mockUserSettingService.Object); - - // Act - var result = await tools.GetTopCirculatingShareholdersAsync("sz002594"); - - // Assert - Assert.IsNotNull(result, "十大流通股东信息不应为空"); - Assert.IsTrue(result.Count > 0, "十大流通股东应至少包含一条记录"); - Console.WriteLine($"十大流通股东记录数: {result.Count}"); - if (result.Count > 0) - { - Console.WriteLine($"第一大流通股东: {result[0].ShareholderName}, 持股比例: {result[0].ShareholdingRatio}"); - } - } - - [TestMethod] - public async Task MarketSentimentTools_GetShareholderCount_ShouldReturnData() - { - // Arrange - var httpClientFactory = _serviceProvider.GetRequiredService(); - var tools = new MarketSentimentTools(httpClientFactory, _mockUserSettingService.Object); - - // Act - var result = await tools.GetShareholderCountAsync("sz002594"); - - // Assert - Assert.IsNotNull(result, "股东数信息不应为空"); - Assert.IsTrue(result.Count > 0, "股东数应至少包含一条记录"); - Console.WriteLine($"股东数历史记录数: {result.Count}"); - if (result.Count > 0) - { - Console.WriteLine($"最近截止日期: {result[0].EndDate}, 股东总数: {result[0].TotalShareholders}"); - } - } - - #endregion - - #region GroundingSearchTools 测试 - - [TestMethod] - [Timeout(60000)] - public async Task GroundingSearchTools_Search_CanCallSuccessfully() - { - // Arrange - 使用 BaseAgentTest 的服务提供者模式 - var serviceCollection = new ServiceCollection(); - serviceCollection.AddHttpClient(); - serviceCollection.AddLogging(); - serviceCollection.AddSingleton(_mockUserSettingService.Object); - - // 模拟必要的 RAG 服务 - var mockOrchestrator = new Mock(); - serviceCollection.AddSingleton(mockOrchestrator.Object); - - var mockWebSearchFactory = new Mock(); - serviceCollection.AddSingleton(mockWebSearchFactory.Object); - - var provider = serviceCollection.BuildServiceProvider(); - var logger = provider.GetRequiredService>(); - - var tools = new GroundingSearchTools( - mockOrchestrator.Object, - mockWebSearchFactory.Object, - _mockUserSettingService.Object, - logger); - - // Act & Assert - 只验证方法能正常调用 - try - { - var result = await tools.SearchAsync("测试查询", 5); - Assert.IsNotNull(result, "搜索结果不应为空"); - Console.WriteLine($"搜索返回 {result.Count} 条结果"); - } - catch (Exception ex) - { - // 如果依赖服务未完全配置,测试可能失败,标记为不确定 - Assert.Inconclusive($"GroundingSearchTools 调用失败(可能因服务未配置): {ex.Message}"); - } - } - - #endregion - - [TestCleanup] - public void Cleanup() - { - (_serviceProvider as IDisposable)?.Dispose(); - } -} - diff --git a/tests/Vectors/ClipImageEmbeddingServiceTest.cs b/tests/Vectors/ClipImageEmbeddingServiceTest.cs index cf666ad..6b263b8 100644 --- a/tests/Vectors/ClipImageEmbeddingServiceTest.cs +++ b/tests/Vectors/ClipImageEmbeddingServiceTest.cs @@ -52,6 +52,7 @@ public void Cleanup() #region 图像嵌入向量生成测试 [TestMethod] + [TestCategory("Unit")] public async Task GenerateAsync_WithValidImage_ShouldReturnVector() { // Act @@ -59,11 +60,11 @@ public async Task GenerateAsync_WithValidImage_ShouldReturnVector() // Assert Assert.IsNotNull(result, "嵌入结果不应为null"); - Assert.IsNotNull(result.Vector, "嵌入向量不应为null"); Assert.AreEqual(1024, result.Vector.Length, "向量维度应为1024"); } [TestMethod] + [TestCategory("Unit")] public async Task GenerateAsync_WithSameImage_ShouldReturnConsistentVector() { // Arrange @@ -86,6 +87,7 @@ public async Task GenerateAsync_WithSameImage_ShouldReturnConsistentVector() } [TestMethod] + [TestCategory("Unit")] public async Task GenerateAsync_WithInvalidImageData_ShouldUseFallback() { // Arrange - 无效的图像数据 @@ -96,15 +98,16 @@ public async Task GenerateAsync_WithInvalidImageData_ShouldUseFallback() // Assert - 应该优雅处理(哈希降级或模型输出) Assert.IsNotNull(result, "无效图像应返回降级结果"); - Assert.IsNotNull(result.Vector, "无效图像应返回降级向量"); + Assert.IsFalse(result.Vector.IsEmpty, "降级向量不应为空"); Assert.AreEqual(1024, result.Vector.Length, "降级向量维度应正确"); } [TestMethod] + [TestCategory("Unit")] public async Task GenerateAsync_WithCancellation_ShouldComplete() { // Arrange - using var cts = new CancellationTokenSource(); + using var cts = new CancellationTokenSource(); cts.Cancel(); // 立即取消 // Act & Assert - 哈希降级不支持取消,但应该快速完成 @@ -117,13 +120,14 @@ public async Task GenerateAsync_WithCancellation_ShouldComplete() #region 图像描述生成测试 [TestMethod] + [TestCategory("Unit")] public async Task GenerateCaptionAsync_WithoutChatService_ShouldReturnFallback() { // Act var result = await _service.CaptionAsync(_testImageBytes); // Assert - Assert.AreEqual("(图像内容待解析)", result); + Assert.AreEqual("(图像内容生成失败)", result); } #endregion @@ -131,6 +135,7 @@ public async Task GenerateCaptionAsync_WithoutChatService_ShouldReturnFallback() #region 资源管理和配置测试 [TestMethod] + [TestCategory("Unit")] public void Dispose_ShouldReleaseResources() { // Arrange @@ -145,6 +150,7 @@ public void Dispose_ShouldReleaseResources() } [TestMethod] + [TestCategory("Unit")] public async Task MultipleOperations_ShouldWorkCorrectly() { // Arrange @@ -159,7 +165,7 @@ public async Task MultipleOperations_ShouldWorkCorrectly() Assert.IsNotNull(embedding, "并发嵌入生成应成功"); Assert.IsNotNull(caption, "并发描述生成应成功"); // 默认无Chat服务,返回占位符 - Assert.AreEqual("(图像内容待解析)", caption); + Assert.AreEqual("(图像内容生成失败)", caption); } #endregion diff --git a/tests/Vectors/DocumentBlockMapperTest.cs b/tests/Vectors/DocumentBlockMapperTest.cs index d4982a7..cf50146 100644 --- a/tests/Vectors/DocumentBlockMapperTest.cs +++ b/tests/Vectors/DocumentBlockMapperTest.cs @@ -22,6 +22,7 @@ public void Setup() } [TestMethod] + [TestCategory("Unit")] public void MapBlock_TextBlock_ShouldCreateCorrectParagraphsWithConsistentIds() { // Arrange @@ -52,6 +53,7 @@ public void MapBlock_TextBlock_ShouldCreateCorrectParagraphsWithConsistentIds() } [TestMethod] + [TestCategory("Unit")] public void MapBlock_HeadingBlock_ShouldCreateCorrectParagraph() { // Arrange @@ -77,6 +79,7 @@ public void MapBlock_HeadingBlock_ShouldCreateCorrectParagraph() } [TestMethod] + [TestCategory("Unit")] public void MapBlock_ListBlock_ShouldCreateCorrectParagraph() { // Arrange @@ -101,6 +104,7 @@ public void MapBlock_ListBlock_ShouldCreateCorrectParagraph() } [TestMethod] + [TestCategory("Unit")] public void MapBlock_TableBlock_ShouldCreateCorrectParagraph() { // Arrange @@ -130,6 +134,7 @@ public void MapBlock_TableBlock_ShouldCreateCorrectParagraph() } [TestMethod] + [TestCategory("Unit")] public void MapBlock_ImageBlock_ShouldCreateCorrectParagraph() { // Arrange diff --git a/tests/Vectors/DocxFileTest.cs b/tests/Vectors/DocxFileTest.cs index 1366985..3f7d5ff 100644 --- a/tests/Vectors/DocxFileTest.cs +++ b/tests/Vectors/DocxFileTest.cs @@ -57,6 +57,7 @@ private static string GetTestProjectDirectory() #region Converter Tests [TestMethod] + [TestCategory("Unit")] public void DocxMarkdownConverter_ShouldHandleDocxFiles() { // Arrange & Act & Assert @@ -66,6 +67,7 @@ public void DocxMarkdownConverter_ShouldHandleDocxFiles() } [TestMethod] + [TestCategory("Unit")] public async Task DocxMarkdownConverter_ShouldConvertRealDocxFile() { // Act @@ -84,6 +86,7 @@ public async Task DocxMarkdownConverter_ShouldConvertRealDocxFile() } [TestMethod] + [TestCategory("Unit")] public async Task DocxMarkdownConverter_ShouldHandleDocumentStructure() { // Act @@ -111,6 +114,7 @@ public async Task DocxMarkdownConverter_ShouldHandleDocumentStructure() #region Reader Tests [TestMethod] + [TestCategory("Unit")] public void DocxBlockReader_ShouldHandleDocxFiles() { // Arrange & Act & Assert @@ -120,6 +124,7 @@ public void DocxBlockReader_ShouldHandleDocxFiles() } [TestMethod] + [TestCategory("Unit")] public async Task DocxBlockReader_ShouldReadDocumentBlocks() { // Act @@ -147,6 +152,7 @@ public async Task DocxBlockReader_ShouldReadDocumentBlocks() } [TestMethod] + [TestCategory("Unit")] public async Task DocxBlockReader_ShouldPreserveBlockOrder() { // Act @@ -165,6 +171,7 @@ public async Task DocxBlockReader_ShouldPreserveBlockOrder() } [TestMethod] + [TestCategory("Unit")] public async Task DocxBlockReader_ShouldExtractTextContent() { // Act @@ -189,6 +196,7 @@ public async Task DocxBlockReader_ShouldExtractTextContent() } [TestMethod] + [TestCategory("Unit")] public async Task DocxBlockReader_ShouldHandleDifferentBlockTypes() { // Act @@ -213,6 +221,7 @@ public async Task DocxBlockReader_ShouldHandleDifferentBlockTypes() } [TestMethod] + [TestCategory("Unit")] public async Task DocxBlockReader_ShouldHandleEmptyOrCorruptFile() { // Arrange @@ -222,19 +231,21 @@ public async Task DocxBlockReader_ShouldHandleEmptyOrCorruptFile() // 创建一个空的或损坏的DOCX文件 await File.WriteAllTextAsync(tempFile, "这不是一个有效的DOCX文件"); - // Act & Assert + // Act & Assert - 损坏的DOCX应返回空块列表或抛出异常 + var threw = false; + List blockList = new(); try { var blocks = await _reader.ReadBlocksAsync(tempFile); - // 如果没有抛出异常,检查结果是否为空或合理 - var blockList = blocks.ToList(); - Assert.IsTrue(blockList.Count == 0, "损坏的DOCX文件应该返回空块列表或抛出异常"); + blockList = blocks.ToList(); } catch (Exception) { - // 抛出异常是预期的行为 - Assert.IsTrue(true, "处理无效DOCX文件时抛出异常是正常的"); + threw = true; } + + // 必须满足以下两种情况之一:抛出异常 或 返回空块列表 + Assert.IsTrue(threw || blockList.Count == 0, "损坏的DOCX文件应该返回空块列表或抛出异常"); } finally { @@ -247,22 +258,24 @@ public async Task DocxBlockReader_ShouldHandleEmptyOrCorruptFile() } [TestMethod] + [TestCategory("Unit")] public async Task DocxBlockReader_ShouldHandleNonExistentFile() { // Arrange var nonExistentFile = "non_existent_file.docx"; - // Act & Assert + // Act & Assert - 不存在的文件应抛出异常 + var threw = false; try { var blocks = await _reader.ReadBlocksAsync(nonExistentFile); - Assert.Fail("应该抛出异常处理不存在的文件"); + _ = blocks.ToList(); // 触发枚举 } catch (Exception) { - // 抛出异常是预期的行为 - Assert.IsTrue(true, "处理不存在的文件时抛出异常是正常的"); + threw = true; } + Assert.IsTrue(threw, "处理不存在的文件时应抛出异常"); } #endregion diff --git a/tests/Vectors/MarkdownDocumentBlockReaderTest.cs b/tests/Vectors/MarkdownDocumentBlockReaderTest.cs index 8cf5f82..6c9a5ec 100644 --- a/tests/Vectors/MarkdownDocumentBlockReaderTest.cs +++ b/tests/Vectors/MarkdownDocumentBlockReaderTest.cs @@ -86,6 +86,7 @@ public void Cleanup() } [TestMethod] + [TestCategory("Unit")] public async Task ReadBlocksAsync_ShouldExtractImageBlocks() { // Arrange & Act @@ -124,6 +125,7 @@ public async Task ReadBlocksAsync_ShouldExtractImageBlocks() } [TestMethod] + [TestCategory("Unit")] public async Task ReadBlocksAsync_ShouldExtractHeadingBlocks() { // Arrange & Act @@ -141,6 +143,7 @@ public async Task ReadBlocksAsync_ShouldExtractHeadingBlocks() } [TestMethod] + [TestCategory("Unit")] public async Task ReadBlocksAsync_ShouldExtractTableBlocks() { // Arrange & Act @@ -157,6 +160,7 @@ public async Task ReadBlocksAsync_ShouldExtractTableBlocks() } [TestMethod] + [TestCategory("Unit")] public async Task ReadBlocksAsync_ShouldExtractListBlocks() { // Arrange & Act @@ -174,6 +178,7 @@ public async Task ReadBlocksAsync_ShouldExtractListBlocks() } [TestMethod] + [TestCategory("Unit")] public async Task ReadBlocksAsync_ShouldExtractTextBlocks() { // Arrange & Act @@ -190,6 +195,7 @@ public async Task ReadBlocksAsync_ShouldExtractTextBlocks() } [TestMethod] + [TestCategory("Unit")] public async Task ReadBlocksAsync_ShouldMaintainCorrectOrder() { // Arrange & Act diff --git a/tests/Vectors/PdfFileTest.cs b/tests/Vectors/PdfFileTest.cs index 2e22d74..5176e9d 100644 --- a/tests/Vectors/PdfFileTest.cs +++ b/tests/Vectors/PdfFileTest.cs @@ -57,6 +57,7 @@ private static string GetTestProjectDirectory() #region Converter Tests [TestMethod] + [TestCategory("Unit")] public void PdfMarkdownConverter_ShouldHandlePdfFiles() { // Arrange & Act & Assert @@ -66,6 +67,7 @@ public void PdfMarkdownConverter_ShouldHandlePdfFiles() } [TestMethod] + [TestCategory("Unit")] public async Task PdfMarkdownConverter_ShouldConvertRealPdfFile() { // Act @@ -84,6 +86,7 @@ public async Task PdfMarkdownConverter_ShouldConvertRealPdfFile() } [TestMethod] + [TestCategory("Unit")] public async Task PdfMarkdownConverter_ShouldHandleDocumentStructure() { // Act @@ -107,6 +110,7 @@ public async Task PdfMarkdownConverter_ShouldHandleDocumentStructure() } [TestMethod] + [TestCategory("Unit")] public async Task PdfMarkdownConverter_ShouldPreserveTextFormatting() { // Act @@ -129,6 +133,7 @@ public async Task PdfMarkdownConverter_ShouldPreserveTextFormatting() #region Reader Tests [TestMethod] + [TestCategory("Unit")] public void PdfBlockReader_ShouldHandlePdfFiles() { // Arrange & Act & Assert @@ -138,6 +143,7 @@ public void PdfBlockReader_ShouldHandlePdfFiles() } [TestMethod] + [TestCategory("Unit")] public async Task PdfBlockReader_ShouldReadDocumentBlocks() { // Act @@ -165,6 +171,7 @@ public async Task PdfBlockReader_ShouldReadDocumentBlocks() } [TestMethod] + [TestCategory("Unit")] public async Task PdfBlockReader_ShouldPreserveBlockOrder() { // Act @@ -183,6 +190,7 @@ public async Task PdfBlockReader_ShouldPreserveBlockOrder() } [TestMethod] + [TestCategory("Unit")] public async Task PdfBlockReader_ShouldExtractTextContent() { // Act @@ -207,6 +215,7 @@ public async Task PdfBlockReader_ShouldExtractTextContent() } [TestMethod] + [TestCategory("Unit")] public async Task PdfBlockReader_ShouldHandleEmptyOrCorruptFile() { // Arrange @@ -216,19 +225,21 @@ public async Task PdfBlockReader_ShouldHandleEmptyOrCorruptFile() // 创建一个空的或损坏的PDF文件 await File.WriteAllTextAsync(tempFile, "这不是一个有效的PDF文件"); - // Act & Assert + // Act & Assert - 损坏的PDF应返回空块列表或抛出异常 + var threw = false; + List blockList = new(); try { var blocks = await _reader.ReadBlocksAsync(tempFile); - // 如果没有抛出异常,检查结果是否为空或合理 - var blockList = blocks.ToList(); - Assert.IsTrue(blockList.Count == 0, "损坏的PDF文件应该返回空块列表或抛出异常"); + blockList = blocks.ToList(); } catch (Exception) { - // 抛出异常是预期的行为 - Assert.IsTrue(true, "处理无效PDF文件时抛出异常是正常的"); + threw = true; } + + // 必须满足以下两种情况之一:抛出异常 或 返回空块列表 + Assert.IsTrue(threw || blockList.Count == 0, "损坏的PDF文件应该返回空块列表或抛出异常"); } finally { @@ -241,22 +252,24 @@ public async Task PdfBlockReader_ShouldHandleEmptyOrCorruptFile() } [TestMethod] + [TestCategory("Unit")] public async Task PdfBlockReader_ShouldHandleNonExistentFile() { // Arrange var nonExistentFile = "non_existent_file.pdf"; - // Act & Assert + // Act & Assert - 不存在的文件应抛出异常 + var threw = false; try { var blocks = await _reader.ReadBlocksAsync(nonExistentFile); - Assert.Fail("应该抛出异常处理不存在的文件"); + _ = blocks.ToList(); // 触发枚举 } catch (Exception) { - // 抛出异常是预期的行为 - Assert.IsTrue(true, "处理不存在的文件时抛出异常是正常的"); + threw = true; } + Assert.IsTrue(threw, "处理不存在的文件时应抛出异常"); } #endregion diff --git a/tests/Vectors/QueryRewriteServiceTest.cs b/tests/Vectors/QueryRewriteServiceTest.cs index 2bd18ff..42a826e 100644 --- a/tests/Vectors/QueryRewriteServiceTest.cs +++ b/tests/Vectors/QueryRewriteServiceTest.cs @@ -18,6 +18,7 @@ public void Initialize() #region Service Resolution Tests [TestMethod] + [TestCategory("Unit")] public void Service_ShouldBeResolvedFromContainer() { // Assert @@ -30,6 +31,7 @@ public void Service_ShouldBeResolvedFromContainer() [TestMethod] + [TestCategory("Unit")] public void Rewrite_WithWhitespaceQuery_ShouldReturnEmptyList() { // Act @@ -41,6 +43,7 @@ public void Rewrite_WithWhitespaceQuery_ShouldReturnEmptyList() } [TestMethod] + [TestCategory("Unit")] public void Rewrite_WithZeroMaxCandidates_ShouldReturnEmptyList() { // Act @@ -52,6 +55,7 @@ public void Rewrite_WithZeroMaxCandidates_ShouldReturnEmptyList() } [TestMethod] + [TestCategory("Unit")] public void Rewrite_WithNegativeMaxCandidates_ShouldReturnEmptyList() { // Act @@ -67,6 +71,7 @@ public void Rewrite_WithNegativeMaxCandidates_ShouldReturnEmptyList() #region Functional Tests [TestMethod] + [TestCategory("Unit")] public void Rewrite_WithValidQuery_ShouldReturnRewrittenQueries() { // Arrange @@ -90,6 +95,7 @@ public void Rewrite_WithValidQuery_ShouldReturnRewrittenQueries() } [TestMethod] + [TestCategory("Unit")] public void Rewrite_WithDefaultMaxCandidates_ShouldReturnLimitedResults() { // Arrange @@ -110,6 +116,7 @@ public void Rewrite_WithDefaultMaxCandidates_ShouldReturnLimitedResults() } [TestMethod] + [TestCategory("Unit")] public void Rewrite_WithSynonymExpansion_ShouldGenerateVariants() { // Arrange - 使用包含同义词的查询 @@ -133,7 +140,8 @@ public void Rewrite_WithSynonymExpansion_ShouldGenerateVariants() } [TestMethod] - public void Rewrite_WithFinancialTerms_ShouldGenerateAnalysisDimensions() + [TestCategory("Unit")] + public void Rewrite_WithFinancialTerms_ShouldGenerateSynonymVariants() { // Arrange var query = "新能源股票投资"; @@ -145,9 +153,13 @@ public void Rewrite_WithFinancialTerms_ShouldGenerateAnalysisDimensions() Assert.IsNotNull(result); Assert.IsTrue(result.Count > 0); - // Should include analysis dimensions like 基本面、技术面 etc. - var hasAnalysisDimension = result.Any(r => - r.Contains("基本面") || r.Contains("技术面") || r.Contains("消息面") || r.Contains("估值")); + // 应通过同义词替换生成变体(如"股票"→"证券"/"股份"等) + var hasSynonymVariant = result.Any(r => + r.Contains("证券") || r.Contains("股份") || r.Contains("股权") || + r.Contains("个股") || r.Contains("股价") || + r.Contains("电动车") || r.Contains("新能车") || r.Contains("锂电")); + + Assert.IsTrue(hasSynonymVariant, "应生成包含同义词替换的变体"); foreach (var variant in result) { @@ -156,7 +168,8 @@ public void Rewrite_WithFinancialTerms_ShouldGenerateAnalysisDimensions() } [TestMethod] - public void Rewrite_WithTimeFrameTerms_ShouldGenerateTimeVariants() + [TestCategory("Unit")] + public void Rewrite_WithTimeFrameTerms_ShouldGenerateSynonymVariants() { // Arrange var query = "芯片股票研究"; @@ -168,7 +181,13 @@ public void Rewrite_WithTimeFrameTerms_ShouldGenerateTimeVariants() Assert.IsNotNull(result); Assert.IsTrue(result.Count > 0); - // Should include time-related variants + // 应通过同义词替换生成变体 + var hasSynonymVariant = result.Any(r => + r.Contains("半导体") || r.Contains("集成电路") || r.Contains("处理器") || + r.Contains("证券") || r.Contains("股份") || r.Contains("个股")); + + Assert.IsTrue(hasSynonymVariant, "应生成包含同义词替换的变体"); + foreach (var variant in result) { Console.WriteLine($"Generated variant: {variant}"); @@ -177,6 +196,7 @@ public void Rewrite_WithTimeFrameTerms_ShouldGenerateTimeVariants() } [TestMethod] + [TestCategory("Unit")] public void Rewrite_ShouldReturnUniqueResults() { // Arrange @@ -201,6 +221,7 @@ public void Rewrite_ShouldReturnUniqueResults() } [TestMethod] + [TestCategory("Unit")] public void Rewrite_WithLargeMaxCandidates_ShouldReturnReasonableAmount() { // Arrange diff --git a/tests/Vectors/RagIngestionServiceIntegrationTest.cs b/tests/Vectors/RagIngestionServiceIntegrationTest.cs index ad53b49..5207ac1 100644 --- a/tests/Vectors/RagIngestionServiceIntegrationTest.cs +++ b/tests/Vectors/RagIngestionServiceIntegrationTest.cs @@ -59,6 +59,7 @@ private static string GetTestProjectDirectory() } [TestMethod] + [TestCategory("Integration")] public async Task TestIngestFileAsync() { // 验证IngestFileAsync能否正确执行 @@ -76,44 +77,33 @@ await _ragIngestionService.IngestFileAsync( Console.WriteLine("文件摄取完成"); // 验证摄取结果 - 使用直接的向量搜索 - try - { - // 生成查询向量 - var queryEmbedding = await _embeddingGenerator.GenerateAsync("文档内容"); + // 生成查询向量 + var queryEmbedding = await _embeddingGenerator.GenerateAsync("文档内容"); - var vectorSearchOptions = new VectorSearchOptions - { - VectorProperty = r => r.TextEmbedding - }; + var vectorSearchOptions = new VectorSearchOptions + { + VectorProperty = r => r.TextEmbedding + }; - // 使用直接向量搜索(指定最大返回数量) - var searchResults = collection.SearchAsync(queryEmbedding, 5, vectorSearchOptions); - var results = new List(); + // 使用直接向量搜索(指定最大返回数量) + var searchResults = collection.SearchAsync(queryEmbedding, 5, vectorSearchOptions); + var results = new List(); - await foreach (var result in searchResults) - { - results.Add(result.Record); - } + await foreach (var result in searchResults) + { + results.Add(result.Record); + } - Assert.IsTrue(results.Count > 0, "应该有数据被摄取到向量存储中"); + Assert.IsTrue(results.Count > 0, "应该有数据被摄取到向量存储中"); - Console.WriteLine($"摄取成功,找到 {results.Count} 个段落"); + Console.WriteLine($"摄取成功,找到 {results.Count} 个段落"); - // 打印前几个结果的内容片段 - foreach (var result in results.Take(3)) - { - var text = result.Text; - var preview = text.Length > 100 ? text[..100] + "..." : text; - Console.WriteLine($"段落: {preview}"); - } - } - catch (Exception ex) + // 打印前几个结果的内容片段 + foreach (var result in results.Take(3)) { - // 如果搜索失败,至少验证数据已经被存储 - Console.WriteLine($"搜索失败: {ex.Message}"); - - // 简单验证:文档摄取过程已完成 - Assert.IsTrue(true, "文档摄取过程已完成,尽管搜索可能因多向量属性问题失败"); + var text = result.Text; + var preview = text.Length > 100 ? text[..100] + "..." : text; + Console.WriteLine($"段落: {preview}"); } } diff --git a/tests/Vectors/RerankerServiceTest.cs b/tests/Vectors/RerankerServiceTest.cs index 15b2fe5..5f4b80a 100644 --- a/tests/Vectors/RerankerServiceTest.cs +++ b/tests/Vectors/RerankerServiceTest.cs @@ -19,6 +19,7 @@ public void Initialize() #region Service Resolution Tests [TestMethod] + [TestCategory("Unit")] public void Service_ShouldBeResolvedFromContainer() { // Assert @@ -30,11 +31,12 @@ public void Service_ShouldBeResolvedFromContainer() #region Core Functionality Tests [TestMethod] + [TestCategory("Unit")] public void Rerank_WithEmptyItems_ShouldReturnEmptyList() { // Arrange var query = "test query"; - var items = new List(); + var items = new List(); // Act var result = _rerankerService.Rerank(query, items); @@ -45,13 +47,14 @@ public void Rerank_WithEmptyItems_ShouldReturnEmptyList() } [TestMethod] + [TestCategory("Unit")] public void Rerank_WithSingleItem_ShouldReturnSameItem() { // Arrange var query = "股票分析"; - var items = new List + var items = new List { - CreateTextSearchResult("result1", "股票市场技术分析指标", "https://example.com/1") + CreateScoredResult("result1", "股票市场技术分析指标", "https://example.com/1", 0.8f) }; // Act @@ -64,15 +67,16 @@ public void Rerank_WithSingleItem_ShouldReturnSameItem() } [TestMethod] + [TestCategory("Unit")] public void Rerank_WithMultipleItems_ShouldReturnReorderedResults() { - // Arrange + // Arrange - 高向量分数的项目应排前面 var query = "AI 人工智能"; - var items = new List + var items = new List { - CreateTextSearchResult("result1", "股票市场基本面分析", "https://example.com/1"), - CreateTextSearchResult("result2", "人工智能AI技术发展趋势", "https://example.com/2"), - CreateTextSearchResult("result3", "机器学习在投资中的应用", "https://example.com/3") + CreateScoredResult("result1", "股票市场基本面分析", "https://example.com/1", 0.5f), + CreateScoredResult("result2", "人工智能AI技术发展趋势", "https://example.com/2", 0.9f), + CreateScoredResult("result3", "机器学习在投资中的应用", "https://example.com/3", 0.7f) }; // Act @@ -82,7 +86,9 @@ public void Rerank_WithMultipleItems_ShouldReturnReorderedResults() Assert.IsNotNull(result); Assert.AreEqual(3, result.Count); - // The AI-related item should be ranked higher + // 向量分数最高的 result2 应排第一 + Assert.AreEqual("result2", result[0].Name); + Console.WriteLine($"Reranked order:"); for (int i = 0; i < result.Count; i++) { @@ -91,16 +97,17 @@ public void Rerank_WithMultipleItems_ShouldReturnReorderedResults() } [TestMethod] + [TestCategory("Unit")] public void Rerank_WithRelevantQuery_ShouldPrioritizeRelevantContent() { // Arrange var query = "新能源汽车投资"; - var items = new List + var items = new List { - CreateTextSearchResult("result1", "传统汽车行业发展", "https://example.com/1"), - CreateTextSearchResult("result2", "新能源汽车市场分析", "https://example.com/2"), - CreateTextSearchResult("result3", "电动汽车技术创新", "https://example.com/3"), - CreateTextSearchResult("result4", "房地产投资策略", "https://example.com/4") + CreateScoredResult("result1", "传统汽车行业发展", "https://example.com/1", 0.4f), + CreateScoredResult("result2", "新能源汽车市场分析", "https://example.com/2", 0.85f), + CreateScoredResult("result3", "电动汽车技术创新", "https://example.com/3", 0.75f), + CreateScoredResult("result4", "房地产投资策略", "https://example.com/4", 0.3f) }; // Act @@ -110,7 +117,6 @@ public void Rerank_WithRelevantQuery_ShouldPrioritizeRelevantContent() Assert.IsNotNull(result); Assert.AreEqual(4, result.Count); - // Should prioritize new energy vehicle related content Console.WriteLine($"Reranked results for '{query}':"); for (int i = 0; i < result.Count; i++) { @@ -119,20 +125,22 @@ public void Rerank_WithRelevantQuery_ShouldPrioritizeRelevantContent() } [TestMethod] + [TestCategory("Unit")] public void Rerank_WithLargeDataset_ShouldHandleEfficiently() { // Arrange var query = "芯片半导体"; - var items = new List(); + var items = new List(); - // Create 15 test items for (int i = 1; i <= 15; i++) { - var isRelevant = i % 3 == 0; // Every 3rd item is relevant + var isRelevant = i % 3 == 0; var content = isRelevant ? $"芯片半导体技术发展报告第{i}部分" : $"一般性市场分析报告第{i}部分"; - items.Add(CreateTextSearchResult($"result{i}", content, $"https://example.com/{i}")); + // 相关项给更高向量分数 + var score = isRelevant ? 0.8f + i * 0.01f : 0.3f + i * 0.01f; + items.Add(CreateScoredResult($"result{i}", content, $"https://example.com/{i}", score)); } // Act @@ -143,19 +151,20 @@ public void Rerank_WithLargeDataset_ShouldHandleEfficiently() Assert.AreEqual(15, result.Count); Console.WriteLine($"Reranked large dataset for '{query}':"); - for (int i = 0; i < Math.Min(5, result.Count); i++) // Show top 5 + for (int i = 0; i < Math.Min(5, result.Count); i++) { Console.WriteLine($"{i + 1}. {result[i].Name}: {result[i].Value}"); } } [TestMethod] + [TestCategory("Unit")] public void Rerank_WithNullQuery_ShouldNotThrowException() { // Arrange - var items = new List + var items = new List { - CreateTextSearchResult("result1", "测试内容", "https://example.com/1") + CreateScoredResult("result1", "测试内容", "https://example.com/1", 0.5f) }; // Act & Assert - Should not throw @@ -166,13 +175,14 @@ public void Rerank_WithNullQuery_ShouldNotThrowException() } [TestMethod] - public void Rerank_WithEmptyQuery_ShouldReturnOriginalOrder() + [TestCategory("Unit")] + public void Rerank_WithEmptyQuery_ShouldReturnByVectorScore() { - // Arrange - var items = new List + // Arrange - 空查询时按向量分数排序 + var items = new List { - CreateTextSearchResult("result1", "第一个结果", "https://example.com/1"), - CreateTextSearchResult("result2", "第二个结果", "https://example.com/2") + CreateScoredResult("result1", "第一个结果", "https://example.com/1", 0.3f), + CreateScoredResult("result2", "第二个结果", "https://example.com/2", 0.9f) }; // Act @@ -181,19 +191,22 @@ public void Rerank_WithEmptyQuery_ShouldReturnOriginalOrder() // Assert Assert.IsNotNull(result); Assert.AreEqual(2, result.Count); + // 向量分数高的应排前面 + Assert.AreEqual("result2", result[0].Name); } [TestMethod] + [TestCategory("Unit")] public void Rerank_ShouldPreserveAllResults() { // Arrange var query = "市场分析"; var originalCount = 10; - var items = new List(); + var items = new List(); for (int i = 1; i <= originalCount; i++) { - items.Add(CreateTextSearchResult($"result{i}", $"内容{i}", $"https://example.com/{i}")); + items.Add(CreateScoredResult($"result{i}", $"内容{i}", $"https://example.com/{i}", 0.5f + i * 0.01f)); } // Act @@ -203,8 +216,7 @@ public void Rerank_ShouldPreserveAllResults() Assert.IsNotNull(result); Assert.AreEqual(originalCount, result.Count); - // All items should be preserved - var originalNames = items.Select(i => i.Name).OrderBy(n => n).ToArray(); + var originalNames = items.Select(i => i.Item.Name).OrderBy(n => n).ToArray(); var resultNames = result.Select(r => r.Name).OrderBy(n => n).ToArray(); CollectionAssert.AreEquivalent(originalNames, resultNames); @@ -214,14 +226,15 @@ public void Rerank_ShouldPreserveAllResults() #region Helper Methods - private static TextSearchResult CreateTextSearchResult(string name, string value, string link) + private static ScoredSearchResult CreateScoredResult(string name, string value, string link, float vectorScore) { - return new TextSearchResult(value) + var textResult = new TextSearchResult(value) { Name = name, Link = link }; + return new ScoredSearchResult(textResult, vectorScore); } #endregion -} \ No newline at end of file +} diff --git a/tests/Vectors/RetrievalOrchestratorIntegrationTest.cs b/tests/Vectors/RetrievalOrchestratorIntegrationTest.cs index b30be3f..6dbdcae 100644 --- a/tests/Vectors/RetrievalOrchestratorIntegrationTest.cs +++ b/tests/Vectors/RetrievalOrchestratorIntegrationTest.cs @@ -102,6 +102,7 @@ private async Task AddTestDataAsync(VectorStoreCollection } [TestMethod] + [TestCategory("Integration")] public async Task TestRetrieveAsync() { // 验证RetrieveAsync能否正常工作 diff --git a/tests/Vectors/TextChunkingServiceTest.cs b/tests/Vectors/TextChunkingServiceTest.cs index 40ef9c7..ba3a431 100644 --- a/tests/Vectors/TextChunkingServiceTest.cs +++ b/tests/Vectors/TextChunkingServiceTest.cs @@ -16,6 +16,7 @@ public void Setup() } [TestMethod] + [TestCategory("Unit")] public void Chunk_ShouldSplitTextIntoParagraphs() { // Arrange @@ -33,6 +34,7 @@ public void Chunk_ShouldSplitTextIntoParagraphs() } [TestMethod] + [TestCategory("Unit")] public void Chunk_ShouldHandleEmptyString() { // Arrange @@ -48,6 +50,7 @@ public void Chunk_ShouldHandleEmptyString() } [TestMethod] + [TestCategory("Unit")] public void Chunk_ShouldHandleNullString() { // Arrange @@ -63,6 +66,7 @@ public void Chunk_ShouldHandleNullString() } [TestMethod] + [TestCategory("Unit")] public void Chunk_ShouldGenerateUniqueKeys() { // Arrange diff --git a/tests/Vectors/TextCleaningServiceTest.cs b/tests/Vectors/TextCleaningServiceTest.cs index ce69697..2dab2ca 100644 --- a/tests/Vectors/TextCleaningServiceTest.cs +++ b/tests/Vectors/TextCleaningServiceTest.cs @@ -16,6 +16,7 @@ public void Setup() } [TestMethod] + [TestCategory("Unit")] public void Clean_ShouldRemoveExtraWhitespace() { // Arrange @@ -30,6 +31,7 @@ public void Clean_ShouldRemoveExtraWhitespace() } [TestMethod] + [TestCategory("Unit")] public void Clean_ShouldRemoveLeadingAndTrailingWhitespace() { // Arrange @@ -44,6 +46,7 @@ public void Clean_ShouldRemoveLeadingAndTrailingWhitespace() } [TestMethod] + [TestCategory("Unit")] public void Clean_ShouldHandleEmptyString() { // Arrange @@ -58,6 +61,7 @@ public void Clean_ShouldHandleEmptyString() } [TestMethod] + [TestCategory("Unit")] public void Clean_ShouldHandleNullString() { // Arrange @@ -72,6 +76,7 @@ public void Clean_ShouldHandleNullString() } [TestMethod] + [TestCategory("Unit")] public void Clean_ShouldRemovePageNumbers() { // Arrange @@ -86,6 +91,7 @@ public void Clean_ShouldRemovePageNumbers() } [TestMethod] + [TestCategory("Unit")] public void Clean_ShouldRemoveUrls() { // Arrange @@ -100,6 +106,7 @@ public void Clean_ShouldRemoveUrls() } [TestMethod] + [TestCategory("Unit")] public void Clean_ShouldNormalizeLineEndings() { // Arrange diff --git a/tests/Vectors/VectorStoreIntegrationTest.cs b/tests/Vectors/VectorStoreIntegrationTest.cs index fd0f455..ea7bcdd 100644 --- a/tests/Vectors/VectorStoreIntegrationTest.cs +++ b/tests/Vectors/VectorStoreIntegrationTest.cs @@ -20,6 +20,7 @@ public void VectorStoreIntegrationTestInitialize() } [TestMethod] + [TestCategory("Integration")] public async Task VectorStore_ShouldStoreAndRetrieveTextParagraphs() { // Arrange diff --git a/tests/demo/doc_image1.png b/tests/demo/doc_image1.png new file mode 100644 index 0000000..0626b2f Binary files /dev/null and b/tests/demo/doc_image1.png differ diff --git a/tests/demo/doc_image2.png b/tests/demo/doc_image2.png new file mode 100644 index 0000000..0b7147b Binary files /dev/null and b/tests/demo/doc_image2.png differ diff --git a/tests/demo/doc_image3.png b/tests/demo/doc_image3.png new file mode 100644 index 0000000..d1ec2b3 Binary files /dev/null and b/tests/demo/doc_image3.png differ diff --git a/tests/demo/page2_image1.png b/tests/demo/page2_image1.png new file mode 100644 index 0000000..7a0fba6 Binary files /dev/null and b/tests/demo/page2_image1.png differ diff --git a/tests/demo/page3_image1.png b/tests/demo/page3_image1.png new file mode 100644 index 0000000..b6a3d36 Binary files /dev/null and b/tests/demo/page3_image1.png differ diff --git a/tests/demo/page5_image1.png b/tests/demo/page5_image1.png new file mode 100644 index 0000000..58a11db Binary files /dev/null and b/tests/demo/page5_image1.png differ