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:
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<AlertConfig>('admin/settings/alerts', payload)
|
||||
|
||||
@@ -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<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 {
|
||||
symbol: string;
|
||||
direction: 'long' | 'short';
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 ~12–15%.</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 { Link } from 'react-router-dom';
|
||||
import { usePaperTrades, useClosePaperTrade } from '../../hooks/usePaperTrades';
|
||||
import { usePaperTrades, useClosePaperTrade, useExitPolicy } from '../../hooks/usePaperTrades';
|
||||
import { tradePnl } from '../../lib/paperTrade';
|
||||
import { formatPrice } from '../../lib/format';
|
||||
import { Section } from '../ui/Section';
|
||||
@@ -18,8 +18,15 @@ function pnlColor(v: number): string {
|
||||
|
||||
export function OpenTradesPanel() {
|
||||
const { data: trades, isLoading } = usePaperTrades('open');
|
||||
const { data: policy } = useExitPolicy();
|
||||
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(() => {
|
||||
let pnl = 0, winners = 0, losers = 0, priced = 0, alphaUsd = 0, alphaPriced = 0;
|
||||
for (const t of trades ?? []) {
|
||||
@@ -44,7 +51,7 @@ export function OpenTradesPanel() {
|
||||
return (
|
||||
<Section
|
||||
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 ? (
|
||||
<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">R</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>
|
||||
</tr>
|
||||
</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">
|
||||
{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}
|
||||
</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">
|
||||
<button
|
||||
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'}`}>
|
||||
{totals.alphaPriced > 0 ? money(totals.alphaUsd) : '—'}
|
||||
</td>
|
||||
<td />
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import * as api from '../api/paperTrades';
|
||||
import type { ExitPolicy } from '../lib/types';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
|
||||
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() {
|
||||
const qc = useQueryClient();
|
||||
const { addToast } = useToast();
|
||||
|
||||
@@ -207,6 +207,14 @@ export interface PaperTrade {
|
||||
benchmark_return_pct: number | null;
|
||||
alpha_pct: 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 {
|
||||
@@ -407,6 +415,7 @@ export interface AlertConfig {
|
||||
score_drop_enabled: boolean;
|
||||
digest_enabled: boolean;
|
||||
regime_quadrant_enabled: boolean;
|
||||
trade_closed_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AlertTestResult {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { ActivationSettings } from '../components/admin/ActivationSettings';
|
||||
import { ExitPolicySettings } from '../components/admin/ExitPolicySettings';
|
||||
import { AlertSettings } from '../components/admin/AlertSettings';
|
||||
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
|
||||
import { DataCleanup } from '../components/admin/DataCleanup';
|
||||
@@ -33,6 +34,7 @@ export default function AdminPage() {
|
||||
{activeTab === 'Settings' && (
|
||||
<div className="space-y-4">
|
||||
<ActivationSettings />
|
||||
<ExitPolicySettings />
|
||||
<AlertSettings />
|
||||
<SentimentProviderSettings />
|
||||
<TickerUniverseBootstrap />
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
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
|
||||
@@ -168,3 +170,53 @@ async def test_dispatch_no_credentials(session):
|
||||
await svc.update_alert_config(session, enabled=True) # enabled but no token/chat
|
||||
res = await svc.dispatch_alerts(session)
|
||||
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
|
||||
|
||||
@@ -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):
|
||||
await svc.set_exit_policy(session, mode="target")
|
||||
tid = await _seed(session, "AAA", close=100.0)
|
||||
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
|
||||
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):
|
||||
await svc.set_exit_policy(session, mode="target")
|
||||
tid = await _seed(session, "AAA", close=100.0)
|
||||
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
|
||||
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):
|
||||
await svc.set_exit_policy(session, mode="target")
|
||||
tid = await _seed(session, "AAA", close=100.0)
|
||||
await svc.create_trade(session, 1, symbol="AAA", direction="long",
|
||||
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]
|
||||
assert row["benchmark_return_pct"] == pytest.approx(0.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
|
||||
|
||||
Reference in New Issue
Block a user