Promote production portfolio strategy

This commit is contained in:
2026-07-04 07:48:38 +02:00
parent 66ef0564c1
commit 5f2d108227
22 changed files with 1677 additions and 132 deletions
+188 -18
View File
@@ -12,6 +12,7 @@ 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, settings_store
from app.services.indicator_service import compute_atr
from app.services.outcome_service import (
OUTCOME_AMBIGUOUS,
OUTCOME_STOP_HIT,
@@ -20,24 +21,25 @@ from app.services.outcome_service import (
evaluate_setup_against_bars,
)
# 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.
# Exit policy for OPEN paper trades (auto-close). Production defaults to the
# July 2026 promoted strategy: initial stop + 3x ATR trailing stop, with a max
# 30-trading-day hold. The older percent trail and target/stop modes remain
# selectable for comparison. Stored in SystemSetting so it's tunable and visible.
KEY_EXIT_MODE = "paper_exit_mode"
KEY_TRAILING_PCT = "paper_trailing_pct"
KEY_ATR_MULTIPLIER = "paper_atr_multiplier"
KEY_HOLD_DAYS = "paper_hold_days"
DEFAULT_EXIT_MODE = "time"
DEFAULT_EXIT_MODE = "atr_trailing"
DEFAULT_TRAILING_PCT = 12.0
DEFAULT_ATR_MULTIPLIER = 3.0
DEFAULT_HOLD_DAYS = 30
_VALID_EXIT_MODES = ("time", "trailing", "target")
_VALID_EXIT_MODES = ("time", "trailing", "atr_trailing", "target")
async def get_exit_policy(db: AsyncSession) -> dict:
"""Active auto-exit policy:
{'mode': 'time'|'trailing'|'target', 'trailing_pct': float, 'hold_days': int}."""
{'mode': 'time'|'trailing'|'atr_trailing'|'target', ...}."""
mode = (await settings_store.get_value(db, KEY_EXIT_MODE, DEFAULT_EXIT_MODE)).strip().lower()
if mode not in _VALID_EXIT_MODES:
mode = DEFAULT_EXIT_MODE
@@ -47,13 +49,24 @@ async def get_exit_policy(db: AsyncSession) -> dict:
except (TypeError, ValueError):
pct = DEFAULT_TRAILING_PCT
pct = max(0.5, min(90.0, pct))
raw_atr = await settings_store.get_value(db, KEY_ATR_MULTIPLIER, str(DEFAULT_ATR_MULTIPLIER))
try:
atr_multiplier = float(raw_atr)
except (TypeError, ValueError):
atr_multiplier = DEFAULT_ATR_MULTIPLIER
atr_multiplier = max(0.5, min(10.0, atr_multiplier))
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}
return {
"mode": mode,
"trailing_pct": pct,
"atr_multiplier": atr_multiplier,
"hold_days": hold_days,
}
async def set_exit_policy(
@@ -61,18 +74,23 @@ async def set_exit_policy(
*,
mode: str | None = None,
trailing_pct: float | None = None,
atr_multiplier: 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 _VALID_EXIT_MODES:
raise ValidationError("mode must be 'time', 'trailing' or 'target'")
raise ValidationError("mode must be 'time', 'trailing', 'atr_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 atr_multiplier is not None:
if not 0.5 <= float(atr_multiplier) <= 10.0:
raise ValidationError("atr_multiplier must be between 0.5 and 10")
await settings_store.upsert_setting(db, KEY_ATR_MULTIPLIER, str(float(atr_multiplier)))
if hold_days is not None:
if not 2 <= int(hold_days) <= 250:
raise ValidationError("hold_days must be between 2 and 250")
@@ -163,6 +181,107 @@ def _trailing_close(
return None
def _atr_from_rows(rows: list[tuple], idx: int) -> float | None:
try:
result = compute_atr(
[float(r[2]) for r in rows[: idx + 1]],
[float(r[3]) for r in rows[: idx + 1]],
[float(r[4]) for r in rows[: idx + 1]],
)
except Exception:
return None
atr = result.get("atr")
return float(atr) if atr and atr > 0 else None
def _atr_trailing_level(
direction: str,
entry: float,
init_stop: float,
atr_multiplier: float,
rows: list[tuple],
opened_on: date,
) -> float:
"""Current ATR trailing stop level after all available post-entry closes."""
long = direction == "long"
stop = float(init_stop)
anchor = float(entry)
for idx, (d, _, _, _, close) in enumerate(rows):
if d <= opened_on:
continue
close = float(close)
atr = _atr_from_rows(rows, idx)
if long:
anchor = max(anchor, close)
if atr is not None:
next_stop = anchor - atr_multiplier * atr
if next_stop < close:
stop = max(stop, next_stop)
else:
anchor = min(anchor, close)
if atr is not None:
next_stop = anchor + atr_multiplier * atr
if next_stop > close:
stop = min(stop, next_stop)
return stop
def _atr_trailing_close(
direction: str,
entry: float,
init_stop: float,
atr_multiplier: float,
hold_days: int,
rows: list[tuple],
opened_on: date,
) -> tuple[float, date, str] | None:
"""Initial stop + ATR trailing stop + max hold, matching the portfolio sim.
Stop checks happen before the same day's trailing update, so a newly ratcheted
stop becomes active on the next bar. Gaps through the stop fill at the open.
"""
long = direction == "long"
stop = float(init_stop)
anchor = float(entry)
bars_held = 0
for idx, (d, open_, high, low, close) in enumerate(rows):
if d <= opened_on:
continue
open_ = float(open_)
high = float(high)
low = float(low)
close = float(close)
bars_held += 1
if long:
if low <= stop:
reason = "trailing" if stop > init_stop + 1e-9 else "stop"
return min(stop, open_), d, reason
else:
if high >= stop:
reason = "trailing" if stop < init_stop - 1e-9 else "stop"
return max(stop, open_), d, reason
if bars_held >= hold_days:
return close, d, "time"
atr = _atr_from_rows(rows, idx)
if long:
anchor = max(anchor, close)
if atr is not None:
next_stop = anchor - atr_multiplier * atr
if next_stop < close:
stop = max(stop, next_stop)
else:
anchor = min(anchor, close)
if atr is not None:
next_stop = anchor + atr_multiplier * atr
if next_stop > close:
stop = min(stop, next_stop)
return None
async def create_trade(
db: AsyncSession,
user_id: int,
@@ -273,7 +392,8 @@ async def list_trades(
# makes a provider call).
benchmark_closes = await benchmark_service.load_benchmark_closes(db)
# Current trailing-stop level + distance for open trades (when trailing is active).
# Current trailing-stop level + distance for open trades (when a trailing
# policy is active).
policy = await get_exit_policy(db)
trailing_info: dict[int, tuple[float, float | None]] = {}
if policy["mode"] == "trailing":
@@ -294,6 +414,33 @@ async def list_trades(
if cur:
dist = ((cur - level) / cur * 100.0) if long else ((level - cur) / cur * 100.0)
trailing_info[t.id] = (level, dist)
elif policy["mode"] == "atr_trailing":
atr_multiplier = float(policy["atr_multiplier"])
for t, _ in rows:
if t.status != "open":
continue
bars_result = await db.execute(
select(
OHLCVRecord.date, OHLCVRecord.open, OHLCVRecord.high,
OHLCVRecord.low, OHLCVRecord.close,
)
.where(OHLCVRecord.ticker_id == t.ticker_id)
.order_by(OHLCVRecord.date.asc())
)
level = _atr_trailing_level(
t.direction,
t.entry_price,
t.stop_loss,
atr_multiplier,
bars_result.all(),
t.opened_at.date(),
)
cur = prices.get(t.ticker_id)
dist = None
if cur:
long = t.direction == "long"
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))
@@ -337,10 +484,11 @@ async def close_trade(
async def resolve_open_trades(db: AsyncSession) -> int:
"""Auto-close open trades per the active exit policy, from the daily bars.
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.
Walks the bars after each trade's open. 'atr_trailing' closes at the initial
stop, a 3x-ATR-style trailing stop, or the hold_days-th close; 'time' closes
at the initial stop or the hold_days-th close; 'trailing' uses the legacy
percent trail; 'target' uses the setup's target or stop. 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())
@@ -350,6 +498,7 @@ async def resolve_open_trades(db: AsyncSession) -> int:
policy = await get_exit_policy(db)
mode = policy["mode"]
trail_frac = policy["trailing_pct"] / 100.0
atr_multiplier = float(policy["atr_multiplier"])
hold_days = policy["hold_days"]
closed = 0
@@ -365,13 +514,13 @@ async def resolve_open_trades(db: AsyncSession) -> int:
)
.order_by(OHLCVRecord.date.asc())
)
rows = bars_result.all()
bars = [Bar(date=d, high=h, low=lo) for d, _, h, lo, _ in rows]
post_rows = bars_result.all()
bars = [Bar(date=d, high=h, low=lo) for d, _, h, lo, _ in post_rows]
if not bars:
continue
if mode == "time":
hit = _time_close(trade.direction, trade.stop_loss, hold_days, rows)
hit = _time_close(trade.direction, trade.stop_loss, hold_days, post_rows)
if hit is None:
continue # neither the stop nor the hold horizon reached yet
close_price, close_date, reason = hit
@@ -380,6 +529,27 @@ async def resolve_open_trades(db: AsyncSession) -> int:
if hit is None:
continue # neither the trailing nor the initial stop reached yet
close_price, close_date, reason = hit
elif mode == "atr_trailing":
all_bars_result = await db.execute(
select(
OHLCVRecord.date, OHLCVRecord.open, OHLCVRecord.high,
OHLCVRecord.low, OHLCVRecord.close,
)
.where(OHLCVRecord.ticker_id == trade.ticker_id)
.order_by(OHLCVRecord.date.asc())
)
hit = _atr_trailing_close(
trade.direction,
trade.entry_price,
trade.stop_loss,
atr_multiplier,
hold_days,
all_bars_result.all(),
trade.opened_at.date(),
)
if hit is None:
continue
close_price, close_date, reason = hit
else:
# max_bars beyond the data so a still-open trade returns undecided (not "expired").
outcome, outcome_date = evaluate_setup_against_bars(