Promote production portfolio strategy
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user