diff --git a/docs/plan/alert-center-refactor-plan.md b/docs/plan/alert-center-refactor-plan.md index 495635d..7b778e1 100644 --- a/docs/plan/alert-center-refactor-plan.md +++ b/docs/plan/alert-center-refactor-plan.md @@ -1,6 +1,9 @@ # 统一告警中心(AlertCenter)重构方案 -> 状态:待审查。审查通过后再择机实施,本文档仅作设计留存,未做任何代码改动。 +> 状态:已在分支 `refactor/alert-center` 实施。价格告警去抖/冷却/限次、告警落库与历史页、 +> 未读徽标、风险与行情连接告警、确认级交易联动门(`IAlertGate`)均已接入, +> 单元测试见 `tests/Application/AlertCenterTest.cs`、`AlertSuppressionPolicyTest.cs`、 +> `AlertGateTest.cs`、`PriceAlertRuleAdvancedTest.cs`。 ## 背景与现状 @@ -36,7 +39,7 @@ - `ConfirmTicks` / `ConfirmSeconds`(去抖确认期:条件需持续成立才触发); - `CooldownMinutes`(冷却期:触发后 M 分钟内不重复); - `TradingImpact`(枚举:None / RequireConfirmation,本期仅这两种)。 -- `AlertDedupeState`:内存态去抖/冷却状态,不落库,重启重置可接受。 +- 去抖/冷却状态:内存态,不落库,重启重置可接受。实现为 `AlertSuppressionPolicy.MergeState`(按去重键)与 `QuotaState`(全局配额窗口),未采用原计划的单一 `AlertDedupeState` 命名——合并状态按去重键、配额状态全局共享,生命周期不同,拆开后纯逻辑可直接单测。 ## 三、AlertCenterService @@ -44,8 +47,8 @@ - 抑制逻辑: - 冷却期内同类告警合并; - 全局每小时配额(默认 20 条),超限只落库不弹窗;Critical 不受限,但同类 5 分钟内合并为一条"发生 N 次"; - - 静默窗口:A 股非交易时段不评估价格类告警(复用现有交易时段判断),Critical 例外。 -- 触达:`AlertRaised` → `INotificationService` 弹窗;新增 `AlertCenterPageView` + ViewModel 呈现历史。 + - 静默窗口: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` 增配置项。 ## 四、迁移现有价格告警 @@ -58,13 +61,17 @@ - 挂接在 `MarketMonitor` 价格消费管线中,与 AI 信号评估同级,**不侵入 `RiskManager`**。 - 触发项: - 持仓回撤达到熔断阈值的一定比例(如 80%)→ Warning; - - AI 信号被风控 Reject / RequireConfirmation → 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),弹窗文案说明"因告警触发需确认"。 +- `TradeExecutor` 在风控校验后、确认环节前查询 `IAlertGate`:命中则强制走 `ConfirmationCallback` 人工确认(即使风控结果为 Pass),弹窗文案说明"因告警触发需确认";无确认订阅者时拒绝下单而非放行。 +- 实现补充(原计划未列): + - **保护性平仓豁免**:`requireClose = true`(止损 / 止盈 / 追踪止损等退出型触发)不受门限制,避免"因告警反而无法及时离场"这一更危险的结果; + - **门判定当前仅覆盖 Crypto**:`TradeExecutor` 本身是 Crypto keyed 服务,A 股侧尚无自动交易链路,A 股告警门仅用于 UI 呈现。 - 原则:**告警事件 ≠ 交易指令**,不自动下单、不修改 `RiskManager`。 ## 七、接线 @@ -75,6 +82,35 @@ ## 八、验证与实施顺序 - `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/src/MarketAssistant.Agents/Analysts/AnalystFailureMessages.cs b/src/MarketAssistant.Agents/Analysts/AnalystFailureMessages.cs index 87e1f83..87ebbbe 100644 --- a/src/MarketAssistant.Agents/Analysts/AnalystFailureMessages.cs +++ b/src/MarketAssistant.Agents/Analysts/AnalystFailureMessages.cs @@ -37,6 +37,48 @@ public static string BuildMissingDimensionNote(IReadOnlyList failedAnaly => $"{MissingDimensionNotePrefix} 以下分析师本次执行失败,其维度结论缺失:{string.Join(";", failedAnalystDescriptions)}。" + "综合报告必须明确标注该数据局限,不得虚构缺失维度的结论。"; + /// + /// 从失败标记文本中提取失败分析师的 Agent 名称(ASCII 名,如 FundamentalAnalyst)。 + /// 标记格式见 :前缀 + 空格 + "AgentName: reason"; + /// 流式失败时标记前可能拼接了部分正文,故先定位标记再取其后段落。 + /// 非失败标记文本返回 null。 + /// + public static string? ExtractAgentName(string? markerText) + { + if (string.IsNullOrWhiteSpace(markerText) || !IsFailureMarker(markerText)) + return null; + + var afterMarker = SliceAfterMarker(markerText); + var separatorIndex = afterMarker.IndexOf(": ", StringComparison.Ordinal); + return separatorIndex >= 0 ? afterMarker[..separatorIndex] : afterMarker; + } + + /// + /// 从失败标记文本中提取失败原因(不含 Agent 名称); + /// 非失败标记文本原样返回,空文本返回「未知错误」。 + /// + public static string ExtractFailureReason(string? markerText) + { + if (string.IsNullOrEmpty(markerText)) + return "未知错误"; + + if (!IsFailureMarker(markerText)) + return markerText; + + var afterMarker = SliceAfterMarker(markerText); + var separatorIndex = afterMarker.IndexOf(": ", StringComparison.Ordinal); + return separatorIndex >= 0 ? afterMarker[(separatorIndex + 2)..] : afterMarker; + } + + /// + /// 截取失败标记前缀之后(并去除起始空白)的文本段。 + /// + private static string SliceAfterMarker(string markerText) + { + var markerIndex = markerText.IndexOf(FailureMarkerPrefix, StringComparison.Ordinal); + return markerText[(markerIndex + FailureMarkerPrefix.Length)..].TrimStart(); + } + private static string NormalizeReason(string reason) { var normalized = reason.ReplaceLineEndings(" ").Trim(); diff --git a/src/MarketAssistant.Agents/Tools/Abstractions/IOnChainTools.cs b/src/MarketAssistant.Agents/Tools/Abstractions/IOnChainTools.cs new file mode 100644 index 0000000..f4e65ff --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Abstractions/IOnChainTools.cs @@ -0,0 +1,11 @@ +using MarketAssistant.Agents.Tools.Abstractions; + +namespace MarketAssistant.Agents.Tools.Abstractions; + +/// +/// 链上数据工具的 DI 分发标记接口,用于 [RequiresTools] 声明和 Keyed DI 注册。 +/// 本身不定义业务方法,具体 API 由市场实现类提供(虚拟币:CryptoOnChainTools)。 +/// +public interface IOnChainTools : IToolsProvider +{ +} \ No newline at end of file diff --git a/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoProjectInfo.cs b/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoProjectInfo.cs index 6caf9c7..fe0bd9d 100644 --- a/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoProjectInfo.cs +++ b/src/MarketAssistant.Agents/Tools/Models/Crypto/CryptoProjectInfo.cs @@ -20,6 +20,9 @@ public class CryptoProjectInfo [Description("其他多平台标识ID")] public Dictionary AlternativeIds { get; set; } = new(); + [Description("该代币在各区块链上的合约地址(键为平台 ID,如 ethereum、binance-smart-chain;来自 CoinGecko)。多链代币会包含多条;用于确定审计与链上查询所需的链与合约")] + public Dictionary ContractAddresses { get; set; } = new(); + [Description("简短项目描述")] public string DescriptionSnippet { get; set; } = string.Empty; diff --git a/src/MarketAssistant.Agents/Tools/Models/Crypto/OnChainModels.cs b/src/MarketAssistant.Agents/Tools/Models/Crypto/OnChainModels.cs new file mode 100644 index 0000000..02dcc96 --- /dev/null +++ b/src/MarketAssistant.Agents/Tools/Models/Crypto/OnChainModels.cs @@ -0,0 +1,135 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Infrastructure.Core; +using System.ComponentModel; + +namespace MarketAssistant.Agents.Tools.Models.Crypto; + +/// +/// 链上工具返回模型:DEX 热门交易对、代币安全审计、蜜罐检测与大额转账信号。 +/// + +[Description("DEX 热门交易对信息")] +public class HotTokenPair +{ + [Description("链 ID(如 eth、bsc、solana)")] + public string ChainId { get; set; } = string.Empty; + + [Description("代币符号")] + public string Symbol { get; set; } = string.Empty; + + [Description("代币全名")] + public string TokenName { get; set; } = string.Empty; + + [Description("代币合约地址")] + public string TokenAddress { get; set; } = string.Empty; + + [Description("当前价格(USD)")] + public decimal? PriceUsd { get; set; } + + [Description("24 小时涨跌幅(%)")] + public decimal? Change24hPercent { get; set; } + + [Description("24 小时成交额(USD)")] + public decimal? Volume24hUsd { get; set; } + + [Description("流动性池规模(USD)")] + public decimal? LiquidityUsd { get; set; } + + [Description("市值(USD)")] + public decimal? MarketCapUsd { get; set; } + + [Description("交易对创建时间")] + public DateTime? PairCreatedAt { get; set; } + + [Description("所属交易所/DEX")] + public string DexId { get; set; } = string.Empty; +} + +[Description("代币安全审计结果")] +public class TokenSecurityReport +{ + [Description("代币符号")] + public string Symbol { get; set; } = string.Empty; + + [Description("代币合约地址")] + public string TokenAddress { get; set; } = string.Empty; + + [Description("所在链 ID")] + public string ChainId { get; set; } = string.Empty; + + [Description("综合风险等级:safe(安全)/ warning(警告)/ danger(高危)")] + public string RiskLevel { get; set; } = string.Empty; + + [Description("风险原因说明列表")] + public List RiskReasons { get; set; } = new(); + + [Description("是否蜜罐代币(买入后无法卖出)")] + public bool IsHoneypot { get; set; } + + [Description("买入税率(%)")] + public decimal? BuyTaxPercent { get; set; } + + [Description("卖出税率(%)")] + public decimal? SellTaxPercent { get; set; } + + [Description("合约是否可增发")] + public bool IsMintable { get; set; } + + [Description("合约所有者是否可随时暂停交易")] + public bool IsTradableRestricted { get; set; } + + [Description("合约是否可修改税率")] + public bool IsFeeChangable { get; set; } + + [Description("合约是否可黑名单地址")] + public bool IsBlacklistable { get; set; } + + [Description("合约是否可白名单限制")] + public bool IsWhitelistable { get; set; } + + [Description("合约是否已放弃所有权(放弃后无法再作恶)")] + public bool IsOwnershipRenounced { get; set; } + + [Description("合约是否开源可验证")] + public bool IsOpenSource { get; set; } + + [Description("是否存在隐藏的代理合约升级风险")] + public bool IsProxyContract { get; set; } + + [Description("是否超过 10 个持有者(false 通常为假币)")] + public bool HasEnoughHolders { get; set; } + + [Description("是否存在非流动性风险(无法卖出换回资产)")] + public bool IsAntiWhaleModified { get; set; } + + [Description("安全建议说明")] + public string SecurityAdvice { get; set; } = string.Empty; +} + +[Description("链上大额转账信号")] +public class LargeTransferSignal +{ + [Description("链 ID")] + public string ChainId { get; set; } = string.Empty; + + [Description("代币符号")] + public string Symbol { get; set; } = string.Empty; + + [Description("代币合约地址")] + public string TokenAddress { get; set; } = string.Empty; + + [Description("转账笔数")] + public int TransferCount { get; set; } + + [Description("转账总额(代币数量)")] + public decimal? TotalAmount { get; set; } + + [Description("转账总价值(USD)")] + public decimal? TotalValueUsd { get; set; } + + [Description("接盘地址数量")] + public int UniqueBuyerCount { get; set; } + + [Description("信号说明")] + public string Signal { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/AGENTS.md b/src/MarketAssistant.App.Services/AGENTS.md index b5fdb62..d4ec722 100644 --- a/src/MarketAssistant.App.Services/AGENTS.md +++ b/src/MarketAssistant.App.Services/AGENTS.md @@ -9,7 +9,7 @@ ``` MarketAssistant.App.Services/ ├── Agents/ ← Agent 实现、工作流、执行器、工具实现 -├── Applications/ ← 面向 UI 的业务服务(Assets/Home/Favorites/Analysis 等) +├── Applications/ ← 面向 UI 的业务服务(Assets/Home/Favorites/Analysis/AlertCenter 等) ├── Infrastructure/ ← 工厂、配置、通用运行时适配 ├── Services/ ← 横切服务(Archive/Browser/Cache/Market/Mcp/Settings 等) └── Trading/ ← 交易引擎、风控、持久化与监控 @@ -44,6 +44,27 @@ MarketAssistant.App.Services/ --- +## 统一告警中心(Applications/AlertCenter) + +价格 / 风险 / 信号三类告警的唯一出口,设计详见 `docs/plan/alert-center-refactor-plan.md`。 + +| 文件 | 职责 | +|------|------| +| `AlertEvent.cs` | 告警模型:`AlertLevel`(Info/Warning/Critical)、`AlertSource`、`AlertTradingImpact`、去重键 `DedupeKey` | +| `IAlertCenterService.cs` / `AlertCenterService.cs` | 落库(`alert_events` 表)、抑制、弹窗触达、联动门登记/释放、历史与未读查询 | +| `AlertSuppressionPolicy.cs` | 纯逻辑:同类 5 分钟合并窗口 + 全局每小时配额(Critical 不受限),无 IO 便于单测 | +| `AlertGate.cs` | `IAlertGate` 内存计数门:某标的是否存在触发中的确认级告警 | +| `AShareTradingHours.cs` | A 股交易时段判定,非交易时段跳过价格告警评估 | + +新增告警来源时的约定: + +- 一律通过 `IAlertCenterService.RaiseAlertAsync(AlertEvent)` 上报,**不要**在业务服务里直接调用 `INotificationService` 弹告警窗(弹窗由告警中心统一按级别、免打扰时段与配额分发)。 +- 需要"告警期间该标的 AI 交易强制人工确认"时,把 `AlertEvent.TradingImpact` 置为 `RequireConfirmation`;条件解除后必须调用 `RaiseAlertClearedAsync(source, marketType, symbol, title)` 释放联动门,否则该标的会持续被门控。 +- 保护性平仓(`requireClose`)不受联动门影响,确保告警期间仍能及时离场。 +- 抑制状态为内存态,重启清零可接受;持久化只存告警记录本身。 + +--- + ## 构建 ```bash diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/FundamentalAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/FundamentalAnalystAgent.cs index 609ca95..bc18dd5 100644 --- a/src/MarketAssistant.App.Services/Agents/Analysts/FundamentalAnalystAgent.cs +++ b/src/MarketAssistant.App.Services/Agents/Analysts/FundamentalAnalystAgent.cs @@ -17,6 +17,7 @@ namespace MarketAssistant.Services.Agents.Analysts; [Description("整合了策略分析师和股票研究分析师的功能")] [RequiredAnalyst] [RequiresTools(typeof(IBasicDataTools))] +[RequiresTools(typeof(IOnChainTools))] public class FundamentalAnalystAgent : AnalystAgentBase { public FundamentalAnalystAgent( diff --git a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/AnalysisAggregatorExecutor.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/AnalysisAggregatorExecutor.cs index 006dd0c..1c5633f 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/AnalysisAggregatorExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/AnalysisAggregatorExecutor.cs @@ -219,12 +219,8 @@ private static void CollectMessage(List target, ChatMessage message target.Add(normalized); } - private string FormatFailures() - => string.Join(";", _failedMessages.Select(message => - $"{message.AuthorName ?? "未知分析师"}({ExtractFailureReason(message.Text)})")); - /// - /// 构建发给 Coordinator 的载荷:降级说明(仅降级路径)+ 系统指引 + 各分析师摘要(全文已落盘,通过工具读取)+ 维度缺失说明。 + /// 构建发给 Coordinator 的载荷:降级说明(仅降级路径)+ 系统指引 + 各分析师摘要(全文已落盘,通过工具读取)+ 维度缺失说明 + 失败标记。 /// 落盘失败的条目自动回退为全文注入。 /// private async Task> BuildCoordinatorPayloadAsync( @@ -264,6 +260,14 @@ private async Task> BuildCoordinatorPayloadAsync( }); } + // 失败标记原样附入载荷(P2 展示层修复):协调阶段不计为有效结论, + // 但报告/侧边栏需要它们渲染「分析师执行失败」灰色占位卡片; + // 维度缺失说明已告知协调器如实标注数据局限,失败标记本身不再额外约束提示词。 + foreach (var failure in failures) + { + messages.Add(failure); + } + return messages; } @@ -322,21 +326,9 @@ private string BuildArtifactInstructionNote() $"本次运行 ID 为 {_runId:N}。需要任何维度的具体数据时,必须调用 get_analyst_artifact 工具读取全文;" + "严禁仅依据摘要编造数值、评级或细节。"; - private static string ExtractFailureReason(string? markerText) - { - if (string.IsNullOrEmpty(markerText)) - return "未知错误"; - - // 标记格式见 AnalystFailureMessages.BuildFailureText:前缀 + 空格 + "AgentName: reason"。 - // 流式失败时标记前可能拼接了部分正文,故先定位标记再取其后的 "AgentName: reason" 段。 - var markerIndex = markerText.IndexOf(AnalystFailureMessages.FailureMarkerPrefix, StringComparison.Ordinal); - if (markerIndex < 0) - return markerText; - - var afterMarker = markerText[(markerIndex + AnalystFailureMessages.FailureMarkerPrefix.Length)..].TrimStart(); - var separatorIndex = afterMarker.IndexOf(": ", StringComparison.Ordinal); - return separatorIndex >= 0 ? afterMarker[(separatorIndex + 2)..] : afterMarker; - } + private string FormatFailures() + => string.Join(";", _failedMessages.Select(message => + $"{message.AuthorName ?? "未知分析师"}({AnalystFailureMessages.ExtractFailureReason(message.Text)})")); /// /// 降级说明:告知协调器本次结果不完整,需在报告中注明。 @@ -348,7 +340,7 @@ private string BuildDegradedNotice(int receivedCount) private string BuildMissingDimensionNote(IReadOnlyList failedMessages) => AnalystFailureMessages.BuildMissingDimensionNote( failedMessages.Select(message => - $"{message.AuthorName ?? "未知分析师"}({ExtractFailureReason(message.Text)})").ToList()); + $"{message.AuthorName ?? "未知分析师"}({AnalystFailureMessages.ExtractFailureReason(message.Text)})").ToList()); private static bool IsAnalystTextMessage(ChatMessage message) => message.Role == ChatRole.Assistant && !string.IsNullOrWhiteSpace(message.Text); diff --git a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs index fae4b93..f46a360 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs @@ -3,6 +3,7 @@ using MarketAssistant.Infrastructure.Core; using MarketAssistant.Infrastructure.Factories; using MarketAssistant.Infrastructure.Providers; +using MarketAssistant.Services.Agents.MarketAnalysis.Artifacts; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; @@ -21,6 +22,8 @@ public sealed partial class CoordinatorExecutor : Executor { private readonly AIAgent _coordinatorAgent; private readonly ILogger _logger; + private readonly Guid _runId; + private readonly IAnalystArtifactStore _artifactStore; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerOptions.Web) { @@ -40,10 +43,14 @@ public sealed partial class CoordinatorExecutor : Executor public CoordinatorExecutor( AIAgent coordinatorAgent, + Guid runId, + IAnalystArtifactStore artifactStore, ILogger logger) : base("Coordinator") { _coordinatorAgent = coordinatorAgent ?? throw new ArgumentNullException(nameof(coordinatorAgent)); + _runId = runId; + _artifactStore = artifactStore ?? throw new ArgumentNullException(nameof(artifactStore)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -237,14 +244,16 @@ private async ValueTask HandleAsync( coordinatorResult.InvestmentRating); // 创建最终报告 - // 仅保存过滤后的分析师消息(不含工具调用细节),避免归档时序列化 FunctionResultContent 失败 + // 仅保存过滤后的分析师消息(不含工具调用细节),避免归档时序列化 FunctionResultContent 失败。 + // 协调对话内使用摘要以省 Token(P1-07),但报告/聊天/导出面向用户, + // 需从产物存储还原全文,保证展示的是分析师完整结论而非内部摘要指令; + // 系统说明(SystemNotice)与落盘失败的条目在回填时分别被跳过或保留原文。 + var reportAnalystMessages = await RehydrateAnalystArtifactsAsync(filteredMessages, cancellationToken); + var finalReport = new MarketAnalysisReport { AssetSymbol = assetSymbol, - AnalystMessages = new List(filteredMessages) - { - coordinatorMessage! - }, + AnalystMessages = [.. reportAnalystMessages, coordinatorMessage!], CoordinatorResult = coordinatorResult, CreatedAt = DateTime.UtcNow }; @@ -269,6 +278,80 @@ private async ValueTask HandleAsync( } } + /// + /// 将上游传入的摘要消息回填为产物全文(P1-07 展示层修复): + /// 聚合器为节省 Token 仅向协调器传摘要、全文落盘。报告/聊天/导出等展示层 + /// 复用 report.AnalystMessages,因此这里在生成报告前将全文读回, + /// 失败的分析师失败标记与无产物条目原样保留。 + /// + private async ValueTask> RehydrateAnalystArtifactsAsync( + IReadOnlyList analystMessages, + CancellationToken cancellationToken) + { + var rehydrated = new List(analystMessages.Count); + var artifactCount = 0; + + foreach (var message in analystMessages) + { + // 失败标记消息无产物可回填,原样保留供 UI 展示失败原因 + if (AnalystFailureMessages.IsFailureMarker(message.Text)) + { + rehydrated.Add(message); + continue; + } + + // 跳过系统说明消息(如产物读取指引、维度缺失说明 SystemNotice),避免内部指令进入展示层 + if (message.Role == ChatRole.System || + string.Equals(message.AuthorName, "SystemNotice", StringComparison.Ordinal)) + { + continue; + } + + var artifact = message.AuthorName is null + ? null + : await ReadArtifactSafelyAsync(message.AuthorName, cancellationToken); + + if (artifact is null) + { + rehydrated.Add(message); + continue; + } + + rehydrated.Add(new ChatMessage(ChatRole.Assistant, artifact) + { + AuthorName = message.AuthorName + }); + artifactCount++; + } + + _logger.LogInformation( + "分析师产物回填完成,输入消息: {InputCount},回填全文: {ArtifactCount},输出消息: {OutputCount}", + analystMessages.Count, artifactCount, rehydrated.Count); + + return rehydrated; + } + + /// + /// 读取产物全文;不存在或读取失败时返回 null(保留摘要消息,不阻断报告生成)。 + /// + private async Task ReadArtifactSafelyAsync(string analystName, CancellationToken cancellationToken) + { + try + { + return await _artifactStore.GetAsync(_runId, analystName, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "读取分析师产物失败,保留摘要消息: Run: {RunId}, Analyst: {Analyst}", + _runId.ToString("N"), analystName); + return null; + } + } + /// /// 构建修复反馈消息:将校验错误连同上一轮输出反馈给模型,要求其仅修正问题并重新输出完整 JSON。 /// diff --git a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs index 76f9424..9eb313c 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs @@ -97,6 +97,11 @@ public async Task AnalyzeAsync( activity?.SetTag("marketassistant.run.id", runId.ToString("N")); activity?.SetTag("marketassistant.asset.symbol", assetSymbol); + // 分析师运行状态跟踪(键为 MAF Agent ASCII Name,插入顺序即创建顺序), + // 随每次进度事件生成快照,供 UI 逐位展示各分析师状态与耗时; + // 声明在 try 外,使取消/失败的 catch 也能构建终态快照 + var analystRunStates = new Dictionary(StringComparer.Ordinal); + try { _logger.LogInformation("开始执行市场分析工作流,标的代码: {AssetSymbol}", assetSymbol); @@ -104,7 +109,9 @@ public async Task AnalyzeAsync( OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs { StageDescription = "正在准备分析环境", - IsInProgress = true + IsInProgress = true, + Phase = AnalysisPhase.Preparing, + Analysts = BuildAnalystSnapshots(analystRunStates) }); // 获取启用的分析师列表 @@ -147,12 +154,15 @@ public async Task AnalyzeAsync( } _logger.LogWarning(exception, "分析师执行失败,降级继续其余分析师: {Analyst}", displayName); + MarkAnalystFailed(analystRunStates, displayName); OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs { StageDescription = $"{displayName} 分析失败,继续其他分析", IsInProgress = true, TotalAnalysts = totalForProgress, - FailedAnalysts = failedSnapshot + FailedAnalysts = failedSnapshot, + Phase = AnalysisPhase.Analyzing, + Analysts = BuildAnalystSnapshots(analystRunStates) }); }); var failedAnalystNames = analystAgents.FailedTypes @@ -161,6 +171,13 @@ public async Task AnalyzeAsync( var createdAgents = analystAgents.Agents; totalForProgress = createdAgents.Count; + // 补齐 Pending 状态条目(降级回调可能在创建期间已提前写入 Failed 条目) + foreach (var (agentName, displayName) in analystAgents.NameToDisplayName) + { + if (!analystRunStates.ContainsKey(agentName)) + analystRunStates[agentName] = new AnalystRunState(displayName); + } + if (createdAgents.Count == 0) { throw new InvalidOperationException("所有分析师创建失败,无法执行分析"); @@ -184,6 +201,8 @@ public async Task AnalyzeAsync( extraTools: [getArtifactTool]); var coordinatorExecutor = new CoordinatorExecutor( coordinatorAgent, + runId, + _artifactStore, _loggerFactory.CreateLogger()); var aggregatorExecutor = new AnalysisAggregatorExecutor( createdAgents.Count, @@ -204,17 +223,20 @@ public async Task AnalyzeAsync( StageDescription = $"{createdAgents.Count} 位分析师正在并发分析", IsInProgress = true, TotalAnalysts = createdAgents.Count, - FailedAnalysts = failedAnalystNames + FailedAnalysts = failedAnalystNames, + Phase = AnalysisPhase.Analyzing, + Analysts = BuildAnalystSnapshots(analystRunStates) }); // 执行工作流(流式处理) - var finalReport = await ExecuteWorkflowAsync( + var (finalReport, analystSnapshots) = await ExecuteWorkflowAsync( workflow, runId, assetSymbol, createdAgents.Count, agentNameToDisplayName, degradedAnalysts, + analystRunStates, cancellationToken); // 缓存分析结果,供交易决策模块使用 @@ -225,7 +247,10 @@ public async Task AnalyzeAsync( OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs { StageDescription = "分析完成", - IsInProgress = false + IsInProgress = false, + Phase = AnalysisPhase.Completed, + Analysts = analystSnapshots, + FailedAnalysts = [.. failedAnalystNames] }); return finalReport; @@ -234,16 +259,35 @@ public async Task AnalyzeAsync( { activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, "cancelled"); activity?.SetTag("error.type", ex.GetType().FullName); + + // 取消路径同样发布携带分析师快照的终态事件: + // 订阅方无需对"无终态事件/无快照"做兜底,UI 据此定格行条目并停止计时 + foreach (var state in analystRunStates.Values) + { + state.MarkSucceededAsStale(); + } + OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs + { + StageDescription = "分析已取消", + IsInProgress = false, + Phase = AnalysisPhase.Completed, + Analysts = BuildAnalystSnapshots(analystRunStates) + }); + throw; } catch (Exception ex) { MarketAssistantDiagnostics.RecordException(activity, ex); _logger.LogError(ex, "执行市场分析工作流时发生错误"); + + // 失败终态事件携带分析师快照,与成功/取消路径保持同一契约 OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs { StageDescription = $"分析失败: {ex.Message}", - IsInProgress = false + IsInProgress = false, + Phase = AnalysisPhase.Completed, + Analysts = BuildAnalystSnapshots(analystRunStates) }); throw; } @@ -252,13 +296,15 @@ public async Task AnalyzeAsync( /// /// 执行工作流并处理事件 /// - private async Task ExecuteWorkflowAsync( + /// 最终报告,以及结束时各分析师的运行状态快照(供完成事件发布) + private async Task<(MarketAnalysisReport FinalReport, List AnalystSnapshots)> ExecuteWorkflowAsync( Workflow workflow, Guid runId, string assetSymbol, int analystCount, IReadOnlyDictionary agentNameToDisplayName, List degradedAnalysts, + Dictionary analystRunStates, CancellationToken cancellationToken) { MarketAnalysisReport? finalReport = null; @@ -270,6 +316,25 @@ private async Task ExecuteWorkflowAsync( // 追踪当前正在运行的 Executor,超时时用于定位卡住的分析师 var activeExecutors = new HashSet(); var executorStartedAt = new Dictionary(StringComparer.Ordinal); + // 追踪处于真实执行(TurnToken)阶段的分析师:MAF 对每个分析师 Executor 会先后 + // 发起两次调用——一次接收广播的 ChatMessage(仅消息转发,毫秒级),一次携带 + // TurnToken 执行真实 LLM 调用。只有 TurnToken 阶段计入“正在分析/分析完成”, + // 否则转发阶段的假完成会把 completedAnalysts 灌满,进度条瞬间冲到 100%, + // 阶段文本也会在同一毫秒内连环翻转。 + var executingAnalysts = new HashSet(StringComparer.Ordinal); + // 当前流程阶段(步骤条高亮用) + var currentPhase = AnalysisPhase.Preparing; + + AnalysisProgressEventArgs CreateProgressArgs(string stageDescription) => new() + { + StageDescription = stageDescription, + IsInProgress = true, + TotalAnalysts = totalAnalysts, + CompletedAnalysts = completedAnalysts, + Phase = currentPhase, + Analysts = BuildAnalystSnapshots(analystRunStates), + FailedAnalysts = [.. failedSteps.Select(f => f.DisplayName)] + }; // 执行工作流(流式处理) // 初始输入 assetSymbol 会触发 Dispatcher,Dispatcher 再通过 context.SendMessageAsync @@ -293,6 +358,25 @@ private async Task ExecuteWorkflowAsync( executorInvoked.ExecutorId, executorInvoked.Data?.GetType().FullName ?? "null"); + if (IsAnalystExecutor(executorInvoked.ExecutorId)) + { + // 仅 TurnToken 阶段代表真实分析开始;ChatMessage 转发阶段静默, + // 避免“正在分析→分析完成→正在分析”在同一毫秒内连环翻转 + if (executorInvoked.Data is TurnToken) + { + executingAnalysts.Add(executorInvoked.ExecutorId); + if (analystRunStates.TryGetValue( + GetExecutorNamePrefix(executorInvoked.ExecutorId), out var runningState)) + { + runningState.MarkRunning(); + } + currentPhase = AnalysisPhase.Analyzing; + OnProgressChanged(runId, assetSymbol, CreateProgressArgs( + $"{GetDisplayNameForExecutorId(executorInvoked.ExecutorId, agentNameToDisplayName)} 正在分析")); + } + break; + } + string stageName = GetExecutorNamePrefix(executorInvoked.ExecutorId) switch { "AnalysisDispatcher" => "正在分发分析任务", @@ -300,14 +384,15 @@ private async Task ExecuteWorkflowAsync( "Coordinator" => "正在生成综合报告", _ => $"{GetDisplayNameForExecutorId(executorInvoked.ExecutorId, agentNameToDisplayName)} 正在分析" }; - - OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs + currentPhase = GetExecutorNamePrefix(executorInvoked.ExecutorId) switch { - StageDescription = stageName, - IsInProgress = true, - TotalAnalysts = totalAnalysts, - CompletedAnalysts = completedAnalysts - }); + "AnalysisDispatcher" => AnalysisPhase.Analyzing, + "AnalysisAggregator" => AnalysisPhase.Aggregating, + "Coordinator" => AnalysisPhase.Reporting, + _ => currentPhase + }; + + OnProgressChanged(runId, assetSymbol, CreateProgressArgs(stageName)); break; case ExecutorCompletedEvent executorComplete: @@ -324,15 +409,19 @@ private async Task ExecuteWorkflowAsync( if (IsAnalystExecutor(executorComplete.ExecutorId)) { - completedAnalysts++; - OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs + // 仅当分析师处于真实执行(TurnToken)阶段完成时才计数并发布进度; + // 消息转发阶段的完成事件不计数,防止进度条被假完成瞬间冲满 + if (executingAnalysts.Remove(executorComplete.ExecutorId)) { - StageDescription = $"{lastCompletedStep} 分析完成", - IsInProgress = true, - TotalAnalysts = totalAnalysts, - CompletedAnalysts = completedAnalysts, - CompletedAnalystName = executorComplete.ExecutorId - }); + completedAnalysts++; + if (analystRunStates.TryGetValue( + GetExecutorNamePrefix(executorComplete.ExecutorId), out var completedState)) + { + completedState.MarkSucceeded(elapsed); + } + OnProgressChanged(runId, assetSymbol, CreateProgressArgs( + $"{lastCompletedStep} 分析完成")); + } } break; @@ -371,20 +460,26 @@ private async Task ExecuteWorkflowAsync( executorFailed.ExecutorId, failedDisplayName, errorDetail); failedSteps.Add((failedDisplayName, errorDetail)); + // 真实执行阶段失败同样计入完成,保证进度条仍能推进到终态 + if (IsAnalystExecutor(executorFailed.ExecutorId) && + executingAnalysts.Remove(executorFailed.ExecutorId)) + { + completedAnalysts++; + if (analystRunStates.TryGetValue( + GetExecutorNamePrefix(executorFailed.ExecutorId), out var failedState)) + { + failedState.MarkFailed(); + } + } + if (IsSystemExecutor(executorFailed.ExecutorId)) { throw new FriendlyException( $"分析流程关键环节「{failedDisplayName}」执行失败: {errorDetail}"); } - OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs - { - StageDescription = $"{failedDisplayName} 分析失败,继续其他分析", - IsInProgress = true, - TotalAnalysts = totalAnalysts, - CompletedAnalysts = completedAnalysts, - FailedAnalysts = failedSteps.Select(f => f.DisplayName).ToList() - }); + OnProgressChanged(runId, assetSymbol, CreateProgressArgs( + $"{failedDisplayName} 分析失败,继续其他分析")); break; case WorkflowErrorEvent workflowError: @@ -439,7 +534,7 @@ private async Task ExecuteWorkflowAsync( if (finalReport != null) { - return finalReport; + return (finalReport, BuildAnalystSnapshots(analystRunStates)); } // 全部分析师被失败隔离包装器降级:聚合器不会派发 Coordinator,事件流自然结束。 @@ -651,6 +746,96 @@ private static string GetDisplayNameForExecutorId( : executorId; } + /// + /// 将所有运行状态与指定显示名匹配的分析师标记为失败(降级隔离回调场景)。 + /// + private static void MarkAnalystFailed( + Dictionary analystRunStates, + string displayName) + { + foreach (var state in analystRunStates.Values) + { + if (string.Equals(state.DisplayName, displayName, StringComparison.Ordinal)) + { + state.MarkFailed(); + } + } + } + + /// + /// 生成各分析师的运行状态快照。运行中的条目携带实时耗时,已完成/失败的条目携带终态耗时。 + /// + private static List BuildAnalystSnapshots( + Dictionary analystRunStates) + { + var snapshots = new List(analystRunStates.Count); + foreach (var state in analystRunStates.Values) + { + snapshots.Add(state.CreateSnapshot()); + } + return snapshots; + } + + /// + /// 单个分析师的运行状态(Run 局部,非线程安全:仅工作流事件循环与降级回调写入)。 + /// + private sealed class AnalystRunState(string displayName) + { + public string DisplayName { get; } = displayName; + + public AnalystRunStatus Status { get; private set; } = AnalystRunStatus.Pending; + + /// 真实执行(TurnToken)阶段开始的时间戳,完成/失败后清空。 + private long? StartedAtTimestamp { get; set; } + + /// 终态耗时(完成或失败时定格)。 + private TimeSpan LastElapsed { get; set; } + + public void MarkRunning() + { + Status = AnalystRunStatus.Running; + StartedAtTimestamp = Stopwatch.GetTimestamp(); + } + + public void MarkSucceeded(TimeSpan elapsed) + { + Status = AnalystRunStatus.Completed; + StartedAtTimestamp = null; + LastElapsed = elapsed; + } + + public void MarkFailed() + { + if (Status == AnalystRunStatus.Running && StartedAtTimestamp.HasValue) + { + LastElapsed = Stopwatch.GetElapsedTime(StartedAtTimestamp.Value); + } + Status = AnalystRunStatus.Failed; + StartedAtTimestamp = null; + } + + /// + /// 运行中被取消时定格耗时并按完成收尾(UI 无"已取消"状态,取消视为正常中断)。 + /// + public void MarkSucceededAsStale() + { + if (Status == AnalystRunStatus.Running && StartedAtTimestamp.HasValue) + { + LastElapsed = Stopwatch.GetElapsedTime(StartedAtTimestamp.Value); + Status = AnalystRunStatus.Completed; + } + StartedAtTimestamp = null; + } + + public AnalystRunSnapshot CreateSnapshot() + { + var elapsed = Status == AnalystRunStatus.Running && StartedAtTimestamp.HasValue + ? Stopwatch.GetElapsedTime(StartedAtTimestamp.Value) + : LastElapsed; + return new AnalystRunSnapshot(DisplayName, Status, elapsed); + } + } + /// /// 构建并发工作流(使用框架原生并发编排) /// 参考: https://learn.microsoft.com/zh-cn/agent-framework/tutorials/workflows/simple-concurrent-workflow @@ -711,6 +896,56 @@ protected virtual void OnProgressChanged( } } +/// +/// 分析流程阶段(用于进度面板的步骤条高亮) +/// +public enum AnalysisPhase +{ + /// 准备分析环境(创建代理、装配上下文) + Preparing, + + /// 分析师并发执行中 + Analyzing, + + /// 聚合各分析师结果 + Aggregating, + + /// 协调器生成综合报告 + Reporting, + + /// 分析完成(或失败终态) + Completed +} + +/// +/// 单个分析师的运行状态 +/// +public enum AnalystRunStatus +{ + /// 等待执行 + Pending, + + /// 正在分析(真实 LLM 调用阶段) + Running, + + /// 分析完成 + Completed, + + /// 分析失败(降级继续) + Failed +} + +/// +/// 单个分析师运行状态的只读快照,随进度事件发布给 UI。 +/// +/// 中文显示名(如"基本面分析师") +/// 运行状态 +/// 运行中为实时耗时,终态为定格耗时 +public sealed record AnalystRunSnapshot( + string DisplayName, + AnalystRunStatus Status, + TimeSpan Elapsed); + /// /// 分析进度变化事件参数 /// @@ -756,6 +991,16 @@ public sealed class AnalysisProgressEventArgs : EventArgs /// public List FailedAnalysts { get; set; } = []; + /// + /// 当前流程阶段,用于步骤条高亮。 + /// + public AnalysisPhase Phase { get; set; } = AnalysisPhase.Preparing; + + /// + /// 各分析师的运行状态快照(顺序即创建顺序)。未提供时为空列表。 + /// + public IReadOnlyList Analysts { get; set; } = []; + /// /// 进度百分比(0-100) /// diff --git a/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs b/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs index 6a63041..b256180 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs @@ -255,6 +255,9 @@ private static string BuildAnalysisSummary(IEnumerable analysisMess var text = message.Text; if (string.IsNullOrWhiteSpace(text)) continue; + // 跳过系统内部说明(如产物读取指引 SystemNotice),避免内部指令污染聊天上下文 + if (message.Role == ChatRole.System) continue; + index++; var author = message.AuthorName ?? $"分析师{index}"; sb.AppendLine($"### {author}"); diff --git a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoBasicTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoBasicTools.cs index e580a49..c99c8c6 100644 --- a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoBasicTools.cs +++ b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoBasicTools.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using System.ComponentModel; +using System.Text.Json; using static MarketAssistant.Infrastructure.Core.CryptoSymbolConverter; namespace MarketAssistant.Agents.Tools.Crypto; @@ -154,7 +155,8 @@ public async Task GetProjectInfoAsync([Description("虚拟币 Change7dPercent = coinDetail.MarketData?.PriceChangePercentage7dInCurrency?.GetValueOrDefault("usd"), Change30dPercent = coinDetail.MarketData?.PriceChangePercentage30dInCurrency?.GetValueOrDefault("usd"), Rankings = ExtractRankings(coinDetail), - Industries = coinDetail.Categories ?? new List() + Industries = coinDetail.Categories ?? new List(), + ContractAddresses = ExtractContractAddresses(coinDetail) }; _logger.LogInformation("成功获取虚拟币项目信息: {Name} ({Symbol})", projectInfo.Name, symbol); @@ -191,6 +193,27 @@ private static string TruncateDescription(string? description, int maxLength) return new RankingInfo { MarketCapRank = detail.MarketCapRank }; } + /// + /// 从 CoinGecko 详情的 detail_platforms 提取各链合约地址(仅保留字符串型地址值)。 + /// 中心化视角回答"某代币部署在哪些链上",供链上工具(审计/交易对)确定 chainId + tokenAddress。 + /// + private static Dictionary ExtractContractAddresses(CoinGeckoCoinDetail? detail) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (detail?.DetailPlatforms == null) + return result; + + foreach (var (platform, value) in detail.DetailPlatforms) + { + if (value is { ValueKind: JsonValueKind.String } element && + element.GetString() is { Length: > 0 } address) + { + result[platform] = address; + } + } + return result; + } + public IEnumerable GetFunctions() { yield return AIFunctionFactory.Create(GetAssetInfoAsync); diff --git a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoOnChainTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoOnChainTools.cs new file mode 100644 index 0000000..75d33b0 --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoOnChainTools.cs @@ -0,0 +1,290 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Agents.Tools.Models.Crypto; +using MarketAssistant.Applications.AlertCenter; +using MarketAssistant.DataProviders.Web3; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.ComponentModel; +using System.Globalization; + +namespace MarketAssistant.Agents.Tools.Crypto; + +/// +/// 虚拟币链上数据工具实现:热门代币/交易对追踪(DexScreener)、 +/// 代币安全审计与蜜罐检测(GoPlus)。仅注册于 Crypto 市场。 +/// 检出蜜罐/高危代币时经统一告警中心发布确认级告警(RequireConfirmation)。 +/// +public sealed class CryptoOnChainTools : IOnChainTools +{ + private readonly ILogger _logger; + private readonly DexScreenerClient _dexScreenerClient; + private readonly GoPlusSecurityClient _goPlusClient; + private readonly IAlertCenterService _alertCenterService; + + public CryptoOnChainTools( + ILogger logger, + DexScreenerClient dexScreenerClient, + GoPlusSecurityClient goPlusClient, + IAlertCenterService alertCenterService) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _dexScreenerClient = dexScreenerClient ?? throw new ArgumentNullException(nameof(dexScreenerClient)); + _goPlusClient = goPlusClient ?? throw new ArgumentNullException(nameof(goPlusClient)); + _alertCenterService = alertCenterService ?? throw new ArgumentNullException(nameof(alertCenterService)); + } + + /// + /// 按关键字搜索链上热门交易对(DexScreener)。 + /// + [Description("搜索链上 DEX 热门交易对,可按代币符号(如PEPE)或合约地址搜索,返回价格、流动性、涨跌幅等信息。")] + public async Task> SearchHotTokenPairsAsync( + [Description("搜索关键字(代币符号、名称或合约地址)")] string query, + CancellationToken cancellationToken = default) + { + try + { + _logger.LogInformation("正在搜索链上热门交易对: {Query}", query); + var pairs = await _dexScreenerClient.SearchPairsAsync(query, cancellationToken); + var result = pairs + .Where(p => p.BaseToken is not null) + .OrderByDescending(p => p.Volume?.H24 ?? 0) + .Take(10) + .Select(MapToHotTokenPair) + .ToList(); + + _logger.LogInformation("搜索到 {Count} 个链上交易对: {Query}", result.Count, query); + return result; + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "搜索链上交易对时发生错误: {Query}", query); + throw new FriendlyException($"搜索链上交易对时发生错误: {ex.Message}", ex); + } + } + + /// + /// 按符号或名称解析代币的链与合约地址,供后续安全审计/交易对查询使用。 + /// + [Description("根据代币符号或名称解析其所在链与合约地址(DEX 视角)。调用安全审计或交易对查询前若不知道合约地址,必须先用本工具解析;返回按流动性排序的候选列表,一般取第一项作为主池。")] + public async Task> ResolveTokenIdentityAsync( + [Description("代币符号或名称(如 PEPE、Arbitrum)")] string query, + CancellationToken cancellationToken = default) + { + try + { + _logger.LogInformation("正在解析代币链上身份: {Query}", query); + var pairs = await _dexScreenerClient.SearchPairsAsync(query, cancellationToken); + var result = pairs + .Where(p => p.BaseToken is not null) + .OrderByDescending(p => p.Liquidity?.Usd ?? 0) + .Take(10) + .Select(MapToHotTokenPair) + .ToList(); + + _logger.LogInformation("解析到 {Count} 个候选交易对: {Query}", result.Count, query); + return result; + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "解析代币链上身份时发生错误: {Query}", query); + throw new FriendlyException($"解析代币链上身份时发生错误: {ex.Message}", ex); + } + } + + /// + /// 获取指定代币合约的全部 DEX 交易对(DexScreener)。 + /// + [Description("获取指定代币在指定链上的所有 DEX 交易对信息(价格、流动性、成交量、涨跌幅)。")] + public async Task> GetTokenPairsAsync( + [Description("链 ID(ethereum、bsc、base、solana 等规范名,也兼容 eth/1、binance-smart-chain、polygon-pos 等写法,自动归一化)")] string chainId, + [Description("代币合约地址")] string tokenAddress, + CancellationToken cancellationToken = default) + { + try + { + _logger.LogInformation("正在获取链上交易对: {ChainId} {TokenAddress}", chainId, tokenAddress); + var pairs = await _dexScreenerClient.GetTokenPairsAsync(chainId, tokenAddress, cancellationToken); + var result = pairs + .OrderByDescending(p => p.Liquidity?.Usd ?? 0) + .Take(10) + .Select(MapToHotTokenPair) + .ToList(); + + _logger.LogInformation("获取到 {Count} 个链上交易对: {ChainId} {TokenAddress}", result.Count, chainId, tokenAddress); + return result; + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "获取链上交易对时发生错误: {ChainId} {TokenAddress}", chainId, tokenAddress); + throw new FriendlyException($"获取链上交易对时发生错误: {ex.Message}", ex); + } + } + + /// + /// 代币安全审计与蜜罐检测(GoPlus)。 + /// + [Description("对代币合约进行安全审计与蜜罐检测,返回蜜罐标记、买卖税率、增发/黑名单/暂停交易等风险项与综合风险等级。分析不熟悉的代币前必须调用。")] + public async Task AuditTokenSecurityAsync( + [Description("链 ID(ethereum、bsc、base、solana 等规范名,也兼容 eth/1、binance-smart-chain、polygon-pos 等写法,自动归一化)")] string chainId, + [Description("代币合约地址")] string tokenAddress, + CancellationToken cancellationToken = default) + { + try + { + _logger.LogInformation("正在进行代币安全审计: {ChainId} {TokenAddress}", chainId, tokenAddress); + var security = await _goPlusClient.GetTokenSecurityAsync(chainId, tokenAddress, cancellationToken); + if (security is null) + throw new FriendlyException("未找到该代币的安全审计数据,请确认合约地址与链 ID 是否正确"); + + var report = MapToSecurityReport(ChainRegistry.Normalize(chainId), tokenAddress, security); + await RaiseSecurityAlertIfDangerousAsync(report, cancellationToken); + _logger.LogInformation( + "代币安全审计完成: {Symbol} 风险等级 {RiskLevel} 蜜罐 {IsHoneypot}", + report.Symbol, report.RiskLevel, report.IsHoneypot); + return report; + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "代币安全审计时发生错误: {ChainId} {TokenAddress}", chainId, tokenAddress); + throw new FriendlyException($"代币安全审计时发生错误: {ex.Message}", ex); + } + } + + /// + /// 蜜罐/高危代币经统一告警中心发布确认级告警(RequireConfirmation), + /// 失败仅记日志,不影响审计结果返回。 + /// + private async Task RaiseSecurityAlertIfDangerousAsync(TokenSecurityReport report, CancellationToken cancellationToken) + { + if (report.RiskLevel != "danger") + return; + + try + { + await _alertCenterService.RaiseAlertAsync(new AlertEvent + { + MarketType = MarketType.Crypto, + Symbol = string.IsNullOrWhiteSpace(report.Symbol) ? report.TokenAddress : report.Symbol, + Level = AlertLevel.Critical, + Source = AlertSource.Risk, + Title = $"高危代币安全告警: {report.Symbol}", + Content = $"链 {report.ChainId} 合约 {report.TokenAddress} 检出高危风险项:\n" + + string.Join("\n", report.RiskReasons) + + $"\n综合风险等级: {report.RiskLevel}。{report.SecurityAdvice}", + TradingImpact = AlertTradingImpact.RequireConfirmation + }, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "发布代币安全告警失败: {Symbol} {TokenAddress}", report.Symbol, report.TokenAddress); + } + } + + /// + /// 将 DexScreener 交易对映射为工具返回模型。 + /// + private static HotTokenPair MapToHotTokenPair(DexScreenerPair pair) + { + DateTime? createdAt = pair.PairCreatedAt is > 0 + ? DateTimeOffset.FromUnixTimeMilliseconds(pair.PairCreatedAt.Value).UtcDateTime + : null; + + return new HotTokenPair + { + ChainId = pair.ChainId ?? string.Empty, + Symbol = pair.BaseToken?.Symbol ?? string.Empty, + TokenName = pair.BaseToken?.Name ?? string.Empty, + TokenAddress = pair.BaseToken?.Address ?? string.Empty, + PriceUsd = decimal.TryParse(pair.PriceUsd, NumberStyles.Float, CultureInfo.InvariantCulture, out var price) ? price : null, + Change24hPercent = pair.PriceChange?.H24, + Volume24hUsd = pair.Volume?.H24, + LiquidityUsd = pair.Liquidity?.Usd, + MarketCapUsd = pair.MarketCap ?? pair.Fdv, + PairCreatedAt = createdAt, + DexId = pair.DexId ?? string.Empty + }; + } + + /// + /// 将 GoPlus 原始审计字段映射为结构化安全报告,并汇总综合风险等级。 + /// + private static TokenSecurityReport MapToSecurityReport(string chainId, string tokenAddress, GoPlusTokenSecurity security) + { + var riskReasons = new List(); + + var isHoneypot = security.IsHoneypot == "1"; + if (isHoneypot) riskReasons.Add("蜜罐代币:买入后无法卖出"); + if (security.CannotSellAll == "1") riskReasons.Add("无法卖出全部持仓"); + if (security.SlippageModifiable == "1") riskReasons.Add("合约可修改交易滑点"); + if (security.IsSlippageModified == "1") riskReasons.Add("合约已修改过交易滑点"); + if (security.IsMintable == "1") riskReasons.Add("合约可增发代币"); + if (security.IsOwnerCanChangeBalance == "1") riskReasons.Add("所有者可任意修改余额"); + if (security.SelfDestruct == "1") riskReasons.Add("合约可自毁"); + if (security.IsBlackList == "1") riskReasons.Add("合约可拉黑地址禁止交易"); + if (security.TransferPausable == "1") riskReasons.Add("所有者可暂停全部转账"); + if (security.HiddenOwner == "1") riskReasons.Add("隐藏所有者权限"); + if (security.IsOpenSource != "1") riskReasons.Add("合约未开源,无法审计"); + if (security.HolderCount != null && int.TryParse(security.HolderCount, out var holders) && holders < 10) + riskReasons.Add($"持有者过少({holders} 个),疑似假币"); + if (security.TradingCooldown == "1") riskReasons.Add("存在交易冷却时间限制"); + if (security.PersonalSlippageModifiable == "1") riskReasons.Add("可对单个地址设置个性化滑点"); + if (security.IsAirdropScam == "1") riskReasons.Add("存在空投诈骗风险"); + if (security.FakeToken == "1") riskReasons.Add("疑似假冒代币"); + + var riskLevel = riskReasons.Count switch + { + 0 => "safe", + _ when isHoneypot || security.IsOwnerCanChangeBalance == "1" || security.FakeToken == "1" => "danger", + _ when riskReasons.Count >= 3 => "danger", + _ => "warning" + }; + + var advice = riskLevel switch + { + "safe" => "未检测到明显风险项,但仍需关注流动性规模与持仓集中度。", + "warning" => "存在中等风险项,参与前需谨慎评估,建议小仓位并设置止损。", + _ => "高风险代币,强烈建议回避;AI 不会为该代币生成买入建议。" + }; + + return new TokenSecurityReport + { + Symbol = security.TokenSymbol ?? string.Empty, + TokenAddress = tokenAddress, + ChainId = chainId, + RiskLevel = riskLevel, + RiskReasons = riskReasons, + IsHoneypot = isHoneypot, + BuyTaxPercent = ParsePercent(security.BuyTax), + SellTaxPercent = ParsePercent(security.SellTax), + IsMintable = security.IsMintable == "1", + IsTradableRestricted = security.TransferPausable == "1" || security.IsBlackList == "1", + IsFeeChangable = security.SlippageModifiable == "1", + IsBlacklistable = security.IsBlackList == "1", + IsWhitelistable = security.IsWhiteList == "1", + IsOwnershipRenounced = security.CannotBeModified == "1", + IsOpenSource = security.IsOpenSource == "1", + IsProxyContract = security.IsProxy == "1", + HasEnoughHolders = security.HolderCount == null || + (int.TryParse(security.HolderCount, out var holderCount) && holderCount >= 10), + IsAntiWhaleModified = security.AntiWhaleModifiable == "1", + SecurityAdvice = advice + }; + } + + /// + /// GoPlus 返回的税率字符串(如 "0.05" 表示 5%)转百分比。 + /// + private static decimal? ParsePercent(string? value) => + decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var result) + ? result * 100 + : null; + + public IEnumerable GetFunctions() + { + yield return AIFunctionFactory.Create(ResolveTokenIdentityAsync); + yield return AIFunctionFactory.Create(SearchHotTokenPairsAsync); + yield return AIFunctionFactory.Create(GetTokenPairsAsync); + yield return AIFunctionFactory.Create(AuditTokenSecurityAsync); + } +} \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/Applications/AlertCenter/AShareTradingHours.cs b/src/MarketAssistant.App.Services/Applications/AlertCenter/AShareTradingHours.cs new file mode 100644 index 0000000..66258fd --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/AlertCenter/AShareTradingHours.cs @@ -0,0 +1,57 @@ +namespace MarketAssistant.Applications.AlertCenter; + +/// +/// A 股交易时段判断(东八区,周一至周五 09:30–11:30 / 13:00–15:00)。 +/// 用于价格类告警的静默窗口:非交易时段不评估,Critical 级别例外。 +/// +public static class AShareTradingHours +{ + private static readonly TimeSpan MorningOpen = new(9, 30, 0); + private static readonly TimeSpan MorningClose = new(11, 30, 0); + private static readonly TimeSpan AfternoonOpen = new(13, 0, 0); + private static readonly TimeSpan AfternoonClose = new(15, 0, 0); + + private static readonly Lazy ChinaTimeZone = new(ResolveChinaTimeZone); + + /// 判断给定 UTC 时间(默认当前)是否处于 A 股交易时段。 + public static bool IsTradingSession(DateTime? utcNow = null) + { + var china = ToChinaTime(utcNow ?? DateTime.UtcNow); + + if (china.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) + return false; + + var timeOfDay = china.TimeOfDay; + return (timeOfDay >= MorningOpen && timeOfDay <= MorningClose) || + (timeOfDay >= AfternoonOpen && timeOfDay <= AfternoonClose); + } + + private static DateTime ToChinaTime(DateTime utc) + { + try + { + return TimeZoneInfo.ConvertTimeFromUtc(utc, ChinaTimeZone.Value); + } + catch (Exception) + { + // 时区数据异常时按固定 +8 偏移兜底,避免静默窗口整体失效 + return utc.AddHours(8); + } + } + + private static TimeZoneInfo ResolveChinaTimeZone() + { + // Windows 与 IANA 时区 ID 不同,按运行平台优先取系统标识 + var id = OperatingSystem.IsWindows() ? "China Standard Time" : "Asia/Shanghai"; + try + { + return TimeZoneInfo.FindSystemTimeZoneById(id); + } + catch (Exception) + { + return OperatingSystem.IsWindows() + ? TimeZoneInfo.FindSystemTimeZoneById("Asia/Shanghai") + : TimeZoneInfo.FindSystemTimeZoneById("China Standard Time"); + } + } +} \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertCenterService.cs b/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertCenterService.cs new file mode 100644 index 0000000..737d9c9 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertCenterService.cs @@ -0,0 +1,350 @@ +using System.Globalization; +using MarketAssistant.Applications.Settings; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Notification; +using MarketAssistant.Services.Settings; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Applications.AlertCenter; + +/// +/// 统一告警中心服务:SQLite 持久化告警事件(alert_events 表),执行冷却合并、 +/// 每小时配额、免打扰静默等抑制逻辑,按级别经 INotificationService 处理触达, +/// 并维护确认级交易联动门(AlertGate)的登记与解除。 +/// +public sealed class AlertCenterService : SqliteServiceBase, IAlertCenterService +{ + private readonly INotificationService _notificationService; + private readonly IUserSettingService _userSettingService; + private readonly AlertGate _alertGate; + private readonly AlertSuppressionPolicy _policy = new(); + + /// 历史告警保留期:超期记录在启动初始化时裁剪,避免 alert_events 无界增长。 + private static readonly TimeSpan RetentionPeriod = TimeSpan.FromDays(30); + + private readonly object _stateSync = new(); + private readonly Dictionary _mergeStates = + new(StringComparer.Ordinal); + private AlertSuppressionPolicy.QuotaState _quotaState = AlertSuppressionPolicy.QuotaState.Empty; + + /// 触发中的确认级告警:(来源, 市场, 标的, 标题) → 告警 Id,供告警解除时释放联动门。 + /// 键含标题:同一标的多条不同条件的确认级规则各自独立登记,解除时精确匹配, + /// 避免后触发覆盖先触发导致门计数无法归零。 + private readonly Dictionary<(AlertSource Source, MarketType MarketType, string Symbol, string Title), string> + _activeGatedAlerts = new(); + + /// + public event Action? AlertRaised; + + /// + public event Action? AlertsChanged; + + public AlertCenterService( + INotificationService notificationService, + IUserSettingService userSettingService, + AlertGate alertGate, + ILogger logger) + : base(logger) + { + _notificationService = notificationService; + _userSettingService = userSettingService; + _alertGate = alertGate; + } + + /// + public Task InitializeAsync(CancellationToken cancellationToken = default) => + EnsureInitializedAsync(InitializeDatabaseAsync); + + /// + public async Task RaiseAlertAsync(AlertEvent alert, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(alert); + await EnsureInitializedAsync(InitializeDatabaseAsync); + + var settings = _userSettingService.CurrentSetting; + AlertSuppressionPolicy.Decision decision; + lock (_stateSync) + { + var mergeState = _mergeStates.GetValueOrDefault(alert.DedupeKey) + ?? AlertSuppressionPolicy.MergeState.Empty; + decision = _policy.Evaluate( + alert, mergeState, _quotaState, settings.AlertHourlyQuota, DateTime.UtcNow); + _mergeStates[alert.DedupeKey] = decision.NewMergeState; + _quotaState = decision.NewQuotaState; + } + + // 命中合并窗口:并入最近一条同类记录(累计次数),不新增行 + var mergedAlertId = decision.IsMerge ? await FindLatestAlertIdAsync(alert, cancellationToken) : null; + if (mergedAlertId != null) + await IncrementOccurrenceAsync(mergedAlertId, alert.Content, cancellationToken); + else + await InsertAlertAsync(alert, cancellationToken); + + // 确认级交易联动:登记触发中的告警,TradeExecutor 据此强制人工确认 + if (alert.TradingImpact == AlertTradingImpact.RequireConfirmation) + { + lock (_stateSync) + { + _activeGatedAlerts[(alert.Source, alert.MarketType, AlertEvent.NormalizeSymbol(alert.Symbol), alert.Title)] = + mergedAlertId ?? alert.Id; + } + + _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)) + Notify(alert); + + AlertRaised?.Invoke(alert); + AlertsChanged?.Invoke(); + } + + /// + public Task RaiseAlertClearedAsync( + AlertSource source, MarketType marketType, string symbol, string title, + CancellationToken cancellationToken = default) + { + var gateKey = (source, marketType, AlertEvent.NormalizeSymbol(symbol), title); + lock (_stateSync) + { + if (_activeGatedAlerts.Remove(gateKey)) + _alertGate.Deactivate(marketType, symbol); + + // 条件已解除,清掉合并状态:下次重新进入区间可立即告警 + _mergeStates.Remove(AlertEvent.BuildDedupeKey(source, marketType, symbol, title)); + } + + AlertsChanged?.Invoke(); + return Task.CompletedTask; + } + + /// + public async Task> GetRecentAlertsAsync( + int limit = 200, CancellationToken cancellationToken = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + var alerts = new List(); + + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT id, market_type, symbol, level, source, title, content, created_at, is_read, trading_impact, occurrence_count + FROM alert_events + ORDER BY created_at DESC + LIMIT @limit + """; + cmd.Parameters.AddWithValue("@limit", Math.Max(1, limit)); + + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + alerts.Add(new AlertEvent + { + Id = reader.GetString(0), + MarketType = (MarketType)reader.GetInt32(1), + Symbol = reader.GetString(2), + Level = (AlertLevel)reader.GetInt32(3), + Source = (AlertSource)reader.GetInt32(4), + Title = reader.GetString(5), + Content = reader.GetString(6), + CreatedAt = DateTime.Parse(reader.GetString(7), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), + IsRead = reader.GetInt64(8) != 0, + TradingImpact = (AlertTradingImpact)reader.GetInt32(9), + OccurrenceCount = reader.GetInt32(10) + }); + } + + return alerts; + } + + /// + public async Task GetUnreadCountAsync(CancellationToken cancellationToken = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM alert_events WHERE is_read = 0"; + var result = await cmd.ExecuteScalarAsync(cancellationToken); + return result is long count ? (int)count : 0; + } + + /// + public async Task MarkAllReadAsync(CancellationToken cancellationToken = default) + { + await EnsureInitializedAsync(InitializeDatabaseAsync); + + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE alert_events SET is_read = 1 WHERE is_read = 0"; + await cmd.ExecuteNonQueryAsync(cancellationToken); + + AlertsChanged?.Invoke(); + } + + protected override async Task InitializeDatabaseAsync() + { + await using var conn = await OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS alert_events ( + id TEXT PRIMARY KEY, + market_type INTEGER NOT NULL, + symbol TEXT NOT NULL DEFAULT '', + level INTEGER NOT NULL, + source INTEGER NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + is_read INTEGER NOT NULL DEFAULT 0, + trading_impact INTEGER NOT NULL DEFAULT 0, + occurrence_count INTEGER NOT NULL DEFAULT 1 + ); + CREATE INDEX IF NOT EXISTS idx_alert_events_created ON alert_events(created_at DESC); + CREATE INDEX IF NOT EXISTS idx_alert_events_unread ON alert_events(is_read); + """; + await cmd.ExecuteNonQueryAsync(); + + await PruneExpiredAlertsAsync(); + } + + /// + /// 裁剪超过保留期的历史告警(随启动初始化执行一次)。 + /// created_at 以 ISO-8601 round-trip 文本存储,用 datetime() 解析后比较, + /// 避免不同时区偏移写法下的字符串长度差异导致误判。 + /// + private async Task PruneExpiredAlertsAsync() + { + await using var conn = await OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM alert_events WHERE datetime(created_at) < datetime(@cutoff)"; + cmd.Parameters.AddWithValue( + "@cutoff", (DateTime.UtcNow - RetentionPeriod).ToString("O", CultureInfo.InvariantCulture)); + + var deleted = await cmd.ExecuteNonQueryAsync(); + if (deleted > 0) + Logger.LogInformation("已裁剪 {Count} 条超过 {Days} 天保留期的历史告警", deleted, RetentionPeriod.Days); + } + + private async Task InsertAlertAsync(AlertEvent alert, CancellationToken cancellationToken) + { + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO alert_events (id, market_type, symbol, level, source, title, content, created_at, is_read, trading_impact, occurrence_count) + VALUES (@id, @marketType, @symbol, @level, @source, @title, @content, @createdAt, @isRead, @tradingImpact, @occurrenceCount) + """; + FillParameters(cmd, alert); + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + + private async Task IncrementOccurrenceAsync(string alertId, string latestContent, CancellationToken cancellationToken) + { + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE alert_events + SET occurrence_count = occurrence_count + 1, content = @content + WHERE id = @id + """; + cmd.Parameters.AddWithValue("@content", latestContent); + cmd.Parameters.AddWithValue("@id", alertId); + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + + /// 查找最近一条同类告警(合并目标)。失败时返回 null 退化为新增,不影响告警主链路。 + private async Task FindLatestAlertIdAsync(AlertEvent alert, CancellationToken cancellationToken) + { + try + { + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT id FROM alert_events + WHERE source = @source AND market_type = @marketType AND symbol = @symbol AND title = @title + ORDER BY created_at DESC + LIMIT 1 + """; + cmd.Parameters.AddWithValue("@source", (int)alert.Source); + cmd.Parameters.AddWithValue("@marketType", (int)alert.MarketType); + cmd.Parameters.AddWithValue("@symbol", AlertEvent.NormalizeSymbol(alert.Symbol)); + cmd.Parameters.AddWithValue("@title", alert.Title); + + var result = await cmd.ExecuteScalarAsync(cancellationToken); + return result as string; + } + catch (Exception ex) + { + Logger.LogWarning(ex, "查找合并目标告警失败,将新增记录"); + return null; + } + } + + private static void FillParameters(SqliteCommand cmd, AlertEvent alert) + { + cmd.Parameters.AddWithValue("@id", alert.Id); + cmd.Parameters.AddWithValue("@marketType", (int)alert.MarketType); + cmd.Parameters.AddWithValue("@symbol", AlertEvent.NormalizeSymbol(alert.Symbol)); + cmd.Parameters.AddWithValue("@level", (int)alert.Level); + cmd.Parameters.AddWithValue("@source", (int)alert.Source); + cmd.Parameters.AddWithValue("@title", alert.Title); + cmd.Parameters.AddWithValue("@content", alert.Content); + cmd.Parameters.AddWithValue("@createdAt", alert.CreatedAt.ToString("O")); + cmd.Parameters.AddWithValue("@isRead", alert.IsRead ? 1 : 0); + cmd.Parameters.AddWithValue("@tradingImpact", (int)alert.TradingImpact); + cmd.Parameters.AddWithValue("@occurrenceCount", alert.OccurrenceCount); + } + + /// 按级别分发弹窗:Critical 最醒目且停留最久。 + private void Notify(AlertEvent alert) + { + var message = alert.OccurrenceCount > 1 + ? $"[{LevelText(alert.Level)}] {alert.Title} {alert.Content}(已发生 {alert.OccurrenceCount} 次)" + : $"[{LevelText(alert.Level)}] {alert.Title} {alert.Content}"; + + switch (alert.Level) + { + case AlertLevel.Critical: + _notificationService.ShowError(message, durationMs: 15000); + break; + case AlertLevel.Warning: + _notificationService.ShowWarning(message, durationMs: 10000); + break; + default: + _notificationService.ShowInfo(message, durationMs: 5000); + break; + } + } + + private static string LevelText(AlertLevel level) => level switch + { + AlertLevel.Critical => "紧急", + AlertLevel.Warning => "警告", + _ => "提示" + }; + + /// + /// 免打扰时段判断(按本地时间)。Critical 不受免打扰限制,保证紧急告警必达。 + /// + private static bool IsInQuietHours(UserSetting settings, AlertLevel level) + { + if (!settings.AlertQuietHoursEnabled || level == AlertLevel.Critical) + return false; + + var start = settings.AlertQuietStartHour; + var end = settings.AlertQuietEndHour; + if (start == end) + return false; + + var hour = DateTime.Now.Hour; + return start < end + ? hour >= start && hour < end + : hour >= start || hour < end; + } +} + diff --git a/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertEvent.cs b/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertEvent.cs new file mode 100644 index 0000000..e73c3e2 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertEvent.cs @@ -0,0 +1,106 @@ +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Applications.AlertCenter; + +/// +/// 告警级别:Info(信号/状态变化)、Warning(风险预警)、Critical(紧急,需立即关注)。 +/// +public enum AlertLevel +{ + Info, + + Warning, + + Critical +} + +/// +/// 告警来源:价格告警规则、风险评估器、AI 信号、系统内部。 +/// +public enum AlertSource +{ + PriceAlert, + + Risk, + + Signal, + + System +} + +/// +/// 告警对交易的影响强度。RequireConfirmation 表示该标的的 AI 交易信号在告警触发期间 +/// 升级为需人工确认(告警事件 ≠ 交易指令,不自动下单)。 +/// +public enum AlertTradingImpact +{ + None, + + RequireConfirmation +} + +/// +/// 统一告警事件。除 IsRead/OccurrenceCount 外均为不可变快照。 +/// +public sealed class AlertEvent +{ + public string Id { get; init; } = Guid.NewGuid().ToString("N"); + + public MarketType MarketType { get; init; } = MarketType.AShare; + + /// 标的代码(系统级告警可为空)。 + public string Symbol { get; init; } = string.Empty; + + public AlertLevel Level { get; init; } = AlertLevel.Warning; + + public AlertSource Source { get; init; } + + public string Title { get; init; } = string.Empty; + + public string Content { get; init; } = string.Empty; + + public DateTime CreatedAt { get; init; } = DateTime.UtcNow; + + public bool IsRead { get; set; } + + public AlertTradingImpact TradingImpact { get; init; } = AlertTradingImpact.None; + + /// 合并窗口内重复触发的累计次数(首次为 1)。 + public int OccurrenceCount { get; set; } = 1; + + /// + /// 去重键:同类告警(同来源/市场/标的/标题)在合并窗口内只产生一条记录。 + /// + public string DedupeKey => BuildDedupeKey(Source, MarketType, Symbol, Title); + + public static string BuildDedupeKey(AlertSource source, MarketType marketType, string symbol, string title) => + $"{source}:{marketType}:{NormalizeSymbol(symbol)}:{title}"; + + public static string NormalizeSymbol(string symbol) => + symbol?.Trim().ToUpperInvariant() ?? string.Empty; + + // ---- 展示辅助(UI 历史列表绑定用)---- + + /// 本地时间展示文本。 + public string LocalTimeText => CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"); + + /// 级别文本。 + public string LevelText => Level switch + { + AlertLevel.Critical => "紧急", + AlertLevel.Warning => "警告", + _ => "提示" + }; + + /// 合并次数展示文本(首次触发不显示)。 + public string OccurrenceText => OccurrenceCount > 1 ? $"已发生 {OccurrenceCount} 次" : string.Empty; + + /// 标的展示文本(系统级告警无标的)。 + public string SymbolText => string.IsNullOrWhiteSpace(Symbol) ? "系统" : Symbol; + + public bool IsLevelInfo => Level == AlertLevel.Info; + + public bool IsLevelWarning => Level == AlertLevel.Warning; + + public bool IsLevelCritical => Level == AlertLevel.Critical; +} \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertGate.cs b/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertGate.cs new file mode 100644 index 0000000..9c86481 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertGate.cs @@ -0,0 +1,66 @@ +using System.Collections.Concurrent; +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Applications.AlertCenter; + +/// +/// 告警交易联动门:查询某标的是否存在触发中的确认级告警 +/// (TradingImpact = RequireConfirmation)。命中时 TradeExecutor 强制走人工确认。 +/// +public interface IAlertGate +{ + bool IsGated(MarketType marketType, string symbol); +} + +/// +/// 基于内存计数器的联动门实现:同一标的可叠加多条触发中的确认级告警, +/// 由 AlertCenterService 在告警触发/解除时增减计数。重启后内存态清零可接受 +/// (价格告警条件仍成立时会很快重新触发)。 +/// +public sealed class AlertGate : IAlertGate +{ + private readonly ConcurrentDictionary _activeCount = new(); + + /// 登记一条触发中的确认级告警。 + public void Activate(MarketType marketType, string symbol) + { + _activeCount.AddOrUpdate( + new GateKey(marketType, AlertEvent.NormalizeSymbol(symbol)), + 1, + (_, count) => count + 1); + } + + /// 解除一条确认级告警(计数归零后移除键,防止字典无限增长)。 + public void Deactivate(MarketType marketType, string symbol) + { + var key = new GateKey(marketType, AlertEvent.NormalizeSymbol(symbol)); + while (true) + { + if (!_activeCount.TryGetValue(key, out var count)) + return; + + if (count <= 1) + { + if (((ICollection>)_activeCount).Remove(new KeyValuePair(key, count))) + return; + continue; + } + + if (_activeCount.TryUpdate(key, count - 1, count)) + return; + } + } + + /// + public bool IsGated(MarketType marketType, string symbol) + { + if (string.IsNullOrWhiteSpace(symbol)) + return false; + + return _activeCount.TryGetValue( + new GateKey(marketType, AlertEvent.NormalizeSymbol(symbol)), + out var count) && count > 0; + } + + private readonly record struct GateKey(MarketType MarketType, string Symbol); +} \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertSuppressionPolicy.cs b/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertSuppressionPolicy.cs new file mode 100644 index 0000000..5911deb --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/AlertCenter/AlertSuppressionPolicy.cs @@ -0,0 +1,87 @@ +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Applications.AlertCenter; + +/// +/// 告警抑制纯逻辑(不依赖 IO,便于单元测试): +/// - 同类告警(同去重键)在合并窗口内只产生一条记录并累计次数,不重复弹窗; +/// - 非 Critical 告警受全局每小时配额限制(配额状态全局共享,而非按去重键独立), +/// 超限只落库不弹窗;Critical 不受配额限制。 +/// +public sealed class AlertSuppressionPolicy +{ + /// 同类告警合并窗口:窗口内的重复触发并入最近一条记录。 + public static readonly TimeSpan MergeWindow = TimeSpan.FromMinutes(5); + + private static readonly TimeSpan QuotaWindow = TimeSpan.FromHours(1); + + /// 单个去重键的合并状态。 + public sealed record MergeState(DateTime? LastRaisedAtUtc = null) + { + public static readonly MergeState Empty = new(); + } + + /// 全局每小时弹窗配额状态(固定窗口,随时间滚动重置)。 + public sealed record QuotaState(DateTime WindowStartUtc = default, int Count = 0) + { + public static readonly QuotaState Empty = new(); + } + + /// 该去重键更新后的合并状态(调用方需按 key 保存)。 + /// 更新后的全局配额状态(调用方需保存)。 + /// 是否命中合并窗口(应并入最近一条记录而非新增)。 + /// 是否允许弹窗触达。 + public sealed record Decision( + MergeState NewMergeState, + QuotaState NewQuotaState, + bool IsMerge, + bool ShouldNotify); + + /// + /// 评估一条告警是否允许触达。调用方需线程安全地保存 + /// (按去重键)与 (全局)。 + /// + public Decision Evaluate( + AlertEvent alert, MergeState mergeState, QuotaState quotaState, int hourlyQuota, DateTime utcNow) + { + ArgumentNullException.ThrowIfNull(alert); + + var isMerge = mergeState.LastRaisedAtUtc.HasValue && + utcNow - mergeState.LastRaisedAtUtc.Value < MergeWindow; + + // 配额固定窗口:距窗口起点超过 1 小时则重新计数 + var windowStart = quotaState.WindowStartUtc; + var count = quotaState.Count; + if (windowStart == default || utcNow - windowStart >= QuotaWindow) + { + windowStart = utcNow; + count = 0; + } + + var canNotify = alert.Level == AlertLevel.Critical || count < hourlyQuota; + + // 合并触发不新增记录也不弹窗,因此不消耗配额;可触达的新告警才计数 + if (!isMerge && canNotify) + count++; + + return new Decision( + new MergeState(utcNow), + new QuotaState(windowStart, count), + isMerge, + isMerge ? false : canNotify); + } + + /// + /// A 股价格类告警的休市静默判定:非交易时段只落库不弹窗(休市行情无变化,弹窗无行动价值)。 + /// Critical 级别例外——确认级价格告警需即时触达并驱动交易联动门。 + /// + public static bool IsSilencedByTradingSession(AlertEvent alert, bool isTradingSession) + { + ArgumentNullException.ThrowIfNull(alert); + + return !isTradingSession + && alert.MarketType == MarketType.AShare + && alert.Source == AlertSource.PriceAlert + && alert.Level != AlertLevel.Critical; + } +} \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/Applications/AlertCenter/IAlertCenterService.cs b/src/MarketAssistant.App.Services/Applications/AlertCenter/IAlertCenterService.cs new file mode 100644 index 0000000..fc8fe12 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/AlertCenter/IAlertCenterService.cs @@ -0,0 +1,39 @@ +namespace MarketAssistant.Applications.AlertCenter; + +/// +/// 统一告警中心:收敛价格 / 风险 / 信号三类告警的产出、抑制与触达。 +/// +public interface IAlertCenterService +{ + /// 新告警产生(含合并更新)时触发,UI 据此刷新历史与未读角标。 + event Action? AlertRaised; + + /// 告警集合发生变化(合并、解除、已读)时触发。 + event Action? AlertsChanged; + + /// 初始化数据库表结构,应在应用启动时调用。 + Task InitializeAsync(CancellationToken cancellationToken = default); + + /// + /// 上报一条告警。内部执行冷却合并、每小时配额、免打扰静默等抑制逻辑, + /// 持久化后按级别弹窗触达,并维护确认级交易联动门(IAlertGate)。 + /// + Task RaiseAlertAsync(AlertEvent alert, CancellationToken cancellationToken = default); + + /// + /// 声明某条确认级告警的触发条件已解除(如价格离开告警区间), + /// 释放该标的的交易联动门,使后续 AI 信号恢复正常决策路径。 + /// + Task RaiseAlertClearedAsync( + AlertSource source, MarketType marketType, string symbol, string title, + CancellationToken cancellationToken = default); + + /// 最近的历史告警(新→旧)。 + Task> GetRecentAlertsAsync(int limit = 200, CancellationToken cancellationToken = default); + + /// 未读告警数量(导航角标用)。 + Task GetUnreadCountAsync(CancellationToken cancellationToken = default); + + /// 全部标记已读。 + Task MarkAllReadAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/Applications/Assets/CryptoAssetInfoService.cs b/src/MarketAssistant.App.Services/Applications/Assets/CryptoAssetInfoService.cs index 7f43422..16dfa25 100644 --- a/src/MarketAssistant.App.Services/Applications/Assets/CryptoAssetInfoService.cs +++ b/src/MarketAssistant.App.Services/Applications/Assets/CryptoAssetInfoService.cs @@ -15,6 +15,9 @@ public class CryptoAssetInfoService : IAssetInfoService /// 首页热门资产展示条数(2 列 × 5 行) private const int HotAssetCount = 10; + /// 币安批量 24hr ticker 单次请求的最大交易对数(API 上限 100) + private const int BinanceTickerBatchSize = 100; + private readonly BinanceMarketDataService _binanceService; private readonly CoinGeckoApiService _coinGeckoService; private readonly ICryptoAliasRegistry _aliasRegistry; @@ -83,6 +86,55 @@ public async Task GetAssetInfoAsync(string code, string market = "", return assetInfo; } + /// + /// 批量获取收藏资产行情:仅用一次(按 100 个一组)币安批量 24hr ticker 请求。 + /// 市值字段留空——收藏列表不展示市值,逐个调用 CoinGecko 会触发限流并拖慢首屏; + /// 详情页 仍按需获取市值。 + /// + public async Task> GetAssetInfosAsync( + IReadOnlyList favorites, CancellationToken cancellationToken = default) + { + if (favorites.Count == 0) + { + return []; + } + + var symbols = favorites + .Select(favorite => ToBinanceFormat(favorite.Code)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var tickers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var chunk in symbols.Chunk(BinanceTickerBatchSize)) + { + foreach (var ticker in await _binanceService.Get24hrTickersAsync(chunk.ToList(), cancellationToken)) + { + tickers[ticker.Symbol] = ticker; + } + } + + return favorites.Select(favorite => + { + var symbol = ToBinanceFormat(favorite.Code); + if (!tickers.TryGetValue(symbol, out var ticker)) + { + _logger.LogWarning("批量行情未返回 {Symbol},使用兜底信息展示", symbol); + return (AssetInfo?)CreateFallbackAssetInfo(favorite); + } + + return (AssetInfo?)new AssetInfo + { + Code = symbol, + Name = ExtractBaseCurrency(ticker.Symbol), + MarketType = MarketType.Crypto, + Market = "Binance", + CurrentPrice = PriceFormatter.Format(ticker.LastPrice), + ChangePercentage = FormatPercentage(ticker.PriceChangePercent), + Volume24h = FormatVolume(ticker.Volume) + }; + }).ToList(); + } + public async Task> GetHotAssetsAsync() { var tickers = await _binanceService.GetAll24hrTickersAsync(); @@ -155,6 +207,18 @@ private async Task FetchMarketCapAsync(string baseCurrency, Cancellation #region 辅助方法 + /// + /// 行情缺失时的兜底 AssetInfo:以基础币种名称展示,等待实时行情推送补齐。 + /// + private AssetInfo CreateFallbackAssetInfo(FavoriteAsset favorite) + => new() + { + Code = ToBinanceFormat(favorite.Code), + Name = ExtractBaseCurrency(favorite.Code), + MarketType = MarketType.Crypto, + Market = "Binance" + }; + /// /// 获取所有交易对信息(使用 IMemoryCache 缓存,只获取 TRADING 状态) /// diff --git a/src/MarketAssistant.App.Services/Applications/Assets/IAssetInfoService.cs b/src/MarketAssistant.App.Services/Applications/Assets/IAssetInfoService.cs index 6d8aabc..9705f1e 100644 --- a/src/MarketAssistant.App.Services/Applications/Assets/IAssetInfoService.cs +++ b/src/MarketAssistant.App.Services/Applications/Assets/IAssetInfoService.cs @@ -21,6 +21,43 @@ public interface IAssetInfoService /// 获取热门资产 /// Task> GetHotAssetsAsync(); + + /// + /// 批量获取资产详情(收藏列表等场景)。返回列表与 等长同序,失败项为 null。 + /// 默认实现为受限并发逐个调用 ; + /// 拥有批量行情源的市场(如虚拟币)应覆写为单次批量请求以降低首屏延迟。 + /// + async Task> GetAssetInfosAsync( + IReadOnlyList favorites, CancellationToken cancellationToken = default) + { + var results = new AssetInfo?[favorites.Count]; + const int maxConcurrency = 3; + using var semaphore = new SemaphoreSlim(maxConcurrency); + + var tasks = favorites.Select(async (favorite, index) => + { + await semaphore.WaitAsync(cancellationToken); + try + { + results[index] = await GetAssetInfoAsync(favorite.Code, favorite.Market, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + // 单项失败不影响其余项,调用方以 null 占位跳过 + } + finally + { + semaphore.Release(); + } + }); + + await Task.WhenAll(tasks); + return results.ToList(); + } } diff --git a/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertRule.cs b/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertRule.cs index 2aee5b8..b6b6eb2 100644 --- a/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertRule.cs +++ b/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertRule.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel; +using MarketAssistant.Applications.AlertCenter; using MarketAssistant.Infrastructure.Core; namespace MarketAssistant.Applications.PriceAlert; @@ -17,6 +18,27 @@ public partial class PriceAlertRule : ObservableObject public decimal TargetPrice { get; set; } public bool IsOneTime { get; set; } + /// 最大触发次数,0 表示不限(一次性规则恒为 1)。 + public int MaxTriggerCount { get; set; } + + /// 去抖确认:需连续满足条件的行情 tick 数,0 表示立即触发。 + public int ConfirmTicks { get; set; } + + /// 去抖确认:条件需持续满足的秒数,0 表示立即触发。 + public int ConfirmSeconds { get; set; } + + /// 冷却分钟数:两次触发之间的最小间隔,0 表示不冷却。 + public int CooldownMinutes { get; set; } + + /// 告警对交易的影响:确认级告警触发期间,该标的 AI 信号强制人工确认。 + public AlertTradingImpact TradingImpact { get; set; } = AlertTradingImpact.None; + + // ---- 运行态(不落库,由行情 tick 驱动)---- + private int _consecutiveMetCount; + private DateTime? _conditionMetSinceUtc; + private DateTime? _lastTriggeredAtUtc; + private int _triggerCount; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(StatusText))] private bool _triggered; @@ -82,6 +104,23 @@ public partial class PriceAlertRule : ObservableObject public string AlertModeText => IsOneTime ? "一次性" : "持续"; + /// + /// 高级选项摘要(去抖 / 冷却 / 限次 / 交易联动),全部为默认配置时返回空字符串。 + /// + public string RuleOptionsText + { + get + { + var parts = new List(); + if (ConfirmTicks > 0) parts.Add($"去抖 {ConfirmTicks} 次"); + if (ConfirmSeconds > 0) parts.Add($"持续 {ConfirmSeconds} 秒"); + if (CooldownMinutes > 0) parts.Add($"冷却 {CooldownMinutes} 分"); + if (!IsOneTime && MaxTriggerCount > 0) parts.Add($"限 {MaxTriggerCount} 次"); + if (TradingImpact == AlertTradingImpact.RequireConfirmation) parts.Add("确认级"); + return parts.Count == 0 ? string.Empty : string.Join(" · ", parts); + } + } + public string CurrentPriceText => CurrentPrice?.ToString("0.########") ?? "--"; public string QuoteDisplayValue => CurrentChangePercent.HasValue @@ -117,19 +156,79 @@ public bool IsConditionMet(decimal price, decimal? changePercent = null) } /// - /// 更新触发状态。一次性告警首次命中后保持触发状态;持续告警离开区间后自动复位。 + /// 更新触发状态(含去抖与冷却判定)。返回 true 表示本次应发出告警。 + /// 去抖:条件需连续满足 ConfirmTicks 个行情 tick 且持续 ConfirmSeconds 秒; + /// 冷却:两次触发之间至少间隔 CooldownMinutes 分钟; + /// 一次性规则首次命中后保持触发状态;持续告警离开区间后自动复位。 /// public bool UpdateTriggerState(decimal price, decimal? changePercent = null) { - if (IsOneTime && Triggered) + var nowUtc = DateTime.UtcNow; + var conditionMet = IsConditionMet(price, changePercent); + + if (!conditionMet) + { + // 条件离开区间:复位去抖进度;持续告警自动复位,一次性保持触发 + _consecutiveMetCount = 0; + _conditionMetSinceUtc = null; + if (_alertActive) + { + // 确认级告警随条件离开而失效,标记服务层解除交易联动门 + _pendingGateClear = TradingImpact == AlertTradingImpact.RequireConfirmation; + _alertActive = false; + } + Triggered = IsOneTime && Triggered; return false; + } - var conditionMet = IsConditionMet(price, changePercent); - var shouldNotify = conditionMet && !Triggered; - Triggered = IsOneTime ? Triggered || conditionMet : conditionMet; - return shouldNotify; + // 达到触发上限后不再告警,但仍需跟踪条件离开以释放联动门 + if (IsTriggerLimitReached || Triggered) + return false; + + // 去抖:连续 tick 数与持续秒数需同时满足(配置为 0 的维度不限制) + _consecutiveMetCount++; + if (ConfirmTicks > 0 && _consecutiveMetCount < ConfirmTicks) + return false; + + _conditionMetSinceUtc ??= nowUtc; + if (ConfirmSeconds > 0 && (nowUtc - _conditionMetSinceUtc.Value).TotalSeconds < ConfirmSeconds) + return false; + + // 冷却:两次触发之间的最小间隔 + if (CooldownMinutes > 0 && _lastTriggeredAtUtc.HasValue && + (nowUtc - _lastTriggeredAtUtc.Value).TotalMinutes < CooldownMinutes) + return false; + + Triggered = true; + _triggerCount++; + _lastTriggeredAtUtc = nowUtc; + _consecutiveMetCount = 0; + _conditionMetSinceUtc = null; + _pendingGateClear = false; + return true; } + /// 是否达到触发次数上限(一次性规则恒为 1 次,MaxTriggerCount > 0 时按配置限制)。 + public bool IsTriggerLimitReached => + IsOneTime ? _triggerCount >= 1 : MaxTriggerCount > 0 && _triggerCount >= MaxTriggerCount; + + /// 告警已实际发出后调用:标记本次告警进入生效期(条件尚未离开区间)。 + public void NotifyTriggered() => _alertActive = true; + + /// + /// 条件已离开区间且需要解除交易联动门;服务层解除后必须调用 复位。 + /// + public bool ShouldClearTradingGate => _pendingGateClear; + + /// 解除交易联动门后调用:结束本次告警生效期。 + public void ResetTradingGate() => _pendingGateClear = false; + + /// 最近一次触发的告警是否仍在条件区间内生效。 + private bool _alertActive; + + /// 条件离开区间后待服务层处理的联动门解除标记。 + private bool _pendingGateClear; + /// /// 更新运行态行情。运行态数据不写入数据库,由行情源持续刷新。 /// diff --git a/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertService.cs b/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertService.cs index 36ee427..74506f8 100644 --- a/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertService.cs +++ b/src/MarketAssistant.App.Services/Applications/PriceAlert/PriceAlertService.cs @@ -1,9 +1,9 @@ using System.Globalization; using System.Text.Json; +using MarketAssistant.Applications.AlertCenter; using MarketAssistant.DataProviders; using MarketAssistant.DataProviders.AShare; using MarketAssistant.Infrastructure.Core; -using MarketAssistant.Services.Notification; using MarketAssistant.Services.Settings; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; @@ -21,7 +21,7 @@ public sealed class PriceAlertService : SqliteServiceBase, IDisposable, IAsyncDi private static readonly TimeSpan ASharePollingInterval = TimeSpan.FromSeconds(20); private readonly BinanceWebSocketService _wsService; - private readonly INotificationService _notificationService; + private readonly IAlertCenterService _alertCenterService; private readonly IUserSettingService _userSettingService; private readonly ClsQuoteClient _clsClient; @@ -49,14 +49,14 @@ public IReadOnlyList Rules public PriceAlertService( BinanceWebSocketService wsService, - INotificationService notificationService, + IAlertCenterService alertCenterService, IUserSettingService userSettingService, ClsQuoteClient clsClient, ILogger logger) : base(logger) { _wsService = wsService; - _notificationService = notificationService; + _alertCenterService = alertCenterService; _userSettingService = userSettingService; _clsClient = clsClient; @@ -103,7 +103,12 @@ CREATE TABLE IF NOT EXISTS price_alert_rules ( is_one_time INTEGER NOT NULL DEFAULT 0, triggered INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + max_trigger_count INTEGER NOT NULL DEFAULT 0, + confirm_ticks INTEGER NOT NULL DEFAULT 0, + confirm_seconds INTEGER NOT NULL DEFAULT 0, + cooldown_minutes INTEGER NOT NULL DEFAULT 0, + trading_impact INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_alert_mt ON price_alert_rules(market_type); CREATE INDEX IF NOT EXISTS idx_alert_enabled ON price_alert_rules(enabled, market_type); @@ -130,6 +135,35 @@ created_at TEXT NOT NULL alterCmd.CommandText = "ALTER TABLE price_alert_rules ADD COLUMN is_one_time INTEGER NOT NULL DEFAULT 0"; await alterCmd.ExecuteNonQueryAsync(); } + + // 新增列迁移:max_trigger_count / confirm_ticks / confirm_seconds / cooldown_minutes / trading_impact + var existingColumns = new HashSet(StringComparer.OrdinalIgnoreCase); + await using var columnsCmd = conn.CreateCommand(); + columnsCmd.CommandText = "PRAGMA table_info(price_alert_rules)"; + await using (var columnsReader = await columnsCmd.ExecuteReaderAsync()) + { + while (await columnsReader.ReadAsync()) + existingColumns.Add(columnsReader.GetString(1)); + } + + var migrations = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["max_trigger_count"] = "ALTER TABLE price_alert_rules ADD COLUMN max_trigger_count INTEGER NOT NULL DEFAULT 0", + ["confirm_ticks"] = "ALTER TABLE price_alert_rules ADD COLUMN confirm_ticks INTEGER NOT NULL DEFAULT 0", + ["confirm_seconds"] = "ALTER TABLE price_alert_rules ADD COLUMN confirm_seconds INTEGER NOT NULL DEFAULT 0", + ["cooldown_minutes"] = "ALTER TABLE price_alert_rules ADD COLUMN cooldown_minutes INTEGER NOT NULL DEFAULT 0", + ["trading_impact"] = "ALTER TABLE price_alert_rules ADD COLUMN trading_impact INTEGER NOT NULL DEFAULT 0" + }; + + foreach (var (column, alterSql) in migrations) + { + if (existingColumns.Contains(column)) + continue; + + await using var alterCmd = conn.CreateCommand(); + alterCmd.CommandText = alterSql; + await alterCmd.ExecuteNonQueryAsync(); + } } public async Task AddRuleAsync(PriceAlertRule rule, CancellationToken cancellationToken = default) @@ -139,8 +173,8 @@ public async Task AddRuleAsync(PriceAlertRule rule, CancellationToken cancellati await using var conn = await OpenConnectionAsync(cancellationToken); await using var cmd = conn.CreateCommand(); cmd.CommandText = """ - INSERT INTO price_alert_rules (id, asset_code, asset_name, market_type, condition, target_price, is_one_time, triggered, enabled, created_at) - VALUES (@id, @assetCode, @assetName, @marketType, @condition, @targetPrice, @isOneTime, @triggered, @enabled, @createdAt) + INSERT INTO price_alert_rules (id, asset_code, asset_name, market_type, condition, target_price, is_one_time, triggered, enabled, created_at, max_trigger_count, confirm_ticks, confirm_seconds, cooldown_minutes, trading_impact) + VALUES (@id, @assetCode, @assetName, @marketType, @condition, @targetPrice, @isOneTime, @triggered, @enabled, @createdAt, @maxTriggerCount, @confirmTicks, @confirmSeconds, @cooldownMinutes, @tradingImpact) """; cmd.Parameters.AddWithValue("@id", rule.Id); cmd.Parameters.AddWithValue("@assetCode", rule.AssetCode); @@ -152,6 +186,11 @@ INSERT INTO price_alert_rules (id, asset_code, asset_name, market_type, conditio cmd.Parameters.AddWithValue("@triggered", rule.Triggered ? 1 : 0); cmd.Parameters.AddWithValue("@enabled", rule.Enabled ? 1 : 0); cmd.Parameters.AddWithValue("@createdAt", rule.CreatedAt.ToString("O")); + cmd.Parameters.AddWithValue("@maxTriggerCount", rule.MaxTriggerCount); + cmd.Parameters.AddWithValue("@confirmTicks", rule.ConfirmTicks); + cmd.Parameters.AddWithValue("@confirmSeconds", rule.ConfirmSeconds); + cmd.Parameters.AddWithValue("@cooldownMinutes", rule.CooldownMinutes); + cmd.Parameters.AddWithValue("@tradingImpact", (int)rule.TradingImpact); await cmd.ExecuteNonQueryAsync(cancellationToken); lock (_syncRoot) @@ -188,6 +227,11 @@ public async Task RemoveRuleAsync(string ruleId, CancellationToken cancellationT _rules.RemoveAll(r => r.Id == ruleId); } + // 规则删除后不再跟踪其条件区间,必须释放可能残留的确认级联动门, + // 否则该标的的 AI 交易信号会被永久强制人工确认(仅重启可解) + if (rule.TradingImpact == AlertTradingImpact.RequireConfirmation) + await RaiseAlertClearedSafeAsync(rule); + RulesChanged?.Invoke(); QueueCryptoSubscriptionRefresh(); } @@ -223,6 +267,11 @@ public async Task ToggleRuleAsync(string ruleId, CancellationToken cancellationT rule.Triggered = false; } + // 禁用确认级规则时释放交易联动门:规则已不再跟踪条件区间, + // 否则该标的 AI 交易信号会被永久强制人工确认(仅重启可解) + if (!newEnabled && rule.TradingImpact == AlertTradingImpact.RequireConfirmation) + await RaiseAlertClearedSafeAsync(rule); + RulesChanged?.Invoke(); QueueCryptoSubscriptionRefresh(); } @@ -262,6 +311,16 @@ private async Task PollASharePricesAsync(CancellationToken cancellationToken) .ToList(); } + // 非交易时段仅评估确认级规则:休市行情无变化,普通规则反复触发无行动价值且浪费轮询请求; + // 确认级规则映射为 Critical 告警,需保证交易联动门及时登记(弹窗静默由告警中心统一处理) + if (!AShareTradingHours.IsTradingSession()) + rules = rules + .Where(r => r.TradingImpact == AlertTradingImpact.RequireConfirmation) + .ToList(); + + if (rules.Count == 0) + continue; + foreach (var rule in rules) { var quote = await GetAShareLatestQuoteAsync(rule.AssetCode, cancellationToken); @@ -317,6 +376,7 @@ private void UpdateRuleQuoteAndEvaluate(string ruleId, decimal lastPrice, decima { PriceAlertRule? rule; bool shouldNotify; + bool shouldClearGate; bool autoDisabled; lock (_syncRoot) { @@ -325,6 +385,13 @@ private void UpdateRuleQuoteAndEvaluate(string ruleId, decimal lastPrice, decima rule.UpdateQuote(lastPrice, changePercent, DateTime.UtcNow); shouldNotify = rule.UpdateTriggerState(lastPrice, changePercent); + if (shouldNotify) + rule.NotifyTriggered(); + + // 条件离开区间:确认级告警释放交易联动门(AI 信号恢复正常决策路径) + shouldClearGate = rule.ShouldClearTradingGate; + if (shouldClearGate) + rule.ResetTradingGate(); // 一次性告警首次触发后自动停用;重新启用需用户手动操作开关 autoDisabled = shouldNotify && rule.IsOneTime; @@ -334,8 +401,13 @@ private void UpdateRuleQuoteAndEvaluate(string ruleId, decimal lastPrice, decima RuleQuoteUpdated?.Invoke(rule); + if (shouldClearGate) + _ = RaiseAlertClearedSafeAsync(rule); + if (autoDisabled) { + // 一次性规则触发后即停用,不再跟踪条件区间。其确认级告警在上报时已按 IsOneTime + // 降为 None、未登记联动门,无需在此释放;仅持久化停用状态并刷新订阅 _ = PersistRuleDisabledAsync(ruleId); QueueCryptoSubscriptionRefresh(); } @@ -343,24 +415,72 @@ private void UpdateRuleQuoteAndEvaluate(string ruleId, decimal lastPrice, decima if (!shouldNotify) return; - if (IsNotificationEnabled()) + _ = RaiseAlertSafeAsync(rule, lastPrice, changePercent); + + RulesChanged?.Invoke(); + } + + /// 确认级告警条件解除后释放交易联动门(fire-and-forget,异常仅记录)。 + private async Task RaiseAlertClearedSafeAsync(PriceAlertRule rule) + { + try + { + await _alertCenterService.RaiseAlertClearedAsync( + AlertSource.PriceAlert, rule.MarketType, rule.AssetCode, BuildAlertTitle(rule)); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "解除价格告警交易联动失败: {RuleId}", rule.Id); + } + } + + /// 价格告警统一上报告警中心(fire-and-forget,异常仅记录,不阻断行情链路)。 + private async Task RaiseAlertSafeAsync(PriceAlertRule rule, decimal lastPrice, decimal? changePercent) + { + try { - var targetText = rule.IsPercentCondition ? $"{rule.TargetPrice:N2}%" : rule.TargetPrice.ToString("N2"); - var valueText = rule.IsPercentCondition && changePercent.HasValue ? $"{changePercent.Value:N2}%" : lastPrice.ToString("N2"); - var direction = rule.Condition switch + await _alertCenterService.RaiseAlertAsync(new AlertEvent { - AlertCondition.PriceAbove => "涨破", - AlertCondition.PriceBelow => "跌破", - AlertCondition.ChangePercentAbove => "涨幅超过", - AlertCondition.ChangePercentBelow => "跌幅超过", - _ => "达到" - }; - _notificationService.ShowWarning( - $"🔔 {rule.AssetName}({rule.AssetCode}) 当前{valueText},已{direction}目标 {targetText}", - durationMs: 10000); + MarketType = rule.MarketType, + Symbol = rule.AssetCode, + Level = rule.TradingImpact == AlertTradingImpact.RequireConfirmation + ? AlertLevel.Critical + : AlertLevel.Warning, + Source = AlertSource.PriceAlert, + Title = BuildAlertTitle(rule), + Content = BuildAlertContent(rule, lastPrice, changePercent), + // 一次性规则触发即停用,不存在"持续触发中"状态,不登记交易联动门, + // 否则门随规则停用后永不释放,该标的 AI 信号会被永久强制人工确认 + TradingImpact = rule.IsOneTime ? AlertTradingImpact.None : rule.TradingImpact + }); } + catch (Exception ex) + { + Logger.LogWarning(ex, "上报价格告警失败: {RuleId}", rule.Id); + } + } - RulesChanged?.Invoke(); + private static string BuildAlertTitle(PriceAlertRule rule) + { + var targetText = rule.IsPercentCondition ? $"{rule.TargetPrice:N2}%" : rule.TargetPrice.ToString("N2"); + return $"{rule.AssetName}({rule.AssetCode}) {ConditionText(rule.Condition)} {targetText}"; + } + + private static string ConditionText(AlertCondition condition) => condition switch + { + AlertCondition.PriceAbove => "涨破", + AlertCondition.PriceBelow => "跌破", + AlertCondition.ChangePercentAbove => "涨幅超过", + AlertCondition.ChangePercentBelow => "跌幅超过", + _ => "达到" + }; + + private static string BuildAlertContent(PriceAlertRule rule, decimal lastPrice, decimal? changePercent) + { + var valueText = rule.IsPercentCondition && changePercent.HasValue + ? $"{changePercent.Value:N2}%" + : lastPrice.ToString("N2"); + return $"当前 {valueText},已进入告警区间"; } private void QueueCryptoSubscriptionRefresh() @@ -430,7 +550,7 @@ private async Task LoadRulesAsync(CancellationToken cancellationToken = default) await using var conn = await OpenConnectionAsync(cancellationToken); await using var cmd = conn.CreateCommand(); cmd.CommandText = """ - SELECT id, asset_code, asset_name, market_type, condition, target_price, is_one_time, triggered, enabled, created_at + SELECT id, asset_code, asset_name, market_type, condition, target_price, is_one_time, triggered, enabled, created_at, max_trigger_count, confirm_ticks, confirm_seconds, cooldown_minutes, trading_impact FROM price_alert_rules """; @@ -449,7 +569,12 @@ FROM price_alert_rules IsOneTime = reader.GetInt32(6) != 0, Triggered = false, Enabled = reader.GetInt32(8) != 0, - CreatedAt = DateTime.Parse(reader.GetString(9), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) + CreatedAt = DateTime.Parse(reader.GetString(9), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), + MaxTriggerCount = reader.GetInt32(10), + ConfirmTicks = reader.GetInt32(11), + ConfirmSeconds = reader.GetInt32(12), + CooldownMinutes = reader.GetInt32(13), + TradingImpact = (AlertTradingImpact)reader.GetInt32(14) }); } @@ -526,9 +651,4 @@ public void Dispose() _pollingCts.Dispose(); GC.SuppressFinalize(this); } - - private bool IsNotificationEnabled() - { - return _userSettingService.CurrentSetting.Notification; - } } diff --git a/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs b/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs index aae1ca5..77a3352 100644 --- a/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs +++ b/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs @@ -60,6 +60,38 @@ public string KnowledgeFileDirectory /// public bool Notification { get; set; } = true; + /// 每小时告警弹窗配额上限(Critical 不受限)。 + private int _alertHourlyQuota = 20; + public int AlertHourlyQuota + { + get => _alertHourlyQuota; + set => SetProperty(ref _alertHourlyQuota, Math.Clamp(value, 1, 200)); + } + + /// 是否启用告警免打扰时段(Critical 不受免打扰限制)。 + private bool _alertQuietHoursEnabled; + public bool AlertQuietHoursEnabled + { + get => _alertQuietHoursEnabled; + set => SetProperty(ref _alertQuietHoursEnabled, value); + } + + /// 免打扰起始小时(本地时间 0-23)。 + private int _alertQuietStartHour = 23; + public int AlertQuietStartHour + { + get => _alertQuietStartHour; + set => SetProperty(ref _alertQuietStartHour, Math.Clamp(value, 0, 23)); + } + + /// 免打扰结束小时(本地时间 0-23,支持跨零点区间)。 + private int _alertQuietEndHour = 7; + public int AlertQuietEndHour + { + get => _alertQuietEndHour; + set => SetProperty(ref _alertQuietEndHour, Math.Clamp(value, 0, 23)); + } + [JsonIgnore] public string ZhiTuApiToken { get; set; } = ""; @@ -190,6 +222,10 @@ public UserSetting Clone() LoadKnowledge = LoadKnowledge, KnowledgeFileDirectory = KnowledgeFileDirectory, Notification = Notification, + AlertHourlyQuota = AlertHourlyQuota, + AlertQuietHoursEnabled = AlertQuietHoursEnabled, + AlertQuietStartHour = AlertQuietStartHour, + AlertQuietEndHour = AlertQuietEndHour, ZhiTuApiToken = ZhiTuApiToken, ThemeMode = ThemeMode, CurrentMarketType = CurrentMarketType, diff --git a/src/MarketAssistant.App.Services/Services/Market/AShareMarketModule.cs b/src/MarketAssistant.App.Services/Services/Market/AShareMarketModule.cs index 27c9c44..843cf6b 100644 --- a/src/MarketAssistant.App.Services/Services/Market/AShareMarketModule.cs +++ b/src/MarketAssistant.App.Services/Services/Market/AShareMarketModule.cs @@ -35,6 +35,7 @@ public void Register(IServiceCollection services) services.AddKeyedSingleton(MarketType.AShare); services.AddKeyedSingleton(MarketType.AShare); services.AddKeyedSingleton(MarketType.AShare); + services.AddKeyedSingleton(MarketType.AShare); // 快讯 & 新闻 services.AddKeyedSingleton(MarketType.AShare); diff --git a/src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs b/src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs index ebea7f5..f59b406 100644 --- a/src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs +++ b/src/MarketAssistant.App.Services/Services/Market/CryptoMarketModule.cs @@ -39,6 +39,7 @@ public void Register(IServiceCollection services) services.AddKeyedSingleton(MarketType.Crypto); services.AddKeyedSingleton(MarketType.Crypto); services.AddKeyedSingleton(MarketType.Crypto); + services.AddKeyedSingleton(MarketType.Crypto); // 快讯 & 新闻 services.AddKeyedSingleton(MarketType.Crypto); diff --git a/src/MarketAssistant.App.Services/Services/Market/NoopOnChainTools.cs b/src/MarketAssistant.App.Services/Services/Market/NoopOnChainTools.cs new file mode 100644 index 0000000..ef4c9d0 --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/Market/NoopOnChainTools.cs @@ -0,0 +1,14 @@ +using MarketAssistant.Agents.Tools.Abstractions; +using Microsoft.Extensions.AI; + +namespace MarketAssistant.Services.Market; + +/// +/// 无链上数据市场(A 股)的空实现:无任何工具函数, +/// 分析师即使声明 [RequiresTools(typeof(IOnChainTools))] 也不会获得链上工具, +/// 工厂对"接口已注册但无函数"与"接口缺失"均正常降级,不产生告警噪音。 +/// +public sealed class NoopOnChainTools : IOnChainTools +{ + public IEnumerable GetFunctions() => []; +} \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs index c473bb2..02e621b 100644 --- a/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs +++ b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using MarketAssistant.Agents.PromptConfiguration; using MarketAssistant.Agents.Trading; using MarketAssistant.Agents.InvestmentSelection; +using MarketAssistant.Applications.AlertCenter; using MarketAssistant.Applications.Analysis; using MarketAssistant.Agents.InvestmentSelection.Executors; using MarketAssistant.Agents.InvestmentSelection.Strategies; @@ -34,6 +35,7 @@ using MarketAssistant.Services.Cache; using MarketAssistant.DataProviders; using MarketAssistant.DataProviders.AShare; +using MarketAssistant.DataProviders.Web3; using MarketAssistant.Rag.Interfaces; using MarketAssistant.Rag.Services; using MarketAssistant.Services.Market; @@ -67,6 +69,7 @@ public static IServiceCollection AddBusinessServices(this IServiceCollection ser services.AddMemoryCache(); services.AddNamedMarketHttpClients(); services.AddAShareDataProviders(); + services.AddWeb3DataProviders(); services.AddAgentTools(); services.AddAgentInfrastructure(); services.AddRagServices(); @@ -224,6 +227,22 @@ private static IServiceCollection AddHttpClientsCore(IServiceCollection services client.Timeout = TimeSpan.FromSeconds(10); }).AddStandardResilienceHandler(); + // Web3 链上数据:DexScreener(多链 DEX 行情,免费无 Key)与 GoPlus(安全审计/蜜罐检测,免费有限速) + services.AddHttpClient("DexScreener", client => + { + client.BaseAddress = new Uri("https://api.dexscreener.com/"); + client.Timeout = TimeSpan.FromSeconds(20); + client.DefaultRequestHeaders.UserAgent.ParseAdd("MarketAssistant/1.0"); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + }).AddStandardResilienceHandler(); + + services.AddHttpClient("GoPlus", client => + { + client.BaseAddress = new Uri("https://api.gopluslabs.io/"); + client.Timeout = TimeSpan.FromSeconds(20); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + }).AddStandardResilienceHandler(); + services.AddHttpClient("GitHub", client => { client.BaseAddress = new Uri(AppInfo.GitHubApiBaseUrl); @@ -359,6 +378,12 @@ private static IServiceCollection AddMarketDataServices(this IServiceCollection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + + // 统一告警中心:AlertGate 单实例(AlertCenterService 与 TradeExecutor 共享) + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -394,6 +419,8 @@ private static IServiceCollection AddTradingServices(this IServiceCollection ser services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs index 4d7bd68..09ad5ac 100644 --- a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs +++ b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs @@ -3,7 +3,6 @@ using System.Threading.Channels; using MarketAssistant.Applications.Crypto; using MarketAssistant.DataProviders; -using MarketAssistant.Services.Notification; using MarketAssistant.Trading.Models; using Microsoft.Extensions.Logging; @@ -23,7 +22,8 @@ public class MarketMonitor : IDisposable private readonly OrderStateSyncService _orderStateSyncService; private readonly TradingStrategyService _strategyService; private readonly TradingDataService _dataService; - private readonly INotificationService _notificationService; + private readonly RiskAlertEvaluator _riskAlertEvaluator; + private readonly SignalAlertEvaluator _signalAlertEvaluator; private readonly ILogger _logger; private readonly SemaphoreSlim _lifecycleLock = new(1, 1); @@ -79,6 +79,9 @@ public class MarketMonitor : IDisposable // 适配 BinanceWebSocketService.PriceUpdated 事件签名(Action) // 到不含 changePercent 的 OnPriceUpdated 处理方法,需存储委托实例以支持 -= 取消订阅 private readonly Action _priceUpdatedAdapter; + // 适配 ConnectionInterrupted/ConnectionRestored(Action)以便 Dispose 时 -= + private readonly Action _connectionInterruptedAdapter; + private readonly Action _connectionRestoredAdapter; public bool IsRunning => _isRunning; @@ -101,7 +104,8 @@ public MarketMonitor( TradingStrategyService strategyService, TradingDataService dataService, BinanceUserDataStreamService userDataStreamService, - INotificationService notificationService, + RiskAlertEvaluator riskAlertEvaluator, + SignalAlertEvaluator signalAlertEvaluator, ILogger logger) { _webSocketService = webSocketService; @@ -112,9 +116,16 @@ public MarketMonitor( _strategyService = strategyService; _dataService = dataService; _userDataStreamService = userDataStreamService; - _notificationService = notificationService; + _riskAlertEvaluator = riskAlertEvaluator; + _signalAlertEvaluator = signalAlertEvaluator; _logger = logger; _priceUpdatedAdapter = (symbol, lastPrice, _) => OnPriceUpdated(symbol, lastPrice); + _connectionInterruptedAdapter = () => + _ = _riskAlertEvaluator.NotifyConnectionInterruptedSafeAsync(); + _connectionRestoredAdapter = () => + _ = _riskAlertEvaluator.NotifyConnectionRestoredSafeAsync(); + _webSocketService.ConnectionInterrupted += _connectionInterruptedAdapter; + _webSocketService.ConnectionRestored += _connectionRestoredAdapter; _strategyService.StrategiesChanged += OnStrategiesChanged; } @@ -247,6 +258,9 @@ public async Task RefreshSubscriptionsAsync() private void OnPriceUpdated(string symbol, decimal lastPrice) { _priceChannel.Writer.TryWrite((symbol, lastPrice)); + + // 风险巡检自带 60 秒节流,价格驱动即可,不必单独起定时器 + _ = _riskAlertEvaluator.EvaluateAccountRiskSafeAsync(MonitorToken); } /// @@ -400,6 +414,10 @@ private async Task HandleTriggeredStrategyAsync(TradingStrategy strategy, decima if (result.TradeExecuted && result.Record != null) TradeExecuted?.Invoke(result.Record); + // 信号告警留痕:AI 信号产出并成交时上报 Info,使告警历史成为自动化决策的审计线索 + if (result.Outcome == AISignalOutcome.Executed) + await _signalAlertEvaluator.NotifySignalExecutedSafeAsync(strategy, currentPrice); + // HOLD/无持仓完结是正常路径:不进失败冷却,重试频率由 // StrategyEngine 的 LastTriggeredAt(analysisInterval)节流 if (result.Outcome != AISignalOutcome.NoTrade) @@ -459,6 +477,7 @@ private async Task CompleteOneShotStrategyAsync(TradingStrategy strategy) _strategyFailureCooldowns.TryRemove(strategy.Id, out _); _logger.LogInformation( "一次性策略已执行并完结: {StrategyId} {Type} {Symbol}", strategy.Id, strategy.Type, strategy.Symbol); + await _signalAlertEvaluator.NotifyStrategyCompletedSafeAsync(strategy); } catch (Exception ex) { @@ -486,6 +505,7 @@ private async Task ApplyTradeFailurePolicy(TradingStrategy strategy, bool succes { _strategyFailureCooldowns.TryRemove(strategy.Id, out _); await PauseStrategyAfterRejectionAsync(strategy, tradeResult); + await _riskAlertEvaluator.EvaluateRejectionSafeAsync(strategy, tradeResult); return; } @@ -496,7 +516,7 @@ private async Task ApplyTradeFailurePolicy(TradingStrategy strategy, bool succes /// /// 拒绝类失败后暂停策略:状态置为 Paused(用户可在策略页重新启用), - /// 并弹通知让用户知晓(策略静默停摆比反复重试更危险)。 + /// 并经告警中心让用户知晓(策略静默停摆比反复重试更危险)。 /// private async Task PauseStrategyAfterRejectionAsync(TradingStrategy strategy, TradeResult? tradeResult) { @@ -508,8 +528,7 @@ private async Task PauseStrategyAfterRejectionAsync(TradingStrategy strategy, Tr try { await _strategyService.UpdateStrategyStatusAsync(strategy.Id, StrategyStatus.Paused, MonitorToken); - _notificationService.ShowWarning( - $"⚠ 策略已自动暂停:{strategy.Symbol} {strategy.Type} — {reason}"); + await _signalAlertEvaluator.NotifyStrategyPausedSafeAsync(strategy, tradeResult); } catch (Exception ex) { @@ -578,6 +597,7 @@ private async Task CheckStrategyCompletionAsync(TradingStrategy strategy) await _strategyEngine.ClearPeakPriceAsync(strategy.Id); _strategyLocks.TryRemove(strategy.Id, out _); _strategyFailureCooldowns.TryRemove(strategy.Id, out _); + await _signalAlertEvaluator.NotifyStrategyCompletedSafeAsync(updated); } } @@ -618,6 +638,8 @@ public void Dispose() TradeExecuted = null; StatusChanged = null; _webSocketService.PriceUpdated -= _priceUpdatedAdapter; + _webSocketService.ConnectionInterrupted -= _connectionInterruptedAdapter; + _webSocketService.ConnectionRestored -= _connectionRestoredAdapter; _userDataStreamService.OrderUpdate -= OnOrderUpdate; _strategyService.StrategiesChanged -= OnStrategiesChanged; diff --git a/src/MarketAssistant.App.Services/Trading/RiskAlertEvaluator.cs b/src/MarketAssistant.App.Services/Trading/RiskAlertEvaluator.cs new file mode 100644 index 0000000..9bf6932 --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/RiskAlertEvaluator.cs @@ -0,0 +1,167 @@ +using System.Collections.Concurrent; +using MarketAssistant.Applications.AlertCenter; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Trading.Models; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Services.Trading; + +/// +/// 风险告警评估器:把风险状态收敛为统一告警(AlertSource.Risk)。 +/// - 账户回撤达熔断阈值的 80% → Warning(60 秒节流,避免每 tick 重复); +/// - 策略信号被风控拒绝 → Warning; +/// - 行情连接中断 → Critical / 恢复 → Info。 +/// +public sealed class RiskAlertEvaluator +{ + private static readonly TimeSpan RiskCheckThrottle = TimeSpan.FromSeconds(60); + + private readonly IAlertCenterService _alertCenterService; + private readonly TradingDataService _dataService; + private readonly CryptoPortfolioService _portfolioService; + private readonly ILogger _logger; + + private DateTime _lastAccountRiskCheckUtc = DateTime.MinValue; + private volatile bool _connectionAlertActive; + + public RiskAlertEvaluator( + IAlertCenterService alertCenterService, + TradingDataService dataService, + CryptoPortfolioService portfolioService, + ILogger logger) + { + _alertCenterService = alertCenterService; + _dataService = dataService; + _portfolioService = portfolioService; + _logger = logger; + } + + /// + /// 评估账户回撤风险:回撤达熔断阈值 80% 时发 Warning。 + /// 自带 60 秒节流,可安全地在每个价格 tick 调用。 + /// + public async Task EvaluateAccountRiskSafeAsync(CancellationToken ct) + { + // 节流:价格 tick 高频,账户快照/峰值查询成本较高 + var now = DateTime.UtcNow; + if (now - _lastAccountRiskCheckUtc < RiskCheckThrottle) + return; + _lastAccountRiskCheckUtc = now; + + try + { + var config = await _dataService.LoadRiskConfigAsync(ct).ConfigureAwait(false); + if (config.MaxDrawdownPercent <= 0) + return; + + var summary = await _portfolioService.GetAccountBalanceSummaryAsync(ct).ConfigureAwait(false); + var totalValue = summary.TotalValueUSDT; + if (totalValue <= 0) + return; + + var peakValue = await _dataService + .GetPeakAccountValueAsync(now.AddDays(-30), ct) + .ConfigureAwait(false); + if (peakValue <= 0) + return; + + var drawdownPercent = (peakValue - totalValue) / peakValue * 100; + var warningThreshold = config.MaxDrawdownPercent * 0.8m; + if (drawdownPercent < warningThreshold) + return; + + await _alertCenterService.RaiseAlertAsync(new AlertEvent + { + MarketType = MarketType.Crypto, + Symbol = string.Empty, + Level = AlertLevel.Warning, + Source = AlertSource.Risk, + Title = "账户回撤接近熔断阈值", + Content = $"当前回撤 {drawdownPercent:F1}%,熔断阈值 {config.MaxDrawdownPercent:F0}%(预警线 80%),请关注仓位风险", + TradingImpact = AlertTradingImpact.None + }, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _logger.LogWarning(ex, "账户回撤风险评估失败"); + } + } + + /// 策略信号被风控拒绝时发 Warning(用户主动拒绝的交易不上报)。 + public async Task EvaluateRejectionSafeAsync(TradingStrategy strategy, TradeResult? tradeResult) + { + var reason = tradeResult?.ErrorMessage ?? "交易被拒绝"; + // 用户主动拒绝 / 确认超时类失败由策略状态信号告警(AlertSource.Signal)覆盖,避免同一事件双告警 + if (TradeRejectionReason.IsUserRejection(reason)) + return; + + try + { + await _alertCenterService.RaiseAlertAsync(new AlertEvent + { + MarketType = MarketType.Crypto, + Symbol = strategy.Symbol, + Level = AlertLevel.Warning, + Source = AlertSource.Risk, + Title = $"策略信号被风控拒绝({strategy.Type})", + Content = $"{reason}。策略已自动暂停,请检查风控配置或持仓状态", + TradingImpact = AlertTradingImpact.None + }).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "上报风控拒绝告警失败: {StrategyId}", strategy.Id); + } + } + + /// 行情连接中断:Critical 告警(重复中断由告警中心 5 分钟合并窗口去重)。 + public async Task NotifyConnectionInterruptedSafeAsync() + { + _connectionAlertActive = true; + try + { + await _alertCenterService.RaiseAlertAsync(new AlertEvent + { + MarketType = MarketType.Crypto, + Symbol = string.Empty, + Level = AlertLevel.Critical, + Source = AlertSource.System, + Title = "行情连接中断", + Content = "Binance WebSocket 连接中断,正在自动重连。中断期间止损/止盈等策略可能无法及时执行", + TradingImpact = AlertTradingImpact.None + }).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "上报行情连接中断告警失败"); + } + } + + /// 行情连接恢复:Info 告警。 + public async Task NotifyConnectionRestoredSafeAsync() + { + if (!_connectionAlertActive) + return; + _connectionAlertActive = false; + + try + { + await _alertCenterService.RaiseAlertAsync(new AlertEvent + { + MarketType = MarketType.Crypto, + Symbol = string.Empty, + Level = AlertLevel.Info, + Source = AlertSource.System, + Title = "行情连接恢复", + Content = "Binance WebSocket 连接已恢复,策略监控继续运行" + }).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "上报行情连接恢复告警失败"); + } + } +} \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/Trading/SignalAlertEvaluator.cs b/src/MarketAssistant.App.Services/Trading/SignalAlertEvaluator.cs new file mode 100644 index 0000000..2e84a33 --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/SignalAlertEvaluator.cs @@ -0,0 +1,79 @@ +using MarketAssistant.Applications.AlertCenter; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Trading.Models; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Services.Trading; + +/// +/// 信号告警评估器:把策略生命周期与 AI 信号产出收敛为统一告警(AlertSource.Signal)。 +/// 仅上报用户需要知晓的状态跃迁(自动暂停 / 完结 / 信号成交),tick 级过程不上报。 +/// 风控拒绝类告警由 负责,二者按拒绝原因互斥分流,不重复告警。 +/// +public sealed class SignalAlertEvaluator +{ + private readonly IAlertCenterService _alertCenterService; + private readonly ILogger _logger; + + public SignalAlertEvaluator( + IAlertCenterService alertCenterService, + ILogger logger) + { + _alertCenterService = alertCenterService; + _logger = logger; + } + + /// + /// 策略因触发被拒而自动暂停。风控拒绝已由 RiskAlertEvaluator 上报(其内容含暂停说明), + /// 此处仅补报用户主动拒绝 / 确认超时的情形,保证"策略静默停摆"始终有告警留痕。 + /// + public Task NotifyStrategyPausedSafeAsync(TradingStrategy strategy, TradeResult? tradeResult) + { + if (!TradeRejectionReason.IsUserRejection(tradeResult?.ErrorMessage)) + return Task.CompletedTask; + + var reason = tradeResult?.ErrorMessage ?? "交易被拒绝"; + return RaiseSafeAsync( + strategy, + AlertLevel.Warning, + "策略已自动暂停", + $"{strategy.Type} 策略({strategy.Symbol})因交易被拒绝已暂停:{reason}。请检查风控配置或在策略页重新启用"); + } + + /// 策略完成全部计划执行次数并退出监控:Info 留痕。 + public Task NotifyStrategyCompletedSafeAsync(TradingStrategy strategy) => + RaiseSafeAsync( + strategy, + AlertLevel.Info, + "策略已完结", + $"{strategy.Type} 策略({strategy.Symbol})已执行完毕并退出监控"); + + /// AI 信号产出并成交:Info 留痕,使告警历史成为自动化决策的完整审计线索。 + public Task NotifySignalExecutedSafeAsync(TradingStrategy strategy, decimal price) => + RaiseSafeAsync( + strategy, + AlertLevel.Info, + "AI 信号已执行", + $"{strategy.Symbol} 按 AI 信号完成交易,成交价 {price:N6}"); + + private async Task RaiseSafeAsync( + TradingStrategy strategy, AlertLevel level, string title, string content) + { + try + { + await _alertCenterService.RaiseAlertAsync(new AlertEvent + { + MarketType = MarketType.Crypto, + Symbol = strategy.Symbol, + Level = level, + Source = AlertSource.Signal, + Title = title, + Content = content + }).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "上报策略信号告警失败: {StrategyId} {Title}", strategy.Id, title); + } + } +} diff --git a/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs b/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs index fd4ad5b..7d0115d 100644 --- a/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs +++ b/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs @@ -1,5 +1,7 @@ using System.Collections.Concurrent; using System.Net.Sockets; +using MarketAssistant.Applications.AlertCenter; +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Services.Trading.Exchanges; using MarketAssistant.Trading.Abstractions; using MarketAssistant.Trading.Models; @@ -20,6 +22,7 @@ public class TradeExecutor : IDisposable private readonly TradingEnvironmentService? _environmentService; private readonly RiskManager _riskManager; private readonly TradingDataService _dataService; + private readonly IAlertGate? _alertGate; private readonly ILogger _logger; /// @@ -35,13 +38,15 @@ public TradeExecutor( RiskManager riskManager, TradingDataService dataService, ILogger logger, - TradingEnvironmentService? environmentService = null) + TradingEnvironmentService? environmentService = null, + IAlertGate? alertGate = null) { _exchangeClient = exchangeClient; _riskManager = riskManager; _dataService = dataService; _logger = logger; _environmentService = environmentService; + _alertGate = alertGate; } /// @@ -149,6 +154,37 @@ public async Task ExecuteOrderAsync( FailureCategory = TradeFailureCategory.Rejected }; } + else if (!requireClose && _alertGate?.IsGated(MarketType.Crypto, instrumentSymbol) == true) + { + // 告警交易联动:确认级告警触发期间,该标的的新开仓信号强制升级为人工确认。 + // requireClose 的退出型触发(止损/止盈等保护性平仓)豁免,确保告警期间仍能及时离场。 + _logger.LogWarning("存在触发中的确认级告警,强制人工确认: {Symbol} {Side}", instrumentSymbol, side); + + if (ConfirmationRequested != null) + { + var approved = await ConfirmationRequested.Invoke( + instrumentSymbol, side, currentPrice, quantity, + "该标的当前存在触发中的紧急告警,交易需人工确认"); + if (!approved) + return new TradeResult + { + Success = false, + ErrorMessage = "用户拒绝交易: 存在触发中的确认级告警", + FailureCategory = TradeFailureCategory.Rejected + }; + + _logger.LogInformation("用户已确认告警联动交易: {Symbol} {Side}", instrumentSymbol, side); + } + else + { + return new TradeResult + { + Success = false, + ErrorMessage = "存在触发中的确认级告警,需人工确认(无确认订阅者)", + FailureCategory = TradeFailureCategory.Rejected + }; + } + } // 仅在实际调用交易所 API 时持有 symbol 锁,防止同一标的并发重复下单 var gate = _symbolExecutionLocks.GetOrAdd( diff --git a/src/MarketAssistant.App.Services/Trading/TradeRejectionReason.cs b/src/MarketAssistant.App.Services/Trading/TradeRejectionReason.cs new file mode 100644 index 0000000..666217e --- /dev/null +++ b/src/MarketAssistant.App.Services/Trading/TradeRejectionReason.cs @@ -0,0 +1,15 @@ +namespace MarketAssistant.Services.Trading; + +/// +/// 交易拒绝原因分类:风控拒绝与用户主动拒绝(含人工确认超时)分别由不同告警源负责, +/// 避免同一事件产生重复告警。判定依据为 写入的 ErrorMessage 前缀。 +/// +public static class TradeRejectionReason +{ + /// 用户主动拒绝或确认超时的错误信息前缀。 + public const string UserRejectionPrefix = "用户拒绝"; + + /// 是否为用户主动拒绝类失败(此类不属风控拦截,改由策略状态信号告警覆盖)。 + public static bool IsUserRejection(string? errorMessage) => + errorMessage?.StartsWith(UserRejectionPrefix, StringComparison.Ordinal) == true; +} diff --git a/src/MarketAssistant.App/App.axaml.cs b/src/MarketAssistant.App/App.axaml.cs index 0459a9b..f3677c4 100644 --- a/src/MarketAssistant.App/App.axaml.cs +++ b/src/MarketAssistant.App/App.axaml.cs @@ -39,6 +39,10 @@ public override void OnFrameworkInitializationCompleted() var priceAlertService = ServiceProvider.GetRequiredService(); _ = InitializePriceAlertServiceAsync(priceAlertService); + // 初始化统一告警中心:建 alert_events 表,确保启动后首个告警可立即落库 + _ = InitializeAlertCenterServiceAsync( + ServiceProvider.GetRequiredService()); + // 激活 HITL 交易确认服务:DI 单例是惰性创建的,仅注册不会实例化, // 必须显式解析一次让构造函数完成对 TradeExecutor.ConfirmationRequested 的订阅, // 否则自动交易的超阈值订单会因无订阅者被静默拒绝 @@ -95,6 +99,22 @@ private static async Task InitializePriceAlertServiceAsync(PriceAlertService pri } } + /// + /// 初始化统一告警中心(建表),失败仅记录日志:告警写入会随首次调用重试 + /// + private static async Task InitializeAlertCenterServiceAsync( + MarketAssistant.Applications.AlertCenter.IAlertCenterService alertCenterService) + { + try + { + await alertCenterService.InitializeAsync(); + } + catch (Exception ex) + { + Log.Error(ex, "统一告警中心初始化失败"); + } + } + private void OnApplicationExit(object? sender, ControlledApplicationLifetimeExitEventArgs e) { try diff --git a/src/MarketAssistant.App/Resources/Styles/CardStyles.axaml b/src/MarketAssistant.App/Resources/Styles/CardStyles.axaml index 6a1ca38..95c15fc 100644 --- a/src/MarketAssistant.App/Resources/Styles/CardStyles.axaml +++ b/src/MarketAssistant.App/Resources/Styles/CardStyles.axaml @@ -145,6 +145,16 @@ + + + + + + + + + + + diff --git a/src/MarketAssistant.App/Resources/Styles/Spacing.axaml b/src/MarketAssistant.App/Resources/Styles/Spacing.axaml index 6185969..a138cd5 100644 --- a/src/MarketAssistant.App/Resources/Styles/Spacing.axaml +++ b/src/MarketAssistant.App/Resources/Styles/Spacing.axaml @@ -13,11 +13,21 @@ 4 8 12 + 0 + 8,0 + 8,4 + 12,4 12 8 4 + 0 + 0,12 + 0,4,8,0 + 8,4,8,4 + 0,0,12,12 + 0,0,8,8 0,0,0,12 @@ -124,9 +134,9 @@ 120 - 480 - 360 - 720 + 560 + 420 + 800 440 120 400 diff --git a/src/MarketAssistant.App/ViewModels/AgentAnalysisViewModel.cs b/src/MarketAssistant.App/ViewModels/AgentAnalysisViewModel.cs index d47b394..ecdd0fa 100644 --- a/src/MarketAssistant.App/ViewModels/AgentAnalysisViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/AgentAnalysisViewModel.cs @@ -1,4 +1,5 @@ -using Avalonia.Platform.Storage; +using Avalonia.Platform.Storage; +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using MarketAssistant.Agents.MarketAnalysis; @@ -10,6 +11,7 @@ using MarketAssistant.Services.Navigation; using Microsoft.Extensions.Logging; using System.Collections.ObjectModel; +using System.Diagnostics; using System.Windows.Input; namespace MarketAssistant.ViewModels; @@ -42,6 +44,27 @@ public partial class AgentAnalysisViewModel : ViewModelBase, INavigationAware + /// 当前流程阶段(步骤条高亮) + /// + [ObservableProperty] + private AnalysisPhase _analysisPhase = AnalysisPhase.Preparing; + + /// + /// 各分析师的运行状态行(逐位展示,运行中带实时耗时) + /// + public ObservableCollection AnalystRunItems { get; } = []; + + /// + /// 平滑百分比插值计时器:目标百分比由完成事件驱动(阶梯式), + /// 计时器按固定步长逼近目标值,避免 0→50→100 的跳变观感 + /// + private readonly DispatcherTimer _progressSmoothTimer; + private double _smoothedPercent; + private double _targetPercent; + private const double SmoothStepSize = 2.0; + private static readonly TimeSpan SmoothTickInterval = TimeSpan.FromMilliseconds(30); + /// /// 当前会话是否已有可展示的分析报告(成功产出或加载历史报告后为 true) /// @@ -141,6 +164,9 @@ public AgentAnalysisViewModel( _orchestrationService = orchestrationService; _analysisReportViewModel = analysisReportViewModel; + _progressSmoothTimer = new DispatcherTimer(SmoothTickInterval, DispatcherPriority.Background, OnProgressSmoothTimerTick); + _progressSmoothTimer.Stop(); + SubscribeToMarketChanges(marketContext); ChatSidebarViewModel = chatSidebarViewModel; @@ -190,15 +216,165 @@ private void OnAnalysisProgressChanged(object? sender, AnalysisProgressEventArgs { IsAnalysisInProgress = e.IsInProgress; AnalysisStage = e.StageDescription; - AnalysisProgressPercent = e.ProgressPercent; if (e.FailedAnalysts.Count > 0) { FailedAnalystsInfo = $"部分分析师失败: {string.Join(", ", e.FailedAnalysts)}"; } + + // 终态事件(完成/失败/取消)均携带分析师快照,行条目由快照直接定格 + if (e.Analysts.Count > 0) + { + AnalysisPhase = e.Phase; + SyncAnalystRunItems(e.Analysts); + } + + UpdateTargetPercent(e); + + if (!_progressSmoothTimer.IsEnabled) + { + _progressSmoothTimer.Start(); + } }); } + /// + /// 将最新快照同步到逐分析师状态行:按显示名增量更新,保持行顺序稳定,避免整体重建闪烁 + /// + private void SyncAnalystRunItems(IReadOnlyList snapshots) + { + var existing = AnalystRunItems.ToDictionary(i => i.DisplayName, i => i); + foreach (var snapshot in snapshots) + { + if (existing.TryGetValue(snapshot.DisplayName, out var item)) + { + item.UpdateFrom(snapshot); + } + else + { + AnalystRunItems.Add(AnalystRunItemViewModel.From(snapshot)); + } + } + } + + /// + /// 根据进度事件计算目标百分比: + /// 分析阶段对运行中的分析师按耗时折算部分完成度,使百分比持续平滑推进而非停在整档 + /// + private void UpdateTargetPercent(AnalysisProgressEventArgs e) + { + if (!e.IsInProgress) + { + _targetPercent = 100; + return; + } + + _targetPercent = e.Phase switch + { + AnalysisPhase.Preparing => 3, + AnalysisPhase.Analyzing when e.Analysts.Count > 0 => 5 + Math.Clamp( + e.Analysts.Sum(a => a.Status switch + { + AnalystRunStatus.Completed or AnalystRunStatus.Failed => 1.0, + AnalystRunStatus.Running => EstimateRunningCredit(a.Elapsed.TotalSeconds), + _ => 0.0 + }) / e.Analysts.Count * 80.0, 0, 80), + AnalysisPhase.Aggregating => 89, + AnalysisPhase.Reporting => 95, + AnalysisPhase.Completed => 100, + _ => _targetPercent + }; + } + + /// + /// 运行中分析师的完成度估算:前 60 秒线性增长至 70%,之后渐近逼近 90% + /// + private static double EstimateRunningCredit(double elapsedSeconds) + => elapsedSeconds <= 60 + ? elapsedSeconds / 60 * 0.7 + : 0.7 + (1 - Math.Exp(-(elapsedSeconds - 60) / 120)) * 0.2; + + /// + /// 平滑百分比插值:按固定步长逼近目标值,同时刷新运行中分析师的实时耗时 + /// + private void OnProgressSmoothTimerTick(object? sender, EventArgs e) + { + var hasRunning = false; + foreach (var item in AnalystRunItems) + { + if (item.Status == AnalystRunStatus.Running) + { + item.RefreshElapsed(); + hasRunning = true; + } + } + + if (hasRunning && AnalysisProgressPercent < 100) + { + RecomputeTargetPercentFromItems(); + } + + var delta = _targetPercent - _smoothedPercent; + if (Math.Abs(delta) > 0.01) + { + _smoothedPercent += Math.Clamp(delta, -SmoothStepSize, SmoothStepSize); + } + + var rounded = (int)Math.Round(Math.Clamp(_smoothedPercent, 0, 100)); + if (rounded != AnalysisProgressPercent) + { + AnalysisProgressPercent = rounded; + } + + if (!IsAnalysisInProgress && !hasRunning && Math.Abs(_targetPercent - _smoothedPercent) <= 0.01) + { + _progressSmoothTimer.Stop(); + } + } + /// + /// 分析执行中:按运行中分析师的实时耗时持续重算目标百分比, + /// 使两位分析师进度事件之间的空闲期百分比仍平滑推进,而非长时间停滞。 + /// 仅在存在运行中条目时生效,避免覆盖阶段事件给出的目标值(聚合 89% / 报告 95%) + /// + private void RecomputeTargetPercentFromItems() + { + var hasRunning = false; + var credit = 0.0; + foreach (var item in AnalystRunItems) + { + if (item.Status == AnalystRunStatus.Pending) + continue; + + if (item.Status == AnalystRunStatus.Running) + { + hasRunning = true; + credit += EstimateRunningCredit(item.CurrentElapsed.TotalSeconds); + } + else + { + credit += 1.0; + } + } + + if (hasRunning) + { + _targetPercent = 5 + Math.Clamp(credit / AnalystRunItems.Count * 80.0, 0, 80); + } + } + /// + /// 重置进度展示状态(新一次分析开始或市场切换时调用) + /// + private void ResetProgressDisplay() + { + _smoothedPercent = 0; + _targetPercent = 0; + _progressSmoothTimer.Stop(); + AnalysisProgressPercent = 0; + AnalysisPhase = AnalysisPhase.Preparing; + FailedAnalystsInfo = string.Empty; + AnalystRunItems.Clear(); + } + private void CancelAnalysis() { _analysisCts?.Cancel(); @@ -220,9 +396,8 @@ public async Task LoadAnalysisDataAsync() { await Dispatcher.UIThread.InvokeAsync(() => { + ResetProgressDisplay(); AnalysisStage = "准备开始..."; - FailedAnalystsInfo = string.Empty; - AnalysisProgressPercent = 0; HasActiveReport = false; AnalysisFailureMessage = string.Empty; }); @@ -390,8 +565,7 @@ protected override void OnMarketChanged(MarketType newMarket) // 重置分析状态 IsAnalysisInProgress = false; AnalysisStage = "等待开始分析"; - AnalysisProgressPercent = 0; - FailedAnalystsInfo = string.Empty; + ResetProgressDisplay(); CanExportReport = false; HasActiveReport = false; AnalysisFailureMessage = string.Empty; @@ -402,6 +576,8 @@ protected override void OnMarketChanged(MarketType newMarket) public void Dispose() { _disposed = true; + _progressSmoothTimer.Stop(); + _progressSmoothTimer.Tick -= OnProgressSmoothTimerTick; _orchestrationService.ProgressChanged -= OnAnalysisProgressChanged; _analysisCts?.Cancel(); _analysisCts?.Dispose(); @@ -420,3 +596,110 @@ public void Dispose() } } +/// +/// 进度面板中的单个分析师状态行视图模型。 +/// +public partial class AnalystRunItemViewModel : ObservableObject +{ + private long _runningStartedTimestamp; + + [ObservableProperty] + private string _displayName = string.Empty; + + [ObservableProperty] + private AnalystRunStatus _status = AnalystRunStatus.Pending; + + [ObservableProperty] + private string _elapsedText = "--:--"; + + /// 状态徽标文本(完成 ✓ / 失败 ✗ / 其他为空) + [ObservableProperty] + private string _statusMark = string.Empty; + + public bool IsCompleted => Status == AnalystRunStatus.Completed; + + public bool IsFailed => Status == AnalystRunStatus.Failed; + + public bool IsRunning => Status == AnalystRunStatus.Running; + + /// 等待执行的灰色静默点 + public bool IsPending => Status == AnalystRunStatus.Pending; + + /// 终态(完成/失败)则不再需要呼吸动画 + public bool IsLive => IsRunning; + + public static AnalystRunItemViewModel From(AnalystRunSnapshot snapshot) + { + var item = new AnalystRunItemViewModel { DisplayName = snapshot.DisplayName }; + item.ApplySnapshot(snapshot); + return item; + } + + /// + /// 用最新快照增量更新本行;从运行态转为终态时定格耗时 + /// + public void UpdateFrom(AnalystRunSnapshot snapshot) + { + ApplySnapshot(snapshot); + } + + /// + /// 计时器驱动:刷新运行中条目的实时耗时显示 + /// + public void RefreshElapsed() + { + if (Status != AnalystRunStatus.Running) + return; + + ElapsedText = FormatElapsed(Stopwatch.GetElapsedTime(_runningStartedTimestamp)); + } + + /// 当前实时耗时(运行中取本地 Stopwatch;仅供进度预估消费运行中条目) + public TimeSpan CurrentElapsed => Status == AnalystRunStatus.Running + ? Stopwatch.GetElapsedTime(_runningStartedTimestamp) + : TimeSpan.Zero; + + private void ApplySnapshot(AnalystRunSnapshot snapshot) + { + Status = snapshot.Status; + if (snapshot.Status == AnalystRunStatus.Running && _runningStartedTimestamp == 0) + { + // 运行起点只记录一次:无论经 UpdateFrom 还是 From 首见 Running 快照都确保有起点, + // 避免 Stopwatch.GetElapsedTime(0) 把进程运行时长误算为耗时 + _runningStartedTimestamp = Stopwatch.GetTimestamp(); + } + StatusMark = snapshot.Status switch + { + AnalystRunStatus.Completed => "✓", + AnalystRunStatus.Failed => "✗", + _ => string.Empty + }; + if (snapshot.Status == AnalystRunStatus.Running) + { + ElapsedText = FormatElapsed(Stopwatch.GetElapsedTime(_runningStartedTimestamp)); + } + else + { + // 终态耗时以工作流快照为准;从未运行过的条目(如取消时仍 Pending)保持 --:-- + ElapsedText = snapshot.Elapsed > TimeSpan.Zero + ? FormatElapsed(snapshot.Elapsed) + : "--:--"; + } + + OnPropertyChanged(nameof(IsCompleted)); + OnPropertyChanged(nameof(IsFailed)); + OnPropertyChanged(nameof(IsRunning)); + OnPropertyChanged(nameof(IsPending)); + OnPropertyChanged(nameof(IsLive)); + } + + /// + /// 耗时格式化为 mm:ss(金融终端风格等宽字体展示) + /// + private static string FormatElapsed(TimeSpan elapsed) + { + var totalSeconds = (int)elapsed.TotalSeconds; + return $"{totalSeconds / 60:D2}:{totalSeconds % 60:D2}"; + } +} + diff --git a/src/MarketAssistant.App/ViewModels/AlertHistoryViewModel.cs b/src/MarketAssistant.App/ViewModels/AlertHistoryViewModel.cs new file mode 100644 index 0000000..881b04b --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/AlertHistoryViewModel.cs @@ -0,0 +1,108 @@ +using System.Collections.ObjectModel; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using MarketAssistant.Applications.AlertCenter; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.ViewModels; + +/// +/// 告警历史 ViewModel:从统一告警中心加载最近告警事件,支持手动刷新与全部标记已读。 +/// 随告警页创建,页面销毁时解除订阅。 +/// +public partial class AlertHistoryViewModel : ViewModelBase, IDisposable +{ + private readonly IAlertCenterService _alertCenterService; + private bool _disposed; + + public ObservableCollection Alerts { get; } = new(); + + /// 是否正在加载。 + [ObservableProperty] + private bool _isLoading; + + /// 空列表提示。 + [ObservableProperty] + private string _emptyHintText = "暂无告警记录"; + + public AlertHistoryViewModel( + IAlertCenterService alertCenterService, + ILogger? logger = null) + : base(logger) + { + _alertCenterService = alertCenterService; + _alertCenterService.AlertsChanged += OnAlertsChanged; + _ = LoadAsync(); + } + + /// 告警集合变化回调(新告警/合并/已读),切回 UI 线程刷新。 + private void OnAlertsChanged() + { + if (_disposed) + return; + + Dispatcher.UIThread.Post(() => _ = LoadAsync()); + } + + /// 手动刷新。 + [RelayCommand] + private async Task RefreshAsync() => await LoadAsync(); + + /// 全部标记已读(同时清除导航未读徽标)。 + [RelayCommand] + private async Task MarkAllReadAsync() + { + try + { + await _alertCenterService.MarkAllReadAsync(); + } + catch (Exception ex) + { + Logger?.LogWarning(ex, "标记告警全部已读失败"); + } + } + + /// 加载最近告警(新→旧)。 + private async Task LoadAsync() + { + if (_disposed) + return; + + IsLoading = true; + try + { + var alerts = await _alertCenterService.GetRecentAlertsAsync(200); + if (_disposed) + return; + + Dispatcher.UIThread.Post(() => + { + if (_disposed) + return; + + Alerts.Clear(); + foreach (var alert in alerts) + Alerts.Add(alert); + EmptyHintText = Alerts.Count == 0 ? "暂无告警记录" : string.Empty; + }); + } + catch (Exception ex) + { + Logger?.LogWarning(ex, "加载告警历史失败"); + } + finally + { + IsLoading = false; + } + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _alertCenterService.AlertsChanged -= OnAlertsChanged; + } +} diff --git a/src/MarketAssistant.App/ViewModels/AnalystCardViewModel.cs b/src/MarketAssistant.App/ViewModels/AnalystCardViewModel.cs new file mode 100644 index 0000000..3e8e212 --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/AnalystCardViewModel.cs @@ -0,0 +1,521 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using MarketAssistant.Agents.MarketAnalysis.Models; +using MarketAssistant.Infrastructure.Extensions; + +namespace MarketAssistant.ViewModels; + +/// +/// 分析师结果卡片中的单条评分条 +/// +public sealed class AnalystScoreBarItem +{ + public AnalystScoreBarItem(string label, double value) + { + Label = label; + Value = value; + NormalizedValue = Math.Clamp(value / 10.0 * 100.0, 0, 100); + ValueText = value.ToString("0.#"); + } + + public string Label { get; } + + /// 原始分值(1-10) + public double Value { get; } + + /// 归一化到 0-100 的进度条值 + public double NormalizedValue { get; } + + public string ValueText { get; } +} + +/// +/// 分析师结果卡片中的单条"标签: 数值"关键数据 +/// +public sealed class AnalystKeyValueItem +{ + public AnalystKeyValueItem(string label, string value) + { + Label = label; + Value = value; + } + + public string Label { get; } + + public string Value { get; } +} + +/// +/// 分析师结构化结果卡片视图模型:将分析师产出的 JSON 产物解析为 +/// 评级徽标 + 评分条 + 核心观点 + 风险提示。 +/// +public sealed class AnalystCardViewModel +{ + private AnalystCardViewModel( + string? ratingText, + AnalystRatingTone ratingTone, + IReadOnlyList scoreBars, + IReadOnlyList keyPoints, + IReadOnlyList keyValues, + IReadOnlyList risks) + { + RatingText = ratingText; + RatingTone = ratingTone; + ScoreBars = scoreBars; + KeyPoints = keyPoints; + KeyValues = keyValues; + Risks = risks; + } + + /// 评级徽标文本(如"买入"/"乐观"),无评级时为 null + public string? RatingText { get; } + + public AnalystRatingTone RatingTone { get; } + + public bool RatingIsPositive => RatingTone == AnalystRatingTone.Positive; + + public bool RatingIsNeutral => RatingTone == AnalystRatingTone.Neutral; + + public bool RatingIsNegative => RatingTone == AnalystRatingTone.Negative; + + public bool HasRating => !string.IsNullOrEmpty(RatingText); + + public IReadOnlyList ScoreBars { get; } + + public bool HasScoreBars => ScoreBars.Count > 0; + + public IReadOnlyList KeyPoints { get; } + + public bool HasKeyPoints => KeyPoints.Count > 0; + + public IReadOnlyList KeyValues { get; } + + public bool HasKeyValues => KeyValues.Count > 0; + + public IReadOnlyList Risks { get; } + + public bool HasRisks => Risks.Count > 0; + + /// + /// 按消息作者(ASCII Agent Name)尝试把内容解析为结构化卡片。 + /// 解析失败(非 JSON、字段不匹配等)返回 false,由调用方回退为富文本展示。 + /// + public static bool TryCreate(string? authorName, string content, out AnalystCardViewModel? card) + { + card = null; + if (string.IsNullOrWhiteSpace(content) || content.Length < 20) + return false; + + var json = ExtractJsonCandidate(content); + if (json is null) + return false; + + card = authorName switch + { + "FundamentalAnalyst" => FromJson(json, FundamentalTypeInfo, BuildFrom), + "FinancialAnalyst" => FromJson(json, FinancialTypeInfo, BuildFrom), + "TechnicalAnalyst" => FromJson(json, TechnicalTypeInfo, BuildFrom), + "MarketSentimentAnalyst" => FromJson(json, SentimentTypeInfo, BuildFrom), + "NewsEventAnalyst" => FromJson(json, NewsTypeInfo, BuildFrom), + "CryptoMetricsAnalyst" => FromJson(json, CryptoTypeInfo, BuildFrom), + "CoordinatorAnalyst" or "Coordinator" => FromJson(json, CoordinatorTypeInfo, BuildFrom), + _ => null + }; + + return card != null; + } + + private static AnalystCardViewModel? FromJson( + string json, + JsonTypeInfo typeInfo, + Func builder) + { + try + { + return builder(JsonSerializer.Deserialize(json, typeInfo)); + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException) + { + // 卡片解析是展示层增强,解析失败(含反序列化配置异常)必须回退为富文本展示, + // 任何异常逃逸都会中断调用方(ChatMessageAdapter 构造)的消息列表渲染流程 + System.Diagnostics.Debug.WriteLine($"分析师卡片解析失败,回退富文本展示: {ex.Message}"); + return null; + } + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + // 必须显式指定 TypeInfoResolver:options 在首次使用时冻结, + // 缺失时经 GetTypeInfo/Deserialize 会抛 InvalidOperationException + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + Converters = { new JsonStringEnumConverter() } + }; + + // 从 JsonOptions 自身缓存各结果类型的 JsonTypeInfo,供 Deserialize 非泛型分发; + // 保证 TypeInfo 与 options 绑定一致,避免运行时解析器缺失异常 + private static readonly JsonTypeInfo FundamentalTypeInfo = + (JsonTypeInfo)JsonOptions.GetTypeInfo(typeof(FundamentalAnalysisResult)); + private static readonly JsonTypeInfo FinancialTypeInfo = + (JsonTypeInfo)JsonOptions.GetTypeInfo(typeof(FinancialAnalysisResult)); + private static readonly JsonTypeInfo TechnicalTypeInfo = + (JsonTypeInfo)JsonOptions.GetTypeInfo(typeof(TechnicalAnalysisResult)); + private static readonly JsonTypeInfo SentimentTypeInfo = + (JsonTypeInfo)JsonOptions.GetTypeInfo(typeof(MarketSentimentAnalysisResult)); + private static readonly JsonTypeInfo NewsTypeInfo = + (JsonTypeInfo)JsonOptions.GetTypeInfo(typeof(NewsEventAnalysisResult)); + private static readonly JsonTypeInfo CryptoTypeInfo = + (JsonTypeInfo)JsonOptions.GetTypeInfo(typeof(CryptoMetricsAnalysisResult)); + private static readonly JsonTypeInfo CoordinatorTypeInfo = + (JsonTypeInfo)JsonOptions.GetTypeInfo(typeof(CoordinatorResult)); + + /// + /// 从可能包含散文前缀/后缀的文本中提取 JSON 对象候选片段 + /// + private static string? ExtractJsonCandidate(string content) + { + var trimmed = content.Trim(); + if (trimmed.StartsWith('{') && trimmed.EndsWith('}')) + return IsValidJsonObject(trimmed) ? trimmed : null; + + var first = trimmed.IndexOf('{'); + var last = trimmed.LastIndexOf('}'); + if (first < 0 || last <= first) + return null; + + var candidate = trimmed[first..(last + 1)]; + return IsValidJsonObject(candidate) ? candidate : null; + } + + private static bool IsValidJsonObject(string text) + { + try + { + using var document = JsonDocument.Parse(text, new JsonDocumentOptions + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip + }); + return document.RootElement.ValueKind == JsonValueKind.Object; + } + catch (JsonException) + { + return false; + } + } + + private static AnalystCardViewModel? BuildFrom(FundamentalAnalysisResult? r) + { + if (r is null) return null; + + var (ratingText, tone) = MapInvestmentRating(r.GrowthValue.InvestmentRating); + var bars = new[] + { + MaybeBar("行业成长性", r.Fundamentals.IndustryGrowthScore), + MaybeBar("业务质量", r.Fundamentals.BusinessQualityScore), + MaybeBar("竞争力强度", r.Competition.CompetenceStrengthScore), + MaybeBar("增长持续性", r.GrowthValue.GrowthSustainabilityScore) + }.Where(b => b != null).Select(b => b!).ToList(); + + var points = CollectNonEmpty( + $"行业:{r.Fundamentals.Industry}", + r.Fundamentals.CoreBusiness, + r.Fundamentals.ProfitabilityOverview, + r.Competition.CoreCompetence, + r.GrowthValue.GrowthDrivers); + points.AddRange(r.GrowthValue.InvestmentHighlights); + points.RemoveAll(string.IsNullOrWhiteSpace); + + return new AnalystCardViewModel( + ratingText, + tone, + bars, + points, + CollectKeyValues(("公司名称", r.BasicInfo.Name), ("目标参考", r.GrowthValue.ValuationTarget)), + CollectNonEmpty(r.GrowthValue.KeyRisk)); + } + + private static AnalystCardViewModel? BuildFrom(FinancialAnalysisResult? r) + { + if (r is null) return null; + + var (ratingText, tone) = MapFinancialStability(r.HealthAssessment.OverallStability); + var bars = new[] + { + MaybeBar("偿债能力", r.HealthAssessment.SolvencyScore), + MaybeBar("财务稳健", r.HealthAssessment.StabilityScore), + MaybeBar("利润质量", r.ProfitQuality.ProfitQualityScore), + MaybeBar("现金流质量", r.CashFlow.CashFlowQualityScore), + MaybeBar("造假风险", r.RiskWarning.FraudRiskScore) + }.Where(b => b != null).Select(b => b!).ToList(); + + var points = CollectNonEmpty( + r.HealthAssessment.CoreInsight, + r.HealthAssessment.SolvencyAssessment, + r.ProfitQuality.ProfitSustainability, + r.CashFlow.EfficiencyDescription); + + var risks = CollectNonEmpty(r.RiskWarning.KeyRiskIndicators.ToArray()); + risks.AddRange(r.RiskWarning.MonitoringPoints); + risks.Add(r.RiskWarning.FraudRiskRationale); + risks.RemoveAll(string.IsNullOrWhiteSpace); + + return new AnalystCardViewModel( + ratingText, + tone, + bars, + points, + CollectKeyValues( + ("ROE", FormatPercent(r.ProfitQuality.ROE)), + ("净利率", FormatPercent(r.ProfitQuality.NetMargin)), + ("资产负债率", FormatPercent(r.HealthAssessment.DebtRatio))), + risks); + } + + private static AnalystCardViewModel? BuildFrom(TechnicalAnalysisResult? r) + { + if (r is null) return null; + + var (ratingText, tone) = MapInvestmentRating(r.Strategy.TechnicalRating); + var bars = new[] + { + MaybeBar("趋势强度", r.PatternTrend.TrendStrengthScore), + MaybeBar("形态可靠性", r.PatternTrend.PatternReliabilityScore), + MaybeBar("支撑强度", r.PriceLevels.SupportStrengthScore), + MaybeBar("阻力强度", r.PriceLevels.ResistanceStrengthScore), + MaybeBar("突破概率", r.PriceLevels.BreakoutProbabilityScore) + }.Where(b => b != null).Select(b => b!).ToList(); + + var points = CollectNonEmpty( + $"当前趋势:{r.PatternTrend.CurrentTrend.GetDescription()}({r.PatternTrend.TimeFrame.GetDescription()})", + r.PatternTrend.KeyPatterns, + r.Indicators.TrendIndicatorSignals, + r.Indicators.MomentumIndicatorSignals, + r.Indicators.IndicatorSynergyDescription); + + return new AnalystCardViewModel( + ratingText, + tone, + bars, + points, + CollectKeyValues( + ("当前价", FormatPrice(r.PriceLevels.CurrentPrice)), + ("目标区间", FormatRange(r.Strategy.TargetPriceLow, r.Strategy.TargetPriceHigh)), + ("止损位", FormatPrice(r.Strategy.StopLossPrice)), + ("持仓周期", r.Strategy.HoldingPeriod.GetDescription()), + ("风险等级", r.Strategy.RiskLevel.GetDescription())), + CollectNonEmpty( + r.Strategy.RiskLevel == Level.High ? "风险等级偏高,注意仓位控制" : null)); + } + + private static AnalystCardViewModel? BuildFrom(MarketSentimentAnalysisResult? r) + { + if (r is null) return null; + + var (ratingText, tone) = MapAtmosphere(r.SentimentAssessment.OverallAtmosphere); + var bars = new[] + { + MaybeBar("情绪强度", r.SentimentAssessment.EmotionIntensityScore), + MaybeBar("氛围强度", r.SentimentAssessment.AtmosphereIntensityScore), + MaybeBar("散户活跃度", r.BehaviorAnalysis.RetailActivityScore), + MaybeBar("偏差程度", r.BehaviorAnalysis.BiasSeverityScore) + }.Where(b => b != null).Select(b => b!).ToList(); + + var points = CollectNonEmpty( + $"主导情绪:{r.SentimentAssessment.DominantEmotion.GetDescription()}", + r.SentimentAssessment.ConfidenceTrendDescription, + $"主力资金:{r.CapitalFlowAnalysis.MainCapitalFlow.GetDescription()},机构动向:{r.CapitalFlowAnalysis.InstitutionTrend.GetDescription()}", + r.BehaviorAnalysis.InstitutionMainTrend, + r.ShortTermStrategy.ShortTermOpportunities, + $"操作建议:{r.ShortTermStrategy.OperationRecommendation.GetDescription()} / 仓位:{r.ShortTermStrategy.PositionRecommendation.GetDescription()}", + r.ShortTermStrategy.BestTiming); + + return new AnalystCardViewModel( + ratingText, + tone, + bars, + points, + CollectKeyValues( + ("热点板块", r.ShortTermStrategy.HotSectors), + ("止损参考", r.ShortTermStrategy.StopLossPosition), + ("目标区间", r.ShortTermStrategy.TargetPriceRange)), + CollectNonEmpty(r.ShortTermStrategy.PsychologicalTrapToAvoid)); + } + + private static AnalystCardViewModel? BuildFrom(NewsEventAnalysisResult? r) + { + if (r is null) return null; + + var (ratingText, tone) = MapEventNature(r.EventAnalysis.EventNature); + var bars = new[] + { + MaybeBar("信息可信度", r.EventAnalysis.CredibilityScore), + MaybeBar("事件重要性", r.EventAnalysis.ImportanceScore), + MaybeBar("基本面影响", r.ImpactEvaluation.FundamentalImpactScore), + MaybeBar("情绪强度", r.ImpactEvaluation.SentimentIntensityScore) + }.Where(b => b != null).Select(b => b!).ToList(); + + var points = CollectNonEmpty( + r.EventAnalysis.EventSummary, + $"事件性质:{r.EventAnalysis.EventNature.GetDescription()}({r.EventAnalysis.InformationSource.GetDescription()})", + r.ImpactEvaluation.FundamentalImpactLogic, + r.InvestmentGuidance.CoreInvestmentLogic, + r.InvestmentGuidance.SpecificActionAdvice); + points.AddRange(r.InvestmentGuidance.FocusPoints); + points.RemoveAll(string.IsNullOrWhiteSpace); + + return new AnalystCardViewModel( + ratingText, + tone, + bars, + points, + CollectKeyValues( + ("应对策略", r.InvestmentGuidance.ResponseStrategy.GetDescription()), + ("影响范围", r.ImpactEvaluation.ImpactScope.GetDescription()), + ("持续时长", r.ImpactEvaluation.ExpectedTimeframe)), + CollectNonEmpty(r.InvestmentGuidance.KeyRiskAlert)); + } + + private static AnalystCardViewModel? BuildFrom(CryptoMetricsAnalysisResult? r) + { + if (r is null) return null; + + var bars = new[] + { + MaybeBar("市值规模", r.MarketCap.MarketCapScore), + MaybeBar("流动性", r.Liquidity.LiquidityScore), + MaybeBar("波动风险", r.VolatilityRisk.RiskScore), + MaybeBar("市场结构", r.MarketStructure.StructureScore) + }.Where(b => b != null).Select(b => b!).ToList(); + + return new AnalystCardViewModel( + null, + AnalystRatingTone.Neutral, + bars, + CollectNonEmpty( + r.MarketCap.CirculatingAssessment, + r.Liquidity.VolumeDistributionNotes, + r.MarketStructure.KeyObservations), + CollectKeyValues(("最大回撤", r.VolatilityRisk.MaxDrawdown ?? string.Empty)), + CollectNonEmpty( + r.VolatilityRisk.RiskScore is > 7 ? "波动风险评分偏高,注意控制仓位与杠杆" : null)); + } + + private static AnalystCardViewModel? BuildFrom(CoordinatorResult? r) + { + if (r is null) return null; + + var (ratingText, tone) = MapInvestmentRating(r.InvestmentRating); + var bars = new[] + { + MaybeBar("综合评分", r.OverallScore), + MaybeBar("基本面", r.DimensionScores.Fundamental), + MaybeBar("技术面", r.DimensionScores.Technical), + MaybeBar("财务面", r.DimensionScores.Financial), + MaybeBar("市场情绪", r.DimensionScores.Sentiment), + MaybeBar("新闻事件", r.DimensionScores.News), + ConfidenceBar(r.ConfidencePercentage) + }.Where(b => b != null).Select(b => b!).ToList(); + + var points = CollectNonEmpty( + r.Summary, + r.ConsensusAnalysis, + r.DisagreementAnalysis); + points.AddRange(r.InvestmentHighlights); + points.AddRange(r.OperationSuggestions); + points.RemoveAll(string.IsNullOrWhiteSpace); + + return new AnalystCardViewModel( + ratingText, + tone, + bars, + points, + CollectKeyValues( + ("目标价", r.TargetPrice), + ("价格预期", r.PriceChangeExpectation), + ("投资周期", JoinNonEmpty(" ", r.TimeHorizon.GetDescription(), r.TimeHorizonDescription)), + ("风险等级", r.RiskLevel.GetDescription())), + CollectNonEmpty(r.RiskFactors.ToArray())); + } + + private static (string?, AnalystRatingTone) MapInvestmentRating(InvestmentRating rating) => rating switch + { + InvestmentRating.StrongBuy or InvestmentRating.Buy => ("买入", AnalystRatingTone.Positive), + InvestmentRating.Hold => ("持有", AnalystRatingTone.Neutral), + InvestmentRating.Reduce => ("减持", AnalystRatingTone.Negative), + InvestmentRating.Sell or InvestmentRating.StrongSell => ("卖出", AnalystRatingTone.Negative), + _ => (null, AnalystRatingTone.Neutral) + }; + + private static (string?, AnalystRatingTone) MapFinancialStability(FinancialStability stability) => stability switch + { + FinancialStability.Strong => ("稳健", AnalystRatingTone.Positive), + FinancialStability.Medium => ("一般", AnalystRatingTone.Neutral), + FinancialStability.Weak => ("偏弱", AnalystRatingTone.Negative), + _ => (null, AnalystRatingTone.Neutral) + }; + + private static (string?, AnalystRatingTone) MapAtmosphere(MarketAtmosphere atmosphere) => atmosphere switch + { + MarketAtmosphere.ExtremelyOptimistic or MarketAtmosphere.Optimistic => ("乐观", AnalystRatingTone.Positive), + MarketAtmosphere.Neutral => ("中性", AnalystRatingTone.Neutral), + MarketAtmosphere.Pessimistic or MarketAtmosphere.ExtremelyPessimistic => ("悲观", AnalystRatingTone.Negative), + _ => (null, AnalystRatingTone.Neutral) + }; + + private static (string?, AnalystRatingTone) MapEventNature(EventNature nature) => nature switch + { + EventNature.MajorPositive or EventNature.Positive => ("利好", AnalystRatingTone.Positive), + EventNature.Neutral => ("中性", AnalystRatingTone.Neutral), + EventNature.Negative or EventNature.MajorNegative => ("利空", AnalystRatingTone.Negative), + _ => (null, AnalystRatingTone.Neutral) + }; + + private static AnalystScoreBarItem? MaybeBar(string label, float value) + => value > 0 ? new AnalystScoreBarItem(label, value) : null; + + private static AnalystScoreBarItem? MaybeBar(string label, float? value) + => value is > 0 ? new AnalystScoreBarItem(label, value.Value) : null; + + /// 置信度为 0-100 的百分比,归一化为 1-10 分制 + private static AnalystScoreBarItem? ConfidenceBar(float confidence) + => confidence > 0 ? new AnalystScoreBarItem("置信度", Math.Clamp(confidence / 10f, 0f, 10f)) : null; + + private static List CollectNonEmpty(params string?[] values) + => values.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!.Trim()).ToList(); + + private static IReadOnlyList CollectKeyValues(params (string Label, string Value)[] items) + => items + .Where(i => !string.IsNullOrWhiteSpace(i.Value)) + .Select(i => new AnalystKeyValueItem(i.Label, i.Value.Trim())) + .ToList(); + + private static string JoinNonEmpty(string separator, params string?[] values) + => string.Join(separator, values.Where(v => !string.IsNullOrWhiteSpace(v))); + + private static string FormatPrice(decimal? price) + => price.HasValue ? $"{price.Value:0.####}" : string.Empty; + + private static string FormatRange(decimal? low, decimal? high) + { + if (low.HasValue && high.HasValue) + return $"{low.Value:0.####} - {high.Value:0.####}"; + return low.HasValue ? $"{low.Value:0.####}" : string.Empty; + } + + private static string FormatPercent(float? value) + => value.HasValue ? $"{value.Value:0.##}%" : string.Empty; +} + +/// +/// 评级徽标色调:A 股惯例红涨绿跌,Positive 用红色、Negative 用绿色 +/// +public enum AnalystRatingTone +{ + Positive, + Neutral, + Negative +} \ No newline at end of file diff --git a/src/MarketAssistant.App/ViewModels/AnalystDisplayNameMapper.cs b/src/MarketAssistant.App/ViewModels/AnalystDisplayNameMapper.cs new file mode 100644 index 0000000..7cfae29 --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/AnalystDisplayNameMapper.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; + +namespace MarketAssistant.ViewModels; + +/// +/// 分析师 Agent 的 ASCII 标识名(如 FundamentalAnalyst)与中文显示名(如"基本面分析师")之间的映射。 +/// 聊天侧栏消息的 AuthorName 是 ASCII 名,需翻译为用户可读的显示名。 +/// +public static class AnalystDisplayNameMapper +{ + /// + /// ASCII Agent Name → 中文显示名(与各分析师 YAML config.name / 类型 DisplayName 特性保持一致) + /// + private static readonly Dictionary NameToDisplayName = new(StringComparer.Ordinal) + { + ["FundamentalAnalyst"] = "基本面分析师", + ["FinancialAnalyst"] = "财务分析师", + ["TechnicalAnalyst"] = "技术分析师", + ["MarketSentimentAnalyst"] = "市场情绪分析师", + ["NewsEventAnalyst"] = "新闻事件分析师", + ["CryptoMetricsAnalyst"] = "项目指标分析师", + ["CoordinatorAnalyst"] = "协调分析师", + ["MarketAnalysisAssistant"] = "市场分析助手", + }; + + /// + /// 将 ASCII Agent Name 翻译为中文显示名;未登记的名称原样返回 + /// + public static string GetDisplayName(string? asciiName) + => !string.IsNullOrEmpty(asciiName) && NameToDisplayName.TryGetValue(asciiName, out var displayName) + ? displayName + : asciiName ?? string.Empty; + + /// + /// 判断给定名称是否为已登记的分析师/协调者 ASCII 名 + /// + public static bool IsKnownAnalystName(string? asciiName) + => !string.IsNullOrEmpty(asciiName) && NameToDisplayName.ContainsKey(asciiName); +} \ No newline at end of file diff --git a/src/MarketAssistant.App/ViewModels/ChatMessageAdapter.cs b/src/MarketAssistant.App/ViewModels/ChatMessageAdapter.cs index 021a65d..468acb6 100644 --- a/src/MarketAssistant.App/ViewModels/ChatMessageAdapter.cs +++ b/src/MarketAssistant.App/ViewModels/ChatMessageAdapter.cs @@ -1,5 +1,6 @@ using AdaptiveCards; using CommunityToolkit.Mvvm.ComponentModel; +using MarketAssistant.Agents.Analysts; using Microsoft.Extensions.AI; using AdaptiveCardConverter = MarketAssistant.Infrastructure.AdaptiveCards.AdaptiveCardConverter; @@ -88,6 +89,26 @@ public partial class ChatMessageAdapter : ObservableObject /// public bool IsAdaptiveCard => AdaptiveCard != null; + /// + /// 分析师结构化结果卡片(评级徽标 + 评分条 + 核心观点 + 风险) + /// + public AnalystCardViewModel? AnalystCard { get; private set; } + + /// + /// 是否为分析师结果卡片 + /// + public bool IsAnalystCard => AnalystCard != null; + + /// + /// 是否显示富文本内容:分析师卡片、Adaptive Card、失败占位卡片均未命中时回退为富文本展示 + /// + public bool ShowRichText => !IsAnalystCard && !IsAdaptiveCard && !IsAnalystFailure; + + /// + /// 发送者显示名(ASCII Agent Name 翻译为中文显示名) + /// + public string DisplaySender => AnalystDisplayNameMapper.GetDisplayName(Sender); + public ChatMessageAdapter(ChatMessage chatMessage, AdaptiveCardConverter? converter = null) { _converter = converter; @@ -95,9 +116,16 @@ public ChatMessageAdapter(ChatMessage chatMessage, AdaptiveCardConverter? conver IsUser = chatMessage.Role == ChatRole.User; Sender = chatMessage.AuthorName ?? (IsUser ? "用户" : "助手"); Status = MessageStatus.Sent; - Timestamp = chatMessage.CreatedAt ?? DateTimeOffset.Now; + + // LLM 服务返回消息的 CreatedAt 通常为 UTC(Offset 为 0),直接格式化会把本地 22:02 显示成 14:02; + // 统一归一化为本地时区后再展示 + var createdAt = chatMessage.CreatedAt; + if (createdAt.HasValue && createdAt.Value.Offset == TimeSpan.Zero) + createdAt = createdAt.Value.ToLocalTime(); + Timestamp = createdAt ?? DateTimeOffset.Now; TryParseAdaptiveCard(Content); + TryParseAnalystCard(Content); } partial void OnContentChanged(string value) @@ -111,16 +139,51 @@ partial void OnContentChanged(string value) { TryParseAdaptiveCard(value); } + + if (AnalystCard == null) + { + TryParseAnalystCard(value); + } } partial void OnStatusChanged(MessageStatus value) { - // 流结束置为终态时,对最终内容统一解析一次 Adaptive Card - if (value is MessageStatus.Sent or MessageStatus.Failed && - AdaptiveCard == null && IsJsonContent(Content)) + // 流结束置为终态时,对最终内容统一解析一次 Adaptive Card / 分析师卡片 + if (value is not (MessageStatus.Sent or MessageStatus.Failed)) + return; + + if (AdaptiveCard == null && IsJsonContent(Content)) { TryParseAdaptiveCard(Content); } + + if (AnalystCard == null) + { + TryParseAnalystCard(Content); + } + } + + /// + /// 尝试把分析师 JSON 产物解析为结构化卡片。 + /// 仅对已知分析师作者名生效,且优先级低于 Adaptive Card; + /// 解析失败时保持 null,由 UI 回退为 RichTextBlock 展示。 + /// + private void TryParseAnalystCard(string content) + { + if (IsUser || IsAdaptiveCard || IsAnalystFailure) + return; + + // 已知分析师的 ASCII 名才尝试结构化解析,避免把普通助手回复误判为卡片 + if (!AnalystDisplayNameMapper.IsKnownAnalystName(Sender)) + return; + + if (AnalystCardViewModel.TryCreate(Sender, content, out var card)) + { + AnalystCard = card; + OnPropertyChanged(nameof(AnalystCard)); + OnPropertyChanged(nameof(IsAnalystCard)); + OnPropertyChanged(nameof(ShowRichText)); + } } private void TryParseAdaptiveCard(string content) @@ -137,6 +200,27 @@ private void TryParseAdaptiveCard(string content) AdaptiveCard = card; OnPropertyChanged(nameof(AdaptiveCard)); OnPropertyChanged(nameof(IsAdaptiveCard)); + OnPropertyChanged(nameof(ShowRichText)); + } + } + + /// + /// 是否为分析师失败标记消息(供侧边栏渲染灰色失败占位卡片) + /// + public bool IsAnalystFailure => AnalystFailureMessages.IsFailureMarker(Content); + + /// + /// 失败占位卡片展示文本:「分析师显示名 本次执行失败:原因」 + /// + public string FailureNoticeText + { + get + { + var agentName = AnalystFailureMessages.ExtractAgentName(Content); + var displayName = string.IsNullOrEmpty(agentName) + ? DisplaySender + : AnalystDisplayNameMapper.GetDisplayName(agentName); + return $"{displayName} 本次执行失败:{AnalystFailureMessages.ExtractFailureReason(Content)}"; } } diff --git a/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs b/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs index 30da8e0..cfd2bd4 100644 --- a/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using System.Collections.ObjectModel; +using System.Diagnostics; namespace MarketAssistant.ViewModels; @@ -38,6 +39,13 @@ public partial class ChatSidebarViewModel : ViewModelBase, IDisposable private CancellationTokenSource? _currentCancellationTokenSource; + /// + /// 流式内容刷新到 UI 的最小间隔:把逐 chunk 的高频赋值合并为按时间片批量刷新 + /// + private static readonly TimeSpan ContentFlushInterval = TimeSpan.FromMilliseconds(100); + private static readonly long FlushIntervalTicks = ContentFlushInterval.Ticks * Stopwatch.Frequency / TimeSpan.TicksPerSecond; + private long _lastFlushTimestamp; + public IAsyncRelayCommand SendMessageCommand { get; } public ChatSidebarViewModel( @@ -95,26 +103,32 @@ private async Task SendMessageAsync() _currentCancellationTokenSource = new CancellationTokenSource(); var contentBuilder = new System.Text.StringBuilder(); bool hasReceivedContent = false; + _lastFlushTimestamp = 0L; await foreach (var chunk in _chatSession!.SendMessageStreamAsync(currentInput, _currentCancellationTokenSource.Token)) { - if (!string.IsNullOrEmpty(chunk)) + if (string.IsNullOrEmpty(chunk)) + continue; + + contentBuilder.Append(chunk); + + if (!hasReceivedContent) { - contentBuilder.Append(chunk); - - if (!hasReceivedContent) - { - hasReceivedContent = true; - aiMessage.Status = MessageStatus.Streaming; - aiMessage.Content = chunk; - } - else - { - aiMessage.Content = contentBuilder.ToString(); - } + hasReceivedContent = true; + aiMessage.Status = MessageStatus.Streaming; + } + + // chunk 合并节流:逐 chunk 全量赋值会让绑定→重渲染管线高频空转, + // 按固定间隔把累积内容批量刷到 Content,流结束后再补一次最终刷新 + var now = Stopwatch.GetTimestamp(); + if (now - _lastFlushTimestamp >= FlushIntervalTicks) + { + _lastFlushTimestamp = now; + aiMessage.Content = contentBuilder.ToString(); } } + aiMessage.Content = contentBuilder.ToString(); aiMessage.Status = MessageStatus.Sent; } catch (OperationCanceledException) @@ -168,13 +182,23 @@ private void EnsureChatSession() private void AddWelcomeMessage() { var content = string.IsNullOrEmpty(StockCode) - ? "欢迎使用智能对话功能!请先选择要分析的股票。" - : $"欢迎使用智能对话功能!当前股票:{StockCode}。请开始分析后查看历史对话。"; + ? "欢迎使用智能助手!请先选择要分析的股票。" + : $"欢迎使用智能助手!当前股票:{StockCode}。请开始分析后查看历史对话。"; var welcomeMessage = new ChatMessageAdapter(new ChatMessage(ChatRole.Assistant, content) { AuthorName = "市场分析助手" }, _adaptiveCardConverter); ChatMessages.Add(welcomeMessage); } + /// + /// 判断分析消息是否适合在聊天侧栏展示: + /// 过滤系统内部说明(产物读取指引、维度缺失说明等), + /// 保留分析师的真实结论文本与失败标记(失败标记渲染为灰色占位卡片)。 + /// + private static bool IsDisplayableAnalystMessage(ChatMessage message) + => message.Role != ChatRole.System && + !string.Equals(message.AuthorName, "SystemNotice", StringComparison.Ordinal) && + !string.IsNullOrWhiteSpace(message.Text); + /// /// 使用分析结果初始化对话上下文。 /// 分析结果会注入到 MAF 会话的系统指令中,同时在 UI 上展示。 @@ -183,7 +207,9 @@ public Task InitializeWithAnalysisHistory(string stockCode, IEnumerable // 集合修改须在 UI 线程执行(加载可能由后台事件触发进入),避免跨线程操作 ObservableCollection await Dispatcher.UIThread.InvokeAsync(Assets.Clear); - await UpdateAssetDataProgressivelyAsync(favoritesCodes, ct); + await UpdateAssetDataAsync(favoritesCodes, ct); RebuildAssetIndex(); @@ -137,48 +137,41 @@ await SafeExecuteAsync(async () => } /// - /// 渐进式加载资产实时数据(限制并发数,避免同时打开过多浏览器页面) + /// 加载收藏资产数据:先命中本地缓存,未命中的部分一次性交给 + /// 批量获取 + /// (虚拟币实现为单次批量行情请求),再统一按请求顺序上 UI。 /// - private async Task UpdateAssetDataProgressivelyAsync(List favorites, CancellationToken ct) + private async Task UpdateAssetDataAsync(List favorites, CancellationToken ct) { - const int maxConcurrency = 3; - using var semaphore = new SemaphoreSlim(maxConcurrency); + var results = new AssetInfo?[favorites.Count]; + var missingIndexes = new List(); - var tasks = favorites.Select(async favorite => + for (var i = 0; i < favorites.Count; i++) { ct.ThrowIfCancellationRequested(); - await semaphore.WaitAsync(ct); - try + var cached = await CacheService.GetCachedAssetInfoAsync(favorites[i].Code); + results[i] = cached; + if (cached == null) { - var assetInfo = await CacheService.GetCachedAssetInfoAsync(favorite.Code); + missingIndexes.Add(i); + } + } - if (assetInfo == null) - { - assetInfo = await AssetInfoService.GetAssetInfoAsync(favorite.Code, favorite.Market); - if (assetInfo != null) - { - CacheService.CacheAssetInfo(favorite.Code, assetInfo); - } - } + if (missingIndexes.Count > 0) + { + var missing = missingIndexes.Select(index => favorites[index]).ToList(); + var fetched = await AssetInfoService.GetAssetInfosAsync(missing, ct); - return assetInfo; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) + for (var i = 0; i < missingIndexes.Count; i++) { - Logger?.LogError(ex, $"加载资产 {favorite.Code} 数据时出错"); - return null; - } - finally - { - semaphore.Release(); + var info = fetched[i]; + results[missingIndexes[i]] = info; + if (info != null) + { + CacheService.CacheAssetInfo(missing[i].Code, info); + } } - }); - - var results = await Task.WhenAll(tasks); + } // 落 UI 前复查取消:旧加载在新的 Clear 之后到达时不得再追加(防残留/重复条目) ct.ThrowIfCancellationRequested(); diff --git a/src/MarketAssistant.App/ViewModels/MainWindowViewModel.cs b/src/MarketAssistant.App/ViewModels/MainWindowViewModel.cs index a2ddf61..1b72183 100644 --- a/src/MarketAssistant.App/ViewModels/MainWindowViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/MainWindowViewModel.cs @@ -1,5 +1,6 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using MarketAssistant.Applications.AlertCenter; using MarketAssistant.Services; using MarketAssistant.Services.Market; using MarketAssistant.Services.Navigation; @@ -18,8 +19,19 @@ public partial class MainWindowViewModel : ViewModelBase private readonly NavigationService _navigationService; private readonly MarketContext _marketContext; private readonly INotificationService _notificationService; + private readonly IAlertCenterService _alertCenterService; private bool _isSynchronizingNavigationSelection; + /// 告警中心未读数(导航徽标)。AlertsChanged 时刷新。 + [ObservableProperty] + private int _unreadAlertCount; + + /// 是否存在未读告警(控制导航图标徽标可见性)。 + public bool HasUnreadAlerts => UnreadAlertCount > 0; + + partial void OnUnreadAlertCountChanged(int value) + => OnPropertyChanged(nameof(HasUnreadAlerts)); + // 主导航与底部导航必须各自持有选中项:两个 ListBox 绑定同一属性时, // 任一选中变化会让另一个列表把 SelectedIndex 归 -1 并回写 null,导致侧栏高亮丢失 [ObservableProperty] @@ -66,6 +78,7 @@ public MainWindowViewModel( NavigationService navigationService, MarketContext marketContext, INotificationService notificationService, + IAlertCenterService alertCenterService, ILogger? logger = null) : base(logger) { @@ -73,6 +86,7 @@ public MainWindowViewModel( _navigationService = navigationService; _marketContext = marketContext; _notificationService = notificationService; + _alertCenterService = alertCenterService; MainNavigationItems = new ObservableCollection(); BottomNavigationItems = new ObservableCollection(); @@ -82,12 +96,40 @@ public MainWindowViewModel( _navigationService.PropertyChanged += OnNavigationServicePropertyChanged; + // 订阅告警中心:新告警/合并/已读时刷新导航未读徽标 + _alertCenterService.AlertsChanged += OnAlertsChanged; + _ = RefreshUnreadAlertCountAsync(); + SubscribeToMarketChanges(_marketContext); // 默认导航到首页。选中项的变更回调负责实际导航,避免重复入栈。 SelectedMainNavigationItem = MainNavigationItems[0]; } + /// 告警集合变化回调:切回 UI 线程刷新未读数。 + private void OnAlertsChanged() + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => _ = RefreshUnreadAlertCountAsync()); + } + + /// 从告警中心拉取未读数并同步导航徽标,失败静默(不影响主流程)。 + private async Task RefreshUnreadAlertCountAsync() + { + try + { + UnreadAlertCount = await _alertCenterService.GetUnreadCountAsync(); + } + catch (Exception ex) + { + Logger?.LogWarning(ex, "刷新告警未读数失败"); + } + + // 切市场会重建导航集合(徽标状态被重置),每次刷新都重新定位告警项回填 + var alertItem = MainNavigationItems.FirstOrDefault(item => item.Title == "告警"); + if (alertItem != null) + alertItem.HasUnreadBadge = UnreadAlertCount > 0; + } + protected override void OnMarketChanged(MarketType newMarket) { OnPropertyChanged(nameof(CurrentMarketText)); @@ -95,6 +137,9 @@ protected override void OnMarketChanged(MarketType newMarket) OnPropertyChanged(nameof(IsCryptoMarket)); RebuildNavigationItems(); RebuildIndexTickers(); + + // 导航集合重建后徽标状态被重置,按当前未读数回填 + _ = RefreshUnreadAlertCountAsync(); } private void RebuildNavigationItems() @@ -267,6 +312,14 @@ public class NavigationItemViewModel : ViewModelBase public string SelectedIconPath { get; } public Func CreateViewModel { get; } + /// 是否显示未读徽标(仅告警导航项在存在未读告警时为 true)。 + private bool _hasUnreadBadge; + public bool HasUnreadBadge + { + get => _hasUnreadBadge; + set => SetProperty(ref _hasUnreadBadge, value); + } + public NavigationItemViewModel(string title, string iconPath, string selectedIconPath, Func createViewModel) { Title = title; diff --git a/src/MarketAssistant.App/ViewModels/PriceAlertPageViewModel.cs b/src/MarketAssistant.App/ViewModels/PriceAlertPageViewModel.cs index 1e39dc2..72f6b97 100644 --- a/src/MarketAssistant.App/ViewModels/PriceAlertPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/PriceAlertPageViewModel.cs @@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using MarketAssistant.Applications; +using MarketAssistant.Applications.AlertCenter; using MarketAssistant.Applications.Assets; using MarketAssistant.Applications.PriceAlert; using MarketAssistant.Services.Dialog; @@ -38,6 +39,7 @@ public partial class PriceAlertPageViewModel : ViewModelBase, IDisposable private readonly MarketContext _marketContext; private readonly IDialogService _dialogService; private readonly IUserSettingService _userSettingService; + private readonly IAlertCenterService _alertCenterService; /// /// 资产下拉列表加载取消令牌,避免快速切换市场时竞态覆盖。 @@ -101,6 +103,43 @@ public partial class PriceAlertPageViewModel : ViewModelBase, IDisposable [ObservableProperty] private bool _newRuleIsOneTime; + /// + /// 是否展开高级选项(去抖 / 冷却 / 限次 / 交易联动) + /// + [ObservableProperty] + private bool _isAdvancedOptionsExpanded; + + /// + /// 新规则 - 去抖确认:条件需连续满足的行情 tick 数,0 表示立即触发。 + /// + [ObservableProperty] + private int _newRuleConfirmTicks; + + /// + /// 新规则 - 去抖持续秒数:条件需持续满足的秒数,0 表示不限制。 + /// 与连续次数同时配置时取更严判定,用于消除 A 股轮询与币安推送的频率差异。 + /// + [ObservableProperty] + private int _newRuleConfirmSeconds; + + /// + /// 新规则 - 冷却分钟数:两次触发之间的最小间隔,0 表示不冷却。 + /// + [ObservableProperty] + private int _newRuleCooldownMinutes; + + /// + /// 新规则 - 最大触发次数,0 表示不限(与"仅提醒一次"互斥,后者恒为 1 次)。 + /// + [ObservableProperty] + private int _newRuleMaxTriggerCount; + + /// + /// 新规则 - 是否升级为确认级告警:触发期间该标的的 AI 交易信号强制走人工确认。 + /// + [ObservableProperty] + private bool _newRuleRequireConfirmation; + /// /// 新规则 - 市场类型(跟随当前市场) /// @@ -157,11 +196,43 @@ NewRuleCondition is AlertCondition.ChangePercentAbove or AlertCondition.ChangePe /// public bool IsNotificationEnabled => _userSettingService.CurrentSetting.Notification; + /// 页内标签页:0 = 规则管理,1 = 告警历史。 + [ObservableProperty] + private int _selectedTabIndex; + + /// 当前是否选中历史标签页。 + public bool IsHistoryTabSelected => SelectedTabIndex == 1; + + /// 告警历史子 ViewModel。 + public AlertHistoryViewModel AlertHistory { get; } + + partial void OnSelectedTabIndexChanged(int value) + { + OnPropertyChanged(nameof(IsHistoryTabSelected)); + + // 进入历史标签页即视为已阅:清除未读徽标(MainWindowViewModel 经 AlertsChanged 刷新) + if (value == 1) + _ = MarkAllAlertsReadAsync(); + } + + private async Task MarkAllAlertsReadAsync() + { + try + { + await _alertCenterService.MarkAllReadAsync(); + } + catch (Exception ex) + { + Logger?.LogWarning(ex, "进入历史页标记告警已读失败"); + } + } + public PriceAlertPageViewModel( PriceAlertService alertService, MarketContext marketContext, IDialogService dialogService, IUserSettingService userSettingService, + IAlertCenterService alertCenterService, ILogger logger) : base(logger) { @@ -169,6 +240,9 @@ public PriceAlertPageViewModel( _marketContext = marketContext; _dialogService = dialogService; _userSettingService = userSettingService; + _alertCenterService = alertCenterService; + AlertHistory = new AlertHistoryViewModel(alertCenterService, logger); + _alertService.RulesChanged += OnRulesChanged; _alertService.RuleQuoteUpdated += OnRuleQuoteUpdated; SubscribeToMarketChanges(_marketContext); @@ -287,6 +361,11 @@ private static PriceAlertRule CreateDisplayRule(PriceAlertRule source) Condition = source.Condition, TargetPrice = source.TargetPrice, IsOneTime = source.IsOneTime, + MaxTriggerCount = source.MaxTriggerCount, + ConfirmTicks = source.ConfirmTicks, + ConfirmSeconds = source.ConfirmSeconds, + CooldownMinutes = source.CooldownMinutes, + TradingImpact = source.TradingImpact, Triggered = source.Triggered, Enabled = source.Enabled, CreatedAt = source.CreatedAt @@ -367,6 +446,13 @@ await SafeExecuteAsync(async () => Condition = NewRuleCondition, TargetPrice = targetValue, IsOneTime = NewRuleIsOneTime, + MaxTriggerCount = NewRuleIsOneTime ? 1 : NewRuleMaxTriggerCount, + ConfirmTicks = NewRuleConfirmTicks, + ConfirmSeconds = NewRuleConfirmSeconds, + CooldownMinutes = NewRuleCooldownMinutes, + TradingImpact = NewRuleRequireConfirmation + ? AlertTradingImpact.RequireConfirmation + : AlertTradingImpact.None, Enabled = true }; @@ -375,6 +461,12 @@ await SafeExecuteAsync(async () => NewRuleSelectedAsset = null; NewRuleAssetText = string.Empty; NewRuleTargetValue = string.Empty; + NewRuleConfirmTicks = 0; + NewRuleConfirmSeconds = 0; + NewRuleCooldownMinutes = 0; + NewRuleMaxTriggerCount = 0; + NewRuleRequireConfirmation = false; + NewRuleIsOneTime = false; Logger?.LogInformation("添加价格告警: {Code} {Condition} {Value}", asset.Code, rule.Condition, targetValue); }, "添加告警规则"); @@ -447,6 +539,7 @@ public void Dispose() _assetLoadCts = null; _alertService.RulesChanged -= OnRulesChanged; _alertService.RuleQuoteUpdated -= OnRuleQuoteUpdated; + AlertHistory.Dispose(); UnsubscribeFromMarketChanges(_marketContext); GC.SuppressFinalize(this); } diff --git a/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.Models.cs b/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.Models.cs index a7a4c04..1214e29 100644 --- a/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.Models.cs +++ b/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.Models.cs @@ -19,7 +19,7 @@ public partial class SettingsPageViewModel [ObservableProperty] private ObservableCollection _models = []; - // 服务商列表(目录运行期不变,缓存实例避免 ComboBox 每次绑定求值新建 List) + // 服务商列表(目录运行期不变,缓存实例避免下拉框每次绑定求值新建 List) public List Providers { get; } = ModelProviderCatalog.Providers.ToList(); [ObservableProperty] diff --git a/src/MarketAssistant.App/Views/Components/AnalysisReportView.axaml b/src/MarketAssistant.App/Views/Components/AnalysisReportView.axaml index 3dd2a72..6e4c50c 100644 --- a/src/MarketAssistant.App/Views/Components/AnalysisReportView.axaml +++ b/src/MarketAssistant.App/Views/Components/AnalysisReportView.axaml @@ -251,7 +251,7 @@ Width="{StaticResource LargeFontSize}" Height="{StaticResource LargeFontSize}" VerticalAlignment="Center"/> - + @@ -280,7 +280,7 @@ Width="{StaticResource LargeFontSize}" Height="{StaticResource LargeFontSize}" VerticalAlignment="Center"/> - + @@ -382,7 +382,7 @@ Width="{StaticResource LargeFontSize}" Height="{StaticResource LargeFontSize}" VerticalAlignment="Center"/> - + diff --git a/src/MarketAssistant.App/Views/Components/AnalystResultCardView.axaml b/src/MarketAssistant.App/Views/Components/AnalystResultCardView.axaml new file mode 100644 index 0000000..67a6c1e --- /dev/null +++ b/src/MarketAssistant.App/Views/Components/AnalystResultCardView.axaml @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/MarketAssistant.App/Views/Components/AnalystResultCardView.axaml.cs b/src/MarketAssistant.App/Views/Components/AnalystResultCardView.axaml.cs new file mode 100644 index 0000000..271b574 --- /dev/null +++ b/src/MarketAssistant.App/Views/Components/AnalystResultCardView.axaml.cs @@ -0,0 +1,26 @@ +using Avalonia; +using Avalonia.Controls; +using MarketAssistant.ViewModels; + +namespace MarketAssistant.Views.Components; + +/// +/// 分析师结构化结果卡片:评级徽标 + 评分条 + 核心观点 + 关键数据 + 风险提示 + 可展开原始文本。 +/// 数据源为 ,由 ChatMessageAdapter 解析分析师 JSON 产物构建。 +/// +public partial class AnalystResultCardView : UserControl +{ + public static readonly StyledProperty CardProperty = + AvaloniaProperty.Register(nameof(Card)); + + public AnalystCardViewModel? Card + { + get => GetValue(CardProperty); + set => SetValue(CardProperty, value); + } + + public AnalystResultCardView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/MarketAssistant.App/Views/Components/ChatSidebarView.axaml b/src/MarketAssistant.App/Views/Components/ChatSidebarView.axaml index 8027891..2244c28 100644 --- a/src/MarketAssistant.App/Views/Components/ChatSidebarView.axaml +++ b/src/MarketAssistant.App/Views/Components/ChatSidebarView.axaml @@ -77,7 +77,7 @@ Width="{StaticResource CloseButtonSize}" Height="{StaticResource CloseButtonSize}" CornerRadius="{StaticResource CloseButtonCornerRadius}" Margin="{StaticResource CloseButtonMargin}" - Padding="0"> + Padding="{StaticResource NoPadding}"> @@ -134,7 +134,7 @@ CornerRadius="{StaticResource SendButtonCornerRadius}" Background="{StaticResource PrimaryBrush}" Margin="{StaticResource SendButtonMargin}" - Padding="0" + Padding="{StaticResource NoPadding}" IsEnabled="{Binding IsProcessing, Converter={x:Static BoolConverters.Not}}"> + ScrollViewer.VerticalScrollBarVisibility="Visible"> + + + + + + - - + + + + + + + + + + + + @@ -48,15 +78,12 @@ VerticalAlignment="Center" Width="{StaticResource ProgressDialogWidth}"> - - + + - - - - - - - - - - - - - + + + + + + + + + + + - - - + + + + + + - + Background="{DynamicResource SurfaceVariantBrush}"/> - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - + + private void OnProviderBoxGotFocus(object? sender, FocusChangedEventArgs e) + { + if (e.NavigationMethod != NavigationMethod.Unspecified + && sender is AutoCompleteBox box + && !box.IsDropDownOpen) + { + box.IsDropDownOpen = true; + } + } + + /// + /// 服务商下拉关闭时恢复显示当前选中项,避免未提交的搜索残留与实际选中项不一致。 + /// 仅影响文本框显示,不回写 ViewModel。 + /// + private void OnProviderBoxDropDownClosed(object? sender, EventArgs e) + { + if (sender is AutoCompleteBox box && box.SelectedItem is ModelProvider provider) + { + box.Text = provider.DisplayName; + } + } + + /// + /// 模型框获得用户焦点(点击/Tab)时展开候选列表,配合输入即时过滤。 + /// + private void OnModelBoxGotFocus(object? sender, FocusChangedEventArgs e) + { + if (e.NavigationMethod != NavigationMethod.Unspecified + && sender is AutoCompleteBox box + && !box.IsDropDownOpen) + { + box.IsDropDownOpen = true; + } + } } \ No newline at end of file diff --git a/src/MarketAssistant.App/Views/Pages/Trading/StrategyConfigView.axaml b/src/MarketAssistant.App/Views/Pages/Trading/StrategyConfigView.axaml index 1c8cb8c..ab8f28c 100644 --- a/src/MarketAssistant.App/Views/Pages/Trading/StrategyConfigView.axaml +++ b/src/MarketAssistant.App/Views/Pages/Trading/StrategyConfigView.axaml @@ -35,7 +35,7 @@ - + + - + + + + + @@ -221,7 +279,8 @@ + FontSize="{StaticResource SmallFontSize}" + Foreground="{DynamicResource TextSecondaryBrush}" TextWrapping="Wrap"/> @@ -233,7 +292,8 @@ + FontSize="{StaticResource SmallFontSize}" + Foreground="{DynamicResource TextSecondaryBrush}" TextWrapping="Wrap"/> @@ -245,33 +305,46 @@ + FontSize="{StaticResource SmallFontSize}" + Foreground="{DynamicResource TextSecondaryBrush}" TextWrapping="Wrap"/> - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - diff --git a/src/MarketAssistant.App/Views/Pages/Trading/TradeHistoryView.axaml b/src/MarketAssistant.App/Views/Pages/Trading/TradeHistoryView.axaml index 33cc821..030317f 100644 --- a/src/MarketAssistant.App/Views/Pages/Trading/TradeHistoryView.axaml +++ b/src/MarketAssistant.App/Views/Pages/Trading/TradeHistoryView.axaml @@ -112,7 +112,7 @@ + Padding="{StaticResource NoPadding}"> diff --git a/src/MarketAssistant.App/Views/Windows/InputDialogWindow.axaml b/src/MarketAssistant.App/Views/Windows/InputDialogWindow.axaml index ab3e3e8..35040ec 100644 --- a/src/MarketAssistant.App/Views/Windows/InputDialogWindow.axaml +++ b/src/MarketAssistant.App/Views/Windows/InputDialogWindow.axaml @@ -31,13 +31,13 @@ Content="确定" Classes="btn-primary" MinWidth="{StaticResource SmallButtonMinWidth}" - Padding="16,8" + Padding="{StaticResource PagePadding}" Click="OnOkClick"/> public event Action? PriceUpdated; + /// 连接中断时触发(断线进入重连流程)。 + public event Action? ConnectionInterrupted; + + /// 断线后成功恢复连接时触发(首次连接不触发)。 + public event Action? ConnectionRestored; + + private int _connectionInterrupted; + public BinanceWebSocketService(ILogger logger) { _logger = logger; @@ -166,6 +174,45 @@ private async Task ApplySubscriptionAsync() _ws = ws; _cts = cts; _ = ReceiveLoopAsync(ws, cts.Token); + + // 曾发生过断线才通知恢复,首次连接不打扰 + if (Interlocked.Exchange(ref _connectionInterrupted, 0) == 1) + { + _logger.LogInformation("Binance WebSocket 已重连成功"); + NotifyConnectionRestored(); + } + } + + /// + /// 通知订阅方连接已恢复。订阅方异常不得影响连接生命周期主链路。 + /// + private void NotifyConnectionRestored() + { + try + { + ConnectionRestored?.Invoke(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ConnectionRestored 订阅方处理异常"); + } + } + + /// + /// 通知订阅方连接已中断。断线路径(服务端关闭/网络异常)统一走这里。 + /// + private void NotifyConnectionInterrupted() + { + Interlocked.Exchange(ref _connectionInterrupted, 1); + try + { + ConnectionInterrupted?.Invoke(); + } + catch (Exception ex) + { + // 订阅方异常不影响重连主链路 + _logger.LogWarning(ex, "ConnectionInterrupted 订阅方处理异常"); + } } private async Task ReceiveLoopAsync(ClientWebSocket ws, CancellationToken ct) @@ -187,6 +234,7 @@ private async Task ReceiveLoopAsync(ClientWebSocket ws, CancellationToken ct) if (result.MessageType == WebSocketMessageType.Close) { _logger.LogInformation("Binance WebSocket 服务端关闭连接"); + NotifyConnectionInterrupted(); return; } messageStream.Write(buffer, 0, result.Count); @@ -206,6 +254,7 @@ private async Task ReceiveLoopAsync(ClientWebSocket ws, CancellationToken ct) // 网络断开、socket 被并发释放等均视为断线;仅当本连接仍是当前连接时才重连, // 避免旧循环把新连接顶掉 _logger.LogWarning(ex, "Binance WebSocket 断开,将在 {Delay}ms 后重连", ReconnectDelayMs); + NotifyConnectionInterrupted(); await ScheduleReconnectIfCurrentAsync(ws, ct); } } diff --git a/src/MarketAssistant.DataProviders/CoinGeckoApiService.cs b/src/MarketAssistant.DataProviders/CoinGeckoApiService.cs index e7e5fd3..95f51ca 100644 --- a/src/MarketAssistant.DataProviders/CoinGeckoApiService.cs +++ b/src/MarketAssistant.DataProviders/CoinGeckoApiService.cs @@ -236,6 +236,8 @@ public class CoinGeckoCoinDetail public CoinGeckoCoinCommunityData? CommunityData { get; set; } [JsonPropertyName("developer_data")] public CoinGeckoCoinDeveloperData? DeveloperData { get; set; } + [JsonPropertyName("detail_platforms")] + public Dictionary? DetailPlatforms { get; set; } [JsonPropertyName("market_cap_rank")] public int? MarketCapRank { get; set; } [JsonPropertyName("coingecko_rank")] diff --git a/src/MarketAssistant.DataProviders/Web3/ChainRegistry.cs b/src/MarketAssistant.DataProviders/Web3/ChainRegistry.cs new file mode 100644 index 0000000..a75d6b2 --- /dev/null +++ b/src/MarketAssistant.DataProviders/Web3/ChainRegistry.cs @@ -0,0 +1,54 @@ +namespace MarketAssistant.DataProviders.Web3; + +/// +/// Web3 链 ID 统一注册表:将多种链 ID 写法归一化为规范链名, +/// 并转换各数据源所需词汇(DexScreener 要求链全名,GoPlus 主端点要求数字链 ID)。 +/// +public static class ChainRegistry +{ + /// 规范链信息:GoPlusChainId 为 GoPlus 主端点的数字链 ID,null 表示须走 Solana 专用端点。 + private static readonly Dictionary Chains = new(StringComparer.OrdinalIgnoreCase) + { + ["ethereum"] = "1", + ["bsc"] = "56", + ["polygon"] = "137", + ["arbitrum"] = "42161", + ["optimism"] = "10", + ["avalanche"] = "43114", + ["base"] = "8453", + ["solana"] = null + }; + + /// 别名 → 规范链名(含规范名自身、CoinGecko 平台 ID、EIP-155 数字链 ID、常用缩写)。 + private static readonly Dictionary Aliases = new(StringComparer.OrdinalIgnoreCase) + { + ["ethereum"] = "ethereum", ["eth"] = "ethereum", ["eth-mainnet"] = "ethereum", ["1"] = "ethereum", + ["bsc"] = "bsc", ["binance-smart-chain"] = "bsc", ["binance_smart_chain"] = "bsc", ["bnb"] = "bsc", ["56"] = "bsc", + ["polygon"] = "polygon", ["polygon-pos"] = "polygon", ["matic"] = "polygon", ["matic-network"] = "polygon", ["137"] = "polygon", + ["arbitrum"] = "arbitrum", ["arbitrum-one"] = "arbitrum", ["arb"] = "arbitrum", ["42161"] = "arbitrum", + ["optimism"] = "optimism", ["optimistic-ethereum"] = "optimism", ["10"] = "optimism", + ["avalanche"] = "avalanche", ["avax"] = "avalanche", ["avalanche-2"] = "avalanche", ["43114"] = "avalanche", + ["base"] = "base", ["8453"] = "base", + ["solana"] = "solana", ["sol"] = "solana", ["mainnet"] = "solana" + }; + + /// + /// 归一化为规范链名(ethereum/bsc/polygon/arbitrum/optimism/avalanche/base/solana)。 + /// 未识别的输入小写后原样透传,保证 DexScreener 的长尾链仍可用。 + /// + public static string Normalize(string? chainId) + { + if (string.IsNullOrWhiteSpace(chainId)) + throw new FriendlyException("缺少链 ID,支持的主要链:" + string.Join("、", Chains.Keys)); + + var key = chainId.Trim(); + return Aliases.TryGetValue(key, out var canonical) ? canonical : key.ToLowerInvariant(); + } + + /// + /// 规范链名 → GoPlus 数字链 ID;Solana 返回 null(须走 GoPlus 专用 Solana 端点)。 + /// 未收录的链返回 null,由调用方决定透传或报错。 + /// + public static string? ToGoPlusChainId(string canonical) => + Chains.TryGetValue(canonical, out var goPlusChainId) ? goPlusChainId : null; +} \ No newline at end of file diff --git a/src/MarketAssistant.DataProviders/Web3/DexScreenerClient.cs b/src/MarketAssistant.DataProviders/Web3/DexScreenerClient.cs new file mode 100644 index 0000000..cf87f6b --- /dev/null +++ b/src/MarketAssistant.DataProviders/Web3/DexScreenerClient.cs @@ -0,0 +1,95 @@ +using System.Net.Http.Json; +using MarketAssistant.DataProviders.Web3; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.DataProviders.Web3; + +/// +/// DexScreener 客户端:多链 DEX 行情(热门代币搜索、交易对流动性/涨跌幅)。 +/// 免费公开接口,无需 API Key;文档 https://docs.dexscreener.com/api/reference。 +/// +public sealed class DexScreenerClient +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public DexScreenerClient(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// 按关键字搜索 DEX 交易对(代币符号/名称/合约地址均可)。 + /// + /// 搜索关键字,如 "PEPE" 或合约地址 + public async Task> SearchPairsAsync(string query, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(query)) + throw new FriendlyException("DexScreener 搜索关键字不能为空"); + + var url = $"latest/dex/search?q={Uri.EscapeDataString(query.Trim())}"; + _logger.LogDebug("调用 DexScreener 搜索: {Query}", query); + + try + { + using var httpClient = _httpClientFactory.CreateClient("DexScreener"); + var response = await httpClient.GetFromJsonAsync(url, cancellationToken); + return response?.Pairs ?? []; + } + catch (HttpRequestException ex) when (ex.HttpRequestError == HttpRequestError.SecureConnectionError) + { + _logger.LogError(ex, "DexScreener API TLS 连接被重置: {Query}", query); + throw new FriendlyException( + "链上行情接口 TLS 连接被重置,多为当前网络对该域名的干扰:请在代理软件的分流规则中将 api.dexscreener.com 设为走代理线路后重试", ex); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "调用 DexScreener 搜索失败: {Query}", query); + throw new FriendlyException($"搜索链上交易对失败: {query},请检查网络连接", ex); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "解析 DexScreener 搜索结果失败: {Query}", query); + throw new FriendlyException($"解析链上交易对数据失败: {ex.Message}", ex); + } + } + + /// + /// 获取指定代币合约在指定链上的所有 DEX 交易对。链 ID 经 ChainRegistry 归一化为链全名。 + /// + /// 链 ID,支持规范名(ethereum/bsc/solana 等)及数字、别名等常见写法 + /// 代币合约地址 + public async Task> GetTokenPairsAsync(string chainId, string tokenAddress, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(tokenAddress)) + throw new FriendlyException("获取链上交易对需要指定代币合约地址"); + + var canonicalChain = ChainRegistry.Normalize(chainId); + var url = $"token-pairs/v1/{Uri.EscapeDataString(canonicalChain)}/{tokenAddress.Trim()}"; + _logger.LogDebug("调用 DexScreener 交易对查询: {ChainId} {TokenAddress}", chainId, tokenAddress); + + try + { + using var httpClient = _httpClientFactory.CreateClient("DexScreener"); + var pairs = await httpClient.GetFromJsonAsync>(url, cancellationToken); + return pairs ?? []; + } + catch (HttpRequestException ex) when (ex.HttpRequestError == HttpRequestError.SecureConnectionError) + { + _logger.LogError(ex, "DexScreener API TLS 连接被重置: {ChainId} {TokenAddress}", chainId, tokenAddress); + throw new FriendlyException( + "链上行情接口 TLS 连接被重置,多为当前网络对该域名的干扰:请在代理软件的分流规则中将 api.dexscreener.com 设为走代理线路后重试", ex); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "调用 DexScreener 交易对查询失败: {ChainId} {TokenAddress}", chainId, tokenAddress); + throw new FriendlyException($"获取链上交易对失败,请检查链 ID 与合约地址是否正确", ex); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "解析 DexScreener 交易对数据失败: {ChainId} {TokenAddress}", chainId, tokenAddress); + throw new FriendlyException($"解析链上交易对数据失败: {ex.Message}", ex); + } + } +} \ No newline at end of file diff --git a/src/MarketAssistant.DataProviders/Web3/GoPlusSecurityClient.cs b/src/MarketAssistant.DataProviders/Web3/GoPlusSecurityClient.cs new file mode 100644 index 0000000..40f45be --- /dev/null +++ b/src/MarketAssistant.DataProviders/Web3/GoPlusSecurityClient.cs @@ -0,0 +1,112 @@ +using System.Net.Http.Json; +using MarketAssistant.DataProviders.Web3; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.DataProviders.Web3; + +/// +/// GoPlus Security 客户端:代币安全审计与蜜罐检测(EVM 多链 + Solana 专用端点)。 +/// API 域名为 api.gopluslabs.io;链 ID 词汇统一经 ChainRegistry 归一化。 +/// +public sealed class GoPlusSecurityClient +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + /// 可选 API Key,由上层(如用户设置服务)在运行时注入。 + public string? ApiKey { get; set; } + + public GoPlusSecurityClient(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// 查询代币安全审计结果(含蜜罐检测、税率、增发/黑名单/暂停交易等风险项)。 + /// Solana 代币自动路由到 GoPlus 专用 Solana 端点。 + /// + /// 链 ID,支持规范名(ethereum/bsc/solana 等)及数字、别名等常见写法 + /// 代币合约地址(Solana 为 mint 地址,保留大小写) + public async Task GetTokenSecurityAsync(string chainId, string tokenAddress, CancellationToken cancellationToken = default) + { + var canonical = ChainRegistry.Normalize(chainId); + var normalAddress = tokenAddress?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(normalAddress)) + throw new FriendlyException("代币安全审计需要提供合约地址"); + + var isSolana = canonical == "solana"; + var goPlusChainId = isSolana ? null : ResolveGoPlusChainId(canonical); + var url = isSolana + ? $"api/v1/solana/token_security?contract_addresses={Uri.EscapeDataString(normalAddress)}" + : $"api/v1/token_security/{goPlusChainId}?contract_addresses={Uri.EscapeDataString(normalAddress)}"; + + var data = await ExecuteAsync(url, cancellationToken); + if (data?.Result is not { Count: > 0 }) + return null; + + var lookupKey = isSolana ? normalAddress : normalAddress.ToLowerInvariant(); + if (data.Result.TryGetValue(lookupKey, out var security)) + return security; + + // GoPlus 返回的 result 键大小写不保证与入参一致,未精确命中时忽略大小写回退 + return data.Result.FirstOrDefault(kv => + string.Equals(kv.Key, normalAddress, StringComparison.OrdinalIgnoreCase)).Value + ?? data.Result.Values.First(); + } + + /// + /// 解析 GoPlus 主端点的数字链 ID:注册链经 ChainRegistry 转换;未注册的数字链 ID 透传, + /// 其余未收录链明确报错,避免拼出缺链 ID 的 URL。 + /// + private static string ResolveGoPlusChainId(string canonical) + { + var registered = ChainRegistry.ToGoPlusChainId(canonical); + if (!string.IsNullOrEmpty(registered)) + return registered; + + if (canonical.All(char.IsAsciiDigit)) + return canonical; + + throw new FriendlyException( + $"GoPlus 暂不支持该链: {canonical},支持的主要链:ethereum、bsc、polygon、arbitrum、optimism、avalanche、base(Solana 自动路由专用端点,也可直接使用数字链 ID)"); + } + + /// + /// 统一执行 GET 请求并解析 GoPlus 包装响应(code 1 = 成功)。 + /// + private async Task ExecuteAsync(string url, CancellationToken cancellationToken) where T : class + { + _logger.LogDebug("调用 GoPlus API: {Url}", url); + try + { + using var httpClient = _httpClientFactory.CreateClient("GoPlus"); + if (!string.IsNullOrWhiteSpace(ApiKey)) + httpClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", ApiKey); + + var response = await httpClient.GetFromJsonAsync(url, cancellationToken); + + if (response is GoPlusSecurityResponse sec && sec.Code != 1) + throw new FriendlyException($"GoPlus 安全审计返回错误: {sec.Message ?? sec.Code.ToString()}"); + + return response; + } + catch (HttpRequestException ex) when (ex.HttpRequestError == HttpRequestError.SecureConnectionError) + { + _logger.LogError(ex, "GoPlus API TLS 连接被重置: {Url}", url); + throw new FriendlyException( + "GoPlus 接口 TLS 连接被重置,请检查网络/代理对 api.gopluslabs.io 的连通性后重试", ex); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "调用 GoPlus API 失败: {Url}", url); + throw new FriendlyException("链上安全数据获取失败,请检查网络连接", ex); + } + catch (Exception ex) when (ex is not FriendlyException) + { + _logger.LogError(ex, "解析 GoPlus API 响应失败: {Url}", url); + throw new FriendlyException($"解析链上安全数据失败: {ex.Message}", ex); + } + } +} \ No newline at end of file diff --git a/src/MarketAssistant.DataProviders/Web3/ServiceCollectionExtensions.cs b/src/MarketAssistant.DataProviders/Web3/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..ef69c2f --- /dev/null +++ b/src/MarketAssistant.DataProviders/Web3/ServiceCollectionExtensions.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace MarketAssistant.DataProviders.Web3; + +/// +/// 注册 Web3 / 链上数据客户端(DexScreener 多链 DEX 行情、GoPlus 安全审计)。 +/// HttpClient 命名注册("DexScreener" / "GoPlus")集中在宿主层 AddHttpClientsCore, +/// 本层通过 IHttpClientFactory + 现有名字消费,与 AShare 子目录模式一致。 +/// +public static class ServiceCollectionExtensions +{ + /// + /// 注册 Web3 / 链上数据客户端。 + /// + public static IServiceCollection AddWeb3DataProviders(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} \ No newline at end of file diff --git a/src/MarketAssistant.DataProviders/Web3/Web3Models.cs b/src/MarketAssistant.DataProviders/Web3/Web3Models.cs new file mode 100644 index 0000000..73b8918 --- /dev/null +++ b/src/MarketAssistant.DataProviders/Web3/Web3Models.cs @@ -0,0 +1,247 @@ +using System.Text.Json.Serialization; + +namespace MarketAssistant.DataProviders.Web3; + +/// +/// DexScreener / GoPlus 响应模型(仅服务数据提供者内部反序列化使用)。 +/// + +public class DexScreenerSearchResponse +{ + [JsonPropertyName("pairs")] + public List Pairs { get; set; } = new(); +} + +public class DexScreenerPair +{ + [JsonPropertyName("chainId")] + public string ChainId { get; set; } = string.Empty; + + [JsonPropertyName("dexId")] + public string DexId { get; set; } = string.Empty; + + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + [JsonPropertyName("baseToken")] + public DexScreenerToken BaseToken { get; set; } = new(); + + [JsonPropertyName("quoteToken")] + public DexScreenerToken QuoteToken { get; set; } = new(); + + [JsonPropertyName("priceUsd")] + public string? PriceUsd { get; set; } + + [JsonPropertyName("volume")] + public DexScreenerVolume? Volume { get; set; } + + [JsonPropertyName("priceChange")] + public DexScreenerPriceChange? PriceChange { get; set; } + + [JsonPropertyName("liquidity")] + public DexScreenerLiquidity? Liquidity { get; set; } + + [JsonPropertyName("fdv")] + public decimal? Fdv { get; set; } + + [JsonPropertyName("marketCap")] + public decimal? MarketCap { get; set; } + + [JsonPropertyName("pairCreatedAt")] + public long? PairCreatedAt { get; set; } +} + +public class DexScreenerToken +{ + [JsonPropertyName("address")] + public string Address { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("symbol")] + public string Symbol { get; set; } = string.Empty; +} + +public class DexScreenerVolume +{ + [JsonPropertyName("h24")] + public decimal? H24 { get; set; } + + [JsonPropertyName("h6")] + public decimal? H6 { get; set; } + + [JsonPropertyName("h1")] + public decimal? H1 { get; set; } +} + +public class DexScreenerPriceChange +{ + [JsonPropertyName("m5")] + public decimal? M5 { get; set; } + + [JsonPropertyName("h1")] + public decimal? H1 { get; set; } + + [JsonPropertyName("h6")] + public decimal? H6 { get; set; } + + [JsonPropertyName("h24")] + public decimal? H24 { get; set; } +} + +public class DexScreenerLiquidity +{ + [JsonPropertyName("usd")] + public decimal? Usd { get; set; } + + [JsonPropertyName("base")] + public decimal? Base { get; set; } + + [JsonPropertyName("quote")] + public decimal? Quote { get; set; } +} + +public class GoPlusSecurityResponse +{ + [JsonPropertyName("code")] + public int Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("result")] + public Dictionary? Result { get; set; } +} + +public class GoPlusTokenSecurity +{ + [JsonPropertyName("token_symbol")] + public string? TokenSymbol { get; set; } + + [JsonPropertyName("token_name")] + public string? TokenName { get; set; } + + [JsonPropertyName("is_open_source")] + public string? IsOpenSource { get; set; } + + [JsonPropertyName("is_proxy")] + public string? IsProxy { get; set; } + + [JsonPropertyName("is_mintable")] + public string? IsMintable { get; set; } + + [JsonPropertyName("owner_percentage")] + public string? OwnerPercentage { get; set; } + + [JsonPropertyName("creator_percentage")] + public string? CreatorPercentage { get; set; } + + [JsonPropertyName("holder_count")] + public string? HolderCount { get; set; } + + [JsonPropertyName("is_in_dex")] + public string? IsInDex { get; set; } + + [JsonPropertyName("is_anti_whale")] + public string? IsAntiWhale { get; set; } + + [JsonPropertyName("is_honeypot")] + public string? IsHoneypot { get; set; } + + [JsonPropertyName("is_black_list")] + public string? IsBlackList { get; set; } + + [JsonPropertyName("is_whitelist")] + public string? IsWhiteList { get; set; } + + [JsonPropertyName("is_owner_can_change_balance")] + public string? IsOwnerCanChangeBalance { get; set; } + + [JsonPropertyName("cannot_sell_all")] + public string? CannotSellAll { get; set; } + + [JsonPropertyName("slippage_modifiable")] + public string? SlippageModifiable { get; set; } + + [JsonPropertyName("is_slippage_modified")] + public string? IsSlippageModified { get; set; } + + [JsonPropertyName("trading_cooldown")] + public string? TradingCooldown { get; set; } + + [JsonPropertyName("personal_slippage_modifiable")] + public string? PersonalSlippageModifiable { get; set; } + + [JsonPropertyName("hidden_owner")] + public string? HiddenOwner { get; set; } + + [JsonPropertyName("anti_whale_modifiable")] + public string? AntiWhaleModifiable { get; set; } + + [JsonPropertyName("cannot_be_modified")] + public string? CannotBeModified { get; set; } + + [JsonPropertyName("is_owner_limit")] + public string? IsOwnerLimit { get; set; } + + [JsonPropertyName("buy_tax")] + public string? BuyTax { get; set; } + + [JsonPropertyName("sell_tax")] + public string? SellTax { get; set; } + + [JsonPropertyName("is_true_token")] + public string? IsTrueToken { get; set; } + + [JsonPropertyName("is_airdrop_scam")] + public string? IsAirdropScam { get; set; } + + [JsonPropertyName("is_1000_token")] + public string? Is1000Token { get; set; } + + [JsonPropertyName("honeypot_with_same_owner")] + public string? HoneypotWithSameOwner { get; set; } + + [JsonPropertyName("transfer_pausable")] + public string? TransferPausable { get; set; } + + [JsonPropertyName("external_call")] + public string? ExternalCall { get; set; } + + [JsonPropertyName("owner_change_balance")] + public string? OwnerChangeBalance { get; set; } + + [JsonPropertyName("self_destruct")] + public string? SelfDestruct { get; set; } + + [JsonPropertyName("trust_list")] + public List? TrustList { get; set; } + + [JsonPropertyName("dex")] + public List? Dex { get; set; } + + [JsonPropertyName("other_potential_risks")] + public string? OtherPotentialRisks { get; set; } + + [JsonPropertyName("unverifyed")] + public string? Unverifyed { get; set; } + + [JsonPropertyName("fake_token")] + public string? FakeToken { get; set; } +} + +public class GoPlusDexInfo +{ + [JsonPropertyName("liquidity")] + public string? Liquidity { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("quote_address")] + public string? QuoteAddress { get; set; } + + [JsonPropertyName("pair_address")] + public string? PairAddress { get; set; } +} \ No newline at end of file diff --git a/tests/Application/AlertCenterTest.cs b/tests/Application/AlertCenterTest.cs new file mode 100644 index 0000000..a01a363 --- /dev/null +++ b/tests/Application/AlertCenterTest.cs @@ -0,0 +1,296 @@ +using System.Globalization; +using MarketAssistant.Applications.AlertCenter; +using MarketAssistant.Applications.Settings; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Notification; +using MarketAssistant.Services.Settings; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace TestMarketAssistant.Application; + +/// +/// AlertCenterService 集成测试:告警落库、同类合并、未读统计、配额与免打扰抑制、 +/// 以及确认级告警的交易联动门(IAlertGate)激活与释放。 +/// 使用独立测试标的,用例前后清理 alert_events 中的测试数据。 +/// +[TestClass] +[DoNotParallelize] +public sealed class AlertCenterTest +{ + private const string TestSymbol = "ALERTTEST"; + + private sealed class StubNotificationService : INotificationService + { + public List Messages { get; } = new(); + + public void ShowSuccess(string message, int durationMs = 3000) => Messages.Add(message); + public void ShowError(string message, int durationMs = 3000) => Messages.Add(message); + public void ShowInfo(string message, int durationMs = 3000) => Messages.Add(message); + public void ShowWarning(string message, int durationMs = 3000) => Messages.Add(message); + } + + private sealed class StubUserSettingService : IUserSettingService + { + public UserSetting CurrentSetting { get; } = new(); + + public void LoadSettings() { } + public void SaveSettings() { } + public void UpdateSettings(UserSetting setting) { } + public void UpdateSetting(Action mutate) => mutate(CurrentSetting); + public void ResetSettings() { } + } + + private AlertCenterService _service = null!; + private StubNotificationService _notification = null!; + private StubUserSettingService _settings = null!; + private AlertGate _gate = null!; + + [TestInitialize] + public async Task Setup() + { + _notification = new StubNotificationService(); + _settings = new StubUserSettingService(); + _gate = new AlertGate(); + _service = new AlertCenterService( + _notification, + _settings, + _gate, + LoggerFactory.Create(_ => { }).CreateLogger()); + + await _service.InitializeAsync(); + await ExecuteAsync("DELETE FROM alert_events WHERE symbol = @symbol"); + } + + [TestCleanup] + public async Task Cleanup() => await ExecuteAsync("DELETE FROM alert_events WHERE symbol = @symbol"); + + [TestMethod] + [TestCategory("Unit")] + public async Task RaiseAlert_ShouldPersistAndRaiseAlertRaisedEvent() + { + AlertEvent? raised = null; + _service.AlertRaised += alert => raised = alert; + + await _service.RaiseAlertAsync(CreateAlert("涨破 100")); + + Assert.IsNotNull(raised); + Assert.AreEqual("涨破 100", raised.Title); + + var stored = await GetTestAlertsAsync(); + Assert.AreEqual(1, stored.Count); + Assert.AreEqual(1, stored[0].OccurrenceCount); + Assert.IsFalse(stored[0].IsRead); + // 首条告警弹窗一次 + Assert.AreEqual(1, _notification.Messages.Count); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task RaiseAlert_SameDedupeKeyWithinMergeWindow_ShouldMergeIntoSingleRecord() + { + for (var i = 0; i < 3; i++) + await _service.RaiseAlertAsync(CreateAlert("重复告警")); + + var stored = await GetTestAlertsAsync(); + + Assert.AreEqual(1, stored.Count); + Assert.AreEqual(3, stored[0].OccurrenceCount); + // 合并触发不重复弹窗 + Assert.AreEqual(1, _notification.Messages.Count); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task RaiseAlert_QuotaExceeded_ShouldPersistWithoutNotify() + { + _settings.CurrentSetting.AlertHourlyQuota = 1; + + await _service.RaiseAlertAsync(CreateAlert("配额内告警")); + await _service.RaiseAlertAsync(CreateAlert("超配额告警")); + + var stored = await GetTestAlertsAsync(); + + // 两条都落库,但仅第一条弹窗 + Assert.AreEqual(2, stored.Count); + Assert.AreEqual(1, _notification.Messages.Count); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task RaiseAlert_DuringQuietHours_ShouldSuppressWarningButNotCritical() + { + var nowHour = DateTime.Now.Hour; + _settings.CurrentSetting.AlertQuietHoursEnabled = true; + _settings.CurrentSetting.AlertQuietStartHour = nowHour; + _settings.CurrentSetting.AlertQuietEndHour = (nowHour + 1) % 24; + + await _service.RaiseAlertAsync(CreateAlert("静默期警告")); + await _service.RaiseAlertAsync(CreateAlert("静默期紧急", AlertLevel.Critical)); + + var stored = await GetTestAlertsAsync(); + + Assert.AreEqual(2, stored.Count); + // 免打扰时段内仅 Critical 触达 + Assert.AreEqual(1, _notification.Messages.Count); + Assert.IsTrue(_notification.Messages[0].Contains("静默期紧急")); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task RaiseAlert_ConfirmationImpact_ShouldActivateGateUntilCleared() + { + var alert = CreateAlert( + "确认级告警", AlertLevel.Critical, AlertTradingImpact.RequireConfirmation); + + await _service.RaiseAlertAsync(alert); + Assert.IsTrue(_gate.IsGated(MarketType.Crypto, TestSymbol)); + + await _service.RaiseAlertClearedAsync( + AlertSource.PriceAlert, MarketType.Crypto, TestSymbol, "确认级告警"); + Assert.IsFalse(_gate.IsGated(MarketType.Crypto, TestSymbol)); + } + + /// + /// 同一标的多条不同条件的确认级规则:门键含标题,各规则独立登记/释放, + /// 防止后触发覆盖先触发导致门计数无法归零(标的水久门控)。 + /// + [TestMethod] + [TestCategory("Unit")] + public async Task RaiseAlert_MultipleConfirmationRulesSameSymbol_ShouldTrackGatesIndependently() + { + var above = CreateAlert("确认级涨破", AlertLevel.Critical, AlertTradingImpact.RequireConfirmation); + var below = CreateAlert("确认级跌破", AlertLevel.Critical, AlertTradingImpact.RequireConfirmation); + + await _service.RaiseAlertAsync(above); + await _service.RaiseAlertAsync(below); + Assert.IsTrue(_gate.IsGated(MarketType.Crypto, TestSymbol)); + + // 释放其中一条,另一条仍触发中,门保持生效 + await _service.RaiseAlertClearedAsync( + AlertSource.PriceAlert, MarketType.Crypto, TestSymbol, "确认级涨破"); + Assert.IsTrue(_gate.IsGated(MarketType.Crypto, TestSymbol)); + + // 两条全部释放,门应彻底关闭(修复前第二次 Remove 因键被覆盖而失败,门残留) + await _service.RaiseAlertClearedAsync( + AlertSource.PriceAlert, MarketType.Crypto, TestSymbol, "确认级跌破"); + Assert.IsFalse(_gate.IsGated(MarketType.Crypto, TestSymbol)); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task RaiseAlert_NonConfirmationImpact_ShouldNotTouchGate() + { + await _service.RaiseAlertAsync(CreateAlert("普通告警")); + + Assert.IsFalse(_gate.IsGated(MarketType.Crypto, TestSymbol)); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task MarkAllRead_ShouldClearUnreadCount() + { + await _service.RaiseAlertAsync(CreateAlert("未读告警")); + Assert.AreEqual(1, await CountAsync( + "SELECT COUNT(*) FROM alert_events WHERE symbol = @symbol AND is_read = 0")); + + await _service.MarkAllReadAsync(); + + Assert.AreEqual(0, await CountAsync( + "SELECT COUNT(*) FROM alert_events WHERE symbol = @symbol AND is_read = 0")); + Assert.AreEqual(1, (await GetTestAlertsAsync()).Count(a => a.IsRead)); + } + + /// + /// 历史告警保留期裁剪:超过保留期的记录在初始化(应用重启)时被清除,保留期内记录不受影响。 + /// + [TestMethod] + [TestCategory("Unit")] + public async Task Initialize_ShouldPruneAlertsBeyondRetentionPeriod() + { + await _service.RaiseAlertAsync(CreateAlert("保留期内告警")); + await InsertRawAlertAsync("超期告警", DateTime.UtcNow.AddDays(-60)); + Assert.AreEqual(2, (await GetTestAlertsAsync()).Count); + + // 新建实例并初始化,模拟应用重启触发裁剪 + var restarted = new AlertCenterService( + _notification, + _settings, + new AlertGate(), + LoggerFactory.Create(_ => { }).CreateLogger()); + await restarted.InitializeAsync(); + + var stored = await GetTestAlertsAsync(); + Assert.AreEqual(1, stored.Count); + Assert.AreEqual("保留期内告警", stored[0].Title); + } + + private static AlertEvent CreateAlert( + string title, + AlertLevel level = AlertLevel.Warning, + AlertTradingImpact tradingImpact = AlertTradingImpact.None) => new() + { + MarketType = MarketType.Crypto, + Symbol = TestSymbol, + Level = level, + Source = AlertSource.PriceAlert, + Title = title, + Content = "测试内容", + TradingImpact = tradingImpact + }; + + private async Task> GetTestAlertsAsync() + { + var recent = await _service.GetRecentAlertsAsync(500); + return recent.Where(a => a.Symbol == TestSymbol).ToList(); + } + + private static string DbPath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + AppInfo.AppName, + // 与 SqliteServiceBase.UnifiedDbFileName 一致:所有持久化服务共享同一库文件 + "market.db"); + + private static async Task ExecuteAsync(string sql) + { + await using var conn = new SqliteConnection($"Data Source={DbPath}"); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@symbol", TestSymbol); + await cmd.ExecuteNonQueryAsync(); + } + + /// 直接写入一条指定时间的告警,用于构造超出保留期的历史数据。 + private static async Task InsertRawAlertAsync(string title, DateTime createdAtUtc) + { + await using var conn = new SqliteConnection($"Data Source={DbPath}"); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO alert_events (id, market_type, symbol, level, source, title, content, created_at, is_read, trading_impact, occurrence_count) + VALUES (@id, @marketType, @symbol, @level, @source, @title, @content, @createdAt, 0, 0, 1) + """; + cmd.Parameters.AddWithValue("@id", Guid.NewGuid().ToString("N")); + cmd.Parameters.AddWithValue("@marketType", (int)MarketType.Crypto); + cmd.Parameters.AddWithValue("@symbol", TestSymbol); + cmd.Parameters.AddWithValue("@level", (int)AlertLevel.Warning); + cmd.Parameters.AddWithValue("@source", (int)AlertSource.PriceAlert); + cmd.Parameters.AddWithValue("@title", title); + cmd.Parameters.AddWithValue("@content", "测试内容"); + cmd.Parameters.AddWithValue("@createdAt", createdAtUtc.ToString("O", CultureInfo.InvariantCulture)); + await cmd.ExecuteNonQueryAsync(); + } + + private static async Task CountAsync(string sql) + { + await using var conn = new SqliteConnection($"Data Source={DbPath}"); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@symbol", TestSymbol); + var result = await cmd.ExecuteScalarAsync(); + return result is long count ? (int)count : 0; + } +} + diff --git a/tests/Application/AlertGateTest.cs b/tests/Application/AlertGateTest.cs new file mode 100644 index 0000000..6bae586 --- /dev/null +++ b/tests/Application/AlertGateTest.cs @@ -0,0 +1,101 @@ +using MarketAssistant.Applications.AlertCenter; +using MarketAssistant.Infrastructure.Core; + +namespace TestMarketAssistant.Application; + +[TestClass] +public sealed class AlertGateTest +{ + [TestMethod] + [TestCategory("Unit")] + public void Activate_ShouldGateSymbolUntilDeactivated() + { + var gate = new AlertGate(); + + Assert.IsFalse(gate.IsGated(MarketType.Crypto, "BTCUSDT")); + + gate.Activate(MarketType.Crypto, "btcusdt"); + Assert.IsTrue(gate.IsGated(MarketType.Crypto, "BTCUSDT")); + + gate.Deactivate(MarketType.Crypto, "BTCUSDT"); + Assert.IsFalse(gate.IsGated(MarketType.Crypto, "BTCUSDT")); + } + + [TestMethod] + [TestCategory("Unit")] + public void MultipleActivations_ShouldRequireMatchingDeactivations() + { + var gate = new AlertGate(); + + gate.Activate(MarketType.Crypto, "BTCUSDT"); + gate.Activate(MarketType.Crypto, "BTCUSDT"); + gate.Deactivate(MarketType.Crypto, "BTCUSDT"); + + // 还有一条生效中的告警,仍处于联动状态 + Assert.IsTrue(gate.IsGated(MarketType.Crypto, "BTCUSDT")); + + gate.Deactivate(MarketType.Crypto, "BTCUSDT"); + Assert.IsFalse(gate.IsGated(MarketType.Crypto, "BTCUSDT")); + } + + [TestMethod] + [TestCategory("Unit")] + public void Gate_ShouldBePerMarketAndSymbol() + { + var gate = new AlertGate(); + + gate.Activate(MarketType.Crypto, "BTCUSDT"); + + Assert.IsFalse(gate.IsGated(MarketType.AShare, "BTCUSDT")); + Assert.IsFalse(gate.IsGated(MarketType.Crypto, "ETHUSDT")); + Assert.IsFalse(gate.IsGated(MarketType.Crypto, "")); + } +} + +[TestClass] +public sealed class AShareTradingHoursTest +{ + [TestMethod] + [TestCategory("Unit")] + public void Weekend_ShouldNotBeTradingSession() + { + // 2026-01-03(周六)14:00 北京时间 = 06:00 UTC + var saturdayAfternoonUtc = new DateTime(2026, 1, 3, 6, 0, 0, DateTimeKind.Utc); + + Assert.IsFalse(AShareTradingHours.IsTradingSession(saturdayAfternoonUtc)); + } + + [TestMethod] + [TestCategory("Unit")] + public void MorningSession_ShouldBeTradingSession() + { + // 2026-01-05(周一)10:00 北京时间 = 02:00 UTC + var mondayMorningUtc = new DateTime(2026, 1, 5, 2, 0, 0, DateTimeKind.Utc); + + Assert.IsTrue(AShareTradingHours.IsTradingSession(mondayMorningUtc)); + } + + [TestMethod] + [TestCategory("Unit")] + public void LunchBreak_ShouldNotBeTradingSession() + { + // 2026-01-05(周一)12:30 北京时间 = 04:30 UTC + var lunchBreakUtc = new DateTime(2026, 1, 5, 4, 30, 0, DateTimeKind.Utc); + + Assert.IsFalse(AShareTradingHours.IsTradingSession(lunchBreakUtc)); + } + + [TestMethod] + [TestCategory("Unit")] + public void AfternoonSession_ShouldBeTradingSession() + { + // 2026-01-05(周一)14:59 北京时间 = 06:59 UTC + var nearCloseUtc = new DateTime(2026, 1, 5, 6, 59, 0, DateTimeKind.Utc); + + Assert.IsTrue(AShareTradingHours.IsTradingSession(nearCloseUtc)); + + // 15:01 已收盘 + var afterCloseUtc = new DateTime(2026, 1, 5, 7, 1, 0, DateTimeKind.Utc); + Assert.IsFalse(AShareTradingHours.IsTradingSession(afterCloseUtc)); + } +} diff --git a/tests/Application/AlertSuppressionPolicyTest.cs b/tests/Application/AlertSuppressionPolicyTest.cs new file mode 100644 index 0000000..aa052e1 --- /dev/null +++ b/tests/Application/AlertSuppressionPolicyTest.cs @@ -0,0 +1,195 @@ +using MarketAssistant.Applications.AlertCenter; +using MarketAssistant.Infrastructure.Core; + +namespace TestMarketAssistant.Application; + +[TestClass] +public sealed class AlertSuppressionPolicyTest +{ + private static AlertEvent CreateAlert( + AlertLevel level = AlertLevel.Warning, + string title = "测试告警", + string symbol = "BTCUSDT", + MarketType marketType = MarketType.Crypto, + AlertSource source = AlertSource.PriceAlert) => new() + { + MarketType = marketType, + Symbol = symbol, + Level = level, + Source = source, + Title = title, + Content = "内容" + }; + + [TestMethod] + [TestCategory("Unit")] + public void Evaluate_FirstAlert_ShouldNotifyAndCountQuota() + { + var policy = new AlertSuppressionPolicy(); + var now = DateTime.UtcNow; + + var decision = policy.Evaluate( + CreateAlert(), AlertSuppressionPolicy.MergeState.Empty, AlertSuppressionPolicy.QuotaState.Empty, 5, now); + + Assert.IsFalse(decision.IsMerge); + Assert.IsTrue(decision.ShouldNotify); + Assert.AreEqual(1, decision.NewQuotaState.Count); + } + + [TestMethod] + [TestCategory("Unit")] + public void Evaluate_WithinMergeWindow_ShouldMergeAndNotNotify() + { + var policy = new AlertSuppressionPolicy(); + var now = DateTime.UtcNow; + + var first = policy.Evaluate( + CreateAlert(), AlertSuppressionPolicy.MergeState.Empty, AlertSuppressionPolicy.QuotaState.Empty, 5, now); + var second = policy.Evaluate(CreateAlert(), first.NewMergeState, first.NewQuotaState, 5, now.AddMinutes(2)); + + Assert.IsTrue(second.IsMerge); + Assert.IsFalse(second.ShouldNotify); + // 合并不消耗配额 + Assert.AreEqual(1, second.NewQuotaState.Count); + } + + [TestMethod] + [TestCategory("Unit")] + public void Evaluate_AfterMergeWindow_ShouldNotifyAgain() + { + var policy = new AlertSuppressionPolicy(); + var now = DateTime.UtcNow; + + var first = policy.Evaluate( + CreateAlert(), AlertSuppressionPolicy.MergeState.Empty, AlertSuppressionPolicy.QuotaState.Empty, 5, now); + var second = policy.Evaluate( + CreateAlert(), first.NewMergeState, first.NewQuotaState, 5, + now + AlertSuppressionPolicy.MergeWindow + TimeSpan.FromSeconds(1)); + + Assert.IsFalse(second.IsMerge); + Assert.IsTrue(second.ShouldNotify); + Assert.AreEqual(2, second.NewQuotaState.Count); + } + + [TestMethod] + [TestCategory("Unit")] + public void Evaluate_DifferentDedupeKey_ShouldNotMerge() + { + var policy = new AlertSuppressionPolicy(); + var now = DateTime.UtcNow; + + var first = policy.Evaluate( + CreateAlert(), AlertSuppressionPolicy.MergeState.Empty, AlertSuppressionPolicy.QuotaState.Empty, 5, now); + + // 另一去重键有独立的合并状态,窗口内仍可触达 + var other = policy.Evaluate( + CreateAlert(title: "另一标的", symbol: "ETHUSDT"), + AlertSuppressionPolicy.MergeState.Empty, first.NewQuotaState, 5, now.AddMinutes(1)); + + Assert.IsFalse(other.IsMerge); + Assert.IsTrue(other.ShouldNotify); + } + + [TestMethod] + [TestCategory("Unit")] + public void Evaluate_QuotaExceeded_ShouldSuppressNonCritical() + { + 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)); + quota = decision.NewQuotaState; + Assert.IsTrue(decision.ShouldNotify); + } + + // 配额耗尽后不再弹窗(仍落库) + var suppressed = policy.Evaluate( + CreateAlert(title: "超额告警"), AlertSuppressionPolicy.MergeState.Empty, quota, 3, now.AddMinutes(10)); + + Assert.IsFalse(suppressed.IsMerge); + Assert.IsFalse(suppressed.ShouldNotify); + Assert.AreEqual(3, suppressed.NewQuotaState.Count); + } + + [TestMethod] + [TestCategory("Unit")] + public void Evaluate_CriticalAlert_ShouldBypassQuota() + { + var policy = new AlertSuppressionPolicy(); + var now = DateTime.UtcNow; + + // 首条已耗尽配额(每小时 1 条) + var first = policy.Evaluate( + CreateAlert(), AlertSuppressionPolicy.MergeState.Empty, AlertSuppressionPolicy.QuotaState.Empty, 1, now); + Assert.IsTrue(first.ShouldNotify); + + // Critical 不受配额限制 + var critical = policy.Evaluate( + CreateAlert(level: AlertLevel.Critical, title: "紧急告警"), + AlertSuppressionPolicy.MergeState.Empty, first.NewQuotaState, 1, now.AddMinutes(1)); + + Assert.IsTrue(critical.ShouldNotify); + } + + [TestMethod] + [TestCategory("Unit")] + public void Evaluate_NewQuotaWindow_ShouldResetCount() + { + var policy = new AlertSuppressionPolicy(); + var now = DateTime.UtcNow; + + var first = policy.Evaluate( + CreateAlert(), AlertSuppressionPolicy.MergeState.Empty, AlertSuppressionPolicy.QuotaState.Empty, 1, now); + Assert.AreEqual(1, first.NewQuotaState.Count); + + // 超过 1 小时后配额窗口重置并重新起算 + var later = policy.Evaluate( + CreateAlert(title: "一小时后"), AlertSuppressionPolicy.MergeState.Empty, first.NewQuotaState, 1, + now.AddHours(1).AddSeconds(1)); + + Assert.IsTrue(later.ShouldNotify); + Assert.AreEqual(1, later.NewQuotaState.Count); + Assert.AreEqual(now.AddHours(1).AddSeconds(1), later.NewQuotaState.WindowStartUtc); + } + + [TestMethod] + [TestCategory("Unit")] + public void IsSilencedByTradingSession_ASharePriceWarning_ShouldSilence() + { + var alert = CreateAlert(marketType: MarketType.AShare); + + Assert.IsTrue(AlertSuppressionPolicy.IsSilencedByTradingSession(alert, isTradingSession: false)); + } + + [TestMethod] + [TestCategory("Unit")] + public void IsSilencedByTradingSession_CriticalOrInSessionOrCrypto_ShouldNotSilence() + { + // Critical(确认级价格告警)例外:休市也须即时触达并驱动交易联动门 + Assert.IsFalse(AlertSuppressionPolicy.IsSilencedByTradingSession( + CreateAlert(level: AlertLevel.Critical, marketType: MarketType.AShare), isTradingSession: false)); + + // 交易时段内不静默 + Assert.IsFalse(AlertSuppressionPolicy.IsSilencedByTradingSession( + CreateAlert(marketType: MarketType.AShare), isTradingSession: true)); + + // 虚拟币 7×24,不受 A 股休市影响 + Assert.IsFalse(AlertSuppressionPolicy.IsSilencedByTradingSession( + CreateAlert(), isTradingSession: false)); + } + + [TestMethod] + [TestCategory("Unit")] + public void IsSilencedByTradingSession_NonPriceSource_ShouldNotSilence() + { + var alert = CreateAlert(marketType: MarketType.AShare, source: AlertSource.Signal); + + Assert.IsFalse(AlertSuppressionPolicy.IsSilencedByTradingSession(alert, isTradingSession: false)); + } +} diff --git a/tests/Application/AssetInfoServiceDefaultImplTest.cs b/tests/Application/AssetInfoServiceDefaultImplTest.cs new file mode 100644 index 0000000..15397ed --- /dev/null +++ b/tests/Application/AssetInfoServiceDefaultImplTest.cs @@ -0,0 +1,114 @@ +using MarketAssistant.Applications.Assets; +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Infrastructure.Core; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace TestMarketAssistant.Application; + +/// +/// IAssetInfoService.GetAssetInfosAsync 默认实现单元测试(无需真实网络) +/// +[TestClass] +public class AssetInfoServiceDefaultImplTest +{ + /// + /// 记录并发峰值的最小 Fake 实现(GetAssetInfoAsync 可按需抛异常) + /// + private sealed class FakeAssetInfoService : IAssetInfoService + { + private readonly HashSet _failingCodes; + private int _current; + + public int MaxConcurrent { get; private set; } + + public FakeAssetInfoService(IEnumerable? failingCodes = null) + { + _failingCodes = failingCodes?.ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new HashSet(); + } + + public Task> SearchAsync(string keyword, CancellationToken cancellationToken = default) + => throw new NotSupportedException("本测试不涉及搜索"); + + public async Task GetAssetInfoAsync(string code, string market = "", CancellationToken cancellationToken = default) + { + // 主动让出一次执行权,确保任务真正并发运行以测出峰值 + await Task.Yield(); + + var current = Interlocked.Increment(ref _current); + MaxConcurrent = Math.Max(MaxConcurrent, current); + try + { + if (_failingCodes.Contains(code)) + { + throw new InvalidOperationException("模拟单项获取失败"); + } + + await Task.Delay(10, cancellationToken); + return new AssetInfo { Code = code, Name = code, MarketType = MarketType.AShare }; + } + finally + { + Interlocked.Decrement(ref _current); + } + } + + public Task> GetHotAssetsAsync() + => throw new NotSupportedException("本测试不涉及热门资产"); + } + + private static FavoriteAsset CreateFavorite(string code) => new() { Code = code, Market = "SH" }; + + [TestMethod] + [TestCategory("Unit")] + public async Task GetAssetInfosAsync_EmptyList_ReturnsEmpty() + { + var service = new FakeAssetInfoService(); + + var results = await ((IAssetInfoService)service).GetAssetInfosAsync(new List()); + + Assert.IsNotNull(results); + Assert.AreEqual(0, results.Count); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task GetAssetInfosAsync_SingleItemFailure_ReturnsNullForThatItem() + { + // Arrange:第二项抛异常,其余成功 + var service = new FakeAssetInfoService(new[] { "SH600002" }); + var favorites = new List + { + CreateFavorite("SH600001"), + CreateFavorite("SH600002"), + CreateFavorite("SH600003") + }; + + // Act + var results = await ((IAssetInfoService)service).GetAssetInfosAsync(favorites); + + // Assert:等长同序,仅失败项为 null + Assert.AreEqual(3, results.Count); + Assert.IsNotNull(results[0]); + Assert.IsNull(results[1]); + Assert.IsNotNull(results[2]); + Assert.AreEqual("SH600001", results[0]!.Code); + Assert.AreEqual("SH600003", results[2]!.Code); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task GetAssetInfosAsync_ConcurrencyLimitedToThree() + { + // Arrange:10 个全部成功的请求 + var service = new FakeAssetInfoService(); + var favorites = Enumerable.Range(1, 10).Select(i => CreateFavorite($"SH6000{i:00}")).ToList(); + + // Act + var results = await ((IAssetInfoService)service).GetAssetInfosAsync(favorites); + + // Assert:全部返回且并发峰值不超过 3 + Assert.AreEqual(10, results.Count); + Assert.IsTrue(results.All(r => r != null)); + Assert.IsTrue(service.MaxConcurrent <= 3, $"并发峰值应不超过3,实际为 {service.MaxConcurrent}"); + } +} diff --git a/tests/Application/PriceAlertRuleAdvancedTest.cs b/tests/Application/PriceAlertRuleAdvancedTest.cs new file mode 100644 index 0000000..50280d2 --- /dev/null +++ b/tests/Application/PriceAlertRuleAdvancedTest.cs @@ -0,0 +1,172 @@ +using MarketAssistant.Applications.AlertCenter; +using MarketAssistant.Applications.PriceAlert; + +namespace TestMarketAssistant.Application; + +/// +/// 价格告警规则高级触发特性:去抖(ConfirmTicks/ConfirmSeconds)、冷却(CooldownMinutes)、 +/// 触发次数上限(MaxTriggerCount)与交易联动门状态(NotifyTriggered/ShouldClearTradingGate)。 +/// +[TestClass] +public sealed class PriceAlertRuleAdvancedTest +{ + private static PriceAlertRule CreateRule(Action? configure = null) + { + var rule = new PriceAlertRule + { + Condition = AlertCondition.PriceAbove, + TargetPrice = 100m + }; + configure?.Invoke(rule); + return rule; + } + + [TestMethod] + [TestCategory("Unit")] + public void ConfirmTicks_ShouldRequireConsecutiveMetTicks() + { + var rule = CreateRule(r => r.ConfirmTicks = 3); + + Assert.IsFalse(rule.UpdateTriggerState(101m)); + Assert.IsFalse(rule.UpdateTriggerState(102m)); + Assert.IsTrue(rule.UpdateTriggerState(103m)); + } + + [TestMethod] + [TestCategory("Unit")] + public void ConfirmTicks_ConditionBreak_ShouldResetProgress() + { + var rule = CreateRule(r => r.ConfirmTicks = 3); + + Assert.IsFalse(rule.UpdateTriggerState(101m)); + Assert.IsFalse(rule.UpdateTriggerState(102m)); + // 中途离开区间重置去抖进度 + Assert.IsFalse(rule.UpdateTriggerState(99m)); + Assert.IsFalse(rule.UpdateTriggerState(101m)); + Assert.IsFalse(rule.UpdateTriggerState(102m)); + Assert.IsTrue(rule.UpdateTriggerState(103m)); + } + + [TestMethod] + [TestCategory("Unit")] + public void CooldownMinutes_ShouldBlockRetriggerWithinInterval() + { + var rule = CreateRule(r => r.CooldownMinutes = 5); + + Assert.IsTrue(rule.UpdateTriggerState(101m)); + rule.NotifyTriggered(); + + // 条件离开后快速回到区间:冷却期内不触发 + Assert.IsFalse(rule.UpdateTriggerState(99m)); + Assert.IsFalse(rule.UpdateTriggerState(101m)); + } + + [TestMethod] + [TestCategory("Unit")] + public void MaxTriggerCount_ShouldStopNotifyingAfterLimit() + { + var rule = CreateRule(r => + { + r.MaxTriggerCount = 2; + r.CooldownMinutes = 0; + }); + + // 第一次触发 + Assert.IsTrue(rule.UpdateTriggerState(101m)); + rule.NotifyTriggered(); + // 离开后重新进入(同秒内无冷却问题) + Assert.IsFalse(rule.UpdateTriggerState(99m)); + Assert.IsTrue(rule.UpdateTriggerState(101m)); + rule.NotifyTriggered(); + + // 达到上限后不再触发 + Assert.IsFalse(rule.UpdateTriggerState(99m)); + Assert.IsFalse(rule.UpdateTriggerState(101m)); + Assert.IsTrue(rule.IsTriggerLimitReached); + } + + [TestMethod] + [TestCategory("Unit")] + public void OneTimeRule_ShouldCountAsSingleTrigger() + { + var rule = CreateRule(r => r.IsOneTime = true); + + Assert.IsTrue(rule.UpdateTriggerState(101m)); + Assert.IsTrue(rule.IsTriggerLimitReached); + + Assert.IsFalse(rule.UpdateTriggerState(99m)); + Assert.IsFalse(rule.UpdateTriggerState(101m)); + } + + [TestMethod] + [TestCategory("Unit")] + public void TradingGate_ShouldRequireClearAfterConditionExit() + { + var rule = CreateRule(r => r.TradingImpact = AlertTradingImpact.RequireConfirmation); + + Assert.IsTrue(rule.UpdateTriggerState(101m)); + rule.NotifyTriggered(); + + // 条件仍在区间内:联动门保持 + Assert.IsFalse(rule.ShouldClearTradingGate); + + // 条件离开区间:需要解除联动门 + Assert.IsFalse(rule.UpdateTriggerState(99m)); + Assert.IsTrue(rule.ShouldClearTradingGate); + + rule.ResetTradingGate(); + Assert.IsFalse(rule.ShouldClearTradingGate); + } + + [TestMethod] + [TestCategory("Unit")] + public void TradingGate_NonConfirmationImpact_ShouldNeverRequireClear() + { + var rule = CreateRule(); // 默认 None + + Assert.IsTrue(rule.UpdateTriggerState(101m)); + rule.NotifyTriggered(); + Assert.IsFalse(rule.UpdateTriggerState(99m)); + + Assert.IsFalse(rule.ShouldClearTradingGate); + } + + [TestMethod] + [TestCategory("Unit")] + public void LegacyRule_AllZeroConfig_ShouldKeepOriginalBehavior() + { + // 旧规则(未配置去抖/冷却/限次)保持原有"进入区间提醒一次、离开自动复位"语义 + var rule = CreateRule(); + + Assert.IsFalse(rule.UpdateTriggerState(99m)); + Assert.IsTrue(rule.UpdateTriggerState(100m)); + Assert.IsFalse(rule.UpdateTriggerState(101m)); + Assert.IsFalse(rule.UpdateTriggerState(99m)); + Assert.IsTrue(rule.UpdateTriggerState(100m)); + } + + [TestMethod] + [TestCategory("Unit")] + public void RuleOptionsText_ShouldSummarizeAdvancedConfig() + { + var plain = CreateRule(); + Assert.AreEqual(string.Empty, plain.RuleOptionsText); + + var configured = CreateRule(r => + { + r.ConfirmTicks = 3; + r.CooldownMinutes = 30; + r.MaxTriggerCount = 5; + r.TradingImpact = AlertTradingImpact.RequireConfirmation; + }); + Assert.AreEqual("去抖 3 次 · 冷却 30 分 · 限 5 次 · 确认级", configured.RuleOptionsText); + + // 一次性规则限次固定为 1,不额外展示 + var oneTime = CreateRule(r => + { + r.IsOneTime = true; + r.MaxTriggerCount = 1; + }); + Assert.AreEqual(string.Empty, oneTime.RuleOptionsText); + } +} diff --git a/tests/Application/SignalAlertEvaluatorTest.cs b/tests/Application/SignalAlertEvaluatorTest.cs new file mode 100644 index 0000000..144e3d2 --- /dev/null +++ b/tests/Application/SignalAlertEvaluatorTest.cs @@ -0,0 +1,162 @@ +using MarketAssistant.Applications.AlertCenter; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Services.Trading; +using MarketAssistant.Trading.Models; +using Microsoft.Extensions.Logging; + +namespace TestMarketAssistant.Application; + +/// +/// SignalAlertEvaluator 单元测试:验证策略状态跃迁收敛为 AlertSource.Signal, +/// 并与风控拒绝告警(AlertSource.Risk)按拒绝原因互斥分流,不产生重复告警。 +/// +[TestClass] +public sealed class SignalAlertEvaluatorTest +{ + /// 记录所有上报告警的桩实现,用于断言来源/级别/标题映射。 + private sealed class RecordingAlertCenter : IAlertCenterService + { + public List Raised { get; } = new(); + + public event Action? AlertRaised { add { } remove { } } + public event Action? AlertsChanged { add { } remove { } } + + public Task InitializeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task RaiseAlertAsync(AlertEvent alert, CancellationToken cancellationToken = default) + { + Raised.Add(alert); + return Task.CompletedTask; + } + + public Task RaiseAlertClearedAsync( + AlertSource source, MarketType marketType, string symbol, string title, + CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task> GetRecentAlertsAsync( + int limit = 200, CancellationToken cancellationToken = default) => + Task.FromResult>(Raised); + + public Task GetUnreadCountAsync(CancellationToken cancellationToken = default) => Task.FromResult(0); + + public Task MarkAllReadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + /// 上报即抛异常的桩实现,用于验证 Safe 语义不向外冒泡。 + private sealed class ThrowingAlertCenter : IAlertCenterService + { + public bool Called { get; private set; } + + public event Action? AlertRaised { add { } remove { } } + public event Action? AlertsChanged { add { } remove { } } + + public Task InitializeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task RaiseAlertAsync(AlertEvent alert, CancellationToken cancellationToken = default) + { + Called = true; + throw new InvalidOperationException("告警中心不可用"); + } + + public Task RaiseAlertClearedAsync( + AlertSource source, MarketType marketType, string symbol, string title, + CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task> GetRecentAlertsAsync( + int limit = 200, CancellationToken cancellationToken = default) => + Task.FromResult>(Array.Empty()); + + public Task GetUnreadCountAsync(CancellationToken cancellationToken = default) => Task.FromResult(0); + + public Task MarkAllReadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private RecordingAlertCenter _alertCenter = null!; + private SignalAlertEvaluator _evaluator = null!; + + [TestInitialize] + public void Setup() + { + _alertCenter = new RecordingAlertCenter(); + _evaluator = new SignalAlertEvaluator( + _alertCenter, + LoggerFactory.Create(_ => { }).CreateLogger()); + } + + private static TradingStrategy CreateStrategy() => new() + { + Symbol = "BTCUSDT", + Type = StrategyType.AISignal + }; + + private static TradeResult Rejection(string errorMessage) => new() + { + Success = false, + ErrorMessage = errorMessage, + FailureCategory = TradeFailureCategory.Rejected + }; + + [TestMethod] + [TestCategory("Unit")] + public async Task NotifyStrategyPaused_UserRejection_ShouldRaiseSignalWarning() + { + await _evaluator.NotifyStrategyPausedSafeAsync( + CreateStrategy(), Rejection("用户拒绝交易: 风控要求人工确认")); + + Assert.AreEqual(1, _alertCenter.Raised.Count); + var alert = _alertCenter.Raised[0]; + Assert.AreEqual(AlertSource.Signal, alert.Source); + Assert.AreEqual(AlertLevel.Warning, alert.Level); + Assert.AreEqual("策略已自动暂停", alert.Title); + Assert.AreEqual(MarketType.Crypto, alert.MarketType); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task NotifyStrategyPaused_RiskRejection_ShouldLeaveAlertToRiskEvaluator() + { + await _evaluator.NotifyStrategyPausedSafeAsync( + CreateStrategy(), Rejection("风控拒绝: 超过单笔仓位上限")); + + Assert.AreEqual(0, _alertCenter.Raised.Count); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task NotifyStrategyCompleted_ShouldRaiseSignalInfo() + { + await _evaluator.NotifyStrategyCompletedSafeAsync(CreateStrategy()); + + Assert.AreEqual(1, _alertCenter.Raised.Count); + Assert.AreEqual(AlertSource.Signal, _alertCenter.Raised[0].Source); + Assert.AreEqual(AlertLevel.Info, _alertCenter.Raised[0].Level); + Assert.AreEqual("策略已完结", _alertCenter.Raised[0].Title); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task NotifySignalExecuted_ShouldRaiseSignalInfoWithPrice() + { + await _evaluator.NotifySignalExecutedSafeAsync(CreateStrategy(), 43250.5m); + + Assert.AreEqual(1, _alertCenter.Raised.Count); + var alert = _alertCenter.Raised[0]; + Assert.AreEqual(AlertSource.Signal, alert.Source); + Assert.AreEqual(AlertLevel.Info, alert.Level); + Assert.AreEqual("BTCUSDT", alert.Symbol); + StringAssert.Contains(alert.Content, 43250.5m.ToString("N6")); + } + + [TestMethod] + [TestCategory("Unit")] + public async Task NotifyStrategyCompleted_AlertCenterFailure_ShouldNotThrow() + { + var throwingCenter = new ThrowingAlertCenter(); + var evaluator = new SignalAlertEvaluator( + throwingCenter, LoggerFactory.Create(_ => { }).CreateLogger()); + + await evaluator.NotifyStrategyCompletedSafeAsync(CreateStrategy()); + + Assert.IsTrue(throwingCenter.Called); + } +} diff --git a/tests/Crypto/CryptoAssetInfoServiceTest.cs b/tests/Crypto/CryptoAssetInfoServiceTest.cs new file mode 100644 index 0000000..8bc01b8 --- /dev/null +++ b/tests/Crypto/CryptoAssetInfoServiceTest.cs @@ -0,0 +1,71 @@ +using MarketAssistant.Applications.Assets; +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.DataProviders; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Extensions.DependencyInjection; +using TestMarketAssistant; + +namespace MarketAssistant.Tests.Crypto; + +/// +/// CryptoAssetInfoService 批量行情测试(真实币安 API 集成验证) +/// +[TestClass] +public class CryptoAssetInfoServiceTest +{ + private ServiceProvider? _serviceProvider; + + [TestInitialize] + public void Setup() + { + var services = new ServiceCollection(); + + services.AddTestMarketDataHttpClients(); + services.AddMemoryCache(); + services.AddLogging(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // 注册被测试的服务 + services.AddKeyedSingleton(MarketType.Crypto); + + _serviceProvider = services.BuildServiceProvider(); + } + + [TestCleanup] + public async Task Cleanup() + { + if (_serviceProvider != null) + { + await _serviceProvider.DisposeAsync(); + } + } + + [TestMethod] + [TestCategory("Integration")] + public async Task GetAssetInfosAsync_ShouldReturnSameLengthInOrder() + { + // Arrange + var service = _serviceProvider!.GetRequiredKeyedService(MarketType.Crypto); + var favorites = new List + { + new() { Code = "BTCUSDT", Market = "Binance" }, + new() { Code = "ETHUSDT", Market = "Binance" }, + new() { Code = "SOLUSDT", Market = "Binance" } + }; + + // Act + var results = await service.GetAssetInfosAsync(favorites); + + // Assert:与请求等长同序,价格与涨跌幅已填充;批量路径不拉取市值(收藏列表不展示) + Assert.AreEqual(favorites.Count, results.Count); + Assert.IsNotNull(results[0]); + Assert.AreEqual("BTCUSDT", results[0]!.Code); + Assert.IsFalse(string.IsNullOrWhiteSpace(results[0]!.CurrentPrice)); + Assert.IsFalse(string.IsNullOrWhiteSpace(results[0]!.ChangePercentage)); + Assert.IsNotNull(results[1]); + Assert.IsNotNull(results[2]); + Assert.IsNull(results[0]!.MarketCap); + } +} \ No newline at end of file diff --git a/tests/DataProviders/ChainRegistryTest.cs b/tests/DataProviders/ChainRegistryTest.cs new file mode 100644 index 0000000..6a567e9 --- /dev/null +++ b/tests/DataProviders/ChainRegistryTest.cs @@ -0,0 +1,73 @@ +using MarketAssistant.DataProviders.Web3; +using MarketAssistant.Infrastructure.Core; + +namespace TestMarketAssistant.DataProviders; + +/// +/// ChainRegistry 链 ID 归一化单元测试。 +/// +[TestClass] +public class ChainRegistryTest +{ + [DataTestMethod] + [DataRow("ethereum", "ethereum")] + [DataRow("eth", "ethereum")] + [DataRow("ETH", "ethereum")] + [DataRow("1", "ethereum")] + [DataRow("eth-mainnet", "ethereum")] + [DataRow("binance-smart-chain", "bsc")] + [DataRow("binance_smart_chain", "bsc")] + [DataRow("bnb", "bsc")] + [DataRow("56", "bsc")] + [DataRow("polygon-pos", "polygon")] + [DataRow("matic-network", "polygon")] + [DataRow("137", "polygon")] + [DataRow("arbitrum-one", "arbitrum")] + [DataRow("arb", "arbitrum")] + [DataRow("optimistic-ethereum", "optimism")] + [DataRow("10", "optimism")] + [DataRow("avalanche-2", "avalanche")] + [DataRow("avax", "avalanche")] + [DataRow("8453", "base")] + [DataRow("sol", "solana")] + [DataRow("mainnet", "solana")] + public void Normalize_KnownAliases_ReturnCanonical(string input, string expected) + { + Assert.AreEqual(expected, ChainRegistry.Normalize(input)); + } + + [TestMethod] + public void Normalize_UnknownChain_PassesThroughLowercase() + { + Assert.AreEqual("zksync", ChainRegistry.Normalize("ZKSYNC")); + Assert.AreEqual("linea", ChainRegistry.Normalize(" linea ")); + } + + [TestMethod] + public void Normalize_EmptyInput_ThrowsFriendly() + { + Assert.ThrowsExactly(() => ChainRegistry.Normalize("")); + Assert.ThrowsExactly(() => ChainRegistry.Normalize(" ")); + Assert.ThrowsExactly(() => ChainRegistry.Normalize(null)); + } + + [DataTestMethod] + [DataRow("ethereum", "1")] + [DataRow("bsc", "56")] + [DataRow("polygon", "137")] + [DataRow("arbitrum", "42161")] + [DataRow("optimism", "10")] + [DataRow("avalanche", "43114")] + [DataRow("base", "8453")] + public void ToGoPlusChainId_EvmChains_ReturnNumericId(string canonical, string expected) + { + Assert.AreEqual(expected, ChainRegistry.ToGoPlusChainId(canonical)); + } + + [TestMethod] + public void ToGoPlusChainId_SolanaAndUnknownChain_ReturnNull() + { + Assert.IsNull(ChainRegistry.ToGoPlusChainId("solana")); + Assert.IsNull(ChainRegistry.ToGoPlusChainId("zksync")); + } +} \ No newline at end of file diff --git a/tests/DataProviders/DexScreenerClientTest.cs b/tests/DataProviders/DexScreenerClientTest.cs new file mode 100644 index 0000000..21bf7e7 --- /dev/null +++ b/tests/DataProviders/DexScreenerClientTest.cs @@ -0,0 +1,123 @@ +using System.Net; +using System.Text; +using MarketAssistant.DataProviders.Web3; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace TestMarketAssistant.DataProviders; + +/// +/// DexScreenerClient 消息层单元测试(桩 HttpMessageHandler,不访问真实网络)。 +/// +[TestClass] +public class DexScreenerClientTest +{ + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func _responder; + + public List RequestUrls { get; } = new(); + + public StubHandler(Func responder) => _responder = responder; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestUrls.Add(request.RequestUri!.PathAndQuery); + return Task.FromResult(_responder(request)); + } + } + + private static DexScreenerClient CreateClient(StubHandler handler) + { + var factory = new Mock(); + // 被测代码 using 释放 HttpClient,故每次调用返回共享 handler 的新实例 + factory.Setup(f => f.CreateClient(It.IsAny())) + .Returns(() => new HttpClient(handler, disposeHandler: false) + { + BaseAddress = new Uri("https://api.dexscreener.com/") + }); + return new DexScreenerClient(factory.Object, NullLogger.Instance); + } + + private static HttpResponseMessage Json(string json) => + new(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; + + [TestMethod] + public async Task GetTokenPairsAsync_ChainAliases_NormalizeToFullName() + { + var handler = new StubHandler(_ => Json("[]")); + var client = CreateClient(handler); + + await client.GetTokenPairsAsync("1", "0xpepe"); + await client.GetTokenPairsAsync("binance-smart-chain", "0xpepe"); + await client.GetTokenPairsAsync("polygon-pos", "0xpepe"); + + CollectionAssert.AreEquivalent(new[] + { + "/token-pairs/v1/ethereum/0xpepe", + "/token-pairs/v1/bsc/0xpepe", + "/token-pairs/v1/polygon/0xpepe" + }, handler.RequestUrls); + } + + [TestMethod] + public async Task SearchPairsAsync_DeserializesRealResponseShape() + { + const string json = """ + { + "schemaVersion": "1.0.0", + "pairs": [ + { + "chainId": "solana", + "dexId": "raydium", + "url": "https://dexscreener.com/solana/pair", + "baseToken": { "address": "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263", "name": "Bonk", "symbol": "BONK" }, + "quoteToken": { "address": "So11111111111111111111111111111111111111112", "name": "Wrapped SOL", "symbol": "WSOL" }, + "priceNative": "1.234e-9", + "priceUsd": "0.000003170", + "volume": { "h24": 900000.5, "h6": 100000.25, "h1": 10000.125 }, + "priceChange": { "m5": 0.1, "h1": 1.5, "h6": -2.5, "h24": 5.5 }, + "liquidity": { "usd": 151502.08, "base": 100, "quote": 50 }, + "fdv": 281729667, + "marketCap": 278977721, + "pairCreatedAt": 1677761811000 + } + ] + } + """; + var handler = new StubHandler(_ => Json(json)); + var client = CreateClient(handler); + + var pairs = await client.SearchPairsAsync("BONK"); + + var pair = pairs.Single(); + Assert.AreEqual("solana", pair.ChainId); + Assert.AreEqual("BONK", pair.BaseToken.Symbol); + Assert.AreEqual("0.000003170", pair.PriceUsd); + Assert.AreEqual(900000.5m, pair.Volume!.H24); + Assert.AreEqual(151502.08m, pair.Liquidity!.Usd); + Assert.AreEqual(278977721m, pair.MarketCap); + Assert.AreEqual(1677761811000L, pair.PairCreatedAt); + } + + [TestMethod] + public async Task SearchPairsAsync_EmptyPairs_ReturnsEmptyList() + { + var handler = new StubHandler(_ => Json("""{"schemaVersion":"1.0.0","pairs":[]}""")); + var client = CreateClient(handler); + + var pairs = await client.SearchPairsAsync("nonexistent"); + + Assert.AreEqual(0, pairs.Count); + } + + [TestMethod] + public async Task GetTokenPairsAsync_EmptyTokenAddress_ThrowsFriendly() + { + var client = CreateClient(new StubHandler(_ => throw new InvalidOperationException("不应发起 HTTP 请求"))); + + await Assert.ThrowsExactlyAsync( + () => client.GetTokenPairsAsync("eth", " ")); + } +} \ No newline at end of file diff --git a/tests/DataProviders/GoPlusSecurityClientTest.cs b/tests/DataProviders/GoPlusSecurityClientTest.cs new file mode 100644 index 0000000..d614eb5 --- /dev/null +++ b/tests/DataProviders/GoPlusSecurityClientTest.cs @@ -0,0 +1,130 @@ +using System.Net; +using System.Text; +using MarketAssistant.DataProviders.Web3; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace TestMarketAssistant.DataProviders; + +/// +/// GoPlusSecurityClient 消息层单元测试(桩 HttpMessageHandler,不访问真实网络)。 +/// +[TestClass] +public class GoPlusSecurityClientTest +{ + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func _responder; + + public List RequestUrls { get; } = new(); + + public StubHandler(Func responder) => _responder = responder; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestUrls.Add(request.RequestUri!.PathAndQuery); + return Task.FromResult(_responder(request)); + } + } + + private static GoPlusSecurityClient CreateClient(StubHandler handler) + { + var factory = new Mock(); + // 被测代码 using 释放 HttpClient,故每次调用返回共享 handler 的新实例 + factory.Setup(f => f.CreateClient(It.IsAny())) + .Returns(() => new HttpClient(handler, disposeHandler: false) + { + BaseAddress = new Uri("https://api.gopluslabs.io/") + }); + return new GoPlusSecurityClient(factory.Object, NullLogger.Instance); + } + + private static HttpResponseMessage Json(string json) => + new(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; + + private const string BonkMint = "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263"; + + [TestMethod] + public async Task GetTokenSecurityAsync_EvmChainAlias_RoutesToNumericChainId() + { + var handler = new StubHandler(_ => Json("""{"code":1,"result":{}}""")); + var client = CreateClient(handler); + + await client.GetTokenSecurityAsync("eth", "0xabc"); + await client.GetTokenSecurityAsync("1", "0xabc"); + + Assert.IsTrue(handler.RequestUrls.All(u => + u.StartsWith("/api/v1/token_security/1?contract_addresses=0xabc", StringComparison.Ordinal))); + } + + [TestMethod] + public async Task GetTokenSecurityAsync_Solana_RoutesToDedicatedEndpointKeepingCase() + { + var json = "{\"code\":1,\"result\":{\"" + BonkMint + "\":{\"token_symbol\":\"BONK\"}}}"; + var handler = new StubHandler(_ => Json(json)); + var client = CreateClient(handler); + + var security = await client.GetTokenSecurityAsync("sol", BonkMint); + + Assert.AreEqual( + "/api/v1/solana/token_security?contract_addresses=" + BonkMint, + handler.RequestUrls.Single()); + Assert.AreEqual("BONK", security!.TokenSymbol); + } + + [TestMethod] + public async Task GetTokenSecurityAsync_LowercasedResultKey_MatchesMixedCaseMint() + { + var handler = new StubHandler(_ => Json( + """{"code":1,"result":{"dezxaz8z7pnrnrjjz3wxborgixca6xjnb7yab1ppb263":{"token_symbol":"BONK"}}}""")); + var client = CreateClient(handler); + + var security = await client.GetTokenSecurityAsync("solana", BonkMint); + + Assert.AreEqual("BONK", security!.TokenSymbol); + } + + [TestMethod] + public async Task GetTokenSecurityAsync_NumericLongTailChain_PassesThrough() + { + var handler = new StubHandler(_ => Json("""{"code":1,"result":{}}""")); + var client = CreateClient(handler); + + await client.GetTokenSecurityAsync("324", "0xabc"); + + Assert.IsTrue(handler.RequestUrls.Single().StartsWith("/api/v1/token_security/324?", StringComparison.Ordinal)); + } + + [TestMethod] + public async Task GetTokenSecurityAsync_UnregisteredNamedChain_FailsFastWithoutHttpCall() + { + var handler = new StubHandler(_ => throw new InvalidOperationException("不应发起 HTTP 请求")); + var client = CreateClient(handler); + + await Assert.ThrowsExactlyAsync( + () => client.GetTokenSecurityAsync("zksync", "0xabc")); + Assert.AreEqual(0, handler.RequestUrls.Count); + } + + [TestMethod] + public async Task GetTokenSecurityAsync_NonSuccessCode_ThrowsFriendly() + { + var handler = new StubHandler(_ => Json("""{"code":2022,"message":"Chain not supported","result":null}""")); + var client = CreateClient(handler); + + await Assert.ThrowsExactlyAsync( + () => client.GetTokenSecurityAsync("eth", "0xabc")); + } + + [TestMethod] + public async Task GetTokenSecurityAsync_EmptyResult_ReturnsNull() + { + var handler = new StubHandler(_ => Json("""{"code":1,"result":{}}""")); + var client = CreateClient(handler); + + var security = await client.GetTokenSecurityAsync("ethereum", "0xabc"); + + Assert.IsNull(security); + } +} \ No newline at end of file diff --git a/tests/MarketAnalysisDegradationTest.cs b/tests/MarketAnalysisDegradationTest.cs index d370627..6058536 100644 --- a/tests/MarketAnalysisDegradationTest.cs +++ b/tests/MarketAnalysisDegradationTest.cs @@ -55,15 +55,16 @@ public async Task AnalyzeAsync_SingleAnalystFails_ShouldDegradeAndReturnReport() Assert.IsFalse(string.IsNullOrWhiteSpace(report.CoordinatorResult.Summary)); var texts = report.AnalystMessages.Select(message => message.Text ?? string.Empty).ToList(); - // P1-07:协调载荷为摘要而非全文,摘要应包含显示名与工具读取指引 - Assert.IsTrue(texts.Any(text => text.Contains("结论摘要", StringComparison.Ordinal) && text.Contains("NewsEventAnalyst", StringComparison.Ordinal)), - $"存活分析师的结论摘要应进入报告\n--- 诊断日志 ---\n{string.Join("\n", diagnosticLogs.TakeLast(80))}"); - Assert.IsTrue(texts.Any(text => text.Contains("get_analyst_artifact", StringComparison.Ordinal)), - "协调载荷应包含产物读取工具指引"); - Assert.IsTrue(texts.All(text => !AnalystFailureMessages.IsFailureMarker(text)), - "失败标记不应进入报告载荷"); - Assert.IsTrue(texts.Any(text => text.StartsWith(AnalystFailureMessages.MissingDimensionNotePrefix, StringComparison.Ordinal)), - "报告中应包含维度缺失说明"); + // P1-07 展示层修复:报告/侧边栏展示回填后的产物全文,内部摘要与系统指引(SystemNotice)不进入报告 + Assert.IsTrue(texts.Any(text => text.Contains("正常的新闻事件分析结论", StringComparison.Ordinal)), + $"存活分析师的产物全文应经回填进入报告\n--- 诊断日志 ---\n{string.Join("\n", diagnosticLogs.TakeLast(80))}"); + Assert.IsTrue(texts.All(text => !text.Contains("结论摘要", StringComparison.Ordinal) && + !text.Contains("get_analyst_artifact", StringComparison.Ordinal)), + "系统内部说明与结论摘要不应进入报告展示层"); + Assert.IsTrue(texts.Any(text => AnalystFailureMessages.IsFailureMarker(text) && + text.Contains("FundamentalAnalyst", StringComparison.Ordinal) && + text.Contains(ThrowingChatClient.FailureReason, StringComparison.Ordinal)), + "失败分析师的失败标记应保留在报告消息中,供侧边栏渲染失败占位卡片"); Assert.IsTrue(coordinatorRequests.Count == 1, "协调分析师应被恰好调用一次"); Assert.IsTrue(progressEvents.Any(args => args.FailedAnalysts.Count > 0), diff --git a/tests/Trading/AISignalHardBoundaryTest.cs b/tests/Trading/AISignalHardBoundaryTest.cs index 2a937c4..32e0a8d 100644 --- a/tests/Trading/AISignalHardBoundaryTest.cs +++ b/tests/Trading/AISignalHardBoundaryTest.cs @@ -20,7 +20,8 @@ public sealed class AISignalHardBoundaryTest [TestCategory("Unit")] public async Task StopLoss_Triggered_ShouldCloseInverse_AndCompleteStrategy_AndRestoreSide() { - var tradeExecutor = new Mock(null!, null!, null!, NullLogger.Instance, null); + var tradeExecutor = new Mock( + null!, null!, null!, NullLogger.Instance, null, null!); var strategyService = new Mock(null!); var (executor, strategy) = CreateExecutor( tradeExecutor, strategyService.Object, hasPosition: true, @@ -44,7 +45,8 @@ public async Task StopLoss_Triggered_ShouldCloseInverse_AndCompleteStrategy_AndR [TestCategory("Unit")] public async Task StopLoss_Triggered_WithoutPosition_ShouldCompleteWithoutOrdering() { - var tradeExecutor = new Mock(null!, null!, null!, NullLogger.Instance, null); + var tradeExecutor = new Mock( + null!, null!, null!, NullLogger.Instance, null, null!); var strategyService = new Mock(null!); var (executor, strategy) = CreateExecutor( tradeExecutor, strategyService.Object, hasPosition: false, @@ -71,7 +73,8 @@ public async Task StopLoss_Triggered_WithoutPosition_ShouldCompleteWithoutOrderi [TestCategory("Unit")] public async Task StopLoss_Triggered_CloseFailed_StrategyShouldStayActive() { - var tradeExecutor = new Mock(null!, null!, null!, NullLogger.Instance, null); + var tradeExecutor = new Mock( + null!, null!, null!, NullLogger.Instance, null, null!); var strategyService = new Mock(null!); var (executor, strategy) = CreateExecutor( tradeExecutor, strategyService.Object, hasPosition: true,