From 490bf2f34a8ef2ed6a7221933c7283f2dc0c3a6c Mon Sep 17 00:00:00 2001 From: MayueCif Date: Fri, 28 Aug 2026 16:16:51 +0800 Subject: [PATCH 1/9] =?UTF-8?q?[=E5=85=A8=E5=BA=93]=20=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=20v2.4=EF=BC=9AK=E7=BA=BF=E5=9B=BE=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E5=8C=96=E3=80=81on-color=20Token=20=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E3=80=81=E5=8A=A8=E6=95=88=E6=97=B6=E9=95=BF=E6=94=B6?= =?UTF-8?q?=E6=95=9B=EF=BC=8C=E6=96=87=E6=A1=A3=E5=90=8C=E6=AD=A5=EF=BC=9B?= =?UTF-8?q?=E5=90=AB=E4=BA=A4=E6=98=93=E7=AD=96=E7=95=A5=E4=B8=8E=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E9=85=8D=E5=A5=97=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Todo.md | 49 - docs/design-prototype.html | 901 ------------------ docs/design-system.md | 230 ++++- ...\350\211\262\351\242\204\350\247\210.html" | 806 ++++------------ .../Tools/Abstractions/IStrategyTools.cs | 10 + .../Tools/Crypto/CryptoSentimentTools.cs | 5 +- .../Tools/Crypto/CryptoStrategyTools.cs | 86 ++ .../Tools/Crypto/CryptoTechnicalTools.cs | 9 +- .../Applications/Settings/MCPServerConfig.cs | 11 + .../MarketAssistant.App.Services.csproj | 1 - .../Services/ServiceCollectionExtensions.cs | 9 - .../Trading/AISignalDecision.cs | 154 +++ .../Trading/AISignalStrategyExecutor.cs | 253 ++++- .../Trading/MarketMonitor.cs | 2 + .../Trading/StrategyEngine.cs | 165 +++- .../Trading/TradingDataService.cs | 33 +- .../Trading/TradingStrategyService.cs | 3 +- .../Assets/Images/icon_ai.svg | 12 + .../Assets/Images/icon_bolt.svg | 3 + .../Assets/Images/icon_bulb.svg | 5 + .../Assets/Images/icon_chart.svg | 5 + .../Assets/Images/icon_chat.svg | 3 + .../Assets/Images/icon_clipboard.svg | 4 + .../Assets/Images/icon_danger.svg | 5 + .../Assets/Images/icon_document.svg | 7 + .../Assets/Images/icon_edit.svg | 4 + .../Assets/Images/icon_grid.svg | 6 + .../Assets/Images/icon_magnifier.svg | 4 + .../Assets/Images/icon_money.svg | 4 + .../Assets/Images/icon_refresh.svg | 5 + .../Assets/Images/icon_rocket.svg | 6 + .../Assets/Images/icon_scale.svg | 7 + .../Assets/Images/icon_shield.svg | 3 + .../Assets/Images/icon_users.svg | 6 + .../Assets/Images/icon_warning.svg | 5 + .../Assets/Raw/kline_chart.html | 95 +- .../Converters/PriceChangeColorConverter.cs | 65 +- src/MarketAssistant.App/Converters/README.md | 16 +- .../Parsers/BaseAdaptiveCardParser.cs | 0 .../Parsers/CoordinatorCardParser.cs | 0 .../Parsers/FinancialCardParser.cs | 0 .../Parsers/FundamentalCardParser.cs | 0 .../Parsers/IAdaptiveCardParser.cs | 0 .../AdaptiveCards/Parsers/NewsCardParser.cs | 0 .../Parsers/SentimentCardParser.cs | 0 .../Parsers/TechnicalCardParser.cs | 0 .../Resources/Styles/ButtonStyles.axaml | 9 +- .../Resources/Styles/CardStyles.axaml | 20 +- .../Resources/Styles/Colors.axaml | 167 +--- .../Resources/Styles/FormStyles.axaml | 2 +- .../Resources/Styles/SkeletonStyles.axaml | 2 +- .../Resources/Styles/Spacing.axaml | 11 +- .../Resources/Styles/TextStyles.axaml | 16 - .../Services/ServiceCollectionExtensions.cs | 11 +- .../ViewModels/MCPConfigPageViewModel.cs | 42 +- .../Trading/StrategyConfigViewModel.cs | 410 +++++--- .../Components/AdaptiveCardView.axaml.cs | 10 +- .../Views/Components/AnalysisReportView.axaml | 58 +- .../Views/Components/ChatSidebarView.axaml | 18 +- .../Views/Components/KLineChartView.cs | 50 +- .../Components/ProgressDisplayView.axaml | 25 +- .../Views/Pages/AboutPageView.axaml | 2 +- .../Views/Pages/AgentAnalysisPageView.axaml | 29 +- .../Views/Pages/AssetPageView.axaml | 17 +- .../Views/Pages/AssetSelectionPageView.axaml | 8 +- .../Views/Pages/FavoritesPageView.axaml | 4 +- .../Views/Pages/HomePageView.axaml | 2 +- .../Views/Pages/PriceAlertPageView.axaml | 6 +- .../Pages/Trading/ApiKeyConfigView.axaml | 17 +- .../Pages/Trading/StrategyConfigView.axaml | 465 +++++---- .../Views/Pages/Trading/TradingPageView.axaml | 2 +- .../Views/Windows/NotificationWindow.axaml | 4 +- src/MarketAssistant.Trading/RiskProfile.cs | 51 + .../ScenarioPresets.cs | 158 +++ src/MarketAssistant.Trading/StrategyParams.cs | 25 + tests/Application/AssetInfoServiceTest.cs | 57 +- tests/Application/KLineServiceTest.cs | 10 +- tests/Infrastructure/DiagnosticsTest.cs | 18 +- .../SecureSettingsMigrationTest.cs | 52 + tests/StructuredOutputTest.cs | 8 +- tests/Tools/BasicDataToolsTest.cs | 134 +-- tests/Tools/CryptoMetricsToolsTest.cs | 2 + tests/Tools/FinancialDataToolsTest.cs | 4 + tests/Tools/SentimentToolsTest.cs | 6 + tests/Tools/TechnicalDataToolsTest.cs | 6 + tests/Trading/AISignalDecisionParserTest.cs | 119 +++ tests/Trading/StrategyEngineTriggerTest.cs | 293 ++++++ .../RagIngestionServiceIntegrationTest.cs | 8 +- .../RetrievalOrchestratorIntegrationTest.cs | 8 +- 89 files changed, 2798 insertions(+), 2565 deletions(-) delete mode 100644 docs/Todo.md delete mode 100644 docs/design-prototype.html create mode 100644 src/MarketAssistant.App.Services/Trading/AISignalDecision.cs create mode 100644 src/MarketAssistant.App/Assets/Images/icon_ai.svg create mode 100644 src/MarketAssistant.App/Assets/Images/icon_bolt.svg create mode 100644 src/MarketAssistant.App/Assets/Images/icon_bulb.svg create mode 100644 src/MarketAssistant.App/Assets/Images/icon_chart.svg create mode 100644 src/MarketAssistant.App/Assets/Images/icon_chat.svg create mode 100644 src/MarketAssistant.App/Assets/Images/icon_clipboard.svg create mode 100644 src/MarketAssistant.App/Assets/Images/icon_danger.svg create mode 100644 src/MarketAssistant.App/Assets/Images/icon_document.svg create mode 100644 src/MarketAssistant.App/Assets/Images/icon_edit.svg create mode 100644 src/MarketAssistant.App/Assets/Images/icon_grid.svg create mode 100644 src/MarketAssistant.App/Assets/Images/icon_magnifier.svg create mode 100644 src/MarketAssistant.App/Assets/Images/icon_money.svg create mode 100644 src/MarketAssistant.App/Assets/Images/icon_refresh.svg create mode 100644 src/MarketAssistant.App/Assets/Images/icon_rocket.svg create mode 100644 src/MarketAssistant.App/Assets/Images/icon_scale.svg create mode 100644 src/MarketAssistant.App/Assets/Images/icon_shield.svg create mode 100644 src/MarketAssistant.App/Assets/Images/icon_users.svg create mode 100644 src/MarketAssistant.App/Assets/Images/icon_warning.svg rename src/{MarketAssistant.App.Services => MarketAssistant.App}/Infrastructure/AdaptiveCards/Parsers/BaseAdaptiveCardParser.cs (100%) rename src/{MarketAssistant.App.Services => MarketAssistant.App}/Infrastructure/AdaptiveCards/Parsers/CoordinatorCardParser.cs (100%) rename src/{MarketAssistant.App.Services => MarketAssistant.App}/Infrastructure/AdaptiveCards/Parsers/FinancialCardParser.cs (100%) rename src/{MarketAssistant.App.Services => MarketAssistant.App}/Infrastructure/AdaptiveCards/Parsers/FundamentalCardParser.cs (100%) rename src/{MarketAssistant.App.Services => MarketAssistant.App}/Infrastructure/AdaptiveCards/Parsers/IAdaptiveCardParser.cs (100%) rename src/{MarketAssistant.App.Services => MarketAssistant.App}/Infrastructure/AdaptiveCards/Parsers/NewsCardParser.cs (100%) rename src/{MarketAssistant.App.Services => MarketAssistant.App}/Infrastructure/AdaptiveCards/Parsers/SentimentCardParser.cs (100%) rename src/{MarketAssistant.App.Services => MarketAssistant.App}/Infrastructure/AdaptiveCards/Parsers/TechnicalCardParser.cs (100%) create mode 100644 src/MarketAssistant.Trading/RiskProfile.cs create mode 100644 src/MarketAssistant.Trading/ScenarioPresets.cs create mode 100644 tests/Trading/AISignalDecisionParserTest.cs create mode 100644 tests/Trading/StrategyEngineTriggerTest.cs diff --git a/docs/Todo.md b/docs/Todo.md deleted file mode 100644 index ce85532..0000000 --- a/docs/Todo.md +++ /dev/null @@ -1,49 +0,0 @@ -# TODO - -> 历史文档《P0-P1重构实施计划.md》与《升级重构核查报告.md》已归档删除,其未完成事项已合并至本清单。 - -## 高优先级 - -- **分析师产物存储生命周期管理**:`FileAnalystArtifactStore` 只有 Save/Get,每次分析在 `%APPDATA%/MarketAssistant/analyst-artifacts/{runId}/` 留一份产物,磁盘无限增长。需增加按 Run 清理策略(保留 N 天或归档联动删除)。 -- **runId 注入方式加固**:当前 runId 依赖摘要文本让 LLM「抄写」到 `get_analyst_artifact` 工具参数,模型抄错即读不到产物;应通过工具上下文自动注入当前 runId。 -- **E2E 验证降级路径**:聚合器「有界宽限(默认 30s)+ 降级发送」机制仅有确定性单测覆盖,需真实环境跑一次 E2E 确认降级报告可用,并按实际分析师耗时校准宽限期。 -- **MAF Fan-In barrier 根因跟踪**:1.18 / 1.19 均未修复消息时序丢失缺陷,持续关注 release notes;修复后可移除聚合器宽限兜底与测试重试循环。 - -## Agent 与 Prompt - -- `xml prompt` 方案评估与落地。 -- 精细化任务指导:让主 Agent 输出更明确的目标、边界和结果格式。 -- 将阶段性工作总结沉淀到外部记忆,并以轻量上下文继续后续任务。 - -## MCP 工具约束 - -- ~~空白名单默认不暴露任何工具;`AllowAllTools` 显式放行全部~~(已落地)。 -- 待办:MCP 配置页列出服务器工具供逐个勾选(当前需手动编辑 `AllowedTools`);旧配置迁移提示"请重新勾选 MCP 工具"。 -- 评估按服务器或业务域拆分插件命名空间,例如 `mcp.` 或 `mcp.search.`。 -- 继续完善函数调用范围限制与提示词级工具使用策略。 - -## RAG 与文档链路 - -- 继续收敛文档摄取链,避免 `PDF/DOCX -> Markdown -> 再解析 Markdown` 的双重解析路径持续扩散(DOCX 试点)。 -- 摄取部分失败的 UI 三态汇总已实现,可补充端到端验收场景。 -- SK → MAF 迁移收尾:RAG 向量存储(SqliteVec)、Web 搜索、文本分块仍依赖 SemanticKernel 包,待 MAF 提供稳定替代后迁移。 - -## 架构与交易 - -- **App.Services 继续拆分**:第一步已将模型接入基础设施拆出为 `src/MarketAssistant.Infrastructure`;下一步可迁移 `Infrastructure/Factories` 中剩余的工厂类与 AdaptiveCards 解析层。 -- **测试基线治理收尾**:分类历史遗留的集成/E2E 失败用例;确认 CI 在 PR 上自动触发(当前仅手动 workflow_dispatch)。 - -## 已完成(近期落地项) - -- MAF 1.13 → 1.17 → **1.19.0** 升级(无破坏性影响)。 -- ~~聚合器有界宽限 + 降级发送、产物落盘全异步化~~(P0-1 缓解收尾)。 -- ~~分析师产物引用传递 + 协调器只读工具~~(P1-07)。 -- ~~检索距离方向~~、~~摄取结果三态与维度校验~~、~~A 股 HTTP 下沉 DataProviders~~、~~MCP 空白名单默认不暴露~~ 等 P0/P1 正确性重构项。 -- ~~IExchangeClientFactory 抽象落地,组合根移除手动构建 Binance 对象~~(P1-5)。 -- ~~测试标签治理:非标 Agent 标签改 Integration、9 个无标签测试补 Unit/Integration——Unit 过滤器现覆盖全部确定性测试~~(P1-7)。 -- ~~CI 默认只跑 Unit(unit.runsettings + TestCaseFilter)~~。 -- ~~RAG 摄取失败三态透出 UI~~(复核确认已有实现)。 - -## 多 Agent 协作 - -- ~~子 Agent 结果写入文件系统并传回轻量引用~~(已通过 `IAnalystArtifactStore` + 摘要传递 + `get_analyst_artifact` 工具落地;生命周期管理与 runId 注入两项收尾见高优先级清单)。 diff --git a/docs/design-prototype.html b/docs/design-prototype.html deleted file mode 100644 index 9e39273..0000000 --- a/docs/design-prototype.html +++ /dev/null @@ -1,901 +0,0 @@ - - - - -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 index 46ca742..d2daa20 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -1,10 +1,40 @@ # MarketAssistant UI 设计系统 -> **版本**: 2.1 +> **版本**: 2.4 > **平台**: Avalonia 11.x 跨平台桌面应用 > **产品**: 金融市场数据终端 (A股 + 虚拟币) > **设计方向**: 清晰专业风 — Bloomberg Terminal 的严谨 × 现代 SaaS 的优雅 > +> **v2.4 变更(K线图主题化 + on-color 收敛轮,代码与文档同步)**: +> 1. K 线图主题化落地:`kline_chart.html` 新增 `setTheme('dark'|'light')` 实现(此前注释声称"由 setTheme 动态切换"但函数从未实现,深色主题下 K 线区永久白底);`KLineChartView` 按 `ActualThemeVariant` 在导航完成后注入并监听 `ActualThemeVariantChanged` 联动;深色 palette 对齐设计系统(`#111722/#1E2636/#8894A8/#5A6680`,涨跌色两主题一致),兜底 HTML 与 echarts loading 配色同步修正(原硬编码 `#4d90fe/#000/白遮罩/#e74c3c`); +> 2. 新增 on-color 语义 Token `TextOnEmphasisBrush`(`text-on-emphasis`,#FFFFFF 明暗同值):全部视图/样式约 22 处 `Foreground="White"` 硬编码收敛为 Token 引用(含 `ButtonStyles` 的 btn-primary/btn-info 定义); +> 3. 动效时长越阶梯值收敛:`CardStyles` 8 处 0.2s、`HomePageView` 0.12s → 150ms;`SkeletonStyles` 0.8s → 600ms。注:Transition.Duration 为 TimeSpan 类型,Avalonia XAML 不支持 TimeSpan 资源 Token,Duration 以阶梯字面量维护(`Spacing.axaml` 有注记); +> 4. `TextStyles.axaml` 兼容区清理:删除无消费方的 `featureTitle` 与重复定义的 `feature-title`(保留 `CardStyles.axaml` 中被 AboutPageView 使用的同名样式); +> 5. 本文档补齐此前未登记的颜色 Token(明暗交互态、`ItemBackgroundBrush`、`ModeCardSelectedTextBrush`、`DangerBackgroundBrush`、`text-on-emphasis` 等),并新增「K 线图配色」小节; +> 6. `docs/配色预览.html` 修正页面文本色 `#E8ECF1` → `#E4EAF5`(原与规范不一致),新增 K 线图主题预览区,版本升至 v2.4。 +> +> **v2.3 变更(偏差清理落地轮,代码与文档同步收敛)**: +> 1. 功能语义色收敛完成:`Success/Warning/Error/Info` 及 `*Hover/*Pressed` 改为 Tailwind 系目标值,与涨跌色的同值语义混叠已消除(第二章注记更新); +> 2. 遗留脏色清除:`#dc3545`(`RequiredFieldColor` / `DialogDangerBackground`)与 `#007bff`(`PrimaryButtonColor`)及其画刷整体删除(均无消费方); +> 3. 补齐 `BullishTagBackgroundBrush` / `BearishTagBackgroundBrush`(约 12% 透明度标签背景);暗/亮 `SelectedBackgroundBrush` 统一为品牌蓝 15% 填充; +> 4. `PrimaryDark` 二选一裁决:采纳规范值 `#0D47A1`; +> 5. 亮色主题文本色对齐 `#1A1D26 / #6B7280 / #9CA3AF`;通知窗口专用色迁入蓝调深色体系(`#161C2A / #1E2636`); +> 6. 6 个视图中的 emoji 全部替换为 feather 风格 SVG 线性图标(新增 `icon_*.svg` 资源,语义配色); +> 7. 修复 `PriceChangeColorConverter` 硬编码 Flat UI 色(`#e74c3c/#2ecc71/#6c757d`)→ 改为读取设计系统资源并带兜底;支持 `ConverterParameter=tag` 返回 12% 背景画刷; +> 8. 合并重复语义色组:`*Light/*Background/*Border/*Text` 硬编码组整体删除,消费方(ButtonStyles / AdaptiveCardView / ApiKeyConfigView / AssetSelectionPageView)全部改指主题感知 `*Panel*` 组; +> 9. 补齐 `BorderActiveBrush`(暗 `#42A5F5` / 亮 `#1976D2`)与 `DarkOverlayBrush` 遮罩 Token,聚焦边框统一改用 `BorderActiveBrush`; +> 10. 字号/圆角阶梯收敛完成:15/20/24 → 14/18/22,圆角 10/16 并入 8/12(`LargeCornerRadius` Token 删除); +> 11. 阴影 Token 收敛:`ChatSidebarView` 本地 4 个 BoxShadow 与 `AgentAnalysisPageView` 侧栏阴影/硬编码遮罩全部迁至全局 Token(`SidebarBoxShadow` / `InputAreaBoxShadow` / `UserBubbleBoxShadow` 新增); +> 12. 涨跌标签 12% 背景落地首个渲染点(收藏页涨跌幅标签:色块背景 + 同色文字,替代实色底白字); +> 13. `docs/配色预览.html` 重写为 v2.3 单一方案预览页,不再保留旧方案对比内容。 +> +> **v2.2 变更(实施现状盘点,与 `Colors.axaml` / `Spacing.axaml` 实际值对齐)**: +> 1. 新增「十一、实施现状与偏差清单」,逐项登记代码实现与本文档规范的差异及待办; +> 2. 布局尺寸与动效时长改为记录实际实现值(导航栏 72px、动效 150/300/600ms、最小窗口 800px); +> 3. 字号与圆角阶梯补充实际存在但超出阶梯的值(15/20/24、10/16),并标注收敛方向; +> 4. 消除 v2.1 头部与第八章响应式断点的矛盾:统一为两档 ≥1100px / <1100px(第八章已重写); +> 5. 功能语义色登记"双套并存"问题(Material 系 vs Tailwind 系),明确收敛策略。 +> > **v2.1 变更(与 design-prototype.html v5 的裁决)**: > 1. 深色背景以本文档色值为准(`bg-root #0A0E17` / `bg-surface #111722` / `bg-elevated #161C2A`),原型中更深的 `#080C14/#0E1320/#141B2A` 弃用; > 2. 导航活跃指示条采用**左侧 3px 竖条**,不采用原型的底部横条;选中项背景使用 `bg-selected` 浅色填充而非整块品牌蓝; @@ -63,6 +93,13 @@ MarketAssistant 是一款面向专业投资者的金融数据终端。界面设 | `text-primary` | `#E4EAF5` | 正文、标题 | | `text-secondary` | `#8894A8` | 辅助说明、标签 | | `text-tertiary` | `#5A6680` | 禁用态、占位符、页码 | +| `text-on-emphasis` | `#FFFFFF` | 品牌蓝/功能色实底上的前景文字(`TextOnEmphasisBrush`,明暗同值,v2.4) | +| `bg-hover` | `#161C2A` | 列表/按钮悬停背景(`HoverBackgroundBrush`,与 bg-elevated 同值) | +| `bg-pressed` | `#1B2233` | 按下态背景(`PressedBackgroundBrush`) | +| `bg-danger-muted` | `#2D1B1B` | 危险提示容器背景(`DangerBackgroundBrush`) | +| `text-active` | `#42A5F5` | 模式卡片选中文字(`ModeCardSelectedTextBrush`) | + +> **v2.4 补记**:`ItemBackgroundBrush`(列表项背景)与 `bg-surface` 同值(暗 `#111722` / 亮 `#FFFFFF`);`ShadowBrush`(暗 `#80000000` / 亮 `#40000000`)仅作阴影兜底,实际阴影走 BoxShadows Token。 ### 亮色主题 @@ -77,6 +114,11 @@ MarketAssistant 是一款面向专业投资者的金融数据终端。界面设 | `text-primary` | `#1A1D26` | 正文、标题 | | `text-secondary` | `#6B7280` | 辅助说明 | | `text-tertiary` | `#9CA3AF` | 禁用态 | +| `text-on-emphasis` | `#FFFFFF` | 品牌蓝/功能色实底上的前景文字(`TextOnEmphasisBrush`,明暗同值,v2.4) | +| `bg-hover` | `#F5F5F5` | 悬停背景(`HoverBackgroundBrush`) | +| `bg-pressed` | `#EEEEEE` | 按下态背景(`PressedBackgroundBrush`) | +| `bg-danger-muted` | `#FEF2F2` | 危险提示容器背景(`DangerBackgroundBrush`) | +| `text-active` | `#0D47A1` | 模式卡片选中文字(`ModeCardSelectedTextBrush`) | ### 市场语义色 (中国市场标准: 红涨绿跌) @@ -94,6 +136,8 @@ MarketAssistant 是一款面向专业投资者的金融数据终端。界面设 | `color-danger` | `#EF4444` | 错误、危险操作 | | `color-info` | `#3B82F6` | 信息提示 | +> **✅ 实施现状(v2.3 已收敛)**:`Colors.axaml` 中 `Success/Warning/Error/Info` 已统一为上表 Tailwind 系取值,交互状态色(`*Hover/*Pressed`)同步重算为对应明暗档;与 `BullishRed/BearishGreen` 的同值语义混叠已消除。剩余待办见第十章 P2+ 第 13 条(亮色硬编码组与 `*Panel*` 组合并)。 + ### 状态背景色 (深色) | Token | 色值 | 用途 | @@ -102,6 +146,23 @@ MarketAssistant 是一款面向专业投资者的金融数据终端。界面设 | `bg-bearish` | `rgba(76,175,80,0.12)` | 下跌标签背景 | | `bg-selected` | `rgba(25,118,210,0.15)` | 选中行/项背景 | +### K 线图配色(✅ v2.4 已落地) + +K 线图为内嵌 WebView 页面(`Assets/Raw/kline_chart.html`,klinecharts 9.x 渲染),由 C# 宿主(`KLineChartView`)在导航完成后按 `ActualThemeVariant` 注入 `setTheme('dark' | 'light')`,并监听 `ActualThemeVariantChanged` 联动切换: + +| 元素 | 浅色 | 深色 | 说明 | +|------|------|------|------| +| 图表背景 | `#FFFFFF` | `#111722`(bg-surface) | CSS 变量 `--chart-bg`,loading/error 浮层同步 | +| 网格线/分隔线/坐标轴线 | `#E6E9EF` / `#D0D5DD` | `#1E2636`(border-default) | | +| 轴文本/最高最低标注 | `#667085` | `#8894A8`(text-secondary) | | +| 十字线 | `#98A2B3` | `#5A6680`(text-tertiary) | 标签底色 `#1976D2` / `#42A5F5` + 白字(text-on-emphasis) | +| 涨跌色 | `#F44336` / `#4CAF50` | 同浅色 | 红涨绿跌,两主题一致 | +| 平盘 | `#98A2B3` | `#5A6680` | | +| 加载指示 | `#1976D2`(brand-blue) | `#42A5F5`(brand-blue-light) | spinner / 兜底页 echarts loading | +| 错误色 | `#EF4444`(color-danger) | 同浅色 | 原 Flat UI `#e74c3c` 已替换 | + +> 兜底 HTML(资产加载失败时的 echarts 页面)配色同步对齐:深底 `#0A0E17`、文本 `#8894A8`、loading 品牌蓝 `#1976D2`、遮罩 `rgba(10,14,23,0.8)`。 + --- ## 三、排版系统 @@ -128,6 +189,8 @@ MarketAssistant 是一款面向专业投资者的金融数据终端。界面设 | `text-2xl` | 22px | 大标题 | | `text-3xl` | 28px | 核心指标(价格、涨跌幅) | +> **实施现状(v2.3 已收敛)**:`Spacing.axaml` 实际阶梯为 11/12/13/14/16/18/22/28,与上表一致。原越界值已并入标准档:`MediumFontSize` 15→14、`LargeFontSize` 20→18、`TitleFontSize` 24→22(h2 同步映射到 22px)。 + ### 行高 | 上下文 | 行高 | @@ -168,11 +231,10 @@ MarketAssistant 是一款面向专业投资者的金融数据终端。界面设 | Token | 值 | 用途 | |-------|-----|------| -| 侧边栏 (折叠) | 60px | 图标导航栏宽度 | -| 侧边栏 (展开) | 280px | 带文字导航宽度 | +| 侧边导航栏 | 72px | 图标导航栏宽度(`NavRailWidth`,实际实现值) | | 右侧面板 | 320px | 详情/盘口/技术指标 | -| 最小窗口宽度 | 900px | 低于此宽度隐藏右侧面板 | -| 最小窗口高度 | 640px | 低于此高度压缩图表区域 | +| 最小窗口宽度 | 800px | 低于此宽度压缩布局(`MainWindowMinWidth`,实际实现值) | +| 最小窗口高度 | 600px | 低于此高度压缩图表区域(`MainWindowMinHeight`) | --- @@ -188,13 +250,17 @@ MarketAssistant 是一款面向专业投资者的金融数据终端。界面设 | `radius-xl` | 12px | 大型面板、图表容器 | | `radius-full` | 999px | 药丸形状、Badge | +> **实施现状(v2.3 已收敛)**:圆角 10px(`MediumCornerRadius`)并入 8px、16px(`LargeCornerRadius`)并入 12px(Token 已删除,消费方改指 `DefaultCornerRadius`)。实际阶梯为 4/6/8/12/28(FAB),与上表一致。 + ### 阴影 (仅亮色主题使用) -| 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)` | 对话框、模态窗口 | +| Token | 实现键 | 效果 | 用途 | +|-------|--------|------|------| +| `shadow-card` | `CardBoxShadow` | `0 2 8 0 #15000000` | 默认卡片 | +| `shadow-subtle` / `shadow-small` | `SubtleBoxShadow` / `SmallBoxShadow` | `0 2 8 0 #10000000` / `0 1 3 0 #20000000` | 气泡、头像等轻量元素 | +| `shadow-elevated` | `ElevatedBoxShadow` | `0 8 32 0 #30000000` | 悬浮卡片、下拉菜单 | +| `shadow-overlay` | `NotificationBoxShadow` | `0 4 16 0 #60000000` | 通知窗口 | +| —(聊天/浮层专用) | `SidebarBoxShadow` / `InputAreaBoxShadow` / `UserBubbleBoxShadow` | 方向性阴影 | 侧栏、输入区、用户气泡 | > 深色主题中,阴影效果不明显。通过 `border` 和背景色差 (`bg-elevated`) 来区分层级。 @@ -205,8 +271,10 @@ MarketAssistant 是一款面向专业投资者的金融数据终端。界面设 | Token | 时长 | 缓动 | 用途 | |-------|------|------|------| | `transition-fast` | 150ms | `ease` | 悬停色变、选中态切换 | -| `transition-normal` | 250ms | `ease` | 面板展开/折叠、页面切换 | -| `transition-slow` | 400ms | `ease-out` | 模态窗口进出 | +| `transition-normal` | 300ms | `ease` | 面板展开/折叠、页面切换(`MediumAnimationSeconds`,实际实现值) | +| `transition-slow` | 600ms | `ease-out` | 模态窗口进出(`SlowAnimationSeconds`,实际实现值) | + +> **实施现状(v2.4 已收敛)**:全部 `Transition.Duration` 字面量收敛至阶梯值——原 `CardStyles` 8 处 0.2s、`HomePageView` 0.12s 归入 150ms(transition-fast),`SkeletonStyles` 0.8s 归入 600ms。因 Avalonia XAML 不支持 TimeSpan 资源 Token,Duration 以阶梯字面量维护(`Spacing.axaml` 内有注记说明)。 ### 动效原则 @@ -223,7 +291,7 @@ MarketAssistant 是一款面向专业投资者的金融数据终端。界面设 | 变体 | 用途 | 样式 | |------|------|------| -| **Primary** | 主要操作(搜索、确认) | `bg-brand-blue`, 白色文字 | +| **Primary** | 主要操作(搜索、确认) | `bg-brand-blue`, `text-on-emphasis` 文字 | | **Secondary** | 次要操作(取消、返回) | `bg-elevated`, 边框, 主色文字 | | **Ghost** | 低优先级操作 | 透明背景, 悬停显示背景 | | **Danger** | 危险操作(删除、清空) | `bg-danger/0.12`, 红色文字 | @@ -299,7 +367,7 @@ MarketAssistant 是一款面向专业投资者的金融数据终端。界面设 ┌──────┬──────────────────────────────────────────┐ │ Icon │ 页面内容 │ │ Nav │ │ -│ 60px │ │ +│ 72px │ │ │ │ │ │ 🏠 │ │ │ ⭐ │ │ @@ -310,10 +378,10 @@ MarketAssistant 是一款面向专业投资者的金融数据终端。界面设 └──────┴──────────────────────────────────────────┘ ``` -- 导航栏宽度: 60px (仅图标) -- 每个导航项: 44x44px 可点击区域 +- 导航栏宽度: 72px(`NavRailWidth`,仅图标,实际实现值) +- 每个导航项: 44x44px 最小可点击区域(当前实现项宽 56 / 高 48,可进一步收敛) - 活跃态: 左侧 3px 蓝色指示条 + 蓝色图标色 -- 图标: 22px SVG +- 图标: 22px SVG(当前实现为 24px `DefaultIconSize`) --- @@ -321,12 +389,14 @@ MarketAssistant 是一款面向专业投资者的金融数据终端。界面设 ### 8.1 首页 (Dashboard) +> v2.1 裁决:首页不放置 K 线区,采用 Bento Grid(热门标的置左最宽 + 快讯 + 最近查看);K 线保留在资产详情页。下图保留原始三栏结构示意,仅作历史参考。 + ``` ┌──────┬──────────────────────────────────┬──────────┐ │ │ TopBar (市场行情条) │ │ │ ├──────────────────────────────────┤ │ │ Nav │ Chart Section (K线图 + 指标) │ Detail │ -│ 60px │ │ Panel │ +│ 72px │ │ Panel │ │ ├──────────────────────────────────┤ 320px │ │ │ Metrics Grid (4列指标卡片) │ │ │ ├──────────────────┬───────────────┤ │ @@ -338,25 +408,34 @@ MarketAssistant 是一款面向专业投资者的金融数据终端。界面设 ### 8.2 响应式断点 +> v2.2 起与 v2.1 裁决统一为**两档**,废弃早期三档方案: + | 断点 | 宽度 | 布局变化 | |------|------|---------| -| Large | >= 1200px | 完整三栏 (导航 + 内容 + 详情面板) | -| Medium | 900-1199px | 两栏 (导航 + 内容,隐藏详情面板) | -| Small | < 900px | 单栏 (导航 + 内容,指标卡片 2 列) | +| Full | >= 1100px | 完整布局(导航 + 内容 + 详情面板) | +| Compact | < 1100px | 单列布局,隐藏详情面板 | + +- 主窗口最小宽度 800px(`MainWindowMinWidth`),Compact 档下指标卡片降为 2 列。 --- ## 九、图标系统 -### 替换 Emoji 方案 +### 替换 Emoji 方案(✅ v2.3 已落地) -| 当前 Emoji | 替换为 | SVG 图标描述 | -|-----------|--------|------------| -| 🤖 | AI 分析图标 | 芯片/神经网络节点 | -| 🔄 | 刷新图标 | 循环箭头 | -| ⚠️ | 警告图标 | 三角感叹号 | -| ⭐ | 收藏图标 | 五角星 | -| 💬 | 聊天图标 | 对话气泡 | +6 个视图中的 emoji 已全部替换为 feather 风格 SVG 线性图标(24x24、stroke-width 2、语义配色),新增资源位于 `Assets/Images/`: + +| 图标资源 | 语义 | 配色 | 替换位置 | +|----------|------|------|----------| +| `icon_ai.svg` | AI/芯片 | 品牌蓝 #42A5F5 | ProgressDisplayView、StrategyConfigView、AssetPageView | +| `icon_chart.svg` / `icon_clipboard.svg` | 评分/清单 | 品牌蓝 | AnalysisReportView 标题 | +| `icon_bulb.svg` / `icon_warning.svg` | 提示/警告 | 琥珀 #F59E0B | AnalysisReportView、ProgressDisplayView、AgentAnalysisPageView | +| `icon_danger.svg` | 危险警告 | 红 #EF4444 | AnalysisReportView(风险区块)、ApiKeyConfigView、AssetPageView | +| `icon_users.svg` / `icon_scale.svg` / `icon_edit.svg` / `icon_magnifier.svg` | 共识/分歧/综述/质量 | 蓝 / 琥珀 / 蓝 / 蓝 | AnalysisReportView 分析区块 | +| `icon_money.svg` / `icon_grid.svg` / `icon_shield.svg` / `icon_rocket.svg` / `icon_bolt.svg` | 定投/网格/保守/进取/快捷 | 绿 / 蓝 / 绿 / 橙 / 橙 | StrategyConfigView | +| `icon_refresh.svg` / `icon_document.svg` / `icon_chat.svg` | 刷新/导出/对话 | 蓝 / 蓝 / 白 | AssetPageView、AgentAnalysisPageView(FAB) | + +> 说明:`✓` / `✕` 为普通字形(对勾/叉),非 emoji,予以保留。涨跌标签若需 12% 背景色块,直接引用 `BullishTagBackgroundBrush` / `BearishTagBackgroundBrush`。 ### 图标规格 @@ -367,32 +446,76 @@ MarketAssistant 是一款面向专业投资者的金融数据终端。界面设 --- -## 十、迁移路线图 +## 十、迁移路线图(v2.2 状态盘点) ### P0 — 立即执行 (阻塞项) -1. **建立排版系统**: 完善 `TextStyles.axaml`,定义 H1-H6 / Body / Caption / Label / Monospace 样式类 -2. **替换 Emoji 图标**: 用专业 SVG 图标替换所有 emoji 使用 -3. **统一按钮变体**: 完善 `ButtonStyles.axaml`,增加 Primary / Secondary / Ghost / Danger / Icon 变体 +1. ✅ **建立排版系统**: `TextStyles.axaml` 已定义 h1-h6 / body / caption / label / overline / data / 语义文本样式类(v2.4:旧样式兼容区已清理,无消费方的 `featureTitle` 删除、`feature-title` 统一收敛至 `CardStyles.axaml`) +2. ✅ **替换 Emoji 图标**: v2.3 已完成。6 个视图中的 emoji 已全部替换为 SVG 线性图标(清单见第九章);`✓/✕` 为普通字形予以保留 +3. ✅ **统一按钮变体**: `ButtonStyles.axaml` 已提供 Primary / Secondary / Ghost / Danger / 图标按钮变体 ### P1 — 本迭代完成 -4. **统一状态组件**: 创建 EmptyState / ErrorState / LoadingSkeleton 复用组件 -5. **完善表单样式**: 输入框、下拉框、复选框统一样式 -6. **完善列表样式**: 列表项选中/悬停/禁用态 -7. **建立数据表格样式**: 金融数据表格专属样式 +4. ✅ **统一状态组件**: EmptyState / LoadingSkeleton(`SkeletonStyles.axaml`)已建立,ErrorState 复用程度待检查 +5. ✅ **完善表单样式**: `FormStyles.axaml` 已统一样式 +6. ✅ **完善列表样式**: `ListStyles.axaml` 已含选中/悬停/禁用态 +7. ✅ **建立数据表格样式**: 行情类列表已覆盖;交易表格(`TradeHistoryView` 等)已核查——35 处设计 Token 引用、0 硬编码色,完全对齐 ### P2 — 下个迭代 -8. **Badge/Tag/Chip 组件**: 创建可复用的标签组件 -9. **Toast 通知组件**: 嵌入式 Toast 替代独立 Window 通知 -10. **响应式布局**: 根据窗口大小自适应隐藏/显示面板 +8. ✅ **Badge/Tag/Chip 组件**: 基础 Tag 样式已存在;涨跌标签背景画刷(v2.1 裁决第 4 条)已注册并落地渲染点(v2.3:收藏页涨跌幅标签改用 `ConverterParameter=tag` 12% 色块 + 同色文字,其余纯文本行情数字维持文字色方案) +9. ✅ **Toast 通知组件**: 按"或"裁决选择蓝调深色体系方案——`NotificationWindow` 专用色已迁入蓝调体系(`NotificationDark*` → `#161C2A/#1E2636/#20293D`),视觉与设计系统一致;嵌入式 Toast 形态降级为 P3 增强候选项 +10. ✅ **响应式布局**: 两档断点(≥1100 完整布局 / <1100 单列隐藏详情面板)已实现,市场切换器随窗口自适应隐藏(8.2 节裁决即当前实现) + +### P2+ — v2.2 登记(v2.3 落地状态) + +11. ✅ **统一功能语义色双套并存**: `Success/Warning/Error/Info` 及 `*Hover/*Pressed` 已收敛至 Tailwind 系目标值,与 `BullishRed/BearishGreen` 的同值语义混叠已消除 +12. ✅ **清理遗留脏色**: `RequiredFieldColor` / `DialogDangerBackground`(`#dc3545`)与 `PrimaryButtonColor`(`#007bff`)确认无消费方,已整体删除 +13. ✅ **合并重复语义色组**: `WarningLight/InfoLight/SuccessLight/DangerLight` 等亮色硬编码组已整体删除(v2.3),消费方(`ButtonStyles` / `AdaptiveCardView` / `ApiKeyConfigView` / `AssetSelectionPageView`)全部改指主题感知的 `*Panel*` 组,暗色主题不再刺眼 +14. ✅ **补齐缺失 Token**: `bg-selected`(暗/亮品牌蓝 15%)、涨跌标签背景画刷(含 `NeutralTagBackgroundBrush`)、`BorderActiveBrush`(暗 `#42A5F5` / 亮 `#1976D2`,`TextBox:focus` 与卡片 `:focus` 边框已接入)、遮罩 `DarkOverlayBrush`(`AgentAnalysisPageView` 已接入)全部补齐 +15. ✅ **对齐亮色主题文本色**: `TextPrimary/Secondary/Disabled` 已对齐 `#1A1D26 / #6B7280 / #9CA3AF` +16. ✅ **主色按下态对齐**: 裁决采纳规范值,`PrimaryDark` 已定为 `#0D47A1` +17. ✅ **收敛字号/圆角阶梯**: 字号 15/20/24 → 14/18/22、圆角 10/16 → 8/12 已完成(`LargeCornerRadius` Token 删除,详见第三、五章注记) +18. ✅ **视图内散落样式收敛**: `ChatSidebarView` 本地 4 个 BoxShadow 与 `AgentAnalysisPageView` 侧栏阴影/硬编码遮罩已全部迁至全局 Token(新增 `SidebarBoxShadow` / `InputAreaBoxShadow` / `UserBubbleBoxShadow`,遮罩改用 `DarkOverlayBrush`) +19. ✅ **修复涨跌转换器硬编码色**(v2.3 新增): `PriceChangeColorConverter` 原硬编码 Flat UI 色(`#e74c3c/#2ecc71/#6c757d`),已改为读取 `BullishBrush/BearishBrush/NeutralBrush` 资源并带兜底常量 +20. ✅ **K 线图主题化**(v2.4 新增): `kline_chart.html` 实现 `setTheme('dark'|'light')`(原注释声称可切换但函数从未实现,深色主题下 K 线区永久白底),宿主 `KLineChartView` 按 `ActualThemeVariant` 注入并监听联动;深色 palette 与兜底 HTML 配色(含 echarts loading 原 `#4d90fe/#000`)对齐设计系统 +21. ✅ **on-color 文字 Token 收敛**(v2.4 新增): 新增 `TextOnEmphasisBrush`(text-on-emphasis),全部视图/样式约 22 处 `Foreground="White"` 硬编码(含 btn-primary/btn-info 变体定义)收敛为 Token 引用 +22. ✅ **动效时长收敛**(v2.4 新增): 越阶梯的 0.2s×8、0.12s、0.8s 统一归入 150/600ms 阶梯(见第六章注记) +23. ✅ **排版兼容区清理**(v2.4 新增): `TextStyles.axaml` 删除无消费方的 `featureTitle` 与重复定义的 `feature-title` + +### P3 — 增强(功能储备,非偏差待办) + +24. **键盘快捷键**: 专业用户常用的快捷键绑定(Ctrl+M 市场切换已实现) +25. **自定义主题色**: 允许用户微调品牌色 +26. **窗口分屏**: 支持拖拽分屏查看多个标的 +27. **嵌入式 Toast**: 以应用内嵌入式 Toast 形态替代独立 `NotificationWindow`(当前配色已对齐,形态升级为体验增强) -### P3 — 增强 +--- -11. **键盘快捷键**: 专业用户常用的快捷键绑定 -12. **自定义主题色**: 允许用户微调品牌色 -13. **窗口分屏**: 支持拖拽分屏查看多个标的 +## 十一、实施现状速览(v2.3) + +| 维度 | 状态 | 说明 | +|------|------|------| +| 深色背景四层体系 | ✅ 已落地 | `PageBackground #0A0E17` / `CardBackground #111722` / `Hover #161C2A` / `SurfaceVariant #1B2233`,与本文档一致 | +| 深色文本三级 | ✅ 已落地 | `#E4EAF5 / #8894A8 / #5A6680`,与本文档一致 | +| 红涨绿跌 | ✅ 已落地 | `BullishRed #F44336` / `BearishGreen #4CAF50`,与本文档一致 | +| 排版样式类 | ✅ 已落地 | `TextStyles.axaml` h1-h6 / body / data / caption / overline | +| 间距 4px 基准 | ✅ 已落地 | `Spacing.axaml`,字号/圆角/按钮高度 token 化 | +| 涨跌标签 12% 背景 | ✅ 已落地 | 画刷已注册(含 `NeutralTagBackgroundBrush`),收藏页涨跌幅标签已接入色块背景 | +| 功能语义色 | ✅ 已收敛 | Tailwind 系统一(v2.3),交互态同步重算;亮色硬编码组已删除,统一走 `*Panel*` 主题字典 | +| Emoji 图标 | ✅ 已替换 | 6 个视图 emoji 全部换为 SVG 线性图标(第九章清单) | +| 亮色主题文本/选中色 | ✅ 已对齐 | 文本 `#1A1D26/#6B7280/#9CA3AF`;选中态品牌蓝 15% 填充 | +| 通知窗口配色 | ✅ 已迁移 | `NotificationDark*` 已入蓝调深色体系(`#161C2A/#1E2636/#20293D`) | +| 涨跌数据着色 | ✅ 已修复 | `PriceChangeColorConverter` 改读设计系统资源(原硬编码 `#e74c3c/#2ecc71`) | +| 聚焦/遮罩 Token | ✅ 已补齐 | `BorderActiveBrush` 接入全部 `:focus` 边框;遮罩统一走 `DarkOverlayBrush` | +| 字号/圆角阶梯 | ✅ 已收敛 | 字号 11/12/13/14/16/18/22/28;圆角 4/6/8/12/28(FAB) | +| 阴影/散落样式 | ✅ 已收敛 | 全局 BoxShadow Token 统一供给,视图本地阴影定义清零 | +| K 线图主题 | ✅ 已落地(v2.4) | 内嵌页面 `setTheme` 接入 `ActualThemeVariant`,深色 palette 与兜底页配色对齐(原永久白底缺陷消除) | +| on-color 文字 | ✅ 已收敛(v2.4) | `TextOnEmphasisBrush` 全量替换 `Foreground="White"` 硬编码(约 22 处) | +| 动效时长 | ✅ 已收敛(v2.4) | 全部 Transition Duration 落在 150/300/600ms 阶梯 | +| 排版兼容区 | ✅ 已清理(v2.4) | `featureTitle` 与重复 `feature-title` 已删除 | + +> 偏差明细与收敛计划统一登记在「十、迁移路线图」P2+ 小节,本表仅作快速索引。 --- @@ -404,7 +527,18 @@ MarketAssistant 是一款面向专业投资者的金融数据终端。界面设 | `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 | +| `brand-blue-dark` | `PrimaryDark` (#0D47A1) | ✅ 已对齐(v2.3 裁决采纳规范值) | +| `bg-surface` (dark) | `CardBackgroundBrush` (#111722) | ✅ 已对齐 | +| `bg-root` (dark) | `PageBackgroundBrush` (#0A0E17) | ✅ 已对齐 | +| `bg-elevated` (dark) | `HoverBackgroundBrush` (#161C2A) | ✅ 已对齐 | +| `text-primary` (dark) | `TextPrimaryBrush` (#E4EAF5) | ✅ 已对齐(减轻眩光) | +| `text-secondary` (dark) | `TextSecondaryBrush` (#8894A8) | ✅ 已对齐 | +| `bg-selected` (dark) | `SelectedBackgroundBrush` (#261976D2) | ✅ 已对齐(v2.3:品牌蓝 15% 填充,暗/亮统一) | +| `text-primary` (light) | `TextPrimaryBrush` (#1A1D26) | ✅ 已对齐(v2.3) | +| `text-secondary` (light) | `TextSecondaryBrush` (#6B7280) | ✅ 已对齐(v2.3) | +| `color-success` | `Success` (#10B981) | ✅ 已对齐(v2.3 收敛) | +| `color-warning` | `Warning` (#F59E0B) | ✅ 已对齐(v2.3 收敛) | +| `color-danger` | `Error` (#EF4444) | ✅ 已对齐(v2.3 收敛) | +| `color-info` | `Info` (#3B82F6) | ✅ 已对齐(v2.3 收敛) | +| `bg-overlay` (dark) | `SurfaceVariantBrush` (#1B2233) | ✅ 已对齐(v2.4 补记:变体面/遮罩底色,无独立 Token) | +| `text-on-emphasis` | `TextOnEmphasisBrush` (#FFFFFF) | ✅ 新增(v2.4:明暗主题同值的 on-color 文字) | diff --git "a/docs/\351\205\215\350\211\262\351\242\204\350\247\210.html" "b/docs/\351\205\215\350\211\262\351\242\204\350\247\210.html" index 610ee32..5b06ae9 100644 --- "a/docs/\351\205\215\350\211\262\351\242\204\350\247\210.html" +++ "b/docs/\351\205\215\350\211\262\351\242\204\350\247\210.html" @@ -1,631 +1,189 @@ - - - - MarketAssistant 配色方案对比与预览 - + + +MarketAssistant 配色预览 v2.4 + - -
-

🎨 MarketAssistant 配色方案对比

- -
- ⚠️ 严重问题:原方案的涨跌颜色与中国用户认知习惯相反,会造成严重的用户混淆! -
- -
- ✅ 已修复:新方案采用"红涨绿跌"标准,符合中国股市习惯,同时采用金融行业标准的蓝色系。 -
- -
- -
-
❌ 原配色方案(有问题)
- -

主色调

-
-
-
-
Primary(紫色)
-
#512BD4
-
-
- -

涨跌颜色(错误)

-
-
-
-
上涨 ❌ 应该是红色
-
#dc3545(红色)
-
-
-
-
-
-
下跌 ❌ 应该是绿色
-
#28a745(绿色)
-
-
- -
-

股价显示示例

-
-
贵州茅台 (600519)
-
¥1,850.00
-
▲ +25.00 (+1.37%)
- ❌ 红色表示上涨(颜色虽对,但整体配置混乱) -
-
-
中国平安 (601318)
-
¥52.30
-
▼ -0.80 (-1.51%)
- ❌ 绿色表示下跌(颜色虽对,但整体配置混乱) -
-
- -
- - - 紫色不符合金融行业规范 - -
-
- - -
-
✅ 新配色方案(优化后)
- -

主色调

-
-
-
-
Primary(金融蓝)
-
#1976D2
-
-
- -

涨跌颜色(正确)

-
-
-
-
上涨(Bullish)✅
-
#F44336(红色)
-
-
-
-
-
-
下跌(Bearish)✅
-
#4CAF50(绿色)
-
-
-
-
-
-
平盘(Neutral)
-
#9E9E9E(灰色)
-
-
- -
-

股价显示示例

-
-
贵州茅台 (600519)
-
¥1,850.00
-
▲ +25.00 (+1.37%)
- ✅ 红色表示上涨(符合认知) -
-
-
中国平安 (601318)
-
¥52.30
-
▼ -0.80 (-1.51%)
- ✅ 绿色表示下跌(符合认知) -
-
-
工商银行 (601398)
-
¥5.68
-
— 0.00 (0.00%)
- ✅ 灰色表示平盘 -
-
- -
- - - - - 蓝色系符合金融行业标准 - -
-
-
- - -
-
功能性颜色系统
-
-
-
-
-
成功
-
#4CAF50
-
-
-
-
-
-
警告
-
#FF9800
-
-
-
-
-
-
错误
-
#F44336
-
-
-
-
-
-
信息
-
#2196F3
-
-
-
-
- - -
-
🎯 关键改进总结
-
-
- ✅ 文化适配(关键) -

- 修正了涨跌颜色错误,采用中国标准"红涨绿跌" -

-
-
- ✅ 行业标准 -

- 主色调改为金融蓝,提升专业感和可信度 -

-
-
- ✅ 可访问性 -

- 警告色对比度从2.3:1提升到3.0:1,满足WCAG标准 -

-
-
- ✅ 语义化 -

- 新增Bullish/Bearish/Neutral语义化颜色定义 -

-
-
-
- - -
-
📊 量化对比数据
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
指标原方案新方案改善
文化匹配度 - 0% ❌ - 100% ✅ - +100%
行业标准符合度30% - 95% - +217%
WCAG可访问性得分70分 - 92分 - +31%
暗色模式评分3/10 - 8/10 - +167%
语义化程度4/109/10+125%
-
- - -
-
🎨 完整配色板
- -

品牌色系

-
-
-
-
-
Primary
-
#1976D2 - 主要按钮、链接
-
-
-
-
-
-
PrimaryDark
-
#1565C0 - 按下状态
-
-
-
-
-
-
PrimaryLight
-
#42A5F5 - 悬停状态
-
-
-
-
-
-
Secondary
-
#424242 - 次要信息
-
-
-
-
-
-
Accent
-
#FF6B35 - 行动召唤
-
-
-
- -

亮色主题

-
-
-
-
-
PageBackground
-
#FAFAFA - 页面背景
-
-
-
-
-
-
CardBackground
-
#FFFFFF - 卡片背景
-
-
-
-
-
-
ItemBackground
-
#F5F5F5 - 项目背景
-
-
-
-
-
-
PrimaryText
-
#212121 - 主要文本
-
-
-
- -

暗色主题

-
-
-
-
-
DarkPageBackground
-
#121212 - 页面背景
-
-
-
-
-
-
DarkCardBackground
-
#1E1E1E - 卡片背景
-
-
-
-
-
-
DarkItemBackground
-
#2C2C2C - 项目背景
-
-
-
-
-
-
DarkPrimaryText
-
#FFFFFF - 主要文本
-
-
-
-
- - -
-
🚀 实施状态
-
-
- ✅ - 配色方案已更新 -
-
- ✅ - 向后兼容性已保证 -
-
- ✅ - 编译测试通过 -
-
- ⏳ - 待迁移到语义化命名 -
-
- ⏳ - 待UI全面应用新配色 -
-
-
- -
-

推荐立即实施

-

- 这次配色优化修复了一个严重的UX问题(涨跌颜色与文化习惯相反),
- 同时全面提升了产品的专业性和可用性。
- 建议立即应用到生产环境。 -

-
- -
-

MarketAssistant 配色方案 v2.0

-

符合中国股市"红涨绿跌"标准 | 遵循金融行业规范 | 满足WCAG可访问性要求

-
+
+ +
+

MarketAssistant 配色方案预览

+ v2.4 · 与实现同步 +

本页仅展示当前实际实现与设计系统规范一致的配色方案。所有取值摘自 src/MarketAssistant.App/Resources/Styles/Colors.axaml。

+
+ + +
+

1. 品牌主色(Brand)

+

专业金融蓝体系。PrimaryDark 用于按下态与强调标题;Accent 仅用于高风险/警示性操作点缀。

+
+
#1976D2
主色
Primary
brand-blue
+
#0D47A1
主色-按下/深
PrimaryDark
brand-blue-dark
+
#42A5F5
主色-浅
PrimaryLight
brand-blue-light
+
#FF6B35
强调色
Accent
accent
+
+
+ + +
+

2. 市场语义色(Market Semantic)

+

中国市场标准:红涨绿跌。标签一律带约 12% 透明度背景色块(BullishTagBackgroundBrush / BearishTagBackgroundBrush)。

+
+
#F44336
看涨-红
BullishRed
bullish-red
+
#4CAF50
看跌-绿
BearishGreen
bearish-green
+
#9E9E9E
中性
Neutral
neutral
+
12% #F44336
看涨标签背景
BullishTagBackgroundBrush
bullish-bg
+
12% #4CAF50
看跌标签背景
BearishTagBackgroundBrush
bearish-bg
+
+

标签效果预览:

+
+
▲ +2.35%
+
▼ -1.26%
+
+ + +
+

3. 功能语义色(Functional Semantic)

+

已收敛至 Tailwind 语义色体系(v2.2 裁决)。Success 不再与 BearishGreen 同值,Error 不再与 BullishRed 同值,消除语义冲突。

+
+
#10B981
成功
Success
emerald-500
+
#F59E0B
警告
Warning
amber-500
+
#EF4444
错误
Error
red-500
+
#3B82F6
信息
Info
blue-500
+
+

交互状态(悬停变浅 / 按下变深):

+
+
#34D399
成功-悬停
SuccessHover
emerald-400
+
#059669
成功-按下
SuccessPressed
emerald-600
+
#FBBF24
警告-悬停
WarningHover
amber-400
+
#D97706
警告-按下
WarningPressed
amber-600
+
#60A5FA
信息-悬停
InfoHover
blue-400
+
#2563EB
信息-按下
InfoPressed
blue-600
+
+
+ + +
+

4. 深色主题(Dark Theme)

+

带蓝调的四层背景体系,本页背景即取自该方案。

+
+
页面背景 · bg-base
PageBackground · #0A0E17
+
卡片背景 · bg-card
CardBackground · #111722
+
浮起背景 · bg-elevated
ElevatedBackground · #161C2A
+
悬浮背景 · bg-hover
HoverBackground · #1B2233
+
选中背景 · bg-selected
SelectedBackground · rgba(25,118,210,0.15)
+
+
+
#1E2636
边框-默认
BorderBrush
border-default
+
#2A3348
边框-强
BorderStrongBrush
border-strong
+
#E8ECF1
主文本
TextPrimaryBrush
text-primary
+
#8894A8
次文本
TextSecondaryBrush
text-secondary
+
#5A6578
禁用文本
TextDisabledBrush
text-disabled
+
+
+ + +
+

5. 亮色主题(Light Theme)

+

文本色已对齐设计系统 v2.2 目标值。

+
+
#FAFAFA
页面背景
PageBackground
bg-light-base
+
#FFFFFF
卡片背景
CardBackground
bg-light-card
+
#1A1D26
主文本
TextPrimaryBrush
text-primary-light
+
#6B7280
次文本
TextSecondaryBrush
text-secondary-light
+
#9CA3AF
禁用文本
TextDisabledBrush
text-disabled-light
+
15% #1976D2
选中背景
SelectedBackgroundBrush
bg-selected-light
+
+
+ + +
+

6. 状态面板背景(Status Panels)

+

主题感知的半透明面板,用于提示、警告与错误区块。

+
+
成功面板 · SuccessPanel
10% 填充 + 30% 边框
+
警告面板 · WarningPanel
10% 填充 + 30% 边框
+
危险面板 · DangerPanel
10% 填充 + 30% 边框
+
信息面板 · InfoPanel
10% 填充 + 30% 边框
+
+
+ + +
+

7. K线图主题(v2.4)

+

内嵌 WebView(klinecharts 9.x)由 C# 宿主按 ActualThemeVariant 注入 setTheme('dark'|'light') 并联动切换,两套 palette 均对齐设计系统令牌。

+
+
浅色 · 图表背景
#FFFFFF · 网格 #E6E9EF · 轴文本 #667085
+
深色 · 图表背景
#111722 · 网格 #1E2636 · 轴文本 #8894A8
+
涨 #F44336
两主题一致(红涨绿跌)
+
跌 #4CAF50
两主题一致(红涨绿跌)
+
加载指示 · 浅色
#1976D2 · 错误色 #EF4444
+
加载指示 · 深色
#42A5F5 · 十字线 #5A6680
+
+
+ + +
+

8. 与实现的同步状态

+

v2.4 轮收敛已完成以下项(详见 design-system.md 第 10/11 章):

+
    +
  • ✅功能语义色收敛至 Tailwind 体系(Success/Warning/Error/Info 及 Hover/Pressed),与涨跌色不再冲突
  • +
  • ✅遗留脏色清除:#dc3545、#007bff 及其画刷已删除;涨跌转换器改读设计系统资源(原 #e74c3c/#2ecc71)
  • +
  • ✅涨跌标签背景画刷(12% 透明度)已注册,可在视图中直接引用
  • +
  • ✅bg-selected 深色/亮色均改为品牌蓝 15% 填充
  • +
  • ✅PrimaryDark 对齐 brand-blue-dark #0D47A1
  • +
  • ✅亮色主题文本对齐 #1A1D26 / #6B7280 / #9CA3AF
  • +
  • ✅通知窗口配色迁入蓝调深色体系(#161C2A / #1E2636)
  • +
  • ✅视图 emoji 全部替换为 SVG 线性图标(feather 风格、语义配色)
  • +
  • ✅亮色硬编码语义色组(*Light/*Background/*Border/*Text)已整体删除,消费方统一走主题感知 *Panel 组
  • +
  • ✅缺失 Token 补齐:BorderActiveBrush(聚焦边框,暗 #42A5F5 / 亮 #1976D2)、DarkOverlayBrush 遮罩、NeutralTagBackgroundBrush
  • +
  • ✅字号/圆角阶梯收敛完成:15/20/24 → 14/18/22;圆角 10/16 并入 8/12(LargeCornerRadius 已删除)
  • +
  • ✅散落样式收敛:聊天侧栏 4 个本地阴影与页面硬编码遮罩全部迁至全局 Token(Sidebar/InputArea/UserBubble BoxShadow)
  • +
  • ✅涨跌标签 12% 背景落地:收藏页涨跌幅标签改为色块背景 + 同色文字(转换器支持 ConverterParameter=tag)
  • +
  • ✅通知窗口配色已对齐蓝调深色体系;嵌入式 Toast 形态列为 P3 体验增强储备(非偏差)
  • +
  • ✅K线图主题化(v2.4):kline_chart.html 实现 setTheme,宿主按 ActualThemeVariant 注入联动(原深色主题下永久白底缺陷消除),兜底页配色同步对齐
  • +
  • ✅on-color 收敛(v2.4):新增 TextOnEmphasisBrush,约 22 处 Foreground="White" 硬编码收敛为 Token 引用(含 btn-primary/btn-info 变体)
  • +
  • ✅动效时长收敛(v2.4):0.2s×8 / 0.12s / 0.8s 统一归入 150/600ms 阶梯
  • +
  • ✅排版兼容区清理(v2.4):featureTitle 与重复定义的 feature-title 删除
  • +
  • ✅本页文本色修正:#E8ECF1 → #E4EAF5(与规范 text-primary 对齐)
  • +
+

✅ v2.4 收敛项已闭环,本页与 Colors.axaml / Spacing.axaml / kline_chart.html 实现一致。遗留待办:导航项尺寸(56/48 → 44×44)与导航图标(24 → 22px)见 design-system.md 第七章。

+
+ +
MarketAssistant Design System · 配色预览 v2.4 · 与 Colors.axaml / kline_chart.html 实现同步 · 2026-08
+
- - \ No newline at end of file + diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/IStrategyTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/IStrategyTools.cs index 6dd8061..8dc5390 100644 --- a/src/MarketAssistant.Agents/Tools/Abstractions/IStrategyTools.cs +++ b/src/MarketAssistant.Agents/Tools/Abstractions/IStrategyTools.cs @@ -10,4 +10,14 @@ public interface IStrategyTools : IToolsProvider Task> GetActiveStrategiesAsync(CancellationToken cancellationToken = default); Task GetStrategyAsync(string strategyId, CancellationToken cancellationToken = default); Task UpdateStrategyStatusAsync(string strategyId, StrategyStatus status, CancellationToken cancellationToken = default); + + /// + /// 为已有持仓创建护栏策略(止损/止盈/追踪止损),保护当前仓位。 + /// + Task CreateGuardrailAsync( + string symbol, + decimal? stopLossPrice, + decimal? takeProfitPrice, + decimal? trailingPercent, + CancellationToken cancellationToken = default); } diff --git a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoSentimentTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoSentimentTools.cs index 1f0b58b..0223be5 100644 --- a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoSentimentTools.cs +++ b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoSentimentTools.cs @@ -164,8 +164,9 @@ private async Task GetLongShortRatioHistoryAsync( var historyPoints = sortedData .Select(h => new LongShortRatioPoint { - LongRatio = decimal.Parse(h.LongAccount, CultureInfo.InvariantCulture), - ShortRatio = decimal.Parse(h.ShortAccount, CultureInfo.InvariantCulture), + // 币安返回 0-1 小数,模型契约为百分比(%),需 ×100(与资金费率处理一致) + LongRatio = decimal.Parse(h.LongAccount, CultureInfo.InvariantCulture) * 100, + ShortRatio = decimal.Parse(h.ShortAccount, CultureInfo.InvariantCulture) * 100, Ratio = decimal.Parse(h.LongShortRatio, CultureInfo.InvariantCulture), Timestamp = h.Timestamp }) diff --git a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoStrategyTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoStrategyTools.cs index 53d8d9b..1da4144 100644 --- a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoStrategyTools.cs +++ b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoStrategyTools.cs @@ -14,13 +14,16 @@ namespace MarketAssistant.Agents.Tools.Crypto; public class CryptoStrategyTools : IStrategyTools { private readonly TradingStrategyService _strategyService; + private readonly TradingDataService _dataService; private readonly ILogger _logger; public CryptoStrategyTools( TradingStrategyService strategyService, + TradingDataService dataService, ILogger logger) { _strategyService = strategyService; + _dataService = dataService; _logger = logger; } @@ -71,10 +74,93 @@ public async Task UpdateStrategyStatusAsync( } } + [Description("为已有持仓创建护栏策略(止损/止盈/追踪止损),保护当前仓位。返回创建的策略 ID 列表描述。")] + public async Task CreateGuardrailAsync( + [Description("交易对符号,如 BTCUSDT")] string symbol, + [Description("止损价(可选,多头持仓填低于现价的卖出触发价)")] decimal? stopLossPrice, + [Description("止盈价(可选,多头持仓填高于现价的卖出触发价)")] decimal? takeProfitPrice, + [Description("追踪止损回调百分比(可选,0-100,如 5 表示从峰值回撤 5% 卖出)")] decimal? trailingPercent, + CancellationToken cancellationToken = default) + { + try + { + symbol = symbol.ToUpperInvariant().Trim(); + var positions = await _dataService.GetOpenPositionsAsync(symbol, cancellationToken); + var totalQty = positions.Sum(p => p.Quantity - p.ClosedQuantity); + if (totalQty <= 0) + return $"无法创建护栏:{symbol} 当前无本地持仓记录。"; + + var created = new List(); + + if (stopLossPrice is > 0) + { + var guard = new TradingStrategy + { + Symbol = symbol, + Type = StrategyType.StopLoss, + Status = StrategyStatus.Active, + Side = OrderSide.Sell, + TriggerPrice = stopLossPrice.Value, + Quantity = totalQty, + MaxExecutions = 1 + }; + await _strategyService.SaveStrategyAsync(guard, cancellationToken); + created.Add($"止损策略 {guard.Id}(触发价 {stopLossPrice})"); + } + + if (takeProfitPrice is > 0) + { + var guard = new TradingStrategy + { + Symbol = symbol, + Type = StrategyType.TakeProfit, + Status = StrategyStatus.Active, + Side = OrderSide.Sell, + TriggerPrice = takeProfitPrice.Value, + Quantity = totalQty, + MaxExecutions = 1 + }; + await _strategyService.SaveStrategyAsync(guard, cancellationToken); + created.Add($"止盈策略 {guard.Id}(触发价 {takeProfitPrice})"); + } + + if (trailingPercent is > 0 and <= 100) + { + var guard = new TradingStrategy + { + Symbol = symbol, + Type = StrategyType.TrailingStop, + Status = StrategyStatus.Active, + Side = OrderSide.Sell, + Quantity = totalQty, + MaxExecutions = 1, + CustomParams = System.Text.Json.JsonSerializer.Serialize(new + { + trailingPercent = trailingPercent.Value + }) + }; + await _strategyService.SaveStrategyAsync(guard, cancellationToken); + created.Add($"追踪止损策略 {guard.Id}(回调 {trailingPercent}%,立即激活)"); + } + + if (created.Count == 0) + return "未创建任何护栏:请至少提供止损价、止盈价或追踪止损回调百分比之一。"; + + _logger.LogInformation("已为 {Symbol} 创建护栏: {Summary}", symbol, string.Join("; ", created)); + return $"已为 {symbol}(持仓 {totalQty})创建护栏:{string.Join("; ", created)}"; + } + 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(GetActiveStrategiesAsync); yield return AIFunctionFactory.Create(GetStrategyAsync); yield return AIFunctionFactory.Create(UpdateStrategyStatusAsync); + yield return AIFunctionFactory.Create(CreateGuardrailAsync); } } diff --git a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoTechnicalTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoTechnicalTools.cs index 685fd40..7b405f7 100644 --- a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoTechnicalTools.cs +++ b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoTechnicalTools.cs @@ -80,12 +80,15 @@ public async Task GetMACDAsync([Description("虚拟币代码( .LastOrDefault(item => item.Macd is not null && item.Signal is not null) ?? throw new FriendlyException($"MACD 指标结果为空: {assetSymbol}"); + var diff = Round(result.Macd) ?? 0; + var dea = Round(result.Signal) ?? 0; 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, + Diff = diff, + Dea = dea, + // 国内惯例 MACD 柱线 = 2*(DIFF-DEA),与模型契约及 A 股智兔数据口径保持一致 + Macd = 2 * (diff - dea), Ema12 = Round(result.FastEma) ?? 0, Ema26 = Round(result.SlowEma) ?? 0 }; diff --git a/src/MarketAssistant.App.Services/Applications/Settings/MCPServerConfig.cs b/src/MarketAssistant.App.Services/Applications/Settings/MCPServerConfig.cs index f4662e3..546095d 100644 --- a/src/MarketAssistant.App.Services/Applications/Settings/MCPServerConfig.cs +++ b/src/MarketAssistant.App.Services/Applications/Settings/MCPServerConfig.cs @@ -66,6 +66,17 @@ public class MCPServerConfig /// public bool AllowAllTools { get; set; } = false; + /// + /// 工具白名单机制的当前配置版本号。 + /// + public const int CurrentToolsSchemaVersion = 1; + + /// + /// 配置结构版本。默认 0 表示旧版本保存的配置(JSON 无此字段或未经新版本 UI 确认), + /// 由 UI 提示用户重新勾选工具白名单后升级为 并落盘。 + /// + public int ToolsSchemaVersion { get; set; } + /// /// 获取传输选项字典 /// diff --git a/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj b/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj index 4a74053..0140af0 100644 --- a/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj +++ b/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj @@ -27,7 +27,6 @@ - diff --git a/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs index 900f156..f81fd46 100644 --- a/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs +++ b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs @@ -26,7 +26,6 @@ using MarketAssistant.Applications.PriceAlert; using MarketAssistant.Applications.Settings; using MarketAssistant.Applications.Telegrams; -using MarketAssistant.Infrastructure.AdaptiveCards.Parsers; using MarketAssistant.Infrastructure.Factories; using MarketAssistant.Infrastructure.Http; using MarketAssistant.Infrastructure.Providers; @@ -304,14 +303,6 @@ private static IServiceCollection AddAgentInfrastructure(this IServiceCollection services.AddSingleton(); services.AddSingleton(); - // AdaptiveCard Parsers(责任链) - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - // MAF 中间件与会话级 Context Provider 工厂 services.AddSingleton(); services.AddSingleton(); diff --git a/src/MarketAssistant.App.Services/Trading/AISignalDecision.cs b/src/MarketAssistant.App.Services/Trading/AISignalDecision.cs new file mode 100644 index 0000000..d6c7043 --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/AISignalDecision.cs @@ -0,0 +1,154 @@ +using System.Text.Json; + +namespace MarketAssistant.Services.Trading; + +/// +/// AI 结构化决策模型:LLM 按约定 JSON schema 输出,本地解析校验后驱动下单与护栏生成。 +/// 决策与执行解耦——AI 只给方向、置信度与止盈止损价位,实际下单数量、仓位上限与护栏 +/// 全部由本地代码强制计算,AI 无法绕过风控。 +/// +public sealed class AISignalDecision +{ + public const string BuyAction = "BUY"; + public const string SellAction = "SELL"; + public const string HoldAction = "HOLD"; + + /// 决策方向:BUY / SELL / HOLD。 + public string Action { get; init; } = HoldAction; + + /// 置信度(0-100),驱动动态仓位 sizing。 + public int Confidence { get; init; } + + /// AI 建议的止损价(可为空,为空时按风险档案兜底生成)。 + public decimal? StopLossPrice { get; init; } + + /// AI 建议的止盈价(可为空,为空时按风险档案兜底生成)。 + public decimal? TakeProfitPrice { get; init; } + + /// 决策理由(写入交易记录的 AIReasoning)。 + public string? Reason { get; init; } + + public bool IsBuy => Action.Equals(BuyAction, StringComparison.OrdinalIgnoreCase); + public bool IsSell => Action.Equals(SellAction, StringComparison.OrdinalIgnoreCase); + public bool IsHold => !IsBuy && !IsSell; +} + +/// +/// AI 决策 JSON 解析器:从 LLM 响应文本中稳健提取首个 JSON 决策对象。 +/// 容忍 markdown 代码块包裹、前后说明文字与属性大小写差异。 +/// +public static class AISignalDecisionParser +{ + private static readonly string[] ActionAliases = ["decision", "action"]; + + /// + /// 尝试从响应文本解析决策。返回 false 表示文本中不存在合法 JSON 决策(视为 HOLD 处理)。 + /// + public static bool TryParse(string? responseText, out AISignalDecision? decision) + { + decision = null; + + var json = ExtractFirstJsonObject(responseText); + if (json == null) + return false; + + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + return false; + + string? action = null; + foreach (var alias in ActionAliases) + { + if (root.TryGetProperty(alias, out var actionEl) && actionEl.ValueKind == JsonValueKind.String) + { + action = actionEl.GetString(); + break; + } + } + + int confidence = 0; + if (root.TryGetProperty("confidence", out var confidenceEl) + && confidenceEl.TryGetInt32(out var parsedConfidence)) + confidence = Math.Clamp(parsedConfidence, 0, 100); + + decimal? stopLossPrice = TryReadDecimal(root, "stopLossPrice"); + decimal? takeProfitPrice = TryReadDecimal(root, "takeProfitPrice"); + string? reason = null; + if (root.TryGetProperty("reason", out var reasonEl) && reasonEl.ValueKind == JsonValueKind.String) + reason = reasonEl.GetString(); + + decision = new AISignalDecision + { + Action = action?.Trim().ToUpperInvariant() ?? AISignalDecision.HoldAction, + Confidence = confidence, + StopLossPrice = stopLossPrice > 0 ? stopLossPrice : null, + TakeProfitPrice = takeProfitPrice > 0 ? takeProfitPrice : null, + Reason = reason + }; + return true; + } + catch (JsonException) + { + return false; + } + } + + /// + /// 提取文本中首个平衡的 JSON 对象(字符串感知,跳过字符串字面量与转义符)。 + /// + private static string? ExtractFirstJsonObject(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return null; + + var start = text.IndexOf('{'); + if (start < 0) + return null; + + var inString = false; + var depth = 0; + for (var i = start; i < text.Length; i++) + { + var ch = text[i]; + if (inString) + { + if (ch == '\\') + i++; // 跳过转义字符 + else if (ch == '"') + inString = false; + continue; + } + + if (ch == '"') + inString = true; + else if (ch == '{') + depth++; + else if (ch == '}') + { + depth--; + if (depth == 0) + return text[start..(i + 1)]; + } + } + + return null; + } + + private static decimal? TryReadDecimal(JsonElement root, string propertyName) + { + if (!root.TryGetProperty(propertyName, out var el)) + return null; + + return el.ValueKind switch + { + JsonValueKind.Number when el.TryGetDecimal(out var value) => value, + JsonValueKind.String when decimal.TryParse( + el.GetString(), System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var parsed) => parsed, + _ => null + }; + } +} \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/Trading/AISignalStrategyExecutor.cs b/src/MarketAssistant.App.Services/Trading/AISignalStrategyExecutor.cs index 9659b5a..f3abc91 100644 --- a/src/MarketAssistant.App.Services/Trading/AISignalStrategyExecutor.cs +++ b/src/MarketAssistant.App.Services/Trading/AISignalStrategyExecutor.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Text.Json; using MarketAssistant.Infrastructure.Factories; using MarketAssistant.Trading.Models; using Microsoft.Agents.AI; @@ -127,13 +128,20 @@ private async Task ExecuteWithAgentAsync( var priorRecords = await _dataService.GetRecordsByStrategyAsync(strategy.Id, ct) .ConfigureAwait(false); - var priorLatestRecordId = priorRecords.FirstOrDefault()?.Id; var prompt = await BuildAIPromptAsync(strategy, currentPrice, priorRecords, ct) .ConfigureAwait(false); - await InvokeAgentAsync(prompt, ct).ConfigureAwait(false); + var responseText = await InvokeAgentAsync(prompt, ct).ConfigureAwait(false); - return await ProcessAgentResponseAsync(strategy, priorLatestRecordId, ct) + if (!AISignalDecisionParser.TryParse(responseText, out var decision) || decision!.IsHold) + { + _logger.LogInformation( + "AI 决策为 HOLD 或无法解析: {StrategyId} 响应片段 {ResponseSnippet}", + strategy.Id, Truncate(responseText, 200)); + return AISignalExecutionResult.NoTrade; + } + + return await ExecuteDecisionAsync(strategy, currentPrice, decision!, ct) .ConfigureAwait(false); } catch (Exception ex) @@ -147,6 +155,165 @@ private async Task ExecuteWithAgentAsync( } } + /// + /// 执行 AI 结构化决策:置信度门控 → 置信度动态仓位(按档案预算与仓位上限封顶)→ + /// 本地下单(AI 不直接调用下单工具)→ 成交后自动附加止盈止损护栏。 + /// + private async Task ExecuteDecisionAsync( + TradingStrategy strategy, + decimal currentPrice, + AISignalDecision decision, + CancellationToken ct) + { + var aiParams = AISignalParams.FromJson(strategy.CustomParams) ?? new AISignalParams(); + + if (decision.Confidence < aiParams.ConfidenceThreshold) + { + _logger.LogInformation( + "AI 置信度 {Confidence} 低于门槛 {Threshold},放弃执行: {StrategyId} 理由: {Reason}", + decision.Confidence, aiParams.ConfidenceThreshold, strategy.Id, decision.Reason); + return AISignalExecutionResult.NoTrade; + } + + var entrySide = strategy.Side; + var budget = aiParams.BudgetUsdt > 0 ? aiParams.BudgetUsdt : strategy.Quantity; + if (budget <= 0) + { + _logger.LogWarning( + "AI 策略未配置开仓预算(BudgetUsdt/Quantity),跳过执行: {StrategyId}", strategy.Id); + return AISignalExecutionResult.NoTrade; + } + + // 置信度动态仓位:预算 × 置信度系数,并按档案仓位上限封顶(AI 无法突破) + var sizedBudget = budget * (decision.Confidence / 100m); + var balanceSummary = await _portfolioService.GetAccountBalanceSummaryAsync(ct).ConfigureAwait(false); + var accountValue = balanceSummary?.TotalValueUSDT ?? 0; + if (accountValue > 0) + { + var capValue = accountValue * aiParams.MaxPositionPercent / 100m; + if (sizedBudget > capValue) + { + _logger.LogInformation( + "预算 {Budget:F2} 超出仓位上限 {Cap:F2}(账户总值 {AccountValue:F2} × {MaxPercent}%),已封顶: {StrategyId}", + sizedBudget, capValue, accountValue, aiParams.MaxPositionPercent, strategy.Id); + sizedBudget = capValue; + } + } + + var quantity = currentPrice > 0 ? Math.Round(sizedBudget / currentPrice, 8) : 0; + if (quantity <= 0) + { + _logger.LogInformation("计算后下单数量为 0,跳过执行: {StrategyId} 预算 {Budget}", strategy.Id, sizedBudget); + return AISignalExecutionResult.NoTrade; + } + + if (aiParams.ShadowMode) + { + _logger.LogInformation( + "影子模式决策(仅记录不下单): {StrategyId} {Symbol} {Action} 置信度 {Confidence}% 数量 {Qty} " + + "止损 {StopLoss} 止盈 {TakeProfit} 理由: {Reason}", + strategy.Id, strategy.Symbol, decision.Action, decision.Confidence, quantity, + decision.StopLossPrice, decision.TakeProfitPrice, decision.Reason); + return AISignalExecutionResult.NoTrade; + } + + var result = await _tradeExecutor.ExecuteOrderAsync( + strategy.Symbol, + entrySide, + OrderType.Market, + quantity, + currentPrice, + strategyId: strategy.Id, + aiReasoning: decision.Reason, + requireClose: false, + ct: ct).ConfigureAwait(false); + + if (!result.Success || result.Record == null) + return new AISignalExecutionResult(null, result); + + // 成交后自动附加护栏:本地按决策价/档案兜底价生成并持久化,下个 tick 即生效 + await ApplyGuardrailsAsync(strategy, aiParams, entrySide, currentPrice, quantity, decision, ct) + .ConfigureAwait(false); + await _dataService.UpdateStrategyTriggeredAsync(strategy.Id, ct).ConfigureAwait(false); + + return new AISignalExecutionResult(result.Record, result); + } + + private static string? Truncate(string? text, int maxLength) + => string.IsNullOrEmpty(text) || text.Length <= maxLength ? text : text[..maxLength] + "…"; + + /// + /// 生成并持久化护栏: + /// - TrailingStop 出场:创建独立的追踪止损伴随策略(复用引擎现有追踪评估,一次性执行); + /// - FixedStop 出场:止损/止盈价写入策略的 StopLossPrice/TakeProfitPrice(硬性边界机制)。 + /// AI 给出的价位仅在与当前价方向关系合理时采用,否则按档案百分比兜底,保证护栏方向永不颠倒。 + /// + private async Task ApplyGuardrailsAsync( + TradingStrategy strategy, + AISignalParams aiParams, + OrderSide entrySide, + decimal currentPrice, + decimal executedQty, + AISignalDecision decision, + CancellationToken ct) + { + var isLong = entrySide == OrderSide.Buy; + + if (aiParams.ParsedExitStyle == ExitStyle.TrailingStop) + { + var trailingPercent = aiParams.TrailingPercent > 0 + ? aiParams.TrailingPercent + : ScenarioPresets.GetTrailingPercent(aiParams.ParsedRiskProfile); + var companion = new TradingStrategy + { + Symbol = strategy.Symbol, + Type = StrategyType.TrailingStop, + Status = StrategyStatus.Active, + // 多头入场 → 追踪卖出出场;空头入场 → 追踪买入出场 + Side = isLong ? OrderSide.Sell : OrderSide.Buy, + TriggerPrice = currentPrice, + Quantity = executedQty, + MaxExecutions = 1, + CustomParams = JsonSerializer.Serialize(new + { + trailingPercent, + activationPrice = currentPrice + }) + }; + await _strategyService.SaveStrategyAsync(companion, ct).ConfigureAwait(false); + _logger.LogInformation( + "AI 已创建追踪止损伴随策略: {CompanionId} 回调 {Percent}% 激活价 {Activation} 关联 {StrategyId}", + companion.Id, trailingPercent, currentPrice, strategy.Id); + return; + } + + decimal? aiStopLoss = decision.StopLossPrice; + var stopLossValid = aiStopLoss.HasValue && + (isLong ? aiStopLoss.Value < currentPrice : aiStopLoss.Value > currentPrice); + var stopLossFallback = isLong + ? currentPrice * (1 - aiParams.StopLossPercent / 100m) + : currentPrice * (1 + aiParams.StopLossPercent / 100m); + var stopLoss = Math.Round(stopLossValid ? aiStopLoss!.Value : stopLossFallback, 8); + + decimal? aiTakeProfit = decision.TakeProfitPrice; + var takeProfitValid = aiTakeProfit.HasValue && + (isLong ? aiTakeProfit.Value > currentPrice : aiTakeProfit.Value < currentPrice); + var takeProfitFallback = isLong + ? currentPrice * (1 + aiParams.TakeProfitPercent / 100m) + : currentPrice * (1 - aiParams.TakeProfitPercent / 100m); + var takeProfit = Math.Round(takeProfitValid ? aiTakeProfit!.Value : takeProfitFallback, 8); + + strategy.StopLossPrice = stopLoss; + strategy.TakeProfitPrice = takeProfit; + await _dataService.UpdateStrategyGuardrailsAsync(strategy.Id, stopLoss, takeProfit, ct) + .ConfigureAwait(false); + _logger.LogInformation( + "AI 护栏已附加: {StrategyId} 止损 {StopLoss} 止盈 {TakeProfit}({Source})", + strategy.Id, stopLoss, takeProfit, + decision.StopLossPrice.HasValue || decision.TakeProfitPrice.HasValue + ? "AI 决策价" : "风险档案兜底价"); + } + private static bool TryHandleHardBoundary( TradingStrategy strategy, decimal currentPrice, @@ -190,36 +357,45 @@ private async Task BuildAIPromptAsync( List priorRecords, CancellationToken ct) { - 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 recentSummary = priorRecords. + Take(5) + .Aggregate(new StringBuilder(), (sb, r) => sb.AppendLine($"{r.CreatedAt:u} {r.Side} 成交量:{r.ExecutedQty} 价:{r.ExecutedPrice} {r.Status}")) + .ToString().TrimEnd(); var positionSummary = await BuildPositionSummaryAsync(strategy.Symbol, ct).ConfigureAwait(false); 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 aiParams = AISignalParams.FromJson(strategy.CustomParams) ?? new AISignalParams(); + var budget = aiParams.BudgetUsdt > 0 ? aiParams.BudgetUsdt : strategy.Quantity; var todayStats = await _dataService.GetTodayStatsAsync(ct).ConfigureAwait(false); var maxDailyTrades = (await _dataService.LoadRiskConfigAsync(ct).ConfigureAwait(false)).MaxDailyTrades; var remainingTrades = Math.Max(0, maxDailyTrades - todayStats.TradeCount); + const string jsonTemplate = """ + { + "decision": "BUY | SELL | HOLD", + "confidence": 0, + "stopLossPrice": null, + "takeProfitPrice": null, + "reason": "一句话决策理由" + } + """; + return $""" - 分析交易标的 {strategy.Symbol},当前价格 {currentPrice}。 + 你是虚拟币智能交易决策引擎,负责 {strategy.Symbol} 的交易决策。当前价格 {currentPrice}。 - ## 风险预算(必须严格遵守) - - 本次交易后该 symbol 总仓位不得超过账户总值的 {maxPositionPercent:F1}% - - 今日已实现盈亏: {todayStats.TotalPnl:F2} USDT - - 今日剩余交易次数: {remainingTrades} + ## 输出格式(强制) + 你的最终回答必须且只能是一个 JSON 对象,格式如下,禁止输出任何其他文字、markdown 代码块标记或工具调用说明。 + 下单由系统完成,禁止调用任何下单(PlaceOrder)工具;你可以调用行情、持仓等技术指标查询工具辅助决策。 + {jsonTemplate} - ## 策略配置 - {strategy.CustomParams ?? "无"} - 风险边界: {stopLossInfo} | {takeProfitInfo} + ## 决策约束 + - 置信度低于 {aiParams.ConfidenceThreshold} 时必须 HOLD(系统会强制拦截,低于门槛的 BUY/SELL 不会执行)。 + - decision 为 BUY 时:stopLossPrice 必须低于当前价、takeProfitPrice 必须高于当前价;SELL 相反。 + - 无法给出合理止盈/止损价时填 null,系统会按风险档案「{aiParams.ParsedRiskProfile.GetDisplayName()}」自动生成护栏。 + - 本次开仓预算约 {budget:F2} USDT,实际下单数量由系统按置信度与仓位上限计算,你无需输出数量。 + - 仓位上限为账户总值的 {aiParams.MaxPositionPercent:F1}%(系统强制执行)。 + - 今日已实现盈亏 {todayStats.TotalPnl:F2} USDT,今日剩余交易次数 {remainingTrades}。 ## 当前仓位状态 {positionSummary} @@ -229,22 +405,13 @@ private async Task BuildAIPromptAsync( 近期该策略成交摘要(最多 5 笔,按时间倒序): {recentSummary} - - ## 决策要求 - 请输出结构化决策: - 1. 决策: BUY / SELL / HOLD - 2. 置信度: 0-100 - 3. 入场逻辑 - 4. 退出计划(止损/止盈具体价位) - 5. 主要风险因素 - - 如果置信度低于 60,建议 HOLD。 - 如果决定交易,请调用 PlaceOrder 工具执行 {strategy.Side} 操作,数量 {strategy.Quantity}。 - 如果决定不交易,请说明理由。 """; } - private async Task InvokeAgentAsync(string prompt, CancellationToken ct) + /// + /// 调用交易 Agent 并返回原始响应文本(由本地解析为结构化决策)。 + /// + private async Task InvokeAgentAsync(string prompt, CancellationToken ct) { var agent = _agentFactory.CreateAutomationAgent(); var messages = new List @@ -255,21 +422,7 @@ private async Task InvokeAgentAsync(string prompt, CancellationToken ct) var response = await agent.RunAsync(messages, session: null, options: null, cancellationToken: ct) .ConfigureAwait(false); _logger.LogDebug("TradingAgent 响应: {Content}", response.Text); - } - - private async Task ProcessAgentResponseAsync( - TradingStrategy strategy, - string? priorLatestRecordId, - CancellationToken ct) - { - var recentRecords = await _dataService.GetRecordsByStrategyAsync(strategy.Id, ct) - .ConfigureAwait(false); - var newestRecord = recentRecords.FirstOrDefault(); - if (newestRecord == null || newestRecord.Id == priorLatestRecordId) - return AISignalExecutionResult.NoTrade; - - await _dataService.UpdateStrategyTriggeredAsync(strategy.Id, ct).ConfigureAwait(false); - return new AISignalExecutionResult(newestRecord); + return response.Text; } private async Task BuildPositionSummaryAsync(string symbol, CancellationToken ct) diff --git a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs index 92c015d..b73d5ad 100644 --- a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs +++ b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs @@ -466,6 +466,8 @@ private static bool IsExitOnlyStrategy(TradingStrategy strategy, decimal current StrategyType.TakeProfit => strategy.Side == OrderSide.Sell, StrategyType.TrailingStop => true, StrategyType.GridTrading => IsGridBreakOut(strategy, currentPrice), + // DCA 评估只会以卖出方向触发出场(止盈/止损清仓),卖出即平仓语义 + StrategyType.DCA => strategy.Side == OrderSide.Sell, _ => false }; } diff --git a/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs b/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs index 88ed133..feda2ab 100644 --- a/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs +++ b/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs @@ -88,7 +88,7 @@ public async Task> EvaluateAndUpdateStrategiesAsync( StrategyType.TakeProfit => (EvaluateTakeProfit(strategy, currentPrice), strategy.Side, strategy.Quantity), StrategyType.TrailingStop => (await EvaluateAndUpdateTrailingStopAsync(strategy, currentPrice, ct), strategy.Side, strategy.Quantity), StrategyType.AISignal => (await EvaluateAndUpdateAISignalAsync(strategy, ct).ConfigureAwait(false), strategy.Side, strategy.Quantity), - StrategyType.GridTrading => EvaluateAndUpdateGridTrading(strategy, currentPrice, out var gs, out var gq) ? (true, gs, gq) : (false, strategy.Side, strategy.Quantity), + StrategyType.GridTrading => await EvaluateAndUpdateGridTradingAsync(strategy, currentPrice, ct).ConfigureAwait(false), StrategyType.DCA => await EvaluateDCAAsync(strategy, currentPrice, ct), _ => (false, strategy.Side, strategy.Quantity) }; @@ -128,29 +128,47 @@ private static bool EvaluateTakeProfit(TradingStrategy strategy, decimal current /// 评估追踪止损触发条件。 /// 注意:此方法会修改入参 的 /// 字段以持久化追踪峰值/谷值状态,并同步写入数据存储,调用方依赖此副作用保持内存与持久化状态一致。 + /// 参数回退策略:trailingPercent 缺失时按策略风险档案(CustomParams.riskProfile)取预设回调比例, + /// activationPrice 缺失时回退到策略触发价;确保安全护栏永不静默失效。 /// 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; + decimal trailingPercent = 0; + decimal activationPrice = 0; - if (!root.TryGetProperty("activationPrice", out var activationPriceEl)) - return false; - var activationPrice = activationPriceEl.GetDecimal(); + if (!string.IsNullOrEmpty(strategy.CustomParams)) + { + using var doc = JsonDocument.Parse(strategy.CustomParams); + var root = doc.RootElement; - if (!root.TryGetProperty("trailingPercent", out var trailingPercentEl)) - return false; - var trailingPercent = trailingPercentEl.GetDecimal(); + if (root.TryGetProperty("trailingPercent", out var trailingPercentEl) + && trailingPercentEl.TryGetDecimal(out var parsedPercent)) + trailingPercent = parsedPercent; + + if (root.TryGetProperty("activationPrice", out var activationPriceEl) + && activationPriceEl.TryGetDecimal(out var parsedActivation)) + activationPrice = parsedActivation; + } + + if (trailingPercent <= 0) + { + var profile = ResolveRiskProfile(strategy); + trailingPercent = ScenarioPresets.GetTrailingPercent(profile); + _logger.LogWarning( + "追踪止损策略 {StrategyId} 缺少 trailingPercent,按风险档案 {Profile} 回退为 {Percent}%", + strategy.Id, profile.GetDisplayName(), trailingPercent); + } + + // 未配置激活价时回退到策略触发价;两者皆无则立即激活(护栏优先于精度) + if (activationPrice <= 0 && strategy.TriggerPrice > 0) + activationPrice = strategy.TriggerPrice; if (strategy.Side == OrderSide.Sell) { - // 未激活且价格未达到激活价:不触发 - if (!strategy.TrailingPeakPrice.HasValue && currentPrice < activationPrice) + // 未激活且价格未达到激活价:不触发(activationPrice 为 0 表示立即激活) + if (!strategy.TrailingPeakPrice.HasValue && activationPrice > 0 && currentPrice < activationPrice) return false; // 追踪最高价(从持久化字段恢复),从峰值回撤 trailingPercent% 时触发卖出 @@ -168,7 +186,7 @@ private async Task EvaluateAndUpdateTrailingStopAsync(TradingStrategy stra } else { - if (!strategy.TrailingPeakPrice.HasValue && currentPrice > activationPrice) + if (!strategy.TrailingPeakPrice.HasValue && activationPrice > 0 && currentPrice > activationPrice) return false; // 追踪最低价(从持久化字段恢复),从谷值反弹 trailingPercent% 时触发买入 @@ -191,6 +209,15 @@ private async Task EvaluateAndUpdateTrailingStopAsync(TradingStrategy stra } } + /// + /// 解析策略的风险档案:优先读取 CustomParams.riskProfile,缺失时回退稳健档。 + /// + private static RiskProfile ResolveRiskProfile(TradingStrategy strategy) + { + var aiParams = AISignalParams.FromJson(strategy.CustomParams); + return aiParams?.ParsedRiskProfile ?? RiskProfile.Balanced; + } + // 未配置时的安全默认值,防止每个价格 tick 都触发 AI 调用 private const int DefaultAISignalIntervalSeconds = 60; @@ -239,36 +266,33 @@ private async Task EvaluateAndUpdateAISignalAsync( /// 网格在 LowerPrice 和 UpperPrice 之间均匀分布。 /// 价格下穿网格线时买入,上穿时卖出。 /// 注意:此方法会修改入参 的 - /// 字段以更新网格的 LastTriggeredIndex 状态,调用方依赖此副作用在交易成功后原子持久化更新后的参数。 + /// 字段以更新网格的 LastTriggeredIndex 状态,并将状态持久化到数据存储; + /// 首次评估的基准索引同样立即落库,避免应用重启后基准丢失导致重复触发。 /// - private bool EvaluateAndUpdateGridTrading(TradingStrategy strategy, decimal currentPrice, - out OrderSide effectiveSide, out decimal effectiveQty) + private async Task<(bool Triggered, OrderSide Side, decimal Qty)> EvaluateAndUpdateGridTradingAsync( + TradingStrategy strategy, decimal currentPrice, CancellationToken ct) { - effectiveSide = strategy.Side; - effectiveQty = strategy.Quantity; - if (string.IsNullOrEmpty(strategy.CustomParams)) - return false; + return (false, strategy.Side, strategy.Quantity); try { var gridParams = JsonSerializer.Deserialize(strategy.CustomParams); if (gridParams == null || gridParams.GridCount <= 1 || gridParams.UpperPrice <= gridParams.LowerPrice) - return false; + return (false, strategy.Side, strategy.Quantity); if (currentPrice < gridParams.LowerPrice) { // 价格跌破网格下界:检查破网止损 if (gridParams.StopLossPrice.HasValue && currentPrice <= gridParams.StopLossPrice.Value) { - effectiveSide = OrderSide.Sell; - effectiveQty = gridParams.QuantityPerGrid * gridParams.GridCount; + var stopQty = gridParams.QuantityPerGrid * gridParams.GridCount; _logger.LogWarning( "网格破网止损触发: {StrategyId} 价格 {Price} <= 止损位 {StopLoss},清仓 {Qty}", - strategy.Id, currentPrice, gridParams.StopLossPrice, effectiveQty); - return true; + strategy.Id, currentPrice, gridParams.StopLossPrice, stopQty); + return (true, OrderSide.Sell, stopQty); } - return false; + return (false, strategy.Side, strategy.Quantity); } if (currentPrice > gridParams.UpperPrice) { @@ -276,14 +300,13 @@ private bool EvaluateAndUpdateGridTrading(TradingStrategy strategy, decimal curr // 与破网止损方向对称;若反向买入会在高点开出全网格量多头。 if (gridParams.TakeProfitPrice.HasValue && currentPrice >= gridParams.TakeProfitPrice.Value) { - effectiveSide = OrderSide.Sell; - effectiveQty = gridParams.QuantityPerGrid * gridParams.GridCount; + var takeQty = gridParams.QuantityPerGrid * gridParams.GridCount; _logger.LogWarning( "网格破网止盈触发: {StrategyId} 价格 {Price} >= 止盈位 {TakeProfit},清仓 {Qty}", - strategy.Id, currentPrice, gridParams.TakeProfitPrice, effectiveQty); - return true; + strategy.Id, currentPrice, gridParams.TakeProfitPrice, takeQty); + return (true, OrderSide.Sell, takeQty); } - return false; + return (false, strategy.Side, strategy.Quantity); } var spacing = gridParams.GridSpacing; @@ -292,16 +315,20 @@ private bool EvaluateAndUpdateGridTrading(TradingStrategy strategy, decimal curr if (gridParams.LastTriggeredIndex < 0) { + // 首次评估:仅记录基准网格线并立即落库,不触发交易 gridParams.LastTriggeredIndex = currentIndex; strategy.CustomParams = JsonSerializer.Serialize(gridParams); - return false; + await _dataService.UpdateStrategyCustomParamsAsync(strategy.Id, strategy.CustomParams, ct); + _logger.LogInformation( + "网格基准初始化: {StrategyId} 基准网格 {Index},价格: {Price}", + strategy.Id, currentIndex, currentPrice); + return (false, strategy.Side, strategy.Quantity); } if (currentIndex == gridParams.LastTriggeredIndex) - return false; + return (false, strategy.Side, strategy.Quantity); - effectiveSide = currentIndex < gridParams.LastTriggeredIndex ? OrderSide.Buy : OrderSide.Sell; - effectiveQty = gridParams.QuantityPerGrid; + var effectiveSide = currentIndex < gridParams.LastTriggeredIndex ? OrderSide.Buy : OrderSide.Sell; gridParams.LastTriggeredIndex = currentIndex; strategy.CustomParams = JsonSerializer.Serialize(gridParams); @@ -309,12 +336,12 @@ private bool EvaluateAndUpdateGridTrading(TradingStrategy strategy, decimal curr _logger.LogInformation( "网格交易触发: {StrategyId} 网格 {Index} → {Side},价格: {Price}", strategy.Id, currentIndex, effectiveSide, currentPrice); - return true; + return (true, effectiveSide, gridParams.QuantityPerGrid); } catch (Exception ex) { _logger.LogWarning(ex, "解析 GridTrading 参数失败: {StrategyId}", strategy.Id); - return false; + return (false, strategy.Side, strategy.Quantity); } } @@ -334,6 +361,12 @@ private bool EvaluateAndUpdateGridTrading(TradingStrategy strategy, decimal curr if (dcaParams == null || dcaParams.AmountPerInterval <= 0) return (false, strategy.Side, strategy.Quantity); + // 出场优先:每 tick 评估止盈/止损,不受定投间隔节流限制(护栏必须实时生效) + var exitTriggered = await EvaluateDCAExitAsync(strategy, dcaParams, currentPrice, ct) + .ConfigureAwait(false); + if (exitTriggered.HasValue) + return exitTriggered.Value; + if (strategy.LastTriggeredAt.HasValue) { var elapsed = (DateTime.UtcNow - strategy.LastTriggeredAt.Value).TotalSeconds; @@ -391,6 +424,60 @@ private bool EvaluateAndUpdateGridTrading(TradingStrategy strategy, decimal curr } } + /// + /// DCA 出场评估:基于 FIFO 持仓均价判断止盈/止损。 + /// 止盈:均价上涨达 TakeProfitPercent 时全部卖出获利了结,定投继续(从零重新积累)。 + /// 止损:均价下跌达 StopLossPercent 时按 StopLossSellOut 决定清仓卖出或仅暂停策略(保守默认)。 + /// 返回 null 表示未触发任何出场条件,继续走买入评估。 + /// + private async Task<(bool Triggered, OrderSide Side, decimal Qty)?> EvaluateDCAExitAsync( + TradingStrategy strategy, DCAParams dcaParams, decimal currentPrice, CancellationToken ct) + { + if (dcaParams.TakeProfitPercent <= 0 && dcaParams.StopLossPercent <= 0) + return null; + + var positions = await _dataService.GetOpenPositionsAsync(strategy.Symbol, ct).ConfigureAwait(false); + var totalQty = positions.Sum(p => p.Quantity - p.ClosedQuantity); + if (totalQty <= 0) + return null; + + var avgEntry = await _dataService.GetOpenPositionAvgEntryPriceAsync(strategy.Symbol, ct) + .ConfigureAwait(false); + if (avgEntry <= 0) + return null; + + // 止盈:达到止盈线全部卖出 + if (dcaParams.TakeProfitPercent > 0 && currentPrice >= avgEntry * (1 + dcaParams.TakeProfitPercent / 100m)) + { + _logger.LogInformation( + "DCA 止盈触发: {StrategyId} 当前价 {Price} >= 均价 {AvgEntry} × (1 + {TakeProfit}%),清仓 {Qty}", + strategy.Id, currentPrice, avgEntry, dcaParams.TakeProfitPercent, totalQty); + return (true, OrderSide.Sell, totalQty); + } + + // 止损:达到止损线按配置卖出清仓或暂停策略 + if (dcaParams.StopLossPercent > 0 && currentPrice <= avgEntry * (1 - dcaParams.StopLossPercent / 100m)) + { + if (dcaParams.StopLossSellOut) + { + _logger.LogWarning( + "DCA 止损清仓触发: {StrategyId} 当前价 {Price} <= 均价 {AvgEntry} × (1 - {StopLoss}%),清仓 {Qty}", + strategy.Id, currentPrice, avgEntry, dcaParams.StopLossPercent, totalQty); + return (true, OrderSide.Sell, totalQty); + } + + // 保守动作:暂停定投保留持仓,等待人工决策 + _logger.LogWarning( + "DCA 止损暂停触发: {StrategyId} 当前价 {Price} <= 均价 {AvgEntry} × (1 - {StopLoss}%),暂停定投(保留持仓)", + strategy.Id, currentPrice, avgEntry, dcaParams.StopLossPercent); + await _strategyService.UpdateStrategyStatusAsync(strategy.Id, StrategyStatus.Paused, ct) + .ConfigureAwait(false); + return (false, strategy.Side, strategy.Quantity); + } + + return null; + } + /// /// 当前交易模式是否支持原生条件单(仅合约模式支持)。 /// diff --git a/src/MarketAssistant.App.Services/Trading/TradingDataService.cs b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs index a79854f..1d8adae 100644 --- a/src/MarketAssistant.App.Services/Trading/TradingDataService.cs +++ b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs @@ -191,7 +191,8 @@ UPDATE strategies /// 仅更新策略的最后评估时间(不增加执行计数),用于 AI 信号策略的评估节流: /// 无论 Agent 是否实际成交,一次评估后都记入冷却期,避免未成交时每个价格 tick 重复调用 LLM。 /// - public async Task UpdateStrategyLastTriggeredAtAsync(string id, CancellationToken ct = default) + /// virtual 供单元测试替换。 + public virtual async Task UpdateStrategyLastTriggeredAtAsync(string id, CancellationToken ct = default) { await EnsureInitializedAsync(InitializeDatabaseAsync); await using var conn = await OpenConnectionAsync(ct); @@ -240,7 +241,8 @@ UPDATE strategies } } - public async Task UpdateStrategyCustomParamsAsync(string id, string? customParams, CancellationToken ct = default) + /// virtual 供单元测试替换。 + public virtual async Task UpdateStrategyCustomParamsAsync(string id, string? customParams, CancellationToken ct = default) { await EnsureInitializedAsync(InitializeDatabaseAsync); await using var conn = await OpenConnectionAsync(ct); @@ -252,10 +254,32 @@ public async Task UpdateStrategyCustomParamsAsync(string id, string? customParam await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } + /// + /// 更新策略护栏位(止损/止盈价)。AI 决策生成护栏后调用, + /// 使 StrategyEngine 的硬性边界评估与 TradeExecutor 的风控检查立即生效。 + /// + public async Task UpdateStrategyGuardrailsAsync( + string id, decimal? stopLossPrice, decimal? takeProfitPrice, CancellationToken ct = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + await using var conn = await OpenConnectionAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE strategies SET stop_loss_price = @stopLoss, take_profit_price = @takeProfit + WHERE id = @id AND environment = @environment + """; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + cmd.Parameters.AddWithValue("@stopLoss", ToDbNullable(stopLossPrice)); + cmd.Parameters.AddWithValue("@takeProfit", ToDbNullable(takeProfitPrice)); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + /// /// 更新追踪止损的峰值/谷值价格(持久化,防止重启丢失) /// - public async Task UpdateStrategyTrailingPeakAsync(string id, decimal? trailingPeakPrice, CancellationToken ct = default) + /// virtual 供单元测试替换。 + public virtual async Task UpdateStrategyTrailingPeakAsync(string id, decimal? trailingPeakPrice, CancellationToken ct = default) { await EnsureInitializedAsync(InitializeDatabaseAsync); await using var conn = await OpenConnectionAsync(ct); @@ -811,7 +835,8 @@ ORDER BY opened_at ASC /// /// 计算指定 symbol 的加权平均开仓价(仅未平仓部分,用于风控与 UI) /// - public async Task GetOpenPositionAvgEntryPriceAsync(string symbol, CancellationToken ct = default) + /// virtual 供单元测试替换。 + public virtual async Task GetOpenPositionAvgEntryPriceAsync(string symbol, CancellationToken ct = default) { await EnsureInitializedAsync(InitializeDatabaseAsync); await using var conn = await OpenConnectionAsync(ct); diff --git a/src/MarketAssistant.App.Services/Trading/TradingStrategyService.cs b/src/MarketAssistant.App.Services/Trading/TradingStrategyService.cs index 61a75ca..5e5ec4a 100644 --- a/src/MarketAssistant.App.Services/Trading/TradingStrategyService.cs +++ b/src/MarketAssistant.App.Services/Trading/TradingStrategyService.cs @@ -19,7 +19,8 @@ public TradingStrategyService(TradingDataService dataService) public Task GetStrategyAsync(string strategyId, CancellationToken ct = default) => _dataService.GetStrategyAsync(strategyId, ct); - public Task> GetStrategiesByStatusAsync( + /// virtual 供单元测试替换(AISignal 硬性边界行为测试)。 + public virtual Task> GetStrategiesByStatusAsync( StrategyStatus status, CancellationToken ct = default) => _dataService.GetStrategiesByStatusAsync(status, ct); diff --git a/src/MarketAssistant.App/Assets/Images/icon_ai.svg b/src/MarketAssistant.App/Assets/Images/icon_ai.svg new file mode 100644 index 0000000..28e989e --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/icon_ai.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/MarketAssistant.App/Assets/Images/icon_bolt.svg b/src/MarketAssistant.App/Assets/Images/icon_bolt.svg new file mode 100644 index 0000000..c80a26f --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/icon_bolt.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/MarketAssistant.App/Assets/Images/icon_bulb.svg b/src/MarketAssistant.App/Assets/Images/icon_bulb.svg new file mode 100644 index 0000000..708c2fe --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/icon_bulb.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/MarketAssistant.App/Assets/Images/icon_chart.svg b/src/MarketAssistant.App/Assets/Images/icon_chart.svg new file mode 100644 index 0000000..1632293 --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/icon_chart.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/MarketAssistant.App/Assets/Images/icon_chat.svg b/src/MarketAssistant.App/Assets/Images/icon_chat.svg new file mode 100644 index 0000000..6ce687c --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/icon_chat.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/MarketAssistant.App/Assets/Images/icon_clipboard.svg b/src/MarketAssistant.App/Assets/Images/icon_clipboard.svg new file mode 100644 index 0000000..2b30680 --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/icon_clipboard.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/MarketAssistant.App/Assets/Images/icon_danger.svg b/src/MarketAssistant.App/Assets/Images/icon_danger.svg new file mode 100644 index 0000000..4ebc3a4 --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/icon_danger.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/MarketAssistant.App/Assets/Images/icon_document.svg b/src/MarketAssistant.App/Assets/Images/icon_document.svg new file mode 100644 index 0000000..298a69e --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/icon_document.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/MarketAssistant.App/Assets/Images/icon_edit.svg b/src/MarketAssistant.App/Assets/Images/icon_edit.svg new file mode 100644 index 0000000..a98f23c --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/icon_edit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/MarketAssistant.App/Assets/Images/icon_grid.svg b/src/MarketAssistant.App/Assets/Images/icon_grid.svg new file mode 100644 index 0000000..370a7a5 --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/icon_grid.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/MarketAssistant.App/Assets/Images/icon_magnifier.svg b/src/MarketAssistant.App/Assets/Images/icon_magnifier.svg new file mode 100644 index 0000000..5743603 --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/icon_magnifier.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/MarketAssistant.App/Assets/Images/icon_money.svg b/src/MarketAssistant.App/Assets/Images/icon_money.svg new file mode 100644 index 0000000..81a4b20 --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/icon_money.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/MarketAssistant.App/Assets/Images/icon_refresh.svg b/src/MarketAssistant.App/Assets/Images/icon_refresh.svg new file mode 100644 index 0000000..cc5e146 --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/icon_refresh.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/MarketAssistant.App/Assets/Images/icon_rocket.svg b/src/MarketAssistant.App/Assets/Images/icon_rocket.svg new file mode 100644 index 0000000..365ceb9 --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/icon_rocket.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/MarketAssistant.App/Assets/Images/icon_scale.svg b/src/MarketAssistant.App/Assets/Images/icon_scale.svg new file mode 100644 index 0000000..95524fc --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/icon_scale.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/MarketAssistant.App/Assets/Images/icon_shield.svg b/src/MarketAssistant.App/Assets/Images/icon_shield.svg new file mode 100644 index 0000000..fd80ef7 --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/icon_shield.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/MarketAssistant.App/Assets/Images/icon_users.svg b/src/MarketAssistant.App/Assets/Images/icon_users.svg new file mode 100644 index 0000000..4ec857a --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/icon_users.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/MarketAssistant.App/Assets/Images/icon_warning.svg b/src/MarketAssistant.App/Assets/Images/icon_warning.svg new file mode 100644 index 0000000..f018730 --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/icon_warning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/MarketAssistant.App/Assets/Raw/kline_chart.html b/src/MarketAssistant.App/Assets/Raw/kline_chart.html index 67672f6..249be6c 100644 --- a/src/MarketAssistant.App/Assets/Raw/kline_chart.html +++ b/src/MarketAssistant.App/Assets/Raw/kline_chart.html @@ -6,11 +6,20 @@ 股票K线图 diff --git a/src/MarketAssistant.App/Resources/Styles/CardStyles.axaml b/src/MarketAssistant.App/Resources/Styles/CardStyles.axaml index ebf13e1..c4bdc9c 100644 --- a/src/MarketAssistant.App/Resources/Styles/CardStyles.axaml +++ b/src/MarketAssistant.App/Resources/Styles/CardStyles.axaml @@ -41,10 +41,10 @@ - - - - + + + + @@ -56,7 +56,7 @@ @@ -73,9 +73,9 @@ - - - + + + @@ -147,7 +147,7 @@ diff --git a/src/MarketAssistant.App/Resources/Styles/Colors.axaml b/src/MarketAssistant.App/Resources/Styles/Colors.axaml index f50d515..ad467ce 100644 --- a/src/MarketAssistant.App/Resources/Styles/Colors.axaml +++ b/src/MarketAssistant.App/Resources/Styles/Colors.axaml @@ -3,30 +3,35 @@ #1976D2 - #1565C0 + #0D47A1 #42A5F5 #424242 #616161 #FF6B35 - - #4CAF50 - #FF9800 - #F44336 - #2196F3 + + #10B981 + #F59E0B + #EF4444 + #3B82F6 - #66BB6A - #388E3C - #FFB74D - #F57C00 - #64B5F6 - #1976D2 + #34D399 + #059669 + #FBBF24 + #D97706 + #60A5FA + #2563EB #F44336 #4CAF50 - + + + + + + #9E9E9E @@ -40,21 +45,21 @@ #FFFFFF #F0F1F3 - - #212121 - #757575 - #BDBDBD + + #1A1D26 + #6B7280 + #9CA3AF #E0E2E8 #EEF0F4 #EEF0F4 - + #F5F5F5 #EEEEEE - #E3F2FD - #1565C0 + #261976D2 + #0D47A1 #40000000 @@ -75,6 +80,10 @@ #FEE2E2 #EF4444 #991B1B + + + #1976D2 + #FEE2E2 @@ -94,10 +103,10 @@ #1E2636 #182032 - + #161C2A #1B2233 - #262D3D + #261976D2 #42A5F5 @@ -118,6 +127,8 @@ #93C5FD #1EEF4444 #EF4444 + #42A5F5 + #2EEF4444 #FCA5A5 @@ -156,15 +167,19 @@ - - - #2D2D30 - #3F3F46 - #3F3F46 - #4F4F56 - #4CAF50 + + + + + + + #161C2A + #1E2636 + #1B2233 + #20293D + #10B981 #FFFFFF - #8F8F8F + #8894A8 #60000000 @@ -175,101 +190,15 @@ - - #dc3545 - - 0 4 16 0 #60000000 0 8 32 0 #30000000 0 2 8 0 #15000000 0 2 8 0 #10000000 0 1 3 0 #20000000 + -4 0 16 0 #40000000 + 0 -2 8 0 #08000000 + 0 2 4 0 #301976D2 - - #FEF3C7 - #F59E0B - #92400E - #78350F - #D97706 - #FFFBEB - #FFF4E5 - #FFB84C - - - - - - - - - - - - - - #DBEAFE - #3B82F6 - #1E40AF - #1E3A8A - #2563EB - #EFF6FF - #F0F9FF - - - - - - - - - - - - - #ECFDF5 - #10B981 - #065F46 - #059669 - #ECFDF5 - - - - - - - - - - - #FEE2E2 - #FEF2F2 - #EF4444 - #991B1B - #DC2626 - #FEE2E2 - #FFF4E5 - #991B1B - #DC2626 - #7F1D1D - - - - - - - - - - - - - - - #dc3545 - #007bff - - - diff --git a/src/MarketAssistant.App/Resources/Styles/FormStyles.axaml b/src/MarketAssistant.App/Resources/Styles/FormStyles.axaml index dffdbd5..af560ec 100644 --- a/src/MarketAssistant.App/Resources/Styles/FormStyles.axaml +++ b/src/MarketAssistant.App/Resources/Styles/FormStyles.axaml @@ -27,7 +27,7 @@ diff --git a/src/MarketAssistant.App/Resources/Styles/Spacing.axaml b/src/MarketAssistant.App/Resources/Styles/Spacing.axaml index 48f3563..6185969 100644 --- a/src/MarketAssistant.App/Resources/Styles/Spacing.axaml +++ b/src/MarketAssistant.App/Resources/Styles/Spacing.axaml @@ -44,11 +44,11 @@ 28 - 24 - 20 + 22 + 18 18 16 - 15 + 14 14 13 12 @@ -73,9 +73,8 @@ 32 - 16 12 - 10 + 8 8 6 4 @@ -189,6 +188,8 @@ 0.3 0.6 1.5 + diff --git a/src/MarketAssistant.App/Resources/Styles/TextStyles.axaml b/src/MarketAssistant.App/Resources/Styles/TextStyles.axaml index c7de234..1055722 100644 --- a/src/MarketAssistant.App/Resources/Styles/TextStyles.axaml +++ b/src/MarketAssistant.App/Resources/Styles/TextStyles.axaml @@ -167,20 +167,4 @@ - - - - - - - - diff --git a/src/MarketAssistant.App/Services/ServiceCollectionExtensions.cs b/src/MarketAssistant.App/Services/ServiceCollectionExtensions.cs index b871d42..a971076 100644 --- a/src/MarketAssistant.App/Services/ServiceCollectionExtensions.cs +++ b/src/MarketAssistant.App/Services/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using MarketAssistant.Applications.Charts; using MarketAssistant.Infrastructure.AdaptiveCards; +using MarketAssistant.Infrastructure.AdaptiveCards.Parsers; using MarketAssistant.Infrastructure.Core; using MarketAssistant.Services.Dialog; using MarketAssistant.Services.Navigation; @@ -32,7 +33,15 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddSingleton(); services.AddSingleton(); - // AdaptiveCard 转换器(依赖 App.Services 注册的 parsers) + // AdaptiveCard 解析器责任链(解析器实现位于本工程 Infrastructure/AdaptiveCards/Parsers) + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // AdaptiveCard 转换器(依赖上方注册的解析器责任链) services.AddSingleton(); // 注册全局异常处理器(Singleton,由 DI 创建实例) diff --git a/src/MarketAssistant.App/ViewModels/MCPConfigPageViewModel.cs b/src/MarketAssistant.App/ViewModels/MCPConfigPageViewModel.cs index 4f0c04f..7064d0d 100644 --- a/src/MarketAssistant.App/ViewModels/MCPConfigPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/MCPConfigPageViewModel.cs @@ -134,6 +134,8 @@ private void LoadServerConfigs() { ServerConfigs.Add(config); } + + PromoteLegacyToolWhitelistConfigs(); } catch (Exception ex) { @@ -142,6 +144,39 @@ private void LoadServerConfigs() } } + /// + /// 识别旧版本保存的配置并提示重新勾选工具白名单。 + /// 旧配置 JSON 无 ToolsSchemaVersion 字段(反序列化为 0);空白名单在现行语义下 + /// 不再向 Agent 暴露任何工具,需提示用户重新勾选或开启“允许全部工具”。 + /// 提示后仅升级已启用服务器的版本号并落盘,保证每台服务器只提示一次; + /// 禁用服务器保持旧版本号,待其启用并保存时自然升级。 + /// + private void PromoteLegacyToolWhitelistConfigs() + { + var legacyEnabledConfigs = ServerConfigs + .Where(config => config.IsEnabled && config.ToolsSchemaVersion < MCPServerConfig.CurrentToolsSchemaVersion) + .ToList(); + + if (legacyEnabledConfigs.Count == 0) + { + return; + } + + var serverNames = legacyEnabledConfigs + .Select(config => string.IsNullOrWhiteSpace(config.Name) ? config.Id : config.Name); + _notificationService?.ShowWarning( + "MCP 工具白名单机制已升级:未勾选工具的服务器默认不向 Agent 暴露任何工具。" + + $"请进入 MCP 配置页为「{string.Join("、", serverNames)}」重新勾选工具,或开启“允许全部工具”。", + 10000); + + foreach (var config in legacyEnabledConfigs) + { + config.ToolsSchemaVersion = MCPServerConfig.CurrentToolsSchemaVersion; + } + + _configService.SaveConfigs(); + } + /// /// 添加服务器 /// @@ -156,7 +191,8 @@ private void AddServer() { Id = Guid.NewGuid().ToString(), TransportType = "stdio", - IsEnabled = true + IsEnabled = true, + ToolsSchemaVersion = MCPServerConfig.CurrentToolsSchemaVersion }; LoadConfigToUI(_editingConfig); @@ -186,7 +222,8 @@ private void EditServer() EnvironmentVariables = new Dictionary(SelectedConfig.EnvironmentVariables), Category = SelectedConfig.Category, AllowedTools = [.. SelectedConfig.AllowedTools], - AllowAllTools = SelectedConfig.AllowAllTools + AllowAllTools = SelectedConfig.AllowAllTools, + ToolsSchemaVersion = SelectedConfig.ToolsSchemaVersion }; LoadConfigToUI(_editingConfig); IsEditing = true; @@ -464,6 +501,7 @@ private void SaveUIToConfig(MCPServerConfig config) config.Arguments = Arguments; config.IsEnabled = IsEnabled; config.AllowAllTools = AllowAllTools; + config.ToolsSchemaVersion = MCPServerConfig.CurrentToolsSchemaVersion; config.AllowedTools = AllowAllTools ? [] : ToolItems.Where(item => item.IsSelected).Select(item => item.Name).ToList(); diff --git a/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs b/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs index 1ae5337..d5ee3b2 100644 --- a/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs @@ -1,10 +1,12 @@ using System.Collections.ObjectModel; +using System.Globalization; using System.Text.Json; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using MarketAssistant.DataProviders; using MarketAssistant.Infrastructure.Core; using MarketAssistant.Infrastructure.Extensions; using MarketAssistant.Services.Dialog; @@ -21,32 +23,85 @@ public partial class StrategyConfigViewModel : ViewModelBase, IDisposable private readonly TradingDataService _dataService; private readonly MarketMonitor _marketMonitor; private readonly IDialogService _dialogService; + private readonly BinanceMarketDataService _marketDataService; private bool _disposed; public ObservableCollection Strategies { get; } = []; [ObservableProperty] private string _newSymbol = string.Empty; - [ObservableProperty] private StrategyType _newStrategyType; - partial void OnNewStrategyTypeChanged(StrategyType value) + /// 当前选择的场景卡片(UI 唯一创建入口:智能策略 / 省心定投 / 区间网格)。 + [ObservableProperty] private ScenarioKind _selectedScenario = ScenarioKind.AISmart; + + /// 当前选择的风险档案,驱动场景参数预填与引擎兜底护栏。 + [ObservableProperty] private RiskProfile _selectedProfile = RiskProfile.Balanced; + + partial void OnSelectedScenarioChanged(ScenarioKind value) + { + OnPropertyChanged(nameof(IsAISmartScenario)); + OnPropertyChanged(nameof(IsDCAScenario)); + OnPropertyChanged(nameof(IsGridScenario)); + ApplyScenarioPreset(); + } + + partial void OnSelectedProfileChanged(RiskProfile value) + { + OnPropertyChanged(nameof(IsConservativeProfile)); + OnPropertyChanged(nameof(IsBalancedProfile)); + OnPropertyChanged(nameof(IsAggressiveProfile)); + ApplyScenarioPreset(); + } + + public bool IsAISmartScenario + { + get => SelectedScenario == ScenarioKind.AISmart; + set { if (value) SelectedScenario = ScenarioKind.AISmart; } + } + + public bool IsDCAScenario + { + get => SelectedScenario == ScenarioKind.DCA; + set { if (value) SelectedScenario = ScenarioKind.DCA; } + } + + public bool IsGridScenario + { + get => SelectedScenario == ScenarioKind.Grid; + set { if (value) SelectedScenario = ScenarioKind.Grid; } + } + + public bool IsConservativeProfile { - OnPropertyChanged(nameof(IsGridTrading)); - OnPropertyChanged(nameof(IsDCA)); - OnPropertyChanged(nameof(IsBasicStrategy)); - OnPropertyChanged(nameof(SideHintText)); + get => SelectedProfile == RiskProfile.Conservative; + set { if (value) SelectedProfile = RiskProfile.Conservative; } } - [ObservableProperty] private OrderSide _newSide = OrderSide.Buy; + public bool IsBalancedProfile + { + get => SelectedProfile == RiskProfile.Balanced; + set { if (value) SelectedProfile = RiskProfile.Balanced; } + } - partial void OnNewSideChanged(OrderSide value) + public bool IsAggressiveProfile { - OnPropertyChanged(nameof(SideHintText)); + get => SelectedProfile == RiskProfile.Aggressive; + set { if (value) SelectedProfile = RiskProfile.Aggressive; } } - [ObservableProperty] private string _newTriggerPrice = string.Empty; - [ObservableProperty] private string _newQuantity = string.Empty; - [ObservableProperty] private string _newStopLossPrice = string.Empty; - [ObservableProperty] private string _newTakeProfitPrice = string.Empty; + public OrderSide[] OrderSides => Enum.GetValues(); + + // ---- 智能策略(AI)参数 ---- + [ObservableProperty] private string _aiBudgetUsdt = string.Empty; + [ObservableProperty] private string _aiIntervalSeconds = string.Empty; + [ObservableProperty] private string _aiConfidenceThreshold = string.Empty; + [ObservableProperty] private string _aiMaxPositionPercent = string.Empty; + [ObservableProperty] private string _aiStopLossPercent = string.Empty; + [ObservableProperty] private string _aiTakeProfitPercent = string.Empty; + [ObservableProperty] private bool _isAiTrailingExit; + [ObservableProperty] private string _aiTrailingPercent = string.Empty; + [ObservableProperty] private bool _aiShadowMode; + [ObservableProperty] private OrderSide _aiSide = OrderSide.Buy; + [ObservableProperty] private bool _isCreating; /// @@ -54,44 +109,54 @@ partial void OnNewSideChanged(OrderSide value) /// [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; + [ObservableProperty] private string _gridStopLossPrice = string.Empty; + [ObservableProperty] private string _gridTakeProfitPrice = string.Empty; - // DCA 参数 + // ---- DCA 参数 ---- [ObservableProperty] private string _dcaIntervalSeconds = "86400"; [ObservableProperty] private string _dcaAmountPerInterval = string.Empty; [ObservableProperty] private string _dcaMaxBuyPrice = string.Empty; [ObservableProperty] private string _dcaDoubleBuyBelowPrice = string.Empty; + [ObservableProperty] private string _dcaTakeProfitPercent = string.Empty; + [ObservableProperty] private string _dcaStopLossPercent = string.Empty; + [ObservableProperty] private bool _dcaStopLossSellOut; /// - /// 当前选择的策略类型是否为网格交易 + /// 按当前风险档案与场景预填表单参数(网格区间需现价,仅预填格数)。 /// - 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; + private void ApplyScenarioPreset() + { + switch (SelectedScenario) + { + case ScenarioKind.AISmart: + var ai = ScenarioPresets.GetAISignalPreset(SelectedProfile); + AiIntervalSeconds = ai.AnalysisIntervalSeconds.ToString(); + AiConfidenceThreshold = ai.ConfidenceThreshold.ToString(); + AiMaxPositionPercent = ai.MaxPositionPercent.ToString(CultureInfo.InvariantCulture); + AiStopLossPercent = ai.StopLossPercent.ToString(CultureInfo.InvariantCulture); + AiTakeProfitPercent = ai.TakeProfitPercent.ToString(CultureInfo.InvariantCulture); + IsAiTrailingExit = ai.ExitStyle == ExitStyle.TrailingStop; + AiTrailingPercent = ai.TrailingPercent.ToString(CultureInfo.InvariantCulture); + break; + + case ScenarioKind.DCA: + var dca = ScenarioPresets.GetDcaPreset(SelectedProfile); + DcaIntervalSeconds = dca.IntervalSeconds.ToString(); + DcaTakeProfitPercent = dca.TakeProfitPercent.ToString(CultureInfo.InvariantCulture); + DcaStopLossPercent = dca.StopLossPercent.ToString(CultureInfo.InvariantCulture); + DcaStopLossSellOut = dca.StopLossSellOut; + break; + + case ScenarioKind.Grid: + GridCount = ScenarioPresets.GetGridPreset(SelectedProfile).GridCount.ToString(); + break; + } + } // 风控配置 [ObservableProperty] private RiskConfig _riskConfig = new(); @@ -106,6 +171,7 @@ public StrategyConfigViewModel( TradingDataService dataService, MarketMonitor marketMonitor, IDialogService dialogService, + BinanceMarketDataService marketDataService, ILogger logger) : base(logger) { @@ -113,8 +179,10 @@ public StrategyConfigViewModel( _dataService = dataService; _marketMonitor = marketMonitor; _dialogService = dialogService; + _marketDataService = marketDataService; IsMonitorRunning = _marketMonitor.IsRunning; _marketMonitor.StatusChanged += OnMonitorStatusChanged; + ApplyScenarioPreset(); _ = InitializeAsync(); } @@ -148,77 +216,18 @@ private async Task CreateStrategyAsync() await SafeExecuteAsync(async () => { ValidationError = string.Empty; + var symbol = NewSymbol.ToUpperInvariant().Trim(); - var strategy = new TradingStrategy + TradingStrategy? strategy = SelectedScenario switch { - Symbol = NewSymbol.ToUpper().Trim(), - Type = NewStrategyType, - Status = StrategyStatus.Active, - Side = NewSide + ScenarioKind.AISmart => BuildAISignalStrategy(symbol), + ScenarioKind.DCA => BuildDCAStrategy(symbol), + ScenarioKind.Grid => BuildGridStrategy(symbol), + _ => null }; - 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; - } + if (strategy == null) + return; // 校验失败,ValidationError 已设置 await _strategyService.SaveStrategyAsync(strategy); Strategies.Insert(0, strategy); @@ -227,6 +236,184 @@ await SafeExecuteAsync(async () => }, "创建策略"); } + /// 构建智能策略(AI 信号)。止损/止盈价由 AI 决策给出,此处仅保存档案预设约束。 + private TradingStrategy? BuildAISignalStrategy(string symbol) + { + if (!decimal.TryParse(AiBudgetUsdt, NumberStyles.Float, CultureInfo.InvariantCulture, out var budget) || budget <= 0) + { + ValidationError = "请填写有效的单次开仓预算(USDT)"; + return null; + } + if (budget < RiskConfig.MinOrderAmount) + { + ValidationError = $"预算 {budget:F2} USDT 低于最小下单金额 {RiskConfig.MinOrderAmount:F2} USDT,容易被交易所拒绝"; + return null; + } + + var aiParams = new AISignalParams + { + RiskProfile = SelectedProfile.ToString(), + BudgetUsdt = budget, + AnalysisIntervalSeconds = ParseIntOr(AiIntervalSeconds, 600), + ConfidenceThreshold = ParseIntOr(AiConfidenceThreshold, 65), + MaxPositionPercent = ParseDecimalOr(AiMaxPositionPercent, 10), + StopLossPercent = ParseDecimalOr(AiStopLossPercent, 8), + TakeProfitPercent = ParseDecimalOr(AiTakeProfitPercent, 15), + ExitStyle = IsAiTrailingExit ? "TrailingStop" : "FixedStop", + TrailingPercent = ParseDecimalOr(AiTrailingPercent, 5), + ShadowMode = AiShadowMode + }; + + return new TradingStrategy + { + Symbol = symbol, + Type = StrategyType.AISignal, + Status = StrategyStatus.Active, + Side = AiSide, + // AI 场景的 Quantity 语义为单次开仓预算(USDT),实际下单量由执行器按置信度换算 + Quantity = budget, + MaxPositionPercent = aiParams.MaxPositionPercent, + CustomParams = JsonSerializer.Serialize(aiParams) + }; + } + + /// 构建省心定投策略。金额必须满足交易所最小下单金额,避免被拒后策略自动暂停。 + private TradingStrategy? BuildDCAStrategy(string symbol) + { + if (!decimal.TryParse(DcaAmountPerInterval, NumberStyles.Float, CultureInfo.InvariantCulture, out var amount) || amount <= 0) + { + ValidationError = "请填写有效的定投金额(USDT)"; + return null; + } + if (amount < RiskConfig.MinOrderAmount) + { + ValidationError = $"定投金额 {amount:F2} USDT 低于最小下单金额 {RiskConfig.MinOrderAmount:F2} USDT,会被交易所拒绝"; + return null; + } + + var dcaParams = new DCAParams + { + RiskProfile = SelectedProfile.ToString(), + AmountPerInterval = amount, + IntervalSeconds = ParseIntOr(DcaIntervalSeconds, 86400), + MaxBuyPrice = ParseDecimalOr(DcaMaxBuyPrice, 0), + DoubleBuyBelowPrice = ParseDecimalOr(DcaDoubleBuyBelowPrice, 0), + TakeProfitPercent = ParseDecimalOr(DcaTakeProfitPercent, 0), + StopLossPercent = ParseDecimalOr(DcaStopLossPercent, 0), + StopLossSellOut = DcaStopLossSellOut + }; + + return new TradingStrategy + { + Symbol = symbol, + Type = StrategyType.DCA, + Status = StrategyStatus.Active, + Side = OrderSide.Buy, + TriggerPrice = dcaParams.MaxBuyPrice, + // DCA 的 Quantity 存储每次定投的 USDT 金额(代币数量在执行时按实时价格换算) + Quantity = amount, + CustomParams = JsonSerializer.Serialize(dcaParams) + }; + } + + /// 构建区间网格策略。校验间距覆盖双边手续费,破网护栏未填时按档案百分比自动生成。 + private TradingStrategy? BuildGridStrategy(string symbol) + { + if (!decimal.TryParse(GridUpperPrice, NumberStyles.Float, CultureInfo.InvariantCulture, out var upper) || + !decimal.TryParse(GridLowerPrice, NumberStyles.Float, CultureInfo.InvariantCulture, out var lower) || + !int.TryParse(GridCount, NumberStyles.Integer, CultureInfo.InvariantCulture, out var gridCount) || + !decimal.TryParse(GridQuantityPerGrid, NumberStyles.Float, CultureInfo.InvariantCulture, out var qtyPerGrid)) + { + ValidationError = "请填写完整的网格参数(上界价格、下界价格、网格数量、每格数量)"; + return null; + } + if (upper <= lower || gridCount < 2 || qtyPerGrid <= 0) + { + ValidationError = "网格参数无效:上界须高于下界,网格数量 ≥ 2,每格数量 > 0"; + return null; + } + + // 间距必须覆盖双边手续费(单边 0.1%),否则每格利润被手续费吃掉 + var midPrice = (upper + lower) / 2m; + var spacingPercent = midPrice > 0 ? (upper - lower) / gridCount / midPrice * 100m : 0; + if (spacingPercent < 0.2m) + { + ValidationError = $"网格间距 {spacingPercent:F3}% 低于双边手续费(0.2%),请加宽区间或减少格数"; + return null; + } + + var gridPreset = ScenarioPresets.GetGridPreset(SelectedProfile); + var gridParams = new GridTradingParams + { + RiskProfile = SelectedProfile.ToString(), + UpperPrice = upper, + LowerPrice = lower, + GridCount = gridCount, + QuantityPerGrid = qtyPerGrid, + // 破网护栏未填时按风险档案百分比自动生成,保证护栏永不缺失 + StopLossPrice = ParseDecimalOrNullable(GridStopLossPrice) ?? lower * (1 - gridPreset.BreakoutStopLossPercent / 100m), + TakeProfitPrice = ParseDecimalOrNullable(GridTakeProfitPrice) ?? upper * (1 + gridPreset.BreakoutTakeProfitPercent / 100m) + }; + + return new TradingStrategy + { + Symbol = symbol, + Type = StrategyType.GridTrading, + Status = StrategyStatus.Active, + Side = OrderSide.Buy, + TriggerPrice = lower, + Quantity = qtyPerGrid, + CustomParams = JsonSerializer.Serialize(gridParams) + }; + } + + /// + /// 按当前价与风险档案自动生成网格区间(含破网护栏),消除手工定价门槛。 + /// + [RelayCommand] + private async Task GenerateGridRangeAsync() + { + if (string.IsNullOrWhiteSpace(NewSymbol)) + { + ValidationError = "请先填写交易对"; + return; + } + if (!decimal.TryParse(GridQuantityPerGrid, NumberStyles.Float, CultureInfo.InvariantCulture, out var qtyPerGrid) || qtyPerGrid <= 0) + { + ValidationError = "请先填写每格数量,再生成网格区间"; + return; + } + + await SafeExecuteAsync(async () => + { + ValidationError = string.Empty; + var symbol = NewSymbol.ToUpperInvariant().Trim(); + var ticker = await _marketDataService.Get24hrTickerAsync(symbol); + var lastPrice = ticker?.LastPrice; + if (lastPrice is not > 0) + { + ValidationError = $"无法获取 {symbol} 当前价格,请检查交易对或手动填写区间"; + return; + } + + var gridParams = ScenarioPresets.CreateGridParams(SelectedProfile, lastPrice.Value, qtyPerGrid); + GridUpperPrice = gridParams.UpperPrice.ToString(CultureInfo.InvariantCulture); + GridLowerPrice = gridParams.LowerPrice.ToString(CultureInfo.InvariantCulture); + GridCount = gridParams.GridCount.ToString(); + GridStopLossPrice = gridParams.StopLossPrice?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; + GridTakeProfitPrice = gridParams.TakeProfitPrice?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; + }, "生成网格区间"); + } + + private static int ParseIntOr(string? text, int fallback) + => int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) && value > 0 ? value : fallback; + + private static decimal ParseDecimalOr(string? text, decimal fallback) + => decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) && value > 0 ? value : fallback; + + private static decimal? ParseDecimalOrNullable(string? text) + => decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) && value > 0 ? value : null; + [RelayCommand] private async Task ToggleStrategyAsync(TradingStrategy strategy) { @@ -320,20 +507,19 @@ private void ToggleCreateForm() 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"; + GridStopLossPrice = string.Empty; + GridTakeProfitPrice = string.Empty; DcaAmountPerInterval = string.Empty; DcaMaxBuyPrice = string.Empty; DcaDoubleBuyBelowPrice = string.Empty; + AiBudgetUsdt = string.Empty; + AiShadowMode = false; ValidationError = string.Empty; IsCreating = false; + ApplyScenarioPreset(); } public void Dispose() diff --git a/src/MarketAssistant.App/Views/Components/AdaptiveCardView.axaml.cs b/src/MarketAssistant.App/Views/Components/AdaptiveCardView.axaml.cs index f2d6ec4..4286fe0 100644 --- a/src/MarketAssistant.App/Views/Components/AdaptiveCardView.axaml.cs +++ b/src/MarketAssistant.App/Views/Components/AdaptiveCardView.axaml.cs @@ -102,9 +102,9 @@ private Control RenderTextBlock(AdaptiveTextBlock textBlock) 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"), + AdaptiveTextColor.Good => new DynamicResourceExtension("SuccessPanelTextBrush"), + AdaptiveTextColor.Warning => new DynamicResourceExtension("WarningPanelTextBrush"), + AdaptiveTextColor.Attention => new DynamicResourceExtension("DangerPanelTextBrush"), _ => textBlock.IsSubtle ? new DynamicResourceExtension("TextSecondaryBrush") : new DynamicResourceExtension("TextPrimaryBrush") @@ -152,8 +152,8 @@ private Control RenderContainer(AdaptiveContainer container) } else if (container.Style == AdaptiveContainerStyle.Attention) { - border[!Border.BackgroundProperty] = new DynamicResourceExtension("DangerBackgroundBrush"); - border[!Border.BorderBrushProperty] = new DynamicResourceExtension("ErrorDarkTextBrush"); + border[!Border.BackgroundProperty] = new DynamicResourceExtension("DangerPanelBackgroundBrush"); + border[!Border.BorderBrushProperty] = new DynamicResourceExtension("DangerPanelBorderBrush"); border.BorderThickness = new Thickness(3, 0, 0, 0); border.CornerRadius = new CornerRadius(ContainerCornerRadius, 0, 0, ContainerCornerRadius); } diff --git a/src/MarketAssistant.App/Views/Components/AnalysisReportView.axaml b/src/MarketAssistant.App/Views/Components/AnalysisReportView.axaml index 7d5692b..3dd2a72 100644 --- a/src/MarketAssistant.App/Views/Components/AnalysisReportView.axaml +++ b/src/MarketAssistant.App/Views/Components/AnalysisReportView.axaml @@ -6,6 +6,7 @@ xmlns:models="using:MarketAssistant.Models" xmlns:controls="using:MarketAssistant.Views.Controls" xmlns:converts="using:MarketAssistant.Converters" + xmlns:svg="clr-namespace:Avalonia.Svg.Skia;assembly=Svg.Controls.Skia.Avalonia" mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="800" x:Class="MarketAssistant.Views.Components.AnalysisReportView" x:DataType="vm:AnalysisReportViewModel"> @@ -86,14 +87,14 @@ @@ -169,7 +170,13 @@ - + + + + @@ -198,7 +205,13 @@ - + + + + @@ -210,7 +223,7 @@ Margin="{StaticResource RightMargin}" VerticalAlignment="Top"> @@ -234,7 +247,10 @@ - + @@ -260,7 +276,10 @@ - + @@ -299,7 +318,10 @@ IsVisible="{Binding HasConsensusAnalysis}"> - + - + - + - + @@ -420,7 +451,10 @@ Padding="{StaticResource CompactCardPadding}" IsVisible="{Binding LimitationsNote, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> - + 12,4 12,0,0,0 4,0,0,0 - 0 2 4 0 #20000000 - 0 -2 8 0 #08000000 - 0 1 3 0 #15000000 - 0 2 4 0 #301976D2 @@ -141,7 +137,7 @@ Padding="0" IsEnabled="{Binding IsProcessing, Converter={x:Static BoolConverters.Not}}"> @@ -207,8 +203,8 @@ Background="{StaticResource PrimaryLightBrush}" VerticalAlignment="Top" Margin="{StaticResource AvatarLeftMargin}" - BoxShadow="{StaticResource ChatAvatarShadow}"> - + BoxShadow="{StaticResource SmallBoxShadow}"> + @@ -221,7 +217,7 @@ @@ -255,7 +251,7 @@ Padding="{StaticResource DefaultCardPadding}" BoxShadow="{StaticResource UserBubbleBoxShadow}"> @@ -269,8 +265,8 @@ Background="{StaticResource SecondaryLightBrush}" VerticalAlignment="Top" Margin="{StaticResource AvatarRightMargin}" - BoxShadow="{StaticResource ChatAvatarShadow}"> - + BoxShadow="{StaticResource SmallBoxShadow}"> + diff --git a/src/MarketAssistant.App/Views/Components/KLineChartView.cs b/src/MarketAssistant.App/Views/Components/KLineChartView.cs index d2342b9..7426b13 100644 --- a/src/MarketAssistant.App/Views/Components/KLineChartView.cs +++ b/src/MarketAssistant.App/Views/Components/KLineChartView.cs @@ -2,6 +2,7 @@ using Avalonia.Controls; using Avalonia.Layout; using Avalonia.Platform; +using Avalonia.Styling; using MarketAssistant.Applications.Charts.Models; using System.Text.Json; @@ -39,6 +40,34 @@ public IEnumerable? Data public KLineChartView() { InitializeComponent(); + + // 跟随应用主题变化,同步 K 线图配色 + ActualThemeVariantChanged += (_, _) => _ = ApplyThemeToChartAsync(); + } + + /// + /// 将当前主题同步到图表内嵌页面(深色/浅色) + /// + private async Task ApplyThemeToChartAsync() + { + if (_webView == null || !_isInitialized) + { + return; + } + + var theme = ActualThemeVariant == ThemeVariant.Dark ? "dark" : "light"; + try + { + await Dispatcher.UIThread.InvokeAsync(async () => + { + await _webView.InvokeScript($"window.stockChartInterface.setTheme('{theme}');"); + }); + } + catch (Exception ex) + { + // 图表脚本尚未就绪时忽略,导航完成回调会再次同步主题 + System.Diagnostics.Debug.WriteLine($"同步K线图主题失败: {ex.Message}"); + } } /// @@ -134,10 +163,19 @@ private void OnWebViewNavigated(object? sender, WebViewNavigationCompletedEventA Dispatcher.UIThread.Post(() => { _isInitialized = true; + ApplyThemeToChart(); HideLoading(); }); } + /// + /// 在 UI 线程上同步图表主题(导航完成后的首次注入) + /// + private void ApplyThemeToChart() + { + Dispatcher.UIThread.Post(() => _ = ApplyThemeToChartAsync()); + } + /// /// 初始化图表 /// @@ -225,9 +263,9 @@ private string GetDefaultChartHtml() K线图表 @@ -261,9 +299,9 @@ private string GetDefaultChartHtml() if (loading) { this.chart.showLoading('default', { text: '正在加载...', - color: '#4d90fe', - textColor: '#000', - maskColor: 'rgba(255, 255, 255, 0.8)' + color: '#1976D2', + textColor: '#8894A8', + maskColor: 'rgba(10, 14, 23, 0.8)' }); } else { this.chart.hideLoading(); @@ -293,7 +331,7 @@ private string GetDefaultChartHtml() setError: function(hasError, message) { if (hasError) { document.getElementById('chartContainer').innerHTML = - '
❌ ' + message + '
'; + '
加载失败: ' + message + '
'; } } }; diff --git a/src/MarketAssistant.App/Views/Components/ProgressDisplayView.axaml b/src/MarketAssistant.App/Views/Components/ProgressDisplayView.axaml index 0366f87..b40a650 100644 --- a/src/MarketAssistant.App/Views/Components/ProgressDisplayView.axaml +++ b/src/MarketAssistant.App/Views/Components/ProgressDisplayView.axaml @@ -3,6 +3,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="200" + xmlns:svg="clr-namespace:Avalonia.Svg.Skia;assembly=Svg.Controls.Skia.Avalonia" x:Class="MarketAssistant.Views.Components.ProgressDisplayView" x:Name="Root"> @@ -50,11 +51,18 @@ - + + + + @@ -137,9 +145,10 @@ - + diff --git a/src/MarketAssistant.App/Views/Pages/AgentAnalysisPageView.axaml b/src/MarketAssistant.App/Views/Pages/AgentAnalysisPageView.axaml index bfcc4a4..d175e3e 100644 --- a/src/MarketAssistant.App/Views/Pages/AgentAnalysisPageView.axaml +++ b/src/MarketAssistant.App/Views/Pages/AgentAnalysisPageView.axaml @@ -7,6 +7,7 @@ xmlns:controls="using:MarketAssistant.Views.Controls" xmlns:converts="using:MarketAssistant.Converters" xmlns:models="using:MarketAssistant.Services.Archive" + xmlns:svg="clr-namespace:Avalonia.Svg.Skia;assembly=Svg.Controls.Skia.Avalonia" mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="800" x:Class="MarketAssistant.Views.Pages.AgentAnalysisPageView" x:DataType="vm:AgentAnalysisViewModel" @@ -92,7 +93,10 @@ Command="{Binding ExportReportCommand}" IsEnabled="{Binding CanExportReport}"> - + @@ -182,23 +186,24 @@ IsHitTestVisible="False"/> - + + - + - - + - + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - @@ -511,11 +494,11 @@
public decimal UpperPrice { get; set; } + /// + /// 风险档案(Conservative/Balanced/Aggressive),供引擎在参数缺失时兜底 + /// + public string RiskProfile { get; set; } = "Balanced"; + /// /// 网格下界价格 /// @@ -56,6 +61,11 @@ public class GridTradingParams /// public class DCAParams { + /// + /// 风险档案(Conservative/Balanced/Aggressive),供引擎在参数缺失时兜底 + /// + public string RiskProfile { get; set; } = "Balanced"; + /// /// 定投间隔(秒) /// @@ -96,4 +106,19 @@ public class DCAParams /// 已加倍次数。用于上限判断,持久化在 CustomParams 中。 /// public int DoubleBuyCount { get; set; } + + /// + /// 定投止盈线(%):持仓均价上涨达到此百分比时全部卖出获利了结。0 表示不启用。 + /// + public decimal TakeProfitPercent { get; set; } + + /// + /// 定投止损线(%):持仓均价下跌达到此百分比时触发止损动作。0 表示不启用。 + /// + public decimal StopLossPercent { get; set; } + + /// + /// 止损动作:true = 触发止损线后清仓卖出;false = 仅暂停定投(保留持仓,保守默认)。 + /// + public bool StopLossSellOut { get; set; } } diff --git a/tests/Application/AssetInfoServiceTest.cs b/tests/Application/AssetInfoServiceTest.cs index 8193010..d0a4855 100644 --- a/tests/Application/AssetInfoServiceTest.cs +++ b/tests/Application/AssetInfoServiceTest.cs @@ -9,7 +9,7 @@ namespace TestMarketAssistant.Application; /// -/// IAssetInfoService 接口测试(覆盖 A股 和 虚拟币 实现) +/// IAssetInfoService 接口测试(A股实现,真实 API 集成验证) /// [TestClass] public class AssetInfoServiceTest @@ -30,12 +30,9 @@ public void Setup() services.AddLogging(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); // 注册被测试的服务 services.AddKeyedSingleton(MarketType.AShare); - services.AddKeyedSingleton(MarketType.Crypto); _serviceProvider = services.BuildServiceProvider(); _marketContext = _serviceProvider.GetRequiredService(); @@ -102,56 +99,4 @@ public async Task GetHotAssetsAsync_AShare_ShouldReturnHotList() 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/KLineServiceTest.cs b/tests/Application/KLineServiceTest.cs index 2d59a05..565c16c 100644 --- a/tests/Application/KLineServiceTest.cs +++ b/tests/Application/KLineServiceTest.cs @@ -4,6 +4,7 @@ using MarketAssistant.Infrastructure.Core; using MarketAssistant.Services; using MarketAssistant.DataProviders; +using MarketAssistant.DataProviders.AShare; using MarketAssistant.Services.Settings; using Microsoft.Extensions.DependencyInjection; using Moq; @@ -23,12 +24,17 @@ public void Setup() { var services = new ServiceCollection(); - // 注册依赖服务 + // 注册 A 股数据提供者(ZhiTuMarketClient,AShareKLineService 构造依赖) + services.AddAShareDataProviders(); + // BinanceMarketDataService 重构后依赖 IMemoryCache + services.AddMemoryCache(); + + // 注册依赖服务(智兔令牌从环境变量读取,不在代码中硬编码) var mockUserSettingService = new Mock(); mockUserSettingService.Setup(s => s.CurrentSetting) .Returns(new UserSetting { - ZhiTuApiToken = "test-token" + ZhiTuApiToken = Environment.GetEnvironmentVariable("ZHITU_API_TOKEN") ?? "" }); services.AddSingleton(mockUserSettingService.Object); diff --git a/tests/Infrastructure/DiagnosticsTest.cs b/tests/Infrastructure/DiagnosticsTest.cs index d477d72..dc0dc24 100644 --- a/tests/Infrastructure/DiagnosticsTest.cs +++ b/tests/Infrastructure/DiagnosticsTest.cs @@ -13,11 +13,18 @@ public sealed class DiagnosticsTest public void TokenTracking_ShouldAttachUsageToCurrentMarketAssistantActivity() { Activity? stoppedActivity = null; + // 方法级并行下其他测试也会产生同名 Source 的 Activity,按名称过滤避免并行污染 using var listener = new ActivityListener { ShouldListenTo = source => source.Name == MarketAssistantDiagnostics.SourceName, Sample = static (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, - ActivityStopped = activity => stoppedActivity = activity + ActivityStopped = activity => + { + if (activity.DisplayName == "test.agent.run") + { + stoppedActivity = activity; + } + } }; ActivitySource.AddActivityListener(listener); var middleware = new TokenTrackingMiddleware(NullLogger.Instance); @@ -43,11 +50,18 @@ public void TokenTracking_ShouldAttachUsageToCurrentMarketAssistantActivity() public void RecordException_ShouldSetErrorStatusWithoutRecordingMessage() { Activity? stoppedActivity = null; + // 方法级并行下其他测试也会产生同名 Source 的 Activity,按名称过滤避免并行污染 using var listener = new ActivityListener { ShouldListenTo = source => source.Name == MarketAssistantDiagnostics.SourceName, Sample = static (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, - ActivityStopped = activity => stoppedActivity = activity + ActivityStopped = activity => + { + if (activity.DisplayName == "test.failure") + { + stoppedActivity = activity; + } + } }; ActivitySource.AddActivityListener(listener); diff --git a/tests/Infrastructure/SecureSettingsMigrationTest.cs b/tests/Infrastructure/SecureSettingsMigrationTest.cs index dc9d10e..1ca63a3 100644 --- a/tests/Infrastructure/SecureSettingsMigrationTest.cs +++ b/tests/Infrastructure/SecureSettingsMigrationTest.cs @@ -179,6 +179,58 @@ public void McpSettings_ExistingSecureValues_ShouldWinAndRemoveLegacyPlaintext() Assert.IsFalse(sanitizedJson.Contains("EnvironmentVariables", StringComparison.Ordinal)); } + [TestMethod] + [TestCategory("Unit")] + public void McpSettings_LegacyConfigWithoutSchemaVersion_ShouldDeserializeAsZero() + { + // 旧版本保存的配置 JSON 无 ToolsSchemaVersion 字段,应反序列化为 0, + // 由 UI 据此提示用户重新勾选工具白名单 + using var directory = new TemporaryDirectory(); + var configPath = Path.Combine(directory.Path, "mcpservers.json"); + File.WriteAllText(configPath, """ + [ + { + "Id": "server-legacy", + "Name": "旧版服务器", + "TransportType": "stdio", + "Command": "npx", + "IsEnabled": true + } + ] + """); + + var service = new MCPServerConfigService(configPath, new InMemorySecureSettingsStore()); + + var configs = service.ServerConfigs; + Assert.HasCount(1, configs); + Assert.AreEqual(0, configs[0].ToolsSchemaVersion); + } + + [TestMethod] + [TestCategory("Unit")] + public void McpSettings_NewConfig_ShouldPersistCurrentSchemaVersion() + { + using var directory = new TemporaryDirectory(); + var configPath = Path.Combine(directory.Path, "mcpservers.json"); + var service = new MCPServerConfigService(configPath, new InMemorySecureSettingsStore()); + + service.AddOrUpdateConfig(new MCPServerConfig + { + Id = "server-new", + Name = "新服务器", + ToolsSchemaVersion = MCPServerConfig.CurrentToolsSchemaVersion + }); + + using var document = JsonDocument.Parse(File.ReadAllText(configPath)); + Assert.AreEqual( + MCPServerConfig.CurrentToolsSchemaVersion, + document.RootElement[0].GetProperty("ToolsSchemaVersion").GetInt32()); + + // 重新加载后版本号保持当前值,不应再触发迁移提示 + var reloaded = new MCPServerConfigService(configPath, new InMemorySecureSettingsStore()); + Assert.AreEqual(MCPServerConfig.CurrentToolsSchemaVersion, reloaded.GetConfig("server-new")!.ToolsSchemaVersion); + } + private sealed class InMemorySecureSettingsStore : ISecureSettingsStore { public bool FailWrites { get; init; } diff --git a/tests/StructuredOutputTest.cs b/tests/StructuredOutputTest.cs index 75e6888..3abb204 100644 --- a/tests/StructuredOutputTest.cs +++ b/tests/StructuredOutputTest.cs @@ -3,6 +3,7 @@ using MarketAssistant.Agents.InvestmentSelection.Models; using MarketAssistant.Agents.InvestmentSelection.Strategies; using MarketAssistant.Applications.AssetScreener.Models; +using MarketAssistant.Infrastructure.AdaptiveCards; using MarketAssistant.Infrastructure.AdaptiveCards.Parsers; using MarketAssistant.Infrastructure.Core; using MarketAssistant.Infrastructure.Providers; @@ -188,11 +189,11 @@ public void InvestmentSelectionExecutors_ShouldRemainMafWorkflowExecutors() [TestMethod] [TestCategory("Unit")] - public void AddBusinessServices_ShouldRegisterAllAdaptiveCardParsers() + public void AddApplicationServices_ShouldRegisterAllAdaptiveCardParsers() { var services = new ServiceCollection(); - services.AddBusinessServices(); + services.AddApplicationServices(); var parserRegistrations = services .Where(descriptor => descriptor.ServiceType == typeof(IJsonToAdaptiveCardParser)) @@ -206,6 +207,9 @@ public void AddBusinessServices_ShouldRegisterAllAdaptiveCardParsers() Assert.IsTrue(parserRegistrations.Contains(typeof(SentimentCardParser))); Assert.IsTrue(parserRegistrations.Contains(typeof(NewsCardParser))); Assert.IsTrue(parserRegistrations.Contains(typeof(TechnicalCardParser))); + + // 转换器消费整条解析器责任链,必须与解析器同容器注册 + Assert.IsTrue(services.Any(descriptor => descriptor.ServiceType == typeof(AdaptiveCardConverter))); } [TestMethod] diff --git a/tests/Tools/BasicDataToolsTest.cs b/tests/Tools/BasicDataToolsTest.cs index e491e18..0f4f307 100644 --- a/tests/Tools/BasicDataToolsTest.cs +++ b/tests/Tools/BasicDataToolsTest.cs @@ -18,12 +18,10 @@ namespace TestMarketAssistant.Tools; /// -/// IBasicDataTools 接口真实场景验证测试(覆盖 A股 和 虚拟币 实现) +/// IBasicDataTools 接口真实场景验证测试(A股实现) /// 使用真实 API 调用验证工具实现的 authenticity: /// - A股 GetAssetInfoAsync:调用财联社(cls.cn)行情接口 /// - A股 GetCompanyInfoAsync:调用智兔 API(需 ZhiTuApiToken) -/// - 虚拟币 GetAssetInfoAsync:调用币安 24h 行情接口 -/// - 虚拟币 GetProjectInfoAsync:调用 CoinDesk 项目元数据接口 /// /// 环境变量(缺失时对应测试直接 Fail,不跳过): /// - ZHITU_API_TOKEN:智兔 API 令牌(A 股公司基本面必需) @@ -60,10 +58,6 @@ public void Setup() // 注册命名 HttpClient(含 BaseAddress 与弹性策略),与生产配置一致 services.AddNamedMarketHttpClients(); - // 注册虚拟币基础数据工具依赖的数据服务(CoinGecko 替代 CoinDesk) - services.AddSingleton(); - services.AddSingleton(); - // 通过 Mock 注入带真实密钥的 UserSetting(避免依赖本地 Preferences 存储) var userSetting = new UserSetting { @@ -82,8 +76,6 @@ public void Setup() // 注册被测试的服务(A股 + 虚拟币,含基接口与子接口) services.AddKeyedSingleton(MarketType.AShare); services.AddKeyedSingleton(MarketType.AShare); - services.AddKeyedSingleton(MarketType.Crypto); - services.AddKeyedSingleton(MarketType.Crypto); _serviceProvider = services.BuildServiceProvider(); } @@ -196,83 +188,6 @@ public async Task GetAssetInfoAsync_AShare_MultipleSymbols_ShouldAllReturnValidD #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] @@ -295,25 +210,6 @@ public void GetFunctions_AShare_ShouldReturnTwoAIFunctions() 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 契约入口) @@ -345,33 +241,5 @@ public async Task AIFunction_GetAssetInfoAsync_AShare_ShouldInvokeRealApi() 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 index 44b0746..9eb2c1e 100644 --- a/tests/Tools/CryptoMetricsToolsTest.cs +++ b/tests/Tools/CryptoMetricsToolsTest.cs @@ -29,6 +29,8 @@ public void Setup() var services = new ServiceCollection(); services.AddLogging(); + // BinanceMarketDataService 重构后依赖 IMemoryCache + services.AddMemoryCache(); // 注册命名 HttpClient(含 BaseAddress 与弹性策略),与生产配置一致 services.AddNamedMarketHttpClients(); // 注册虚拟币指标工具依赖的数据服务(Binance 行情 + CoinGecko 市场指标) diff --git a/tests/Tools/FinancialDataToolsTest.cs b/tests/Tools/FinancialDataToolsTest.cs index b10f159..d477d4c 100644 --- a/tests/Tools/FinancialDataToolsTest.cs +++ b/tests/Tools/FinancialDataToolsTest.cs @@ -5,6 +5,7 @@ using MarketAssistant.Applications.Settings; using MarketAssistant.Infrastructure.Core; using MarketAssistant.Services; +using MarketAssistant.DataProviders.AShare; using MarketAssistant.Services.Settings; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -32,6 +33,9 @@ public void Setup() var services = new ServiceCollection(); + // 注册 A 股数据提供者(ZhiTuMarketClient 等,AShareFinancialTools 构造依赖) + services.AddAShareDataProviders(); + services.AddLogging(builder => { builder.SetMinimumLevel(LogLevel.Debug); diff --git a/tests/Tools/SentimentToolsTest.cs b/tests/Tools/SentimentToolsTest.cs index a7e0903..9233fb6 100644 --- a/tests/Tools/SentimentToolsTest.cs +++ b/tests/Tools/SentimentToolsTest.cs @@ -5,6 +5,7 @@ using MarketAssistant.Infrastructure.Core; using MarketAssistant.Services; using MarketAssistant.DataProviders; +using MarketAssistant.DataProviders.AShare; using MarketAssistant.Services.Settings; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -33,6 +34,11 @@ public void Setup() var services = new ServiceCollection(); + // 注册 A 股数据提供者(ZhiTuMarketClient 等,AShareSentimentTools 构造依赖) + services.AddAShareDataProviders(); + // BinanceMarketDataService 重构后依赖 IMemoryCache + services.AddMemoryCache(); + services.AddLogging(builder => { builder.SetMinimumLevel(LogLevel.Debug); diff --git a/tests/Tools/TechnicalDataToolsTest.cs b/tests/Tools/TechnicalDataToolsTest.cs index b7c1b0a..032e36b 100644 --- a/tests/Tools/TechnicalDataToolsTest.cs +++ b/tests/Tools/TechnicalDataToolsTest.cs @@ -8,6 +8,7 @@ using MarketAssistant.Infrastructure.Core; using MarketAssistant.Services; using MarketAssistant.DataProviders; +using MarketAssistant.DataProviders.AShare; using MarketAssistant.Services.Settings; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -34,6 +35,11 @@ public void Setup() var services = new ServiceCollection(); + // 注册 A 股数据提供者(ZhiTuMarketClient 等,AShareTechnicalTools 与 AShareKLineService 构造依赖) + services.AddAShareDataProviders(); + // BinanceMarketDataService 重构后依赖 IMemoryCache + services.AddMemoryCache(); + services.AddLogging(builder => { builder.SetMinimumLevel(LogLevel.Debug); diff --git a/tests/Trading/AISignalDecisionParserTest.cs b/tests/Trading/AISignalDecisionParserTest.cs new file mode 100644 index 0000000..ebdb240 --- /dev/null +++ b/tests/Trading/AISignalDecisionParserTest.cs @@ -0,0 +1,119 @@ +using MarketAssistant.Services.Trading; + +namespace TestMarketAssistant.Trading; + +/// +/// AI 结构化决策解析器测试:验证从 LLM 原始响应中稳健提取 JSON 决策, +/// 容忍 markdown 包裹、多余文本、属性别名与字符串内大括号。 +/// +[TestClass] +public sealed class AISignalDecisionParserTest +{ + [TestMethod] + [TestCategory("Unit")] + public void PlainJson_IsFullyParsed() + { + var ok = AISignalDecisionParser.TryParse( + """{"decision":"BUY","confidence":80,"stopLossPrice":95.5,"takeProfitPrice":120,"reason":"突破放量"}""", + out var decision); + + Assert.IsTrue(ok); + Assert.IsTrue(decision!.IsBuy); + Assert.AreEqual(80, decision.Confidence); + Assert.AreEqual(95.5m, decision.StopLossPrice); + Assert.AreEqual(120m, decision.TakeProfitPrice); + Assert.AreEqual("突破放量", decision.Reason); + } + + [TestMethod] + [TestCategory("Unit")] + public void MarkdownFencedJson_IsExtracted() + { + var response = """ + 根据分析,我的决策如下: + + ```json + {"decision": "SELL", "confidence": 70, "stopLossPrice": null, "takeProfitPrice": 90} + ``` + + 以上仅供参考。 + """; + + var ok = AISignalDecisionParser.TryParse(response, out var decision); + + Assert.IsTrue(ok); + Assert.IsTrue(decision!.IsSell); + Assert.AreEqual(70, decision.Confidence); + Assert.IsNull(decision.StopLossPrice); + Assert.AreEqual(90m, decision.TakeProfitPrice); + } + + [TestMethod] + [TestCategory("Unit")] + public void ActionAlias_IsAccepted_AndNormalized() + { + var ok = AISignalDecisionParser.TryParse( + """{"action": "hold", "confidence": 30}""", out var decision); + + Assert.IsTrue(ok); + Assert.IsTrue(decision!.IsHold); + } + + [TestMethod] + [TestCategory("Unit")] + public void BracesInsideStringReason_AreHandled() + { + var ok = AISignalDecisionParser.TryParse( + """{"decision":"HOLD","confidence":40,"reason":"价格 {快速} 回落,观望 {更稳妥}"}""", + out var decision); + + Assert.IsTrue(ok); + Assert.IsTrue(decision!.IsHold); + Assert.IsTrue(decision.Reason!.Contains("{快速}")); + } + + [TestMethod] + [TestCategory("Unit")] + public void ConfidenceOutOfRange_IsClampedTo100() + { + var ok = AISignalDecisionParser.TryParse( + """{"decision":"BUY","confidence":150}""", out var decision); + + Assert.IsTrue(ok); + Assert.AreEqual(100, decision!.Confidence); + } + + [TestMethod] + [TestCategory("Unit")] + public void StringPrice_IsAccepted() + { + var ok = AISignalDecisionParser.TryParse( + """{"decision":"BUY","confidence":75,"stopLossPrice":"95.5"}""", out var decision); + + Assert.IsTrue(ok); + Assert.AreEqual(95.5m, decision!.StopLossPrice); + } + + [TestMethod] + [TestCategory("Unit")] + public void NonJsonText_ReturnsFalse() + { + Assert.IsFalse(AISignalDecisionParser.TryParse("我认为应该买入 BTC", out _)); + Assert.IsFalse(AISignalDecisionParser.TryParse(null, out _)); + Assert.IsFalse(AISignalDecisionParser.TryParse(string.Empty, out _)); + Assert.IsFalse(AISignalDecisionParser.TryParse("""{"decision": broken}""", out _)); + } + + [TestMethod] + [TestCategory("Unit")] + public void NonPositivePrices_AreTreatedAsNull() + { + var ok = AISignalDecisionParser.TryParse( + """{"decision":"BUY","confidence":75,"stopLossPrice":0,"takeProfitPrice":-5}""", + out var decision); + + Assert.IsTrue(ok); + Assert.IsNull(decision!.StopLossPrice); + Assert.IsNull(decision.TakeProfitPrice); + } +} \ No newline at end of file diff --git a/tests/Trading/StrategyEngineTriggerTest.cs b/tests/Trading/StrategyEngineTriggerTest.cs new file mode 100644 index 0000000..d2930bf --- /dev/null +++ b/tests/Trading/StrategyEngineTriggerTest.cs @@ -0,0 +1,293 @@ +using System.Text.Json; +using MarketAssistant.Applications.Settings; +using MarketAssistant.Services.Settings; +using MarketAssistant.Services.Trading; +using MarketAssistant.Services.Trading.Exchanges; +using MarketAssistant.Trading.Abstractions; +using MarketAssistant.Trading.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace TestMarketAssistant.Trading; + +/// +/// StrategyEngine 触发分支行为测试:覆盖基础止损/止盈、追踪止损参数回退、 +/// 网格基准落库与穿越触发、DCA 买入与出场(止盈清仓/止损暂停)。 +/// +[TestClass] +public sealed class StrategyEngineTriggerTest +{ + private const string Symbol = "BTCUSDT"; + + private static (StrategyEngine Engine, Mock Data, Mock Strategies) CreateEngine() + { + var settingService = new Mock(); + settingService.SetupGet(service => service.CurrentSetting).Returns(new UserSetting()); + + var environment = new TradingEnvironmentService( + settingService.Object, + () => throw new InvalidOperationException("触发评估测试中不应创建 MarketMonitor"), + NullLogger.Instance); + + var data = new Mock(environment, NullLogger.Instance); + var strategies = new Mock(data.Object); + // RoutingExchangeClient 为 sealed,使用真实实例:触发评估路径不会调用交易所客户端 + var exchange = new RoutingExchangeClient( + environment, new Dictionary()); + + var engine = new StrategyEngine( + data.Object, strategies.Object, exchange, environment, + NullLogger.Instance); + return (engine, data, strategies); + } + + private static void SetupStrategies( + (StrategyEngine Engine, Mock Data, Mock Strategies) ctx, + params TradingStrategy[] strategyList) + { + ctx.Strategies + .Setup(service => service.GetStrategiesByStatusAsync(StrategyStatus.Active, It.IsAny())) + .ReturnsAsync(strategyList.ToList()); + } + + private static TradingStrategy CreateStrategy(StrategyType type, OrderSide side, string? customParams = null) + => new() + { + Id = $"strategy-{type}-{side}", + Symbol = Symbol, + Type = type, + Side = side, + Status = StrategyStatus.Active, + Quantity = 1m, + CustomParams = customParams + }; + + [TestMethod] + [TestCategory("Unit")] + public async Task StopLoss_SellSide_TriggersBelowTriggerPrice() + { + var ctx = CreateEngine(); + var strategy = CreateStrategy(StrategyType.StopLoss, OrderSide.Sell); + strategy.TriggerPrice = 95m; + SetupStrategies(ctx, strategy); + + var above = await ctx.Engine.EvaluateAndUpdateStrategiesAsync(Symbol, 96m); + var below = await ctx.Engine.EvaluateAndUpdateStrategiesAsync(Symbol, 94m); + + Assert.AreEqual(0, above.Count, "高于触发价时不应触发卖出止损"); + Assert.AreEqual(1, below.Count, "跌破触发价应触发止损"); + Assert.AreEqual(OrderSide.Sell, below[0].Side); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task TakeProfit_SellSide_TriggersAboveTriggerPrice() + { + var ctx = CreateEngine(); + var strategy = CreateStrategy(StrategyType.TakeProfit, OrderSide.Sell); + strategy.TriggerPrice = 105m; + SetupStrategies(ctx, strategy); + + var below = await ctx.Engine.EvaluateAndUpdateStrategiesAsync(Symbol, 104m); + var above = await ctx.Engine.EvaluateAndUpdateStrategiesAsync(Symbol, 106m); + + Assert.AreEqual(0, below.Count, "低于触发价时不应触发止盈"); + Assert.AreEqual(1, above.Count, "涨破触发价应触发止盈"); + Assert.AreEqual(OrderSide.Sell, above[0].Side); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task TrailingStop_MissingParams_FallsBackToProfileDefault_AndTriggers() + { + var ctx = CreateEngine(); + // 仅含风险档案,缺少 trailingPercent/activationPrice → 按稳健档回退 5%,立即激活 + var strategy = CreateStrategy(StrategyType.TrailingStop, OrderSide.Sell, """{"riskProfile":"Balanced"}"""); + SetupStrategies(ctx, strategy); + + var first = await ctx.Engine.EvaluateAndUpdateStrategiesAsync(Symbol, 100m); + var second = await ctx.Engine.EvaluateAndUpdateStrategiesAsync(Symbol, 94m); + + Assert.AreEqual(0, first.Count, "首次评估仅记录峰值,不应触发"); + Assert.AreEqual(1, second.Count, "从峰值回撤 6% > 5% 回退比例应触发"); + Assert.AreEqual(OrderSide.Sell, second[0].Side); + ctx.Data.Verify( + data => data.UpdateStrategyTrailingPeakAsync(strategy.Id, 100m, It.IsAny()), + Times.Once, "峰值 100 应被持久化"); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task Grid_FirstEvaluation_PersistsBaselineIndex_WithoutTrigger() + { + var ctx = CreateEngine(); + var gridParams = new GridTradingParams + { + UpperPrice = 110m, LowerPrice = 90m, GridCount = 10, + QuantityPerGrid = 1m, LastTriggeredIndex = -1 + }; + var strategy = CreateStrategy( + StrategyType.GridTrading, OrderSide.Buy, JsonSerializer.Serialize(gridParams)); + SetupStrategies(ctx, strategy); + + string? persisted = null; + ctx.Data + .Setup(data => data.UpdateStrategyCustomParamsAsync(strategy.Id, It.IsAny(), It.IsAny())) + .Callback((_, customParams, _) => persisted = customParams) + .Returns(Task.CompletedTask); + + var triggered = await ctx.Engine.EvaluateAndUpdateStrategiesAsync(Symbol, 100m); + + Assert.AreEqual(0, triggered.Count, "首次评估只记录基准,不应触发交易"); + Assert.IsNotNull(persisted, "基准索引必须立即落库(修复重启后基准丢失缺陷)"); + var saved = JsonSerializer.Deserialize(persisted!); + Assert.AreEqual(5, saved!.LastTriggeredIndex, "价格 100 位于 90-110 十格网格的第 5 格"); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task Grid_CrossingLines_TriggersBuyBelow_AndSellAbove() + { + var ctx = CreateEngine(); + var gridParams = new GridTradingParams + { + UpperPrice = 110m, LowerPrice = 90m, GridCount = 10, + QuantityPerGrid = 1m, LastTriggeredIndex = 5 + }; + var strategy = CreateStrategy( + StrategyType.GridTrading, OrderSide.Buy, JsonSerializer.Serialize(gridParams)); + SetupStrategies(ctx, strategy); + + string? persistedAfterDown = null; + ctx.Data + .Setup(data => data.UpdateStrategyCustomParamsAsync(strategy.Id, It.IsAny(), It.IsAny())) + .Callback((_, customParams, _) => persistedAfterDown = customParams) + .Returns(Task.CompletedTask); + + var down = await ctx.Engine.EvaluateAndUpdateStrategiesAsync(Symbol, 94m); + // 引擎会原地修改策略对象(文档化副作用),上穿评估会覆盖 Side,需先快照下穿结果 + var downCount = down.Count; + var downSide = downCount > 0 ? down[0].Side : default(OrderSide); + var downQty = downCount > 0 ? down[0].Quantity : 0m; + var up = await ctx.Engine.EvaluateAndUpdateStrategiesAsync(Symbol, 106m); + + Assert.AreEqual(1, downCount, $"下穿应触发一次,落库参数: {persistedAfterDown}"); + Assert.AreEqual(OrderSide.Buy, downSide, "下穿网格线应买入"); + Assert.AreEqual(1m, downQty); + Assert.AreEqual(1, up.Count); + Assert.AreEqual(OrderSide.Sell, up[0].Side, "上穿网格线应卖出"); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task DCA_NoPosition_TriggersBuyAfterInterval() + { + var ctx = CreateEngine(); + var dcaParams = new DCAParams + { + AmountPerInterval = 100m, IntervalSeconds = 1, + TakeProfitPercent = 10m, StopLossPercent = 20m + }; + var strategy = CreateStrategy(StrategyType.DCA, OrderSide.Buy, JsonSerializer.Serialize(dcaParams)); + strategy.LastTriggeredAt = DateTime.UtcNow.AddHours(-1); + SetupStrategies(ctx, strategy); + + ctx.Data + .Setup(data => data.GetOpenPositionsAsync(Symbol, It.IsAny())) + .ReturnsAsync([]); + + var triggered = await ctx.Engine.EvaluateAndUpdateStrategiesAsync(Symbol, 100m); + + Assert.AreEqual(1, triggered.Count, "无持仓且间隔已过应触发买入"); + Assert.AreEqual(OrderSide.Buy, triggered[0].Side); + Assert.AreEqual(1m, triggered[0].Quantity, "100 USDT ÷ 100 价格 = 1 BTC"); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task DCA_TakeProfitReached_SellsFullRemainingPosition() + { + var ctx = CreateEngine(); + var dcaParams = new DCAParams + { + AmountPerInterval = 100m, IntervalSeconds = 1, + TakeProfitPercent = 10m, StopLossPercent = 20m + }; + var strategy = CreateStrategy(StrategyType.DCA, OrderSide.Buy, JsonSerializer.Serialize(dcaParams)); + strategy.LastTriggeredAt = DateTime.UtcNow; + SetupStrategies(ctx, strategy); + + ctx.Data + .Setup(data => data.GetOpenPositionsAsync(Symbol, It.IsAny())) + .ReturnsAsync([new Position { Symbol = Symbol, Side = PositionSide.Long, Quantity = 2m, ClosedQuantity = 0.5m }]); + ctx.Data + .Setup(data => data.GetOpenPositionAvgEntryPriceAsync(Symbol, It.IsAny())) + .ReturnsAsync(100m); + + // 115 >= 100 × (1 + 10%) → 止盈清仓剩余 1.5;出场评估不受定投间隔限制 + var triggered = await ctx.Engine.EvaluateAndUpdateStrategiesAsync(Symbol, 115m); + + Assert.AreEqual(1, triggered.Count); + Assert.AreEqual(OrderSide.Sell, triggered[0].Side, "止盈应卖出"); + Assert.AreEqual(1.5m, triggered[0].Quantity, "应卖出剩余持仓(2 - 0.5)"); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task DCA_StopLossReached_WithoutSellOut_PausesStrategy() + { + var ctx = CreateEngine(); + var dcaParams = new DCAParams + { + AmountPerInterval = 100m, IntervalSeconds = 1, + TakeProfitPercent = 10m, StopLossPercent = 20m, + StopLossSellOut = false + }; + var strategy = CreateStrategy(StrategyType.DCA, OrderSide.Buy, JsonSerializer.Serialize(dcaParams)); + strategy.LastTriggeredAt = DateTime.UtcNow; + SetupStrategies(ctx, strategy); + + ctx.Data + .Setup(data => data.GetOpenPositionsAsync(Symbol, It.IsAny())) + .ReturnsAsync([new Position { Symbol = Symbol, Side = PositionSide.Long, Quantity = 1m, ClosedQuantity = 0m }]); + ctx.Data + .Setup(data => data.GetOpenPositionAvgEntryPriceAsync(Symbol, It.IsAny())) + .ReturnsAsync(100m); + + var triggered = await ctx.Engine.EvaluateAndUpdateStrategiesAsync(Symbol, 79m); + + Assert.AreEqual(0, triggered.Count, "保守止损动作是暂停策略而非卖出"); + ctx.Strategies.Verify( + strategies => strategies.UpdateStrategyStatusAsync(strategy.Id, StrategyStatus.Paused, It.IsAny()), + Times.Once, "触及止损线应暂停定投"); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task DCA_StopLossReached_WithSellOut_SellsFullPosition() + { + var ctx = CreateEngine(); + var dcaParams = new DCAParams + { + AmountPerInterval = 100m, IntervalSeconds = 1, + TakeProfitPercent = 10m, StopLossPercent = 20m, + StopLossSellOut = true + }; + var strategy = CreateStrategy(StrategyType.DCA, OrderSide.Buy, JsonSerializer.Serialize(dcaParams)); + strategy.LastTriggeredAt = DateTime.UtcNow; + SetupStrategies(ctx, strategy); + + ctx.Data + .Setup(data => data.GetOpenPositionsAsync(Symbol, It.IsAny())) + .ReturnsAsync([new Position { Symbol = Symbol, Side = PositionSide.Long, Quantity = 1m, ClosedQuantity = 0m }]); + ctx.Data + .Setup(data => data.GetOpenPositionAvgEntryPriceAsync(Symbol, It.IsAny())) + .ReturnsAsync(100m); + + var triggered = await ctx.Engine.EvaluateAndUpdateStrategiesAsync(Symbol, 79m); + + Assert.AreEqual(1, triggered.Count); + Assert.AreEqual(OrderSide.Sell, triggered[0].Side); + Assert.AreEqual(1m, triggered[0].Quantity); + } +} \ No newline at end of file diff --git a/tests/Vectors/RagIngestionServiceIntegrationTest.cs b/tests/Vectors/RagIngestionServiceIntegrationTest.cs index fc56e17..5d14541 100644 --- a/tests/Vectors/RagIngestionServiceIntegrationTest.cs +++ b/tests/Vectors/RagIngestionServiceIntegrationTest.cs @@ -20,8 +20,12 @@ public async Task Initialize() { base.BaseInitialize(); - // 获取服务 - _embeddingGenerator = _serviceProvider.GetRequiredService>>(); + // 获取服务(嵌入生成器仅在 JINA_API_KEY 已配置时注册,缺失时给出明确失败原因) + _embeddingGenerator = _serviceProvider.GetService>>(); + if (_embeddingGenerator is null) + { + Assert.Fail("JINA_API_KEY 环境变量未配置,无法创建嵌入生成器进行真实场景验证"); + } _vectorStore = _serviceProvider.GetRequiredService(); // 从 DI 容器获取 RagIngestionService diff --git a/tests/Vectors/RetrievalOrchestratorIntegrationTest.cs b/tests/Vectors/RetrievalOrchestratorIntegrationTest.cs index 6dbdcae..14be3ca 100644 --- a/tests/Vectors/RetrievalOrchestratorIntegrationTest.cs +++ b/tests/Vectors/RetrievalOrchestratorIntegrationTest.cs @@ -22,8 +22,12 @@ public async Task Initialize() { base.BaseInitialize(); - // 从 DI 容器获取所有服务 - _embeddingGenerator = _serviceProvider.GetRequiredService>>(); + // 从 DI 容器获取所有服务(嵌入生成器仅在 JINA_API_KEY 已配置时注册,缺失时给出明确失败原因) + _embeddingGenerator = _serviceProvider.GetService>>(); + if (_embeddingGenerator is null) + { + Assert.Fail("JINA_API_KEY 环境变量未配置,无法创建嵌入生成器进行真实场景验证"); + } _vectorStore = _serviceProvider.GetRequiredService(); _retrievalOrchestrator = _serviceProvider.GetRequiredService(); From aadbfd9016e6c0ec124f634f6663729c887355cb Mon Sep 17 00:00:00 2001 From: MayueCif Date: Fri, 28 Aug 2026 22:51:38 +0800 Subject: [PATCH 2/9] =?UTF-8?q?[Trading]=20=E8=B5=84=E9=87=91=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E4=BF=AE=E5=A4=8D=EF=BC=9A=E5=88=A0=E9=99=A4=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E5=8E=9F=E7=94=9F=E6=9D=A1=E4=BB=B6=E5=8D=95=E3=80=81?= =?UTF-8?q?=E4=B8=8B=E5=8D=95=E9=87=8D=E8=AF=95=E4=B8=8E=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E7=AB=9E=E6=80=81=E3=80=81=E5=87=AD=E8=AF=81?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E5=AE=89=E5=85=A8=E3=80=81=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E8=8A=82=E6=B5=81=E5=AE=8C=E7=BB=93=E3=80=81=E9=A3=8E=E6=8E=A7?= =?UTF-8?q?fail-closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ss_8fc947a7-0f60-4bee-8091-04b858439e94.md | 61 +++++ ...241\346\212\245\345\221\212-2026-08-28.md" | 229 ++++++++++++++++++ .../Trading/AISignalStrategyExecutor.cs | 2 +- .../Trading/CryptoPortfolioService.cs | 14 +- .../Exchanges/RoutingExchangeClient.cs | 6 + .../Trading/MarketMonitor.cs | 52 +++- .../Trading/RiskManager.cs | 11 +- .../Trading/StrategyEngine.cs | 106 +------- .../Trading/TradeExecutor.cs | 107 +++++++- .../Trading/TradingCredentialStore.cs | 23 +- .../Trading/TradingDataService.cs | 27 +-- .../Trading/TradingEnvironmentService.cs | 21 +- .../Trading/StrategyConfigViewModel.cs | 9 + .../TradingStrategy.cs | 7 - tests/Trading/AISignalHardBoundaryTest.cs | 6 +- tests/Trading/StrategyEngineTriggerTest.cs | 38 ++- tests/Trading/TradeExecutorConcurrencyTest.cs | 2 +- .../TradingDataServiceMigrationTest.cs | 1 - 18 files changed, 534 insertions(+), 188 deletions(-) create mode 100644 .zcode/plans/plan-sess_8fc947a7-0f60-4bee-8091-04b858439e94.md create mode 100644 "docs/\344\273\243\347\240\201\345\256\241\350\256\241\346\212\245\345\221\212-2026-08-28.md" diff --git a/.zcode/plans/plan-sess_8fc947a7-0f60-4bee-8091-04b858439e94.md b/.zcode/plans/plan-sess_8fc947a7-0f60-4bee-8091-04b858439e94.md new file mode 100644 index 0000000..b531233 --- /dev/null +++ b/.zcode/plans/plan-sess_8fc947a7-0f60-4bee-8091-04b858439e94.md @@ -0,0 +1,61 @@ +# MarketAssistant 重构修复计划(全量 P0+P1+P2) + +基于审计报告 + 三个并行源码核实。P0 16 条中 13 条完全属实、3 条部分准确(细节修正已并入下文)、无虚警;P1 抽查发现 2 条虚警(#17、#30,**从计划中剔除**);核实另发现 4 个报告遗漏的 sibling 问题,已纳入对应阶段。 + +**既有决策**(沿用):WebView HTML 渲染默认禁用;交易域死代码直接删除整块。 + +--- + +## 阶段 0 — 文档勘误(先落 docs/代码审计报告-2026-08-28.md) +- 修正 P0-3:并非零节流(`MarketMonitor.cs:272` 已有失败冷却);Paused 已正确写入(L447),缺的是用户可见通知。 +- 修正 P0-16:泄漏根因是 P0-14 导致旧 VM 连 Dispose 都不发生,时序与报告所述相反。 +- 修正 P0-13 严重度:模态框滞留为 UX 级问题。 +- P1-17("批量嵌入"实为逐个)与 P1-30(停止按钮不可达)标记为虚警删除。 +- 补充 4 个新 sibling:TradeExecutor.cs:247 stopPrice 硬编码 null;InvestmentSelection 三个 Executor 均吞取消;零向量破坏 `null=不可用` 契约;OnMarketChanged 需 disposed 防护的其余 4 个 VM。 +- 修正路径偏差:Singleton 注册在 `App.Services/Services/ServiceCollectionExtensions.cs`。 + +## 阶段 1 — 资金安全(P0-1~5 + 放大器 + sibling #1) +1. **删除原生条件单整套**(按决策):`StrategyEngine.cs:484,491-559,564-582`、`TradingDataService.cs:297-307,1348`、`TradingStrategy.cs:56-60`、`strategies.native_order_id` 列及迁移;在合约止损策略创建路径加"客户端轮询执行、进程退出期间不生效"显式警示。 +2. **TradeExecutor 重试循环**(P0-2):改为 `IsTransient(ex)` 递归判定(HttpRequestException/TimeoutException/SocketException/非取消 TaskCanceledException,含 InnerException 链),catch filter 用之;L320 失败分类复用同一函数。同时修 sibling:FriendlyException(inner=HttpRequestException)应归 Network。 +3. **TradingCredentialStore._cache**(P0-5):改 `ConcurrentDictionary`,`SetCredentials`/`ClearCredentials` 用 `AddOrUpdate`/`TryRemove`。 +4. **StrategyEngine/MarketMonitor 节流**(P0-3 修正版):触发成功后按策略类型统一加 `LastTriggeredAt` 间隔节流;`StopLoss/TakeProfit/TrailingStop` 触发成功置 `Completed`;`MaxExecutions==null` 兜底视为 1;`PauseStrategyAfterRejectionAsync` 发用户可见通知(复用 `_notificationService`)。 +5. **环境切换竞态**(P0-4):`TradeExecutor` 入口把 `_exchangeClient` 快照为局部变量,风控/杠杆/下单/撤单全用快照;下单前再校验 mode 与快照一致,不一致拒绝;`_currentMode` 加 `volatile`;`ApplyModeAsync` 在 StopAsync 超时(`MarketMonitor.cs:184`)时中止切换并报错。 +6. **风控路径禁用 3 秒缓存**(放大器):`RiskManager.cs:63`、`AISignalStrategyExecutor.cs:189` 改走实时查询(缓存仅 UI 展示用);`RiskManager.cs:138-142` fail-open 改 fail-closed。 +7. **sibling**:若保留条件单的映射逻辑不再需要(已删除整块),`TradeExecutor.cs:247` 随删除一并清理。 + +## 阶段 2 — RAG 数据污染(P0-6~9 + sibling #3) +1. **TextCleaningService**(P0-6):删除 `RepeatingChars` 规则;`PhonePattern` 改为带边界断言的中国手机号语义 `(?()` 激活 HITL(P0-10)。 +2. **确认对话框**(P0-13):`IDialogService.ShowConfirmationAsync` 增 `CancellationToken` 重载,内部 `ct.Register(() => Dispatcher.UIThread.Post(dialog.Close))`;TradeConfirmationService 用 `CancellationTokenSource(60s)`,catch OCE 走超时拒绝路径。 +3. **AssetPageViewModel**(P0-15):删 `Task.Run`(L274 改 `_ = LoadAssetDataAsync(...)`),简化 L192 冗余 `InvokeAsync`。 +4. **MainWindow**(P0-14):拆 `SelectedMainNavigationItem`/`SelectedBottomNavigationItem`;`SwitchToMarket` 先缓存 `currentTitle` 到局部变量,切完按 title 查找 item 并导航。 +5. **SettingsPageViewModel**(P0-11):`InitializeAsync` 改 `UserSetting = CurrentSetting.Clone()` 草稿模式;`SaveAsync` 时 `UpdateSettings(UserSetting.Clone())` 提交;修正 L369-371/L388 注释;`UserSetting` 补 `Clone()` 方法。 +6. **RichTextBlock**(P0-12,按决策默认禁用):`DetectContentFormat` 不再自动识别 HTML,仅 Markdown/PlainText;保留显式 `Format=Html` 分支入口但注释标注需白名单消毒+CSP 后才可启用。 +7. **TelegraphNewsViewModel**(P0-16 修正版):`OnMarketChanged` lambda 首行 `if (_disposed) return;`;`Dispose()` 幂等退订;**联动阶段 3-4**——P0-14 修复后旧 HomePageViewModel 才会被真正 Dispose,需一并验证。 +8. 全仓排查其余 4 个重写 `OnMarketChanged` 的 VM(AgentAnalysis/AssetSelection/Favorites/PriceAlert)补 disposed 防护与完整退订。 + +## 阶段 4 — P1 批量修复(37 条,剔除 2 虚警) +按第三节编号顺序,分组处理: +- **交易域 1-13**:#1 金额列读改 `ReadDecimal()`(strategies L1226-1239、trade_records L1268-1271);#2 `strategies` 表加 `order_type`/`slippage_tolerance` 列 + 迁移;#3 exchangeInfo filters 缓存 + 下单前本地取整(stepSize/tickSize);#4 `BinanceUserDataStreamService._ws` 数据竞争(局部快照引用);#5 与阶段 1-6 合并;#6 RiskManager 补买入余额校验;#7 回撤熔断改 30 天滚动窗口 + 重置入口;#8 `MarketContext._currentMarket` 加 volatile;#9 `PriceAlertService.Dispose` 改 IAsyncDisposable;#10 `IsClosePositionAsync` 改三态,查询失败中止下单;#11 AISignal 伴随策略去重 + `StopLossPrice` 写入(L287 去掉提前 return);#12 `MarketMonitor.Dispose` 先 await 在途任务再释放信号量;#13 `AnalysisReportCache` 显式传 MarketType。 +- **Agent/RAG 14-26(去掉 #17)**:#14 CoinGecko 改 resilience RateLimiter 删手写限流;#15 哈希改 `SHA256.ComputeHash(File.OpenRead)`;#16 补传 cancellationToken;#18 同义词替换加词边界断言(拉丁键 `\b`);#19 `RetrievalOrchestrator.cs:93-104` 补条数校验;#20 `ClipImageEmbeddingService` 构造去掉服务定位器 + `_initAttempted` 加锁;#21 `DocxMarkdownConverter` 无状态化;#22 AITool 补 CT + WebSearch 独立兜底;#23 雪球请求传 CT;#24 GenerateCriteria 对齐 OCE 处理(阶段 2 已覆盖,确认即可);#25 runId 捕获进闭包,工具只暴露 analystName;#26 Executor 改每次 Run 新建。 +- **UI/Core 27-39(去掉 #30)**:#27 `TradingPageViewModel.Dispose` 补退订 StrategyConfig;#28 `MCPConfigPageViewModel` ContinueWith 改 UI 线程调度;#29 `ChatMessageAdapter` 补 `OnPropertyChanged(nameof(AdaptiveCard))`;#31 TradeMonitorViewModel 补 UI 线程切换;#32/#33 FavoritesPageViewModel 定时器与 Dispatcher 修复;#34 `StockSymbolConverter` 收敛单一 `ResolveExchange()` + 北交所 8/43 段;#35 `CryptoSymbolConverter` 白名单基础币(WBTC/WETH/STETH/WBNB);#36 `NullabilityInfoContext` 改每次新建;#37 KLineChartView 加 SemaphoreSlim + 单次订阅;#38 testConfig 拷贝对齐 SaveUIToConfig;#39 `StrategyConfigViewModel` 走 `IDialogService`。 +- **补测试**:`510300`、`WBTC`、`STETH`、`600519`、`113016`、`830799` 符号转换用例。 + +## 阶段 5 — 死代码删除与重复造轮子收敛(P2) +- **删除(按决策)**:P2-1(随阶段 1)、#2 RoutingExchangeClient 纯转发(需先完成阶段 1-5 快照方案改造,确认无引用后删)、#3/#4/#5/#6/#7/#9/#10/#11 死代码整块;#8 随阶段 2 接上后不再是死代码。 +- **Directory.Build.props**(#12):重写为集中管理 `TargetFramework`/`Nullable`/`ImplicitUsings`,删除 WinUI 段。 +- **收敛**:#13 抽 `MarketAssistant.Infrastructure.ITokenCounter` 单例(两处 Tiktoken 合一);#14 `SourceType` 统一从 documentUri 推断;#15 `AnalysisReportCache` 换 `IMemoryCache`;#16 Reranker 去重分词 + 常量经 DI。 +- **其余 #17-30**:Console.WriteLine 清理、HttpClient.Timeout 覆盖问题、签名硬编码移配置、SqliteRagDocumentCatalog 开 WAL + 释放锁、PasswordChar 补齐、`ModelDiscoveryService` 每次 CreateClient、AboutPage 路径净化与空 catch、StackOverflow 分支删除 + depth 上限、PriceChangeColorConverter 补 InvariantCulture、KLineChartView CDN 兜底改本地资源、CTS 传递、`[NotifyPropertyChangedFor]` 改造。 + +## 验证(每阶段必做) +- 每阶段:`dotnet build MarketAssistant.slnx -c Debug` 通过;`dotnet format` 后 `--verify-no-changes`。 +- 阶段 1/2 结束:`dotnet test tests/TestMarketAssistant.csproj -c Debug`(现有 395 个 MSTest 用例 + 本计划新增用例全绿)。 +- 阶段 3 结束:手动冒烟——切市场导航刷新、交易确认弹窗超时关闭、聊天 Markdown 渲染、详情页骨架屏无跨线程异常。 +- 每阶段一个 commit,格式 `[模块] 变更概要`;阶段 1 完成前不启用实盘模式。 +- 执行顺序:阶段 0 → 1 → 2 → 3 → 4 → 5,各阶段独立可交付,中途可随时叫停。 \ No newline at end of file diff --git "a/docs/\344\273\243\347\240\201\345\256\241\350\256\241\346\212\245\345\221\212-2026-08-28.md" "b/docs/\344\273\243\347\240\201\345\256\241\350\256\241\346\212\245\345\221\212-2026-08-28.md" new file mode 100644 index 0000000..aa04fa7 --- /dev/null +++ "b/docs/\344\273\243\347\240\201\345\256\241\350\256\241\346\212\245\345\221\212-2026-08-28.md" @@ -0,0 +1,229 @@ +# MarketAssistant 代码审计报告(GLM 5.2/5.3 生成代码) + +审计日期:2026-08-28 +审计范围:`src/` 下 489 个 `.cs`(约 2.84 万行)+ `tests/` 74 个测试文件,分三个域并行审计。 + +> **核实说明(2026-08-28)**:本报告全部 16 条 P0 已逐条对照源码复核:13 条完全属实,3 条部分准确(细节修正已直接并入正文,原文偏差不再保留),无完全错误条目。P1 抽查发现 2 条虚警(原 #17、#30,已剔除)。另核实发现 4 个原报告遗漏的 sibling 问题,已补充。统计:**P0 16 条 / P1 37 条 / P2 30 条**。 +> +> **已确认的决策**(2026-08-28 用户拍板): +> 1. WebView HTML 渲染 → **默认禁用** +> 2. 交易域死代码 → **直接删除整块** + +--- + +## 一、总体结论 + +**架构层合格,实现层粗心;骨架是对的,血肉是错的。** + +做对的地方: +- HTTP 全部走 `IHttpClientFactory` + `AddStandardResilienceHandler`,无裸 `new HttpClient` +- 全仓 `async void` = 0、`Thread.Sleep` = 0、`GetAwaiter().GetResult()` = 0 +- 多市场用 .NET Keyed Services,DI 有 `ValidateOnBuild`,密钥进 OS 安全存储 +- MSTest 395 个 `[TestMethod]` + +真正的问题集中在三类生成式代码典型指纹: + +1. **注释与实现脱节**——注释写着"已修复""批量生成""仅修改本地",实现却不是。 +2. **同一代码库里两套相反约定**——A 处对、B 处错,且往往在相邻文件。 +3. **先铺抽象再补实现,实现永远没补上**——约 30% 代码是死代码或纯透传空壳。 + +--- + +## 二、P0 清单(16 条) + +### A. 资金安全(交易域,5 条) + +#### P0-1 交易所原生条件单整套失效,合约止损无服务端兜底 ✅ +- 文件:`src/MarketAssistant.App.Services/Trading/Exchanges/BinanceExchangeClient.cs:60-63` ↔ `src/MarketAssistant.App.Services/Applications/Crypto/BinanceAccountService.cs:142,166-175` +- 问题:`OrderType.StopMarket.ToString().ToUpper()` = `"STOPMARKET"`(枚举定义在 `MarketAssistant.Trading/TradingEnums.cs:47-57`),而 `BinanceAccountService.cs:167/172` 比较的是 `"STOP_MARKET"`/`"TAKE_PROFIT_MARKET"`,恒 false → `stopPrice`/`trailingDelta` 不进请求体,订单被币安 `-1116` 拒绝,异常在 `StrategyEngine.cs:553-558` 被捕获后仅 LogError 并 `return null`(回退客户端评估,但错误码细节被吞)。 +- **核实补充 sibling**:`TradeExecutor.cs:247` 通用下单路径硬编码 `stopPrice: null, trailingDelta: null` —— 即使修好类型映射,所有经 TradeExecutor 的条件单也永远缺参数。随删除整套原生条件单一并清理。 +- 修复(按决策):**删除整套原生条件单**——`StrategyEngine.cs:484,491-559,564-582`、`TradingDataService.cs:297-307,1348`、`TradingStrategy.cs:56-60`、`strategies.native_order_id` 列及迁移;在合约止损策略创建路径加"止损由客户端每秒轮询执行、进程退出或网络中断期间不生效"的显式警示。 + +#### P0-2 下单重试循环是死代码——catch 的异常类型永不出现 ✅ +- 文件:`src/MarketAssistant.App.Services/Trading/TradeExecutor.cs:239-262` +- 问题:重试循环 `catch (HttpRequestException)`(L250),但 `BinanceAccountService.cs:198-207` 把所有异常包装成 `FriendlyException : Exception`(`MarketAssistant.Core/Infrastructure/Core/FriendlyException.cs:6`),重试永不触发;一次瞬时抖动即下单失败 → 30 秒冷却。 +- **核实补充**:L320 的分类逻辑(`ex.InnerException is HttpRequestException`)对 FriendlyException 包装网络错误能正确归 Network,但重试仍失效。 +- 修复:`IsTransient(ex)` 递归判定(含 InnerException 链),catch filter 与 L320 分类复用同一函数。 + +#### P0-3 止损/止盈触发成功后无节流,条件持续满足时每秒下一单 ⚠️(修正版) +- 文件:`src/MarketAssistant.App.Services/Trading/StrategyEngine.cs:82-125`、`Trading/MarketMonitor.cs:263-284,503-516` +- **修正**:并非"零节流"——`MarketMonitor.cs:272` 已有失败冷却机制(`IsInFailureCooldown`),成功/拒绝路径清冷却(L415-419/424);"Paused 仅 LogWarning"也不准确——`PauseStrategyAfterRejectionAsync` L447 确实调用了 `UpdateStrategyStatusAsync(Paused)`,缺的是用户可见通知。 +- 仍然成立的核心风险:① 触发**成功**后无节流;② `CheckStrategyCompletionAsync`(L505)在 `MaxExecutions == null` 时直接 return,策略永不自动完结;③ Buy 侧 `TakeProfit`(语义=跌破买入)`requireClose=false`,而 `TradeExecutor.cs:152` 可平量复检只针对 Sell,买入完全不受阻,配合市价单几秒内可打光账户。 +- 修复:① 照搬 AISignal 的 `LastTriggeredAt` 机制给所有策略类型加统一成功节流;② `StopLoss/TakeProfit/TrailingStop` 触发成功后置 `Completed`;③ `MaxExecutions == null` 兜底视为 1;④ Pause 时发用户可见通知。 + +#### P0-4 模拟盘/实盘切换与在途订单竞态 ✅ +- 文件:`Trading/TradingEnvironmentService.cs:17,32,58-80`、`Exchanges/RoutingExchangeClient.cs:78-85`、`TradeExecutor.cs:100,198,243` +- 问题:`_currentMode` 普通字段无 volatile 无锁;`RoutingExchangeClient` 每方法独立 `GetActiveClient()`;一次下单链路(风控→IsFutures→杠杆→下单)多次独立解析,中途切模式订单落错环境;`ApplyModeAsync` 的 `monitor.StopAsync()` 超时(`MarketMonitor.cs:184` WaitAsync(10s))后仅 LogWarning 继续。 +- 修复:TradeExecutor 入口快照 `_exchangeClient`;`_currentMode` 加 volatile;下单前校验 mode 与快照一致;切换超时中止并报错。 + +#### P0-5 凭证 Dictionary 无锁,UI 线程写 / 后台签名线程读 ✅ +- 文件:`src/MarketAssistant.App.Services/Trading/TradingCredentialStore.cs:32,45-69,134-149`(Singleton 注册在 `src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs:376`) +- 问题:普通 `Dictionary`,`GetCredentials`(L47)无锁读、`SetCredentials`(L54)/`ClearCredentials`(L67)无锁写,扩容 rehash 期间并发读可致进程内异常甚至死循环;`Save()` 在锁内但突变点在锁外。`BinanceAuthService.cs:112` 每次签名请求都读。 +- 修复:改 `ConcurrentDictionary`。 + +> **放大器(已核实 ✅)**: +> - `CryptoPortfolioService.cs:19` 账户估值 3 秒缓存被风控使用(`RiskManager.cs:63`、`AISignalStrategyExecutor.cs:~189`),1 秒 tick 下连发订单可突破仓位上限。修复:风控路径走实时查询。 +> - `RiskManager.cs:138-142` 合约持仓查询失败 fail-open(仅 LogWarning 放行),与 L117-119 fail-closed 自相矛盾。修复:改 fail-closed。 + +### B. 会产出错误答案(Agent/RAG 域,4 条) + +#### P0-6 文本清洗规则吞掉/改写金融数字,RAG 知识库入库即被污染 ✅ +- 文件:`src/MarketAssistant.Rag/Services/TextCleaningService.cs:28-31,86-91` +- 实际推演(已核实):`成交额1000000000元` → `成交额 元`(10 位数字被 PhonePattern 整体删除);`营收10000000元` → `营收100元`(RepeatingChars 把 7 连 0 折叠成 2 个,**缩水 10 万倍**);`13800138000` → 删除;`1,000,000` 不变(逗号不匹配);`哈哈哈哈` → `哈哈`(中文叠词同样被折叠,且发生在标题/列表清洗路径)。该 Clean() 在 `DocumentBlockMapper.cs:56/84/114` 应用于所有文本块,**清洗后才嵌入入库**。`IsCleaningSuccessful` 全仓零调用。 +- 修复:① 删 `RepeatingChars`;② `PhonePattern` 改中国手机号语义 `(?()` 激活。 + +#### P0-11 设置页改的是全局单例对象,未保存的编辑被静默落盘,绕过实盘二次确认 ✅ +- 文件:`src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs:496-499,362-393,803-827` ↔ `Services/Settings/UserSettingService.cs:26,145-157`、`Services/Market/MarketContext.cs:84` +- 问题:`CurrentSetting => _currentSetting` 无拷贝,设置页全部编辑直接写单例本体;任何一次切市场(`UpdateSetting` → `SaveSettings()` 整体序列化)把未保存的编辑(含 `CryptoTradingMode=LiveFutures`、半填 API Key)静默写盘,`SaveAsync` 内的实盘二次确认被完全绕过。 +- 修复:`Clone()` 草稿模式;`IUserSettingService.CurrentSetting` 返回只读视图;修正 L369-371/L388 注释。 + +#### P0-12 LLM 输出未消毒拼进 HTML 交给 WebView 执行 → **决策:默认禁用** ✅ +- 文件:`src/MarketAssistant.App/Views/Controls/RichTextBlock.cs:236-273` +- 核实:`IsHtmlContent`(L160)任何 HTML 实体即判 HTML;L268 原始插值、L273 `NavigateToString`,无消毒无 CSP。 +- 修复(按决策):`DetectContentFormat` 不再自动识别 HTML,仅 Markdown/PlainText;显式 `Format=Html` 分支保留但注释标注需白名单消毒 + CSP 后才可启用。 + +#### P0-13 确认对话框超时后不关闭 ⚠️(严重度修正:UX 级) +- 文件:`src/MarketAssistant.App/Services/Trading/TradeConfirmationService.cs:63-95`、`src/MarketAssistant.App/Services/Dialog/DialogService.cs:46-49` +- 核实:核心成立——超时 `return false` 后模态对话框仍留屏,用户点"批准"的结果被丢弃,用户以为批准实际已 Rejected。**修正**:finally 复位计数本身正确,资金风险为中等,定性为 UX/竞态问题。 +- 修复:`IDialogService.ShowConfirmationAsync` 增 `CancellationToken` 重载(内部 `ct.Register` 关闭对话框),超时改 `CancellationTokenSource(60s)` + catch OCE。 + +#### P0-14 两个 ListBox 共用同一个 SelectedItem → 侧栏高亮丢失 + 切市场后页面不刷新 ✅ +- 文件:`src/MarketAssistant.App/Views/Windows/MainWindow.axaml:129,137` ↔ `ViewModels/MainWindowViewModel.cs:95-98,219-236` +- 核实:绑定行号实际为 L129/L137(微偏);同步事件链成立——切市场 → `Clear()` → ListBox 回写 null → L230 判空为假 → `NavigateToRoot` 不执行,页面停在旧市场数据。补充:`OnSelectedNavigationItemChanged` 对 null 直接 return,加重该问题。 +- 修复:拆两个选中属性;`SwitchToMarket` 先缓存 `currentTitle`,切完按 title 查找并导航。 + +#### P0-15 后台线程写绑定属性 ✅ +- 文件:`src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs:274,180-182,204-205,210` +- 核实:`Task.Run` 丢 UI 上下文,`IsBusy`/`HasError`/`ErrorMessage` 在线程池线程写绑定属性,仅 L191-192 对集合做了 `InvokeAsync`。 +- 修复:删 `Task.Run`(`OnNavigatedTo` 在 UI 线程,首个 await 后自动回 UI 线程),简化冗余 `InvokeAsync`。 + +#### P0-16 切市场泄漏 TelegraphNewsViewModel,新闻轮询永不停止 ⚠️(时序修正:比原报告更糟) +- 文件:`src/MarketAssistant.App/ViewModels/Home/TelegraphNewsViewModel.cs:64-89,145-162` +- **修正**:原报告称"Dispose 在 Dispatcher lambda 执行前发生"——实际上由于 P0-14,切市场时 `NavigateToRoot` 根本不执行,**旧 HomePageViewModel 连 Dispose 都不发生**:每次切换其 `OnMarketChanged` 的 lambda 继续堆叠执行(换服务、StartUpdates),泄漏比描述的更糟。lambda 无 `_disposed` 检查属实。 +- 修复:`OnMarketChanged` lambda 首行 `if (_disposed) return;`;`Dispose()` 幂等退订;**必须与 P0-14 联动修复并验证**。 +- **核实补充 sibling**:其余重写 `OnMarketChanged` 的 VM(AgentAnalysisViewModel、AssetSelectionPageViewModel、FavoritesPageViewModel、PriceAlertPageViewModel)需同样检查 disposed 防护与退订完整性。 + +--- + +## 三、P1 清单(37 条) + +> 核实状态:#1、#2、#7、#18、#19、#29 已抽查属实;**原 #17("批量嵌入"实为逐个调用)为虚警**——`RagIngestionService.cs:143` 是批量调用 `GenerateAsync(texts, ...)`;**原 #30(停止生成按钮不可达)为虚警**——`CanSendMessage()` 在 `IsProcessing=true` 时返回 true。其余条目未逐条复核,执行时需先确认再改。 + +**交易域** +1. `TradingDataService.cs:1226-1239,1268-1271` — 金额列已迁 TEXT 但读仍用 `(decimal)reader.GetDouble(...)`,精度改造作废(strategies/trade_records 两张资金表)。改用已有 `ReadDecimal()`(L64,已核实存在)。 +2. `TradingStrategy.cs:31-34` — `OrderType`/`SlippageTolerance` 从未持久化(DDL 无此两列,已核实 `TradingDataService.cs:1021-1040`),重启后永远退回 Market+0.003。加列 + 迁移。 +3. `BinanceAccountService.cs:143,161,169` — 下单参数硬编码 `"F8"`,未按 stepSize/tickSize 取整 → `-1111` 拒绝。缓存 exchangeInfo filters 并本地校验。 +4. `BinanceUserDataStreamService.cs:243-286` — `_ws` 字段数据竞争:`StopAsync` 判空后重连循环可能置 null → NRE 中断清理。改局部快照引用。 +5. `CryptoPortfolioService.cs:19` / `RiskManager.cs:63` — 3 秒缓存用于风控(见 P0 放大器,随阶段 1 修复)。 +6. `RiskManager.cs:112-157` — fail-open/fail-closed 矛盾(见放大器);且整个分支只校验 Sell,买入无余额校验。 +7. `RiskManager.cs:160-170` — 回撤熔断用历史全量峰值(`TradingDataService.cs:911-923` 全表 MAX,已核实)且无重置 → 触发后永久停摆。改 30 天滚动窗口 + 重置入口。 +8. `MarketContext.cs:35,45,74-81` — 写加锁、读不加锁且非 volatile。改 volatile。 +9. `PriceAlertService.cs:467-483` — `Dispose()` 内 `.Wait(5s)` 同步阻塞且 `ex.Handle` 会重抛中断 dispose 链。改 `IAsyncDisposable`。 +10. `TradeExecutor.cs:337-359` — `IsClosePositionAsync` 查询失败默认"开仓" → 平仓可能变反向开仓。改三态并中止下单。 +11. `AISignalStrategyExecutor.cs:251-288` — 每次开仓无条件新建伴随追踪止损策略,无去重无上限;L287 提前 return 导致 `StopLossPrice` 从不写入——选 TrailingStop 的策略**完全没有硬性止损**。 +12. `MarketMonitor.cs:518-536` — `Dispose()` 不等在途任务就释放 `SemaphoreSlim`,持有者 `Release()` 抛 ObjectDisposedException。 +13. `AnalysisReportCache.cs:22-27,57-58` — 缓存键依赖全局可变 `CurrentMarket`,后台 AI 交易会 miss 或读到错误市场报告。显式传 `MarketType`。 + +**Agent / RAG 域** +14. `CoinGeckoApiService.cs:18-20,40-58` — 静态 `SemaphoreSlim` 手写限流,与 resilience 管线重复且拖垮工具循环。改 RateLimiter。 +15. `RagIngestionService.cs:216-224` — `File.ReadAllText` 算二进制哈希:同步阻塞、大文件爆内存、可能碰撞。改 `SHA256.ComputeHash(File.OpenRead(path))`。 +16. `RagIngestionService.cs:246,259,265` — 图片 Caption/向量调用未传 cancellationToken。 +18. `QueryRewriteService.cs:126-135` — 子串 IgnoreCase 同义词替换,SynonymMap 短键 `"AI"`/`"GDP"`(已核实 L33/L43)会把 `"chain"` 改成 `"ch人工智能n"`。拉丁键加词边界。 +19. `RetrievalOrchestrator.cs:93-104` — `queryEmbeddings[qi]` 无条数校验,越界被 catch 吞掉 → 子查询被静默跳过、召回减少(已核实,后果比原报告轻但不一致)。补校验。 +20. `ClipImageEmbeddingService.cs:50,60-69,290-320` — 构造期 `GetService()` 服务定位器风险;`_initAttempted` 无锁可泄漏 InferenceSession。 +21. `DocxMarkdownConverter.cs:25-35,45-57` — Singleton 持有 4 个可变字典字段。无状态化。 +22. `GroundingSearchTools.cs:40-42,103-112,123` — AITool 无 CancellationToken;WebSearch 失败会连已成功的知识库结果一起丢。 +23. `ScreenInvestmentTargetsExecutor.cs:32,50,56` — 雪球 HTTP(15s 超时)未传 CT。 +25. `MarketAnalysisWorkflow.cs:171-177` — `Guid.ParseExact(runId, "N")` 解析 LLM 参数,模型返回带连字符 UUID 即抛 FormatException 中断整次分析。runId 捕获进闭包。 +26. `InvestmentSelectionWorkflow.cs:17-36,88-104` — 4 个 Executor 全 Singleton 被并发共用,与 MarketAnalysisWorkflow"每次 Run 新建"约定相反;`_disposed` 从未被读取。 + +**UI / Core / Infrastructure 域** +27. `TradingPageViewModel.cs:83-88` — Dispose 漏退订 `StrategyConfig`(订阅单例 `MarketMonitor.StatusChanged`)→ 每次进交易页泄漏 VM。 +28. `MCPConfigPageViewModel.cs:372-378,444-448` — `ContinueWith` 默认线程池调度,3 秒后在非 UI 线程写 `TestStatus`。 +29. `ChatMessageAdapter.cs:84,112-126` — 设置 `AdaptiveCard` 只通知 `IsAdaptiveCard` 未通知自身 → 流式回复解析出卡片后整条气泡空白(已核实)。 +31. `TradeMonitorViewModel.cs:178-181` — 同一单例事件未切 UI 线程,而 `StrategyConfigViewModel.cs:189-193` 切了。 +32. `FavoritesPageViewModel.cs:271-288,316-327` — 惰性 DispatcherTimer 用 Post 创建,晚于 Dispose 执行则永久运行;Dispose 只 Stop 不解绑 Tick。 +33. `FavoritesPageViewModel.cs:169-179` — 注释"在UI线程上批量添加"但无 Dispatcher。 +34. `StockSymbolConverter.cs:101-111` vs `119-140` — 同一代码两函数给出不同交易所:沪市 ETF `510300` 前缀函数判 SZ、后缀函数判 SH。收敛单一 `ResolveExchange()` + 北交所 8/43 段。 +35. `CryptoSymbolConverter.cs:29-40,57-65` — `WBTC`/`WETH`/`STETH`/`WBNB` 判"已含计价货币",`ExtractBaseCurrency("WBTC")` 得 `"W"` → `-1121`。白名单短路。 +36. `StructuredOutputValidator.cs:12,92-99` — `NullabilityInfoContext`(非线程安全)做成 static readonly 供并行链路调用。每次新建。 +37. `KLineChartView.cs:76-88,204,390-403` — `NavigationCompleted +=` 每次初始化执行且从不 `-=`;Data 变更可并发进入 `InitializeChartAsync` 互相打断。加 SemaphoreSlim(1,1) + 单次订阅。 +38. `MCPConfigPageViewModel.cs:322-344` — 构造 testConfig 手工拷贝漏 `AllowAllTools`,"发现 N 个工具"数字不符。对齐 `SaveUIToConfig`。 +39. `StrategyConfigViewModel.cs:479-490` — VM 直接 `new StrategyExecutionWindow()` 绕过 `IDialogService`,不可单元测试。 + +--- + +## 四、P2 清单(30 条) + +**死代码与空壳(按"直接删除整块"决策,主要清理对象)** +1. 原生条件单整套(见 P0-1,随阶段 1 删除) +2. `Exchanges/RoutingExchangeClient.cs:30-76` — 12 个方法全单行转发,且是 P0-4 直接成因(阶段 1 快照方案改造后确认无引用再删) +3. `TradingStrategyService.cs:19-35` — 9 个方法 5 个纯转发;与 TradingDataService 双路径访问同一份数据 +4. `IExchangeClient.cs:77,82` + 5 层实现 — `SetMarginTypeAsync`/`GetUserTradesAsync` 零调用方 +5. `BinanceUserDataStreamService.cs:38,339,354` — `AccountUpdate` 事件零订阅者;L354 把增量塞进语义为"可用余额"的 `Free` 字段 +6. `TradingDataService.cs:642-663,668-689` — `GetAverageEntryPriceAsync`/`GetAverageSellPriceAsync` 零调用 +7. `MarketAnalysisWorkflow.cs:643-644` — `IsDispatcherExecutor` 零调用 +8. `ITextCleaningService.IsCleaningSuccessful` — 随 P0-6 修复接上后不再是死代码 +9. `MarketSnapshotContextProvider.cs:23-29` — `GetAllData()`/`Clear()` 零调用 +10. `DocumentBlockReaderFactory.cs:24` + `MarkdownDocumentBlockReader.cs:32-35` — `CanRead` 对 .pdf/.docx 也返回 true 且注册最前 → `DocxBlockReader`/`PdfBlockReader` 两行都不执行 +11. `TradeConfirmationService.cs:40-44` — `HasPendingConfirmation` 重构后忘删 +12. `Directory.Build.props:3-10,22-25` — 全部针对 WinUI/Windows TFM 而无 WinUI 工程;应集中管理 TargetFramework/Nullable/ImplicitUsings + +**重复造轮子** +13. `TokenEstimator.cs` 与 `TextChunkingService.cs:298-337` — 同一套 Tiktoken 启发式写两遍,启动加载两份词表。抽 `MarketAssistant.Infrastructure.ITokenCounter` 单例。 +14. `TextChunkingService.cs:60-64` 与 `DocumentBlockMapper.cs:185-194` — `SourceType` 两个生产者两套取值域(`.md` 一处标 "text" 一处标 "markdown")。统一从 documentUri 推断。 +15. `AnalysisReportCache.cs` — 手搓 97 行 LRU,项目已有 `IMemoryCache`;`Set` 两步操作非原子。换 IMemoryCache。 +16. `RerankerService.cs:101-109,240-272` — 每条候选 2/3-gram 分词执行两遍;常量硬编码无法经 DI 调整。 + +**其他** +17. `PdfMarkdownConverter.cs:383-399,415,481-485,494,519` — 生产库 `Console.WriteLine` 调试输出;L389 循环内 `List.IndexOf` O(n²) +18. `PdfMarkdownConverter.cs:112-122` — `allFontSizes.Average()` 写在逐行循环体内(每轮 O(n)),L681/709 重复计算 +19. `EastMoneyNewsClient.cs:68` / `ClsQuoteClient.cs:64` — 拿到 factory HttpClient 后再改 `Timeout`,覆盖集中配置 +20. `ClsQuoteClient.cs:50` — 第三方签名 `sign=b02d8f7b...`、`sv=8.7.9` 硬编码 const。移配置,401/403 时提示"签名可能已过期" +21. `SqliteRagDocumentCatalog.cs:14,80-85,87-122` — `_initLock` 未 Dispose;每操作同步 Open();未开 WAL +22. `SettingsPageView.axaml:280-281,334-335` — `WebSearchApiKey`/`ZhiTuApiToken` 缺 `PasswordChar="●"`(同文件另 3 处 + ApiKeyConfigView 都有) +23. `ModelDiscoveryService.cs:26-31` — Singleton 缓存 HttpClient 字段,抵消 handler 轮换。改每次 `CreateClient` +24. `AboutPageViewModel.cs:140-181` — 远端 `asset.Name` 未净化就 `Path.Combine`;下载产物无校验和却提示直接运行 +25. `AboutPageViewModel.cs:218-228,284-294` — 空 catch 吞异常,已注入 Logger/NotificationService 未用 +26. `GlobalExceptionHandler.cs:126` / `ErrorMessageMapper.cs:60` — `StackOverflowException` 分支永不可达,误导维护者。删除并改在 `StructuredOutputValidator.ValidateNode` 加 depth 上限 +27. `PriceChangeColorConverter.cs:25-31` — `decimal.TryParse` 单参重载用 CurrentCulture,de-DE 区域下所有涨跌标签变灰。补 InvariantCulture +28. `KLineChartView.cs:255-264` — 兜底图表从 cdn.jsdelivr.net 拉 ECharts,无 SRI 无 CSP,离线永久白屏。改本地资源 +29. `AssetPageViewModel.cs:176-189` — CTS 未传给 `GetKLineDataAsync`(仅事后检查),旧 CTS 不 Dispose。对比 `PriceAlertPageViewModel.cs:212-215` 是对的 +30. `AssetSelectionPageViewModel.cs:243-254` — 重复两次 `OnPropertyChanged(CurrentButtonText)`;背后是手工枚举依赖属性模式。改 `[NotifyPropertyChangedFor]` + +--- + +## 五、修复计划 + +详见批准的执行计划(阶段 0 勘误 → 阶段 1 资金安全 → 阶段 2 RAG → 阶段 3 UI → 阶段 4 P1 → 阶段 5 P2)。验证约定按根 AGENTS.md:`.cs` 改动必须 `dotnet build MarketAssistant.slnx -c Debug`;阶段 1/2 结束跑 `dotnet test tests/TestMarketAssistant.csproj -c Debug`;每阶段 `dotnet format --verify-no-changes`;**阶段 1 完成前不启用任何实盘模式**。 + +--- + +## 六、与 GLM 的能力对比(原审计者自评,保留供参考) + +- 跨文件一致性推理与注释-实现落差检测是本次审计的核心价值(两套相反约定、"声称已修好"类问题)。 +- 局限:静态审计,未运行应用;"会导致崩溃"的判断基于代码路径推理,Avalonia 跨线程写属性的实际行为需阶段 3 冒烟确认。 +- 本次核实独立复核确认了大部分 P0,同时修正了 3 条细节(P0-3 节流现状、P0-13 严重度、P0-16 时序)并发现 4 个新 sibling 问题——说明单一审计者结论仍需交叉验证。 diff --git a/src/MarketAssistant.App.Services/Trading/AISignalStrategyExecutor.cs b/src/MarketAssistant.App.Services/Trading/AISignalStrategyExecutor.cs index f3abc91..371ca33 100644 --- a/src/MarketAssistant.App.Services/Trading/AISignalStrategyExecutor.cs +++ b/src/MarketAssistant.App.Services/Trading/AISignalStrategyExecutor.cs @@ -186,7 +186,7 @@ private async Task ExecuteDecisionAsync( // 置信度动态仓位:预算 × 置信度系数,并按档案仓位上限封顶(AI 无法突破) var sizedBudget = budget * (decision.Confidence / 100m); - var balanceSummary = await _portfolioService.GetAccountBalanceSummaryAsync(ct).ConfigureAwait(false); + var balanceSummary = await _portfolioService.GetAccountBalanceSummaryAsync(ct, useCache: false).ConfigureAwait(false); var accountValue = balanceSummary?.TotalValueUSDT ?? 0; if (accountValue > 0) { diff --git a/src/MarketAssistant.App.Services/Trading/CryptoPortfolioService.cs b/src/MarketAssistant.App.Services/Trading/CryptoPortfolioService.cs index 54a55de..4ad5da6 100644 --- a/src/MarketAssistant.App.Services/Trading/CryptoPortfolioService.cs +++ b/src/MarketAssistant.App.Services/Trading/CryptoPortfolioService.cs @@ -13,8 +13,8 @@ namespace MarketAssistant.Services.Trading; public class CryptoPortfolioService { /// - /// 账户概览缓存时长。风控与 AI 决策高频调用估值,3 秒内的陈旧数据对 - /// 仓位校验精度影响可忽略,却能显著降低网格等高频策略的账户查询压力。 + /// 账户概览缓存时长。仅供 UI 展示复用;风控与 AI 仓位封顶等资金安全路径 + /// 必须实时查询(useCache: false),1 秒级价格 tick 下陈旧快照会让连发订单绕过仓位上限。 /// private static readonly TimeSpan AccountSummaryCacheTtl = TimeSpan.FromSeconds(3); @@ -41,10 +41,16 @@ public CryptoPortfolioService( _logger = logger; } - public virtual async Task GetAccountBalanceSummaryAsync(CancellationToken ct = default) + /// + /// 获取账户估值概览。 + /// + /// 取消令牌 + /// 是否允许使用 3 秒缓存。仅 UI 展示可传 true; + /// 风控仓位校验、AI 仓位封顶等资金安全路径必须传 false 走实时查询。 + public virtual async Task GetAccountBalanceSummaryAsync(CancellationToken ct = default, bool useCache = true) { var cacheKey = CacheKeys.GetCryptoAccountSummaryKey(_environmentService.CurrentMode); - if (_memoryCache.TryGetValue(cacheKey, out AccountBalanceSummary? cached) && cached != null) + if (useCache && _memoryCache.TryGetValue(cacheKey, out AccountBalanceSummary? cached) && cached != null) return cached; var accountInfo = await _exchangeClient.GetAccountInfoAsync(ct); diff --git a/src/MarketAssistant.App.Services/Trading/Exchanges/RoutingExchangeClient.cs b/src/MarketAssistant.App.Services/Trading/Exchanges/RoutingExchangeClient.cs index 56cc1bf..cde8a69 100644 --- a/src/MarketAssistant.App.Services/Trading/Exchanges/RoutingExchangeClient.cs +++ b/src/MarketAssistant.App.Services/Trading/Exchanges/RoutingExchangeClient.cs @@ -75,6 +75,12 @@ public Task SetMarginTypeAsync(string instrumentSymbol, string marginType, Cance public Task> GetUserTradesAsync(string instrumentSymbol, CancellationToken ct = default) => GetActiveClient().GetUserTradesAsync(instrumentSymbol, ct); + /// + /// 获取当前活跃客户端的快照。下单链路(风控→杠杆→下单)必须对同一客户端实例完成, + /// 若各步骤独立重新解析,用户在等待确认期间切换模拟盘/实盘会让订单落到错误环境。 + /// + public IExchangeClient GetActiveClientSnapshot() => GetActiveClient(); + private IExchangeClient GetActiveClient() { if (_clients.TryGetValue(_environmentService.CurrentMode, out var client)) diff --git a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs index b73d5ad..32d7245 100644 --- a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs +++ b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs @@ -3,6 +3,7 @@ using System.Threading.Channels; using MarketAssistant.Applications.Crypto; using MarketAssistant.DataProviders; +using MarketAssistant.Services.Notification; using MarketAssistant.Trading.Models; using Microsoft.Extensions.Logging; @@ -21,6 +22,7 @@ public class MarketMonitor : IDisposable private readonly AISignalStrategyExecutor _aiSignalExecutor; private readonly OrderStateSyncService _orderStateSyncService; private readonly TradingStrategyService _strategyService; + private readonly INotificationService _notificationService; private readonly ILogger _logger; private readonly SemaphoreSlim _lifecycleLock = new(1, 1); @@ -97,6 +99,7 @@ public MarketMonitor( OrderStateSyncService orderStateSyncService, TradingStrategyService strategyService, BinanceUserDataStreamService userDataStreamService, + INotificationService notificationService, ILogger logger) { _webSocketService = webSocketService; @@ -106,6 +109,7 @@ public MarketMonitor( _orderStateSyncService = orderStateSyncService; _strategyService = strategyService; _userDataStreamService = userDataStreamService; + _notificationService = notificationService; _logger = logger; _priceUpdatedAdapter = (symbol, lastPrice, _) => OnPriceUpdated(symbol, lastPrice); _strategyService.StrategiesChanged += OnStrategiesChanged; @@ -152,13 +156,20 @@ public async Task StartAsync() /// /// 停止后台监控 /// - public async Task StopAsync() + public Task StopAsync() => TryStopAsync(); + + /// + /// 停止后台监控并报告是否完整停止。 + /// + /// true = 所有在途策略任务已结束;false = 等待超时,仍有未完成任务, + /// 调用方(如交易模式切换)应据此中止后续操作 + public async Task TryStopAsync() { await _lifecycleLock.WaitAsync(); try { if (!_isRunning) - return; + return true; _webSocketService.PriceUpdated -= _priceUpdatedAdapter; _userDataStreamService.OrderUpdate -= OnOrderUpdate; @@ -186,6 +197,7 @@ public async Task StopAsync() catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning(ex, "等待策略任务完成时超时或出错,部分状态可能未持久化"); + return false; } } @@ -194,6 +206,7 @@ public async Task StopAsync() _isRunning = false; _logger.LogInformation("MarketMonitor 已停止"); StatusChanged?.Invoke(false); + return true; } finally { @@ -399,7 +412,40 @@ private async Task HandleTriggeredStrategyAsync(TradingStrategy strategy, decima await ApplyTradeFailurePolicy(strategy, result.Success, result); + if (result.Success) + await CompleteOneShotStrategyAsync(strategy); + else + await CheckStrategyCompletionAsync(strategy); + } + } + + /// + /// 一次性策略的成交后完结:止损/止盈/追踪止损语义上只执行一次(触发即出场或建仓), + /// 成交后必须置为 Completed,否则条件持续满足时每个价格 tick 都会再次下单, + /// 未设 MaxExecutions 的策略会反复打光账户资金。 + /// + private async Task CompleteOneShotStrategyAsync(TradingStrategy strategy) + { + var isOneShot = strategy.Type is StrategyType.StopLoss or StrategyType.TakeProfit or StrategyType.TrailingStop; + if (!isOneShot) + { + // 网格/DCA 可多次执行,仍按 MaxExecutions 计数完结 await CheckStrategyCompletionAsync(strategy); + return; + } + + try + { + await _strategyService.UpdateStrategyStatusAsync(strategy.Id, StrategyStatus.Completed, MonitorToken); + await _strategyEngine.ClearPeakPriceAsync(strategy.Id); + _strategyLocks.TryRemove(strategy.Id, out _); + _strategyFailureCooldowns.TryRemove(strategy.Id, out _); + _logger.LogInformation( + "一次性策略已执行并完结: {StrategyId} {Type} {Symbol}", strategy.Id, strategy.Type, strategy.Symbol); + } + catch (Exception ex) + { + _logger.LogError(ex, "完结一次性策略失败,可能重复触发: {StrategyId}", strategy.Id); } } @@ -445,6 +491,8 @@ private async Task PauseStrategyAfterRejectionAsync(TradingStrategy strategy, Tr try { await _strategyService.UpdateStrategyStatusAsync(strategy.Id, StrategyStatus.Paused, MonitorToken); + _notificationService.ShowWarning( + $"⚠ 策略已自动暂停:{strategy.Symbol} {strategy.Type} — {reason}"); } catch (Exception ex) { diff --git a/src/MarketAssistant.App.Services/Trading/RiskManager.cs b/src/MarketAssistant.App.Services/Trading/RiskManager.cs index b862ee6..86c27ac 100644 --- a/src/MarketAssistant.App.Services/Trading/RiskManager.cs +++ b/src/MarketAssistant.App.Services/Trading/RiskManager.cs @@ -60,7 +60,9 @@ public virtual async Task ValidateOrderAsync( AccountBalanceSummary portfolioSummary; try { - portfolioSummary = await _portfolioService.GetAccountBalanceSummaryAsync(ct).ConfigureAwait(false); + // 风控路径必须实时估值:3 秒缓存仅供 UI 展示,1 秒级价格 tick 下 + // 连发订单若共用同一份快照会绕过仓位上限 + portfolioSummary = await _portfolioService.GetAccountBalanceSummaryAsync(ct, useCache: false).ConfigureAwait(false); } catch (Exception ex) { @@ -137,8 +139,11 @@ public virtual async Task ValidateOrderAsync( } catch (Exception ex) { - // 查询交易所持仓失败时不阻止交易(可能是网络问题),仅记录警告 - _logger.LogWarning(ex, "查询交易所持仓用于风控校验失败,跳过合约平多校验: {Symbol}", instrumentSymbol); + // fail-closed:无法确认交易所持仓就放行,平多单可能在持仓已平后 + // 以 reduceOnly=false 落地变成反向开仓,与基础资产解析失败的拒单策略保持一致 + _logger.LogError(ex, "查询交易所持仓用于风控校验失败,拒绝交易(fail-closed): {Symbol}", instrumentSymbol); + return RiskCheckResult.Reject( + $"无法查询 {instrumentSymbol} 的交易所持仓,合约卖出校验失败(fail-closed)"); } } else diff --git a/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs b/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs index feda2ab..23b4a2b 100644 --- a/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs +++ b/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs @@ -9,8 +9,8 @@ namespace MarketAssistant.Services.Trading; /// /// 策略引擎,管理用户策略并评估触发条件。 -/// 合约模式下支持将止损/止盈/追踪止损提交为交易所原生条件单, -/// 由交易所服务端监控触发,无需客户端持续轮询价格。 +/// 止损/止盈/追踪止损均由客户端按价格 tick 轮询评估并执行, +/// 进程退出或网络中断期间不生效(创建策略时会向用户明确提示该限制)。 /// public class StrategyEngine { @@ -478,106 +478,4 @@ await _strategyService.UpdateStrategyStatusAsync(strategy.Id, StrategyStatus.Pau return null; } - /// - /// 当前交易模式是否支持原生条件单(仅合约模式支持)。 - /// - public bool IsNativeConditionalOrderSupported => _exchangeClient.IsFutures; - - /// - /// 尝试将止损/止盈/追踪止损策略提交为交易所原生条件单。 - /// 仅合约模式支持;现货模式返回 null,调用方应回退到客户端轮询评估。 - /// - /// 交易所返回的订单 ID;不支持或失败时返回 null - public async Task TryPlaceNativeConditionalOrderAsync( - TradingStrategy strategy, CancellationToken ct = default) - { - if (!_exchangeClient.IsFutures) - return null; - - try - { - var orderType = strategy.Type switch - { - StrategyType.StopLoss => OrderType.StopMarket, - StrategyType.TakeProfit => OrderType.TakeProfitMarket, - StrategyType.TrailingStop => OrderType.TrailingStopMarket, - _ => (OrderType?)null - }; - - if (!orderType.HasValue) - return null; - - // 条件单均以 reduceOnly=true 提交,确保只平仓不开新仓 - decimal? stopPrice = null; - int? trailingDelta = null; - - if (strategy.Type == StrategyType.TrailingStop) - { - // 从 CustomParams 解析回调比例(百分比转基点:1% = 100) - if (!string.IsNullOrEmpty(strategy.CustomParams)) - { - using var doc = JsonDocument.Parse(strategy.CustomParams); - if (doc.RootElement.TryGetProperty("trailingPercent", out var tpEl)) - { - var percent = tpEl.GetDecimal(); - trailingDelta = (int)(percent * 100); - } - } - if (!trailingDelta.HasValue || trailingDelta.Value <= 0) - { - _logger.LogWarning("追踪止损策略 {StrategyId} 缺少 trailingPercent 参数,无法提交原生条件单", strategy.Id); - return null; - } - } - else - { - stopPrice = strategy.TriggerPrice; - } - - var result = await _exchangeClient.PlaceOrderAsync( - strategy.Symbol, - strategy.Side, - orderType.Value, - strategy.Quantity, - stopPrice: stopPrice, - reduceOnly: true, - trailingDelta: trailingDelta, - ct: ct); - - _logger.LogInformation( - "策略 {StrategyId} 已提交为原生条件单:{Type} {Side} {Symbol} 订单ID={OrderId}", - strategy.Id, orderType.Value, strategy.Side, strategy.Symbol, result.OrderId); - - return result.OrderId; - } - catch (Exception ex) - { - _logger.LogError(ex, - "策略 {StrategyId} 提交原生条件单失败,将回退到客户端评估", strategy.Id); - return null; - } - } - - /// - /// 取消交易所上的原生条件单(策略完成或删除时调用)。 - /// - public async Task TryCancelNativeConditionalOrderAsync( - string symbol, string orderId, CancellationToken ct = default) - { - if (!_exchangeClient.IsFutures || string.IsNullOrEmpty(orderId)) - return false; - - try - { - await _exchangeClient.CancelOrderAsync(symbol, orderId, ct); - _logger.LogInformation("已取消原生条件单:{Symbol} 订单ID={OrderId}", symbol, orderId); - return true; - } - catch (Exception ex) - { - _logger.LogWarning(ex, - "取消原生条件单失败:{Symbol} 订单ID={OrderId}", symbol, orderId); - return false; - } - } } diff --git a/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs b/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs index 8bebed0..0ddf078 100644 --- a/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs +++ b/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs @@ -1,4 +1,6 @@ using System.Collections.Concurrent; +using System.Net.Sockets; +using MarketAssistant.Services.Trading.Exchanges; using MarketAssistant.Trading.Abstractions; using MarketAssistant.Trading.Models; using Microsoft.Extensions.Logging; @@ -15,6 +17,7 @@ public class TradeExecutor : IDisposable new(StringComparer.OrdinalIgnoreCase); private readonly IExchangeClient _exchangeClient; + private readonly TradingEnvironmentService? _environmentService; private readonly RiskManager _riskManager; private readonly TradingDataService _dataService; private readonly ILogger _logger; @@ -31,12 +34,14 @@ public TradeExecutor( [FromKeyedServices(MarketType.Crypto)] IExchangeClient exchangeClient, RiskManager riskManager, TradingDataService dataService, - ILogger logger) + ILogger logger, + TradingEnvironmentService? environmentService = null) { _exchangeClient = exchangeClient; _riskManager = riskManager; _dataService = dataService; _logger = logger; + _environmentService = environmentService; } /// @@ -95,6 +100,12 @@ public async Task ExecuteOrderAsync( bool requireClose = false, CancellationToken ct = default) { + // 环境快照:一次下单的判断(IsFutures/持仓/杠杆/下单)必须全部落在同一个客户端上。 + // RoutingExchangeClient 每次调用独立解析活跃客户端,若无快照,用户在风控/确认等待期间 + // 切换模拟盘/实盘会让这笔订单落到错误环境(真金白银场景下不可接受)。 + var exchangeClient = ResolveClientSnapshot(); + var modeAtEntry = _environmentService?.CurrentMode; + // 风控校验和人工确认在 symbol 锁外完成,防止 ConfirmationCallback 等待期间 // 持有 SemaphoreSlim,导致同一标的后续所有交易永久阻塞。 var riskCheck = await _riskManager.ValidateOrderAsync(instrumentSymbol, side, quantity, currentPrice, type, ct); @@ -145,11 +156,26 @@ public async Task ExecuteOrderAsync( await gate.WaitAsync(ct).ConfigureAwait(false); try { + // 快照一致性复检:等待风控/用户确认期间交易模式可能已被切换, + // 与入口快照不一致时拒绝本笔订单,绝不把它路由到另一个环境 + if (modeAtEntry.HasValue && _environmentService!.CurrentMode != modeAtEntry.Value) + { + _logger.LogError( + "交易模式在确认期间被切换({EntryMode} → {CurrentMode}),拒绝下单: {Symbol} {Side}", + modeAtEntry.Value, _environmentService.CurrentMode, instrumentSymbol, side); + return new TradeResult + { + Success = false, + ErrorMessage = $"交易模式在确认期间被切换({modeAtEntry.Value} → {_environmentService.CurrentMode}),本笔订单已取消,请重新发起", + FailureCategory = TradeFailureCategory.Rejected + }; + } + // 锁内复检:风控校验与人工确认在锁外完成(防确认等待期间锁死同一标的), // 等待期间同一标的的其他现货卖出可能已消耗本地 FIFO 持仓。两笔并发卖出若都基于 // 同一份持仓快照通过风控,会依次成交造成超卖与负持仓,故获取锁后必须重验。 // 合约模式以交易所持仓为准且 reduceOnly 由交易所强制,无需本地复检。 - if (side == OrderSide.Sell && !_exchangeClient.IsFutures) + if (side == OrderSide.Sell && !exchangeClient.IsFutures) { var openPositions = await _dataService.GetOpenPositionsAsync(instrumentSymbol, ct).ConfigureAwait(false); var availableQuantity = openPositions @@ -171,6 +197,7 @@ public async Task ExecuteOrderAsync( } return await ExecuteApprovedOrderAsync( + exchangeClient, modeAtEntry, instrumentSymbol, side, type, quantity, currentPrice, limitPrice, strategyId, aiReasoning, requireClose, ct).ConfigureAwait(false); } @@ -180,7 +207,17 @@ public async Task ExecuteOrderAsync( } } + /// + /// 解析当前活跃客户端快照:RoutingExchangeClient 按当前模式路由, + /// 其他实现(单元测试 Mock)原样返回。 + /// + private static IExchangeClient ResolveClientSnapshot(IExchangeClient client) + => client is RoutingExchangeClient router ? router.GetActiveClientSnapshot() : client; + + private IExchangeClient ResolveClientSnapshot() => ResolveClientSnapshot(_exchangeClient); + private async Task ExecuteApprovedOrderAsync( + IExchangeClient exchangeClient, CryptoTradingMode? modeAtEntry, string instrumentSymbol, OrderSide side, OrderType type, decimal quantity, decimal currentPrice, decimal? limitPrice, string strategyId, string? aiReasoning, bool requireClose, @@ -195,11 +232,26 @@ private async Task ExecuteApprovedOrderAsync( // 合约模式:判断本次操作是开仓还是平仓 // 平仓 = 持有多头时卖出 / 持有空头时买入,需要 reduceOnly=true - var isFutures = _exchangeClient.IsFutures; + var isFutures = exchangeClient.IsFutures; var reduceOnly = false; if (isFutures) { - reduceOnly = await IsClosePositionAsync(instrumentSymbol, side, ct); + // 持仓查询失败时无法判定开/平仓:默认按开仓会把平仓单变成反向开仓(10x 风险敞口), + // 必须中止本笔订单(fail-closed),按网络类失败走短冷却后重试 + var closePosition = await IsClosePositionAsync(exchangeClient, instrumentSymbol, side, ct); + if (!closePosition.HasValue) + { + _logger.LogError( + "查询合约持仓失败,无法判定开/平仓,拒绝下单以免平仓变反向开仓: {Symbol} {Side}", + instrumentSymbol, side); + return new TradeResult + { + Success = false, + ErrorMessage = $"无法确认 {instrumentSymbol} 合约持仓状态,本笔订单已取消,请稍后重试", + FailureCategory = TradeFailureCategory.Network + }; + } + reduceOnly = closePosition.Value; // 退出型触发要求本笔为平仓:交易所无对应方向持仓时拒绝, // 防止止损/追踪止损/网格破网/AI 硬性边界在持仓已平后反向开出新仓。 @@ -221,7 +273,7 @@ private async Task ExecuteApprovedOrderAsync( { try { - await _exchangeClient.SetLeverageAsync(instrumentSymbol, DefaultFuturesLeverage, ct); + await exchangeClient.SetLeverageAsync(instrumentSymbol, DefaultFuturesLeverage, ct); } catch (Exception ex) { @@ -240,14 +292,14 @@ private async Task ExecuteApprovedOrderAsync( { try { - response = await _exchangeClient.PlaceOrderAsync( + response = await exchangeClient.PlaceOrderAsync( instrumentSymbol, side, type, quantity, type == OrderType.Limit ? limitPrice : null, clientOrderId, reduceOnly, stopPrice: null, trailingDelta: null, ct: ct); break; } - catch (HttpRequestException ex) + catch (Exception ex) when (IsTransient(ex, out _) && !ct.IsCancellationRequested) { lastNetworkException = ex; if (attempt >= maxRetries) @@ -316,14 +368,41 @@ await _dataService.UpdateDailyStatsAsync(pnl, record.Commission, catch (Exception ex) { _logger.LogError(ex, "交易执行失败: {InstrumentSymbol} {Side}", instrumentSymbol, side); - // 网络类异常(含重试耗尽)短期可恢复;其余归为其他失败由调用方按冷却策略处理 - var category = ex is HttpRequestException or TimeoutException || ex.InnerException is HttpRequestException + // 网络类异常(含重试耗尽)短期可恢复;其余归为其他失败由调用方按冷却策略处理。 + // 交易所异常统一被包装成 FriendlyException,必须沿 InnerException 链递归判定。 + var category = IsTransient(ex, out _) ? TradeFailureCategory.Network : TradeFailureCategory.Other; return new TradeResult { Success = false, ErrorMessage = ex.Message, FailureCategory = category }; } } + /// + /// 递归判定异常是否为瞬时(网络/超时)类,沿 InnerException 链检查。 + /// 交易所客户端会把 HttpRequestException 包装成 FriendlyException 抛出, + /// 仅判断顶层类型会让重试与冷却分类全部失效。 + /// + /// 待判定异常 + /// 出参:TaskCanceledException 对应的取消令牌是否为用户主动取消 + internal static bool IsTransient(Exception ex, out bool userCanceled) + { + userCanceled = false; + for (var current = ex; current != null; current = current.InnerException) + { + if (current is HttpRequestException or TimeoutException or SocketException) + return true; + if (current is TaskCanceledException taskCanceled) + { + // 令牌未被取消 = HttpClient 超时触发的取消,属瞬时网络问题; + // 令牌已取消 = 用户/系统主动取消,不重试 + if (!taskCanceled.CancellationToken.IsCancellationRequested) + return true; + userCanceled = true; + } + } + return false; + } + /// /// 合约默认杠杆倍数。开仓前自动设置,避免使用交易所默认的 20x 导致强平风险过高。 /// @@ -333,12 +412,13 @@ await _dataService.UpdateDailyStatsAsync(pnl, record.Commission, /// 判断合约交易方向是否为平仓操作。 /// 持有多头(PositionAmt > 0)时卖出 = 平多 /// 持有空头(PositionAmt < 0)时买入 = 平空 + /// 返回 null 表示持仓查询失败、开/平仓无法判定(调用方必须中止下单)。 /// - private async Task IsClosePositionAsync(string symbol, OrderSide side, CancellationToken ct) + private async Task IsClosePositionAsync(IExchangeClient exchangeClient, string symbol, OrderSide side, CancellationToken ct) { try { - var positions = await _exchangeClient.GetPositionsAsync(symbol, ct); + var positions = await exchangeClient.GetPositionsAsync(symbol, ct); foreach (var pos in positions) { if (!string.Equals(pos.Symbol, symbol, StringComparison.OrdinalIgnoreCase)) @@ -350,12 +430,13 @@ private async Task IsClosePositionAsync(string symbol, OrderSide side, Can if (posAmt < 0 && side == OrderSide.Buy) return true; // 平空 } + return false; } catch (Exception ex) { - _logger.LogWarning(ex, "查询合约持仓失败,无法判断是否为平仓,默认按开仓处理: {Symbol}", symbol); + _logger.LogWarning(ex, "查询合约持仓失败,无法判断是否为平仓: {Symbol}", symbol); + return null; } - return false; } /// diff --git a/src/MarketAssistant.App.Services/Trading/TradingCredentialStore.cs b/src/MarketAssistant.App.Services/Trading/TradingCredentialStore.cs index c644d73..a2579aa 100644 --- a/src/MarketAssistant.App.Services/Trading/TradingCredentialStore.cs +++ b/src/MarketAssistant.App.Services/Trading/TradingCredentialStore.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -29,7 +30,10 @@ public sealed class TradingCredentialStore : ITradingCredentialStore private readonly ILogger _logger; private readonly object _fileLock = new(); - private Dictionary _cache; + + // UI 线程写入(保存密钥)与后台签名线程读取(每次交易所请求)并发访问, + // 必须使用并发容器,普通 Dictionary 扩容期间被并发读会抛异常甚至死循环 + private ConcurrentDictionary _cache; public TradingCredentialStore( IUserSettingService userSettingService, @@ -51,7 +55,7 @@ public TradingCredentialStore( public void SetCredentials(CryptoTradingMode mode, string apiKey, string secretKey) { - _cache[mode] = new CredentialEntry(apiKey, secretKey); + _cache.AddOrUpdate(mode, new CredentialEntry(apiKey, secretKey), (_, _) => new CredentialEntry(apiKey, secretKey)); Save(); } @@ -64,7 +68,7 @@ public bool IsConfigured(CryptoTradingMode mode) public void ClearCredentials(CryptoTradingMode mode) { - if (_cache.Remove(mode)) + if (_cache.TryRemove(mode, out _)) Save(); } @@ -111,14 +115,14 @@ private void MigrateFromUserSettingIfNeeded(UserSetting setting) } } - private Dictionary Load() + private ConcurrentDictionary Load() { lock (_fileLock) { try { if (!File.Exists(FilePath)) - return new Dictionary(); + return new ConcurrentDictionary(); var bytes = File.ReadAllBytes(FilePath); return Decrypt(bytes); @@ -126,7 +130,7 @@ private Dictionary Load() catch (Exception ex) { _logger.LogWarning(ex, "加载交易凭证失败,使用空配置"); - return new Dictionary(); + return new ConcurrentDictionary(); } } } @@ -170,10 +174,10 @@ private byte[] Encrypt(string plaintext) return result; } - private Dictionary Decrypt(byte[] data) + private ConcurrentDictionary Decrypt(byte[] data) { if (data.Length < SaltSize + NonceSize + TagSize) - return new Dictionary(); + return new ConcurrentDictionary(); var salt = new byte[SaltSize]; var nonce = new byte[NonceSize]; @@ -192,8 +196,9 @@ private Dictionary Decrypt(byte[] data) aes.Decrypt(nonce, ciphertext, tag, plaintextBytes); var json = Encoding.UTF8.GetString(plaintextBytes); - return JsonSerializer.Deserialize>(json) + var entries = JsonSerializer.Deserialize>(json) ?? new Dictionary(); + return new ConcurrentDictionary(entries); } /// diff --git a/src/MarketAssistant.App.Services/Trading/TradingDataService.cs b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs index 1d8adae..598d5bc 100644 --- a/src/MarketAssistant.App.Services/Trading/TradingDataService.cs +++ b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs @@ -88,11 +88,11 @@ public async Task SaveStrategyAsync(TradingStrategy strategy, CancellationToken INSERT OR REPLACE INTO strategies (id, environment, 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, native_order_id) + execution_count, max_executions, trailing_peak_price) VALUES (@id, @environment, @symbol, @type, @status, @side, @triggerPrice, @slPrice, @tpPrice, @qty, @maxPos, @customParams, @createdAt, @lastTriggered, - @execCount, @maxExec, @trailingPeak, @nativeOrderId) + @execCount, @maxExec, @trailingPeak) """; cmd.Parameters.AddWithValue("@id", strategy.Id); cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); @@ -111,7 +111,6 @@ INSERT OR REPLACE INTO strategies cmd.Parameters.AddWithValue("@execCount", strategy.ExecutionCount); cmd.Parameters.AddWithValue("@maxExec", strategy.MaxExecutions.HasValue ? (object)strategy.MaxExecutions.Value : DBNull.Value); cmd.Parameters.AddWithValue("@trailingPeak", ToDbNullable(strategy.TrailingPeakPrice)); - cmd.Parameters.AddWithValue("@nativeOrderId", (object?)strategy.NativeOrderId ?? DBNull.Value); await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } @@ -291,21 +290,6 @@ public virtual async Task UpdateStrategyTrailingPeakAsync(string id, decimal? tr await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } - /// - /// 更新策略的原生条件单订单 ID(提交原生条件单后调用) - /// - public async Task UpdateStrategyNativeOrderIdAsync(string id, string? nativeOrderId, CancellationToken ct = default) - { - await EnsureInitializedAsync(InitializeDatabaseAsync); - await using var conn = await OpenConnectionAsync(ct); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "UPDATE strategies SET native_order_id = @nativeOrderId WHERE id = @id AND environment = @environment"; - cmd.Parameters.AddWithValue("@id", id); - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@nativeOrderId", (object?)nativeOrderId ?? DBNull.Value); - await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - } - #endregion #region 交易记录 @@ -1035,8 +1019,7 @@ CREATE TABLE IF NOT EXISTS strategies ( last_triggered_at TEXT, execution_count INTEGER DEFAULT 0, max_executions INTEGER, - trailing_peak_price TEXT, - native_order_id TEXT + trailing_peak_price TEXT ) """, ["trade_records"] = """ @@ -1250,9 +1233,6 @@ private static TradingStrategy ReadStrategy(SqliteDataReader reader) var trailingOrd = reader.GetOrdinal("trailing_peak_price"); if (!reader.IsDBNull(trailingOrd)) strategy.TrailingPeakPrice = (decimal)reader.GetDouble(trailingOrd); - var nativeOrd = reader.GetOrdinal("native_order_id"); - if (!reader.IsDBNull(nativeOrd)) strategy.NativeOrderId = reader.GetString(nativeOrd); - return strategy; } @@ -1345,7 +1325,6 @@ private static async Task EnsureEnvironmentSchemaAsync( SqliteTransaction transaction) { await EnsureColumnAsync(conn, transaction, "strategies", "environment", $"TEXT NOT NULL DEFAULT '{LiveSpotEnvironment}'").ConfigureAwait(false); - await EnsureColumnAsync(conn, transaction, "strategies", "native_order_id", "TEXT").ConfigureAwait(false); await EnsureColumnAsync(conn, transaction, "trade_records", "environment", $"TEXT NOT NULL DEFAULT '{LiveSpotEnvironment}'").ConfigureAwait(false); await EnsureColumnAsync(conn, transaction, "positions", "environment", $"TEXT NOT NULL DEFAULT '{LiveSpotEnvironment}'").ConfigureAwait(false); await MigrateDailyStatsAsync(conn, transaction).ConfigureAwait(false); diff --git a/src/MarketAssistant.App.Services/Trading/TradingEnvironmentService.cs b/src/MarketAssistant.App.Services/Trading/TradingEnvironmentService.cs index 2224dbd..5c57321 100644 --- a/src/MarketAssistant.App.Services/Trading/TradingEnvironmentService.cs +++ b/src/MarketAssistant.App.Services/Trading/TradingEnvironmentService.cs @@ -1,3 +1,4 @@ +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Services.Settings; using MarketAssistant.Trading.Models; using Microsoft.Extensions.Logging; @@ -14,7 +15,10 @@ public sealed class TradingEnvironmentService private readonly IUserSettingService _userSettingService; private readonly ILogger _logger; private readonly Func _marketMonitorFactory; - private CryptoTradingMode _currentMode; + + // 后台下单链路(MarketMonitor 消费者/TradeExecutor)与 UI 线程并发读写, + // 必须 volatile 保证模式切换立即对后台线程可见,避免"切到实盘仍在 Demo 下单"的反向错误 + private volatile CryptoTradingMode _currentMode; public TradingEnvironmentService( IUserSettingService userSettingService, @@ -52,8 +56,9 @@ public static bool RequiresLiveModeConfirmation( } /// - /// 切换交易模式。若监控正在运行,先等待其完全停止(最长 10 秒)再切换, - /// 避免切换瞬间在途策略任务或订单状态同步访问新环境的账户与数据。 + /// 切换交易模式。若监控正在运行,先等待其完全停止(最长 10 秒)再切换; + /// 停止超时(存在未完成的在途策略任务)时中止切换并抛错, + /// 防止在途订单被路由到新环境造成"该下单到模拟盘却落到实盘"的资金安全事故。 /// public async Task ApplyModeAsync(CryptoTradingMode mode) { @@ -66,7 +71,15 @@ public async Task ApplyModeAsync(CryptoTradingMode mode) if (monitor.IsRunning) { _logger.LogInformation("切换交易模式前停止市场监控: {OldMode} → {NewMode}", _currentMode, mode); - await monitor.StopAsync(); + var stopped = await monitor.TryStopAsync().ConfigureAwait(false); + if (!stopped) + { + _logger.LogError( + "市场监控停止超时(存在在途策略任务),已中止交易模式切换: {OldMode} → {NewMode}", + _currentMode, mode); + throw new FriendlyException( + "存在正在执行的策略任务,市场监控未能及时停止,交易模式切换已中止。请稍后重试;若持续失败请检查网络后重启应用。"); + } } _currentMode = mode; diff --git a/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs b/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs index d5ee3b2..e59aaaf 100644 --- a/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs @@ -10,6 +10,7 @@ using MarketAssistant.Infrastructure.Core; using MarketAssistant.Infrastructure.Extensions; using MarketAssistant.Services.Dialog; +using MarketAssistant.Services.Notification; using MarketAssistant.Services.Trading; using MarketAssistant.Trading.Models; using MarketAssistant.Views.Windows; @@ -24,6 +25,7 @@ public partial class StrategyConfigViewModel : ViewModelBase, IDisposable private readonly MarketMonitor _marketMonitor; private readonly IDialogService _dialogService; private readonly BinanceMarketDataService _marketDataService; + private readonly INotificationService _notificationService; private bool _disposed; public ObservableCollection Strategies { get; } = []; @@ -172,6 +174,7 @@ public StrategyConfigViewModel( MarketMonitor marketMonitor, IDialogService dialogService, BinanceMarketDataService marketDataService, + INotificationService notificationService, ILogger logger) : base(logger) { @@ -180,6 +183,7 @@ public StrategyConfigViewModel( _marketMonitor = marketMonitor; _dialogService = dialogService; _marketDataService = marketDataService; + _notificationService = notificationService; IsMonitorRunning = _marketMonitor.IsRunning; _marketMonitor.StatusChanged += OnMonitorStatusChanged; ApplyScenarioPreset(); @@ -232,6 +236,11 @@ await SafeExecuteAsync(async () => await _strategyService.SaveStrategyAsync(strategy); Strategies.Insert(0, strategy); + // 安全警示必须显式可见:本应用没有交易所原生条件单兜底, + // 止损/止盈/追踪止损全部由客户端按秒轮询执行,进程退出或网络中断期间不生效 + _notificationService.ShowWarning( + "⚠ 注意:止损/止盈/追踪止损由本程序每秒轮询执行,程序退出或断网期间不会触发,请勿完全依赖程序止损。"); + ClearForm(); }, "创建策略"); } diff --git a/src/MarketAssistant.Trading/TradingStrategy.cs b/src/MarketAssistant.Trading/TradingStrategy.cs index f7d35ec..1aef63d 100644 --- a/src/MarketAssistant.Trading/TradingStrategy.cs +++ b/src/MarketAssistant.Trading/TradingStrategy.cs @@ -51,11 +51,4 @@ public class TradingStrategy [Description("追踪止损的峰值/谷值价格")] public decimal? TrailingPeakPrice { get; set; } - - /// - /// 原生条件单的交易所订单 ID。非空时表示该策略已提交为交易所原生条件单, - /// 客户端无需再轮询评估;策略完成/删除时应调用 TryCancelNativeConditionalOrderAsync 取消。 - /// - [Description("原生条件单订单ID")] - public string? NativeOrderId { get; set; } } diff --git a/tests/Trading/AISignalHardBoundaryTest.cs b/tests/Trading/AISignalHardBoundaryTest.cs index a6cf698..2a937c4 100644 --- a/tests/Trading/AISignalHardBoundaryTest.cs +++ b/tests/Trading/AISignalHardBoundaryTest.cs @@ -20,7 +20,7 @@ public sealed class AISignalHardBoundaryTest [TestCategory("Unit")] public async Task StopLoss_Triggered_ShouldCloseInverse_AndCompleteStrategy_AndRestoreSide() { - var tradeExecutor = new Mock(null!, null!, null!, NullLogger.Instance); + var tradeExecutor = new Mock(null!, null!, null!, NullLogger.Instance, null); var strategyService = new Mock(null!); var (executor, strategy) = CreateExecutor( tradeExecutor, strategyService.Object, hasPosition: true, @@ -44,7 +44,7 @@ public async Task StopLoss_Triggered_ShouldCloseInverse_AndCompleteStrategy_AndR [TestCategory("Unit")] public async Task StopLoss_Triggered_WithoutPosition_ShouldCompleteWithoutOrdering() { - var tradeExecutor = new Mock(null!, null!, null!, NullLogger.Instance); + var tradeExecutor = new Mock(null!, null!, null!, NullLogger.Instance, null); var strategyService = new Mock(null!); var (executor, strategy) = CreateExecutor( tradeExecutor, strategyService.Object, hasPosition: false, @@ -71,7 +71,7 @@ public async Task StopLoss_Triggered_WithoutPosition_ShouldCompleteWithoutOrderi [TestCategory("Unit")] public async Task StopLoss_Triggered_CloseFailed_StrategyShouldStayActive() { - var tradeExecutor = new Mock(null!, null!, null!, NullLogger.Instance); + var tradeExecutor = new Mock(null!, null!, null!, NullLogger.Instance, null); var strategyService = new Mock(null!); var (executor, strategy) = CreateExecutor( tradeExecutor, strategyService.Object, hasPosition: true, diff --git a/tests/Trading/StrategyEngineTriggerTest.cs b/tests/Trading/StrategyEngineTriggerTest.cs index d2930bf..45115bd 100644 --- a/tests/Trading/StrategyEngineTriggerTest.cs +++ b/tests/Trading/StrategyEngineTriggerTest.cs @@ -123,8 +123,11 @@ public async Task Grid_FirstEvaluation_PersistsBaselineIndex_WithoutTrigger() var ctx = CreateEngine(); var gridParams = new GridTradingParams { - UpperPrice = 110m, LowerPrice = 90m, GridCount = 10, - QuantityPerGrid = 1m, LastTriggeredIndex = -1 + UpperPrice = 110m, + LowerPrice = 90m, + GridCount = 10, + QuantityPerGrid = 1m, + LastTriggeredIndex = -1 }; var strategy = CreateStrategy( StrategyType.GridTrading, OrderSide.Buy, JsonSerializer.Serialize(gridParams)); @@ -151,8 +154,11 @@ public async Task Grid_CrossingLines_TriggersBuyBelow_AndSellAbove() var ctx = CreateEngine(); var gridParams = new GridTradingParams { - UpperPrice = 110m, LowerPrice = 90m, GridCount = 10, - QuantityPerGrid = 1m, LastTriggeredIndex = 5 + UpperPrice = 110m, + LowerPrice = 90m, + GridCount = 10, + QuantityPerGrid = 1m, + LastTriggeredIndex = 5 }; var strategy = CreateStrategy( StrategyType.GridTrading, OrderSide.Buy, JsonSerializer.Serialize(gridParams)); @@ -185,8 +191,10 @@ public async Task DCA_NoPosition_TriggersBuyAfterInterval() var ctx = CreateEngine(); var dcaParams = new DCAParams { - AmountPerInterval = 100m, IntervalSeconds = 1, - TakeProfitPercent = 10m, StopLossPercent = 20m + AmountPerInterval = 100m, + IntervalSeconds = 1, + TakeProfitPercent = 10m, + StopLossPercent = 20m }; var strategy = CreateStrategy(StrategyType.DCA, OrderSide.Buy, JsonSerializer.Serialize(dcaParams)); strategy.LastTriggeredAt = DateTime.UtcNow.AddHours(-1); @@ -210,8 +218,10 @@ public async Task DCA_TakeProfitReached_SellsFullRemainingPosition() var ctx = CreateEngine(); var dcaParams = new DCAParams { - AmountPerInterval = 100m, IntervalSeconds = 1, - TakeProfitPercent = 10m, StopLossPercent = 20m + AmountPerInterval = 100m, + IntervalSeconds = 1, + TakeProfitPercent = 10m, + StopLossPercent = 20m }; var strategy = CreateStrategy(StrategyType.DCA, OrderSide.Buy, JsonSerializer.Serialize(dcaParams)); strategy.LastTriggeredAt = DateTime.UtcNow; @@ -239,8 +249,10 @@ public async Task DCA_StopLossReached_WithoutSellOut_PausesStrategy() var ctx = CreateEngine(); var dcaParams = new DCAParams { - AmountPerInterval = 100m, IntervalSeconds = 1, - TakeProfitPercent = 10m, StopLossPercent = 20m, + AmountPerInterval = 100m, + IntervalSeconds = 1, + TakeProfitPercent = 10m, + StopLossPercent = 20m, StopLossSellOut = false }; var strategy = CreateStrategy(StrategyType.DCA, OrderSide.Buy, JsonSerializer.Serialize(dcaParams)); @@ -269,8 +281,10 @@ public async Task DCA_StopLossReached_WithSellOut_SellsFullPosition() var ctx = CreateEngine(); var dcaParams = new DCAParams { - AmountPerInterval = 100m, IntervalSeconds = 1, - TakeProfitPercent = 10m, StopLossPercent = 20m, + AmountPerInterval = 100m, + IntervalSeconds = 1, + TakeProfitPercent = 10m, + StopLossPercent = 20m, StopLossSellOut = true }; var strategy = CreateStrategy(StrategyType.DCA, OrderSide.Buy, JsonSerializer.Serialize(dcaParams)); diff --git a/tests/Trading/TradeExecutorConcurrencyTest.cs b/tests/Trading/TradeExecutorConcurrencyTest.cs index ba6dd01..1ee163f 100644 --- a/tests/Trading/TradeExecutorConcurrencyTest.cs +++ b/tests/Trading/TradeExecutorConcurrencyTest.cs @@ -97,7 +97,7 @@ public async Task Sell_OnUnparseableSymbol_RiskCheckShouldFailClosed() var portfolioService = new Mock(null!, null!, null!, null!, null!, NullLogger.Instance); portfolioService - .Setup(service => service.GetAccountBalanceSummaryAsync(It.IsAny())) + .Setup(service => service.GetAccountBalanceSummaryAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new AccountBalanceSummary { TotalValueUSDT = 1000m, diff --git a/tests/Trading/TradingDataServiceMigrationTest.cs b/tests/Trading/TradingDataServiceMigrationTest.cs index f0dca7b..c4a2bc2 100644 --- a/tests/Trading/TradingDataServiceMigrationTest.cs +++ b/tests/Trading/TradingDataServiceMigrationTest.cs @@ -38,7 +38,6 @@ public async Task MigrateDatabaseSchemaAsync_FromLegacySchema_PreservesDataAndCr await TradingDataService.MigrateDatabaseSchemaAsync(connection); await AssertColumnExistsAsync(connection, "strategies", "environment"); - await AssertColumnExistsAsync(connection, "strategies", "native_order_id"); await AssertColumnExistsAsync(connection, "trade_records", "environment"); await AssertColumnExistsAsync(connection, "positions", "environment"); await AssertColumnExistsAsync(connection, "daily_stats", "environment"); From 6633f8a1bbcaf1e75f0f07fe6baf157e37fc1653 Mon Sep 17 00:00:00 2001 From: MayueCif Date: Fri, 28 Aug 2026 22:51:47 +0800 Subject: [PATCH 3/9] =?UTF-8?q?[Infra]=20=E5=BF=BD=E7=95=A5=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=20.zcode=20=E8=AE=A1=E5=88=92=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- ...ss_8fc947a7-0f60-4bee-8091-04b858439e94.md | 61 ------------------- 2 files changed, 1 insertion(+), 62 deletions(-) delete mode 100644 .zcode/plans/plan-sess_8fc947a7-0f60-4bee-8091-04b858439e94.md diff --git a/.gitignore b/.gitignore index 2ff28cc..3f47674 100644 --- a/.gitignore +++ b/.gitignore @@ -408,4 +408,4 @@ FodyWeavers.xsd .secrets .env *.pem -*.key \ No newline at end of file +*.key.zcode/ diff --git a/.zcode/plans/plan-sess_8fc947a7-0f60-4bee-8091-04b858439e94.md b/.zcode/plans/plan-sess_8fc947a7-0f60-4bee-8091-04b858439e94.md deleted file mode 100644 index b531233..0000000 --- a/.zcode/plans/plan-sess_8fc947a7-0f60-4bee-8091-04b858439e94.md +++ /dev/null @@ -1,61 +0,0 @@ -# MarketAssistant 重构修复计划(全量 P0+P1+P2) - -基于审计报告 + 三个并行源码核实。P0 16 条中 13 条完全属实、3 条部分准确(细节修正已并入下文)、无虚警;P1 抽查发现 2 条虚警(#17、#30,**从计划中剔除**);核实另发现 4 个报告遗漏的 sibling 问题,已纳入对应阶段。 - -**既有决策**(沿用):WebView HTML 渲染默认禁用;交易域死代码直接删除整块。 - ---- - -## 阶段 0 — 文档勘误(先落 docs/代码审计报告-2026-08-28.md) -- 修正 P0-3:并非零节流(`MarketMonitor.cs:272` 已有失败冷却);Paused 已正确写入(L447),缺的是用户可见通知。 -- 修正 P0-16:泄漏根因是 P0-14 导致旧 VM 连 Dispose 都不发生,时序与报告所述相反。 -- 修正 P0-13 严重度:模态框滞留为 UX 级问题。 -- P1-17("批量嵌入"实为逐个)与 P1-30(停止按钮不可达)标记为虚警删除。 -- 补充 4 个新 sibling:TradeExecutor.cs:247 stopPrice 硬编码 null;InvestmentSelection 三个 Executor 均吞取消;零向量破坏 `null=不可用` 契约;OnMarketChanged 需 disposed 防护的其余 4 个 VM。 -- 修正路径偏差:Singleton 注册在 `App.Services/Services/ServiceCollectionExtensions.cs`。 - -## 阶段 1 — 资金安全(P0-1~5 + 放大器 + sibling #1) -1. **删除原生条件单整套**(按决策):`StrategyEngine.cs:484,491-559,564-582`、`TradingDataService.cs:297-307,1348`、`TradingStrategy.cs:56-60`、`strategies.native_order_id` 列及迁移;在合约止损策略创建路径加"客户端轮询执行、进程退出期间不生效"显式警示。 -2. **TradeExecutor 重试循环**(P0-2):改为 `IsTransient(ex)` 递归判定(HttpRequestException/TimeoutException/SocketException/非取消 TaskCanceledException,含 InnerException 链),catch filter 用之;L320 失败分类复用同一函数。同时修 sibling:FriendlyException(inner=HttpRequestException)应归 Network。 -3. **TradingCredentialStore._cache**(P0-5):改 `ConcurrentDictionary`,`SetCredentials`/`ClearCredentials` 用 `AddOrUpdate`/`TryRemove`。 -4. **StrategyEngine/MarketMonitor 节流**(P0-3 修正版):触发成功后按策略类型统一加 `LastTriggeredAt` 间隔节流;`StopLoss/TakeProfit/TrailingStop` 触发成功置 `Completed`;`MaxExecutions==null` 兜底视为 1;`PauseStrategyAfterRejectionAsync` 发用户可见通知(复用 `_notificationService`)。 -5. **环境切换竞态**(P0-4):`TradeExecutor` 入口把 `_exchangeClient` 快照为局部变量,风控/杠杆/下单/撤单全用快照;下单前再校验 mode 与快照一致,不一致拒绝;`_currentMode` 加 `volatile`;`ApplyModeAsync` 在 StopAsync 超时(`MarketMonitor.cs:184`)时中止切换并报错。 -6. **风控路径禁用 3 秒缓存**(放大器):`RiskManager.cs:63`、`AISignalStrategyExecutor.cs:189` 改走实时查询(缓存仅 UI 展示用);`RiskManager.cs:138-142` fail-open 改 fail-closed。 -7. **sibling**:若保留条件单的映射逻辑不再需要(已删除整块),`TradeExecutor.cs:247` 随删除一并清理。 - -## 阶段 2 — RAG 数据污染(P0-6~9 + sibling #3) -1. **TextCleaningService**(P0-6):删除 `RepeatingChars` 规则;`PhonePattern` 改为带边界断言的中国手机号语义 `(?()` 激活 HITL(P0-10)。 -2. **确认对话框**(P0-13):`IDialogService.ShowConfirmationAsync` 增 `CancellationToken` 重载,内部 `ct.Register(() => Dispatcher.UIThread.Post(dialog.Close))`;TradeConfirmationService 用 `CancellationTokenSource(60s)`,catch OCE 走超时拒绝路径。 -3. **AssetPageViewModel**(P0-15):删 `Task.Run`(L274 改 `_ = LoadAssetDataAsync(...)`),简化 L192 冗余 `InvokeAsync`。 -4. **MainWindow**(P0-14):拆 `SelectedMainNavigationItem`/`SelectedBottomNavigationItem`;`SwitchToMarket` 先缓存 `currentTitle` 到局部变量,切完按 title 查找 item 并导航。 -5. **SettingsPageViewModel**(P0-11):`InitializeAsync` 改 `UserSetting = CurrentSetting.Clone()` 草稿模式;`SaveAsync` 时 `UpdateSettings(UserSetting.Clone())` 提交;修正 L369-371/L388 注释;`UserSetting` 补 `Clone()` 方法。 -6. **RichTextBlock**(P0-12,按决策默认禁用):`DetectContentFormat` 不再自动识别 HTML,仅 Markdown/PlainText;保留显式 `Format=Html` 分支入口但注释标注需白名单消毒+CSP 后才可启用。 -7. **TelegraphNewsViewModel**(P0-16 修正版):`OnMarketChanged` lambda 首行 `if (_disposed) return;`;`Dispose()` 幂等退订;**联动阶段 3-4**——P0-14 修复后旧 HomePageViewModel 才会被真正 Dispose,需一并验证。 -8. 全仓排查其余 4 个重写 `OnMarketChanged` 的 VM(AgentAnalysis/AssetSelection/Favorites/PriceAlert)补 disposed 防护与完整退订。 - -## 阶段 4 — P1 批量修复(37 条,剔除 2 虚警) -按第三节编号顺序,分组处理: -- **交易域 1-13**:#1 金额列读改 `ReadDecimal()`(strategies L1226-1239、trade_records L1268-1271);#2 `strategies` 表加 `order_type`/`slippage_tolerance` 列 + 迁移;#3 exchangeInfo filters 缓存 + 下单前本地取整(stepSize/tickSize);#4 `BinanceUserDataStreamService._ws` 数据竞争(局部快照引用);#5 与阶段 1-6 合并;#6 RiskManager 补买入余额校验;#7 回撤熔断改 30 天滚动窗口 + 重置入口;#8 `MarketContext._currentMarket` 加 volatile;#9 `PriceAlertService.Dispose` 改 IAsyncDisposable;#10 `IsClosePositionAsync` 改三态,查询失败中止下单;#11 AISignal 伴随策略去重 + `StopLossPrice` 写入(L287 去掉提前 return);#12 `MarketMonitor.Dispose` 先 await 在途任务再释放信号量;#13 `AnalysisReportCache` 显式传 MarketType。 -- **Agent/RAG 14-26(去掉 #17)**:#14 CoinGecko 改 resilience RateLimiter 删手写限流;#15 哈希改 `SHA256.ComputeHash(File.OpenRead)`;#16 补传 cancellationToken;#18 同义词替换加词边界断言(拉丁键 `\b`);#19 `RetrievalOrchestrator.cs:93-104` 补条数校验;#20 `ClipImageEmbeddingService` 构造去掉服务定位器 + `_initAttempted` 加锁;#21 `DocxMarkdownConverter` 无状态化;#22 AITool 补 CT + WebSearch 独立兜底;#23 雪球请求传 CT;#24 GenerateCriteria 对齐 OCE 处理(阶段 2 已覆盖,确认即可);#25 runId 捕获进闭包,工具只暴露 analystName;#26 Executor 改每次 Run 新建。 -- **UI/Core 27-39(去掉 #30)**:#27 `TradingPageViewModel.Dispose` 补退订 StrategyConfig;#28 `MCPConfigPageViewModel` ContinueWith 改 UI 线程调度;#29 `ChatMessageAdapter` 补 `OnPropertyChanged(nameof(AdaptiveCard))`;#31 TradeMonitorViewModel 补 UI 线程切换;#32/#33 FavoritesPageViewModel 定时器与 Dispatcher 修复;#34 `StockSymbolConverter` 收敛单一 `ResolveExchange()` + 北交所 8/43 段;#35 `CryptoSymbolConverter` 白名单基础币(WBTC/WETH/STETH/WBNB);#36 `NullabilityInfoContext` 改每次新建;#37 KLineChartView 加 SemaphoreSlim + 单次订阅;#38 testConfig 拷贝对齐 SaveUIToConfig;#39 `StrategyConfigViewModel` 走 `IDialogService`。 -- **补测试**:`510300`、`WBTC`、`STETH`、`600519`、`113016`、`830799` 符号转换用例。 - -## 阶段 5 — 死代码删除与重复造轮子收敛(P2) -- **删除(按决策)**:P2-1(随阶段 1)、#2 RoutingExchangeClient 纯转发(需先完成阶段 1-5 快照方案改造,确认无引用后删)、#3/#4/#5/#6/#7/#9/#10/#11 死代码整块;#8 随阶段 2 接上后不再是死代码。 -- **Directory.Build.props**(#12):重写为集中管理 `TargetFramework`/`Nullable`/`ImplicitUsings`,删除 WinUI 段。 -- **收敛**:#13 抽 `MarketAssistant.Infrastructure.ITokenCounter` 单例(两处 Tiktoken 合一);#14 `SourceType` 统一从 documentUri 推断;#15 `AnalysisReportCache` 换 `IMemoryCache`;#16 Reranker 去重分词 + 常量经 DI。 -- **其余 #17-30**:Console.WriteLine 清理、HttpClient.Timeout 覆盖问题、签名硬编码移配置、SqliteRagDocumentCatalog 开 WAL + 释放锁、PasswordChar 补齐、`ModelDiscoveryService` 每次 CreateClient、AboutPage 路径净化与空 catch、StackOverflow 分支删除 + depth 上限、PriceChangeColorConverter 补 InvariantCulture、KLineChartView CDN 兜底改本地资源、CTS 传递、`[NotifyPropertyChangedFor]` 改造。 - -## 验证(每阶段必做) -- 每阶段:`dotnet build MarketAssistant.slnx -c Debug` 通过;`dotnet format` 后 `--verify-no-changes`。 -- 阶段 1/2 结束:`dotnet test tests/TestMarketAssistant.csproj -c Debug`(现有 395 个 MSTest 用例 + 本计划新增用例全绿)。 -- 阶段 3 结束:手动冒烟——切市场导航刷新、交易确认弹窗超时关闭、聊天 Markdown 渲染、详情页骨架屏无跨线程异常。 -- 每阶段一个 commit,格式 `[模块] 变更概要`;阶段 1 完成前不启用实盘模式。 -- 执行顺序:阶段 0 → 1 → 2 → 3 → 4 → 5,各阶段独立可交付,中途可随时叫停。 \ No newline at end of file From 8bb9fb7d8fd9a0f1ec1b8e540e3f48994d2af282 Mon Sep 17 00:00:00 2001 From: MayueCif Date: Fri, 28 Aug 2026 23:03:39 +0800 Subject: [PATCH 4/9] =?UTF-8?q?[Rag]=20=E6=95=B0=E6=8D=AE=E6=B1=A1?= =?UTF-8?q?=E6=9F=93=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=B8=85=E6=B4=97=E8=A7=84?= =?UTF-8?q?=E5=88=99=E6=8B=86=E5=88=86Normalize/Denoise=E5=B9=B6=E4=BF=9D?= =?UTF-8?q?=E6=8A=A4=E9=87=91=E8=9E=8D=E6=95=B0=E5=AD=97=E3=80=81CLIP?= =?UTF-8?q?=E9=9B=B6=E5=90=91=E9=87=8F=E6=94=B9=E6=8A=9B=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E3=80=81=E6=8C=87=E6=A0=87=E5=B7=A5=E5=85=B7InvariantCulture?= =?UTF-8?q?=E4=B8=8E=E5=8F=82=E6=95=B0=E9=92=B3=E5=88=B6=E3=80=81=E6=8A=95?= =?UTF-8?q?=E8=B5=84=E9=80=89=E6=8B=A9=E9=93=BE=E8=B7=AF=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=90=9E=E5=8F=96=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ss_8fc947a7-0f60-4bee-8091-04b858439e94.md | 61 ++++++++++++ .../Executors/AnalyzeAssetsExecutor.cs | 12 ++- .../Executors/GenerateCriteriaExecutor.cs | 7 +- .../ScreenInvestmentTargetsExecutor.cs | 5 + .../InvestmentSelectionWorkflow.cs | 7 +- .../Agents/Tools/Crypto/CryptoMetricsTools.cs | 57 ++++++++++-- .../Interfaces/ITextCleaningService.cs | 10 +- .../Services/ClipImageEmbeddingService.cs | 28 +++--- .../Services/DocumentBlockMapper.cs | 22 ++++- .../Services/TextCleaningService.cs | 93 ++++++++++++------- tests/Vectors/TextCleaningServiceTest.cs | 39 +++++++- 11 files changed, 273 insertions(+), 68 deletions(-) create mode 100644 .zcode/plans/plan-sess_8fc947a7-0f60-4bee-8091-04b858439e94.md diff --git a/.zcode/plans/plan-sess_8fc947a7-0f60-4bee-8091-04b858439e94.md b/.zcode/plans/plan-sess_8fc947a7-0f60-4bee-8091-04b858439e94.md new file mode 100644 index 0000000..b531233 --- /dev/null +++ b/.zcode/plans/plan-sess_8fc947a7-0f60-4bee-8091-04b858439e94.md @@ -0,0 +1,61 @@ +# MarketAssistant 重构修复计划(全量 P0+P1+P2) + +基于审计报告 + 三个并行源码核实。P0 16 条中 13 条完全属实、3 条部分准确(细节修正已并入下文)、无虚警;P1 抽查发现 2 条虚警(#17、#30,**从计划中剔除**);核实另发现 4 个报告遗漏的 sibling 问题,已纳入对应阶段。 + +**既有决策**(沿用):WebView HTML 渲染默认禁用;交易域死代码直接删除整块。 + +--- + +## 阶段 0 — 文档勘误(先落 docs/代码审计报告-2026-08-28.md) +- 修正 P0-3:并非零节流(`MarketMonitor.cs:272` 已有失败冷却);Paused 已正确写入(L447),缺的是用户可见通知。 +- 修正 P0-16:泄漏根因是 P0-14 导致旧 VM 连 Dispose 都不发生,时序与报告所述相反。 +- 修正 P0-13 严重度:模态框滞留为 UX 级问题。 +- P1-17("批量嵌入"实为逐个)与 P1-30(停止按钮不可达)标记为虚警删除。 +- 补充 4 个新 sibling:TradeExecutor.cs:247 stopPrice 硬编码 null;InvestmentSelection 三个 Executor 均吞取消;零向量破坏 `null=不可用` 契约;OnMarketChanged 需 disposed 防护的其余 4 个 VM。 +- 修正路径偏差:Singleton 注册在 `App.Services/Services/ServiceCollectionExtensions.cs`。 + +## 阶段 1 — 资金安全(P0-1~5 + 放大器 + sibling #1) +1. **删除原生条件单整套**(按决策):`StrategyEngine.cs:484,491-559,564-582`、`TradingDataService.cs:297-307,1348`、`TradingStrategy.cs:56-60`、`strategies.native_order_id` 列及迁移;在合约止损策略创建路径加"客户端轮询执行、进程退出期间不生效"显式警示。 +2. **TradeExecutor 重试循环**(P0-2):改为 `IsTransient(ex)` 递归判定(HttpRequestException/TimeoutException/SocketException/非取消 TaskCanceledException,含 InnerException 链),catch filter 用之;L320 失败分类复用同一函数。同时修 sibling:FriendlyException(inner=HttpRequestException)应归 Network。 +3. **TradingCredentialStore._cache**(P0-5):改 `ConcurrentDictionary`,`SetCredentials`/`ClearCredentials` 用 `AddOrUpdate`/`TryRemove`。 +4. **StrategyEngine/MarketMonitor 节流**(P0-3 修正版):触发成功后按策略类型统一加 `LastTriggeredAt` 间隔节流;`StopLoss/TakeProfit/TrailingStop` 触发成功置 `Completed`;`MaxExecutions==null` 兜底视为 1;`PauseStrategyAfterRejectionAsync` 发用户可见通知(复用 `_notificationService`)。 +5. **环境切换竞态**(P0-4):`TradeExecutor` 入口把 `_exchangeClient` 快照为局部变量,风控/杠杆/下单/撤单全用快照;下单前再校验 mode 与快照一致,不一致拒绝;`_currentMode` 加 `volatile`;`ApplyModeAsync` 在 StopAsync 超时(`MarketMonitor.cs:184`)时中止切换并报错。 +6. **风控路径禁用 3 秒缓存**(放大器):`RiskManager.cs:63`、`AISignalStrategyExecutor.cs:189` 改走实时查询(缓存仅 UI 展示用);`RiskManager.cs:138-142` fail-open 改 fail-closed。 +7. **sibling**:若保留条件单的映射逻辑不再需要(已删除整块),`TradeExecutor.cs:247` 随删除一并清理。 + +## 阶段 2 — RAG 数据污染(P0-6~9 + sibling #3) +1. **TextCleaningService**(P0-6):删除 `RepeatingChars` 规则;`PhonePattern` 改为带边界断言的中国手机号语义 `(?()` 激活 HITL(P0-10)。 +2. **确认对话框**(P0-13):`IDialogService.ShowConfirmationAsync` 增 `CancellationToken` 重载,内部 `ct.Register(() => Dispatcher.UIThread.Post(dialog.Close))`;TradeConfirmationService 用 `CancellationTokenSource(60s)`,catch OCE 走超时拒绝路径。 +3. **AssetPageViewModel**(P0-15):删 `Task.Run`(L274 改 `_ = LoadAssetDataAsync(...)`),简化 L192 冗余 `InvokeAsync`。 +4. **MainWindow**(P0-14):拆 `SelectedMainNavigationItem`/`SelectedBottomNavigationItem`;`SwitchToMarket` 先缓存 `currentTitle` 到局部变量,切完按 title 查找 item 并导航。 +5. **SettingsPageViewModel**(P0-11):`InitializeAsync` 改 `UserSetting = CurrentSetting.Clone()` 草稿模式;`SaveAsync` 时 `UpdateSettings(UserSetting.Clone())` 提交;修正 L369-371/L388 注释;`UserSetting` 补 `Clone()` 方法。 +6. **RichTextBlock**(P0-12,按决策默认禁用):`DetectContentFormat` 不再自动识别 HTML,仅 Markdown/PlainText;保留显式 `Format=Html` 分支入口但注释标注需白名单消毒+CSP 后才可启用。 +7. **TelegraphNewsViewModel**(P0-16 修正版):`OnMarketChanged` lambda 首行 `if (_disposed) return;`;`Dispose()` 幂等退订;**联动阶段 3-4**——P0-14 修复后旧 HomePageViewModel 才会被真正 Dispose,需一并验证。 +8. 全仓排查其余 4 个重写 `OnMarketChanged` 的 VM(AgentAnalysis/AssetSelection/Favorites/PriceAlert)补 disposed 防护与完整退订。 + +## 阶段 4 — P1 批量修复(37 条,剔除 2 虚警) +按第三节编号顺序,分组处理: +- **交易域 1-13**:#1 金额列读改 `ReadDecimal()`(strategies L1226-1239、trade_records L1268-1271);#2 `strategies` 表加 `order_type`/`slippage_tolerance` 列 + 迁移;#3 exchangeInfo filters 缓存 + 下单前本地取整(stepSize/tickSize);#4 `BinanceUserDataStreamService._ws` 数据竞争(局部快照引用);#5 与阶段 1-6 合并;#6 RiskManager 补买入余额校验;#7 回撤熔断改 30 天滚动窗口 + 重置入口;#8 `MarketContext._currentMarket` 加 volatile;#9 `PriceAlertService.Dispose` 改 IAsyncDisposable;#10 `IsClosePositionAsync` 改三态,查询失败中止下单;#11 AISignal 伴随策略去重 + `StopLossPrice` 写入(L287 去掉提前 return);#12 `MarketMonitor.Dispose` 先 await 在途任务再释放信号量;#13 `AnalysisReportCache` 显式传 MarketType。 +- **Agent/RAG 14-26(去掉 #17)**:#14 CoinGecko 改 resilience RateLimiter 删手写限流;#15 哈希改 `SHA256.ComputeHash(File.OpenRead)`;#16 补传 cancellationToken;#18 同义词替换加词边界断言(拉丁键 `\b`);#19 `RetrievalOrchestrator.cs:93-104` 补条数校验;#20 `ClipImageEmbeddingService` 构造去掉服务定位器 + `_initAttempted` 加锁;#21 `DocxMarkdownConverter` 无状态化;#22 AITool 补 CT + WebSearch 独立兜底;#23 雪球请求传 CT;#24 GenerateCriteria 对齐 OCE 处理(阶段 2 已覆盖,确认即可);#25 runId 捕获进闭包,工具只暴露 analystName;#26 Executor 改每次 Run 新建。 +- **UI/Core 27-39(去掉 #30)**:#27 `TradingPageViewModel.Dispose` 补退订 StrategyConfig;#28 `MCPConfigPageViewModel` ContinueWith 改 UI 线程调度;#29 `ChatMessageAdapter` 补 `OnPropertyChanged(nameof(AdaptiveCard))`;#31 TradeMonitorViewModel 补 UI 线程切换;#32/#33 FavoritesPageViewModel 定时器与 Dispatcher 修复;#34 `StockSymbolConverter` 收敛单一 `ResolveExchange()` + 北交所 8/43 段;#35 `CryptoSymbolConverter` 白名单基础币(WBTC/WETH/STETH/WBNB);#36 `NullabilityInfoContext` 改每次新建;#37 KLineChartView 加 SemaphoreSlim + 单次订阅;#38 testConfig 拷贝对齐 SaveUIToConfig;#39 `StrategyConfigViewModel` 走 `IDialogService`。 +- **补测试**:`510300`、`WBTC`、`STETH`、`600519`、`113016`、`830799` 符号转换用例。 + +## 阶段 5 — 死代码删除与重复造轮子收敛(P2) +- **删除(按决策)**:P2-1(随阶段 1)、#2 RoutingExchangeClient 纯转发(需先完成阶段 1-5 快照方案改造,确认无引用后删)、#3/#4/#5/#6/#7/#9/#10/#11 死代码整块;#8 随阶段 2 接上后不再是死代码。 +- **Directory.Build.props**(#12):重写为集中管理 `TargetFramework`/`Nullable`/`ImplicitUsings`,删除 WinUI 段。 +- **收敛**:#13 抽 `MarketAssistant.Infrastructure.ITokenCounter` 单例(两处 Tiktoken 合一);#14 `SourceType` 统一从 documentUri 推断;#15 `AnalysisReportCache` 换 `IMemoryCache`;#16 Reranker 去重分词 + 常量经 DI。 +- **其余 #17-30**:Console.WriteLine 清理、HttpClient.Timeout 覆盖问题、签名硬编码移配置、SqliteRagDocumentCatalog 开 WAL + 释放锁、PasswordChar 补齐、`ModelDiscoveryService` 每次 CreateClient、AboutPage 路径净化与空 catch、StackOverflow 分支删除 + depth 上限、PriceChangeColorConverter 补 InvariantCulture、KLineChartView CDN 兜底改本地资源、CTS 传递、`[NotifyPropertyChangedFor]` 改造。 + +## 验证(每阶段必做) +- 每阶段:`dotnet build MarketAssistant.slnx -c Debug` 通过;`dotnet format` 后 `--verify-no-changes`。 +- 阶段 1/2 结束:`dotnet test tests/TestMarketAssistant.csproj -c Debug`(现有 395 个 MSTest 用例 + 本计划新增用例全绿)。 +- 阶段 3 结束:手动冒烟——切市场导航刷新、交易确认弹窗超时关闭、聊天 Markdown 渲染、详情页骨架屏无跨线程异常。 +- 每阶段一个 commit,格式 `[模块] 变更概要`;阶段 1 完成前不启用实盘模式。 +- 执行顺序:阶段 0 → 1 → 2 → 3 → 4 → 5,各阶段独立可交付,中途可随时叫停。 \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/AnalyzeAssetsExecutor.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/AnalyzeAssetsExecutor.cs index 3e7a59b..c2c9c4d 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/AnalyzeAssetsExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/AnalyzeAssetsExecutor.cs @@ -133,10 +133,20 @@ private async ValueTask HandleAsync( return result; } + catch (OperationCanceledException) + { + // 用户主动取消必须向上传播,不得伪装成"分析失败"的默认结果 + throw; + } + catch (FriendlyException) + { + // 校验失败等业务异常已包含用户友好信息,向上抛出让工作流以错误呈现 + throw; + } catch (Exception ex) { _logger.LogError(ex, "[步骤3/3-{MarketType}] AI分析失败", originalRequest.MarketType); - return CreateDefaultResult($"分析失败: {ex.Message}"); + throw new FriendlyException($"AI 分析失败: {ex.Message}", ex); } } diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/GenerateCriteriaExecutor.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/GenerateCriteriaExecutor.cs index babf2f2..1bf4ae7 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/GenerateCriteriaExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/GenerateCriteriaExecutor.cs @@ -90,6 +90,11 @@ public override async ValueTask HandleAsync( OriginalRequest = input }; } + catch (OperationCanceledException) + { + // 用户主动取消必须向上传播,不得包装成业务错误 + throw; + } catch (Exception ex) { _logger.LogError(ex, "[步骤1/3-{MarketType}] 生成筛选条件失败", _strategy.SupportedMarketType); @@ -97,7 +102,7 @@ public override async ValueTask HandleAsync( { throw; } - throw new FriendlyException(ex.Message); + throw new FriendlyException($"生成筛选条件失败: {ex.Message}", ex); } } diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/ScreenInvestmentTargetsExecutor.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/ScreenInvestmentTargetsExecutor.cs index 16f0f3f..968687c 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/ScreenInvestmentTargetsExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/ScreenInvestmentTargetsExecutor.cs @@ -65,6 +65,11 @@ private async ValueTask HandleAsync( OriginalRequest = originalRequest }; } + catch (OperationCanceledException) + { + // 用户主动取消必须向上传播,不得包装成业务错误 + throw; + } catch (FriendlyException) { // FriendlyException 已包含用户友好信息,直接抛出避免双重包装丢失原始错误 diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs index 2eb01fb..69c3f5d 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs @@ -147,7 +147,12 @@ private async Task ExecuteWorkflowAsync( } } - return finalResult ?? CreateDefaultResult("工作流未返回结果"); + // 事件流未产出结果说明工作流中途失败(各 Executor 已把异常包装上抛), + // 把"失败"伪装成结构完整的默认结果会让用户误以为"AI 认为没有合适标的" + if (finalResult == null) + throw new FriendlyException("投资选择工作流未返回结果,分析未完成,请重试。"); + + return finalResult; } private static InvestmentSelectionResult CreateDefaultResult(string? problem = null) diff --git a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoMetricsTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoMetricsTools.cs index 410f7a0..a1b0686 100644 --- a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoMetricsTools.cs +++ b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoMetricsTools.cs @@ -94,13 +94,13 @@ public async Task GetOHLCVAsync(string symbol, MarketInterval inter 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"), + Open = ParseInvariantDecimal(arr[1]), + High = ParseInvariantDecimal(arr[2]), + Low = ParseInvariantDecimal(arr[3]), + Close = ParseInvariantDecimal(arr[4]), + Volume = ParseInvariantDecimal(arr[5]), CloseTime = arr[6]?.GetValue() ?? 0, - QuoteVolume = decimal.Parse(arr[7]?.GetValue() ?? "0"), + QuoteVolume = ParseInvariantDecimal(arr[7]), TradeCount = arr[8]?.GetValue() ?? 0 }); } @@ -112,6 +112,11 @@ public async Task GetOHLCVAsync(string symbol, MarketInterval inter Candles = candles }; } + catch (OperationCanceledException) + { + // 用户取消必须向上传播,不得包装成业务错误 + throw; + } catch (Exception ex) when (ex is not FriendlyException) { _logger.LogError(ex, "获取K线数据失败: {Symbol}, Interval: {Interval}", symbol, intervalStr); @@ -233,6 +238,11 @@ public async Task GetOrderBookDepthAsync(string symbol, in Asks = asks }; } + catch (OperationCanceledException) + { + // 用户取消必须向上传播,不得包装成业务错误 + throw; + } catch (Exception ex) when (ex is not FriendlyException) { _logger.LogError(ex, "获取订单簿深度失败: {Symbol}", symbol); @@ -294,6 +304,11 @@ public async Task GetRecentTradesAsync(string symbol, int li SellerVolumePercent = totalVolume > 0 ? sellVolume / totalVolume * 100 : 0 }; } + catch (OperationCanceledException) + { + // 用户取消必须向上传播,不得包装成业务错误 + throw; + } catch (Exception ex) when (ex is not FriendlyException) { _logger.LogError(ex, "获取最近成交失败: {Symbol}", symbol); @@ -346,6 +361,11 @@ public async Task GetMarketMetricsAsync(string symbol, Canc LastUpdated = data["last_updated"]?.GetValue() ?? DateTime.UtcNow }; } + catch (OperationCanceledException) + { + // 用户取消必须向上传播,不得包装成业务错误 + throw; + } catch (Exception ex) when (ex is not FriendlyException) { _logger.LogError(ex, "获取市场指标失败: {Symbol}", symbol); @@ -394,12 +414,25 @@ public async Task> GetVolumeDistributionAsync(string sy }) .ToList(); } + catch (OperationCanceledException) + { + // 用户取消必须向上传播,不得包装成业务错误 + throw; + } catch (Exception ex) when (ex is not FriendlyException) { _logger.LogError(ex, "获取交易量分布失败: {Symbol}", symbol); throw new FriendlyException($"获取交易量分布失败: {ex.Message}", ex); } } + /// + /// 以 InvariantCulture 解析币安返回的数值字符串。 + /// 币安 API 恒定使用 "." 作小数点,若按 CurrentCulture 解析(如 de-DE), + /// "0.5" 会被解析成 5,价格放大 10 倍并流入交易决策。 + /// + private static decimal ParseInvariantDecimal(System.Text.Json.Nodes.JsonNode? node) + => decimal.Parse(node?.GetValue() ?? "0", NumberStyles.Float, CultureInfo.InvariantCulture); + /// /// 获取波动性指标 /// @@ -408,11 +441,14 @@ public async Task GetVolatilityMetricsAsync(string symb { try { + // days 由 LLM 传入,钳制到合理区间:过小会导致样本数不足除零,过大会拉取过量数据 + days = Math.Clamp(days, 3, 365); + // 获取历史K线数据 var ohlcv = await GetOHLCVAsync(symbol, MarketInterval.OneDay, days + 1, cancellationToken: cancellationToken); - if (ohlcv.Candles.Count < 2) + if (ohlcv.Candles.Count < 3) { - throw new FriendlyException($"数据不足,无法计算波动性指标"); + throw new FriendlyException($"数据不足,无法计算波动性指标(至少需要 3 根日K线)"); } var candles = ohlcv.Candles; @@ -440,7 +476,10 @@ public async Task GetVolatilityMetricsAsync(string symb trueRanges.Add(Math.Max(tr1, Math.Max(tr2, tr3))); } - // 计算统计指标 + // 计算统计指标;样本不足时显式报错而非让除零/空序列异常穿透 + if (returns.Count < 2) + throw new FriendlyException($"有效收益率样本不足({returns.Count} 个),无法计算波动性指标"); + var avgReturn = returns.Average(); var variance = returns.Sum(r => (r - avgReturn) * (r - avgReturn)) / (returns.Count - 1); var stdDev = (decimal)Math.Sqrt((double)variance); diff --git a/src/MarketAssistant.Rag/Interfaces/ITextCleaningService.cs b/src/MarketAssistant.Rag/Interfaces/ITextCleaningService.cs index 5012a24..122443b 100644 --- a/src/MarketAssistant.Rag/Interfaces/ITextCleaningService.cs +++ b/src/MarketAssistant.Rag/Interfaces/ITextCleaningService.cs @@ -7,12 +7,20 @@ namespace MarketAssistant.Rag.Interfaces; public interface ITextCleaningService { /// - /// 清洗单段文本。 + /// 清洗单段文本(含有损去噪)。 /// /// 原始文本 /// 清洗后的文本 string Clean(string? text); + /// + /// 无损归一化:仅做不改变语义字符的处理,RAG 摄取管线应使用本方法而非 , + /// 避免金融数字(成交额/证券代码/订单号)被有损规则破坏。 + /// + /// 原始文本 + /// 归一化后的文本 + string Normalize(string? text); + /// /// 验证清洗结果是否可接受 /// diff --git a/src/MarketAssistant.Rag/Services/ClipImageEmbeddingService.cs b/src/MarketAssistant.Rag/Services/ClipImageEmbeddingService.cs index 34654d8..03e0653 100644 --- a/src/MarketAssistant.Rag/Services/ClipImageEmbeddingService.cs +++ b/src/MarketAssistant.Rag/Services/ClipImageEmbeddingService.cs @@ -70,17 +70,13 @@ public ClipImageEmbeddingService(ILogger logger, ISer /// /// 生成图像嵌入向量(RAG系统的核心功能) - /// - /// 【学习要点】: - /// - 异常降级:CLIP模型异常 -> 哈希算法,确保系统稳定性 - /// - 异步编程:使用Task.Run将计算密集型任务移至后台线程,避免阻塞UI - /// - 向量归一化:确保向量在单位超球面上,便于余弦相似度计算 - /// - 维度统一:统一到TargetDim维度,支持哈希降级 - /// + /// /// 【实现细节】: /// - ONNX推理:使用预训练CLIP模型进行图像编码 /// - 预处理:将图像预处理为标准张量格式 - /// - 内存管理:使用using确保资源及时释放 + /// - 向量归一化:确保向量在单位超球面上,便于余弦相似度计算 + /// - 失败语义:任何失败都抛出 InvalidOperationException,由调用方降级为 Caption 文本召回; + /// 不降级为哈希向量,也不产出零向量(P1-03) /// public async Task> GenerateAsync(byte[] imageBytes, CancellationToken ct = default) { @@ -308,14 +304,15 @@ private void EnsureSession() } else { - // 警告:配置的模型不存在时发出提示 - _logger.LogWarning("CLIP model not found at {Path}, using hash fallback", _modelPath); + // 警告:配置的模型不存在时发出提示(不降级为哈希向量,图像嵌入将不可用) + _logger.LogWarning("CLIP model not found at {Path}, image embedding will be unavailable", _modelPath); } } catch (Exception ex) { - // 初始化失败:记录错误但不抛出异常,保证降级可用 - _logger.LogWarning(ex, "Failed to init CLIP model session; fallback to hash embedding"); + // 初始化失败:记录错误但不抛出,GenerateAsync 会因会话不可用而抛出, + // 由调用方降级为 Caption 文本召回 + _logger.LogWarning(ex, "Failed to init CLIP model session; image embedding will be unavailable"); } } @@ -397,10 +394,11 @@ private static DenseTensor PreprocessToTensor(byte[] bytes) return new DenseTensor(tensorData, new[] { 1, channels, size, size }); } - catch (Exception) + catch (Exception ex) { - // 异常降级:返回全零张量确保不崩溃 - return new DenseTensor(new float[1 * channels * size * size], new[] { 1, channels, size, size }); + // 预处理失败必须抛出:静默返回全零张量会被当作合法输入跑完推理, + // 产出语义上无意义的零向量入库,污染多模态召回 + throw new InvalidOperationException($"图像预处理失败: {ex.Message}", ex); } } diff --git a/src/MarketAssistant.Rag/Services/DocumentBlockMapper.cs b/src/MarketAssistant.Rag/Services/DocumentBlockMapper.cs index e9d3e81..c2896d5 100644 --- a/src/MarketAssistant.Rag/Services/DocumentBlockMapper.cs +++ b/src/MarketAssistant.Rag/Services/DocumentBlockMapper.cs @@ -26,6 +26,22 @@ public DocumentBlockMapper(ITextCleaningService cleaning, ITextChunkingService c _chunking = chunking; } + /// + /// 摄取前的文本归一化(只做无损处理)。 + /// 兜底校验:归一化结果过度压缩或丢失全部有效内容时保留原文并记录错误, + /// 防止被破坏的文本带着错误数字进入向量库。 + /// + private string NormalizeForIngestion(string text) + { + var normalized = _cleaning.Normalize(text); + if (!_cleaning.IsCleaningSuccessful(text, normalized)) + { + // 兜底防线:保留原文,禁止可疑结果入库 + return text.Trim(); + } + return normalized; + } + /// /// 将文档块转换为文本段落 /// @@ -53,7 +69,7 @@ public DocumentBlockMapper(ITextCleaningService cleaning, ITextChunkingService c switch (block) { case TextBlock textBlock when !string.IsNullOrWhiteSpace(textBlock.Text): - var cleaned = _cleaning.Clean(textBlock.Text); + var cleaned = NormalizeForIngestion(textBlock.Text); var chunks = _chunking.Chunk(filePath, cleaned); foreach (var chunk in chunks) { @@ -81,7 +97,7 @@ public DocumentBlockMapper(ITextCleaningService cleaning, ITextChunkingService c break; case HeadingBlock headingBlock when !string.IsNullOrWhiteSpace(headingBlock.Text): - var headingText = _cleaning.Clean(headingBlock.Text).Trim(); + var headingText = NormalizeForIngestion(headingBlock.Text).Trim(); if (!string.IsNullOrEmpty(headingText)) { var hash = Sha256Hex(headingText); @@ -111,7 +127,7 @@ public DocumentBlockMapper(ITextCleaningService cleaning, ITextChunkingService c case ListBlock listBlock when listBlock.Items?.Count > 0: var listText = listBlock.Text; - var cleanedList = _cleaning.Clean(listText).Trim(); + var cleanedList = NormalizeForIngestion(listText).Trim(); if (!string.IsNullOrEmpty(cleanedList)) { var hash = Sha256Hex(cleanedList); diff --git a/src/MarketAssistant.Rag/Services/TextCleaningService.cs b/src/MarketAssistant.Rag/Services/TextCleaningService.cs index 7f9ceb9..0e224f3 100644 --- a/src/MarketAssistant.Rag/Services/TextCleaningService.cs +++ b/src/MarketAssistant.Rag/Services/TextCleaningService.cs @@ -26,9 +26,9 @@ public class TextCleaningService : ITextCleaningService // 新增的清洗规则 private static readonly Regex EmailPattern = new(@"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", RegexOptions.Compiled); - private static readonly Regex PhonePattern = new(@"(?:\+?1[-.\s]?)?\(?([0-9]{3})\)?[-.\s]?([0-9]{3})[-.\s]?([0-9]{4})", RegexOptions.Compiled); - private static readonly Regex ChinesePhonePattern = new(@"1[3-9]\d{9}", RegexOptions.Compiled); - private static readonly Regex RepeatingChars = new(@"(.)\1{3,}", RegexOptions.Compiled); // 连续重复字符 + // 仅匹配中国手机号语义(13-19 开头共 11 位),带前后边界断言。 + // 严禁使用通用 N 位数字模式:金融文档中任意长数字串(成交额、证券代码、订单号)会被整段删除。 + private static readonly Regex PhonePattern = new(@"(? logger) @@ -43,7 +43,12 @@ public TextCleaningService() : this(Microsoft.Extensions.Logging.Abstractions.Nu { } - public string Clean(string? text) + /// + /// 无损归一化:仅做不改变任何语义字符的处理(Unicode 标准化、换行统一、控制字符、全角空格、 + /// 多余空白合并、空行规范)。RAG 摄取管线必须使用本方法——金融文档中的数字 + /// (成交额、证券代码、手机号、日期)承载核心语义,任何有损规则都可能产出错误投资结论。 + /// + public string Normalize(string? text) { if (string.IsNullOrWhiteSpace(text)) { @@ -51,9 +56,6 @@ public string Clean(string? text) return string.Empty; } - var originalLength = text.Length; - _logger.LogDebug("开始清洗文本,原始长度: {Length}", originalLength); - try { // 1. Unicode标准化 @@ -68,45 +70,64 @@ public string Clean(string? text) // 4. 处理中文全角空格 text = ChineseSpecialChars.Replace(text, " "); - // 5. 移除页眉页脚 - text = HeaderFooterPattern.Replace(text, "\n"); + // 5. 合并多余空白 + text = MultiSpace.Replace(text, " "); - // 6. 修复英文断词 - text = HyphenBreak.Replace(text, m => m.Groups[1].Value + m.Groups[2].Value); + // 6. 规范空行 + text = NormalizeEmptyLines(text); - // 7. 移除页码 - text = PageNumber.Replace(text, " "); + return text; + } + catch (Exception ex) + { + _logger.LogError(ex, "文本归一化过程中发生错误"); + throw new InvalidOperationException("文本归一化失败", ex); + } + } - // 8. 移除URL - text = UrlPattern.Replace(text, " "); + /// + /// 有损去噪:在 基础上移除页眉页脚/页码/URL/邮箱/电话。 + /// 仅适用于确定无需保留这些内容的通用文本,禁止用于金融文档摄取。 + /// + public string Denoise(string? text) + { + text = Normalize(text); + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; - // 9. 移除邮箱 - text = EmailPattern.Replace(text, " "); + // 移除页眉页脚 + text = HeaderFooterPattern.Replace(text, "\n"); - // 10. 移除电话号码 - text = PhonePattern.Replace(text, " "); - text = ChinesePhonePattern.Replace(text, " "); + // 修复英文断词 + text = HyphenBreak.Replace(text, m => m.Groups[1].Value + m.Groups[2].Value); - // 11. 处理重复字符 - text = RepeatingChars.Replace(text, "$1$1"); + // 移除页码 + text = PageNumber.Replace(text, " "); - // 12. 合并多余空白 - text = MultiSpace.Replace(text, " "); + // 移除URL + text = UrlPattern.Replace(text, " "); - // 13. 规范空行 - text = NormalizeEmptyLines(text); + // 移除邮箱 + text = EmailPattern.Replace(text, " "); - var finalLength = text.Length; - _logger.LogDebug("文本清洗完成,最终长度: {Length},压缩比: {Ratio:P2}", - finalLength, 1.0 - (double)finalLength / originalLength); + // 移除电话号码(仅中国手机号语义,带边界断言) + text = PhonePattern.Replace(text, " "); - return text; - } - catch (Exception ex) - { - _logger.LogError(ex, "文本清洗过程中发生错误"); - throw new InvalidOperationException("文本清洗失败", ex); - } + // 移除类规则以空格占位,结束后再次合并空白并规范空行 + text = MultiSpace.Replace(text, " "); + text = NormalizeEmptyLines(text); + + return text; + } + + public string Clean(string? text) + { + var originalLength = text?.Length ?? 0; + var cleaned = Denoise(text); + var finalLength = cleaned.Length; + _logger.LogDebug("文本清洗完成,原始长度: {Original},最终长度: {Length},压缩比: {Ratio:P2}", + originalLength, finalLength, originalLength == 0 ? 0 : 1.0 - (double)finalLength / originalLength); + return cleaned; } /// diff --git a/tests/Vectors/TextCleaningServiceTest.cs b/tests/Vectors/TextCleaningServiceTest.cs index 2dab2ca..81bc201 100644 --- a/tests/Vectors/TextCleaningServiceTest.cs +++ b/tests/Vectors/TextCleaningServiceTest.cs @@ -119,4 +119,41 @@ public void Clean_ShouldNormalizeLineEndings() // Assert Assert.AreEqual(expected, result); } -} \ No newline at end of file + + // ---- P0-6 回归测试:金融数字不得被清洗规则吞掉或改写 ---- + + [TestMethod] + [TestCategory("Unit")] + public void Clean_ShouldNotTouchFinancialNumbers() + { + // 10 位长数字曾被通用"电话号码"规则整段删除 + Assert.AreEqual("成交额1000000000元", _service.Clean("成交额1000000000元").Trim()); + // 8 位长数字曾被"重复字符折叠"规则缩水 10 万倍 + Assert.AreEqual("营收10000000元", _service.Clean("营收10000000元").Trim()); + // 千分位格式保持不变 + Assert.AreEqual("1,000,000", _service.Clean("1,000,000").Trim()); + // 中文叠词不是噪声,不得折叠 + Assert.IsTrue(_service.Clean("哈哈哈哈").Contains("哈哈哈哈")); + } + + [TestMethod] + [TestCategory("Unit")] + public void Clean_ShouldStillRemoveChinesePhoneNumber() + { + // 手机号语义(11 位 1[3-9] 开头)仍应被有损清洗移除 + var result = _service.Clean("咨询电话13800138000谢谢"); + Assert.IsFalse(result.Contains("13800138000")); + Assert.IsTrue(result.Contains("咨询电话") && result.Contains("谢谢")); + } + + [TestMethod] + [TestCategory("Unit")] + public void Normalize_ShouldPreserveAllContent() + { + // 摄取路径使用的 Normalize 必须完全无损 + var input = "成交额1000000000元\n咨询电话13800138000"; + var result = _service.Normalize(input); + StringAssert.Contains(result, "1000000000"); + StringAssert.Contains(result, "13800138000"); + } +} From 3d879f1c8d472a1485d73df90f8ecae1bff00fc8 Mon Sep 17 00:00:00 2001 From: MayueCif Date: Fri, 28 Aug 2026 23:15:50 +0800 Subject: [PATCH 5/9] =?UTF-8?q?[App]=20UI=20=E7=94=9F=E5=91=BD=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E4=B8=8E=E7=BA=BF=E7=A8=8B=E4=BF=AE=E5=A4=8D=EF=BC=9A?= =?UTF-8?q?=E6=BF=80=E6=B4=BBHITL=E7=A1=AE=E8=AE=A4=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E3=80=81=E7=A1=AE=E8=AE=A4=E6=A1=86=E8=B6=85=E6=97=B6=E4=B8=BB?= =?UTF-8?q?=E5=8A=A8=E5=85=B3=E9=97=AD=E3=80=81=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E6=A8=A1=E5=BC=8F=E3=80=81=E5=8F=8C=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E9=80=89=E4=B8=AD=E6=8B=86=E5=88=86=E3=80=81=E7=A6=81?= =?UTF-8?q?=E7=94=A8HTML=E8=87=AA=E5=8A=A8=E8=AF=86=E5=88=AB=E3=80=81?= =?UTF-8?q?=E5=B8=82=E5=9C=BA=E5=88=87=E6=8D=A2=20disposed=20=E9=98=B2?= =?UTF-8?q?=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settings/InvestmentPreference.cs | 9 +++ .../Applications/Settings/UserSetting.cs | 39 +++++++++++++ .../Services/Dialog/IDialogService.cs | 8 ++- src/MarketAssistant.App/App.axaml.cs | 5 ++ .../Services/Dialog/DialogService.cs | 14 +++-- .../Trading/TradeConfirmationService.cs | 35 ++++++------ .../ViewModels/AgentAnalysisViewModel.cs | 7 +++ .../ViewModels/AssetPageViewModel.cs | 18 +++--- .../ViewModels/AssetSelectionPageViewModel.cs | 7 +++ .../ViewModels/FavoritesPageViewModel.cs | 7 +++ .../ViewModels/Home/TelegraphNewsViewModel.cs | 25 +++++---- .../ViewModels/MainWindowViewModel.cs | 56 +++++++++++++++---- .../ViewModels/PriceAlertPageViewModel.cs | 7 +++ .../ViewModels/SettingsPageViewModel.cs | 11 ++-- .../Views/Controls/RichTextBlock.cs | 48 ++++------------ .../Views/Windows/MainWindow.axaml | 4 +- 16 files changed, 200 insertions(+), 100 deletions(-) diff --git a/src/MarketAssistant.App.Services/Applications/Settings/InvestmentPreference.cs b/src/MarketAssistant.App.Services/Applications/Settings/InvestmentPreference.cs index 801739c..46ce418 100644 --- a/src/MarketAssistant.App.Services/Applications/Settings/InvestmentPreference.cs +++ b/src/MarketAssistant.App.Services/Applications/Settings/InvestmentPreference.cs @@ -48,6 +48,15 @@ public InvestmentHorizonType InvestmentHorizon set => SetProperty(ref _investmentHorizon, value); } + /// + /// 深拷贝一份独立副本(配合 UserSetting.Clone 的草稿模式)。 + /// + public InvestmentPreference Clone() => new() + { + RiskTolerance = RiskTolerance, + InvestmentHorizon = InvestmentHorizon + }; + public event PropertyChangedEventHandler? PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) diff --git a/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs b/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs index ffac5b5..aae1ca5 100644 --- a/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs +++ b/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs @@ -170,6 +170,45 @@ public InvestmentPreference InvestmentPreference set => SetProperty(ref _investmentPreference, value); } + /// + /// 深拷贝一份独立副本。 + /// 设置页编辑必须基于副本(草稿模式):直接编辑单例本体会让未保存的修改 + /// (如交易模式改为实盘、半填的密钥)随任何一次切市场的整体落盘而静默生效。 + /// 不能用 JSON 往返实现——密钥字段带 [JsonIgnore],序列化会丢失。 + /// + public UserSetting Clone() + { + var clone = new UserSetting + { + ProviderId = ProviderId, + EmbeddingModelId = EmbeddingModelId, + EmbeddingEndpoint = EmbeddingEndpoint, + EmbeddingApiKey = EmbeddingApiKey, + ProviderApiKeys = new Dictionary(ProviderApiKeys, StringComparer.Ordinal), + ProviderModelIds = new Dictionary(ProviderModelIds, StringComparer.Ordinal), + ProviderEndpoints = new Dictionary(ProviderEndpoints, StringComparer.Ordinal), + LoadKnowledge = LoadKnowledge, + KnowledgeFileDirectory = KnowledgeFileDirectory, + Notification = Notification, + ZhiTuApiToken = ZhiTuApiToken, + ThemeMode = ThemeMode, + CurrentMarketType = CurrentMarketType, + CryptoTradingMode = CryptoTradingMode, + CoinGeckoApiKey = CoinGeckoApiKey, + BinanceApiKey = BinanceApiKey, + BinanceSecretKey = BinanceSecretKey, + BinanceFuturesTestnetApiKey = BinanceFuturesTestnetApiKey, + BinanceFuturesTestnetSecretKey = BinanceFuturesTestnetSecretKey, + LogPath = LogPath, + EnableWebSearch = EnableWebSearch, + WebSearchApiKey = WebSearchApiKey, + WebSearchProvider = WebSearchProvider, + EnabledAnalystRoles = new Dictionary(EnabledAnalystRoles, StringComparer.Ordinal), + InvestmentPreference = InvestmentPreference.Clone() + }; + return clone; + } + #region INotifyPropertyChanged Implementation public event PropertyChangedEventHandler? PropertyChanged; diff --git a/src/MarketAssistant.App.Services/Services/Dialog/IDialogService.cs b/src/MarketAssistant.App.Services/Services/Dialog/IDialogService.cs index a9ecb42..ca37c61 100644 --- a/src/MarketAssistant.App.Services/Services/Dialog/IDialogService.cs +++ b/src/MarketAssistant.App.Services/Services/Dialog/IDialogService.cs @@ -12,13 +12,15 @@ public interface IDialogService /// /// 显示确认对话框(两个按钮,可自定义文本) + /// 取消令牌触发时会主动关闭对话框并返回取消按钮语义的结果(false)。 /// - Task ShowConfirmationAsync(string title, string message, string accept = "确认", string cancel = "取消"); + Task ShowConfirmationAsync(string title, string message, string accept = "确认", string cancel = "取消", CancellationToken ct = default); /// - /// 显示带有自定义按钮的对话框 + /// 显示带有自定义按钮的对话框。 + /// 取消令牌触发时会主动关闭对话框(返回 null)。 /// - Task ShowCustomDialogAsync(string title, string message, string[] buttons); + Task ShowCustomDialogAsync(string title, string message, string[] buttons, CancellationToken ct = default); /// /// 显示输入对话框 diff --git a/src/MarketAssistant.App/App.axaml.cs b/src/MarketAssistant.App/App.axaml.cs index 9c6191c..f859d54 100644 --- a/src/MarketAssistant.App/App.axaml.cs +++ b/src/MarketAssistant.App/App.axaml.cs @@ -38,6 +38,11 @@ public override void OnFrameworkInitializationCompleted() var priceAlertService = ServiceProvider.GetRequiredService(); _ = priceAlertService.InitializeAsync(); + // 激活 HITL 交易确认服务:DI 单例是惰性创建的,仅注册不会实例化, + // 必须显式解析一次让构造函数完成对 TradeExecutor.ConfirmationRequested 的订阅, + // 否则自动交易的超阈值订单会因无订阅者被静默拒绝 + ServiceProvider.GetRequiredService(); + // 应用保存的主题 var settingService = ServiceProvider.GetRequiredService(); RequestedThemeVariant = settingService.CurrentSetting.ThemeMode switch diff --git a/src/MarketAssistant.App/Services/Dialog/DialogService.cs b/src/MarketAssistant.App/Services/Dialog/DialogService.cs index 1806912..5b8e53a 100644 --- a/src/MarketAssistant.App/Services/Dialog/DialogService.cs +++ b/src/MarketAssistant.App/Services/Dialog/DialogService.cs @@ -21,23 +21,26 @@ public async Task ShowMessageAsync(string title, string message, string button = /// /// 显示确认对话框(两个按钮,都可自定义) + /// 取消令牌触发时主动关闭对话框,返回 false(取消语义)。 /// /// 如果用户点击确认返回 true,点击取消返回 false - public async Task ShowConfirmationAsync(string title, string message, string accept = "确认", string cancel = "取消") + public async Task ShowConfirmationAsync(string title, string message, string accept = "确认", string cancel = "取消", CancellationToken ct = default) { - var result = await ShowCustomDialogAsync(title, message, new[] { accept, cancel }); + var result = await ShowCustomDialogAsync(title, message, new[] { accept, cancel }, ct); return result == accept; } /// /// 显示带有自定义按钮的对话框 + /// 取消令牌触发时主动关闭对话框(Result 为 null), + /// 避免"超时已自动拒绝但对话框仍挂在屏幕上、用户点击结果被丢弃"的错位。 /// - /// 用户选择的按钮文本 - public async Task ShowCustomDialogAsync(string title, string message, string[] buttons) + /// 用户选择的按钮文本;取消令牌触发或无活动窗口时为 null + public async Task ShowCustomDialogAsync(string title, string message, string[] buttons, CancellationToken ct = default) { if (!Dispatcher.UIThread.CheckAccess()) { - return await Dispatcher.UIThread.InvokeAsync(() => ShowCustomDialogAsync(title, message, buttons)); + return await Dispatcher.UIThread.InvokeAsync(() => ShowCustomDialogAsync(title, message, buttons, ct)); } var owner = GetActiveWindow(); @@ -45,6 +48,7 @@ public async Task ShowConfirmationAsync(string title, string message, stri var dialog = new MessageDialogWindow(); dialog.SetContent(title, message, buttons); + using var cancelRegistration = ct.Register(() => Dispatcher.UIThread.Post(() => dialog.Close())); await dialog.ShowDialog(owner); return dialog.Result; } diff --git a/src/MarketAssistant.App/Services/Trading/TradeConfirmationService.cs b/src/MarketAssistant.App/Services/Trading/TradeConfirmationService.cs index 2928253..15cde31 100644 --- a/src/MarketAssistant.App/Services/Trading/TradeConfirmationService.cs +++ b/src/MarketAssistant.App/Services/Trading/TradeConfirmationService.cs @@ -60,30 +60,31 @@ private async Task OnConfirmationRequestedAsync( $"交易对:{symbol}\n方向:{side}\n价格:{price:F2}\n数量:{quantity}\n\n" + $"触发原因:{reason}\n\n({ConfirmationTimeoutSeconds} 秒内未操作将自动拒绝)"; - var confirmationTask = _dialogService.ShowConfirmationAsync(title, message, "批准", "拒绝"); + // 超时通过取消令牌主动关闭模态对话框:仅 Task.WhenAny 竞争会让对话框 + // 留在屏幕上,用户随后点击"批准"的结果会被丢弃,误以为交易已批准 + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(ConfirmationTimeoutSeconds)); + var approved = await _dialogService + .ShowConfirmationAsync(title, message, "批准", "拒绝", timeoutCts.Token) + .ConfigureAwait(false); - // 60 秒超时自动拒绝:结果竞争,先完成者生效 - var timeoutTask = Task.Delay(TimeSpan.FromSeconds(ConfirmationTimeoutSeconds)); - var completed = await Task.WhenAny(confirmationTask, timeoutTask).ConfigureAwait(false); - - if (completed == timeoutTask) - { - _logger.LogWarning("交易确认超时自动拒绝: {Symbol} {Side}", symbol, side); - _notificationService.ShowWarning( - $"⚠ 交易确认超时已自动拒绝:{symbol} {side} {quantity}"); - return false; - } - - var approved = await confirmationTask.ConfigureAwait(false); if (approved) { _logger.LogInformation("用户批准自动交易: {Symbol} {Side}", symbol, side); } else { - // ShowCustomDialogAsync 拿不到活动窗口时返回 null(视为拒绝),提醒用户开启主窗口 - _logger.LogWarning("交易确认被拒绝或窗口不可用: {Symbol} {Side}", symbol, side); - _notificationService.ShowWarning($"已拒绝自动交易:{symbol} {side} {quantity}"); + // 超时/窗口不可用/用户拒绝均走此分支;ShowCustomDialogAsync 拿不到活动窗口时返回 null(视为拒绝) + if (timeoutCts.IsCancellationRequested) + { + _logger.LogWarning("交易确认超时自动拒绝: {Symbol} {Side}", symbol, side); + _notificationService.ShowWarning( + $"⚠ 交易确认超时已自动拒绝:{symbol} {side} {quantity}"); + } + else + { + _logger.LogWarning("交易确认被拒绝或窗口不可用: {Symbol} {Side}", symbol, side); + _notificationService.ShowWarning($"已拒绝自动交易:{symbol} {side} {quantity}"); + } } return approved; diff --git a/src/MarketAssistant.App/ViewModels/AgentAnalysisViewModel.cs b/src/MarketAssistant.App/ViewModels/AgentAnalysisViewModel.cs index bb7434d..0e5d111 100644 --- a/src/MarketAssistant.App/ViewModels/AgentAnalysisViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/AgentAnalysisViewModel.cs @@ -372,8 +372,14 @@ await Dispatcher.UIThread.InvokeAsync(async () => }, "加载历史报告"); } + private bool _disposed; + protected override void OnMarketChanged(MarketType newMarket) { + // 事件来自单例 MarketContext,Dispose 后不得再触发(重置 UI 状态/启动加载) + if (_disposed) + return; + // 取消进行中的分析任务,避免旧市场的分析结果污染新市场 _analysisCts?.Cancel(); _analysisCts?.Dispose(); @@ -395,6 +401,7 @@ protected override void OnMarketChanged(MarketType newMarket) public void Dispose() { + _disposed = true; _orchestrationService.ProgressChanged -= OnAnalysisProgressChanged; _analysisCts?.Cancel(); _analysisCts?.Dispose(); diff --git a/src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs b/src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs index 455b91e..3839d5c 100644 --- a/src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs @@ -188,12 +188,10 @@ private async Task LoadAssetDataAsync(string assetCode) cancellationToken.ThrowIfCancellationRequested(); - // 在 UI 线程上更新 ObservableCollection,避免后台线程修改绑定属性 - await Dispatcher.UIThread.InvokeAsync(() => - { - KLineData = new ObservableCollection(kLineDataList); - CalculatePriceInfo(kLineDataList); - }); + // 方法从 UI 线程启动且未脱离同步上下文,await 之后天然回到 UI 线程, + // 无需再手动 InvokeAsync + KLineData = new ObservableCollection(kLineDataList); + CalculatePriceInfo(kLineDataList); } catch (OperationCanceledException) { @@ -267,11 +265,13 @@ public void OnNavigatedTo(AssetNavigationParameter parameter, bool isReactivatio PriceChange = 0; } - // 4. 在后台线程加载完整数据(不阻塞导航) - // GoBack 重新激活时不重复加载,避免重复订阅 WebSocket 和重复请求 + // 4. 异步加载完整数据(不阻塞导航)。 + // 不要包 Task.Run:OnNavigatedTo 已在 UI 线程,LoadAssetDataAsync 第一个 await + // 之后自动回到 UI 线程,包 Task.Run 会把 IsBusy/HasError 等绑定属性丢到线程池线程写入, + // 触发 Avalonia 跨线程异常 if (!isReactivation) { - _ = Task.Run(async () => await LoadAssetDataAsync(parameter.Code)); + _ = LoadAssetDataAsync(parameter.Code); // 5. 虚拟币市场订阅 WebSocket 实时价格 // 优先使用参数携带的 MarketType,避免导航期间切换市场导致的竞态 diff --git a/src/MarketAssistant.App/ViewModels/AssetSelectionPageViewModel.cs b/src/MarketAssistant.App/ViewModels/AssetSelectionPageViewModel.cs index bc88671..320577c 100644 --- a/src/MarketAssistant.App/ViewModels/AssetSelectionPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/AssetSelectionPageViewModel.cs @@ -260,8 +260,14 @@ partial void OnSelectionResultChanged(InvestmentSelectionResult? value) OnPropertyChanged(nameof(HasRiskWarnings)); } + private bool _disposed; + protected override void OnMarketChanged(MarketType newMarket) { + // 事件来自单例 MarketContext,Dispose 后不得再触发(更新状态/启动加载) + if (_disposed) + return; + OnPropertyChanged(nameof(LoadingText)); OnPropertyChanged(nameof(PageTitle)); OnPropertyChanged(nameof(ModeLabel)); @@ -491,6 +497,7 @@ await SafeExecuteAsync(() => public void Dispose() { + _disposed = true; UnsubscribeFromMarketChanges(_marketContext); GC.SuppressFinalize(this); } diff --git a/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs b/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs index ada77f5..d584551 100644 --- a/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs @@ -90,9 +90,15 @@ public FavoritesPageViewModel( /// protected override void OnMarketChanged(MarketType newMarket) { + // 事件来自单例 MarketContext,Dispose 后不得再触发(重启加载/WebSocket 订阅) + if (_disposed) + return; + _ = LoadFavoriteAssetsAsync(); } + private bool _disposed; + /// /// 加载收藏资产列表 /// @@ -315,6 +321,7 @@ public void Receive(AssetFavoritesChanged message) public void Dispose() { + _disposed = true; _loadCts?.Cancel(); _loadCts?.Dispose(); _priceFlushTimer?.Stop(); diff --git a/src/MarketAssistant.App/ViewModels/Home/TelegraphNewsViewModel.cs b/src/MarketAssistant.App/ViewModels/Home/TelegraphNewsViewModel.cs index 9a8d8d1..1ce01cf 100644 --- a/src/MarketAssistant.App/ViewModels/Home/TelegraphNewsViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Home/TelegraphNewsViewModel.cs @@ -65,6 +65,11 @@ protected override void OnMarketChanged(MarketType newMarket) { Dispatcher.UIThread.InvokeAsync(() => { + // lambda 是排入 Dispatcher 队列后延迟执行的,期间本 VM 可能已被 Dispose; + // 不检查 _disposed 会给已释放的 VM 重新订阅单例事件并重启轮询,造成泄漏 + if (_disposed) + return; + // 停止旧服务并取消事件订阅 _newsUpdateService.StopUpdates(); _newsUpdateService.NewsUpdated -= OnNewsUpdated; @@ -138,24 +143,24 @@ await SafeExecuteAsync(async () => } /// - /// 释放资源 + /// 释放资源(幂等:市场切换回调与 Dispose 存在交错可能,退订可安全重复执行) /// public void Dispose() { if (!_disposed) { - // 取消市场切换事件订阅 - UnsubscribeFromMarketChanges(_marketContext); + _disposed = true; + } - // 取消新闻服务事件订阅 - _newsUpdateService.NewsUpdated -= OnNewsUpdated; - _newsUpdateService.CountdownUpdated -= OnCountdownUpdated; + // 取消市场切换事件订阅 + UnsubscribeFromMarketChanges(_marketContext); - // 停止新闻更新服务 - _newsUpdateService.StopUpdates(); + // 取消新闻服务事件订阅 + _newsUpdateService.NewsUpdated -= OnNewsUpdated; + _newsUpdateService.CountdownUpdated -= OnCountdownUpdated; - _disposed = true; - } + // 停止新闻更新服务 + _newsUpdateService.StopUpdates(); GC.SuppressFinalize(this); } diff --git a/src/MarketAssistant.App/ViewModels/MainWindowViewModel.cs b/src/MarketAssistant.App/ViewModels/MainWindowViewModel.cs index b18f83b..e47a0df 100644 --- a/src/MarketAssistant.App/ViewModels/MainWindowViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/MainWindowViewModel.cs @@ -18,8 +18,13 @@ public partial class MainWindowViewModel : ViewModelBase private readonly INotificationService _notificationService; private bool _isSynchronizingNavigationSelection; + // 主导航与底部导航必须各自持有选中项:两个 ListBox 绑定同一属性时, + // 任一选中变化会让另一个列表把 SelectedIndex 归 -1 并回写 null,导致侧栏高亮丢失 [ObservableProperty] - private NavigationItemViewModel? _selectedNavigationItem; + private NavigationItemViewModel? _selectedMainNavigationItem; + + [ObservableProperty] + private NavigationItemViewModel? _selectedBottomNavigationItem; public ViewModelBase? CurrentPage => _navigationService.CurrentPage; public bool CanGoBack => _navigationService.CanGoBack; @@ -79,8 +84,8 @@ public MainWindowViewModel( // 监听市场切换事件 SubscribeToMarketChanges(_marketContext); - // 默认导航到首页。SelectedNavigationItem 的变更回调负责实际导航,避免重复入栈。 - SelectedNavigationItem = MainNavigationItems[0]; + // 默认导航到首页。选中项的变更回调负责实际导航,避免重复入栈。 + SelectedMainNavigationItem = MainNavigationItems[0]; } protected override void OnMarketChanged(MarketType newMarket) @@ -163,10 +168,13 @@ private void OnNavigationServicePropertyChanged(object? sender, PropertyChangedE _isSynchronizingNavigationSelection = true; try { - SelectedNavigationItem = MainNavigationItems.FirstOrDefault( - item => item.Title == _navigationService.CurrentRootNavigationItemTitle) - ?? BottomNavigationItems.FirstOrDefault( + var mainItem = MainNavigationItems.FirstOrDefault( item => item.Title == _navigationService.CurrentRootNavigationItemTitle); + SelectedMainNavigationItem = mainItem; + SelectedBottomNavigationItem = mainItem == null + ? BottomNavigationItems.FirstOrDefault( + item => item.Title == _navigationService.CurrentRootNavigationItemTitle) + : null; } finally { @@ -218,6 +226,10 @@ private void SwitchMarket(MarketType market) /// private void SwitchToMarket(MarketType newMarket) { + // 切市场会触发导航集合重建(Clear 使 ListBox 清空选中并回写 null), + // 必须先把当前页标题缓存到局部变量,切完按标题重新定位并导航 + var currentTitle = SelectedMainNavigationItem?.Title ?? SelectedBottomNavigationItem?.Title; + _marketContext.SwitchMarket(newMarket); // 显示切换提示 @@ -226,16 +238,36 @@ private void SwitchToMarket(MarketType newMarket) Logger?.LogInformation("市场已切换到: {Market} ({MarketName})", newMarket, marketName); - // 刷新当前页面(重新加载数据) - if (SelectedNavigationItem != null) + // 按标题重新定位导航项:原市场特有的页面(如交易)在新市场不存在时回退到首页 + var target = currentTitle != null + ? MainNavigationItems.FirstOrDefault(item => item.Title == currentTitle) + ?? BottomNavigationItems.FirstOrDefault(item => item.Title == currentTitle) + : null; + target ??= MainNavigationItems[0]; + + var viewModel = target.CreateViewModel(); + _navigationService.NavigateToRoot(viewModel, target.Title); + + // 同步两个列表的选中态(NavigateToRoot 会经 NavigationService 事件同步,此处兜底显式设置) + _isSynchronizingNavigationSelection = true; + try { - var currentTitle = SelectedNavigationItem.Title; - var viewModel = SelectedNavigationItem.CreateViewModel(); - _navigationService.NavigateToRoot(viewModel, currentTitle); + SelectedMainNavigationItem = MainNavigationItems.Contains(target) ? target : null; + SelectedBottomNavigationItem = BottomNavigationItems.Contains(target) ? target : null; + } + finally + { + _isSynchronizingNavigationSelection = false; } } - partial void OnSelectedNavigationItemChanged(NavigationItemViewModel? value) + partial void OnSelectedMainNavigationItemChanged(NavigationItemViewModel? value) + => OnNavigationItemSelected(value); + + partial void OnSelectedBottomNavigationItemChanged(NavigationItemViewModel? value) + => OnNavigationItemSelected(value); + + private void OnNavigationItemSelected(NavigationItemViewModel? value) { if (value is null || _isSynchronizingNavigationSelection) return; diff --git a/src/MarketAssistant.App/ViewModels/PriceAlertPageViewModel.cs b/src/MarketAssistant.App/ViewModels/PriceAlertPageViewModel.cs index d750ee6..c41f661 100644 --- a/src/MarketAssistant.App/ViewModels/PriceAlertPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/PriceAlertPageViewModel.cs @@ -192,8 +192,14 @@ partial void OnNewRuleAssetTextChanged(string value) _ = SearchAssetsAsync(value.Trim()); } + private bool _disposed; + protected override void OnMarketChanged(MarketType newMarket) { + // 事件来自单例 MarketContext,Dispose 后不得再触发(更新状态/启动加载) + if (_disposed) + return; + OnPropertyChanged(nameof(IsCryptoMarket)); OnPropertyChanged(nameof(NewRuleMarketType)); OnPropertyChanged(nameof(AssetLabelText)); @@ -441,6 +447,7 @@ await SafeExecuteAsync(async () => public void Dispose() { + _disposed = true; _assetLoadCts?.Cancel(); _assetLoadCts?.Dispose(); _assetLoadCts = null; diff --git a/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs b/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs index 0356f9c..a773493 100644 --- a/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs @@ -366,7 +366,7 @@ public bool IsAShareMarket { if (value && UserSetting.CurrentMarketType != MarketType.AShare) { - // 仅修改本地 UserSetting,不立即调用 SwitchMarket + // 仅修改本地草稿 UserSetting,不立即调用 SwitchMarket // 避免触发 MainWindowViewModel 重建导航,导致正在编辑的设置丢失 // 实际市场切换统一在 Save() 中执行 UserSetting.CurrentMarketType = MarketType.AShare; @@ -495,8 +495,10 @@ public void SetStorageProvider(IStorageProvider? storageProvider) private async Task InitializeAsync() { - // 加载用户设置(OnUserSettingChanged 会自动订阅 PropertyChanged) - UserSetting = _userSettingService.CurrentSetting; + // 加载用户设置为独立草稿副本(OnUserSettingChanged 会自动订阅 PropertyChanged)。 + // 不能直接绑定 CurrentSetting 本体:页面上的每次编辑都会立刻生效于全进程, + // 任何一次切市场的整体落盘都会把未保存的修改(含交易模式改实盘、半填密钥)静默写盘 + UserSetting = _userSettingService.CurrentSetting.Clone(); // 同步服务商选择。初始化期间保留已保存的 ModelId 和 Endpoint, // 仅用户主动切换服务商时才清空这些字段。 @@ -823,7 +825,8 @@ await SafeExecuteAsync(async () => // 同步市场类型到MarketContext _marketContext.SwitchMarket(UserSetting.CurrentMarketType); - _userSettingService.UpdateSettings(UserSetting); + // 提交草稿副本(而非页面持有的编辑中实例),保证服务内部状态与页面编辑解耦 + _userSettingService.UpdateSettings(UserSetting.Clone()); await _tradingEnvironmentService.ApplyModeAsync(UserSetting.CryptoTradingMode); _notificationService.ShowSuccess("设置已保存"); Logger?.LogInformation("保存设置,市场类型:{MarketType},交易模式:{TradingMode}", diff --git a/src/MarketAssistant.App/Views/Controls/RichTextBlock.cs b/src/MarketAssistant.App/Views/Controls/RichTextBlock.cs index 44db5ef..e9a2f17 100644 --- a/src/MarketAssistant.App/Views/Controls/RichTextBlock.cs +++ b/src/MarketAssistant.App/Views/Controls/RichTextBlock.cs @@ -111,7 +111,11 @@ private void UpdateContent() } /// - /// 自动检测内容格式 + /// 自动检测内容格式。 + /// 安全决策(2026-08-28):不再自动识别 HTML。聊天内容来源(模型流式输出、Web 搜索 + /// 抓取的网页正文、MCP 工具返回的第三方内容)均不可信,自动走 WebView 渲染意味着 + /// 未经消毒的 HTML/脚本可被间接提示注入触发执行。显式设置 Format=Html 的分支保留, + /// 但启用前必须先实现白名单消毒(如 AngleSharp)+ CSP,见 RenderAsHtml。 /// private ContentFormat DetectContentFormat(string content) { @@ -120,10 +124,6 @@ private ContentFormat DetectContentFormat(string content) var trimmedContent = content.Trim(); - // 检测HTML(更严格的判断) - if (IsHtmlContent(trimmedContent)) - return ContentFormat.Html; - // 检测Markdown(常见语法) if (IsMarkdownContent(trimmedContent)) return ContentFormat.Markdown; @@ -131,38 +131,6 @@ private ContentFormat DetectContentFormat(string content) return ContentFormat.PlainText; } - /// - /// 判断是否为HTML内容 - /// - private bool IsHtmlContent(string content) - { - // 检查是否以HTML标签开始和结束 - if (content.StartsWith("", "
", "
" }; - var lowerContent = content.ToLowerInvariant(); - - int htmlTagCount = 0; - foreach (var tag in htmlStructureTags) - { - if (lowerContent.Contains(tag)) - htmlTagCount++; - } - - // 如果包含多个HTML标签,认为是HTML - if (htmlTagCount >= 2) - return true; - - // 检查是否包含HTML实体 - if (Regex.IsMatch(content, @"&[a-z]+;|&#\d+;", RegexOptions.IgnoreCase)) - return true; - - return false; - } - /// /// 判断是否为Markdown内容 /// @@ -219,7 +187,11 @@ private void RenderContent(string content, ContentFormat format) } /// - /// 使用HTML渲染 + /// 使用HTML渲染。 + /// ⚠ 安全限制(2026-08-28 决策):自动识别已禁用,仅当调用方显式设置 Format=Html 时才可达。 + /// 当前实现把原始内容零消毒直接插值进模板交给 WebView 执行——启用前必须先实现 + /// 标签/属性白名单消毒(建议 AngleSharp)并注入 CSP: + /// <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"> /// private void RenderAsHtml(string htmlContent) { diff --git a/src/MarketAssistant.App/Views/Windows/MainWindow.axaml b/src/MarketAssistant.App/Views/Windows/MainWindow.axaml index 9858655..fd56c51 100644 --- a/src/MarketAssistant.App/Views/Windows/MainWindow.axaml +++ b/src/MarketAssistant.App/Views/Windows/MainWindow.axaml @@ -127,7 +127,7 @@ 注意:必须声明在主导航之前,否则作为 DockPanel 最后一个子元素会被 LastChildFill 当作填充项,Bottom 停靠失效导致两项不可见 --> Date: Fri, 28 Aug 2026 23:45:27 +0800 Subject: [PATCH 6/9] =?UTF-8?q?[=E5=85=A8=E5=BA=93]=20P1=20=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E4=BF=AE=E5=A4=8D=EF=BC=9A=E9=87=91=E9=A2=9D=E5=88=97?= =?UTF-8?q?ReadDecimal=E3=80=81OrderType=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E3=80=81=E4=BA=A4=E6=98=93=E6=89=80=E7=B2=BE=E5=BA=A6=E5=8F=96?= =?UTF-8?q?=E6=95=B4=E3=80=81=E9=A3=8E=E6=8E=A7=E4=B9=B0=E5=85=A5=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E4=B8=8E30=E5=A4=A9=E5=9B=9E=E6=92=A4=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E3=80=81AISignal=E6=8A=A4=E6=A0=8F=E5=8E=BB=E9=87=8D?= =?UTF-8?q?=E3=80=81=E9=99=90=E6=B5=81/=E5=93=88=E5=B8=8C/=E8=AF=8D?= =?UTF-8?q?=E8=BE=B9=E7=95=8C/=E8=B6=8A=E7=95=8C=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E3=80=81=E7=BA=BF=E7=A8=8B=E4=B8=8E=E7=94=9F=E5=91=BD=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E4=BF=AE=E5=A4=8D=E3=80=81=E7=AC=A6=E5=8F=B7=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2=E6=94=B6=E6=95=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Packages.props | 1 + .../ScreenInvestmentTargetsExecutor.cs | 4 +- .../InvestmentSelectionWorkflow.cs | 77 +++----- .../MarketAnalysis/MarketAnalysisWorkflow.cs | 8 +- .../Agents/Tools/GroundingSearchTools.cs | 84 ++++---- .../AssetScreener/CryptoScreenerService.cs | 22 ++- .../AssetScreener/IAssetScreenerService.cs | 3 +- .../AssetScreener/StockScreenerService.cs | 18 +- .../Crypto/BinanceAccountService.cs | 38 +++- .../Crypto/BinanceFuturesAccountService.cs | 1 + .../Crypto/BinanceUserDataStreamService.cs | 29 ++- .../Crypto/ExchangeSymbolFilterCache.cs | 179 ++++++++++++++++++ .../PriceAlert/PriceAlertService.cs | 43 ++++- .../Services/Dialog/IDialogService.cs | 7 + .../Services/Market/AShareMarketModule.cs | 3 +- .../Services/Market/CryptoMarketModule.cs | 3 +- .../Services/Market/MarketContext.cs | 3 +- .../Services/ServiceCollectionExtensions.cs | 7 +- .../Trading/AISignalStrategyExecutor.cs | 65 ++++++- .../Trading/AnalysisReportCache.cs | 27 ++- .../Trading/MarketMonitor.cs | 18 ++ .../Trading/RiskManager.cs | 18 +- .../Trading/TradingDataService.cs | 59 ++++-- .../Services/Dialog/DialogService.cs | 20 ++ .../ViewModels/ChatMessageAdapter.cs | 1 + .../ViewModels/FavoritesPageViewModel.cs | 27 ++- .../ViewModels/MCPConfigPageViewModel.cs | 15 +- .../Trading/StrategyConfigViewModel.cs | 16 +- .../Trading/TradeMonitorViewModel.cs | 3 +- .../Trading/TradingPageViewModel.cs | 2 + .../Views/Components/KLineChartView.cs | 20 +- .../Core/CryptoSymbolConverter.cs | 21 ++ .../Core/StockSymbolConverter.cs | 54 ++---- .../CoinGeckoApiService.cs | 37 ++-- .../MarketAssistant.DataProviders.csproj | 1 + .../Providers/StructuredOutputValidator.cs | 16 +- .../Services/ClipImageEmbeddingService.cs | 56 ++++-- .../Services/DocxMarkdownConverter.cs | 99 +++++----- .../Services/QueryRewriteService.cs | 26 ++- .../Services/RagIngestionService.cs | 32 +++- .../Services/RetrievalOrchestrator.cs | 10 + .../TradingDataServiceMigrationTest.cs | 4 + 42 files changed, 842 insertions(+), 335 deletions(-) create mode 100644 src/MarketAssistant.App.Services/Applications/Crypto/ExchangeSymbolFilterCache.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index f397fd3..2e21b4c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,6 +17,7 @@ + diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/ScreenInvestmentTargetsExecutor.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/ScreenInvestmentTargetsExecutor.cs index 968687c..cfce374 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/ScreenInvestmentTargetsExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/ScreenInvestmentTargetsExecutor.cs @@ -52,8 +52,8 @@ private async ValueTask HandleAsync( _logger.LogInformation("[步骤2/3] 使用市场类型: {MarketType}, 筛选服务: {ServiceType}", originalRequest.MarketType, screenerService.GetType().Name); - // 调用筛选服务 - List assets = await screenerService.ScreenAsync(input.Criteria); + // 调用筛选服务(透传取消令牌,用户中断时立即终止上游 HTTP 请求) + List assets = await screenerService.ScreenAsync(input.Criteria, cancellationToken); _logger.LogInformation("[步骤2/3] 筛选完成,获得 {Count} 个投资标的", assets.Count); diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs index 69c3f5d..55e382a 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs @@ -4,6 +4,7 @@ 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; @@ -12,26 +13,21 @@ namespace MarketAssistant.Agents.InvestmentSelection; /// AI 投资选择工作流,使用 Agent Framework Workflows 实现确定性三步骤流程: /// 生成筛选条件 → 执行筛选 → AI 分析结果。 ///
-public class InvestmentSelectionWorkflow : IDisposable +/// +/// 与 MarketAnalysisWorkflow 对齐:4 个 Executor 均为 Transient 注册, +/// 在每次 Run 内重新解析构造,避免 Singleton Executor 在并发分析间共享 +/// 可变状态与模型/运行时引用。 +/// +public class InvestmentSelectionWorkflow { - private readonly GenerateCriteriaExecutor _generateStockCriteriaExecutor; - private readonly GenerateCriteriaExecutor _generateCryptoCriteriaExecutor; - private readonly ScreenInvestmentTargetsExecutor _screenTargetsExecutor; - private readonly AnalyzeAssetsExecutor _analyzeAssetsExecutor; + private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; - private bool _disposed; public InvestmentSelectionWorkflow( - GenerateCriteriaExecutor generateStockCriteriaExecutor, - GenerateCriteriaExecutor generateCryptoCriteriaExecutor, - ScreenInvestmentTargetsExecutor screenTargetsExecutor, - AnalyzeAssetsExecutor analyzeAssetsExecutor, + IServiceProvider serviceProvider, 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)); + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -85,17 +81,27 @@ private async Task ExecuteWorkflowAsync( request.MarketType, request.IsNewsAnalysis ? "新闻热点" : "用户需求"); + // 每次运行新建 Executor 实例(Transient),避免并发 Run 间共享状态 + var generateStockCriteriaExecutor = + _serviceProvider.GetRequiredService>(); + var generateCryptoCriteriaExecutor = + _serviceProvider.GetRequiredService>(); + var screenTargetsExecutor = + _serviceProvider.GetRequiredService(); + var analyzeAssetsExecutor = + _serviceProvider.GetRequiredService(); + WorkflowBuilder workflowBuilder = request.MarketType switch { - MarketType.AShare => new WorkflowBuilder(_generateStockCriteriaExecutor) - .AddEdge(_generateStockCriteriaExecutor, _screenTargetsExecutor) - .AddEdge(_screenTargetsExecutor, _analyzeAssetsExecutor) - .WithOutputFrom(_analyzeAssetsExecutor), + 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), + MarketType.Crypto => new WorkflowBuilder(generateCryptoCriteriaExecutor) + .AddEdge(generateCryptoCriteriaExecutor, screenTargetsExecutor) + .AddEdge(screenTargetsExecutor, analyzeAssetsExecutor) + .WithOutputFrom(analyzeAssetsExecutor), _ => throw new NotSupportedException($"不支持的市场类型: {request.MarketType}") }; @@ -154,31 +160,4 @@ private async Task ExecuteWorkflowAsync( return finalResult; } - - private static InvestmentSelectionResult CreateDefaultResult(string? problem = null) - { - return new InvestmentSelectionResult - { - Recommendations = [], - ConfidenceScore = 0, - AnalysisSummary = problem ?? "分析过程中遇到问题,请稍后重试。", - MarketEnvironmentAnalysis = "无可用分析", - InvestmentAdvice = "建议稍后重试", - RiskWarnings = ["系统异常,请联系技术支持"] - }; - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!_disposed && disposing) - { - _disposed = true; - } - } } diff --git a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs index df25707..dcd16e7 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs @@ -168,11 +168,13 @@ public async Task AnalyzeAsync( // 同一次 Run 的分析师与 Coordinator 绑定同一个 Runtime Client。 // P1-07:协调器附带只读产物读取工具,全文按需获取而非随消息注入。 + // Run ID 通过闭包捕获真实值,不暴露给 LLM 填写(LLM 传入伪造/格式错误的 + // runId 会解析失败),工具仅暴露 analystName 参数。 + var currentRunId = runId; var getArtifactTool = AIFunctionFactory.Create( - ([Description("本次分析的 Run ID(32 位十六进制)")] string runId, - [Description("分析师名称")] string analystName, + ([Description("分析师名称")] string analystName, CancellationToken ct) - => _artifactStore.GetAsync(Guid.ParseExact(runId, "N"), analystName, ct), + => _artifactStore.GetAsync(currentRunId, analystName, ct), name: "get_analyst_artifact", description: "读取本次运行中某位分析师的完整分析产物全文。仅在需要某维度细节时调用,不要凭摘要编造内容。"); diff --git a/src/MarketAssistant.App.Services/Agents/Tools/GroundingSearchTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/GroundingSearchTools.cs index 407c578..fabaefd 100644 --- a/src/MarketAssistant.App.Services/Agents/Tools/GroundingSearchTools.cs +++ b/src/MarketAssistant.App.Services/Agents/Tools/GroundingSearchTools.cs @@ -39,7 +39,8 @@ public GroundingSearchTools( [Description("综合信息检索工具。可同时检索互联网公开信息和内部知识库(如用户文档、历史研报)。")] public async Task> SearchAsync( [Description("搜索的查询语句或关键词。")] string query, - [Description("返回结果数量,建议3-6个")] int top = 6) + [Description("返回结果数量,建议3-6个")] int top = 6, + CancellationToken cancellationToken = default) { // 参数约束:避免极端模式使用和极端参数 if (top <= 0) top = 3; @@ -56,9 +57,14 @@ public async Task> SearchAsync( try { - var searchResults = await ExecuteSearchStrategy(query, hasKnowledgeEnabled, hasWebSearchEnabled, top); + var searchResults = await ExecuteSearchStrategy(query, hasKnowledgeEnabled, hasWebSearchEnabled, top, cancellationToken); return searchResults.Take(top).ToList(); } + catch (OperationCanceledException) + { + // 取消必须向上传播,不得吞成空结果 + throw; + } catch (Exception ex) { _logger.LogError(ex, "搜索执行失败: {Query}", query); @@ -73,54 +79,49 @@ private async Task> ExecuteSearchStrategy( string query, bool hasKnowledgeEnabled, bool hasWebSearchEnabled, - int top) + int top, + CancellationToken cancellationToken) { _logger.LogInformation("执行搜索策略 - 知识库: {Knowledge}, 网络: {Web}, 查询: {Query}", hasKnowledgeEnabled, hasWebSearchEnabled, query); - try + // 并行执行启用的搜索方式(各路搜索内部已做失败隔离,不会互相拖垮) + var tasks = new List>>(); + + if (hasKnowledgeEnabled) { - // 并行执行启用的搜索方式 - var tasks = new List>>(); - - if (hasKnowledgeEnabled) - { - tasks.Add(ExecuteKnowledgeSearch(query, top)); - } - - if (hasWebSearchEnabled) - { - tasks.Add(ExecuteWebSearch(query, top)); - } - - // 如果没有启用任何搜索方式 - if (tasks.Count == 0) - { - return []; - } - - // 等待所有任务完成 - var results = await Task.WhenAll(tasks); - - // 合并所有结果 - return CombineResults(results); + tasks.Add(ExecuteKnowledgeSearch(query, top, cancellationToken)); } - catch (Exception ex) + + if (hasWebSearchEnabled) + { + tasks.Add(ExecuteWebSearch(query, top, cancellationToken)); + } + + // 如果没有启用任何搜索方式 + if (tasks.Count == 0) { - _logger.LogError(ex, "搜索策略执行失败: {Query}", query); return []; } + + // 等待所有任务完成并合并结果 + var results = await Task.WhenAll(tasks); + return CombineResults(results); } /// /// 执行知识库搜索 /// - private async Task> ExecuteKnowledgeSearch(string query, int top) + private async Task> ExecuteKnowledgeSearch(string query, int top, CancellationToken cancellationToken) { try { var collectionName = UserSetting.VectorCollectionName; - return await _orchestrator.RetrieveAsync(query, collectionName, top); + return await _orchestrator.RetrieveAsync(query, collectionName, top, cancellationToken); + } + catch (OperationCanceledException) + { + throw; } catch (Exception ex) { @@ -130,11 +131,24 @@ private async Task> ExecuteKnowledgeSearch(strin } /// - /// 执行网络搜索 + /// 执行网络搜索。失败时仅记录告警并返回空结果, + /// 不让网络搜索异常拖垮已经成功的知识库结果。 /// - private Task> ExecuteWebSearch(string query, int top) + private async Task> ExecuteWebSearch(string query, int top, CancellationToken cancellationToken) { - return _webSearchService.SearchAsync(query, top); + try + { + return await _webSearchService.SearchAsync(query, top, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "网络搜索失败,忽略网络结果: {Query}", query); + return new List(); + } } /// diff --git a/src/MarketAssistant.App.Services/Applications/AssetScreener/CryptoScreenerService.cs b/src/MarketAssistant.App.Services/Applications/AssetScreener/CryptoScreenerService.cs index 11355b9..e816761 100644 --- a/src/MarketAssistant.App.Services/Applications/AssetScreener/CryptoScreenerService.cs +++ b/src/MarketAssistant.App.Services/Applications/AssetScreener/CryptoScreenerService.cs @@ -31,7 +31,7 @@ public CryptoScreenerService( /// 根据筛选条件筛选虚拟币 /// 优先使用 CoinGecko(含市值/排名/供应量数据),失败时降级到 Binance(仅价格/成交量/涨跌幅) /// - public async Task> ScreenAsync(object criteria) + public async Task> ScreenAsync(object criteria, CancellationToken cancellationToken = default) { if (criteria is not CryptoCriteria cryptoCriteria) { @@ -47,21 +47,26 @@ public async Task> ScreenAsync(object criteria) List results; try { - var markets = await FetchFromCoinGeckoAsync(cryptoCriteria); + var markets = await FetchFromCoinGeckoAsync(cryptoCriteria, cancellationToken); 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) + catch (Exception ex) when (ex is not ArgumentException and not OperationCanceledException) { _logger.LogWarning(ex, "CoinGecko 数据源不可用,降级到 Binance 兜底"); - results = await FetchFromBinanceFallbackAsync(cryptoCriteria); + results = await FetchFromBinanceFallbackAsync(cryptoCriteria, cancellationToken); _logger.LogInformation("Binance 兜底筛选完成,结果数量: {Count}", results.Count); } return results; } + catch (OperationCanceledException) + { + // 用户取消必须向上传播,不得包装成业务失败 + throw; + } catch (Exception ex) { _logger.LogError(ex, "虚拟币筛选过程中发生错误"); @@ -72,7 +77,7 @@ public async Task> ScreenAsync(object criteria) /// /// 从CoinGecko获取数据 /// - private async Task> FetchFromCoinGeckoAsync(CryptoCriteria criteria) + private async Task> FetchFromCoinGeckoAsync(CryptoCriteria criteria, CancellationToken cancellationToken) { // 确定排序方式 var order = DetermineOrder(criteria); @@ -96,7 +101,8 @@ private async Task> FetchFromCoinGeckoAsync(CryptoCriteria order: order, perPage: perPage, page: page, - priceChangePercentage: priceChangePercentage); + priceChangePercentage: priceChangePercentage, + cancellationToken: cancellationToken); return markets; } @@ -173,9 +179,9 @@ private List ConvertToScreenerInfo(List mark /// Binance 兜底筛选:当 CoinGecko 不可用时,从币安获取 USDT 交易对行情 /// 注意:Binance 不提供市值/排名/供应量数据,相关筛选条件将被忽略 ///
- private async Task> FetchFromBinanceFallbackAsync(CryptoCriteria criteria) + private async Task> FetchFromBinanceFallbackAsync(CryptoCriteria criteria, CancellationToken cancellationToken) { - var tickers = await _binanceService.GetAll24hrTickersFullAsync(); + var tickers = await _binanceService.GetAll24hrTickersFullAsync(cancellationToken); // 仅保留指定计价货币的交易对(默认 USDT),排除杠杆/稳定币交易对 var quoteCurrency = string.IsNullOrWhiteSpace(criteria.QuoteCurrency) ? "USDT" : criteria.QuoteCurrency; diff --git a/src/MarketAssistant.App.Services/Applications/AssetScreener/IAssetScreenerService.cs b/src/MarketAssistant.App.Services/Applications/AssetScreener/IAssetScreenerService.cs index bc31990..a1183d4 100644 --- a/src/MarketAssistant.App.Services/Applications/AssetScreener/IAssetScreenerService.cs +++ b/src/MarketAssistant.App.Services/Applications/AssetScreener/IAssetScreenerService.cs @@ -11,7 +11,8 @@ public interface IAssetScreenerService /// 根据筛选条件筛选资产 ///
/// 筛选条件对象(支持 StockCriteria、CryptoCriteria 等) + /// 取消令牌。 /// 筛选结果列表 - Task> ScreenAsync(object criteria); + Task> ScreenAsync(object criteria, CancellationToken cancellationToken = default); } diff --git a/src/MarketAssistant.App.Services/Applications/AssetScreener/StockScreenerService.cs b/src/MarketAssistant.App.Services/Applications/AssetScreener/StockScreenerService.cs index 2a7dccf..c4ac2a9 100644 --- a/src/MarketAssistant.App.Services/Applications/AssetScreener/StockScreenerService.cs +++ b/src/MarketAssistant.App.Services/Applications/AssetScreener/StockScreenerService.cs @@ -76,7 +76,7 @@ public StockScreenerService( _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public async Task> ScreenAsync(object criteria) + public async Task> ScreenAsync(object criteria, CancellationToken cancellationToken = default) { if (criteria is not StockCriteria stockCriteria) { @@ -88,22 +88,22 @@ public async Task> ScreenAsync(object criteria) try { - await EnsureCookiesAsync(); + await EnsureCookiesAsync(cancellationToken); var queryParams = BuildQueryParams(stockCriteria); - var stocks = await FetchFromXueqiuAsync(queryParams); + var stocks = await FetchFromXueqiuAsync(queryParams, cancellationToken); _logger.LogInformation("雪球选股完成,结果数量: {Count}", stocks.Count); return stocks.Cast().ToList(); } - catch (Exception ex) when (ex is not ArgumentException) + catch (Exception ex) when (ex is not ArgumentException and not OperationCanceledException) { _logger.LogError(ex, "雪球选股过程中发生错误"); throw new FriendlyException($"筛选股票失败: {ex.Message}", ex); } } - private async Task EnsureCookiesAsync() + private async Task EnsureCookiesAsync(CancellationToken cancellationToken) { var cookies = _cookieContainer.GetCookies(new Uri("https://xueqiu.com")); if (cookies.Count > 0) @@ -114,7 +114,7 @@ private async Task EnsureCookiesAsync() _logger.LogDebug("雪球 Cookie 为空,访问首页获取 Cookie"); using var client = _httpClientFactory.CreateClient("Xueqiu"); using var request = new HttpRequestMessage(HttpMethod.Get, "/"); - using var response = await client.SendAsync(request); + using var response = await client.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { @@ -216,7 +216,7 @@ private string BuildQueryParams(StockCriteria criteria) filterString; } - private async Task> FetchFromXueqiuAsync(string queryParams) + private async Task> FetchFromXueqiuAsync(string queryParams, CancellationToken cancellationToken) { var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); var url = $"{ScreenerEndpoint}?{queryParams}&_={timestamp}"; @@ -225,9 +225,9 @@ private async Task> FetchFromXueqiuAsync(string queryPar using var client = _httpClientFactory.CreateClient("Xueqiu"); using var request = new HttpRequestMessage(HttpMethod.Get, url); - using var response = await client.SendAsync(request); + using var response = await client.SendAsync(request, cancellationToken); - var json = await response.Content.ReadAsStringAsync(); + var json = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) { diff --git a/src/MarketAssistant.App.Services/Applications/Crypto/BinanceAccountService.cs b/src/MarketAssistant.App.Services/Applications/Crypto/BinanceAccountService.cs index fcf268e..f2a8d98 100644 --- a/src/MarketAssistant.App.Services/Applications/Crypto/BinanceAccountService.cs +++ b/src/MarketAssistant.App.Services/Applications/Crypto/BinanceAccountService.cs @@ -19,6 +19,12 @@ public abstract class BinanceAccountServiceBase protected readonly string HttpClientName; protected readonly string Label; + /// + /// 交易对精度过滤器缓存(进程内共享,按 HttpClient 名称维度隔离)。 + /// 下单时用于对数量/价格取整,避免精度违规被交易所拒单。 + /// + private ExchangeSymbolFilterCache? _filterCache; + /// HttpClient 工厂 /// 日志器 /// 鉴权服务(决定实盘/Testnet 密钥来源) @@ -53,6 +59,11 @@ protected BinanceAccountServiceBase( /// protected abstract string OpenOrdersEndpoint { get; } + /// + /// 交易对精度过滤器端点(现货 /api/v3/exchangeInfo,合约 /fapi/v1/exchangeInfo) + /// + protected abstract string ExchangeInfoEndpoint { get; } + /// /// 解析账户信息响应。子类负责将合约/现货特定结构映射为统一的 。 /// @@ -135,12 +146,24 @@ public async Task PlaceOrderAsync( throw new FriendlyException($"{Label}账户当前被限制交易,无法下单(可能因违规、KYC 未完成或地区限制)"); } + // 按交易所精度过滤器取整:数量向下取整到 stepSize,价格/触发价四舍五入到 tickSize; + // 拿不到过滤器信息时保持原值,由交易所侧校验兜底 + _filterCache ??= new ExchangeSymbolFilterCache(HttpClientFactory, Logger); + var filters = await _filterCache.GetFiltersAsync(symbol, HttpClientName, ExchangeInfoEndpoint, cancellationToken); + + var roundedQuantity = ExchangeSymbolFilterCache.RoundQuantityToStep(quantity, filters?.StepSize) ?? quantity; + if (roundedQuantity != quantity) + { + Logger.LogDebug("下单数量按 stepSize {StepSize} 取整: {Original} → {Rounded} ({Symbol})", + filters!.StepSize, quantity, roundedQuantity, symbol); + } + var parameters = new Dictionary { ["symbol"] = symbol.ToUpper(), ["side"] = side.ToUpper(), ["type"] = type.ToUpper(), - ["quantity"] = quantity.ToString("F8", CultureInfo.InvariantCulture) + ["quantity"] = roundedQuantity.ToString("F8", CultureInfo.InvariantCulture) }; if (!string.IsNullOrEmpty(clientOrderId)) @@ -158,7 +181,13 @@ public async Task PlaceOrderAsync( { throw new ArgumentException("限价单必须指定价格"); } - parameters["price"] = price.Value.ToString("F8", CultureInfo.InvariantCulture); + var roundedPrice = ExchangeSymbolFilterCache.RoundPriceToTick(price.Value, filters?.TickSize) ?? price.Value; + if (roundedPrice != price.Value) + { + Logger.LogDebug("限价单价格按 tickSize {TickSize} 取整: {Original} → {Rounded} ({Symbol})", + filters!.TickSize, price.Value, roundedPrice, symbol); + } + parameters["price"] = roundedPrice.ToString("F8", CultureInfo.InvariantCulture); parameters["timeInForce"] = "GTC"; } @@ -166,7 +195,9 @@ public async Task PlaceOrderAsync( var typeUpper = type.ToUpper(); if (stopPrice.HasValue && (typeUpper == "STOP_MARKET" || typeUpper == "TAKE_PROFIT_MARKET")) { - parameters["stopPrice"] = stopPrice.Value.ToString("F8", CultureInfo.InvariantCulture); + // 触发价同样受 tickSize 约束,否则条件单会被交易所以精度错误拒绝 + var roundedStopPrice = ExchangeSymbolFilterCache.RoundPriceToTick(stopPrice.Value, filters?.TickSize) ?? stopPrice.Value; + parameters["stopPrice"] = roundedStopPrice.ToString("F8", CultureInfo.InvariantCulture); } if (trailingDelta.HasValue && typeUpper == "TRAILING_STOP_MARKET") @@ -364,6 +395,7 @@ public BinanceSpotAccountService( protected override string AccountEndpoint => "/api/v3/account"; protected override string OrderEndpoint => "/api/v3/order"; protected override string OpenOrdersEndpoint => "/api/v3/openOrders"; + protected override string ExchangeInfoEndpoint => "/api/v3/exchangeInfo"; protected override async Task ParseAccountInfoAsync(HttpContent content, CancellationToken cancellationToken) { diff --git a/src/MarketAssistant.App.Services/Applications/Crypto/BinanceFuturesAccountService.cs b/src/MarketAssistant.App.Services/Applications/Crypto/BinanceFuturesAccountService.cs index 492037c..f388618 100644 --- a/src/MarketAssistant.App.Services/Applications/Crypto/BinanceFuturesAccountService.cs +++ b/src/MarketAssistant.App.Services/Applications/Crypto/BinanceFuturesAccountService.cs @@ -26,6 +26,7 @@ public BinanceFuturesAccountService( protected override string AccountEndpoint => "/fapi/v2/account"; protected override string OrderEndpoint => "/fapi/v1/order"; protected override string OpenOrdersEndpoint => "/fapi/v1/openOrders"; + protected override string ExchangeInfoEndpoint => "/fapi/v1/exchangeInfo"; /// /// 合约下单附加 positionSide(单向模式 BOTH / 双向模式 LONG/SHORT)。 diff --git a/src/MarketAssistant.App.Services/Applications/Crypto/BinanceUserDataStreamService.cs b/src/MarketAssistant.App.Services/Applications/Crypto/BinanceUserDataStreamService.cs index 4fad581..1c3d27c 100644 --- a/src/MarketAssistant.App.Services/Applications/Crypto/BinanceUserDataStreamService.cs +++ b/src/MarketAssistant.App.Services/Applications/Crypto/BinanceUserDataStreamService.cs @@ -114,19 +114,22 @@ public async Task StopAsync() if (_ws != null) { - if (_ws.State == WebSocketState.Open) + // 快照到局部变量后统一关闭/释放:重连循环与 StopAsync 并发时, + // 判空后字段可能被对方置 null,直接使用字段存在 NullReference 竞争 + var ws = _ws; + _ws = null; + if (ws.State == WebSocketState.Open) { try { - await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "stop", CancellationToken.None); + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "stop", CancellationToken.None); } catch { // 忽略关闭异常 } } - _ws.Dispose(); - _ws = null; + ws.Dispose(); } if (_listenKey != null && TryGetSpotContext(out var apiKey, out var httpClientName)) @@ -246,12 +249,14 @@ private async Task ConnectAndReceiveAsync(string listenKey, CancellationToken ct while (!ct.IsCancellationRequested) { - _ws = new ClientWebSocket(); + // 使用局部变量持有连接,避免与 StopAsync 并发时对字段判空后字段被置 null 的竞争 + var ws = new ClientWebSocket(); + _ws = ws; try { _logger.LogInformation("连接币安用户数据流 WebSocket"); - await _ws.ConnectAsync(new Uri(url), ct); - await ReceiveLoopAsync(_ws, ct); + await ws.ConnectAsync(new Uri(url), ct); + await ReceiveLoopAsync(ws, ct); } catch (OperationCanceledException) { @@ -267,8 +272,10 @@ private async Task ConnectAndReceiveAsync(string listenKey, CancellationToken ct } finally { - _ws.Dispose(); - _ws = null; + ws.Dispose(); + // 仅当字段仍指向本次连接时才清空,避免误清 StopAsync 已快照或重连后新赋值的实例 + if (ReferenceEquals(_ws, ws)) + _ws = null; } if (ct.IsCancellationRequested) @@ -383,7 +390,9 @@ public void Dispose() _keepaliveTimer?.Dispose(); _cts?.Cancel(); _cts?.Dispose(); - _ws?.Dispose(); + var ws = _ws; + _ws = null; + ws?.Dispose(); _gate.Dispose(); GC.SuppressFinalize(this); } diff --git a/src/MarketAssistant.App.Services/Applications/Crypto/ExchangeSymbolFilterCache.cs b/src/MarketAssistant.App.Services/Applications/Crypto/ExchangeSymbolFilterCache.cs new file mode 100644 index 0000000..0eb74a2 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Crypto/ExchangeSymbolFilterCache.cs @@ -0,0 +1,179 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Applications.Crypto; + +/// +/// 交易所交易对精度过滤器(LOT_SIZE stepSize / PRICE_FILTER tickSize)的进程内缓存。 +/// 下单前按过滤器对数量/价格取整,避免因精度违规被交易所拒单(如 -1111、-1013)。 +/// 过滤器信息极少变化,缓存 24 小时,避免每个下单请求都拉取 exchangeInfo。 +/// 拿不到过滤器信息时调用方应保持原始值下单(宁可信服交易所报错也不阻断交易)。 +/// +public sealed class ExchangeSymbolFilterCache +{ + private static readonly TimeSpan CacheLifetime = TimeSpan.FromHours(24); + + /// + /// 按 "httpClientName:SYMBOL" 维度缓存(实盘/Testnet/Demo 各自独立),跨实例共享, + /// 因为账户服务由工厂按模式临时创建,实例级缓存会失效。 + /// + private static readonly ConcurrentDictionary Cache = new(); + + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public ExchangeSymbolFilterCache(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + /// + /// 获取指定交易对的精度过滤器;网络失败或解析失败返回 null(调用方保持原值并继续)。 + /// + /// 交易对(如 BTCUSDT) + /// 命名 HttpClient(决定现货/合约/Testnet 域名) + /// exchangeInfo 端点(现货 /api/v3/exchangeInfo,合约 /fapi/v1/exchangeInfo) + public async Task GetFiltersAsync( + string symbol, string httpClientName, string exchangeInfoEndpoint, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(symbol)) + return null; + + var normalizedSymbol = symbol.ToUpperInvariant(); + var cacheKey = $"{httpClientName}:{normalizedSymbol}"; + + if (Cache.TryGetValue(cacheKey, out var cached) && + DateTimeOffset.UtcNow - cached.FetchedAt < CacheLifetime) + { + return cached.Filters; + } + + try + { + var client = _httpClientFactory.CreateClient(httpClientName); + var url = $"{exchangeInfoEndpoint}?symbol={Uri.EscapeDataString(normalizedSymbol)}"; + using var request = new HttpRequestMessage(HttpMethod.Get, url); + using var response = await client.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content + .ReadFromJsonAsync(cancellationToken); + var info = payload?.Symbols?.FirstOrDefault(s => + string.Equals(s.Symbol, normalizedSymbol, StringComparison.OrdinalIgnoreCase)); + if (info == null) + { + _logger.LogDebug("exchangeInfo 中未找到交易对 {Symbol}({HttpClientName})", normalizedSymbol, httpClientName); + return null; + } + + var filters = new SymbolFilters + { + StepSize = ParsePositiveDecimal(info.LotSizeFilter?.StepSize), + TickSize = ParsePositiveDecimal(info.PriceFilter?.TickSize) + }; + + Cache[cacheKey] = (DateTimeOffset.UtcNow, filters); + return filters; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // 拿不到过滤器时保持原值下单,由交易所侧校验兜底 + _logger.LogDebug(ex, "获取 {Symbol} 的交易所精度过滤器失败,下单将使用原始数量/价格", normalizedSymbol); + return null; + } + } + + /// + /// 数量向下取整到 stepSize 的整数倍(宁可少买不可超量)。 + /// + public static decimal? RoundQuantityToStep(decimal quantity, decimal? stepSize) + => RoundToStep(quantity, stepSize, floor: true); + + /// + /// 价格四舍五入到 tickSize 的整数倍。 + /// + public static decimal? RoundPriceToTick(decimal price, decimal? tickSize) + => RoundToStep(price, tickSize, floor: false); + + /// + /// 将值取整为步长的整数倍;步长无效或为 0 时返回 null(表示保持原值)。 + /// + private static decimal? RoundToStep(decimal value, decimal? step, bool floor) + { + if (!step.HasValue || step.Value <= 0 || value <= 0) + return null; + + var steps = value / step.Value; + var roundedSteps = floor + ? Math.Floor(steps) + : Math.Round(steps, MidpointRounding.AwayFromZero); + return decimal.Round(roundedSteps * step.Value, GetDecimalPlaces(step.Value)); + } + + /// + /// 由步长字符串推断结果应保留的小数位数(如 stepSize 0.001 → 3 位), + /// 消除 decimal 乘法产生的多余尾位。 + /// + private static int GetDecimalPlaces(decimal step) + { + var text = step.ToString("0.############################", CultureInfo.InvariantCulture); + var dotIndex = text.IndexOf('.'); + return dotIndex < 0 ? 0 : text.Length - dotIndex - 1; + } + + private static decimal? ParsePositiveDecimal(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + return decimal.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) && parsed > 0 + ? parsed + : null; + } + + private sealed class ExchangeInfoResponse + { + public List? Symbols { get; set; } + } + + private sealed class ExchangeSymbolInfo + { + public string Symbol { get; set; } = string.Empty; + + [JsonPropertyName("filters")] + public List? Filters { get; set; } + + public ExchangeSymbolFilter? LotSizeFilter => Filters?.FirstOrDefault(f => + string.Equals(f.FilterType, "LOT_SIZE", StringComparison.OrdinalIgnoreCase)); + + public ExchangeSymbolFilter? PriceFilter => Filters?.FirstOrDefault(f => + string.Equals(f.FilterType, "PRICE_FILTER", StringComparison.OrdinalIgnoreCase)); + } + + private sealed class ExchangeSymbolFilter + { + [JsonPropertyName("filterType")] + public string FilterType { get; set; } = string.Empty; + + [JsonPropertyName("stepSize")] + public string? StepSize { get; set; } + + [JsonPropertyName("tickSize")] + public string? TickSize { get; set; } + } + + /// + /// 交易对精度过滤器:数量步长与价格步长。 + /// + public sealed record SymbolFilters + { + /// LOT_SIZE stepSize:下单数量必须是该值的整数倍 + public decimal? StepSize { get; init; } + + /// PRICE_FILTER tickSize:价格必须是该值的整数倍 + public decimal? TickSize { get; init; } + } +} diff --git a/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertService.cs b/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertService.cs index 3a9d9a1..4799ebc 100644 --- a/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertService.cs +++ b/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertService.cs @@ -16,7 +16,7 @@ namespace MarketAssistant.Applications.PriceAlert; /// 价格预警服务,监听 WebSocket 价格并触发通知。 /// 持久化通过 SQLite(market.db)实现,规则在启动时异步加载到内存。 /// -public sealed class PriceAlertService : SqliteServiceBase, IDisposable +public sealed class PriceAlertService : SqliteServiceBase, IDisposable, IAsyncDisposable { private static readonly TimeSpan ASharePollingInterval = TimeSpan.FromSeconds(20); @@ -464,6 +464,38 @@ FROM price_alert_rules } } + /// + /// 异步释放:取消轮询并等待后台任务收尾,超时(5 秒)后不再等待; + /// OperationCanceledException 属正常取消路径,静默处理,其余异常记录后不重抛。 + /// + public async ValueTask DisposeAsync() + { + _wsService.PriceUpdated -= OnCryptoPriceUpdated; + _ = _wsService.UnsubscribeAllAsync(WebSocketSubscriberKeys.PriceAlerts); + _pollingCts.Cancel(); + try + { + await Task.WhenAll(_pollingTask ?? Task.CompletedTask, _subscriptionTask ?? Task.CompletedTask) + .WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (OperationCanceledException) + { + // 正常取消路径 + } + catch (Exception ex) + { + Logger.LogWarning(ex, "价格预警服务后台任务清理异常(已忽略)"); + } + + _subscriptionLock.Dispose(); + _pollingCts.Dispose(); + GC.SuppressFinalize(this); + } + + /// + /// 同步释放:与异步释放相同的清理逻辑,但不再向调用方重抛非取消异常, + /// 避免 Dispose 在应用退出时抛出导致终止流程失败。 + /// public void Dispose() { _wsService.PriceUpdated -= OnCryptoPriceUpdated; @@ -476,10 +508,19 @@ public void Dispose() } catch (AggregateException ex) { + // 取消属正常路径;其余异常仅记录,不再重抛(同步 Dispose 中重抛会中断应用退出) ex.Handle(e => e is OperationCanceledException); + foreach (var inner in ex.InnerExceptions.Where(e => e is not OperationCanceledException)) + Logger.LogWarning(inner, "价格预警服务后台任务清理异常(已忽略)"); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "价格预警服务后台任务清理异常(已忽略)"); } + _subscriptionLock.Dispose(); _pollingCts.Dispose(); + GC.SuppressFinalize(this); } private bool IsNotificationEnabled() diff --git a/src/MarketAssistant.App.Services/Services/Dialog/IDialogService.cs b/src/MarketAssistant.App.Services/Services/Dialog/IDialogService.cs index ca37c61..6eab52b 100644 --- a/src/MarketAssistant.App.Services/Services/Dialog/IDialogService.cs +++ b/src/MarketAssistant.App.Services/Services/Dialog/IDialogService.cs @@ -1,3 +1,5 @@ +using MarketAssistant.Trading.Models; + namespace MarketAssistant.Services.Dialog; /// @@ -10,6 +12,11 @@ public interface IDialogService /// Task ShowMessageAsync(string title, string message, string button = "确定"); + /// + /// 显示策略执行历史对话框窗口 + /// + Task ShowStrategyExecutionAsync(TradingStrategy strategy, IReadOnlyList records); + /// /// 显示确认对话框(两个按钮,可自定义文本) /// 取消令牌触发时会主动关闭对话框并返回取消按钮语义的结果(false)。 diff --git a/src/MarketAssistant.App.Services/Services/Market/AShareMarketModule.cs b/src/MarketAssistant.App.Services/Services/Market/AShareMarketModule.cs index 5270b10..b8ae88a 100644 --- a/src/MarketAssistant.App.Services/Services/Market/AShareMarketModule.cs +++ b/src/MarketAssistant.App.Services/Services/Market/AShareMarketModule.cs @@ -59,6 +59,7 @@ public void Register(IServiceCollection services) // 工作流 services.AddKeyedSingleton(MarketType.AShare); services.AddSingleton, StockCriteriaGenerationStrategy>(); - services.AddSingleton>(); + // Transient:由投资选择工作流在每次 Run 内重新解析,避免并发共享状态 + services.AddTransient>(); } } diff --git a/src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs b/src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs index 80f3238..82a7303 100644 --- a/src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs +++ b/src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs @@ -63,7 +63,8 @@ public void Register(IServiceCollection services) // 工作流 services.AddKeyedSingleton(MarketType.Crypto); services.AddSingleton, CryptoCriteriaGenerationStrategy>(); - services.AddSingleton>(); + // Transient:由投资选择工作流在每次 Run 内重新解析,避免并发共享状态 + services.AddTransient>(); // 交易所客户端 services.AddKeyedSingleton( diff --git a/src/MarketAssistant.App.Services/Services/Market/MarketContext.cs b/src/MarketAssistant.App.Services/Services/Market/MarketContext.cs index 03602fb..12667f7 100644 --- a/src/MarketAssistant.App.Services/Services/Market/MarketContext.cs +++ b/src/MarketAssistant.App.Services/Services/Market/MarketContext.cs @@ -32,7 +32,8 @@ public class MarketContext : INotifyPropertyChanged private readonly IUserSettingService _userSettingService; private readonly IServiceProvider _serviceProvider; private readonly object _marketLock = new(); - private MarketType _currentMarket; + // volatile 保证无锁读取 CurrentMarket 时能立即看到其他线程的切换结果 + private volatile MarketType _currentMarket; /// /// 当前激活的市场类型(静态快照,供 UI Converter 等无法直接依赖注入的场景读取) diff --git a/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs index f81fd46..b0af920 100644 --- a/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs +++ b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs @@ -409,9 +409,10 @@ private static IServiceCollection AddTradingServices(this IServiceCollection ser private static IServiceCollection AddWorkflowServices(this IServiceCollection services) { - // 投资选择工作流 - services.AddSingleton(); - services.AddSingleton(); + // 投资选择工作流;Executor 为 Transient,由工作流在每次 Run 内重新解析, + // 避免 Singleton Executor 在并发分析间共享可变状态和模型引用。 + services.AddTransient(); + services.AddTransient(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/MarketAssistant.App.Services/Trading/AISignalStrategyExecutor.cs b/src/MarketAssistant.App.Services/Trading/AISignalStrategyExecutor.cs index 371ca33..72f7ffc 100644 --- a/src/MarketAssistant.App.Services/Trading/AISignalStrategyExecutor.cs +++ b/src/MarketAssistant.App.Services/Trading/AISignalStrategyExecutor.cs @@ -1,5 +1,6 @@ using System.Text; using System.Text.Json; +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Infrastructure.Factories; using MarketAssistant.Trading.Models; using Microsoft.Agents.AI; @@ -239,6 +240,49 @@ await ApplyGuardrailsAsync(strategy, aiParams, entrySide, currentPrice, quantity return new AISignalExecutionResult(result.Record, result); } + /// + /// 查找与父策略关联的现存追踪止损伴随策略: + /// 匹配 CustomParams 中 parentStrategyId 指向当前策略的 + /// 同 Symbol + TrailingStop + Active + 同方向策略;无关联记录时返回 null。 + /// + private async Task FindTrailingStopCompanionAsync( + TradingStrategy parentStrategy, CancellationToken ct) + { + var activeStrategies = await _strategyService + .GetStrategiesByStatusAsync(StrategyStatus.Active, ct) + .ConfigureAwait(false); + + return activeStrategies.FirstOrDefault(s => + s.Type == StrategyType.TrailingStop && + s.Symbol.Equals(parentStrategy.Symbol, StringComparison.OrdinalIgnoreCase) && + s.Side == (parentStrategy.Side == OrderSide.Buy ? OrderSide.Sell : OrderSide.Buy) && + HasParentStrategyId(s, parentStrategy.Id)); + } + + /// + /// 判断伴随策略的 CustomParams 是否显式关联指定父策略(parentStrategyId 字段)。 + /// + private static bool HasParentStrategyId(TradingStrategy companion, string parentStrategyId) + { + if (string.IsNullOrEmpty(companion.CustomParams)) + return false; + + try + { + using var doc = JsonDocument.Parse(companion.CustomParams); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return false; + + return doc.RootElement.TryGetProperty("parentStrategyId", out var idProp) && + idProp.ValueKind == JsonValueKind.String && + string.Equals(idProp.GetString(), parentStrategyId, StringComparison.Ordinal); + } + catch (JsonException) + { + return false; + } + } + private static string? Truncate(string? text, int maxLength) => string.IsNullOrEmpty(text) || text.Length <= maxLength ? text : text[..maxLength] + "…"; @@ -276,10 +320,27 @@ private async Task ApplyGuardrailsAsync( MaxExecutions = 1, CustomParams = JsonSerializer.Serialize(new { + parentStrategyId = strategy.Id, trailingPercent, activationPrice = currentPrice }) }; + + // 去重:同 Symbol + TrailingStop + Active 且 CustomParams 关联同一父策略的伴随策略已存在时, + // 更新其数量/触发价/参数而不是新建,防止多次加仓后堆积大量重复的追踪止损策略 + var existing = await FindTrailingStopCompanionAsync(strategy, ct).ConfigureAwait(false); + if (existing != null) + { + companion.Id = existing.Id; + companion.ExecutionCount = existing.ExecutionCount; + companion.TrailingPeakPrice = existing.TrailingPeakPrice; + await _strategyService.SaveStrategyAsync(companion, ct).ConfigureAwait(false); + _logger.LogInformation( + "AI 已更新既有追踪止损伴随策略: {CompanionId} 回调 {Percent}% 激活价 {Activation} 关联 {StrategyId}", + companion.Id, trailingPercent, currentPrice, strategy.Id); + return; + } + await _strategyService.SaveStrategyAsync(companion, ct).ConfigureAwait(false); _logger.LogInformation( "AI 已创建追踪止损伴随策略: {CompanionId} 回调 {Percent}% 激活价 {Activation} 关联 {StrategyId}", @@ -455,7 +516,9 @@ private async Task BuildPositionSummaryAsync(string symbol, Cancellation private string BuildAnalysisContext(string symbol) { - var cached = _reportCache.Get(symbol); + // 后台 AI 交易固定运行在虚拟币市场,显式传入市场类型, + // 不依赖随时可能被 UI 切换的全局 MarketContext + var cached = _reportCache.Get(symbol, MarketType.Crypto); if (cached == null) return "(暂无分析报告,建议先运行市场分析工作流)"; diff --git a/src/MarketAssistant.App.Services/Trading/AnalysisReportCache.cs b/src/MarketAssistant.App.Services/Trading/AnalysisReportCache.cs index df30d50..96ec2cc 100644 --- a/src/MarketAssistant.App.Services/Trading/AnalysisReportCache.cs +++ b/src/MarketAssistant.App.Services/Trading/AnalysisReportCache.cs @@ -26,18 +26,29 @@ public AnalysisReportCache(MarketContext marketContext) _marketContext = marketContext; } - public void Set(string symbol, MarketAnalysisReport report) + /// + /// 写入缓存(显式市场类型)。后台交易链路应显式传入市场, + /// 避免依赖随时可能被 UI 切换的全局 MarketContext 状态。 + /// + public void Set(string symbol, MarketType market, MarketAnalysisReport report) { // 惰性清理:写入前移除已过期条目,并在超限时淘汰最旧的条目 EvictExpired(); EnsureCapacity(); - _reports[BuildKey(symbol)] = new CachedReport(report, DateTime.UtcNow); + _reports[BuildKey(market, symbol)] = new CachedReport(report, DateTime.UtcNow); } - public CachedReport? Get(string symbol) + /// 写入缓存(沿用全局 MarketContext 当前市场,供市场分析工作流使用)。 + public void Set(string symbol, MarketAnalysisReport report) + => Set(symbol, _marketContext.CurrentMarket, report); + + /// + /// 读取缓存(显式市场类型)。 + /// + public CachedReport? Get(string symbol, MarketType market) { - var key = BuildKey(symbol); + var key = BuildKey(market, symbol); if (!_reports.TryGetValue(key, out var cached)) return null; @@ -50,12 +61,16 @@ public void Set(string symbol, MarketAnalysisReport report) return cached; } + /// 读取缓存(沿用全局 MarketContext 当前市场)。 + public CachedReport? Get(string symbol) + => Get(symbol, _marketContext.CurrentMarket); + /// /// 构建包含市场类型的缓存键,避免跨市场冲突。 /// 键格式由 统一管理。 /// - private string BuildKey(string symbol) - => CacheKeys.GetTradingAnalysisReportKey(_marketContext.CurrentMarket, symbol); + private static string BuildKey(MarketType market, string symbol) + => CacheKeys.GetTradingAnalysisReportKey(market, symbol); /// /// 移除所有已过期条目 diff --git a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs index 32d7245..fcbd31e 100644 --- a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs +++ b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs @@ -568,6 +568,24 @@ public void Dispose() _cts?.Cancel(); _cts?.Dispose(); _priceChannel.Writer.TryComplete(); + + // 释放策略级锁之前,先短暂等待在途任务收尾(最多 3 秒,Dispose 为同步方法不允许无限等待), + // 防止任务在锁对象已被 Dispose 后调用 Release/WaitAsync 抛出 ObjectDisposedException + try + { + Task[] pendingTasks; + lock (_pendingTasksLock) + pendingTasks = _pendingStrategyTasks.ToArray(); + + if (pendingTasks.Length > 0) + Task.WaitAll(pendingTasks, TimeSpan.FromSeconds(3)); + } + catch (Exception ex) + { + // 等待超时或个别任务失败都不阻断释放,仅记录 + _logger.LogWarning(ex, "Dispose 等待在途策略任务超时或失败,部分状态可能未持久化"); + } + foreach (var kvp in _strategyLocks) kvp.Value.Dispose(); _strategyLocks.Clear(); diff --git a/src/MarketAssistant.App.Services/Trading/RiskManager.cs b/src/MarketAssistant.App.Services/Trading/RiskManager.cs index 86c27ac..23306d7 100644 --- a/src/MarketAssistant.App.Services/Trading/RiskManager.cs +++ b/src/MarketAssistant.App.Services/Trading/RiskManager.cs @@ -161,10 +161,24 @@ public virtual async Task ValidateOrderAsync( } } - // 最大回撤熔断 + // 买入订单校验报价资产(USDT)可用余额: + // - 现货:本地余额(Free + Locked)必须覆盖订单金额,防止下单后因余额不足被交易所拒绝 + // - 合约:以交易所保证金为准,跳过本地余额校验(杠杆下占用保证金远小于订单名义价值) + if (side == OrderSide.Buy && !_exchangeClient.IsFutures) + { + var availableQuote = CryptoPortfolioService.GetUsdtBalance(portfolioSummary); + if (orderValueUSDT > availableQuote) + return RiskCheckResult.Reject( + $"买入金额 {orderValueUSDT:F2} USDT 超过可用余额 {availableQuote:F2} USDT"); + } + + // 最大回撤熔断:峰值取最近 30 天滚动窗口, + // 窗口随时间滑动,历史峰值自动"过期",无需手动重置 if (config.MaxDrawdownPercent > 0) { - var peakValue = await _dataService.GetPeakAccountValueAsync(ct).ConfigureAwait(false); + var peakValue = await _dataService + .GetPeakAccountValueAsync(DateTime.UtcNow.AddDays(-30), ct) + .ConfigureAwait(false); if (peakValue > 0) { var drawdownPercent = (peakValue - totalUSDT) / peakValue * 100; diff --git a/src/MarketAssistant.App.Services/Trading/TradingDataService.cs b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs index 598d5bc..a755daa 100644 --- a/src/MarketAssistant.App.Services/Trading/TradingDataService.cs +++ b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs @@ -86,11 +86,13 @@ public async Task SaveStrategyAsync(TradingStrategy strategy, CancellationToken await using var cmd = conn.CreateCommand(); cmd.CommandText = """ INSERT OR REPLACE INTO strategies - (id, environment, symbol, type, status, side, trigger_price, stop_loss_price, take_profit_price, + (id, environment, symbol, type, status, side, order_type, slippage_tolerance, + trigger_price, stop_loss_price, take_profit_price, quantity, max_position_percent, custom_params, created_at, last_triggered_at, execution_count, max_executions, trailing_peak_price) VALUES - (@id, @environment, @symbol, @type, @status, @side, @triggerPrice, @slPrice, @tpPrice, + (@id, @environment, @symbol, @type, @status, @side, @orderType, @slippage, + @triggerPrice, @slPrice, @tpPrice, @qty, @maxPos, @customParams, @createdAt, @lastTriggered, @execCount, @maxExec, @trailingPeak) """; @@ -100,6 +102,8 @@ INSERT OR REPLACE INTO strategies cmd.Parameters.AddWithValue("@type", (int)strategy.Type); cmd.Parameters.AddWithValue("@status", (int)strategy.Status); cmd.Parameters.AddWithValue("@side", (int)strategy.Side); + cmd.Parameters.AddWithValue("@orderType", (int)strategy.OrderType); + cmd.Parameters.AddWithValue("@slippage", ToDb(strategy.SlippageTolerance)); cmd.Parameters.AddWithValue("@triggerPrice", ToDb(strategy.TriggerPrice)); cmd.Parameters.AddWithValue("@slPrice", ToDbNullable(strategy.StopLossPrice)); cmd.Parameters.AddWithValue("@tpPrice", ToDbNullable(strategy.TakeProfitPrice)); @@ -890,16 +894,23 @@ ON CONFLICT(environment, date) DO UPDATE SET } /// - /// 获取历史最高账户价值(用于计算回撤) + /// 获取历史最高账户价值(用于计算回撤),支持时间窗口下限。 /// - public async Task GetPeakAccountValueAsync(CancellationToken ct = default) + /// 仅统计 snapshot_at >= 该时刻的快照;传 null 时统计全部历史。 + /// 传入滚动窗口起点(如 30 天前)后,峰值随窗口滑动自动"重置", + /// 无需额外的重置接口:超出窗口的历史峰值不再参与回撤计算。 + public async Task GetPeakAccountValueAsync(DateTime? since = null, CancellationToken ct = default) { await EnsureInitializedAsync(InitializeDatabaseAsync); await using var conn = await OpenConnectionAsync(ct); await using var cmd = conn.CreateCommand(); - // 金额列以 TEXT 存储,MAX 会按字符串比较,需 CAST 为数值后再取最大值 - cmd.CommandText = "SELECT MAX(CAST(total_value_usdt AS REAL)) FROM account_snapshots WHERE environment = @environment"; + // 金额列以 TEXT 存储,MAX 会按字符串比较,需 CAST 为数值后再取最大值; + // snapshot_at 统一为 "O" 格式 UTC 字符串,字典序与时间序一致,可直接比较 + var where = since.HasValue ? " AND snapshot_at >= @since" : ""; + cmd.CommandText = $"SELECT MAX(CAST(total_value_usdt AS REAL)) FROM account_snapshots WHERE environment = @environment{where}"; cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + if (since.HasValue) + cmd.Parameters.AddWithValue("@since", since.Value.ToString("O")); var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); if (result is double d) return (decimal)d; @@ -1009,6 +1020,8 @@ CREATE TABLE IF NOT EXISTS strategies ( type INTEGER NOT NULL, status INTEGER NOT NULL, side INTEGER NOT NULL, + order_type INTEGER NOT NULL DEFAULT 0, + slippage_tolerance TEXT, trigger_price TEXT NOT NULL, stop_loss_price TEXT, take_profit_price TEXT, @@ -1206,20 +1219,28 @@ private static TradingStrategy ReadStrategy(SqliteDataReader reader) 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")), + OrderType = (OrderType)reader.GetInt32(reader.GetOrdinal("order_type")), + TriggerPrice = ReadDecimal(reader, reader.GetOrdinal("trigger_price")), + Quantity = ReadDecimal(reader, reader.GetOrdinal("quantity")), CreatedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("created_at")), CultureInfo.InvariantCulture), ExecutionCount = reader.GetInt32(reader.GetOrdinal("execution_count")) }; var slOrd = reader.GetOrdinal("stop_loss_price"); - if (!reader.IsDBNull(slOrd)) strategy.StopLossPrice = (decimal)reader.GetDouble(slOrd); + if (!reader.IsDBNull(slOrd)) strategy.StopLossPrice = ReadDecimal(reader, slOrd); var tpOrd = reader.GetOrdinal("take_profit_price"); - if (!reader.IsDBNull(tpOrd)) strategy.TakeProfitPrice = (decimal)reader.GetDouble(tpOrd); + if (!reader.IsDBNull(tpOrd)) strategy.TakeProfitPrice = ReadDecimal(reader, tpOrd); var mpOrd = reader.GetOrdinal("max_position_percent"); - if (!reader.IsDBNull(mpOrd)) strategy.MaxPositionPercent = (decimal)reader.GetDouble(mpOrd); + if (!reader.IsDBNull(mpOrd)) strategy.MaxPositionPercent = ReadDecimal(reader, mpOrd); + + var slipOrd = reader.GetOrdinal("slippage_tolerance"); + if (!reader.IsDBNull(slipOrd)) + { + // 兼容 TEXT(新格式)与 REAL(历史数据)两种存储形态;显式写入的 0 视为有效配置 + strategy.SlippageTolerance = ReadDecimal(reader, slipOrd); + } var cpOrd = reader.GetOrdinal("custom_params"); if (!reader.IsDBNull(cpOrd)) strategy.CustomParams = reader.GetString(cpOrd); @@ -1231,7 +1252,7 @@ private static TradingStrategy ReadStrategy(SqliteDataReader reader) 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); + if (!reader.IsDBNull(trailingOrd)) strategy.TrailingPeakPrice = ReadDecimal(reader, trailingOrd); return strategy; } @@ -1245,17 +1266,17 @@ private static TradeRecord ReadTradeRecord(SqliteDataReader reader) 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")), + RequestedQty = ReadDecimal(reader, reader.GetOrdinal("requested_qty")), + ExecutedQty = ReadDecimal(reader, reader.GetOrdinal("executed_qty")), + ExecutedPrice = ReadDecimal(reader, reader.GetOrdinal("executed_price")), + Commission = ReadDecimal(reader, reader.GetOrdinal("commission")), Status = (TradeRecordStatus)reader.GetInt32(reader.GetOrdinal("status")), ExchangeOrderId = reader.GetInt64(reader.GetOrdinal("binance_order_id")), CreatedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("created_at")), CultureInfo.InvariantCulture) }; var rpOrd = reader.GetOrdinal("requested_price"); - if (!reader.IsDBNull(rpOrd)) record.RequestedPrice = (decimal)reader.GetDouble(rpOrd); + if (!reader.IsDBNull(rpOrd)) record.RequestedPrice = ReadDecimal(reader, rpOrd); var caOrd = reader.GetOrdinal("commission_asset"); if (!reader.IsDBNull(caOrd)) record.CommissionAsset = reader.GetString(caOrd); @@ -1327,6 +1348,10 @@ private static async Task EnsureEnvironmentSchemaAsync( await EnsureColumnAsync(conn, transaction, "strategies", "environment", $"TEXT NOT NULL DEFAULT '{LiveSpotEnvironment}'").ConfigureAwait(false); await EnsureColumnAsync(conn, transaction, "trade_records", "environment", $"TEXT NOT NULL DEFAULT '{LiveSpotEnvironment}'").ConfigureAwait(false); await EnsureColumnAsync(conn, transaction, "positions", "environment", $"TEXT NOT NULL DEFAULT '{LiveSpotEnvironment}'").ConfigureAwait(false); + // 滑点容忍度以 TEXT(十进制字符串)存储,与金额列存储策略一致; + // 历史库补充列时给出默认值,保证旧行读回为 Market / 0.003 的既有行为 + await EnsureColumnAsync(conn, transaction, "strategies", "order_type", "INTEGER NOT NULL DEFAULT 0").ConfigureAwait(false); + await EnsureColumnAsync(conn, transaction, "strategies", "slippage_tolerance", "TEXT DEFAULT '0.003'").ConfigureAwait(false); await MigrateDailyStatsAsync(conn, transaction).ConfigureAwait(false); await MigrateAccountSnapshotsAsync(conn, transaction).ConfigureAwait(false); await MigrateRiskConfigAsync(conn, transaction).ConfigureAwait(false); diff --git a/src/MarketAssistant.App/Services/Dialog/DialogService.cs b/src/MarketAssistant.App/Services/Dialog/DialogService.cs index 5b8e53a..3e19cae 100644 --- a/src/MarketAssistant.App/Services/Dialog/DialogService.cs +++ b/src/MarketAssistant.App/Services/Dialog/DialogService.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; +using MarketAssistant.Trading.Models; using MarketAssistant.Views.Windows; namespace MarketAssistant.Services.Dialog; @@ -53,6 +54,25 @@ public async Task ShowConfirmationAsync(string title, string message, stri return dialog.Result; } + /// + /// 显示策略执行历史对话框窗口 + /// + public async Task ShowStrategyExecutionAsync(TradingStrategy strategy, IReadOnlyList records) + { + if (!Dispatcher.UIThread.CheckAccess()) + { + await Dispatcher.UIThread.InvokeAsync(() => ShowStrategyExecutionAsync(strategy, records)); + return; + } + + var owner = GetActiveWindow(); + if (owner == null) return; + + var window = new StrategyExecutionWindow(); + window.SetContent(strategy, records); + await window.ShowDialog(owner); + } + /// /// 显示输入对话框 /// diff --git a/src/MarketAssistant.App/ViewModels/ChatMessageAdapter.cs b/src/MarketAssistant.App/ViewModels/ChatMessageAdapter.cs index e2ce77e..1c236a2 100644 --- a/src/MarketAssistant.App/ViewModels/ChatMessageAdapter.cs +++ b/src/MarketAssistant.App/ViewModels/ChatMessageAdapter.cs @@ -121,6 +121,7 @@ private void TryParseAdaptiveCard(string content) if (card != null) { AdaptiveCard = card; + OnPropertyChanged(nameof(AdaptiveCard)); OnPropertyChanged(nameof(IsAdaptiveCard)); } } diff --git a/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs b/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs index d584551..101214e 100644 --- a/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs @@ -174,14 +174,17 @@ private async Task UpdateAssetDataProgressivelyAsync(List favorit var results = await Task.WhenAll(tasks); - // 在UI线程上批量添加结果 - foreach (var assetInfo in results) + // 集合修改须在 UI 线程执行(加载可能由后台事件触发进入),避免跨线程操作 ObservableCollection + await Dispatcher.UIThread.InvokeAsync(() => { - if (assetInfo != null) + foreach (var assetInfo in results) { - Assets.Add(assetInfo); + if (assetInfo != null) + { + Assets.Add(assetInfo); + } } - } + }); } /// @@ -281,14 +284,14 @@ private void EnsurePriceFlushTimer() Dispatcher.UIThread.Post(() => { - if (_priceFlushTimer != null) + if (_disposed || _priceFlushTimer != null) return; _priceFlushTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) }; - _priceFlushTimer.Tick += (_, _) => FlushPendingPriceUpdates(); + _priceFlushTimer.Tick += FlushPendingPriceUpdates; _priceFlushTimer.Start(); }); } @@ -296,7 +299,7 @@ private void EnsurePriceFlushTimer() /// /// 批量应用暂存的价格更新到展示对象(UI 线程,每 250ms 至多一次)。 /// - private void FlushPendingPriceUpdates() + private void FlushPendingPriceUpdates(object? sender, EventArgs e) { foreach (var symbol in _pendingPriceUpdates.Keys.ToList()) { @@ -324,8 +327,12 @@ public void Dispose() _disposed = true; _loadCts?.Cancel(); _loadCts?.Dispose(); - _priceFlushTimer?.Stop(); - _priceFlushTimer = null; + if (_priceFlushTimer != null) + { + _priceFlushTimer.Tick -= FlushPendingPriceUpdates; + _priceFlushTimer.Stop(); + _priceFlushTimer = null; + } UnsubscribeFromMarketChanges(_marketContext); _wsService.PriceUpdated -= OnWebSocketPriceUpdated; _ = _wsService.UnsubscribeAllAsync(WebSocketSubscriberKeys.Favorites); diff --git a/src/MarketAssistant.App/ViewModels/MCPConfigPageViewModel.cs b/src/MarketAssistant.App/ViewModels/MCPConfigPageViewModel.cs index 7064d0d..eb5c509 100644 --- a/src/MarketAssistant.App/ViewModels/MCPConfigPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/MCPConfigPageViewModel.cs @@ -332,7 +332,9 @@ private async Task TestConnection() IsEnabled = true, EnvironmentVariables = ParseEnvironmentVariables(), Category = _editingConfig.Category, - AllowedTools = [.. _editingConfig.AllowedTools] + AllowedTools = [.. _editingConfig.AllowedTools], + AllowAllTools = AllowAllTools, + ToolsSchemaVersion = MCPServerConfig.CurrentToolsSchemaVersion }; // 设置超时 @@ -373,8 +375,10 @@ private async Task TestConnection() { IsTesting = false; - // 3秒后清除状态信息 - _ = Task.Delay(3000).ContinueWith(_ => TestStatus = string.Empty); + // 3秒后清除状态信息(TestStatus 为 UI 绑定属性,回调须在 UI 线程执行) + _ = Task.Delay(3000).ContinueWith( + _ => TestStatus = string.Empty, + TaskScheduler.FromCurrentSynchronizationContext()); } } @@ -444,7 +448,10 @@ private async Task LoadToolsAsync() finally { IsTesting = false; - _ = Task.Delay(3000).ContinueWith(_ => TestStatus = string.Empty); + // TestStatus 为 UI 绑定属性,回调须在 UI 线程执行 + _ = Task.Delay(3000).ContinueWith( + _ => TestStatus = string.Empty, + TaskScheduler.FromCurrentSynchronizationContext()); } } diff --git a/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs b/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs index e59aaaf..373ab19 100644 --- a/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs @@ -3,7 +3,6 @@ using System.Text.Json; using Avalonia; using Avalonia.Controls; -using Avalonia.Controls.ApplicationLifetimes; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using MarketAssistant.DataProviders; @@ -13,7 +12,6 @@ using MarketAssistant.Services.Notification; using MarketAssistant.Services.Trading; using MarketAssistant.Trading.Models; -using MarketAssistant.Views.Windows; using Microsoft.Extensions.Logging; namespace MarketAssistant.ViewModels.Trading; @@ -485,18 +483,8 @@ await SafeExecuteAsync(async () => strategy.LastTriggeredAt = latest.LastTriggeredAt; } - await Dispatcher.UIThread.InvokeAsync(async () => - { - if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) - return; - - var owner = desktop.Windows.FirstOrDefault(w => w.IsActive) ?? desktop.MainWindow; - if (owner == null) return; - - var window = new StrategyExecutionWindow(); - window.SetContent(strategy, records); - await window.ShowDialog(owner); - }); + // 通过对话框服务展示,避免 ViewModel 直接 new 窗口(便于测试与解耦) + await _dialogService.ShowStrategyExecutionAsync(strategy, records); }, "查看策略执行历史"); } diff --git a/src/MarketAssistant.App/ViewModels/Trading/TradeMonitorViewModel.cs b/src/MarketAssistant.App/ViewModels/Trading/TradeMonitorViewModel.cs index 3a04074..54a2a7e 100644 --- a/src/MarketAssistant.App/ViewModels/Trading/TradeMonitorViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Trading/TradeMonitorViewModel.cs @@ -177,7 +177,8 @@ await SafeExecuteAsync(async () => private void OnMonitorStatusChanged(bool isRunning) { - IsMonitorRunning = isRunning; + // StatusChanged 由后台线程触发,需切回 UI 线程更新绑定属性 + Avalonia.Threading.Dispatcher.UIThread.Post(() => IsMonitorRunning = isRunning); } public void Dispose() diff --git a/src/MarketAssistant.App/ViewModels/Trading/TradingPageViewModel.cs b/src/MarketAssistant.App/ViewModels/Trading/TradingPageViewModel.cs index 8ef880c..cd61556 100644 --- a/src/MarketAssistant.App/ViewModels/Trading/TradingPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Trading/TradingPageViewModel.cs @@ -83,6 +83,8 @@ private void UpdateTradingModeState(CryptoTradingMode mode) public void Dispose() { _tradingEnvironmentService.ModeChanged -= OnTradingModeChanged; + // StrategyConfig 订阅了单例 MarketMonitor.StatusChanged,需一并释放避免泄漏 + StrategyConfig.Dispose(); TradeMonitor.Dispose(); GC.SuppressFinalize(this); } diff --git a/src/MarketAssistant.App/Views/Components/KLineChartView.cs b/src/MarketAssistant.App/Views/Components/KLineChartView.cs index 7426b13..c881a42 100644 --- a/src/MarketAssistant.App/Views/Components/KLineChartView.cs +++ b/src/MarketAssistant.App/Views/Components/KLineChartView.cs @@ -18,6 +18,8 @@ public class KLineChartView : UserControl private const int CheckIntervalMs = 100; // 100毫秒 private bool _isInitialized = false; + private bool _navigationHandlerSubscribed = false; + private readonly SemaphoreSlim _updateSemaphore = new(1, 1); private NativeWebView? _webView; private StackPanel? _loadingPanel; private StackPanel? _errorPanel; @@ -79,7 +81,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang if (change.Property == DataProperty) { - // 当数据源发生变化时,自动更新图表 + // 当数据源发生变化时,自动更新图表(UpdateChartAsync 内部串行化,跳过并发重入) if (change.NewValue is IEnumerable data) { _ = UpdateChartAsync(data); @@ -200,8 +202,12 @@ private async Task InitializeChartAsync() return; } - // 监听 WebView 加载完成事件(必须在 NavigateToString 之前注册) - _webView.NavigationCompleted += OnWebViewNavigated; + // 监听 WebView 加载完成事件(必须在 NavigateToString 之前注册;仅订阅一次,避免重试后重复触发) + if (!_navigationHandlerSubscribed) + { + _webView.NavigationCompleted += OnWebViewNavigated; + _navigationHandlerSubscribed = true; + } // 使用 NativeWebView 的 NavigateToString 方法加载 HTML 内容 _webView.NavigateToString(htmlContent); @@ -392,6 +398,10 @@ public async Task UpdateChartAsync(IEnumerable kLineData) if (kLineData == null || !kLineData.Any() || _webView == null) return; + // 数据源变更可能高频触发,信号量不可立即进入说明上一次更新尚未完成,直接跳过本次 + if (!_updateSemaphore.Wait(0)) + return; + try { // 首次调用时才初始化图表(延迟初始化) @@ -434,6 +444,10 @@ await Dispatcher.UIThread.InvokeAsync(async () => { ShowError($"更新失败: {ex.Message}"); } + finally + { + _updateSemaphore.Release(); + } } /// diff --git a/src/MarketAssistant.Core/Infrastructure/Core/CryptoSymbolConverter.cs b/src/MarketAssistant.Core/Infrastructure/Core/CryptoSymbolConverter.cs index e8e3654..f7a1b06 100644 --- a/src/MarketAssistant.Core/Infrastructure/Core/CryptoSymbolConverter.cs +++ b/src/MarketAssistant.Core/Infrastructure/Core/CryptoSymbolConverter.cs @@ -5,6 +5,15 @@ namespace MarketAssistant.Infrastructure.Core; /// public static class CryptoSymbolConverter { + /// + /// 包装币白名单:其代码本身以 BTC/ETH/BNB 等计价货币字母结尾, + /// 但并非交易对,须短路处理,避免被误判为“已含计价货币”。 + /// + private static readonly HashSet WrappedBaseCurrencies = new(StringComparer.OrdinalIgnoreCase) + { + "WBTC", "WETH", "STETH", "WBNB" + }; + /// /// 将币种代码转换为币安交易对格式(如 BTC → BTCUSDT) /// @@ -26,6 +35,12 @@ public static string ToBinanceFormat(string symbol, string quoteCurrency = "USDT quoteCurrency = quoteCurrency.ToUpperInvariant(); + // 包装币(WBTC/WETH 等)本身以计价货币字母结尾,须先于后缀判断短路,避免漏加计价货币 + if (WrappedBaseCurrencies.Contains(symbol)) + { + return $"{symbol}{quoteCurrency}"; + } + // 如果已经包含计价货币后缀,直接返回 var quoteCurrencies = new[] { "USDT", "BUSD", "USDC", "BTC", "ETH", "BNB", "DAI" }; foreach (var quote in quoteCurrencies) @@ -54,6 +69,12 @@ public static string ExtractBaseCurrency(string tradingPair) tradingPair = tradingPair.ToUpperInvariant(); + // 包装币(WBTC/WETH 等)本身以计价货币字母结尾,须先于后缀剥离短路,避免被截断成错误基础币 + if (WrappedBaseCurrencies.Contains(tradingPair)) + { + return tradingPair; + } + // 移除常见计价货币后缀(按长度倒序,避免误匹配) var quoteCurrencies = new[] { "USDT", "BUSD", "USDC", "DAI", "BTC", "ETH", "BNB" }; foreach (var quote in quoteCurrencies) diff --git a/src/MarketAssistant.Core/Infrastructure/Core/StockSymbolConverter.cs b/src/MarketAssistant.Core/Infrastructure/Core/StockSymbolConverter.cs index 8d977ef..d3bbccb 100644 --- a/src/MarketAssistant.Core/Infrastructure/Core/StockSymbolConverter.cs +++ b/src/MarketAssistant.Core/Infrastructure/Core/StockSymbolConverter.cs @@ -26,7 +26,7 @@ public static string ToClsFormat(string stockCode) string digits = ExtractDigits(stockCode); if (string.IsNullOrEmpty(digits)) return stockCode; // 无法提取数字则返回原值 - string prefix = GetExchangePrefix(digits); + string prefix = ResolveExchange(digits); return $"{prefix}{digits}".ToLowerInvariant(); } @@ -77,7 +77,7 @@ public static string ToZhiTuFormat(string stockCode) if (cleanCode.All(char.IsDigit)) { string digits = cleanCode; - string suffix = GetExchangeSuffix(digits); + string suffix = ResolveExchange(digits); return $"{digits}.{suffix}"; } @@ -94,48 +94,24 @@ private static string ExtractDigits(string stockCode) } /// - /// 根据股票代码数字获取交易所前缀(SH/SZ) - /// 上海证券交易所(SH):60、688、900 开头 - /// 深圳证券交易所(SZ):00、002、003、300、301、399 开头 + /// 根据代码数字统一解析所属交易所(单一判定入口,供各类格式转换复用)。 + /// 上海证券交易所(SH):6 开头(主板/科创板)、5 开头(基金 ETF/LOF)、11 开头(可转债)、9 开头(B股) + /// 深圳证券交易所(SZ):0/3 开头(主板/创业板)、12 开头(可转债)、15/16 开头(基金 ETF) + /// 北京证券交易所(BJ):8 开头(北交所/新三板)、43/92 开头 + /// 默认:未知代码默认为上海证券交易所(保持与原有代码行为一致) /// - private static string GetExchangePrefix(string digits) + private static string ResolveExchange(string digits) { - // 上海证券交易所:60开头(主板)、688开头(科创板)、900开头(B股) - if (digits.StartsWith("60") || - digits.StartsWith("688") || - digits.StartsWith("900")) - return "SH"; - - // 深圳证券交易所:其他所有情况 - return "SZ"; - } + // 北交所:8 开头(83/87/88 等)、43/92 开头 + if (digits.StartsWith("8") || digits.StartsWith("43") || digits.StartsWith("92")) + return "BJ"; - /// - /// 根据股票代码数字获取交易所后缀(SH/SZ) - /// 上海证券交易所(SH):600、601、603、605、688、900 开头 - /// 深圳证券交易所(SZ):000、001、002、003、300、301、399 开头 - /// 默认:未知代码默认为上海证券交易所 - /// - private static string GetExchangeSuffix(string digits) - { - // 深圳证券交易所:000/001(主板)、002(中小板)、003(主板)、300/301(创业板)、399(指数) - if (digits.StartsWith("000") || digits.StartsWith("001") || - digits.StartsWith("002") || digits.StartsWith("003") || - digits.StartsWith("300") || digits.StartsWith("301") || - digits.StartsWith("399")) - { + // 深圳证券交易所:0/3 开头(主板/创业板)、12 开头(可转债)、15/16 开头(ETF/LOF) + if (digits.StartsWith("0") || digits.StartsWith("3") || + digits.StartsWith("12") || digits.StartsWith("15") || digits.StartsWith("16")) return "SZ"; - } - - // 上海证券交易所:600/601/603/605(主板)、688(科创板)、900(B股) - if (digits.StartsWith("600") || digits.StartsWith("601") || - digits.StartsWith("603") || digits.StartsWith("605") || - digits.StartsWith("688") || digits.StartsWith("900")) - { - return "SH"; - } - // 默认返回上海交易所(保持与原有代码行为一致) + // 其余默认上海证券交易所:6 开头(主板/科创板)、5 开头(ETF/LOF)、11 开头(可转债)、9 开头(B股) return "SH"; } } diff --git a/src/MarketAssistant.DataProviders/CoinGeckoApiService.cs b/src/MarketAssistant.DataProviders/CoinGeckoApiService.cs index e45c317..b57f507 100644 --- a/src/MarketAssistant.DataProviders/CoinGeckoApiService.cs +++ b/src/MarketAssistant.DataProviders/CoinGeckoApiService.cs @@ -1,4 +1,5 @@ using System.Net.Http.Json; +using System.Threading.RateLimiting; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -15,9 +16,16 @@ 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; + // 统一限流:令牌桶容量 1、每 2.5 秒补充 1 个令牌,等价于串行 + 最小 2.5s 请求间隔 + private static readonly TokenBucketRateLimiter Throttle = new(new TokenBucketRateLimiterOptions + { + TokenLimit = 1, + TokensPerPeriod = 1, + ReplenishmentPeriod = TimeSpan.FromMilliseconds(2500), + QueueLimit = int.MaxValue, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + AutoReplenishment = true + }); // 支持 API 返回的字符串数值/null 自动容错转换为 decimal?(CoinGeckoMarket 全为 decimal?) private static readonly JsonSerializerOptions CoinGeckoJsonOptions = new() @@ -39,22 +47,13 @@ public CoinGeckoApiService( /// 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(); - } + // AcquireAsync 在令牌不足时按 QueueLimit 排队等待,天然实现串行 + 固定间隔 + using RateLimitLease lease = await Throttle.AcquireAsync(1, cancellationToken); + if (!lease.IsAcquired) + throw new InvalidOperationException("CoinGecko 限流租约获取失败"); + + using var httpClient = _httpClientFactory.CreateClient("CoinGecko"); + return await action(httpClient); } /// diff --git a/src/MarketAssistant.DataProviders/MarketAssistant.DataProviders.csproj b/src/MarketAssistant.DataProviders/MarketAssistant.DataProviders.csproj index b29c5f3..5357de3 100644 --- a/src/MarketAssistant.DataProviders/MarketAssistant.DataProviders.csproj +++ b/src/MarketAssistant.DataProviders/MarketAssistant.DataProviders.csproj @@ -14,5 +14,6 @@ + diff --git a/src/MarketAssistant.Infrastructure/Providers/StructuredOutputValidator.cs b/src/MarketAssistant.Infrastructure/Providers/StructuredOutputValidator.cs index b4531e3..d547243 100644 --- a/src/MarketAssistant.Infrastructure/Providers/StructuredOutputValidator.cs +++ b/src/MarketAssistant.Infrastructure/Providers/StructuredOutputValidator.cs @@ -9,8 +9,6 @@ namespace MarketAssistant.Infrastructure.Providers; /// public static class StructuredOutputValidator { - private static readonly NullabilityInfoContext NullabilityContext = new(); - public static IReadOnlyList Validate(object? value) { if (value is null) @@ -20,7 +18,10 @@ public static IReadOnlyList Validate(object? value) var errors = new List(); var visited = new HashSet(ReferenceEqualityComparer.Instance); - ValidateNode(value, "$", errors, visited); + // NullabilityInfoContext 非线程安全(内部有可变缓存),且本类可能被并发调用, + // 故每次 Validate 新建实例而非共享 static readonly + var nullabilityContext = new NullabilityInfoContext(); + ValidateNode(value, "$", errors, visited, nullabilityContext); return errors; } @@ -28,7 +29,8 @@ private static void ValidateNode( object value, string path, List errors, - HashSet visited) + HashSet visited, + NullabilityInfoContext nullabilityContext) { var type = value.GetType(); if (IsTerminalType(type)) @@ -57,7 +59,7 @@ private static void ValidateNode( } else { - ValidateNode(item, $"{path}[{index}]", errors, visited); + ValidateNode(item, $"{path}[{index}]", errors, visited, nullabilityContext); } index++; @@ -91,7 +93,7 @@ private static void ValidateNode( var propertyValue = property.GetValue(value); if (propertyValue is null) { - if (NullabilityContext.Create(property).ReadState == NullabilityState.NotNull) + if (nullabilityContext.Create(property).ReadState == NullabilityState.NotNull) { errors.Add($"{path}.{property.Name}: 值不能为空"); } @@ -99,7 +101,7 @@ private static void ValidateNode( continue; } - ValidateNode(propertyValue, $"{path}.{property.Name}", errors, visited); + ValidateNode(propertyValue, $"{path}.{property.Name}", errors, visited, nullabilityContext); } } diff --git a/src/MarketAssistant.Rag/Services/ClipImageEmbeddingService.cs b/src/MarketAssistant.Rag/Services/ClipImageEmbeddingService.cs index 03e0653..5be3c74 100644 --- a/src/MarketAssistant.Rag/Services/ClipImageEmbeddingService.cs +++ b/src/MarketAssistant.Rag/Services/ClipImageEmbeddingService.cs @@ -47,7 +47,8 @@ public class ClipImageEmbeddingService : IImageEmbeddingService, IDisposable // 【状态管理】:ONNX推理会话和初始化标志 private InferenceSession? _session; // ONNX运行时推理会话 - private bool _initAttempted; // 防止重复初始化的标志 + private volatile bool _initAttempted; // 防止重复初始化的标志 + private readonly object _initLock = new(); // 初始化锁:保证并发下仅创建一个 InferenceSession /// /// 构造函数:使用依赖注入获取服务,支持环境变量配置模型路径 @@ -60,7 +61,10 @@ public class ClipImageEmbeddingService : IImageEmbeddingService, IDisposable public ClipImageEmbeddingService(ILogger logger, IServiceProvider sp) { _logger = logger; - // 尝试获取聊天服务(多模态Caption功能),可选 + // 尝试获取聊天服务(多模态Caption功能),可选。 + // 注意:此处为服务定位器模式,若 IChatCompletionService 为 Scoped 且本服务为 Singleton, + // 会形成 captive dependency(Scoped 实例被 Singleton 捕获)。当前 IChatCompletionService + // 注册为 Singleton,风险可控;如改为 Scoped 生命周期需重构为工厂委托注入。 _chat = sp.GetService(); // 模型路径配置:优先环境变量,否则使用本地默认路径 @@ -285,34 +289,44 @@ public async Task CaptionAsync(byte[] imageBytes, CancellationToken ct = /// private void EnsureSession() { - // 检查点:防止重复初始化尝试 + // 快速路径:已初始化过(无论成败)则直接返回 if (_initAttempted) return; - _initAttempted = true; - try + // 双检查锁:并发首次调用时仅允许一个线程创建 InferenceSession, + // 其余线程等待后复用同一会话(或复用"初始化已失败"的结果),不会重复加载模型 + lock (_initLock) { - // 资源检查:验证模型文件路径 - if (!string.IsNullOrWhiteSpace(_modelPath) && File.Exists(_modelPath)) + if (_initAttempted) return; + try { - // 创建ONNX运行时推理会话 - _session = new InferenceSession(_modelPath); + // 资源检查:验证模型文件路径 + if (!string.IsNullOrWhiteSpace(_modelPath) && File.Exists(_modelPath)) + { + // 创建ONNX运行时推理会话 + _session = new InferenceSession(_modelPath); - // 记录模型信息(输入输出节点)便于调试 - LogModelInfo(); + // 记录模型信息(输入输出节点)便于调试 + LogModelInfo(); - _logger.LogInformation("Loaded CLIP image ONNX model: {Path}", _modelPath); + _logger.LogInformation("Loaded CLIP image ONNX model: {Path}", _modelPath); + } + else + { + // 警告:配置的模型不存在时发出提示(不降级为哈希向量,图像嵌入将不可用) + _logger.LogWarning("CLIP model not found at {Path}, image embedding will be unavailable", _modelPath); + } } - else + catch (Exception ex) { - // 警告:配置的模型不存在时发出提示(不降级为哈希向量,图像嵌入将不可用) - _logger.LogWarning("CLIP model not found at {Path}, image embedding will be unavailable", _modelPath); + // 初始化失败:记录错误但不抛出,GenerateAsync 会因会话不可用而抛出, + // 由调用方降级为 Caption 文本召回 + _logger.LogWarning(ex, "Failed to init CLIP model session; image embedding will be unavailable"); + } + finally + { + // 无论成败只尝试一次,避免失败后反复加载模型 + _initAttempted = true; } - } - catch (Exception ex) - { - // 初始化失败:记录错误但不抛出,GenerateAsync 会因会话不可用而抛出, - // 由调用方降级为 Caption 文本召回 - _logger.LogWarning(ex, "Failed to init CLIP model session; image embedding will be unavailable"); } } diff --git a/src/MarketAssistant.Rag/Services/DocxMarkdownConverter.cs b/src/MarketAssistant.Rag/Services/DocxMarkdownConverter.cs index a36270b..bcbdb67 100644 --- a/src/MarketAssistant.Rag/Services/DocxMarkdownConverter.cs +++ b/src/MarketAssistant.Rag/Services/DocxMarkdownConverter.cs @@ -22,17 +22,19 @@ public class ListInfo /// public class DocxMarkdownConverter : IMarkdownConverter { - private readonly Dictionary _numberingFormats = new(); - private readonly Dictionary _imageReferences = new(); - private readonly IImageStorageService _imageStorageService; - private readonly Dictionary _listItemCounters = new(); - private int _imageCounter = 0; - /// - /// 转换过程串行化锁:实例字段在并发调用时非线程安全, - /// 同一 Singleton 实例的并发转换必须串行执行。 + /// 单次转换的上下文状态:编号定义、图片引用、列表计数器等仅在本次转换内有效, + /// 通过方法参数传递,保证转换器本身无共享可变状态(可安全注册为 Singleton 并发使用)。 /// - private readonly SemaphoreSlim _convertLock = new(1, 1); + private sealed class ConversionContext + { + public readonly Dictionary NumberingFormats = new(); + public readonly Dictionary ImageReferences = new(StringComparer.Ordinal); + public readonly Dictionary ListItemCounters = new(); + public int ImageCounter; + } + + private readonly IImageStorageService _imageStorageService; public DocxMarkdownConverter(IImageStorageService imageStorageService) { @@ -44,16 +46,7 @@ public bool CanConvert(string filePath) => public async Task ConvertToMarkdownAsync(string filePath) { - // 串行化转换:实例字段(_numberingFormats 等)在并发调用时非线程安全 - await _convertLock.WaitAsync(); - try - { - return await ConvertCoreAsync(filePath); - } - finally - { - _convertLock.Release(); - } + return await ConvertCoreAsync(filePath); } private async Task ConvertCoreAsync(string filePath) @@ -66,15 +59,12 @@ private async Task ConvertCoreAsync(string filePath) if (main?.Document?.Body == null) return string.Empty; - // 清理之前的状态 - _numberingFormats.Clear(); - _imageReferences.Clear(); - _listItemCounters.Clear(); - _imageCounter = 0; + // 每次转换独立的上下文状态,转换器实例本身无共享可变状态 + var state = new ConversionContext(); // 预处理编号定义和图片 - await ProcessNumberingDefinitionsAsync(doc); - await ProcessImageReferencesAsync(doc, filePath); + await ProcessNumberingDefinitionsAsync(doc, state); + await ProcessImageReferencesAsync(doc, filePath, state); var markdown = new StringBuilder(); var previousWasList = false; @@ -87,11 +77,11 @@ private async Task ConvertCoreAsync(string filePath) switch (element) { case Paragraph paragraph: - isCurrentList = ProcessParagraph(paragraph, markdown); + isCurrentList = ProcessParagraph(paragraph, markdown, state); break; case Table table: - ProcessTable(table, markdown); + ProcessTable(table, markdown, state); break; default: @@ -127,8 +117,9 @@ private async Task ConvertCoreAsync(string filePath) /// /// 段落 /// markdown构建器 + /// 本次转换的上下文状态 /// 是否为列表项 - private bool ProcessParagraph(Paragraph paragraph, StringBuilder markdown) + private bool ProcessParagraph(Paragraph paragraph, StringBuilder markdown, ConversionContext state) { // 检查是否为列表项 var numberingId = GetNumberingId(paragraph); @@ -136,12 +127,12 @@ private bool ProcessParagraph(Paragraph paragraph, StringBuilder markdown) if (numberingId.HasValue) { - ProcessListItem(paragraph, markdown, numberingId.Value, numberingLevel); + ProcessListItem(paragraph, markdown, numberingId.Value, numberingLevel, state); return true; } // 处理段落格式化文本(包括图片) - var formattedText = ProcessTextFormatting(paragraph); + var formattedText = ProcessTextFormatting(paragraph, state); if (string.IsNullOrWhiteSpace(formattedText)) { @@ -167,7 +158,7 @@ private bool ProcessParagraph(Paragraph paragraph, StringBuilder markdown) /// /// 处理表格元素 /// - private void ProcessTable(Table table, StringBuilder markdown) + private void ProcessTable(Table table, StringBuilder markdown, ConversionContext state) { var rows = new List>(); @@ -176,7 +167,7 @@ private void ProcessTable(Table table, StringBuilder markdown) var row = new List(); foreach (var tableCell in tableRow.Elements()) { - var cellText = ExtractTableCellText(tableCell); + var cellText = ExtractTableCellText(tableCell, state); row.Add(cellText); } @@ -236,7 +227,7 @@ private int GetHeadingLevel(Paragraph paragraph) /// /// 处理文本格式化 /// - private string ProcessTextFormatting(Paragraph paragraph) + private string ProcessTextFormatting(Paragraph paragraph, ConversionContext state) { var result = new StringBuilder(); @@ -246,7 +237,7 @@ private string ProcessTextFormatting(Paragraph paragraph) var drawing = run.Elements().FirstOrDefault(); if (drawing != null) { - var imageMarkdown = ProcessImage(drawing); + var imageMarkdown = ProcessImage(drawing, state); if (!string.IsNullOrEmpty(imageMarkdown)) { result.Append(imageMarkdown); @@ -287,13 +278,13 @@ private string ProcessTextFormatting(Paragraph paragraph) /// /// 提取表格单元格文本 /// - private string ExtractTableCellText(TableCell cell) + private string ExtractTableCellText(TableCell cell, ConversionContext state) { var cellContent = new StringBuilder(); foreach (var paragraph in cell.Elements()) { - var formattedText = ProcessTextFormatting(paragraph); + var formattedText = ProcessTextFormatting(paragraph, state); if (!string.IsNullOrWhiteSpace(formattedText)) { if (cellContent.Length > 0) @@ -348,9 +339,9 @@ private string EscapeMarkdownTableCell(string text) /// /// 预处理编号定义 /// - private async Task ProcessNumberingDefinitionsAsync(WordprocessingDocument doc) + private async Task ProcessNumberingDefinitionsAsync(WordprocessingDocument doc, ConversionContext state) { - _numberingFormats.Clear(); + state.NumberingFormats.Clear(); await Task.Run(() => { @@ -381,7 +372,7 @@ await Task.Run(() => Prefix = numFmt?.Value == NumberFormatValues.Bullet ? "- " : "1. " }; - _numberingFormats[numId.Value] = listInfo; + state.NumberingFormats[numId.Value] = listInfo; } } } @@ -409,12 +400,12 @@ private int GetNumberingLevel(Paragraph paragraph) /// /// 处理列表项 /// - private void ProcessListItem(Paragraph paragraph, StringBuilder markdown, int numberingId, int level) + private void ProcessListItem(Paragraph paragraph, StringBuilder markdown, int numberingId, int level, ConversionContext state) { - var text = ProcessTextFormatting(paragraph); + var text = ProcessTextFormatting(paragraph, state); if (string.IsNullOrWhiteSpace(text)) return; - var listInfo = _numberingFormats.GetValueOrDefault(numberingId, new ListInfo { Prefix = "- ", IsOrdered = false }); + var listInfo = state.NumberingFormats.GetValueOrDefault(numberingId, new ListInfo { Prefix = "- ", IsOrdered = false }); // 添加缩进 var indent = new string(' ', level * 2); @@ -423,11 +414,11 @@ private void ProcessListItem(Paragraph paragraph, StringBuilder markdown, int nu { // 为有序列表维护计数器 var counterKey = numberingId * 100 + level; // 组合键考虑级别 - if (!_listItemCounters.ContainsKey(counterKey)) - _listItemCounters[counterKey] = 1; + if (!state.ListItemCounters.ContainsKey(counterKey)) + state.ListItemCounters[counterKey] = 1; - markdown.AppendLine($"{indent}{_listItemCounters[counterKey]}. {text}"); - _listItemCounters[counterKey]++; + markdown.AppendLine($"{indent}{state.ListItemCounters[counterKey]}. {text}"); + state.ListItemCounters[counterKey]++; } else { @@ -438,9 +429,9 @@ private void ProcessListItem(Paragraph paragraph, StringBuilder markdown, int nu /// /// 预处理图片引用 /// - private async Task ProcessImageReferencesAsync(WordprocessingDocument doc, string documentPath) + private async Task ProcessImageReferencesAsync(WordprocessingDocument doc, string documentPath, ConversionContext state) { - _imageReferences.Clear(); + state.ImageReferences.Clear(); await Task.Run(async () => { @@ -452,10 +443,10 @@ await Task.Run(async () => try { var relationshipId = doc.MainDocumentPart.GetIdOfPart(imagePart); - _imageCounter++; + state.ImageCounter++; // 使用 IImageStorageService 保存图片 - var imageFileName = $"doc_image{_imageCounter}.{GetImageExtension(imagePart.ContentType)}"; + var imageFileName = $"doc_image{state.ImageCounter}.{GetImageExtension(imagePart.ContentType)}"; using var stream = imagePart.GetStream(); using var memoryStream = new MemoryStream(); @@ -466,7 +457,7 @@ await Task.Run(async () => var imagePath = await _imageStorageService.SaveImageAsync(imageBytes, imageFileName, documentPath); // 存储完整的绝对路径 - _imageReferences[relationshipId] = imagePath; + state.ImageReferences[relationshipId] = imagePath; } catch (Exception ex) { @@ -480,7 +471,7 @@ await Task.Run(async () => /// /// 处理图片元素 /// - private string ProcessImage(Drawing drawing) + private string ProcessImage(Drawing drawing, ConversionContext state) { try { @@ -488,7 +479,7 @@ private string ProcessImage(Drawing drawing) if (blip?.Embed?.Value == null) return string.Empty; var relationshipId = blip.Embed.Value; - if (_imageReferences.TryGetValue(relationshipId, out var imagePath)) + if (state.ImageReferences.TryGetValue(relationshipId, out var imagePath)) { // 从文件路径中提取图片序号来生成有意义的alt文本 var fileName = IOPath.GetFileNameWithoutExtension(imagePath); diff --git a/src/MarketAssistant.Rag/Services/QueryRewriteService.cs b/src/MarketAssistant.Rag/Services/QueryRewriteService.cs index 3c08293..c53a3b1 100644 --- a/src/MarketAssistant.Rag/Services/QueryRewriteService.cs +++ b/src/MarketAssistant.Rag/Services/QueryRewriteService.cs @@ -1,5 +1,6 @@ using MarketAssistant.Rag.Interfaces; using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; using System.Text.RegularExpressions; namespace MarketAssistant.Rag.Services; @@ -52,6 +53,9 @@ public class QueryRewriteService : IQueryRewriteService // 预编译正则表达式 private static readonly Regex ChineseWordRegex = new(@"[\u4e00-\u9fa5]{2,}", RegexOptions.Compiled); + // 拉丁字母键(如 AI/GDP)的词边界正则缓存:避免 "AI" 命中 "wait"、"GDP" 命中其他字母串的子串误替换 + private static readonly ConcurrentDictionary LatinKeyRegexCache = new(StringComparer.OrdinalIgnoreCase); + public QueryRewriteService(ILogger logger) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -121,12 +125,32 @@ private static string NormalizeQuery(string query) /// /// 生成同义词变体 /// + /// + /// 拉丁字母键使用词边界正则替换(避免子串误命中,如 "AI" 命中 "wait"); + /// 中文键保持简单子串替换。 + /// private static IEnumerable GenerateSynonymVariants(string query) { foreach (var kvp in SynonymMap) { - if (query.Contains(kvp.Key, StringComparison.OrdinalIgnoreCase)) + if (char.IsAsciiLetter(kvp.Key[0])) + { + // 拉丁字母键:词边界正则匹配(大小写不敏感),未命中则跳过 + var wordRegex = LatinKeyRegexCache.GetOrAdd(kvp.Key, static key => + new Regex(@$"\b{Regex.Escape(key)}\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)); + if (!wordRegex.IsMatch(query)) + { + continue; + } + + foreach (var synonym in kvp.Value) + { + yield return wordRegex.Replace(query, synonym); + } + } + else if (query.Contains(kvp.Key, StringComparison.OrdinalIgnoreCase)) { + // 中文键:保持原有子串替换逻辑 foreach (var synonym in kvp.Value) { yield return query.Replace(kvp.Key, synonym, StringComparison.OrdinalIgnoreCase); diff --git a/src/MarketAssistant.Rag/Services/RagIngestionService.cs b/src/MarketAssistant.Rag/Services/RagIngestionService.cs index 024d515..8537d72 100644 --- a/src/MarketAssistant.Rag/Services/RagIngestionService.cs +++ b/src/MarketAssistant.Rag/Services/RagIngestionService.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Microsoft.Extensions.VectorData; +using System.Security.Cryptography; namespace MarketAssistant.Rag.Services; @@ -123,7 +124,7 @@ private async Task IngestWithBlocksAsync( // 处理图片块的去重和嵌入生成 if (block is ImageBlock imageBlock && imageBlock.ImageBytes.Length > 0) { - imageMetadata = await ProcessImageBlockAsync(imageBlock, seenImageHashes); + imageMetadata = await ProcessImageBlockAsync(imageBlock, seenImageHashes, cancellationToken); if (imageMetadata == null) continue; // 跳过重复或处理失败的图片 } @@ -213,15 +214,22 @@ private async Task IngestWithBlocksAsync( filePath, staleKeys.Count, newKeys.Count); } + // 文档内容哈希:以原始字节流计算 SHA-256,避免整文件读入内存 + string contentHash; + using (var fileStream = File.OpenRead(filePath)) + { + contentHash = Convert.ToHexString(SHA256.HashData(fileStream)); + } + await _documentCatalog.ReplaceAsync(new RagDocumentCatalogEntry( collectionName, documentId, filePath, - RagDocumentId.Compute(File.ReadAllText(filePath)), - newKeys, - embeddingModelId ?? "unknown", - embeddingDimension ?? RagConstants.EmbeddingDimension, - DateTimeOffset.UtcNow), cancellationToken); + RagDocumentId.Compute(contentHash), + newKeys, + embeddingModelId ?? "unknown", + embeddingDimension ?? RagConstants.EmbeddingDimension, + DateTimeOffset.UtcNow), cancellationToken); } catch (OperationCanceledException) { @@ -242,8 +250,9 @@ await _documentCatalog.ReplaceAsync(new RagDocumentCatalogEntry( /// /// 图片块 /// 当前文档已见的图片哈希集合 + /// 取消令牌。 /// 图片元数据,如果跳过则返回null - private async Task ProcessImageBlockAsync(ImageBlock imageBlock, HashSet seenImageHashes) + private async Task ProcessImageBlockAsync(ImageBlock imageBlock, HashSet seenImageHashes, CancellationToken cancellationToken) { var imageHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(imageBlock.ImageBytes)); @@ -256,13 +265,13 @@ await _documentCatalog.ReplaceAsync(new RagDocumentCatalogEntry( try { // 生成图片说明(Caption 成功即可被文本检索命中) - var caption = await _imageEmbeddingService.CaptionAsync(imageBlock.ImageBytes); + var caption = await _imageEmbeddingService.CaptionAsync(imageBlock.ImageBytes, cancellationToken); // 图像向量独立生成:CLIP 失败不降级为哈希向量,置 null 并依赖 Caption 召回 Embedding? imageEmbedding = null; try { - imageEmbedding = await _imageEmbeddingService.GenerateAsync(imageBlock.ImageBytes); + imageEmbedding = await _imageEmbeddingService.GenerateAsync(imageBlock.ImageBytes, cancellationToken); } catch (OperationCanceledException) { @@ -278,6 +287,11 @@ await _documentCatalog.ReplaceAsync(new RagDocumentCatalogEntry( return new ImageMetadata(caption, imagePath, imageEmbedding); } + catch (OperationCanceledException) + { + // 取消必须向上传播,不得当作图片处理失败吞掉 + throw; + } catch (Exception ex) { _logger.LogWarning(ex, "Failed to process image metadata for image hash {ImageHash}", imageHash); diff --git a/src/MarketAssistant.Rag/Services/RetrievalOrchestrator.cs b/src/MarketAssistant.Rag/Services/RetrievalOrchestrator.cs index 82749a4..9ca6957 100644 --- a/src/MarketAssistant.Rag/Services/RetrievalOrchestrator.cs +++ b/src/MarketAssistant.Rag/Services/RetrievalOrchestrator.cs @@ -93,6 +93,16 @@ public async Task> RetrieveAsync( for (int qi = 0; qi < distinctQueries.Count; qi++) { var q = distinctQueries[qi]; + + // 防御校验:嵌入返回条数可能少于查询条数,越界会抛出异常 + if (qi >= queryEmbeddings.Count) + { + _logger.LogWarning( + "Query embedding missing for index {Index}, query: {Query} (embeddings: {Count})", + qi, q, queryEmbeddings.Count); + continue; + } + try { var queryVector = queryEmbeddings[qi]; diff --git a/tests/Trading/TradingDataServiceMigrationTest.cs b/tests/Trading/TradingDataServiceMigrationTest.cs index c4a2bc2..b437a98 100644 --- a/tests/Trading/TradingDataServiceMigrationTest.cs +++ b/tests/Trading/TradingDataServiceMigrationTest.cs @@ -44,6 +44,10 @@ public async Task MigrateDatabaseSchemaAsync_FromLegacySchema_PreservesDataAndCr await AssertColumnExistsAsync(connection, "account_snapshots", "environment"); await AssertColumnExistsAsync(connection, "risk_config", "environment"); + // 新增的持久化列:下单类型与滑点容忍度(历史库通过 EnsureColumnAsync 补齐) + await AssertColumnExistsAsync(connection, "strategies", "order_type"); + await AssertColumnExistsAsync(connection, "strategies", "slippage_tolerance"); + await AssertRowValueAsync(connection, "SELECT environment FROM strategies WHERE id = 'strategy-1'", LiveSpotEnvironment); await AssertRowValueAsync(connection, "SELECT environment FROM trade_records WHERE id = 'record-1'", LiveSpotEnvironment); await AssertRowValueAsync(connection, "SELECT environment FROM positions WHERE id = 'position-1'", LiveSpotEnvironment); From f49204c5b7246a7da3e81aad50c2a1ec17f702ea Mon Sep 17 00:00:00 2001 From: MayueCif Date: Sat, 29 Aug 2026 00:06:44 +0800 Subject: [PATCH 7/9] =?UTF-8?q?[=E5=85=A8=E5=BA=93]=20P2=20=E6=B8=85?= =?UTF-8?q?=E7=90=86=EF=BC=9A=E5=88=A0=E9=99=A4=E6=AD=BB=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E4=B8=8E=E9=9B=B6=E8=B0=83=E7=94=A8=E6=8E=A5=E5=8F=A3=E3=80=81?= =?UTF-8?q?Directory.Build.props=20=E9=9B=86=E4=B8=AD=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E3=80=81ITokenCounter=20=E6=94=B6=E6=95=9B=20Tiktoken=E3=80=81?= =?UTF-8?q?SourceType=20=E7=BB=9F=E4=B8=80=E6=8E=A8=E6=96=AD=E3=80=81Analy?= =?UTF-8?q?sisReportCache=20=E6=8D=A2=20IMemoryCache=E3=80=81=E6=9D=82?= =?UTF-8?q?=E9=A1=B9=E5=81=A5=E5=A3=AE=E6=80=A7=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Build.props | 20 +-- .../MarketAssistant.Agents.csproj | 5 +- .../TokenManagement/TokenEstimator.cs | 63 +-------- .../MarketAnalysis/MarketAnalysisWorkflow.cs | 6 - .../MarketSnapshotContextProvider.cs | 11 -- .../Tools/Crypto/CryptoStrategyTools.cs | 2 +- .../Crypto/BinanceFuturesAccountService.cs | 126 ------------------ .../Crypto/BinanceUserDataStreamModels.cs | 41 ------ .../Crypto/BinanceUserDataStreamService.cs | 33 ----- .../MarketAssistant.App.Services.csproj | 4 - .../Trading/AnalysisReportCache.cs | 75 ++--------- .../Exchanges/BinanceExchangeClient.cs | 12 -- .../Exchanges/BinanceFuturesExchangeClient.cs | 32 ----- .../Exchanges/RoutingExchangeClient.cs | 6 - .../Trading/MarketMonitor.cs | 5 +- .../Trading/TradingDataService.cs | 52 -------- .../Trading/TradingStrategyService.cs | 16 +-- .../Converters/PriceChangeColorConverter.cs | 3 +- .../Core/GlobalExceptionHandler.cs | 6 +- .../MarketAssistant.App.csproj | 3 - .../Trading/TradeConfirmationService.cs | 6 - .../ViewModels/AboutPageViewModel.cs | 27 +++- .../ViewModels/AssetPageViewModel.cs | 4 + .../ViewModels/AssetSelectionPageViewModel.cs | 1 - .../Trading/StrategyConfigViewModel.cs | 8 +- .../Views/Components/KLineChartView.cs | 2 + .../Views/Pages/SettingsPageView.axaml | 2 + .../Infrastructure/Core/ErrorMessageMapper.cs | 2 +- .../MarketAssistant.Core.csproj | 4 - .../AShare/ClsQuoteClient.cs | 4 +- .../AShare/EastMoneyNewsClient.cs | 2 +- .../MarketAssistant.DataProviders.csproj | 4 - .../Factories/ModelDiscoveryService.cs | 6 +- .../MarketAssistant.Infrastructure.csproj | 6 +- .../Providers/StructuredOutputValidator.cs | 17 ++- .../Tokenization/ITokenCounter.cs | 13 ++ .../Tokenization/TiktokenTokenCounter.cs | 74 ++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 4 + .../MarketAssistant.Rag.csproj | 6 +- .../Services/DocumentBlockMapper.cs | 13 +- .../Services/MarkdownDocumentBlockReader.cs | 7 +- .../Services/PdfMarkdownConverter.cs | 34 +---- .../Services/RagSourceType.cs | 24 ++++ .../Services/RerankerService.cs | 35 +++-- .../Services/SqliteRagDocumentCatalog.cs | 15 ++- .../Services/TextChunkingService.cs | 95 +++++-------- .../Abstractions/IExchangeClient.cs | 65 --------- .../MarketAssistant.Trading.csproj | 4 - tests/TestMarketAssistant.csproj | 4 +- 49 files changed, 289 insertions(+), 720 deletions(-) create mode 100644 src/MarketAssistant.Infrastructure/Tokenization/ITokenCounter.cs create mode 100644 src/MarketAssistant.Infrastructure/Tokenization/TiktokenTokenCounter.cs create mode 100644 src/MarketAssistant.Rag/Services/RagSourceType.cs diff --git a/Directory.Build.props b/Directory.Build.props index 47a6e6b..dcde77e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,13 +1,10 @@ - - false - - - false - - - $(DefaultItemExcludes);**/*WinUI*/** + + net10.0 + enable + enable + MarketAssistant zh-Hans;en-us - - - - - - \ No newline at end of file + diff --git a/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj b/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj index 47d982e..077d20e 100644 --- a/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj +++ b/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj @@ -1,9 +1,5 @@ - net10.0 - enable - MarketAssistant - enable $(NoWarn);MAAI001 @@ -14,6 +10,7 @@ + diff --git a/src/MarketAssistant.Agents/TokenManagement/TokenEstimator.cs b/src/MarketAssistant.Agents/TokenManagement/TokenEstimator.cs index 3796792..b4dac24 100644 --- a/src/MarketAssistant.Agents/TokenManagement/TokenEstimator.cs +++ b/src/MarketAssistant.Agents/TokenManagement/TokenEstimator.cs @@ -1,33 +1,16 @@ +using MarketAssistant.Infrastructure.Tokenization; using Microsoft.Extensions.AI; -using Microsoft.ML.Tokenizers; namespace MarketAssistant.Agents.TokenManagement; /// -/// Token 估算器,基于 tiktoken(cl100k_base)精确计算 Token 数。 -/// 默认使用 GPT-4o 的分词模型;若初始化失败则回退到字符启发式估算。 +/// Token 估算器(静态门面):基于 (cl100k_base)精确计算 Token 数。 +/// 词表由 ITokenCounter 实现统一延迟加载一次;后续可逐步改为构造注入 ITokenCounter +/// (TokenTrackingMiddleware/MarketChatSession 目前由测试手工构造,故暂时保留静态门面)。 /// 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; - } - } + private static readonly ITokenCounter Counter = new TiktokenTokenCounter(); /// /// 估算单条消息的 Token 数 @@ -42,14 +25,7 @@ public static int EstimateTokens(ChatMessage message) /// 估算文本的 Token 数 /// public static int EstimateTokens(string text) - { - if (string.IsNullOrEmpty(text)) return 0; - - if (_tokenizer != null) - return _tokenizer.CountTokens(text); - - return FallbackEstimate(text); - } + => Counter.CountTokens(text); /// /// 估算对话历史的总 Token 数 @@ -58,31 +34,4 @@ 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.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs index dcd16e7..c7ba872 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs @@ -639,12 +639,6 @@ private static bool IsSystemExecutor(string executorId) /// private static bool IsAnalystExecutor(string executorId) => !IsSystemExecutor(executorId); - /// - /// 判断是否为 Dispatcher Executor。 - /// - private static bool IsDispatcherExecutor(string executorId) - => GetExecutorNamePrefix(executorId) == "AnalysisDispatcher"; - /// /// 从工作流 ExecutorId 中提取分析师显示名称。 /// 按第一个下划线切出 Name 前缀,再在 中查中文显示名。 diff --git a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketSnapshotContextProvider.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketSnapshotContextProvider.cs index 22f606a..21e10c6 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketSnapshotContextProvider.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketSnapshotContextProvider.cs @@ -17,17 +17,6 @@ public class MarketSnapshotContextProvider : MessageAIContextProvider /// 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) { diff --git a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoStrategyTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoStrategyTools.cs index 1da4144..dadbb8c 100644 --- a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoStrategyTools.cs +++ b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoStrategyTools.cs @@ -48,7 +48,7 @@ public async Task> GetActiveStrategiesAsync(CancellationTo { try { - return await _strategyService.GetStrategyAsync(strategyId, cancellationToken); + return await _dataService.GetStrategyAsync(strategyId, cancellationToken); } catch (Exception ex) when (ex is not FriendlyException) { diff --git a/src/MarketAssistant.App.Services/Applications/Crypto/BinanceFuturesAccountService.cs b/src/MarketAssistant.App.Services/Applications/Crypto/BinanceFuturesAccountService.cs index f388618..8640ea3 100644 --- a/src/MarketAssistant.App.Services/Applications/Crypto/BinanceFuturesAccountService.cs +++ b/src/MarketAssistant.App.Services/Applications/Crypto/BinanceFuturesAccountService.cs @@ -144,87 +144,6 @@ internal async Task SetLeverageAsync(string symbol, int leverage, CancellationTo } } - /// - /// 设置合约保证金模式(全仓/逐仓)。 - /// 端点:POST /fapi/v1/marginType - /// - /// "ISOLATED"(逐仓)或 "CROSSED"(全仓) - internal async Task SetMarginTypeAsync(string symbol, string marginType, CancellationToken cancellationToken = default) - { - try - { - var marginTypeUpper = marginType.ToUpper(); - if (marginTypeUpper is not ("ISOLATED" or "CROSSED")) - throw new ArgumentException("保证金模式必须为 ISOLATED 或 CROSSED"); - - var queryString = $"symbol={symbol.ToUpper()}&marginType={marginTypeUpper}"; - var signedQuery = await AuthService.SignQueryStringAsync(queryString, cancellationToken); - var url = $"/fapi/v1/marginType?{signedQuery}"; - - var request = new HttpRequestMessage(HttpMethod.Post, url); - AuthService.AddAuthHeaders(request); - - using var httpClient = HttpClientFactory.CreateClient(HttpClientName); - var response = await httpClient.SendAsync(request, cancellationToken); - - // -4046: No need to change margin type(已经是目标模式) - if (response.StatusCode == System.Net.HttpStatusCode.BadRequest) - { - var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); - if (errorContent.Contains("-4046")) - { - Logger.LogInformation("{Label}保证金模式已是 {MarginType},无需切换", Label, marginTypeUpper); - return; - } - } - - await EnsureSuccessWithBinanceErrorAsync(response, $"{Label}设置保证金模式", cancellationToken); - Logger.LogInformation("{Label}保证金模式已设置为 {MarginType}", Label, marginTypeUpper); - } - catch (HttpRequestException ex) - { - Logger.LogError(ex, "{Label}设置保证金模式失败 - 网络错误", Label); - throw new FriendlyException($"{Label}设置保证金模式失败: 网络连接错误", ex); - } - catch (Exception ex) when (ex is not FriendlyException) - { - Logger.LogError(ex, "{Label}设置保证金模式失败", Label); - throw new FriendlyException($"{Label}设置保证金模式失败: {ex.Message}", ex); - } - } - - /// - /// 查询合约成交明细。 - /// 端点:GET /fapi/v1/userTrades - /// - internal async Task> GetUserTradesAsync(string symbol, CancellationToken cancellationToken = default) - { - try - { - var queryString = $"symbol={symbol.ToUpper()}&limit=100"; - var signedQuery = await AuthService.SignQueryStringAsync(queryString, cancellationToken); - var url = $"/fapi/v1/userTrades?{signedQuery}"; - - var request = new HttpRequestMessage(HttpMethod.Get, url); - AuthService.AddAuthHeaders(request); - - using var httpClient = HttpClientFactory.CreateClient(HttpClientName); - var response = await httpClient.SendAsync(request, cancellationToken); - await EnsureSuccessWithBinanceErrorAsync(response, $"{Label}查询成交明细", cancellationToken); - - return await response.Content.ReadFromJsonAsync>(cancellationToken) ?? []; - } - catch (HttpRequestException ex) - { - Logger.LogError(ex, "{Label}查询成交明细失败 - 网络错误", Label); - throw new FriendlyException($"{Label}查询成交明细失败: 网络连接错误", ex); - } - catch (Exception ex) when (ex is not FriendlyException) - { - Logger.LogError(ex, "{Label}查询成交明细失败", Label); - throw new FriendlyException($"{Label}查询成交明细失败: {ex.Message}", ex); - } - } /// /// 将合约订单响应映射为统一的 BinanceOrderResponse。 @@ -330,49 +249,4 @@ internal sealed class BinanceFuturesPositionRisk public long UpdateTime { get; set; } } -/// -/// 币安合约成交明细(/fapi/v1/userTrades) -/// -internal sealed class BinanceFuturesUserTrade -{ - [JsonPropertyName("symbol")] - public string Symbol { get; set; } = string.Empty; - - [JsonPropertyName("id")] - public long TradeId { get; set; } - - [JsonPropertyName("orderId")] - public long OrderId { get; set; } - - [JsonPropertyName("side")] - public string Side { get; set; } = string.Empty; - - [JsonPropertyName("positionSide")] - public string PositionSide { get; set; } = string.Empty; - - [JsonPropertyName("price")] - public string Price { get; set; } = string.Empty; - - [JsonPropertyName("qty")] - public string Quantity { get; set; } = string.Empty; - - [JsonPropertyName("quoteQty")] - public string QuoteQuantity { get; set; } = string.Empty; - - [JsonPropertyName("realizedPnl")] - public string RealizedPnl { get; set; } = string.Empty; - - [JsonPropertyName("commission")] - public string Commission { get; set; } = string.Empty; - - [JsonPropertyName("commissionAsset")] - public string CommissionAsset { get; set; } = string.Empty; - - [JsonPropertyName("buyer")] - public bool IsBuyer { get; set; } - - [JsonPropertyName("time")] - public long Time { get; set; } -} - #endregion diff --git a/src/MarketAssistant.App.Services/Applications/Crypto/BinanceUserDataStreamModels.cs b/src/MarketAssistant.App.Services/Applications/Crypto/BinanceUserDataStreamModels.cs index 6aac0bc..f7f9c5d 100644 --- a/src/MarketAssistant.App.Services/Applications/Crypto/BinanceUserDataStreamModels.cs +++ b/src/MarketAssistant.App.Services/Applications/Crypto/BinanceUserDataStreamModels.cs @@ -33,44 +33,3 @@ public class ExecutionReport [JsonPropertyName("T")] public long TradeTime { get; set; } } -/// -/// outboundAccountPosition 事件中的单个余额项。 -/// -public class AccountBalanceEntry -{ - [JsonPropertyName("a")] public string Asset { get; set; } = string.Empty; - [JsonPropertyName("f")] public decimal Free { get; set; } - [JsonPropertyName("l")] public decimal Locked { get; set; } -} - -/// -/// outboundAccountPosition 事件:余额变动后的账户快照。 -/// -public class OutboundAccountPosition -{ - [JsonPropertyName("e")] public string EventType { get; set; } = string.Empty; - [JsonPropertyName("u")] public long LastAccountUpdate { get; set; } - [JsonPropertyName("B")] public List Balances { get; set; } = new(); -} - -/// -/// balanceUpdate 事件:余额增量变动。 -/// -public class BalanceUpdate -{ - [JsonPropertyName("e")] public string EventType { get; set; } = string.Empty; - [JsonPropertyName("a")] public string Asset { get; set; } = string.Empty; - [JsonPropertyName("d")] public decimal Delta { get; set; } - [JsonPropertyName("T")] public long ClearTime { get; set; } -} - -/// -/// 统一的账户变动事件参数,供订阅方消费。 -/// -public class AccountUpdate -{ - public string Asset { get; set; } = string.Empty; - public decimal Free { get; set; } - public decimal Locked { get; set; } - public DateTime UpdateTime { get; set; } -} diff --git a/src/MarketAssistant.App.Services/Applications/Crypto/BinanceUserDataStreamService.cs b/src/MarketAssistant.App.Services/Applications/Crypto/BinanceUserDataStreamService.cs index 1c3d27c..c2658e0 100644 --- a/src/MarketAssistant.App.Services/Applications/Crypto/BinanceUserDataStreamService.cs +++ b/src/MarketAssistant.App.Services/Applications/Crypto/BinanceUserDataStreamService.cs @@ -34,8 +34,6 @@ public sealed class BinanceUserDataStreamService : IAsyncDisposable, IDisposable /// 收到订单状态变更回报时触发。 public event Action? OrderUpdate; - /// 收到账户余额变动时触发。 - public event Action? AccountUpdate; public BinanceUserDataStreamService( IHttpClientFactory httpClientFactory, @@ -336,37 +334,6 @@ private void ProcessMessage(string json) OrderUpdate?.Invoke(report); break; - case "outboundAccountPosition": - var account = JsonSerializer.Deserialize(json); - if (account != null) - { - var updateTime = DateTimeOffset.FromUnixTimeMilliseconds(account.LastAccountUpdate).UtcDateTime; - foreach (var b in account.Balances) - { - AccountUpdate?.Invoke(new AccountUpdate - { - Asset = b.Asset, - Free = b.Free, - Locked = b.Locked, - UpdateTime = updateTime - }); - } - } - break; - - case "balanceUpdate": - var delta = JsonSerializer.Deserialize(json); - if (delta != null) - { - AccountUpdate?.Invoke(new AccountUpdate - { - Asset = delta.Asset, - Free = delta.Delta, - Locked = 0, - UpdateTime = DateTimeOffset.FromUnixTimeMilliseconds(delta.ClearTime).UtcDateTime - }); - } - break; } } catch (Exception ex) diff --git a/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj b/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj index 0140af0..c5a308d 100644 --- a/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj +++ b/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj @@ -1,9 +1,5 @@ - net10.0 - enable - MarketAssistant - enable $(NoWarn);MAAI001;SKEXP0001;SKEXP0050 diff --git a/src/MarketAssistant.App.Services/Trading/AnalysisReportCache.cs b/src/MarketAssistant.App.Services/Trading/AnalysisReportCache.cs index 96ec2cc..5c0f080 100644 --- a/src/MarketAssistant.App.Services/Trading/AnalysisReportCache.cs +++ b/src/MarketAssistant.App.Services/Trading/AnalysisReportCache.cs @@ -1,8 +1,8 @@ -using System.Collections.Concurrent; using MarketAssistant.Agents.MarketAnalysis.Models; using MarketAssistant.Applications.Cache; using MarketAssistant.Infrastructure.Core; using MarketAssistant.Services.Market; +using Microsoft.Extensions.Caching.Memory; namespace MarketAssistant.Services.Trading; @@ -10,20 +10,22 @@ namespace MarketAssistant.Services.Trading; /// 分析报告内存缓存:线程安全地存储最近一次市场分析结果, /// 供交易模块在 AI 信号策略决策时读取,打通分析-交易链路。 /// 缓存键包含市场类型前缀,避免 A 股与虚拟币同代码(理论上)相互覆盖。 +/// 内部实现由手搓 ConcurrentDictionary+LRU 收敛为 : +/// 过期语义沿用原实现(绝对 TTL 24 小时,写入后固定时长失效); +/// 容量上限(原 50 条)随 LRU 一并移除——报告按 symbol 存储、条目数天然有限,无需淘汰。 /// 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 IMemoryCache _cache; private readonly MarketContext _marketContext; - public AnalysisReportCache(MarketContext marketContext) + public AnalysisReportCache(MarketContext marketContext, IMemoryCache? cache = null) { _marketContext = marketContext; + // 测试/手工构造可缺省;DI 注入应用级共享缓存实例 + _cache = cache ?? new MemoryCache(new MemoryCacheOptions()); } /// @@ -32,11 +34,10 @@ public AnalysisReportCache(MarketContext marketContext) /// public void Set(string symbol, MarketType market, MarketAnalysisReport report) { - // 惰性清理:写入前移除已过期条目,并在超限时淘汰最旧的条目 - EvictExpired(); - EnsureCapacity(); - - _reports[BuildKey(market, symbol)] = new CachedReport(report, DateTime.UtcNow); + _cache.Set( + BuildKey(market, symbol), + new CachedReport(report, DateTime.UtcNow), + new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = Ttl }); } /// 写入缓存(沿用全局 MarketContext 当前市场,供市场分析工作流使用)。 @@ -44,22 +45,10 @@ public void Set(string symbol, MarketAnalysisReport report) => Set(symbol, _marketContext.CurrentMarket, report); /// - /// 读取缓存(显式市场类型)。 + /// 读取缓存(显式市场类型)。过期条目由 IMemoryCache 自动移除。 /// public CachedReport? Get(string symbol, MarketType market) - { - var key = BuildKey(market, symbol); - if (!_reports.TryGetValue(key, out var cached)) - return null; - - if (DateTime.UtcNow - cached.CachedAt > Ttl) - { - _reports.TryRemove(key, out _); - return null; - } - - return cached; - } + => _cache.TryGetValue(BuildKey(market, symbol), out var cached) ? cached : null; /// 读取缓存(沿用全局 MarketContext 当前市场)。 public CachedReport? Get(string symbol) @@ -72,41 +61,5 @@ public void Set(string symbol, MarketAnalysisReport report) private static string BuildKey(MarketType market, string symbol) => CacheKeys.GetTradingAnalysisReportKey(market, 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/Exchanges/BinanceExchangeClient.cs b/src/MarketAssistant.App.Services/Trading/Exchanges/BinanceExchangeClient.cs index 4893716..fa66d0e 100644 --- a/src/MarketAssistant.App.Services/Trading/Exchanges/BinanceExchangeClient.cs +++ b/src/MarketAssistant.App.Services/Trading/Exchanges/BinanceExchangeClient.cs @@ -99,18 +99,6 @@ public virtual Task> GetPositionsAsync( public virtual Task SetLeverageAsync(string instrumentSymbol, int leverage, CancellationToken ct = default) => Task.CompletedTask; - /// - /// 现货无需设置保证金模式,为空操作。合约由子类覆写。 - /// - public virtual Task SetMarginTypeAsync(string instrumentSymbol, string marginType, CancellationToken ct = default) - => Task.CompletedTask; - - /// - /// 现货成交明细由子类覆写(如需支持),默认返回空列表。 - /// - public virtual Task> GetUserTradesAsync(string instrumentSymbol, CancellationToken ct = default) - => Task.FromResult>([]); - /// /// 将币安订单响应映射为统一的 ExchangeOrderResult。 /// diff --git a/src/MarketAssistant.App.Services/Trading/Exchanges/BinanceFuturesExchangeClient.cs b/src/MarketAssistant.App.Services/Trading/Exchanges/BinanceFuturesExchangeClient.cs index 1bd958d..25e2233 100644 --- a/src/MarketAssistant.App.Services/Trading/Exchanges/BinanceFuturesExchangeClient.cs +++ b/src/MarketAssistant.App.Services/Trading/Exchanges/BinanceFuturesExchangeClient.cs @@ -53,36 +53,4 @@ public override async Task SetLeverageAsync(string instrumentSymbol, int leverag await _futuresAccountService.SetLeverageAsync(instrumentSymbol, leverage, ct); } - - /// - /// 设置合约保证金模式(全仓/逐仓)。 - /// - public override async Task SetMarginTypeAsync(string instrumentSymbol, string marginType, CancellationToken ct = default) - { - await _futuresAccountService.SetMarginTypeAsync(instrumentSymbol, marginType, ct); - } - - /// - /// 查询合约成交明细。 - /// - public override async Task> GetUserTradesAsync(string instrumentSymbol, CancellationToken ct = default) - { - var trades = await _futuresAccountService.GetUserTradesAsync(instrumentSymbol, ct); - - return trades.Select(t => new ExchangeTradeDetail - { - Symbol = t.Symbol, - TradeId = t.TradeId, - OrderId = t.OrderId, - Side = t.Side, - PositionSide = t.PositionSide, - Price = decimal.TryParse(t.Price, NumberStyles.Number, CultureInfo.InvariantCulture, out var p) ? p : 0, - Quantity = decimal.TryParse(t.Quantity, NumberStyles.Number, CultureInfo.InvariantCulture, out var q) ? q : 0, - QuoteQuantity = decimal.TryParse(t.QuoteQuantity, NumberStyles.Number, CultureInfo.InvariantCulture, out var qq) ? qq : 0, - Commission = decimal.TryParse(t.Commission, NumberStyles.Number, CultureInfo.InvariantCulture, out var c) ? c : 0, - CommissionAsset = t.CommissionAsset, - IsBuyer = t.IsBuyer, - Time = t.Time - }).ToList(); - } } diff --git a/src/MarketAssistant.App.Services/Trading/Exchanges/RoutingExchangeClient.cs b/src/MarketAssistant.App.Services/Trading/Exchanges/RoutingExchangeClient.cs index cde8a69..463bda3 100644 --- a/src/MarketAssistant.App.Services/Trading/Exchanges/RoutingExchangeClient.cs +++ b/src/MarketAssistant.App.Services/Trading/Exchanges/RoutingExchangeClient.cs @@ -69,12 +69,6 @@ public Task> GetPositionsAsync( public Task SetLeverageAsync(string instrumentSymbol, int leverage, CancellationToken ct = default) => GetActiveClient().SetLeverageAsync(instrumentSymbol, leverage, ct); - public Task SetMarginTypeAsync(string instrumentSymbol, string marginType, CancellationToken ct = default) - => GetActiveClient().SetMarginTypeAsync(instrumentSymbol, marginType, ct); - - public Task> GetUserTradesAsync(string instrumentSymbol, CancellationToken ct = default) - => GetActiveClient().GetUserTradesAsync(instrumentSymbol, ct); - /// /// 获取当前活跃客户端的快照。下单链路(风控→杠杆→下单)必须对同一客户端实例完成, /// 若各步骤独立重新解析,用户在等待确认期间切换模拟盘/实盘会让订单落到错误环境。 diff --git a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs index fcbd31e..2ff093c 100644 --- a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs +++ b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs @@ -22,6 +22,7 @@ public class MarketMonitor : IDisposable private readonly AISignalStrategyExecutor _aiSignalExecutor; private readonly OrderStateSyncService _orderStateSyncService; private readonly TradingStrategyService _strategyService; + private readonly TradingDataService _dataService; private readonly INotificationService _notificationService; private readonly ILogger _logger; @@ -98,6 +99,7 @@ public MarketMonitor( AISignalStrategyExecutor aiSignalExecutor, OrderStateSyncService orderStateSyncService, TradingStrategyService strategyService, + TradingDataService dataService, BinanceUserDataStreamService userDataStreamService, INotificationService notificationService, ILogger logger) @@ -108,6 +110,7 @@ public MarketMonitor( _aiSignalExecutor = aiSignalExecutor; _orderStateSyncService = orderStateSyncService; _strategyService = strategyService; + _dataService = dataService; _userDataStreamService = userDataStreamService; _notificationService = notificationService; _logger = logger; @@ -553,7 +556,7 @@ private async Task CheckStrategyCompletionAsync(TradingStrategy strategy) if (!strategy.MaxExecutions.HasValue) return; - var updated = await _strategyService.GetStrategyAsync(strategy.Id); + var updated = await _dataService.GetStrategyAsync(strategy.Id); if (updated != null && updated.ExecutionCount >= updated.MaxExecutions!.Value) { await _strategyService.UpdateStrategyStatusAsync(strategy.Id, StrategyStatus.Completed); diff --git a/src/MarketAssistant.App.Services/Trading/TradingDataService.cs b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs index a755daa..e22220a 100644 --- a/src/MarketAssistant.App.Services/Trading/TradingDataService.cs +++ b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs @@ -624,58 +624,6 @@ ON CONFLICT(environment, date) DO UPDATE SET 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 environment = @environment AND symbol = @symbol AND side = @side AND executed_qty > 0 AND status = @status - """; - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - 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 environment = @environment AND symbol = @symbol AND side = @side AND executed_qty > 0 AND status = @status - """; - cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - 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 追踪 diff --git a/src/MarketAssistant.App.Services/Trading/TradingStrategyService.cs b/src/MarketAssistant.App.Services/Trading/TradingStrategyService.cs index 5e5ec4a..1c24d69 100644 --- a/src/MarketAssistant.App.Services/Trading/TradingStrategyService.cs +++ b/src/MarketAssistant.App.Services/Trading/TradingStrategyService.cs @@ -16,24 +16,16 @@ public TradingStrategyService(TradingDataService dataService) public event EventHandler? StrategiesChanged; - public Task GetStrategyAsync(string strategyId, CancellationToken ct = default) - => _dataService.GetStrategyAsync(strategyId, ct); - + /// + /// 按状态查询策略。该转发保留:调用方(StrategyEngine/MarketMonitor 等)依赖策略变更广播语义, + /// 且单元测试通过 virtual 覆写替换实现(AISignal 硬性边界行为测试)。 + /// /// virtual 供单元测试替换(AISignal 硬性边界行为测试)。 public virtual Task> GetStrategiesByStatusAsync( StrategyStatus status, CancellationToken ct = default) => _dataService.GetStrategiesByStatusAsync(status, ct); - public Task> GetAllStrategiesAsync(CancellationToken ct = default) - => _dataService.GetAllStrategiesAsync(ct); - - public Task LoadRiskConfigAsync(CancellationToken ct = default) - => _dataService.LoadRiskConfigAsync(ct); - - public Task SaveRiskConfigAsync(RiskConfig config, CancellationToken ct = default) - => _dataService.SaveRiskConfigAsync(config, ct); - public async Task SaveStrategyAsync(TradingStrategy strategy, CancellationToken ct = default) { await _dataService.SaveStrategyAsync(strategy, ct).ConfigureAwait(false); diff --git a/src/MarketAssistant.App/Converters/PriceChangeColorConverter.cs b/src/MarketAssistant.App/Converters/PriceChangeColorConverter.cs index 91cb2c6..fc0d80d 100644 --- a/src/MarketAssistant.App/Converters/PriceChangeColorConverter.cs +++ b/src/MarketAssistant.App/Converters/PriceChangeColorConverter.cs @@ -26,7 +26,8 @@ public class PriceChangeColorConverter : IValueConverter { decimal priceChange => priceChange, string percentageStr when !string.IsNullOrEmpty(percentageStr) - => decimal.TryParse(percentageStr.Replace("%", "").Trim(), out var parsed) ? parsed : null, + // 固定使用 InvariantCulture 解析,避免区域设置(如 de-DE 的小数逗号)导致解析失败而变灰 + => decimal.TryParse(percentageStr.Replace("%", "").Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed) ? parsed : null, _ => null }; diff --git a/src/MarketAssistant.App/Infrastructure/Core/GlobalExceptionHandler.cs b/src/MarketAssistant.App/Infrastructure/Core/GlobalExceptionHandler.cs index 47f2559..cbac881 100644 --- a/src/MarketAssistant.App/Infrastructure/Core/GlobalExceptionHandler.cs +++ b/src/MarketAssistant.App/Infrastructure/Core/GlobalExceptionHandler.cs @@ -95,7 +95,7 @@ private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEv /// private void OnDispatcherUnhandledException(object sender, Avalonia.Threading.DispatcherUnhandledExceptionEventArgs e) { - // OutOfMemoryException / StackOverflowException 之类的致命错误不应被吞并,让进程终止并写入崩溃日志 + // OutOfMemoryException / AccessViolationException 之类的致命错误不应被吞并,让进程终止并写入崩溃日志 if (IsFatalException(e.Exception)) { _logger.LogCritical(e.Exception, "UI 线程发生致命异常,不处理,允许进程终止"); @@ -118,11 +118,11 @@ private void OnDispatcherUnhandledException(object sender, Avalonia.Threading.Di } /// - /// 判断是否是不可恢复的致命异常 + /// 判断是否是不可恢复的致命异常。 + /// 注意:StackOverflowException 在 .NET 上会直接终止进程,无法以异常对象形式到达此处,故不列出。 /// private static bool IsFatalException(Exception ex) => ex is OutOfMemoryException - or StackOverflowException or AccessViolationException or AppDomainUnloadedException; diff --git a/src/MarketAssistant.App/MarketAssistant.App.csproj b/src/MarketAssistant.App/MarketAssistant.App.csproj index 3ca30b3..0915d9a 100644 --- a/src/MarketAssistant.App/MarketAssistant.App.csproj +++ b/src/MarketAssistant.App/MarketAssistant.App.csproj @@ -1,9 +1,6 @@ WinExe - net10.0 - enable - MarketAssistant true app.manifest true diff --git a/src/MarketAssistant.App/Services/Trading/TradeConfirmationService.cs b/src/MarketAssistant.App/Services/Trading/TradeConfirmationService.cs index 15cde31..a31963e 100644 --- a/src/MarketAssistant.App/Services/Trading/TradeConfirmationService.cs +++ b/src/MarketAssistant.App/Services/Trading/TradeConfirmationService.cs @@ -37,12 +37,6 @@ public TradeConfirmationService( _tradeExecutor.ConfirmationRequested += OnConfirmationRequestedAsync; } - /// - /// 是否有确认请求正在进行(同一时刻只允许一个对话框,后续请求直接拒绝, - /// 避免多个待确认交易叠加导致用户误批)。 - /// - private bool HasPendingConfirmation => Volatile.Read(ref _pendingConfirmationCount) > 0; - private async Task OnConfirmationRequestedAsync( string symbol, OrderSide side, decimal price, decimal quantity, string reason) { diff --git a/src/MarketAssistant.App/ViewModels/AboutPageViewModel.cs b/src/MarketAssistant.App/ViewModels/AboutPageViewModel.cs index 4bcd558..f05f2ac 100644 --- a/src/MarketAssistant.App/ViewModels/AboutPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/AboutPageViewModel.cs @@ -148,6 +148,16 @@ await SafeExecuteAsync(async () => return; } + // 净化远端资产文件名:仅取文件名部分,并拒绝包含路径分隔符的名称,防止路径穿越 + var assetName = Path.GetFileName(asset.Name); + if (string.IsNullOrEmpty(assetName) || + asset.Name.Contains('/') || asset.Name.Contains('\\')) + { + Logger?.LogWarning("检测到可疑的资产文件名,已拒绝下载: {Name}", asset.Name); + _notificationService.ShowError("更新资产文件名异常,已取消下载,请前往 GitHub 手动下载"); + return; + } + // 确定保存路径 var downloadsPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); downloadsPath = Path.Combine(downloadsPath, "Downloads"); @@ -156,7 +166,7 @@ await SafeExecuteAsync(async () => downloadsPath = Path.GetTempPath(); } - var savePath = Path.Combine(downloadsPath, asset.Name); + var savePath = Path.Combine(downloadsPath, assetName); Logger?.LogInformation("准备下载更新:{Url} -> {Path}", asset.DownloadUrl, savePath); _notificationService.ShowInfo($"开始下载 {asset.Name}..."); @@ -176,9 +186,11 @@ await SafeExecuteAsync(async () => Logger?.LogInformation("更新文件下载完成: {Path}", downloadedPath); + // 风险提示:下载产物未做哈希/签名校验(GitHub Release 未提供校验和信息), + // 提示用户自行确认来源后再运行安装程序。 // 下载完成 UpdateStatus = "下载完成!"; - _notificationService.ShowSuccess($"更新文件已下载到:\n{downloadedPath}\n\n请手动运行安装程序进行更新"); + _notificationService.ShowSuccess($"更新文件已下载到:\n{downloadedPath}\n\n请确认文件来源后手动运行安装程序进行更新(当前未提供校验和验证)"); // 打开下载目录 Process.Start(new ProcessStartInfo @@ -221,9 +233,11 @@ private void OpenGitHub() { Process.Start(new ProcessStartInfo(AppInfo.GitHubRepoUrl) { UseShellExecute = true }); } - catch (Exception) + catch (Exception ex) { - // 处理异常 + // 打开浏览器失败不影响主流程,记录日志即可 + Logger?.LogWarning(ex, "打开 GitHub 仓库页面失败: {Url}", AppInfo.GitHubRepoUrl); + _notificationService.ShowWarning("无法打开浏览器,请手动访问 GitHub 仓库"); } } @@ -287,9 +301,10 @@ private void OpenUrl(string url) { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); } - catch (Exception) + catch (Exception ex) { - // 处理异常 + // 打开链接失败不影响主流程,记录日志即可 + Logger?.LogWarning(ex, "打开链接失败: {Url}", url); } } diff --git a/src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs b/src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs index 3839d5c..0b65586 100644 --- a/src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs @@ -173,7 +173,9 @@ private async Task LoadAssetDataAsync(string assetCode) if (string.IsNullOrEmpty(assetCode)) return; + // 取消并释放上一次加载的 CTS,避免泄漏 _loadingCancellationTokenSource?.Cancel(); + _loadingCancellationTokenSource?.Dispose(); _loadingCancellationTokenSource = new CancellationTokenSource(); var cancellationToken = _loadingCancellationTokenSource.Token; @@ -184,6 +186,8 @@ private async Task LoadAssetDataAsync(string assetCode) try { var klineService = _klineServiceResolver(_marketContext.CurrentMarket); + // IKLineService.GetKLineDataAsync 暂不支持 CancellationToken, + // 仅能通过取消令牌在返回后丢弃过期结果 var kLineDataList = await klineService.GetKLineDataAsync(assetCode, CurrentKLineType); cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/MarketAssistant.App/ViewModels/AssetSelectionPageViewModel.cs b/src/MarketAssistant.App/ViewModels/AssetSelectionPageViewModel.cs index 320577c..46207ed 100644 --- a/src/MarketAssistant.App/ViewModels/AssetSelectionPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/AssetSelectionPageViewModel.cs @@ -247,7 +247,6 @@ partial void OnSelectedModeChanged(SelectionModeItem? value) OnPropertyChanged(nameof(CurrentInputContent)); OnPropertyChanged(nameof(CurrentPlaceholder)); OnPropertyChanged(nameof(CurrentButtonText)); - OnPropertyChanged(nameof(CurrentButtonText)); OnPropertyChanged(nameof(IsInputAreaVisible)); OnPropertyChanged(nameof(IsQuickStrategyAreaVisible)); } diff --git a/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs b/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs index 373ab19..feedfd7 100644 --- a/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Trading/StrategyConfigViewModel.cs @@ -198,8 +198,8 @@ private async Task InitializeAsync() { await SafeExecuteAsync(async () => { - RiskConfig = await _strategyService.LoadRiskConfigAsync(); - var strategies = await _strategyService.GetAllStrategiesAsync(); + RiskConfig = await _dataService.LoadRiskConfigAsync(); + var strategies = await _dataService.GetAllStrategiesAsync(); Strategies.Clear(); foreach (var s in strategies) Strategies.Add(s); @@ -475,7 +475,7 @@ await SafeExecuteAsync(async () => var records = await _dataService.GetRecordsByStrategyAsync(strategy.Id); // 刷新策略最新状态(执行次数可能已被后台引擎更新) - var latest = await _strategyService.GetStrategyAsync(strategy.Id); + var latest = await _dataService.GetStrategyAsync(strategy.Id); if (latest != null) { strategy.ExecutionCount = latest.ExecutionCount; @@ -491,7 +491,7 @@ await SafeExecuteAsync(async () => [RelayCommand] private async Task SaveRiskConfigAsync() { - await _strategyService.SaveRiskConfigAsync(RiskConfig); + await _dataService.SaveRiskConfigAsync(RiskConfig); } [RelayCommand] diff --git a/src/MarketAssistant.App/Views/Components/KLineChartView.cs b/src/MarketAssistant.App/Views/Components/KLineChartView.cs index c881a42..a4fc4f2 100644 --- a/src/MarketAssistant.App/Views/Components/KLineChartView.cs +++ b/src/MarketAssistant.App/Views/Components/KLineChartView.cs @@ -267,6 +267,8 @@ private string GetDefaultChartHtml() K线图表 + - + - - + + - - - - - + + + + + - - - + + + - - - - - - + + + + + + + - - - - + + + + diff --git a/src/MarketAssistant.App/Views/Pages/HomePageView.axaml b/src/MarketAssistant.App/Views/Pages/HomePageView.axaml index 85e1571..4796929 100644 --- a/src/MarketAssistant.App/Views/Pages/HomePageView.axaml +++ b/src/MarketAssistant.App/Views/Pages/HomePageView.axaml @@ -113,34 +113,24 @@ - - - + + - + - - - - - + Margin="{StaticResource SmallLeftMargin}"> @@ -181,7 +171,7 @@ - + @@ -300,10 +290,9 @@ - + 滚轮每格横向滚动距离(像素) + private const double WheelHorizontalStep = 50; + public HomePageView() { InitializeComponent(); + + // 最近查看条仅有横向内容:在隧道阶段把纵向滚轮翻译为横向偏移并标记 Handled, + // 抢在 ScrollContentPresenter 原生处理之前,避免两种行为叠加导致双倍滚动。 + RecentScroller.AddHandler( + PointerWheelChangedEvent, + RecentScroller_PointerWheelChanged, + RoutingStrategies.Tunnel); + } + + /// + /// 最近查看条滚轮处理:纵向滚轮转为横向滚动;带横向增量(如 Shift+滚轮)时交给原生处理 + /// + private void RecentScroller_PointerWheelChanged(object? sender, PointerWheelEventArgs e) + { + if (sender is not ScrollViewer scroller || e.Delta.X != 0) + return; + + // 内容未超出可视宽度时无需横向滚动,交回默认处理 + if (scroller.Extent.Width - scroller.Viewport.Width <= 0) + return; + + scroller.Offset = new Vector(scroller.Offset.X - e.Delta.Y * WheelHorizontalStep, scroller.Offset.Y); + e.Handled = true; } /// diff --git a/src/MarketAssistant.App/Views/Windows/MainWindow.axaml b/src/MarketAssistant.App/Views/Windows/MainWindow.axaml index fd56c51..180c4ce 100644 --- a/src/MarketAssistant.App/Views/Windows/MainWindow.axaml +++ b/src/MarketAssistant.App/Views/Windows/MainWindow.axaml @@ -184,10 +184,11 @@ VerticalAlignment="Center" Foreground="{DynamicResource TextPrimaryBrush}"/> - + diff --git a/src/MarketAssistant.App/Views/Windows/MainWindow.axaml.cs b/src/MarketAssistant.App/Views/Windows/MainWindow.axaml.cs index 184b5b7..e17f0a1 100644 --- a/src/MarketAssistant.App/Views/Windows/MainWindow.axaml.cs +++ b/src/MarketAssistant.App/Views/Windows/MainWindow.axaml.cs @@ -14,10 +14,7 @@ public MainWindow() /// protected override void OnClosing(WindowClosingEventArgs e) { - // 取消关闭操作 e.Cancel = true; - - // 隐藏窗口到托盘 Hide(); base.OnClosing(e); diff --git a/src/MarketAssistant.App/Views/Windows/NotificationWindow.axaml.cs b/src/MarketAssistant.App/Views/Windows/NotificationWindow.axaml.cs index 35fe7a8..2c3c3f6 100644 --- a/src/MarketAssistant.App/Views/Windows/NotificationWindow.axaml.cs +++ b/src/MarketAssistant.App/Views/Windows/NotificationWindow.axaml.cs @@ -15,12 +15,19 @@ public partial class NotificationWindow : Window private const int MinimumDisplayDuration = 3000; private const int DurationPerLine = 700; private const int MaximumDisplayDuration = 12000; + private const double MarginFromEdge = 16; + private const double StackGap = 8; private static readonly Color SuccessColor = Color.Parse("#4CAF50"); private static readonly Color ErrorColor = Color.Parse("#F44336"); private static readonly Color WarningColor = Color.Parse("#FF9800"); private static readonly Color InfoColor = Color.Parse("#2196F3"); + // 堆叠槽位(0 = 底部基准位,向上依次偏移),由 NotificationService 分配 + private int _slot; + private bool _closing; + private readonly CancellationTokenSource _closeCts = new(); + public NotificationWindow() { InitializeComponent(); @@ -36,6 +43,25 @@ public NotificationWindow() } } + /// + /// 设置堆叠槽位,由 在显示前分配 + /// + public void SetStackSlot(int slot) => _slot = Math.Max(0, slot); + + /// + /// 槽位回收后整体挪位(下方通知关闭后,上方通知顺次下移) + /// + public void MoveToSlot(int slot) + { + _slot = Math.Max(0, slot); + var screen = Screens.Primary; + if (screen == null) return; + Position = new PixelPoint(Position.X, ComputeY(screen.WorkingArea)); + } + + private int ComputeY(PixelRect workingArea) => + (int)(workingArea.Bottom - Height - MarginFromEdge - _slot * (Height + StackGap)); + /// /// 设置通知消息 /// @@ -77,7 +103,8 @@ public void SetMessage(string message, NotificationType type) } /// - /// 显示通知(带动画) + /// 显示通知(带动画)。单一关闭路径:点击关闭按钮只取消等待, + /// 滑出与 Close 统一由本方法收尾,避免双重动画/二次 Close。 /// public async Task ShowNotificationAsync(int durationMs = DisplayDuration) { @@ -87,24 +114,35 @@ public async Task ShowNotificationAsync(int durationMs = DisplayDuration) { var workingArea = screen.WorkingArea; var startX = workingArea.Right; - var finalX = workingArea.Right - Width - 16; - var y = workingArea.Bottom - Height - 16; + var y = ComputeY(workingArea); - Position = new PixelPoint((int)startX, (int)y); + Position = new PixelPoint((int)startX, y); } Show(); - // 滑入动画 - await SlideInAsync(); - - // 长文本按预计行数延长停留时间,避免用户尚未读完就消失。 - await Task.Delay(CalculateDisplayDuration(durationMs)); - - // 滑出动画 - await SlideOutAsync(); + try + { + // 滑入动画 + await SlideInAsync(); - Close(); + // 长文本按预计行数延长停留时间,避免用户尚未读完就消失。 + try + { + await Task.Delay(CalculateDisplayDuration(durationMs), _closeCts.Token); + } + catch (OperationCanceledException) + { + // 用户点击了关闭按钮:跳过剩余等待,由下方统一滑出 + } + } + finally + { + _closing = true; + await SlideOutAsync(); + Close(); + _closeCts.Dispose(); + } } /// @@ -116,9 +154,8 @@ private void PositionWindow() if (screen != null) { var workingArea = screen.WorkingArea; - var x = workingArea.Right - Width - 16; - var y = workingArea.Bottom - Height - 16; - Position = new PixelPoint((int)x, (int)y); + var x = workingArea.Right - Width - MarginFromEdge; + Position = new PixelPoint((int)x, ComputeY(workingArea)); } } @@ -132,8 +169,8 @@ private async Task SlideInAsync() var workingArea = screen.WorkingArea; var startX = workingArea.Right; - var endX = workingArea.Right - Width - 16; - var y = workingArea.Bottom - Height - 16; + var endX = workingArea.Right - Width - MarginFromEdge; + var y = ComputeY(workingArea); var steps = 20; var stepDuration = AnimationDuration / steps; @@ -167,9 +204,9 @@ private async Task SlideOutAsync() if (screen == null) return; var workingArea = screen.WorkingArea; - var startX = workingArea.Right - Width - 16; + var startX = workingArea.Right - Width - MarginFromEdge; var endX = workingArea.Right; - var y = workingArea.Bottom - Height - 16; + var y = ComputeY(workingArea); var steps = 20; var stepDuration = AnimationDuration / steps; @@ -193,11 +230,13 @@ private async Task SlideOutAsync() private double EaseInCubic(double t) => t * t * t; /// - /// 关闭按钮点击事件 + /// 关闭按钮点击事件:只取消显示等待,滑出与 Close 由 统一收尾 /// private void OnCloseButtonClick(object? sender, RoutedEventArgs e) { - _ = SlideOutAsync().ContinueWith(_ => Dispatcher.UIThread.Post(() => Close())); + if (_closing) return; + _closing = true; + _closeCts.Cancel(); } } diff --git a/src/MarketAssistant.Core/Infrastructure/Core/CryptoSymbolConverter.cs b/src/MarketAssistant.Core/Infrastructure/Core/CryptoSymbolConverter.cs index f7a1b06..63e5842 100644 --- a/src/MarketAssistant.Core/Infrastructure/Core/CryptoSymbolConverter.cs +++ b/src/MarketAssistant.Core/Infrastructure/Core/CryptoSymbolConverter.cs @@ -75,8 +75,8 @@ public static string ExtractBaseCurrency(string tradingPair) return tradingPair; } - // 移除常见计价货币后缀(按长度倒序,避免误匹配) - var quoteCurrencies = new[] { "USDT", "BUSD", "USDC", "DAI", "BTC", "ETH", "BNB" }; + // 移除常见计价货币后缀(先长后短,避免 "BTCFDUSD" 被 USD 类短后缀误截断) + var quoteCurrencies = new[] { "USDT", "BUSD", "USDC", "FDUSD", "TUSD", "DAI", "BTC", "ETH", "BNB", "EUR", "TRY", "BRL" }; foreach (var quote in quoteCurrencies) { if (tradingPair.EndsWith(quote) && tradingPair.Length > quote.Length) @@ -117,36 +117,54 @@ public static string ToCoinGeckoId(string symbol) baseCurrency = symbol; } - baseCurrency = baseCurrency.ToLower(); + baseCurrency = baseCurrency.ToLowerInvariant(); - // 常见币种映射表 - var mapping = new Dictionary(StringComparer.OrdinalIgnoreCase) + return TryGetCoinGeckoId(baseCurrency, out var coinId) ? coinId : baseCurrency; + } + + /// + /// 查询内置的 symbol → CoinGecko ID 映射表。 + /// 与 不同,本方法能区分"未命中"与"映射值恰好与输入相同"(如 dai → dai)。 + /// + /// 是否命中映射表 + public static bool TryGetCoinGeckoId(string baseSymbol, out string coinId) + { + if (string.IsNullOrWhiteSpace(baseSymbol)) { - { "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; + coinId = string.Empty; + return false; + } + + return CoinGeckoIdMapping.TryGetValue(baseSymbol.Trim().ToLowerInvariant(), out coinId!); } + + /// + /// 常见币种的 CoinGecko ID 映射表(静态只读,避免每次调用重建) + /// + private static readonly Dictionary CoinGeckoIdMapping = new(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" } + }; } diff --git a/src/MarketAssistant.Core/Infrastructure/Core/ErrorMessageMapper.cs b/src/MarketAssistant.Core/Infrastructure/Core/ErrorMessageMapper.cs index 551dd67..4724493 100644 --- a/src/MarketAssistant.Core/Infrastructure/Core/ErrorMessageMapper.cs +++ b/src/MarketAssistant.Core/Infrastructure/Core/ErrorMessageMapper.cs @@ -8,9 +8,6 @@ namespace MarketAssistant.Infrastructure.Core; /// public static class ErrorMessageMapper { - /// - /// 获取用户友好的错误消息 - /// public static string GetUserFriendlyMessage(Exception exception) { return exception switch @@ -70,9 +67,6 @@ Exception ex when ex.GetType().Name.Contains("Sqlite", StringComparison.Ordinal) }; } - /// - /// 获取带上下文的用户友好错误消息 - /// public static string GetUserFriendlyMessageWithContext(Exception exception, string operationName) { var baseMessage = GetUserFriendlyMessage(exception); diff --git a/src/MarketAssistant.Core/Infrastructure/Extensions/EnumExtensions.cs b/src/MarketAssistant.Core/Infrastructure/Extensions/EnumExtensions.cs index 0f87ce3..f4be8b3 100644 --- a/src/MarketAssistant.Core/Infrastructure/Extensions/EnumExtensions.cs +++ b/src/MarketAssistant.Core/Infrastructure/Extensions/EnumExtensions.cs @@ -4,14 +4,8 @@ namespace MarketAssistant.Infrastructure.Extensions; -/// -/// 枚举扩展方法 -/// public static class EnumExtensions { - /// - /// 获取枚举的 Description 属性值 - /// public static string GetDescription(this Enum value) { var field = value.GetType().GetField(value.ToString()); diff --git a/src/MarketAssistant.Core/MarketAssistant.Core.csproj b/src/MarketAssistant.Core/MarketAssistant.Core.csproj index 352e618..b6905ea 100644 --- a/src/MarketAssistant.Core/MarketAssistant.Core.csproj +++ b/src/MarketAssistant.Core/MarketAssistant.Core.csproj @@ -4,8 +4,5 @@ - - - diff --git a/src/MarketAssistant.DataProviders/AShare/SinaFundFlowClient.cs b/src/MarketAssistant.DataProviders/AShare/SinaFundFlowClient.cs index 40749f3..7726a9d 100644 --- a/src/MarketAssistant.DataProviders/AShare/SinaFundFlowClient.cs +++ b/src/MarketAssistant.DataProviders/AShare/SinaFundFlowClient.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json; namespace MarketAssistant.DataProviders.AShare; @@ -86,7 +87,8 @@ private static double ParseDouble(JsonElement element, string propertyName) return property.ValueKind switch { - JsonValueKind.String => double.TryParse(property.GetString(), out var value) ? value : 0, + // 新浪返回点分小数,必须按 InvariantCulture 解析,逗号小数区域下否则全部归零 + JsonValueKind.String => double.TryParse(property.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : 0, JsonValueKind.Number => property.GetDouble(), _ => 0 }; diff --git a/src/MarketAssistant.DataProviders/BinanceFundingRateResponse.cs b/src/MarketAssistant.DataProviders/BinanceFundingRateResponse.cs index 3f19771..9ad149f 100644 --- a/src/MarketAssistant.DataProviders/BinanceFundingRateResponse.cs +++ b/src/MarketAssistant.DataProviders/BinanceFundingRateResponse.cs @@ -11,7 +11,7 @@ public class BinanceFundingRateResponse public string Symbol { get; set; } = string.Empty; [JsonPropertyName("fundingRate")] - public string FundingRate { get; set; } = string.Empty; + public decimal FundingRate { get; set; } [JsonPropertyName("fundingTime")] public long FundingTime { get; set; } diff --git a/src/MarketAssistant.DataProviders/BinanceLongShortRatioResponse.cs b/src/MarketAssistant.DataProviders/BinanceLongShortRatioResponse.cs index 17cb341..6cca2e4 100644 --- a/src/MarketAssistant.DataProviders/BinanceLongShortRatioResponse.cs +++ b/src/MarketAssistant.DataProviders/BinanceLongShortRatioResponse.cs @@ -17,13 +17,13 @@ public class BinanceLongShortRatioResponse public string Symbol { get; set; } = string.Empty; [JsonPropertyName("longShortRatio")] - public string LongShortRatio { get; set; } = string.Empty; + public decimal LongShortRatio { get; set; } [JsonPropertyName("longAccount")] - public string LongAccount { get; set; } = string.Empty; + public decimal LongAccount { get; set; } [JsonPropertyName("shortAccount")] - public string ShortAccount { get; set; } = string.Empty; + public decimal ShortAccount { get; set; } [JsonPropertyName("timestamp")] public long Timestamp { get; set; } diff --git a/src/MarketAssistant.DataProviders/BinanceMarketDataService.cs b/src/MarketAssistant.DataProviders/BinanceMarketDataService.cs index 640ccc6..5d7f72e 100644 --- a/src/MarketAssistant.DataProviders/BinanceMarketDataService.cs +++ b/src/MarketAssistant.DataProviders/BinanceMarketDataService.cs @@ -20,6 +20,11 @@ public sealed class BinanceMarketDataService /// private const string ExchangeInfoCacheKey = "BinanceExchangeInfo"; + /// + /// 交易所信息回源闸门:冷启动并发首调只放一个请求出去(exchangeInfo weight=20),其余等结果共享缓存 + /// + private readonly SemaphoreSlim _exchangeInfoGate = new(1, 1); + private static readonly JsonSerializerOptions BinanceJsonSerializerOptions = new() { PropertyNameCaseInsensitive = true, @@ -65,14 +70,11 @@ private async Task CheckAndReadResponseAsync(HttpResponseMessage respons #region 24小时价格统计 - /// - /// 获取单个交易对的24小时价格变动统计 - /// public async Task Get24hrTickerAsync( string symbol, CancellationToken cancellationToken = default) { - var url = $"/api/v3/ticker/24hr?symbol={symbol.ToUpperInvariant()}"; + var url = $"/api/v3/ticker/24hr?symbol={Uri.EscapeDataString(symbol.ToUpperInvariant())}"; _logger.LogDebug("调用币安API: {Url}", url); using var httpClient = _httpClientFactory.CreateClient("Binance"); @@ -162,34 +164,44 @@ public async Task> GetAll24hrTickersFullAsync( public async Task GetExchangeInfoAsync( CancellationToken cancellationToken = default) { - // 尝试从缓存获取 if (_memoryCache.TryGetValue(ExchangeInfoCacheKey, out BinanceExchangeInfo? cachedInfo) && cachedInfo != null) { _logger.LogDebug("从缓存获取交易所信息,交易对数量: {Count}", cachedInfo.Symbols?.Count ?? 0); return cachedInfo; } - var url = "/api/v3/exchangeInfo?symbolStatus=TRADING&showPermissionSets=false"; - _logger.LogDebug("调用币安交易所信息API"); + await _exchangeInfoGate.WaitAsync(cancellationToken); + try + { + // 拿到闸门后再查一次:等闸门期间可能有先行者已完成回源 + if (_memoryCache.TryGetValue(ExchangeInfoCacheKey, out cachedInfo) && cachedInfo != null) + return cachedInfo; - using var httpClient = _httpClientFactory.CreateClient("Binance"); - var response = await httpClient.GetAsync(url, cancellationToken); - var content = await CheckAndReadResponseAsync(response, cancellationToken); + var url = "/api/v3/exchangeInfo?symbolStatus=TRADING&showPermissionSets=false"; + _logger.LogDebug("调用币安交易所信息API"); - var exchangeInfo = JsonSerializer.Deserialize(content, BinanceJsonSerializerOptions); - _logger.LogInformation("成功获取交易所信息,交易对数量: {Count}", exchangeInfo?.Symbols?.Count ?? 0); + using var httpClient = _httpClientFactory.CreateClient("Binance"); + var response = await httpClient.GetAsync(url, cancellationToken); + var content = await CheckAndReadResponseAsync(response, cancellationToken); - // 设置缓存(1小时过期) - if (exchangeInfo != null) - { - var cacheOptions = new MemoryCacheEntryOptions + var exchangeInfo = JsonSerializer.Deserialize(content, BinanceJsonSerializerOptions); + _logger.LogInformation("成功获取交易所信息,交易对数量: {Count}", exchangeInfo?.Symbols?.Count ?? 0); + + if (exchangeInfo != null) { - AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) - }; - _memoryCache.Set(ExchangeInfoCacheKey, exchangeInfo, cacheOptions); - } + var cacheOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) + }; + _memoryCache.Set(ExchangeInfoCacheKey, exchangeInfo, cacheOptions); + } - return exchangeInfo; + return exchangeInfo; + } + finally + { + _exchangeInfoGate.Release(); + } } /// @@ -252,9 +264,6 @@ public async Task> GetAll24hrTickersFullAsync( #region K线数据 - /// - /// 获取K线数据(OHLCV) - /// public async Task GetKlinesAsync( string symbol, string interval = "1d", @@ -263,7 +272,7 @@ public async Task> GetAll24hrTickersFullAsync( long? endTime = null, CancellationToken cancellationToken = default) { - var url = $"/api/v3/klines?symbol={symbol.ToUpperInvariant()}&interval={interval}&limit={limit}"; + var url = $"/api/v3/klines?symbol={Uri.EscapeDataString(symbol.ToUpperInvariant())}&interval={Uri.EscapeDataString(interval)}&limit={limit}"; if (startTime.HasValue) url += $"&startTime={startTime.Value}"; @@ -285,15 +294,12 @@ public async Task> GetAll24hrTickersFullAsync( #region 订单簿深度 - /// - /// 获取订单簿深度数据 - /// public async Task GetDepthAsync( string symbol, int limit = 100, CancellationToken cancellationToken = default) { - var url = $"/api/v3/depth?symbol={symbol.ToUpperInvariant()}&limit={limit}"; + var url = $"/api/v3/depth?symbol={Uri.EscapeDataString(symbol.ToUpperInvariant())}&limit={limit}"; _logger.LogDebug("调用币安深度API: {Symbol}", symbol); @@ -307,15 +313,12 @@ public async Task> GetAll24hrTickersFullAsync( #region 最近交易 - /// - /// 获取最近交易记录 - /// public async Task GetRecentTradesAsync( string symbol, int limit = 500, CancellationToken cancellationToken = default) { - var url = $"/api/v3/trades?symbol={symbol.ToUpperInvariant()}&limit={limit}"; + var url = $"/api/v3/trades?symbol={Uri.EscapeDataString(symbol.ToUpperInvariant())}&limit={limit}"; _logger.LogDebug("调用币安交易记录API: {Symbol}", symbol); @@ -329,14 +332,11 @@ public async Task> GetAll24hrTickersFullAsync( #region 期货API - /// - /// 获取资金费率 - /// public async Task GetPremiumIndexAsync( string symbol, CancellationToken cancellationToken = default) { - var url = $"/fapi/v1/premiumIndex?symbol={symbol.ToUpperInvariant()}"; + var url = $"/fapi/v1/premiumIndex?symbol={Uri.EscapeDataString(symbol.ToUpperInvariant())}"; _logger.LogDebug("调用币安期货资金费率API: {Symbol}", symbol); using var httpClient = _httpClientFactory.CreateClient("BinanceFutures"); @@ -346,9 +346,6 @@ public async Task> GetAll24hrTickersFullAsync( return JsonSerializer.Deserialize(content, BinanceJsonSerializerOptions); } - /// - /// 获取历史资金费率 - /// public async Task> GetFundingRateHistoryAsync( string symbol, int limit = 30, @@ -356,7 +353,7 @@ public async Task> GetFundingRateHistoryAsync( long? endTime = null, CancellationToken cancellationToken = default) { - var url = $"/fapi/v1/fundingRate?symbol={symbol.ToUpperInvariant()}&limit={limit}"; + var url = $"/fapi/v1/fundingRate?symbol={Uri.EscapeDataString(symbol.ToUpperInvariant())}&limit={limit}"; if (startTime.HasValue) url += $"&startTime={startTime.Value}"; @@ -373,18 +370,23 @@ public async Task> GetFundingRateHistoryAsync( ?? new List(); } - /// - /// 获取多空比数据 - /// + /// 统计端点(枚举白名单,杜绝路径拼接) public async Task> GetLongShortRatioAsync( - string endpoint, + LongShortRatioEndpoint 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); + var endpointPath = endpoint switch + { + LongShortRatioEndpoint.GlobalLongShortAccountRatio => "globalLongShortAccountRatio", + LongShortRatioEndpoint.TopLongShortAccountRatio => "topLongShortAccountRatio", + LongShortRatioEndpoint.TopLongShortPositionRatio => "topLongShortPositionRatio", + _ => throw new ArgumentOutOfRangeException(nameof(endpoint), endpoint, null) + }; + var url = $"/futures/data/{endpointPath}?symbol={Uri.EscapeDataString(symbol.ToUpperInvariant())}&period={Uri.EscapeDataString(period)}&limit={limit}"; + _logger.LogDebug("调用币安期货多空比API: {Symbol} {Endpoint}", symbol, endpointPath); using var httpClient = _httpClientFactory.CreateClient("BinanceFutures"); var response = await httpClient.GetAsync(url, cancellationToken); @@ -394,16 +396,13 @@ public async Task> GetLongShortRatioAsync( ?? 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}"; + var url = $"/futures/data/openInterestHist?symbol={Uri.EscapeDataString(symbol.ToUpperInvariant())}&period={Uri.EscapeDataString(period)}&limit={limit}"; _logger.LogDebug("调用币安期货持仓量API: {Symbol}", symbol); using var httpClient = _httpClientFactory.CreateClient("BinanceFutures"); @@ -416,3 +415,18 @@ public async Task> GetOpenInterestHistAsync( #endregion } + +/// +/// 多空比统计端点(币安 /futures/data 下三种口径的白名单) +/// +public enum LongShortRatioEndpoint +{ + /// 全局账户多空比 + GlobalLongShortAccountRatio, + + /// 顶级交易员账户多空比 + TopLongShortAccountRatio, + + /// 顶级交易员持仓多空比 + TopLongShortPositionRatio +} diff --git a/src/MarketAssistant.DataProviders/BinanceOpenInterestResponse.cs b/src/MarketAssistant.DataProviders/BinanceOpenInterestResponse.cs index d2f2284..54b19d5 100644 --- a/src/MarketAssistant.DataProviders/BinanceOpenInterestResponse.cs +++ b/src/MarketAssistant.DataProviders/BinanceOpenInterestResponse.cs @@ -11,10 +11,10 @@ public class BinanceOpenInterestResponse public string Symbol { get; set; } = string.Empty; [JsonPropertyName("sumOpenInterest")] - public string SumOpenInterest { get; set; } = string.Empty; + public decimal SumOpenInterest { get; set; } [JsonPropertyName("sumOpenInterestValue")] - public string SumOpenInterestValue { get; set; } = string.Empty; + public decimal SumOpenInterestValue { get; set; } [JsonPropertyName("timestamp")] public long Timestamp { get; set; } diff --git a/src/MarketAssistant.DataProviders/BinancePremiumIndexResponse.cs b/src/MarketAssistant.DataProviders/BinancePremiumIndexResponse.cs index 4edca4b..a1aa598 100644 --- a/src/MarketAssistant.DataProviders/BinancePremiumIndexResponse.cs +++ b/src/MarketAssistant.DataProviders/BinancePremiumIndexResponse.cs @@ -11,10 +11,10 @@ public class BinancePremiumIndexResponse public string Symbol { get; set; } = string.Empty; [JsonPropertyName("markPrice")] - public string MarkPrice { get; set; } = string.Empty; + public decimal MarkPrice { get; set; } [JsonPropertyName("lastFundingRate")] - public string LastFundingRate { get; set; } = string.Empty; + public decimal LastFundingRate { get; set; } [JsonPropertyName("nextFundingTime")] public long NextFundingTime { get; set; } diff --git a/src/MarketAssistant.DataProviders/BinanceWebSocketService.cs b/src/MarketAssistant.DataProviders/BinanceWebSocketService.cs index ba35248..9485a45 100644 --- a/src/MarketAssistant.DataProviders/BinanceWebSocketService.cs +++ b/src/MarketAssistant.DataProviders/BinanceWebSocketService.cs @@ -12,9 +12,12 @@ namespace MarketAssistant.DataProviders; public sealed class BinanceWebSocketService : IAsyncDisposable, IDisposable { private const string WsBaseUrl = "wss://stream.binance.com:9443/stream?streams="; + private const int ReconnectDelayMs = 5000; private readonly ILogger _logger; private ClientWebSocket? _ws; private CancellationTokenSource? _cts; + private volatile bool _disposed; + private int _retryScheduled; /// /// 各订阅方(价格告警、交易监控、收藏页、资产详情)独立维护的交易对集合。 @@ -24,12 +27,19 @@ public sealed class BinanceWebSocketService : IAsyncDisposable, IDisposable private readonly Dictionary> _subscriberSymbols = new(StringComparer.OrdinalIgnoreCase); /// - /// 当前实际连接的交易对集合(所有订阅方并集,小写),供 ReconnectAsync 使用。 + /// 当前实际连接的交易对集合(所有订阅方并集,小写),供重连使用。 /// private HashSet _subscribedSymbols = new(StringComparer.OrdinalIgnoreCase); private readonly Lock _lock = new(); + /// + /// 连接生命周期闸门:订阅变更、断开、重连必须整体串行执行。 + /// UI 线程(收藏页/资产页)与后台线程(价格告警/交易监控)可能并发触发, + /// 无闸门时两个并发重连会互相覆盖 _ws,导致已连接 socket 成为孤儿并重复推送。 + /// + private readonly SemaphoreSlim _lifecycleGate = new(1, 1); + /// /// 收到价格更新时触发,参数为 (symbol, lastPrice, priceChangePercent) /// @@ -50,19 +60,28 @@ public async Task SubscribeAsync(string subscriberKey, IEnumerable symbo { var symbolSet = symbols.Select(s => s.ToLowerInvariant()).Distinct().ToHashSet(); - bool changed; - lock (_lock) + await _lifecycleGate.WaitAsync(); + try { - _subscriberSymbols[subscriberKey] = symbolSet; - changed = RecomputeSubscribedSymbolsLocked(); - } - - if (!changed) return; + bool changed; + lock (_lock) + { + _subscriberSymbols[subscriberKey] = symbolSet; + changed = RecomputeSubscribedSymbolsLocked(); + } - if (_subscribedSymbols.Count == 0) - await DisconnectAsync(); - else - await ReconnectAsync(); + if (!changed) return; + await ApplySubscriptionAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Binance WebSocket 订阅连接失败,{Delay}ms 后自动重试", ReconnectDelayMs); + ScheduleConnectRetry(); + } + finally + { + _lifecycleGate.Release(); + } } /// @@ -71,18 +90,27 @@ public async Task SubscribeAsync(string subscriberKey, IEnumerable symbo /// public async Task UnsubscribeAllAsync(string subscriberKey) { - bool changed; - lock (_lock) + await _lifecycleGate.WaitAsync(); + try { - changed = _subscriberSymbols.Remove(subscriberKey) && RecomputeSubscribedSymbolsLocked(); - } - - if (!changed) return; + bool changed; + lock (_lock) + { + changed = _subscriberSymbols.Remove(subscriberKey) && RecomputeSubscribedSymbolsLocked(); + } - if (_subscribedSymbols.Count == 0) - await DisconnectAsync(); - else - await ReconnectAsync(); + if (!changed) return; + await ApplySubscriptionAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Binance WebSocket 退订重连失败,{Delay}ms 后自动重试", ReconnectDelayMs); + ScheduleConnectRetry(); + } + finally + { + _lifecycleGate.Release(); + } } /// @@ -101,11 +129,15 @@ private bool RecomputeSubscribedSymbolsLocked() return true; } - private async Task ReconnectAsync() + /// + /// 按当前并集重建连接(先断开旧连接再连接新集合)。调用方必须已持有 。 + /// 连接失败时抛出异常(资源已清理),由调用方决定重试策略。 + /// + private async Task ApplySubscriptionAsync() { - await DisconnectAsync(); + await DisconnectCoreAsync(); - List symbols; + HashSet symbols; lock (_lock) { symbols = [.. _subscribedSymbols]; @@ -116,19 +148,24 @@ private async Task ReconnectAsync() var streams = string.Join("/", symbols.Select(s => $"{s}@miniTicker")); var url = WsBaseUrl + streams; - _cts = new CancellationTokenSource(); - _ws = new ClientWebSocket(); - + var cts = new CancellationTokenSource(); + var ws = new ClientWebSocket(); try { _logger.LogInformation("连接 Binance WebSocket,订阅 {Count} 个交易对", symbols.Count); - await _ws.ConnectAsync(new Uri(url), _cts.Token); - _ = ReceiveLoopAsync(_ws, _cts.Token); + await ws.ConnectAsync(new Uri(url), cts.Token); } - catch (Exception ex) + catch { - _logger.LogError(ex, "Binance WebSocket 连接失败"); + cts.Dispose(); + ws.Dispose(); + throw; } + + // 连接成功后才落字段,失败路径不残留半初始化状态 + _ws = ws; + _cts = cts; + _ = ReceiveLoopAsync(ws, cts.Token); } private async Task ReceiveLoopAsync(ClientWebSocket ws, CancellationToken ct) @@ -162,22 +199,98 @@ private async Task ReceiveLoopAsync(ClientWebSocket ws, CancellationToken ct) } catch (OperationCanceledException) { - // 正常取消 + // 正常取消(退订或释放) + } + catch (Exception ex) + { + // 网络断开、socket 被并发释放等均视为断线;仅当本连接仍是当前连接时才重连, + // 避免旧循环把新连接顶掉 + _logger.LogWarning(ex, "Binance WebSocket 断开,将在 {Delay}ms 后重连", ReconnectDelayMs); + await ScheduleReconnectIfCurrentAsync(ws, ct); } - catch (WebSocketException ex) + } + + /// + /// 延迟后重连,但仅当 仍是当前连接时执行; + /// 期间若发生过退订/重订阅/释放则放弃,避免覆盖新状态。 + /// + private async Task ScheduleReconnectIfCurrentAsync(ClientWebSocket ws, CancellationToken ct) + { + try { - _logger.LogWarning(ex, "Binance WebSocket 断开,将在 5 秒后重连"); // 使用 ct 而非 CancellationToken.None,确保应用关闭时重连延迟可被取消 - try - { - await Task.Delay(5000, ct); - } - catch (OperationCanceledException) + await Task.Delay(ReconnectDelayMs, ct); + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) + { + return; + } + + await _lifecycleGate.WaitAsync(); + try + { + if (_disposed || _ws != ws) return; + await ApplySubscriptionAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Binance WebSocket 重连失败,{Delay}ms 后自动重试", ReconnectDelayMs); + ScheduleConnectRetry(); + } + finally + { + _lifecycleGate.Release(); + } + } + + /// + /// 连接失败后的退避重试(去重排队)。成功连接或并集清空后自动退出。 + /// + private void ScheduleConnectRetry() + { + if (Interlocked.Exchange(ref _retryScheduled, 1) == 1) return; + _ = ConnectRetryLoopAsync(); + } + + private async Task ConnectRetryLoopAsync() + { + try + { + while (true) { - return; + await Task.Delay(ReconnectDelayMs); + + await _lifecycleGate.WaitAsync(); + bool shouldContinue; + try + { + if (_disposed) return; + lock (_lock) + { + if (_subscribedSymbols.Count == 0) return; + } + + await ApplySubscriptionAsync(); + shouldContinue = false; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Binance WebSocket 重试连接失败,将继续重试"); + shouldContinue = true; + } + finally + { + Interlocked.Exchange(ref _retryScheduled, 0); + _lifecycleGate.Release(); + } + + if (!shouldContinue) return; } - if (!ct.IsCancellationRequested) - _ = ReconnectAsync(); + } + catch (Exception ex) + { + Interlocked.Exchange(ref _retryScheduled, 0); + _logger.LogWarning(ex, "Binance WebSocket 重连循环终止"); } } @@ -210,30 +323,37 @@ private void ProcessMessage(string json) } } - private async Task DisconnectAsync() + /// + /// 断开并清理当前连接。调用方必须已持有 。 + /// 先清字段再关闭,确保在飞的重连检查立即把本连接视为过期。 + /// + private async Task DisconnectCoreAsync() { - if (_cts != null) + var cts = _cts; + _cts = null; + var ws = _ws; + _ws = null; + + if (cts != null) { - await _cts.CancelAsync(); - _cts.Dispose(); - _cts = null; + await cts.CancelAsync(); + cts.Dispose(); } - if (_ws != null) + if (ws != null) { - if (_ws.State == WebSocketState.Open) + if (ws.State == WebSocketState.Open) { try { - await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "unsubscribe", CancellationToken.None); + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "unsubscribe", CancellationToken.None); } catch { // 忽略关闭异常 } } - _ws.Dispose(); - _ws = null; + ws.Dispose(); } } @@ -242,17 +362,29 @@ private async Task DisconnectAsync() /// public async ValueTask DisposeAsync() { - await DisconnectAsync().ConfigureAwait(false); + _disposed = true; + await _lifecycleGate.WaitAsync().ConfigureAwait(false); + try + { + await DisconnectCoreAsync().ConfigureAwait(false); + } + finally + { + _lifecycleGate.Release(); + } + _lifecycleGate.Dispose(); GC.SuppressFinalize(this); } public void Dispose() { + _disposed = true; _cts?.Cancel(); _cts?.Dispose(); _cts = null; - // 同步释放底层 WebSocket,不等待异步 CloseOutputAsync + // 同步释放底层 WebSocket,不等待异步 CloseOutputAsync; + // 在飞的接收循环会因 _ws 不再匹配而放弃重连 _ws?.Dispose(); _ws = null; diff --git a/src/MarketAssistant.DataProviders/CoinDeskApiService.cs b/src/MarketAssistant.DataProviders/CoinDeskApiService.cs deleted file mode 100644 index 168d9b5..0000000 --- a/src/MarketAssistant.DataProviders/CoinDeskApiService.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Net.Http.Json; -using System.Text.Json; -using Microsoft.Extensions.Logging; - - -namespace MarketAssistant.DataProviders; - -/// -/// 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 deleted file mode 100644 index 7c4d7de..0000000 --- a/src/MarketAssistant.DataProviders/CoinDeskMetadataResponse.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System.Text.Json.Serialization; - -namespace MarketAssistant.DataProviders; - -/// -/// 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 deleted file mode 100644 index 8cbe9d9..0000000 --- a/src/MarketAssistant.DataProviders/CoinDeskNewsResponse.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Text.Json.Serialization; - -namespace MarketAssistant.DataProviders; - -/// -/// 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 index 05872cc..e7e5fd3 100644 --- a/src/MarketAssistant.DataProviders/CoinGeckoApiService.cs +++ b/src/MarketAssistant.DataProviders/CoinGeckoApiService.cs @@ -57,9 +57,6 @@ private async Task ThrottledExecuteAsync(Func> action, return await action(httpClient); } - /// - /// 获取市场数据(支持筛选) - /// public async Task> GetCoinsMarketsAsync( string vsCurrency = "usd", string? category = null, @@ -100,9 +97,6 @@ public async Task> GetCoinsMarketsAsync( }, cancellationToken); } - /// - /// 获取单币种市场数据(含价格变化百分比) - /// public async Task GetCoinMarketDataAsync( string coinId, string vsCurrency = "usd", @@ -117,9 +111,6 @@ await httpClient.GetFromJsonAsync(url, cancellationToken), cancellationToken); } - /// - /// 获取币种在各交易所的交易对数据 - /// public async Task GetCoinTickersAsync( string coinId, CancellationToken cancellationToken = default) @@ -135,9 +126,6 @@ await httpClient.GetFromJsonAsync( cancellationToken); } - /// - /// 搜索币种 - /// public async Task SearchCoinsAsync( string query, CancellationToken cancellationToken = default) @@ -199,9 +187,9 @@ await httpClient.GetFromJsonAsync( /// 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)) + // 优先使用内置映射表(覆盖主流币种,避免一次 HTTP 请求)。 + // TryGet 区分"未命中"与"映射值恰与输入相同"(如 dai→dai),后者不再白打一次 /coins/list + if (MarketAssistant.Infrastructure.Core.CryptoSymbolConverter.TryGetCoinGeckoId(baseSymbol, out var mappedId)) { return mappedId; } @@ -222,7 +210,7 @@ await httpClient.GetFromJsonAsync( return hit?.Id; }, cancellationToken); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning(ex, "调用 /coins/list 解析 coinId 失败: {Symbol}", baseSymbol); return null; diff --git a/src/MarketAssistant.DataProviders/StringToDecimalConverter.cs b/src/MarketAssistant.DataProviders/StringToDecimalConverter.cs index ed77bf4..e95fb4e 100644 --- a/src/MarketAssistant.DataProviders/StringToDecimalConverter.cs +++ b/src/MarketAssistant.DataProviders/StringToDecimalConverter.cs @@ -25,7 +25,7 @@ public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, Json if (reader.TokenType == JsonTokenType.String) { var stringValue = reader.GetString(); - if (decimal.TryParse(stringValue, out var value)) + if (decimal.TryParse(stringValue, NumberStyles.Number, CultureInfo.InvariantCulture, out var value)) return value; } else if (reader.TokenType == JsonTokenType.Number) @@ -33,12 +33,14 @@ public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, Json return reader.GetDecimal(); } - return 0m; + // 与 System.Text.Json 默认行为对齐:无法解析(含 null/空串喂给非空字段)直接报错, + // 绝不静默归零——0 会被下游当成合法行情参与计算 + throw new JsonException($"无法将 '{reader.TokenType}' 解析为非空 decimal。"); } public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options) { - writer.WriteStringValue(value.ToString()); + writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture)); } } @@ -68,7 +70,7 @@ private sealed class NullableDecimalConverter : JsonConverter public override void Write(Utf8JsonWriter writer, decimal? value, JsonSerializerOptions options) { if (value.HasValue) - writer.WriteStringValue(value.Value.ToString()); + writer.WriteStringValue(value.Value.ToString(CultureInfo.InvariantCulture)); else writer.WriteNullValue(); } diff --git a/src/MarketAssistant.Infrastructure/Providers/ModelProvider.cs b/src/MarketAssistant.Infrastructure/Providers/ModelProvider.cs index 9531217..c7e182a 100644 --- a/src/MarketAssistant.Infrastructure/Providers/ModelProvider.cs +++ b/src/MarketAssistant.Infrastructure/Providers/ModelProvider.cs @@ -33,21 +33,12 @@ public record ModelProvider( { private IModelProviderPolicy EffectivePolicy => Policy ?? DefaultModelProviderPolicy.Instance; - /// - /// 获取指定模型使用的 API 协议。 - /// public ModelApiProtocol GetProtocol(string modelId) => EffectivePolicy.GetProtocol(this, modelId); - /// - /// 判断当前凭据是否允许访问模型列表。 - /// public bool CanListModels(string? apiKey) => SupportsModelListing && (!ModelListingRequiresApiKey || !string.IsNullOrWhiteSpace(apiKey)); - /// - /// 判断指定模型是否需要 API Key。 - /// /// /// “免费模型”不等于“匿名接口”。只有服务商通过模型目录或稳定命名约定明确确认无需鉴权时,才允许留空 API Key。 /// diff --git a/src/MarketAssistant.Infrastructure/Tokenization/TiktokenTokenCounter.cs b/src/MarketAssistant.Infrastructure/Tokenization/TiktokenTokenCounter.cs index ad94d36..8136686 100644 --- a/src/MarketAssistant.Infrastructure/Tokenization/TiktokenTokenCounter.cs +++ b/src/MarketAssistant.Infrastructure/Tokenization/TiktokenTokenCounter.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.ML.Tokenizers; namespace MarketAssistant.Infrastructure.Tokenization; @@ -13,9 +15,15 @@ public sealed class TiktokenTokenCounter : ITokenCounter private const double OtherTokenRatio = 4.0; private readonly Lazy _tokenizer; + private readonly ILogger _logger; - public TiktokenTokenCounter() + public TiktokenTokenCounter() : this(NullLogger.Instance) { + } + + public TiktokenTokenCounter(ILogger logger) + { + _logger = logger; // 延迟加载词表:cl100k_base 对中文分词偏保守(token 数更多),用于估算更安全; // 且与具体 LLM 提供商无关,适用于 DeepSeek/Qwen 等非 OpenAI 模型。 _tokenizer = new Lazy(() => @@ -26,8 +34,10 @@ public TiktokenTokenCounter() } catch (Exception ex) { - // 离线环境或编码数据不可用时回退到启发式估算 - System.Diagnostics.Debug.WriteLine($"TiktokenTokenCounter 初始化 tiktoken 失败,回退到启发式估算: {ex.Message}"); + // 离线环境或编码数据不可用时回退到启发式估算。 + // Release 下 Debug.WriteLine 会被编译掉,必须用日志留下诊断痕迹, + // 否则生产环境的 token 计数偏差完全不可见 + _logger.LogWarning(ex, "tiktoken 词表加载失败,Token 计数回退到启发式估算(精度降低)"); return null; } }, LazyThreadSafetyMode.ExecutionAndPublication); diff --git a/src/MarketAssistant.Rag/Extensions/ServiceCollectionExtensions.cs b/src/MarketAssistant.Rag/Extensions/ServiceCollectionExtensions.cs index 91094ff..4029d28 100644 --- a/src/MarketAssistant.Rag/Extensions/ServiceCollectionExtensions.cs +++ b/src/MarketAssistant.Rag/Extensions/ServiceCollectionExtensions.cs @@ -54,9 +54,6 @@ public static IServiceCollection AddRagServices(this IServiceCollection services services.AddSingleton(); - // 延迟工厂:仅在真正使用 RAG 时解析,避免宿主浏览页面时触发整条 RAG 依赖链构造 - services.AddSingleton>(sp => sp.GetRequiredService); - return services; } } diff --git a/src/MarketAssistant.Rag/Infrastructure/Factories/IImageCaptionClientFactory.cs b/src/MarketAssistant.Rag/Infrastructure/Factories/IImageCaptionClientFactory.cs new file mode 100644 index 0000000..39c9c56 --- /dev/null +++ b/src/MarketAssistant.Rag/Infrastructure/Factories/IImageCaptionClientFactory.cs @@ -0,0 +1,12 @@ +using Microsoft.SemanticKernel.ChatCompletion; + +namespace MarketAssistant.Infrastructure.Factories; + +/// +/// 图像描述(Caption)客户端工厂:为 Rag 层提供可选的多模态聊天能力。 +/// AI 未配置时返回 null,由调用方降级为占位符描述。 +/// +public interface IImageCaptionClientFactory +{ + IChatCompletionService? Create(); +} diff --git a/src/MarketAssistant.Rag/Interfaces/IDocumentBlockReader.cs b/src/MarketAssistant.Rag/Interfaces/IDocumentBlockReader.cs index fe89439..007c224 100644 --- a/src/MarketAssistant.Rag/Interfaces/IDocumentBlockReader.cs +++ b/src/MarketAssistant.Rag/Interfaces/IDocumentBlockReader.cs @@ -7,5 +7,7 @@ namespace MarketAssistant.Rag.Interfaces; public interface IDocumentBlockReader { bool CanRead(string filePath); - Task> ReadBlocksAsync(string filePath); + + /// 取消令牌:文档解析(含 PDF/DOCX 解码)阶段可取消 + Task> ReadBlocksAsync(string filePath, CancellationToken cancellationToken = default); } diff --git a/src/MarketAssistant.Rag/Interfaces/IMarkdownConverter.cs b/src/MarketAssistant.Rag/Interfaces/IMarkdownConverter.cs index 89d3f5e..2737f65 100644 --- a/src/MarketAssistant.Rag/Interfaces/IMarkdownConverter.cs +++ b/src/MarketAssistant.Rag/Interfaces/IMarkdownConverter.cs @@ -1,21 +1,8 @@ namespace MarketAssistant.Rag.Interfaces; -/// -/// 文档到Markdown转换器接口 -/// public interface IMarkdownConverter { - /// - /// 判断是否支持转换指定类型的文件 - /// - /// 文件路径 - /// 是否支持转换 bool CanConvert(string filePath); - /// - /// 将文档转换为Markdown文本 - /// - /// 文件路径 - /// 转换后的Markdown文本 Task ConvertToMarkdownAsync(string filePath); } diff --git a/src/MarketAssistant.Rag/Services/ClipImageEmbeddingService.cs b/src/MarketAssistant.Rag/Services/ClipImageEmbeddingService.cs index 5be3c74..7929f7b 100644 --- a/src/MarketAssistant.Rag/Services/ClipImageEmbeddingService.cs +++ b/src/MarketAssistant.Rag/Services/ClipImageEmbeddingService.cs @@ -1,3 +1,4 @@ +using MarketAssistant.Infrastructure.Factories; using MarketAssistant.Rag.Interfaces; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; @@ -11,25 +12,10 @@ namespace MarketAssistant.Rag.Services; /// -/// CLIP图像嵌入服务(多模态RAG的核心组件) -/// -/// 【学习要点】: -/// 1. 多模态AI:处理图像和文本的AI模型 -/// 2. CLIP模型:OpenAI发布的多模态模型,可以将图像和文本映射到同一个空间 -/// 3. 降级策略:构建系统的可靠性保障,在服务失败时自动切换到备用方案 -/// 4. ONNX运行时:跨平台的机器学习推理引擎,用于部署机器学习模型 -/// -/// 【功能概要】: -/// - 双重能力:图像嵌入(向量化) + 图像描述生成(Caption) -/// - 分层降级:CLIP模型 -> 哈希(图像) / 多模态服务 -> 占位符(文本) -/// - 资源管理:实现IDisposable自动释放ONNX会话资源 -/// - 延迟初始化:首次调用时才加载模型,优化启动速度 -/// -/// 【技术栈】: -/// - Microsoft.ML.OnnxRuntime:ONNX模型推理 -/// - SkiaSharp:跨平台图像处理 -/// - Microsoft.SemanticKernel:多模态服务编排 -/// - Microsoft.Extensions.AI:AI嵌入标准接口 +/// CLIP 图像嵌入服务(多模态 RAG 组件)。 +/// 双重能力:图像嵌入(ONNX 推理)+ 图像描述生成(Caption,可选多模态服务)。 +/// 降级策略:图像嵌入失败抛出异常由调用方降级为 Caption 文本召回; +/// Caption 不可用或失败时返回占位符。ONNX 会话延迟到首次调用时加载。 /// public class ClipImageEmbeddingService : IImageEmbeddingService, IDisposable { @@ -40,47 +26,31 @@ public class ClipImageEmbeddingService : IImageEmbeddingService, IDisposable 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; // 结构化日志记录 - private readonly IChatCompletionService? _chat; // 多模态聊天服务(可选) - private readonly string? _modelPath; // CLIP ONNX模型文件路径 + private readonly ILogger _logger; + private readonly IChatCompletionService? _chat; + private readonly string? _modelPath; - // 【状态管理】:ONNX推理会话和初始化标志 - private InferenceSession? _session; // ONNX运行时推理会话 - private volatile bool _initAttempted; // 防止重复初始化的标志 - private readonly object _initLock = new(); // 初始化锁:保证并发下仅创建一个 InferenceSession + private InferenceSession? _session; + private volatile bool _initAttempted; + private readonly object _initLock = new(); /// - /// 构造函数:使用依赖注入获取服务,支持环境变量配置模型路径 - /// - /// 【学习要点】: - /// - 依赖注入模式:通过IServiceProvider获取可选服务,遵循单一职责原则 - /// - 配置优先级:环境变量 > 默认路径,适应不同部署环境 - /// - 延迟加载:构造时不加载模型,首次使用时才初始化 + /// 构造函数:模型路径优先取环境变量 CLIP_IMAGE_ONNX,否则用本地默认路径。 + /// Caption 客户端由工厂延迟创建,AI 未配置时为 null(Caption 降级为占位符)。 /// - public ClipImageEmbeddingService(ILogger logger, IServiceProvider sp) + public ClipImageEmbeddingService(ILogger logger, IImageCaptionClientFactory captionClientFactory) { _logger = logger; - // 尝试获取聊天服务(多模态Caption功能),可选。 - // 注意:此处为服务定位器模式,若 IChatCompletionService 为 Scoped 且本服务为 Singleton, - // 会形成 captive dependency(Scoped 实例被 Singleton 捕获)。当前 IChatCompletionService - // 注册为 Singleton,风险可控;如改为 Scoped 生命周期需重构为工厂委托注入。 - _chat = sp.GetService(); + _chat = captionClientFactory.Create(); - // 模型路径配置:优先环境变量,否则使用本地默认路径 _modelPath = Environment.GetEnvironmentVariable("CLIP_IMAGE_ONNX") ?? Path.Combine(AppContext.BaseDirectory, "models", "clip-image.onnx"); } /// - /// 生成图像嵌入向量(RAG系统的核心功能) - /// - /// 【实现细节】: - /// - ONNX推理:使用预训练CLIP模型进行图像编码 - /// - 预处理:将图像预处理为标准张量格式 - /// - 向量归一化:确保向量在单位超球面上,便于余弦相似度计算 - /// - 失败语义:任何失败都抛出 InvalidOperationException,由调用方降级为 Caption 文本召回; - /// 不降级为哈希向量,也不产出零向量(P1-03) + /// 生成图像嵌入向量(ONNX 推理 + 归一化)。 + /// 失败语义:任何失败都抛出 InvalidOperationException,由调用方降级为 Caption 文本召回; + /// 不降级为哈希向量,也不产出零向量(P1-03)。 /// public async Task> GenerateAsync(byte[] imageBytes, CancellationToken ct = default) { @@ -140,12 +110,7 @@ public async Task> GenerateAsync(byte[] imageBytes, Cancellatio } /// - /// 解析ONNX模型的输入输出节点名称 - /// - /// 【自适应】:自动适配模型的图像输入和输出节点名称 - /// 常见名称: - /// - imageInput: 图像输入节点名称 ("pixel_values", "image") - /// - imageOutput: 图像输出节点名称 ("image_embeds", "pooler_output") + /// 解析 ONNX 模型的输入输出节点名称,自动适配不同 CLIP 导出模型的常见命名。 /// private (string? imageInput, string? imageOutput) ResolveVisionIO() { @@ -175,11 +140,8 @@ public async Task> GenerateAsync(byte[] imageBytes, Cancellatio } /// - /// 创建模型输入列表(处理多输入模型) - /// - /// 【兼容性】: - /// 某些CLIP模型可能需要多输入(如文本+图像双塔模型) - /// 此方法确保提供图像输入,并为不需要的文本输入提供空/默认值 + /// 创建模型输入列表。部分 CLIP 导出模型带文本双塔输入, + /// 对非图像输入补空张量,保证单图推理可执行。 /// private List CreateModelInputs(string imageInputName, DenseTensor imageTensor) { @@ -222,17 +184,8 @@ private List CreateModelInputs(string imageInputName, DenseTenso } /// - /// 生成图像描述(多模态生成能力,可选) - /// - /// 【学习要点】: - /// - 多模态提示:结合文本指令和图像数据的复杂提示 - /// - 降级保护:服务不可用或异常时,返回占位符 - /// - 异步处理:支持取消令牌,避免长时阻塞 - /// - /// 【业务价值】: - /// - 增强搜索:为图像提供文本描述,支持文本搜索 - /// - 可访问性:辅助视障用户理解图像内容 - /// - 降级兼容:无文本描述时,图像内容仍可被索引(虽然不准确) + /// 生成图像描述(多模态生成能力,可选)。 + /// 多模态服务不可用或生成失败时降级为占位符,图像仍可被索引。 /// public async Task CaptionAsync(byte[] imageBytes, CancellationToken ct = default) { @@ -275,17 +228,9 @@ public async Task CaptionAsync(byte[] imageBytes, CancellationToken ct = } /// - /// 延迟初始化ONNX推理会话(单例/缓存模式) - /// - /// 【学习要点】: - /// - 延迟加载:避免启动时加载大模型影响性能 - /// - 状态锁:使用标志位确保只尝试一次,避免重复失败 - /// - 资源检查:验证模型文件存在性,提供清晰的错误信息 - /// - 异常吞没:初始化失败不影响降级功能的可用性 - /// - /// 【设计模式】: - /// - 懒加载模式:通过标志位控制初始化 - /// - 资源管理:ONNX会话需在Dispose时正确释放 + /// 延迟初始化 ONNX 推理会话。双检查锁保证并发下仅创建一个会话; + /// 初始化失败不抛出(仅标记已尝试),由 GenerateAsync 因会话不可用而失败, + /// 调用方降级为 Caption 文本召回。 /// private void EnsureSession() { @@ -364,11 +309,9 @@ private void LogModelInfo() } /// - /// 图像预处理:将原始字节转换为CLIP模型所需的标准张量 - /// - /// 【处理流程】:图像解码 -> 缩放到224x224 -> ImageNet标准化 -> CHW张量格式 - /// CLIP 模型训练时使用 ImageNet 均值和方差标准化,不做标准化会导致 - /// 图像嵌入与文本嵌入向量空间错位,跨模态检索失效。 + /// 图像预处理:解码 → 缩放到 224x224 → ImageNet 标准化 → CHW 张量。 + /// 必须用 CLIP 训练时的均值方差标准化,否则图像与文本嵌入向量空间错位, + /// 跨模态检索失效。预处理失败必须抛出,静默零张量会污染多模态召回。 /// private static DenseTensor PreprocessToTensor(byte[] bytes) { @@ -417,18 +360,8 @@ private static DenseTensor PreprocessToTensor(byte[] bytes) } /// - /// 向量归一化和维度调整(确保向量数学正确性) - /// - /// 【学习要点】: - /// - L2归一化:||v|| = 1,确保余弦相似度计算准确 - /// - 维度对齐:不同模型输出维度可能不同,需要统一 - /// - 鲁棒性:处理零向量和维度不匹配情况 - /// - 零填充/截断:避免循环填充引入周期性模式破坏余弦相似度 - /// - /// 【数学原理】: - /// - L2范数:||v|| = sqrt(v1² + v2² + ... + vn²) - /// - 归一化:v_norm = v / ||v|| - /// - 余弦相似度:cos(θ) = (a·b) / (||a|| · ||b||),归一化后为 a·b + /// 向量归一化与维度调整:L2 归一化保证余弦相似度计算准确; + /// 目标维度不足零填充、超长截断(零填充不引入伪周期模式)。 /// /// 原始输出向量 /// 目标维度 @@ -458,18 +391,7 @@ private static float[] NormalizeAndResize(float[] src, int dim) } /// - /// 资源释放:正确释放ONNX推理会话 - /// - /// 【学习要点】: - /// - 资源管理:ONNX会话包含非托管资源,需显式释放 - /// - IDisposable模式:.NET资源管理的标准模式 - /// - 内存泄漏预防:机器学习模型通常占用大量内存 - /// - 最佳实践:在容器生命周期结束时调用Dispose - /// - /// 【实现细节】: - /// - 显式释放:避免长期占用GPU/CPU内存 - /// - 空值检查:_session可能为null(重复释放安全) - /// - 托管资源:_logger, _chatCompletion等由DI容器管理,无需手动释放 + /// 释放 ONNX 推理会话的非托管资源(重复释放安全)。 /// public void Dispose() { diff --git a/src/MarketAssistant.Rag/Services/DocxBlockReader.cs b/src/MarketAssistant.Rag/Services/DocxBlockReader.cs index 10bca94..76474d2 100644 --- a/src/MarketAssistant.Rag/Services/DocxBlockReader.cs +++ b/src/MarketAssistant.Rag/Services/DocxBlockReader.cs @@ -17,9 +17,9 @@ public DocxBlockReader(MarkdownDocumentBlockReader markdownReader) public bool CanRead(string filePath) => filePath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase); - public async Task> ReadBlocksAsync(string filePath) + public async Task> ReadBlocksAsync(string filePath, CancellationToken cancellationToken = default) { // 直接委托给 MarkdownDocumentBlockReader 处理 - return await _markdownReader.ReadBlocksAsync(filePath); + return await _markdownReader.ReadBlocksAsync(filePath, cancellationToken); } } diff --git a/src/MarketAssistant.Rag/Services/DocxMarkdownConverter.cs b/src/MarketAssistant.Rag/Services/DocxMarkdownConverter.cs index bcbdb67..a1a81e3 100644 --- a/src/MarketAssistant.Rag/Services/DocxMarkdownConverter.cs +++ b/src/MarketAssistant.Rag/Services/DocxMarkdownConverter.cs @@ -6,9 +6,6 @@ namespace MarketAssistant.Rag.Services; -/// -/// 列表信息 -/// public class ListInfo { public string Prefix { get; set; } = string.Empty; @@ -62,14 +59,12 @@ private async Task ConvertCoreAsync(string filePath) // 每次转换独立的上下文状态,转换器实例本身无共享可变状态 var state = new ConversionContext(); - // 预处理编号定义和图片 await ProcessNumberingDefinitionsAsync(doc, state); await ProcessImageReferencesAsync(doc, filePath, state); var markdown = new StringBuilder(); var previousWasList = false; - // 按文档顺序处理所有元素 foreach (var element in main.Document.Body.ChildElements) { var isCurrentList = false; @@ -95,7 +90,6 @@ private async Task ConvertCoreAsync(string filePath) break; } - // 如果从列表切换到非列表,添加额外的空行 if (previousWasList && !isCurrentList) { markdown.AppendLine(); @@ -112,16 +106,8 @@ private async Task ConvertCoreAsync(string filePath) } } - /// - /// 处理段落元素 - /// - /// 段落 - /// markdown构建器 - /// 本次转换的上下文状态 - /// 是否为列表项 private bool ProcessParagraph(Paragraph paragraph, StringBuilder markdown, ConversionContext state) { - // 检查是否为列表项 var numberingId = GetNumberingId(paragraph); var numberingLevel = GetNumberingLevel(paragraph); @@ -131,7 +117,6 @@ private bool ProcessParagraph(Paragraph paragraph, StringBuilder markdown, Conve return true; } - // 处理段落格式化文本(包括图片) var formattedText = ProcessTextFormatting(paragraph, state); if (string.IsNullOrWhiteSpace(formattedText)) @@ -140,7 +125,6 @@ private bool ProcessParagraph(Paragraph paragraph, StringBuilder markdown, Conve return false; } - // 检查是否为标题 var headingLevel = GetHeadingLevel(paragraph); if (headingLevel > 0) { @@ -155,9 +139,6 @@ private bool ProcessParagraph(Paragraph paragraph, StringBuilder markdown, Conve return false; } - /// - /// 处理表格元素 - /// private void ProcessTable(Table table, StringBuilder markdown, ConversionContext state) { var rows = new List>(); @@ -179,7 +160,6 @@ private void ProcessTable(Table table, StringBuilder markdown, ConversionContext if (rows.Count == 0) return; - // 标准化列数 var maxColumns = rows.Max(r => r.Count); foreach (var row in rows) { @@ -189,14 +169,10 @@ private void ProcessTable(Table table, StringBuilder markdown, ConversionContext } } - // 生成Markdown表格 GenerateMarkdownTable(rows, markdown); markdown.AppendLine(); // 表格后空行 } - /// - /// 获取段落的标题级别 - /// private int GetHeadingLevel(Paragraph paragraph) { var style = paragraph.ParagraphProperties?.ParagraphStyleId?.Val?.Value; @@ -204,7 +180,6 @@ private int GetHeadingLevel(Paragraph paragraph) var styleLower = style.ToLowerInvariant(); - // 检查标准的Word标题样式 if (styleLower.StartsWith("heading")) { var levelStr = styleLower.Replace("heading", ""); @@ -212,11 +187,9 @@ private int GetHeadingLevel(Paragraph paragraph) return level; } - // 检查数字样式 if (int.TryParse(styleLower, out var numLevel) && numLevel >= 1 && numLevel <= 6) return numLevel; - // 检查大纲级别 var outlineLevel = paragraph.ParagraphProperties?.OutlineLevel?.Val?.Value; if (outlineLevel.HasValue && outlineLevel.Value >= 0 && outlineLevel.Value <= 5) return outlineLevel.Value + 1; @@ -224,16 +197,12 @@ private int GetHeadingLevel(Paragraph paragraph) return 0; } - /// - /// 处理文本格式化 - /// private string ProcessTextFormatting(Paragraph paragraph, ConversionContext state) { var result = new StringBuilder(); foreach (var run in paragraph.Elements()) { - // 检查是否包含图片 var drawing = run.Elements().FirstOrDefault(); if (drawing != null) { @@ -275,9 +244,6 @@ private string ProcessTextFormatting(Paragraph paragraph, ConversionContext stat return result.ToString(); } - /// - /// 提取表格单元格文本 - /// private string ExtractTableCellText(TableCell cell, ConversionContext state) { var cellContent = new StringBuilder(); @@ -296,25 +262,19 @@ private string ExtractTableCellText(TableCell cell, ConversionContext state) return cellContent.ToString().Trim(); } - /// - /// 生成Markdown表格 - /// private void GenerateMarkdownTable(List> rows, StringBuilder markdown) { if (rows.Count == 0) return; - // 表头 var header = rows[0]; markdown.Append("| "); markdown.AppendJoin(" | ", header.Select(EscapeMarkdownTableCell)); markdown.AppendLine(" |"); - // 分隔线 markdown.Append("| "); markdown.AppendJoin(" | ", header.Select(_ => "---")); markdown.AppendLine(" |"); - // 数据行 foreach (var row in rows.Skip(1)) { markdown.Append("| "); @@ -323,9 +283,6 @@ private void GenerateMarkdownTable(List> rows, StringBuilder markdo } } - /// - /// 转义Markdown表格单元格内容 - /// private string EscapeMarkdownTableCell(string text) { if (string.IsNullOrEmpty(text)) return string.Empty; @@ -336,9 +293,6 @@ private string EscapeMarkdownTableCell(string text) .Trim(); } - /// - /// 预处理编号定义 - /// private async Task ProcessNumberingDefinitionsAsync(WordprocessingDocument doc, ConversionContext state) { state.NumberingFormats.Clear(); @@ -364,7 +318,6 @@ await Task.Run(() => var numFmt = level?.NumberingFormat?.Val; var levelValue = level?.LevelIndex?.Value ?? 0; - // 根据编号格式确定列表类型 var listInfo = new ListInfo { Level = levelValue, @@ -379,27 +332,18 @@ await Task.Run(() => }); } - /// - /// 获取段落的编号ID - /// private int? GetNumberingId(Paragraph paragraph) { var numPr = paragraph.ParagraphProperties?.NumberingProperties; return numPr?.NumberingId?.Val?.Value; } - /// - /// 获取段落的编号级别 - /// private int GetNumberingLevel(Paragraph paragraph) { var numPr = paragraph.ParagraphProperties?.NumberingProperties; return numPr?.NumberingLevelReference?.Val?.Value ?? 0; } - /// - /// 处理列表项 - /// private void ProcessListItem(Paragraph paragraph, StringBuilder markdown, int numberingId, int level, ConversionContext state) { var text = ProcessTextFormatting(paragraph, state); @@ -407,12 +351,10 @@ private void ProcessListItem(Paragraph paragraph, StringBuilder markdown, int nu var listInfo = state.NumberingFormats.GetValueOrDefault(numberingId, new ListInfo { Prefix = "- ", IsOrdered = false }); - // 添加缩进 var indent = new string(' ', level * 2); if (listInfo.IsOrdered) { - // 为有序列表维护计数器 var counterKey = numberingId * 100 + level; // 组合键考虑级别 if (!state.ListItemCounters.ContainsKey(counterKey)) state.ListItemCounters[counterKey] = 1; @@ -426,9 +368,6 @@ private void ProcessListItem(Paragraph paragraph, StringBuilder markdown, int nu } } - /// - /// 预处理图片引用 - /// private async Task ProcessImageReferencesAsync(WordprocessingDocument doc, string documentPath, ConversionContext state) { state.ImageReferences.Clear(); @@ -445,7 +384,6 @@ await Task.Run(async () => var relationshipId = doc.MainDocumentPart.GetIdOfPart(imagePart); state.ImageCounter++; - // 使用 IImageStorageService 保存图片 var imageFileName = $"doc_image{state.ImageCounter}.{GetImageExtension(imagePart.ContentType)}"; using var stream = imagePart.GetStream(); @@ -453,10 +391,8 @@ await Task.Run(async () => await stream.CopyToAsync(memoryStream); var imageBytes = memoryStream.ToArray(); - // 通过 IImageStorageService 保存图片并获取路径 var imagePath = await _imageStorageService.SaveImageAsync(imageBytes, imageFileName, documentPath); - // 存储完整的绝对路径 state.ImageReferences[relationshipId] = imagePath; } catch (Exception ex) @@ -468,9 +404,6 @@ await Task.Run(async () => }); } - /// - /// 处理图片元素 - /// private string ProcessImage(Drawing drawing, ConversionContext state) { try @@ -486,7 +419,6 @@ private string ProcessImage(Drawing drawing, ConversionContext state) var imageNumber = fileName.Replace("doc_image", ""); var altText = $"文档图片{imageNumber}"; - // 使用绝对路径,转换为URI格式 var fileUri = new Uri(imagePath).AbsoluteUri; return $"![{altText}]({fileUri})"; } @@ -499,9 +431,6 @@ private string ProcessImage(Drawing drawing, ConversionContext state) return string.Empty; } - /// - /// 根据内容类型获取图片扩展名 - /// private static string GetImageExtension(string contentType) { return contentType.ToLowerInvariant() switch @@ -517,9 +446,6 @@ private static string GetImageExtension(string contentType) }; } - /// - /// 清理和规范化Markdown内容 - /// private string CleanupMarkdown(string markdown) { if (string.IsNullOrEmpty(markdown)) @@ -545,7 +471,7 @@ private string CleanupMarkdown(string markdown) else { consecutiveEmptyLines = 0; - cleanedLines.Add(line.TrimEnd()); // 移除行尾空白 + cleanedLines.Add(line.TrimEnd()); } } diff --git a/src/MarketAssistant.Rag/Services/MarkdownDocumentBlockReader.cs b/src/MarketAssistant.Rag/Services/MarkdownDocumentBlockReader.cs index b323549..5b240c0 100644 --- a/src/MarketAssistant.Rag/Services/MarkdownDocumentBlockReader.cs +++ b/src/MarketAssistant.Rag/Services/MarkdownDocumentBlockReader.cs @@ -35,7 +35,7 @@ public bool CanRead(string filePath) filePath.EndsWith(".markdown", StringComparison.OrdinalIgnoreCase); } - public async Task> ReadBlocksAsync(string filePath) + public async Task> ReadBlocksAsync(string filePath, CancellationToken cancellationToken = default) { string markdown; @@ -43,7 +43,7 @@ public async Task> ReadBlocksAsync(string filePath) if (filePath.EndsWith(".md", StringComparison.OrdinalIgnoreCase) || filePath.EndsWith(".markdown", StringComparison.OrdinalIgnoreCase)) { - markdown = await File.ReadAllTextAsync(filePath); + markdown = await File.ReadAllTextAsync(filePath, cancellationToken); } else { diff --git a/src/MarketAssistant.Rag/Services/PdfBlockReader.cs b/src/MarketAssistant.Rag/Services/PdfBlockReader.cs index cd9d8e7..e18eea8 100644 --- a/src/MarketAssistant.Rag/Services/PdfBlockReader.cs +++ b/src/MarketAssistant.Rag/Services/PdfBlockReader.cs @@ -18,11 +18,11 @@ public PdfBlockReader(MarkdownDocumentBlockReader markdownReader) public bool CanRead(string filePath) => filePath.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase); - public async Task> ReadBlocksAsync(string filePath) + public async Task> ReadBlocksAsync(string filePath, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(filePath); // 直接委托给 MarkdownDocumentBlockReader 处理 - return await _markdownReader.ReadBlocksAsync(filePath); + return await _markdownReader.ReadBlocksAsync(filePath, cancellationToken); } } diff --git a/src/MarketAssistant.Rag/Services/QueryRewriteService.cs b/src/MarketAssistant.Rag/Services/QueryRewriteService.cs index 1aeb0ea..e7d10e6 100644 --- a/src/MarketAssistant.Rag/Services/QueryRewriteService.cs +++ b/src/MarketAssistant.Rag/Services/QueryRewriteService.cs @@ -122,9 +122,6 @@ private static string NormalizeQuery(string query) return s; } - /// - /// 生成同义词变体 - /// /// /// 拉丁字母键使用 ASCII 边界正则替换(避免子串误命中,如 "AI" 命中 "chain"); /// 中文键保持简单子串替换。 @@ -213,9 +210,6 @@ private static List ExtractKeywords(string query) .ToList(); } - /// - /// 移除停用词 - /// private static string RemoveStopWords(string query) { var words = query.Split([' ', ',', '。', '、'], StringSplitOptions.RemoveEmptyEntries); diff --git a/src/MarketAssistant.Rag/Services/RagIngestionService.cs b/src/MarketAssistant.Rag/Services/RagIngestionService.cs index 8537d72..efe2b58 100644 --- a/src/MarketAssistant.Rag/Services/RagIngestionService.cs +++ b/src/MarketAssistant.Rag/Services/RagIngestionService.cs @@ -93,7 +93,7 @@ private async Task IngestWithBlocksAsync( IDocumentBlockReader blockReader, CancellationToken cancellationToken) { - var blocks = (await blockReader.ReadBlocksAsync(filePath)).OrderBy(b => b.Order).ToList(); + var blocks = (await blockReader.ReadBlocksAsync(filePath, cancellationToken)).OrderBy(b => b.Order).ToList(); if (blocks.Count == 0) { _logger.LogWarning("Document contains no blocks: {File}", filePath); diff --git a/src/MarketAssistant.Rag/Services/RerankerService.cs b/src/MarketAssistant.Rag/Services/RerankerService.cs index 858b903..7ba6ca8 100644 --- a/src/MarketAssistant.Rag/Services/RerankerService.cs +++ b/src/MarketAssistant.Rag/Services/RerankerService.cs @@ -218,9 +218,6 @@ private static double CalculateFreshnessScore(RagSearchCandidate item) return contentScore > 0 ? contentScore : 0.5; } - /// - /// 计算长度评分 - /// private static double CalculateLengthScore(string text) { if (string.IsNullOrWhiteSpace(text)) return 0.0; @@ -266,9 +263,6 @@ private static void ApplyDiversityOptimization(List results) } } - /// - /// 计算Jaccard相似度 - /// private static double CalculateJaccardSimilarity(HashSet a, HashSet b) { if (a.Count == 0 || b.Count == 0) return 0.0; diff --git a/src/MarketAssistant.Rag/Services/SqliteRagDocumentCatalog.cs b/src/MarketAssistant.Rag/Services/SqliteRagDocumentCatalog.cs index db2e7ff..319fe41 100644 --- a/src/MarketAssistant.Rag/Services/SqliteRagDocumentCatalog.cs +++ b/src/MarketAssistant.Rag/Services/SqliteRagDocumentCatalog.cs @@ -26,7 +26,7 @@ public async Task> GetKeysAsync( string collectionName, string documentId, CancellationToken cancellationToken = default) { await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false); - await using var conn = CreateConnection(); + await using var conn = await CreateConnectionAsync(cancellationToken).ConfigureAwait(false); await using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT keys_json FROM rag_document_catalog WHERE collection = $c AND document_id = $d"; cmd.Parameters.AddWithValue("$c", collectionName); @@ -44,7 +44,7 @@ public async Task ReplaceAsync(RagDocumentCatalogEntry entry, CancellationToken ArgumentNullException.ThrowIfNull(entry); await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false); - await using var conn = CreateConnection(); + await using var conn = await CreateConnectionAsync(cancellationToken).ConfigureAwait(false); await using var cmd = conn.CreateCommand(); cmd.CommandText = """ INSERT INTO rag_document_catalog(collection, document_id, document_uri, content_hash, keys_json, embedding_model_id, dimension, updated_at) @@ -69,7 +69,7 @@ public async Task RemoveAsync( string collectionName, string documentId, CancellationToken cancellationToken = default) { await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false); - await using var conn = CreateConnection(); + await using var conn = await CreateConnectionAsync(cancellationToken).ConfigureAwait(false); await using var cmd = conn.CreateCommand(); cmd.CommandText = "DELETE FROM rag_document_catalog WHERE collection = $c AND document_id = $d"; cmd.Parameters.AddWithValue("$c", collectionName); @@ -77,10 +77,17 @@ public async Task RemoveAsync( await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } - private SqliteConnection CreateConnection() + private async Task CreateConnectionAsync(CancellationToken cancellationToken) { var conn = new SqliteConnection($"Data Source={_dbPath}"); - conn.Open(); + await conn.OpenAsync(cancellationToken).ConfigureAwait(false); + + // busy_timeout 是连接级属性(WAL 才是库级持久属性),必须每连接设置, + // 否则并发写清单时写锁冲突会直接抛 "database is locked" + await using var pragmaCmd = conn.CreateCommand(); + pragmaCmd.CommandText = "PRAGMA busy_timeout=5000;"; + await pragmaCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + return conn; } @@ -97,7 +104,7 @@ private async Task EnsureInitializedAsync(CancellationToken cancellationToken) Directory.CreateDirectory(Path.GetDirectoryName(_dbPath)!); - await using var conn = CreateConnection(); + await using var conn = await CreateConnectionAsync(cancellationToken).ConfigureAwait(false); // 启用 WAL 模式,提升并发读写性能 await using var walCmd = conn.CreateCommand(); walCmd.CommandText = "PRAGMA journal_mode=WAL;"; diff --git a/src/MarketAssistant.Rag/Services/TextCleaningService.cs b/src/MarketAssistant.Rag/Services/TextCleaningService.cs index 0e224f3..15d99f2 100644 --- a/src/MarketAssistant.Rag/Services/TextCleaningService.cs +++ b/src/MarketAssistant.Rag/Services/TextCleaningService.cs @@ -15,8 +15,10 @@ public class TextCleaningService : ITextCleaningService // 预编译的正则表达式 - 固定清洗规则 private static readonly Regex MultiSpace = new(@"[\t\x0B\f ]{2,}", RegexOptions.Compiled); + // "第"分支要求后随"页":否则"第 3 季度"这类正文中带空格的数字写法会被整段误删; + // Latin/页/共 前缀与数字的组合语义明确,保持原有宽匹配 private static readonly Regex PageNumber = new( - @"(?:^|\s)(?:Page|页|第|p\.|P\.)\s*\d+(?:\s*(?:of|\/|共|页|总)\s*\d+)?(?:\s|$)", + @"(?:^|\s)(?:Page|页|p\.|P\.)\s*\d+(?:\s*(?:of|\/|共|页|总)\s*\d+)?(?:\s|$)|(?:^|\s)第\s*\d+\s*页|(?:^|\s)共\s*\d+\s*页", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex HyphenBreak = new(@"([A-Za-z])-\n([A-Za-z])", RegexOptions.Compiled); private static readonly Regex UrlPattern = new(@"https?://\S+", RegexOptions.IgnoreCase | RegexOptions.Compiled); diff --git a/src/MarketAssistant.Trading/Abstractions/IExchangeClient.cs b/src/MarketAssistant.Trading/Abstractions/IExchangeClient.cs index 54edf47..9268952 100644 --- a/src/MarketAssistant.Trading/Abstractions/IExchangeClient.cs +++ b/src/MarketAssistant.Trading/Abstractions/IExchangeClient.cs @@ -18,14 +18,8 @@ public interface IExchangeClient /// bool IsFutures { get; } - /// - /// 获取账户余额 - /// Task GetAccountInfoAsync(CancellationToken ct = default); - /// - /// 对交易标的下单 - /// /// 客户端自定义订单 ID,用于网络重试时实现幂等性,避免重复下单 /// 合约平仓时传 true,确保只平仓不开新仓(仅合约有效) /// 持仓方向(合约双向模式:LONG/SHORT;单向模式/null:BOTH) diff --git a/src/MarketAssistant.Trading/StrategyParams.cs b/src/MarketAssistant.Trading/StrategyParams.cs index d48ba8c..78a9ed1 100644 --- a/src/MarketAssistant.Trading/StrategyParams.cs +++ b/src/MarketAssistant.Trading/StrategyParams.cs @@ -45,14 +45,8 @@ public class GridTradingParams /// public decimal? TakeProfitPrice { get; set; } - /// - /// 计算网格间距 - /// public decimal GridSpacing => GridCount > 1 ? (UpperPrice - LowerPrice) / GridCount : 0; - /// - /// 获取指定索引处的网格价格 - /// public decimal GetGridPrice(int index) => LowerPrice + GridSpacing * index; } diff --git a/tests/Application/HomeSearchViewModelTest.cs b/tests/Application/HomeSearchViewModelTest.cs index 5eb0278..6f5b0c1 100644 --- a/tests/Application/HomeSearchViewModelTest.cs +++ b/tests/Application/HomeSearchViewModelTest.cs @@ -1,5 +1,14 @@ +using MarketAssistant.Applications; +using MarketAssistant.Applications.Assets; using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Applications.Cache; +using MarketAssistant.Applications.Charts; +using MarketAssistant.Applications.Favorites; +using MarketAssistant.Applications.History; +using MarketAssistant.Applications.Home; +using MarketAssistant.Applications.News; using MarketAssistant.Applications.Settings; +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Services.Market; using MarketAssistant.Services.Settings; using MarketAssistant.ViewModels.Home; @@ -15,6 +24,37 @@ namespace TestMarketAssistant.Application; [TestClass] public class HomeSearchViewModelTest { + private sealed class StubMarketServiceRegistry : IMarketServiceRegistry + { + private readonly IServiceProvider _serviceProvider; + + public StubMarketServiceRegistry(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public IKLineService GetKLineService(MarketType marketType) + => throw new NotSupportedException(); + + public IAssetInfoService GetAssetInfoService(MarketType marketType) + => throw new NotSupportedException(); + + public INewsUpdateService GetNewsUpdateService(MarketType marketType) + => throw new NotSupportedException(); + + public IHomeAssetService GetHomeAssetService(MarketType marketType) + => _serviceProvider.GetRequiredKeyedService(marketType); + + public IAssetHistoryService GetAssetHistoryService(MarketType marketType) + => throw new NotSupportedException(); + + public IFavoriteService GetFavoriteService(MarketType marketType) + => throw new NotSupportedException(); + + public IAssetCacheService GetAssetCacheService(MarketType marketType) + => throw new NotSupportedException(); + } + private static HomeSearchViewModel CreateViewModel() { var services = new ServiceCollection(); @@ -27,7 +67,7 @@ private static HomeSearchViewModel CreateViewModel() var marketContext = new MarketContext(userSettingService.Object, serviceProvider); return new HomeSearchViewModel( - serviceProvider, + new StubMarketServiceRegistry(serviceProvider), marketContext, NullLogger.Instance); } diff --git a/tests/Trading/StrategyEngineTriggerTest.cs b/tests/Trading/StrategyEngineTriggerTest.cs index 45115bd..9d79abc 100644 --- a/tests/Trading/StrategyEngineTriggerTest.cs +++ b/tests/Trading/StrategyEngineTriggerTest.cs @@ -19,6 +19,15 @@ public sealed class StrategyEngineTriggerTest { private const string Symbol = "BTCUSDT"; + /// + /// 触发评估路径不应创建 MarketMonitor,一旦创建立即失败。 + /// + private sealed class ThrowingMarketMonitorProvider : IMarketMonitorProvider + { + public MarketMonitor GetMonitor() + => throw new InvalidOperationException("触发评估测试中不应创建 MarketMonitor"); + } + private static (StrategyEngine Engine, Mock Data, Mock Strategies) CreateEngine() { var settingService = new Mock(); @@ -26,7 +35,7 @@ private static (StrategyEngine Engine, Mock Data, Mock throw new InvalidOperationException("触发评估测试中不应创建 MarketMonitor"), + new ThrowingMarketMonitorProvider(), NullLogger.Instance); var data = new Mock(environment, NullLogger.Instance);