前言

最近用 Python + ccxt 从零搭建了一个跑在 Kraken Futures(永续合约)上的量化交易机器人,断断续续迭代了很多版本——从最早的多币种梯度分配策略,到后来简化成单币种固定保证金 + 自适应杠杆搜索策略。这篇文章不讲"如何靠量化交易赚钱"(那种标题党我自己都不信),而是想把这个过程中真正有工程价值的设计决策和踩过的坑记录下来,希望对同样在做交易系统/量化机器人的朋友有点参考价值。

先说结论:这套系统目前跑在真实资金(小额)上,核心目标不是"跑赢市场",而是在杠杆交易这种高风险场景下,把"程序不能崩、不能因为异常状态卡死、不能吞掉真实亏损而不自知"这几件事做扎实。下面展开讲。


一、整体架构

项目大致分这么几个模块:

main.py            # 主循环:轮询、开仓决策、仓位调度
trader.py           # 仓位生命周期管理:开仓、监控、平仓结算
config.py           # 配置中心:币种池、策略参数、风控阈值
exchange_client.py   # 交易所连接封装(Kraken Futures + Kraken Spot 双账户)
risk.py             # 杠杆等风控参数的最终校验闸门
profit_allocator.py / profit_reserve.py   # 利润分流:一部分自动转出到现货账户"存起来"
logger_setup.py      # 滚动日志

最初的版本是"多币种梯度分配"策略:BTC/ETH/若干山寨币按不同比例分配仓位、随机开多空方向、按净利润率止盈止损。后来经过多轮实盘验证和调参,逐步简化成了现在这版:单币种(BTC)、固定保证金起步、自适应杠杆搜索、固定金额止盈、不设止损、止盈后强制冷却。这个简化过程本身就是一次挺有意思的"从复杂到简单"的重构经历,后面细讲。


二、几个我觉得值得写出来的设计模式

1. 双轨止盈监控:交易所托管单 + 软件轮询,谁先触发算谁

单纯依赖交易所的条件单(比如挂一个止盈限价单)有个问题:程序如果重启、断线,条件单本身还在交易所那边生效,不会丢单,这是好事;但如果止盈条件是动态计算的(比如"净利润扣完手续费后达到某个百分比"),条件单没法表达这么复杂的逻辑。

所以设计成两条轨道并行:

  • 轨道 A(硬止盈):开仓的同时,在交易所挂一个固定百分比的止盈限价单(比如 +10%),作为断线兜底——哪怕程序挂了、电脑关机,这个单子在交易所那边还是有效的,不会变成完全裸奔的仓位。
  • 轨道 B(软止盈):程序主动轮询当前价格,实时计算净利润(已经扣除双边手续费),一旦达到目标(可以是固定百分比,也可以是固定美金数),就主动撤掉轨道A的挂单、市价平仓。

每一轮监控,先查轨道A的单子是不是已经被交易所自动成交了(说明断线期间触发了),没有的话再走轨道B的实时判断。谁先触发,就按谁的结算逻辑走审计日志。

def monitor_position(futures_exchange, spot_exchange, position, quote_currency=None):
    if not _position_still_open_on_exchange(futures_exchange, position.symbol):
        _reconcile_vanished_position(futures_exchange, position)
        return 0.0, None, False

    try:
        result = check_hard_take_profit_fill(futures_exchange, spot_exchange, position, quote_currency)
        if result is not None:
            return result
    except ccxt.OrderNotFound:
        # 见下文"踩坑实录"第3条
        log.warning(...)

    return check_soft_take_profit(futures_exchange, spot_exchange, position, quote_currency)

2. 自适应杠杆搜索:顶格杠杆不等于能成交的杠杆

这是整个项目里最反直觉的一个发现。

按理说,交易所标出来"这个币种最高支持100倍杠杆",那用100倍杠杆开仓应该没问题吧?实测发现不是这样——用理论最高杠杆去下单,经常被交易所以 wouldCauseLiquidation(会导致爆仓)为由直接拒单,哪怕账户里的可用保证金远超这笔仓位需要的量、名义仓位价值也很小。

用 ccxt 的 create_order_request()(这是个纯本地方法,不发真实网络请求)对比了一下相同参数、只是杠杆不同的下单请求,请求体本身没有任何问题。后来实测发现,用低于理论最高值一截的杠杆(比如从100倍降到42倍、60倍这种),同样的仓位反而能顺利成交。

猜测原因是:交易所在做保证金检查的时候,理论最高杠杆和实际能安全成交的杠杆之间需要留一点缓冲空间(可能是为了防止价格波动瞬间就击穿维持保证金线),顶格杠杆几乎没有这个缓冲,稍微有点行情波动就会被判定为"这笔单子开出来立刻就会有爆仓风险",直接在下单阶段拒绝。

基于这个发现,把开仓逻辑改成了自适应搜索

def _open_single_position_with_leverage_search(futures_exchange, symbol, side):
    start_leverage = min(LEVERAGE_SEARCH_START, symbol_max_leverage)  # 不从理论最高开始
    margin_usd = SINGLE_POSITION_MARGIN_USD

    while margin_usd <= margin_ceiling:
        leverage = start_leverage
        while leverage >= LEVERAGE_SEARCH_FLOOR:
            try:
                return open_position(..., leverage=leverage)
            except Exception:
                leverage -= LEVERAGE_SEARCH_STEP   # 降杠杆重试
        margin_usd += 1.0   # 杠杆降到底还不行,加保证金,从起始杠杆重新搜索
    # 都试完了还不行,判定资金真的不够,进入纯监控模式,不再无限重试

这套"降杠杆 → 不行就加保证金 → 重新降杠杆"的双层搜索,本质上是把"这个组合到底能不能成交"这件事从猜测变成了实测——不再假设理论上限就是实际上限。

3. "绝不崩溃"不是一句口号,是要对每一种异常状态都有明确归宿

杠杆交易的仓位可能会以很多种方式"消失":正常止盈平仓、用户手动在网页/App上平仓、没设止损被交易所强制平仓……如果程序只处理"我自己发起的平仓"这一种情况,剩下的场景全部会在下一轮监控的时候因为"查不到这个仓位/这个订单"而抛异常。

这里最开始踩了一个坑:仓位已经不存在了,但轨道A的止盈单查询会抛 ccxt.OrderNotFound,代码里判断"如果确认仓位已经不在了,就清理收尾;如果仓位还在,那就是查询抽风,把异常原样往外抛,交给上层重试"。

问题是后来发现了一种没考虑到的情况:仓位确实还在,但对应的止盈单因为某种原因在交易所那边永久性地查不到了(不是查询抽风,是真的没了)。这种情况下代码会把异常一直往外抛——导致每一轮监控都在这一步就中断,根本没走到后面真正在起作用的软止盈判断,仓位实际上处于"半失明"状态,止盈目标即使达到了也不会被发现。这个 bug 在生产环境里安静地跑了小几个小时,通过日志里同一个订单号反复报错几百次才发现。

修复思路:硬止盈(轨道A)查询失败,不应该阻断软止盈(轨道B)的检查。两条轨道是互相独立的兜底关系,一条失效了,另一条应该照常工作,而不是级联失败。

try:
    result = check_hard_take_profit_fill(...)
    if result is not None:
        return result
except ccxt.OrderNotFound:
    log.warning(f"硬止盈单查询不到但仓位确认还在,跳过轨道A,继续走轨道B")
    # 不 re-raise,让流程继续往下走

return check_soft_take_profit(...)

这个教训挺典型的:"发生异常时不崩溃"和"发生异常时系统仍然在做它该做的事"是两个不同层次的目标,只做到第一层,问题会以更隐蔽的方式存在(不报错,但也不干活)。

4. 资产隔离:让利润物理上"跑"到亏不到的地方

因为不设止损(这是有意为之的风险选择,后面单独说),所以专门做了一个"利润留存"机制:每次盈利结算,从合约账户往现货账户转一小部分钱,转账走的是交易所内部的钱包间划转接口,不是市价买卖。

这样做的好处是:合约账户不管后面因为没有止损亏成什么样,已经转出去的这部分钱是物理上独立的,合约账户的保证金计算、强平逻辑完全碰不到现货钱包里的余额。这是一种"既然选择了高风险敞口,就至少让部分收益跑到风险敞口之外"的朴素思路,不是什么高深的对冲,但确实是真实生效的资产隔离。


三、几个环境/工程上的小坑

  • nohup 启动的进程 + 程序自己的文件日志 handler,如果 stdout 重定向到同一个日志文件,会导致每一行日志被写两遍——一个是程序自己往文件里写的,一个是终端输出被重定向进同一个文件的。定位过程中最开始一度怀疑是"随机数逻辑有问题、连续开出同方向仓位",后来才发现只是日志重复了。
  • 单实例互斥锁:早期一次手滑,同一个策略脚本被启动了两次,两个进程互相抢同一份账户保证金、互相撤销对方挂的保护单,很快意识到必须加进程级别的互斥锁(用 fcntl.flock 实现),启动时抢不到锁就直接退出。
  • 交易所批量下单接口和单笔下单接口的参数 schema 不完全一样:批量接口要求止损单也必须带限价(limitPrice),单笔接口不需要;ccxt 某些版本里批量下单自动生成的 order_tag 是整数而不是字符串,会被交易所的 schema 校验直接拒绝——这类坑基本都是靠"用本地不发网络请求的 create_order_request() 方法打印出真实请求体,肉眼比对"排查出来的,比纯猜测靠谱很多。

四、关于风险管理,说点真实的

这篇文章不想回避这一点:当前这版策略是明确不设止损的,风险管理完全依赖人工监控、随时可能手动平仓介入。这是经过反复权衡后的一个个人风险偏好选择,不是"技术上做不到止损",代码里实际上是有完整的止损实现能力的(一开始就实现了硬止损 + 软止损两层,后来又加了比例制软止损和"止损后反向重开"的机制,都实测跑通过),只是当前这一版权衡下来选择了不用。

这里想强调的是:这套系统的工程可靠性(不崩溃、状态一致、异常有归宿)和它的资金风险敞口,是两个完全独立的维度。一个工程上做得很扎实的自动化交易系统,完全可能配上一个高风险的策略参数(比如不设止损、高杠杆)——工程质量不能替你承担策略选择的风险,这个责任始终在做决策的人身上,不会因为代码写得健壮就自动转移给系统。如果你也在做类似的东西,这一点值得想清楚:你要解决的是"程序会不会crash",还是"我会不会亏钱",这是两个问题,用一套代码质量去回答另一个问题,是会出事的。


五、小结

这个项目对我来说,价值不在"用它赚了多少钱"(目前投入金额很小,本质上是个真实资金环境下的工程与风控实验),而在于逼着自己去正视几个平时写业务代码不太会认真考虑的问题:

  • 异常处理写"不崩溃"很容易,写"每种异常状态都有正确的业务归宿"很难;
  • 交易所文档写的"理论上限",不代表工程上应该直接拿来当参数用,很多边界值需要实测验证,不能纸上谈兵;
  • 涉及真实资金的自动化系统,日志的完整性和可读性本身就是风控的一部分——出问题的时候,你能不能从日志里准确复现"当时到底发生了什么",直接决定了你能不能真正吸取教训。

以上,欢迎交流。


本文不构成任何投资建议,文中涉及的杠杆倍数、止盈止损参数均为个人测试环境下的选择,不建议直接照搬用于自己的实盘交易。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐