ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Python与Django构建海龟交易管理系统:从信号扫描到仓位控制实战

基于Python与Django构建海龟交易管理系统:从信号扫描到仓位控制实战 直接开门见山。乌龟交易系统这个源自Richard Dennis和William Eckhardt在1980年代搞出来的趋势跟踪策略直到今天依然是量化圈子里的经典入门必修课。但大部分人拿到海龟法则都是在Jupyter Notebook里跑个回测、画个净值曲线就结束了离真正能“用”还有一段距离。我这次想做的不是再写一遍策略而是基于Python和Django把海龟策略的整个流程——信号扫描、仓位管理、止损止盈、交易日志——沉淀成一个真正能日用的管理系统。这篇文章适合谁看一种是已经懂海龟策略、但想把策略工程化、产品化的开发者另一种是刚学完Django基础、想找个有含金量的实战项目来练手的朋友。无论你是哪一类这篇文章都会从需求拆解讲到代码落地再讲到部署避坑。我会先聊清楚系统架构和数据模型怎么设计再给出关键代码实现最后把我在开发过程中踩过的坑、排查过的性能问题一次性都翻了底朝天。1. 项目整体设计与核心需求拆解1.1 乌龟交易法则的核心逻辑回顾很多人一提到海龟交易脑子里就浮现出“20日突破买入、10日突破卖出”这两条简单规则。没错这是海龟系统的入场逻辑但完整的海龟法则远不止这一点。它包含了一整套资金管理框架以ATR真实波动幅度作为仓位和止损的统一度量单位用N值计算每笔交易的头寸规模单笔风险控制在账户权益的2%以内同时采用“分批建仓、金字塔加仓”的方式在趋势延续时逐步放大仓位而在价格回撤达到一定幅度时逐步减仓离场。用大白话解释就是海龟系统不是靠预测涨跌赚钱的而是靠“抓住大趋势严格控住亏损”这两条腿走路。入场后对了就拿着错了就按规则止损绝不主观犹豫。这一套逻辑放到软件系统里天然就适合拆分成几个独立模块行情数据获取、信号计算、仓位计算、订单管理、风控检查。这也就是我要用Django来实现的核心骨架。1.2 为什么管理系统比单跑策略更“值钱”如果只是在Notebook里调现成的talib库写个双均线交叉信号再算算累计收益两三个小时就能搞定。但这种方式有一个致命问题——策略永远停留在“回测通过”的阶段离真实可操作还隔着十万八千里。真实场景下我们每天要面对的是今天有没有触发信号如果触发了用多大的仓位进场账户里当前挂着多少敞口上一次止损之后系统有没有正确重置状态这些问题的答案是需要一个持续运行、能记录状态、能追踪历史的管理系统来回答的。Django在这个场景下派上了大用场。它的ORM帮你把策略状态、交易记录、账户快照持久化到数据库它的自带Admin后台在开发阶段就能快速可视化交易数据而它的迁移机制、信号机制和中间件体系让你可以像一个真正的产品团队一样去组织代码和迭代功能。我在设计这个系统时定的原则很简单策略引擎和业务模型要彻底分离。策略引擎是纯Python模块不依赖Django的任何Features这样以后想换FastAPI甚至CLI脚本都能复用。而Django这一侧只管接收、存储、展示和调度相当于一个“总控台”。1.3 典型用户与使用场景这个系统到底给谁用我的答案是两类人。第一类是个人量化交易者尤其是做期货、趋势跟踪策略的他们可以用这个系统替代手工盯盘、用Excel记交易的方式每天自动化扫描信号再按系统提示下单和调整仓位。第二类是培训机构或自学者把海龟系统作为教学案例演示如何把策略思路转成工程化系统。我在开发时设定了两个核心使用场景。第一个是每日收盘后的批量扫描系统自动拉取最新行情计算ATR、突破位、当前持仓状态推送当日信号列表。第二个是交易记录追踪每次下单后手动或自动录入成交价、手数、时间系统自动计算当前浮动盈亏、剩余可用头寸单位、止损价位置。这两个场景覆盖了海龟策略从“发现机会”到“离场结算”的全周期。1.4 技术选型背后的理由既然标题是“Python基于Django”Django自然是主框架但这不代表所有组件都得是Django的。我最终的选型是Django 4.2作为Web应用框架、Django ORM配PostgreSQL做持久化、Celery Redis做定时任务和异步扫描、Bootstrap 5做前端界面Django模板渲染、plotly.js做图表。这套组合的取舍逻辑很实际Django生态成熟ORM和Admin能显著减少重复工作Celery是为了让行情扫描这种耗时任务不阻塞Web请求PostgreSQL是因为我要用到数组字段和JSON字段来存储信号快照这在MySQL里写起来没那么顺手。有一点要提醒Django的项目级实战真正复杂的不是代码本身而是数据流的设计。信号从行情扫描模块进来需要写入信号表关联到当日的市场状态交易从订单表进来需要联动持仓表、资金表和绩效表。如果一开始表结构设计不合理后面改起来非常痛苦。我为此重新设计了三次数据库模型后面会详细讲最终版的表结构和字段含义。2. 数据库模型设计与核心业务实现2.1 实体关系与表结构设计我最终定稿的数据库模型围绕五个核心实体Market品种、PriceBar日线行情、Signal交易信号、Position持仓记录、TradeLog交易流水。品种表很简单字段包括代码、名称、市场类型、合约乘数、最小变动价位、每手大小。这些基础信息是仓位计算必需的。比如你交易螺纹钢合约乘数是10吨/手最小变动价位是1元那么一个点的盈亏就是10元。如果连合约乘数都不存仓位计算就是无源之水。行情表存储每日的OHLCV数据我特意加了atr字段直接在行情入库时一并计算避免每次回测或扫描时重复计算ATR。信号表的字段最复杂包含了信号类型多头/空头、触发日期、入场价格、入场时机描述、系统类型System1或System2、有效状态未处理/已处理/已过期、关联的当日ATR值。持仓表记录当前未平仓头寸包括开仓时间、开仓价格、当前手数、已加仓次数、止损价格、初始止损价格。交易流水表则记录每一次平仓或平今的操作便于后期做绩效归因。2.2 Django模型代码与关键字段解释模型的实现我直接贴核心代码注释我会写得比较细。from django.db import models from django.utils import timezone from decimal import Decimal class Market(models.Model): symbol models.CharField(合约代码, max_length20, uniqueTrue) name models.CharField(品种名称, max_length50) exchange models.CharField(交易所, max_length20) multiplier models.DecimalField(合约乘数, max_digits12, decimal_places2, default10) min_tick models.DecimalField(最小变动价位, max_digits12, decimal_places4, default1) tick_value models.DecimalField(每跳盈亏, max_digits12, decimal_places4, blankTrue) def save(self, *args, **kwargs): self.tick_value self.multiplier * self.min_tick super().save(*args, **kwargs) def __str__(self): return f{self.name}({self.symbol}) class PriceBar(models.Model): market models.ForeignKey(Market, on_deletemodels.CASCADE, related_namebars) date models.DateField() open models.DecimalField(开盘, max_digits12, decimal_places4) high models.DecimalField(最高, max_digits12, decimal_places4) low models.DecimalField(最低, max_digits12, decimal_places4) close models.DecimalField(收盘, max_digits12, decimal_places4) volume models.BigIntegerField(成交量) atr14 models.DecimalField(ATR14, max_digits12, decimal_places4, nullTrue, blankTrue) class Meta: ordering [date] indexes [ models.Index(fields[market, date]), models.Index(fields[date]), ] unique_together (market, date) def __str__(self): return f{self.market.symbol} {self.date} {self.close}Markets里我用Decimal而不是Float这是因为资金计算和价格计算对精度极其敏感。你可能觉得浮点数差个零点零零几无所谓但当你计算加仓预算、保证金占用、浮动盈亏时一个小数尾差就可能导致保证金不足或下单手数错误。这个坑在金融系统里算是老生常谈但我仍然要强调所有涉及金额和价格的字段一律用Decimal否则早晚会被浮点误差坑到。PriceBar表里的unique_together非常关键它保证了同一品种同一天不会出现两条行情记录。定时任务扫重复数据时可以直接用update_or_create来避免主键冲突。2.3 持仓与资金表的联动设计持仓表我单独拿出来讲因为这是整个系统状态流转的核心。class Position(models.Model): SIDE_CHOICES [ (LONG, 多头), (SHORT, 空头), ] STATUS_CHOICES [ (OPEN, 持仓中), (CLOSED, 已平仓), (PENDING, 待开仓), ] market models.ForeignKey(Market, on_deletemodels.CASCADE, related_namepositions) opened_at models.DateTimeField(defaulttimezone.now) closed_at models.DateTimeField(nullTrue, blankTrue) side models.CharField(max_length10, choicesSIDE_CHOICES) status models.CharField(max_length10, choicesSTATUS_CHOICES, defaultPENDING) units models.IntegerField(当前单位数, default0) entry_price models.DecimalField(入场均价, max_digits12, decimal_places4) current_stop models.DecimalField(当前止损价, max_digits12, decimal_places4) initial_stop models.DecimalField(初始止损价, max_digits12, decimal_places4) max_add_units models.IntegerField(最大加仓次数, default4) added_units models.IntegerField(已加仓次数, default0) def unrealized_pnl(self, current_price): price_diff current_price - self.entry_price if self.side SHORT: price_diff -price_diff return price_diff * self.market.multiplier * self.unitsunrealized_pnl方法是持仓表的核心它接收当前价格直接返回浮动盈亏。这个方法的计算逻辑其实就一行但它被大量复用在Admin后台、用户页面、风控检查、绩效报表四类场景里。Django模型的自定义方法让我不必在多个视图里重复抄计算逻辑这也是ORM用得好能明显减少冗余代码的地方。资金表我用了稍微激进一点的设计——把账户资金快照按日存储而不是只存一个实时值。因为汇率调整、手续费扣除、浮盈浮亏的变化如果只保留当前状态你根本没法复盘一周前为什么回撤那么大。资金日表每天收盘后自动生成一条快照包含当日权益、累计盈亏、回撤比例、可用保证金、已用保证金这样绩效分析和风险复盘都有了时间序列数据支撑。2.4 为什么要给信号表加“状态机”信号表不能只记录“今天突破了、建议做多”因为一个信号从触发到执行中间还隔着人的决策、交易所的交易时间、跳空开盘等不确定性。我加了一个status字段生命周期是CREATED已生成 → CONFIRMED已确认待下单 → EXECUTED已执行 → EXPIRED已过期 → IGNORED已忽略。这个状态机是几次实战教训换来的。最早版本里信号表只有“是/否”两个状态结果遇到一次夜盘跳空高开系统生成的多头信号价格远低于开盘价如果蠢到直接按信号价进场等于在最高的位置接盘。后来我加入了“确认”机制用户每天早上看信号列表手动确认哪些信号执行、哪些跳过自动化辅助决策但保留人的否决权。这个设计对注重风控的个人交易者非常重要真正优秀的交易系统从来不是全自动无脑执行而是把人的纪律和机器的计算能力结合起来。3. ATR计算与信号扫描模块的实现细节3.1 手写ATR计算的正确打开方式ATRAverage True Range是海龟系统的地基。ATR在这里不是用来做技术指标装饰花的它是仓位计算、止损设置、突破判定三者的统一长度单位。理论上你有两种实现方式调talib的ATR函数或者自己手写。我的建议是手写因为自己写才能彻底理解这个指标的计算过程而且可以在计算过程中顺便校验数据质量。ATR的计算分两步。第一步计算每个交易日的真实波幅True RangeTR。TR是以下三个值中的最大值当日最高价减当日最低价、当日最高价减前一日收盘价的绝对值、当日最低价减前一日收盘价的绝对值。第二步对TR取N日简单移动平均。海龟系统的经典参数是N20所以A就是20日TR的均值。def calculate_atr(bars, period20): if len(bars) period 1: return Decimal(0) tr_list [] for i in range(1, len(bars)): high float(bars[i].high) low float(bars[i].low) prev_close float(bars[i - 1].close) tr max( high - low, abs(high - prev_close), abs(low - prev_close) ) tr_list.append(tr) if len(tr_list) period: return Decimal(0) atr sum(tr_list[-period:]) / period return Decimal(str(atr))这段代码的逻辑并不复杂但有几个细节值得注意。第一第一个交易日前没有前一日收盘价所以TR序列是从第2根K线开始的。第二我用的是简单均值而非平滑均值虽然很多技术指标库用的是Wilder平滑但海龟原版规则用的就是简单移动平均翻《海龟交易法则》附录可以验证。第三如果数据不足20日返回0比抛异常要好因为在批量扫描时有些新品种上市时间短数据量天然不足这个情况应该被记录而非中断。3.2 突破信号判定与System1/System2双系统海龟系统里有两套入场系统参数不同目的也不同。System1采用20日突破入场、10日突破离场对应的是中短周期趋势跟踪System2采用55日突破入场、20日突破离场对应的是长周期趋势跟踪。两套系统叠加运行等于同时捕捉中速和慢速两类趋势行情。用Django ORM实现突破判定核心就是一条查询找出当前收盘价大于或小于前20日或55日最高价或最低价最高值的记录。def check_breakout(market, bars, lookback20, breakout_typehigh): if len(bars) lookback 1: return False, None recent_bars bars[:-1] # 排除当日 if breakout_type high: threshold max(float(b.high) for b in recent_bars[-lookback:]) return float(bars[-1].close) threshold, threshold else: threshold min(float(b.low) for b in recent_bars[-lookback:]) return float(bars[-1].close) threshold, threshold这里最关键的设计是bars[:-1]也就是用当日之前的数据去计算突破位。为什么不是包含当日因为盘中最高价、最低价是动态变化的你不可能用还没收盘的数据去触发一个收盘确认的信号。正确的海龟玩法是收盘后确认收盘价是否突破前N日的极值。这个“未来函数”的坑量化新手最容易踩——回测时把当日数据加入突破位计算结果信号极其频繁实盘一跑就废了。System1和System2的差异不只是参数不同还有一套复杂的“上次同方向触发是否盈利”过滤器海龟称之为System1过滤器但这个过滤器在工程实现上比较复杂我初版没有实现。理由是它依赖每一笔已平仓交易的盈亏结果来动态调整当前交易日的信号有效性这个状态在数据库里要额外记录“上次信号结果”对数据库更新频率要求很高。我建议先把基础版本跑通确认数据流和页面展示没问题再逐渐增加过滤规则工程上的推进节奏很重要。3.3 Celery定时任务与每日自动扫描信号扫描不能每天手动点按钮尤其当你有十几个交易品种时每次扫描要计算每个品种的ATR、20日突破、55日突破、当前持仓状态串行跑可能要十几秒。这个任务放在Web请求里显然不合适所以我用Celery的定时任务每天收盘后比如交易日下午15:10自动执行一次全市场扫描。CELERY_BEAT_SCHEDULE { scan_daily_signals: { task: trading.tasks.scan_daily_signals, schedule: crontab(hour15, minute10, day_of_weekmon-fri), }, }扫描任务的任务核心逻辑就是遍历每个品种读取最近60根K线计算ATR判断两个系统的突破信号如果有信号则生成Signal记录。这里有一个容易被忽略的问题交易所有中午休市和夜盘交易K线数据的结束时间点不同。如果你直接用自然日分组节假日前后可能串数据。我的做法是按交易日历去重而不是按自然日去重因为行情接口返回的数据里通常带trade_date字段按这个字段去重才准确。3.4 用Django Admin做手动干预出口虽然我们写了自动化扫描但一个完整的管理系统必须保留人工干预的入口。Django Admin作为“穷人版管理后台”在这里恰好够用。我在Admin里注册了所有模型并重写了几个关键方法admin.register(Signal) class SignalAdmin(admin.ModelAdmin): list_display (market, signal_type, system, trigger_date, entry_price, status) list_filter (status, signal_type, system) actions [confirm_selected] admin.action(description确认选中信号) def confirm_selected(self, request, queryset): queryset.update(statusCONFIRMED)Admin里列表页、筛选条件、批量操作这三个特性几乎不费吹灰之力就实现了交易信号的管理界面。很多Django教程讲Admin都只讲注册模型实际上Admin的可扩展性远比想象中强。你把list_display、list_filter、actions用起来就等于做了一套能处理的业务后台这在个人项目和创业公司早期阶段非常够用。4. 仓位计算与止损风控功能的工程实现4.1 用N值计算头寸数量的完整逻辑仓位管理是海龟系统区别于普通趋势策略的核心。海龟法则规定单个品种的仓位大小由该品种的N值和账户权益共同决定。每一单位的头寸规模计算公式是单位数量 (账户权益 × 1%) ÷ (N × 合约乘数)注意这里用的不是2%的风险比例而是1%。因为后续还有加仓逻辑每次加仓都用0.5个N的间距去递增单笔交易的累计风险会被放大到约2%。用1%作为基础单位后续加仓后的总风险才刚好控制在2%。这个计算逻辑直接写成一个独立的函数同时接受账户权益和ATR作为参数def calculate_units(account_equity, atr_value, market, risk_percent1.0): if atr_value 0: return 0 risk_amount account_equity * Decimal(risk_percent / 100) unit_value atr_value * market.multiplier units int(risk_amount / unit_value) return max(units, 1)这里用int()向下取整是因为交易手数必须是整数不能出现0.5手这种实际不存在的数量。向下取整比四舍五入保守宁可少开一手也不能让单笔风险暴露超过计划值。4.2 金字塔加仓与止损位上移的算法实现海龟的加仓规则是以初始入场价格为基准每上涨做多时0.5个ATR就加一个单位最多加到4个额外单位。每次加仓后止损位也相应上移0.5个ATR。这个“移动止损”的用途就是保护浮盈——趋势继续走止损跟着走趋势突然反转系统在成本线上方或至少不亏大钱的位置离场。class PositionManager: def __init__(self, position, current_atr): self.position position self.current_atr current_atr def should_add_at(self, current_price): if self.position.added_units self.position.max_add_units: return False add_distance self.current_atr * Decimal(0.5) if self.position.side LONG: target self.position.entry_price add_distance * (self.position.added_units 1) return current_price target else: target self.position.entry_price - add_distance * (self.position.added_units 1) return current_price target def update_stop(self): if self.position.side LONG: new_stop self.position.entry_price self.current_atr * Decimal(0.5) * self.position.added_units else: new_stop self.position.entry_price - self.current_atr * Decimal(0.5) * self.position.added_units if self.position.side LONG: if new_stop self.position.current_stop: self.position.current_stop new_stop else: if new_stop self.position.current_stop: self.position.current_stop new_stop这段代码里最需要留意的是update_stop里那个比较判断止损只能向有利方向移动不能因为ATR变小就把止损往回拉。ATR作为波动率指标本身是动态变化的趋势行情中波动加剧ATR变大震荡行情中波动收窄ATR变小。如果止损位允许随ATR变小而回撤那等于给了趋势反转更多亏损空间这违背了海龟系统“截断亏损、让利润奔跑”的核心原则。4.3 风控模块与仓位调整的联动当多个品种同时触发信号时海龟系统还要求总体仓位上限——同一时间最多持有一定数量的“市场单位”而且单个市场的单位数量不能超限。这个风控逻辑其实就是一个持仓汇总查询def current_total_units(user): open_positions Position.objects.filter( statusOPEN ) total_units sum( p.units * (1 if p.side LONG else -1) for p in open_positions ) return total_units这个函数可以在每次加仓前调用判断当前账户整体敞口。如果总单位数超过上限比如海龟原版规定不超过12个单位同一方向不超过4个单位就拒绝新的加仓指令。这种在开仓、加仓前统一做风控检查的做法比在每个视图里单独写判断要优雅得多也方便以后把风控策略拆成独立的配置项。4.4 资金曲线的实时展示与K线联动仓位算完了信号也确认了管理系统还缺一个直观的驾驶舱页面。我用plotly.js在Django模板里渲染了三个核心图表账户资金曲线、各品种的当前浮动盈亏分布、K线图上叠加的入场点和止损线。资金曲线直接用资金日表的时间序列数据绘制K线图用plotly的candlesticktrace。我特意实现了“在K线图上叠加止盈止损线”的功能——把持仓记录的current_stop值作为一条水平虚线画在图上这样你一眼就能看出当前价格离止损还有多远。这个功能在开发时增加了不少工作量但实际使用中价值极高比看一堆冷冰冰的数字直观得多。5. 数据接入、信号测试与部署5.1 行情数据接入方案对比Django本身不生产数据它只是数据的搬运工和管理员。行情数据从哪里来我在开发中对比了三种方案。第一是Tushare Pro数据质量好字段全但注册门槛高积分要求越来越多很多基础接口的权限都对低积分用户关闭。第二是Baostock免费、免注册、直接用日线数据、分钟数据都有格式对Django来说也够用。第三是自己爬交易所官网维护成本高不推荐。最终我选的是Baostock理由很简单免费、无积分门槛、数据量对日线策略足够。BAOSTOCK取数据的过程就不再贴完整代码了核心是先把历史数据一次性灌入再用Celery每日增量更新收盘数据。这里要提醒数据源的复权问题。baostock默认返回的是前复权数据海龟趋势跟踪策略如果做历史回测必须统一复权方式。如果混用不复权和前复权的数据计算出来的ATR和突破位会严重失真。我最终选择了“不复权”的原始数据因为实盘交易时你在行情软件里看到的也是不复权价格这样信号和实际屏幕价格之间没有隐含的差异。5.2 信号准确性的验证方法双轨测试写完了信号扫描模块你绝对不能直接上实盘至少要做一个“模拟盘双轨测试”。我的做法是在同一个数据库里开一张paper_trade_log表与真实交易日志的结构完全相同但标记为PAPER。系统每天扫描生成的信号统一记入模拟盘我把模拟盘运行了一个月与真实盯盘结果对比确认信号触发率、方向准确率尤其是要确认有没有因为数据源错误导致“假突破”。双轨测试还有一个额外好处它可以验证仓位计算是否合理。模拟盘里按海龟公式计算出单位数量再用模拟的资金账户去做盈亏核算看最大回撤是否在可接受范围内。这个过程可以帮你发现一系列工程Bug——比如ATR计算时索引越界、突破阈值取错K线范围、加仓逻辑中重复触发等。5.3 Linux服务器部署uWSGI Nginx PostgreSQL系统本地开发没问题后还有最重要的一步部署到服务器上持续运行。我的部署方案是基于Linux Nginx uWSGI PostgreSQL的经典组合。Django的部署其实不复杂核心就两条用Nginx处理静态文件和反向代理用uWSGI启动Django应用。部署时我踩过一个不小的坑Celery的beat调度在服务器上常驻内存如果服务器重启不会自动拉起。你必须把Celery和Celery Beat注册为systemd服务设置开机自启和崩溃自动重启。只部署Django而忘了Celery会导致行情扫描和信号推送在服务器重启后彻底罢工而且不会有人立刻发现。另一个性能调优点是数据库索引。行情表数据增长很快三个月的数据就有几十万行如果不加索引每日扫描时filter(marketxxx, date__gtexxx)这类查询会非常慢。我在模型设计阶段就给行情表加了复合索引这一点之前已经体现实际效果明显扫描任务从最初的四五秒缩短到几百毫秒。5.4 常见问题与排查心得开发过程中我整理了一份高频问题清单遇到问题直接对照排查能省不少时间。症状可能原因解决方案信号从未触发突破判定用了当日数据包含检查判定函数是否排除bars[-1]ATR为0历史数据不足20根K线在扫描任务里打印跳过原因加仓后止损反而降低update_stop缺少方向判断检查止损只向有利方向移动每日扫描任务不执行Celery Beat未注册systemd检查systemctl status celery-beat查询行情极慢缺少市场日期复合索引用explain分析查询计划资金是负数浮点数运算导致精度丢失全部金额字段换成Decimal信号触发价与盘面不一致复权方式不统一所有数据统一用前复权或全部不复权最后再分享一个我的心法。金融量化系统的项目不要一上来就堆功能。先把“行情入库 → 信号扫描 → 人工确认 → 仓位计算 → 交易记录 → 绩效展示”这条主干路打通再逐步加风控规则、加自动化推送、加模拟盘。主干通了系统才是个能用的活物否则代码再多也只是个半成品展示品。乌龟交易系统本身就是一套很简单但极其严密的规则工程上也是一样把最核心的流程做成稳如磐石比炫技式的复杂架构有价值得多。
返回列表