1793d9167a
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). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
161 lines
7.1 KiB
Python
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
|