-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathintraday_exit_manager.py
More file actions
165 lines (135 loc) · 6.1 KB
/
intraday_exit_manager.py
File metadata and controls
165 lines (135 loc) · 6.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
"""
Intraday exit rule engine — evaluates exit conditions on each price update.
Four independent rules:
1. ATR trailing stop: price below high_water - (ATR × multiple)
2. Profit-taking: price up > threshold % from entry → REDUCE 50%
3. Intraday collapse: price drops > threshold % within the day → full EXIT
4. Time-based tightening: after N days held, tighten trail multiplier
These are software-side rules (daemon-enforced). Broker-side trailing stops
from bracket_orders.py provide a safety net if the daemon is down.
"""
from __future__ import annotations
import logging
from datetime import date
logger = logging.getLogger(__name__)
class IntradayExitManager:
"""Evaluate intraday exit rules against live price state."""
def __init__(self, strategy_config: dict):
self._config = strategy_config
def evaluate(self, stop: dict, price_state: dict) -> dict | None:
"""
Check all exit rules for a position.
Args:
stop: active stop record from order book:
{ticker, entry_price, current_stop, trail_atr, atr_multiple,
high_water, entry_date, shares}
price_state: from PriceMonitor:
{last, high, low, close, volume, updated_at}
Returns:
Exit signal dict if triggered, else None.
{ticker, action, shares, reason, detail}
"""
ticker = stop["ticker"]
current_price = price_state.get("last")
if not current_price or current_price <= 0:
return None
# 1. ATR trailing stop
result = self._check_trailing_stop(stop, current_price)
if result:
return result
# 2. Profit-taking
result = self._check_profit_take(stop, current_price)
if result:
return result
# 3. Intraday collapse
result = self._check_collapse(stop, price_state)
if result:
return result
# Update high-water mark (no exit triggered)
return None
def should_update_trail(self, stop: dict, current_price: float) -> tuple[float, float] | None:
"""
Check if the trailing stop should be tightened upward.
Returns (new_high_water, new_stop_price) if update needed, else None.
"""
high_water = stop.get("high_water", 0)
if current_price <= high_water:
return None
trail_atr = stop.get("trail_atr", 0)
if not trail_atr or trail_atr <= 0:
return None # no ATR data — cannot compute meaningful trail distance
atr_multiple = stop.get("atr_multiple", 2.0)
# Time-based tightening: after N days, reduce multiplier
entry_date = stop.get("entry_date")
if entry_date:
try:
days_held = (date.today() - date.fromisoformat(entry_date)).days
except (ValueError, TypeError):
days_held = 0
tighten_after = self._config.get("intraday_tighten_after_days", 3)
if days_held >= tighten_after:
atr_multiple = min(atr_multiple, self._config.get("intraday_tighten_atr_multiple", 1.5))
trail_distance = trail_atr * atr_multiple
new_stop = round(current_price - trail_distance, 2)
current_stop = stop.get("current_stop", 0)
# Only ratchet up, never down
if new_stop > current_stop:
return current_price, new_stop
return None
# ── Private rule checks ──────────────────────────────────────────────────
def _check_trailing_stop(self, stop: dict, current_price: float) -> dict | None:
"""Exit if price falls below trailing stop level."""
current_stop = stop.get("current_stop")
if not current_stop or current_stop <= 0:
return None
if current_price <= current_stop:
trail_atr = stop.get("trail_atr", 0)
atr_multiple = stop.get("atr_multiple", 0)
return {
"ticker": stop["ticker"],
"action": "EXIT",
"shares": stop.get("shares", 0),
"reason": "intraday_trailing_stop",
"detail": (
f"price ${current_price:.2f} <= stop ${current_stop:.2f} "
f"(ATR ${trail_atr:.2f} × {atr_multiple})"
),
}
return None
def _check_profit_take(self, stop: dict, current_price: float) -> dict | None:
"""Reduce 50% when profit exceeds threshold. Fires at most once per position."""
if stop.get("profit_take_executed"):
return None
threshold = self._config.get("intraday_profit_take_pct", 0.08)
entry_price = stop.get("entry_price")
if not entry_price or entry_price <= 0:
return None
gain_pct = (current_price - entry_price) / entry_price
if gain_pct >= threshold:
shares = stop.get("shares", 0)
reduce_shares = max(1, shares // 2)
return {
"ticker": stop["ticker"],
"action": "REDUCE",
"shares": reduce_shares,
"reason": "intraday_profit_take",
"detail": f"gain {gain_pct:.1%} >= {threshold:.1%} threshold",
}
return None
def _check_collapse(self, stop: dict, price_state: dict) -> dict | None:
"""Full exit on severe intraday price drop."""
threshold = self._config.get("intraday_collapse_pct", 0.05)
current_price = price_state.get("last", 0)
day_high = price_state.get("high", 0)
if not day_high or day_high <= 0 or not current_price:
return None
intraday_drop = (day_high - current_price) / day_high
if intraday_drop >= threshold:
return {
"ticker": stop["ticker"],
"action": "EXIT",
"shares": stop.get("shares", 0),
"reason": "intraday_collapse",
"detail": f"intraday drop {intraday_drop:.1%} >= {threshold:.1%} (high=${day_high:.2f})",
}
return None