diff --git a/alembic/versions/013_add_paper_trade_close_reason.py b/alembic/versions/013_add_paper_trade_close_reason.py
new file mode 100644
index 0000000..8660dd7
--- /dev/null
+++ b/alembic/versions/013_add_paper_trade_close_reason.py
@@ -0,0 +1,29 @@
+"""add close_reason to paper_trades
+
+Records how an open paper trade was closed (trailing | stop | target | manual) so
+the close alert can summarise it and the UI can show why a position exited.
+
+Revision ID: 013
+Revises: 012
+Create Date: 2026-06-30 00:00:00.000000
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = "013"
+down_revision: Union[str, None] = "012"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column("paper_trades", sa.Column("close_reason", sa.String(length=10), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column("paper_trades", "close_reason")
diff --git a/app/models/paper_trade.py b/app/models/paper_trade.py
index a457e59..5700ec0 100644
--- a/app/models/paper_trade.py
+++ b/app/models/paper_trade.py
@@ -34,3 +34,5 @@ class PaperTrade(Base):
)
close_price: Mapped[float | None] = mapped_column(Float, nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ # How the trade was closed: "trailing" | "stop" | "target" | "manual".
+ close_reason: Mapped[str | None] = mapped_column(String(10), nullable=True)
diff --git a/app/routers/paper_trades.py b/app/routers/paper_trades.py
index a7edbf1..869ff85 100644
--- a/app/routers/paper_trades.py
+++ b/app/routers/paper_trades.py
@@ -3,10 +3,15 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
-from app.dependencies import get_db, require_access
+from app.dependencies import get_db, require_access, require_admin
from app.models.user import User
from app.schemas.common import APIEnvelope
-from app.schemas.paper_trade import PaperTradeClose, PaperTradeCreate, PaperTradeResponse
+from app.schemas.paper_trade import (
+ ExitPolicyUpdate,
+ PaperTradeClose,
+ PaperTradeCreate,
+ PaperTradeResponse,
+)
from app.services import paper_trade_service
router = APIRouter(tags=["paper-trades"])
@@ -40,6 +45,26 @@ async def list_paper_trades(
return APIEnvelope(status="success", data=data)
+@router.get("/paper-trades/exit-policy", response_model=APIEnvelope)
+async def read_exit_policy(
+ _user: User = Depends(require_access),
+ db: AsyncSession = Depends(get_db),
+) -> APIEnvelope:
+ """The active auto-exit policy for open paper trades (shown in the UI)."""
+ return APIEnvelope(status="success", data=await paper_trade_service.get_exit_policy(db))
+
+
+@router.put("/paper-trades/exit-policy", response_model=APIEnvelope)
+async def write_exit_policy(
+ body: ExitPolicyUpdate,
+ _user: User = Depends(require_admin),
+ db: AsyncSession = Depends(get_db),
+) -> APIEnvelope:
+ """Change the auto-exit policy (admin)."""
+ data = await paper_trade_service.set_exit_policy(db, mode=body.mode, trailing_pct=body.trailing_pct)
+ return APIEnvelope(status="success", data=data)
+
+
@router.post("/paper-trades", response_model=APIEnvelope, status_code=201)
async def create_paper_trade(
body: PaperTradeCreate,
diff --git a/app/schemas/admin.py b/app/schemas/admin.py
index 9cdf9e9..40b2943 100644
--- a/app/schemas/admin.py
+++ b/app/schemas/admin.py
@@ -100,3 +100,4 @@ class AlertConfigUpdate(BaseModel):
score_drop_enabled: bool | None = None
digest_enabled: bool | None = None
regime_quadrant_enabled: bool | None = None
+ trade_closed_enabled: bool | None = None
diff --git a/app/schemas/paper_trade.py b/app/schemas/paper_trade.py
index ccf5fd9..9b960d6 100644
--- a/app/schemas/paper_trade.py
+++ b/app/schemas/paper_trade.py
@@ -20,6 +20,12 @@ class PaperTradeClose(BaseModel):
close_price: float | None = Field(default=None, gt=0)
+class ExitPolicyUpdate(BaseModel):
+ """Auto-exit policy for open paper trades."""
+ mode: str | None = Field(default=None, pattern=r"^(trailing|target)$")
+ trailing_pct: float | None = Field(default=None, ge=0.5, le=90)
+
+
class PaperTradeResponse(BaseModel):
id: int
symbol: str
@@ -38,3 +44,8 @@ class PaperTradeResponse(BaseModel):
benchmark_return_pct: float | None = None
alpha_pct: float | None = None
alpha_usd: float | None = None
+ close_reason: str | None = None
+ # Live trailing-stop level + how far price sits above it (% ), for open trades
+ # when the trailing exit policy is active.
+ trailing_stop: float | None = None
+ trailing_distance_pct: float | None = None
diff --git a/app/services/alert_service.py b/app/services/alert_service.py
index 0b406ed..cca3a34 100644
--- a/app/services/alert_service.py
+++ b/app/services/alert_service.py
@@ -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"💼 {len(open_trades)} open trade(s):")
+ 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 '🔴'} {symbol} {trade.direction.upper()} closed ({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:
diff --git a/app/services/paper_trade_service.py b/app/services/paper_trade_service.py
index 397251f..64485f3 100644
--- a/app/services/paper_trade_service.py
+++ b/app/services/paper_trade_service.py
@@ -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:
diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts
index 88f4b02..8af388e 100644
--- a/frontend/src/api/admin.ts
+++ b/frontend/src/api/admin.ts
@@ -134,6 +134,8 @@ export function updateAlertSettings(payload: {
sr_proximity_enabled?: boolean;
score_drop_enabled?: boolean;
digest_enabled?: boolean;
+ regime_quadrant_enabled?: boolean;
+ trade_closed_enabled?: boolean;
}) {
return apiClient
.put('admin/settings/alerts', payload)
diff --git a/frontend/src/api/paperTrades.ts b/frontend/src/api/paperTrades.ts
index f9235f6..e4b17a9 100644
--- a/frontend/src/api/paperTrades.ts
+++ b/frontend/src/api/paperTrades.ts
@@ -1,5 +1,5 @@
import apiClient from './client';
-import type { PaperTrade } from '../lib/types';
+import type { ExitPolicy, PaperTrade } from '../lib/types';
export function listPaperTrades(status?: 'open' | 'closed') {
return apiClient
@@ -7,6 +7,14 @@ export function listPaperTrades(status?: 'open' | 'closed') {
.then((r) => r.data);
}
+export function getExitPolicy() {
+ return apiClient.get('paper-trades/exit-policy').then((r) => r.data);
+}
+
+export function updateExitPolicy(payload: Partial) {
+ return apiClient.put('paper-trades/exit-policy', payload).then((r) => r.data);
+}
+
export interface CreatePaperTradeBody {
symbol: string;
direction: 'long' | 'short';
diff --git a/frontend/src/components/admin/AlertSettings.tsx b/frontend/src/components/admin/AlertSettings.tsx
index 038672e..9dad744 100644
--- a/frontend/src/components/admin/AlertSettings.tsx
+++ b/frontend/src/components/admin/AlertSettings.tsx
@@ -13,14 +13,16 @@ type TriggerKey =
| 'sr_proximity_enabled'
| 'score_drop_enabled'
| 'digest_enabled'
- | 'regime_quadrant_enabled';
+ | 'regime_quadrant_enabled'
+ | 'trade_closed_enabled';
const TRIGGERS: { key: TriggerKey; label: string; hint: string }[] = [
{ key: 'qualified_enabled', label: 'Qualified setups', hint: 'a setup newly clears the activation gate' },
{ key: 'sr_proximity_enabled', label: 'Watchlist S/R proximity', hint: 'a watched ticker nears a strong support/resistance' },
{ key: 'score_drop_enabled', label: 'Score deterioration', hint: 'a watched ticker’s composite drops sharply' },
- { key: 'digest_enabled', label: 'Daily digest', hint: 'one end-of-day summary of qualified setups' },
+ { key: 'digest_enabled', label: 'Daily digest', hint: 'end-of-day summary incl. open trades + trailing stops' },
{ key: 'regime_quadrant_enabled', label: 'Regime quadrant change', hint: 'the regime monitor shifts quadrant (hysteresis + cooldown)' },
+ { key: 'trade_closed_enabled', label: 'Trade closed', hint: 'a paper trade auto-closes (trailing/target/stop) — incl. losses' },
];
function Toggle({ checked, onChange, label, hint }: {
@@ -59,6 +61,7 @@ export function AlertSettings() {
score_drop_enabled: true,
digest_enabled: true,
regime_quadrant_enabled: true,
+ trade_closed_enabled: true,
});
useEffect(() => {
@@ -71,6 +74,7 @@ export function AlertSettings() {
score_drop_enabled: data.score_drop_enabled,
digest_enabled: data.digest_enabled,
regime_quadrant_enabled: data.regime_quadrant_enabled,
+ trade_closed_enabled: data.trade_closed_enabled,
});
}
}, [data]);
diff --git a/frontend/src/components/admin/ExitPolicySettings.tsx b/frontend/src/components/admin/ExitPolicySettings.tsx
new file mode 100644
index 0000000..42e4d52
--- /dev/null
+++ b/frontend/src/components/admin/ExitPolicySettings.tsx
@@ -0,0 +1,68 @@
+import { useEffect, useState } from 'react';
+import type { ExitPolicy } from '../../lib/types';
+import { useExitPolicy, useUpdateExitPolicy } from '../../hooks/usePaperTrades';
+import { SkeletonCard } from '../ui/Skeleton';
+
+export function ExitPolicySettings() {
+ const { data, isLoading } = useExitPolicy();
+ const update = useUpdateExitPolicy();
+ const [mode, setMode] = useState('trailing');
+ const [pct, setPct] = useState(12);
+
+ useEffect(() => {
+ if (data) {
+ setMode(data.mode);
+ setPct(data.trailing_pct);
+ }
+ }, [data]);
+
+ if (isLoading) return ;
+
+ return (
+
+
+
Paper-Trade Exit
+
+ How open paper trades auto-close (in the nightly/intraday outcome job).{' '}
+ Trailing rides a trailing stop — the backtest's best exit,
+ it lets winners run; Target / stop closes at the setup's
+ target or stop. The setup's initial stop is always the floor.
+