-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathposition_manager.py
More file actions
452 lines (396 loc) · 14.6 KB
/
Copy pathposition_manager.py
File metadata and controls
452 lines (396 loc) · 14.6 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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
"""Per-position hold / close advisory engine."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from zoneinfo import ZoneInfo
import MetaTrader5 as mt5
from config import RISK
from indicators import IndicatorBundle, TrendBias
from macro_engine import MacroDaySummary, MacroStatus
from mt5_connector import MarketSnapshot, PositionInfo
from position_tracker import PositionTrackState
from risk_engine import Verdict, VerdictStatus
from signal_lab import SignalLabSnapshot, synthesize_m1_verdict
from trading_style import StyleGuide, TradingStyle
from trend_brief import TrendBrief
from user_insights import CheckItem, CheckStatus
ACTION_CLOSE = "ZAVŘÍT"
ACTION_PROTECT = "CHRÁNIT"
ACTION_WATCH = "KOREKCE"
ACTION_HOLD = "DRŽET"
_ACTION_ORDER = {ACTION_CLOSE: 0, ACTION_PROTECT: 1, ACTION_WATCH: 2, ACTION_HOLD: 3}
_TONE_MAP = {
ACTION_CLOSE: "close",
ACTION_PROTECT: "protect",
ACTION_WATCH: "watch",
ACTION_HOLD: "hold",
}
@dataclass(frozen=True)
class PositionVerdict:
ticket: int
side: str
volume: float
profit: float
r_current: float | None
action: str
tone: str
headline: str
reasons: tuple[str, ...]
metrics: tuple[CheckItem, ...]
confidence: int
position: PositionInfo
trend_alignment_pct: int = 50
trend_alignment_label: str = "STABILNÍ / KOREKCE"
def _mtf_direction(indicators: IndicatorBundle | None, tfs: tuple[str, ...] = ("M5", "M15")) -> str:
if not indicators:
return "NEUTRAL"
mtf = indicators.mtf_bias
bulls = sum(1 for tf in tfs if mtf.get(tf) == TrendBias.BULL)
bears = sum(1 for tf in tfs if mtf.get(tf) == TrendBias.BEAR)
if bulls > bears:
return "BULL"
if bears > bulls:
return "BEAR"
return "NEUTRAL"
def _is_aligned(side: str, mtf_dir: str) -> bool:
if mtf_dir == "NEUTRAL":
return True
if side == "BUY" and mtf_dir == "BULL":
return True
if side == "SELL" and mtf_dir == "BEAR":
return True
return False
def _macro_caution_soon(macro: MacroDaySummary | None, now: datetime) -> bool:
if not macro or macro.status != MacroStatus.CAUTION:
return False
if macro.caution_from and macro.caution_until:
tz = ZoneInfo(RISK.timezone)
local_now = now.astimezone(tz)
if macro.caution_from <= local_now <= macro.caution_until:
return True
delta = (macro.caution_from - local_now).total_seconds()
return 0 <= delta <= 15 * 60
return macro.status == MacroStatus.CAUTION
def _build_metrics(
pos: PositionInfo,
track: PositionTrackState | None,
mtf_dir: str,
aligned: bool,
macro: MacroDaySummary | None,
market: MarketSnapshot | None,
) -> tuple[CheckItem, ...]:
items: list[CheckItem] = []
r = pos.r_current
items.append(
CheckItem(
"R nyní",
f"{r:+.2f}R" if r is not None else "—",
CheckStatus.OK if r and r >= 0 else CheckStatus.WARN if r and r > -0.5 else CheckStatus.FAIL,
)
)
if track:
items.append(
CheckItem(
"MFE / MAE",
f"{track.mfe_r:+.1f}R / {track.mae_r:+.1f}R",
CheckStatus.OK,
)
)
if pos.sl_distance_pts is not None and market and market.atr > 0:
sl_atr = pos.sl_distance_pts / market.atr
items.append(
CheckItem(
"SL vzdálenost",
f"{pos.sl_distance_pts:.1f} ({sl_atr:.1f}× ATR)",
CheckStatus.WARN if sl_atr < 0.25 else CheckStatus.OK,
)
)
items.append(
CheckItem(
"MTF M5/M15",
mtf_dir + (" ✓" if aligned else " ✗"),
CheckStatus.OK if aligned else CheckStatus.WARN,
)
)
macro_label = macro.status.value if macro else "—"
items.append(
CheckItem(
"Macro",
macro_label,
CheckStatus.FAIL
if macro and macro.status == MacroStatus.BLOCKED
else CheckStatus.WARN
if macro and macro.status == MacroStatus.CAUTION
else CheckStatus.OK,
)
)
return tuple(items[:5])
def _calc_trend_alignment(
side: str,
aligned: bool,
m1_direction: str,
trend_brief: TrendBrief | None,
r: float | None,
track: PositionTrackState | None,
action: str,
market: MarketSnapshot | None = None,
indicators: IndicatorBundle | None = None,
signal_lab: SignalLabSnapshot | None = None,
style_guide: StyleGuide | None = None,
macro_summary: MacroDaySummary | None = None,
) -> tuple[int, str]:
"""0–100 % how strongly the market supports holding this position direction."""
is_buy = side == "BUY"
is_sell = side == "SELL"
score = 0.0
if indicators and indicators.mtf_bias:
mtf = indicators.mtf_bias
tf_points = 0.0
for tf in ("M1", "M5", "M15", "H1"):
bias = mtf.get(tf, TrendBias.NEUTRAL)
if is_buy:
if bias == TrendBias.BULL:
tf_points += 6.25
elif bias == TrendBias.BEAR:
tf_points -= 4.0
else:
tf_points += 1.5
elif is_sell:
if bias == TrendBias.BEAR:
tf_points += 6.25
elif bias == TrendBias.BULL:
tf_points -= 4.0
else:
tf_points += 1.5
score += max(0.0, min(25.0, tf_points))
elif aligned:
score += 18.0
else:
score += 4.0
if is_buy and m1_direction == "LONG":
score += 20.0
elif is_sell and m1_direction == "SHORT":
score += 20.0
elif m1_direction in ("LONG", "SHORT"):
score -= 22.0
else:
score += 6.0
if trend_brief:
score += (trend_brief.strength_now / 10.0) * 16.0
if trend_brief.strength_delta > 0:
score += 4.0
elif trend_brief.strength_delta < 0:
score -= 8.0
if is_buy and trend_brief.now_direction == "BUY":
score += 4.0
elif is_sell and trend_brief.now_direction == "SELL":
score += 4.0
elif trend_brief.now_direction in ("BUY", "SELL"):
score -= 6.0
if signal_lab:
regime = (signal_lab.regime or "").upper()
if regime in ("SWEEP", "EXTENDED"):
score -= 18.0
elif regime == "CHOP":
score -= 14.0
elif regime == "TREND":
score += 10.0
if style_guide:
if style_guide.style == TradingStyle.MOMENTUM_TREND:
score += 8.0
elif style_guide.style == TradingStyle.RANGE_SCALP:
score += 2.0
elif style_guide.style in (TradingStyle.WAIT, TradingStyle.NO_TRADE):
score -= 14.0
if market and market.atr > 0:
candle_ratio = market.current_candle_range / market.atr
if candle_ratio >= 0.85:
score += 6.0
elif candle_ratio >= 0.55:
score += 3.0
if market.atr_impulse:
score += 4.0
if market.spread_warning:
score -= 10.0
if r is not None:
if r >= 1.0:
score += 8.0
elif r >= 0.35:
score += 4.0
elif r < -0.35:
score -= 12.0
if r < -0.75:
score -= 10.0
if track and track.mfe_r >= 0.8 and r is not None and r <= max(0.35, track.mfe_r * 0.45):
score -= 16.0
if macro_summary:
if macro_summary.status == MacroStatus.BLOCKED:
score -= 25.0
elif macro_summary.status == MacroStatus.CAUTION:
score -= 8.0
if action == ACTION_CLOSE:
score = min(score, 25.0)
elif action == ACTION_PROTECT:
score = min(score, 45.0)
elif action == ACTION_WATCH:
score = min(score, 58.0)
pct = int(round(max(5.0, min(98.0, score))))
if pct >= 85:
label = "ULTRA SILNÝ TREND"
elif pct >= 70:
label = "SILNÝ TREND"
elif pct >= 50:
label = "KOREKCE / SLEDUJ"
elif pct >= 30:
label = "KOREKCE — SLABÁ PODPORA"
else:
label = "TRH PROTI POZICI"
return pct, label
def _evaluate_single(
pos: PositionInfo,
track: PositionTrackState | None,
market: MarketSnapshot | None,
indicators: IndicatorBundle | None,
signal_lab: SignalLabSnapshot | None,
style_guide: StyleGuide | None,
verdict: Verdict | None,
macro_summary: MacroDaySummary | None,
now: datetime,
trend_brief: TrendBrief | None = None,
) -> PositionVerdict:
side = pos.side or ("BUY" if pos.type == mt5.ORDER_TYPE_BUY else "SELL")
r = pos.r_current
mtf_dir = _mtf_direction(indicators)
aligned = _is_aligned(side, mtf_dir)
m1 = synthesize_m1_verdict(signal_lab)
scores: list[tuple[int, str, str, str]] = [] # priority, action, headline, reason
def add(priority: int, action: str, headline: str, reason: str) -> None:
scores.append((priority, action, headline, reason))
if verdict and verdict.status in (VerdictStatus.CRITICAL, VerdictStatus.BLOCKED):
add(100, ACTION_CLOSE, "Účet v kritické zóně — zavři expozici", verdict.messages[0] if verdict.messages else "DD limit")
if macro_summary and macro_summary.status == MacroStatus.BLOCKED:
add(95, ACTION_CLOSE, "Macro blokuje držení", macro_summary.headline or "High-impact okno")
if r is not None and r <= -0.75:
add(90, ACTION_CLOSE, "Blízko stopu — thesis na hraně", f"Aktuálně {r:+.2f}R")
if market and market.atr > 0 and pos.sl_distance_pts is not None:
if pos.sl_distance_pts < market.atr * 0.25:
add(88, ACTION_CLOSE, "Cena téměř u stop lossu", f"SL vzdálenost {pos.sl_distance_pts:.1f} (<0.25× ATR)")
if market and market.spread_warning and pos.profit < 0:
add(85, ACTION_CLOSE, "Spread vysoký + ztráta", "Exekuce zhoršuje R:R — exit")
if track and track.mfe_r >= 1.0 and r is not None and r <= 0.4:
if not aligned:
add(80, ACTION_CLOSE, "Giveback zisku + MTF proti", f"MFE {track.mfe_r:+.1f}R → nyní {r:+.2f}R")
else:
add(70, ACTION_PROTECT, "Vratil profit z maxima", f"MFE {track.mfe_r:+.1f}R → zvaž BE / partial")
if _macro_caution_soon(macro_summary, now):
add(65, ACTION_PROTECT, "Macro okno blízko", "Posuň SL na BE nebo zmenši lot")
if market and market.spread_warning and pos.profit >= 0:
add(60, ACTION_PROTECT, "Spread nad normálem", "Chraň zisk — ne přidávej")
if signal_lab and signal_lab.regime == "SWEEP":
sweep_against = (side == "BUY" and m1.direction == "SHORT") or (side == "SELL" and m1.direction == "LONG")
if sweep_against or m1.direction == "WAIT":
add(55, ACTION_WATCH, "Liquidity sweep — ne chase proti", signal_lab.headline)
if not aligned:
if r is not None and r < 0:
add(75, ACTION_CLOSE, "MTF proti směru pozice", f"Long/Short vs M5/M15 {mtf_dir}")
else:
add(50, ACTION_WATCH, "Krátkodobý proti-pohyb", f"MTF {mtf_dir} — sleduj SL, zatím korekce")
if track and track.mae_r > -0.5 and aligned and r is not None and r < 0.3:
add(40, ACTION_WATCH, "Pullback ve směru trendu", "Drž, ale sleduj — MAE v normě")
if style_guide and style_guide.style in (TradingStyle.WAIT, TradingStyle.NO_TRADE):
add(45, ACTION_WATCH, "Režim se změnil", style_guide.headline or style_guide.style.value)
if r is not None and r >= 0.5 and aligned:
add(10, ACTION_HOLD, "Trend drží — můžeš v klidu držet", f"+{r:.2f}R · MTF aligned")
if not scores:
if aligned:
add(5, ACTION_HOLD, "Bez silného signálu — drž dle plánu", "Thesis zatím platí")
else:
add(30, ACTION_WATCH, "Neutrální kontext", "Sleduj MTF a SL")
scores.sort(key=lambda x: x[0], reverse=True)
_, action, headline, top_reason = scores[0]
reasons = tuple(dict.fromkeys(s[3] for s in scores[:4]))
confidence = min(100, scores[0][0] + (20 if action == ACTION_CLOSE else 10 if action == ACTION_PROTECT else 0))
metrics = _build_metrics(pos, track, mtf_dir, aligned, macro_summary, market)
trend_pct, trend_label = _calc_trend_alignment(
side,
aligned,
m1.direction,
trend_brief,
r,
track,
action,
market,
indicators,
signal_lab,
style_guide,
macro_summary,
)
return PositionVerdict(
ticket=pos.ticket,
side=side,
volume=pos.volume,
profit=pos.profit,
r_current=r,
action=action,
tone=_TONE_MAP[action],
headline=headline,
reasons=reasons,
metrics=metrics,
confidence=confidence,
position=pos,
trend_alignment_pct=trend_pct,
trend_alignment_label=trend_label,
)
def evaluate_positions(
positions: list[PositionInfo],
tracks: dict[int, PositionTrackState] | None,
market: MarketSnapshot | None,
indicators: IndicatorBundle | None,
signal_lab: SignalLabSnapshot | None,
style_guide: StyleGuide | None,
verdict: Verdict | None,
macro_summary: MacroDaySummary | None,
now: datetime | None = None,
trend_brief: TrendBrief | None = None,
) -> list[PositionVerdict]:
tz = ZoneInfo(RISK.timezone)
now = now or datetime.now(tz)
tracks = tracks or {}
results = [
_evaluate_single(
pos,
tracks.get(pos.ticket),
market,
indicators,
signal_lab,
style_guide,
verdict,
macro_summary,
now,
trend_brief,
)
for pos in positions
]
results.sort(key=lambda v: (_ACTION_ORDER[v.action], -v.confidence, v.ticket))
return results
def summarize_position_verdicts(verdicts: list[PositionVerdict]) -> str:
if not verdicts:
return "0 pozic"
counts: dict[str, int] = {}
for v in verdicts:
counts[v.action] = counts.get(v.action, 0) + 1
parts = []
for action in (ACTION_CLOSE, ACTION_PROTECT, ACTION_WATCH, ACTION_HOLD):
if counts.get(action):
parts.append(f"{counts[action]}× {action}")
return " · ".join(parts)
def close_toast_candidates(verdicts: list[PositionVerdict]) -> list[tuple[str, str]]:
"""Return (toast_key, label) for ZAVŘÍT positions."""
out: list[tuple[str, str]] = []
for v in verdicts:
if v.action != ACTION_CLOSE:
continue
r_txt = f"{v.r_current:+.1f}R" if v.r_current is not None else "—R"
label = f"ZAVŘÍT #{v.ticket} · {v.side} · {r_txt} · {v.headline[:36]}"
out.append((f"close-{v.ticket}", label))
return out