""" ForexBot2 — trade size and open-exposure lines (display only) ============================================================== Two lines a surface prints next to a trade setup. Nothing here is read by the signal core, a gate or the backtester: * size line — how many lots keep the loss at the stop inside the risk the owner set in config.json (`trade_size.risk_per_trade`). Off until that is set. The size reveals the owner's risk per trade, so it goes to the owner's own chat (telegram.chat_id) and the CLI only, unless `trade_size.telegram_channel` (every other Telegram chat: the channel, a group) / `trade_size.dashboard` switch it on for the public surfaces. * open line — how many ALERTED plans in the same direction are still open in the advisory outcome model (logs/signals.db, read-only). Taking every alert stacks positions: on the 3-year M5 + M15 books two entries in three already have a same-direction plan open, one in four has three or more, and up to 13 are open at once (backtest/research/daily_trend_campaign_2026-09-30.md, section 5.11). Those stacked entries did not pay worse; the point of the line is that the full risk on each alert is several times that risk on one move. Attached post-hoc by the surface (Telegram bot, CLI, dashboard), the same pattern as violent_watch.attach_watch. Never raises: a display line must not break a scan or an alert. """ import logging from decimal import ROUND_FLOOR, Decimal, InvalidOperation from typing import Any, Dict, List, Optional, Tuple import app_config import signal_log logger = logging.getLogger(__name__) # Surfaces a size line may be attached for, and the config flag that # shares it with a public audience (None = the owner's own surface). SURFACES = {"telegram": None, "cli": None, "dashboard": "dashboard"} _TF_LABEL = {"5min": "M5", "15min": "M15", "1h": "H1", "4h": "H4"} def lot_size(stop_distance: float, risk: float, contract_size: float = 100.0, lot_step: float = 0.01) -> Tuple[float, float]: """(lots, money at risk with those lots): the largest multiple of `lot_step` whose loss at the stop stays within `risk`. One lot is `contract_size` ounces, so a $1 move is worth `contract_size` dollars per lot. Decimal arithmetic with a floor, so the size never rounds UP past the risk limit and 0.07 never prints as 0.0699. (0.0, 0.0) when any input is unusable.""" try: d, r = Decimal(str(stop_distance)), Decimal(str(risk)) c, s = Decimal(str(contract_size)), Decimal(str(lot_step)) except (InvalidOperation, ValueError): return 0.0, 0.0 if not (d.is_finite() and r.is_finite() and c.is_finite() and s.is_finite()): return 0.0, 0.0 if d <= 0 or r <= 0 or c <= 0 or s <= 0: return 0.0, 0.0 steps = (r / (d * c * s)).to_integral_value(rounding=ROUND_FLOOR) lots = steps * s return float(lots), float(lots * d * c) def _decimals(step: float) -> int: text = format(Decimal(str(step)).normalize(), "f") return len(text.split(".")[1]) if "." in text else 0 def size_line(setup: Any, settings: Optional[Dict[str, Any]]) -> str: """"Size: 0.05 lots risks $99.00 at the stop (your limit $100.00 per trade)", or "" when the size is not configured or the setup has no usable stop. The stop distance is taken from the prices as they are printed (two decimals, subtracted exactly), so the line agrees with the entry and stop above it and a $20.00 stop is never read as $20.000000000000004.""" if setup is None or not settings: return "" try: risk = float(settings.get("risk_per_trade") or 0.0) contract = float(settings.get("contract_size", 100.0)) step = float(settings.get("lot_step", 0.01)) distance = abs(Decimal(f"{float(setup.entry_price):.2f}") - Decimal(f"{float(setup.stop_loss):.2f}")) except (TypeError, ValueError, InvalidOperation): return "" if not risk > 0 or not distance.is_finite() or not distance > 0: return "" lots, at_risk = lot_size(distance, risk, contract, step) places = _decimals(step) if lots <= 0: smallest = float(distance) * contract * step if not smallest > 0: return "" return (f"Size: the stop is too wide for your ${risk:,.2f} limit — the smallest " f"size ({step:.{places}f} lots) would risk ${smallest:,.2f}") return (f"Size: {lots:.{places}f} lots risks ${at_risk:,.2f} at the stop " f"(your limit ${risk:,.2f} per trade)") def open_line(rows: Optional[List[Dict[str, Any]]], direction: str) -> str: """"Already open: 2 buy plans alerted earlier (M5, M15) — taking this one adds to the same position", or "" when no alerted plan in `direction` is still open.""" same = [r for r in (rows or []) if r.get("direction") == direction] if not same: return "" counts: Dict[str, int] = {} for r in same: label = _TF_LABEL.get(str(r.get("timeframe")), str(r.get("timeframe"))) counts[label] = counts.get(label, 0) + 1 where = ", ".join(f"{n} on {tf}" if n > 1 else tf for tf, n in counts.items()) n = len(same) return (f"Already open: {n} {direction.lower()} plan{'s' if n != 1 else ''} alerted " f"earlier ({where}) — taking this one adds to the same position") def size_shared(flag: str) -> bool: """True when config.json shares the size line with a public audience (`flag` = "telegram_channel" or "dashboard"). Never raises.""" try: settings = app_config.trade_size_settings() return bool(settings and settings.get(flag)) except Exception: return False def attach_exposure(report: Any, surface: str = "telegram") -> Any: """Stamp report.size_line and report.open_line for `surface` ("telegram" / "cli": the owner's own view; "dashboard": the size only when trade_size.dashboard is true). Display only; never raises.""" report.size_line = "" report.open_line = "" setup = getattr(report, "trade_setup", None) if setup is None: return report try: settings = app_config.trade_size_settings() flag = SURFACES.get(surface, "unknown-surface") if settings and (flag is None or settings.get(flag)): report.size_line = size_line(setup, settings) except Exception: logger.debug("exposure: size line unavailable", exc_info=True) try: # The same identity the logger stores for this scan's row, so the # plan being alerted now never counts itself (a re-alert of an # unchanged plan is the same position, not a second one). analysis = getattr(report, "primary_analysis", None) key = signal_log.setup_key( {"direction": setup.direction, "entry": setup.entry_price, "stop_loss": setup.stop_loss, "strategy": getattr(setup, "strategy", None)}, analysis.timeframe if analysis is not None else "") rows = signal_log.open_alerted_setups(report.symbol, exclude_key=key) report.open_line = open_line(rows, setup.direction) except Exception: logger.debug("exposure: open line unavailable", exc_info=True) return report