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:
@@ -26,6 +26,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.models.alert import AlertLog
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.paper_trade import PaperTrade
|
||||
from app.models.score import CompositeScore
|
||||
from app.models.sr_level import SRLevel
|
||||
from app.models.ticker import Ticker
|
||||
@@ -47,6 +48,7 @@ KEY_SR = "alerts_sr_proximity_enabled"
|
||||
KEY_SCORE_DROP = "alerts_score_drop_enabled"
|
||||
KEY_DIGEST = "alerts_digest_enabled"
|
||||
KEY_REGIME_QUADRANT = "alerts_regime_quadrant_enabled"
|
||||
KEY_TRADE_CLOSED = "alerts_trade_closed_enabled"
|
||||
|
||||
_BOOL_DEFAULTS = {
|
||||
KEY_ENABLED: False,
|
||||
@@ -55,8 +57,15 @@ _BOOL_DEFAULTS = {
|
||||
KEY_SCORE_DROP: True,
|
||||
KEY_DIGEST: True,
|
||||
KEY_REGIME_QUADRANT: True,
|
||||
KEY_TRADE_CLOSED: True,
|
||||
}
|
||||
|
||||
# Paper-trade auto-close alert: catch every close at least once (the job runs
|
||||
# hourly), then never re-send the same trade (a huge cooldown ≈ once-per-trade).
|
||||
CLOSED_LOOKBACK_HOURS = 26
|
||||
CLOSED_ALERT_COOLDOWN_HOURS = 24 * 365 * 5
|
||||
TRADE_CLOSED_TYPE = "trade_closed"
|
||||
|
||||
# Tunables (kept as constants for now; promote to settings if needed)
|
||||
SR_PROXIMITY_PCT = 2.0 # within this % of a strong zone → alert
|
||||
SR_MIN_STRENGTH = 60 # only strong zones are alert-worthy
|
||||
@@ -90,7 +99,7 @@ def _as_bool(value: str | None, default: bool) -> bool:
|
||||
|
||||
|
||||
async def _resolve(db: AsyncSession) -> dict:
|
||||
keys = [KEY_ENABLED, KEY_TOKEN, KEY_CHAT_ID, KEY_QUALIFIED, KEY_SR, KEY_SCORE_DROP, KEY_DIGEST, KEY_REGIME_QUADRANT]
|
||||
keys = [KEY_ENABLED, KEY_TOKEN, KEY_CHAT_ID, KEY_QUALIFIED, KEY_SR, KEY_SCORE_DROP, KEY_DIGEST, KEY_REGIME_QUADRANT, KEY_TRADE_CLOSED]
|
||||
stored = await settings_store.get_map(db, keys)
|
||||
|
||||
db_token = (stored.get(KEY_TOKEN) or "").strip()
|
||||
@@ -113,6 +122,7 @@ async def _resolve(db: AsyncSession) -> dict:
|
||||
"score_drop": _as_bool(stored.get(KEY_SCORE_DROP), _BOOL_DEFAULTS[KEY_SCORE_DROP]),
|
||||
"digest": _as_bool(stored.get(KEY_DIGEST), _BOOL_DEFAULTS[KEY_DIGEST]),
|
||||
"regime_quadrant": _as_bool(stored.get(KEY_REGIME_QUADRANT), _BOOL_DEFAULTS[KEY_REGIME_QUADRANT]),
|
||||
"trade_closed": _as_bool(stored.get(KEY_TRADE_CLOSED), _BOOL_DEFAULTS[KEY_TRADE_CLOSED]),
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +139,7 @@ async def get_alert_config(db: AsyncSession) -> dict:
|
||||
"score_drop_enabled": r["score_drop"],
|
||||
"digest_enabled": r["digest"],
|
||||
"regime_quadrant_enabled": r["regime_quadrant"],
|
||||
"trade_closed_enabled": r["trade_closed"],
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +154,7 @@ async def update_alert_config(
|
||||
score_drop_enabled: bool | None = None,
|
||||
digest_enabled: bool | None = None,
|
||||
regime_quadrant_enabled: bool | None = None,
|
||||
trade_closed_enabled: bool | None = None,
|
||||
) -> dict:
|
||||
"""Persist config. An empty/omitted bot_token leaves the stored token intact."""
|
||||
bool_updates = {
|
||||
@@ -152,6 +164,7 @@ async def update_alert_config(
|
||||
KEY_SCORE_DROP: score_drop_enabled,
|
||||
KEY_DIGEST: digest_enabled,
|
||||
KEY_REGIME_QUADRANT: regime_quadrant_enabled,
|
||||
KEY_TRADE_CLOSED: trade_closed_enabled,
|
||||
}
|
||||
for key, val in bool_updates.items():
|
||||
if val is not None:
|
||||
@@ -376,9 +389,81 @@ async def _collect_digest(db: AsyncSession) -> tuple[str, str] | None:
|
||||
)
|
||||
else:
|
||||
lines.append("No qualified setups today.")
|
||||
|
||||
# Open paper trades: unrealized gain + the live trailing stop and how far away.
|
||||
from app.services import paper_trade_service
|
||||
|
||||
open_trades = await paper_trade_service.list_trades(db, status="open")
|
||||
if open_trades:
|
||||
lines.append("")
|
||||
lines.append(f"💼 <b>{len(open_trades)} open trade(s):</b>")
|
||||
for t in open_trades:
|
||||
entry = t["entry_price"]
|
||||
cur = t.get("current_price")
|
||||
sign = 1.0 if t["direction"] == "long" else -1.0
|
||||
if cur and entry:
|
||||
gain_pct = (cur - entry) / entry * 100.0 * sign
|
||||
gain_usd = (cur - entry) * t["shares"] * sign
|
||||
gain = f"{gain_pct:+.1f}% ({'+' if gain_usd >= 0 else '−'}${abs(gain_usd):.0f})"
|
||||
else:
|
||||
gain = "n/a"
|
||||
ts = t.get("trailing_stop")
|
||||
if ts is not None:
|
||||
dist = t.get("trailing_distance_pct")
|
||||
stop_txt = f"trail {ts:.2f}" + (f" ({dist:.1f}% away)" if dist is not None else "")
|
||||
else:
|
||||
stop_txt = f"stop {t['stop_loss']:.2f}"
|
||||
lines.append(f"• {t['symbol']} {t['direction'].upper()} {gain} · {stop_txt}")
|
||||
|
||||
return key, "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paper-trade close trigger (one summary per auto-closed trade)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _format_closed_trade(trade: PaperTrade, symbol: str) -> str:
|
||||
sign = 1.0 if trade.direction == "long" else -1.0
|
||||
entry = trade.entry_price
|
||||
exit_price = trade.close_price if trade.close_price is not None else entry
|
||||
per_share = (exit_price - entry) * sign
|
||||
pnl_pct = (per_share / entry * 100.0) if entry else 0.0
|
||||
pnl_usd = per_share * trade.shares
|
||||
risk = abs(entry - trade.stop_loss)
|
||||
r_mult = (per_share / risk) if risk > 0 else None
|
||||
win = per_share > 0
|
||||
money = f"{'+' if pnl_usd >= 0 else '−'}${abs(pnl_usd):.2f}"
|
||||
r_txt = f" · {r_mult:+.2f}R" if r_mult is not None else ""
|
||||
days = (trade.closed_at - trade.opened_at).days if (trade.closed_at and trade.opened_at) else None
|
||||
held = f" · held {days}d" if days is not None else ""
|
||||
reason = {"trailing": "trailing stop", "stop": "stop-loss", "target": "target"}.get(
|
||||
trade.close_reason or "", trade.close_reason or "closed"
|
||||
)
|
||||
return (
|
||||
f"{'✅' if win else '🔴'} <b>{symbol} {trade.direction.upper()} closed</b> ({reason})\n"
|
||||
f"{pnl_pct:+.1f}% · {money}{r_txt}{held}\n"
|
||||
f"{entry:.2f} → {exit_price:.2f}"
|
||||
)
|
||||
|
||||
|
||||
async def _collect_closed_trades(db: AsyncSession) -> list[tuple[str, str]]:
|
||||
"""One alert per auto-closed paper trade (trailing / stop / target). Manual
|
||||
closes are skipped — you already know about those. Dedup is by trade id."""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=CLOSED_LOOKBACK_HOURS)
|
||||
result = await db.execute(
|
||||
select(PaperTrade, Ticker.symbol)
|
||||
.join(Ticker, PaperTrade.ticker_id == Ticker.id)
|
||||
.where(
|
||||
PaperTrade.status == "closed",
|
||||
PaperTrade.closed_at.is_not(None),
|
||||
PaperTrade.closed_at > cutoff,
|
||||
PaperTrade.close_reason.in_(("trailing", "stop", "target")),
|
||||
)
|
||||
.order_by(PaperTrade.closed_at.desc())
|
||||
)
|
||||
return [(str(trade.id), _format_closed_trade(trade, symbol)) for trade, symbol in result.all()]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regime quadrant-change trigger (hysteresis + cooldown)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -500,6 +585,11 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
|
||||
for key, text in await _collect_regime_quadrant(db):
|
||||
outgoing.append((QUAD_TYPE, key, text))
|
||||
|
||||
if cfg["trade_closed"]:
|
||||
for key, text in await _collect_closed_trades(db):
|
||||
if not await _recently_alerted(db, TRADE_CLOSED_TYPE, key, cooldown_hours=CLOSED_ALERT_COOLDOWN_HOURS):
|
||||
outgoing.append((TRADE_CLOSED_TYPE, key, text))
|
||||
|
||||
sent = 0
|
||||
if outgoing:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
|
||||
Reference in New Issue
Block a user