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:
@@ -668,6 +668,11 @@ async def partial_derisk(
|
||||
trade = row.scalar_one_or_none()
|
||||
if trade is None or trade.closed_at is not None:
|
||||
return True # gone / already fully closed — nothing to do
|
||||
if trade.released_at is not None:
|
||||
# User took back manual control between tick capture and
|
||||
# execution — do NOT partially reduce a position they're
|
||||
# now driving themselves.
|
||||
return True
|
||||
if (trade.derisk_steps_done or 0) > step_idx:
|
||||
return True # this step already executed (idempotent)
|
||||
if (trade.derisk_steps_done or 0) != step_idx:
|
||||
@@ -757,6 +762,10 @@ async def pyramid_add(
|
||||
trade = row.scalar_one_or_none()
|
||||
if trade is None or trade.closed_at is not None:
|
||||
return (True, None, None)
|
||||
if trade.released_at is not None:
|
||||
# User took back manual control — never pyramid into a
|
||||
# position they're now managing themselves.
|
||||
return (True, None, None)
|
||||
if (trade.derisk_steps_done or 0) > 0:
|
||||
return (True, None, None) # went underwater — no pyramiding
|
||||
if (trade.addon_steps_done or 0) > step_idx:
|
||||
@@ -840,28 +849,44 @@ async def close_and_finalize(
|
||||
asset: str,
|
||||
wallet: str,
|
||||
reason: str = "max_hold",
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Close a live HL position and write exit_price/pnl to BotTrade.
|
||||
Race-safe: a conditional UPDATE `closed_at = now WHERE closed_at IS NULL`
|
||||
lets exactly one caller win; losers return silently. Also wraps in a
|
||||
per-trade asyncio.Lock to prevent us even *attempting* the HL API call twice.
|
||||
|
||||
`force=True` overrides the released_at guard — only the explicit user
|
||||
close path (manual_close API) should pass it. Automated paths (tp/sl
|
||||
monitor, max-hold, time-stop, reconciler) must respect release.
|
||||
"""
|
||||
async with _lock_for(trade_id):
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
# Atomic claim: only one caller sets closed_at.
|
||||
claim = await db.execute(
|
||||
# Atomic claim: only one caller sets closed_at. For automated
|
||||
# callers, ALSO require released_at IS NULL — a price tick
|
||||
# captured BEFORE release_management ran would otherwise be
|
||||
# able to close an HL position the user just took back
|
||||
# control of. The manual user-close endpoint sets force=True
|
||||
# because the user explicitly wants the position gone
|
||||
# regardless of release state.
|
||||
claim_stmt = (
|
||||
update(BotTrade)
|
||||
.where(BotTrade.id == trade_id)
|
||||
.where(BotTrade.closed_at.is_(None))
|
||||
.values(closed_at=now_naive)
|
||||
)
|
||||
if not force:
|
||||
claim_stmt = claim_stmt.where(BotTrade.released_at.is_(None))
|
||||
claim = await db.execute(claim_stmt.values(closed_at=now_naive))
|
||||
await db.commit()
|
||||
if claim.rowcount == 0:
|
||||
return # someone else closed it
|
||||
# Either someone else closed it, or (automated path) the
|
||||
# user released it between the price-tick capture and
|
||||
# this close attempt. Either way: bail without touching HL.
|
||||
return
|
||||
|
||||
# Reload the row we just claimed
|
||||
result = await db.execute(select(BotTrade).where(BotTrade.id == trade_id))
|
||||
|
||||
Reference in New Issue
Block a user