fix(trading): close 3 race / lifecycle bugs in adopt-then-release path

1. recovery.py rehydrated adopted trades with no System-2 ladder
   ─────────────────────────────────────────────────────────────────
   On backend restart, recovery resolved stop_ladder / derisk_ladder /
   addon_ladder / peak_trail from trigger_post.category. Adopted trades
   have trigger_post_id=NULL (the user opened on HL manually, there's no
   source post) so all four resolved to None and the trade restarted as
   a vanilla "stop_loss + max_hold" position — the entire System-2 ladder
   logic silently disappeared across any restart.

   Fix: detect System-2 from the row itself (sys2_mode IS NOT NULL falls
   back to the adopted-category constant), then rebuild the full ladder
   set from frozen leverage + mode. derisk_steps_done / addon_steps_done /
   peak_gain_pct on the row let us pick up exactly where we left off.

2. close_and_finalize raced with release_management
   ─────────────────────────────────────────────────────────────────
   on_price_tick snapshots _watched, evaluates per trade, appends to a
   triggered list, then spawns _fire_close as an async task. If
   release_management runs between the snapshot and _fire_close firing,
   close_and_finalize would still close the HL position the user just
   took back manual control of.

   Fix: atomic claim now requires released_at IS NULL by default. New
   `force=True` parameter on close_and_finalize bypasses the guard for
   the explicit user-close API (manual_close passes it) so a deliberate
   user click still works on a released trade.

3. partial_derisk + pyramid_add raced with release_management
   ─────────────────────────────────────────────────────────────────
   Same race shape: a price tick captured before release could still
   trigger a partial reduce-only close, or pyramid INTO a position the
   user is now driving themselves.

   Fix: both functions return idempotent-success if released_at IS NOT
   NULL — match the closed_at handling we already had.

Tests still 64/64 + preflight + smoke all green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-26 13:18:48 +08:00
parent 3754d2caf8
commit 7599d4952d
3 changed files with 61 additions and 21 deletions
+28 -17
View File
@@ -90,6 +90,30 @@ async def rehydrate_open_trades() -> None:
select(Post).where(Post.id == t.trigger_post_id)
)
trigger_post = post_res.scalar_one_or_none()
# System-2 detection. Two paths reach here:
# (a) Auto-opened (legacy): trigger_post.category is set
# to e.g. "btc_bottom_reversal_long" → look up via
# the post's category.
# (b) Adopted (current): trigger_post_id is NULL because
# the user opened on HL manually. The trade IS a
# System-2 trade — sys2_mode is non-NULL on the row —
# but it has no source post to read category from.
# Fall back to the adopted-category constant so the
# ladder still rebuilds correctly on restart.
#
# Bug this guards: before this fix, adopted trades restarted
# with stop_ladder=None / derisk=None / addon=None /
# peak_trail=None — the entire System-2 ladder logic was
# silently lost across any backend restart, downgrading the
# trade to a plain stop_loss + max_hold position.
_cat_for_ladders = (
(trigger_post.category if trigger_post else None)
or (
"btc_bottom_reversal_long"
if t.sys2_mode is not None else None
)
)
_stop_ladder = _get_stop_ladder(_cat_for_ladders)
register_trade(
trade_id=t.id,
wallet=t.wallet_address,
@@ -109,34 +133,21 @@ async def rehydrate_open_trades() -> None:
else (trigger_post.invalidation_price if trigger_post else None)
),
min_hold_until_ts=t.eff_min_hold_until_ts,
stop_ladder=_get_stop_ladder(
trigger_post.category if trigger_post else None
),
# Restore staged de-risk: rebuild the ladder from the trade's
# FROZEN leverage and skip steps already executed before the
# restart (derisk_steps_done is persisted on the row).
# Restore staged de-risk/pyramid/peak-trail with the trade's
# FROZEN risk mode + leverage, skipping steps already executed
# before the restart (persisted on the row).
stop_ladder=_stop_ladder,
derisk_ladder=(
_sys2_derisk_ladder(t.leverage or 1, t.sys2_mode)
if _get_stop_ladder(trigger_post.category if trigger_post else None)
else None
if _stop_ladder else None
),
derisk_done=(t.derisk_steps_done or 0),
addon_ladder=(
_sys2_addon_ladder(t.sys2_mode)
if _get_stop_ladder(trigger_post.category if trigger_post else None)
else None
if _stop_ladder else None
),
addon_done=(t.addon_steps_done or 0),
# Restore the monotonic peak so a pyramided / in-profit trade
# doesn't fall back to the underwater de-risk regime on restart.
initial_peak=(t.peak_gain_pct or 0.0),
peak_trail=(
_sys2_peak_trail(t.sys2_mode)
if _get_stop_ladder(trigger_post.category if trigger_post else None)
else None
if _stop_ladder else None
),
grow_mode=bool(getattr(t, "grow_mode", False)),
)