项目:CryptoSpot(仓库名 netcorespot)
地址:https://github.com/liuzhixin405/netcorespot
技术栈:.NET 9 / ASP.NET Core · React 18 · SignalR + MessagePack · MySQL(EF Core) · OpenAI 兼容协议
很多人做「AI + 交易」的 Demo,止步于让模型聊两句行情、给点建议。这个项目不一样的地方在于:它让 AI 真正能下单,但把 AI 的每一个动作都关进了一个由「工具隔离 + 规则风控 + 人工审批 + 全链路审计 + 模型容错」组成的笼子里。
后端是一个典型的 Clean Architecture 五层结构:
CryptoSpot.Domain 实体、枚举、仓储接口(无任何依赖)
▲
CryptoSpot.Application DTO、服务抽象(IAiModel/ITradingService/...)、接口
▲
CryptoSpot.Infrastructure 实现层:撮合引擎、行情源、AI、SignalR、后台服务
▲
CryptoSpot.Persistence EF Core DbContext、仓储实现
▲
CryptoSpot.API 控制器、中间件、Program.cs(组合根)
关键点:所有「AI 能干什么」的能力,在 Application 层只以接口形式存在。比如 AI 统一抽象 IAiModel、只读工具执行器 IAiToolExecutor、风控/审计/审批服务,都定义在 Application.Abstractions 里,Infrastructure 才给出具体实现。这个分层是后面整套 AI 安全设计能成立的前提——业务规则与「模型供应商」彻底解耦。
Program.cs 是纯组合根,用扩展方法 AddInfrastructure(configuration)、AddCleanArchitecture() 一次性装配。值得注意的几处基础设施:
access_token 走 query string(SignalR WebSocket 握手时浏览器没法加 header)。AddRateLimiter 用固定窗口,auth 接口 1 分钟 5 次。long 会被序列化成字符串(LongToStringJsonConverter),避免 JS 精度丢失——这是交易系统里很常见但容易被忽略的坑。UseGlobalExceptionHandling() 全局中间件在最前。AddCryptoSpotHealthChecks + 启动时 PerformStartupHealthChecks。撮合引擎在 CryptoSpot.Infrastructure/MatchEngine,核心是 ChannelMatchEngineService,纯内存、无 Redis 依赖(配置文件里的 Redis 段仅历史保留)。
private readonly ConcurrentDictionary<string, IOrderBook> _orderBooks = new();
private readonly ConcurrentDictionary<string, Channel<OrderRequest>> _orderChannels = new();
private readonly ConcurrentDictionary<string, Task> _processingTasks = new();
public void InitializeSymbol(string symbol)
{
var normalizedSymbol = NormalizeSymbol(symbol);
if (!_orderBooks.TryAdd(normalizedSymbol, new InMemoryOrderBook(normalizedSymbol)))
return;
var channel = Channel.CreateBounded<OrderRequest>(new BoundedChannelOptions(10000)
{
FullMode = BoundedChannelFullMode.Wait
});
_orderChannels[normalizedSymbol] = channel;
_processingTasks[normalizedSymbol] = Task.Run(async () =>
{
await foreach (var request in channel.Reader.ReadAllAsync(_cts.Token))
{
try
{
if (request.Type == OrderRequestType.Cancel) { ProcessCancelOrder(...); continue; }
await ProcessSingleOrderAsync(request.Order, orderBook, symbol, _cts.Token);
}
catch (Exception ex) { request.Completion?.TrySetException(ex); }
}
});
}
设计意图很清晰:
InMemoryOrderBook 的 GetBestOpposite 也就无需线程安全)。BoundedChannelFullMode.Wait,写入端满 10000 时阻塞等待,而不是无限膨胀内存。PlaceOrderAsync 先 FreezeAssetAsync 冻结资产(失败直接抛「余额不足」),再把订单入队,保证「先占款、后撮合」。PriceTimePriorityMatchingAlgorithm.Match(book, taker) 是一个 yield return 的迭代器,逐个产出 MatchSlice:
public IEnumerable<MatchSlice> Match(IOrderBook book, Order taker)
{
while (taker.FilledQuantity < taker.Quantity)
{
var maker = book.GetBestOpposite(taker.Side);
if (maker == null) yield break; // 对手盘为空
if (!PriceCross(taker, maker)) yield break; // 价格不交叉
if (maker.UserId == taker.UserId) // 自成交防护
{ book.Remove(maker); continue; }
var qty = Math.Min(taker.Quantity - taker.FilledQuantity,
maker.Quantity - maker.FilledQuantity);
var price = maker.Price ?? taker.Price ?? 0m; // 以 maker 价为成交价
yield return new MatchSlice(maker, taker, price, qty);
}
}
taker.Price >= maker.Price,卖单反之;市价单直接视为可成交。ProcessSingleOrderAsync 里,每产出一个 MatchSlice 就 SettleTradeAsync 做纯内存资产结算(买方解冻计价币、增加基准币;卖方解冻基准币、增加计价币),再累加双方 FilledQuantity、更新状态、发布 SignalR 事件。
持久化有两点值得学习:
affectedMakers 集合去重后单次 DB 往返,避免一单吃多档时对每个 maker 各打一次 UPDATE。Id=0 交给数据库自增,内存里用 Interlocked 生成临时 id,避免并发下主键冲突(注释里明确写了 // 数据库自增,避免 Duplicate entry)。撮合中「结算失败则 break 终止」、CancelOrderAsync 用 TaskCompletionSource + WaitAsync 等待消费循环处理结果,这些都属于生产级的健壮性处理。
business K 线)驱动内存订单簿与价格。BinanceMarketDataProvider 提供 REST 历史 K 线,带 ProxyUrl 代理配置,并通过 AddStandardResilienceHandler() 加了重试/超时/熔断的 Resilience 策略。ITradingService 暴露统一查询接口(ticker / klines / orderbook / recent trades),AI 的只读工具底层调的就是这些。这种「实时通道 + REST 兜底」的双源设计,是行情类系统里很实用的稳定思路。
这是全文的重点。整个 AI 体系可以拆成六块来看。
IAiModel,彻底解耦供应商Application 层只有一个模型抽象,Infrastructure 用 OpenAiCompatAiModel 实现,走 OpenAI 兼容的 /chat/completions:
public interface IAiModel
{
string ModelId { get; }
Task<AiChatResponse> ChatAsync(AiChatRequest request, CancellationToken ct = default);
IAsyncEnumerable<AiChatChunk> ChatStreamAsync(AiChatRequest request, CancellationToken ct = default);
}
默认配置指向本地 Ollama(http://localhost:11434/v1 + qwen2.5:7b),但只要改 appsettings 的 Ai 节点,就能换成 DeepSeek / GLM / Qwen / llama.cpp 等任何 /v1 兼容端点,代码零改动。
非流式 ChatAsync 的请求体把 messages、tools、temperature、max_tokens 全部透传,并把工具定义里的 JSON Schema 字符串 Deserialize<JsonElement> 后回填,保证发给模型的 schema 是合法 JSON。响应解析时同时取 content 和 tool_calls,并且校验「content 和 tool_calls 不能同时为空」,空响应直接抛异常——避免静默吞掉异常响应。
流式 ChatStreamAsync 走 SSE(text/event-stream),逐行解析 data: 前缀,识别 [DONE]。这里有一个很关键的细节——流式工具调用参数的累积:
// 工具调用参数是分片到达的:{" ... {"x":100 ... },必须按 index 累积
var toolCalls = new Dictionary<int, StreamingToolCall>();
...
if (delta.TryGetProperty("tool_calls", out var toolCallsElement))
{
foreach (var toolCall in toolCallsElement.EnumerateArray())
{
var index = toolCall.GetProperty("index").GetInt32();
if (!toolCalls.TryGetValue(index, out var accumulated))
{ accumulated = new StreamingToolCall(); toolCalls[index] = accumulated; }
if (toolCall.TryGetProperty("id", out var id)) accumulated.Id = id.GetString();
if (toolCall.TryGetProperty("function", out var function))
{
if (function.TryGetProperty("name", out var name)) accumulated.Name = name.GetString();
if (function.TryGetProperty("arguments", out var arguments)) accumulated.Arguments.Append(arguments.GetString());
}
}
}
// 只有当 finish_reason == "tool_calls" 时才把累积结果拼成完整 AiToolCall
这是很多 OpenAI 兼容流式实现最容易踩的坑:模型流式返回工具参数是逐 token 分片的,如果拿到一块就 JsonDocument.Parse,一定会解析失败。这里选择了「按 index 累积到 StringBuilder,流结束时统一组装」的正确做法。
工具分两类,边界非常清楚:
只读工具(AiToolExecutor,6 个)——用于分析,绝不能改状态:
| 工具 | 说明 |
|---|---|
get_ticker |
实时行情 |
get_klines |
最近 24 根 1h K 线 |
get_orderbook |
前 10 档订单簿 |
get_assets |
当前用户资产 |
get_open_orders |
未完成订单 |
get_recent_trades |
市场最近成交(50 条) |
写工具(AiTradeService 内,3 个)——用于交易,全部经过风控:
| 工具 | 说明 |
|---|---|
place_order |
下市价/限价单 |
cancel_order |
撤单 |
cancel_all_orders |
批量撤单(必审批) |
每个工具都有严格的 JSON Schema(additionalProperties:false,quantity 用 exclusiveMinimum:0)。更关键的是入参二次校验:ParseOrder 里 EnsureOnlyProperties 只允许白名单字段,GetRequiredString / GetRequiredDecimal 强校验 side/type/quantity/price,任何不合法的参数直接抛 ArgumentException 返回 denied——即使用户能注入恶意 tool call,也过不了参数层。
AiAnalyzer:多轮工具调用 + 数据引用AiAnalyzer.AnalyzeAsync 是一条典型的多轮 ReAct 循环:
for (var round = 0; round < 5; round++)
{
response = await _model.ChatAsync(...); // 带 Tools
if (response.ToolCalls is not { Count: > 0 })
return new AiAnalysisResult(response.Content, citations);
messages.Add(new AiMessage("assistant", response.Content, ToolCalls: response.ToolCalls));
foreach (var toolCall in response.ToolCalls)
{
var result = await _tools.ExecuteAsync(userId, toolCall, ct);
citations.Add(toolCall.Name);
await _audit.WriteAsync(... "ai.tool.call" ...);
messages.Add(new AiMessage("tool", result, toolCall.Id));
}
}
throw new InvalidOperationException("AI analysis exceeded the maximum of five tool-call rounds.");
三个设计点:
citations 收集:模型最终结论里引用了哪些数据工具,前端可展示「本结论基于:get_ticker / get_klines」,从机制上对抗幻觉。AiTradeService:规划 → 风控 → 审批 → 二次校验执行这是整个项目最核心、也最能体现「AI 能干但不能乱来」的地方。
PlanAndExecuteAsync 的编排与分析链路类似(5 轮上限、Temperature = 0、MaxTokens = 512),但关键在 ExecuteToolAsync 对写工具的 switch 分发:
return toolCall.Name switch
{
"place_order" => await PlanOrSubmitOrderAsync(...),
"cancel_order" => await CancelOrderAsync(...),
"cancel_all_orders" => await CreateCancellationApprovalAsync(...),
_ => throw new InvalidOperationException($"AI attempted to use unsupported tool '{toolCall.Name}'.")
};
place_order 会先走 EvaluateOrderAsync 做下单前风控评估,评估结果决定三种走向:
DenyReason != null → 直接拒绝(denied),不落库、不审批。RequiresApproval == true → 生成 Pending 审批单,返回 pending_approval + approvalId。SubmitOrderAsync 直接提交。审批通过后的执行并不是「直接提交」,而是 ExecuteApprovedAsync 再次跑一遍 EvaluateOrderAsync:
case "place_order":
var plan = ParseOrder(approval.PayloadJson);
var evaluation = await EvaluateOrderAsync(userId, plan, ct);
outcome = evaluation.DenyReason is not null
? await DenyAsync(...)
: await SubmitOrderAsync(...);
这个「审批时风控一次、执行时再风控一次」的双重校验很关键——因为审批到真正执行之间有时间差,市场价可能已经变了,必须用最新价格重新算偏离度。
AiGuardrailOptions)默认阈值(全部可配):
| 规则 | 默认值 | 动作 |
|---|---|---|
| 市价单审批阈值 | 1000 USDT | 超过 → 转审批 |
| 限价单审批阈值 | 5000 USDT | 超过 → 转审批 |
| 价格偏离 | 5% | 超过 → 直接拒绝 |
| 下单频率 | 5 次/分钟 | 超过 → 直接拒绝 |
| 日累计成交额 | 10000 USDT | 超过 → 后续转审批 |
EvaluateOrderAsync 的完整检查链:
place_order 成功次数 ≥ 5 → 拒(查审计日志表统计)。notional 累加)+ 本单 > 阈值 → 转审批。注意第 4、6 条:风控数据来源是 AI 审计日志本身,形成了一个自洽的「审计即数据」闭环,不需要额外建风控计数表。
AiApprovalService 生成 Pending 审批单(ActionType + PayloadJson),用户通过 POST /api/ai/approvals/{id}/approve|reject 决策。approve 后由 ExecuteApprovedAsync 真正执行,状态流转 Pending → Approved → Executed/Failed。AiAuditService 贯穿全链路,动作类型覆盖 ai.analyze、ai.tool.call、ai.tool.execute、ai.trade.plan、ai.approval.approved/rejected、ai.docs.answer、ai.news.sentiment、ai.portfolio.report,每条记录都含 userId、modelId、toolName、arguments、result、status、elapsedMs。出问题时可以精确还原「AI 在什么时间、用哪个模型、调了什么工具、给了什么参数、结果是什么」。AiExpansionService 实现了三个「增强型」AI 能力,它们的共同设计是 LLM 为主 + 确定性 fallback 兜底:
① 文档助手(一个轻量 RAG)
Lazy 加载 docs/ 下所有 .md,按 \n\n 切块(单块 ≤ 1400 字符)。score = 交集数 + 包含原问题 ? 10 : 0,取 Top 4。HttpRequestException / 超时,则回退到「抽取式答案」——直接把最相关的片段原文返回给用户。② 新闻情绪分析
AnalyzeLexically)得到确定性基线:命中利好词(上涨/获批/突破/bullish…)加分、利空词(暴跌/黑客/处罚/bearish…)减分,算出 [-1,1] 的 score。sentiment 必须三选一、score 必须可解析、summary 非空,否则抛异常走 fallback)。③ 持仓报告
这三个能力展示了同一种工程范式:AI 是不可靠的,所以永远要有一条「没有 AI 也能给出合理结果」的兜底路径。
OpenAiCompatAiModel 内置了简易熔断器:
private readonly ConcurrentDictionary<string, CircuitState> _circuits = new();
private bool IsOpen(AiModelEndpoint endpoint) =>
_circuits.TryGetValue(endpoint.BaseUrl, out var state) &&
state.OpenUntil > DateTimeOffset.UtcNow;
private void RecordFailure(AiModelEndpoint endpoint)
{
_circuits.AddOrUpdate(endpoint.BaseUrl, _ => new CircuitState(1, ...),
(_, state) =>
{
var failures = state.Failures + 1;
return failures >= options.CircuitBreakerFailureThreshold // 默认 3 次
? new CircuitState(failures, DateTimeOffset.UtcNow.AddSeconds(options.CircuitBreakerCooldownSeconds)) // 默认 60s
: new CircuitState(failures, state.OpenUntil);
});
}
配合 Endpoints() 依次产出「主模型 + FallbackModels 备用列表」,ChatAsync / ChatStreamAsync 会遍历所有端点,跳过熔断中的,任一成功即返回。主模型连续失败 3 次 → 熔断 60 秒 → 自动切换备用模型,全链路对调用方透明。同时每次请求都套 CreateTimeoutTokenSource 做超时控制。
Program.cs 里 AddSignalR().AddMessagePackProtocol(),Hub 映射到 /tradingHub,推送价格、订单簿、K 线、订单、风控事件。MessagePack 相比 JSON 更省带宽,适合高频行情推送。AI 的 SSE 对话流走 text/event-stream(event: message / event: done),与 SignalR 分开,各司其职。
| 端点 | 作用 |
|---|---|
POST /api/ai/analyze |
行情分析(含 citations、会话、审计) |
POST /api/ai/trade |
自然语言交易(规划 → 风控 → 审批) |
POST /api/ai/approvals/{id}/approve / reject |
审批决策 |
POST /api/ai/chat |
SSE 流式对话 |
POST /api/ai/assistant |
文档助手(RAG) |
POST /api/ai/news/sentiment |
新闻情绪 |
POST /api/ai/portfolio/report |
持仓报告 |
GET /api/ai/conversations / {id} |
会话管理 |
GET /api/ai/audit |
审计查询 |
所有 AI 端点都在 [Authorize] 下,且通过 ICurrentUserService 拿当前用户 id,AI 工具执行时也只能操作「当前用户」的资产与订单,天然做了租户隔离。
后端 appsettings 的 Ai 节点即可控制一切:
"Ai": {
"Enabled": true,
"BaseUrl": "http://localhost:11434/v1",
"ApiKey": "ollama",
"Model": "qwen2.5:7b",
"Temperature": 0.2,
"MaxTokens": 2048,
"TimeoutSeconds": 120,
"FallbackModels": [],
"CircuitBreakerFailureThreshold": 3,
"CircuitBreakerCooldownSeconds": 60
}
不想要 AI 就把 Enabled 设为 false,交易、撮合、行情、推送等其余功能完全不受影响——AI 是插拔式的能力,而不是系统的命脉,这本身就是架构解耦的证明。
netcorespot 最值得借鉴的不是某个 AI 接口写得漂亮,而是那一整套工程约束:
IAiModel + OpenAI 兼容协议,供应商可替换、AI 可开关。把「交易」换成「审批、报销、下单、发消息」等任何真实业务动作,这套模式都成立。这正是它比一个「会聊天的交易机器人」更有价值的根本原因。
⚠️ 免责声明:本项目为学习/演示用途,撮合与行情均为模拟/公共数据,不构成任何投资建议。