diff --git a/Directory.Packages.props b/Directory.Packages.props
index cf4fbbfc..dba7db7f 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -49,6 +49,7 @@
+
diff --git a/docs/design-system.md b/docs/design-system.md
deleted file mode 100644
index d2daa20c..00000000
--- a/docs/design-system.md
+++ /dev/null
@@ -1,544 +0,0 @@
-# MarketAssistant UI 设计系统
-
-> **版本**: 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` 浅色填充而非整块品牌蓝;
-> 3. 首页不放 K 线区,采用 Bento Grid(热门标的置左最宽 + 快讯 + 最近查看);K 线保留在资产详情页。原型中的"AI 市场概览横幅"待数据源就绪后再引入;
-> 4. 涨跌标签一律带约 12% 透明度背景(`BullishTagBackgroundBrush` 等),靠色块而非纯文字色区分语义;
-> 5. 响应式简化为两档:≥1100px 完整布局,<1100px 单列隐藏详情面板;
-> 6. TopBar 常驻:标题/返回 + A股/虚拟币分段切换器(复用 Ctrl+M 同一逻辑);行情 ticker 需真实指数数据源,无数据时整段隐藏。
-
----
-
-## 一、设计理念
-
-### 设计目标
-
-MarketAssistant 是一款面向专业投资者的金融数据终端。界面设计需同时满足:
-
-| 维度 | 目标 | 对应策略 |
-|------|------|---------|
-| **可扫描性** | 用户在 3 秒内识别关键信息 | 层次分明的排版、颜色编码、网格布局 |
-| **数据密度** | 单屏展示尽可能多的有效信息 | 紧凑但不拥挤的间距系统、等宽数字字体 |
-| **专业性** | 传达可靠、严谨、值得信赖的感觉 | 深色主题为主、克制的配色、清晰的边框分隔 |
-| **可访问性** | 长时间盯盘不疲劳 | 深色背景减少眩光、足够的文字对比度 |
-| **跨市场** | A股与虚拟币统一体验 | 市场切换时保持布局一致,仅数据变化 |
-
-### 设计原则
-
-1. **数据优先,装饰其次**: 每一个像素都应服务于信息传递,去除无意义装饰
-2. **暗色为主,亮色为辅**: 默认深色主题,提供亮色作为备选
-3. **一致性高于个性化**: 相同类型数据使用相同呈现方式
-4. **渐进式信息披露**: 概览→详情,点击深入而非一次性堆砌
-
----
-
-## 二、颜色系统
-
-### 品牌主色
-
-| Token | 色值 | 用途 |
-|-------|------|------|
-| `brand-blue` | `#1976D2` | 主按钮、选中态、链接、进度条 |
-| `brand-blue-light` | `#42A5F5` | 悬停态、辅助强调 |
-| `brand-blue-dark` | `#0D47A1` | 按下态 |
-| `brand-accent` | `#FF6B35` | 提醒、CTA、特殊标注 |
-
-### 深色主题 (默认)
-
-| Token | 色值 | 用途 |
-|-------|------|------|
-| `bg-root` | `#0A0E17` | 页面最底层背景 |
-| `bg-surface` | `#111722` | 卡片、面板、侧边栏 |
-| `bg-elevated` | `#161C2A` | 悬浮卡片、下拉菜单、tooltip |
-| `bg-overlay` | `#1B2233` | 对话框遮罩背景 |
-| `border-default` | `#1E2636` | 默认边框 |
-| `border-subtle` | `#182032` | 细微分割线(卡片内部) |
-| `border-active` | `#1976D2` | 聚焦/选中边框 |
-| `text-primary` | `#E4EAF5` | 正文、标题 |
-| `text-secondary` | `#8894A8` | 辅助说明、标签 |
-| `text-tertiary` | `#5A6680` | 禁用态、占位符、页码 |
-| `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。
-
-### 亮色主题
-
-| Token | 色值 | 用途 |
-|-------|------|------|
-| `bg-root` | `#F5F6F8` | 页面最底层背景 |
-| `bg-surface` | `#FFFFFF` | 卡片、面板 |
-| `bg-elevated` | `#F0F1F3` | 悬浮卡片 |
-| `bg-overlay` | `#E8E9EC` | 对话框背景 |
-| `border-default` | `#E0E2E8` | 默认边框 |
-| `border-subtle` | `#EEF0F4` | 细微分割线 |
-| `text-primary` | `#1A1D26` | 正文、标题 |
-| `text-secondary` | `#6B7280` | 辅助说明 |
-| `text-tertiary` | `#9CA3AF` | 禁用态 |
-| `text-on-emphasis` | `#FFFFFF` | 品牌蓝/功能色实底上的前景文字(`TextOnEmphasisBrush`,明暗同值,v2.4) |
-| `bg-hover` | `#F5F5F5` | 悬停背景(`HoverBackgroundBrush`) |
-| `bg-pressed` | `#EEEEEE` | 按下态背景(`PressedBackgroundBrush`) |
-| `bg-danger-muted` | `#FEF2F2` | 危险提示容器背景(`DangerBackgroundBrush`) |
-| `text-active` | `#0D47A1` | 模式卡片选中文字(`ModeCardSelectedTextBrush`) |
-
-### 市场语义色 (中国市场标准: 红涨绿跌)
-
-| Token | 色值 | 用途 |
-|-------|------|------|
-| `color-bullish` | `#F44336` | 上涨、买入、多头 |
-| `color-bearish` | `#4CAF50` | 下跌、卖出、空头 |
-
-### 功能语义色
-
-| Token | 色值 | 用途 |
-|-------|------|------|
-| `color-success` | `#10B981` | 成功、完成、在线状态 |
-| `color-warning` | `#F59E0B` | 警告、需要注意 |
-| `color-danger` | `#EF4444` | 错误、危险操作 |
-| `color-info` | `#3B82F6` | 信息提示 |
-
-> **✅ 实施现状(v2.3 已收敛)**:`Colors.axaml` 中 `Success/Warning/Error/Info` 已统一为上表 Tailwind 系取值,交互状态色(`*Hover/*Pressed`)同步重算为对应明暗档;与 `BullishRed/BearishGreen` 的同值语义混叠已消除。剩余待办见第十章 P2+ 第 13 条(亮色硬编码组与 `*Panel*` 组合并)。
-
-### 状态背景色 (深色)
-
-| Token | 色值 | 用途 |
-|-------|------|------|
-| `bg-bullish` | `rgba(244,67,54,0.12)` | 上涨标签背景 |
-| `bg-bearish` | `rgba(76,175,80,0.12)` | 下跌标签背景 |
-| `bg-selected` | `rgba(25,118,210,0.15)` | 选中行/项背景 |
-
-### 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)`。
-
----
-
-## 三、排版系统
-
-### 字体族
-
-| 层级 | 字体 | 用途 |
-|------|------|------|
-| **UI 字体** | `-apple-system, "PingFang SC", "Microsoft YaHei", sans-serif` | 界面文字、标题、按钮 |
-| **数据字体** | `"JetBrains Mono", "SF Mono", "Consolas", monospace` | 价格、百分比、成交量、代码 |
-
-> 数据字体必须启用 `tnum` (tabular numbers) 特性,确保数字等宽对齐。
-
-### 字号阶梯
-
-| Token | 字号 | 用途 |
-|-------|------|------|
-| `text-xs` | 11px | 代码标签、辅助说明、时间戳 |
-| `text-sm` | 12px | 次要文本、标签、表格内容 |
-| `text-base` | 13px | 正文(默认) |
-| `text-md` | 14px | 面板标题、强调正文 |
-| `text-lg` | 16px | 区块标题、卡片标题 |
-| `text-xl` | 18px | 页面标题、K线图标题 |
-| `text-2xl` | 22px | 大标题 |
-| `text-3xl` | 28px | 核心指标(价格、涨跌幅) |
-
-> **实施现状(v2.3 已收敛)**:`Spacing.axaml` 实际阶梯为 11/12/13/14/16/18/22/28,与上表一致。原越界值已并入标准档:`MediumFontSize` 15→14、`LargeFontSize` 20→18、`TitleFontSize` 24→22(h2 同步映射到 22px)。
-
-### 行高
-
-| 上下文 | 行高 |
-|--------|------|
-| 正文段落 | 1.6 |
-| 列表项/表格 | 1.4 |
-| 标题 | 1.2 |
-| 数据数字 | 1.0 |
-
-### 字重
-
-| 字重 | 数值 | 用途 |
-|------|------|------|
-| Regular | 400 | 正文 |
-| Medium | 500 | 标签、数据 |
-| Semibold | 600 | 面板标题、卡片标题 |
-| Bold | 700 | 页面标题、核心指标 |
-
----
-
-## 四、间距系统
-
-采用 **4px 基准** 间距系统,所有间距为 4 的倍数:
-
-| Token | 值 | CSS 变量 | 用途 |
-|-------|-----|---------|------|
-| `space-1` | 4px | `--space-1` | 图标与文字间距、紧凑元素间距 |
-| `space-2` | 8px | `--space-2` | 列表项内边距、标签间距 |
-| `space-3` | 12px | `--space-3` | 列表项间距、表单元素间距 |
-| `space-4` | 16px | `--space-4` | 卡片内边距、组件间距 |
-| `space-5` | 20px | `--space-5` | 区块间距 |
-| `space-6` | 24px | `--space-6` | 页面内边距、大区块间距 |
-| `space-8` | 32px | `--space-8` | 独立区块间距 |
-| `space-10` | 40px | `--space-10` | 页面顶部间距 |
-| `space-12` | 48px | `--space-12` | 页面级分隔 |
-
-### 布局尺寸
-
-| Token | 值 | 用途 |
-|-------|-----|------|
-| 侧边导航栏 | 72px | 图标导航栏宽度(`NavRailWidth`,实际实现值) |
-| 右侧面板 | 320px | 详情/盘口/技术指标 |
-| 最小窗口宽度 | 800px | 低于此宽度压缩布局(`MainWindowMinWidth`,实际实现值) |
-| 最小窗口高度 | 600px | 低于此高度压缩图表区域(`MainWindowMinHeight`) |
-
----
-
-## 五、圆角与阴影
-
-### 圆角
-
-| Token | 值 | 用途 |
-|-------|-----|------|
-| `radius-sm` | 4px | 按钮、标签、输入框 |
-| `radius-md` | 6px | 小型卡片、下拉菜单 |
-| `radius-lg` | 8px | 卡片、面板 |
-| `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` | `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`) 来区分层级。
-
----
-
-## 六、动效系统
-
-| Token | 时长 | 缓动 | 用途 |
-|-------|------|------|------|
-| `transition-fast` | 150ms | `ease` | 悬停色变、选中态切换 |
-| `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` 内有注记说明)。
-
-### 动效原则
-
-- 仅对 `opacity`、`transform`、`background-color`、`border-color` 做动效
-- 禁止对 `width`、`height`、`margin`、`padding` 做动画(性能差)
-- 所有循环动画(如加载指示器)需可被 `prefers-reduced-motion` 暂停
-- 不使用弹性/回弹缓动(专业金融工具的克制感)
-
----
-
-## 七、组件规范
-
-### 7.1 按钮 (Button)
-
-| 变体 | 用途 | 样式 |
-|------|------|------|
-| **Primary** | 主要操作(搜索、确认) | `bg-brand-blue`, `text-on-emphasis` 文字 |
-| **Secondary** | 次要操作(取消、返回) | `bg-elevated`, 边框, 主色文字 |
-| **Ghost** | 低优先级操作 | 透明背景, 悬停显示背景 |
-| **Danger** | 危险操作(删除、清空) | `bg-danger/0.12`, 红色文字 |
-| **Icon** | 纯图标按钮 | 44x44px 点击区域, 图标 22px |
-
-### 按钮尺寸
-
-| 尺寸 | 高度 | 水平内边距 | 字号 |
-|------|------|-----------|------|
-| Small | 28px | 10px | 11px |
-| Medium (默认) | 32px | 16px | 12px |
-| Large | 40px | 20px | 14px |
-
-### 7.2 输入框 (Input)
-
-- 高度: 32px (Small) / 40px (Medium)
-- 边框: 1px `border-default`
-- 聚焦: 边框 `border-active` + `box-shadow: 0 0 0 2px rgba(25,118,210,0.2)`
-- 禁用: 背景 `bg-elevated`, 文字 `text-tertiary`
-- 错误: 边框 `color-danger` + 下方错误文字
-
-### 7.3 卡片 (Card)
-
-所有卡片采用统一结构:
-
-```
-┌─ Card ──────────────────────────────────┐
-│ Header: 标题 + 操作按钮 │
-│ ─────────────────────────────────────── │
-│ Body: 内容区 (可滚动) │
-│ │
-└──────────────────────────────────────────┘
-```
-
-- 背景: `bg-surface`
-- 边框: 1px `border-default`
-- 圆角: `radius-lg` (8px)
-- 内边距: Header `space-4` / Body 由内容决定
-
-### 7.4 标签 (Tag/Badge)
-
-| 变体 | 背景 | 文字色 |
-|------|------|--------|
-| Default | `bg-elevated` | `text-secondary` |
-| Bullish | `rgba(244,67,54,0.12)` | `color-bullish` |
-| Bearish | `rgba(76,175,80,0.12)` | `color-bearish` |
-| Primary | `rgba(25,118,210,0.15)` | `brand-blue-light` |
-| Warning | `rgba(245,158,11,0.12)` | `color-warning` |
-
-### 7.5 表格 (Table)
-
-金融数据表格规范:
-
-- 表头: `text-xs`, `text-tertiary`, 大写, 加粗
-- 数据行: `text-sm`, 交替行背景 (每 2 行微变)
-- 数字列: 右对齐, `JetBrains Mono`
-- 行高: 36px (紧凑) / 44px (舒适)
-- 悬停行: `bg-elevated`
-- 选中行: `bg-selected` + 左侧 3px `brand-blue` 指示条
-
-### 7.6 状态组件
-
-| 状态 | 组件 | 说明 |
-|------|------|------|
-| **加载中** | Skeleton | 灰色脉冲占位块,模拟内容形状 |
-| **空数据** | EmptyState | 图标 + 标题 + 描述 + 行动按钮 |
-| **错误** | ErrorState | 图标 + 错误信息 + 重试按钮 |
-| **成功** | Toast | 右上角滑入通知,3秒自动消失 |
-
-### 7.7 导航
-
-```
-┌──────┬──────────────────────────────────────────┐
-│ Icon │ 页面内容 │
-│ Nav │ │
-│ 72px │ │
-│ │ │
-│ 🏠 │ │
-│ ⭐ │ │
-│ 🤖 │ │
-│ 📊 │ │
-│ │ │
-│ ⚙️ │ │
-└──────┴──────────────────────────────────────────┘
-```
-
-- 导航栏宽度: 72px(`NavRailWidth`,仅图标,实际实现值)
-- 每个导航项: 44x44px 最小可点击区域(当前实现项宽 56 / 高 48,可进一步收敛)
-- 活跃态: 左侧 3px 蓝色指示条 + 蓝色图标色
-- 图标: 22px SVG(当前实现为 24px `DefaultIconSize`)
-
----
-
-## 八、布局模式
-
-### 8.1 首页 (Dashboard)
-
-> v2.1 裁决:首页不放置 K 线区,采用 Bento Grid(热门标的置左最宽 + 快讯 + 最近查看);K 线保留在资产详情页。下图保留原始三栏结构示意,仅作历史参考。
-
-```
-┌──────┬──────────────────────────────────┬──────────┐
-│ │ TopBar (市场行情条) │ │
-│ ├──────────────────────────────────┤ │
-│ Nav │ Chart Section (K线图 + 指标) │ Detail │
-│ 72px │ │ Panel │
-│ ├──────────────────────────────────┤ 320px │
-│ │ Metrics Grid (4列指标卡片) │ │
-│ ├──────────────────┬───────────────┤ │
-│ │ Hot Assets │ News Feed │ │
-│ │ (热门标的列表) │ (7x24快讯) │ │
-│ └──────────────────┴───────────────┴──────────┘
-└────────────────────────────────────────────────────┘
-```
-
-### 8.2 响应式断点
-
-> v2.2 起与 v2.1 裁决统一为**两档**,废弃早期三档方案:
-
-| 断点 | 宽度 | 布局变化 |
-|------|------|---------|
-| Full | >= 1100px | 完整布局(导航 + 内容 + 详情面板) |
-| Compact | < 1100px | 单列布局,隐藏详情面板 |
-
-- 主窗口最小宽度 800px(`MainWindowMinWidth`),Compact 档下指标卡片降为 2 列。
-
----
-
-## 九、图标系统
-
-### 替换 Emoji 方案(✅ v2.3 已落地)
-
-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`。
-
-### 图标规格
-
-- 导航图标: 22x22px, stroke-width 1.5
-- 按钮内图标: 16x16px, stroke-width 1.5
-- 状态指示器: 8x8px 实心圆
-- 文件格式: SVG (支持 Avalonia Svg.Skia 渲染)
-
----
-
-## 十、迁移路线图(v2.2 状态盘点)
-
-### P0 — 立即执行 (阻塞项)
-
-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 / LoadingSkeleton(`SkeletonStyles.axaml`)已建立,ErrorState 复用程度待检查
-5. ✅ **完善表单样式**: `FormStyles.axaml` 已统一样式
-6. ✅ **完善列表样式**: `ListStyles.axaml` 已含选中/悬停/禁用态
-7. ✅ **建立数据表格样式**: 行情类列表已覆盖;交易表格(`TradeHistoryView` 等)已核查——35 处设计 Token 引用、0 硬编码色,完全对齐
-
-### P2 — 下个迭代
-
-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`(当前配色已对齐,形态升级为体验增强)
-
----
-
-## 十一、实施现状速览(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+ 小节,本表仅作快速索引。
-
----
-
-## 附录: 与现有系统的对应关系
-
-| 新设计系统 Token | 现有 Colors.axaml Key | 说明 |
-|-----------------|----------------------|------|
-| `color-bullish` | `BullishRed` (#F44336) | 保持一致 |
-| `color-bearish` | `BearishGreen` (#4CAF50) | 保持一致 |
-| `brand-blue` | `Primary` (#1976D2) | 保持一致 |
-| `brand-accent` | `Accent` (#FF6B35) | 保持一致 |
-| `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/design.md b/docs/design.md
new file mode 100644
index 00000000..781b43bf
--- /dev/null
+++ b/docs/design.md
@@ -0,0 +1,321 @@
+# MarketAssistant 界面设计与交互架构规范 (Design.md)
+
+> **文档版本**: 1.1
+> **更新日期**: 2026-09-12
+> **定位**: 本文档是 MarketAssistant (跨平台金融数据与 AI 交易终端) 的完整页面设计、交互架构与重构蓝图,**当前为唯一生效的设计规范**。
+> **审视基准**: 本文档基于当前真实代码实现、金融终端业务痛点与 AI 原生交互范式建立。
+> **历史说明**: 原设计系统文档(v2.4,`docs/design-system.md`)因存在若干与代码脱节的陈旧声明与理想化偏差,已被本文档取代并**于 2026-09-12 删除**;其完整颜色 Token 对照表以 `src/MarketAssistant.App/Resources/Styles/Colors.axaml` 与 `Spacing.axaml` 为唯一事实源,可视化预览见 [`配色预览.html`](配色预览.html),原文可通过 git 历史追溯。下文第二章审计表保留作为该取代关系的背景记录。
+
+---
+
+## 一、产品定位与核心设计哲学
+
+### 1.1 产品定位
+MarketAssistant 是一款面向高阶投资者与量化交易者的 **AI 原生金融市场工作站**。它横跨 **A 股市场**(严监管、T+1、交易时段限制、人民币计价)与 **虚拟币市场**(7×24小时、高波动、杠杆合约、USDT/BTC 计价),集成了多分析师协同(MAF Multi-Agent)、本地/外部知识库(RAG/MCP)、实时行情监控与自动化交易执行。
+
+### 1.2 核心设计哲学
+1. **金融终端的严谨 × 现代 SaaS 的克制**:
+ - 杜绝花哨无意义动效,优先确保数据刷新时的极低渲染抖动与即时可读性。
+ - 采用深色背景四层体系,减少长时间盯着盘面产生的眩光。
+2. **AI 原生而非 AI 附庸 (AI-Native, Not AI-Addon)**:
+ - 传统的 AI 助手往往只是一个悬浮侧边抽屉或独立聊天页,导致“看盘时无法对话,对话时看不到数据”。
+ - MarketAssistant 将 AI 深度嵌入业务流:在资产页提供即时异动归因,在分析页提供分屏常驻伴侣(Docked Co-Pilot),在策略页提供 AI 参数回测与风险释义。
+3. **闭环决策漏斗 (Closed-Loop Workflow)**:
+ - 形成清晰的投资链路:**市场概览 (Home) → 标的初筛 (Screening) → 深度多智能体研判 (Analysis) → 策略回测与风控 (Strategy) → 执行与盯盘 (Trading/Alert)**。
+4. **统一中的市场异构 (Dual-Market Cohesion)**:
+ - 框架与交互结构统一,但底层字段、时间轴、风控维度按市场严格区分,不做生硬套用。
+
+---
+
+## 二、当前页面实现与设计系统差异审计 (Reality Check)
+
+经过对当前代码库 `src/MarketAssistant.App` 的全面审查,`docs/design-system.md` 存在若干“文档声称已完成,但实际代码未落地或存在严重冲突”的问题:
+
+| 检查项 | `design-system.md` 声称状态 | 实际代码真实状态 | 影响与隐患 |
+|---|---|---|---|
+| **White 硬编码文字色** | 声称 v2.4 已将约 22 处 `Foreground="White"` 全部收敛至 `TextOnEmphasisBrush` | `ButtonStyles.axaml` (6处) 与 `MainWindow.axaml` (1处) 仍存在 `` | 浅色模式下若按钮变体继承异常会出现白底白字,破坏 Token 一致性 |
+| **Emoji 清理** | 声称 v2.3 已全量替换为 SVG 线性图标 | `AssetSelectionPageViewModel.cs` 中仍硬编码 `👤`, `📰`, `⚡`, `📈`;`AdaptiveCard` 解析器仍包含 `📈`, `📰`, `💰` | 跨平台 (Windows/macOS/Linux) 下不同系统的 Emoji 渲染字形与风格严重分裂 |
+| **数据字体 Token 化** | 声称统一使用 `StaticResource DataFontFamily` | `AnalystResultCardView.axaml` (第89行) 仍硬编码 `FontFamily="Consolas, 'Cascadia Mono', monospace"` 与硬编码字号 | 破坏字体层级,Mac/Linux 下若缺对应字体会导致等宽对齐失效 |
+| **K 线图表控件封装** | 声称图表主题化已彻底落地 | `KLineChartView.cs` 在代码后置中通过 C# 动态拼装 UI,Loading/Error 视图为硬编码样式,未绑定应用主题画刷 | 深浅主题切换时,异常面板和加载状态闪烁且与宿主背景色突兀 |
+| **顶栏行情条 (TopBar Ticker)** | 原型与文档设计了完整的大盘行情滚动条 | `MainWindowViewModel.cs` 中 `IndexTickers` 完全为空,`RebuildIndexTickers()` 仅清空集合,长期处于隐藏状态 | 顶部横向空间严重浪费,用户无法对大盘指数一瞥便知 |
+| **AI 伴侣交互形态** | 当前实现为模态遮罩抽屉 (`AgentAnalysisPageView.axaml` ZIndex 2000) | 打开抽屉时将整个主报告区域全部置灰变暗 (`DarkOverlayBrush`) | 用户向 AI 提问报告细节时,报告本身被暗色遮罩遮挡,无法一边看图表一边对话 |
+| **自选股展示模式** | 仅提供固定 290px 卡片的 `WrapPanel` | 缺乏高密度数据表格视图 (Table View) | 当自选标的高达数十只时,翻页与滚动手感极差,无法快速多字段对比排序 |
+
+---
+
+## 三、信息架构与导航拓扑重构
+
+### 3.1 导航链路拓扑图
+
+```mermaid
+flowchart LR
+ subgraph Shell [主框架 MainWindow]
+ NavRail[左侧导航轨 72px]
+ TopBar[常驻顶栏 44px]
+ ContentHost[页面承载容器]
+ end
+
+ subgraph Level1 [一级核心视图]
+ HomePage[1. 综合看盘 Dashboard]
+ FavPage[2. 自选看板 Watchlist]
+ ScreenerPage[3. AI 选标的 Screener]
+ AlertPage[4. 告警中心 AlertCenter]
+ TradePage[5. 交易终端 Trading]
+ end
+
+ subgraph Level2 [二级深度工作区]
+ AssetDetail[标的详情 AssetPage]
+ DeepAnalysis[多分析师研判 AgentAnalysis]
+ BalanceDetail[资产明细 BalancePage]
+ end
+
+ NavRail --> Level1
+ HomePage -->|点击标的| AssetDetail
+ FavPage -->|点击标的| AssetDetail
+ ScreenerPage -->|一键研判| DeepAnalysis
+ ScreenerPage -->|查看详情| AssetDetail
+ AssetDetail -->|AI 综合研判| DeepAnalysis
+ AssetDetail -->|快速建仓/策略| TradePage
+ DeepAnalysis -->|采纳策略| TradePage
+```
+
+### 3.2 导航结构重构建议
+1. **一级导航主项**:
+ - `首页 (Dashboard)`: 市场总览、行情异动、快讯流、快捷搜索。
+ - `自选 (Watchlist)`: 多分组管理、双视图切换(卡片视图 / 高密度网格表)。
+ - `AI选标的 (Screener)`: 需求驱动选股、热点事件选股、量化指标初筛。
+ - `交易 (Trading)`: 仅在当前市场能力支持时展示(Crypto 支持,A股隐藏或转为模拟盘/沙盘)。
+ - `告警 (Alerts)`: 价格告警与风控联动门统一出口,带动态未读计数红点。
+2. **底部固定项**:
+ - `设置 (Settings)`: API 密钥、模型配置、知识库管理。
+ - `关于 (About)`: 版本信息、环境状态。
+3. **深度研判页 (AgentAnalysisPageView) 定位纠偏**:
+ - 保持作为二级沉浸式工作区,但从 `AssetDetailPage` 与 `ScreenerPage` 均可平滑跳转,并在顶部提供明确的层级面包屑或返回按钮。
+
+---
+
+## 四、核心页面详细设计与实现蓝图
+
+### 4.1 主窗口框架 (MainWindow & TopBar)
+
+#### 4.1.1 现状与痛点
+- 窗口原生标题栏与应用内部 TopBar 形成“双层顶栏”,浪费垂直空间 30-40px。
+- 市场切换分段器 (`A股` / `虚拟币`) 放在顶栏右侧,但在二级页面时会被直接隐藏(`IsVisible="{Binding !CanGoBack}"`),导致在二级页面无法感知当前市场上下文。
+- 顶部行情条长期空置。
+
+#### 4.1.2 重构方案
+- **高度整合的紧凑顶栏 (Unified TopBar 44px)**:
+ - 左侧:页面标题与返回层级(`← 返回上一页 | 标的详情 - 600519 贵州茅台`)。
+ - 中间:大盘指数滚动条(A股:上证指数、深证成指、创业板指;Crypto:BTC、ETH、SOL、全网总市值)。未接入真实数据前展示骨架或折叠,不留大片空白。
+ - 右侧:常驻市场切换胶囊(A股 / Crypto),即便在二级页面也明确标识当前市场;旁边放置系统告警铃铛与连接健康状态点。
+- **左侧导航轨 (Nav Rail 72px)**:
+ - 维持克制图标化设计,增加当前选中项左侧 3px 竖条(`PrimaryBrush`)与浅色底面高亮(`SelectedBackgroundBrush`)。
+ - 底部告警项与顶部导航项统一支持红点/徽标显示。
+
+---
+
+### 4.2 首页大盘 (HomePageView)
+
+#### 4.2.1 现状与痛点
+- 搜索框放置在顶部独立 Card 中,输入资产跳转单一。
+- Bento Grid 采用硬编码 3:2 列宽比,宽屏下左列大量留白,窄屏下右侧快讯挤压。
+- 缺乏市场情绪冷热、涨跌家数分布(大盘广度 Breadth)等宏观指标。
+
+#### 4.2.2 重构设计蓝图
+```
+┌────────────────────────────────────────────────────────────────────────┐
+│ 🔍 全局命令搜索条 (支持拼音首字母/代码/自然语言: "找市盈率<20的新能源股") │
+├────────────────────────────────────────────────────────────────────────┤
+│ 宏观晴雨表: [上涨 3,240 | 平 420 | 下跌 1,510] 成交额 1.2万亿 北向 +45.2亿 │
+├───────────────────────────────────┬────────────────────────────────────┤
+│ 热门标的榜单 (Hot Assets - 60%) │ 7×24 实时快讯 (Telegraph - 40%) │
+│ [涨幅榜] [成交榜] [AI热议榜] │ [全部] [重大利好] [风险预警] │
+│ - 标的行: 代码/名称/现价/涨跌幅 │ - 时间戳 + 情绪胶囊 + 正文摘要 │
+│ - 迷你趋势折线 (Sparkline) │ - 关联股票快捷 Tag (点击即直达) │
+├───────────────────────────────────┴────────────────────────────────────┤
+│ 最近浏览历史 (横向紧凑药丸列表,支持鼠标滚轮平滑滚动) │
+└────────────────────────────────────────────────────────────────────────┘
+```
+
+#### 4.2.3 关键技术与实现细节
+- **搜索防抖与 IME 保护**: 保持当前的自绘 `Popup` + `ListBox` 模式,增加键盘 `Up/Down` 选词与 `Enter` 直达,避免原生 `AutoCompleteBox` 的拼音输入法冲突。
+- **快讯卡片增强**: 引入基于正负面词法的背景色弱强调(利好浅绿背景/边框,利空浅红背景/边框),并展示关联标的代码药丸。
+
+---
+
+### 4.3 标的详情与行情分析 (AssetPageView)
+
+#### 4.3.1 现状与痛点
+- 页面仅有基本价格 + 大卡片 K 线图,信息密度显著低于专业金融终端。
+- 缺少五档买卖盘(盘口)、分时成交明细(Tick 流)、技术指标数值面板。
+- 用户在当前页无法直接进行快捷交易配置或加入自选操作。
+
+#### 4.3.2 重构设计蓝图
+```
+┌────────────────────────────────────────────────────────────────────────┐
+│ 标的信息栏: 名称 [代码] 现价 1,820.00 +32.50 (+1.82%) 最高/最低/成交量 │
+│ 操作区: [⭐ 加入自选] [🤖 AI深度研判] [⚡ 快捷下单/网格策略] [🔄 刷新] │
+├────────────────────────────────────────┬───────────────────────────────┤
+│ K 线图表工作区 (主栏 75%) │ 深度盘口与指标面板 (右侧 25%) │
+│ - 周期切换: 分时 / 1m / 15m / 1h / 日K │ - 买五卖五 / 订单簿深度堆叠 │
+│ - 指标开关: MA / EMA / MACD / BOLL / KDJ│ - 逐笔交易最新流 (Time & Sales)│
+│ - WebView (klinecharts) 交互 │ - 核心基本面/技术面速览指标 │
+├────────────────────────────────────────┴───────────────────────────────┤
+│ 底部选项卡: [新闻公告] [AI 异动速评] [财务摘要] │
+└────────────────────────────────────────────────────────────────────────┘
+```
+
+#### 4.3.3 K 线图表宿主技术重构
+- 彻底移除 `KLineChartView.cs` 代码后置中的硬编码字体和字号。
+- 将 Loading/Error 面板声明在 XAML 或使用统一的 `SkeletonStyles` / `ErrorPanel` 样式,确保与设计系统画刷(`DynamicResource CardBackgroundBrush` 等)绝对同步。
+
+---
+
+### 4.4 AI 选标的 (AssetSelectionPageView)
+
+#### 4.4.1 现状与痛点
+- 界面上的三个模式卡片使用 Emoji 图标(`👤`, `📰`, `⚡`)。
+- 生成结果列表后,缺少多选操作、批量自选或结果横向对比功能。
+- 分析执行时使用全屏阻塞蒙层,无法进行其他页面浏览。
+
+#### 4.4.2 重构设计蓝图
+- **替换 Emoji 为矢量 SVG 图标**:
+ - 用户需求模式:`/Assets/Images/icon_users.svg`
+ - 新闻热点模式:`/Assets/Images/icon_document.svg`
+ - 快速量化模式:`/Assets/Images/icon_bolt.svg`
+- **结果呈现区升级**:
+ - 卡片顶部显示综合匹配度得分(Score Badge)。
+ - 提供 `加入自选` 与 `发起深度研判` 双快捷按钮。
+ - 增加按得分降序、按涨跌幅排序的过滤栏。
+
+---
+
+### 4.5 多智能体协同分析 (AgentAnalysisPageView)
+
+#### 4.5.1 现状与交互致命伤
+- **遮罩抽屉冲突**: 点击 FAB 打开聊天侧边栏时,主界面被 `DarkOverlayBrush` 覆盖且半透明置灰。用户无法在看清技术分析图表或财报数字的同时与助手交互。
+- **分析等待期黑盒**: 分析中弹出一个全局 Progress 弹窗,用户只能干等 20-30 秒,不能实时查看已经跑完的分析师(如基本面分析师已输出,技术分析师还在跑)。
+
+#### 4.5.2 重构设计蓝图 (分屏并列架构)
+```
+┌─────────────────────────────────────────────────┬──────────────────────┐
+│ 研判综合看板 (主视图 65%-70%) │ AI 伴侣分屏 (30%-35%) │
+├─────────────────────────────────────────────────┤ │
+│ [顶栏]: 标的名称/评级徽章/目标价/更多导出(⋮) │ [常驻或一键折叠] │
+│ [Hero 卡片]: 综合评分 8.5/10 (强力买入) │ - 结构化追问快捷词: │
+│ [分析师共识矩阵]: 基本面 | 财务 | 技术 | 情绪 │ "解释下技术面风险" │
+│ [多维度卡片流]: │ "若跌破支撑位如何" │
+│ - 技术面卡片: 支撑压力位、指标背离图 │ - 流式对话问答流 │
+│ - 财务面卡片: 营收增速、估值分位 │ - 引用报告锚点卡片 │
+│ - 风险雷达: 负面舆情、解禁预警 │ - 下拉式输入框 │
+└─────────────────────────────────────────────────┴──────────────────────┘
+```
+
+#### 4.5.3 交互优势
+- **无遮挡对话**: 报告始终清晰可见,AI 聊天流中引用报告观点时,点击可平滑滚动至对应报告卡片。
+- **流式增量显影**: 取消中心全屏阻塞弹窗,采用顶部步骤条 + 各分析师卡片骨架屏(Skeleton)并行点亮动效。
+
+---
+
+### 4.6 交易终端 (TradingPageView & SubViews)
+
+#### 4.6.1 现状与痛点
+- 交易监控、策略配置、历史记录、API 配置以单调的水平 Tab 呈现。
+- 策略配置界面为超长纵向展开列表(Expander),表单繁重,缺乏网格价格区间等参数的可视化直观反馈。
+- 缺乏交易员紧急避险动作(如:一键全平、一键撤单)。
+
+#### 4.6.2 重构设计蓝图
+```
+┌────────────────────────────────────────────────────────────────────────┐
+│ 运行状态栏: 🟢 实盘运行中 | 币安现货 | 今日盈亏 +$128.50 (+2.4%) | [一键撤单] │
+├───────────────────────┬────────────────────────────────────────────────┤
+│ 策略与参数工作台 │ 活跃仓位与委托挂单 │
+│ - 策略选择 (网格/DCA) │ - 实时持仓列表 (开仓均价 / 标记价 / 未实现盈亏)│
+│ - 可视化区间滑块设定 │ - 当前委托挂单表 (支持单笔取消) │
+│ - 风险等级与止损阈值 │ - 最近成交记录 │
+├───────────────────────┴────────────────────────────────────────────────┤
+│ 历史成交表格 (可按标的/方向/时间范围筛选并支持导出 CSV) │
+└────────────────────────────────────────────────────────────────────────┘
+```
+
+#### 4.6.3 严格风控交互
+- 任何真实下单或策略启动操作,弹出的确认框必须采用强对比度语义背景(`DangerPanelBackgroundBrush` 或 `WarningPanelBackgroundBrush`),明确显示预计最大回撤与止损线,杜绝误触。
+
+---
+
+### 4.7 自选看板 (FavoritesPageView)
+
+#### 4.7.1 现状与痛点
+- 仅提供单一卡片流(宽 290px),无法满足关注数十只标的时的快速对比与排序诉求。
+
+#### 4.7.2 重构设计
+- **双模态切换 (Cards / Table View)**:
+ - **卡片视图 (Card Grid)**: 适合休闲盯盘,展示大字号涨跌幅与价格。
+ - **表格视图 (Dense Table)**: 适合专业看盘,单行高度 36px,等宽数字字体,支持列头点击排序(按代码、现价、涨跌幅、换手率、24小时成交额)。
+- **右键上下文菜单**:
+ - `查看详情`、`发起 AI 分析`、`设置价格告警`、`移出自选`。
+
+---
+
+### 4.8 统一告警中心 (PriceAlertPageView & AlertHistoryView)
+
+#### 4.8.1 现状与优化
+- 目前已实现 `PriceAlertRule` 与底层 `IAlertCenterService` 的对接。
+- **优化点**:
+ - 新建告警表单由默认展开改为紧凑抽屉或快捷弹窗,将页面首屏完全留给已有规则列表。
+ - 告警历史增加按级别(Info/Warning/Critical)和标的符号的多维筛选过滤器。
+
+---
+
+### 4.9 设置与模型配置 (SettingsPageView & MCPConfigPageView)
+
+#### 4.9.1 现状与优化
+- 设置项结构清晰,但 RAG 知识库管理与 MCP 工具管理散落在两处。
+- **优化点**:
+ - 保持设置页作为全局枢纽,将“模型配置”、“知识库与向量检索 (RAG)”、“MCP 扩展工具”整合为清晰的二级导航标签,便于统一管理。
+
+---
+
+## 五、设计系统核心 Token 审计与收敛标准
+
+针对当前代码库中未完全收敛的颜色与尺寸,强制执行如下映射规范:
+
+### 5.1 文字与强调色 (On-Color)
+- **绝对禁止使用纯文本字面量 `Foreground="White"`**。
+- 一律引用 `{StaticResource TextOnEmphasisBrush}`。
+- 当处于警告色或浅底容器时,文字需采用对应的语义文字刷(如 `WarningPanelTextBrush` / `SuccessPanelTextBrush`)。
+
+### 5.2 数字字体规范
+- 所有价格、涨跌百分比、成交量、资金数值一律指定:
+ ```xml
+ FontFamily="{StaticResource DataFontFamily}"
+ ```
+ 该 Token 在深色主题下启用等宽对齐,严禁在个别控件中自行拼写 `"Consolas, 'Cascadia Mono'"`。
+
+### 5.3 涨跌标签规范
+- 摒弃早期“纯文字变色”方案,全面采纳“12% 透明度背景色块 + 同色加粗前景文字”:
+ - 上涨标签背景:`{StaticResource BullishTagBackgroundBrush}`
+ - 下跌标签背景:`{StaticResource BearishTagBackgroundBrush}`
+ - 平盘标签背景:`{StaticResource NeutralTagBackgroundBrush}`
+
+---
+
+## 六、实施与重构路线图 (Implementation Roadmap)
+
+### Phase 1: 规范与硬编码收敛 (阻断级修正)
+1. **清理 `Foreground="White"` 遗留**:收敛 `ButtonStyles.axaml` (6处) 及 `MainWindow.axaml` (1处) 为 `TextOnEmphasisBrush`。
+2. **根除残留 Emoji**:重构 `AssetSelectionPageViewModel.cs` 与 AdaptiveCard 解析器,全面切换为 SVG 矢量资源。
+3. **消除 `AnalystResultCardView` 本地字体声明**:替换硬编码 Consolas 为 `DataFontFamily`,收敛字号至 `Spacing.axaml` 阶梯。
+4. **重构 `KLineChartView.cs` 的 Loading/Error 视图**:使用应用主题画刷重构,彻底解决切主题时的闪烁与底色断层。
+
+### Phase 2: 交互体验深度优化 (核心交互破局)
+1. **AgentAnalysis 侧边栏交互改造**:由遮罩式遮挡抽屉升级为**平铺分屏 / 可折叠伴侣面板 (Docked Assistant)**,实现边看深度报告边对话。
+2. **FavoritesPageView 双视图改造**:在保留卡片网格的同时,新增专业级金融数据表格视图(带列排序)。
+3. **TopBar 行情条真实化**:对接**市场级聚合指标**数据源,消除空置占位。A 股取三大指数(上证 / 深证成指 / 创业板指,新浪 hq 指数端点);Crypto 取对等的三项市场级指标(全网总市值+24h涨跌幅、BTC 主导率,CoinGecko `/global`;恐惧贪婪指数,alternative.me `/fng`)。**禁止展示具体标的价格**(如 BTC/ETH/SOL 单价),其语义与"市场指数"不对等。
+4. **AssetPageView 信息密度提升**:增补买卖盘五档/深度概览卡片,并增加一键直达交易与告警入口。
+5. **导航尺寸收敛**:导航项点击区域 56/48 → 44×44,导航图标 24 → 22px(原 design-system.md 第七章遗留待办,收敛至 `Spacing.axaml` Token)。
+
+### Phase 3: 交易与智能化高级演进
+1. **TradingPageView 策略可视化**:为网格/定投策略增加价格区间与止损线的图形化预览。
+2. **紧急避险控制中心**:在交易状态栏增加经过二次校验的一键全平/一键撤单快捷通道。
+3. **多窗口/面板拆分支持**:探索 Avalonia 跨屏幕/可拖拽浮动窗口,支持专业交易员多显示器同时盯盘与对话。
diff --git a/docs/plan/alert-center-refactor-plan.md b/docs/plan/alert-center-refactor-plan.md
deleted file mode 100644
index 7b778e1d..00000000
--- a/docs/plan/alert-center-refactor-plan.md
+++ /dev/null
@@ -1,116 +0,0 @@
-# 统一告警中心(AlertCenter)重构方案
-
-> 状态:已在分支 `refactor/alert-center` 实施。价格告警去抖/冷却/限次、告警落库与历史页、
-> 未读徽标、风险与行情连接告警、确认级交易联动门(`IAlertGate`)均已接入,
-> 单元测试见 `tests/Application/AlertCenterTest.cs`、`AlertSuppressionPolicyTest.cs`、
-> `AlertGateTest.cs`、`PriceAlertRuleAdvancedTest.cs`。
-
-## 背景与现状
-
-项目已有一套完整的价格预警闭环:
-
-- `PriceAlertRule`(`src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertRule.cs`):条件判定、`IsOneTime` 一次性触发。
-- `PriceAlertService`(`src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertService.cs`):SQLite 持久化规则;A 股 `PeriodicTimer` 20s 轮询、crypto 走 `BinanceWebSocketService` 推送评估。
-- 通知:`INotificationService` + `NotificationWindow` 右下角弹窗;`App.axaml.cs` 启动接线。
-- 交易链路:`MarketMonitor`(价格流)→ `AISignalStrategyExecutor`(AI 信号)→ `TradeExecutor`(风控 → 确认 → 下单 → 记录),`RiskManager.ValidateOrderAsync` 返回 `RiskCheckResult`(Pass / Reject / RequireConfirmation),`TradeExecutor.ConfirmationCallback` 为现成的人工确认钩子。
-
-现有痛点:告警出口分散、只有价格一类、无去抖/冷却/限次机制、告警与交易决策无联动。
-
-## 设计决策(已确认)
-
-- **范围**:统一告警中心,收敛价格 / 风险 / 信号三类告警,现有价格告警迁入。
-- **交易联动强度**:默认"确认级"——告警触发中时 AI 交易信号升级为需人工确认,不自动下单。
-
-## 一、告警分类
-
-| 类型 | 来源 | 默认级别 |
-|------|------|---------|
-| 价格告警 | PriceAlertRule(已有) | Warning |
-| 风险告警 | 回撤接近熔断、信号被风控拒绝、WS 断线/数据源异常 | Warning / Critical |
-| 信号告警 | AI 信号产出、MarketMonitor 策略状态变化 | Info |
-
-统一产出 `AlertEvent`,UI 提供历史列表 + 未读角标,触达走 `INotificationService`。
-
-## 二、核心模型(新增,`src/MarketAssistant.App.Services/Applications/AlertCenter/`)
-
-- `AlertEvent`:Id、MarketType、Symbol、Level(Info/Warning/Critical)、Source(PriceAlert/Risk/Signal/System)、Title、Content、CreatedAt、IsRead。
-- `PriceAlertRule` 扩展:
- - `MaxTriggerCount`(一次性 / 限次 / 不限次,替代 `IsOneTime` 语义,保留兼容);
- - `ConfirmTicks` / `ConfirmSeconds`(去抖确认期:条件需持续成立才触发);
- - `CooldownMinutes`(冷却期:触发后 M 分钟内不重复);
- - `TradingImpact`(枚举:None / RequireConfirmation,本期仅这两种)。
-- 去抖/冷却状态:内存态,不落库,重启重置可接受。实现为 `AlertSuppressionPolicy.MergeState`(按去重键)与 `QuotaState`(全局配额窗口),未采用原计划的单一 `AlertDedupeState` 命名——合并状态按去重键、配额状态全局共享,生命周期不同,拆开后纯逻辑可直接单测。
-
-## 三、AlertCenterService
-
-- 接口 `IAlertCenterService`:`RaiseAlertAsync(AlertEvent)`、`AlertRaised` 事件、历史/未读查询、标记已读;SQLite 持久化 `AlertEvent`(复用 `SqliteServiceBase`)。
-- 抑制逻辑:
- - 冷却期内同类告警合并;
- - 全局每小时配额(默认 20 条),超限只落库不弹窗;Critical 不受限,但同类 5 分钟内合并为一条"发生 N 次";
- - 静默窗口:A 股非交易时段的价格类告警只落库不弹窗,**Critical 例外**(确认级告警需即时触达并驱动交易联动门)。判定落在告警中心抑制层(`AlertSuppressionPolicy.IsSilencedByTradingSession`);轮询层休市时仅继续评估确认级规则,普通规则跳过以省去无效行情请求。注:项目内原无可复用的交易时段判断(`IsMarketOpen` / `IsTradingTime` 全库无实现),故新建 `AShareTradingHours`(东八区 09:30–11:30 / 13:00–15:00,含 Windows / IANA 时区 ID 回退)。
-- 触达:`AlertRaised` → `INotificationService` 弹窗;历史页实现为 `AlertHistoryView` + `AlertHistoryViewModel`,作为"价格预警"页内的"告警历史"标签呈现(未采用原计划的独立 `AlertCenterPageView` 导航页——三类告警共用一份历史即可,独立页会稀释规则入口);主窗口导航以未读徽标跨类提示。
-- 用户偏好加入 `UserSetting`(免打扰时段、每小时配额),`SettingsPageViewModel` 增配置项。
-
-## 四、迁移现有价格告警
-
-- `PriceAlertService` 触发出口改为调用 `IAlertCenterService.RaiseAlertAsync`,不再直接弹通知;轮询/WS 评估逻辑不动。
-- `UpdateTriggerState` 内实现确认期 / 冷却期 / 限次判定。
-
-## 五、风险告警评估器(RiskAlertEvaluator)
-
-- 挂接在 `MarketMonitor` 价格消费管线中,与 AI 信号评估同级,**不侵入 `RiskManager`**。
-- 触发项:
- - 持仓回撤达到熔断阈值的一定比例(如 80%)→ Warning;
- - 策略触发被风控 Reject / RequireConfirmation 拒绝 → Warning(`Source=Risk`);用户主动拒绝或人工确认超时改由信号告警覆盖(`Source=Signal`),二者按 `TradeRejectionReason.IsUserRejection` 互斥分流,避免同一事件双告警;
- - `BinanceWebSocketService` 断线 / 数据源异常 → Critical,重连恢复后发 Info。
-- 信号类告警由同层的 `SignalAlertEvaluator`(`AlertSource.Signal`)产出:策略自动暂停(用户拒绝路径,Warning)、策略完结(Info)、AI 信号成交(Info)。`MarketMonitor` 因此不再持有 `INotificationService`,最后一处直连弹窗(策略自动暂停提示)已收敛到告警中心。
-
-## 六、确认级交易联动(IAlertGate)
-
-- 新增轻量 DI 单例 `IAlertGate`:`IsGated(MarketType, symbol)` —— 该标的是否存在 `TradingImpact=RequireConfirmation` 且触发中的告警。
-- `TradeExecutor` 在风控校验后、确认环节前查询 `IAlertGate`:命中则强制走 `ConfirmationCallback` 人工确认(即使风控结果为 Pass),弹窗文案说明"因告警触发需确认";无确认订阅者时拒绝下单而非放行。
-- 实现补充(原计划未列):
- - **保护性平仓豁免**:`requireClose = true`(止损 / 止盈 / 追踪止损等退出型触发)不受门限制,避免"因告警反而无法及时离场"这一更危险的结果;
- - **门判定当前仅覆盖 Crypto**:`TradeExecutor` 本身是 Crypto keyed 服务,A 股侧尚无自动交易链路,A 股告警门仅用于 UI 呈现。
-- 原则:**告警事件 ≠ 交易指令**,不自动下单、不修改 `RiskManager`。
-
-## 七、接线
-
-- DI:`AddBusinessServices()` 注册 `IAlertCenterService`、`IAlertGate` 及各评估器;`App.axaml.cs` 启动初始化(与现有 PriceAlertService 同模式)。
-- 分层依赖保持在 App.Services 内部,不新增跨层依赖。
-
-## 八、验证与实施顺序
-
-- `dotnet build MarketAssistant.slnx -c Debug` 通过。
-- 单元测试已补齐:触发判定(去抖/确认期/冷却/限次)、抑制策略(合并/配额/免打扰/休市静默)、告警中心集成(落库/合并/未读/联动门)、交易门与交易时段、信号告警分流。`dotnet test --filter TestCategory=Unit` 360/360 通过;`dotnet format --verify-no-changes` 无差异。
-
-实施顺序:模型与 AlertCenterService → 通知/历史页 → 价格告警迁移 → 风险评估器 → IAlertGate 交易联动 → 设置项 → 构建验证。
-
-## 九、实现取舍与遗留
-
-- **`TradingImpact` 与 `Level` 耦合**:价格告警中 `TradingImpact = RequireConfirmation` 映射为 `AlertLevel.Critical`(`PriceAlertService.RaiseAlertSafeAsync`),因此确认级规则天然绕过每小时配额、免打扰时段与休市静默。取舍是"确认级必达"优先于"弹窗噪声";若噪声过大可改为两维解耦(级别由规则单独配置)。
-- **门与告警状态均为内存态**:重启后 `AlertGate` 清空,同时 `PriceAlertService.LoadRulesAsync` 将规则 `Triggered` 重置为 false,两者一致,不会出现"规则显示已触发但门未生效"的残留。
-- **信号告警覆盖面**:当前仅上报策略自动暂停(用户拒绝路径)、策略完结、AI 信号成交三类;策略手动启停、网格/DCA 常规成交暂不上报,按实际噪声反馈再决定是否纳入。
-- **遗留死代码**:`PriceAlertRule.AlertModeText`("一次性/持续")全库无引用(视图使用内联 `IsOneTime` 标签),待小改动清理。
-- **静默告警仍消耗每小时配额**:配额在 `AlertSuppressionPolicy.Evaluate` 内递增,而休市/免打扰抑制在其外部判定,因此被压掉弹窗的告警仍计入配额。未修——修正需把抑制判定并入 policy(涉及将 `AShareTradingHours` 注入纯逻辑层的可测性设计);若实测夜间噪声明显挤占白天配额再处理。
-
-## 十、审计后补修(第二轮)
-
-对首轮实现逐项审计,发现并修复 3 个计划外缺陷:
-
-- **联动门泄漏**:`PriceAlertService.RemoveRuleAsync` 删除规则时未释放确认级联动门(禁用路径已释放),导致删除"触发中的确认级规则"后该标的 AI 信号被永久强制人工确认,仅重启可解。现删除路径同样调用 `RaiseAlertClearedSafeAsync`;Risk/Signal 评估器 `TradingImpact` 恒为 `None`,不经此路径。
-- **`ConfirmSeconds` 能力不可达**:模型、建表、持久化、判定、摘要文本齐备,但表单仅有 `ConfirmTicks`。已补"去抖持续(秒)"输入(`PriceAlertPageView.axaml` + `NewRuleConfirmSeconds`)。tick 间隔在 A 股(20s 轮询)与币安(WS 推送)下语义不同,秒维度才是跨市场一致的判定口径。
-- **`alert_events` 无界增长**:原实现只有 INSERT/SELECT。新增 30 天保留期,随启动初始化执行一次 `PruneExpiredAlertsAsync`(用 `datetime()` 解析比较,避免 ISO-8601 偏移写法下字符串长度差异导致误判)。
-
-测试:新增 `Initialize_ShouldPruneAlertsBeyondRetentionPeriod`,`dotnet test --filter TestCategory=Unit` 361/361 通过。门泄漏与 `ConfirmSeconds` 属服务装配/表单绑定,`PriceAlertService` 依赖 `BinanceWebSocketService`、`ClsQuoteClient` 具体类且无既有测试替身,未补自动化覆盖,由构建与代码走查保证。
-
-## 十一、审计后补修(第三轮)
-
-对照方案逐项复核代码,修复 3 处门管理缺陷与 1 处展示缺陷:
-
-- **禁用路径联动门泄漏(第二轮误判)**:第二轮称"禁用路径已释放",实际 `PriceAlertService.ToggleRuleAsync` 禁用规则时同样未释放确认级联动门,仅删除路径已释放。现禁用路径在 `newEnabled == false` 且 `TradingImpact == RequireConfirmation` 时同样调用 `RaiseAlertClearedSafeAsync`,否则禁用触发中的确认级规则后该标的 AI 信号被永久强制人工确认。
-- **同标的多条确认级规则门计数泄漏**:`AlertCenterService._activeGatedAlerts` 键原为 `(Source, MarketType, Symbol)`(不含标题),同一标的两条不同条件的确认级规则先后触发→解除时,后触发覆盖先触发,第二次 `RaiseAlertClearedAsync` 的 `Remove` 失败、门计数不归零,标的水久门控。现键扩展为 `(Source, MarketType, Symbol, Title)`,各规则独立登记/释放。
-- **一次性确认级规则门永久残留**:一次性规则触发即停用,但原实现 `autoDisabled` 分支先 `RaiseAlertClearedAsync`(门尚未登记,`Remove` 失败)、后 `RaiseAlertAsync`(`Activate` 生效),导致门随规则停用后永不释放。现 `RaiseAlertSafeAsync` 对一次性规则将 `TradingImpact` 降为 `None`(弹窗级别仍为 `Critical`),不再登记门;`autoDisabled` 分支不再释放。
-- **规则列表摘要漏显"持续秒"**:`PriceAlertPageViewModel.CreateDisplayRule` 未复制 `ConfirmSeconds`,导致 `RuleOptionsText` 不显示"持续 X 秒"。已补复制。
-
-测试:新增 `RaiseAlert_MultipleConfirmationRulesSameSymbol_ShouldTrackGatesIndependently`,告警相关测试 `dotnet test --filter FullyQualifiedName~Alert` 51/51 通过;`dotnet build MarketAssistant.slnx -c Debug` 0 错误。
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 5b06ae92..1484e412 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"
@@ -158,7 +158,7 @@
7. K线图主题(v2.4)
8. 与实现的同步状态
- v2.4 轮收敛已完成以下项(详见 design-system.md 第 10/11 章):
+ v2.4 轮收敛已完成以下项(原 design-system.md 第 10/11 章,现以 design.md 第五章与 Colors.axaml / Spacing.axaml 为准):
- ✅功能语义色收敛至 Tailwind 体系(Success/Warning/Error/Info 及 Hover/Pressed),与涨跌色不再冲突
- ✅遗留脏色清除:#dc3545、#007bff 及其画刷已删除;涨跌转换器改读设计系统资源(原 #e74c3c/#2ecc71)
@@ -180,7 +180,7 @@ 8. 与实现的同步状态
- ✅排版兼容区清理(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 第七章。
+ ✅ v2.4 收敛项已闭环,本页与 Colors.axaml / Spacing.axaml / kline_chart.html 实现一致。遗留待办:导航项尺寸(56/48 → 44×44)与导航图标(24 → 22px)见 design.md 第六章 Phase 2.5。
diff --git a/src/MarketAssistant.Agents/MarketAnalysis/Models/CoordinatorResult.cs b/src/MarketAssistant.Agents/MarketAnalysis/Models/CoordinatorResult.cs
index 14ae948d..f47f3755 100644
--- a/src/MarketAssistant.Agents/MarketAnalysis/Models/CoordinatorResult.cs
+++ b/src/MarketAssistant.Agents/MarketAnalysis/Models/CoordinatorResult.cs
@@ -38,7 +38,7 @@ public sealed class CoordinatorResult
[Description("置信度百分比,基于信息完整性和一致性。评分标准:" + ScoringStandards.Confidence)]
public float ConfidencePercentage { get; set; }
- [Description("各维度评分,包含基本面、技术面、财务面、市场情绪和新闻事件的评分。评分范围 1-10。评分标准:" + ScoringStandards.Performance)]
+ [Description("各维度评分,包含基本面、技术面、财务面、市场情绪、新闻事件的评分,以及虚拟币专属指标评分(仅 Crypto 有效)。评分范围 1-10。评分标准:" + ScoringStandards.Performance)]
public AnalysisDimensionScores DimensionScores { get; set; } = new();
[MinLength(1)]
@@ -136,4 +136,12 @@ public sealed class AnalysisDimensionScores
[Range(1, 10)]
[Description("新闻事件评分")]
public float News { get; set; }
+
+ ///
+ /// 虚拟币专属指标评分(市值/流动性/波动率/市场结构综合)。
+ /// A 股分析固定为 null;Crypto 分析缺数据时为 null。
+ ///
+ [Range(1, 10)]
+ [Description("虚拟币指标评分(仅 Crypto 市场有效,无数据时为 null)")]
+ public float? CryptoMetrics { get; set; }
}
diff --git a/src/MarketAssistant.Agents/MarketAnalysis/Models/TechnicalAnalysisResult.cs b/src/MarketAssistant.Agents/MarketAnalysis/Models/TechnicalAnalysisResult.cs
index fe4382da..a623422a 100644
--- a/src/MarketAssistant.Agents/MarketAnalysis/Models/TechnicalAnalysisResult.cs
+++ b/src/MarketAssistant.Agents/MarketAnalysis/Models/TechnicalAnalysisResult.cs
@@ -49,8 +49,8 @@ public sealed class ChartPatternTrend
[Description("关键价位分析")]
public sealed class KeyPriceLevels
{
- [Range(0.01, 100000)]
- [Description("当前价格,单位:元")]
+ [Range(0.00000001, 10000000)]
+ [Description("当前价格。A 股单位:元;虚拟币单位:USDT")]
public decimal CurrentPrice { get; set; }
[MinLength(1)]
@@ -127,16 +127,16 @@ public sealed class TradingStrategyRecommendation
[Description("操作方向")]
public OperationRecommendation OperationDirection { get; set; }
- [Range(0.01, 100000)]
- [Description("目标价位区间下限,单位:元,无法预测时设为null")]
+ [Range(0.00000001, 10000000)]
+ [Description("目标价位区间下限。A 股单位:元;虚拟币单位:USDT,无法预测时设为null")]
public decimal? TargetPriceLow { get; set; }
- [Range(0.01, 100000)]
- [Description("目标价位区间上限,单位:元,无法预测时设为null")]
+ [Range(0.00000001, 10000000)]
+ [Description("目标价位区间上限。A 股单位:元;虚拟币单位:USDT,无法预测时设为null")]
public decimal? TargetPriceHigh { get; set; }
- [Range(0.01, 100000)]
- [Description("止损价格点位,单位:元,无法预测时设为null")]
+ [Range(0.00000001, 10000000)]
+ [Description("止损价格点位。A 股单位:元;虚拟币单位:USDT,无法预测时设为null")]
public decimal? StopLossPrice { get; set; }
[Description("持仓周期")]
diff --git a/src/MarketAssistant.App.Services/AGENTS.md b/src/MarketAssistant.App.Services/AGENTS.md
index d4ec722e..bd444ba6 100644
--- a/src/MarketAssistant.App.Services/AGENTS.md
+++ b/src/MarketAssistant.App.Services/AGENTS.md
@@ -46,7 +46,7 @@ MarketAssistant.App.Services/
## 统一告警中心(Applications/AlertCenter)
-价格 / 风险 / 信号三类告警的唯一出口,设计详见 `docs/plan/alert-center-refactor-plan.md`。
+价格 / 风险 / 信号三类告警的唯一出口。
| 文件 | 职责 |
|------|------|
diff --git a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs
index f46a3602..e6339870 100644
--- a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs
+++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs
@@ -399,6 +399,10 @@ private static void ClampNumericScores(CoordinatorResult result)
result.DimensionScores.Financial = Math.Clamp(result.DimensionScores.Financial, 1, 10);
result.DimensionScores.Sentiment = Math.Clamp(result.DimensionScores.Sentiment, 1, 10);
result.DimensionScores.News = Math.Clamp(result.DimensionScores.News, 1, 10);
+ if (result.DimensionScores.CryptoMetrics.HasValue)
+ {
+ result.DimensionScores.CryptoMetrics = Math.Clamp(result.DimensionScores.CryptoMetrics.Value, 1, 10);
+ }
}
private void LogMessageDiagnostics(string stage, IEnumerable messages)
diff --git a/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertCenterService.cs b/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertCenterService.cs
index 737d9c9d..647a17bd 100644
--- a/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertCenterService.cs
+++ b/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertCenterService.cs
@@ -63,13 +63,21 @@ public async Task RaiseAlertAsync(AlertEvent alert, CancellationToken cancellati
await EnsureInitializedAsync(InitializeDatabaseAsync);
var settings = _userSettingService.CurrentSetting;
+ // 触达抑制(休市静默 / 用户关闭通知 / 免打扰时段)在配额评估前算好传入 policy:
+ // 被抑制的告警只落库不弹窗,也不消耗每小时配额,避免静默期噪声挤占交易时段配额
+ var isSessionSilenced = AlertSuppressionPolicy.IsSilencedByTradingSession(
+ alert, AShareTradingHours.IsTradingSession());
+ var suppressNotification =
+ isSessionSilenced || !settings.Notification || IsInQuietHours(settings, alert.Level);
+
AlertSuppressionPolicy.Decision decision;
lock (_stateSync)
{
var mergeState = _mergeStates.GetValueOrDefault(alert.DedupeKey)
?? AlertSuppressionPolicy.MergeState.Empty;
decision = _policy.Evaluate(
- alert, mergeState, _quotaState, settings.AlertHourlyQuota, DateTime.UtcNow);
+ alert, mergeState, _quotaState, settings.AlertHourlyQuota, DateTime.UtcNow,
+ suppressNotification);
_mergeStates[alert.DedupeKey] = decision.NewMergeState;
_quotaState = decision.NewQuotaState;
}
@@ -93,11 +101,9 @@ public async Task RaiseAlertAsync(AlertEvent alert, CancellationToken cancellati
_alertGate.Activate(alert.MarketType, alert.Symbol);
}
- // 触达抑制三重:每小时配额(policy 内判定)+ A 股休市静默 + 用户免打扰时段;
- // 均只压弹窗,告警仍落库、确认级交易联动门仍登记
- var isSessionSilenced = AlertSuppressionPolicy.IsSilencedByTradingSession(
- alert, AShareTradingHours.IsTradingSession());
- if (decision.ShouldNotify && !isSessionSilenced && settings.Notification && !IsInQuietHours(settings, alert.Level))
+ // policy 已统一判定触达抑制(配额 + 静默),被抑制的告警仍落库、
+ // 确认级交易联动门仍登记
+ if (decision.ShouldNotify)
Notify(alert);
AlertRaised?.Invoke(alert);
diff --git a/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertSuppressionPolicy.cs b/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertSuppressionPolicy.cs
index 5911debd..c1221170 100644
--- a/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertSuppressionPolicy.cs
+++ b/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertSuppressionPolicy.cs
@@ -6,7 +6,9 @@ namespace MarketAssistant.Applications.AlertCenter;
/// 告警抑制纯逻辑(不依赖 IO,便于单元测试):
/// - 同类告警(同去重键)在合并窗口内只产生一条记录并累计次数,不重复弹窗;
/// - 非 Critical 告警受全局每小时配额限制(配额状态全局共享,而非按去重键独立),
-/// 超限只落库不弹窗;Critical 不受配额限制。
+/// 超限只落库不弹窗;Critical 不受配额限制;
+/// - 调用方传入触达抑制标志(休市静默/免打扰/关闭通知)时,告警只落库不弹窗且不消耗配额,
+/// 避免静默期噪声挤占交易时段配额。
///
public sealed class AlertSuppressionPolicy
{
@@ -41,8 +43,19 @@ public sealed record Decision(
/// 评估一条告警是否允许触达。调用方需线程安全地保存
/// (按去重键)与 (全局)。
///
+ /// 待评估的告警。
+ /// 该去重键当前的合并状态。
+ /// 当前全局配额状态。
+ /// 每小时弹窗配额上限。
+ /// 当前 UTC 时间(固定窗口与合并窗口判定基准)。
+ ///
+ /// 调用方计算的触达抑制标志(休市静默 / 免打扰时段 / 用户关闭通知)。
+ /// 被抑制的告警仍正常落库与合并,但不消耗每小时配额——弹窗本就不会发生,
+ /// 不应挤占后续交易时段的配额。
+ ///
public Decision Evaluate(
- AlertEvent alert, MergeState mergeState, QuotaState quotaState, int hourlyQuota, DateTime utcNow)
+ AlertEvent alert, MergeState mergeState, QuotaState quotaState, int hourlyQuota,
+ DateTime utcNow, bool suppressNotification = false)
{
ArgumentNullException.ThrowIfNull(alert);
@@ -58,9 +71,10 @@ public Decision Evaluate(
count = 0;
}
- var canNotify = alert.Level == AlertLevel.Critical || count < hourlyQuota;
+ var canNotify = !suppressNotification &&
+ (alert.Level == AlertLevel.Critical || count < hourlyQuota);
- // 合并触发不新增记录也不弹窗,因此不消耗配额;可触达的新告警才计数
+ // 合并触发与静默告警都不弹窗,因此不消耗配额;可触达的新告警才计数
if (!isMerge && canNotify)
count++;
diff --git a/src/MarketAssistant.App.Services/Applications/Assets/Models/AssetInfo.cs b/src/MarketAssistant.App.Services/Applications/Assets/Models/AssetInfo.cs
index ef981f1e..017a462a 100644
--- a/src/MarketAssistant.App.Services/Applications/Assets/Models/AssetInfo.cs
+++ b/src/MarketAssistant.App.Services/Applications/Assets/Models/AssetInfo.cs
@@ -64,6 +64,26 @@ public string ChangePercentage
///
public string? Volume24h { get; set; }
+ ///
+ /// 现价数值(从 解析,去除千分位逗号)。
+ /// 供表格视图按数值排序使用;无法解析时为 0。
+ ///
+ public decimal PriceValue => TryParseNumber(CurrentPrice);
+
+ ///
+ /// 涨跌幅数值(从 解析,去除百分号与正号)。
+ /// 供表格视图按数值排序使用;无法解析时为 0。
+ ///
+ public decimal ChangePercentValue => TryParseNumber(ChangePercentage?.TrimEnd('%'));
+
+ private static decimal TryParseNumber(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text)) return 0;
+ var cleaned = text.Replace(",", "").Replace("+", "").Trim();
+ return decimal.TryParse(cleaned, System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out var value) ? value : 0;
+ }
+
public event PropertyChangedEventHandler? PropertyChanged;
private void SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null)
diff --git a/src/MarketAssistant.App.Services/Applications/Crypto/BinanceAccountService.cs b/src/MarketAssistant.App.Services/Applications/Crypto/BinanceAccountService.cs
index 89c09e22..9cf88960 100644
--- a/src/MarketAssistant.App.Services/Applications/Crypto/BinanceAccountService.cs
+++ b/src/MarketAssistant.App.Services/Applications/Crypto/BinanceAccountService.cs
@@ -473,6 +473,11 @@ public class BinanceOrderResponse
public string Type { get; set; } = string.Empty;
public string Side { get; set; } = string.Empty;
+ ///
+ /// 条件单触发价(StopMarket/TakeProfitMarket 订单)。
+ ///
+ public string StopPrice { get; set; } = string.Empty;
+
///
/// 成交流水(仅 POST /api/v3/order 响应包含,查询接口不返回)。
/// 用于汇总手续费。
diff --git a/src/MarketAssistant.App.Services/Applications/Favorites/FavoriteCodeNormalizer.cs b/src/MarketAssistant.App.Services/Applications/Favorites/FavoriteCodeNormalizer.cs
new file mode 100644
index 00000000..4173835d
--- /dev/null
+++ b/src/MarketAssistant.App.Services/Applications/Favorites/FavoriteCodeNormalizer.cs
@@ -0,0 +1,33 @@
+namespace MarketAssistant.Applications.Favorites;
+
+///
+/// 收藏(自选)代码归一化工具:所有写入/查询路径共用唯一入口,
+/// 保证 SQLite 文本比较(区分大小写)下收藏状态判定一致。
+/// 规则:统一大写;A股剥离 SH/SZ 交易所前缀;虚拟币保持交易对原样(大写)。
+///
+public static class FavoriteCodeNormalizer
+{
+ ///
+ /// 按市场归一化收藏代码(如 sh600519 → 600519、btcusdt → BTCUSDT)。
+ ///
+ /// 原始资产代码(可能带交易所前缀或大小写混杂)
+ /// 资产所属市场
+ /// 归一化后的代码;输入为空白时返回空字符串
+ public static string Normalize(string code, MarketType marketType)
+ {
+ if (string.IsNullOrWhiteSpace(code))
+ return string.Empty;
+
+ var normalized = code.Trim().ToUpperInvariant();
+
+ if (marketType == MarketType.AShare
+ && normalized.Length > 2
+ && (normalized.StartsWith("SH", StringComparison.Ordinal)
+ || normalized.StartsWith("SZ", StringComparison.Ordinal)))
+ {
+ normalized = normalized[2..];
+ }
+
+ return normalized;
+ }
+}
\ No newline at end of file
diff --git a/src/MarketAssistant.App.Services/Applications/Favorites/FavoriteService.cs b/src/MarketAssistant.App.Services/Applications/Favorites/FavoriteService.cs
index ec586fa8..a140760a 100644
--- a/src/MarketAssistant.App.Services/Applications/Favorites/FavoriteService.cs
+++ b/src/MarketAssistant.App.Services/Applications/Favorites/FavoriteService.cs
@@ -53,6 +53,19 @@ created_at TEXT NOT NULL
CREATE INDEX IF NOT EXISTS idx_fav_mt ON favorite_assets(market_type);
""";
await cmd.ExecuteNonQueryAsync();
+
+ // 存量数据归一化迁移(幂等):历史数据存在小写/带 SH/SZ 前缀的代码,
+ // 与归一化后的查询(大写、无前缀)无法匹配,导致详情页收藏状态判定失败。
+ // 顺序:先按归一化后的键去重(保留最早一条),再统一大写,最后剥离 A 股交易所前缀。
+ await using var migrateCmd = conn.CreateCommand();
+ migrateCmd.CommandText = """
+ DELETE FROM favorite_assets
+ WHERE id NOT IN (SELECT MIN(id) FROM favorite_assets GROUP BY UPPER(code), market_type);
+ UPDATE favorite_assets SET code = UPPER(code);
+ UPDATE favorite_assets SET code = SUBSTR(code, 3)
+ WHERE market_type = 0 AND SUBSTR(code, 1, 2) IN ('SH', 'SZ');
+ """;
+ await migrateCmd.ExecuteNonQueryAsync();
}
public async Task AddFavoriteAsync(string code, string market, CancellationToken cancellationToken = default)
@@ -62,6 +75,8 @@ public async Task AddFavoriteAsync(string code, string market, CancellationToken
code = code.Trim();
market = market.Trim();
+ // 写入前统一归一化,保证与查询端(详情页/首页)的代码格式一致
+ code = FavoriteCodeNormalizer.Normalize(code, _marketType);
try
{
@@ -98,7 +113,7 @@ public async Task RemoveFavoriteAsync(string code, string market, CancellationTo
await using var conn = await OpenConnectionAsync(cancellationToken);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
- DELETE FROM favorite_assets WHERE code = @code AND market_type = @marketType
+ DELETE FROM favorite_assets WHERE UPPER(code) = @code AND market_type = @marketType
""";
cmd.Parameters.AddWithValue("@code", code);
cmd.Parameters.AddWithValue("@marketType", (int)_marketType);
@@ -127,7 +142,7 @@ public async Task IsFavoriteAsync(string code, string market, Cancellation
await using var conn = await OpenConnectionAsync(cancellationToken);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
- SELECT COUNT(1) FROM favorite_assets WHERE code = @code AND market_type = @marketType
+ SELECT COUNT(1) FROM favorite_assets WHERE UPPER(code) = @code AND market_type = @marketType
""";
cmd.Parameters.AddWithValue("@code", code);
cmd.Parameters.AddWithValue("@marketType", (int)_marketType);
diff --git a/src/MarketAssistant.App.Services/Applications/Home/HomeAssetService.cs b/src/MarketAssistant.App.Services/Applications/Home/HomeAssetService.cs
index e3d1cfe5..feb1f025 100644
--- a/src/MarketAssistant.App.Services/Applications/Home/HomeAssetService.cs
+++ b/src/MarketAssistant.App.Services/Applications/Home/HomeAssetService.cs
@@ -69,16 +69,13 @@ public async Task AddToFavoriteAsync(object assetParameter)
if (await _favoriteService.IsFavoriteAsync(code, market))
{
- var alreadyMsg = _marketType == MarketType.Crypto
- ? "该虚拟币已在收藏列表中"
- : "该资产已在收藏列表中";
- await _dialogService.ShowMessageAsync("提示", alreadyMsg);
+ await _dialogService.ShowMessageAsync("提示", "该资产已在自选列表中");
return false;
}
var confirmed = await _dialogService.ShowConfirmationAsync(
- "添加收藏",
- $"确定要将 {assetName} 添加到收藏列表吗?",
+ "加入自选",
+ $"确定要将 {assetName} 加入自选列表吗?",
"确认",
"取消");
@@ -86,7 +83,7 @@ public async Task AddToFavoriteAsync(object assetParameter)
return false;
await _favoriteService.AddFavoriteAsync(code, market);
- await _dialogService.ShowMessageAsync("收藏成功", $"已将 {assetName} 添加到收藏列表");
+ await _dialogService.ShowMessageAsync("加入自选成功", $"已将 {assetName} 加入自选列表");
return true;
}
@@ -105,26 +102,29 @@ private bool TryMapFavoriteAsset(
if (assetParameter is HotAsset hotAsset)
{
assetName = hotAsset.Name;
- code = hotAsset.Code;
market = _marketType == MarketType.AShare ? hotAsset.Market : string.Empty;
+ code = FavoriteCodeNormalizer.Normalize(hotAsset.Code, _marketType);
return true;
}
if (assetParameter is AssetItem assetItem)
{
assetName = assetItem.Name;
- code = assetItem.Code;
+ // A 股保留交易所标记(如 sh600519 → Market: SH, Code: 600519);虚拟币无标记
+ market = string.Empty;
if (_marketType == MarketType.AShare)
{
- if (code.StartsWith("sh", StringComparison.OrdinalIgnoreCase)
- || code.StartsWith("sz", StringComparison.OrdinalIgnoreCase))
+ var rawCode = assetItem.Code.Trim().ToUpperInvariant();
+ if (rawCode.Length > 2
+ && (rawCode.StartsWith("SH", StringComparison.Ordinal)
+ || rawCode.StartsWith("SZ", StringComparison.Ordinal)))
{
- market = code[..2].ToUpperInvariant();
- code = code[2..];
+ market = rawCode[..2];
}
}
+ code = FavoriteCodeNormalizer.Normalize(assetItem.Code, _marketType);
return true;
}
diff --git a/src/MarketAssistant.App.Services/Applications/InvestmentSelection/InvestmentSelectionService.cs b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/InvestmentSelectionService.cs
index 5b014c19..cd164911 100644
--- a/src/MarketAssistant.App.Services/Applications/InvestmentSelection/InvestmentSelectionService.cs
+++ b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/InvestmentSelectionService.cs
@@ -145,7 +145,7 @@ private List GetStockStrategies()
{
Strategy = QuickSelectionStrategy.ValueInvestment,
Name = "价值投资",
- Icon = "💎",
+ IconPath = "avares://MarketAssistant/Assets/Images/icon_shield.svg",
Description = "筛选PE低、PB低、ROE高的优质价值标的",
Scenario = "适合稳健型投资者,追求长期价值投资",
RiskLevel = "低风险"
@@ -154,7 +154,7 @@ private List GetStockStrategies()
{
Strategy = QuickSelectionStrategy.GrowthInvestment,
Name = "成长投资",
- Icon = "🚀",
+ IconPath = "avares://MarketAssistant/Assets/Images/icon_rocket.svg",
Description = "筛选营收和利润高增长的成长型标的",
Scenario = "适合积极型投资者,追求高成长收益",
RiskLevel = "中高风险"
@@ -163,7 +163,7 @@ private List GetStockStrategies()
{
Strategy = QuickSelectionStrategy.ActiveTrading,
Name = "活跃标的",
- Icon = "🔥",
+ IconPath = "avares://MarketAssistant/Assets/Images/icon_bolt.svg",
Description = "筛选换手率高、成交活跃的热门标的",
Scenario = "适合短线交易者,追求市场热点",
RiskLevel = "高风险"
@@ -172,7 +172,7 @@ private List GetStockStrategies()
{
Strategy = QuickSelectionStrategy.LargeCap,
Name = "大盘标的",
- Icon = "🏢",
+ IconPath = "avares://MarketAssistant/Assets/Images/icon_grid.svg",
Description = "筛选市值大、业绩稳定的蓝筹标的",
Scenario = "适合保守型投资者,追求稳定收益",
RiskLevel = "低风险"
@@ -181,7 +181,7 @@ private List GetStockStrategies()
{
Strategy = QuickSelectionStrategy.SmallCap,
Name = "小盘标的",
- Icon = "🌱",
+ IconPath = "avares://MarketAssistant/Assets/Images/icon_bulb.svg",
Description = "筛选市值较小、具有成长潜力的标的",
Scenario = "适合风险偏好较高的投资者",
RiskLevel = "高风险"
@@ -190,7 +190,7 @@ private List GetStockStrategies()
{
Strategy = QuickSelectionStrategy.HighYield,
Name = "高股息",
- Icon = "💰",
+ IconPath = "avares://MarketAssistant/Assets/Images/icon_money.svg",
Description = "筛选股息率高、分红稳定的高股息标的",
Scenario = "适合追求稳定现金流的投资者",
RiskLevel = "低风险"
@@ -209,7 +209,7 @@ private List GetCryptoStrategies()
{
Strategy = QuickSelectionStrategy.ValueInvestment,
Name = "价值币种",
- Icon = "💎",
+ IconPath = "avares://MarketAssistant/Assets/Images/icon_shield.svg",
Description = "筛选市值大、技术成熟、社区活跃的主流币种",
Scenario = "适合稳健型投资者,追求长期持有价值币",
RiskLevel = "低风险"
@@ -218,7 +218,7 @@ private List GetCryptoStrategies()
{
Strategy = QuickSelectionStrategy.GrowthInvestment,
Name = "高成长币",
- Icon = "🚀",
+ IconPath = "avares://MarketAssistant/Assets/Images/icon_rocket.svg",
Description = "筛选7日/30日涨幅较高、交易量增长的高成长币种",
Scenario = "适合积极型投资者,追求高收益潜力币",
RiskLevel = "中高风险"
@@ -227,7 +227,7 @@ private List GetCryptoStrategies()
{
Strategy = QuickSelectionStrategy.ActiveTrading,
Name = "热门币种",
- Icon = "🔥",
+ IconPath = "avares://MarketAssistant/Assets/Images/icon_bolt.svg",
Description = "筛选24h交易量大、价格波动活跃的热门币种",
Scenario = "适合短线交易者,追捧市场热点币",
RiskLevel = "高风险"
@@ -236,7 +236,7 @@ private List GetCryptoStrategies()
{
Strategy = QuickSelectionStrategy.LargeCap,
Name = "主流大币",
- Icon = "🏢",
+ IconPath = "avares://MarketAssistant/Assets/Images/icon_grid.svg",
Description = "筛选市值排名前50、流动性充足的蓝筹主流币",
Scenario = "适合保守型投资者,追求稳定的主流币",
RiskLevel = "低风险"
@@ -245,7 +245,7 @@ private List GetCryptoStrategies()
{
Strategy = QuickSelectionStrategy.SmallCap,
Name = "潜力小币",
- Icon = "🌱",
+ IconPath = "avares://MarketAssistant/Assets/Images/icon_bulb.svg",
Description = "筛选市值排名100-500、具有创新性的潜力币种",
Scenario = "适合风险偏好较高的投资者,寻找黑马币",
RiskLevel = "高风险"
@@ -254,7 +254,7 @@ private List GetCryptoStrategies()
{
Strategy = QuickSelectionStrategy.HighYield,
Name = "高波动币",
- Icon = "⚡",
+ IconPath = "avares://MarketAssistant/Assets/Images/icon_money.svg",
Description = "筛选24h价格波动大于±5%的高波动币种",
Scenario = "适合激进型交易者,追求短期高波动收益",
RiskLevel = "高风险"
diff --git a/src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/QuickSelectionStrategy.cs b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/QuickSelectionStrategy.cs
index 70902e13..cf78954f 100644
--- a/src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/QuickSelectionStrategy.cs
+++ b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/Models/QuickSelectionStrategy.cs
@@ -52,9 +52,9 @@ public class QuickSelectionStrategyInfo
public string Name { get; set; } = string.Empty;
///
- /// 策略图标
+ /// 策略矢量图标资源路径 (SVG)
///
- public string Icon { get; set; } = string.Empty;
+ public string IconPath { get; set; } = string.Empty;
///
/// 策略描述
diff --git a/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertRule.cs b/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertRule.cs
index b6b6eb2f..ef714256 100644
--- a/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertRule.cs
+++ b/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertRule.cs
@@ -102,8 +102,6 @@ public partial class PriceAlertRule : ObservableObject
public string StatusText => IsOneTime && Triggered ? "重新启用" : Enabled ? "禁用" : "启用";
- public string AlertModeText => IsOneTime ? "一次性" : "持续";
-
///
/// 高级选项摘要(去抖 / 冷却 / 限次 / 交易联动),全部为默认配置时返回空字符串。
///
diff --git a/src/MarketAssistant.App.Services/Services/Export/MarkdownReportExporter.cs b/src/MarketAssistant.App.Services/Services/Export/MarkdownReportExporter.cs
index 0750054a..3250b6e8 100644
--- a/src/MarketAssistant.App.Services/Services/Export/MarkdownReportExporter.cs
+++ b/src/MarketAssistant.App.Services/Services/Export/MarkdownReportExporter.cs
@@ -46,6 +46,10 @@ public static string Export(MarketAnalysisReport report)
sb.AppendLine($"| 财务面 | {ds.Financial:F1} |");
sb.AppendLine($"| 市场情绪 | {ds.Sentiment:F1} |");
sb.AppendLine($"| 新闻事件 | {ds.News:F1} |");
+ if (ds.CryptoMetrics.HasValue)
+ {
+ sb.AppendLine($"| 虚拟币指标 | {ds.CryptoMetrics.Value:F1} |");
+ }
sb.AppendLine();
AppendListSection(sb, "操作建议", cr.OperationSuggestions);
diff --git a/src/MarketAssistant.App.Services/Services/Market/AShareIndexQuoteService.cs b/src/MarketAssistant.App.Services/Services/Market/AShareIndexQuoteService.cs
new file mode 100644
index 00000000..62db0362
--- /dev/null
+++ b/src/MarketAssistant.App.Services/Services/Market/AShareIndexQuoteService.cs
@@ -0,0 +1,34 @@
+using MarketAssistant.DataProviders.AShare;
+
+namespace MarketAssistant.Services.Market;
+
+///
+/// A 股顶栏行情条:拉取新浪三大指数(上证/深证成指/创业板指),格式化为点位 + 涨跌幅。
+///
+public sealed class AShareIndexQuoteService : IIndexQuoteService
+{
+ private readonly SinaIndexQuoteClient _client;
+
+ public AShareIndexQuoteService(SinaIndexQuoteClient client)
+ {
+ _client = client;
+ }
+
+ public async Task> GetLatestAsync(CancellationToken cancellationToken = default)
+ {
+ var quotes = await _client.GetIndexesAsync(cancellationToken);
+
+ var items = new List(quotes.Count);
+ foreach (var q in quotes)
+ {
+ var trend = q.ChangePercent > 0 ? TickerTrend.Up
+ : q.ChangePercent < 0 ? TickerTrend.Down
+ : TickerTrend.Flat;
+
+ var changeText = $"{q.ChangePercent:+0.00;-0.00;0.00}%";
+ items.Add(new IndexQuoteItem(q.Name, q.Point.ToString("N2"), changeText, trend));
+ }
+
+ return items;
+ }
+}
diff --git a/src/MarketAssistant.App.Services/Services/Market/AShareMarketModule.cs b/src/MarketAssistant.App.Services/Services/Market/AShareMarketModule.cs
index 843cf6b7..bc7e8517 100644
--- a/src/MarketAssistant.App.Services/Services/Market/AShareMarketModule.cs
+++ b/src/MarketAssistant.App.Services/Services/Market/AShareMarketModule.cs
@@ -55,6 +55,9 @@ public void Register(IServiceCollection services)
services.AddKeyedSingleton(MarketType.AShare);
services.AddKeyedSingleton(MarketType.AShare);
+ // 顶栏行情条:A 股三大指数(新浪 hq 指数端点)
+ services.AddKeyedSingleton(MarketType.AShare);
+
// 工作流
services.AddKeyedSingleton(MarketType.AShare);
services.AddSingleton, StockCriteriaGenerationStrategy>();
diff --git a/src/MarketAssistant.App.Services/Services/Market/CryptoIndexQuoteService.cs b/src/MarketAssistant.App.Services/Services/Market/CryptoIndexQuoteService.cs
new file mode 100644
index 00000000..7f34f2ab
--- /dev/null
+++ b/src/MarketAssistant.App.Services/Services/Market/CryptoIndexQuoteService.cs
@@ -0,0 +1,92 @@
+using MarketAssistant.DataProviders;
+
+namespace MarketAssistant.Services.Market;
+
+///
+/// 虚拟币顶栏行情条:全网总市值+24h涨跌幅、BTC 主导率(CoinGecko /global 单次请求)
+/// 与恐惧贪婪指数(alternative.me)。三项均为市场级聚合指标,非具体标的价格。
+/// 任一数据源失败仅省略对应项,全失败返回空集合(顶栏整体隐藏)。
+///
+public sealed class CryptoIndexQuoteService : IIndexQuoteService
+{
+ private readonly CoinGeckoApiService _coinGecko;
+ private readonly AlternativeMeClient _alternativeMe;
+
+ public CryptoIndexQuoteService(CoinGeckoApiService coinGecko, AlternativeMeClient alternativeMe)
+ {
+ _coinGecko = coinGecko;
+ _alternativeMe = alternativeMe;
+ }
+
+ public async Task> GetLatestAsync(CancellationToken cancellationToken = default)
+ {
+ // 两个独立数据源并行拉取,互不阻塞
+ var globalTask = SafeAsync(() => _coinGecko.GetGlobalStatsAsync(cancellationToken));
+ var fngTask = SafeAsync(() => _alternativeMe.GetLatestAsync(cancellationToken));
+ await Task.WhenAll(globalTask, fngTask);
+
+ var items = new List(3);
+
+ if (globalTask.Result is { } global)
+ {
+ // 全网总市值 + 24h 涨跌幅
+ var capTrend = global.MarketCapChangePct24h > 0 ? TickerTrend.Up
+ : global.MarketCapChangePct24h < 0 ? TickerTrend.Down
+ : TickerTrend.Flat;
+ items.Add(new IndexQuoteItem(
+ "总市值",
+ FormatUsdCompact(global.TotalMarketCapUsd),
+ $"{global.MarketCapChangePct24h:+0.00;-0.00;0.00}%",
+ capTrend));
+
+ // BTC 主导率(无涨跌语义,仅展示占比)
+ items.Add(new IndexQuoteItem("BTC主导率", $"{global.BtcDominancePct:F1}%", null, TickerTrend.None));
+ }
+
+ if (fngTask.Result is { } fng)
+ {
+ // 恐惧贪婪指数:数值 + 中文分类,无涨跌语义
+ items.Add(new IndexQuoteItem("恐惧贪婪", fng.Value.ToString(), TranslateFng(fng.Classification), TickerTrend.None));
+ }
+
+ return items;
+ }
+
+ /// 吞掉单个数据源异常,返回 null,保证一个源失败不影响其余项。
+ private static async Task SafeAsync(Func> action) where T : struct
+ {
+ try
+ {
+ return await action();
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ /// 美元大数缩写:$2.41T / $98.5B / $1.2M。
+ private static string FormatUsdCompact(decimal usd)
+ {
+ var abs = Math.Abs(usd);
+ if (abs >= 1_000_000_000_000m) return $"${usd / 1_000_000_000_000m:F2}T";
+ if (abs >= 1_000_000_000m) return $"${usd / 1_000_000_000m:F2}B";
+ if (abs >= 1_000_000m) return $"${usd / 1_000_000m:F2}M";
+ return $"${usd:N0}";
+ }
+
+ /// 恐惧贪婪英文分类 → 中文短词。
+ private static string TranslateFng(string classification) => classification switch
+ {
+ "Extreme Fear" => "极度恐慌",
+ "Fear" => "恐慌",
+ "Neutral" => "中性",
+ "Greed" => "贪婪",
+ "Extreme Greed" => "极度贪婪",
+ _ => classification,
+ };
+}
diff --git a/src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs b/src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs
index f59b4067..6bc17a07 100644
--- a/src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs
+++ b/src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs
@@ -59,6 +59,9 @@ public void Register(IServiceCollection services)
services.AddKeyedSingleton(MarketType.Crypto);
services.AddKeyedSingleton(MarketType.Crypto);
+ // 顶栏行情条:全网总市值+涨跌幅、BTC 主导率(CoinGecko /global)与恐惧贪婪指数(alternative.me)
+ services.AddKeyedSingleton(MarketType.Crypto);
+
// 工作流
services.AddKeyedSingleton(MarketType.Crypto);
services.AddSingleton, CryptoCriteriaGenerationStrategy>();
diff --git a/src/MarketAssistant.App.Services/Services/Market/IIndexQuoteService.cs b/src/MarketAssistant.App.Services/Services/Market/IIndexQuoteService.cs
new file mode 100644
index 00000000..e5554a13
--- /dev/null
+++ b/src/MarketAssistant.App.Services/Services/Market/IIndexQuoteService.cs
@@ -0,0 +1,46 @@
+namespace MarketAssistant.Services.Market;
+
+///
+/// 行情条指标的趋势语义,决定顶栏涨跌幅文本的配色。
+///
+public enum TickerTrend
+{
+ /// 上涨(红涨)。A 股语义:涨为红。
+ Up,
+
+ /// 下跌(绿跌)。
+ Down,
+
+ /// 平盘(涨跌为 0,使用中性色)。
+ Flat,
+
+ /// 该指标无涨跌语义(如恐惧贪婪指数、主导率),仅展示数值。
+ None,
+}
+
+///
+/// 顶栏行情条的单个市场级指标项。所有展示字符串均已在服务侧格式化完成,
+/// 视图层保持纯展示,不参与数值解析或本地化。
+///
+/// 指标名称(如"上证指数"、"总市值"、"恐惧贪婪")。
+/// 已格式化的数值文本(如"3,094.67"、"$2.4T"、"52.3%"、"44")。
+/// 已格式化的涨跌/分类文本(如"+1.82%"、"恐惧");无则为 null。
+/// 涨跌语义,决定 ChangeText 配色。
+public sealed record IndexQuoteItem(
+ string Name,
+ string Value,
+ string? ChangeText,
+ TickerTrend Trend);
+
+///
+/// 市场级聚合指标(行情条)拉取抽象:返回当前市场的顶层指数/情绪指标,
+/// 而非具体标的价格。各市场实现负责取数与格式化,消费方(顶栏)纯展示。
+///
+public interface IIndexQuoteService
+{
+ ///
+ /// 拉取当前市场的行情条指标集合。实现内部应吞掉可恢复的局部失败(降级为部分项),
+ /// 仅在完全无数据时返回空集合(顶栏随之整体隐藏)。
+ ///
+ Task> GetLatestAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs
index 02e621b8..995afb24 100644
--- a/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs
+++ b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs
@@ -189,6 +189,25 @@ private static IServiceCollection AddHttpClientsCore(IServiceCollection services
client.DefaultRequestHeaders.Referrer = new Uri("https://vip.stock.finance.sina.com.cn/");
}).AddStandardResilienceHandler();
+ // 新浪指数简版行情(顶栏行情条 A 股三大指数)
+ // hq.sinajs.cn 强制校验 Referer,缺失返回 403;响应为 GBK 编码(客户端侧解码)
+ services.AddHttpClient("SinaHq", client =>
+ {
+ client.BaseAddress = new Uri("https://hq.sinajs.cn");
+ client.Timeout = TimeSpan.FromSeconds(10);
+ client.DefaultRequestHeaders.UserAgent.ParseAdd(
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36");
+ client.DefaultRequestHeaders.Referrer = new Uri("https://finance.sina.com.cn/");
+ }).AddStandardResilienceHandler();
+
+ // alternative.me 恐惧贪婪指数(顶栏行情条 Crypto 市场情绪指标,免费无需 Key)
+ services.AddHttpClient("AlternativeMe", client =>
+ {
+ client.BaseAddress = new Uri("https://api.alternative.me/");
+ client.Timeout = TimeSpan.FromSeconds(10);
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("MarketAssistant/1.0");
+ }).AddStandardResilienceHandler();
+
// 雪球选股 API(支持全部 38 个筛选指标,Cookie 跨请求共享)
services.AddSingleton();
services.AddHttpClient("Xueqiu", client =>
@@ -378,6 +397,7 @@ private static IServiceCollection AddMarketDataServices(this IServiceCollection
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
// 统一告警中心:AlertGate 单实例(AlertCenterService 与 TradeExecutor 共享)
services.AddSingleton();
@@ -419,6 +439,7 @@ private static IServiceCollection AddTradingServices(this IServiceCollection ser
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/src/MarketAssistant.App.Services/Trading/CryptoPortfolioService.cs b/src/MarketAssistant.App.Services/Trading/CryptoPortfolioService.cs
index 4ad5da6e..4a478523 100644
--- a/src/MarketAssistant.App.Services/Trading/CryptoPortfolioService.cs
+++ b/src/MarketAssistant.App.Services/Trading/CryptoPortfolioService.cs
@@ -1,5 +1,7 @@
using MarketAssistant.Applications.Cache;
+using MarketAssistant.Applications.Crypto;
using MarketAssistant.DataProviders;
+using MarketAssistant.Infrastructure.Core;
using MarketAssistant.Trading.Abstractions;
using MarketAssistant.Trading.Models;
using Microsoft.Extensions.Caching.Memory;
@@ -61,6 +63,60 @@ public virtual async Task GetAccountBalanceSummaryAsync(C
/// virtual 供单元测试替换(AISignal 硬性边界行为测试)。
public virtual async Task> GetCurrentPositionsAsync(CancellationToken ct = default)
+ {
+ // 合约模式无现货余额语义:直接映射交易所持仓(多空归一为绝对数量 + 方向无关的展示)。
+ if (_exchangeClient.IsFutures)
+ return await GetFuturesPositionsAsync(ct).ConfigureAwait(false);
+
+ return await GetSpotPositionsAsync(ct).ConfigureAwait(false);
+ }
+
+ private async Task> GetFuturesPositionsAsync(CancellationToken ct)
+ {
+ var positions = new List();
+
+ List exchangePositions;
+ try
+ {
+ exchangePositions = await _exchangeClient.GetPositionsAsync(null, ct).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "查询合约持仓失败,返回空持仓视图");
+ return positions;
+ }
+
+ foreach (var exchangePosition in exchangePositions)
+ {
+ var quantity = Math.Abs(exchangePosition.PositionAmt);
+ if (quantity <= 0 || string.IsNullOrWhiteSpace(exchangePosition.Symbol))
+ continue;
+
+ var position = new PositionInfo
+ {
+ Symbol = exchangePosition.Symbol,
+ Quantity = quantity,
+ EntryPrice = exchangePosition.EntryPrice,
+ CurrentPrice = exchangePosition.MarkPrice > 0 ? exchangePosition.MarkPrice : exchangePosition.EntryPrice,
+ };
+
+ if (position.EntryPrice > 0 && position.CurrentPrice > 0)
+ {
+ var direction = exchangePosition.PositionAmt > 0 ? 1m : -1m;
+ // 杠杆为 0(交易所未返回)时按 1 倍兜底
+ var leverage = exchangePosition.Leverage > 0 ? exchangePosition.Leverage : 1m;
+ position.UnrealizedPnl = (position.CurrentPrice - position.EntryPrice) * position.Quantity * direction;
+ // 百分比为含杠杆的收益率(ROE)口径:价格变动比例 × 杠杆 × 方向
+ position.UnrealizedPnlPercent = (position.CurrentPrice - position.EntryPrice) / position.EntryPrice * 100 * direction * leverage;
+ }
+
+ positions.Add(position);
+ }
+
+ return positions;
+ }
+
+ private async Task> GetSpotPositionsAsync(CancellationToken ct)
{
var accountInfo = await _exchangeClient.GetAccountInfoAsync(ct);
var positions = new List();
@@ -73,33 +129,31 @@ public virtual async Task> GetCurrentPositionsAsync(Cancellat
continue;
}
+ // 非 USDT 计价资产不拼凑 USDT 交易对:无对应行情时跳过,避免幽灵持仓。
+ var symbol = CryptoSymbolConverter.ToBinanceFormat(balance.Asset);
+ var ticker = await TryGetTickerAsync(symbol, ct).ConfigureAwait(false);
+ if (ticker == null)
+ {
+ _logger.LogDebug("跳过无 USDT 行情的现货资产持仓: {Asset}", balance.Asset);
+ continue;
+ }
+
var position = new PositionInfo
{
- Symbol = $"{balance.Asset}USDT",
+ Symbol = symbol,
Quantity = quantity
};
- try
- {
- var ticker = await _marketDataService.Get24hrTickerAsync(position.Symbol, ct);
- if (ticker != null)
- {
- position.CurrentPrice = ticker.LastPrice;
+ position.CurrentPrice = ticker.LastPrice;
- // 只按未平仓的 FIFO 持仓计算均价,避免已平仓记录污染浮盈显示
- var avgEntry = await _tradingDataService.GetOpenPositionAvgEntryPriceAsync(position.Symbol, ct);
- position.EntryPrice = avgEntry > 0 ? avgEntry : ticker.LastPrice;
+ // 只按未平仓的 FIFO 持仓计算均价,避免已平仓记录污染浮盈显示
+ var avgEntry = await _tradingDataService.GetOpenPositionAvgEntryPriceAsync(position.Symbol, ct);
+ position.EntryPrice = avgEntry > 0 ? avgEntry : ticker.LastPrice;
- if (position.EntryPrice > 0)
- {
- position.UnrealizedPnl = (position.CurrentPrice - position.EntryPrice) * position.Quantity;
- position.UnrealizedPnlPercent = (position.CurrentPrice - position.EntryPrice) / position.EntryPrice * 100;
- }
- }
- }
- catch (HttpRequestException)
+ if (position.EntryPrice > 0)
{
- _logger.LogDebug("获取持仓价格失败,跳过: {Symbol}", position.Symbol);
+ position.UnrealizedPnl = (position.CurrentPrice - position.EntryPrice) * position.Quantity;
+ position.UnrealizedPnlPercent = (position.CurrentPrice - position.EntryPrice) / position.EntryPrice * 100;
}
positions.Add(position);
@@ -108,6 +162,19 @@ public virtual async Task> GetCurrentPositionsAsync(Cancellat
return positions;
}
+ private async Task TryGetTickerAsync(string symbol, CancellationToken ct)
+ {
+ try
+ {
+ return await _marketDataService.Get24hrTickerAsync(symbol, ct).ConfigureAwait(false);
+ }
+ catch (HttpRequestException)
+ {
+ _logger.LogDebug("获取持仓价格失败,跳过: {Symbol}", symbol);
+ return null;
+ }
+ }
+
public static decimal GetUsdtBalance(AccountBalanceSummary summary)
{
return summary.Assets
@@ -144,7 +211,9 @@ private async Task BuildBalanceSummaryAsync(
{
try
{
- var ticker = await _marketDataService.Get24hrTickerAsync($"{balance.Asset}USDT", ct);
+ // 与持仓视图同源:统一走 CryptoSymbolConverter,包装币(WBTC 等)可正确拼出计价对
+ var symbol = CryptoSymbolConverter.ToBinanceFormat(balance.Asset);
+ var ticker = await _marketDataService.Get24hrTickerAsync(symbol, ct);
if (ticker != null)
{
assetBalance.ValueUSDT = totalAmount * ticker.LastPrice;
diff --git a/src/MarketAssistant.App.Services/Trading/ExchangeConditionOrderService.cs b/src/MarketAssistant.App.Services/Trading/ExchangeConditionOrderService.cs
new file mode 100644
index 00000000..be5007af
--- /dev/null
+++ b/src/MarketAssistant.App.Services/Trading/ExchangeConditionOrderService.cs
@@ -0,0 +1,290 @@
+using System.Collections.Concurrent;
+using MarketAssistant.Services.Trading.Exchanges;
+using MarketAssistant.Trading.Abstractions;
+using MarketAssistant.Trading.Models;
+using Microsoft.Extensions.Logging;
+
+namespace MarketAssistant.Services.Trading;
+
+///
+/// 交易所侧保护性条件单管理器:为合约模式下的退出型策略(止损/追踪止损/止盈平仓)
+/// 维护 reduceOnly 条件单,使"进程退出或网络中断期间止损失效"的限制不再成立。
+///
+/// 权威原则:条件单挂出后,平仓由交易所触发(权威),客户端 tick 评估降级为兜底——
+/// 对账周期内发现挂单缺失则补挂;策略删除/暂停后挂单残留则撤销孤儿单。
+///
+/// 对账时机:监控启动、策略集合变化(含编辑后参数漂移撤旧挂新)、每 60 秒周期。
+/// 挂单本身无成交,成交后由 OrderStateSyncService 既有对账管线回写持仓与统计,
+/// 并经 事件驱动策略完结。
+/// 条件单常驻交易所:监控停止/应用退出不撤单,重启后对账自动接管(这正是兜底价值所在)。
+///
+public sealed class ExchangeConditionOrderService
+{
+ /// 周期对账间隔。覆盖追踪止损激活(峰值首次落库绕过策略变更广播)等旁路状态变化。
+ public static readonly TimeSpan ReconcileInterval = TimeSpan.FromSeconds(60);
+
+ private readonly TradingStrategyService _strategyService;
+ private readonly TradeExecutor _tradeExecutor;
+ private readonly TradingDataService _dataService;
+ private readonly RoutingExchangeClient _exchangeClient;
+ private readonly TradingEnvironmentService _environmentService;
+ private readonly ILogger _logger;
+
+ /// 策略 ID → 在途条件单交易所订单 ID。内存态:重启后由对账重建。
+ private readonly ConcurrentDictionary _liveOrderIds = new(StringComparer.OrdinalIgnoreCase);
+ private readonly SemaphoreSlim _reconcileGate = new(1, 1);
+ private readonly object _lifecycleLock = new();
+ private CancellationTokenSource? _reconcileCts;
+ private Task? _reconcileLoopTask;
+
+ public ExchangeConditionOrderService(
+ TradingStrategyService strategyService,
+ TradeExecutor tradeExecutor,
+ TradingDataService dataService,
+ RoutingExchangeClient exchangeClient,
+ TradingEnvironmentService environmentService,
+ ILogger logger)
+ {
+ _strategyService = strategyService;
+ _tradeExecutor = tradeExecutor;
+ _dataService = dataService;
+ _exchangeClient = exchangeClient;
+ _environmentService = environmentService;
+ _logger = logger;
+
+ // 环境切换后旧环境的订单映射全部失效(不同 API Key 的交易所),由新环境对账重建
+ _environmentService.ModeChanged += OnModeChanged;
+ }
+
+ ///
+ /// 判定策略是否应由交易所条件单保护(一次性退出语义;止盈 Buy 侧为限价建仓,不在此列)。
+ ///
+ public static bool IsProtectiveExitStrategy(TradingStrategy strategy) => strategy.Type switch
+ {
+ StrategyType.StopLoss => true,
+ StrategyType.TrailingStop => true,
+ StrategyType.TakeProfit => strategy.Side == OrderSide.Sell,
+ _ => false
+ };
+
+ ///
+ /// 条件单参数指纹(类型|方向|数量|触发价|回调基点):策略编辑后与在途挂单比对,
+ /// 不一致则撤旧挂新。
+ ///
+ internal static string BuildFingerprint(TradingStrategy strategy)
+ {
+ var trailingDelta = strategy.Type == StrategyType.TrailingStop
+ ? TradeExecutor.ResolveTrailingDeltaBasisPoints(strategy).ToString()
+ : string.Empty;
+ return $"{strategy.Type}|{strategy.Side}|{strategy.Quantity}|{strategy.TriggerPrice}|{trailingDelta}";
+ }
+
+ ///
+ /// 指定策略当前是否有在途交易所条件单(客户端触发执行据此跳过,防止双触发)。
+ ///
+ public bool HasLiveOrder(string strategyId) => _liveOrderIds.ContainsKey(strategyId);
+
+ /// 策略完结/对账撤销后清理在途映射。
+ public void ClearLiveOrder(string strategyId) => _liveOrderIds.TryRemove(strategyId, out _);
+
+ ///
+ /// 启动周期对账循环并执行首次对账(幂等)。由 MarketMonitor 启动时调用。
+ ///
+ public async Task StartAsync()
+ {
+ lock (_lifecycleLock)
+ {
+ if (_reconcileLoopTask != null)
+ return;
+
+ var cts = new CancellationTokenSource();
+ _reconcileCts = cts;
+ _reconcileLoopTask = Task.Run(() => ReconcileLoopAsync(cts.Token));
+ }
+
+ await ReconcileSafeAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// 停止周期对账循环。不撤销交易所挂单:条件单常驻交易所,监控停止期间保护依然有效,
+ /// 重启后对账自动接管。
+ ///
+ public void Stop()
+ {
+ lock (_lifecycleLock)
+ {
+ _reconcileCts?.Cancel();
+ _reconcileCts = null;
+ _reconcileLoopTask = null;
+ }
+ }
+
+ /// 触发一次对账(吞异常记日志,供事件回调 fire-and-forget 调用)。
+ public async Task ReconcileSafeAsync()
+ {
+ try
+ {
+ await ReconcileAsync(CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogWarning(ex, "交易所条件单对账失败,将在下个周期重试");
+ }
+ }
+
+ ///
+ /// 对账:撤销孤儿单、补挂缺失单、参数漂移撤旧挂新。
+ /// 通过 _reconcileGate 串行化,避免策略变更风暴下并发对账重复挂单。
+ ///
+ public async Task ReconcileAsync(CancellationToken ct)
+ {
+ if (!await _reconcileGate.WaitAsync(0, ct).ConfigureAwait(false))
+ return;
+
+ try
+ {
+ await ReconcileCoreAsync(ct).ConfigureAwait(false);
+ }
+ finally
+ {
+ _reconcileGate.Release();
+ }
+ }
+
+ private async Task ReconcileCoreAsync(CancellationToken ct)
+ {
+ var exchangeClient = _exchangeClient.GetActiveClientSnapshot();
+ if (!exchangeClient.IsFutures)
+ {
+ // 现货无条件单语义:清空映射,退出型策略由客户端评估兜底
+ _liveOrderIds.Clear();
+ return;
+ }
+
+ var activeStrategies = await _strategyService
+ .GetStrategiesByStatusAsync(StrategyStatus.Active, ct).ConfigureAwait(false);
+ var protectiveStrategies = activeStrategies
+ .Where(IsProtectiveExitStrategy)
+ .ToList();
+ var protectiveIds = protectiveStrategies
+ .Select(s => s.Id)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ var openOrders = await exchangeClient.GetOpenOrdersAsync(null, ct).ConfigureAwait(false);
+ var managedOrders = openOrders
+ .Where(o => o.ClientOrderId.StartsWith(
+ TradeExecutor.ConditionOrderClientOrderIdPrefix, StringComparison.Ordinal))
+ .ToList();
+
+ // 1. 撤销孤儿单:挂单仍在交易所,但对应策略已非活跃保护策略(删除/暂停/完结/编辑后类型不再匹配)
+ foreach (var order in managedOrders)
+ {
+ var strategyId = order.ClientOrderId[TradeExecutor.ConditionOrderClientOrderIdPrefix.Length..];
+ if (protectiveIds.Contains(strategyId))
+ continue;
+
+ try
+ {
+ await exchangeClient.CancelOrderAsync(order.Symbol, order.OrderId, ct).ConfigureAwait(false);
+ _liveOrderIds.TryRemove(strategyId, out _);
+ _logger.LogInformation(
+ "已撤销孤儿条件单: {Symbol} {OrderId} (策略 {StrategyId})",
+ order.Symbol, order.OrderId, strategyId);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "撤销孤儿条件单失败,下个对账周期重试: {OrderId}", order.OrderId);
+ }
+ }
+
+ // 2. 补挂缺失单 / 参数漂移撤旧挂新
+ foreach (var strategy in protectiveStrategies)
+ {
+ ct.ThrowIfCancellationRequested();
+ await EnsureConditionOrderAsync(exchangeClient, strategy, managedOrders, ct).ConfigureAwait(false);
+ }
+ }
+
+ private async Task EnsureConditionOrderAsync(
+ IExchangeClient exchangeClient,
+ TradingStrategy strategy,
+ List managedOrders,
+ CancellationToken ct)
+ {
+ var clientOrderId = TradeExecutor.BuildConditionOrderClientOrderId(strategy.Id);
+ var existing = managedOrders.FirstOrDefault(o =>
+ string.Equals(o.ClientOrderId, clientOrderId, StringComparison.Ordinal));
+ var expectedFingerprint = BuildFingerprint(strategy);
+
+ if (existing != null)
+ {
+ _liveOrderIds[strategy.Id] = existing.OrderId;
+ if (string.Equals(strategy.ConditionOrderFingerprint, expectedFingerprint, StringComparison.Ordinal))
+ return;
+
+ // 参数漂移(用户编辑策略):撤旧挂新;撤单失败则留待下个周期,避免出现双单
+ try
+ {
+ await exchangeClient.CancelOrderAsync(existing.Symbol, existing.OrderId, ct).ConfigureAwait(false);
+ _logger.LogInformation(
+ "策略参数已变化,撤销旧条件单待重挂: {StrategyId} {OrderId}",
+ strategy.Id, existing.OrderId);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "参数漂移撤单失败,下个对账周期重试: {StrategyId} {OrderId}",
+ strategy.Id, existing.OrderId);
+ return;
+ }
+ }
+ else if (strategy.Type == StrategyType.TrailingStop && !strategy.TrailingPeakPrice.HasValue)
+ {
+ // 交易所追踪单挂出即激活:追踪尚未激活(无峰值记录)时提前挂单会过早止损,
+ // 仅保留客户端评估兜底,激活后(峰值首次落库)由周期对账补挂
+ _liveOrderIds.TryRemove(strategy.Id, out _);
+ return;
+ }
+
+ var result = await _tradeExecutor.PlaceConditionOrderAsync(strategy, ct).ConfigureAwait(false);
+ if (result.Success && result.Record != null)
+ {
+ _liveOrderIds[strategy.Id] = result.Record.ExchangeOrderId.ToString();
+ await _dataService
+ .UpdateStrategyConditionOrderFingerprintAsync(strategy.Id, expectedFingerprint, ct)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ // 挂新失败:清在途映射使客户端评估兜底保持激活。参数漂移路径此处旧单已撤,
+ // 若保留旧映射会令 HasLiveOrder=true 而跳过客户端执行,产生最长一个对账周期的保护空窗
+ _liveOrderIds.TryRemove(strategy.Id, out _);
+ _logger.LogWarning(
+ "挂条件单失败,客户端评估保持兜底: {StrategyId} {Error}",
+ strategy.Id, result.ErrorMessage);
+ }
+ }
+
+ private async Task ReconcileLoopAsync(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(ReconcileInterval, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+
+ await ReconcileSafeAsync().ConfigureAwait(false);
+ }
+ }
+
+ private void OnModeChanged(CryptoTradingMode mode)
+ {
+ _liveOrderIds.Clear();
+ _ = ReconcileSafeAsync();
+ }
+}
diff --git a/src/MarketAssistant.App.Services/Trading/Exchanges/BinanceExchangeClient.cs b/src/MarketAssistant.App.Services/Trading/Exchanges/BinanceExchangeClient.cs
index fa66d0e3..5feabe81 100644
--- a/src/MarketAssistant.App.Services/Trading/Exchanges/BinanceExchangeClient.cs
+++ b/src/MarketAssistant.App.Services/Trading/Exchanges/BinanceExchangeClient.cs
@@ -121,9 +121,11 @@ private protected static ExchangeOrderResult MapOrderResponse(BinanceOrderRespon
{
Symbol = response.Symbol,
OrderId = response.OrderId.ToString(),
+ ClientOrderId = response.ClientOrderId,
Status = response.Status,
Side = response.Side,
Type = response.Type,
+ StopPrice = decimal.TryParse(response.StopPrice, NumberStyles.Number, CultureInfo.InvariantCulture, out var sp) ? sp : 0,
RequestedQty = decimal.TryParse(response.OrigQty, NumberStyles.Number, CultureInfo.InvariantCulture, out var rq) ? rq : 0,
ExecutedQty = decimal.TryParse(response.ExecutedQty, NumberStyles.Number, CultureInfo.InvariantCulture, out var eq) ? eq : 0,
Price = decimal.TryParse(response.Price, NumberStyles.Number, CultureInfo.InvariantCulture, out var p) ? p : 0,
diff --git a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs
index 09ad5ac1..f241a70a 100644
--- a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs
+++ b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs
@@ -20,6 +20,7 @@ public class MarketMonitor : IDisposable
private readonly TradeExecutor _tradeExecutor;
private readonly AISignalStrategyExecutor _aiSignalExecutor;
private readonly OrderStateSyncService _orderStateSyncService;
+ private readonly ExchangeConditionOrderService _conditionOrderService;
private readonly TradingStrategyService _strategyService;
private readonly TradingDataService _dataService;
private readonly RiskAlertEvaluator _riskAlertEvaluator;
@@ -31,13 +32,14 @@ public class MarketMonitor : IDisposable
private bool _isRunning;
private Task? _consumerTask;
- private readonly Channel<(string Symbol, decimal Price)> _priceChannel =
- Channel.CreateBounded<(string, decimal)>(new BoundedChannelOptions(1000)
- {
- SingleReader = true,
- SingleWriter = false,
- FullMode = BoundedChannelFullMode.DropOldest
- });
+ // 价格管线:每个 symbol 仅保留最新价。写入方更新 map 并在首次标记时入队 symbol,
+ // 消费端处理完移除标记——高负载下同一 symbol 的中间价自动合并为最新一次,避免逐条处理过期 tick
+ // (网格快速穿越多线时,后续线由后续 tick 的处理继续,不会跳线丢失)。
+ // symbol 通道无界是安全的:仅在有新价且无待处理标记时入队,在途条目数受活跃 symbol 数约束。
+ private readonly ConcurrentDictionary _latestPrices = new();
+ private readonly ConcurrentDictionary _pendingPriceSymbols = new();
+ private readonly Channel _pendingSymbolChannel = Channel.CreateUnbounded(
+ new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
private readonly ConcurrentDictionary _strategyLocks = new();
@@ -101,6 +103,7 @@ public MarketMonitor(
TradeExecutor tradeExecutor,
AISignalStrategyExecutor aiSignalExecutor,
OrderStateSyncService orderStateSyncService,
+ ExchangeConditionOrderService conditionOrderService,
TradingStrategyService strategyService,
TradingDataService dataService,
BinanceUserDataStreamService userDataStreamService,
@@ -113,6 +116,7 @@ public MarketMonitor(
_tradeExecutor = tradeExecutor;
_aiSignalExecutor = aiSignalExecutor;
_orderStateSyncService = orderStateSyncService;
+ _conditionOrderService = conditionOrderService;
_strategyService = strategyService;
_dataService = dataService;
_userDataStreamService = userDataStreamService;
@@ -127,6 +131,7 @@ public MarketMonitor(
_webSocketService.ConnectionInterrupted += _connectionInterruptedAdapter;
_webSocketService.ConnectionRestored += _connectionRestoredAdapter;
_strategyService.StrategiesChanged += OnStrategiesChanged;
+ _orderStateSyncService.TradeRecordFilled += OnTradeRecordFilled;
}
///
@@ -158,6 +163,9 @@ public async Task StartAsync()
_userDataStreamService.OrderUpdate += OnOrderUpdate;
await _userDataStreamService.StartAsync();
+ // 条件单对账:启动即对账一次(接管重启前的交易所挂单),之后周期补挂/撤孤儿单
+ await _conditionOrderService.StartAsync();
+
_logger.LogInformation("MarketMonitor 已启动,监控 {Count} 个交易标的", instrumentSymbols.Count);
StatusChanged?.Invoke(true);
}
@@ -190,6 +198,9 @@ public async Task TryStopAsync()
await _userDataStreamService.StopAsync();
_cts?.Cancel();
+ // 仅停对账循环,不撤交易所挂单:条件单常驻交易所,监控停止期间保护依然有效
+ _conditionOrderService.Stop();
+
if (_consumerTask != null)
{
try { await _consumerTask; }
@@ -257,7 +268,9 @@ public async Task RefreshSubscriptionsAsync()
private void OnPriceUpdated(string symbol, decimal lastPrice)
{
- _priceChannel.Writer.TryWrite((symbol, lastPrice));
+ _latestPrices[symbol] = lastPrice;
+ if (_pendingPriceSymbols.TryAdd(symbol, 0))
+ _pendingSymbolChannel.Writer.TryWrite(symbol);
// 风险巡检自带 60 秒节流,价格驱动即可,不必单独起定时器
_ = _riskAlertEvaluator.EvaluateAccountRiskSafeAsync(MonitorToken);
@@ -297,19 +310,34 @@ private async Task ConsumePriceUpdatesAsync(CancellationToken ct)
{
try
{
- await foreach (var (symbol, price) in _priceChannel.Reader.ReadAllAsync(ct))
+ await foreach (var symbol in _pendingSymbolChannel.Reader.ReadAllAsync(ct))
{
+ // 先清待处理标记再取价:两步之间到来的新价会重新入队,最新价不会丢失
+ _pendingPriceSymbols.TryRemove(symbol, out _);
+ if (!_latestPrices.TryRemove(symbol, out var price))
+ continue;
+
try
{
await _orderStateSyncService.SyncPendingOrdersAsync(symbol, ct: ct);
var triggered = await _strategyEngine.EvaluateAndUpdateStrategiesAsync(symbol, price, ct);
- foreach (var strategy in triggered)
+ foreach (var trigger in triggered)
{
// 执行失败后处于冷却期的策略跳过,防止每 tick 重复触发请求风暴
- if (IsInFailureCooldown(strategy.Id))
+ if (IsInFailureCooldown(trigger.Strategy.Id))
continue;
- var task = ExecuteWithStrategyLockAsync(strategy, price, ct);
+ // 交易所条件单已兜底:平仓由交易所触发(权威),客户端执行跳过防止双触发。
+ // 引擎的峰值追踪/状态评估在本次 Evaluate 中已照常完成
+ if (_conditionOrderService.HasLiveOrder(trigger.Strategy.Id))
+ {
+ _logger.LogDebug(
+ "策略 {Id} 已由交易所条件单保护,跳过客户端触发执行",
+ trigger.Strategy.Id);
+ continue;
+ }
+
+ var task = ExecuteWithStrategyLockAsync(trigger, price, ct);
lock (_pendingTasksLock)
_pendingStrategyTasks.Add(task);
// 任务完成后自动从列表移除,避免无限增长
@@ -339,6 +367,8 @@ private void OnStrategiesChanged(object? sender, EventArgs e)
return;
_ = RefreshSubscriptionsOnChangeAsync();
+ // 策略增删改后立即对账条件单:新保护策略补挂、删除的策略撤孤儿单、编辑的策略漂移重建
+ _ = _conditionOrderService.ReconcileSafeAsync();
}
private async Task RefreshSubscriptionsOnChangeAsync()
@@ -357,24 +387,24 @@ private async Task RefreshSubscriptionsOnChangeAsync()
/// 带策略级锁的异步执行,防止同一策略并发触发
///
private async Task ExecuteWithStrategyLockAsync(
- TradingStrategy strategy, decimal price, CancellationToken ct)
+ StrategyTrigger trigger, decimal price, CancellationToken ct)
{
- var strategyLock = _strategyLocks.GetOrAdd(strategy.Id, _ => new SemaphoreSlim(1, 1));
+ var strategyLock = _strategyLocks.GetOrAdd(trigger.Strategy.Id, _ => new SemaphoreSlim(1, 1));
if (!await strategyLock.WaitAsync(0, ct))
{
- _logger.LogDebug("策略 {Id} 正在执行中,跳过本次触发", strategy.Id);
+ _logger.LogDebug("策略 {Id} 正在执行中,跳过本次触发", trigger.Strategy.Id);
return;
}
try
{
- await HandleTriggeredStrategyAsync(strategy, price);
+ await HandleTriggeredStrategyAsync(trigger, price);
}
catch (Exception ex)
{
- _logger.LogError(ex, "策略执行异常: {StrategyId}", strategy.Id);
+ _logger.LogError(ex, "策略执行异常: {StrategyId}", trigger.Strategy.Id);
// 未分类异常(未走到 TradeResult 分类)按常规冷却处理
- RecordStrategyFailureCooldown(strategy.Id, StrategyFailureCooldown);
+ RecordStrategyFailureCooldown(trigger.Strategy.Id, StrategyFailureCooldown);
}
finally
{
@@ -406,8 +436,9 @@ private bool IsInFailureCooldown(string strategyId)
return true;
}
- private async Task HandleTriggeredStrategyAsync(TradingStrategy strategy, decimal currentPrice)
+ private async Task HandleTriggeredStrategyAsync(StrategyTrigger trigger, decimal currentPrice)
{
+ var strategy = trigger.Strategy;
if (strategy.Type == StrategyType.AISignal)
{
var result = await _aiSignalExecutor.ExecuteAsync(strategy, currentPrice, MonitorToken);
@@ -429,10 +460,15 @@ private async Task HandleTriggeredStrategyAsync(TradingStrategy strategy, decima
{
// 网格交易:交易成功后原子地持久化更新后的网格参数,防止计数和参数不一致
var pendingCustomParams = strategy.Type == StrategyType.GridTrading ? strategy.CustomParams : null;
- var result = await _tradeExecutor.ExecuteTradeAsync(
- strategy, currentPrice,
+ // 引擎预计算的出场语义 + 本地动态复核取并集:任一判定为出场即按 requireClose 平仓,
+ // 网格破网/DCA 出场等动态语义仍按有效方向复核,避免引擎误标导致反向开仓。
+ var exitTrigger = trigger with
+ {
+ RequireClose = trigger.RequireClose || IsExitOnlyStrategy(trigger, currentPrice)
+ };
+ var result = await _tradeExecutor.ExecuteTriggeredTradeAsync(
+ exitTrigger, currentPrice,
pendingCustomParams: pendingCustomParams,
- requireClose: IsExitOnlyStrategy(strategy, currentPrice),
ct: MonitorToken);
if (result.Success && result.Record != null)
@@ -541,17 +577,19 @@ private async Task PauseStrategyAfterRejectionAsync(TradingStrategy strategy, Tr
/// 防止合约模式下退出型触发在持仓已平后反向开出新仓。
/// 止损/追踪止损为纯退出;止盈仅 Sell 侧为退出(Buy 侧语义为限价建仓);
/// 网格仅破网(价格突破网格边界且触及止损/止盈位)为清仓退出,普通网格线买卖仍是开平仓组合。
+ /// 按有效方向判定(trigger.EffectiveSide),避免策略本体 Side 被复用污染。
///
- private static bool IsExitOnlyStrategy(TradingStrategy strategy, decimal currentPrice)
+ private static bool IsExitOnlyStrategy(StrategyTrigger trigger, decimal currentPrice)
{
+ var strategy = trigger.Strategy;
return strategy.Type switch
{
StrategyType.StopLoss => true,
- StrategyType.TakeProfit => strategy.Side == OrderSide.Sell,
+ StrategyType.TakeProfit => trigger.EffectiveSide == OrderSide.Sell,
StrategyType.TrailingStop => true,
StrategyType.GridTrading => IsGridBreakOut(strategy, currentPrice),
// DCA 评估只会以卖出方向触发出场(止盈/止损清仓),卖出即平仓语义
- StrategyType.DCA => strategy.Side == OrderSide.Sell,
+ StrategyType.DCA => trigger.EffectiveSide == OrderSide.Sell,
_ => false
};
}
@@ -584,6 +622,53 @@ private static bool IsGridBreakOut(TradingStrategy strategy, decimal currentPric
}
}
+ ///
+ /// 交易所侧条件单成交(对账路径感知):完结一次性保护策略并通知 UI。
+ /// 本地持仓 FIFO 与日统计已由 ReconcileTradeRecordAsync 回写,此处只补策略状态闭环。
+ ///
+ private void OnTradeRecordFilled(TradeRecord record)
+ {
+ if (string.IsNullOrEmpty(record.StrategyId) || record.StrategyId == "manual")
+ return;
+
+ _ = HandleProtectiveOrderFilledAsync(record);
+ }
+
+ private async Task HandleProtectiveOrderFilledAsync(TradeRecord record)
+ {
+ try
+ {
+ var strategy = await _dataService.GetStrategyAsync(record.StrategyId, MonitorToken);
+ if (strategy == null || !ExchangeConditionOrderService.IsProtectiveExitStrategy(strategy))
+ {
+ _conditionOrderService.ClearLiveOrder(record.StrategyId);
+ return;
+ }
+
+ TradeExecuted?.Invoke(record);
+
+ if (strategy.Status != StrategyStatus.Active)
+ {
+ _conditionOrderService.ClearLiveOrder(strategy.Id);
+ return;
+ }
+
+ await _strategyService.UpdateStrategyStatusAsync(strategy.Id, StrategyStatus.Completed, MonitorToken);
+ await _strategyEngine.ClearPeakPriceAsync(strategy.Id);
+ _strategyLocks.TryRemove(strategy.Id, out _);
+ _strategyFailureCooldowns.TryRemove(strategy.Id, out _);
+ _conditionOrderService.ClearLiveOrder(strategy.Id);
+ _logger.LogInformation(
+ "交易所条件单已成交,策略自动完结: {StrategyId} {Type} {Symbol}",
+ strategy.Id, strategy.Type, strategy.Symbol);
+ await _signalAlertEvaluator.NotifyStrategyCompletedSafeAsync(strategy);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "处理条件单成交回写失败: {RecordId} {StrategyId}", record.Id, record.StrategyId);
+ }
+ }
+
private async Task CheckStrategyCompletionAsync(TradingStrategy strategy)
{
if (!strategy.MaxExecutions.HasValue)
@@ -605,7 +690,7 @@ public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
- _priceChannel.Writer.TryComplete();
+ _pendingSymbolChannel.Writer.TryComplete();
Task[] pendingTasks;
lock (_pendingTasksLock)
@@ -642,6 +727,7 @@ public void Dispose()
_webSocketService.ConnectionRestored -= _connectionRestoredAdapter;
_userDataStreamService.OrderUpdate -= OnOrderUpdate;
_strategyService.StrategiesChanged -= OnStrategiesChanged;
+ _orderStateSyncService.TradeRecordFilled -= OnTradeRecordFilled;
GC.SuppressFinalize(this);
}
diff --git a/src/MarketAssistant.App.Services/Trading/OrderStateSyncService.cs b/src/MarketAssistant.App.Services/Trading/OrderStateSyncService.cs
index b75a1bb6..40cedaf4 100644
--- a/src/MarketAssistant.App.Services/Trading/OrderStateSyncService.cs
+++ b/src/MarketAssistant.App.Services/Trading/OrderStateSyncService.cs
@@ -20,6 +20,13 @@ public sealed class OrderStateSyncService
private readonly TradingDataService _dataService;
private readonly CryptoPortfolioService _portfolioService;
private readonly ILogger _logger;
+
+ ///
+ /// 交易记录转为成交终态(Filled)时触发,携带最新记录。
+ /// 供策略层完结一次性保护策略(如交易所侧条件单成交后的自动完结);
+ /// 本地持仓 FIFO 与日统计已由 回写。
+ ///
+ public event Action? TradeRecordFilled;
private readonly ConcurrentDictionary _lastSyncAt =
new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _syncGate = new(1, 1);
@@ -113,6 +120,10 @@ private async Task SyncSymbolGroupAsync(
{
updatedCount++;
}
+
+ // 首次到达成交终态:通知策略层完结保护策略(条件单成交自动离场)
+ if (previousStatus != TradeRecordStatus.Filled && record.Status == TradeRecordStatus.Filled)
+ TradeRecordFilled?.Invoke(record);
}
catch (Exception ex) when (ex is FriendlyException or HttpRequestException)
{
diff --git a/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs b/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs
index 23b4a2b7..ccaf66ca 100644
--- a/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs
+++ b/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs
@@ -7,6 +7,24 @@
namespace MarketAssistant.Services.Trading;
+///
+/// 策略触发结果:策略本体保持只读,有效方向/数量与出场语义承载在独立字段。
+/// StrategyEngine 不再修改策略本体的 Side/Quantity,避免返回对象被持久化时污染策略配置。
+///
+public sealed record StrategyTrigger
+{
+ public required TradingStrategy Strategy { get; init; }
+
+ public OrderSide EffectiveSide { get; init; }
+
+ public decimal EffectiveQuantity { get; init; }
+
+ ///
+ /// 出场语义(止损/破网/AI 硬边界):合约下 requireClose=true 只减仓不反向开仓。
+ ///
+ public bool RequireClose { get; init; }
+}
+
///
/// 策略引擎,管理用户策略并评估触发条件。
/// 止损/止盈/追踪止损均由客户端按价格 tick 轮询评估并执行,
@@ -19,6 +37,16 @@ public class StrategyEngine
private readonly RoutingExchangeClient _exchangeClient;
private readonly TradingEnvironmentService _environmentService;
private readonly ILogger _logger;
+ private readonly object _strategiesCacheLock = new();
+
+ ///
+ /// 活跃策略快照缓存 TTL。价格 tick 约每秒到达,逐 tick 全量查询活跃策略是纯开销;
+ /// 策略增删改经 即时失效,
+ /// 绕过服务直写 DB 的路径(如追踪峰值持久化)由 TTL 兜底。
+ ///
+ private static readonly TimeSpan StrategiesCacheTtl = TimeSpan.FromSeconds(2);
+ private List? _activeStrategiesCache;
+ private DateTime _activeStrategiesCacheAtUtc;
public StrategyEngine(
TradingDataService dataService,
@@ -32,6 +60,35 @@ public StrategyEngine(
_exchangeClient = exchangeClient;
_environmentService = environmentService;
_logger = logger;
+
+ // 本引擎为单例,订阅策略变更广播以即时失效快照缓存
+ _strategyService.StrategiesChanged += OnStrategiesChanged;
+ }
+
+ private void OnStrategiesChanged(object? sender, EventArgs e)
+ {
+ lock (_strategiesCacheLock)
+ _activeStrategiesCache = null;
+ }
+
+ ///
+ /// 获取活跃策略快照:TTL 内复用缓存,避免逐 tick 查询 DB。
+ ///
+ private async Task> GetActiveStrategiesSnapshotAsync(CancellationToken ct)
+ {
+ lock (_strategiesCacheLock)
+ {
+ if (_activeStrategiesCache != null && DateTime.UtcNow - _activeStrategiesCacheAtUtc < StrategiesCacheTtl)
+ return _activeStrategiesCache;
+ }
+
+ var strategies = await _strategyService.GetStrategiesByStatusAsync(StrategyStatus.Active, ct);
+ lock (_strategiesCacheLock)
+ {
+ _activeStrategiesCache = strategies;
+ _activeStrategiesCacheAtUtc = DateTime.UtcNow;
+ }
+ return strategies;
}
///
@@ -41,16 +98,14 @@ public async Task ClearPeakPriceAsync(string strategyId, CancellationToken ct =
=> await _dataService.UpdateStrategyTrailingPeakAsync(strategyId, null, ct);
///
- /// 评估指定交易标的的所有活跃策略,返回触发的策略列表。
- /// 注意:此方法会修改返回列表中策略对象的 和
- /// 字段(用于反映触发时的有效方向和数量,
- /// 如网格交易、DCA 等动态计算值),调用方依赖这些副作用将策略传递给交易执行器。
+ /// 评估指定交易标的的所有活跃策略,返回触发结果列表。
+ /// 策略本体保持只读;有效方向/数量与出场语义见 。
///
- public async Task> EvaluateAndUpdateStrategiesAsync(
+ public async Task> EvaluateAndUpdateStrategiesAsync(
string symbol, decimal currentPrice, CancellationToken ct = default)
{
- var activeStrategies = await _strategyService.GetStrategiesByStatusAsync(StrategyStatus.Active, ct);
- var triggered = new List();
+ var activeStrategies = await GetActiveStrategiesSnapshotAsync(ct);
+ var triggered = new List();
foreach (var strategy in activeStrategies)
{
@@ -66,19 +121,34 @@ public async Task> EvaluateAndUpdateStrategiesAsync(
var (triggeredFlag, effectiveSide, effectiveQty) = await IsTriggeredAsync(strategy, currentPrice, ct);
if (triggeredFlag)
{
- strategy.Side = effectiveSide;
- strategy.Quantity = effectiveQty;
_logger.LogInformation(
"策略触发: {StrategyId} {Type} {Symbol} 触发价:{TriggerPrice} 当前价:{CurrentPrice}",
strategy.Id, strategy.Type, symbol, strategy.TriggerPrice, currentPrice);
- triggered.Add(strategy);
+ triggered.Add(new StrategyTrigger
+ {
+ Strategy = strategy,
+ EffectiveSide = effectiveSide,
+ EffectiveQuantity = effectiveQty,
+ RequireClose = IsExitTrigger(strategy)
+ });
}
}
return triggered;
}
+ private static bool IsExitTrigger(TradingStrategy strategy) => strategy.Type switch
+ {
+ StrategyType.StopLoss => true,
+ StrategyType.TrailingStop => true,
+ // 止盈:卖出方向为持有多头获利了结(平仓语义),买入方向为建仓,不视为出场
+ StrategyType.TakeProfit => strategy.Side == OrderSide.Sell,
+ // Grid/DCA/AISignal 的出场语义依赖有效方向(网格破网、DCA 出场卖出),
+ // 由 MarketMonitor.IsExitOnlyStrategy 按 trigger.EffectiveSide 复核,此处保持非出场
+ _ => false
+ };
+
private async Task<(bool Triggered, OrderSide Side, decimal Qty)> IsTriggeredAsync(
TradingStrategy strategy, decimal currentPrice, CancellationToken ct)
{
@@ -231,22 +301,9 @@ private async Task EvaluateAndUpdateAISignalAsync(
TradingStrategy strategy, CancellationToken ct)
{
var intervalSeconds = DefaultAISignalIntervalSeconds;
-
- if (!string.IsNullOrEmpty(strategy.CustomParams))
- {
- try
- {
- using var doc = JsonDocument.Parse(strategy.CustomParams);
- var root = doc.RootElement;
- if (root.TryGetProperty("analysisInterval", out var intervalEl))
- intervalSeconds = intervalEl.GetInt32();
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "解析 AISignal 参数失败,使用默认间隔 {DefaultInterval}s: {StrategyId}",
- DefaultAISignalIntervalSeconds, strategy.Id);
- }
- }
+ var aiParams = AISignalParams.FromJson(strategy.CustomParams);
+ if (aiParams is { AnalysisIntervalSeconds: > 0 })
+ intervalSeconds = aiParams.AnalysisIntervalSeconds;
if (strategy.LastTriggeredAt.HasValue)
{
diff --git a/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs b/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs
index 7d0115d2..daf8fed8 100644
--- a/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs
+++ b/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs
@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Net.Sockets;
+using System.Text.Json;
using MarketAssistant.Applications.AlertCenter;
using MarketAssistant.Infrastructure.Core;
using MarketAssistant.Services.Trading.Exchanges;
@@ -50,7 +51,8 @@ public TradeExecutor(
}
///
- /// 执行策略触发的交易(委托给通用下单方法)。
+ /// 执行策略触发的交易(兼容包装,委托给 )。
+ /// 有效方向/数量直接取自策略本体;
/// 表示该触发语义为"平仓退出"(如止损、追踪止损、网格破网、AI 硬性边界):
/// 合约模式下若交易所不存在对应方向的持仓则拒绝下单,防止退出型触发在无持仓时反向开出新仓。
///
@@ -61,9 +63,30 @@ public virtual async Task ExecuteTradeAsync(
bool requireClose = false,
CancellationToken ct = default)
{
+ return await ExecuteTriggeredTradeAsync(
+ new StrategyTrigger
+ {
+ Strategy = strategy,
+ EffectiveSide = strategy.Side,
+ EffectiveQuantity = strategy.Quantity,
+ RequireClose = requireClose
+ },
+ currentPrice, aiReasoning, pendingCustomParams, ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// 执行策略触发的交易(无副作用版本):有效方向/数量与出场语义由
+ /// 承载,策略本体保持只读。
+ ///
+ public virtual async Task ExecuteTriggeredTradeAsync(
+ StrategyTrigger trigger, decimal currentPrice, string? aiReasoning = null,
+ string? pendingCustomParams = null,
+ CancellationToken ct = default)
+ {
+ var strategy = trigger.Strategy;
_logger.LogInformation("开始执行交易: {StrategyId} {Symbol} {Side} 数量:{Qty}",
- strategy.Id, strategy.Symbol, strategy.Side, strategy.Quantity);
+ strategy.Id, strategy.Symbol, trigger.EffectiveSide, trigger.EffectiveQuantity);
// 限价单基于当前价计算滑点保护价
decimal? limitPrice = null;
@@ -71,15 +94,15 @@ public virtual async Task ExecuteTradeAsync(
if (orderType == OrderType.Limit)
{
var slippage = strategy.SlippageTolerance > 0 ? strategy.SlippageTolerance : 0.003m;
- limitPrice = strategy.Side == OrderSide.Buy
+ limitPrice = trigger.EffectiveSide == OrderSide.Buy
? currentPrice * (1 + slippage)
: currentPrice * (1 - slippage);
}
var result = await ExecuteOrderAsync(
- strategy.Symbol, strategy.Side, orderType, strategy.Quantity,
+ strategy.Symbol, trigger.EffectiveSide, orderType, trigger.EffectiveQuantity,
currentPrice, limitPrice: limitPrice, strategyId: strategy.Id,
- aiReasoning: aiReasoning, requireClose: requireClose, ct: ct);
+ aiReasoning: aiReasoning, requireClose: trigger.RequireClose, ct: ct);
if (result.Success)
{
@@ -92,6 +115,179 @@ public virtual async Task ExecuteTradeAsync(
return result;
}
+ ///
+ /// 交易所侧保护性条件单的 clientOrderId 前缀(普通下单为 "MA" 前缀)。
+ /// 对账时按前缀识别本应用挂出的条件单,孤儿单可安全撤销。
+ ///
+ public const string ConditionOrderClientOrderIdPrefix = "MC";
+
+ ///
+ /// 生成策略条件单的确定性 clientOrderId:"MC" + 策略 ID(32 位 GUID "N" 格式,总长 34 ≤ 币安 36 字符上限)。
+ /// 确定性 ID 使对账可按策略精确匹配挂单,无需在策略表持久化交易所订单 ID。
+ ///
+ public static string BuildConditionOrderClientOrderId(string strategyId)
+ => ConditionOrderClientOrderIdPrefix + strategyId;
+
+ ///
+ /// 解析追踪止损的回调基点(1% = 100 基点):优先 CustomParams.trailingPercent,
+ /// 缺失时按风险档案兜底(与策略引擎的回退规则一致)。
+ ///
+ internal static int ResolveTrailingDeltaBasisPoints(TradingStrategy strategy)
+ {
+ decimal trailingPercent = 0;
+ if (!string.IsNullOrEmpty(strategy.CustomParams))
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(strategy.CustomParams);
+ if (doc.RootElement.TryGetProperty("trailingPercent", out var el)
+ && el.TryGetDecimal(out var parsed))
+ trailingPercent = parsed;
+ }
+ catch (JsonException)
+ {
+ // 参数损坏按档案兜底,与引擎行为一致
+ }
+ }
+
+ if (trailingPercent <= 0)
+ trailingPercent = ScenarioPresets.GetTrailingPercent(
+ AISignalParams.FromJson(strategy.CustomParams)?.ParsedRiskProfile ?? RiskProfile.Balanced);
+
+ return decimal.ToInt32(trailingPercent * 100);
+ }
+
+ ///
+ /// 由策略参数推导条件单规格(订单类型/触发价/回调基点)。
+ /// 返回 error 非空表示该策略无法挂条件单(调用方以客户端评估兜底)。
+ ///
+ internal static (OrderType? Type, decimal? StopPrice, int? TrailingDelta, string? Error) BuildConditionOrderSpec(
+ TradingStrategy strategy)
+ {
+ switch (strategy.Type)
+ {
+ case StrategyType.StopLoss:
+ if (strategy.TriggerPrice <= 0)
+ return (null, null, null, $"止损策略缺少触发价,无法挂条件单: {strategy.Id}");
+ return (OrderType.StopMarket, strategy.TriggerPrice, null, null);
+
+ case StrategyType.TakeProfit when strategy.Side == OrderSide.Sell:
+ if (strategy.TriggerPrice <= 0)
+ return (null, null, null, $"止盈策略缺少触发价,无法挂条件单: {strategy.Id}");
+ return (OrderType.TakeProfitMarket, strategy.TriggerPrice, null, null);
+
+ case StrategyType.TrailingStop:
+ var bps = ResolveTrailingDeltaBasisPoints(strategy);
+ // 币安 TrailingStopMarket 回调比例有效范围 0.1%-10%(10-1000 基点)
+ if (bps is < 10 or > 1000)
+ return (null, null, null, $"追踪止损回调比例 {bps} 基点超出币安有效范围(10-1000): {strategy.Id}");
+ return (OrderType.TrailingStopMarket, null, bps, null);
+
+ default:
+ return (null, null, null, $"策略类型 {strategy.Type} 不支持交易所条件单: {strategy.Id}");
+ }
+ }
+
+ ///
+ /// 为退出型策略挂交易所侧 reduceOnly 条件单(仅合约):
+ /// StopLoss→StopMarket、止盈 Sell→TakeProfitMarket、TrailingStop→TrailingStopMarket。
+ /// 保护性离场不受风控确认与告警联动门限制(与 requireClose 豁免同一原则);
+ /// 挂单本身无成交,持仓 FIFO 与日统计由订单对账路径在成交后回写,
+ /// 策略完结由 MarketMonitor 的条件单成交事件驱动。
+ /// 现货模式直接拒绝(调用方以客户端评估兜底)。
+ ///
+ public virtual async Task PlaceConditionOrderAsync(
+ TradingStrategy strategy, CancellationToken ct = default)
+ {
+ var exchangeClient = ResolveClientSnapshot();
+ if (!exchangeClient.IsFutures)
+ return new TradeResult
+ {
+ Success = false,
+ ErrorMessage = "条件单兜底仅支持合约模式",
+ FailureCategory = TradeFailureCategory.Rejected
+ };
+
+ var (orderType, stopPrice, trailingDelta, error) = BuildConditionOrderSpec(strategy);
+ if (orderType == null)
+ return new TradeResult { Success = false, ErrorMessage = error, FailureCategory = TradeFailureCategory.Rejected };
+
+ var gate = _symbolExecutionLocks.GetOrAdd(strategy.Symbol, _ => new SemaphoreSlim(1, 1));
+ await gate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ // 确定性 clientOrderId:对账按策略精确匹配挂单
+ var clientOrderId = BuildConditionOrderClientOrderId(strategy.Id);
+
+ // 网络异常重试:与普通下单一致的指数退避(1s/2s/4s)
+ ExchangeOrderResult? response = null;
+ Exception? lastNetworkException = null;
+ const int maxRetries = 3;
+ for (var attempt = 1; attempt <= maxRetries; attempt++)
+ {
+ try
+ {
+ response = await exchangeClient.PlaceOrderAsync(
+ strategy.Symbol, strategy.Side, orderType.Value, strategy.Quantity,
+ price: null, clientOrderId,
+ reduceOnly: true,
+ stopPrice: stopPrice, trailingDelta: trailingDelta, ct: ct).ConfigureAwait(false);
+ break;
+ }
+ catch (Exception ex) when (IsTransient(ex) && !ct.IsCancellationRequested)
+ {
+ lastNetworkException = ex;
+ if (attempt >= maxRetries)
+ break;
+
+ var delayMs = (int)Math.Pow(2, attempt - 1) * 1000;
+ _logger.LogWarning(ex,
+ "挂条件单网络异常,{Attempt}/{Max} 次重试,{Delay}ms 后重试(幂等ID={ClientOrderId}): {Symbol} {StrategyId}",
+ attempt, maxRetries, delayMs, clientOrderId, strategy.Symbol, strategy.Id);
+ await Task.Delay(delayMs, ct).ConfigureAwait(false);
+ }
+ }
+
+ if (response == null)
+ throw new InvalidOperationException(
+ $"挂条件单响应为空(已重试 {maxRetries} 次)", lastNetworkException);
+
+ // 挂单成功即落 Pending 交易记录:OrderStateSyncService 对既有对账管线
+ // 自动感知成交(回写持仓 FIFO 与日统计),无需新增成交流
+ var record = new TradeRecord
+ {
+ StrategyId = strategy.Id,
+ Symbol = strategy.Symbol,
+ Side = strategy.Side,
+ OrderType = orderType.Value,
+ RequestedQty = strategy.Quantity,
+ ExecutedQty = 0,
+ Status = MapStatus(response.Status),
+ ExchangeOrderId = long.TryParse(response.OrderId, out var orderId) ? orderId : 0,
+ CreatedAt = DateTime.UtcNow
+ };
+ await _dataService.SaveTradeRecordAsync(record, ct).ConfigureAwait(false);
+
+ _logger.LogInformation(
+ "条件单已挂出: {StrategyId} {Type} {Symbol} {Side} 数量:{Qty} 订单ID:{OrderId}",
+ strategy.Id, orderType.Value, strategy.Symbol, strategy.Side, strategy.Quantity, response.OrderId);
+
+ return new TradeResult { Success = true, Record = record };
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "挂条件单失败: {StrategyId} {Symbol}", strategy.Id, strategy.Symbol);
+ var category = IsTransient(ex)
+ ? TradeFailureCategory.Network
+ : TradeFailureCategory.Other;
+ return new TradeResult { Success = false, ErrorMessage = ex.Message, FailureCategory = category };
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
///
/// 通用下单方法,所有交易路径(策略触发、AI Agent、手动)的统一入口。
/// 风控检查和人工确认在 symbol 锁之外执行,避免等待用户输入时锁死后续交易。
diff --git a/src/MarketAssistant.App.Services/Trading/TradingDataService.cs b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs
index 54df2454..ac3b6aa4 100644
--- a/src/MarketAssistant.App.Services/Trading/TradingDataService.cs
+++ b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs
@@ -65,6 +65,11 @@ public virtual Task UpdateStrategyLastTriggeredAtAsync(string id, CancellationTo
public Task UpdateStrategyTriggeredWithParamsAsync(string id, string? customParams, CancellationToken ct = default)
=> _strategies.UpdateStrategyTriggeredWithParamsAsync(id, customParams, ct);
+ /// virtual 供单元测试替换。
+ public virtual Task UpdateStrategyConditionOrderFingerprintAsync(
+ string id, string? fingerprint, CancellationToken ct = default)
+ => _strategies.UpdateStrategyConditionOrderFingerprintAsync(id, fingerprint, ct);
+
/// virtual 供单元测试替换。
public virtual Task UpdateStrategyCustomParamsAsync(string id, string? customParams, CancellationToken ct = default)
=> _strategies.UpdateStrategyCustomParamsAsync(id, customParams, ct);
diff --git a/src/MarketAssistant.App.Services/Trading/TradingSchemaInitializer.cs b/src/MarketAssistant.App.Services/Trading/TradingSchemaInitializer.cs
index 7649325c..a8baef59 100644
--- a/src/MarketAssistant.App.Services/Trading/TradingSchemaInitializer.cs
+++ b/src/MarketAssistant.App.Services/Trading/TradingSchemaInitializer.cs
@@ -37,7 +37,8 @@ CREATE TABLE IF NOT EXISTS strategies (
last_triggered_at TEXT,
execution_count INTEGER DEFAULT 0,
max_executions INTEGER,
- trailing_peak_price TEXT
+ trailing_peak_price TEXT,
+ condition_order_fingerprint TEXT
)
""",
["trade_records"] = """
@@ -283,6 +284,8 @@ private static async Task EnsureEnvironmentSchemaAsync(
// 历史库补充列时给出默认值,保证旧行读回为 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 EnsureColumnAsync(conn, transaction, "strategies", "condition_order_fingerprint", "TEXT").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/Trading/TradingStrategyRepository.cs b/src/MarketAssistant.App.Services/Trading/TradingStrategyRepository.cs
index 89beca55..ef2fb098 100644
--- a/src/MarketAssistant.App.Services/Trading/TradingStrategyRepository.cs
+++ b/src/MarketAssistant.App.Services/Trading/TradingStrategyRepository.cs
@@ -27,12 +27,12 @@ INSERT OR REPLACE INTO strategies
(id, environment, symbol, type, status, side, order_type, slippage_tolerance,
trigger_price, stop_loss_price, take_profit_price,
quantity, max_position_percent, custom_params, created_at, last_triggered_at,
- execution_count, max_executions, trailing_peak_price)
+ execution_count, max_executions, trailing_peak_price, condition_order_fingerprint)
VALUES
(@id, @environment, @symbol, @type, @status, @side, @orderType, @slippage,
@triggerPrice, @slPrice, @tpPrice,
@qty, @maxPos, @customParams, @createdAt, @lastTriggered,
- @execCount, @maxExec, @trailingPeak)
+ @execCount, @maxExec, @trailingPeak, @conditionFingerprint)
""";
cmd.Parameters.AddWithValue("@id", strategy.Id);
cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey);
@@ -53,6 +53,7 @@ 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("@conditionFingerprint", (object?)strategy.ConditionOrderFingerprint ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
@@ -227,6 +228,22 @@ public async Task UpdateStrategyTrailingPeakAsync(string id, decimal? trailingPe
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
+ ///
+ /// 更新交易所侧保护性条件单的参数指纹(挂单成功后回写,供对账检测参数漂移)
+ ///
+ public async Task UpdateStrategyConditionOrderFingerprintAsync(
+ string id, string? fingerprint, CancellationToken ct = default)
+ {
+ await EnsureInitializedAsync();
+ await using var conn = await OpenConnectionAsync(ct);
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "UPDATE strategies SET condition_order_fingerprint = @fp WHERE id = @id AND environment = @environment";
+ cmd.Parameters.AddWithValue("@id", id);
+ cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey);
+ cmd.Parameters.AddWithValue("@fp", (object?)fingerprint ?? DBNull.Value);
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
+ }
+
private static TradingStrategy ReadStrategy(SqliteDataReader reader)
{
var strategy = new TradingStrategy
@@ -270,6 +287,9 @@ private static TradingStrategy ReadStrategy(SqliteDataReader reader)
var trailingOrd = reader.GetOrdinal("trailing_peak_price");
if (!reader.IsDBNull(trailingOrd)) strategy.TrailingPeakPrice = ReadDecimal(reader, trailingOrd);
+ var fpOrd = reader.GetOrdinal("condition_order_fingerprint");
+ if (!reader.IsDBNull(fpOrd)) strategy.ConditionOrderFingerprint = reader.GetString(fpOrd);
+
return strategy;
}
diff --git a/src/MarketAssistant.App/App.axaml b/src/MarketAssistant.App/App.axaml
index 2876492f..d39a3353 100644
--- a/src/MarketAssistant.App/App.axaml
+++ b/src/MarketAssistant.App/App.axaml
@@ -21,6 +21,7 @@
+
diff --git a/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/BaseAdaptiveCardParser.cs b/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/BaseAdaptiveCardParser.cs
index 7b899097..c2ecbcb6 100644
--- a/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/BaseAdaptiveCardParser.cs
+++ b/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/BaseAdaptiveCardParser.cs
@@ -58,15 +58,52 @@ protected virtual bool IsValid(T model)
return true;
}
- protected void AddHeader(IList container, string title, AdaptiveTextColor color)
+ protected void AddHeader(IList container, string title, AdaptiveTextColor color, string? iconPath = null)
{
- container.Add(new AdaptiveTextBlock
+ if (!string.IsNullOrEmpty(iconPath))
{
- Text = title,
- Size = AdaptiveTextSize.Medium,
- Weight = AdaptiveTextWeight.Bolder,
- Color = color
- });
+ var colSet = new AdaptiveColumnSet { Spacing = AdaptiveSpacing.Small };
+ colSet.Columns.Add(new AdaptiveColumn
+ {
+ Width = "auto",
+ VerticalContentAlignment = AdaptiveVerticalContentAlignment.Center,
+ Items =
+ {
+ new AdaptiveImage
+ {
+ Url = new Uri(iconPath, UriKind.RelativeOrAbsolute),
+ PixelWidth = 20,
+ PixelHeight = 20
+ }
+ }
+ });
+ colSet.Columns.Add(new AdaptiveColumn
+ {
+ Width = "stretch",
+ VerticalContentAlignment = AdaptiveVerticalContentAlignment.Center,
+ Items =
+ {
+ new AdaptiveTextBlock
+ {
+ Text = title,
+ Size = AdaptiveTextSize.Medium,
+ Weight = AdaptiveTextWeight.Bolder,
+ Color = color
+ }
+ }
+ });
+ container.Add(colSet);
+ }
+ else
+ {
+ container.Add(new AdaptiveTextBlock
+ {
+ Text = title,
+ Size = AdaptiveTextSize.Medium,
+ Weight = AdaptiveTextWeight.Bolder,
+ Color = color
+ });
+ }
}
protected void AddSectionHeader(IList container, string title)
@@ -156,8 +193,8 @@ protected void AddStrategyBox(
}
///
- /// 统一的风险警告容器:Attention 样式 + ⚠️ 标题 + 正文 + 可选列表。
- /// 财务/基本面的容器实现与新闻/情绪的行内 ⚠️ 文本均改走此构件。
+ /// 统一的风险警告容器:Attention 样式 + 风险标题 + 正文 + 可选列表。
+ /// 财务/基本面的容器实现与新闻/情绪的行内风险文本均改走此构件。
///
protected void AddRiskBox(
IList container,
@@ -171,12 +208,36 @@ protected void AddRiskBox(
Spacing = AdaptiveSpacing.Medium
};
- box.Items.Add(new AdaptiveTextBlock
+ var titleCols = new AdaptiveColumnSet { Spacing = AdaptiveSpacing.Small };
+ titleCols.Columns.Add(new AdaptiveColumn
{
- Text = "⚠️ " + title,
- Weight = AdaptiveTextWeight.Bolder,
- Color = AdaptiveTextColor.Attention
+ Width = "auto",
+ VerticalContentAlignment = AdaptiveVerticalContentAlignment.Center,
+ Items =
+ {
+ new AdaptiveImage
+ {
+ Url = new Uri("avares://MarketAssistant/Assets/Images/icon_danger.svg", UriKind.RelativeOrAbsolute),
+ PixelWidth = 16,
+ PixelHeight = 16
+ }
+ }
+ });
+ titleCols.Columns.Add(new AdaptiveColumn
+ {
+ Width = "stretch",
+ VerticalContentAlignment = AdaptiveVerticalContentAlignment.Center,
+ Items =
+ {
+ new AdaptiveTextBlock
+ {
+ Text = title,
+ Weight = AdaptiveTextWeight.Bolder,
+ Color = AdaptiveTextColor.Attention
+ }
+ }
});
+ box.Items.Add(titleCols);
if (!string.IsNullOrEmpty(content))
{
diff --git a/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/CoordinatorCardParser.cs b/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/CoordinatorCardParser.cs
index c99fe3c2..4be73e5e 100644
--- a/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/CoordinatorCardParser.cs
+++ b/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/CoordinatorCardParser.cs
@@ -22,7 +22,7 @@ public override AdaptiveCard Parse(CoordinatorResult model)
FallbackText = $"综合分析报告:{summaryRating} (评分 {summaryScore}),请查看完整报告。",
Speak = "综合分析报告已生成。"
};
- AddHeader(card.Body, "📑 综合分析报告", AdaptiveTextColor.Accent);
+ AddHeader(card.Body, "综合分析报告", AdaptiveTextColor.Accent, "avares://MarketAssistant/Assets/Images/icon_clipboard.svg");
// Investment Rating & Score
var rating = GetEnumDescription(model.InvestmentRating);
@@ -77,6 +77,11 @@ public override AdaptiveCard Parse(CoordinatorResult model)
scoreFacts.Facts.Add(new AdaptiveFact("财务面", model.DimensionScores.Financial.ToString("F1")));
scoreFacts.Facts.Add(new AdaptiveFact("市场情绪", model.DimensionScores.Sentiment.ToString("F1")));
scoreFacts.Facts.Add(new AdaptiveFact("新闻事件", model.DimensionScores.News.ToString("F1")));
+ // 虚拟币专属维度:仅 Crypto 分析有值(A 股为 null),无值时不渲染该条目
+ if (model.DimensionScores.CryptoMetrics.HasValue)
+ {
+ scoreFacts.Facts.Add(new AdaptiveFact("虚拟币指标", model.DimensionScores.CryptoMetrics.Value.ToString("F1")));
+ }
AddFactSection(card.Body, "维度评分", scoreFacts);
}
diff --git a/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/FinancialCardParser.cs b/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/FinancialCardParser.cs
index 668536f9..4c219c06 100644
--- a/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/FinancialCardParser.cs
+++ b/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/FinancialCardParser.cs
@@ -24,7 +24,7 @@ public override AdaptiveCard Parse(FinancialAnalysisResult model)
FallbackText = $"财务分析报告:偿债评分 {summaryScore},观点:{summaryInsight},请查看完整报告。",
Speak = "财务分析报告已生成。"
};
- AddHeader(card.Body, "💰 财务分析报告", AdaptiveTextColor.Accent);
+ AddHeader(card.Body, "财务分析报告", AdaptiveTextColor.Accent, "avares://MarketAssistant/Assets/Images/icon_money.svg");
// 使用两列布局展示核心指标
var metricsCols = new AdaptiveColumnSet();
diff --git a/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/FundamentalCardParser.cs b/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/FundamentalCardParser.cs
index 6ce901c6..32041f03 100644
--- a/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/FundamentalCardParser.cs
+++ b/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/FundamentalCardParser.cs
@@ -22,7 +22,7 @@ public override AdaptiveCard Parse(FundamentalAnalysisResult model)
FallbackText = $"基本面分析:{summaryName} - 评级:{summaryRating},请查看完整报告。",
Speak = "基本面分析已生成。"
};
- AddHeader(card.Body, "📊 基本面分析", AdaptiveTextColor.Accent);
+ AddHeader(card.Body, "基本面分析", AdaptiveTextColor.Accent, "avares://MarketAssistant/Assets/Images/icon_scale.svg");
if (model.BasicInfo != null)
{
diff --git a/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/NewsCardParser.cs b/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/NewsCardParser.cs
index 502125ce..5118aa59 100644
--- a/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/NewsCardParser.cs
+++ b/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/NewsCardParser.cs
@@ -24,7 +24,7 @@ public override AdaptiveCard Parse(NewsEventAnalysisResult model)
FallbackText = $"新闻事件分析:[{summaryNature}] {summaryEvent},请查看完整报告。",
Speak = "新闻事件分析已生成。"
};
- AddHeader(card.Body, "📰 新闻事件分析", AdaptiveTextColor.Accent);
+ AddHeader(card.Body, "新闻事件分析", AdaptiveTextColor.Accent, "avares://MarketAssistant/Assets/Images/icon_document.svg");
// 1. Event & Impact in 2 columns
var topCols = new AdaptiveColumnSet();
diff --git a/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/SentimentCardParser.cs b/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/SentimentCardParser.cs
index 1f4fef56..78027911 100644
--- a/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/SentimentCardParser.cs
+++ b/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/SentimentCardParser.cs
@@ -22,7 +22,7 @@ public override AdaptiveCard Parse(MarketSentimentAnalysisResult model)
FallbackText = $"市场情绪分析:主导情绪{summaryEmotion} (强度 {summaryScore}),请查看完整报告。",
Speak = "市场情绪分析已生成。"
};
- AddHeader(card.Body, "🌡️ 市场情绪分析", AdaptiveTextColor.Accent);
+ AddHeader(card.Body, "市场情绪分析", AdaptiveTextColor.Accent, "avares://MarketAssistant/Assets/Images/icon_bulb.svg");
// 1. Sentiment & Capital Flow in 2 columns
var topCols = new AdaptiveColumnSet();
diff --git a/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/TechnicalCardParser.cs b/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/TechnicalCardParser.cs
index 9766bb82..2221b4ab 100644
--- a/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/TechnicalCardParser.cs
+++ b/src/MarketAssistant.App/Infrastructure/AdaptiveCards/Parsers/TechnicalCardParser.cs
@@ -22,7 +22,7 @@ public override AdaptiveCard Parse(TechnicalAnalysisResult model)
FallbackText = $"技术分析报告:当前趋势{summaryTrend} (强度 {summaryScore}),请查看完整报告。",
Speak = "技术分析报告已生成。"
};
- AddHeader(card.Body, "📈 技术分析报告", AdaptiveTextColor.Accent);
+ AddHeader(card.Body, "技术分析报告", AdaptiveTextColor.Accent, "avares://MarketAssistant/Assets/Images/icon_chart.svg");
// 1. Pattern & Price in 2 columns
var topCols = new AdaptiveColumnSet();
diff --git a/src/MarketAssistant.App/MarketAssistant.App.csproj b/src/MarketAssistant.App/MarketAssistant.App.csproj
index 0915d9ae..af82b22a 100644
--- a/src/MarketAssistant.App/MarketAssistant.App.csproj
+++ b/src/MarketAssistant.App/MarketAssistant.App.csproj
@@ -70,6 +70,7 @@
+
diff --git a/src/MarketAssistant.App/Resources/Styles/ButtonStyles.axaml b/src/MarketAssistant.App/Resources/Styles/ButtonStyles.axaml
index 382917cc..8b548aa3 100644
--- a/src/MarketAssistant.App/Resources/Styles/ButtonStyles.axaml
+++ b/src/MarketAssistant.App/Resources/Styles/ButtonStyles.axaml
@@ -30,7 +30,7 @@
@@ -151,7 +151,7 @@
@@ -171,7 +171,7 @@
@@ -185,7 +185,7 @@
@@ -237,7 +237,7 @@
@@ -32,14 +32,14 @@
@@ -69,7 +69,7 @@
@@ -81,10 +81,10 @@
VerticalAlignment="Center"/>
@@ -100,7 +100,7 @@
Background="{StaticResource PrimaryBrush}"
VerticalAlignment="Center" Margin="0,1,7,1"/>
@@ -120,10 +120,10 @@
@@ -136,18 +136,18 @@
diff --git a/src/MarketAssistant.App/Views/Components/ChatSidebarView.axaml b/src/MarketAssistant.App/Views/Components/ChatSidebarView.axaml
index 2244c287..1f91bbd8 100644
--- a/src/MarketAssistant.App/Views/Components/ChatSidebarView.axaml
+++ b/src/MarketAssistant.App/Views/Components/ChatSidebarView.axaml
@@ -6,6 +6,7 @@
xmlns:converts="using:MarketAssistant.Converters"
xmlns:controls="using:MarketAssistant.Views.Controls"
xmlns:components="using:MarketAssistant.Views.Components"
+ xmlns:svg="clr-namespace:Avalonia.Svg.Skia;assembly=Svg.Controls.Skia.Avalonia"
mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="800"
x:Class="MarketAssistant.Views.Components.ChatSidebarView"
x:DataType="vm:ChatSidebarViewModel"
@@ -239,7 +240,10 @@
Padding="{StaticResource CardPadding}"
IsVisible="{Binding IsAnalystFailure}">
-
+
_ = InitializeChartAsync();
_errorPanel.Children.Add(_retryButton);
diff --git a/src/MarketAssistant.App/Views/Components/ProgressDisplayView.axaml b/src/MarketAssistant.App/Views/Components/ProgressDisplayView.axaml
index f3d61fb4..ad0caed6 100644
--- a/src/MarketAssistant.App/Views/Components/ProgressDisplayView.axaml
+++ b/src/MarketAssistant.App/Views/Components/ProgressDisplayView.axaml
@@ -111,7 +111,7 @@
240
-
-
-
-
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
@@ -187,7 +195,7 @@
ZIndex="999"
IsHitTestVisible="False"/>
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/MarketAssistant.App/Views/Pages/AgentAnalysisPageView.axaml.cs b/src/MarketAssistant.App/Views/Pages/AgentAnalysisPageView.axaml.cs
index 87cdb687..4f432185 100644
--- a/src/MarketAssistant.App/Views/Pages/AgentAnalysisPageView.axaml.cs
+++ b/src/MarketAssistant.App/Views/Pages/AgentAnalysisPageView.axaml.cs
@@ -22,15 +22,4 @@ public AgentAnalysisPageView()
}
};
}
-
- ///
- /// 点击遮罩层关闭侧边栏
- ///
- private void OnOverlayTapped(object? sender, TappedEventArgs e)
- {
- if (DataContext is AgentAnalysisViewModel vm)
- {
- vm.ToggleChatSidebarCommand.Execute(null);
- }
- }
}
diff --git a/src/MarketAssistant.App/Views/Pages/AssetPageView.axaml b/src/MarketAssistant.App/Views/Pages/AssetPageView.axaml
index 6e1ea39d..bc84afea 100644
--- a/src/MarketAssistant.App/Views/Pages/AssetPageView.axaml
+++ b/src/MarketAssistant.App/Views/Pages/AssetPageView.axaml
@@ -88,6 +88,25 @@
Orientation="Horizontal"
Spacing="{StaticResource SmallSpacing}"
VerticalAlignment="Center">
+
+
+
+
+
+
diff --git a/src/MarketAssistant.App/Views/Pages/FavoritesPageView.axaml b/src/MarketAssistant.App/Views/Pages/FavoritesPageView.axaml
index 37d5f0e2..18cac4ce 100644
--- a/src/MarketAssistant.App/Views/Pages/FavoritesPageView.axaml
+++ b/src/MarketAssistant.App/Views/Pages/FavoritesPageView.axaml
@@ -49,14 +49,24 @@
VerticalAlignment="Center"
Spacing="{StaticResource LargeSpacing}">
-
-
+
+
+
+
+
+
+
-
-
-
+
@@ -156,6 +166,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/MarketAssistant.App/Views/Pages/FavoritesPageView.axaml.cs b/src/MarketAssistant.App/Views/Pages/FavoritesPageView.axaml.cs
index 5059cd7c..bd4da1d7 100644
--- a/src/MarketAssistant.App/Views/Pages/FavoritesPageView.axaml.cs
+++ b/src/MarketAssistant.App/Views/Pages/FavoritesPageView.axaml.cs
@@ -48,4 +48,17 @@ border.Tag is AssetInfo asset &&
viewModel.SelectFavoriteAssetCommand?.Execute(asset);
}
}
+
+ ///
+ /// 表格视图行双击:进入标的详情(与卡片单击行为一致)。
+ ///
+ private void OnGridRowDoubleTapped(object? sender, TappedEventArgs e)
+ {
+ if (sender is DataGrid grid &&
+ grid.SelectedItem is AssetInfo asset &&
+ DataContext is FavoritesPageViewModel viewModel)
+ {
+ viewModel.SelectFavoriteAssetCommand?.Execute(asset);
+ }
+ }
}
\ No newline at end of file
diff --git a/src/MarketAssistant.App/Views/Pages/PriceAlertPageView.axaml b/src/MarketAssistant.App/Views/Pages/PriceAlertPageView.axaml
index dee6d632..c4c5a8e3 100644
--- a/src/MarketAssistant.App/Views/Pages/PriceAlertPageView.axaml
+++ b/src/MarketAssistant.App/Views/Pages/PriceAlertPageView.axaml
@@ -34,6 +34,42 @@
+
+
+
+
+
+
@@ -68,6 +104,7 @@
diff --git a/src/MarketAssistant.App/Views/Windows/MainWindow.axaml b/src/MarketAssistant.App/Views/Windows/MainWindow.axaml
index 4778b378..8e87b74f 100644
--- a/src/MarketAssistant.App/Views/Windows/MainWindow.axaml
+++ b/src/MarketAssistant.App/Views/Windows/MainWindow.axaml
@@ -45,17 +45,17 @@
-
-
-
@@ -74,8 +74,8 @@
diff --git a/src/MarketAssistant.DataProviders/AShare/ServiceCollectionExtensions.cs b/src/MarketAssistant.DataProviders/AShare/ServiceCollectionExtensions.cs
index fd166b41..a1412e19 100644
--- a/src/MarketAssistant.DataProviders/AShare/ServiceCollectionExtensions.cs
+++ b/src/MarketAssistant.DataProviders/AShare/ServiceCollectionExtensions.cs
@@ -3,7 +3,7 @@
namespace MarketAssistant.DataProviders.AShare;
///
-/// 注册 A 股数据客户端(财联社行情/搜索、智兔市场数据、东方财富新闻、新浪资金流)。
+/// 注册 A 股数据客户端(财联社行情/搜索、智兔市场数据、东方财富新闻、新浪资金流、新浪指数行情)。
/// 由宿主层的 AddBusinessServices 调用;HttpClient 命名注册仍留在
/// 宿主的 AddHttpClientsCore(弹性管线集中在宿主配置),本层通过
/// IHttpClientFactory + 现有名字消费。
@@ -19,6 +19,7 @@ public static IServiceCollection AddAShareDataProviders(this IServiceCollection
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
return services;
}
}
\ No newline at end of file
diff --git a/src/MarketAssistant.DataProviders/AShare/SinaIndexQuoteClient.cs b/src/MarketAssistant.DataProviders/AShare/SinaIndexQuoteClient.cs
new file mode 100644
index 00000000..289e8d5e
--- /dev/null
+++ b/src/MarketAssistant.DataProviders/AShare/SinaIndexQuoteClient.cs
@@ -0,0 +1,85 @@
+using System.Globalization;
+
+namespace MarketAssistant.DataProviders.AShare;
+
+///
+/// 新浪指数简版行情(hq.sinajs.cn/list=s_*)单条解析结果。
+///
+/// 指数名称(如"上证指数")。
+/// 最新点位。
+/// 涨跌幅(百分数值,如 -3.97 表示 -3.97%)。
+public sealed record SinaIndexQuote(
+ string Name,
+ decimal Point,
+ decimal ChangePercent);
+
+///
+/// 新浪指数行情客户端:一次请求拉取 A 股三大指数(上证/深证成指/创业板指)。
+/// 仅负责 HTTP 访问与 var hq_str_* 文本解析(GBK 解码),不承载业务映射逻辑。
+///
+public sealed class SinaIndexQuoteClient
+{
+ /// 三大指数代码:上证指数、深证成指、创业板指。
+ private const string IndexList = "s_sh000001,s_sz399001,s_sz399006";
+
+ private const string QueryPath = "/list=" + IndexList;
+
+ private readonly IHttpClientFactory _httpClientFactory;
+
+ static SinaIndexQuoteClient()
+ {
+ // 新浪 hq 接口返回 GBK 编码,.NET 默认不支持,需注册 CodePagesEncodingProvider(幂等)。
+ System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
+ }
+
+ public SinaIndexQuoteClient(IHttpClientFactory httpClientFactory)
+ {
+ _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
+ }
+
+ ///
+ /// 拉取三大指数行情。整体失败或无数据时返回空列表(由上层顶栏降级隐藏)。
+ ///
+ public async Task> GetIndexesAsync(CancellationToken cancellationToken = default)
+ {
+ using var httpClient = _httpClientFactory.CreateClient("SinaHq");
+ using var stream = await httpClient.GetStreamAsync(QueryPath, cancellationToken);
+ using var reader = new StreamReader(stream, System.Text.Encoding.GetEncoding("gbk"));
+ var text = await reader.ReadToEndAsync(cancellationToken);
+
+ var result = new List();
+ // 每行形如:var hq_str_s_sh000001="上证指数,3094.668,-128.073,-3.97,264837,31490343";
+ foreach (var line in text.Split('\n', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var quote = ParseLine(line);
+ if (quote is not null)
+ result.Add(quote);
+ }
+
+ return result;
+ }
+
+ private static SinaIndexQuote? ParseLine(string line)
+ {
+ var open = line.IndexOf('"');
+ var close = line.LastIndexOf('"');
+ if (open < 0 || close <= open)
+ return null;
+
+ var payload = line[(open + 1)..close];
+ var fields = payload.Split(',');
+ // 需要 名称,点位,涨跌额,涨跌幅 至少 4 段
+ if (fields.Length < 4)
+ return null;
+
+ var name = fields[0].Trim();
+ if (name.Length == 0)
+ return null;
+
+ if (!decimal.TryParse(fields[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var point))
+ return null;
+ decimal.TryParse(fields[3], NumberStyles.Any, CultureInfo.InvariantCulture, out var changePercent);
+
+ return new SinaIndexQuote(name, point, changePercent);
+ }
+}
diff --git a/src/MarketAssistant.DataProviders/AlternativeMeClient.cs b/src/MarketAssistant.DataProviders/AlternativeMeClient.cs
new file mode 100644
index 00000000..32868df4
--- /dev/null
+++ b/src/MarketAssistant.DataProviders/AlternativeMeClient.cs
@@ -0,0 +1,82 @@
+using System.Globalization;
+using System.Text.Json.Serialization;
+
+namespace MarketAssistant.DataProviders;
+
+///
+/// alternative.me 恐惧贪婪指数响应(GET /fng)。免费、无需 API Key。
+///
+public sealed class FearGreedResponse
+{
+ [JsonPropertyName("data")]
+ public List? Data { get; set; }
+
+ [JsonPropertyName("status")]
+ public string? Status { get; set; }
+
+ [JsonPropertyName("error")]
+ public string? Error { get; set; }
+}
+
+///
+/// 恐惧贪婪指数单日条目。 为 0-100,越大越贪婪。
+///
+public sealed class FearGreedEntry
+{
+ [JsonPropertyName("value")]
+ public string? Value { get; set; }
+
+ [JsonPropertyName("value_classification")]
+ public string? Classification { get; set; }
+
+ [JsonPropertyName("timestamp")]
+ public long Timestamp { get; set; }
+}
+
+///
+/// alternative.me 恐惧贪婪指数客户端。仅负责 HTTP 访问与解析,失败时返回 null 交由上层降级。
+///
+public sealed class AlternativeMeClient
+{
+ private const string LatestPath = "/fng/";
+
+ private readonly IHttpClientFactory _httpClientFactory;
+
+ public AlternativeMeClient(IHttpClientFactory httpClientFactory)
+ {
+ _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
+ }
+
+ ///
+ /// 获取最新恐惧贪婪指数。网络或解析失败返回 null(不抛异常,顶栏据此省略该项)。
+ ///
+ public async Task<(int Value, string Classification)?> GetLatestAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ using var httpClient = _httpClientFactory.CreateClient("AlternativeMe");
+ var json = await httpClient.GetStringAsync(LatestPath, cancellationToken);
+ using var doc = JsonDocument.Parse(json);
+
+ if (!doc.RootElement.TryGetProperty("data", out var data)
+ || data.ValueKind != JsonValueKind.Array
+ || data.GetArrayLength() == 0)
+ {
+ return null;
+ }
+
+ var first = data[0];
+ var rawValue = first.TryGetProperty("value", out var v) ? v.GetString() : null;
+ var classification = first.TryGetProperty("value_classification", out var c) ? c.GetString() : null;
+
+ if (!int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
+ return null;
+
+ return (value, classification ?? string.Empty);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/src/MarketAssistant.DataProviders/CoinGeckoApiService.cs b/src/MarketAssistant.DataProviders/CoinGeckoApiService.cs
index 95f51caa..749a6d15 100644
--- a/src/MarketAssistant.DataProviders/CoinGeckoApiService.cs
+++ b/src/MarketAssistant.DataProviders/CoinGeckoApiService.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using System.Net.Http.Json;
using System.Threading.RateLimiting;
using System.Text.Json;
@@ -126,6 +127,40 @@ await httpClient.GetFromJsonAsync(
cancellationToken);
}
+ ///
+ /// 获取全网宏观指标(GET /global):总市值(USD)、24h 市值涨跌幅、BTC 主导率。
+ /// 一次请求即得顶栏行情条所需的 Crypto 市场级指标。失败/无数据返回 null(上层降级)。
+ ///
+ public async Task<(decimal TotalMarketCapUsd, decimal MarketCapChangePct24h, decimal BtcDominancePct)?>
+ GetGlobalStatsAsync(CancellationToken cancellationToken = default)
+ {
+ var json = await ThrottledExecuteAsync(async httpClient =>
+ await httpClient.GetStringAsync("global", cancellationToken),
+ cancellationToken);
+
+ using var doc = JsonDocument.Parse(json);
+ if (!doc.RootElement.TryGetProperty("data", out var data))
+ return null;
+
+ decimal totalCap = 0, capChange = 0, btcDominance = 0;
+
+ if (data.TryGetProperty("total_market_cap", out var tmc)
+ && tmc.TryGetProperty("usd", out var tmcUsd))
+ decimal.TryParse(tmcUsd.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out totalCap);
+
+ if (data.TryGetProperty("market_cap_change_percentage_24h_usd", out var mcc))
+ decimal.TryParse(mcc.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out capChange);
+
+ if (data.TryGetProperty("market_cap_percentage", out var mcp)
+ && mcp.TryGetProperty("btc", out var btc))
+ decimal.TryParse(btc.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out btcDominance);
+
+ if (totalCap == 0 && btcDominance == 0)
+ return null;
+
+ return (totalCap, capChange, btcDominance);
+ }
+
public async Task SearchCoinsAsync(
string query,
CancellationToken cancellationToken = default)
diff --git a/src/MarketAssistant.Trading/Abstractions/IExchangeClient.cs b/src/MarketAssistant.Trading/Abstractions/IExchangeClient.cs
index 9268952e..7c44ee3b 100644
--- a/src/MarketAssistant.Trading/Abstractions/IExchangeClient.cs
+++ b/src/MarketAssistant.Trading/Abstractions/IExchangeClient.cs
@@ -91,9 +91,19 @@ public class ExchangeOrderResult
{
public string Symbol { get; set; } = string.Empty;
public string OrderId { get; set; } = string.Empty;
+
+ ///
+ /// 客户端自定义订单 ID。条件单对账依赖它按前缀识别本应用挂出的订单。
+ ///
+ public string ClientOrderId { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string Side { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
+
+ ///
+ /// 条件单触发价(StopMarket/TakeProfitMarket)。对账时用于检测策略参数漂移。
+ ///
+ public decimal StopPrice { get; set; }
public decimal RequestedQty { get; set; }
public decimal ExecutedQty { get; set; }
public decimal Price { get; set; }
diff --git a/src/MarketAssistant.Trading/TradingStrategy.cs b/src/MarketAssistant.Trading/TradingStrategy.cs
index 1aef63d6..76e7e692 100644
--- a/src/MarketAssistant.Trading/TradingStrategy.cs
+++ b/src/MarketAssistant.Trading/TradingStrategy.cs
@@ -51,4 +51,10 @@ public class TradingStrategy
[Description("追踪止损的峰值/谷值价格")]
public decimal? TrailingPeakPrice { get; set; }
+
+ ///
+ /// 交易所侧保护性条件单的参数指纹(类型|方向|数量|触发价|回调基点)。
+ /// 对账时与策略当前参数比对,不一致则撤旧挂新;挂单成功后回写。
+ ///
+ public string? ConditionOrderFingerprint { get; set; }
}
diff --git a/tests/AnalysisAggregatorExecutorTest.cs b/tests/AnalysisAggregatorExecutorTest.cs
index 83d046c3..efe35f51 100644
--- a/tests/AnalysisAggregatorExecutorTest.cs
+++ b/tests/AnalysisAggregatorExecutorTest.cs
@@ -34,8 +34,8 @@ await DeliverAsync(aggregator, context,
Assert.AreEqual(1, sentBatches.Count, "凑齐成功+失败后应恰好派发一次");
var payload = sentBatches[0];
- // 载荷:系统指引 + 成功分析师摘要 + 维度缺失说明(P1-07 摘要化后多一条系统指引)
- Assert.AreEqual(3, payload.Count);
+ // 载荷:系统指引 + 成功分析师摘要 + 维度缺失说明 + 失败标记原样(渲染失败占位卡片)
+ Assert.AreEqual(4, payload.Count);
System.IO.File.WriteAllLines(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "agg-debug.txt"), payload.Select(m => $"author={m.AuthorName} text={m.Text}"));
StringAssert.Contains(payload[0].Text, "get_analyst_artifact", "首条应为工具使用指引");
StringAssert.Contains(payload[1].Text, "【NewsEventAnalyst】结论摘要", "成功条目应为摘要而非全文");
@@ -43,6 +43,7 @@ await DeliverAsync(aggregator, context,
StringAssert.Contains(payload[2].Text, AnalystFailureMessages.MissingDimensionNotePrefix);
StringAssert.Contains(payload[2].Text, "FundamentalAnalyst");
StringAssert.Contains(payload[2].Text, "模型超时");
+ StringAssert.Contains(payload[3].Text, "模型超时", "失败标记应原样附入载荷");
// 全文应已落盘,可按 runId+analyst 读回
var artifact = await store.GetAsync(TestRunId, "NewsEventAnalyst");
diff --git a/tests/Application/AlertSuppressionPolicyTest.cs b/tests/Application/AlertSuppressionPolicyTest.cs
index aa052e1d..53cbd057 100644
--- a/tests/Application/AlertSuppressionPolicyTest.cs
+++ b/tests/Application/AlertSuppressionPolicyTest.cs
@@ -158,6 +158,52 @@ public void Evaluate_NewQuotaWindow_ShouldResetCount()
Assert.AreEqual(now.AddHours(1).AddSeconds(1), later.NewQuotaState.WindowStartUtc);
}
+ [TestMethod]
+ [TestCategory("Unit")]
+ public void Evaluate_Suppressed_ShouldNotConsumeQuota_AndResumeAfterUnsuppressed()
+ {
+ var policy = new AlertSuppressionPolicy();
+ var now = DateTime.UtcNow;
+
+ // 静默期(休市/免打扰/关闭通知)内多条告警:只落库不弹窗,配额零消耗
+ var quota = AlertSuppressionPolicy.QuotaState.Empty;
+ for (var i = 0; i < 3; i++)
+ {
+ var decision = policy.Evaluate(
+ CreateAlert(title: $"静默告警{i}"), AlertSuppressionPolicy.MergeState.Empty, quota, 3,
+ now.AddMinutes(i), suppressNotification: true);
+ quota = decision.NewQuotaState;
+ Assert.IsFalse(decision.ShouldNotify, "静默告警不应弹窗");
+ }
+
+ Assert.AreEqual(0, quota.Count, "静默告警不应消耗配额");
+
+ // 静默解除后,完整配额立即可用(合并窗口按各自键独立,不受影响)
+ var resumed = policy.Evaluate(
+ CreateAlert(title: "交易时段告警"), AlertSuppressionPolicy.MergeState.Empty, quota, 3,
+ now.AddMinutes(10));
+
+ Assert.IsTrue(resumed.ShouldNotify, "静默解除后配额未被占用,应可正常触达");
+ Assert.AreEqual(1, resumed.NewQuotaState.Count);
+ }
+
+ [TestMethod]
+ [TestCategory("Unit")]
+ public void Evaluate_SuppressedCritical_ShouldNotConsumeQuota()
+ {
+ var policy = new AlertSuppressionPolicy();
+ var now = DateTime.UtcNow;
+
+ // Critical 原不受配额限制,但被抑制时同样不弹窗不计数(口径统一)
+ var decision = policy.Evaluate(
+ CreateAlert(level: AlertLevel.Critical, title: "静默紧急告警"),
+ AlertSuppressionPolicy.MergeState.Empty, AlertSuppressionPolicy.QuotaState.Empty, 5,
+ now, suppressNotification: true);
+
+ Assert.IsFalse(decision.ShouldNotify);
+ Assert.AreEqual(0, decision.NewQuotaState.Count);
+ }
+
[TestMethod]
[TestCategory("Unit")]
public void IsSilencedByTradingSession_ASharePriceWarning_ShouldSilence()
diff --git a/tests/Application/PriceAlertRuleTest.cs b/tests/Application/PriceAlertRuleTest.cs
index fe264807..163e5c22 100644
--- a/tests/Application/PriceAlertRuleTest.cs
+++ b/tests/Application/PriceAlertRuleTest.cs
@@ -122,7 +122,6 @@ public void OneTimeRule_ShouldExposeReenableStatus()
IsOneTime = true
};
- Assert.AreEqual("一次性", rule.AlertModeText);
Assert.AreEqual("禁用", rule.StatusText);
rule.Enabled = true;
@@ -141,7 +140,6 @@ public void ContinuousRule_ShouldExposeContinuousMode()
IsOneTime = false
};
- Assert.AreEqual("持续", rule.AlertModeText);
Assert.AreEqual("禁用", rule.StatusText);
rule.Enabled = false;
diff --git a/tests/Trading/CryptoPortfolioServiceFuturesTest.cs b/tests/Trading/CryptoPortfolioServiceFuturesTest.cs
new file mode 100644
index 00000000..3ee9fb1c
--- /dev/null
+++ b/tests/Trading/CryptoPortfolioServiceFuturesTest.cs
@@ -0,0 +1,96 @@
+using MarketAssistant.Services.Trading;
+using MarketAssistant.Trading.Abstractions;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+
+namespace TestMarketAssistant.Trading;
+
+///
+/// CryptoPortfolioService 合约持仓映射测试:
+/// 验证多空归一为绝对数量、方向性浮盈、杠杆 ROE 口径与查询异常兜底。
+///
+[TestClass]
+public sealed class CryptoPortfolioServiceFuturesTest
+{
+ private static Mock CreateService(IExchangeClient exchangeClient)
+ {
+ // 沿用 AISignalHardBoundaryTest 的部分模拟模式:构造依赖传 null,CallBase 走真实逻辑
+ return new Mock(
+ exchangeClient, null!, null!, null!, null!, NullLogger.Instance)
+ {
+ CallBase = true
+ };
+ }
+
+ private static Mock CreateFuturesClient(List positions)
+ {
+ var client = new Mock();
+ client.SetupGet(c => c.IsFutures).Returns(true);
+ client.Setup(c => c.GetPositionsAsync(null, It.IsAny()))
+ .ReturnsAsync(positions);
+ return client;
+ }
+
+ [TestMethod]
+ [TestCategory("Unit")]
+ public async Task Futures_LongAndShort_ShouldNormalizeQuantity_AndDirectionalPnlWithLeverage()
+ {
+ var positions = new List
+ {
+ // 多头:数量 2,开仓 100,标记 110,10 倍杠杆
+ new() { Symbol = "BTCUSDT", PositionAmt = 2m, EntryPrice = 100m, MarkPrice = 110m, Leverage = 10m },
+ // 空头:数量 -1,开仓 50,标记 45,5 倍杠杆
+ new() { Symbol = "ETHUSDT", PositionAmt = -1m, EntryPrice = 50m, MarkPrice = 45m, Leverage = 5m },
+ // 数量为 0 与无符号的持仓应被过滤
+ new() { Symbol = "ZEROUSDT", PositionAmt = 0m, EntryPrice = 1m, MarkPrice = 1m, Leverage = 1m },
+ new() { Symbol = "", PositionAmt = 1m, EntryPrice = 1m, MarkPrice = 1m, Leverage = 1m },
+ };
+ var service = CreateService(CreateFuturesClient(positions).Object);
+
+ var result = await service.Object.GetCurrentPositionsAsync();
+
+ Assert.AreEqual(2, result.Count, "零数量与空符号持仓应被过滤");
+
+ var longPosition = result.Single(p => p.Symbol == "BTCUSDT");
+ Assert.AreEqual(2m, longPosition.Quantity, "多头数量应归一为绝对值");
+ Assert.AreEqual(110m, longPosition.CurrentPrice, "标记价格有效时应使用标记价格");
+ Assert.AreEqual(20m, longPosition.UnrealizedPnl, "多头浮盈 = (110-100) × 2 × 1");
+ Assert.AreEqual(100m, longPosition.UnrealizedPnlPercent, "多头 ROE = 价格涨幅 10% × 10 倍杠杆");
+
+ var shortPosition = result.Single(p => p.Symbol == "ETHUSDT");
+ Assert.AreEqual(1m, shortPosition.Quantity, "空头数量应归一为绝对值");
+ Assert.AreEqual(5m, shortPosition.UnrealizedPnl, "空头浮盈 = (45-50) × 1 × (-1)");
+ Assert.AreEqual(50m, shortPosition.UnrealizedPnlPercent, "空头 ROE = 价格跌幅 10% × 5 倍杠杆 × (-1) 方向");
+ }
+
+ [TestMethod]
+ [TestCategory("Unit")]
+ public async Task Futures_ZeroLeverage_ShouldFallBackTo1x()
+ {
+ var positions = new List
+ {
+ new() { Symbol = "BTCUSDT", PositionAmt = 1m, EntryPrice = 100m, MarkPrice = 110m, Leverage = 0m },
+ };
+ var service = CreateService(CreateFuturesClient(positions).Object);
+
+ var result = await service.Object.GetCurrentPositionsAsync();
+
+ var position = result.Single();
+ Assert.AreEqual(10m, position.UnrealizedPnlPercent, "交易所未返回杠杆时应按 1 倍兜底,百分比 = (110-100)/100 × 100");
+ }
+
+ [TestMethod]
+ [TestCategory("Unit")]
+ public async Task Futures_QueryFails_ShouldReturnEmptyList()
+ {
+ var client = new Mock();
+ client.SetupGet(c => c.IsFutures).Returns(true);
+ client.Setup(c => c.GetPositionsAsync(null, It.IsAny()))
+ .ThrowsAsync(new System.Net.Http.HttpRequestException("network down"));
+ var service = CreateService(client.Object);
+
+ var result = await service.Object.GetCurrentPositionsAsync();
+
+ Assert.AreEqual(0, result.Count, "合约持仓查询失败应返回空视图而非抛出");
+ }
+}
diff --git a/tests/Trading/ExchangeConditionOrderServiceTest.cs b/tests/Trading/ExchangeConditionOrderServiceTest.cs
new file mode 100644
index 00000000..98aa1705
--- /dev/null
+++ b/tests/Trading/ExchangeConditionOrderServiceTest.cs
@@ -0,0 +1,307 @@
+using MarketAssistant.Applications.Settings;
+using MarketAssistant.Infrastructure.Factories;
+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;
+
+///
+/// 交易所侧保护性条件单测试:
+/// 覆盖缺失补挂、孤儿单撤销、参数漂移重建、追踪未激活不挂单、
+/// 现货短路,以及 TradeExecutor 条件单规格映射与 Pending 记录落库。
+///
+[TestClass]
+public sealed class ExchangeConditionOrderServiceTest
+{
+ private const string Symbol = "BTCUSDT";
+
+ private sealed class ThrowingMarketMonitorProvider : IMarketMonitorProvider
+ {
+ public MarketMonitor GetMonitor()
+ => throw new InvalidOperationException("条件单测试中不应创建 MarketMonitor");
+ }
+
+ private static (
+ ExchangeConditionOrderService Service,
+ Mock Client,
+ Mock Executor,
+ Mock Strategies,
+ Mock Data) CreateContext(bool futures)
+ {
+ var mode = futures ? CryptoTradingMode.BinanceFuturesTestnet : CryptoTradingMode.BinanceSpotDemo;
+ var settingService = new Mock();
+ settingService.SetupGet(s => s.CurrentSetting).Returns(new UserSetting { CryptoTradingMode = mode });
+
+ var environment = new TradingEnvironmentService(
+ settingService.Object,
+ new ThrowingMarketMonitorProvider(),
+ NullLogger.Instance);
+
+ var client = new Mock();
+ client.SetupGet(c => c.IsFutures).Returns(futures);
+
+ var router = new RoutingExchangeClient(
+ environment,
+ new Dictionary { [mode] = client.Object });
+
+ var data = new Mock(environment, NullLogger.Instance);
+ var strategies = new Mock(data.Object);
+ var executor = new Mock(
+ client.Object, null!, data.Object, NullLogger.Instance, environment, null!)
+ { CallBase = true };
+
+ var service = new ExchangeConditionOrderService(
+ strategies.Object, executor.Object, data.Object, router, environment,
+ NullLogger.Instance);
+
+ return (service, client, executor, strategies, data);
+ }
+
+ private static TradingStrategy CreateStopLossStrategy(string id = "stoploss-1") => new()
+ {
+ Id = id,
+ Symbol = Symbol,
+ Type = StrategyType.StopLoss,
+ Status = StrategyStatus.Active,
+ Side = OrderSide.Sell,
+ TriggerPrice = 90m,
+ Quantity = 1m
+ };
+
+ private static ExchangeOrderResult CreateManagedOrder(string strategyId, decimal stopPrice = 90m) => new()
+ {
+ Symbol = Symbol,
+ OrderId = "999",
+ ClientOrderId = TradeExecutor.BuildConditionOrderClientOrderId(strategyId),
+ Status = "NEW",
+ Type = "STOP_MARKET",
+ StopPrice = stopPrice,
+ RequestedQty = 1m
+ };
+
+ [TestMethod]
+ [TestCategory("Unit")]
+ public async Task Futures_MissingProtectiveOrder_ShouldPlace_AndStoreFingerprint()
+ {
+ var ctx = CreateContext(futures: true);
+ var strategy = CreateStopLossStrategy();
+ ctx.Strategies
+ .Setup(s => s.GetStrategiesByStatusAsync(StrategyStatus.Active, It.IsAny()))
+ .ReturnsAsync([strategy]);
+ ctx.Client
+ .Setup(c => c.GetOpenOrdersAsync(null, It.IsAny()))
+ .ReturnsAsync([]);
+ var placedRecord = new TradeRecord { ExchangeOrderId = 777, Status = TradeRecordStatus.Pending };
+ ctx.Executor
+ .Setup(e => e.PlaceConditionOrderAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new TradeResult { Success = true, Record = placedRecord });
+
+ await ctx.Service.ReconcileAsync(CancellationToken.None);
+
+ ctx.Executor.Verify(
+ e => e.PlaceConditionOrderAsync(strategy, It.IsAny()),
+ Times.Once, "缺失的保护策略条件单应补挂");
+ ctx.Data.Verify(
+ d => d.UpdateStrategyConditionOrderFingerprintAsync(
+ strategy.Id, ExchangeConditionOrderService.BuildFingerprint(strategy), It.IsAny()),
+ Times.Once, "挂单成功后应回写参数指纹");
+ Assert.IsTrue(ctx.Service.HasLiveOrder(strategy.Id), "挂单成功后在途映射应可见");
+ }
+
+ [TestMethod]
+ [TestCategory("Unit")]
+ public async Task Futures_OrphanOrder_ShouldCancel()
+ {
+ var ctx = CreateContext(futures: true);
+ var orphan = CreateManagedOrder("deleted-strategy");
+ ctx.Strategies
+ .Setup(s => s.GetStrategiesByStatusAsync(StrategyStatus.Active, It.IsAny()))
+ .ReturnsAsync([]);
+ ctx.Client
+ .Setup(c => c.GetOpenOrdersAsync(null, It.IsAny()))
+ .ReturnsAsync([orphan]);
+
+ await ctx.Service.ReconcileAsync(CancellationToken.None);
+
+ ctx.Client.Verify(
+ c => c.CancelOrderAsync(Symbol, orphan.OrderId, It.IsAny()),
+ Times.Once, "非活跃策略的残留条件单应作为孤儿单撤销");
+ Assert.IsFalse(ctx.Service.HasLiveOrder("deleted-strategy"));
+ }
+
+ [TestMethod]
+ [TestCategory("Unit")]
+ public async Task Futures_MatchedOrder_SameFingerprint_ShouldSkipPlace()
+ {
+ var ctx = CreateContext(futures: true);
+ var strategy = CreateStopLossStrategy();
+ strategy.ConditionOrderFingerprint = ExchangeConditionOrderService.BuildFingerprint(strategy);
+ var managedOrder = CreateManagedOrder(strategy.Id);
+ ctx.Strategies
+ .Setup(s => s.GetStrategiesByStatusAsync(StrategyStatus.Active, It.IsAny()))
+ .ReturnsAsync([strategy]);
+ ctx.Client
+ .Setup(c => c.GetOpenOrdersAsync(null, It.IsAny()))
+ .ReturnsAsync([managedOrder]);
+
+ await ctx.Service.ReconcileAsync(CancellationToken.None);
+
+ ctx.Executor.Verify(
+ e => e.PlaceConditionOrderAsync(It.IsAny(), It.IsAny()),
+ Times.Never, "挂单在位且指纹一致时不应重复挂单");
+ ctx.Client.Verify(
+ c => c.CancelOrderAsync(It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never, "挂单在位且指纹一致时不应撤单");
+ Assert.IsTrue(ctx.Service.HasLiveOrder(strategy.Id), "在途挂单应记入映射供客户端执行跳过");
+ }
+
+ [TestMethod]
+ [TestCategory("Unit")]
+ public async Task Futures_FingerprintDrift_ShouldCancelAndReorder()
+ {
+ var ctx = CreateContext(futures: true);
+ var strategy = CreateStopLossStrategy();
+ strategy.ConditionOrderFingerprint = "Stale|Fingerprint";
+ var managedOrder = CreateManagedOrder(strategy.Id);
+ ctx.Strategies
+ .Setup(s => s.GetStrategiesByStatusAsync(StrategyStatus.Active, It.IsAny()))
+ .ReturnsAsync([strategy]);
+ ctx.Client
+ .Setup(c => c.GetOpenOrdersAsync(null, It.IsAny()))
+ .ReturnsAsync([managedOrder]);
+ ctx.Executor
+ .Setup(e => e.PlaceConditionOrderAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new TradeResult { Success = true, Record = new TradeRecord { ExchangeOrderId = 888 } });
+
+ await ctx.Service.ReconcileAsync(CancellationToken.None);
+
+ ctx.Client.Verify(
+ c => c.CancelOrderAsync(Symbol, managedOrder.OrderId, It.IsAny()),
+ Times.Once, "参数漂移时应撤销旧条件单");
+ ctx.Executor.Verify(
+ e => e.PlaceConditionOrderAsync(strategy, It.IsAny()),
+ Times.Once, "参数漂移撤旧后应按新参数重挂");
+ ctx.Data.Verify(
+ d => d.UpdateStrategyConditionOrderFingerprintAsync(
+ strategy.Id, It.IsAny(), It.IsAny()),
+ Times.Once);
+ }
+
+ [TestMethod]
+ [TestCategory("Unit")]
+ public async Task Futures_TrailingStop_NotActivated_ShouldNotPlace()
+ {
+ var ctx = CreateContext(futures: true);
+ var strategy = new TradingStrategy
+ {
+ Id = "trailing-1",
+ Symbol = Symbol,
+ Type = StrategyType.TrailingStop,
+ Status = StrategyStatus.Active,
+ Side = OrderSide.Sell,
+ Quantity = 1m,
+ // TrailingPeakPrice 为空 = 追踪尚未激活
+ };
+ ctx.Strategies
+ .Setup(s => s.GetStrategiesByStatusAsync(StrategyStatus.Active, It.IsAny()))
+ .ReturnsAsync([strategy]);
+ ctx.Client
+ .Setup(c => c.GetOpenOrdersAsync(null, It.IsAny()))
+ .ReturnsAsync([]);
+
+ await ctx.Service.ReconcileAsync(CancellationToken.None);
+
+ ctx.Executor.Verify(
+ e => e.PlaceConditionOrderAsync(It.IsAny(), It.IsAny()),
+ Times.Never, "追踪未激活时提前挂交易所追踪单会过早止损,不应挂单");
+ }
+
+ [TestMethod]
+ [TestCategory("Unit")]
+ public async Task Spot_ShouldShortCircuit_WithoutPlacing()
+ {
+ var ctx = CreateContext(futures: false);
+ ctx.Strategies
+ .Setup(s => s.GetStrategiesByStatusAsync(StrategyStatus.Active, It.IsAny()))
+ .ReturnsAsync([CreateStopLossStrategy()]);
+
+ await ctx.Service.ReconcileAsync(CancellationToken.None);
+
+ ctx.Executor.Verify(
+ e => e.PlaceConditionOrderAsync(It.IsAny(), It.IsAny()),
+ Times.Never, "现货无条件单语义,退出型策略由客户端评估兜底");
+ ctx.Client.Verify(
+ c => c.GetOpenOrdersAsync(It.IsAny(), It.IsAny()),
+ Times.Never, "现货模式不应查询交易所挂单");
+ }
+
+ [TestMethod]
+ [TestCategory("Unit")]
+ public async Task PlaceConditionOrderAsync_StopLoss_ShouldPlaceReduceOnlyStopMarket_AndSavePendingRecord()
+ {
+ var ctx = CreateContext(futures: true);
+ var strategy = CreateStopLossStrategy();
+ ctx.Client
+ .Setup(c => c.PlaceOrderAsync(
+ Symbol, OrderSide.Sell, OrderType.StopMarket, 1m,
+ null, TradeExecutor.BuildConditionOrderClientOrderId(strategy.Id),
+ true, null, 90m, null, It.IsAny()))
+ .ReturnsAsync(new ExchangeOrderResult { OrderId = "123", Status = "NEW" });
+
+ var result = await ctx.Executor.Object.PlaceConditionOrderAsync(strategy);
+
+ Assert.IsTrue(result.Success, "止损策略应成功挂出 StopMarket 条件单");
+ Assert.IsNotNull(result.Record);
+ Assert.AreEqual(TradeRecordStatus.Pending, result.Record.Status, "条件单挂出即为待成交");
+ Assert.AreEqual(123, result.Record.ExchangeOrderId);
+ ctx.Data.Verify(
+ d => d.SaveTradeRecordAsync(It.Is(r =>
+ r.StrategyId == strategy.Id && r.Status == TradeRecordStatus.Pending),
+ It.IsAny()),
+ Times.Once, "挂单成功应落 Pending 交易记录供既有对账管线感知成交");
+ }
+
+ [TestMethod]
+ [TestCategory("Unit")]
+ public async Task PlaceConditionOrderAsync_TakeProfitBuySide_ShouldReject()
+ {
+ var ctx = CreateContext(futures: true);
+ var strategy = CreateStopLossStrategy();
+ strategy.Type = StrategyType.TakeProfit;
+ strategy.Side = OrderSide.Buy;
+
+ var result = await ctx.Executor.Object.PlaceConditionOrderAsync(strategy);
+
+ Assert.IsFalse(result.Success, "止盈 Buy 侧为限价建仓语义,不属于保护性条件单");
+ ctx.Client.Verify(
+ c => c.PlaceOrderAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [TestMethod]
+ [TestCategory("Unit")]
+ public async Task PlaceConditionOrderAsync_MissingTriggerPrice_ShouldRejectWithoutExchangeCall()
+ {
+ var ctx = CreateContext(futures: true);
+ var strategy = CreateStopLossStrategy();
+ strategy.TriggerPrice = 0;
+
+ var result = await ctx.Executor.Object.PlaceConditionOrderAsync(strategy);
+
+ Assert.IsFalse(result.Success, "缺少触发价的止损策略无法挂条件单");
+ ctx.Client.Verify(
+ c => c.PlaceOrderAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never, "规格校验失败不应触达交易所");
+ }
+}
diff --git a/tests/Trading/StrategyEngineTriggerTest.cs b/tests/Trading/StrategyEngineTriggerTest.cs
index 9d79abcc..d1fd0249 100644
--- a/tests/Trading/StrategyEngineTriggerTest.cs
+++ b/tests/Trading/StrategyEngineTriggerTest.cs
@@ -85,7 +85,9 @@ public async Task StopLoss_SellSide_TriggersBelowTriggerPrice()
Assert.AreEqual(0, above.Count, "高于触发价时不应触发卖出止损");
Assert.AreEqual(1, below.Count, "跌破触发价应触发止损");
- Assert.AreEqual(OrderSide.Sell, below[0].Side);
+ Assert.AreEqual(strategy, below[0].Strategy, "策略本体应保持引用一致");
+ Assert.AreEqual(OrderSide.Sell, below[0].EffectiveSide);
+ Assert.IsTrue(below[0].RequireClose, "止损为纯退出语义");
}
[TestMethod]
@@ -102,7 +104,8 @@ public async Task TakeProfit_SellSide_TriggersAboveTriggerPrice()
Assert.AreEqual(0, below.Count, "低于触发价时不应触发止盈");
Assert.AreEqual(1, above.Count, "涨破触发价应触发止盈");
- Assert.AreEqual(OrderSide.Sell, above[0].Side);
+ Assert.AreEqual(OrderSide.Sell, above[0].EffectiveSide);
+ Assert.IsTrue(above[0].RequireClose, "卖出止盈为获利了结(平仓语义)");
}
[TestMethod]
@@ -119,7 +122,8 @@ public async Task TrailingStop_MissingParams_FallsBackToProfileDefault_AndTrigge
Assert.AreEqual(0, first.Count, "首次评估仅记录峰值,不应触发");
Assert.AreEqual(1, second.Count, "从峰值回撤 6% > 5% 回退比例应触发");
- Assert.AreEqual(OrderSide.Sell, second[0].Side);
+ Assert.AreEqual(OrderSide.Sell, second[0].EffectiveSide);
+ Assert.IsTrue(second[0].RequireClose, "追踪止损为纯退出语义");
ctx.Data.Verify(
data => data.UpdateStrategyTrailingPeakAsync(strategy.Id, 100m, It.IsAny()),
Times.Once, "峰值 100 应被持久化");
@@ -180,17 +184,17 @@ public async Task Grid_CrossingLines_TriggersBuyBelow_AndSellAbove()
.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 downSide = downCount > 0 ? down[0].EffectiveSide : default(OrderSide);
+ var downQty = downCount > 0 ? down[0].EffectiveQuantity : 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, "上穿网格线应卖出");
+ Assert.AreEqual(OrderSide.Sell, up[0].EffectiveSide, "上穿网格线应卖出");
+ Assert.AreSame(strategy, down[0].Strategy, "策略本体不应被改写,仅有效字段承载触发值");
}
[TestMethod]
@@ -216,8 +220,10 @@ public async Task DCA_NoPosition_TriggersBuyAfterInterval()
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");
+ Assert.AreEqual(OrderSide.Buy, triggered[0].EffectiveSide);
+ Assert.AreEqual(1m, triggered[0].EffectiveQuantity, "100 USDT ÷ 100 价格 = 1 BTC");
+ Assert.IsFalse(triggered[0].RequireClose, "DCA 建仓买入不是出场语义");
+ Assert.AreEqual(OrderSide.Buy, strategy.Side, "策略本体 Side 不应被引擎改写");
}
[TestMethod]
@@ -247,8 +253,8 @@ public async Task DCA_TakeProfitReached_SellsFullRemainingPosition()
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)");
+ Assert.AreEqual(OrderSide.Sell, triggered[0].EffectiveSide, "止盈应卖出");
+ Assert.AreEqual(1.5m, triggered[0].EffectiveQuantity, "应卖出剩余持仓(2 - 0.5)");
}
[TestMethod]
@@ -310,7 +316,43 @@ public async Task DCA_StopLossReached_WithSellOut_SellsFullPosition()
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);
+ Assert.AreEqual(OrderSide.Sell, triggered[0].EffectiveSide);
+ Assert.AreEqual(1m, triggered[0].EffectiveQuantity);
+ }
+
+ [TestMethod]
+ [TestCategory("Unit")]
+ public async Task AISignal_ConfiguredInterval_GatesEvaluation()
+ {
+ var ctx = CreateEngine();
+ var aiParams = new AISignalParams { AnalysisIntervalSeconds = 3600 };
+ var strategy = CreateStrategy(StrategyType.AISignal, OrderSide.Buy, JsonSerializer.Serialize(aiParams));
+ strategy.LastTriggeredAt = DateTime.UtcNow.AddSeconds(-120);
+ SetupStrategies(ctx, strategy);
+
+ var triggered = await ctx.Engine.EvaluateAndUpdateStrategiesAsync(Symbol, 100m);
+
+ Assert.AreEqual(0, triggered.Count, "距上次评估 120s < 配置间隔 3600s,不应触发");
+ ctx.Data.Verify(
+ data => data.UpdateStrategyLastTriggeredAtAsync(strategy.Id, It.IsAny()),
+ Times.Never, "未触发时不应更新节流时间");
+ }
+
+ [TestMethod]
+ [TestCategory("Unit")]
+ public async Task AISignal_ElapsedBeyondConfiguredInterval_Triggers()
+ {
+ var ctx = CreateEngine();
+ var aiParams = new AISignalParams { AnalysisIntervalSeconds = 60 };
+ var strategy = CreateStrategy(StrategyType.AISignal, OrderSide.Buy, JsonSerializer.Serialize(aiParams));
+ strategy.LastTriggeredAt = DateTime.UtcNow.AddSeconds(-120);
+ SetupStrategies(ctx, strategy);
+
+ var triggered = await ctx.Engine.EvaluateAndUpdateStrategiesAsync(Symbol, 100m);
+
+ Assert.AreEqual(1, triggered.Count, "距上次评估 120s > 配置间隔 60s,应触发");
+ ctx.Data.Verify(
+ data => data.UpdateStrategyLastTriggeredAtAsync(strategy.Id, It.IsAny()),
+ Times.Once, "触发时应立即持久化节流时间");
}
}
\ No newline at end of file