Skip to content

Commit fca773c

Browse files
committed
fix: preserve broker attribution on protection orders
1 parent 73cffb5 commit fca773c

5 files changed

Lines changed: 239 additions & 3 deletions

File tree

‎backend_api_python/app/services/live_trading/native_protection.py‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,20 +121,30 @@ def _place_binance(client: Any, request: NativeProtectionRequest) -> List[Dict[s
121121
if not dual:
122122
base["reduceOnly"] = "true"
123123
responses: List[Dict[str, Any]] = []
124-
for kind, price in (
125-
("STOP_MARKET", request.stop_loss_price),
126-
("TAKE_PROFIT_MARKET", request.take_profit_price),
124+
for kind, price, suffix in (
125+
("STOP_MARKET", request.stop_loss_price, "sl"),
126+
("TAKE_PROFIT_MARKET", request.take_profit_price, "tp"),
127127
):
128128
if price <= 0:
129129
continue
130130
body = dict(base, type=kind, triggerPrice=str(price))
131+
client_algo_id = client._format_client_order_id(
132+
f"{request.client_order_id}-{suffix}" if request.client_order_id else ""
133+
)
134+
if client_algo_id:
135+
body["clientAlgoId"] = client_algo_id
131136
responses.append(client._signed_request("POST", "/fapi/v1/algoOrder", params=body))
132137
if request.trailing_stop_pct > 0:
133138
callback = min(10.0, max(0.1, request.trailing_stop_pct * 100.0))
134139
body = dict(base, type="TRAILING_STOP_MARKET", callbackRate=str(callback))
135140
activation = _activation_price(request)
136141
if activation > 0:
137142
body["activatePrice"] = str(activation)
143+
client_algo_id = client._format_client_order_id(
144+
f"{request.client_order_id}-trail" if request.client_order_id else ""
145+
)
146+
if client_algo_id:
147+
body["clientAlgoId"] = client_algo_id
138148
responses.append(client._signed_request("POST", "/fapi/v1/algoOrder", params=body))
139149
return responses
140150

‎backend_api_python/tests/test_native_protection.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def test_binance_uses_current_algo_order_endpoint():
4747
from app.services.live_trading.binance import BinanceFuturesClient
4848

4949
client = BinanceFuturesClient.__new__(BinanceFuturesClient)
50+
client.broker_id = BinanceFuturesClient._BROKER_ID
5051
client.get_dual_side_position = lambda: False
5152
calls = []
5253
client._signed_request = lambda method, path, params: calls.append((method, path, params)) or {"algoId": 1}
@@ -57,6 +58,39 @@ def test_binance_uses_current_algo_order_endpoint():
5758
assert {call[2]["type"] for call in calls} == {"STOP_MARKET", "TAKE_PROFIT_MARKET"}
5859
assert all(call[1] == "/fapi/v1/algoOrder" for call in calls)
5960
assert all(call[2]["reduceOnly"] == "true" for call in calls)
61+
assert all(
62+
call[2]["clientAlgoId"].startswith(f"x-{BinanceFuturesClient._BROKER_ID}")
63+
for call in calls
64+
)
65+
assert len({call[2]["clientAlgoId"] for call in calls}) == 2
66+
67+
68+
def test_binance_trailing_protection_keeps_partner_attribution():
69+
from app.services.live_trading.binance import BinanceFuturesClient
70+
71+
client = BinanceFuturesClient.__new__(BinanceFuturesClient)
72+
client.broker_id = BinanceFuturesClient._BROKER_ID
73+
client.get_dual_side_position = lambda: False
74+
calls = []
75+
client._signed_request = (
76+
lambda method, path, params: calls.append((method, path, params)) or {"algoId": 1}
77+
)
78+
79+
result = place_native_protection_orders(
80+
client,
81+
_request(
82+
stop_loss_price=0,
83+
take_profit_price=0,
84+
trailing_stop_pct=0.02,
85+
trailing_activation_pct=0.01,
86+
),
87+
)
88+
89+
assert len(result) == 1
90+
assert calls[0][2]["type"] == "TRAILING_STOP_MARKET"
91+
assert calls[0][2]["clientAlgoId"].startswith(
92+
f"x-{BinanceFuturesClient._BROKER_ID}"
93+
)
6094

6195

6296
def test_okx_hedge_protection_uses_position_side_without_net_only_reduce_flag():

‎backend_api_python/tests/test_partner_attribution.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,17 @@ def test_all_exchange_request_attribution_is_built_in():
9797
},
9898
market_type="swap",
9999
),
100+
"bitget_reality": create_client(
101+
{
102+
"exchange_id": "bitget",
103+
"api_key": "key",
104+
"secret_key": "secret",
105+
"passphrase": "pass",
106+
"api_family": "reality",
107+
"instrument_id": "AAPLUSDT",
108+
},
109+
market_type="spot",
110+
),
100111
"bybit": create_client(
101112
{"exchange_id": "bybit", "api_key": "key", "secret_key": "secret"},
102113
market_type="swap",
@@ -105,6 +116,19 @@ def test_all_exchange_request_attribution_is_built_in():
105116
{"exchange_id": "gate", "api_key": "key", "secret_key": "secret"},
106117
market_type="spot",
107118
),
119+
"gate_stock": create_client(
120+
{
121+
"exchange_id": "gate",
122+
"api_key": "key",
123+
"secret_key": "secret",
124+
"api_family": "stock",
125+
},
126+
market_type="spot",
127+
),
128+
"gate_swap": create_client(
129+
{"exchange_id": "gate", "api_key": "key", "secret_key": "secret"},
130+
market_type="swap",
131+
),
108132
"htx": create_client(
109133
{"exchange_id": "htx", "api_key": "key", "secret_key": "secret"},
110134
market_type="spot",
@@ -130,8 +154,13 @@ def test_all_exchange_request_attribution_is_built_in():
130154
assert clients["bitget_swap"]._headers(
131155
"1", "sign", "/api/v2/mix/order/place-order"
132156
)["X-CHANNEL-API-CODE"] == profile.bitget_channel_api_code
157+
assert clients["bitget_reality"]._headers(
158+
"1", "sign", "/api/v3/trade/place-reality-order"
159+
)["X-CHANNEL-API-CODE"] == profile.bitget_channel_api_code
133160
assert clients["bybit"]._headers("1", "sign")["Referer"] == profile.bybit_referer
134161
assert clients["gate"]._headers("1", "sign")["X-Gate-Channel-Id"] == profile.gate_channel_id
162+
assert clients["gate_stock"]._headers("1", "sign")["X-Gate-Channel-Id"] == profile.gate_channel_id
163+
assert clients["gate_swap"]._headers("1", "sign")["X-Gate-Channel-Id"] == profile.gate_channel_id
135164
assert clients["htx"]._format_spot_client_order_id("order").startswith(profile.htx_broker_id)
136165
assert clients["htx"].spot_source == profile.htx_spot_source
137166

‎docs/AUTOPILOT_PRODUCT_PLAN_CN.md‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# QuantDinger Autopilot 产品规划
2+
3+
## 产品定位
4+
5+
Autopilot 是 QuantDinger 的 AI 资产管理入口。用户只需要定义资金、允许交易的资产、收益目标和最大回撤,系统负责研究、策略组合、仓位分配、执行、风控、复盘与调整。
6+
7+
收益目标应表达为优化目标和概率区间,不能表达为承诺收益。任何 AI 决策都必须受确定性风控引擎约束;风控、订单状态机、持仓对账和紧急停止不能交给模型自由决定。
8+
9+
## 首版范围
10+
11+
首版应主动收窄,先验证用户是否愿意把真实资金交给系统:
12+
13+
- 交易所:先支持两个归因和执行最稳定的主流交易所。
14+
- 标的:BTC、ETH、SOL 等高流动性资产,允许用户选择白名单。
15+
- 模式:现货与最高 2 倍永续,默认从模拟盘开始。
16+
- 策略:趋势、均值回归、突破、网格等经过验证的策略族,由 AI 组合和调参。
17+
- 上线节奏:模拟盘 → 小额金丝雀实盘 → 满足条件后逐步放大。
18+
- 禁止项:AI 临时生成未经回测的新代码后直接实盘、无限加仓、自动突破最大回撤限制。
19+
20+
## 用户输入
21+
22+
创建 Autopilot 时只要求用户完成六个决定:
23+
24+
1. 投入金额和交易账户。
25+
2. 交易标的或允许 AI 从白名单选择。
26+
3. 收益目标和投资周期。
27+
4. 最大回撤与单日最大亏损。
28+
5. 是否允许合约以及最大杠杆。
29+
6. 模拟盘、小额实盘或直接实盘。
30+
31+
高级设置收进折叠区,包含单资产上限、单策略上限、交易频率、隔夜持仓、新闻事件保护和滑点上限。
32+
33+
## 系统能力
34+
35+
Autopilot 由八个层次组成:
36+
37+
1. **Mandate**:把用户目标转换为不可越过的资金与风险约束。
38+
2. **Market Regime**:识别趋势、震荡、波动率和流动性状态。
39+
3. **Strategy Registry**:只从通过样本外检验和实盘兼容检查的策略池中选择。
40+
4. **Portfolio Allocator**:根据相关性、风险预算和市场状态分配策略与资产。
41+
5. **Risk Governor**:确定性限制最大回撤、日损失、杠杆、敞口、滑点和连续亏损。
42+
6. **Execution Engine**:使用现有订单状态机、Broker ID、成交同步和持仓归属能力执行。
43+
7. **Reconciliation**:交易所持仓、系统账本和策略账本持续对账,异常时禁止加仓并保留减仓能力。
44+
8. **Decision Journal**:保存每次观察、判断、动作、证据、模型版本和结果,供用户解释与审计。
45+
46+
运行状态建议统一为:草稿、分析中、模拟运行、小额实盘、正式运行、防御模式、暂停、已停止、异常待处理。
47+
48+
## PC 端位置与页面
49+
50+
在现有顶部导航增加一级入口 **Autopilot**,放在“AI投研”之后、“市场/指标/策略”之前。它是产品的主转化入口,不应藏在策略或实盘的二级菜单中。
51+
52+
PC 端包含三个核心页面:
53+
54+
### 1. Autopilot 首页
55+
56+
- 顶部展示总托管资产、累计收益、当前回撤、风险预算使用率和运行状态。
57+
- 主按钮为“创建 Autopilot”。
58+
- 卡片列出每个计划的账户、资产范围、收益、回撤、风险级别、当前市场状态和下一步动作。
59+
- 空状态先解释“你设目标,系统完成研究、组合、执行和风控”,再引导连接账户或创建模拟计划。
60+
61+
### 2. 创建向导
62+
63+
- 第一步:资金、账户、标的与周期。
64+
- 第二步:收益目标、最大回撤、日损失和杠杆限制。
65+
- 第三步:AI 给出策略组合、资金分配、历史压力测试、预期区间和失败条件。
66+
- 第四步:用户确认模拟运行或小额实盘,明确展示系统权限和紧急停止规则。
67+
68+
第三步必须让用户看到 AI 为什么这样配置,同时允许调整风险,不把专业参数一次性抛给用户。
69+
70+
### 3. 运行详情
71+
72+
- 概览:净值、收益、回撤、风险预算和市场状态。
73+
- 组合:资产和策略分配、相关性、当前敞口。
74+
- 决策:AI 的观察、证据、结论、执行动作和结果。
75+
- 交易:持仓、订单、成交、费用和 Broker 对账。
76+
- 风控:当前限制、触发记录、恢复条件。
77+
- 固定操作:降低风险、暂停新开仓、暂停全部、紧急停止。
78+
79+
现有“实盘”页继续作为统一运行监控中心,Autopilot 计划以“AI托管”标签出现在策略列表中,点击后进入 Autopilot 详情。AI投研报告增加“交给 Autopilot”,回测结果增加“加入候选策略池”,形成研究到成交的转化链路。
80+
81+
## 手机端位置与页面
82+
83+
手机底部已经有五个主入口。建议把中间的“AI投研”升级为 **Autopilot**,并把原有 AI 研究作为 Autopilot 页面内的“研究”页签,避免增加第六个底部入口。
84+
85+
- 首页首屏是正在运行计划的状态卡:今日变化、总收益、当前回撤、风险等级和下一动作。
86+
- 创建流程使用全屏分步表单,每屏只做一个决定,底部固定下一步按钮。
87+
- 运行详情保留“概览、持仓、决策”三个页签,订单和风险细节从卡片进入二级页。
88+
- “降低风险”和“暂停”固定在页面底部;紧急停止放在更多操作中并二次确认。
89+
- 推送只覆盖需要行动的事件:接近回撤上限、执行失败、对账异常、进入防御模式、请求扩大资金。
90+
91+
移动端主要解决查看状态、处理异常和快速干预;复杂回测、策略对比和详细参数保留在 PC 端。
92+
93+
## 转化设计
94+
95+
Autopilot 的关键漏斗为:
96+
97+
1. AI 投研或市场机会让用户看到可执行想法。
98+
2. 一键生成风险受控的 Autopilot 建议。
99+
3. 无需 API Key 也可先跑模拟盘。
100+
4. 模拟结果达到条件后引导连接支持 Broker 归因的交易所。
101+
5. 小额实盘验证成交、滑点和回撤后再建议增加资金。
102+
103+
核心指标应同时衡量产品价值和商业价值:创建率、模拟到实盘转化率、实盘 7/30 日留存、托管资产、归因成交额、风险事件率、最大回撤兑现率、人工干预率和返佣收入。
104+
105+
## 发布阶段
106+
107+
- **阶段一:建议模式**。AI 生成计划和调仓建议,用户确认执行,用于验证信任和解释层。
108+
- **阶段二:受限 Autopilot**。只运行白名单策略、白名单资产和小额资金,完整上线风控与对账。
109+
- **阶段三:组合 Autopilot**。跨策略动态配置、市场状态切换和自动降风险。
110+
- **阶段四:多账户配置**。在合规和交易所规则允许的范围内进行多交易所路由与组合管理。
111+
112+
首个可发布版本的成功标准不是最高收益,而是用户能清楚理解系统在做什么、风险限制能可靠生效、异常能及时停止加仓并恢复,以及实盘成交能稳定产生 Broker 归因。
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Broker ID 归因路径审计
2+
3+
审计日期:2026-10-16
4+
5+
## 结论
6+
7+
QuantDinger 的加密货币实盘下单统一通过 `live_trading.factory.create_client` 创建交易所客户端。Partner 标识由客户端内部常量强制注入,凭证、策略、Agent 和 API 请求中的同名字段会被剥离,用户不能覆盖平台归因。
8+
9+
本次审计覆盖了策略运行、快捷交易、Agent/MCP、网格、DCA、马丁、限价转市价、止盈止损和追踪止损。普通订单路径已覆盖 Broker ID。审计发现 Binance 原生 Algo 止盈、止损和追踪止损此前没有设置带 Broker 前缀的 `clientAlgoId`,现已补齐并加入回归测试。
10+
11+
## 入口路径
12+
13+
| 业务入口 | 最终执行路径 | 结果 |
14+
| --- | --- | --- |
15+
| Strategy API V2 | pending order worker → exchange adapter/client | 已归因 |
16+
| 默认模板、DCA、马丁、分层马丁 | pending order worker → exchange adapter/client | 已归因 |
17+
| 网格策略 | grid exchange orders → exchange client | 已归因 |
18+
| Web 快捷交易 | quick trade → execution/client | 已归因 |
19+
| Agent/MCP 快捷交易 | agent quick trade → execution/client | 已归因 |
20+
| 市价单 | execution adapter → `place_market_order` | 已归因 |
21+
| 限价单 | execution adapter → `place_limit_order` | 已归因 |
22+
| 限价转市价 | 两个阶段复用带 Client Order ID 的 intent | 已归因 |
23+
| 原生止盈止损 | native protection → exchange native API | 除 HTX 待合作方确认外已覆盖 |
24+
25+
IBKR 与 Alpaca 不属于当前加密交易所 Broker ID 体系,本审计不把它们计入返佣覆盖率。
26+
27+
## 交易所覆盖
28+
29+
| 交易所 | 普通订单归因方式 | 原生保护单 | 审计结果 |
30+
| --- | --- | --- | --- |
31+
| Binance Spot | `newClientOrderId` 使用 `x-{brokerId}` 前缀 | 不适用当前合约保护模块 | 已覆盖 |
32+
| Binance Futures | `newClientOrderId` 使用 `x-{brokerId}` 前缀 | `clientAlgoId` 使用相同前缀 | 已修复并覆盖 |
33+
| OKX | 请求体 `tag` | Algo Order 同样注入 `tag` | 已覆盖 |
34+
| Bitget Spot/Mix/Reality | 下单路径请求头 `X-CHANNEL-API-CODE` | TPSL 路径在请求头白名单内 | 已覆盖 |
35+
| Bybit Spot/Linear | 已签名请求头 `Referer` | 条件单仍走签名请求 | 已覆盖 |
36+
| Gate Spot/Stock/Futures | 已签名请求头 `X-Gate-Channel-Id` | Price Order 仍走签名请求 | 已覆盖 |
37+
| HTX Spot | `source` 和带 Broker 前缀的 Client Order ID | 不适用合约保护模块 | 已覆盖 |
38+
| HTX Swap | 普通订单请求体 `channel_code` | 公开 TPSL 接口未列出 `channel_code` | 待合作方确认 |
39+
40+
HTX TPSL 不应在未获得 Partner API 口径前强行添加未知参数,否则可能导致真实保护单被拒绝。当前应向 HTX 确认 TPSL 订单是否继承开仓归因、是否接受 `channel_code`,并用 Partner 后台订单明细做一次真实小额核验。
41+
42+
## 防篡改与可观测性
43+
44+
- `strip_partner_config` 会从用户和策略配置中移除所有 Partner 字段。
45+
- 客户端构造器使用类内置常量,不接受环境变量或用户输入覆盖。
46+
- `redact_partner_attribution` 会在日志和 API 响应中隐藏实际 Partner 标识。
47+
- 回归测试覆盖 Binance Spot/Futures、OKX、Bitget Spot/Mix/Reality、Bybit、Gate Spot/Stock/Futures、HTX。
48+
49+
## 后续运营核验
50+
51+
代码归因存在不等于交易所最终结算成功。建议增加内部 Broker Attribution Dashboard,按交易所展示:归因订单数、成交额、Partner 后台可见成交额、预估返佣、结算返佣及差异率。每次新增交易所接口、订单类型或执行算法时,必须同时添加归因契约测试。

0 commit comments

Comments
 (0)