複数戦略・複数botの資金を分離管理する
サブアカウント・資金移動API・HRP配分

1つの口座で複数のbotを走らせると、1戦略の事故が全資産を巻き込みます。UTAの証拠金は口座内で共有されるため、片方の含み損がもう片方の清算を引き起こすこともあります。この記事ではサブアカウントによる資金の物理的な分離、create_internal_transfer と create_universal_transfer の使い分け、そしてHRP(階層的リスクパリティ)による戦略間ウェイトの自動リバランスまでをサンプルコード7本で実装します。

確認日
2026-07-31
API バージョン
Bybit V5
Python
3.10 以上
依存
pybit / numpy / scipy

※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。

目次

  1. なぜ資金を分離するのか
  2. サブアカウントとAPIキーの整備
  3. 同一UID内の資金移動
  4. UID間の資金移動
  5. 全サブアカウント横断の残高集計
  6. HRPによる戦略間ウェイト
  7. 閾値リバランサと監査
  8. よくある疑問Q&A

なぜ資金を分離するのか

分離の方法証拠金APIキー事故の波及手間
分離しない(1口座) 全戦略で共有 1つ 全資産に波及 なし
分離マージン ポジション単位で独立 1つ ポジション内で完結 小
サブアカウント UID単位で完全独立 UIDごと 波及しない 中(資金移動が必要)
別取引所 完全独立 別々 波及しない 大
サブアカウントで得られるもの・得られないもの

得られるもの: 証拠金の完全分離(1戦略の清算が他に波及しない)、APIキーの権限分離(1つのキーが漏洩しても被害が限定される)、戦略別の損益がAPIレベルで明確に分かれること。
得られないもの: レート制限の緩和にはなりません。IP単位の制限(600 req/5s)はUIDをまたいで共有されるため、同じサーバから複数UIDを叩けば合算されます。またサブアカウント間の資金移動には時間がかかるため、リアルタイムな資金の融通はできません。

API用途方向必要な権限
create_internal_transfer 同一UID内のアカウント種別間
(FUND ↔ UNIFIED など)
自分の中だけ Asset Transfer
create_universal_transfer UID間(メイン ↔ サブ、サブ ↔ サブ) UIDをまたぐ マスターUIDのキーのみ
get_transferable_coin 移動可能な通貨の事前確認 — Asset Transfer
get_coin_balance 指定アカウントの残高照会 — Asset(読み取り)

サブアカウントとAPIキーの整備

subaccount_setup.pyサンプル1
"""subaccount_setup.py — サブアカウントとAPIキーを整備する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  重要な前提:
    - サブUIDの作成・APIキー発行は「マスターUIDのAPIキー」でのみ可能
    - マスターキーには相応の権限が必要(アカウント管理権限)
    - 発行された apiSecret は「この応答でのみ」返る。再取得不可

  セキュリティ:
    - 発行したシークレットは絶対にログに出さない・コミットしない
    - 戦略ごとのキーには最小限の権限だけを付ける
      (多くのbotに Withdraw 権限は不要)
    - IP制限を必ず設定する(VPSの固定IP。記事38)
"""
from __future__ import annotations

import os
from dataclasses import dataclass
from pathlib import Path

from client import make_client

# APIキーの権限セット。必要最小限を選ぶ。
PERMISSIONS_TRADING_ONLY = {
    "ContractTrade": ["Order", "Position"],
    "Spot": ["SpotTrade"],
    "Wallet": ["AccountTransfer"],      # 資金移動を行う場合のみ
    "Options": [],
    "Derivatives": [],
    "Exchange": [],
    "CopyTrading": [],
    "BlockTrade": [],
    "NFT": [],
}

PERMISSIONS_READ_ONLY = {
    "ContractTrade": [],
    "Spot": [],
    "Wallet": [],
    "Options": [],
    "Derivatives": [],
    "Exchange": [],
    "CopyTrading": [],
    "BlockTrade": [],
    "NFT": [],
}


@dataclass(frozen=True)
class SubAccount:
    uid: str
    username: str
    member_type: int          # 1=通常サブアカウント
    status: int               # 1=正常, 2=ログイン禁止, 4=凍結
    remark: str = ""

    @property
    def is_active(self) -> bool:
        return self.status == 1

    def line(self) -> str:
        flag = "" if self.is_active else f"  [status={self.status}]"
        return f"  UID {self.uid:<14}{self.username:<24}{self.remark}{flag}"


def list_sub_accounts(http) -> list[SubAccount]:
    """サブアカウントの一覧を取得する(マスターキーが必要)。"""
    r = http.get_sub_uid()
    if r["retCode"] != 0:
        raise RuntimeError(
            f"get_sub_uid失敗: {r['retCode']} {r['retMsg']}\n"
            "マスターUIDのAPIキーを使っているか確認してください")

    out = []
    for s in r["result"].get("subMembers", []):
        out.append(SubAccount(
            uid=str(s.get("uid", "")),
            username=s.get("username", ""),
            member_type=int(s.get("memberType", 1)),
            status=int(s.get("status", 1)),
            remark=s.get("remark", "")))
    return out


def create_sub_account(http, username: str, note: str = "",
                       member_type: int = 1) -> SubAccount:
    """サブアカウントを作成する。

    username は英数字のみ・6〜16文字。全アカウント間で一意である必要がある。
    """
    r = http.create_sub_uid(
        username=username,
        memberType=member_type,     # 1: 通常サブアカウント
        switch=1,                   # 1: クイックログインを有効化
        note=note)
    if r["retCode"] != 0:
        raise RuntimeError(
            f"create_sub_uid失敗: {r['retCode']} {r['retMsg']}")

    res = r["result"]
    return SubAccount(
        uid=str(res.get("uid", "")),
        username=res.get("username", username),
        member_type=int(res.get("memberType", member_type)),
        status=int(res.get("status", 1)),
        remark=note)


def create_sub_api_key(http, sub_uid: str, note: str,
                       permissions: dict | None = None,
                       ips: list[str] | None = None,
                       read_only: bool = False) -> dict:
    """サブアカウント用のAPIキーを発行する。

    戻り値の apiSecret はこの応答でのみ取得できる。
    失うと再発行するしかない。
    """
    params = {
        "subuid": int(sub_uid),
        "note": note,
        "readOnly": 1 if read_only else 0,
        "permissions": permissions or PERMISSIONS_TRADING_ONLY,
    }
    if ips:
        params["ips"] = ",".join(ips)
    else:
        print("  [警告] IP制限が未設定です。")
        print("         漏洩時の被害が大きくなります(記事01)")

    r = http.create_sub_api_key(**params)
    if r["retCode"] != 0:
        raise RuntimeError(
            f"create_sub_api_key失敗: {r['retCode']} {r['retMsg']}")
    return r["result"]


def write_env_template(strategy: str, uid: str, api_key: str,
                       out_dir: Path = Path("./env")) -> Path:
    """戦略ごとの .env テンプレートを書き出す。

    シークレットは書き込まない。手で追記させる。
    自動で書くと、うっかりコミットする事故が起きる。
    """
    out_dir.mkdir(parents=True, exist_ok=True)
    path = out_dir / f".env.{strategy}"

    if path.exists():
        print(f"  [skip] 既存: {path}")
        return path

    path.write_text(
        f"# 戦略: {strategy}\n"
        f"# サブUID: {uid}\n"
        f"BYBIT_API_KEY={api_key}\n"
        f"BYBIT_API_SECRET=\n"
        f"BYBIT_TESTNET=true\n"
        f"BYBIT_SUB_UID={uid}\n"
        f"STRATEGY_NAME={strategy}\n",
        encoding="utf-8")

    os.chmod(path, 0o600)     # 所有者のみ読み書き可
    print(f"  作成: {path} (権限 600)")
    print("        BYBIT_API_SECRET は手で追記してください")
    return path


if __name__ == "__main__":
    import sys

    c = make_client()

    print("=== 既存のサブアカウント ===")
    try:
        subs = list_sub_accounts(c.http)
        if not subs:
            print("  ありません。")
        for s in subs:
            print(s.line())
    except RuntimeError as e:
        print(f"  {e}")
        sys.exit(1)

    if "--create" not in sys.argv:
        print("\n※ 作成するには --create を付けてください。")
        print("  例: python subaccount_setup.py --create trendbot")
        sys.exit(0)

    if len(sys.argv) < 3:
        print("戦略名を指定してください。")
        sys.exit(1)

    strategy = sys.argv[sys.argv.index("--create") + 1]
    username = f"bot{strategy}"[:16]

    print(f"\n=== サブアカウント作成: {username} ===")
    sub = create_sub_account(c.http, username, note=f"strategy={strategy}")
    print(f"  作成しました: UID={sub.uid}")

    print("\n=== APIキー発行 ===")
    my_ip = os.environ.get("BOT_PUBLIC_IP", "")
    result = create_sub_api_key(
        c.http, sub.uid, note=f"{strategy}-bot",
        permissions=PERMISSIONS_TRADING_ONLY,
        ips=[my_ip] if my_ip else None)

    api_key = result.get("apiKey", "")
    print(f"  apiKey: {api_key}")
    print("  apiSecret: (画面には出しません。安全な場所に保管してください)")

    write_env_template(strategy, sub.uid, api_key)

    print("\n!! 重要 !!")
    print("  apiSecret はこの応答でのみ取得できます。")
    print("  失った場合は再発行が必要です。")
    print("  ログファイル・スクリーンショット・チャットに残さないでください。")

同一UID内の資金移動

UTAでは FUND(資金調達アカウント)と UNIFIED(統合取引アカウント)が分かれています。取引に使えるのはUNIFIED側なので、入金直後は移動が必要です。

transfer_internal.pyサンプル2
"""transfer_internal.py — 同一UID内の資金移動(冪等・事前検証つき)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  アカウント種別:
    FUND      資金調達アカウント(入出金・送金の受け皿)
    UNIFIED   統合取引アカウント(デリバティブ・現物の取引用)
    CONTRACT  旧デリバティブ口座(UTA移行前)
    SPOT      旧現物口座(UTA移行前)

  冪等性:
    transferId(UUID)を指定すると、同じIDでの再送は
    新しい移動を作らず、既存の結果を返す。
    タイムアウト時の二重送金を防ぐ唯一の手段。
"""
from __future__ import annotations

import json
import time
import uuid
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path

from client import make_client
from margin_snapshot import d

TRANSFER_LOG = Path("./transfer_log.jsonl")

ACCOUNT_TYPES = ("FUND", "UNIFIED", "CONTRACT", "SPOT", "OPTION")


@dataclass(frozen=True)
class TransferResult:
    transfer_id: str
    status: str            # SUCCESS / PENDING / FAILED
    coin: str
    amount: Decimal
    from_account: str
    to_account: str
    ret_code: int = 0
    ret_msg: str = ""

    @property
    def ok(self) -> bool:
        return self.status in ("SUCCESS", "PENDING")

    def line(self) -> str:
        tail = f"  {self.ret_code} {self.ret_msg}" if self.ret_code else ""
        return (f"  {self.from_account} → {self.to_account}  "
                f"{self.amount} {self.coin}  [{self.status}]{tail}")


def make_transfer_id(purpose: str = "") -> str:
    """冪等キーを作る。

    完全にランダムなUUIDでよい。
    重要なのは「1回の意図につき1つ生成し、再送では同じものを使う」こと。
    毎回生成し直すと冪等性が失われる。
    """
    return str(uuid.uuid4())


def get_transferable_coins(http, from_account: str,
                           to_account: str) -> list[str]:
    """移動可能な通貨を事前確認する。

    移動できない通貨を指定すると 131002 系のエラーになる。
    事前確認しておけば、無駄なリトライを避けられる。
    """
    r = http.get_transferable_coin(
        fromAccountType=from_account, toAccountType=to_account)
    if r["retCode"] != 0:
        raise RuntimeError(
            f"get_transferable_coin失敗: {r['retCode']} {r['retMsg']}")
    return r["result"].get("list", [])


def get_balance(http, account_type: str, coin: str) -> Decimal:
    """指定アカウントの残高を取得する。"""
    r = http.get_coin_balance(accountType=account_type, coin=coin)
    if r["retCode"] != 0:
        raise RuntimeError(
            f"get_coin_balance失敗: {r['retCode']} {r['retMsg']}")
    balance = r["result"].get("balance", {})
    return d(balance.get("transferBalance"))


def internal_transfer(http, coin: str, amount: Decimal,
                      from_account: str, to_account: str,
                      transfer_id: str = "",
                      verify: bool = True) -> TransferResult:
    """同一UID内で資金を移動する。"""
    if from_account not in ACCOUNT_TYPES or to_account not in ACCOUNT_TYPES:
        raise ValueError(f"不正なアカウント種別: {from_account} / {to_account}")
    if from_account == to_account:
        raise ValueError("同じアカウント間の移動はできません")
    if amount <= 0:
        raise ValueError(f"移動額が不正です: {amount}")

    tid = transfer_id or make_transfer_id()

    if verify:
        coins = get_transferable_coins(http, from_account, to_account)
        if coins and coin not in coins:
            return TransferResult(
                tid, "FAILED", coin, amount, from_account, to_account,
                ret_code=-1,
                ret_msg=f"{coin} は {from_account}→{to_account} で移動できません")

        available = get_balance(http, from_account, coin)
        if available < amount:
            return TransferResult(
                tid, "FAILED", coin, amount, from_account, to_account,
                ret_code=-1,
                ret_msg=f"残高不足: 移動可能 {available} < 要求 {amount}")

    r = http.create_internal_transfer(
        transferId=tid, coin=coin, amount=str(amount),
        fromAccountType=from_account, toAccountType=to_account)

    if r["retCode"] != 0:
        return TransferResult(tid, "FAILED", coin, amount,
                              from_account, to_account,
                              r["retCode"], r["retMsg"])

    status = r["result"].get("status", "UNKNOWN")
    result = TransferResult(tid, status, coin, amount,
                            from_account, to_account)
    log_transfer(result)
    return result


def query_transfer(http, transfer_id: str) -> dict | None:
    """移動の結果を照会する。PENDINGの確認に使う。"""
    r = http.get_internal_transfer_records(transferId=transfer_id)
    if r["retCode"] != 0:
        return None
    items = r["result"].get("list", [])
    return items[0] if items else None


def wait_for_completion(http, transfer_id: str,
                        timeout_sec: float = 30.0,
                        poll_sec: float = 2.0) -> str:
    """完了まで待つ。"""
    deadline = time.time() + timeout_sec
    while time.time() < deadline:
        record = query_transfer(http, transfer_id)
        if record:
            status = record.get("status", "")
            if status in ("SUCCESS", "FAILED"):
                return status
        time.sleep(poll_sec)
    return "TIMEOUT"


def log_transfer(result: TransferResult) -> None:
    """移動を記録する。監査に使う(サンプル7)。"""
    TRANSFER_LOG.parent.mkdir(parents=True, exist_ok=True)
    with TRANSFER_LOG.open("a", encoding="utf-8") as f:
        f.write(json.dumps({
            "ts": time.time(),
            "kind": "internal",
            "transfer_id": result.transfer_id,
            "status": result.status,
            "coin": result.coin,
            "amount": str(result.amount),
            "from": result.from_account,
            "to": result.to_account,
        }, ensure_ascii=False) + "\n")


if __name__ == "__main__":
    c = make_client()

    print("=== アカウント種別ごとの残高 (USDT) ===")
    for acct in ("FUND", "UNIFIED"):
        try:
            bal = get_balance(c.http, acct, "USDT")
            print(f"  {acct:<10}{bal:>16.4f} USDT")
        except RuntimeError as e:
            print(f"  {acct:<10}取得失敗: {e}")

    print("\n=== 移動可能な通貨 (FUND → UNIFIED) ===")
    try:
        coins = get_transferable_coins(c.http, "FUND", "UNIFIED")
        tail = " ..." if len(coins) > 15 else ""
        print(f"  {len(coins)}種類: {', '.join(coins[:15])}{tail}")
    except RuntimeError as e:
        print(f"  {e}")

    print("\n=== 冪等性のデモ ===")
    tid = make_transfer_id("demo")
    print(f"  transferId: {tid}")
    print("  同じIDで2回送っても、移動は1回しか起きない。")
    print("  タイムアウト時は『新しいIDを作らず、同じIDで再送』が正解。")

    # 実際の移動(コメントアウトを外して実行)
    # result = internal_transfer(c.http, "USDT", Decimal("10"),
    #                            "FUND", "UNIFIED", transfer_id=tid)
    # print(result.line())
    # if result.status == "PENDING":
    #     final = wait_for_completion(c.http, tid)
    #     print(f"  最終状態: {final}")

UID間の資金移動

transfer_universal.pyサンプル3
"""transfer_universal.py — UID間(メイン↔サブ)の資金移動
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  create_universal_transfer の制約:
    - 実行できるのは「マスターUIDのAPIキー」のみ
      サブのキーでは権限エラーになる
    - fromMemberId / toMemberId に UID を指定する
    - 同一UID内の移動には使えない(internal を使う)
    - サブ→サブ の直接移動も可能(マスターキーがあれば)

  設計上の要点:
    資金移動は「マスターの管理プロセス」だけが行う。
    各戦略botに移動権限を持たせない。
    botが自分で資金を引っ張れると、
    暴走したbotが全資産を吸い上げる事故が起きる。
"""
from __future__ import annotations

import json
import time
from dataclasses import dataclass
from decimal import Decimal

from client import make_client
from margin_snapshot import d
from transfer_internal import TRANSFER_LOG, TransferResult, make_transfer_id


@dataclass(frozen=True)
class UidBalance:
    uid: str
    label: str
    coin: str
    balance: Decimal

    def line(self) -> str:
        return f"  {self.label:<20}(UID {self.uid:<12}) {self.balance:>16.4f}"


def universal_transfer(http, coin: str, amount: Decimal,
                       from_uid: str, to_uid: str,
                       from_account: str = "UNIFIED",
                       to_account: str = "UNIFIED",
                       transfer_id: str = "") -> TransferResult:
    """UID間で資金を移動する(マスターキーが必要)。"""
    if from_uid == to_uid:
        raise ValueError(
            "同一UIDの移動には create_internal_transfer を使ってください")
    if amount <= 0:
        raise ValueError(f"移動額が不正です: {amount}")

    tid = transfer_id or make_transfer_id()
    src = f"UID{from_uid}/{from_account}"
    dst = f"UID{to_uid}/{to_account}"

    r = http.create_universal_transfer(
        transferId=tid, coin=coin, amount=str(amount),
        fromMemberId=int(from_uid), toMemberId=int(to_uid),
        fromAccountType=from_account, toAccountType=to_account)

    if r["retCode"] != 0:
        hint = ""
        if r["retCode"] in (131228, 10004, 10005):
            hint = ("\n    マスターUIDのAPIキーを使っているか、"
                    "Asset Transfer 権限があるか確認してください")
        return TransferResult(tid, "FAILED", coin, amount, src, dst,
                              r["retCode"], r["retMsg"] + hint)

    status = r["result"].get("status", "UNKNOWN")
    result = TransferResult(tid, status, coin, amount, src, dst)

    with TRANSFER_LOG.open("a", encoding="utf-8") as f:
        f.write(json.dumps({
            "ts": time.time(), "kind": "universal",
            "transfer_id": tid, "status": status,
            "coin": coin, "amount": str(amount),
            "from_uid": from_uid, "to_uid": to_uid,
            "from": from_account, "to": to_account,
        }, ensure_ascii=False) + "\n")

    return result


def query_universal(http, transfer_id: str = "",
                    limit: int = 20) -> list[dict]:
    """UID間移動の履歴を照会する。"""
    params = {"limit": limit}
    if transfer_id:
        params["transferId"] = transfer_id

    r = http.get_universal_transfer_records(**params)
    if r["retCode"] != 0:
        raise RuntimeError(
            f"get_universal_transfer_records失敗: "
            f"{r['retCode']} {r['retMsg']}")
    return r["result"].get("list", [])


def sub_balance(http, uid: str, coin: str = "USDT",
                account_type: str = "UNIFIED") -> Decimal:
    """サブアカウントの残高を取得する(マスターキーから)。

    get_all_coins_balance に memberId を渡すと
    サブアカウントの残高を見られる。
    """
    r = http.get_all_coins_balance(
        accountType=account_type, memberId=int(uid), coin=coin)
    if r["retCode"] != 0:
        raise RuntimeError(
            f"get_all_coins_balance(uid={uid})失敗: "
            f"{r['retCode']} {r['retMsg']}")

    for b in r["result"].get("balance", []):
        if b.get("coin") == coin:
            return d(b.get("transferBalance") or b.get("walletBalance"))
    return Decimal(0)


def safe_transfer(http, coin: str, amount: Decimal,
                  from_uid: str, to_uid: str,
                  max_ratio: Decimal = Decimal("0.5"),
                  min_remaining: Decimal = Decimal("100")) -> TransferResult:
    """安全弁つきの資金移動。

    - 送り元の残高の max_ratio 以上は一度に動かさない
    - 送り元に min_remaining 以上を必ず残す

    「1回の操作で全資産を1箇所に集めてしまう」事故を防ぐ。
    リバランスは複数回に分けて行えばよい。
    """
    available = sub_balance(http, from_uid, coin)

    if available <= min_remaining:
        return TransferResult(
            "", "FAILED", coin, amount, from_uid, to_uid, -1,
            f"送り元残高 {available} が最低残高 {min_remaining} 以下です")

    max_movable = min(available * max_ratio, available - min_remaining)

    actual = min(amount, max_movable)
    if actual < amount:
        print(f"  [制限] 要求 {amount} → 実行 {actual} "
              f"(残高の{max_ratio:.0%}上限 / 最低残高 {min_remaining})")

    if actual <= 0:
        return TransferResult("", "FAILED", coin, amount,
                              from_uid, to_uid, -1, "移動可能額がゼロです")

    return universal_transfer(http, coin, actual, from_uid, to_uid)


if __name__ == "__main__":
    from subaccount_setup import list_sub_accounts

    c = make_client()

    print("=== 全UIDの残高 (USDT / UNIFIED) ===")
    balances: list[UidBalance] = []

    # マスター自身
    try:
        r = c.http.get_coin_balance(accountType="UNIFIED", coin="USDT")
        master_balance = (d(r["result"]["balance"].get("transferBalance"))
                          if r["retCode"] == 0 else Decimal(0))
        balances.append(UidBalance("master", "マスター", "USDT",
                                   master_balance))
    except Exception as e:
        print(f"  マスター残高の取得に失敗: {e}")

    try:
        for sub in list_sub_accounts(c.http):
            try:
                bal = sub_balance(c.http, sub.uid)
                balances.append(UidBalance(
                    sub.uid, sub.remark or sub.username, "USDT", bal))
            except RuntimeError as e:
                print(f"  UID {sub.uid}: {e}")
    except RuntimeError as e:
        print(f"  サブアカウント一覧の取得に失敗: {e}")

    for b in balances:
        print(b.line())

    total = sum((b.balance for b in balances), Decimal(0))
    print(f"\n  合計: {total:.4f} USDT")

    print("\n=== 直近の UID間移動 ===")
    try:
        for rec in query_universal(c.http, limit=5):
            print(f"  {rec.get('coin')} {rec.get('amount')}  "
                  f"UID{rec.get('fromMemberId')} → "
                  f"UID{rec.get('toMemberId')}  "
                  f"[{rec.get('status')}]")
    except RuntimeError as e:
        print(f"  {e}")

    print("\n※ create_universal_transfer はマスターキーでのみ実行可能。")
    print("  各戦略botには資金移動権限を与えないこと。")
    print("  暴走したbotが全資産を吸い上げる事故を防ぐ。")

全サブアカウント横断の残高集計

portfolio_view.pyサンプル4
"""portfolio_view.py — 全サブアカウントを横断したポートフォリオビュー
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  複数UIDに資金が散らばると、「全体でいくら持っているか」が
  Bybitの画面でも分かりにくくなる。
  APIで横断集計し、戦略別の資産推移を記録する。

  記録した資産推移は、サンプル5のHRP配分の入力になる。
  つまりこのモジュールは「観測」であり、
  観測が無ければ配分の最適化もできない。
"""
from __future__ import annotations

import json
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path

import pandas as pd

from client import make_client
from margin_snapshot import d
from subaccount_setup import list_sub_accounts
from transfer_universal import sub_balance

HISTORY_PATH = Path("./portfolio_history.jsonl")


@dataclass
class AccountSnapshot:
    uid: str
    label: str
    equity: Decimal
    available: Decimal
    unrealised_pnl: Decimal
    position_count: int
    error: str = ""

    @property
    def ok(self) -> bool:
        return not self.error

    def line(self, total: Decimal) -> str:
        if self.error:
            return f"  {self.label:<20}取得失敗: {self.error}"
        share = self.equity / total * 100 if total > 0 else Decimal(0)
        return (f"  {self.label:<20}"
                f"{self.equity:>14.2f}"
                f"{share:>8.1f}%"
                f"{self.unrealised_pnl:>+13.2f}"
                f"{self.position_count:>7}")


@dataclass
class PortfolioSnapshot:
    ts: float
    accounts: list[AccountSnapshot] = field(default_factory=list)

    @property
    def total_equity(self) -> Decimal:
        return sum((a.equity for a in self.accounts if a.ok), Decimal(0))

    @property
    def total_upl(self) -> Decimal:
        return sum((a.unrealised_pnl for a in self.accounts if a.ok),
                   Decimal(0))

    def weights(self) -> dict:
        total = self.total_equity
        if total <= 0:
            return {}
        return {a.label: float(a.equity / total)
                for a in self.accounts if a.ok}

    def report(self) -> str:
        when = datetime.fromtimestamp(self.ts, tz=timezone.utc) \
            .strftime("%Y-%m-%d %H:%M:%S UTC")
        lines = [
            f"=== ポートフォリオ ({when}) ===",
            f"  {'戦略':<20}{'資産(USD)':>14}{'配分':>8}"
            f"{'含み損益':>13}{'建玉':>7}",
            "  " + "-" * 60,
        ]
        total = self.total_equity
        lines.extend(a.line(total) for a in self.accounts)
        lines.append("  " + "-" * 60)
        lines.append(f"  {'合計':<20}{total:>14.2f}"
                     f"{100.0:>7.1f}%{self.total_upl:>+13.2f}")
        return "\n".join(lines)

    def save(self, path: Path = HISTORY_PATH) -> None:
        path.parent.mkdir(parents=True, exist_ok=True)
        with path.open("a", encoding="utf-8") as f:
            f.write(json.dumps({
                "ts": self.ts,
                "total_equity": str(self.total_equity),
                "accounts": {a.label: str(a.equity)
                             for a in self.accounts if a.ok},
            }, ensure_ascii=False) + "\n")


def snapshot_account(http, uid: str, label: str,
                     is_master: bool = False) -> AccountSnapshot:
    """1アカウントの状態を取得する。

    マスターは自分のキーで直接、サブは memberId 指定で取得する。
    ポジション数はサブアカウントのキーが無いと取れないため、
    マスターのみ取得する(サブは 0 のまま)。
    """
    try:
        if is_master:
            r = http.get_wallet_balance(accountType="UNIFIED")
            if r["retCode"] != 0 or not r["result"]["list"]:
                return AccountSnapshot(uid, label, Decimal(0), Decimal(0),
                                       Decimal(0), 0,
                                       error=f"{r['retCode']} {r['retMsg']}")
            a = r["result"]["list"][0]
            equity = d(a.get("totalEquity"))
            available = d(a.get("totalAvailableBalance"))
            upl = d(a.get("totalPerpUPL"))

            pos = http.get_positions(category="linear", settleCoin="USDT")
            count = (sum(1 for p in pos["result"]["list"]
                         if d(p.get("size")) > 0)
                     if pos["retCode"] == 0 else 0)
            return AccountSnapshot(uid, label, equity, available, upl, count)

        balance = sub_balance(http, uid, "USDT")
        return AccountSnapshot(uid, label, balance, balance, Decimal(0), 0)

    except Exception as e:
        return AccountSnapshot(uid, label, Decimal(0), Decimal(0),
                               Decimal(0), 0, error=f"{type(e).__name__}: {e}")


def take_snapshot(http, labels: dict | None = None) -> PortfolioSnapshot:
    """全アカウントのスナップショットを取る。

    labels: {uid: 戦略名} の対応表。無ければ remark を使う。
    """
    labels = labels or {}
    accounts = [snapshot_account(http, "master", "マスター", is_master=True)]

    try:
        for sub in list_sub_accounts(http):
            label = labels.get(sub.uid) or sub.remark or sub.username
            accounts.append(snapshot_account(http, sub.uid, label))
    except RuntimeError as e:
        print(f"  [warn] サブアカウント一覧の取得に失敗: {e}")

    return PortfolioSnapshot(ts=time.time(), accounts=accounts)


def load_history(path: Path = HISTORY_PATH) -> pd.DataFrame:
    """記録した資産推移を読み込む。

    戻り値: index=時刻, columns=戦略名, values=資産
    """
    if not path.exists():
        return pd.DataFrame()

    rows = []
    with path.open(encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                rec = json.loads(line)
            except json.JSONDecodeError:
                continue
            row = {"ts": rec["ts"]}
            row.update({k: float(v)
                        for k, v in rec.get("accounts", {}).items()})
            rows.append(row)

    if not rows:
        return pd.DataFrame()

    df = pd.DataFrame(rows)
    df["datetime"] = pd.to_datetime(df["ts"], unit="s", utc=True)
    return df.drop(columns=["ts"]).set_index("datetime").sort_index()


def strategy_returns(history: pd.DataFrame, freq: str = "D") -> pd.DataFrame:
    """戦略別のリターン系列を作る(HRPの入力)。

    重要: 資産の変化には「損益」と「資金移動」が混ざる。
    資金移動の日を除外しないと、リターンが歪む。
    サンプル7の監査ログと突き合わせて除外するのが正確だが、
    ここでは異常値のクリップで簡易的に対処する。
    """
    if history.empty:
        return pd.DataFrame()

    resampled = history.resample(freq).last().ffill()
    returns = resampled.pct_change().dropna(how="all")

    # 資金移動由来の異常なジャンプをクリップする
    # (1日で±50%はほぼ確実に資金移動)
    return returns.clip(-0.5, 0.5)


if __name__ == "__main__":
    c = make_client()

    labels = {
        # "12345678": "トレンドフォロー",
        # "12345679": "グリッド",
        # "12345680": "アービトラージ",
    }

    snap = take_snapshot(c.http, labels)
    print(snap.report())
    snap.save()

    weights = snap.weights()
    detail = (", ".join(f"{k} {v:.1%}" for k, v in weights.items())
              if weights else "算出不可")
    print(f"\n  配分: {detail}")

    history = load_history()
    if not history.empty:
        print(f"\n=== 資産推移 ({len(history)}件の記録) ===")
        print(history.tail(5).to_string(
            float_format=lambda v: f"{v:,.2f}"))

        returns = strategy_returns(history)
        if len(returns) > 5:
            print("\n  日次リターンの統計:")
            for col in returns.columns:
                r = returns[col].dropna()
                if len(r) < 3:
                    continue
                print(f"    {col:<20}"
                      f"平均 {r.mean():>+7.3%}  "
                      f"標準偏差 {r.std():>6.3%}")
    else:
        print("\n  資産推移の記録がまだありません。")
        print("  cronで定期実行して蓄積してください(記事38)。")
        print("    0 * * * * cd /path/to/bot && python portfolio_view.py")

HRPによる戦略間ウェイト

戦略ごとの資金配分をHierarchical Risk Parityで決めます。共分散行列の逆行列を使わないため、戦略数が少なくても推定誤差に頑健です。

hrp_allocation.pyサンプル5
"""hrp_allocation.py — Hierarchical Risk Parityによる戦略間ウェイト
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

参考: López de Prado, M. (2016)
      "Building Diversified Portfolios that Outperform Out-of-Sample",
      Journal of Portfolio Management 42(4)

  HRPの3段階:
    1. 階層クラスタリング  : 相関距離で戦略をグループ化
    2. 準対角化            : 相関の近い戦略が隣り合うよう並べ替え
    3. 再帰的二分法        : クラスタを分割しながらリスク逆比例で配分

  平均分散最適化(Markowitz)との違い:
    - 共分散行列の逆行列を計算しない → 特異行列でも動く
    - 推定誤差が増幅されない → アウトオブサンプルで安定
    - 「集中しすぎない」性質がある

  戦略数が2〜3ではクラスタが作れないので、
  その場合は逆ボラウェイトに自動フォールバックする。
"""
from __future__ import annotations

from dataclasses import dataclass, field

import numpy as np
import pandas as pd
from scipy.cluster.hierarchy import linkage, to_tree
from scipy.spatial.distance import squareform


@dataclass
class AllocationResult:
    weights: pd.Series
    method: str
    n_strategies: int
    n_observations: int
    diversification_ratio: float = 0.0
    warnings: list = field(default_factory=list)

    def report(self) -> str:
        lines = [
            f"配分方式: {self.method}",
            f"  戦略数  : {self.n_strategies}",
            f"  観測数  : {self.n_observations}",
            f"  分散比率: {self.diversification_ratio:.3f}",
            "  --- ウェイト ---",
        ]
        for name, w in self.weights.sort_values(ascending=False).items():
            bar = "#" * int(w * 50)
            lines.append(f"    {name:<20}{w:>7.2%}  {bar}")
        if self.warnings:
            lines.append("  --- 注意 ---")
            lines.extend(f"    {w}" for w in self.warnings)
        return "\n".join(lines)


def correlation_distance(corr: pd.DataFrame) -> pd.DataFrame:
    """相関を距離に変換する。d = sqrt((1−ρ)/2)

    ρ=1 → d=0(同一)、ρ=0 → d=0.707、ρ=−1 → d=1(正反対)
    """
    dist = np.sqrt(np.clip((1 - corr) / 2, 0, 1))
    np.fill_diagonal(dist.values, 0.0)
    return dist


def quasi_diagonal_order(link: np.ndarray) -> list:
    """クラスタリング結果から、相関の近い順の並びを取り出す。"""
    tree = to_tree(link, rd=False)
    return list(tree.pre_order())


def cluster_variance(cov: pd.DataFrame, items: list) -> float:
    """クラスタ内を逆分散ウェイトで束ねたときの分散。"""
    sub = cov.iloc[items, items].to_numpy()
    diag = np.diag(sub)
    if np.any(diag <= 0):
        return float(np.mean(diag[diag > 0])) if np.any(diag > 0) else 1.0
    inv = 1 / diag
    w = inv / inv.sum()
    return float(w @ sub @ w)


def recursive_bisection(cov: pd.DataFrame, order: list) -> pd.Series:
    """再帰的にクラスタを分割し、リスク逆比例で配分する。"""
    weights = pd.Series(1.0, index=order)
    clusters = [order]

    while clusters:
        next_level = []
        for cluster in clusters:
            if len(cluster) <= 1:
                continue
            mid = len(cluster) // 2
            left, right = cluster[:mid], cluster[mid:]

            var_left = cluster_variance(cov, left)
            var_right = cluster_variance(cov, right)
            total = var_left + var_right
            # リスクの小さい側に多く配分する
            alpha = 1 - var_left / total if total > 0 else 0.5

            weights[left] *= alpha
            weights[right] *= (1 - alpha)
            next_level.extend([left, right])
        clusters = next_level

    return weights


def inverse_vol_weights(returns: pd.DataFrame) -> pd.Series:
    """逆ボラウェイト(戦略数が少ないときのフォールバック)。"""
    vol = returns.std().replace(0, np.nan).dropna()
    if vol.empty:
        n = returns.shape[1]
        return pd.Series(1 / n, index=returns.columns)
    w = 1 / vol
    return (w / w.sum()).reindex(returns.columns).fillna(0)


def diversification_ratio(weights: pd.Series,
                          returns: pd.DataFrame) -> float:
    """分散比率 = 加重平均ボラ / ポートフォリオボラ。

    1.0 なら分散効果ゼロ(完全相関)。大きいほど分散が効いている。
    """
    w = weights.reindex(returns.columns).fillna(0).to_numpy()
    vol = returns.std().to_numpy()
    cov = returns.cov().to_numpy()

    weighted_vol = float(w @ vol)
    port_vol = float(np.sqrt(max(w @ cov @ w, 1e-18)))
    return weighted_vol / port_vol if port_vol > 0 else 1.0


def compute_hrp(returns: pd.DataFrame,
                min_observations: int = 30,
                min_strategies: int = 4) -> AllocationResult:
    """HRPウェイトを計算する。"""
    warnings: list = []
    clean = returns.dropna(axis=1, how="all").dropna()

    n_obs, n_str = clean.shape

    if n_str == 0:
        return AllocationResult(pd.Series(dtype=float), "計算不可", 0, 0,
                                warnings=["有効なデータがありません"])
    if n_str == 1:
        return AllocationResult(
            pd.Series(1.0, index=clean.columns), "単一戦略", 1, n_obs)

    if n_obs < min_observations:
        warnings.append(
            f"観測数 {n_obs} が少なく、推定が不安定です "
            f"(推奨 {min_observations} 以上)")

    if n_str < min_strategies:
        w = inverse_vol_weights(clean)
        warnings.append(
            f"戦略数 {n_str} ではクラスタリングの意味が薄いため "
            "逆ボラウェイトを使用")
        return AllocationResult(
            w, "逆ボラウェイト", n_str, n_obs,
            diversification_ratio(w, clean), warnings)

    corr = clean.corr()
    cov = clean.cov()

    if corr.isna().any().any():
        w = inverse_vol_weights(clean)
        warnings.append("相関行列に欠損があるため逆ボラウェイトを使用")
        return AllocationResult(w, "逆ボラウェイト", n_str, n_obs,
                                diversification_ratio(w, clean), warnings)

    dist = correlation_distance(corr)
    link = linkage(squareform(dist.to_numpy(), checks=False),
                   method="single")
    order = quasi_diagonal_order(link)

    raw = recursive_bisection(cov, order)
    weights = pd.Series(raw.to_numpy(), index=corr.columns[raw.index])
    weights = (weights / weights.sum()).reindex(clean.columns).fillna(0)

    avg_corr = corr.to_numpy()[np.triu_indices_from(corr, 1)].mean()
    if avg_corr > 0.8:
        warnings.append(
            f"戦略間の平均相関が {avg_corr:.2f} と高く、"
            "分散効果は限定的です")

    return AllocationResult(
        weights, "HRP", n_str, n_obs,
        diversification_ratio(weights, clean), warnings)


def apply_constraints(weights: pd.Series,
                      min_weight: float = 0.05,
                      max_weight: float = 0.50) -> pd.Series:
    """ウェイトに上下限を課して正規化し直す。

    HRPは理論上ゼロに近いウェイトを出すことがあるが、
    実務では「戦略を完全に止める」判断は別途行うべきで、
    配分アルゴリズムに任せない。
    """
    w = weights.clip(lower=min_weight, upper=max_weight)
    return w / w.sum()


if __name__ == "__main__":
    from portfolio_view import load_history, strategy_returns

    history = load_history()

    if history.empty or history.shape[1] < 2:
        print("記録が不足しているため、合成データで動作を示します。\n")
        rng = np.random.default_rng(7)
        n = 250
        # 相関構造を持つ5戦略を作る
        common = rng.normal(0, 0.01, n)
        returns = pd.DataFrame({
            "トレンドA": common * 0.8 + rng.normal(0.0005, 0.015, n),
            "トレンドB": common * 0.7 + rng.normal(0.0004, 0.018, n),
            "グリッド": rng.normal(0.0003, 0.006, n) - common * 0.3,
            "ファンディングArb": rng.normal(0.0002, 0.003, n),
            "マーケットメイク": rng.normal(0.0004, 0.008, n) - common * 0.1,
        })
    else:
        returns = strategy_returns(history)
        print(f"記録から {returns.shape[1]} 戦略 × "
              f"{len(returns)} 観測を読み込みました。\n")

    print("=== 戦略間の相関 ===")
    print(returns.corr().to_string(float_format=lambda v: f"{v:>6.3f}"))

    print("\n=== 各戦略の統計 ===")
    print(f"  {'戦略':<20}{'平均':>10}{'標準偏差':>11}{'シャープ':>10}")
    print("  " + "-" * 50)
    for col in returns.columns:
        r = returns[col].dropna()
        sharpe = r.mean() / r.std() * np.sqrt(365) if r.std() > 0 else 0
        print(f"  {col:<20}{r.mean():>+10.4%}{r.std():>11.4%}"
              f"{sharpe:>10.2f}")

    print("\n" + "=" * 56)
    result = compute_hrp(returns)
    print(result.report())

    print("\n=== 制約適用後(下限5% / 上限50%) ===")
    constrained = apply_constraints(result.weights)
    for name, w in constrained.sort_values(ascending=False).items():
        original = result.weights.get(name, 0)
        mark = "  ← 調整" if abs(w - original) > 0.001 else ""
        print(f"  {name:<20}{w:>7.2%}  (元 {original:>6.2%}){mark}")

    print("\n=== 比較: 均等配分 ===")
    n = returns.shape[1]
    equal = pd.Series(1 / n, index=returns.columns)
    print(f"  均等配分の分散比率: "
          f"{diversification_ratio(equal, returns):.3f}")
    print(f"  HRPの分散比率    : {result.diversification_ratio:.3f}")
    print("\n  ※ 分散比率が大きいほど、分散効果が効いている。")
    print("    HRPが均等配分に勝てないなら、")
    print("    戦略同士が実質的に同じ賭けをしている可能性が高い。")

閾値リバランサと監査

rebalancer.pyサンプル6
"""rebalancer.py — 目標ウェイトへの閾値リバランス
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  なぜ「閾値」リバランスか:
    毎日きっちり目標に合わせると、資金移動が頻発する。
    移動には時間がかかり、その間ポジションを持てない。
    「乖離が閾値を超えたときだけ動かす」ほうが実務的。

  安全設計:
    - dry_run が既定。実行には明示的な指定が必要
    - 1回の移動額に上限を設ける
    - ポジションを持っている口座からは資金を抜かない
      (証拠金が減って清算される)
    - 移動は必ずマスターUID経由(サブ→マスター→サブ)
"""
from __future__ import annotations

import time
from dataclasses import dataclass, field
from decimal import Decimal

import pandas as pd

from client import make_client
from portfolio_view import take_snapshot
from transfer_universal import safe_transfer


@dataclass(frozen=True)
class RebalanceConfig:
    threshold: Decimal = Decimal("0.10")     # 乖離がこれを超えたら実行
    min_transfer: Decimal = Decimal("50")    # これ未満は移動しない
    max_transfer_ratio: Decimal = Decimal("0.30")  # 1回の上限(総資産比)
    min_account_balance: Decimal = Decimal("100")  # 各口座の最低残高
    coin: str = "USDT"
    dry_run: bool = True
    skip_if_positions: bool = True   # ポジション保有中の口座はスキップ


@dataclass
class RebalanceAction:
    from_label: str
    from_uid: str
    to_label: str
    to_uid: str
    amount: Decimal
    reason: str
    executed: bool = False
    error: str = ""

    def line(self) -> str:
        mark = "実行" if self.executed else ("試算" if not self.error else "失敗")
        tail = f"  ({self.error})" if self.error else ""
        return (f"  [{mark}] {self.from_label} → {self.to_label}  "
                f"{self.amount:.2f}  ({self.reason}){tail}")


@dataclass
class RebalancePlan:
    current: dict = field(default_factory=dict)
    target: dict = field(default_factory=dict)
    deviations: dict = field(default_factory=dict)
    actions: list = field(default_factory=list)
    total_equity: Decimal = Decimal(0)
    triggered: bool = False

    def report(self) -> str:
        lines = [
            f"=== リバランス計画 (総資産 {self.total_equity:.2f}) ===",
            f"  {'戦略':<20}{'現在':>9}{'目標':>9}{'乖離':>9}",
            "  " + "-" * 47,
        ]
        for label in sorted(self.current, key=lambda k: -self.current[k]):
            cur = self.current[label]
            tgt = self.target.get(label, Decimal(0))
            dev = self.deviations.get(label, Decimal(0))
            flag = "  ←" if abs(dev) >= Decimal("0.10") else ""
            lines.append(f"  {label:<20}{cur:>8.1%}{tgt:>9.1%}"
                         f"{dev:>+9.1%}{flag}")

        lines.append("")
        if not self.triggered:
            lines.append("  閾値内のためリバランスは不要です。")
        elif self.actions:
            lines.append(f"  --- 移動 ({len(self.actions)}件) ---")
            lines.extend(a.line() for a in self.actions)
        else:
            lines.append("  乖離はあるが、実行可能な移動がありません。")
        return "\n".join(lines)


def has_open_positions(http, uid: str) -> bool:
    """その口座がポジションを持っているか。

    サブアカウントのポジションはマスターキーでは直接取れないため、
    残高の transferBalance と walletBalance の差から推定する。
    正確に知りたい場合は、各botに自己申告させる仕組みを作る。
    """
    try:
        r = http.get_all_coins_balance(
            accountType="UNIFIED", memberId=int(uid), coin="USDT")
        if r["retCode"] != 0:
            return True      # 不明なら安全側(移動しない)
        for b in r["result"].get("balance", []):
            if b.get("coin") != "USDT":
                continue
            wallet = Decimal(str(b.get("walletBalance") or "0"))
            transferable = Decimal(str(b.get("transferBalance") or "0"))
            # 移動可能額がウォレット残高より明確に小さい = 拘束されている
            if wallet > 0 and transferable < wallet * Decimal("0.9"):
                return True
        return False
    except Exception:
        return True


def build_plan(http, target_weights: pd.Series, uid_map: dict,
               cfg: RebalanceConfig = RebalanceConfig()) -> RebalancePlan:
    """現状と目標を比較して移動計画を作る。

    uid_map: {戦略名: UID}
    """
    snap = take_snapshot(http, {v: k for k, v in uid_map.items()})
    plan = RebalancePlan(total_equity=snap.total_equity)

    if snap.total_equity <= 0:
        return plan

    current = {a.label: a.equity / snap.total_equity
               for a in snap.accounts if a.ok}
    plan.current = current
    plan.target = {k: Decimal(str(v)) for k, v in target_weights.items()}

    for label in set(current) | set(plan.target):
        cur = current.get(label, Decimal(0))
        tgt = plan.target.get(label, Decimal(0))
        plan.deviations[label] = cur - tgt

    max_dev = max((abs(v) for v in plan.deviations.values()),
                  default=Decimal(0))
    plan.triggered = max_dev >= cfg.threshold
    if not plan.triggered:
        return plan

    # 過剰な口座(送り手)と不足している口座(受け手)を分ける
    senders = sorted(
        [(l, v) for l, v in plan.deviations.items() if v > 0],
        key=lambda x: -x[1])
    receivers = sorted(
        [(l, -v) for l, v in plan.deviations.items() if v < 0],
        key=lambda x: -x[1])

    max_amount = snap.total_equity * cfg.max_transfer_ratio

    for send_label, send_excess in senders:
        send_uid = uid_map.get(send_label, "")
        if not send_uid:
            continue

        if cfg.skip_if_positions and has_open_positions(http, send_uid):
            plan.actions.append(RebalanceAction(
                send_label, send_uid, "-", "", Decimal(0),
                "ポジション保有中のためスキップ", error="skipped"))
            continue

        remaining = min(send_excess * snap.total_equity, max_amount)

        for recv_label, recv_need in receivers:
            if remaining < cfg.min_transfer:
                break
            recv_uid = uid_map.get(recv_label, "")
            if not recv_uid:
                continue

            amount = min(remaining, recv_need * snap.total_equity)
            if amount < cfg.min_transfer:
                continue

            plan.actions.append(RebalanceAction(
                send_label, send_uid, recv_label, recv_uid,
                amount.quantize(Decimal("0.01")),
                f"乖離 {send_excess:+.1%} → {-recv_need:+.1%}"))
            remaining -= amount

    return plan


def execute_plan(http, plan: RebalancePlan,
                 cfg: RebalanceConfig = RebalanceConfig()) -> None:
    """計画を実行する。"""
    if cfg.dry_run:
        print("\n  [dry_run] 実際の移動は行いません。")
        return

    for action in plan.actions:
        if action.error or action.amount <= 0:
            continue
        try:
            result = safe_transfer(
                http, cfg.coin, action.amount,
                action.from_uid, action.to_uid,
                min_remaining=cfg.min_account_balance)
            if result.ok:
                action.executed = True
            else:
                action.error = f"{result.ret_code} {result.ret_msg}"
        except Exception as e:
            action.error = f"{type(e).__name__}: {e}"
        time.sleep(1.0)


if __name__ == "__main__":
    from hrp_allocation import apply_constraints, compute_hrp
    from portfolio_view import load_history, strategy_returns

    c = make_client()

    uid_map = {
        # "トレンドフォロー": "12345678",
        # "グリッド": "12345679",
        # "ファンディングArb": "12345680",
    }

    history = load_history()
    if history.empty or history.shape[1] < 2:
        print("資産推移の記録が不足しています。")
        print("portfolio_view.py を定期実行して蓄積してください。")
        raise SystemExit(0)

    returns = strategy_returns(history)
    allocation = compute_hrp(returns)
    target = apply_constraints(allocation.weights)

    print(allocation.report())
    print()

    if not uid_map:
        print("uid_map が空です。戦略名とUIDの対応を設定してください。")
        raise SystemExit(0)

    plan = build_plan(c.http, target, uid_map, RebalanceConfig(dry_run=True))
    print(plan.report())

    execute_plan(c.http, plan, RebalanceConfig(dry_run=True))

    print("\n※ 閾値10%は保守的な設定。")
    print("  頻繁にリバランスすると、移動中に取引できない時間が増える。")
    print("  月1回程度の頻度が現実的。")
transfer_audit.pyサンプル7
"""transfer_audit.py — 資金移動の履歴照合と整合性チェック
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  照合すること:
    1. ローカルの記録(transfer_log.jsonl)と取引所の履歴が一致するか
    2. PENDING のまま放置されている移動が無いか
    3. 記録に無い移動が発生していないか(← 最重要。不正の兆候)
    4. 失敗した移動が積み上がっていないか

  3 が検出されたら、APIキーの漏洩を疑うべき状況です。
"""
from __future__ import annotations

import json
import time
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path

from client import make_client
from transfer_internal import TRANSFER_LOG
from transfer_universal import query_universal


@dataclass
class AuditIssue:
    severity: str        # info / warning / critical
    kind: str
    message: str
    detail: dict = field(default_factory=dict)

    def line(self) -> str:
        mark = {"info": "  ", "warning": "! ", "critical": "!!"}[self.severity]
        return f"{mark} [{self.kind}] {self.message}"


def load_local_transfers(path: Path = TRANSFER_LOG) -> list:
    if not path.exists():
        return []
    out = []
    with path.open(encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                out.append(json.loads(line))
            except json.JSONDecodeError:
                continue
    return out


def fetch_remote_transfers(http, limit: int = 50) -> list:
    """取引所側の移動履歴を取得する(internal と universal の両方)。"""
    records = []

    try:
        r = http.get_internal_transfer_records(limit=limit)
        if r["retCode"] == 0:
            for item in r["result"].get("list", []):
                item["_kind"] = "internal"
                records.append(item)
    except Exception as e:
        print(f"  [warn] internal履歴の取得に失敗: {e}")

    try:
        for item in query_universal(http, limit=limit):
            item["_kind"] = "universal"
            records.append(item)
    except Exception as e:
        print(f"  [warn] universal履歴の取得に失敗: {e}")

    return records


def audit(http, lookback_days: float = 30.0) -> list:
    """整合性を確認する。"""
    issues: list = []
    cutoff = time.time() - lookback_days * 86400

    local = [t for t in load_local_transfers() if t.get("ts", 0) >= cutoff]
    remote = fetch_remote_transfers(http)

    local_ids = {t["transfer_id"] for t in local if t.get("transfer_id")}
    remote_ids = {r.get("transferId", "") for r in remote}
    remote_ids.discard("")

    # 1) ローカルにあるが取引所に無い
    for tid in local_ids - remote_ids:
        rec = next(t for t in local if t.get("transfer_id") == tid)
        issues.append(AuditIssue(
            "warning", "未確認",
            f"ローカル記録があるが取引所履歴に無い: "
            f"{rec.get('amount')} {rec.get('coin')}",
            {"transfer_id": tid}))

    # 2) 取引所にあるがローカルに無い ← 最重要
    for tid in remote_ids - local_ids:
        rec = next(r for r in remote if r.get("transferId") == tid)
        ts = int(rec.get("timestamp", 0)) / 1000
        if ts < cutoff:
            continue
        when = datetime.fromtimestamp(ts, tz=timezone.utc) \
            .strftime("%Y-%m-%d %H:%M UTC")
        issues.append(AuditIssue(
            "critical", "記録外の移動",
            f"botの記録に無い資金移動: {rec.get('amount')} "
            f"{rec.get('coin')} ({when})",
            {"transfer_id": tid, "record": rec}))

    # 3) PENDING のまま放置
    for rec in remote:
        if rec.get("status") != "PENDING":
            continue
        ts = int(rec.get("timestamp", 0)) / 1000
        age_min = (time.time() - ts) / 60
        if age_min > 30:
            issues.append(AuditIssue(
                "warning", "保留中",
                f"{age_min:.0f}分間PENDINGのままの移動: "
                f"{rec.get('amount')} {rec.get('coin')}",
                {"transfer_id": rec.get("transferId", "")}))

    # 4) 失敗の集計
    failed = [t for t in local if t.get("status") == "FAILED"]
    if failed:
        issues.append(AuditIssue(
            "info", "失敗",
            f"直近{lookback_days:.0f}日で {len(failed)}件の移動が失敗",
            {"count": len(failed)}))

    return issues


def summarize_flows(lookback_days: float = 30.0) -> None:
    """資金の流れを集計する。"""
    cutoff = time.time() - lookback_days * 86400
    local = [t for t in load_local_transfers()
             if t.get("ts", 0) >= cutoff and t.get("status") != "FAILED"]

    if not local:
        print("  記録がありません。")
        return

    flows = defaultdict(Decimal)
    for t in local:
        key = (t.get("from", "?"), t.get("to", "?"))
        try:
            flows[key] += Decimal(str(t.get("amount", "0")))
        except Exception:
            continue

    print(f"  {'送り元':<24}{'送り先':<24}{'合計':>14}")
    print("  " + "-" * 62)
    for (src, dst), amount in sorted(flows.items(), key=lambda kv: -kv[1]):
        print(f"  {src:<24}{dst:<24}{amount:>14.2f}")

    total = sum(flows.values(), Decimal(0))
    print(f"\n  移動総額: {total:.2f}  ({len(local)}件)")


if __name__ == "__main__":
    c = make_client()

    print("=== 資金移動の監査(過去30日) ===\n")
    issues = audit(c.http, lookback_days=30)

    if not issues:
        print("  問題は見つかりませんでした。")
    else:
        order = {"critical": 0, "warning": 1, "info": 2}
        for issue in sorted(issues, key=lambda i: order[i.severity]):
            print(issue.line())

    critical = [i for i in issues if i.severity == "critical"]
    if critical:
        print("\n!! 記録に無い資金移動が検出されました !!")
        print("   自分が手動で行った移動でなければ、")
        print("   APIキーの漏洩を疑ってください。")
        print("   即座にキーを無効化し、記事24のキルスイッチを実行してください。")

    print("\n=== 資金の流れ ===")
    summarize_flows(30)

    print("\n※ 監査はcronで日次実行し、")
    print("  critical が出たら記事39のアラートで通知すること。")
参考リンク・文献

よくある疑問Q&A

サブアカウントを分ければレート制限も分かれますか
分かれません。UID×エンドポイント別の制限は分かれますが、IP単位の制限(600 req/5s)はUIDをまたいで共有されます。同じサーバから複数UIDを叩けば合算されるため、レート回避の手段にはなりません。
サブアカウントの目的は証拠金の分離とAPIキーの権限分離です。レート対策は記事05の流量ゲートで行ってください。
internal transferとuniversal transferの使い分けは
同一UID内なら internal、UIDをまたぐなら universalです。
入金直後の資金は FUND アカウントに入るため、取引するには create_internal_transfer で FUND → UNIFIED に移す必要があります。サブアカウントへの配分は create_universal_transfer で、マスターUIDのAPIキーでのみ実行可能です。サブのキーで呼ぶと権限エラーになります。
資金移動がタイムアウトしました。再送していいですか
同じ transferId で再送してください。新しいIDを生成すると二重送金になります。
transferId はUUIDでよく、重要なのは「1回の意図につき1つ生成し、再送では同じものを使う」ことです。サンプル2の wait_for_completion() で PENDING の解決を待つか、後から query_transfer() で結果を確認してください。
botに資金移動権限を持たせてもいいですか
持たせないでください。暴走したbotが全資産を1つの口座に吸い上げる事故が起きます。分離した意味が消えます。
資金移動はマスターUIDの管理プロセスだけが行い、各戦略botには取引権限のみを与えます(サンプル1の PERMISSIONS_TRADING_ONLY)。加えてWithdraw権限は絶対に付けないでください。botに出金権限は不要です。
HRPは戦略が3つでも使えますか
計算はできますが意味が薄いです。階層クラスタリングが機能するには目安5戦略以上が必要で、3つでは実質的に逆ボラウェイトと変わりません。
サンプル5の compute_hrp() は戦略数が min_strategies 未満のとき自動的に逆ボラウェイトにフォールバックします。無理にHRPを使うより、単純な方式のほうが頑健です。
リバランスの頻度はどのくらいが適切ですか
月1回程度が現実的です。資金移動には時間がかかり、その間ポジションを持てない口座が生じます。
サンプル6は閾値リバランス(乖離が10%を超えたときだけ実行)を採用しています。カレンダーベースで毎日合わせると、移動コストと機会損失のほうが分散効果を上回ります。ポジション保有中の口座からは資金を抜かない設計にしている点も重要です(証拠金が減って清算されます)。
戦略別のリターンはどう測ればいいですか
サブアカウントごとの資産推移を定期記録するのが最も単純です(サンプル4)。ただし資産の変化には損益と資金移動が混ざります。
正確に測るには、サンプル7の資金移動記録と突き合わせて移動分を除外します。サンプル4の strategy_returns() は簡易的に±50%でクリップしていますが、これは近似です。リバランス直後のリターンは信頼せず、除外するのが安全です。
記録に無い資金移動が検出されました
自分が手動で行った移動でなければ、APIキーの漏洩を疑ってください。サンプル7の監査が critical を返した場合です。
直ちに該当キーを無効化し、記事24のキルスイッチで全ポジションを解消してください。その上で、IP制限の設定漏れ・.env のコミット・ログへのシークレット出力といった漏洩経路を確認します。この監査を日次で自動実行し、critical を通知するのが最低限の防御です。

注意事項・免責事項

投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。

当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。

当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。

当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。

当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。

当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。

当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。