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 件の残存バグが観測された:
- Inject された position の
entry_params={}が空 →CR=0.00 TP=0.00ghost として PT state に残り、TP/SL 評価が効かない - 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)¶
この時点で 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_spreads の entry_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_positionstest_reconcile_startup_working_no_order_id_filled_when_position_existsAEGIS/WORK_LOG/2026-04-11_codex_lt_rc_drift_follow_up.mdの作業ログ
❌ Forbidden¶
- BT lane (
aegis_v3/aegis-bt-rs/**) は一切触らない - 既存
_auto_inject_saxo_excess_spreadsの signature は変更しない (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_spreadtest が 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_spread と test_inject_respects_existing_pt_pair_count の expected behavior が変わるので、assertion を新しい挙動に合わせる。
6. 実装後の検証手順¶
pytest tests/unit/test_paper_trading_reconcile_inject.py -v→ 11 tests pass (既存 8 + 新規 3)pytest tests/unit/test_order_manager*.py→ 既存 + 新規 2 tests passpytest tests/で全 regression- commit → push → Deploy PT workflow 自動起動 → container recreate
- container restart 時に OrderManager の reconcile_on_startup が新コードで走り、CENX 相当の lost detection が Saxo fallback 経由で安全に処理されるか確認
- 次の
_reconcile_positions(~7.5 分後) で PLTR の ghost record が 新規 inject ではなく merge される挙動を確認 /eecheckで LT_RC state が "PLTR qty=2 + entry_params 保持" になっているか確認
7. Stop conditions (Codex 向け)¶
以下の場合は作業を停止し、Claude (BT lane) と相談:
- merge ロジックが既存の
test_inject_respects_existing_pt_pair_count(同 strike 重複防止) と矛盾する場合 _reconcile_fill_handlerの locking / async 競合で順路 B に Saxo fallback を足すと deadlock になる場合- 既存 pytest が新コードで 1 件でも regression する場合
- Option A の merge 後に exit 評価 (TP hit で close) で quantity の扱いが壊れる場合
- 本番で
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)