feat: trailing-stop auto-exit for paper trades + close/digest alerts
Applies the backtest-validated trailing stop to live paper trading, and surfaces it transparently. Exit (A): - New paper-trade exit policy (paper_exit_mode=trailing, paper_trailing_pct=12), tunable in Admin → Paper-Trade Exit. resolve_open_trades runs a trailing stop (initial stop as floor, ratchets up from the peak; target ignored — the validated rule) and records close_reason (trailing|stop|target|manual; +migration 013). - list_trades enriches open trades with the live trailing-stop level + distance %. Open Trades panel shows the active tactic and a Trail Stop column. Alerts (B): - Daily digest now lists open trades with unrealized gain, trailing stop, and how far away it is. - New "trade closed" alert: one summary per auto-close (trailing/target/stop, not manual) — direction, reason, days held, P&L abs+%/R — covering wins AND stop-loss losses. Deduped by trade id; toggle in Admin alerts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ from app.exceptions import NotFoundError, ValidationError
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.paper_trade import PaperTrade
|
||||
from app.models.ticker import Ticker
|
||||
from app.services import benchmark_service
|
||||
from app.services import benchmark_service, settings_store
|
||||
from app.services.outcome_service import (
|
||||
OUTCOME_AMBIGUOUS,
|
||||
OUTCOME_STOP_HIT,
|
||||
@@ -20,6 +20,45 @@ from app.services.outcome_service import (
|
||||
evaluate_setup_against_bars,
|
||||
)
|
||||
|
||||
# Exit policy for OPEN paper trades (auto-close). "trailing" rides a trailing stop
|
||||
# (validated as the best exit in the backtest); "target" closes at the setup's
|
||||
# stop/target. Stored in SystemSetting so it's tunable + transparent in the UI.
|
||||
KEY_EXIT_MODE = "paper_exit_mode"
|
||||
KEY_TRAILING_PCT = "paper_trailing_pct"
|
||||
DEFAULT_EXIT_MODE = "trailing"
|
||||
DEFAULT_TRAILING_PCT = 12.0
|
||||
|
||||
|
||||
async def get_exit_policy(db: AsyncSession) -> dict:
|
||||
"""Active auto-exit policy: {'mode': 'trailing'|'target', 'trailing_pct': float}."""
|
||||
mode = (await settings_store.get_value(db, KEY_EXIT_MODE, DEFAULT_EXIT_MODE)).strip().lower()
|
||||
if mode not in ("trailing", "target"):
|
||||
mode = DEFAULT_EXIT_MODE
|
||||
raw = await settings_store.get_value(db, KEY_TRAILING_PCT, str(DEFAULT_TRAILING_PCT))
|
||||
try:
|
||||
pct = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
pct = DEFAULT_TRAILING_PCT
|
||||
pct = max(0.5, min(90.0, pct))
|
||||
return {"mode": mode, "trailing_pct": pct}
|
||||
|
||||
|
||||
async def set_exit_policy(
|
||||
db: AsyncSession, *, mode: str | None = None, trailing_pct: float | None = None
|
||||
) -> dict:
|
||||
"""Persist the auto-exit policy (admin). Validates inputs."""
|
||||
if mode is not None:
|
||||
mode = mode.strip().lower()
|
||||
if mode not in ("trailing", "target"):
|
||||
raise ValidationError("mode must be 'trailing' or 'target'")
|
||||
await settings_store.upsert_setting(db, KEY_EXIT_MODE, mode)
|
||||
if trailing_pct is not None:
|
||||
if not 0.5 <= float(trailing_pct) <= 90.0:
|
||||
raise ValidationError("trailing_pct must be between 0.5 and 90")
|
||||
await settings_store.upsert_setting(db, KEY_TRAILING_PCT, str(float(trailing_pct)))
|
||||
await db.commit()
|
||||
return await get_exit_policy(db)
|
||||
|
||||
|
||||
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
||||
normalised = symbol.strip().upper()
|
||||
@@ -51,6 +90,41 @@ async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, fl
|
||||
return {tid: float(close) for tid, close in result.all()}
|
||||
|
||||
|
||||
async def _max_high_after(db: AsyncSession, ticker_id: int, since: date) -> float | None:
|
||||
"""Highest high strictly after ``since`` — the running peak for a trailing stop."""
|
||||
result = await db.execute(
|
||||
select(func.max(OHLCVRecord.high)).where(
|
||||
OHLCVRecord.ticker_id == ticker_id, OHLCVRecord.date > since
|
||||
)
|
||||
)
|
||||
v = result.scalar()
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
def _trailing_close(
|
||||
direction: str, entry: float, init_stop: float, trail_frac: float, bars: list[Bar]
|
||||
) -> tuple[float, date, str] | None:
|
||||
"""Walk post-entry bars; return (price, date, reason) when the trailing or initial
|
||||
stop is hit, else None. The stop only ratchets up: max(init_stop, peak*(1-trail))
|
||||
for a long. reason = 'trailing' once it's above the initial stop, else 'stop'."""
|
||||
long = direction == "long"
|
||||
peak = entry
|
||||
for b in bars:
|
||||
if long:
|
||||
level = max(init_stop, peak * (1 - trail_frac))
|
||||
if b.low <= level:
|
||||
return level, b.date, ("trailing" if level > init_stop else "stop")
|
||||
if b.high > peak:
|
||||
peak = b.high
|
||||
else:
|
||||
level = min(init_stop, peak * (1 + trail_frac))
|
||||
if b.high >= level:
|
||||
return level, b.date, ("trailing" if level < init_stop else "stop")
|
||||
if b.low < peak:
|
||||
peak = b.low
|
||||
return None
|
||||
|
||||
|
||||
async def create_trade(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
@@ -91,6 +165,7 @@ def _to_dict(
|
||||
symbol: str,
|
||||
current_price: float | None,
|
||||
benchmark_closes: dict[date, float] | None = None,
|
||||
trailing: tuple[float, float | None] | None = None,
|
||||
) -> dict:
|
||||
# For open trades, mark to market; for closed, the realized exit price.
|
||||
ref = current_price if trade.status == "open" else trade.close_price
|
||||
@@ -130,19 +205,23 @@ def _to_dict(
|
||||
"benchmark_return_pct": benchmark_return,
|
||||
"alpha_pct": alpha_pct,
|
||||
"alpha_usd": alpha_usd,
|
||||
"close_reason": trade.close_reason,
|
||||
"trailing_stop": trailing[0] if trailing else None,
|
||||
"trailing_distance_pct": trailing[1] if trailing else None,
|
||||
}
|
||||
|
||||
|
||||
async def list_trades(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
user_id: int | None = None,
|
||||
status: str | None = None,
|
||||
) -> list[dict]:
|
||||
stmt = (
|
||||
select(PaperTrade, Ticker.symbol)
|
||||
.join(Ticker, PaperTrade.ticker_id == Ticker.id)
|
||||
.where(PaperTrade.user_id == user_id)
|
||||
)
|
||||
if user_id is not None: # None → all users (single-user app; used by the digest)
|
||||
stmt = stmt.where(PaperTrade.user_id == user_id)
|
||||
if status is not None:
|
||||
stmt = stmt.where(PaperTrade.status == status)
|
||||
stmt = stmt.order_by(PaperTrade.opened_at.desc())
|
||||
@@ -156,7 +235,32 @@ async def list_trades(
|
||||
# makes a provider call).
|
||||
benchmark_closes = await benchmark_service.load_benchmark_closes(db)
|
||||
|
||||
return [_to_dict(t, sym, prices.get(t.ticker_id), benchmark_closes) for t, sym in rows]
|
||||
# Current trailing-stop level + distance for open trades (when trailing is active).
|
||||
policy = await get_exit_policy(db)
|
||||
trailing_info: dict[int, tuple[float, float | None]] = {}
|
||||
if policy["mode"] == "trailing":
|
||||
trail_frac = policy["trailing_pct"] / 100.0
|
||||
for t, _ in rows:
|
||||
if t.status != "open":
|
||||
continue
|
||||
max_high = await _max_high_after(db, t.ticker_id, t.opened_at.date())
|
||||
peak = max(t.entry_price, max_high) if max_high is not None else t.entry_price
|
||||
long = t.direction == "long"
|
||||
level = (
|
||||
max(t.stop_loss, peak * (1 - trail_frac))
|
||||
if long
|
||||
else min(t.stop_loss, peak * (1 + trail_frac))
|
||||
)
|
||||
cur = prices.get(t.ticker_id)
|
||||
dist = None
|
||||
if cur:
|
||||
dist = ((cur - level) / cur * 100.0) if long else ((level - cur) / cur * 100.0)
|
||||
trailing_info[t.id] = (level, dist)
|
||||
|
||||
return [
|
||||
_to_dict(t, sym, prices.get(t.ticker_id), benchmark_closes, trailing_info.get(t.id))
|
||||
for t, sym in rows
|
||||
]
|
||||
|
||||
|
||||
async def close_trade(
|
||||
@@ -185,6 +289,7 @@ async def close_trade(
|
||||
|
||||
trade.status = "closed"
|
||||
trade.close_price = float(close_price)
|
||||
trade.close_reason = "manual"
|
||||
trade.closed_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await db.refresh(trade)
|
||||
@@ -204,6 +309,10 @@ async def resolve_open_trades(db: AsyncSession) -> int:
|
||||
if not open_trades:
|
||||
return 0
|
||||
|
||||
policy = await get_exit_policy(db)
|
||||
mode = policy["mode"]
|
||||
trail_frac = policy["trailing_pct"] / 100.0
|
||||
|
||||
closed = 0
|
||||
for trade in open_trades:
|
||||
bars_result = await db.execute(
|
||||
@@ -218,21 +327,27 @@ async def resolve_open_trades(db: AsyncSession) -> int:
|
||||
if not bars:
|
||||
continue
|
||||
|
||||
# max_bars beyond the data so a still-open trade returns undecided (not "expired").
|
||||
outcome, outcome_date = evaluate_setup_against_bars(
|
||||
trade.direction, trade.stop_loss, trade.target, bars, max_bars=len(bars) + 1
|
||||
)
|
||||
if outcome == OUTCOME_TARGET_HIT:
|
||||
trade.close_price = trade.target
|
||||
elif outcome in (OUTCOME_STOP_HIT, OUTCOME_AMBIGUOUS):
|
||||
trade.close_price = trade.stop_loss
|
||||
if mode == "trailing":
|
||||
hit = _trailing_close(trade.direction, trade.entry_price, trade.stop_loss, trail_frac, bars)
|
||||
if hit is None:
|
||||
continue # neither the trailing nor the initial stop reached yet
|
||||
close_price, close_date, reason = hit
|
||||
else:
|
||||
continue
|
||||
# max_bars beyond the data so a still-open trade returns undecided (not "expired").
|
||||
outcome, outcome_date = evaluate_setup_against_bars(
|
||||
trade.direction, trade.stop_loss, trade.target, bars, max_bars=len(bars) + 1
|
||||
)
|
||||
if outcome == OUTCOME_TARGET_HIT:
|
||||
close_price, close_date, reason = trade.target, outcome_date, "target"
|
||||
elif outcome in (OUTCOME_STOP_HIT, OUTCOME_AMBIGUOUS):
|
||||
close_price, close_date, reason = trade.stop_loss, outcome_date, "stop"
|
||||
else:
|
||||
continue
|
||||
|
||||
trade.status = "closed"
|
||||
trade.closed_at = datetime.combine(
|
||||
outcome_date, datetime.min.time(), tzinfo=timezone.utc
|
||||
)
|
||||
trade.close_price = float(close_price)
|
||||
trade.close_reason = reason
|
||||
trade.closed_at = datetime.combine(close_date, datetime.min.time(), tzinfo=timezone.utc)
|
||||
closed += 1
|
||||
|
||||
if closed:
|
||||
|
||||
Reference in New Issue
Block a user