feat: adopt Phase 3 gate and paper-trade exit policy
Production strategy change based on the July 2026 backtest: paper trades now default to a 30-trading-day hold with the initial stop (classic momentum hold-and-rerank), while target and trailing exits remain available in Admin. The exit policy API/UI now carries hold_days and close_reason can be 'time'. The activation confidence floor default is now 0/off because the gate ablation showed it added no per-trade edge while filtering out usable setups. Migration 015 clears stored activation_min_confidence and paper_exit_mode so the new defaults take effect; this intentionally resets Track Record comparability from this deploy. Verification: 451 backend tests pass, ruff check app/ clean, frontend npm run build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@ Once a day (default 07:00). Steps run **in dependency order**, each consuming th
|
||||
1. **OHLCV** — fetch the latest daily bars for every tracked ticker (Alpaca); new tickers backfill ~5 years.
|
||||
2. **Sentiment** — fetch sentiment for the names that matter and are stale (> 5 days): top-pick feeders (momentum leaders with a tradeable long setup), the watchlist, and open paper trades, plus a top-N-by-composite discovery net. Runs *before* the scan so the scan sees fresh sentiment.
|
||||
3. **R:R Scan** — recompute S/R zones, the 5-dimension scores and long/short setups (ATR stops, S/R targets) for every ticker, and attach each ticker's 12‑1 momentum percentile.
|
||||
4. **Outcome Eval** — resolve setups that hit target/stop or expired (default 30 trading days) and close paper trades that hit a level.
|
||||
4. **Outcome Eval** — resolve setups that hit target/stop or expired (default 30 trading days) and auto-close paper trades per the exit policy (default: hold 30 trading days with the initial stop — the backtest-validated exit).
|
||||
5. **Market Regime** — recompute the regime index (breadth/trend).
|
||||
6. **Regime Monitor** — observational early-warning snapshot (VIX, credit spreads via FRED); feeds nothing else.
|
||||
|
||||
@@ -33,7 +33,7 @@ Fundamentals (weekly, early Monday) · Alerts (hourly, Telegram) · Backtest (we
|
||||
|
||||
1. **Composite score** — technical, S/R-quality, sentiment, fundamental and momentum sub-scores (0–100) combine into a weighted composite (weights configurable; missing dimensions re-normalize).
|
||||
2. **Setups** — the scanner builds long/short setups with ATR stops and S/R targets, then adds a confidence score, conflict flags and a target reach-probability.
|
||||
3. **Activation gate** — a setup *qualifies* only if it clears the R:R and confidence floors **and** ranks in the top momentum percentile of the universe (the validated edge is long-only momentum).
|
||||
3. **Activation gate** — a setup *qualifies* only if it clears the R:R floor **and** ranks in the top momentum percentile of the universe (the validated edge is long-only momentum; the confidence floor was ablated to zero effect and defaults off).
|
||||
4. **Top pick** — the highest-momentum qualified setup; highlighted on the Dashboard and labelled on the ticker page.
|
||||
|
||||
## Strategy Status — What's Validated and What Isn't
|
||||
@@ -102,9 +102,9 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
|
||||
- Fundamental data tracking (P/E, revenue growth, earnings surprise, market cap)
|
||||
- 5-dimension scoring engine (technical, S/R quality, sentiment, fundamental, momentum) with configurable weights
|
||||
- Risk:Reward scanner — long and short setups, ATR-based stops, S/R-based targets, configurable R:R threshold (default 1.5:1)
|
||||
- Activation gate — qualifies setups on a momentum-percentile floor plus R:R/confidence (validated long-only edge); ranks the rest by expected value
|
||||
- Activation gate — qualifies setups on a momentum-percentile floor plus an R:R floor (validated long-only edge)
|
||||
- Recommendation layer — directional confidence, conflict detection, per-target reach-probability
|
||||
- Paper trading — take a setup, mark-to-market vs. latest close, auto-close on stop/target, realized track record + outcome evaluation
|
||||
- Paper trading — take a setup, mark-to-market vs. latest close, auto-close per the exit policy (default: hold 30 trading days with the initial stop; trailing / target-stop selectable), realized track record + outcome evaluation
|
||||
- Market-regime index + FRED early-warning monitor (VIX, credit spreads); weekly backtest + manual event study
|
||||
- Telegram alerts (e.g. regime-quadrant changes)
|
||||
- User-curated watchlist (cap: 20), enriched with composite score, R:R and S/R summary
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Phase 3 strategy adoption: time-based exit + confidence floor removed.
|
||||
|
||||
The July 2026 backtest (gate ablation graded under both exit models, plus the
|
||||
capital-constrained portfolio simulation) concluded:
|
||||
|
||||
- The best exit is hold-to-horizon: keep the initial ATR stop and exit at the
|
||||
30th trading day's close (+0.50R net/trade vs +0.13R for the S/R target
|
||||
exit; simulated book +31.9% vs +23.7% CAGR at the same drawdown). The paper
|
||||
trade exit-policy default is now ``time`` (30 trading days).
|
||||
- The confidence floor adds nothing (identical net/trade with it removed,
|
||||
under both exit models) while cutting ~25% of qualified trades. Its default
|
||||
is now 0 (off).
|
||||
|
||||
Stored rows for these two settings were written under the old semantics, so
|
||||
they are cleared here and the new code defaults take effect. Re-tune in
|
||||
Admin -> Activation / Paper-Trade Exit if desired. Note: this changes which
|
||||
setups qualify and how paper trades close, so Track Record comparability
|
||||
resets from this deploy.
|
||||
|
||||
Revision ID: 015
|
||||
Revises: 014
|
||||
Create Date: 2026-07-02 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "015"
|
||||
down_revision: Union[str, None] = "014"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"DELETE FROM system_settings "
|
||||
"WHERE key IN ('activation_min_confidence', 'paper_exit_mode')"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# One-way data reset: the old per-key values aren't recoverable. Code
|
||||
# defaults apply until re-tuned, so there is nothing to restore.
|
||||
pass
|
||||
@@ -61,7 +61,9 @@ async def write_exit_policy(
|
||||
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)
|
||||
data = await paper_trade_service.set_exit_policy(
|
||||
db, mode=body.mode, trailing_pct=body.trailing_pct, hold_days=body.hold_days
|
||||
)
|
||||
return APIEnvelope(status="success", data=data)
|
||||
|
||||
|
||||
|
||||
@@ -22,8 +22,9 @@ class PaperTradeClose(BaseModel):
|
||||
|
||||
class ExitPolicyUpdate(BaseModel):
|
||||
"""Auto-exit policy for open paper trades."""
|
||||
mode: str | None = Field(default=None, pattern=r"^(trailing|target)$")
|
||||
mode: str | None = Field(default=None, pattern=r"^(time|trailing|target)$")
|
||||
trailing_pct: float | None = Field(default=None, ge=0.5, le=90)
|
||||
hold_days: int | None = Field(default=None, ge=2, le=250)
|
||||
|
||||
|
||||
class PaperTradeResponse(BaseModel):
|
||||
|
||||
@@ -39,10 +39,9 @@ SUPPORTED_TICKER_UNIVERSES = {"sp500", "nasdaq100", "nasdaq_all"}
|
||||
# Track Record's qualified stats. The outcome evaluator deliberately ignores
|
||||
# these — every setup is evaluated so the gate itself can be validated.
|
||||
#
|
||||
# The core test is expected value (in R): probability-weighted asymmetry, so a
|
||||
# fat-but-improbable target and a likely-but-thin one are both rejected. R:R and
|
||||
# confidence are floors; high-conviction / clean-read / target-probability are
|
||||
# optional tighteners (off by default — turn on to be more selective).
|
||||
# The core selection is cross-sectional 12-1 momentum (top percentile of the
|
||||
# universe, long-only). R:R and confidence are floors; high-conviction /
|
||||
# clean-read are optional tighteners (off by default).
|
||||
_ACTIVATION_FLOAT_KEYS: dict[str, str] = {
|
||||
"min_momentum_percentile": "activation_min_momentum_percentile",
|
||||
"min_rr": "activation_min_rr",
|
||||
@@ -56,7 +55,10 @@ _ACTIVATION_BOOL_KEYS: dict[str, str] = {
|
||||
ACTIVATION_DEFAULTS: dict[str, float | bool] = {
|
||||
"min_momentum_percentile": 80.0,
|
||||
"min_rr": 1.2,
|
||||
"min_confidence": 55.0,
|
||||
# 0 = off. The July 2026 gate ablation showed the confidence floor added
|
||||
# nothing (identical net/trade with it removed, under both exit models)
|
||||
# while cutting ~25% of qualified trades.
|
||||
"min_confidence": 0.0,
|
||||
"require_high_conviction": False,
|
||||
"exclude_conflicts": False,
|
||||
# On by default: a NEUTRAL ("no clear setup") recommendation isn't an
|
||||
|
||||
@@ -20,19 +20,26 @@ 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.
|
||||
# Exit policy for OPEN paper trades (auto-close). "time" holds a fixed number of
|
||||
# trading days with the initial stop and exits at that day's close — the exit the
|
||||
# July 2026 backtest validated (the classic momentum hold-and-re-rank); "trailing"
|
||||
# rides a trailing stop; "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"
|
||||
KEY_HOLD_DAYS = "paper_hold_days"
|
||||
DEFAULT_EXIT_MODE = "time"
|
||||
DEFAULT_TRAILING_PCT = 12.0
|
||||
DEFAULT_HOLD_DAYS = 30
|
||||
|
||||
_VALID_EXIT_MODES = ("time", "trailing", "target")
|
||||
|
||||
|
||||
async def get_exit_policy(db: AsyncSession) -> dict:
|
||||
"""Active auto-exit policy: {'mode': 'trailing'|'target', 'trailing_pct': float}."""
|
||||
"""Active auto-exit policy:
|
||||
{'mode': 'time'|'trailing'|'target', 'trailing_pct': float, 'hold_days': int}."""
|
||||
mode = (await settings_store.get_value(db, KEY_EXIT_MODE, DEFAULT_EXIT_MODE)).strip().lower()
|
||||
if mode not in ("trailing", "target"):
|
||||
if mode not in _VALID_EXIT_MODES:
|
||||
mode = DEFAULT_EXIT_MODE
|
||||
raw = await settings_store.get_value(db, KEY_TRAILING_PCT, str(DEFAULT_TRAILING_PCT))
|
||||
try:
|
||||
@@ -40,22 +47,36 @@ async def get_exit_policy(db: AsyncSession) -> dict:
|
||||
except (TypeError, ValueError):
|
||||
pct = DEFAULT_TRAILING_PCT
|
||||
pct = max(0.5, min(90.0, pct))
|
||||
return {"mode": mode, "trailing_pct": pct}
|
||||
raw_days = await settings_store.get_value(db, KEY_HOLD_DAYS, str(DEFAULT_HOLD_DAYS))
|
||||
try:
|
||||
hold_days = int(float(raw_days))
|
||||
except (TypeError, ValueError):
|
||||
hold_days = DEFAULT_HOLD_DAYS
|
||||
hold_days = max(2, min(250, hold_days))
|
||||
return {"mode": mode, "trailing_pct": pct, "hold_days": hold_days}
|
||||
|
||||
|
||||
async def set_exit_policy(
|
||||
db: AsyncSession, *, mode: str | None = None, trailing_pct: float | None = None
|
||||
db: AsyncSession,
|
||||
*,
|
||||
mode: str | None = None,
|
||||
trailing_pct: float | None = None,
|
||||
hold_days: int | 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'")
|
||||
if mode not in _VALID_EXIT_MODES:
|
||||
raise ValidationError("mode must be 'time', '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)))
|
||||
if hold_days is not None:
|
||||
if not 2 <= int(hold_days) <= 250:
|
||||
raise ValidationError("hold_days must be between 2 and 250")
|
||||
await settings_store.upsert_setting(db, KEY_HOLD_DAYS, str(int(hold_days)))
|
||||
await db.commit()
|
||||
return await get_exit_policy(db)
|
||||
|
||||
@@ -101,6 +122,23 @@ async def _max_high_after(db: AsyncSession, ticker_id: int, since: date) -> floa
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
def _time_close(
|
||||
direction: str, init_stop: float, hold_days: int, rows: list[tuple]
|
||||
) -> tuple[float, date, str] | None:
|
||||
"""Walk post-entry ``rows`` of (date, open, high, low, close); close at the
|
||||
initial stop if hit (a gap through it fills at the open, matching the
|
||||
backtest's fill model), else at the ``hold_days``-th bar's close ('time').
|
||||
None while neither has happened."""
|
||||
long = direction == "long"
|
||||
for i, (d, open_, high, low, close) in enumerate(rows):
|
||||
if (low <= init_stop) if long else (high >= init_stop):
|
||||
fill = min(init_stop, open_) if long else max(init_stop, open_)
|
||||
return float(fill), d, "stop"
|
||||
if i + 1 >= hold_days:
|
||||
return float(close), d, "time"
|
||||
return None
|
||||
|
||||
|
||||
def _trailing_close(
|
||||
direction: str, entry: float, init_stop: float, trail_frac: float, bars: list[Bar]
|
||||
) -> tuple[float, date, str] | None:
|
||||
@@ -297,12 +335,12 @@ async def close_trade(
|
||||
|
||||
|
||||
async def resolve_open_trades(db: AsyncSession) -> int:
|
||||
"""Auto-close open trades whose stop or target was hit in the daily bars.
|
||||
"""Auto-close open trades per the active exit policy, from the daily bars.
|
||||
|
||||
Walks the bars after each trade's open (same logic as the outcome evaluator).
|
||||
Target hit → close at the target; stop (or an ambiguous same-bar touch) →
|
||||
close at the stop. Trades that have hit neither stay open. Returns the count
|
||||
closed.
|
||||
Walks the bars after each trade's open. 'time' closes at the initial stop or
|
||||
the hold_days-th close; 'trailing' at the trailing/initial stop; 'target' at
|
||||
the setup's target or stop (same logic as the outcome evaluator). Trades that
|
||||
have hit nothing stay open. Returns the count closed.
|
||||
"""
|
||||
result = await db.execute(select(PaperTrade).where(PaperTrade.status == "open"))
|
||||
open_trades = list(result.scalars().all())
|
||||
@@ -312,22 +350,32 @@ async def resolve_open_trades(db: AsyncSession) -> int:
|
||||
policy = await get_exit_policy(db)
|
||||
mode = policy["mode"]
|
||||
trail_frac = policy["trailing_pct"] / 100.0
|
||||
hold_days = policy["hold_days"]
|
||||
|
||||
closed = 0
|
||||
for trade in open_trades:
|
||||
bars_result = await db.execute(
|
||||
select(OHLCVRecord.date, OHLCVRecord.high, OHLCVRecord.low)
|
||||
select(
|
||||
OHLCVRecord.date, OHLCVRecord.open, OHLCVRecord.high,
|
||||
OHLCVRecord.low, OHLCVRecord.close,
|
||||
)
|
||||
.where(
|
||||
OHLCVRecord.ticker_id == trade.ticker_id,
|
||||
OHLCVRecord.date > trade.opened_at.date(),
|
||||
)
|
||||
.order_by(OHLCVRecord.date.asc())
|
||||
)
|
||||
bars = [Bar(date=d, high=h, low=lo) for d, h, lo in bars_result.all()]
|
||||
rows = bars_result.all()
|
||||
bars = [Bar(date=d, high=h, low=lo) for d, _, h, lo, _ in rows]
|
||||
if not bars:
|
||||
continue
|
||||
|
||||
if mode == "trailing":
|
||||
if mode == "time":
|
||||
hit = _time_close(trade.direction, trade.stop_loss, hold_days, rows)
|
||||
if hit is None:
|
||||
continue # neither the stop nor the hold horizon reached yet
|
||||
close_price, close_date, reason = hit
|
||||
elif 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
|
||||
|
||||
@@ -6,13 +6,15 @@ import { SkeletonCard } from '../ui/Skeleton';
|
||||
export function ExitPolicySettings() {
|
||||
const { data, isLoading } = useExitPolicy();
|
||||
const update = useUpdateExitPolicy();
|
||||
const [mode, setMode] = useState<ExitPolicy['mode']>('trailing');
|
||||
const [mode, setMode] = useState<ExitPolicy['mode']>('time');
|
||||
const [pct, setPct] = useState(12);
|
||||
const [holdDays, setHoldDays] = useState(30);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setMode(data.mode);
|
||||
setPct(data.trailing_pct);
|
||||
setHoldDays(data.hold_days ?? 30);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
@@ -24,12 +26,14 @@ export function ExitPolicySettings() {
|
||||
<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.
|
||||
<span className="text-gray-300">Hold</span> keeps the initial stop and exits at the Nth trading
|
||||
day's close — the backtest-validated exit (classic momentum: hold ~a month, re-rank);{' '}
|
||||
<span className="text-gray-300">Trailing</span> rides a trailing stop;{' '}
|
||||
<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">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">Exit mode</span>
|
||||
<select
|
||||
@@ -37,10 +41,25 @@ export function ExitPolicySettings() {
|
||||
onChange={(e) => setMode(e.target.value as ExitPolicy['mode'])}
|
||||
className="w-full input-glass px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="time">Hold N days + stop</option>
|
||||
<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">Hold (trading days)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={250}
|
||||
step={1}
|
||||
value={holdDays}
|
||||
onChange={(e) => setHoldDays(Number(e.target.value))}
|
||||
disabled={mode !== 'time'}
|
||||
className="w-full input-glass px-3 py-2 text-sm disabled:opacity-50"
|
||||
/>
|
||||
<span className="text-[11px] text-gray-600">Backtest optimum: 30 (its evaluation horizon).</span>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">Trailing width (%)</span>
|
||||
<input
|
||||
@@ -53,13 +72,13 @@ export function ExitPolicySettings() {
|
||||
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>
|
||||
<span className="text-[11px] text-gray-600">Give-back from the peak. ≥15% ≈ the hold exit.</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 })}
|
||||
onClick={() => update.mutate({ mode, trailing_pct: pct, hold_days: holdDays })}
|
||||
>
|
||||
{update.isPending ? 'Saving…' : 'Save Exit Policy'}
|
||||
</button>
|
||||
|
||||
@@ -211,14 +211,15 @@ export interface PaperTrade {
|
||||
benchmark_return_pct: number | null;
|
||||
alpha_pct: number | null;
|
||||
alpha_usd: number | null;
|
||||
close_reason: 'trailing' | 'stop' | 'target' | 'manual' | null;
|
||||
close_reason: 'time' | 'trailing' | 'stop' | 'target' | 'manual' | null;
|
||||
trailing_stop: number | null;
|
||||
trailing_distance_pct: number | null;
|
||||
}
|
||||
|
||||
export interface ExitPolicy {
|
||||
mode: 'trailing' | 'target';
|
||||
mode: 'time' | 'trailing' | 'target';
|
||||
trailing_pct: number;
|
||||
hold_days: number;
|
||||
}
|
||||
|
||||
export interface BacktestBucket {
|
||||
|
||||
@@ -27,7 +27,7 @@ class TestActivationConfig:
|
||||
assert config == {
|
||||
"min_momentum_percentile": 80.0,
|
||||
"min_rr": 1.2,
|
||||
"min_confidence": 55.0,
|
||||
"min_confidence": 0.0, # off — the July 2026 ablation showed it adds nothing
|
||||
"require_high_conviction": False,
|
||||
"exclude_conflicts": False,
|
||||
"exclude_neutral": True,
|
||||
|
||||
@@ -205,9 +205,12 @@ class TestTrailingClose:
|
||||
|
||||
|
||||
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}
|
||||
# Default: the backtest-validated hold-to-horizon exit.
|
||||
assert await svc.get_exit_policy(session) == {
|
||||
"mode": "time", "trailing_pct": 12.0, "hold_days": 30,
|
||||
}
|
||||
updated = await svc.set_exit_policy(session, mode="target", trailing_pct=15.0, hold_days=21)
|
||||
assert updated == {"mode": "target", "trailing_pct": 15.0, "hold_days": 21}
|
||||
assert (await svc.get_exit_policy(session))["mode"] == "target"
|
||||
|
||||
|
||||
@@ -216,10 +219,64 @@ async def test_exit_policy_rejects_bad_input(session):
|
||||
await svc.set_exit_policy(session, mode="bogus")
|
||||
with pytest.raises(ValidationError):
|
||||
await svc.set_exit_policy(session, trailing_pct=200.0)
|
||||
with pytest.raises(ValidationError):
|
||||
await svc.set_exit_policy(session, hold_days=1)
|
||||
|
||||
|
||||
def _r(d: date, open_: float, hi: float, lo: float, close: float) -> tuple:
|
||||
return (d, open_, hi, lo, close)
|
||||
|
||||
|
||||
class TestTimeClose:
|
||||
def test_closes_at_hold_days_close(self):
|
||||
rows = [
|
||||
_r(date(2026, 1, 2), 101, 103, 100, 102),
|
||||
_r(date(2026, 1, 3), 102, 104, 101, 103),
|
||||
_r(date(2026, 1, 4), 103, 106, 102, 105),
|
||||
]
|
||||
assert svc._time_close("long", 95.0, 3, rows) == (105.0, date(2026, 1, 4), "time")
|
||||
|
||||
def test_stop_before_horizon(self):
|
||||
rows = [_r(date(2026, 1, 2), 100, 101, 94, 96)]
|
||||
assert svc._time_close("long", 95.0, 30, rows) == (95.0, date(2026, 1, 2), "stop")
|
||||
|
||||
def test_gap_through_stop_fills_at_open(self):
|
||||
rows = [_r(date(2026, 1, 2), 92, 93, 90, 91)]
|
||||
assert svc._time_close("long", 95.0, 30, rows) == (92.0, date(2026, 1, 2), "stop")
|
||||
|
||||
def test_none_before_horizon(self):
|
||||
rows = [_r(date(2026, 1, 2), 101, 103, 100, 102)]
|
||||
assert svc._time_close("long", 95.0, 5, rows) is None
|
||||
|
||||
|
||||
async def test_resolve_time_mode_closes_at_horizon(session):
|
||||
await svc.set_exit_policy(session, mode="time", hold_days=2)
|
||||
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=200.0)
|
||||
await _add_bars(session, tid, [(103, 101), (105, 102)], start=date.today())
|
||||
assert await svc.resolve_open_trades(session) == 1
|
||||
await session.refresh(trade)
|
||||
assert trade.status == "closed"
|
||||
assert trade.close_reason == "time"
|
||||
assert trade.close_price == pytest.approx((105 + 102) / 2) # day-2 close (= bar mid)
|
||||
|
||||
|
||||
async def test_resolve_time_mode_stop_still_governs(session):
|
||||
await svc.set_exit_policy(session, mode="time", hold_days=30)
|
||||
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=200.0)
|
||||
await _add_bars(session, tid, [(101, 94)], start=date.today()) # low pierces the stop
|
||||
assert await svc.resolve_open_trades(session) == 1
|
||||
await session.refresh(trade)
|
||||
assert trade.close_reason == "stop"
|
||||
assert trade.close_price == pytest.approx(95.0)
|
||||
|
||||
|
||||
async def test_resolve_trailing_closes_with_reason(session):
|
||||
tid = await _seed(session, "AAA", close=100.0) # default policy: trailing 12%
|
||||
await svc.set_exit_policy(session, mode="trailing", trailing_pct=12.0)
|
||||
tid = await _seed(session, "AAA", close=100.0)
|
||||
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
|
||||
@@ -236,6 +293,7 @@ async def test_manual_close_sets_reason(session):
|
||||
|
||||
|
||||
async def test_list_open_exposes_trailing_stop(session):
|
||||
await svc.set_exit_policy(session, mode="trailing", trailing_pct=12.0)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user