Initial commit: IB auto-trading bot with multi-strategy support
This commit is contained in:
commit
e50d3a160b
10
.env.example
Normal file
10
.env.example
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
IB_HOST=127.0.0.1
|
||||||
|
# IB Gateway: 4002=paper, 4001=live
|
||||||
|
# TWS: 7497=paper, 7496=live
|
||||||
|
# 实盘使用 4001 时,请确认你已充分测试过策略
|
||||||
|
IB_PORT=4001
|
||||||
|
IB_CLIENT_ID=1
|
||||||
|
# 你的 IB 账户号(如 U4845070)
|
||||||
|
IB_ACCOUNT=
|
||||||
|
# 仅用于启动时的日志警示,真实连接模式由端口/Gateway 登录类型决定
|
||||||
|
IB_PAPER=false
|
||||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
.env
|
||||||
|
|
||||||
|
bot_state.json
|
||||||
|
day_pnl.json
|
||||||
|
recent_sells.json
|
||||||
|
trades.jsonl
|
||||||
|
trading_bot.log
|
||||||
|
|
||||||
|
*.log
|
||||||
96
AGENTS.md
Normal file
96
AGENTS.md
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
# AGENTS.md — AI 助手项目上下文
|
||||||
|
|
||||||
|
> 本文件供 AI 编码助手(如 OpenCode)在新会话中快速恢复项目上下文。
|
||||||
|
> **修改本项目后请同步更新本文件。**
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
Interactive Brokers 股票自动交易机器人(Python + ib_insync),多策略并行。
|
||||||
|
**实盘运行**:账户 `U4845070`,IB Gateway 端口 4001(Gateway Live)。
|
||||||
|
另有账户内独立持仓 `HF x300` —— 永远不属于 bot 管理,禁止触碰。
|
||||||
|
|
||||||
|
交易标的(1分钟K线,每策略每标的最多1笔):**AMZN、AAPL、NVDA、NOK**(2026-08-03 起加 NVDA、NOK;此前为11只科技股的组合已停用)。
|
||||||
|
每标的持仓价值上限 `max_symbol_value_usd`=$2300(三策略合计,`tracker.symbol_value()` 计算)。
|
||||||
|
|
||||||
|
## 文件结构与职责
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `config.py` | dataclass 配置;自动加载 `.env`(python-dotenv);`state_file` 为基于 `__file__` 的绝对路径 |
|
||||||
|
| `connection.py` | IB 连接单例 `ib_conn`(connect/reconnect/ensure_connected) |
|
||||||
|
| `bars.py` | `BarManager`:每个合约一条 `keepUpToDate=True` 实时K线订阅,多策略共享;`to_completed_df` 丢弃未收盘bar;`is_market_active` 通过最新bar时效判断开闭市 |
|
||||||
|
| `state.py` | `PositionTracker`:持仓归属(哪个策略拥有哪笔仓位),持久化到 `bot_state.json`;启动/重连时 `reconcile()` 对账;每笔买卖追加写入 `trades.jsonl` 流水账本(首次运行以当前持仓为 seed) |
|
||||||
|
| `daily_report.py` / `report.sh` | 当日成交明细+盈亏报告(FIFO,数据源 `trades.jsonl`);用法 `./report.sh [YYYY-MM-DD]` |
|
||||||
|
| `orders.py` | `execute_market_order`:下单+等待成交(30s超时撤单)+防重复单(`has_open_order`,按 orderRef 匹配) |
|
||||||
|
| `strategies/` | `ma_cross`(SMA20/50+ADX)、`short_term`(EMA5/10+VWAP+max_hold)、`mean_reversion`(RSI/布林/急跌抄底)、`forex`(默认关闭) |
|
||||||
|
| `main.py` | 主循环60s;disconnectedEvent 只注册一次且有并发/关机防护;重连后 `bar_manager.reset()` + 重新 `on_start` |
|
||||||
|
|
||||||
|
## 关键设计决策(不要违反)
|
||||||
|
|
||||||
|
1. **持仓归属**:IB 只报账户级持仓,归属靠 `bot_state.json`。策略**只卖自己的持仓**。账户中未被追踪的持仓标记为 UNMANAGED,永不出卖。
|
||||||
|
2. **交易所硬止损(主)+ 软止损(兜底)**:买入成交后立即挂 GTC STP 卖单(`stop_loss_pct`,outsideRth=True),bot 停机/断线期间仍由交易所执行。`base.py` 止损单机制:每周期 `_sync_stop_orders()`(成交→record_sell;终态订单按 `_trade_uid`(permId/orderId) **只消费一次**;只认领未完成订单;owned 但无单→补挂);策略卖出前 `_cancel_stop()` 并处理撤单竞态中的成交;软止损仅在 `_has_active_stop()==False` 时兜底。外汇仍用软止损(市场连续,且策略默认关闭)。
|
||||||
|
3. **卖出不阻塞**:死叉类退出若盈利不足 `min_profit_pct`(1.5%),**每轮重检**(不是等下次交叉事件),达标即卖。
|
||||||
|
4. **按金额定股数**:每笔 `trade_value_usd`($2000,07-27 从 $1000 提高)按信号价折算(`base._order_quantity`,floor、最少1股);佣金占比约 0.05%+0.1%往返。
|
||||||
|
5. **全局持仓上限**:`config.max_positions`(12 批,≈$24k 敞口,07-28 从 15 调低)。策略买入前检查 `tracker.total_positions()`,达上限则跳过买入(日志 SKIPPED);已有持仓正常管理退出,不强制平仓。
|
||||||
|
6. **每日亏损熔断**:`config.max_daily_loss`($150)。`PositionTracker` 按 record_sell 实时累计当日已实现盈亏(持久化 `day_pnl.json`,跨重启保留、本地午夜重置),触限后当日禁止开新仓(`base._can_open_position` 统一闸门,返回原因)。
|
||||||
|
7. **止损/失败冷却**:同一策略同一标的被止损(硬止损成交或软止损卖出)后 `stop_cooldown_minutes`(30分钟)内禁止重买;下单被拒/超时也进冷却(防每周期重试刷屏)。
|
||||||
|
8. **全局卖出冷却(跨策略防 flip-flop)**:任何策略卖出某标的后,所有策略在 `sell_cooldown_minutes`(30分钟)内禁止买入该标的;超过冷却但仍在 `sell_improvement_window_minutes`(60分钟)内,重买价必须 ≥ `sell_improvement_pct`(0.5%)低于最近卖价。登记点统一在 `tracker.record_sell` / `_record_external_sell`(覆盖策略卖出、硬止损成交、撤单竞态成交、断线回填),持久化到 `recent_sells.json`(跨重启保留,惰性清理)。2026-08-04 修复当日 3 起同价 flip:NOK 卖→买(跨策略)、NVDA 卖→买(同策略 4 分钟原价买回)、AAPL 卖→买(跨策略)。
|
||||||
|
8. **MeanRev 趋势过滤**:仅在 `close > SMA(trend_ma_period=50)` 时允许抄底,避免下跌趋势中接飞刀。
|
||||||
|
9. **信号只用已收盘K线**:最后一根 forming bar 必须丢弃(时间戳比较法)。
|
||||||
|
10. **休市禁交易**:最新bar年龄 > `bar_seconds*5` 视为休市,跳过全部信号。收盘后约5分钟内 bar 仍"新鲜",此窗口的市价单会隔夜排队——收市停机需提前(参考 15:59 EOD 停止的做法)。
|
||||||
|
11. **K线流量**:禁止每轮全量拉历史数据(旧版 5 小时 845MB);用 keepUpToDate 订阅(约 3.5MB/5小时)。`formatDate=2`(epoch,时区无歧义)。
|
||||||
|
|
||||||
|
## ⚠️ 已修复的 bug(勿重新引入)
|
||||||
|
|
||||||
|
- **ADX**:`down_move = -low.diff()`(Wilder 定义),**不是** `low.diff().abs()`。abs 版本会把上涨中的低点抬升误记为空头 DM,导致趋势方向完全颠倒。
|
||||||
|
- **RSI**:`rs = avg_gain / avg_loss` 直接除,让 `inf` 自然传播(纯涨→RSI 100)。不要 `.replace(0, nan)`,否则纯涨时 RSI 变 NaN。
|
||||||
|
- **state_file 必须绝对路径**:cron/nohup 启动时 CWD 是 `$HOME`,相对路径会把状态文件写错位置。
|
||||||
|
- **从 shell 工具启动 bot 必须 `setsid`**:`setsid nohup .venv/bin/python main.py >> trading_bot.log 2>&1 < /dev/null &`。否则终端会话结束/工具超时杀进程组时 bot 会被带走。
|
||||||
|
- **BarManager.subscribe 竞态**:先存 in-flight Task 再 await(重连任务的 `on_start` 与主循环 `on_bar` 会并发订阅同一合约,旧代码 `await` 后才存导致重复订阅+订阅泄漏)。主循环断线期间直接跳过周期。
|
||||||
|
- **断线窗口的止损成交必须回填账本**:STP 在 bot 断线时成交,bot 收不到事件;`reconcile()` 清理/裁剪仓位时必须调用 `_record_external_sell` 补记 `trades.jsonl`(价格取 `reqCompletedOrders` 实际成交价,取不到则按止损价 -2% 估记并标 `est:true`)。否则报告出现幽灵持仓、漏记亏损(07-30 修复,含一次性历史修复)。
|
||||||
|
- 重连事件重复注册、关机后误触发重连:已在 main.py 修复,注意保持 `_running` 与 `_reconnect_task` 防护逻辑。
|
||||||
|
- **`reqCompletedOrdersAsync` 必须传 `apiOnly` 参数**:ib_insync 0.9.86 起签名必填。bot 回填断线 STP 成交用 `apiOnly=True`(只取 API 订单,天然排除 TWS 手工单)。不传会抛 TypeError,断线回填静默失效(07-31 修复)。
|
||||||
|
- **`reqCompletedOrdersAsync` 会把已完成订单注入 `wrapper.trades` 造成"幻影订单"**:ib_insync 0.9.86 的 `wrapper.completedOrder`(wrapper.py:402)以非终态 OrderStatus(filled=0、status 取 orderState.status)把全部已完成订单塞进 `ib.trades()`/`ib.openTrades()`——同 ref 的僵尸单(如 08-03 遗留 NOK STP)在重启后以 `PreSubmitted/done=False` 现形 → `has_open_order` 永远返回 True → 该 key 的止损**永不补挂**(持仓裸奔);done 幻影还会触发每轮 "ended with status=Filled - re-placing now" 噪音。防御(08-04 修复):`main._completed_stp_fills` 收集完成交价后按 `permId` 把幻影从 `ib.wrapper.trades` 弹出;`orders.has_open_order` 另加 `remaining > 0` 硬条件(幻影 remaining 恒为 0,真实在挂单 >0)。
|
||||||
|
- **外部成交价 0.0 必须按"未知"处理**:`reqCompletedOrders` 对跨会话(上次 bot 会话)的已完成订单可能返回 `avgFillPrice=0.0`。`main.py` 的 `_completed_stp_fills` 要跳过 `fill_price <= 0` 的订单;`state.py` 的 `_record_external_sell` 用 `estimated = not price`(None 或 0.0 都视为取不到)→ 按止损价 -2% 估记并标 `est:true`。否则会把 0.0 当真价写账本,亏损虚算(07-31 修复:迁移目录时踩中,造成 day_pnl 虚增 -1668.74)。
|
||||||
|
- **僵尸 STP(PreSubmitted 永不变更)+ `has_open_order` 造成"假卡死"**:被风控拒掉/IB 端失同步的 STP 会以 PreSubmitted 卡在 `ib.openTrades()` 里(isDone()=False),`has_open_order` 永远返回 True → `_place_stop` 静默 skip,且 `_raise_stop_if_needed` 只在 target>current 时撤单重挂,价格不符的僵尸单永不处理 → bot 看起来"卡死"(无日志,持仓无有效 STP)。症状:`reqAllOpenOrdersAsync`(跨 clientId)可见这些单,`cancelOrder` 报 Error 10147 取不掉。恢复:`reqGlobalCancel()` 清掉全部僵尸单后重启。防御(08-03 修复):`_raise_stop_if_needed` 改为 **auxPrice 与目标价不匹配(容差 <0.01)就撤单重挂**,不只处理上移。另:`has_open_order` 只看 `openTrades()`(本 clientId 自己的单),`reqOpenOrdersAsync` 返回本 clientId 的订单,诊断全账户订单要用 `reqAllOpenOrdersAsync`。
|
||||||
|
- **Cancelled STP 必须当轮重挂**:`_sync_stop_orders` 消费终态订单后若 owned 且未成交,**同一周期**就 `_place_stop`,不要拖到下轮(旧代码 `continue` 后依赖下一轮 by_ref 找不到已 consumed 的单才重挂,重启后首轮只挂 1 条就停)。08-03 修复。
|
||||||
|
- **正式目录是 `~/Desktop/DeepSeek/BotDeepSeek`**:bot 自此从该目录启动(`restart_bot.sh` 自定位)。`~/Desktop/kimi/ib-trading-bot-kimi` 仅作备份保留,勿再从那里启动;两目录代码保持一致。
|
||||||
|
|
||||||
|
## 环境特殊性
|
||||||
|
|
||||||
|
- `.venv` **无 pip/ensurepip**;装包用 `python3 -m pip install --target=.venv/lib/python3.12/site-packages <pkg>`
|
||||||
|
- pandas 3.0.3:避免使用已移除的 API(如 groupby.apply 的 include_groups);VWAP 用 groupby+cumsum 向量化实现
|
||||||
|
- 系统无 `at`,有一次性任务用 **cron + 执行后自删**(`crontab -l | grep -v <pattern> | crontab -`),参考 `close_legacy_positions.sh` / `stop_bot_eod.sh`
|
||||||
|
- 系统时区 EDT = 美股时间;cron 按本地时间
|
||||||
|
- IB Gateway 自带 JRE 17(`~/.local/share/i4j_jres/`);`~/jdk`(Temurin 21)是备用,`start_gateway.sh` 引用它
|
||||||
|
- Gateway API:clientId=1 给 bot;临时只读检查用 clientId=77,查完即断开
|
||||||
|
|
||||||
|
## 安全红线
|
||||||
|
|
||||||
|
- **这是实盘账户**:任何测试不得真实下单。验证用离线测试(纯函数:指标/状态/K线工具),参考做法:构造合成数据测 `_calc_rsi/_calc_adx/_calc_vwap`、`PositionTracker`、`to_completed_df`
|
||||||
|
- 不要为了"测试"运行 `main.py`——它会下真实订单
|
||||||
|
- 修改策略逻辑后:先 `py_compile`,再跑离线测试,最后才考虑重启 bot
|
||||||
|
|
||||||
|
## 日常操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start_gateway.sh # 启动 IB Gateway(GUI,需登录状态)
|
||||||
|
./restart_bot.sh # 重启 bot(杀旧进程 + nohup 启动)
|
||||||
|
tail -f trading_bot.log # 运行日志
|
||||||
|
cat bot_state.json # 当前策略持仓归属与成本
|
||||||
|
```
|
||||||
|
|
||||||
|
一次性定时任务示例(cron):`close_legacy_positions.sh`(平仓+自动启动bot)、`stop_bot_eod.sh`(15:59 收市前停机)。
|
||||||
|
|
||||||
|
## 当前状态(2026-08-03 盘中)
|
||||||
|
|
||||||
|
- **已恢复正常交易**(`sell_only=False`),标的 AMZN/AAPL/NVDA/NOK,每标的持仓价值上限 $2300
|
||||||
|
- 当前持仓(08-03 盘中):ShortTerm AMZN x7 @285.86、NVDA x9 @208.17、NOK x212 @9.3997;MAStock AAPL x6 @304.08。四笔均有 GTC STP 在交易所(clientId=1)
|
||||||
|
- 交易参数:每笔 ~$2000 折股、min_profit 1.5%、入场 2 根K线确认、止损 -2.5% STP(2026-08-03 从 -2% 放宽,依据 256 笔成交统计:61 笔亏损平仓中 82% 已控制在 -3% 内,2% 执行良好;放宽仅为减少 whipsaw,每笔风险 +$10 仍在 $150 日熔断内)、每日熔断 $150、全局持仓 12 批
|
||||||
|
- **移动止损方案A**(08-03 实现):盈利 ≥ min_profit(1.5%) 后,STP 从"入场价-2.5%"上移到"持仓期间最高收盘价-2.0%",且永不低于固定止损。`use_trailing_stop/trailing_stop_pct` 在 config 三股票策略里
|
||||||
|
- **持仓不设上限(08-05 撤销 5 天强制卖出)**:`ShortTermConfig.max_hold_days=0`(0=不限),short_term.py 仅在 `>0` 时触发 max-hold 卖出。持仓由 EMA 死叉+min_profit、硬止损、移动止损自然退出,可长期持有趋势单。
|
||||||
|
- **全局卖出冷却(08-04 实现,方案C)**:`sell_cooldown_minutes=30`(任何卖出后所有策略禁买)+ `sell_improvement_pct=0.5%`(冷却后**120分钟内**重买须比最近卖价低0.5%,08-06 由60→120)+ 登记在 `recent_sells.json`
|
||||||
|
- **硬止损成交当日禁买(08-06)**:`_mark_hard_stop_cooldown` 使硬止损成交后该标的冷却到**次日零时**(`_next_day_start`),防隔夜跳空止损后当天同价回补;软止损/订单失败冷却仍 30 分钟。
|
||||||
|
- **NOK 减半仓位(08-06)**:`config.symbol_trade_value_usd={"NOK": 1000.0}`,base `_trade_value_usd` 供各策略 `_order_quantity/_can_open_position` 使用,其余标的一律默认 2000。
|
||||||
|
- **MAStock 入场过滤收紧(08-06)**:ADX 门槛 25→30(`adx_min`),且新增 `require_slow_ma_slope=True`——仅当慢 MA 自身在确认窗口内上升才允许买入(防下跌趋势中金叉接刀,08-05 NOK 案例)。
|
||||||
|
- **过夜敞口观察中(2026-07-31 起)**:07-31 早盘 AAPL x5 隔夜跳空 -8.9% 止损成交(STP @265.52→实际 304.145,亏损 $148≈止损预期的4倍)。决策暂不改为 EOD 平仓,先积累 AMZN/AAPL 新 regime 样本(2-4周)统计跳空频率/实际过夜损耗,再决定是否加 EOD 强制平仓。
|
||||||
141
README.md
Normal file
141
README.md
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
# Interactive Brokers 自动交易系统
|
||||||
|
|
||||||
|
基于 Python + ib_insync 的 IB 自动化交易框架,支持多策略并行、持仓归属追踪、
|
||||||
|
自动止损、断线自动重连。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
ib-trading-bot-kimi/
|
||||||
|
├── config.py # 配置管理(dataclass + .env 环境变量)
|
||||||
|
├── connection.py # TWS/IB Gateway 连接管理(含自动重连)
|
||||||
|
├── bars.py # 共享K线订阅管理(keepUpToDate 增量推送)
|
||||||
|
├── orders.py # 下单执行(成交确认、超时撤单、防重复单)
|
||||||
|
├── state.py # 持仓归属追踪(JSON 持久化 + 启动对账)
|
||||||
|
├── main.py # 主程序入口
|
||||||
|
├── strategies/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── base.py # 策略基类
|
||||||
|
│ ├── ma_cross.py # SMA 20/50 金叉 + ADX 趋势过滤
|
||||||
|
│ ├── short_term.py # EMA 5/10 金叉 + VWAP 确认 + 最长持仓限制
|
||||||
|
│ ├── mean_reversion.py # RSI超卖/布林带下轨/急跌 抄底
|
||||||
|
│ └── forex.py # 外汇均线交叉(默认关闭)
|
||||||
|
├── bot_state.json # 运行时生成:持仓归属状态(勿手动编辑,除非迁移持仓)
|
||||||
|
├── requirements.txt
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## 策略说明
|
||||||
|
|
||||||
|
| 策略 | 入场 | 出场(每轮重检) |
|
||||||
|
|------|------|------------------|
|
||||||
|
| MAStockStrategy | 金叉 + 连续2根确认 且 ADX>25 | **交易所STP止损 -2%**;快线跌破慢线且盈利 ≥1.5% |
|
||||||
|
| ShortTermMAVWAP | EMA金叉 + 连续2根确认 且 价格>VWAP | **交易所STP止损 -2%**;持仓≥5天;死叉且盈利 ≥1.5% |
|
||||||
|
| MeanReversion | RSI<30 / 触及布林下轨(均需连续2根)/ 5根急跌1% | **交易所STP止损 -2%**;RSI>70 / 回到布林中轨 / 反弹1.5%(需盈利≥1.5%) |
|
||||||
|
| ForexMAStrategy | 快线金叉慢线 | **软止损 -1%**;死叉 |
|
||||||
|
|
||||||
|
**交易规模**:每笔按 `trade_value_usd`(默认 $2000)按信号价折算股数
|
||||||
|
(如 $600 的股买 3 股,$95 的股买 21 股),每策略每标的最多一笔。
|
||||||
|
**入场确认**:交叉类信号要求交叉后下一根 K 线仍同向,超卖类信号要求
|
||||||
|
连续 2 根 K 线满足条件,过滤单根毛刺、降低交易频率。
|
||||||
|
|
||||||
|
所有策略共用 2 只标的(AMZN、AAPL),但**各自独立管理自己的持仓**(见下文"持仓归属")。
|
||||||
|
|
||||||
|
## 风控机制
|
||||||
|
|
||||||
|
1. **交易所硬止损(主)+ 软止损(兜底)**:每笔买入成交后,立即在交易所挂
|
||||||
|
**GTC 止损卖单(STP)**(`stop_loss_pct`,股票默认 -2%),`outsideRth=True` 盘后/隔夜
|
||||||
|
同样有效——即使 bot 停止、断网、电脑关机,止损仍由交易所执行。bot 每 60 秒同步
|
||||||
|
止损单状态:成交→更新持仓归属;被撤/丢失→自动补挂;重启→自动认领。策略正常卖出前
|
||||||
|
会先撤掉对应止损单。若止损单因故不存在(如挂单被拒),原"软止损"(每 60 秒检查
|
||||||
|
收盘价)自动兜底。
|
||||||
|
⚠️ STP 触发后变成市价单,快速行情中成交价可能略低于止损价(滑点)。
|
||||||
|
2. **持仓归属**:`bot_state.json` 记录每笔持仓属于哪个策略。策略只卖自己的持仓,
|
||||||
|
不会互相平仓。启动/重连时自动与账户真实持仓对账:
|
||||||
|
- 状态中有、账户没有 → 清除(说明已被手动平仓或断线期间成交)
|
||||||
|
- 账户中有、状态没有 → 标记为 **UNMANAGED**(bot 永不会卖,需手动处理或迁移)
|
||||||
|
3. **最低盈利限制解除阻塞**:死叉卖出被盈利条件阻止后,每轮都会重检,
|
||||||
|
盈利达标即卖;同时止损兜底,不会无限套牢。
|
||||||
|
3b. **全局持仓上限**:`max_positions`(默认 12 批)限制所有策略的并发持仓总数,
|
||||||
|
控制最大敞口约 $24k(12 × $2000)。达上限后新买入被跳过(日志记录 SKIPPED),
|
||||||
|
已有持仓正常管理退出,不会被强制平仓。
|
||||||
|
3c. **每日亏损熔断**:当日已实现亏损达到 `max_daily_loss`(默认 $150)后,
|
||||||
|
当天禁止开新仓(次日自动重置,已有持仓正常退出)。
|
||||||
|
3d. **止损冷却期**:同一策略同一标的被止损后 30 分钟内禁止重买,
|
||||||
|
避免下跌通道中"买入→止损→再买"的连续失血;下单被拒/超时同样进入冷却。
|
||||||
|
3e. **MeanRev 趋势过滤**:仅在收盘价高于 SMA50 时允许抄底,不接下跌中的飞刀。
|
||||||
|
4. **完整K线信号**:信号只用已收盘的K线计算,避免未收完K线导致的信号闪烁。
|
||||||
|
5. **休市禁交易**:通过最新K线时间判断市场是否开盘,收盘/周末/节假日自动暂停信号。
|
||||||
|
6. **订单确认**:下单后等待成交(默认30秒),超时自动撤单;同一策略同一标的
|
||||||
|
有未成交单时不会重复下单。每笔订单带 `orderRef`(如 `MAStockStrategy:META`)方便在 TWS 中识别来源。
|
||||||
|
7. **增量K线订阅**:每个标的只订阅一次 `keepUpToDate` 实时K线流,三个策略共享,
|
||||||
|
替代原来每分钟全量拉取(原来 5 小时约 845MB 流量)。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
复制 `.env.example` 为 `.env` 后编辑(程序自动加载 `.env`):
|
||||||
|
|
||||||
|
| 变量 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `IB_HOST` | `127.0.0.1` | IB Gateway 地址 |
|
||||||
|
| `IB_PORT` | `4001` | **4001=Gateway实盘**, 4002=Gateway模拟, 7496=TWS实盘, 7497=TWS模拟 |
|
||||||
|
| `IB_CLIENT_ID` | `1` | 客户端唯一 ID |
|
||||||
|
| `IB_ACCOUNT` | `U4845070` | 指定下单账户 |
|
||||||
|
| `IB_PAPER` | `false` | 仅用于启动日志警示;真实模式由端口/Gateway登录类型决定 |
|
||||||
|
|
||||||
|
策略参数在 `config.py` 中调整:`symbols`、`fast_ma_period`、`slow_ma_period`、
|
||||||
|
`trade_quantity`、`stop_loss_pct`、`min_profit_pct` 等。
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 启动 IB Gateway(脚本会自动准备 JDK;注意 /tmp 重启后会被清空)
|
||||||
|
./start_gateway.sh
|
||||||
|
# 在 Gateway 中登录,并确认 API 设置:Enable ActiveX and Socket Clients、
|
||||||
|
# 端口、Trusted IPs 加入 127.0.0.1
|
||||||
|
|
||||||
|
# 2. 启动 bot(前台调试)
|
||||||
|
.venv/bin/python main.py
|
||||||
|
|
||||||
|
# 或后台重启(会自动先杀掉旧进程)
|
||||||
|
./restart_bot.sh
|
||||||
|
tail -f trading_bot.log
|
||||||
|
|
||||||
|
# 3. 查看当天成交明细与盈亏(可指定日期)
|
||||||
|
./report.sh [YYYY-MM-DD]
|
||||||
|
```
|
||||||
|
|
||||||
|
启动时如为实盘模式,日志会有醒目的 `LIVE TRADING MODE` 警示。
|
||||||
|
|
||||||
|
## ⚠️ 从旧版本迁移(重要)
|
||||||
|
|
||||||
|
旧版本没有持仓归属记录。如果升级时账户里还有旧 bot 开的仓位,新 bot
|
||||||
|
会把它们标记为 **UNMANAGED 并永远不管**(不会止损、不会止盈)。两个选择:
|
||||||
|
|
||||||
|
1. **手动平掉旧仓位**后再启动新 bot(推荐,最干净);
|
||||||
|
2. 或手动编辑 `bot_state.json` 把旧仓位归属到某个策略,例如:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"MAStockStrategy": {
|
||||||
|
"NVDA": {"quantity": 1, "entry_price": 207.33, "entry_date": "2026-07-20"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:归属后该仓位立即受止损/止盈规则约束,若浮亏已超过 `stop_loss_pct`,
|
||||||
|
启动后第一轮检查就会被止损卖出。
|
||||||
|
|
||||||
|
### 一次性迁移任务(2026-07-22 已排期)
|
||||||
|
|
||||||
|
`close_legacy_positions.py` + cron 实现无人值守迁移:开盘后(09:45 ET)
|
||||||
|
自动卖出 5 笔旧仓位(NVDA/META/AVGO/MSFT/PLTR 各 1 股),**成功后才自动
|
||||||
|
启动新 bot**;失败则不启动(避免遗留 UNMANAGED 仓位),日志见
|
||||||
|
`close_legacy_positions.log`。该 cron 任务执行后自动删除。
|
||||||
|
|
||||||
|
## 安全警告
|
||||||
|
|
||||||
|
- 当前配置为**实盘模式**(4001 端口),下单即真实成交
|
||||||
|
- 策略有止损,但软止损依赖程序持续运行;请保证机器、网络、Gateway 稳定
|
||||||
|
- 建议定期查看 `trading_bot.log` 和 `bot_state.json`
|
||||||
|
- 三个策略对同一标的最多各持 1 份(共 3 份),请确认账户风险敞口可接受
|
||||||
127
bars.py
Normal file
127
bars.py
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from ib_insync import IB, Contract
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_UNIT_SECONDS = {
|
||||||
|
"sec": 1,
|
||||||
|
"min": 60,
|
||||||
|
"hour": 3600,
|
||||||
|
"day": 86400,
|
||||||
|
"week": 604800,
|
||||||
|
"month": 2592000,
|
||||||
|
}
|
||||||
|
|
||||||
|
def parse_bar_size_seconds(bar_size: str) -> int:
|
||||||
|
"""Parse IB bar size strings like '1 min', '5 secs', '1 hour' to seconds."""
|
||||||
|
m = re.match(r"^\s*(\d+)\s*([a-zA-Z]+)\s*$", bar_size)
|
||||||
|
if not m:
|
||||||
|
raise ValueError(f"Unsupported bar size: {bar_size!r}")
|
||||||
|
n, unit = int(m.group(1)), m.group(2).lower()
|
||||||
|
for prefix, seconds in _UNIT_SECONDS.items():
|
||||||
|
if unit.startswith(prefix):
|
||||||
|
return n * seconds
|
||||||
|
raise ValueError(f"Unsupported bar size unit: {bar_size!r}")
|
||||||
|
|
||||||
|
class BarManager:
|
||||||
|
"""Maintains one live (keepUpToDate) historical bar subscription per
|
||||||
|
(contract, bar size, data type), shared by all strategies.
|
||||||
|
|
||||||
|
This replaces per-cycle full history downloads: after the initial fetch,
|
||||||
|
IB pushes incremental updates, cutting traffic dramatically.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ib: IB):
|
||||||
|
self.ib = ib
|
||||||
|
# key -> BarDataList (ready) or asyncio.Task (subscription in flight).
|
||||||
|
# Storing the in-flight task makes concurrent subscribe() calls for the
|
||||||
|
# same key share one request instead of firing duplicate subscriptions.
|
||||||
|
self._bars: dict[tuple, object] = {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _key(contract: Contract, bar_size: str, what_to_show: str) -> tuple:
|
||||||
|
return (contract.conId, bar_size, what_to_show)
|
||||||
|
|
||||||
|
async def subscribe(self, contract: Contract, bar_size: str, duration: str,
|
||||||
|
what_to_show: str, use_rth: bool = True):
|
||||||
|
|
||||||
|
key = self._key(contract, bar_size, what_to_show)
|
||||||
|
existing = self._bars.get(key)
|
||||||
|
if existing is not None:
|
||||||
|
if isinstance(existing, asyncio.Task):
|
||||||
|
return await existing # share the in-flight request
|
||||||
|
return existing
|
||||||
|
|
||||||
|
task = asyncio.create_task(self.ib.reqHistoricalDataAsync(
|
||||||
|
contract=contract,
|
||||||
|
endDateTime="",
|
||||||
|
durationStr=duration,
|
||||||
|
barSizeSetting=bar_size,
|
||||||
|
whatToShow=what_to_show,
|
||||||
|
useRTH=use_rth,
|
||||||
|
formatDate=2, # epoch seconds -> unambiguous UTC timestamps
|
||||||
|
keepUpToDate=True,
|
||||||
|
))
|
||||||
|
self._bars[key] = task
|
||||||
|
try:
|
||||||
|
bars = await task
|
||||||
|
except Exception:
|
||||||
|
self._bars.pop(key, None) # allow retry on failure
|
||||||
|
raise
|
||||||
|
self._bars[key] = bars
|
||||||
|
logger.info(
|
||||||
|
"Bar subscription started: conId=%s %s %s (%d bars)",
|
||||||
|
contract.conId, bar_size, what_to_show, len(bars),
|
||||||
|
)
|
||||||
|
return bars
|
||||||
|
|
||||||
|
def get(self, contract: Contract, bar_size: str, what_to_show: str):
|
||||||
|
|
||||||
|
v = self._bars.get(self._key(contract, bar_size, what_to_show))
|
||||||
|
return None if isinstance(v, asyncio.Task) else v
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Cancel all live subscriptions (e.g. after a disconnect)."""
|
||||||
|
|
||||||
|
for key, v in self._bars.items():
|
||||||
|
try:
|
||||||
|
if isinstance(v, asyncio.Task):
|
||||||
|
v.cancel()
|
||||||
|
else:
|
||||||
|
self.ib.cancelHistoricalData(v)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self._bars:
|
||||||
|
logger.info("Bar subscriptions cleared (%d)", len(self._bars))
|
||||||
|
self._bars.clear()
|
||||||
|
|
||||||
|
def to_completed_df(bars, bar_seconds: int) -> pd.DataFrame:
|
||||||
|
"""Convert bars to a DataFrame containing only completed bars.
|
||||||
|
|
||||||
|
The last bar of a live subscription is still forming; signals computed on
|
||||||
|
it would repaint, so it is dropped until it closes.
|
||||||
|
"""
|
||||||
|
if not bars:
|
||||||
|
return pd.DataFrame()
|
||||||
|
df = pd.DataFrame(bars)
|
||||||
|
df["date"] = pd.to_datetime(df["date"], utc=True)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if len(df) and df["date"].iloc[-1] + timedelta(seconds=bar_seconds) > now:
|
||||||
|
df = df.iloc[:-1]
|
||||||
|
return df
|
||||||
|
|
||||||
|
def last_bar_age_seconds(df: pd.DataFrame) -> float:
|
||||||
|
"""Age of the most recent completed bar. Large age => market closed/stale."""
|
||||||
|
if df.empty:
|
||||||
|
return float("inf")
|
||||||
|
return (datetime.now(timezone.utc) - df["date"].iloc[-1]).total_seconds()
|
||||||
|
|
||||||
|
def is_market_active(df: pd.DataFrame, bar_seconds: int, factor: int = 5) -> bool:
|
||||||
|
"""Data-driven market-hours check: if the latest completed bar is too old,
|
||||||
|
the market is closed (overnight/weekend/holiday) and we must not trade."""
|
||||||
|
return last_bar_age_seconds(df) <= bar_seconds * factor
|
||||||
120
close_legacy_positions.py
Normal file
120
close_legacy_positions.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"""One-shot task: close the legacy (pre-refactor) positions after market open.
|
||||||
|
|
||||||
|
Sells up to LEGACY_QTY shares of each legacy symbol with market orders,
|
||||||
|
waits for fill confirmation, then verifies the account is flat for them.
|
||||||
|
|
||||||
|
Scheduled via cron for 2026-07-22 09:45 ET; also safe to run manually:
|
||||||
|
.venv/bin/python close_legacy_positions.py
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
from connection import ib_conn
|
||||||
|
from orders import create_stock_contract, execute_market_order
|
||||||
|
|
||||||
|
# legacy positions observed in trading_bot.log on 2026-07-21
|
||||||
|
LEGACY_QTY = {"NVDA": 1, "META": 1, "AVGO": 1, "MSFT": 1, "PLTR": 1}
|
||||||
|
|
||||||
|
CONNECT_RETRY_SECONDS = 30
|
||||||
|
CONNECT_DEADLINE_MINUTES = 60 # gateway might be started a bit late
|
||||||
|
ORDER_TIMEOUT = 60
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
logging.getLogger("ib_insync.wrapper").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("ib_insync.client").setLevel(logging.WARNING)
|
||||||
|
logger = logging.getLogger("close_legacy")
|
||||||
|
|
||||||
|
|
||||||
|
async def connect_with_retries() -> bool:
|
||||||
|
deadline = datetime.now() + timedelta(minutes=CONNECT_DEADLINE_MINUTES)
|
||||||
|
attempt = 0
|
||||||
|
while datetime.now() < deadline:
|
||||||
|
attempt += 1
|
||||||
|
if await ib_conn.ensure_connected():
|
||||||
|
logger.info("Connected on attempt %d", attempt)
|
||||||
|
return True
|
||||||
|
logger.warning(
|
||||||
|
"Connect attempt %d failed, retrying in %ds (giving up at %s)...",
|
||||||
|
attempt, CONNECT_RETRY_SECONDS, deadline.strftime("%H:%M"),
|
||||||
|
)
|
||||||
|
await asyncio.sleep(CONNECT_RETRY_SECONDS)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> int:
|
||||||
|
logger.warning("=" * 60)
|
||||||
|
logger.warning("LEGACY POSITION CLEANUP - LIVE account %s", config.ib.account)
|
||||||
|
logger.warning("Will market-SELL up to: %s", LEGACY_QTY)
|
||||||
|
logger.warning("=" * 60)
|
||||||
|
|
||||||
|
if not await connect_with_retries():
|
||||||
|
logger.error(
|
||||||
|
"Could not connect to IB Gateway within %d minutes - aborting. "
|
||||||
|
"Is the Gateway running and logged in?",
|
||||||
|
CONNECT_DEADLINE_MINUTES,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
ib = ib_conn.ib
|
||||||
|
accounts = ib.managedAccounts()
|
||||||
|
logger.info("Managed accounts: %s", accounts)
|
||||||
|
if config.ib.account and config.ib.account not in accounts:
|
||||||
|
logger.error(
|
||||||
|
"Configured account %s not in managed accounts %s - aborting",
|
||||||
|
config.ib.account, accounts,
|
||||||
|
)
|
||||||
|
ib_conn.disconnect()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
positions: dict[str, float] = {}
|
||||||
|
for p in ib.positions():
|
||||||
|
if p.contract.secType == "STK":
|
||||||
|
positions[p.contract.symbol] = positions.get(p.contract.symbol, 0) + p.position
|
||||||
|
logger.info("Current stock positions: %s", positions)
|
||||||
|
|
||||||
|
failures = 0
|
||||||
|
for symbol, expected_qty in LEGACY_QTY.items():
|
||||||
|
actual = positions.get(symbol, 0)
|
||||||
|
sell_qty = min(actual, expected_qty)
|
||||||
|
if sell_qty <= 0:
|
||||||
|
logger.info("%s: no legacy position (actual=%g), skipping", symbol, actual)
|
||||||
|
continue
|
||||||
|
contract = create_stock_contract(symbol)
|
||||||
|
await ib.qualifyContractsAsync(contract)
|
||||||
|
trade = await execute_market_order(
|
||||||
|
ib, contract, "SELL", sell_qty, f"LegacyCleanup:{symbol}", timeout=ORDER_TIMEOUT
|
||||||
|
)
|
||||||
|
if trade and trade.orderStatus.filled > 0:
|
||||||
|
logger.info(
|
||||||
|
"%s: SOLD %g @ %.2f", symbol,
|
||||||
|
trade.orderStatus.filled, trade.orderStatus.avgFillPrice,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error("%s: SELL FAILED", symbol)
|
||||||
|
failures += 1
|
||||||
|
|
||||||
|
# verify the account is flat for the legacy symbols
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
remaining = {
|
||||||
|
p.contract.symbol: p.position
|
||||||
|
for p in ib.positions()
|
||||||
|
if p.contract.secType == "STK" and p.contract.symbol in LEGACY_QTY and p.position > 0
|
||||||
|
}
|
||||||
|
if remaining:
|
||||||
|
logger.error("Cleanup incomplete, remaining positions: %s", remaining)
|
||||||
|
else:
|
||||||
|
logger.info("All legacy positions closed.")
|
||||||
|
|
||||||
|
ib_conn.disconnect()
|
||||||
|
return 1 if (failures or remaining) else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(asyncio.run(main()))
|
||||||
33
close_legacy_positions.sh
Executable file
33
close_legacy_positions.sh
Executable file
@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# One-shot scheduled task: close legacy positions, then start the new trading bot.
|
||||||
|
# Removes its own cron entry after running.
|
||||||
|
BOT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
LOG="$BOT_DIR/close_legacy_positions.log"
|
||||||
|
BOT_LOG="$BOT_DIR/trading_bot.log"
|
||||||
|
|
||||||
|
if pgrep -f "python.*main\.py" > /dev/null; then
|
||||||
|
echo "$(date) ABORT: trading bot is already running; skipping cleanup" >> "$LOG"
|
||||||
|
else
|
||||||
|
echo "=== $(date) legacy cleanup start ===" >> "$LOG"
|
||||||
|
"$BOT_DIR/.venv/bin/python" "$BOT_DIR/close_legacy_positions.py" >> "$LOG" 2>&1
|
||||||
|
RC=$?
|
||||||
|
echo "=== $(date) legacy cleanup done (exit=$RC) ===" >> "$LOG"
|
||||||
|
|
||||||
|
if [ "$RC" -eq 0 ]; then
|
||||||
|
echo "=== $(date) cleanup OK, starting new trading bot ===" >> "$LOG"
|
||||||
|
setsid nohup "$BOT_DIR/.venv/bin/python" "$BOT_DIR/main.py" >> "$BOT_LOG" 2>&1 < /dev/null &
|
||||||
|
BOT_PID=$!
|
||||||
|
sleep 8
|
||||||
|
if kill -0 "$BOT_PID" 2>/dev/null; then
|
||||||
|
echo "=== bot started successfully (PID: $BOT_PID) ===" >> "$LOG"
|
||||||
|
else
|
||||||
|
echo "=== bot FAILED to start, check $BOT_LOG ===" >> "$LOG"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "=== $(date) cleanup FAILED (exit=$RC) - bot NOT started." >> "$LOG"
|
||||||
|
echo "=== Fix manually (run: .venv/bin/python close_legacy_positions.py), then ./restart_bot.sh ===" >> "$LOG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# self-remove from crontab (one-shot task)
|
||||||
|
crontab -l 2>/dev/null | grep -v "close_legacy_positions" | crontab -
|
||||||
145
config.py
Normal file
145
config.py
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
_PROJECT_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IBConfig:
|
||||||
|
host: str = os.getenv("IB_HOST", "127.0.0.1")
|
||||||
|
port: int = int(os.getenv("IB_PORT", "4001"))
|
||||||
|
client_id: int = int(os.getenv("IB_CLIENT_ID", "1"))
|
||||||
|
account: Optional[str] = os.getenv("IB_ACCOUNT", "U4845070")
|
||||||
|
is_paper: bool = os.getenv("IB_PAPER", "false").lower() == "true"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def port_label(self) -> str:
|
||||||
|
if self.port == 4002:
|
||||||
|
return "Gateway Paper (4002)"
|
||||||
|
elif self.port == 4001:
|
||||||
|
return "Gateway Live (4001)"
|
||||||
|
elif self.port == 7497:
|
||||||
|
return "TWS Paper (7497)"
|
||||||
|
elif self.port == 7496:
|
||||||
|
return "TWS Live (7496)"
|
||||||
|
return f"Unknown ({self.port})"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StockStrategyConfig:
|
||||||
|
enabled: bool = True
|
||||||
|
symbols: list[str] = field(default_factory=lambda: ["AMZN", "AAPL", "NVDA", "NOK"])
|
||||||
|
currency: str = "USD"
|
||||||
|
exchange: str = "SMART"
|
||||||
|
bar_size: str = "1 min"
|
||||||
|
lookback_days: int = 5
|
||||||
|
fast_ma_period: int = 20
|
||||||
|
slow_ma_period: int = 50
|
||||||
|
trade_value_usd: float = 2000.0 # per-trade dollar amount; qty = floor(value / price)
|
||||||
|
min_profit_pct: float = 1.5
|
||||||
|
stop_loss_pct: float = 2.5
|
||||||
|
use_hard_stop: bool = True # place real GTC STP orders at the exchange
|
||||||
|
entry_confirm_bars: int = 2 # entry signal must hold for N consecutive bars
|
||||||
|
stop_cooldown_minutes: int = 30 # no re-entry on same symbol for N min after a stop-out
|
||||||
|
use_trailing_stop: bool = True # once profit >= min_profit, trail the stop below the peak high
|
||||||
|
trailing_stop_pct: float = 2.0 # trailing stop distance below peak high
|
||||||
|
adx_min: float = 30.0 # ADX trend filter for entries (was 25; 08-06 raised to avoid weak-trend whipsaw)
|
||||||
|
require_slow_ma_slope: bool = True # only enter if the slow MA is itself rising (no knife-catching in a downtrend) high
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ShortTermConfig:
|
||||||
|
enabled: bool = True
|
||||||
|
symbols: list[str] = field(default_factory=lambda: ["AMZN", "AAPL", "NVDA", "NOK"])
|
||||||
|
currency: str = "USD"
|
||||||
|
exchange: str = "SMART"
|
||||||
|
bar_size: str = "1 min"
|
||||||
|
lookback_days: int = 5
|
||||||
|
fast_ma_period: int = 5
|
||||||
|
slow_ma_period: int = 10
|
||||||
|
trade_value_usd: float = 2000.0
|
||||||
|
max_hold_days: int = 0 # 0 = unlimited hold (08-05 撤销 5 天上限)
|
||||||
|
min_profit_pct: float = 1.5
|
||||||
|
stop_loss_pct: float = 2.5
|
||||||
|
use_hard_stop: bool = True
|
||||||
|
entry_confirm_bars: int = 2
|
||||||
|
stop_cooldown_minutes: int = 30
|
||||||
|
use_trailing_stop: bool = True
|
||||||
|
trailing_stop_pct: float = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ForexStrategyConfig:
|
||||||
|
enabled: bool = False
|
||||||
|
pairs: list[str] = field(default_factory=lambda: ["EUR.USD", "GBP.USD"])
|
||||||
|
exchange: str = "IDEALPRO"
|
||||||
|
bar_size: str = "1 min"
|
||||||
|
lookback_days: int = 5
|
||||||
|
fast_ma_period: int = 12
|
||||||
|
slow_ma_period: int = 26
|
||||||
|
trade_units: int = 3000
|
||||||
|
stop_loss_pct: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MeanReversionConfig:
|
||||||
|
enabled: bool = True
|
||||||
|
symbols: list[str] = field(default_factory=lambda: ["AMZN", "AAPL", "NVDA", "NOK"])
|
||||||
|
currency: str = "USD"
|
||||||
|
exchange: str = "SMART"
|
||||||
|
bar_size: str = "1 min"
|
||||||
|
lookback_days: int = 5
|
||||||
|
trade_value_usd: float = 2000.0
|
||||||
|
rsi_period: int = 14
|
||||||
|
rsi_oversold: float = 30.0
|
||||||
|
rsi_overbought: float = 70.0
|
||||||
|
bb_period: int = 20
|
||||||
|
bb_std: float = 2.0
|
||||||
|
rapid_drop_pct: float = 1.0
|
||||||
|
rapid_drop_bars: int = 5
|
||||||
|
recovery_pct: float = 1.5
|
||||||
|
min_profit_pct: float = 1.5
|
||||||
|
stop_loss_pct: float = 2.5
|
||||||
|
use_hard_stop: bool = True
|
||||||
|
entry_confirm_bars: int = 2
|
||||||
|
stop_cooldown_minutes: int = 30
|
||||||
|
use_trailing_stop: bool = True
|
||||||
|
trailing_stop_pct: float = 2.0
|
||||||
|
trend_ma_period: int = 50 # mean-reversion buys only above this SMA
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppConfig:
|
||||||
|
ib: IBConfig = field(default_factory=IBConfig)
|
||||||
|
stock: StockStrategyConfig = field(default_factory=StockStrategyConfig)
|
||||||
|
short_term: ShortTermConfig = field(default_factory=ShortTermConfig)
|
||||||
|
forex: ForexStrategyConfig = field(default_factory=ForexStrategyConfig)
|
||||||
|
mean_reversion: MeanReversionConfig = field(default_factory=MeanReversionConfig)
|
||||||
|
max_retries: int = 5
|
||||||
|
retry_delay: int = 5
|
||||||
|
loop_interval: float = 60.0
|
||||||
|
order_timeout: float = 30.0
|
||||||
|
max_positions: int = 12 # global cap on concurrent open lots across all strategies
|
||||||
|
max_daily_loss: float = 150.0 # stop opening new positions for the day beyond this realized loss
|
||||||
|
sell_only: bool = False # liquidation mode: no new buys, strategies only manage exits
|
||||||
|
max_symbol_value_usd: float = 2300.0 # per-symbol total position value cap (all strategies combined)
|
||||||
|
sell_cooldown_minutes: int = 30 # global: no strategy may (re)buy a symbol within N min of ANY sell
|
||||||
|
sell_improvement_pct: float = 0.5 # beyond the cooldown, re-buy only if price is >=0.5% below last sell
|
||||||
|
sell_improvement_window_minutes: int = 120 # how long the price-improvement rule applies after a sell
|
||||||
|
symbol_trade_value_usd: dict[str, float] = field(
|
||||||
|
# per-symbol per-trade dollar value override (halved for gap-prone NOK, 08-06)
|
||||||
|
default_factory=lambda: {"NOK": 1000.0}
|
||||||
|
)
|
||||||
|
# absolute path: independent of the working directory the bot is started from
|
||||||
|
state_file: str = str(_PROJECT_DIR / "bot_state.json")
|
||||||
|
|
||||||
|
|
||||||
|
config = AppConfig()
|
||||||
56
connection.py
Normal file
56
connection.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ib_insync import IB
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class IBConnection:
|
||||||
|
def __init__(self):
|
||||||
|
self.ib = IB()
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
if self._connected:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
await self.ib.connectAsync(
|
||||||
|
host=config.ib.host,
|
||||||
|
port=config.ib.port,
|
||||||
|
clientId=config.ib.client_id,
|
||||||
|
account=config.ib.account or "",
|
||||||
|
)
|
||||||
|
self._connected = True
|
||||||
|
logger.info(
|
||||||
|
"Connected to IB Gateway at %s:%s (%s)",
|
||||||
|
config.ib.host, config.ib.port, config.ib.port_label,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Connection failed: %s", e)
|
||||||
|
self._connected = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
if self._connected:
|
||||||
|
self.ib.disconnect()
|
||||||
|
self._connected = False
|
||||||
|
logger.info("Disconnected")
|
||||||
|
|
||||||
|
async def reconnect(self) -> bool:
|
||||||
|
self.disconnect()
|
||||||
|
return await self.connect()
|
||||||
|
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
return self._connected and self.ib.isConnected()
|
||||||
|
|
||||||
|
async def ensure_connected(self) -> bool:
|
||||||
|
if not self.is_connected():
|
||||||
|
return await self.reconnect()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
ib_conn = IBConnection()
|
||||||
151
daily_report.py
Normal file
151
daily_report.py
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Daily trade report: today's fills + realized P&L from the trade ledger.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
.venv/bin/python daily_report.py [YYYY-MM-DD]
|
||||||
|
./report.sh [YYYY-MM-DD]
|
||||||
|
|
||||||
|
Data source: trades.jsonl (append-only ledger written by PositionTracker).
|
||||||
|
P&L method: FIFO per (strategy, symbol). Current open lots shown at the end;
|
||||||
|
if the IB Gateway is reachable, live market values are included (read-only).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
LEDGER = Path(os.environ.get("TRADES_LEDGER", Path(__file__).resolve().parent / "trades.jsonl"))
|
||||||
|
|
||||||
|
STRATEGY_SHORT = {
|
||||||
|
"MAStockStrategy": "MAStock",
|
||||||
|
"ShortTermMAVWAPStrategy": "ShortTerm",
|
||||||
|
"MeanReversionStrategy": "MeanRev",
|
||||||
|
"ForexMAStrategy": "Forex",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def short(name: str) -> str:
|
||||||
|
return STRATEGY_SHORT.get(name, name[:12])
|
||||||
|
|
||||||
|
|
||||||
|
def load_and_replay():
|
||||||
|
"""Replay the whole ledger; return (today fills, realized maps, open lots)."""
|
||||||
|
lots = defaultdict(deque) # (strategy, symbol) -> deque([qty, price])
|
||||||
|
fills_today = [] # display rows for the requested day
|
||||||
|
realized_sym = defaultdict(float)
|
||||||
|
realized_strat = defaultdict(float)
|
||||||
|
unknown_cost = 0.0
|
||||||
|
day = sys.argv[1] if len(sys.argv) > 1 else date.today().isoformat()
|
||||||
|
|
||||||
|
for line in LEDGER.read_text().splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
r = json.loads(line)
|
||||||
|
key = (r["strategy"], r["symbol"])
|
||||||
|
ts_day, ts_time = r["ts"][:10], r["ts"][11:19]
|
||||||
|
|
||||||
|
if r["type"] in ("seed", "buy"):
|
||||||
|
lots[key].append([r["qty"], r["price"]])
|
||||||
|
if r["type"] == "buy" and ts_day == day:
|
||||||
|
fills_today.append((ts_time, r["strategy"], r["symbol"], "BUY", r["qty"], r["price"], None))
|
||||||
|
|
||||||
|
elif r["type"] in ("sell", "sell_external"):
|
||||||
|
pnl = 0.0
|
||||||
|
remain = r["qty"]
|
||||||
|
dq = lots[key]
|
||||||
|
while remain > 1e-9 and dq:
|
||||||
|
take = min(dq[0][0], remain)
|
||||||
|
pnl += (r["price"] - dq[0][1]) * take
|
||||||
|
dq[0][0] -= take
|
||||||
|
remain -= take
|
||||||
|
if dq[0][0] <= 1e-9:
|
||||||
|
dq.popleft()
|
||||||
|
if remain > 1e-9:
|
||||||
|
pnl = None # cost basis unknown (lot predates the ledger)
|
||||||
|
if ts_day == day:
|
||||||
|
action = "SELL*" if r.get("est") else "SELL"
|
||||||
|
fills_today.append((ts_time, r["strategy"], r["symbol"], action, r["qty"], r["price"], pnl))
|
||||||
|
if pnl is not None:
|
||||||
|
realized_sym[r["symbol"]] += pnl
|
||||||
|
realized_strat[r["strategy"]] += pnl
|
||||||
|
else:
|
||||||
|
unknown_cost += r["qty"]
|
||||||
|
|
||||||
|
return day, fills_today, realized_sym, realized_strat, lots, unknown_cost
|
||||||
|
|
||||||
|
|
||||||
|
def live_positions():
|
||||||
|
"""Read-only: current positions + market values from IB. None if unreachable."""
|
||||||
|
try:
|
||||||
|
import asyncio
|
||||||
|
from ib_insync import IB
|
||||||
|
from config import config as cfg
|
||||||
|
|
||||||
|
async def _fetch():
|
||||||
|
ib = IB()
|
||||||
|
await ib.connectAsync(cfg.ib.host, cfg.ib.port, clientId=77, timeout=10)
|
||||||
|
data = {
|
||||||
|
item.contract.symbol: (item.position, item.marketPrice, item.unrealizedPNL)
|
||||||
|
for item in ib.portfolio()
|
||||||
|
if item.contract.secType == "STK" and item.position > 0
|
||||||
|
}
|
||||||
|
ib.disconnect()
|
||||||
|
return data
|
||||||
|
|
||||||
|
return asyncio.run(_fetch())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not LEDGER.exists():
|
||||||
|
print("还没有交易记录(trades.jsonl 不存在)")
|
||||||
|
return
|
||||||
|
|
||||||
|
day, fills, realized_sym, realized_strat, lots, unknown_cost = load_and_replay()
|
||||||
|
|
||||||
|
print(f"========== {day} 成交明细 ==========")
|
||||||
|
if not fills:
|
||||||
|
print(" 当天无成交")
|
||||||
|
for ts, strat, sym, action, qty, price, pnl in fills:
|
||||||
|
line = f"{ts} {short(strat):10s} {sym:6s} {action:4s} x{qty:g} @ {price:>10.2f}"
|
||||||
|
if pnl is not None:
|
||||||
|
line += f" 盈亏 {pnl:+.2f}"
|
||||||
|
elif action == "SELL":
|
||||||
|
line += " 盈亏 ? (成本未知)"
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
print(f"\n========== {day} 已实现盈亏 ==========")
|
||||||
|
if realized_sym:
|
||||||
|
print(" 按标的:")
|
||||||
|
for sym, v in sorted(realized_sym.items()):
|
||||||
|
print(f" {sym:6s} {v:+10.2f}")
|
||||||
|
if realized_strat:
|
||||||
|
print(" 按策略:")
|
||||||
|
for strat, v in sorted(realized_strat.items()):
|
||||||
|
print(f" {short(strat):10s} {v:+10.2f}")
|
||||||
|
total = sum(realized_sym.values())
|
||||||
|
print(f" 合计: {total:+.2f}")
|
||||||
|
if unknown_cost:
|
||||||
|
print(f" (另有 {unknown_cost:g} 股卖出成本未知,未计入)")
|
||||||
|
|
||||||
|
open_lots = [(s, sym, q, p) for (s, sym), dq in lots.items() for q, p in dq if q > 1e-9]
|
||||||
|
print("\n========== 当前持仓(成本价) ==========")
|
||||||
|
if not open_lots:
|
||||||
|
print(" 无")
|
||||||
|
else:
|
||||||
|
live = live_positions()
|
||||||
|
for strat, sym, qty, price in sorted(open_lots, key=lambda x: (x[1], x[0])):
|
||||||
|
line = f" {short(strat):10s} {sym:6s} x{qty:g} @ {price:>10.2f}"
|
||||||
|
if live and sym in live:
|
||||||
|
_, mkt, _ = live[sym]
|
||||||
|
line += f" 现价 {mkt:>9.2f} 浮动 {(mkt - price) * qty:+.2f}"
|
||||||
|
print(line)
|
||||||
|
if not live:
|
||||||
|
print(" (实时价格不可用)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
220
main.py
Normal file
220
main.py
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import asyncio
|
||||||
|
import faulthandler
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from bars import BarManager
|
||||||
|
from config import config
|
||||||
|
from connection import ib_conn
|
||||||
|
from state import PositionTracker
|
||||||
|
from strategies import MAStockStrategy, ForexMAStrategy, ShortTermMAVWAPStrategy, MeanReversionStrategy
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
# silence ib_insync's per-message INFO spam (portfolio updates etc.)
|
||||||
|
logging.getLogger("ib_insync.wrapper").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("ib_insync.client").setLevel(logging.WARNING)
|
||||||
|
logger = logging.getLogger("main")
|
||||||
|
|
||||||
|
# dump stack traces to stderr on SIGUSR1 (diagnostics for a hung main loop)
|
||||||
|
faulthandler.register(signal.SIGUSR1, all_threads=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TradingApp:
|
||||||
|
def __init__(self):
|
||||||
|
self.strategies: list = []
|
||||||
|
self._running = False
|
||||||
|
self._reconnect_task: asyncio.Task | None = None
|
||||||
|
self.tracker = PositionTracker(config.state_file)
|
||||||
|
self.bar_manager = BarManager(ib_conn.ib)
|
||||||
|
|
||||||
|
def setup_signal_handlers(self):
|
||||||
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
signal.signal(sig, self._signal_handler)
|
||||||
|
|
||||||
|
def _signal_handler(self, sig, frame):
|
||||||
|
logger.info("Received signal %s, shutting down...", sig)
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
retries = 0
|
||||||
|
while retries < config.max_retries:
|
||||||
|
if await ib_conn.ensure_connected():
|
||||||
|
logger.info(
|
||||||
|
"Connected to IB Gateway at %s:%s (%s)",
|
||||||
|
config.ib.host, config.ib.port, config.ib.port_label,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
retries += 1
|
||||||
|
if retries < config.max_retries:
|
||||||
|
logger.warning("Retry %d/%d in %ds...", retries, config.max_retries, config.retry_delay)
|
||||||
|
await asyncio.sleep(config.retry_delay)
|
||||||
|
logger.error("Failed to connect after %d retries", config.max_retries)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _account_position_keys(self) -> dict[str, float]:
|
||||||
|
"""Real account positions keyed like the tracker: 'META' or 'EUR.USD'."""
|
||||||
|
result: dict[str, float] = {}
|
||||||
|
for p in ib_conn.ib.positions():
|
||||||
|
c = p.contract
|
||||||
|
key = f"{c.symbol}.{c.currency}" if c.secType == "CASH" else c.symbol
|
||||||
|
result[key] = result.get(key, 0) + p.position
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _completed_stp_fills(self) -> dict[str, float]:
|
||||||
|
"""{orderRef: avgFillPrice} for today's filled STP orders, so reconcile can
|
||||||
|
backfill the ledger with real prices for fills the bot missed while offline."""
|
||||||
|
fills: dict[str, float] = {}
|
||||||
|
try:
|
||||||
|
for t in await ib_conn.ib.reqCompletedOrdersAsync(apiOnly=True):
|
||||||
|
ref = getattr(t.order, "orderRef", "") or ""
|
||||||
|
fill_price = getattr(t.orderStatus, "avgFillPrice", 0) or 0
|
||||||
|
if ref.endswith(":STP") and t.orderStatus.status == "Filled" and fill_price > 0:
|
||||||
|
fills[ref] = fill_price
|
||||||
|
# ib_insync inserts completed orders into wrapper.trades with a
|
||||||
|
# non-terminal OrderStatus (filled=0, status from orderState).
|
||||||
|
# Purge them so they don't masquerade as open orders: has_open_order
|
||||||
|
# would block stop placement forever for a key whose real STP is
|
||||||
|
# missing but whose completed order is still visible (08-04: NOK
|
||||||
|
# zombie STP appeared "PreSubmitted" in openTrades after restart).
|
||||||
|
ib_conn.ib.wrapper.trades.pop(t.order.permId, None)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("reqCompletedOrders failed: %s", e)
|
||||||
|
return fills
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
self.setup_signal_handlers()
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
if config.ib.is_paper:
|
||||||
|
logger.info("Starting Trading Bot | Account: %s | Mode: PAPER", config.ib.account)
|
||||||
|
else:
|
||||||
|
logger.warning("*" * 70)
|
||||||
|
logger.warning(
|
||||||
|
"LIVE TRADING MODE - real orders will be placed! Account=%s Gateway=%s:%s (%s)",
|
||||||
|
config.ib.account, config.ib.host, config.ib.port, config.ib.port_label,
|
||||||
|
)
|
||||||
|
logger.warning("*" * 70)
|
||||||
|
|
||||||
|
if not await self.connect():
|
||||||
|
logger.error("Initial connection failed, entering reconnect loop (Ctrl+C to abort)...")
|
||||||
|
await self._handle_disconnect()
|
||||||
|
if not self._running or not ib_conn.is_connected():
|
||||||
|
return
|
||||||
|
|
||||||
|
# registered exactly once; guarded against shutdown and duplicates
|
||||||
|
ib_conn.ib.disconnectedEvent += self._on_disconnected
|
||||||
|
|
||||||
|
if config.stock.enabled:
|
||||||
|
self.strategies.append(MAStockStrategy(ib_conn.ib, self.bar_manager, self.tracker))
|
||||||
|
if config.short_term.enabled:
|
||||||
|
self.strategies.append(ShortTermMAVWAPStrategy(ib_conn.ib, self.bar_manager, self.tracker))
|
||||||
|
if config.forex.enabled:
|
||||||
|
self.strategies.append(ForexMAStrategy(ib_conn.ib, self.bar_manager, self.tracker))
|
||||||
|
if config.mean_reversion.enabled:
|
||||||
|
self.strategies.append(MeanReversionStrategy(ib_conn.ib, self.bar_manager, self.tracker))
|
||||||
|
|
||||||
|
if not self.strategies:
|
||||||
|
logger.error("No strategies enabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
# reconcile tracked ownership against real account positions before trading
|
||||||
|
external_fills = await self._completed_stp_fills()
|
||||||
|
self.tracker.reconcile(self._account_position_keys(), external_fills)
|
||||||
|
|
||||||
|
# populate open orders so strategies can adopt existing GTC stop orders
|
||||||
|
try:
|
||||||
|
await ib_conn.ib.reqOpenOrdersAsync()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("reqOpenOrders failed: %s", e)
|
||||||
|
|
||||||
|
for s in self.strategies:
|
||||||
|
await s.on_start()
|
||||||
|
logger.info("Strategy loaded: %s", s.name)
|
||||||
|
|
||||||
|
logger.info("Bot is running. Press Ctrl+C to stop. Check interval: %.0fs", config.loop_interval)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while self._running:
|
||||||
|
# skip cycles while the reconnect task is working
|
||||||
|
if not ib_conn.is_connected():
|
||||||
|
await asyncio.sleep(config.retry_delay)
|
||||||
|
continue
|
||||||
|
loop_start = datetime.now()
|
||||||
|
for s in self.strategies:
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
await s.on_bar()
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error in %s: %s", s.name, e)
|
||||||
|
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
|
||||||
|
elapsed = (datetime.now() - loop_start).total_seconds()
|
||||||
|
await asyncio.sleep(max(0, config.loop_interval - elapsed))
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
await self.shutdown()
|
||||||
|
|
||||||
|
def _on_disconnected(self):
|
||||||
|
# ignore disconnects we triggered ourselves during shutdown
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
# never run two reconnect loops concurrently
|
||||||
|
if self._reconnect_task and not self._reconnect_task.done():
|
||||||
|
return
|
||||||
|
logger.warning("IB Gateway disconnected! Attempting reconnect...")
|
||||||
|
self._reconnect_task = asyncio.create_task(self._handle_disconnect())
|
||||||
|
|
||||||
|
async def _handle_disconnect(self):
|
||||||
|
attempt = 0
|
||||||
|
while self._running:
|
||||||
|
attempt += 1
|
||||||
|
delay = min(config.retry_delay * attempt, 60)
|
||||||
|
logger.warning("Reconnect attempt %d, waiting %ds...", attempt, delay)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
if await ib_conn.ensure_connected():
|
||||||
|
logger.info("Reconnected successfully on attempt %d", attempt)
|
||||||
|
# live bar subscriptions died with the connection; re-subscribe
|
||||||
|
self.bar_manager.reset()
|
||||||
|
external_fills = await self._completed_stp_fills()
|
||||||
|
self.tracker.reconcile(self._account_position_keys(), external_fills)
|
||||||
|
try:
|
||||||
|
await ib_conn.ib.reqOpenOrdersAsync()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("reqOpenOrders failed after reconnect: %s", e)
|
||||||
|
for s in self.strategies:
|
||||||
|
await s.on_start()
|
||||||
|
return
|
||||||
|
|
||||||
|
async def shutdown(self):
|
||||||
|
logger.info("Shutting down...")
|
||||||
|
self._running = False
|
||||||
|
if self._reconnect_task and not self._reconnect_task.done():
|
||||||
|
self._reconnect_task.cancel()
|
||||||
|
for s in self.strategies:
|
||||||
|
try:
|
||||||
|
await s.on_stop()
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error stopping %s: %s", s.name, e)
|
||||||
|
self.bar_manager.reset()
|
||||||
|
ib_conn.disconnect()
|
||||||
|
logger.info("Shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
app = TradingApp()
|
||||||
|
await app.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
119
orders.py
Normal file
119
orders.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ib_insync import IB, Contract, MarketOrder, Trade
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_stock_contract(symbol: str, exchange: str = "SMART", currency: str = "USD") -> Contract:
|
||||||
|
return Contract(symbol=symbol, secType="STK", exchange=exchange, currency=currency)
|
||||||
|
|
||||||
|
|
||||||
|
def has_open_order(ib: IB, order_ref: str) -> bool:
|
||||||
|
"""True if an unfinished order with this orderRef already exists."""
|
||||||
|
for trade in ib.openTrades():
|
||||||
|
if (trade.order.orderRef == order_ref and not trade.isDone()
|
||||||
|
and trade.orderStatus.remaining > 0):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_trade_done(trade: Trade, timeout: float) -> bool:
|
||||||
|
"""Event-driven wait for a trade to reach a terminal state.
|
||||||
|
|
||||||
|
Waits on trade.statusEvent (which fires on every status update) instead of
|
||||||
|
polling, returning as soon as isDone() becomes true or the timeout elapses.
|
||||||
|
"""
|
||||||
|
done = asyncio.Event()
|
||||||
|
|
||||||
|
def _on_status(_):
|
||||||
|
if trade.isDone():
|
||||||
|
done.set()
|
||||||
|
|
||||||
|
trade.statusEvent.connect(_on_status)
|
||||||
|
try:
|
||||||
|
if trade.isDone():
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(done.wait(), timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return False
|
||||||
|
return trade.isDone()
|
||||||
|
finally:
|
||||||
|
trade.statusEvent.disconnect(_on_status)
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_market_order(
|
||||||
|
ib: IB,
|
||||||
|
contract: Contract,
|
||||||
|
action: str,
|
||||||
|
quantity: float,
|
||||||
|
order_ref: str,
|
||||||
|
timeout: Optional[float] = None,
|
||||||
|
) -> Optional[Trade]:
|
||||||
|
"""Place a market order and wait for the fill.
|
||||||
|
|
||||||
|
Returns the Trade if (partially) filled, None on rejection/timeout.
|
||||||
|
On timeout the order is cancelled before giving up.
|
||||||
|
"""
|
||||||
|
if has_open_order(ib, order_ref):
|
||||||
|
logger.warning(
|
||||||
|
"Skip %s %s x%g: an open order already exists (ref=%s)",
|
||||||
|
action, contract.symbol, quantity, order_ref,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
order = MarketOrder(action, quantity)
|
||||||
|
if config.ib.account:
|
||||||
|
order.account = config.ib.account
|
||||||
|
order.orderRef = order_ref
|
||||||
|
order.tif = "DAY"
|
||||||
|
order.outsideRth = False
|
||||||
|
|
||||||
|
trade = ib.placeOrder(contract, order)
|
||||||
|
logger.info(
|
||||||
|
"Placed %s market order: %s x%g (ref=%s, orderId=%s)",
|
||||||
|
action, contract.symbol, quantity, order_ref, trade.order.orderId,
|
||||||
|
)
|
||||||
|
|
||||||
|
timeout = timeout or config.order_timeout
|
||||||
|
if not await wait_trade_done(trade, timeout):
|
||||||
|
logger.warning(
|
||||||
|
"%s %s x%g not filled within %.0fs, cancelling...",
|
||||||
|
action, contract.symbol, quantity, timeout,
|
||||||
|
)
|
||||||
|
ib.cancelOrder(order)
|
||||||
|
await wait_trade_done(trade, 2.0) # wait for the cancel to confirm
|
||||||
|
|
||||||
|
if trade.isDone():
|
||||||
|
status = trade.orderStatus.status
|
||||||
|
if status == "Filled":
|
||||||
|
logger.info(
|
||||||
|
"%s %s x%g FILLED @ %.4f",
|
||||||
|
action, contract.symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice,
|
||||||
|
)
|
||||||
|
return trade
|
||||||
|
logger.error(
|
||||||
|
"%s %s x%g finished with status=%s: %s",
|
||||||
|
action, contract.symbol, quantity, status,
|
||||||
|
[m.message for m in trade.log] if trade.log else "",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if trade.orderStatus.filled > 0:
|
||||||
|
logger.info(
|
||||||
|
"%s %s partially filled x%g @ %.4f before cancel",
|
||||||
|
action, contract.symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice,
|
||||||
|
)
|
||||||
|
return trade
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
"%s %s x%g cancelled/unfilled (status=%s): %s",
|
||||||
|
action, contract.symbol, quantity, trade.orderStatus.status,
|
||||||
|
[m.message for m in trade.log] if trade.log else "",
|
||||||
|
)
|
||||||
|
return None
|
||||||
5
report.sh
Executable file
5
report.sh
Executable file
@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 查看当天(或指定日期)的成交明细与盈亏
|
||||||
|
# 用法: ./report.sh [YYYY-MM-DD]
|
||||||
|
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
exec "$DIR/.venv/bin/python" "$DIR/daily_report.py" "$@"
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
ib_insync>=0.9.86
|
||||||
|
pandas>=2.0.0
|
||||||
|
numpy>=1.24.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
28
restart_bot.sh
Executable file
28
restart_bot.sh
Executable file
@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BOT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
LOG_FILE="$BOT_DIR/trading_bot.log"
|
||||||
|
|
||||||
|
echo "=== IB Trading Bot Starter ==="
|
||||||
|
echo "BOT_DIR: $BOT_DIR"
|
||||||
|
|
||||||
|
# Step 1: Kill any existing bot process
|
||||||
|
pkill -f "main.py" 2>/dev/null || true
|
||||||
|
echo "[1/2] Stopped old bot process (if any)"
|
||||||
|
|
||||||
|
# Step 2: Start the trading bot (setsid so it survives this shell exiting)
|
||||||
|
echo "[2/2] Starting trading bot..."
|
||||||
|
setsid nohup "$BOT_DIR/.venv/bin/python" "$BOT_DIR/main.py" >> "$LOG_FILE" 2>&1 < /dev/null &
|
||||||
|
BOT_PID=$!
|
||||||
|
echo "Bot PID: $BOT_PID"
|
||||||
|
|
||||||
|
# Wait a few seconds and check
|
||||||
|
sleep 5
|
||||||
|
if kill -0 "$BOT_PID" 2>/dev/null; then
|
||||||
|
echo "=== Bot started successfully (PID: $BOT_PID) ==="
|
||||||
|
echo "Log: tail -f $LOG_FILE"
|
||||||
|
else
|
||||||
|
echo "=== Bot failed to start, check log ==="
|
||||||
|
tail -5 "$LOG_FILE"
|
||||||
|
fi
|
||||||
16
start_gateway.sh
Executable file
16
start_gateway.sh
Executable file
@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 启动 IB Gateway
|
||||||
|
# 确保先登录 IB 账户并开启 API 连接
|
||||||
|
|
||||||
|
JDK_LINK="$HOME/jdk"
|
||||||
|
|
||||||
|
if [ ! -x "$JDK_LINK/bin/java" ]; then
|
||||||
|
echo "ERROR: JDK not found at $JDK_LINK"
|
||||||
|
echo "Download Temurin JDK and link it: ln -sfn ~/jdk-<version> ~/jdk"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export JAVA_HOME="$JDK_LINK"
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
|
||||||
|
~/ibgateway/ibgateway "$@"
|
||||||
320
state.py
Normal file
320
state.py
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import config as app_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write_text(path: Path, text: str):
|
||||||
|
"""Write text atomically: temp file in the same dir, then os.replace.
|
||||||
|
|
||||||
|
Prevents corruption of the state files if the bot crashes (or the box
|
||||||
|
loses power) mid-write - os.replace is atomic on POSIX.
|
||||||
|
"""
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
f.write(text)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
class PositionTracker:
|
||||||
|
"""Tracks which strategy owns which positions, persisted to a JSON file.
|
||||||
|
|
||||||
|
IB only reports account-level positions, so ownership is tracked locally:
|
||||||
|
{strategy_name: {symbol_or_pair: {"quantity": float, "entry_price": float,
|
||||||
|
"entry_date": "YYYY-MM-DD"}}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, state_file: str):
|
||||||
|
self.state_file = Path(state_file)
|
||||||
|
self.ledger_file = self.state_file.with_name("trades.jsonl")
|
||||||
|
self._day_pnl_file = self.state_file.with_name("day_pnl.json")
|
||||||
|
self._recent_sells_file = self.state_file.with_name("recent_sells.json")
|
||||||
|
self._data: dict[str, dict[str, dict]] = {}
|
||||||
|
self.load()
|
||||||
|
self._seed_ledger_once()
|
||||||
|
self._day_date, self._day_realized = self._load_day_pnl()
|
||||||
|
self._recent_sells: dict[str, dict] = self._load_recent_sells()
|
||||||
|
|
||||||
|
# ---------- daily realized P&L (for the max_daily_loss circuit breaker) ----------
|
||||||
|
|
||||||
|
def _load_day_pnl(self) -> tuple[str, float]:
|
||||||
|
try:
|
||||||
|
d = json.loads(self._day_pnl_file.read_text())
|
||||||
|
if d.get("date") == date.today().isoformat():
|
||||||
|
return d["date"], float(d.get("realized", 0.0))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return date.today().isoformat(), 0.0
|
||||||
|
|
||||||
|
def _save_day_pnl(self):
|
||||||
|
try:
|
||||||
|
_atomic_write_text(
|
||||||
|
self._day_pnl_file,
|
||||||
|
json.dumps({"date": self._day_date, "realized": self._day_realized}),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to save day pnl: %s", e)
|
||||||
|
|
||||||
|
def _add_day_pnl(self, pnl: float):
|
||||||
|
today = date.today().isoformat()
|
||||||
|
if self._day_date != today:
|
||||||
|
self._day_date = today
|
||||||
|
self._day_realized = 0.0
|
||||||
|
self._day_realized += pnl
|
||||||
|
self._save_day_pnl()
|
||||||
|
|
||||||
|
def day_realized_pnl(self) -> float:
|
||||||
|
"""Today's realized P&L in USD (resets at local midnight)."""
|
||||||
|
if self._day_date != date.today().isoformat():
|
||||||
|
return 0.0
|
||||||
|
return self._day_realized
|
||||||
|
|
||||||
|
# ---------- global sell registry (cross-strategy re-entry guard) ----------
|
||||||
|
#
|
||||||
|
# Every sell of a symbol (any strategy, any reason, incl. backfilled
|
||||||
|
# external STP fills) is stamped here so no strategy can flip-flop:
|
||||||
|
# buy-sell-buy at the same price in a few minutes.
|
||||||
|
|
||||||
|
def _load_recent_sells(self) -> dict[str, dict]:
|
||||||
|
try:
|
||||||
|
d = json.loads(self._recent_sells_file.read_text())
|
||||||
|
if isinstance(d, dict):
|
||||||
|
return {k: v for k, v in d.items()
|
||||||
|
if isinstance(v, dict) and v.get("ts") is not None}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _save_recent_sells(self):
|
||||||
|
try:
|
||||||
|
_atomic_write_text(self._recent_sells_file, json.dumps(self._recent_sells))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to save recent sells: %s", e)
|
||||||
|
|
||||||
|
def _record_recent_sell(self, key: str, price: float):
|
||||||
|
self._recent_sells[key] = {
|
||||||
|
"ts": datetime.now(timezone.utc).timestamp(),
|
||||||
|
"price": price,
|
||||||
|
}
|
||||||
|
self._save_recent_sells()
|
||||||
|
|
||||||
|
def recent_sell(self, key: str) -> Optional[dict]:
|
||||||
|
"""Latest sell record {ts (epoch), price} for the symbol, or None once
|
||||||
|
older than the improvement window (stale entries are purged lazily)."""
|
||||||
|
rec = self._recent_sells.get(key)
|
||||||
|
if not rec:
|
||||||
|
return None
|
||||||
|
window = app_config.config.sell_improvement_window_minutes * 60
|
||||||
|
if time.time() - rec["ts"] > window:
|
||||||
|
del self._recent_sells[key]
|
||||||
|
self._save_recent_sells()
|
||||||
|
return None
|
||||||
|
return rec
|
||||||
|
|
||||||
|
# ---------- trade ledger (append-only JSONL, used by daily_report.py) ----------
|
||||||
|
|
||||||
|
def _ledger_append(self, type_: str, strategy: str, key: str, qty: float, price: float,
|
||||||
|
estimated: bool = False):
|
||||||
|
rec = {
|
||||||
|
"ts": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"type": type_, "strategy": strategy, "symbol": key,
|
||||||
|
"qty": qty, "price": price,
|
||||||
|
}
|
||||||
|
if estimated:
|
||||||
|
rec["est"] = True
|
||||||
|
try:
|
||||||
|
with open(self.ledger_file, "a") as f:
|
||||||
|
f.write(json.dumps(rec) + "\n")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to append trade ledger: %s", e)
|
||||||
|
|
||||||
|
def _seed_ledger_once(self):
|
||||||
|
"""On first run after the ledger was introduced, record currently tracked
|
||||||
|
positions as opening lots so future sells can compute realized P&L."""
|
||||||
|
if self.ledger_file.exists():
|
||||||
|
return
|
||||||
|
for strategy, entries in self._data.items():
|
||||||
|
for key, e in entries.items():
|
||||||
|
self._ledger_append("seed", strategy, key, e["quantity"], e["entry_price"])
|
||||||
|
if self._data:
|
||||||
|
logger.info("Trade ledger seeded with %d opening lots -> %s",
|
||||||
|
sum(len(v) for v in self._data.values()), self.ledger_file)
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
if not self.state_file.exists():
|
||||||
|
self._data = {}
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._data = json.loads(self.state_file.read_text())
|
||||||
|
logger.info("Loaded state from %s", self.state_file)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to load state file %s: %s (starting empty)", self.state_file, e)
|
||||||
|
self._data = {}
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
try:
|
||||||
|
_atomic_write_text(self.state_file, json.dumps(self._data, indent=2))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to save state file %s: %s", self.state_file, e)
|
||||||
|
|
||||||
|
def get(self, strategy: str, key: str) -> Optional[dict]:
|
||||||
|
"""Return owned position dict or None."""
|
||||||
|
entry = self._data.get(strategy, {}).get(key)
|
||||||
|
if entry and entry.get("quantity", 0) > 0:
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
def total_positions(self) -> int:
|
||||||
|
"""Number of open lots across all strategies (for the global position cap)."""
|
||||||
|
return sum(
|
||||||
|
1
|
||||||
|
for entries in self._data.values()
|
||||||
|
for e in entries.values()
|
||||||
|
if e.get("quantity", 0) > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
def symbol_value(self, key: str) -> float:
|
||||||
|
"""Total tracked position value (qty x entry price) for one symbol,
|
||||||
|
summed across all strategies (for the per-symbol value cap)."""
|
||||||
|
return sum(
|
||||||
|
e["quantity"] * e["entry_price"]
|
||||||
|
for entries in self._data.values()
|
||||||
|
for k, e in entries.items()
|
||||||
|
if k == key and e.get("quantity", 0) > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_buy(self, strategy: str, key: str, quantity: float, price: float):
|
||||||
|
entries = self._data.setdefault(strategy, {})
|
||||||
|
entry = entries.get(key)
|
||||||
|
if entry:
|
||||||
|
prev_qty = entry["quantity"]
|
||||||
|
total_qty = prev_qty + quantity
|
||||||
|
entry["entry_price"] = (entry["entry_price"] * prev_qty + price * quantity) / total_qty
|
||||||
|
entry["quantity"] = total_qty
|
||||||
|
else:
|
||||||
|
entries[key] = {
|
||||||
|
"quantity": quantity,
|
||||||
|
"entry_price": price,
|
||||||
|
"entry_date": date.today().isoformat(),
|
||||||
|
"entry_ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
self.save()
|
||||||
|
self._ledger_append("buy", strategy, key, quantity, price)
|
||||||
|
logger.info("Tracker: %s owns %s x%g @ %.4f", strategy, key, entries[key]["quantity"], entries[key]["entry_price"])
|
||||||
|
|
||||||
|
def record_sell(self, strategy: str, key: str, quantity: float, price: float = None):
|
||||||
|
entries = self._data.get(strategy, {})
|
||||||
|
entry = entries.get(key)
|
||||||
|
if not entry:
|
||||||
|
return
|
||||||
|
entry["quantity"] -= quantity
|
||||||
|
if entry["quantity"] <= 0:
|
||||||
|
del entries[key]
|
||||||
|
self.save()
|
||||||
|
if price is not None:
|
||||||
|
self._add_day_pnl((price - entry["entry_price"]) * quantity)
|
||||||
|
self._ledger_append("sell", strategy, key, quantity, price)
|
||||||
|
self._record_recent_sell(key, price)
|
||||||
|
logger.info("Tracker: %s sold %s x%g, remaining=%s", strategy, key, quantity, entry.get("quantity", 0))
|
||||||
|
|
||||||
|
def _record_external_sell(self, strategy: str, key: str, qty: float,
|
||||||
|
entry_price: float, external_fills: Optional[dict]):
|
||||||
|
"""A tracked position vanished without a bot-recorded sell (e.g. its STP
|
||||||
|
order filled while the bot was disconnected). Backfill the ledger so the
|
||||||
|
report and day-PnL stay accurate: use the real fill price when IB can
|
||||||
|
provide it, otherwise estimate at the stop price and mark it."""
|
||||||
|
price = (external_fills or {}).get(f"{strategy}:{key}:STP")
|
||||||
|
estimated = not price # None or 0.0 (IB may report 0.0 for cross-session fills)
|
||||||
|
if estimated:
|
||||||
|
price = round(entry_price * 0.98, 2) # rough STP fill estimate
|
||||||
|
logger.warning(
|
||||||
|
"Tracker: backfilling external sell %s %s x%g @ %.2f%s",
|
||||||
|
strategy, key, qty, price, " (ESTIMATED)" if estimated else "",
|
||||||
|
)
|
||||||
|
self._ledger_append("sell_external", strategy, key, qty, price, estimated=estimated)
|
||||||
|
self._add_day_pnl((price - entry_price) * qty)
|
||||||
|
self._record_recent_sell(key, price)
|
||||||
|
|
||||||
|
def reconcile(self, actual: dict[str, float], external_fills: Optional[dict] = None):
|
||||||
|
"""Reconcile tracked state against real account positions.
|
||||||
|
|
||||||
|
actual: {symbol_or_pair: total quantity held in account}.
|
||||||
|
external_fills: {orderRef: avgFillPrice} for STP fills retrieved from IB.
|
||||||
|
- tracked entries with no real position are cleared (ledger backfilled)
|
||||||
|
- tracked quantities exceeding the real position are clamped (ledger backfilled)
|
||||||
|
- real positions with no owner are reported as UNMANAGED (never sold by the bot)
|
||||||
|
"""
|
||||||
|
changed = False
|
||||||
|
for strategy in list(self._data):
|
||||||
|
entries = self._data[strategy]
|
||||||
|
for key in list(entries):
|
||||||
|
if actual.get(key, 0) <= 0:
|
||||||
|
entry = entries[key]
|
||||||
|
logger.warning(
|
||||||
|
"Tracker: %s owns %s x%g but account holds none - clearing stale state",
|
||||||
|
strategy, key, entry["quantity"],
|
||||||
|
)
|
||||||
|
self._record_external_sell(strategy, key, entry["quantity"],
|
||||||
|
entry["entry_price"], external_fills)
|
||||||
|
del entries[key]
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
def tracked_totals():
|
||||||
|
totals: dict[str, float] = {}
|
||||||
|
for entries in self._data.values():
|
||||||
|
for key, e in entries.items():
|
||||||
|
totals[key] = totals.get(key, 0) + e["quantity"]
|
||||||
|
return totals
|
||||||
|
|
||||||
|
for key, total in tracked_totals().items():
|
||||||
|
avail = actual.get(key, 0)
|
||||||
|
if total > avail:
|
||||||
|
logger.warning(
|
||||||
|
"Tracker: tracked %s x%g exceeds account position %g - clamping",
|
||||||
|
key, total, avail,
|
||||||
|
)
|
||||||
|
remaining = avail
|
||||||
|
for strategy, entries in self._data.items():
|
||||||
|
if key not in entries:
|
||||||
|
continue
|
||||||
|
entry = entries[key]
|
||||||
|
keep = min(entry["quantity"], remaining)
|
||||||
|
removed = entry["quantity"] - keep
|
||||||
|
if removed > 1e-9:
|
||||||
|
self._record_external_sell(strategy, key, removed,
|
||||||
|
entry["entry_price"], external_fills)
|
||||||
|
entry["quantity"] = keep
|
||||||
|
changed = True
|
||||||
|
remaining -= keep
|
||||||
|
if entry["quantity"] <= 0:
|
||||||
|
del entries[key]
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
tracked = tracked_totals()
|
||||||
|
for key, qty in actual.items():
|
||||||
|
if qty > tracked.get(key, 0):
|
||||||
|
logger.warning(
|
||||||
|
"Tracker: account holds %s x%g but only %g tracked - "
|
||||||
|
"%g unit(s) UNMANAGED (bot will never sell them; "
|
||||||
|
"manage manually or add them to %s)",
|
||||||
|
key, qty, tracked.get(key, 0), qty - tracked.get(key, 0), self.state_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
self.save()
|
||||||
|
logger.info("State reconciled with account: %s", self._data)
|
||||||
16
stop_bot_eod.sh
Executable file
16
stop_bot_eod.sh
Executable file
@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# One-shot task: gracefully stop the trading bot just before market close (15:59 ET),
|
||||||
|
# so no signals can fire after hours and queue orders for the next open.
|
||||||
|
# Self-removes from crontab after running.
|
||||||
|
BOT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
LOG="$BOT_DIR/trading_bot.log"
|
||||||
|
|
||||||
|
echo "=== $(date) EOD stop requested ===" >> "$LOG"
|
||||||
|
if pkill -TERM -f "python.*main\.py"; then
|
||||||
|
echo "$(date) SIGTERM sent to bot (graceful shutdown, may take up to 60s)" >> "$LOG"
|
||||||
|
else
|
||||||
|
echo "$(date) no bot process found" >> "$LOG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# self-remove from crontab (one-shot task)
|
||||||
|
crontab -l 2>/dev/null | grep -v "stop_bot_eod" | crontab -
|
||||||
7
strategies/__init__.py
Normal file
7
strategies/__init__.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from .base import BaseStrategy
|
||||||
|
from .ma_cross import MAStockStrategy
|
||||||
|
from .forex import ForexMAStrategy
|
||||||
|
from .short_term import ShortTermMAVWAPStrategy
|
||||||
|
from .mean_reversion import MeanReversionStrategy
|
||||||
|
|
||||||
|
__all__ = ["BaseStrategy", "MAStockStrategy", "ForexMAStrategy", "ShortTermMAVWAPStrategy", "MeanReversionStrategy"]
|
||||||
351
strategies/base.py
Normal file
351
strategies/base.py
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from datetime import date, datetime, time as dtime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from ib_insync import IB, Contract, StopOrder, Trade
|
||||||
|
|
||||||
|
from bars import BarManager
|
||||||
|
from config import config
|
||||||
|
from orders import has_open_order, wait_trade_done
|
||||||
|
from state import PositionTracker
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseStrategy(ABC):
|
||||||
|
def __init__(self, ib: IB, bar_manager: BarManager, tracker: PositionTracker):
|
||||||
|
self.ib = ib
|
||||||
|
self.bar_manager = bar_manager
|
||||||
|
self.tracker = tracker
|
||||||
|
self.name = self.__class__.__name__
|
||||||
|
self._stop_trades: dict[str, Trade] = {}
|
||||||
|
self._stop_consumed: set = set() # uids of done stop orders already processed
|
||||||
|
self._stop_cooldown: dict[str, datetime] = {} # key -> last stop-out time
|
||||||
|
self._peak_high: dict[str, float] = {} # key -> highest high since entry (trailing)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def on_tick(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def on_bar(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def on_start(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def on_stop(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<{self.name}>"
|
||||||
|
|
||||||
|
def _order_quantity(self, key: str, price: float) -> int:
|
||||||
|
"""Share count targeting the (per-symbol) dollar value per trade (min 1)."""
|
||||||
|
value = self._trade_value_usd(key)
|
||||||
|
if price and price > 0:
|
||||||
|
return max(1, int(value // price))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def _trade_value_usd(self, key: str) -> float:
|
||||||
|
"""Per-trade dollar value for a symbol; global per-symbol override wins."""
|
||||||
|
override = config.symbol_trade_value_usd.get(key)
|
||||||
|
return override if override else self.cfg.trade_value_usd
|
||||||
|
|
||||||
|
def _can_open_position(self, key: str, order_value: float, price: float = 0.0) -> tuple[bool, str]:
|
||||||
|
"""Global entry gates shared by all strategies.
|
||||||
|
|
||||||
|
Returns (allowed, reason_if_blocked):
|
||||||
|
- sell_only mode -> no new positions at all
|
||||||
|
- daily realized loss hit max_daily_loss -> no new positions today
|
||||||
|
- concurrent lots hit max_positions -> no new positions until some close
|
||||||
|
- symbol's combined position value would exceed max_symbol_value_usd
|
||||||
|
- symbol sold by ANY strategy within sell_cooldown_minutes -> no re-entry
|
||||||
|
- beyond the cooldown (within sell_improvement_window_minutes), a re-buy
|
||||||
|
is only allowed if its price is >= sell_improvement_pct below the
|
||||||
|
last sell price (prevents same-price flip-flops across strategies)
|
||||||
|
"""
|
||||||
|
rec = self.tracker.recent_sell(key)
|
||||||
|
if rec is not None:
|
||||||
|
age_min = (time.time() - rec["ts"]) / 60
|
||||||
|
if age_min < config.sell_cooldown_minutes:
|
||||||
|
return False, (f"{key} sold {age_min:.0f} min ago by another strategy - "
|
||||||
|
f"global sell cooldown {config.sell_cooldown_minutes} min")
|
||||||
|
if price > 0 and age_min < config.sell_improvement_window_minutes:
|
||||||
|
need = rec["price"] * (1 - config.sell_improvement_pct / 100)
|
||||||
|
if price >= need:
|
||||||
|
return False, (f"{key} re-buy price {price:.2f} not >= "
|
||||||
|
f"{config.sell_improvement_pct:.1f}% below last sell "
|
||||||
|
f"{rec['price']:.2f} (need < {need:.2f})")
|
||||||
|
if config.sell_only:
|
||||||
|
return False, "sell-only mode (liquidating)"
|
||||||
|
day_pnl = self.tracker.day_realized_pnl()
|
||||||
|
if day_pnl <= -config.max_daily_loss:
|
||||||
|
return False, f"daily loss limit hit (realized {day_pnl:+.2f} <= -{config.max_daily_loss:.0f})"
|
||||||
|
if self.tracker.total_positions() >= config.max_positions:
|
||||||
|
return False, f"global cap of {config.max_positions} positions reached"
|
||||||
|
symbol_value = self.tracker.symbol_value(key)
|
||||||
|
if symbol_value + order_value > config.max_symbol_value_usd:
|
||||||
|
return False, (f"{key} value cap: ${symbol_value + order_value:,.0f} would exceed "
|
||||||
|
f"${config.max_symbol_value_usd:,.0f}")
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
# ---------- stop-out / order-failure cooldown (per strategy+symbol) ----------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _next_day_start() -> datetime:
|
||||||
|
"""End-of-day boundary: next local midnight (rest of today = no re-entry)."""
|
||||||
|
return datetime.combine(date.today() + timedelta(days=1), dtime.min)
|
||||||
|
def _mark_cooldown(self, key: str, reason: str, until: Optional[datetime] = None):
|
||||||
|
expiry = until or (datetime.now() + timedelta(minutes=self.cfg.stop_cooldown_minutes))
|
||||||
|
self._stop_cooldown[key] = expiry
|
||||||
|
desc = "until EOD" if until else f"{self.cfg.stop_cooldown_minutes} min"
|
||||||
|
logger.info("%s: %s cooldown for %s (%s, %s)", self.name, key, key, desc, reason)
|
||||||
|
|
||||||
|
def _mark_stop_cooldown(self, key: str):
|
||||||
|
self._mark_cooldown(key, "stop-out")
|
||||||
|
|
||||||
|
def _mark_hard_stop_cooldown(self, key: str):
|
||||||
|
"""After a hard-stop fill, block re-entry on this symbol for the WHOLE day
|
||||||
|
(prevents same-price re-buys after an overnight-gap stop-out)."""
|
||||||
|
self._mark_cooldown(key, "hard stop-out", until=self._next_day_start())
|
||||||
|
|
||||||
|
def _in_stop_cooldown(self, key: str) -> bool:
|
||||||
|
until = self._stop_cooldown.get(key)
|
||||||
|
if until is None:
|
||||||
|
return False
|
||||||
|
if datetime.now() >= until:
|
||||||
|
del self._stop_cooldown[key]
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ---------- exchange-side hard stop (GTC STP order) management ----------
|
||||||
|
|
||||||
|
def _use_hard_stop(self) -> bool:
|
||||||
|
return getattr(self.cfg, "use_hard_stop", False)
|
||||||
|
|
||||||
|
def _stop_ref(self, key: str) -> str:
|
||||||
|
return f"{self.name}:{key}:STP"
|
||||||
|
|
||||||
|
def _has_active_stop(self, key: str) -> bool:
|
||||||
|
t = self._stop_trades.get(key)
|
||||||
|
return t is not None and not t.isDone()
|
||||||
|
|
||||||
|
# ---------- trailing stop: raise the stop as price makes new highs ----------
|
||||||
|
|
||||||
|
def _use_trailing_stop(self) -> bool:
|
||||||
|
return bool(getattr(self.cfg, "use_trailing_stop", False))
|
||||||
|
|
||||||
|
def _target_stop_price(self, key: str, entry_price: float, peak_high: float | None) -> float:
|
||||||
|
"""Desired STP price for a position.
|
||||||
|
|
||||||
|
Fixed stop = entry - stop_loss_pct (always the floor).
|
||||||
|
Once the peak high since entry is >= min_profit above entry, switch to a
|
||||||
|
trailing stop = peak_high - trailing_stop_pct, but never below the fixed stop.
|
||||||
|
"""
|
||||||
|
fixed = round(entry_price * (1 - self.cfg.stop_loss_pct / 100), 2)
|
||||||
|
if not self._use_trailing_stop() or not peak_high:
|
||||||
|
return fixed
|
||||||
|
peak_profit_pct = (peak_high - entry_price) / entry_price * 100
|
||||||
|
if peak_profit_pct < self.cfg.min_profit_pct:
|
||||||
|
return fixed
|
||||||
|
trailing = round(peak_high * (1 - self.cfg.trailing_stop_pct / 100), 2)
|
||||||
|
return max(trailing, fixed)
|
||||||
|
|
||||||
|
def _track_peak_high(self, key: str, high: float):
|
||||||
|
"""Remember the highest high seen for a symbol (call with each completed bar)."""
|
||||||
|
current = self._peak_high.get(key)
|
||||||
|
if current is None or high > current:
|
||||||
|
self._peak_high[key] = high
|
||||||
|
|
||||||
|
def _current_peak_high(self, key: str) -> float | None:
|
||||||
|
return self._peak_high.get(key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _trade_uid(trade: Trade):
|
||||||
|
"""Stable unique id for a trade (permId once assigned by IB)."""
|
||||||
|
return trade.order.permId or trade.order.orderId or id(trade)
|
||||||
|
|
||||||
|
async def _sync_stop_orders(self):
|
||||||
|
"""Reconcile in-memory/exchange stop orders with tracked positions.
|
||||||
|
|
||||||
|
Runs at the top of every on_bar cycle:
|
||||||
|
- STP filled -> record the sell in the tracker (position is gone)
|
||||||
|
- STP dead (cancelled/inactive) while position owned -> re-place next cycle
|
||||||
|
- STP open -> adopt it (e.g. after a bot restart); raise it to the
|
||||||
|
trailing target if it has moved up
|
||||||
|
- owned but no STP anywhere -> place a new one
|
||||||
|
"""
|
||||||
|
if not self._use_hard_stop():
|
||||||
|
return
|
||||||
|
by_ref = {}
|
||||||
|
for t in self.ib.trades():
|
||||||
|
ref = getattr(t.order, "orderRef", "") or ""
|
||||||
|
if ref:
|
||||||
|
by_ref[ref] = t
|
||||||
|
|
||||||
|
for key, contract in self.contracts.items():
|
||||||
|
owned = self.tracker.get(self.name, key)
|
||||||
|
trade = self._stop_trades.get(key)
|
||||||
|
if trade is None:
|
||||||
|
candidate = by_ref.get(self._stop_ref(key))
|
||||||
|
if candidate is not None:
|
||||||
|
if not candidate.isDone():
|
||||||
|
trade = candidate # adopt open order (e.g. after restart)
|
||||||
|
elif self._trade_uid(candidate) not in self._stop_consumed:
|
||||||
|
trade = candidate # consume terminal state exactly once
|
||||||
|
|
||||||
|
if trade is not None and trade.isDone():
|
||||||
|
self._stop_consumed.add(self._trade_uid(trade))
|
||||||
|
status = trade.orderStatus.status
|
||||||
|
filled = trade.orderStatus.filled or 0.0
|
||||||
|
if status == "Filled" and filled > 0:
|
||||||
|
if owned:
|
||||||
|
logger.info(
|
||||||
|
"%s HARD STOP filled: %s x%g @ %.2f",
|
||||||
|
self.name, key, filled, trade.orderStatus.avgFillPrice,
|
||||||
|
)
|
||||||
|
self.tracker.record_sell(self.name, key, filled, trade.orderStatus.avgFillPrice)
|
||||||
|
self._mark_hard_stop_cooldown(key)
|
||||||
|
elif owned:
|
||||||
|
logger.warning(
|
||||||
|
"%s stop order for %s ended with status=%s - re-placing now",
|
||||||
|
self.name, key, status,
|
||||||
|
)
|
||||||
|
self._stop_trades.pop(key, None)
|
||||||
|
if owned and not (status == "Filled" and filled > 0):
|
||||||
|
# re-place immediately (same cycle), don't wait for the next one
|
||||||
|
target = await self._current_stop_target(key, contract, owned)
|
||||||
|
await self._place_stop(key, contract, owned["quantity"], target)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if trade is not None:
|
||||||
|
self._stop_trades[key] = trade # adopt existing open order
|
||||||
|
if owned:
|
||||||
|
await self._raise_stop_if_needed(key, contract, owned)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if owned:
|
||||||
|
target = await self._current_stop_target(key, contract, owned)
|
||||||
|
await self._place_stop(key, contract, owned["quantity"], target)
|
||||||
|
|
||||||
|
async def _current_stop_target(self, key: str, contract: Contract, owned: dict) -> float:
|
||||||
|
"""Desired STP price for the position right now (fixed or trailing)."""
|
||||||
|
entry = owned["entry_price"]
|
||||||
|
peak = await self._peak_high_since_entry(key, contract, owned)
|
||||||
|
return self._target_stop_price(key, entry, peak)
|
||||||
|
|
||||||
|
async def _peak_high_since_entry(self, key: str, contract: Contract, owned: dict) -> float | None:
|
||||||
|
"""Highest high of completed bars since the position was opened, if any."""
|
||||||
|
try:
|
||||||
|
df = await self._get_df(contract)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if df is None or df.empty:
|
||||||
|
return None
|
||||||
|
cutoff = None
|
||||||
|
entry_ts = owned.get("entry_ts")
|
||||||
|
if entry_ts:
|
||||||
|
try:
|
||||||
|
cutoff = pd.Timestamp(entry_ts, tz="UTC")
|
||||||
|
except Exception:
|
||||||
|
cutoff = None
|
||||||
|
if cutoff is None:
|
||||||
|
ed = owned.get("entry_date")
|
||||||
|
if ed:
|
||||||
|
try:
|
||||||
|
cutoff = pd.Timestamp(ed, tz="America/New_York").tz_convert("UTC")
|
||||||
|
except Exception:
|
||||||
|
cutoff = None
|
||||||
|
if cutoff is None:
|
||||||
|
return None
|
||||||
|
since = df.loc[df["date"] >= cutoff, "high"]
|
||||||
|
return float(since.max()) if not since.empty else None
|
||||||
|
|
||||||
|
async def _raise_stop_if_needed(self, key: str, contract: Contract, owned: dict):
|
||||||
|
"""Cancel and re-place the stop if its price no longer matches the target.
|
||||||
|
|
||||||
|
Handles both upward trailing moves and stale/zombie orders whose auxPrice
|
||||||
|
differs from the desired stop (e.g. a PreSubmitted order that IB never
|
||||||
|
accepted; cancel+re-place recovers it instead of silently keeping it).
|
||||||
|
"""
|
||||||
|
trade = self._stop_trades.get(key)
|
||||||
|
if trade is None or trade.isDone():
|
||||||
|
return
|
||||||
|
target = await self._current_stop_target(key, contract, owned)
|
||||||
|
current = getattr(trade.order, "auxPrice", 0) or 0
|
||||||
|
if current and abs(target - current) < 0.01:
|
||||||
|
return
|
||||||
|
logger.info(
|
||||||
|
"%s raising stop %s %.2f -> %.2f (trailing)", self.name, key, current, target,
|
||||||
|
)
|
||||||
|
stop_filled, stop_price, confirmed = await self._cancel_stop(key)
|
||||||
|
if stop_filled > 0:
|
||||||
|
logger.info("%s: stop filled %g during trailing raise", key, stop_filled)
|
||||||
|
self.tracker.record_sell(self.name, key, stop_filled, stop_price)
|
||||||
|
remaining = owned["quantity"] - stop_filled
|
||||||
|
if remaining <= 0:
|
||||||
|
return
|
||||||
|
if not confirmed:
|
||||||
|
logger.warning(
|
||||||
|
"%s: trailing raise aborted for %s - stop cancel not confirmed",
|
||||||
|
self.name, key,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await self._place_stop(key, contract, remaining, target)
|
||||||
|
|
||||||
|
async def _place_stop(self, key: str, contract: Contract, quantity: float, stop_price: float):
|
||||||
|
"""Place a GTC stop-loss sell order at the exchange."""
|
||||||
|
stop_price = round(stop_price, 2)
|
||||||
|
ref = self._stop_ref(key)
|
||||||
|
if has_open_order(self.ib, ref):
|
||||||
|
_matching = [
|
||||||
|
f"{t.order.orderRef}|{t.orderStatus.status}|client{t.order.clientId}|done={t.isDone()}"
|
||||||
|
for t in self.ib.openTrades() if t.order.orderRef == ref
|
||||||
|
]
|
||||||
|
logger.info(
|
||||||
|
"%s skipping stop place for %s: matching open order exists %s (debug: owned=%s stop_trades=%s)",
|
||||||
|
self.name, key, _matching,
|
||||||
|
self.tracker.get(self.name, key),
|
||||||
|
self._stop_trades.get(key),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
order = StopOrder("SELL", quantity, stop_price)
|
||||||
|
if config.ib.account:
|
||||||
|
order.account = config.ib.account
|
||||||
|
order.orderRef = ref
|
||||||
|
order.tif = "GTC"
|
||||||
|
order.outsideRth = True # allow triggering in extended hours / overnight gaps
|
||||||
|
try:
|
||||||
|
trade = self.ib.placeOrder(contract, order)
|
||||||
|
self._stop_trades[key] = trade
|
||||||
|
logger.info(
|
||||||
|
"%s hard stop placed: %s x%g STP @ %.2f (GTC, outsideRth)",
|
||||||
|
self.name, key, quantity, stop_price,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("%s failed to place stop for %s: %s", self.name, key, e)
|
||||||
|
async def _cancel_stop(self, key: str) -> tuple[float, float, bool]:
|
||||||
|
"""Cancel the stop order for key.
|
||||||
|
|
||||||
|
Returns (quantity, avg_price) filled in the cancel race, and whether the
|
||||||
|
cancellation was confirmed before giving up. If not confirmed, the caller
|
||||||
|
must NOT proceed with a market sell - the STP may still be live and both
|
||||||
|
orders could fill (double sell). Leave the position for next cycle.
|
||||||
|
"""
|
||||||
|
trade = self._stop_trades.pop(key, None)
|
||||||
|
if trade is None:
|
||||||
|
return 0.0, 0.0, True
|
||||||
|
if not trade.isDone():
|
||||||
|
self.ib.cancelOrder(trade.order)
|
||||||
|
if not await wait_trade_done(trade, 5.0):
|
||||||
|
logger.warning(
|
||||||
|
"%s: cancel of stop for %s not confirmed after 5s - "
|
||||||
|
"assuming it may still be active",
|
||||||
|
self.name, key,
|
||||||
|
)
|
||||||
|
return trade.orderStatus.filled or 0.0, trade.orderStatus.avgFillPrice or 0.0, False
|
||||||
|
return trade.orderStatus.filled or 0.0, trade.orderStatus.avgFillPrice or 0.0, True
|
||||||
120
strategies/forex.py
Normal file
120
strategies/forex.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from ib_insync import Contract, IB
|
||||||
|
|
||||||
|
from bars import BarManager, is_market_active, parse_bar_size_seconds, to_completed_df
|
||||||
|
from config import config
|
||||||
|
from orders import execute_market_order
|
||||||
|
from state import PositionTracker
|
||||||
|
from strategies.base import BaseStrategy
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ForexMAStrategy(BaseStrategy):
|
||||||
|
"""Forex MA cross strategy (disabled by default).
|
||||||
|
|
||||||
|
Exits: hard stop loss at -stop_loss_pct, or fast MA crossing below slow MA.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ib: IB, bar_manager: BarManager, tracker: PositionTracker):
|
||||||
|
super().__init__(ib, bar_manager, tracker)
|
||||||
|
self.cfg = config.forex
|
||||||
|
self.fast_period = self.cfg.fast_ma_period
|
||||||
|
self.slow_period = self.cfg.slow_ma_period
|
||||||
|
self.units = self.cfg.trade_units
|
||||||
|
self.bar_seconds = parse_bar_size_seconds(self.cfg.bar_size)
|
||||||
|
self.contracts: dict[str, Contract] = {}
|
||||||
|
|
||||||
|
async def on_start(self):
|
||||||
|
logger.info(
|
||||||
|
"Forex MA strategy started: pairs=%s, fast=%d, slow=%d, units=%d, stop_loss=%.1f%%",
|
||||||
|
self.cfg.pairs, self.fast_period, self.slow_period, self.units, self.cfg.stop_loss_pct,
|
||||||
|
)
|
||||||
|
for pair in self.cfg.pairs:
|
||||||
|
base, quote = pair.split(".")
|
||||||
|
contract = Contract(secType="CASH", symbol=base, currency=quote, exchange=self.cfg.exchange)
|
||||||
|
await self.ib.qualifyContractsAsync(contract)
|
||||||
|
if not contract.conId:
|
||||||
|
logger.error("Forex %s: failed to qualify contract, skipped", pair)
|
||||||
|
continue
|
||||||
|
self.contracts[pair] = contract
|
||||||
|
await self.bar_manager.subscribe(
|
||||||
|
contract, self.cfg.bar_size, f"{self.cfg.lookback_days} D", "MIDPOINT"
|
||||||
|
)
|
||||||
|
logger.info("Forex %s: subscribed, owned=%s", pair, self.tracker.get(self.name, pair))
|
||||||
|
|
||||||
|
async def _get_df(self, contract: Contract) -> pd.DataFrame:
|
||||||
|
bars = self.bar_manager.get(contract, self.cfg.bar_size, "MIDPOINT")
|
||||||
|
if bars is None:
|
||||||
|
bars = await self.bar_manager.subscribe(
|
||||||
|
contract, self.cfg.bar_size, f"{self.cfg.lookback_days} D", "MIDPOINT"
|
||||||
|
)
|
||||||
|
return to_completed_df(bars, self.bar_seconds)
|
||||||
|
|
||||||
|
async def on_bar(self):
|
||||||
|
for pair, contract in self.contracts.items():
|
||||||
|
try:
|
||||||
|
await self._process_pair(pair, contract)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Forex %s: error: %s", pair, e)
|
||||||
|
|
||||||
|
async def _process_pair(self, pair: str, contract: Contract):
|
||||||
|
df = await self._get_df(contract)
|
||||||
|
if len(df) < self.slow_period + 2:
|
||||||
|
return
|
||||||
|
if not is_market_active(df, self.bar_seconds):
|
||||||
|
return
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
df["fast_ma"] = df["close"].rolling(self.fast_period).mean()
|
||||||
|
df["slow_ma"] = df["close"].rolling(self.slow_period).mean()
|
||||||
|
|
||||||
|
last = df.iloc[-1]
|
||||||
|
prev = df.iloc[-2]
|
||||||
|
fast_above = last["fast_ma"] > last["slow_ma"]
|
||||||
|
prev_fast_above = prev["fast_ma"] > prev["slow_ma"]
|
||||||
|
cross_up = fast_above and not prev_fast_above
|
||||||
|
cross_down = not fast_above and prev_fast_above
|
||||||
|
|
||||||
|
owned = self.tracker.get(self.name, pair)
|
||||||
|
|
||||||
|
if owned:
|
||||||
|
entry = owned["entry_price"]
|
||||||
|
if last["close"] <= entry * (1 - self.cfg.stop_loss_pct / 100):
|
||||||
|
logger.info(
|
||||||
|
"FOREX STOP-LOSS SELL: %s close=%.5f entry=%.5f",
|
||||||
|
pair, last["close"], entry,
|
||||||
|
)
|
||||||
|
await self._sell(pair, contract, owned["quantity"])
|
||||||
|
elif cross_down:
|
||||||
|
logger.info(
|
||||||
|
"FOREX SELL %s (fast MA %.5f < slow MA %.5f)",
|
||||||
|
pair, last["fast_ma"], last["slow_ma"],
|
||||||
|
)
|
||||||
|
await self._sell(pair, contract, owned["quantity"])
|
||||||
|
else:
|
||||||
|
if cross_up:
|
||||||
|
logger.info(
|
||||||
|
"FOREX BUY %s (fast MA %.5f crossed above slow MA %.5f)",
|
||||||
|
pair, last["fast_ma"], last["slow_ma"],
|
||||||
|
)
|
||||||
|
await self._buy(pair, contract)
|
||||||
|
|
||||||
|
async def _buy(self, pair: str, contract: Contract):
|
||||||
|
ref = f"{self.name}:{pair}"
|
||||||
|
trade = await execute_market_order(self.ib, contract, "BUY", self.units, ref)
|
||||||
|
if trade and trade.orderStatus.filled > 0:
|
||||||
|
self.tracker.record_buy(
|
||||||
|
self.name, pair, trade.orderStatus.filled, trade.orderStatus.avgFillPrice
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _sell(self, pair: str, contract: Contract, quantity: float):
|
||||||
|
ref = f"{self.name}:{pair}"
|
||||||
|
trade = await execute_market_order(self.ib, contract, "SELL", quantity, ref)
|
||||||
|
if trade and trade.orderStatus.filled > 0:
|
||||||
|
self.tracker.record_sell(self.name, pair, trade.orderStatus.filled)
|
||||||
|
|
||||||
|
async def on_tick(self):
|
||||||
|
pass
|
||||||
212
strategies/ma_cross.py
Normal file
212
strategies/ma_cross.py
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from ib_insync import Contract, IB
|
||||||
|
|
||||||
|
from bars import BarManager, is_market_active, parse_bar_size_seconds, to_completed_df
|
||||||
|
from config import config
|
||||||
|
from orders import execute_market_order
|
||||||
|
from state import PositionTracker
|
||||||
|
from strategies.base import BaseStrategy
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MAStockStrategy(BaseStrategy):
|
||||||
|
"""SMA fast/slow golden-cross strategy with ADX trend filter.
|
||||||
|
|
||||||
|
Exits (re-evaluated every cycle while in position):
|
||||||
|
- hard stop loss at -stop_loss_pct (always honoured)
|
||||||
|
- fast MA below slow MA, once profit >= min_profit_pct
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ib: IB, bar_manager: BarManager, tracker: PositionTracker):
|
||||||
|
super().__init__(ib, bar_manager, tracker)
|
||||||
|
self.cfg = config.stock
|
||||||
|
self.fast_period = self.cfg.fast_ma_period
|
||||||
|
self.slow_period = self.cfg.slow_ma_period
|
||||||
|
self.bar_seconds = parse_bar_size_seconds(self.cfg.bar_size)
|
||||||
|
self.contracts: dict[str, Contract] = {}
|
||||||
|
|
||||||
|
async def on_start(self):
|
||||||
|
logger.info(
|
||||||
|
"Stock MA strategy started: symbols=%s, fast=%d, slow=%d, value=$%.0f, "
|
||||||
|
"stop_loss=%.1f%%, min_profit=%.1f%%, confirm_bars=%d",
|
||||||
|
self.cfg.symbols, self.fast_period, self.slow_period, self.cfg.trade_value_usd,
|
||||||
|
self.cfg.stop_loss_pct, self.cfg.min_profit_pct, self.cfg.entry_confirm_bars,
|
||||||
|
)
|
||||||
|
for symbol in self.cfg.symbols:
|
||||||
|
contract = self._contract(symbol)
|
||||||
|
await self.ib.qualifyContractsAsync(contract)
|
||||||
|
if not contract.conId:
|
||||||
|
logger.error("Stock %s: failed to qualify contract, skipped", symbol)
|
||||||
|
continue
|
||||||
|
self.contracts[symbol] = contract
|
||||||
|
await self.bar_manager.subscribe(
|
||||||
|
contract, self.cfg.bar_size, f"{self.cfg.lookback_days} D", "TRADES"
|
||||||
|
)
|
||||||
|
logger.info("Stock %s: subscribed, owned=%s", symbol, self.tracker.get(self.name, symbol))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calc_adx(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
||||||
|
"""ADX with standard Wilder smoothing."""
|
||||||
|
high, low, close = df["high"], df["low"], df["close"]
|
||||||
|
prev_close = close.shift(1)
|
||||||
|
tr = pd.concat([
|
||||||
|
(high - low).abs(),
|
||||||
|
(high - prev_close).abs(),
|
||||||
|
(low - prev_close).abs(),
|
||||||
|
], axis=1).max(axis=1)
|
||||||
|
up_move = high.diff()
|
||||||
|
down_move = -low.diff() # Wilder: positive only when the low moves DOWN
|
||||||
|
plus_dm = ((up_move > down_move) & (up_move > 0)).astype(float) * up_move
|
||||||
|
minus_dm = ((down_move > up_move) & (down_move > 0)).astype(float) * down_move
|
||||||
|
atr = tr.ewm(alpha=1 / period, adjust=False).mean()
|
||||||
|
plus_di = 100 * plus_dm.ewm(alpha=1 / period, adjust=False).mean() / atr
|
||||||
|
minus_di = 100 * minus_dm.ewm(alpha=1 / period, adjust=False).mean() / atr
|
||||||
|
dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, float("nan"))
|
||||||
|
return dx.ewm(alpha=1 / period, adjust=False).mean()
|
||||||
|
|
||||||
|
async def _get_df(self, contract: Contract) -> pd.DataFrame:
|
||||||
|
bars = self.bar_manager.get(contract, self.cfg.bar_size, "TRADES")
|
||||||
|
if bars is None:
|
||||||
|
bars = await self.bar_manager.subscribe(
|
||||||
|
contract, self.cfg.bar_size, f"{self.cfg.lookback_days} D", "TRADES"
|
||||||
|
)
|
||||||
|
return to_completed_df(bars, self.bar_seconds)
|
||||||
|
|
||||||
|
async def on_bar(self):
|
||||||
|
await self._sync_stop_orders()
|
||||||
|
for symbol, contract in self.contracts.items():
|
||||||
|
try:
|
||||||
|
await self._process_symbol(symbol, contract)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Stock %s: error: %s", symbol, e)
|
||||||
|
|
||||||
|
async def _process_symbol(self, symbol: str, contract: Contract):
|
||||||
|
df = await self._get_df(contract)
|
||||||
|
if len(df) < self.slow_period + 28:
|
||||||
|
return
|
||||||
|
if not is_market_active(df, self.bar_seconds):
|
||||||
|
return # market closed / stale data - never trade
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
df["fast_ma"] = df["close"].rolling(self.fast_period).mean()
|
||||||
|
df["slow_ma"] = df["close"].rolling(self.slow_period).mean()
|
||||||
|
df["adx"] = self._calc_adx(df)
|
||||||
|
|
||||||
|
last = df.iloc[-1]
|
||||||
|
prev = df.iloc[-2]
|
||||||
|
fast_above = last["fast_ma"] > last["slow_ma"]
|
||||||
|
|
||||||
|
# entry confirmation: the cross happened (confirm_bars-1) bars back and
|
||||||
|
# the fast MA is still above the slow MA now -> filters 1-bar whipsaws
|
||||||
|
cb = self.cfg.entry_confirm_bars
|
||||||
|
ref = df.iloc[-cb]
|
||||||
|
ref_prev = df.iloc[-cb - 1]
|
||||||
|
cross_then = ref["fast_ma"] > ref["slow_ma"] and ref_prev["fast_ma"] <= ref_prev["slow_ma"]
|
||||||
|
confirmed = cross_then and fast_above
|
||||||
|
|
||||||
|
is_trending = last["adx"] > self.cfg.adx_min
|
||||||
|
slow_rising = True
|
||||||
|
if self.cfg.require_slow_ma_slope and len(df) >= 2:
|
||||||
|
# slow MA must itself be rising over the confirmation window
|
||||||
|
slow_rising = last["slow_ma"] > df["slow_ma"].iloc[-self.cfg.entry_confirm_bars - 1]
|
||||||
|
|
||||||
|
owned = self.tracker.get(self.name, symbol)
|
||||||
|
|
||||||
|
if owned:
|
||||||
|
entry = owned["entry_price"]
|
||||||
|
profit_pct = (last["close"] - entry) / entry * 100
|
||||||
|
|
||||||
|
# soft stop is only a fallback: the exchange-side STP order is primary
|
||||||
|
if not self._has_active_stop(symbol) and last["close"] <= entry * (1 - self.cfg.stop_loss_pct / 100):
|
||||||
|
logger.info(
|
||||||
|
"STOCK STOP-LOSS SELL (soft fallback): %s close=%.2f entry=%.2f (%.2f%%)",
|
||||||
|
symbol, last["close"], entry, profit_pct,
|
||||||
|
)
|
||||||
|
await self._sell(symbol, contract, owned["quantity"])
|
||||||
|
self._mark_stop_cooldown(symbol)
|
||||||
|
elif not fast_above:
|
||||||
|
# re-checked every cycle: exits as soon as profit requirement is met
|
||||||
|
if profit_pct >= self.cfg.min_profit_pct:
|
||||||
|
logger.info(
|
||||||
|
"STOCK SELL: %s (fast MA %.2f < slow MA %.2f, profit=%.2f%%, ADX=%.1f)",
|
||||||
|
symbol, last["fast_ma"], last["slow_ma"], profit_pct, last["adx"],
|
||||||
|
)
|
||||||
|
await self._sell(symbol, contract, owned["quantity"])
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"STOCK SELL WAITING: %s profit %.2f%% < min %.1f%%",
|
||||||
|
symbol, profit_pct, self.cfg.min_profit_pct,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if confirmed and is_trending and slow_rising:
|
||||||
|
if self._in_stop_cooldown(symbol):
|
||||||
|
logger.info("STOCK BUY SKIPPED: %s (stop-out cooldown %d min)",
|
||||||
|
symbol, self.cfg.stop_cooldown_minutes)
|
||||||
|
return
|
||||||
|
value = self._trade_value_usd(symbol)
|
||||||
|
ok, reason = self._can_open_position(symbol, value, last["close"])
|
||||||
|
if not ok:
|
||||||
|
logger.info("STOCK BUY SKIPPED: %s (%s)", symbol, reason)
|
||||||
|
return
|
||||||
|
qty = self._order_quantity(symbol, last["close"])
|
||||||
|
logger.info(
|
||||||
|
"STOCK BUY: %s x%d (cross confirmed over %d bars, fast MA %.2f > slow MA %.2f, ADX=%.1f)",
|
||||||
|
symbol, qty, self.cfg.entry_confirm_bars, last["fast_ma"], last["slow_ma"], last["adx"],
|
||||||
|
)
|
||||||
|
await self._buy(symbol, contract, qty)
|
||||||
|
|
||||||
|
async def _buy(self, symbol: str, contract: Contract, quantity: int):
|
||||||
|
ref = f"{self.name}:{symbol}"
|
||||||
|
trade = await execute_market_order(self.ib, contract, "BUY", quantity, ref)
|
||||||
|
if trade and trade.orderStatus.filled > 0:
|
||||||
|
self.tracker.record_buy(
|
||||||
|
self.name, symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice
|
||||||
|
)
|
||||||
|
if self._use_hard_stop():
|
||||||
|
stop_price = self._target_stop_price(
|
||||||
|
symbol, trade.orderStatus.avgFillPrice, None
|
||||||
|
)
|
||||||
|
await self._place_stop(
|
||||||
|
symbol, contract, trade.orderStatus.filled, stop_price
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# order rejected/timed out (e.g. insufficient buying power):
|
||||||
|
# cool down to avoid retrying every cycle
|
||||||
|
self._mark_cooldown(symbol, "order failed")
|
||||||
|
|
||||||
|
async def _sell(self, symbol: str, contract: Contract, quantity: float):
|
||||||
|
# cancel the hard stop first; it may have filled in the cancel race
|
||||||
|
if self._use_hard_stop():
|
||||||
|
stop_filled, stop_price, cancel_confirmed = await self._cancel_stop(symbol)
|
||||||
|
if stop_filled > 0:
|
||||||
|
logger.info("%s: stop order filled %g during cancel", symbol, stop_filled)
|
||||||
|
self.tracker.record_sell(self.name, symbol, stop_filled, stop_price)
|
||||||
|
quantity -= stop_filled
|
||||||
|
if quantity <= 0:
|
||||||
|
return
|
||||||
|
if not cancel_confirmed:
|
||||||
|
# STP may still be live - a market sell could double-sell;
|
||||||
|
# leave the position for the next cycle
|
||||||
|
logger.warning(
|
||||||
|
"STOCK SELL ABORTED: %s (stop cancel not confirmed - "
|
||||||
|
"may still be active, will retry next cycle)", symbol,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
ref = f"{self.name}:{symbol}"
|
||||||
|
trade = await execute_market_order(self.ib, contract, "SELL", quantity, ref)
|
||||||
|
if trade and trade.orderStatus.filled > 0:
|
||||||
|
self.tracker.record_sell(self.name, symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice)
|
||||||
|
|
||||||
|
async def on_tick(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _contract(self, symbol: str):
|
||||||
|
return Contract(
|
||||||
|
symbol=symbol,
|
||||||
|
secType="STK",
|
||||||
|
exchange=self.cfg.exchange,
|
||||||
|
currency=self.cfg.currency,
|
||||||
|
)
|
||||||
237
strategies/mean_reversion.py
Normal file
237
strategies/mean_reversion.py
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from ib_insync import Contract, IB
|
||||||
|
|
||||||
|
from bars import BarManager, is_market_active, parse_bar_size_seconds, to_completed_df
|
||||||
|
from config import config
|
||||||
|
from orders import execute_market_order
|
||||||
|
from state import PositionTracker
|
||||||
|
from strategies.base import BaseStrategy
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MeanReversionStrategy(BaseStrategy):
|
||||||
|
"""Buy oversold dips (RSI / lower Bollinger / rapid drop), sell on recovery.
|
||||||
|
|
||||||
|
Exits (re-evaluated every cycle while in position):
|
||||||
|
- hard stop loss at -stop_loss_pct (always honoured, overrides min profit)
|
||||||
|
- RSI overbought / price back at Bollinger mid / recovery_pct reached,
|
||||||
|
once profit >= min_profit_pct
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ib: IB, bar_manager: BarManager, tracker: PositionTracker):
|
||||||
|
super().__init__(ib, bar_manager, tracker)
|
||||||
|
self.cfg = config.mean_reversion
|
||||||
|
self.bar_seconds = parse_bar_size_seconds(self.cfg.bar_size)
|
||||||
|
self.contracts: dict[str, Contract] = {}
|
||||||
|
|
||||||
|
async def on_start(self):
|
||||||
|
logger.info(
|
||||||
|
"MeanReversion strategy started: symbols=%s, value=$%.0f, "
|
||||||
|
"RSI(%d, %.0f/%.0f), BB(%d, %.1f), drop=%.1f%%/%dbars, recovery=%.1f%%, "
|
||||||
|
"stop_loss=%.1f%%, confirm_bars=%d",
|
||||||
|
self.cfg.symbols, self.cfg.trade_value_usd,
|
||||||
|
self.cfg.rsi_period, self.cfg.rsi_oversold, self.cfg.rsi_overbought,
|
||||||
|
self.cfg.bb_period, self.cfg.bb_std,
|
||||||
|
self.cfg.rapid_drop_pct, self.cfg.rapid_drop_bars, self.cfg.recovery_pct,
|
||||||
|
self.cfg.stop_loss_pct, self.cfg.entry_confirm_bars,
|
||||||
|
)
|
||||||
|
for symbol in self.cfg.symbols:
|
||||||
|
contract = self._contract(symbol)
|
||||||
|
await self.ib.qualifyContractsAsync(contract)
|
||||||
|
if not contract.conId:
|
||||||
|
logger.error("MeanRev %s: failed to qualify contract, skipped", symbol)
|
||||||
|
continue
|
||||||
|
self.contracts[symbol] = contract
|
||||||
|
await self.bar_manager.subscribe(
|
||||||
|
contract, self.cfg.bar_size, f"{self.cfg.lookback_days} D", "TRADES"
|
||||||
|
)
|
||||||
|
logger.info("MeanRev %s: subscribed, owned=%s", symbol, self.tracker.get(self.name, symbol))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calc_rsi(series: pd.Series, period: int = 14) -> pd.Series:
|
||||||
|
"""RSI with standard Wilder smoothing."""
|
||||||
|
delta = series.diff()
|
||||||
|
gain = delta.where(delta > 0, 0.0)
|
||||||
|
loss = -delta.where(delta < 0, 0.0)
|
||||||
|
avg_gain = gain.ewm(alpha=1 / period, adjust=False).mean()
|
||||||
|
avg_loss = loss.ewm(alpha=1 / period, adjust=False).mean()
|
||||||
|
# no-loss division yields inf -> RSI 100; 0/0 (flat) yields NaN -> no signal
|
||||||
|
rs = avg_gain / avg_loss
|
||||||
|
return 100 - (100 / (1 + rs))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calc_bollinger(series: pd.Series, period: int = 20, num_std: float = 2.0):
|
||||||
|
mid = series.rolling(period).mean()
|
||||||
|
std = series.rolling(period).std()
|
||||||
|
upper = mid + num_std * std
|
||||||
|
lower = mid - num_std * std
|
||||||
|
return mid, upper, lower
|
||||||
|
|
||||||
|
async def _get_df(self, contract: Contract) -> pd.DataFrame:
|
||||||
|
bars = self.bar_manager.get(contract, self.cfg.bar_size, "TRADES")
|
||||||
|
if bars is None:
|
||||||
|
bars = await self.bar_manager.subscribe(
|
||||||
|
contract, self.cfg.bar_size, f"{self.cfg.lookback_days} D", "TRADES"
|
||||||
|
)
|
||||||
|
return to_completed_df(bars, self.bar_seconds)
|
||||||
|
|
||||||
|
async def on_bar(self):
|
||||||
|
await self._sync_stop_orders()
|
||||||
|
for symbol, contract in self.contracts.items():
|
||||||
|
try:
|
||||||
|
await self._process_symbol(symbol, contract)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("MeanRev %s: error: %s", symbol, e)
|
||||||
|
|
||||||
|
async def _process_symbol(self, symbol: str, contract: Contract):
|
||||||
|
df = await self._get_df(contract)
|
||||||
|
min_bars = max(self.cfg.rsi_period, self.cfg.bb_period, self.cfg.rapid_drop_bars,
|
||||||
|
self.cfg.trend_ma_period) + 5
|
||||||
|
if len(df) < min_bars:
|
||||||
|
return
|
||||||
|
if not is_market_active(df, self.bar_seconds):
|
||||||
|
return # market closed / stale data - never trade
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
df["rsi"] = self._calc_rsi(df["close"], self.cfg.rsi_period)
|
||||||
|
df["bb_mid"], df["bb_upper"], df["bb_lower"] = self._calc_bollinger(
|
||||||
|
df["close"], self.cfg.bb_period, self.cfg.bb_std
|
||||||
|
)
|
||||||
|
df["trend_ma"] = df["close"].rolling(self.cfg.trend_ma_period).mean()
|
||||||
|
|
||||||
|
last = df.iloc[-1]
|
||||||
|
owned = self.tracker.get(self.name, symbol)
|
||||||
|
|
||||||
|
if not owned:
|
||||||
|
buy_signal = None
|
||||||
|
|
||||||
|
# entry confirmation: the oversold condition must hold on the last
|
||||||
|
# confirm_bars consecutive bars -> filters 1-bar spikes
|
||||||
|
cb = self.cfg.entry_confirm_bars
|
||||||
|
recent_cb = df.iloc[-cb:]
|
||||||
|
|
||||||
|
if (recent_cb["rsi"] < self.cfg.rsi_oversold).all():
|
||||||
|
buy_signal = "RSI"
|
||||||
|
elif (recent_cb["close"] <= recent_cb["bb_lower"]).all():
|
||||||
|
buy_signal = "Bollinger"
|
||||||
|
elif len(df) > self.cfg.rapid_drop_bars:
|
||||||
|
recent = df.iloc[-self.cfg.rapid_drop_bars - 1:]
|
||||||
|
price_change_pct = (recent.iloc[-1]["close"] - recent.iloc[0]["close"]) / recent.iloc[0]["close"] * 100
|
||||||
|
if price_change_pct <= -self.cfg.rapid_drop_pct:
|
||||||
|
buy_signal = "RapidDrop"
|
||||||
|
|
||||||
|
if buy_signal and last["close"] <= last["trend_ma"]:
|
||||||
|
# trend filter: don't catch falling knives below the slow SMA
|
||||||
|
logger.debug(
|
||||||
|
"MeanRev BUY blocked by trend filter: %s [%s] close %.2f <= SMA%d %.2f",
|
||||||
|
symbol, buy_signal, last["close"], self.cfg.trend_ma_period, last["trend_ma"],
|
||||||
|
)
|
||||||
|
buy_signal = None
|
||||||
|
|
||||||
|
if buy_signal:
|
||||||
|
if self._in_stop_cooldown(symbol):
|
||||||
|
logger.info("MeanRev BUY SKIPPED: %s [%s] (stop-out cooldown %d min)",
|
||||||
|
symbol, buy_signal, self.cfg.stop_cooldown_minutes)
|
||||||
|
return
|
||||||
|
ok, reason = self._can_open_position(symbol, self._trade_value_usd(symbol), last["close"])
|
||||||
|
if not ok:
|
||||||
|
logger.info("MeanRev BUY SKIPPED: %s [%s] (%s)", symbol, buy_signal, reason)
|
||||||
|
return
|
||||||
|
qty = self._order_quantity(symbol, last["close"])
|
||||||
|
logger.info(
|
||||||
|
"MeanRev BUY: %s x%d [%s] RSI=%.1f, close=%.2f, BB_lower=%.2f",
|
||||||
|
symbol, qty, buy_signal, last["rsi"], last["close"], last["bb_lower"],
|
||||||
|
)
|
||||||
|
await self._buy(symbol, contract, qty)
|
||||||
|
else:
|
||||||
|
entry = owned["entry_price"]
|
||||||
|
profit_pct = (last["close"] - entry) / entry * 100
|
||||||
|
|
||||||
|
# stop loss always honoured, overrides min profit;
|
||||||
|
# soft stop is only a fallback: the exchange-side STP order is primary
|
||||||
|
if not self._has_active_stop(symbol) and last["close"] <= entry * (1 - self.cfg.stop_loss_pct / 100):
|
||||||
|
logger.info(
|
||||||
|
"MeanRev STOP-LOSS SELL (soft fallback): %s close=%.2f entry=%.2f (%.2f%%)",
|
||||||
|
symbol, last["close"], entry, profit_pct,
|
||||||
|
)
|
||||||
|
await self._sell(symbol, contract, owned["quantity"])
|
||||||
|
self._mark_stop_cooldown(symbol)
|
||||||
|
return
|
||||||
|
|
||||||
|
sell_signal = None
|
||||||
|
if last["rsi"] > self.cfg.rsi_overbought:
|
||||||
|
sell_signal = "RSI"
|
||||||
|
elif last["close"] >= last["bb_mid"]:
|
||||||
|
sell_signal = "Bollinger"
|
||||||
|
elif profit_pct >= self.cfg.recovery_pct:
|
||||||
|
sell_signal = "Recovery"
|
||||||
|
|
||||||
|
if sell_signal and profit_pct < self.cfg.min_profit_pct:
|
||||||
|
logger.debug(
|
||||||
|
"MeanRev SELL WAITING: %s [%s] profit=%.2f%% < min %.2f%%",
|
||||||
|
symbol, sell_signal, profit_pct, self.cfg.min_profit_pct,
|
||||||
|
)
|
||||||
|
sell_signal = None
|
||||||
|
|
||||||
|
if sell_signal:
|
||||||
|
logger.info(
|
||||||
|
"MeanRev SELL: %s [%s] RSI=%.1f, close=%.2f, entry=%.2f, profit=%.2f%%",
|
||||||
|
symbol, sell_signal, last["rsi"], last["close"], entry, profit_pct,
|
||||||
|
)
|
||||||
|
await self._sell(symbol, contract, owned["quantity"])
|
||||||
|
|
||||||
|
async def _buy(self, symbol: str, contract: Contract, quantity: int):
|
||||||
|
ref = f"{self.name}:{symbol}"
|
||||||
|
trade = await execute_market_order(self.ib, contract, "BUY", quantity, ref)
|
||||||
|
if trade and trade.orderStatus.filled > 0:
|
||||||
|
self.tracker.record_buy(
|
||||||
|
self.name, symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice
|
||||||
|
)
|
||||||
|
if self._use_hard_stop():
|
||||||
|
stop_price = self._target_stop_price(
|
||||||
|
symbol, trade.orderStatus.avgFillPrice, None
|
||||||
|
)
|
||||||
|
await self._place_stop(
|
||||||
|
symbol, contract, trade.orderStatus.filled, stop_price
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# order rejected/timed out (e.g. insufficient buying power):
|
||||||
|
# cool down to avoid retrying every cycle
|
||||||
|
self._mark_cooldown(symbol, "order failed")
|
||||||
|
|
||||||
|
async def _sell(self, symbol: str, contract: Contract, quantity: float):
|
||||||
|
# cancel the hard stop first; it may have filled in the cancel race
|
||||||
|
if self._use_hard_stop():
|
||||||
|
stop_filled, stop_price, cancel_confirmed = await self._cancel_stop(symbol)
|
||||||
|
if stop_filled > 0:
|
||||||
|
logger.info("%s: stop order filled %g during cancel", symbol, stop_filled)
|
||||||
|
self.tracker.record_sell(self.name, symbol, stop_filled, stop_price)
|
||||||
|
quantity -= stop_filled
|
||||||
|
if quantity <= 0:
|
||||||
|
return
|
||||||
|
if not cancel_confirmed:
|
||||||
|
# STP may still be live - a market sell could double-sell;
|
||||||
|
# leave the position for the next cycle
|
||||||
|
logger.warning(
|
||||||
|
"MeanRev SELL ABORTED: %s (stop cancel not confirmed - "
|
||||||
|
"may still be active, will retry next cycle)", symbol,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
ref = f"{self.name}:{symbol}"
|
||||||
|
trade = await execute_market_order(self.ib, contract, "SELL", quantity, ref)
|
||||||
|
if trade and trade.orderStatus.filled > 0:
|
||||||
|
self.tracker.record_sell(self.name, symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice)
|
||||||
|
|
||||||
|
async def on_tick(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _contract(self, symbol: str):
|
||||||
|
return Contract(
|
||||||
|
symbol=symbol,
|
||||||
|
secType="STK",
|
||||||
|
exchange=self.cfg.exchange,
|
||||||
|
currency=self.cfg.currency,
|
||||||
|
)
|
||||||
216
strategies/short_term.py
Normal file
216
strategies/short_term.py
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from ib_insync import Contract, IB
|
||||||
|
|
||||||
|
from bars import BarManager, is_market_active, parse_bar_size_seconds, to_completed_df
|
||||||
|
from config import config
|
||||||
|
from orders import execute_market_order
|
||||||
|
from state import PositionTracker
|
||||||
|
from strategies.base import BaseStrategy
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ShortTermMAVWAPStrategy(BaseStrategy):
|
||||||
|
"""Fast EMA cross above slow EMA with price above daily VWAP.
|
||||||
|
|
||||||
|
Exits (re-evaluated every cycle while in position):
|
||||||
|
- hard stop loss at -stop_loss_pct (always honoured)
|
||||||
|
- max holding period reached
|
||||||
|
- fast EMA below slow EMA, once profit >= min_profit_pct
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ib: IB, bar_manager: BarManager, tracker: PositionTracker):
|
||||||
|
super().__init__(ib, bar_manager, tracker)
|
||||||
|
self.cfg = config.short_term
|
||||||
|
self.fast_period = self.cfg.fast_ma_period
|
||||||
|
self.slow_period = self.cfg.slow_ma_period
|
||||||
|
self.bar_seconds = parse_bar_size_seconds(self.cfg.bar_size)
|
||||||
|
self.contracts: dict[str, Contract] = {}
|
||||||
|
|
||||||
|
async def on_start(self):
|
||||||
|
hold_desc = "unlimited" if self.cfg.max_hold_days <= 0 else f"{self.cfg.max_hold_days} days"
|
||||||
|
logger.info(
|
||||||
|
"Short-term MA+VWAP strategy started: symbols=%s, fast=%d, slow=%d, value=$%.0f, "
|
||||||
|
"max_hold=%s, stop_loss=%.1f%%, min_profit=%.1f%%, confirm_bars=%d",
|
||||||
|
self.cfg.symbols, self.fast_period, self.slow_period, self.cfg.trade_value_usd,
|
||||||
|
hold_desc, self.cfg.stop_loss_pct, self.cfg.min_profit_pct,
|
||||||
|
self.cfg.entry_confirm_bars,
|
||||||
|
)
|
||||||
|
for symbol in self.cfg.symbols:
|
||||||
|
contract = self._contract(symbol)
|
||||||
|
await self.ib.qualifyContractsAsync(contract)
|
||||||
|
if not contract.conId:
|
||||||
|
logger.error("ShortTerm %s: failed to qualify contract, skipped", symbol)
|
||||||
|
continue
|
||||||
|
self.contracts[symbol] = contract
|
||||||
|
await self.bar_manager.subscribe(
|
||||||
|
contract, self.cfg.bar_size, f"{self.cfg.lookback_days} D", "TRADES"
|
||||||
|
)
|
||||||
|
logger.info("ShortTerm %s: subscribed, owned=%s", symbol, self.tracker.get(self.name, symbol))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calc_vwap(df: pd.DataFrame) -> pd.Series:
|
||||||
|
"""VWAP reset each trading day (vectorized, no groupby-apply)."""
|
||||||
|
typical_price = (df["high"] + df["low"] + df["close"]) / 3
|
||||||
|
pv = typical_price * df["volume"]
|
||||||
|
day = df["date"].dt.date
|
||||||
|
cum_pv = pv.groupby(day).cumsum()
|
||||||
|
cum_vol = df["volume"].groupby(day).cumsum()
|
||||||
|
return cum_pv / cum_vol.replace(0, float("nan"))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calc_ema(series: pd.Series, period: int) -> pd.Series:
|
||||||
|
return series.ewm(span=period, adjust=False).mean()
|
||||||
|
|
||||||
|
async def _get_df(self, contract: Contract) -> pd.DataFrame:
|
||||||
|
bars = self.bar_manager.get(contract, self.cfg.bar_size, "TRADES")
|
||||||
|
if bars is None:
|
||||||
|
bars = await self.bar_manager.subscribe(
|
||||||
|
contract, self.cfg.bar_size, f"{self.cfg.lookback_days} D", "TRADES"
|
||||||
|
)
|
||||||
|
return to_completed_df(bars, self.bar_seconds)
|
||||||
|
|
||||||
|
async def on_bar(self):
|
||||||
|
await self._sync_stop_orders()
|
||||||
|
for symbol, contract in self.contracts.items():
|
||||||
|
try:
|
||||||
|
await self._process_symbol(symbol, contract)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("ShortTerm %s: error: %s", symbol, e)
|
||||||
|
|
||||||
|
async def _process_symbol(self, symbol: str, contract: Contract):
|
||||||
|
df = await self._get_df(contract)
|
||||||
|
if len(df) < self.slow_period + 2:
|
||||||
|
return
|
||||||
|
if not is_market_active(df, self.bar_seconds):
|
||||||
|
return # market closed / stale data - never trade
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
df["fast_ema"] = self._calc_ema(df["close"], self.fast_period)
|
||||||
|
df["slow_ema"] = self._calc_ema(df["close"], self.slow_period)
|
||||||
|
df["vwap"] = self._calc_vwap(df)
|
||||||
|
|
||||||
|
last = df.iloc[-1]
|
||||||
|
prev = df.iloc[-2]
|
||||||
|
fast_above = last["fast_ema"] > last["slow_ema"]
|
||||||
|
|
||||||
|
# entry confirmation: the cross happened (confirm_bars-1) bars back and
|
||||||
|
# the fast EMA is still above the slow EMA now -> filters 1-bar whipsaws
|
||||||
|
cb = self.cfg.entry_confirm_bars
|
||||||
|
ref = df.iloc[-cb]
|
||||||
|
ref_prev = df.iloc[-cb - 1]
|
||||||
|
cross_then = ref["fast_ema"] > ref["slow_ema"] and ref_prev["fast_ema"] <= ref_prev["slow_ema"]
|
||||||
|
confirmed = cross_then and fast_above
|
||||||
|
|
||||||
|
price_above_vwap = last["close"] > last["vwap"]
|
||||||
|
|
||||||
|
owned = self.tracker.get(self.name, symbol)
|
||||||
|
|
||||||
|
if owned:
|
||||||
|
entry = owned["entry_price"]
|
||||||
|
profit_pct = (last["close"] - entry) / entry * 100
|
||||||
|
|
||||||
|
# soft stop is only a fallback: the exchange-side STP order is primary
|
||||||
|
if not self._has_active_stop(symbol) and last["close"] <= entry * (1 - self.cfg.stop_loss_pct / 100):
|
||||||
|
logger.info(
|
||||||
|
"ShortTerm STOP-LOSS SELL (soft fallback): %s close=%.2f entry=%.2f (%.2f%%)",
|
||||||
|
symbol, last["close"], entry, profit_pct,
|
||||||
|
)
|
||||||
|
await self._sell(symbol, contract, owned["quantity"])
|
||||||
|
self._mark_stop_cooldown(symbol)
|
||||||
|
return
|
||||||
|
|
||||||
|
entry_date = date.fromisoformat(owned["entry_date"]) if owned.get("entry_date") else None
|
||||||
|
if entry_date and self.cfg.max_hold_days > 0:
|
||||||
|
hold_days = (date.today() - entry_date).days
|
||||||
|
if hold_days >= self.cfg.max_hold_days:
|
||||||
|
logger.info("ShortTerm SELL %s: max hold reached (%d days)", symbol, hold_days)
|
||||||
|
await self._sell(symbol, contract, owned["quantity"])
|
||||||
|
return
|
||||||
|
|
||||||
|
if not fast_above:
|
||||||
|
# re-checked every cycle: exits as soon as profit requirement is met
|
||||||
|
if profit_pct >= self.cfg.min_profit_pct:
|
||||||
|
logger.info(
|
||||||
|
"ShortTerm SELL: %s (fast EMA %.2f < slow EMA %.2f, profit=%.2f%%)",
|
||||||
|
symbol, last["fast_ema"], last["slow_ema"], profit_pct,
|
||||||
|
)
|
||||||
|
await self._sell(symbol, contract, owned["quantity"])
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"ShortTerm SELL WAITING: %s profit %.2f%% < min %.1f%%",
|
||||||
|
symbol, profit_pct, self.cfg.min_profit_pct,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if confirmed and price_above_vwap:
|
||||||
|
if self._in_stop_cooldown(symbol):
|
||||||
|
logger.info("ShortTerm BUY SKIPPED: %s (stop-out cooldown %d min)",
|
||||||
|
symbol, self.cfg.stop_cooldown_minutes)
|
||||||
|
return
|
||||||
|
ok, reason = self._can_open_position(symbol, self._trade_value_usd(symbol), last["close"])
|
||||||
|
if not ok:
|
||||||
|
logger.info("ShortTerm BUY SKIPPED: %s (%s)", symbol, reason)
|
||||||
|
return
|
||||||
|
qty = self._order_quantity(symbol, last["close"])
|
||||||
|
logger.info(
|
||||||
|
"ShortTerm BUY: %s x%d (cross confirmed over %d bars, fast EMA %.2f > slow EMA %.2f, close %.2f > VWAP %.2f)",
|
||||||
|
symbol, qty, self.cfg.entry_confirm_bars,
|
||||||
|
last["fast_ema"], last["slow_ema"], last["close"], last["vwap"],
|
||||||
|
)
|
||||||
|
await self._buy(symbol, contract, qty)
|
||||||
|
|
||||||
|
async def _buy(self, symbol: str, contract: Contract, quantity: int):
|
||||||
|
ref = f"{self.name}:{symbol}"
|
||||||
|
trade = await execute_market_order(self.ib, contract, "BUY", quantity, ref)
|
||||||
|
if trade and trade.orderStatus.filled > 0:
|
||||||
|
self.tracker.record_buy(
|
||||||
|
self.name, symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice
|
||||||
|
)
|
||||||
|
if self._use_hard_stop():
|
||||||
|
stop_price = self._target_stop_price(
|
||||||
|
symbol, trade.orderStatus.avgFillPrice, None
|
||||||
|
)
|
||||||
|
await self._place_stop(
|
||||||
|
symbol, contract, trade.orderStatus.filled, stop_price
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# order rejected/timed out (e.g. insufficient buying power):
|
||||||
|
# cool down to avoid retrying every cycle
|
||||||
|
self._mark_cooldown(symbol, "order failed")
|
||||||
|
|
||||||
|
async def _sell(self, symbol: str, contract: Contract, quantity: float):
|
||||||
|
# cancel the hard stop first; it may have filled in the cancel race
|
||||||
|
if self._use_hard_stop():
|
||||||
|
stop_filled, stop_price, cancel_confirmed = await self._cancel_stop(symbol)
|
||||||
|
if stop_filled > 0:
|
||||||
|
logger.info("%s: stop order filled %g during cancel", symbol, stop_filled)
|
||||||
|
self.tracker.record_sell(self.name, symbol, stop_filled, stop_price)
|
||||||
|
quantity -= stop_filled
|
||||||
|
if quantity <= 0:
|
||||||
|
return
|
||||||
|
if not cancel_confirmed:
|
||||||
|
# STP may still be live - a market sell could double-sell;
|
||||||
|
# leave the position for the next cycle
|
||||||
|
logger.warning(
|
||||||
|
"ShortTerm SELL ABORTED: %s (stop cancel not confirmed - "
|
||||||
|
"may still be active, will retry next cycle)", symbol,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
ref = f"{self.name}:{symbol}"
|
||||||
|
trade = await execute_market_order(self.ib, contract, "SELL", quantity, ref)
|
||||||
|
if trade and trade.orderStatus.filled > 0:
|
||||||
|
self.tracker.record_sell(self.name, symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice)
|
||||||
|
|
||||||
|
async def on_tick(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _contract(self, symbol: str):
|
||||||
|
return Contract(
|
||||||
|
symbol=symbol,
|
||||||
|
secType="STK",
|
||||||
|
exchange=self.cfg.exchange,
|
||||||
|
currency=self.cfg.currency,
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user