peさんのNoteを解読する②
peさんのNote「pybottersとasyncioでpenny jump!」を解読する。
元の記事
ストラテジー
penny jumpの具体的なストラテジーは以下の通り
1. 板を監視し、厚い板(サイズ、最良気配値との距離が閾値を超える注文)が発生したことを確認し、その板の手前(marginだけずらして)にエントリー指値を出す。
2. 厚い板がなくなったらエントリー指値をキャンセル
3. エントリー指値が約定したら最良気配値にエグジット指値を出す。
メイン関数の構成
Ⅰ. イベントを監視するための関数(watch)
Ⅱ. 注文に関する関数(limit_order - compute_pnl)
Ⅲ. ロジックに関する関数
Ⅳ. メイン関数呼び出し時に稼働する部分
Ⅰ. イベントを監視するための関数
watch関数には
order → 出した注文の約定orキャンセルを監視
wall → 厚い板の有無を監視
という2つの機能があり、これらはwatch関数内で関数として定義されている。
watch(イベント, イベントを定義する関数の引数)
で、イベントの監視を非同期のタスクにスケジューリングできる。
order関数は、子注文のDataStoreを監視し更新されたタイミングで指定したorder_idと一致した注文であり、event_typeがEXECUTION, CANCEL, ORDER_FAILEDのいずれかの時に更新されたデータを返す関数である。
wall関数は、指定したside, priceの板が消えるか、サイズが閾値以下となるまで監視する関数である。
Ⅱ. 注文に関する関数
limit_order関数、cancel_order関数については以下を参照
market_order関数は、指定した情報に従って、成行注文を出す関数である。また、wait_executionがTrueの場合、約定した情報を返すようになっている。
best_price関数は、指定したsideのベスト価格からmarginだけ離れた価格を返す関数である。
compute_pnl関数は、指定したエントリー価格、エグジット価格から1トレードの損益を返す関数である。なお、おそらくこの実装だとトレードのポジションサイズは考慮されていない気がするのでsize * compute_pnlを損益とした方がいい気がする。
Ⅲ. ロジックに関する関数
_cancel_otherwise_market関数は、エントリー指値が刺さらないままタイムアウトした場合or厚い板が消えた場合に呼び出され、板の監視をタスクから外し、注文をキャンセルしてその情報を返す関数である。キャンセルに失敗した場合は、いつの間にか注文が約定した場合なので成行で決済する。
# logic
async def penny_jump(side: str, price: int):
if side == "BUY":
entry_price = price + args.margin
exit_side = "SELL"
else:
entry_price = price - args.margin
exit_side = "BUY"
# 逆ポジションを持っていればその分を新規注文で打ち消す(簡易部分約定対策)
entry_size = args.size + sum(
[x["size"] for x in store.positions.find({"side": exit_side})]
)
logger.debug(
f"start penny jump: side={side} wall={price:.0f} entry={entry_price:.0f}"
)
# 壁監視タスク
wall_watcher = watch("wall", side, price)
# リードタイム分待機
done, pending = await asyncio.wait([wall_watcher], timeout=args.lead_time)
if done:
# 壁がすでにないのでエントリーせずに終了
logger.debug(f"the wall already disappeared before ordering. next.")
wall_watcher.cancel()
return None, None
# エントリー
entry_order_id = await limit_order(side, entry_size, entry_price)
entry_watcher = watch("order", entry_order_id)
# 注文約定 or 壁消失待機
done, pending = await asyncio.wait(
[entry_watcher, wall_watcher],
timeout=args.expire_seconds,
return_when=asyncio.FIRST_COMPLETED,
)
# 時間切れ
if len(done) == 0:
logger.debug("timeout")
return await _cancel_otherwise_market()
if not entry_watcher.done():
# entry_watcherが終了していない
# => wall_watcherは終了している
# => 壁消失 + 注文未約定
# => 注文キャンセル
return await _cancel_otherwise_market()
logger.debug(f"success entry. go exit.")
# entry_watcherが終了している = 約定 = 最良気配値で決済注文
exit_order_id = await limit_order(
exit_side, args.size, best_price(exit_side)
)
exit_watcher = watch("order", exit_order_id)
# 注文約定 or 壁消失待機
await asyncio.wait(
[exit_watcher, wall_watcher], return_when=asyncio.FIRST_COMPLETED
)
# watchタスクが残らないように掃除
entry_watcher.cancel()
exit_watcher.cancel()
wall_watcher.cancel()
if exit_watcher.done():
# 決済指値約定
return entry_watcher.result(), exit_watcher.result()
else:
# 壁崩壊寸前+決済指値未約定 = 指値取消+成行逃走
# キャンセルを確認してからmarket_orderすると二重約定を防げる。が、そのうちに壁がなく
# なってしまうかもしれないのでここではキャンセルと成行を同時に出している。
_, exit_result = await asyncio.gather(
cancel_order(exit_order_id),
market_order(exit_side, args.size, wait_execution=True),
)
return entry_watcher.result(), exit_result
penny_jump関数は、
1. 板の監視を開始
↓
2. 厚い板がない場合はNoneを返す。
3. 厚い板が発生している場合は指定した情報に従ってエントリー指値を出し、エントリー注文の監視を開始する。
↓
4. エントリー指値が刺さらないままタイムアウトした場合or厚い板が消えた場合、板の監視をタスクから外し、注文をキャンセルしてその情報を返す
5. エントリー指値が約定した場合、ベスト価格でエグジット指値を出しエグジット注文の監視を開始する。
↓
6. エグジット指値が約定した場合はエントリー注文の情報、エグジット注文の情報を返す。
7. エグジット指値が約定せず、厚い板が消えた場合はエグジット指値をキャンセル&成行決済し、エントリー注文の情報、エグジット注文の情報を返す。
のような手順で動いている。
Ⅳ. メイン関数呼び出し時に稼働する部分
前半部分でwebsocketに接続し、板情報、子注文の購読をしている。そして、購読が開始されるまで待機する。
後半部分では、板情報を監視し、新規注文によって厚い板が発生するかを観察し、厚い板が発生した場合はpenny_jump関数で注文を出しトレードを行い、トレードが終了したら損益を計算し出力している。最後に不必要になった板情報をキューから取り除いている。
以上