見出し画像

さよなら手動トレード:Docker×FastAPIで育てた EdgeX 用Botの制作記


「人間の手は震えるが、ボットは震えない」
EdgeX の SDK でボリンジャーバンド戦略を実装し、FastAPI ダッシュボードとリアルタイムログ付きで運用開始するまでの記録です。
最後は DRY_RUN→実発注へ切り替え。
失敗談も交えて、セットアップ手順と設計ポイントをまとめました。

想定読者

  • これから自作トレードボットを作ってみたい人

  • コンテナで “再現性のある運用” を始めたい人

  • 最小構成のダッシュボードを自前で用意したい人




1. 作ったもの(機能まとめ)

  • ボリンジャーバンド(窓/標準偏差を UI から変更)× ミドル復帰決済のシンプル戦略

  • TP/SL を利益率で指定日次ドローダウン上限で新規エントリー自動停止

  • FastAPI ダッシュボード(/ui)で設定保存・Start/Stop・ステータス表示

  • SSE(Server-Sent Events)で画面下部にログが流れる

  • DRY_RUN ←→ 実発注の切替

  • おまけ:簡易バックテスト API


2. 技術スタックと全体像

  • 言語:Python 3.11

  • Web:FastAPI(Uvicorn)

  • UI:シンプルな HTML + PicoCSS(CDN)

  • ログ配信:SSE(/logs/stream)

  • コンテナ:Docker(python:3.11-slim)

  • 依存:edgex-python-sdk, python-dotenv, pandas, fastapi, uvicorn

アーキテクチャ(概念図)

UI (/ui) ──HTTP──> FastAPI (/status, /config, /start, /stop, /backtest)
                     │
                     ├─ publish() → SSE (/logs/stream)  ← UIはここを購読
                     └─ bot.py(BB戦略、TP/SL、日次上限、状態保存)

3. セットアップ手順(Docker 5分クッキング)

# 1) ビルド
docker build -t edgex-bot .

# 2) 既存コンテナがあれば停止/削除(任意)
docker rm -f edgex-bot 2>$null

# 3) 起動
docker run -d --name edgex-bot `
  --restart always `
  --env-file .env `
  -e PYTHONUNBUFFERED=1 `
  -p 8080:8080 `
  -v "$(${PWD}.Path):/app" `
  edgex-bot

# 4) ヘルスチェック
Invoke-RestMethod http://localhost:8080/

# 5) UI を開く
Start-Process "http://localhost:8080/ui"

.env の主なキー:

  • EDGEX_BASE_URL, PRIVATE_KEY, EDGEX_ACCOUNT_ID/WALLET_ADDRESS

  • EDGEX_CONTRACT_ID(例:BTCUSD → 10000001)

  • 初期パラメタ:DRY_RUN=true, LOT_SIZE=0.002, STOP_LOSS_PCT=0.005, TAKE_PROFIT_PCT=0.01 など


4. ダッシュボードの使い方

  • Status:Running/Stopped、Position、Entry、日次 Realized PnL、上限ブロック

  • Runtime Config

    • LOT、TP%、SL%、BB Window、BB Std、Candle Limit、Timeframe、Dry Run

    • %入力は UI 側で 100→1.00 に換算してサーバへ

  • Start / Stop:即時反映(内部では asyncio.create_task で bot 起動)

  • Logs:ページ下部で ライブに流れる(SSE)。[loop]/🟢/🔴/⚪/❌ などの実行ログを確認できます。


5. 実発注への切り替えポイント(重要)

  1. DRY_RUN を false に変更(UI か .env)

  2. 秘密鍵や URL・アカウント ID を再確認

  3. 最初は LOT を極小で試行 → 期待通りの挙動かログで確認

  4. 日次損失上限(MAX_DAILY_LOSS_USDT)を設定し、強制ストッパーを用意

参考:日次は Asia/Tokyo の日付で自動リセット


6. 実装メモ(戦略・損益管理・ログ)

  • 戦略

    • BB 下限割れでロング、上限超えでショート

    • ミドル(SMA)復帰でクローズ

    • TP/SL はエントリー価格の比率で判定、成立時は即 Close

  • 損益

    • 1約定ごとに概算 PnL(手数料考慮)を日次合算

    • 上限超過で新規エントリー停止(クローズは許可)

  • ログ

    • print しつつ logbus に publish → UI へ SSE

    • 例:

      • [loop] candles=100 last=... BB=(...)

      • 🟢 ロングエントリー, 🔴 ショートエントリー

      • ⚪ クローズ, ❌ 損切

      • 🚧 新規停止(上限超過)


7. つまずきメモ Best 5

  1. uvicorn 入れ忘れ → リスタート地獄。requirements.txtに明記。

  2. PowerShell の改行 → Linux の \ を持ち込んで失敗。バッククォート(`)か1行で。

  3. /bot/start 404 → 互換エンドポイントを追加して解決。

  4. ログが流れない → SSE 実装で一撃解決。

  5. 日次リセット → TZ を Asia/Tokyo に固定して安定化。


8. 今後の拡張

  • 未実現 PnL / 現値のリアルタイム表示

  • トレーリングストップ分割利確

  • Prometheus/Grafana でメトリクス可視化

  • WebSocket でティック取得 → レイテンシ短縮


9. まとめ

  • 最小構成でも “見える化” を徹底すると運用が一気に楽になる

  • ログは正義:SSE で “F5 いらず” の世界へ

  • ボットはブレない。でも人間は設計で守る。
    小さく始めて、少しずつ強くしていきましょう。

  • Good luck & Good risk! ☕️📈


おまけ:よく使うコマンド

# 再ビルド & 再起動
docker build -t edgex-bot .
docker rm -f edgex-bot 2>$null
docker run -d --name edgex-bot `
  --restart always `
  --env-file .env `
  -e PYTHONUNBUFFERED=1 `
  -p 8080:8080 `
  -v "$(${PWD}.Path):/app" `
  edgex-bot

# API quick check
Invoke-RestMethod -Method POST http://localhost:8080/start
Invoke-RestMethod -Method POST http://localhost:8080/stop
Invoke-RestMethod -Method GET  http://localhost:8080/status

Bot.py 全文のコードです。Pythonプログラムが動く環境とedgexの取引高が一定量ないとAPIが使えなかったはずです。

# -*- coding: utf-8 -*-
# bot.py
# EdgeX 自動売買ボット
# 対応MODE:
# - breakout_fsm_v1  ★ FSM + 新足ガード + 理由ログ(完成形)
#
# 重要:
# - 15m Klineは /quote/get_k_line から取得
# - 新足(klineTime)更新時だけFSMを進める(新足ガード)
# - volume判定をログで可視化(理由ログ)
# - klineがAPIで「新しい順」で返っても、timeで昇順ソートして正規化する

import os
import json
import asyncio
from datetime import datetime, timezone
from typing import Optional

import pandas as pd
from fastapi import FastAPI
from pydantic import BaseModel
import uvicorn

from edgex_sdk.client import Client
from edgex_sdk.order.types import (
    CreateOrderParams,
    OrderSide,
    OrderType,
)

print("=== BOT.PY LOADED ===")

# ============================================================
# 環境変数
# ============================================================
EDGEX_BASE_URL = os.getenv("EDGEX_BASE_URL", "https://pro.edgex.exchange")

# 互換: 以前の変数名にも対応
PRIVATE_KEY = (
    os.getenv("EDGEX_SIGNER_PRIVKEY")
    or os.getenv("STARK_PRIVATE_KEY")
    or os.getenv("PRIVATE_KEY")
)

ACCOUNT_ID_RAW = os.getenv("EDGEX_ACCOUNT_ID")
CONTRACT_ID = os.getenv("EDGEX_CONTRACT_ID", "10000001")

TIMEFRAME = os.getenv("TIMEFRAME", "15m")  # 表示用(実際の取得は15m固定)
MODE = os.getenv("MODE", "breakout_fsm_v1")
DRY_RUN = os.getenv("DRY_RUN", "true").lower() == "true"

LOOP_SLEEP = int(os.getenv("LOOP_SLEEP", "10"))
STATE_FILE = "runtime_state.json"

# klineのサンプルログを新足のときに出すか(デフォルトOFF)
DEBUG_KLINE_SAMPLE = os.getenv("DEBUG_KLINE_SAMPLE", "false").lower() == "true"

# ============================================================
# Breakout FSM パラメータ
# ============================================================
BO_RANGE_N = int(os.getenv("BO_RANGE_N", "20"))
BO_CONFIRM_BODIES = int(os.getenv("BO_CONFIRM_BODIES", "2"))
BO_CONFIRM_MAX_BARS = int(os.getenv("BO_CONFIRM_MAX_BARS", "3"))

BO_VOL_M = int(os.getenv("BO_VOL_M", "20"))
BO_VOL_MULT = float(os.getenv("BO_VOL_MULT", "1.5"))

BO_LOT_SIZE = float(os.getenv("BO_LOT_SIZE", os.getenv("BO_ORDER_QTY", "0.03")))
BO_TP_PCT = float(os.getenv("BO_TP_PCT", "0.006"))
BO_SL_PCT = float(os.getenv("BO_SL_PCT", "0.003"))

BO_MAX_HOLD_BARS = int(os.getenv("BO_MAX_HOLD_BARS", "16"))
BO_COOLDOWN_BARS = int(os.getenv("BO_COOLDOWN_BARS", "4"))

# ============================================================
# 状態管理
# ============================================================
def load_state():
    if os.path.exists(STATE_FILE):
        try:
            with open(STATE_FILE, "r", encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            return {}
    return {}


def save_state(st):
    try:
        with open(STATE_FILE, "w", encoding="utf-8") as f:
            json.dump(st, f, ensure_ascii=False, indent=2)
    except Exception:
        # 状態保存失敗は致命ではないので黙って継続
        pass


runtime_state = load_state()

# ============================================================
# ログ
# ============================================================
def log(*args):
    ts = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
    print(ts, "|", *args, flush=True)


# ============================================================
# Client
# ============================================================
def build_client() -> Client:
    if not PRIVATE_KEY:
        raise RuntimeError("PRIVATE_KEY / EDGEX_SIGNER_PRIVKEY 未設定")
    if not ACCOUNT_ID_RAW:
        raise RuntimeError("EDGEX_ACCOUNT_ID 未設定")

    # edgex_sdk.client.Client の signature:
    # (base_url: str, account_id: int, stark_private_key: str, signing_adapter: Optional[...] = None, timeout: float = 30.0)
    return Client(
        base_url=EDGEX_BASE_URL,
        account_id=int(ACCOUNT_ID_RAW),
        stark_private_key=PRIVATE_KEY,
    )


# ============================================================
# Kline
# ============================================================
async def fetch_klines_15m(client: Client, limit: int):
    """
    15分足を取得して内部フォーマットに正規化する:
    {open, high, low, close, volume, time}

    - time: klineTime(ms) を int
    - volume: value を float(EdgeXのレスポンスで value が出来高($換算)に相当)
    - APIが「新しい順/古い順」どちらで返しても time で昇順に並べ替える(重要)
    """
    from edgex_sdk.quote.client import GetKLineParams, KlineType

    params = GetKLineParams(
        contract_id=str(CONTRACT_ID),
        kline_type=KlineType.MINUTE_15,
        size=limit,
    )
    resp = await client.quote.get_k_line(params)

    data = resp["data"]["dataList"] if resp and resp.get("data") else []
    kl = []
    for r in data:
        kl.append(
            {
                "open": float(r["open"]),
                "high": float(r["high"]),
                "low": float(r["low"]),
                "close": float(r["close"]),
                "volume": float(r.get("value") or 0.0),
                "time": int(r.get("klineTime") or 0),
            }
        )

    # ★最重要:time昇順に正規化(これで「最後の行=最新足」が保証される)
    kl.sort(key=lambda x: x["time"])
    return kl


# ============================================================
# 発注(疑似成行)
# ============================================================
async def place_market_like(client: Client, side: str, qty: float, reduce_only: bool):
    if DRY_RUN:
        log(f"[DRY] {side.upper()} {qty} reduce_only={reduce_only}")
        return

    sd = OrderSide.BUY if side == "buy" else OrderSide.SELL
    params = CreateOrderParams(
        contract_id=str(CONTRACT_ID),
        size=str(qty),
        side=sd,
        type=OrderType.MARKET,
        reduce_only=reduce_only,
    )
    await client.create_order(params)


# ============================================================
# Breakout FSM
# ============================================================
BO_WAIT = "WAIT"
BO_CAND = "CANDIDATE"
BO_CONFIRM = "CONFIRM"
BO_INPOS = "IN_POS"


def bo_reset(st):
    """
    FSMをWAITへ戻す(ルール: 決済後は必ずWAITへ)
    """
    st["breakout"] = {
        "state": BO_WAIT,
        "dir": None,          # "UP" / "DOWN"
        "entry": None,
        "bars": 0,
        "confirm": 0,
        "cooldown": 0,

        # ★ 新足ガード用(同一klineTimeの二重処理を防ぐ)
        "last_kline_time": 0,

        # 理由ログのために「直近の非エントリー理由」を残しても良い
        "last_reason": "",
    }


async def breakout_fsm_v1_loop(client: Client):
    st = runtime_state
    st["running"] = True
    st.setdefault("breakout", {})

    if not st["breakout"]:
        bo_reset(st)
    else:
        # 既存stateに不足キーがあれば補完
        st["breakout"].setdefault("last_kline_time", 0)
        st["breakout"].setdefault("cooldown", 0)
        st["breakout"].setdefault("state", BO_WAIT)
        st["breakout"].setdefault("confirm", 0)
        st["breakout"].setdefault("bars", 0)
        st["breakout"].setdefault("last_reason", "")

    save_state(st)
    log("[run_bot] client ready")
    log(f"[hb] mode={MODE} tf={TIMEFRAME} dry={DRY_RUN} BO_VOL_MULT={BO_VOL_MULT}")

    while st["running"]:
        # 必要本数: range + vol + 余裕
        kl = await fetch_klines_15m(client, BO_RANGE_N + BO_VOL_M + 5)
        df = pd.DataFrame(kl)

        if len(df) < (BO_RANGE_N + 2):
            log("[bo] skip: not enough klines")
            await asyncio.sleep(LOOP_SLEEP)
            continue

        # 直近足(最新) ※fetch側でtime昇順にしているので df.iloc[-1] が必ず最新
        last = df.iloc[-1]
        close = float(last["close"])
        open_ = float(last["open"])
        volume = float(last["volume"])
        cur_t = int(last["time"] or 0)

        bo = st["breakout"]

        # ========================================================
        # ★ 新足ガード(最重要)
        # - 同じ足を10秒ごとに評価して confirm が増える等の事故を防ぐ
        # ========================================================
        if cur_t == 0:
            bo["last_reason"] = "kline time is 0 (normalize issue)"
            log("[bo] skip:", bo["last_reason"])
            await asyncio.sleep(LOOP_SLEEP)
            continue

        if bo.get("last_kline_time", 0) == cur_t:
            # 同一足なので何もしない(静かに待つ)
            await asyncio.sleep(LOOP_SLEEP)
            continue

        # 新足に更新
        bo["last_kline_time"] = cur_t

        # 新足のときだけサンプル表示(必要なら)
        if DEBUG_KLINE_SAMPLE:
            log("[kline] sample:", df.tail(2).to_dict("records"))

        # レンジ(過去N本。最新足は除く)
        rng = df.iloc[-(BO_RANGE_N + 1):-1]
        hi = float(rng["high"].max())
        lo = float(rng["low"].min())

        # 出来高判定(最新足は除いた平均と、最新足volume比較)
        vol_avg = float(df.iloc[-(BO_VOL_M + 1):-1]["volume"].mean())
        vol_ok = (volume >= vol_avg * BO_VOL_MULT) if vol_avg > 0 else False

        state = bo.get("state", BO_WAIT)

        # ========================================================
        # ★ 理由ログ(これで「なぜ遷移しないか」が一目で分かる)
        # ========================================================
        log(
            f"[bo] close={close:.2f} state={state} hi={hi:.2f} lo={lo:.2f} "
            f"vol={volume:.1f} avg={vol_avg:.1f} mult={BO_VOL_MULT} vol_ok={vol_ok} "
            f"time={cur_t}"
        )

        # ======================
        # 状態①:待機
        # ======================
        if state == BO_WAIT:
            if bo.get("cooldown", 0) > 0:
                bo["cooldown"] = int(bo["cooldown"]) - 1
                bo["last_reason"] = f"cooldown({bo['cooldown']})"
            else:
                if close > hi:
                    bo.update({"state": BO_CAND, "dir": "UP"})
                    bo["last_reason"] = "breakout candidate: UP"
                elif close < lo:
                    bo.update({"state": BO_CAND, "dir": "DOWN"})
                    bo["last_reason"] = "breakout candidate: DOWN"
                else:
                    bo["last_reason"] = "inside range"

        # ======================
        # 状態②:ブレイク候補
        # ======================
        elif state == BO_CAND:
            # ライン内に戻ったらWAITへ
            if lo <= close <= hi:
                # 候補→待機は仕様(「巻き戻さない」ルールに反しない)
                prev_last = cur_t
                bo_reset(st)
                st["breakout"]["last_kline_time"] = prev_last
                st["breakout"]["last_reason"] = "candidate canceled: returned inside range"
            else:
                bo.update({"state": BO_CONFIRM, "confirm": 0, "bars": 0})
                bo["last_reason"] = "enter CONFIRM"

        # ======================
        # 状態③:確認
        # ======================
        elif state == BO_CONFIRM:
            bo["bars"] = int(bo.get("bars", 0)) + 1
            bo.setdefault("confirm", 0)

            # 実体方向(終値 > 始値 なら上、逆なら下)
            body_dir = 1 if close > open_ else -1 if close < open_ else 0

            # 方向一致ならconfirm加算
            if (body_dir == 1 and bo.get("dir") == "UP") or (body_dir == -1 and bo.get("dir") == "DOWN"):
                bo["confirm"] = int(bo["confirm"]) + 1

            # 条件OKならエントリー(volume条件もここでチェック)
            if int(bo["confirm"]) >= BO_CONFIRM_BODIES and vol_ok:
                side = "buy" if bo.get("dir") == "UP" else "sell"
                await place_market_like(client, side, BO_LOT_SIZE, reduce_only=False)
                bo.update({"state": BO_INPOS, "entry": close, "bars": 0})
                st["position"] = "LONG" if side == "buy" else "SHORT"
                bo["last_reason"] = f"ENTRY {st['position']} (confirm={bo['confirm']}, vol_ok={vol_ok})"

            else:
                # エントリーしない理由を明示
                reasons = []
                if int(bo["confirm"]) < BO_CONFIRM_BODIES:
                    reasons.append(f"confirm不足({bo['confirm']}/{BO_CONFIRM_BODIES})")
                if not vol_ok:
                    reasons.append("vol不足")
                bo["last_reason"] = "no entry: " + ",".join(reasons)

                # 期限切れならWAITへ
                if int(bo["bars"]) > BO_CONFIRM_MAX_BARS:
                    prev_last = cur_t
                    bo_reset(st)
                    st["breakout"]["last_kline_time"] = prev_last
                    st["breakout"]["last_reason"] = f"confirm timeout({BO_CONFIRM_MAX_BARS})"
                    st["position"] = None

        # ======================
        # 状態④:エントリー後管理
        # ======================
        elif state == BO_INPOS:
            bo["bars"] = int(bo.get("bars", 0)) + 1
            entry = float(bo.get("entry") or close)

            pos = st.get("position")

            if pos == "LONG":
                tp = entry * (1 + BO_TP_PCT)
                sl = entry * (1 - BO_SL_PCT)
                hit = (close >= tp) or (close <= sl)
                exit_side = "sell"
            else:
                # SHORT or None(念のため)
                tp = entry * (1 - BO_TP_PCT)
                sl = entry * (1 + BO_SL_PCT)
                hit = (close <= tp) or (close >= sl)
                exit_side = "buy"

            if hit:
                bo["last_reason"] = "EXIT: TP/SL hit"
            elif int(bo["bars"]) >= BO_MAX_HOLD_BARS:
                bo["last_reason"] = f"EXIT: time stop ({BO_MAX_HOLD_BARS} bars)"
            else:
                bo["last_reason"] = "in position: monitoring"
                save_state(st)
                await asyncio.sleep(LOOP_SLEEP)
                continue

            # 決済
            await place_market_like(client, exit_side, BO_LOT_SIZE, reduce_only=True)

            prev_last = cur_t
            bo_reset(st)
            st["breakout"]["last_kline_time"] = prev_last
            st["breakout"]["cooldown"] = BO_COOLDOWN_BARS
            st["position"] = None

        else:
            # 不明stateは安全側でWAITへ
            prev_last = cur_t
            bo_reset(st)
            st["breakout"]["last_kline_time"] = prev_last
            st["breakout"]["last_reason"] = "unknown state -> reset"

        save_state(st)
        await asyncio.sleep(LOOP_SLEEP)


# ============================================================
# FastAPI
# ============================================================
app = FastAPI()
bot_task: Optional[asyncio.Task] = None


class StartPayload(BaseModel):
    MODE: Optional[str] = None
    DRY_RUN: Optional[bool] = None
    TIMEFRAME: Optional[str] = None


@app.get("/status")
async def status():
    # 追加で可視化したい値があればここに
    return runtime_state


@app.post("/start")
async def start(payload: StartPayload):
    global bot_task, MODE, DRY_RUN, TIMEFRAME

    if payload.MODE:
        MODE = payload.MODE
    if payload.TIMEFRAME:
        TIMEFRAME = payload.TIMEFRAME
    if payload.DRY_RUN is not None:
        DRY_RUN = payload.DRY_RUN

    runtime_state["running"] = True
    runtime_state["mode"] = MODE
    runtime_state["timeframe"] = TIMEFRAME
    runtime_state["dry_run"] = DRY_RUN
    save_state(runtime_state)

    client = build_client()

    # 二重起動を避ける
    if bot_task and not bot_task.done():
        return {
            "status": "already running",
            "mode": MODE,
            "dry_run": DRY_RUN,
            "timeframe": TIMEFRAME,
        }

    if MODE != "breakout_fsm_v1":
        return {"status": f"unsupported MODE: {MODE} (use breakout_fsm_v1)"}

    bot_task = asyncio.create_task(breakout_fsm_v1_loop(client))

    return {
        "status": "bot started",
        "mode": MODE,
        "dry_run": DRY_RUN,
        "timeframe": TIMEFRAME,
    }


@app.post("/stop")
async def stop():
    runtime_state["running"] = False
    save_state(runtime_state)
    return {"status": "stopped"}


if __name__ == "__main__":
    uvicorn.run("bot:app", host="0.0.0.0", port=8080)


いいなと思ったら応援しよう!

noboru 応援ありがとうございます。