feat: trailing-stop auto-exit for paper trades + close/digest alerts
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 54s
Deploy / deploy (push) Successful in 33s

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:
2026-06-30 18:48:05 +02:00
parent ab9ce18809
commit 1566b84379
17 changed files with 558 additions and 25 deletions
@@ -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")
+2
View File
@@ -34,3 +34,5 @@ class PaperTrade(Base):
) )
close_price: Mapped[float | None] = mapped_column(Float, nullable=True) close_price: Mapped[float | None] = mapped_column(Float, nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), 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)
+27 -2
View File
@@ -3,10 +3,15 @@
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession 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.models.user import User
from app.schemas.common import APIEnvelope 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 from app.services import paper_trade_service
router = APIRouter(tags=["paper-trades"]) router = APIRouter(tags=["paper-trades"])
@@ -40,6 +45,26 @@ async def list_paper_trades(
return APIEnvelope(status="success", data=data) 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) @router.post("/paper-trades", response_model=APIEnvelope, status_code=201)
async def create_paper_trade( async def create_paper_trade(
body: PaperTradeCreate, body: PaperTradeCreate,
+1
View File
@@ -100,3 +100,4 @@ class AlertConfigUpdate(BaseModel):
score_drop_enabled: bool | None = None score_drop_enabled: bool | None = None
digest_enabled: bool | None = None digest_enabled: bool | None = None
regime_quadrant_enabled: bool | None = None regime_quadrant_enabled: bool | None = None
trade_closed_enabled: bool | None = None
+11
View File
@@ -20,6 +20,12 @@ class PaperTradeClose(BaseModel):
close_price: float | None = Field(default=None, gt=0) 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): class PaperTradeResponse(BaseModel):
id: int id: int
symbol: str symbol: str
@@ -38,3 +44,8 @@ class PaperTradeResponse(BaseModel):
benchmark_return_pct: float | None = None benchmark_return_pct: float | None = None
alpha_pct: float | None = None alpha_pct: float | None = None
alpha_usd: 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
+91 -1
View File
@@ -26,6 +26,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.models.alert import AlertLog from app.models.alert import AlertLog
from app.models.ohlcv import OHLCVRecord from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
from app.models.score import CompositeScore from app.models.score import CompositeScore
from app.models.sr_level import SRLevel from app.models.sr_level import SRLevel
from app.models.ticker import Ticker from app.models.ticker import Ticker
@@ -47,6 +48,7 @@ KEY_SR = "alerts_sr_proximity_enabled"
KEY_SCORE_DROP = "alerts_score_drop_enabled" KEY_SCORE_DROP = "alerts_score_drop_enabled"
KEY_DIGEST = "alerts_digest_enabled" KEY_DIGEST = "alerts_digest_enabled"
KEY_REGIME_QUADRANT = "alerts_regime_quadrant_enabled" KEY_REGIME_QUADRANT = "alerts_regime_quadrant_enabled"
KEY_TRADE_CLOSED = "alerts_trade_closed_enabled"
_BOOL_DEFAULTS = { _BOOL_DEFAULTS = {
KEY_ENABLED: False, KEY_ENABLED: False,
@@ -55,8 +57,15 @@ _BOOL_DEFAULTS = {
KEY_SCORE_DROP: True, KEY_SCORE_DROP: True,
KEY_DIGEST: True, KEY_DIGEST: True,
KEY_REGIME_QUADRANT: 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) # Tunables (kept as constants for now; promote to settings if needed)
SR_PROXIMITY_PCT = 2.0 # within this % of a strong zone → alert SR_PROXIMITY_PCT = 2.0 # within this % of a strong zone → alert
SR_MIN_STRENGTH = 60 # only strong zones are alert-worthy 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: 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) stored = await settings_store.get_map(db, keys)
db_token = (stored.get(KEY_TOKEN) or "").strip() 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]), "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]), "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]), "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"], "score_drop_enabled": r["score_drop"],
"digest_enabled": r["digest"], "digest_enabled": r["digest"],
"regime_quadrant_enabled": r["regime_quadrant"], "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, score_drop_enabled: bool | None = None,
digest_enabled: bool | None = None, digest_enabled: bool | None = None,
regime_quadrant_enabled: bool | None = None, regime_quadrant_enabled: bool | None = None,
trade_closed_enabled: bool | None = None,
) -> dict: ) -> dict:
"""Persist config. An empty/omitted bot_token leaves the stored token intact.""" """Persist config. An empty/omitted bot_token leaves the stored token intact."""
bool_updates = { bool_updates = {
@@ -152,6 +164,7 @@ async def update_alert_config(
KEY_SCORE_DROP: score_drop_enabled, KEY_SCORE_DROP: score_drop_enabled,
KEY_DIGEST: digest_enabled, KEY_DIGEST: digest_enabled,
KEY_REGIME_QUADRANT: regime_quadrant_enabled, KEY_REGIME_QUADRANT: regime_quadrant_enabled,
KEY_TRADE_CLOSED: trade_closed_enabled,
} }
for key, val in bool_updates.items(): for key, val in bool_updates.items():
if val is not None: if val is not None:
@@ -376,9 +389,81 @@ async def _collect_digest(db: AsyncSession) -> tuple[str, str] | None:
) )
else: else:
lines.append("No qualified setups today.") 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) 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) # 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): for key, text in await _collect_regime_quadrant(db):
outgoing.append((QUAD_TYPE, key, text)) 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 sent = 0
if outgoing: if outgoing:
async with httpx.AsyncClient(timeout=15) as client: async with httpx.AsyncClient(timeout=15) as client:
+124 -9
View File
@@ -11,7 +11,7 @@ from app.exceptions import NotFoundError, ValidationError
from app.models.ohlcv import OHLCVRecord from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade from app.models.paper_trade import PaperTrade
from app.models.ticker import Ticker 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 ( from app.services.outcome_service import (
OUTCOME_AMBIGUOUS, OUTCOME_AMBIGUOUS,
OUTCOME_STOP_HIT, OUTCOME_STOP_HIT,
@@ -20,6 +20,45 @@ from app.services.outcome_service import (
evaluate_setup_against_bars, 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: async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
normalised = symbol.strip().upper() 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()} 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( async def create_trade(
db: AsyncSession, db: AsyncSession,
user_id: int, user_id: int,
@@ -91,6 +165,7 @@ def _to_dict(
symbol: str, symbol: str,
current_price: float | None, current_price: float | None,
benchmark_closes: dict[date, float] | None = None, benchmark_closes: dict[date, float] | None = None,
trailing: tuple[float, float | None] | None = None,
) -> dict: ) -> dict:
# For open trades, mark to market; for closed, the realized exit price. # For open trades, mark to market; for closed, the realized exit price.
ref = current_price if trade.status == "open" else trade.close_price ref = current_price if trade.status == "open" else trade.close_price
@@ -130,19 +205,23 @@ def _to_dict(
"benchmark_return_pct": benchmark_return, "benchmark_return_pct": benchmark_return,
"alpha_pct": alpha_pct, "alpha_pct": alpha_pct,
"alpha_usd": alpha_usd, "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( async def list_trades(
db: AsyncSession, db: AsyncSession,
user_id: int, user_id: int | None = None,
status: str | None = None, status: str | None = None,
) -> list[dict]: ) -> list[dict]:
stmt = ( stmt = (
select(PaperTrade, Ticker.symbol) select(PaperTrade, Ticker.symbol)
.join(Ticker, PaperTrade.ticker_id == Ticker.id) .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: if status is not None:
stmt = stmt.where(PaperTrade.status == status) stmt = stmt.where(PaperTrade.status == status)
stmt = stmt.order_by(PaperTrade.opened_at.desc()) stmt = stmt.order_by(PaperTrade.opened_at.desc())
@@ -156,7 +235,32 @@ async def list_trades(
# makes a provider call). # makes a provider call).
benchmark_closes = await benchmark_service.load_benchmark_closes(db) 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( async def close_trade(
@@ -185,6 +289,7 @@ async def close_trade(
trade.status = "closed" trade.status = "closed"
trade.close_price = float(close_price) trade.close_price = float(close_price)
trade.close_reason = "manual"
trade.closed_at = datetime.now(timezone.utc) trade.closed_at = datetime.now(timezone.utc)
await db.commit() await db.commit()
await db.refresh(trade) await db.refresh(trade)
@@ -204,6 +309,10 @@ async def resolve_open_trades(db: AsyncSession) -> int:
if not open_trades: if not open_trades:
return 0 return 0
policy = await get_exit_policy(db)
mode = policy["mode"]
trail_frac = policy["trailing_pct"] / 100.0
closed = 0 closed = 0
for trade in open_trades: for trade in open_trades:
bars_result = await db.execute( bars_result = await db.execute(
@@ -218,21 +327,27 @@ async def resolve_open_trades(db: AsyncSession) -> int:
if not bars: if not bars:
continue continue
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:
# max_bars beyond the data so a still-open trade returns undecided (not "expired"). # max_bars beyond the data so a still-open trade returns undecided (not "expired").
outcome, outcome_date = evaluate_setup_against_bars( outcome, outcome_date = evaluate_setup_against_bars(
trade.direction, trade.stop_loss, trade.target, bars, max_bars=len(bars) + 1 trade.direction, trade.stop_loss, trade.target, bars, max_bars=len(bars) + 1
) )
if outcome == OUTCOME_TARGET_HIT: if outcome == OUTCOME_TARGET_HIT:
trade.close_price = trade.target close_price, close_date, reason = trade.target, outcome_date, "target"
elif outcome in (OUTCOME_STOP_HIT, OUTCOME_AMBIGUOUS): elif outcome in (OUTCOME_STOP_HIT, OUTCOME_AMBIGUOUS):
trade.close_price = trade.stop_loss close_price, close_date, reason = trade.stop_loss, outcome_date, "stop"
else: else:
continue continue
trade.status = "closed" trade.status = "closed"
trade.closed_at = datetime.combine( trade.close_price = float(close_price)
outcome_date, datetime.min.time(), tzinfo=timezone.utc trade.close_reason = reason
) trade.closed_at = datetime.combine(close_date, datetime.min.time(), tzinfo=timezone.utc)
closed += 1 closed += 1
if closed: if closed:
+2
View File
@@ -134,6 +134,8 @@ export function updateAlertSettings(payload: {
sr_proximity_enabled?: boolean; sr_proximity_enabled?: boolean;
score_drop_enabled?: boolean; score_drop_enabled?: boolean;
digest_enabled?: boolean; digest_enabled?: boolean;
regime_quadrant_enabled?: boolean;
trade_closed_enabled?: boolean;
}) { }) {
return apiClient return apiClient
.put<AlertConfig>('admin/settings/alerts', payload) .put<AlertConfig>('admin/settings/alerts', payload)
+9 -1
View File
@@ -1,5 +1,5 @@
import apiClient from './client'; import apiClient from './client';
import type { PaperTrade } from '../lib/types'; import type { ExitPolicy, PaperTrade } from '../lib/types';
export function listPaperTrades(status?: 'open' | 'closed') { export function listPaperTrades(status?: 'open' | 'closed') {
return apiClient return apiClient
@@ -7,6 +7,14 @@ export function listPaperTrades(status?: 'open' | 'closed') {
.then((r) => r.data); .then((r) => r.data);
} }
export function getExitPolicy() {
return apiClient.get<ExitPolicy>('paper-trades/exit-policy').then((r) => r.data);
}
export function updateExitPolicy(payload: Partial<ExitPolicy>) {
return apiClient.put<ExitPolicy>('paper-trades/exit-policy', payload).then((r) => r.data);
}
export interface CreatePaperTradeBody { export interface CreatePaperTradeBody {
symbol: string; symbol: string;
direction: 'long' | 'short'; direction: 'long' | 'short';
@@ -13,14 +13,16 @@ type TriggerKey =
| 'sr_proximity_enabled' | 'sr_proximity_enabled'
| 'score_drop_enabled' | 'score_drop_enabled'
| 'digest_enabled' | 'digest_enabled'
| 'regime_quadrant_enabled'; | 'regime_quadrant_enabled'
| 'trade_closed_enabled';
const TRIGGERS: { key: TriggerKey; label: string; hint: string }[] = [ const TRIGGERS: { key: TriggerKey; label: string; hint: string }[] = [
{ key: 'qualified_enabled', label: 'Qualified setups', hint: 'a setup newly clears the activation gate' }, { 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: '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 tickers composite drops sharply' }, { key: 'score_drop_enabled', label: 'Score deterioration', hint: 'a watched tickers 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: '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 }: { function Toggle({ checked, onChange, label, hint }: {
@@ -59,6 +61,7 @@ export function AlertSettings() {
score_drop_enabled: true, score_drop_enabled: true,
digest_enabled: true, digest_enabled: true,
regime_quadrant_enabled: true, regime_quadrant_enabled: true,
trade_closed_enabled: true,
}); });
useEffect(() => { useEffect(() => {
@@ -71,6 +74,7 @@ export function AlertSettings() {
score_drop_enabled: data.score_drop_enabled, score_drop_enabled: data.score_drop_enabled,
digest_enabled: data.digest_enabled, digest_enabled: data.digest_enabled,
regime_quadrant_enabled: data.regime_quadrant_enabled, regime_quadrant_enabled: data.regime_quadrant_enabled,
trade_closed_enabled: data.trade_closed_enabled,
}); });
} }
}, [data]); }, [data]);
@@ -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<ExitPolicy['mode']>('trailing');
const [pct, setPct] = useState(12);
useEffect(() => {
if (data) {
setMode(data.mode);
setPct(data.trailing_pct);
}
}, [data]);
if (isLoading) return <SkeletonCard />;
return (
<div className="glass p-5 space-y-4">
<div>
<h3 className="text-sm font-semibold text-gray-200">Paper-Trade Exit</h3>
<p className="mt-1 text-xs text-gray-500">
How open paper trades auto-close (in the nightly/intraday outcome job).{' '}
<span className="text-gray-300">Trailing</span> rides a trailing stop the backtest's best exit,
it lets winners run; <span className="text-gray-300">Target / stop</span> closes at the setup's
target or stop. The setup's initial stop is always the floor.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-gray-400">Exit mode</span>
<select
value={mode}
onChange={(e) => setMode(e.target.value as ExitPolicy['mode'])}
className="w-full input-glass px-3 py-2 text-sm"
>
<option value="trailing">Trailing stop</option>
<option value="target">Target / stop</option>
</select>
</label>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Trailing width (%)</span>
<input
type="number"
min={0.5}
max={90}
step={0.5}
value={pct}
onChange={(e) => setPct(Number(e.target.value))}
disabled={mode !== 'trailing'}
className="w-full input-glass px-3 py-2 text-sm disabled:opacity-50"
/>
<span className="text-[11px] text-gray-600">Give-back from the peak. Backtest sweet spot ~1215%.</span>
</label>
</div>
<button
className="btn-primary px-4 py-2 text-sm disabled:opacity-50"
disabled={update.isPending}
onClick={() => update.mutate({ mode, trailing_pct: pct })}
>
{update.isPending ? 'Saving' : 'Save Exit Policy'}
</button>
</div>
);
}
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { usePaperTrades, useClosePaperTrade } from '../../hooks/usePaperTrades'; import { usePaperTrades, useClosePaperTrade, useExitPolicy } from '../../hooks/usePaperTrades';
import { tradePnl } from '../../lib/paperTrade'; import { tradePnl } from '../../lib/paperTrade';
import { formatPrice } from '../../lib/format'; import { formatPrice } from '../../lib/format';
import { Section } from '../ui/Section'; import { Section } from '../ui/Section';
@@ -18,8 +18,15 @@ function pnlColor(v: number): string {
export function OpenTradesPanel() { export function OpenTradesPanel() {
const { data: trades, isLoading } = usePaperTrades('open'); const { data: trades, isLoading } = usePaperTrades('open');
const { data: policy } = useExitPolicy();
const close = useClosePaperTrade(); const close = useClosePaperTrade();
const exitLabel = policy
? policy.mode === 'trailing'
? `auto-exit: trailing ${Math.round(policy.trailing_pct)}%`
: 'auto-exit: target/stop'
: null;
const totals = useMemo(() => { const totals = useMemo(() => {
let pnl = 0, winners = 0, losers = 0, priced = 0, alphaUsd = 0, alphaPriced = 0; let pnl = 0, winners = 0, losers = 0, priced = 0, alphaUsd = 0, alphaPriced = 0;
for (const t of trades ?? []) { for (const t of trades ?? []) {
@@ -44,7 +51,7 @@ export function OpenTradesPanel() {
return ( return (
<Section <Section
title="Open Trades" title="Open Trades"
hint={rows.length > 0 ? `${rows.length} open · ${totals.winners}${totals.losers}` : 'paper trading'} hint={rows.length > 0 ? `${rows.length} open · ${totals.winners}${totals.losers}${exitLabel ? ` · ${exitLabel}` : ''}` : 'paper trading'}
> >
{rows.length === 0 ? ( {rows.length === 0 ? (
<Callout variant="empty"> <Callout variant="empty">
@@ -64,6 +71,7 @@ export function OpenTradesPanel() {
<th className="px-4 py-3 text-right">%</th> <th className="px-4 py-3 text-right">%</th>
<th className="px-4 py-3 text-right">R</th> <th className="px-4 py-3 text-right">R</th>
<th className="px-4 py-3 text-right">Alpha</th> <th className="px-4 py-3 text-right">Alpha</th>
<th className="px-4 py-3 text-right">Trail Stop</th>
<th className="px-4 py-3"></th> <th className="px-4 py-3"></th>
</tr> </tr>
</thead> </thead>
@@ -99,6 +107,20 @@ export function OpenTradesPanel() {
<td className={`num px-4 py-3 text-right ${t.alpha_pct != null ? pnlColor(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period"> <td className={`num px-4 py-3 text-right ${t.alpha_pct != null ? pnlColor(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">
{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'} {t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}
</td> </td>
<td className="num px-4 py-3 text-right text-gray-300" title="Current trailing-stop level · how far below the price">
{t.trailing_stop != null ? (
<>
{formatPrice(t.trailing_stop)}
{t.trailing_distance_pct != null && (
<span className="ml-1 text-[10px] text-gray-500">
{Math.abs(t.trailing_distance_pct).toFixed(1)}%
</span>
)}
</>
) : (
<span className="text-gray-500"></span>
)}
</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<button <button
onClick={() => { onClick={() => {
@@ -128,7 +150,7 @@ export function OpenTradesPanel() {
<td className={`num px-4 py-2.5 text-right font-semibold ${totals.alphaPriced > 0 ? pnlColor(totals.alphaUsd) : 'text-gray-500'}`}> <td className={`num px-4 py-2.5 text-right font-semibold ${totals.alphaPriced > 0 ? pnlColor(totals.alphaUsd) : 'text-gray-500'}`}>
{totals.alphaPriced > 0 ? money(totals.alphaUsd) : '—'} {totals.alphaPriced > 0 ? money(totals.alphaUsd) : '—'}
</td> </td>
<td /> <td colSpan={2} />
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
+22
View File
@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as api from '../api/paperTrades'; import * as api from '../api/paperTrades';
import type { ExitPolicy } from '../lib/types';
import { useToast } from '../components/ui/Toast'; import { useToast } from '../components/ui/Toast';
export function usePaperTrades(status?: 'open' | 'closed') { export function usePaperTrades(status?: 'open' | 'closed') {
@@ -10,6 +11,27 @@ export function usePaperTrades(status?: 'open' | 'closed') {
}); });
} }
export function useExitPolicy() {
return useQuery({
queryKey: ['paper-trades', 'exit-policy'],
queryFn: () => api.getExitPolicy(),
staleTime: 5 * 60 * 1000,
});
}
export function useUpdateExitPolicy() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (body: Partial<ExitPolicy>) => api.updateExitPolicy(body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['paper-trades'] });
addToast('success', 'Exit policy updated.');
},
onError: (e: Error) => addToast('error', e.message || 'Failed to update exit policy'),
});
}
export function useCreatePaperTrade() { export function useCreatePaperTrade() {
const qc = useQueryClient(); const qc = useQueryClient();
const { addToast } = useToast(); const { addToast } = useToast();
+9
View File
@@ -207,6 +207,14 @@ export interface PaperTrade {
benchmark_return_pct: number | null; benchmark_return_pct: number | null;
alpha_pct: number | null; alpha_pct: number | null;
alpha_usd: number | null; alpha_usd: number | null;
close_reason: 'trailing' | 'stop' | 'target' | 'manual' | null;
trailing_stop: number | null;
trailing_distance_pct: number | null;
}
export interface ExitPolicy {
mode: 'trailing' | 'target';
trailing_pct: number;
} }
export interface BacktestBucket { export interface BacktestBucket {
@@ -407,6 +415,7 @@ export interface AlertConfig {
score_drop_enabled: boolean; score_drop_enabled: boolean;
digest_enabled: boolean; digest_enabled: boolean;
regime_quadrant_enabled: boolean; regime_quadrant_enabled: boolean;
trade_closed_enabled: boolean;
} }
export interface AlertTestResult { export interface AlertTestResult {
+2
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { ActivationSettings } from '../components/admin/ActivationSettings'; import { ActivationSettings } from '../components/admin/ActivationSettings';
import { ExitPolicySettings } from '../components/admin/ExitPolicySettings';
import { AlertSettings } from '../components/admin/AlertSettings'; import { AlertSettings } from '../components/admin/AlertSettings';
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings'; import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
import { DataCleanup } from '../components/admin/DataCleanup'; import { DataCleanup } from '../components/admin/DataCleanup';
@@ -33,6 +34,7 @@ export default function AdminPage() {
{activeTab === 'Settings' && ( {activeTab === 'Settings' && (
<div className="space-y-4"> <div className="space-y-4">
<ActivationSettings /> <ActivationSettings />
<ExitPolicySettings />
<AlertSettings /> <AlertSettings />
<SentimentProviderSettings /> <SentimentProviderSettings />
<TickerUniverseBootstrap /> <TickerUniverseBootstrap />
+52
View File
@@ -3,11 +3,13 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from types import SimpleNamespace
import pytest import pytest
from app.models.alert import AlertLog from app.models.alert import AlertLog
from app.models.ohlcv import OHLCVRecord from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
from app.models.score import CompositeScore from app.models.score import CompositeScore
from app.models.sr_level import SRLevel from app.models.sr_level import SRLevel
from app.models.ticker import Ticker from app.models.ticker import Ticker
@@ -168,3 +170,53 @@ async def test_dispatch_no_credentials(session):
await svc.update_alert_config(session, enabled=True) # enabled but no token/chat await svc.update_alert_config(session, enabled=True) # enabled but no token/chat
res = await svc.dispatch_alerts(session) res = await svc.dispatch_alerts(session)
assert res["status"] == "no_credentials" assert res["status"] == "no_credentials"
async def _add_closed_trade(session, symbol: str, reason: str, *,
close: float = 110.0, closed_hours_ago: float = 1.0) -> None:
if await session.get(User, 1) is None:
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
await session.flush()
t = Ticker(symbol=symbol)
session.add(t)
await session.flush()
now = datetime.now(timezone.utc)
session.add(PaperTrade(
user_id=1, ticker_id=t.id, direction="long",
entry_price=100.0, shares=10.0, stop_loss=95.0, target=120.0,
status="closed", opened_at=now - timedelta(days=5),
close_price=close, closed_at=now - timedelta(hours=closed_hours_ago),
close_reason=reason,
))
await session.commit()
async def test_config_includes_trade_closed_toggle(session):
assert (await svc.get_alert_config(session))["trade_closed_enabled"] is True
cfg = await svc.update_alert_config(session, trade_closed_enabled=False)
assert cfg["trade_closed_enabled"] is False
async def test_collect_closed_trades_filters_manual_and_old(session):
await _add_closed_trade(session, "WIN", "trailing", close=110.0, closed_hours_ago=1)
await _add_closed_trade(session, "MAN", "manual", close=110.0, closed_hours_ago=1) # manual → skip
await _add_closed_trade(session, "OLD", "stop", close=95.0, closed_hours_ago=100) # too old → skip
out = await svc._collect_closed_trades(session)
assert len(out) == 1
_, text = out[0]
assert "WIN" in text and "trailing stop" in text
def test_format_closed_trade_win():
now = datetime.now(timezone.utc)
trade = SimpleNamespace(
direction="long", entry_price=100.0, close_price=110.0, shares=10.0,
stop_loss=95.0, opened_at=now - timedelta(days=12), closed_at=now,
close_reason="trailing",
)
txt = svc._format_closed_trade(trade, "AAA")
assert "" in txt # win
assert "+10.0%" in txt
assert "+2.00R" in txt # +10% over a 5% stop
assert "held 12d" in txt
+71
View File
@@ -94,6 +94,7 @@ async def _add_bars(session, ticker_id: int, highs_lows: list[tuple[float, float
async def test_resolve_closes_on_target(session): async def test_resolve_closes_on_target(session):
await svc.set_exit_policy(session, mode="target")
tid = await _seed(session, "AAA", close=100.0) tid = await _seed(session, "AAA", close=100.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long", trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=10, stop_loss=95.0, target=110.0) entry_price=100.0, shares=10, stop_loss=95.0, target=110.0)
@@ -107,6 +108,7 @@ async def test_resolve_closes_on_target(session):
async def test_resolve_closes_on_stop(session): async def test_resolve_closes_on_stop(session):
await svc.set_exit_policy(session, mode="target")
tid = await _seed(session, "AAA", close=100.0) tid = await _seed(session, "AAA", close=100.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long", trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=10, stop_loss=95.0, target=110.0) entry_price=100.0, shares=10, stop_loss=95.0, target=110.0)
@@ -118,6 +120,7 @@ async def test_resolve_closes_on_stop(session):
async def test_resolve_leaves_open_when_neither_hit(session): async def test_resolve_leaves_open_when_neither_hit(session):
await svc.set_exit_policy(session, mode="target")
tid = await _seed(session, "AAA", close=100.0) tid = await _seed(session, "AAA", close=100.0)
await svc.create_trade(session, 1, symbol="AAA", direction="long", await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=10, stop_loss=95.0, target=110.0) entry_price=100.0, shares=10, stop_loss=95.0, target=110.0)
@@ -171,3 +174,71 @@ async def test_alpha_short_and_missing_benchmark(session):
row = (await svc.list_trades(session, 1, status="open"))[0] row = (await svc.list_trades(session, 1, status="open"))[0]
assert row["benchmark_return_pct"] == pytest.approx(0.0) assert row["benchmark_return_pct"] == pytest.approx(0.0)
assert row["alpha_pct"] == pytest.approx(10.0) assert row["alpha_pct"] == pytest.approx(10.0)
def _b(d: date, hi: float, lo: float):
return svc.Bar(date=d, high=hi, low=lo)
class TestTrailingClose:
def test_long_locks_gain(self):
# Runs to 120; the 12%-from-peak stop (120 → 105.6) is pierced on the drop.
bars = [_b(date(2026, 1, 2), 120, 110), _b(date(2026, 1, 3), 130, 100)]
hit = svc._trailing_close("long", 100.0, 95.0, 0.12, bars)
assert hit is not None
price, when, reason = hit
assert price == pytest.approx(105.6)
assert reason == "trailing"
assert when == date(2026, 1, 3)
def test_initial_stop_caps_loss(self):
bars = [_b(date(2026, 1, 2), 101, 94)] # through the initial stop before running
hit = svc._trailing_close("long", 100.0, 95.0, 0.12, bars)
assert hit is not None
price, _, reason = hit
assert price == pytest.approx(95.0)
assert reason == "stop"
def test_none_when_neither_hit(self):
bars = [_b(date(2026, 1, 2), 105, 99), _b(date(2026, 1, 3), 106, 100)]
assert svc._trailing_close("long", 100.0, 95.0, 0.12, bars) is None
async def test_exit_policy_defaults_and_round_trip(session):
assert await svc.get_exit_policy(session) == {"mode": "trailing", "trailing_pct": 12.0}
updated = await svc.set_exit_policy(session, mode="target", trailing_pct=15.0)
assert updated == {"mode": "target", "trailing_pct": 15.0}
assert (await svc.get_exit_policy(session))["mode"] == "target"
async def test_exit_policy_rejects_bad_input(session):
with pytest.raises(ValidationError):
await svc.set_exit_policy(session, mode="bogus")
with pytest.raises(ValidationError):
await svc.set_exit_policy(session, trailing_pct=200.0)
async def test_resolve_trailing_closes_with_reason(session):
tid = await _seed(session, "AAA", close=100.0) # default policy: trailing 12%
await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10)
await _add_bars(session, tid, [(120, 110), (130, 100)], start=date.today()) # run up, pull back
assert await svc.resolve_open_trades(session) == 1
closed = await svc.list_trades(session, 1, status="closed")
assert closed[0]["close_reason"] == "trailing"
async def test_manual_close_sets_reason(session):
await _seed(session, "AAA", close=112.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=5, stop_loss=95.0, target=120.0)
await svc.close_trade(session, 1, trade.id)
assert (await svc.list_trades(session, 1, status="closed"))[0]["close_reason"] == "manual"
async def test_list_open_exposes_trailing_stop(session):
tid = await _seed(session, "AAA", close=120.0)
await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10)
await _add_bars(session, tid, [(125, 118)], start=date.today()) # peak 125
row = (await svc.list_trades(session, 1, status="open"))[0]
assert row["trailing_stop"] == pytest.approx(110.0) # 125 * (1 - 0.12)
assert row["trailing_distance_pct"] is not None