Files
ForexBot2/exposure.py
Harwinder Sekhon 2cc5915b00 Daily-trend veto on M15 + lot-size / already-open lines (campaign 2026-09-30), and the 2026-10-01 whole-codebase bug review
Two bodies of work that touch the same files, so one commit.

Direction and setup-size campaign (2026-09-30 → 10-01, owner's RISK-FIRST rule,
written before the runs):
- SIGNAL: M15 daily-trend veto (factory override 7, xau_strategy.daily_trend_veto
  "both"). A finished setup against a clear daily trend (closed D1 bars, Sunday
  stubs dropped; close vs its 50-bar EMA AND vs the close 60 kept days earlier)
  is removed as the last step of the core. One definition
  (signal_aggregator.daily_trend_series) for live, the bar replay and the twin's
  host side; D1 is a required frame of the M15 scan; mirrored in
  cuda_signal_kernels.py K2. W3 824 / +205.12R / PF 1.73 / max DD 12.4R vs
  1105 / +195.79R / 1.49 / 20.9R; 2019 → 2026 1473 / +223.33R / 1.42 / 41.5R vs
  2091 / +192.53R / 1.24 / 64.8R; unseen holdout −4.62R vs −6.63R.
- FAILED, not shipped: the veto on M5 (W3 drawdown 10.1R → 15.2R) and on H1
  (−9.7R), buy-only and sell-only on M15, the wide-stop cap
  (xau_strategy.wide_stop_cap_atr stays 0.0, a research knob).
- New M15 pins: W2 dfc647ee…, OOS e87b6de1…, W3 ad3b0dbd…. M5 / H1 / H4 pins
  unchanged. --set xau_strategy.daily_trend_veto=off reproduces the previous M15
  pins byte for byte. Twin and reference script MD5-equal on every window;
  recorder and feature-export parity re-proved; base-rate table rebuilt.
- DISPLAY (books byte-identical): exposure.py — the lot size for
  trade_size.risk_per_trade (new config.json block, off by default, private to
  the owner) and the count of alerted same-direction plans still open
  (signals.alerted_at, read-only open_alerted_setups); the daily trend in words
  on Telegram, the CLI and the dashboard; signals.daily_trend logged.
- providers/oanda: a date window with no bars is an empty page, not an error
  (a D1 pad starting on a Saturday could not be fetched).
- Reports only, nothing shipped: seven context screens (no state nominated) and
  eight outside data sources (none adds a forecast to gold's own daily trend).
- Independent verification (report section 5.13): every figure reproduced on
  both scripts, scoring identical to the previous commit on all four
  timeframes, no look-ahead. Caveats recorded there: this is a risk filter, not
  a more accurate direction call; nine tenths of the 2019 → 2026 gain is 2022;
  the neighbour clause is thin (a simple 100-day average misses the R line by
  1.2R); "never adds a trade" is an observation, not a guarantee.

Bug review of the whole codebase (2026-10-01). Signal path clean. 17 defects
elsewhere, plus 4 found by an independent review of the fixes; all fixed, each
with a test; no trade book changed:
- telegram_bot: one _deliver_alert for the candle scan and the level break. The
  row is stamped alerted_at BEFORE the send, on a one-worker alert-db pool, and
  un-stamped when no chat received it or the task is cancelled mid-stamp, so
  two alerts at one candle close count each other and no SQLite write runs on
  the event loop. The 5-bar cooldown counts bars of the scan's own frame
  (== gpu_backtest._apply_cooldown; clock time released the 22:00 UTC bar after
  the settlement pause) and is restored from signals.db at start-up. The
  lot-size line goes to the owner's own chat only (a group the owner ran
  /autoscan in used to receive it). /status gains a news-model line and escapes
  failure details.
- signal_log: mark_alerted returns the stamp it wrote; unmark_alerted;
  last_alerted_bars (read-only).
- advisory_outcome: candle continuity is judged bar by bar inside the walk; a
  hole after a trade's exit no longer voids it. The stored-candle replay
  resolves 108 of 111 logged setups instead of 99, all equal to the live
  resolver's R.
- providers/oanda: an empty date window is accepted only when it is 5 days or
  shorter and no malformed candle was skipped; a longer one is retried, then
  refused, instead of being cached as history with a hole.
- news_engine: blank RSS titles skipped; one unreadable headline no longer
  costs the local model its whole batch (checked on the real bundle); model
  health recorded; no DeepSeek audit call in a process that does not archive;
  event-direction cache keyed per release; peek_week_events never fetches.
- llm_client: chat_json never raises and never logs the API key (requests
  quotes the Authorization value in InvalidHeader; error bodies are redacted).
- signal_aggregator: the scan's news block is guarded; the replay path
  (use_news=False) assigns exactly the values it did before.
- agent_panel: PANEL_PROMPT_VERSION v2.2 — same prompt text, corrected input
  (event timing over the cached calendar week, headlines newest first).
  scripts/panel_replay.py replays the logged payload per persona;
  scripts/label_scan_outcomes.py counts with an ISO cutoff.
- test_all.py: no ALTER TABLE … DROP COLUMN (the live box has SQLite 3.34);
  tests that write Parquet print a notice and return without pyarrow.

Verification after the last code change: suite 632 passed, 0 failed (also with
both Parquet engines blocked and FB2_BACKEND=cpu, the live box's view); the
five reference MD5s reproduce on both backtesters, 0 cache fallbacks; Module 37
green; static Python 3.9 / SQLite 3.34 check clean on every touched file.

Evidence: backtest/research/daily_trend_campaign_2026-09-30.md,
context_screens_2026-10.md, new_inputs_2026-10.md; CLAUDE.md sections 2, 4, 6,
8, 9, 10 and 11; pair_signals/xauusd.py docstring (M15 override 7).
2026-10-01 10:28:28 -04:00

161 lines
7.1 KiB
Python

"""
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