コンテンツにスキップ

Proposal: LT_RC PT/Saxo drift 残存バグ 2 件の follow-up 修正

Target agent: Codex (LT runtime lane owner) Author: Claude (BT lane) Date: 2026-04-11 Parent commit: 889c0ceaa fix(paper_trading): PLTR drift 根本修正 — Saxo 余剰 spread の自動注入 reconcile (Codex, 2026-04-11 04:34 JST) Observation date: 2026-04-11 05:00 JST, LT_RC 本番 paper_trading 直読み Related WORK_LOG: AEGIS/WORK_LOG/2026-04-11_lt_rc_drift_forensics.md (to be created)


TL;DR

Codex が 04:34 JST に入れた _auto_inject_saxo_excess_spreads 修正は正しい方向性だが、実運用で以下 2 件の残存バグが観測された:

  1. Inject された position の entry_params={} が空CR=0.00 TP=0.00 ghost として PT state に残り、TP/SL 評価が効かない
  2. OrderManager reconcile_on_startup の「順路 B」(WORKING state + order_id 空) が Saxo 実態を確認せず cleanup → 生きている working order を誤って lost 扱いにし、同一 underlying で重複 entry が出る risk

この proposal は follow-up として、上記 2 件を Codex lane (paper_trading.py / order_manager.py) で修正することを依頼する。BT lane (Claude) は本件に直接触らず、proposal doc と forensics のみを提供する。


1. 観測データ (2026-04-11 05:00 JST)

1.1 LT_RC PT state (pt_multi_lt_rc_state.json 直読み)

Virtual capital (base): $4,135.00
PT equity: $3,922.00  cash: $1,493.95  reserved_margin: $2,285.00
Saxo NLV: $2,852.95  Saxo cash: $4,306.37  Saxo margin util: 65.77%

=== PT open positions (7) ===
  ARQQ   12.5/10.0 w=2.5 qty=1 exp=2026-05-15 DTE=34 CR=0.25 TP=0.38 uPnL=$-23
  PLTR   135.0/130.0 w=5.0 qty=1 exp=2026-05-08 DTE=27 CR=0.25 TP=0.38 uPnL=$-164  ← original
  MNDY   60.0/55.0 w=5.0 qty=1 exp=2026-05-15 DTE=34 CR=0.25 TP=0.38 uPnL=$-100
  CENX   55.0/50.0 w=5.0 qty=1 exp=2026-05-15 DTE=34 CR=0.25 TP=0.38 uPnL=$+43
  AAOI   115.0/110.0 w=5.0 qty=1 exp=2026-05-15 DTE=34 CR=0.35 TP=0.38 uPnL=$-20
  LEU    160.0/155.0 w=5.0 qty=1 exp=2026-05-15 DTE=34 CR=0.35 TP=0.38 uPnL=$+15
  PLTR   135.0/130.0 w=5.0 qty=1 exp=2026-05-08 DTE=27 CR=0.00 TP=0.00 uPnL=$-156  ← ghost (injected)

1.2 Saxo 実ポジション (TraderGO UI 確認、14 legs)

Symbol Spread Qty
AAOI 115/110 Put 1
ARQQ 12.5/10.0 Put 1
CENX 55/50 Put 1
LEU 160/155 Put 1
MNDY 60/55 Put 1
PLTR 135/130 Put 2

PT open_positions との対応: - underlying レベルでは一致 (AAOI, ARQQ, CENX, LEU, MNDY, PLTR) - PLTR だけ Saxo qty=2 vs PT qty=1+1 (分裂した 2 records)

1.3 reconcile ログ (2026-04-11 04:45:01)

04:45:01 | DEBUG    | [LT_RC] [SaxoAdapter] Retrieved 14 positions
04:45:01 | WARNING  | [LT_RC] RECONCILE QTY DRIFT: PLTR PT_qty(legs)=2 Saxo_qty(legs)=4. Possible partial fill or manual adjustment.
04:45:01 | WARNING  | [LT_RC] RECONCILE INJECT: PLTR P spread 135.0/130.0 exp=2026-05-08 credit=$1.3600 max_loss=$3.64 (cash NOT adjusted — already received historically)
04:45:01 | WARNING  | [LT_RC] RECONCILE AUTO-INJECT completed. PT state updated from Saxo truth.
05:00:41 | DEBUG    | [LT_RC] Position reconciliation OK: PT=['AAOI', 'ARQQ', 'CENX', 'LEU', 'MNDY', 'PLTR'] Saxo=['AAOI', 'ARQQ', 'CENX', 'LEU', 'MNDY', 'PLTR']

credit=$1.3600 で inject されているので entry_credit フィールドは正しく設定されているが、entry_params={} が空のため TP/SL 判定に必要な cr tp が参照できない。

1.4 CENX "no order_id, treating as lost" (2026-04-11 04:25:46)

04:25:46 | WARNING  | [OrderManager] LT_RC (LT_RC:CENX) has no order_id, treating as lost

この時点で CENX は: - PT state では open position として存在 (qty=1) - Saxo 側では同日に deploy で recreate された container が reconcile_on_startup を走らせた - OrderManager の persisted orders に CENX の WORKING state record が order_id 空で残っていた - L542 の「順路 B」で Saxo 側を確認せず _cleanup_order(key) → OrderManager の tracking 消失

その直後の Saxo UI スクリーンショット ([images 8/9/10]) では CENX Vertical 15K26 P50/P55 が 注文中 (working) として表示されている。つまり Saxo 側には生きている working order があるのに、OrderManager が lost 扱いにした = has_working_entry_order=False を返す → 次の scan で重複 entry が submit される risk。


2. Starting state — 修正が必要な箇所

2.1 _auto_inject_saxo_excess_spreadsentry_params={} (paper_trading.py L3999)

# L3978-L4002
pos_dict = {
    "underlying": ul,
    "symbol": ul,
    "strategy_name": "BeatShield",  # best-guess default
    "quantity": 1,
    "entry_credit": entry_credit,
    "current_price": entry_credit,
    "max_loss": actual_max_loss,
    "spread_width": spread_width,
    "short_strike": short_strike,
    "long_strike": long_strike,
    "short_expiration": exp.isoformat() if exp else "",
    "option_type": norm_otype,
    "opened_at": now_et.date().isoformat(),
    "entry_date": now_et.date().isoformat(),
    "entry_ts": now_et.isoformat(),
    "order_id": "",
    "open_order_id": "",
    "unrealized_pnl": 0.0,
    "entry_params": {},                      # ← 空辞書 = CR=0.00 TP=0.00
    "reconciled_from_saxo": True,
    "reconciled_at": now_et.isoformat(),
}

問題: exit 判定で entry_params["tp"]entry_params["cr"] を参照する箇所で 0.00 として扱われ、TP hit / SL hit が永遠にトリガーしない。

2.2 OrderManager 順路 B (order_manager.py L542-L547)

# L542-L547
if not order.order_id:
    logger.warning(
        f"[OrderManager] {scenario_label} ({key}) has no order_id, treating as lost"
    )
    self._cleanup_order(key)
    continue

この分岐は state が WORKING/CANCELLING のケース (L485-L488 の条件を満たすケース) で、Saxo 側確認なしに cleanup している。L500-L540 の順路 A (PLACING/REPRICING) では Saxo positions fallback が走るのと対照的。


3. Target state — 期待する挙動

3.1 _auto_inject_saxo_excess_spreads の修正

Option A (推奨): 既存 PT position と同じ strikes/expiry がある場合、inject せずに既存の qty を 1 加算する

# After computing (short_strike, long_strike, spread_width, exp, norm_otype),
# first try to find a matching existing PT position and increment its quantity.
merged = False
for pos in self.state.open_positions:
    if (pos.get("underlying") or "").upper() != ul:
        continue
    if pos.get("short_strike") != short_strike:
        continue
    if pos.get("long_strike") != long_strike:
        continue
    pos_exp = pos.get("short_expiration", "")
    try:
        pos_exp_date = date.fromisoformat(str(pos_exp)[:10]) if pos_exp else None
    except (ValueError, TypeError):
        pos_exp_date = None
    if pos_exp_date != exp:
        continue
    pos_otype = (pos.get("option_type") or "").upper()
    if pos_otype not in (norm_otype, otype.upper()):
        continue
    # Match — increment quantity, keep entry_params unchanged.
    pos["quantity"] = int(pos.get("quantity", 1)) + 1
    pos["reconciled_from_saxo"] = True
    pos["reconciled_at"] = now_et.isoformat()
    injected_any = True
    logger.warning(
        f"RECONCILE MERGE: {ul} {norm_otype} spread {short_strike}/{long_strike} "
        f"exp={exp} — existing PT position qty incremented to {pos['quantity']}"
    )
    merged = True
    break

if merged:
    continue  # skip append

# Otherwise: no matching position, inject a new one (existing logic)

理由: - 同じ strikes/expiry/option_type を持つ2件は論理的に同一 spread (qty だけ違う) - PT 側の entry_params (cr, tp 等) を温存できるので TP/SL 判定が機能する - quantity field を素直に 2 に増やすだけで exit 評価時に正しく multiply される

注意: - pt_existing_pair_count は既存の重複防止ロジックだが、to_inject > 0 の判定が「既存 pair 数より Saxo が多い」ケースを対象にしている。Merge ロジックを追加した場合も、既存の pt_existing_pair_count チェックは残す必要がある (merge 済みの分を二重カウントしないため)。

Option B (代替): inject 時に既存 PT position の entry_params をコピー

同 underlying の「どの position」からコピーするかが曖昧なので、Option A の方が安全。

3.2 OrderManager 順路 B の Saxo fallback 追加

L542-L547 を以下に置き換え (順路 A のロジックを共有 helper に抽出推奨):

if not order.order_id:
    logger.warning(
        f"[OrderManager] {scenario_label} ({key}) has no order_id in {order.state}, "
        f"checking Saxo positions before discarding..."
    )
    positions_found = False
    try:
        broker_positions = await self._broker.get_positions()
        ul = order.underlying.upper() if order.underlying else ""
        if ul and any(
            (p.underlying or p.symbol or "").upper() == ul
            for p in broker_positions
            if p.option_type
        ):
            logger.warning(
                f"[OrderManager] {scenario_label} ({key}) no order_id but {ul} has "
                f"positions on Saxo — treating as filled"
            )
            result = OrderResult(
                order_id="",
                status=OrderStatus.FILLED,
                average_price=order.limit_price,
                message="Reconciled (path B): no order_id but position found on Saxo",
            )
            if self._reconcile_fill_handler:
                await self._reconcile_fill_handler(key, order, result)
            else:
                await self._invoke_fill_callback(key, result)
            positions_found = True
    except Exception as pe:
        logger.error(
            f"[OrderManager] {scenario_label} ({key}) positions fallback failed: {pe}"
        )
    if not positions_found:
        logger.warning(
            f"[OrderManager] {scenario_label} ({key}) in {order.state}, "
            f"no order_id and no position — treating as lost"
        )
    self._cleanup_order(key)
    continue

さらに改善: Saxo get_orders() で order_id を knwn state の working orders と matching する方法も考えられるが、現状 order_id が空 の状態で始まっているため無意味。get_positions() で matched if position exists → filled 扱い、なければ真の lost という path が現状取れる最善。

Risk: もし CENX のような「Saxo 側には working order だけあって、まだ position にはなっていない」状態の場合、position fallback では検出できない。しかしその場合は OrderManager の _orders dict に order_id が空のまま WORKING state として残ることは稀 (submit_entry 経路で order_id は _place_and_poll 経由で必ずセットされるはず)。

更なる改善案 (optional): broker.get_orders() を追加 fallback として呼び、underlying と limit_price でマッチする working order を Saxo 側から探す。これは proposal の scope 外で、より慎重な実装が必要。


4. Allowed vs Forbidden

✅ Allowed

  • aegis_v3/aegis/engine/paper_trading.py_auto_inject_saxo_excess_spreads 修正 (Option A)
  • aegis_v3/aegis/engine/order_manager.py の順路 B (L542-L547) に Saxo fallback 追加
  • aegis_v3/tests/unit/test_paper_trading_reconcile_inject.py への regression test 追加:
  • test_inject_merges_with_existing_pt_position_same_strikes_expiry (merge ロジック)
  • test_inject_preserves_existing_entry_params_on_merge (entry_params 温存)
  • test_inject_injects_new_position_when_no_match (既存の behavior)
  • aegis_v3/tests/unit/test_order_manager.py (or 新規) への regression test 追加:
  • test_reconcile_startup_working_no_order_id_checks_saxo_positions
  • test_reconcile_startup_working_no_order_id_filled_when_position_exists
  • AEGIS/WORK_LOG/2026-04-11_codex_lt_rc_drift_follow_up.md の作業ログ

❌ Forbidden

  • BT lane (aegis_v3/aegis-bt-rs/**) は一切触らない
  • 既存 _auto_inject_saxo_excess_spreadssignature は変更しない (unit tests が壊れる)
  • 既存 reconcile_on_startup の順路 A (L485-L540) は変更しない
  • strategy_name: "BeatShield" の default や entry_credit 計算ロジックは変更しない
  • PT state file (pt_multi_lt_rc_state.json) を直接手で編集しない (次の deploy 時の container recreate で新コードが自動処理)

⚠️ Review-first (human confirmation 必須)

  • Option A 実装時、pt_existing_pair_count ロジックとの interaction — merge 後の既存カウントをどう更新するか
  • 既存 test_inject_adds_missing_pltr_spread test が Option A で壊れる可能性大 — merge ロジック追加に合わせて expected behavior を更新する必要あり
  • Order_manager の Saxo fallback 追加で、既存の 8 orphan detection tests に影響がないか確認
  • _reconcile_fill_handler の呼び出し順序と locking の挙動確認 (順路 A は同じパターンで既に動いているので参考になる)

5. 受け入れテスト

5.1 新規テスト (paper_trading reconcile inject)

Test 1: 既存 PT position に merge

Given: PT state に PLTR 135/130 qty=1 CR=0.25 TP=0.38 が 1 件存在
       Saxo に PLTR 135/130 が 4 legs (qty=2 相当) 存在
       (pt_legs=2, saxo_legs=4, qty drift 発生)
When: _auto_inject_saxo_excess_spreads が走る
Then: PT state に PLTR 135/130 は 1 件のまま (新規追加なし)
      既存 position の quantity が 1 → 2 に増える
      entry_params は {"strategy":"BeatShield", "cr":0.25, "tp":0.38, ...} のまま温存
      reconciled_from_saxo=True フラグが立つ
      RECONCILE MERGE ログが出る

Test 2: 完全新規の position (既存と strikes 違う)

Given: PT state に PLTR 135/130 qty=1 が存在
       Saxo に PLTR 140/135 が別途 2 legs 存在
       (pt_legs=2, saxo_legs=4, qty drift)
When: _auto_inject_saxo_excess_spreads が走る
Then: PT state に PLTR 140/135 qty=1 が**新規追加**される (merge しない)
      entry_params={} (既存の動作)
      RECONCILE INJECT ログ (not MERGE)

Test 3: merge 後の二重 inject 防止

Given: PT state に PLTR 135/130 qty=2 (既に merge 済)
       Saxo に PLTR 135/130 が 4 legs (qty=2)
When: _auto_inject_saxo_excess_spreads が走る
Then: pt_existing_pair_count=2 == pair_count=2 なので to_inject=0
      何も起きない (merge も inject も走らない)

5.2 新規テスト (order_manager reconcile startup)

Test 4: 順路 B (WORKING + no order_id) で Saxo position あり → filled 扱い

Given: OrderManager に LT_RC:CENX が WORKING state で order_id="" として persist
       Saxo broker.get_positions() が CENX の position を返す
When: OrderManager.reconcile_on_startup() が走る
Then: _reconcile_fill_handler が CENX に対して呼ばれる
      _cleanup_order で orders dict から削除
      ログに "no order_id but CENX has positions on Saxo — treating as filled"
      `has_working_entry_order("LT_RC")` は False を返す (cleanup 済み)

Test 5: 順路 B で Saxo position なし → lost 扱い

Given: OrderManager に LT_RC:NOTHING が WORKING state で order_id="" として persist
       Saxo broker.get_positions() が NOTHING の position を返さない
When: OrderManager.reconcile_on_startup() が走る
Then: _cleanup_order で削除
      ログに "no order_id and no position — treating as lost"

5.3 統合 smoke test

pytest tests/unit/test_paper_trading_reconcile_inject.py tests/unit/test_paper_trading_mid_fallback.py tests/unit/test_paper_trading_broker_pricing.py で全 pass。特に merge ロジック追加後に既存の test_inject_adds_missing_pltr_spreadtest_inject_respects_existing_pt_pair_count の expected behavior が変わるので、assertion を新しい挙動に合わせる。


6. 実装後の検証手順

  1. pytest tests/unit/test_paper_trading_reconcile_inject.py -v → 11 tests pass (既存 8 + 新規 3)
  2. pytest tests/unit/test_order_manager*.py → 既存 + 新規 2 tests pass
  3. pytest tests/ で全 regression
  4. commit → push → Deploy PT workflow 自動起動 → container recreate
  5. container restart 時に OrderManager の reconcile_on_startup が新コードで走り、CENX 相当の lost detection が Saxo fallback 経由で安全に処理されるか確認
  6. 次の _reconcile_positions (~7.5 分後) で PLTR の ghost record が 新規 inject ではなく merge される挙動を確認
  7. /eecheck で LT_RC state が "PLTR qty=2 + entry_params 保持" になっているか確認

7. Stop conditions (Codex 向け)

以下の場合は作業を停止し、Claude (BT lane) と相談:

  1. merge ロジックが既存の test_inject_respects_existing_pt_pair_count (同 strike 重複防止) と矛盾する場合
  2. _reconcile_fill_handler の locking / async 競合で順路 B に Saxo fallback を足すと deadlock になる場合
  3. 既存 pytest が新コードで 1 件でも regression する場合
  4. Option A の merge 後に exit 評価 (TP hit で close) で quantity の扱いが壊れる場合
  5. 本番で reconciled_from_saxo=True フラグを参照している別コードが存在し、merge 経路でも立てるべきか立てないべきか判断がつかない場合

8. 本 proposal の scope 外 (別タスク)

  • Saxo UI に残存する 14 件の historical cancel 済み注文の整理 → ユーザーが TraderGO から直接操作 (cosmetic)
  • Saxo UI の "注文中" 2 件 (CENX working, MNDY working) の cancel → ユーザーが TraderGO から手動 cancel を推奨
  • 理由: 両方とも LT_RC が既に open position を保有している underlying で、追加 entry の意図なし
  • weekend 中に cancel すれば market open 時に想定外約定しない
  • Claude からの API 経由 cancel は deploy rule 抵触のため実行しない
  • reconciliation OK の判定を strikes/qty まで含めたタプル比較に強化 → follow-up proposal (本件は merge で対症療法)

9. 関連

  • 889c0ceaa fix(paper_trading): PLTR drift 根本修正 — Saxo 余剰 spread の自動注入 reconcile (Codex 親 commit)
  • 6e6535fb4 [auto] fix: drift detection false negative, exit escalation cap, startup dedup (一度実装された startup dedup)
  • e3d4bc66f [auto] revert: remove startup dedup — same strikes can be legitimate duplicate positions (その revert)
  • project_lt_rc_cr035_decision.md (memory)
  • project_mid_fallback_cr_clamping_20260410.md (memory)
  • Saxo Ticket #16633 (multileg pricing OpenAPI)

Claude (BT lane) → Codex (LT runtime lane)