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。

MarketAssistant Design System · 配色预览 v2.4 · 与 Colors.axaml / kline_chart.html 实现同步 · 2026-08
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/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