985ec83ef3
Zero callers anywhere (tests included), verified by grep/AST: - dashboard_publisher.DashboardPublisher.publish_all (run_cycle inlines both publishes) - signal_log.SignalLogger.advance_with_bars (the backfill script uses backfill_seeds / resolve_from_store / reseed_unresolvable) and the never-read SignalLogger.path property plus its write-only self._path - llm_client.reset_llm_client (tests reset the singleton by assignment) - market_outlook._last_run_ts, residue of the old "now - last < 3600" dedup; _last_run_hour (the UTC hour bucket) is the live key - news_engine FundamentalContext.event_impact_on_pairs: written once, read by nothing; its only feeder NewsEngine._get_affected_pairs and that helper's unit test go with it - position_sizing.LIVE_DEPTH_BARS (referenced only by its own docstring, which now states the depths inline), the unused Iterable import and the unused atr_ok parameter of _fill_geometry - analyze_signal_log: unused `field` import and the unused `rows` parameter of persona_scorecard / news_outcome_buckets (both read the DB directly) The two backfill/label scripts' --db help text now names the config.json setting it actually falls back to. Suite: 463 passed, 0 failed (one test removed with its subject). Claude-Session: https://claude.ai/code/session_0156YhogLytzj9P2iH1skGbL
612 lines
24 KiB
Python
612 lines
24 KiB
Python
"""
|
|
ForexBot2 — Position sizing (stake-multiplier overlay)
|
|
======================================================
|
|
|
|
Single source of truth for **decision-time features** and the resulting
|
|
**stake multiplier**. Everything here is designed to be called from
|
|
inside the signal path so that live and backtest see byte-identical
|
|
inputs (rule 4: live == backtest parity).
|
|
|
|
Design contract
|
|
---------------
|
|
* **Bounded lookbacks.** No feature may read further back than the live
|
|
fetch depth of its timeframe (5000 bars on M5/M15, 1460 on H1, 380 on
|
|
H4). The primary frame is hard-capped at ``MAX_LOOKBACK_BARS`` rows,
|
|
confirmation frames at 60 rows, and the burst scan at 200 rows, so a
|
|
feature computed on a full backtest frame equals the same feature
|
|
computed on the shorter live tail.
|
|
* **Closed bars only.** The frames handed in are exactly the frames
|
|
``analyze_from_dataframes`` received: UTC tz-aware, oldest-first,
|
|
capitalized OHLCV columns, forming candle already dropped. The last
|
|
row is the decision bar; nothing peeks past it.
|
|
* **Never raises into a scan.** ``build_features`` and
|
|
``stake_multiplier`` swallow every exception (logged at DEBUG) and
|
|
fall back to neutral defaults. A sizing failure can never break a
|
|
signal.
|
|
* **Advisory today.** ``stake_multiplier`` returns ``1.0`` in every case
|
|
at this stage — artifact evaluation lands in a later step. The guard
|
|
order is already implemented so the wiring is testable now.
|
|
|
|
Dependencies are deliberately limited to numpy/pandas plus ``config``
|
|
(the ``Timeframe`` enum): this module ships live and must not drag the
|
|
provider/news stack into any process that only wants features.
|
|
|
|
VERDICT 2026-09-06 (backtest/research/sizing_campaign_2026-09.md): the walk-forward
|
|
stake-multiplier model FAILED the pre-registered acceptance rule on both M5 and
|
|
M15 (M5 a clean null; M15 lift inside the label-shuffle null and explained by
|
|
BUY-vs-SELL). NO artifact ships and nothing reads a multiplier live. This module
|
|
is research plumbing: `build_features` feeds the backtester's --export-features
|
|
flag; `stake_multiplier` returns 1.0 unless a validated artifact is ever added
|
|
under a NEW pre-registration.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import math
|
|
from pathlib import Path
|
|
from typing import Dict, Optional, Sequence, Tuple
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from config import SignalStrength, Timeframe
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
#: Bump whenever FEATURE_ORDER or any feature's definition changes.
|
|
#: A trained artifact carrying a different value is ignored.
|
|
FEATURE_VERSION = 1
|
|
|
|
#: Hard cap on any primary-timeframe window used in this module.
|
|
MAX_LOOKBACK_BARS = 500
|
|
|
|
#: Bars of each confirmation timeframe used by the cross-TF features.
|
|
CONFIRM_LOOKBACK_BARS = 60
|
|
|
|
#: Window for the "bars since the last volatility burst" feature. Also
|
|
#: the sentinel returned when no burst is present in the window.
|
|
BURST_LOOKBACK_BARS = 200
|
|
|
|
#: Burst threshold: bar range >= this multiple of the PRIOR bar's ATR.
|
|
BURST_ATR_MULTIPLE = 3.0
|
|
|
|
#: Candle duration in seconds. Deliberate local copy of
|
|
#: ``providers.base.BaseDataProvider.CANDLE_SECONDS`` — importing the
|
|
#: providers package here would pull requests/twelvedata into every
|
|
#: feature-only consumer. A test asserts the copies agree.
|
|
CANDLE_SECONDS: Dict[Timeframe, int] = {
|
|
Timeframe.M5: 300,
|
|
Timeframe.M15: 900,
|
|
Timeframe.H1: 3600,
|
|
Timeframe.H4: 14400,
|
|
Timeframe.D1: 86400,
|
|
}
|
|
|
|
#: Grade -> ordinal used by ``f_quality_rank`` (D/F both fall to 0).
|
|
_QUALITY_RANK = {"A+": 4.0, "A": 3.0, "B": 2.0, "C": 1.0}
|
|
|
|
_BULLISH = (SignalStrength.BUY, SignalStrength.STRONG_BUY)
|
|
_BEARISH = (SignalStrength.SELL, SignalStrength.STRONG_SELL)
|
|
|
|
|
|
FEATURE_ORDER: Tuple[str, ...] = (
|
|
# ── Time ──────────────────────────────────────────────────────
|
|
"f_hour_utc",
|
|
"f_hour_sin",
|
|
"f_hour_cos",
|
|
"f_weekday",
|
|
"f_sess_asian",
|
|
"f_sess_london",
|
|
"f_sess_overlap",
|
|
"f_sess_ny",
|
|
"f_minutes_to_hour",
|
|
"f_day_of_month",
|
|
"f_first_friday",
|
|
# ── Volatility / setup geometry ───────────────────────────────
|
|
"f_atr",
|
|
"f_atr_pct",
|
|
"f_atr_rel_500",
|
|
"f_range_pctile_500",
|
|
"f_bar_range_atr",
|
|
"f_close_pos_in_bar",
|
|
"f_risk_price",
|
|
"f_sl_atr",
|
|
"f_tp1_atr",
|
|
"f_rr_planned",
|
|
# ── Exhaustion block ──────────────────────────────────────────
|
|
"f_swing_dist_atr",
|
|
"f_ema21_dist_atr",
|
|
"f_ema200_dist_atr",
|
|
"f_ema200_dist_pct",
|
|
"f_exhaustion_count",
|
|
"f_tag_exhaustion",
|
|
"f_tag_no_pullback",
|
|
"f_tag_weak_pullback",
|
|
"f_tag_rsi_extended",
|
|
"f_tag_bb_overextended",
|
|
"f_tag_ema200_near",
|
|
"f_tag_ema200_far",
|
|
"f_penalty_total",
|
|
"f_momentum_fading",
|
|
# ── Signal quality ────────────────────────────────────────────
|
|
"f_score_dir",
|
|
"f_confidence",
|
|
"f_quality_rank",
|
|
"f_ind_agree_frac",
|
|
"f_ind_oppose_frac",
|
|
"f_is_buy",
|
|
"f_strat_atr",
|
|
"f_strat_pb",
|
|
"f_strat_cp",
|
|
"f_strat_generic",
|
|
# ── Multi-timeframe ───────────────────────────────────────────
|
|
"f_mtf_primary",
|
|
"f_mtf_c1",
|
|
"f_mtf_c2",
|
|
"f_mtf_agreement",
|
|
# ── Primary-TF indicators ─────────────────────────────────────
|
|
"f_rsi",
|
|
"f_rsi_dir",
|
|
"f_bb_pos",
|
|
"f_bb_pos_dir",
|
|
"f_adx",
|
|
"f_kc_ext_atr",
|
|
"f_roc_dir",
|
|
"f_macd_hist_dir",
|
|
# ── Price path ────────────────────────────────────────────────
|
|
"f_ret_6_atr",
|
|
"f_ret_12_atr",
|
|
"f_ret_24_atr",
|
|
"f_ret_48_atr",
|
|
"f_consec_closes_dir",
|
|
"f_bars_since_burst",
|
|
# ── Cross-timeframe extension ─────────────────────────────────
|
|
"f_h1_ext_atr",
|
|
"f_c_other_ext_atr",
|
|
)
|
|
|
|
#: Value each feature falls back to when it cannot be computed. Only
|
|
#: ``f_bars_since_burst`` has a non-zero sentinel (its "none in window"
|
|
#: cap); everything else degrades to a neutral 0.0.
|
|
_DEFAULTS: Dict[str, float] = {name: 0.0 for name in FEATURE_ORDER}
|
|
_DEFAULTS["f_bars_since_burst"] = float(BURST_LOOKBACK_BARS)
|
|
|
|
#: Penalty tags recorded by SignalAggregator._compute_final_signal that
|
|
#: this module exposes as one-hot features.
|
|
_TAG_FEATURES = (
|
|
("f_tag_exhaustion", "exhaustion"),
|
|
("f_tag_no_pullback", "no_pullback"),
|
|
("f_tag_weak_pullback", "weak_pullback"),
|
|
("f_tag_rsi_extended", "rsi_extended"),
|
|
("f_tag_bb_overextended", "bb_overextended"),
|
|
("f_tag_ema200_near", "ema200_near"),
|
|
("f_tag_ema200_far", "ema200_far"),
|
|
)
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════
|
|
# Small helpers
|
|
# ══════════════════════════════════════════════════════════════════
|
|
|
|
def _finite(value, default: float = 0.0) -> float:
|
|
"""Coerce to a finite Python float, falling back to ``default``."""
|
|
try:
|
|
out = float(value)
|
|
except (TypeError, ValueError):
|
|
return float(default)
|
|
if not math.isfinite(out):
|
|
return float(default)
|
|
return out
|
|
|
|
|
|
def wilder_atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
|
"""Wilder ATR, identical convention to ``engine._compute_atr``.
|
|
|
|
True Range, then an EWM with ``alpha = 1/period``,
|
|
``min_periods = period`` and ``adjust=False``.
|
|
"""
|
|
high, low, close = df["High"], df["Low"], df["Close"]
|
|
tr1 = high - low
|
|
tr2 = (high - close.shift()).abs()
|
|
tr3 = (low - close.shift()).abs()
|
|
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
|
|
return tr.ewm(alpha=1.0 / period, min_periods=period, adjust=False).mean()
|
|
|
|
|
|
def decision_time(primary_df: pd.DataFrame, tf: Timeframe) -> pd.Timestamp:
|
|
"""The clock every gate uses: last CLOSED bar label + its duration."""
|
|
last = pd.Timestamp(primary_df.index[-1])
|
|
return last + pd.Timedelta(seconds=CANDLE_SECONDS.get(tf, 3600))
|
|
|
|
|
|
def session_label(hour_utc: int) -> str:
|
|
"""Trading-session bucket for a UTC hour."""
|
|
h = int(hour_utc)
|
|
if 3 <= h <= 6:
|
|
return "asian"
|
|
if 7 <= h <= 12:
|
|
return "london"
|
|
if 13 <= h <= 16:
|
|
return "overlap"
|
|
if 17 <= h <= 22:
|
|
return "ny"
|
|
return "off"
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════
|
|
# Artifact plumbing
|
|
# ══════════════════════════════════════════════════════════════════
|
|
|
|
#: path -> (mtime, artifact-or-None)
|
|
_ARTIFACT_CACHE: Dict[str, Tuple[float, Optional[dict]]] = {}
|
|
|
|
|
|
def artifact_path_for(primary_tf: Timeframe, base_dir) -> Path:
|
|
"""Where the trained artifact for one primary timeframe lives."""
|
|
return Path(base_dir) / f"xauusd_{primary_tf.value}.json"
|
|
|
|
|
|
def load_artifact(path) -> Optional[dict]:
|
|
"""Load a sizing artifact (JSON object), cached on (path, mtime).
|
|
|
|
Returns ``None`` when the file is missing, unreadable, not JSON, or
|
|
not a JSON object. Never raises.
|
|
"""
|
|
try:
|
|
p = Path(path)
|
|
mtime = p.stat().st_mtime
|
|
except Exception:
|
|
return None
|
|
|
|
key = str(p)
|
|
cached = _ARTIFACT_CACHE.get(key)
|
|
if cached is not None and cached[0] == mtime:
|
|
return cached[1]
|
|
|
|
data: Optional[dict]
|
|
try:
|
|
with p.open("r", encoding="utf-8") as fh:
|
|
loaded = json.load(fh)
|
|
data = loaded if isinstance(loaded, dict) else None
|
|
except Exception:
|
|
logger.debug("position_sizing: artifact %s is unreadable", key,
|
|
exc_info=True)
|
|
data = None
|
|
|
|
_ARTIFACT_CACHE[key] = (mtime, data)
|
|
return data
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════
|
|
# Feature construction
|
|
# ══════════════════════════════════════════════════════════════════
|
|
|
|
def _direction(setup, report) -> float:
|
|
"""+1.0 for a long, -1.0 for a short — from the TRADE, not the score."""
|
|
direction = getattr(setup, "direction", None)
|
|
if direction is None:
|
|
return 1.0 if _finite(getattr(report, "final_score", 0.0)) >= 0 else -1.0
|
|
return 1.0 if str(direction).upper() == "BUY" else -1.0
|
|
|
|
|
|
def _ext_vs_sma(df: pd.DataFrame, d: float) -> float:
|
|
"""Direction-signed distance from the 20-bar SMA in ATR units.
|
|
|
|
Computed on the LAST ``CONFIRM_LOOKBACK_BARS`` rows only, so the
|
|
value is identical on a full history and on its live-depth tail.
|
|
"""
|
|
if df is None or len(df) < 21:
|
|
return 0.0
|
|
tail = df.iloc[-CONFIRM_LOOKBACK_BARS:]
|
|
sma = tail["Close"].rolling(20).mean().iloc[-1]
|
|
atr = wilder_atr(tail, 14).iloc[-1]
|
|
atr_f = _finite(atr, 0.0)
|
|
if atr_f <= 0:
|
|
return 0.0
|
|
return d * (_finite(tail["Close"].iloc[-1]) - _finite(sma)) / atr_f
|
|
|
|
|
|
def _fill_time(out: Dict[str, float], dt: pd.Timestamp) -> None:
|
|
hour = int(dt.hour)
|
|
out["f_hour_utc"] = float(hour)
|
|
out["f_hour_sin"] = float(math.sin(2.0 * math.pi * hour / 24.0))
|
|
out["f_hour_cos"] = float(math.cos(2.0 * math.pi * hour / 24.0))
|
|
weekday = int(dt.weekday())
|
|
out["f_weekday"] = float(weekday)
|
|
label = session_label(hour)
|
|
out["f_sess_asian"] = 1.0 if label == "asian" else 0.0
|
|
out["f_sess_london"] = 1.0 if label == "london" else 0.0
|
|
out["f_sess_overlap"] = 1.0 if label == "overlap" else 0.0
|
|
out["f_sess_ny"] = 1.0 if label == "ny" else 0.0
|
|
out["f_minutes_to_hour"] = float((60 - int(dt.minute)) % 60)
|
|
out["f_day_of_month"] = float(int(dt.day))
|
|
out["f_first_friday"] = 1.0 if (weekday == 4 and int(dt.day) <= 7) else 0.0
|
|
|
|
|
|
def _fill_geometry(out: Dict[str, float], P: pd.DataFrame, setup,
|
|
atr: float, norm, d: float) -> None:
|
|
out["f_atr"] = max(0.0, _finite(atr))
|
|
ranges = (P["High"] - P["Low"]).astype(float)
|
|
last_range = _finite(ranges.iloc[-1])
|
|
mean_range = _finite(ranges.mean())
|
|
out["f_atr_rel_500"] = (_finite(atr) / mean_range) if mean_range > 0 else 0.0
|
|
n = int(len(ranges))
|
|
out["f_range_pctile_500"] = (
|
|
float((ranges.to_numpy() < last_range).sum()) / n if n else 0.0)
|
|
out["f_bar_range_atr"] = norm(last_range)
|
|
|
|
high = _finite(P["High"].iloc[-1])
|
|
low = _finite(P["Low"].iloc[-1])
|
|
close = _finite(P["Close"].iloc[-1])
|
|
span = high - low
|
|
if span > 0:
|
|
out["f_close_pos_in_bar"] = (
|
|
(close - low) / span if d > 0 else (high - close) / span)
|
|
else:
|
|
out["f_close_pos_in_bar"] = 0.5
|
|
|
|
entry = _finite(getattr(setup, "entry_price", None))
|
|
stop = _finite(getattr(setup, "stop_loss", None))
|
|
tp1 = _finite(getattr(setup, "take_profit_1", None))
|
|
risk = abs(entry - stop)
|
|
out["f_risk_price"] = risk
|
|
out["f_sl_atr"] = norm(risk)
|
|
out["f_tp1_atr"] = norm(abs(tp1 - entry))
|
|
out["f_rr_planned"] = _finite(getattr(setup, "risk_reward", None))
|
|
|
|
|
|
def _fill_exhaustion(out: Dict[str, float], report, KL: Dict[str, float],
|
|
px: float, d: float, norm) -> None:
|
|
if d > 0:
|
|
swings = [KL[k] for k in ("swing_low_1", "swing_low_2", "swing_low_3")
|
|
if KL.get(k) is not None]
|
|
out["f_swing_dist_atr"] = norm(px - min(swings)) if swings else 0.0
|
|
else:
|
|
swings = [KL[k] for k in ("swing_high_1", "swing_high_2", "swing_high_3")
|
|
if KL.get(k) is not None]
|
|
out["f_swing_dist_atr"] = norm(max(swings) - px) if swings else 0.0
|
|
|
|
ema21 = KL.get("ema_medium")
|
|
out["f_ema21_dist_atr"] = (
|
|
norm(d * (px - _finite(ema21))) if ema21 is not None else 0.0)
|
|
|
|
ema200 = KL.get("ema_trend")
|
|
ema200_f = _finite(ema200, 0.0) if ema200 is not None else 0.0
|
|
if ema200 is not None and ema200_f != 0.0:
|
|
out["f_ema200_dist_atr"] = norm(d * (px - ema200_f))
|
|
out["f_ema200_dist_pct"] = d * (px - ema200_f) / ema200_f * 100.0
|
|
else:
|
|
out["f_ema200_dist_atr"] = 0.0
|
|
out["f_ema200_dist_pct"] = 0.0
|
|
|
|
out["f_exhaustion_count"] = _finite(getattr(report, "exhaustion_count", 0))
|
|
tags = getattr(report, "penalty_tags", None) or {}
|
|
for feature, tag in _TAG_FEATURES:
|
|
out[feature] = 1.0 if tag in tags else 0.0
|
|
out["f_penalty_total"] = _finite(getattr(report, "penalty_total", 0.0))
|
|
out["f_momentum_fading"] = 1.0 if getattr(report, "momentum_fading", False) else 0.0
|
|
|
|
|
|
def _fill_quality(out: Dict[str, float], report, setup, IND: dict,
|
|
d: float) -> None:
|
|
out["f_score_dir"] = d * _finite(getattr(report, "final_score", 0.0))
|
|
out["f_confidence"] = _finite(getattr(report, "final_confidence", 0.0))
|
|
out["f_quality_rank"] = _QUALITY_RANK.get(
|
|
getattr(report, "signal_quality", ""), 0.0)
|
|
|
|
agree = oppose = total = 0
|
|
for name, ind in (IND or {}).items():
|
|
if str(name).startswith("_"):
|
|
continue
|
|
if _finite(getattr(ind, "weight", 0.0)) == 0.0:
|
|
continue
|
|
total += 1
|
|
signal = getattr(ind, "signal", None)
|
|
if d > 0:
|
|
if signal in _BULLISH:
|
|
agree += 1
|
|
elif signal in _BEARISH:
|
|
oppose += 1
|
|
else:
|
|
if signal in _BEARISH:
|
|
agree += 1
|
|
elif signal in _BULLISH:
|
|
oppose += 1
|
|
out["f_ind_agree_frac"] = (agree / total) if total else 0.0
|
|
out["f_ind_oppose_frac"] = (oppose / total) if total else 0.0
|
|
|
|
out["f_is_buy"] = 1.0 if d > 0 else 0.0
|
|
strategy = str(getattr(setup, "strategy", "") or "")
|
|
# The ATR path stamps "XAU_ATR"; "XAU" is accepted for legacy setups.
|
|
out["f_strat_atr"] = 1.0 if strategy in ("XAU_ATR", "XAU") else 0.0
|
|
out["f_strat_pb"] = 1.0 if strategy == "XAU_PB" else 0.0
|
|
out["f_strat_cp"] = 1.0 if strategy == "XAU_CP" else 0.0
|
|
out["f_strat_generic"] = 1.0 if strategy == "GENERIC" else 0.0
|
|
|
|
|
|
def _fill_mtf(out: Dict[str, float], report, primary_tf: Timeframe,
|
|
confirmation_tfs: Sequence[Timeframe], d: float) -> None:
|
|
scores = getattr(report, "mtf_scores", None) or {}
|
|
out["f_mtf_primary"] = d * _finite(scores.get(primary_tf.value, 0.0))
|
|
confirms = list(confirmation_tfs or [])
|
|
for idx, key in ((0, "f_mtf_c1"), (1, "f_mtf_c2")):
|
|
if idx < len(confirms):
|
|
out[key] = d * _finite(scores.get(confirms[idx].value, 0.0))
|
|
else:
|
|
out[key] = 0.0
|
|
out["f_mtf_agreement"] = 1.0 if getattr(report, "mtf_agreement", False) else 0.0
|
|
|
|
|
|
def _fill_indicators(out: Dict[str, float], IND: dict, d: float, norm) -> None:
|
|
IND = IND or {}
|
|
|
|
def _val(name, default):
|
|
ind = IND.get(name)
|
|
return _finite(getattr(ind, "value", None), default) if ind else default
|
|
|
|
rsi = _val("rsi", 50.0)
|
|
out["f_rsi"] = rsi
|
|
out["f_rsi_dir"] = d * (rsi - 50.0)
|
|
bb_pos = _val("bollinger", 0.5) # bollinger.value = bb_position
|
|
out["f_bb_pos"] = bb_pos
|
|
out["f_bb_pos_dir"] = d * (bb_pos - 0.5)
|
|
out["f_adx"] = _val("adx", 0.0)
|
|
# keltner.value = close - kc_mid (signed extension from the mid line)
|
|
out["f_kc_ext_atr"] = norm(d * _val("keltner", 0.0))
|
|
out["f_roc_dir"] = d * _val("roc", 0.0)
|
|
# macd.value = histogram
|
|
out["f_macd_hist_dir"] = d * _val("macd", 0.0)
|
|
|
|
|
|
def _fill_path(out: Dict[str, float], P: pd.DataFrame, d: float, norm) -> None:
|
|
close = P["Close"].astype(float).to_numpy()
|
|
n = len(close)
|
|
for lag, key in ((6, "f_ret_6_atr"), (12, "f_ret_12_atr"),
|
|
(24, "f_ret_24_atr"), (48, "f_ret_48_atr")):
|
|
out[key] = norm(d * (close[-1] - close[-1 - lag])) if n >= lag + 1 else 0.0
|
|
|
|
run = 0.0
|
|
if n >= 2:
|
|
diffs = np.diff(close)
|
|
last = diffs[-1]
|
|
if last > 0:
|
|
sign = 1.0
|
|
elif last < 0:
|
|
sign = -1.0
|
|
else:
|
|
sign = 0.0
|
|
if sign != 0.0:
|
|
count = 0
|
|
for value in diffs[::-1]:
|
|
if (value > 0 and sign > 0) or (value < 0 and sign < 0):
|
|
count += 1
|
|
else:
|
|
break
|
|
run = sign * count
|
|
out["f_consec_closes_dir"] = max(-20.0, min(20.0, d * run))
|
|
|
|
out["f_bars_since_burst"] = float(BURST_LOOKBACK_BARS)
|
|
prev_atr = wilder_atr(P, 14).shift(1).to_numpy(dtype=float)
|
|
ranges = (P["High"] - P["Low"]).astype(float).to_numpy()
|
|
start = max(0, n - BURST_LOOKBACK_BARS)
|
|
for i in range(n - 1, start - 1, -1):
|
|
threshold = prev_atr[i]
|
|
if not math.isfinite(threshold) or threshold <= 0:
|
|
continue
|
|
if ranges[i] >= BURST_ATR_MULTIPLE * threshold:
|
|
out["f_bars_since_burst"] = float((n - 1) - i)
|
|
break
|
|
|
|
|
|
def _fill_cross_tf(out: Dict[str, float], dfs: Dict[Timeframe, pd.DataFrame],
|
|
confirmation_tfs: Sequence[Timeframe], d: float) -> None:
|
|
out["f_h1_ext_atr"] = _ext_vs_sma(dfs.get(Timeframe.H1), d)
|
|
out["f_c_other_ext_atr"] = 0.0
|
|
for tf in (confirmation_tfs or []):
|
|
if tf is Timeframe.H1:
|
|
continue
|
|
df = dfs.get(tf)
|
|
if df is None or len(df) < 21:
|
|
continue
|
|
out["f_c_other_ext_atr"] = _ext_vs_sma(df, d)
|
|
break
|
|
|
|
|
|
def build_features(
|
|
report,
|
|
setup,
|
|
dfs: Dict[Timeframe, pd.DataFrame],
|
|
primary_tf: Timeframe,
|
|
confirmation_tfs: Sequence[Timeframe],
|
|
) -> Dict[str, float]:
|
|
"""Decision-time feature vector for one signal.
|
|
|
|
Returns a dict whose keys are exactly ``FEATURE_ORDER`` (in order),
|
|
every value a finite Python float. Degenerate inputs fall back to
|
|
the neutral defaults instead of raising — this runs inside a scan.
|
|
"""
|
|
out: Dict[str, float] = dict(_DEFAULTS)
|
|
|
|
try:
|
|
P = dfs.get(primary_tf)
|
|
if P is None or len(P) == 0:
|
|
return {k: out[k] for k in FEATURE_ORDER}
|
|
P = P.iloc[-MAX_LOOKBACK_BARS:]
|
|
|
|
d = _direction(setup, report)
|
|
primary = getattr(report, "primary_analysis", None)
|
|
KL = dict(getattr(primary, "key_levels", None) or {})
|
|
IND = getattr(primary, "indicators", None) or {}
|
|
|
|
atr_raw = KL.get("atr")
|
|
atr = _finite(atr_raw, 0.0)
|
|
atr_ok = atr > 0
|
|
atr_div = atr if atr_ok else 1e-9
|
|
|
|
def norm(value: float) -> float:
|
|
"""ATR-normalise, or 0.0 when ATR is missing/non-positive."""
|
|
return (_finite(value) / atr_div) if atr_ok else 0.0
|
|
|
|
px = _finite(P["Close"].iloc[-1])
|
|
out["f_atr_pct"] = _finite(KL.get("atr_pct"), 0.0)
|
|
|
|
for step, args in (
|
|
(_fill_time, (out, decision_time(P, primary_tf))),
|
|
(_fill_geometry, (out, P, setup, atr, norm, d)),
|
|
(_fill_exhaustion, (out, report, KL, px, d, norm)),
|
|
(_fill_quality, (out, report, setup, IND, d)),
|
|
(_fill_mtf, (out, report, primary_tf, confirmation_tfs, d)),
|
|
(_fill_indicators, (out, IND, d, norm)),
|
|
(_fill_path, (out, P, d, norm)),
|
|
(_fill_cross_tf, (out, dfs, confirmation_tfs, d)),
|
|
):
|
|
try:
|
|
step(*args)
|
|
except Exception:
|
|
logger.debug("position_sizing: %s failed", step.__name__,
|
|
exc_info=True)
|
|
except Exception:
|
|
logger.debug("position_sizing.build_features failed", exc_info=True)
|
|
|
|
return {k: _finite(out.get(k), _DEFAULTS[k]) for k in FEATURE_ORDER}
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════
|
|
# Stake multiplier
|
|
# ══════════════════════════════════════════════════════════════════
|
|
|
|
def stake_multiplier(
|
|
report,
|
|
setup,
|
|
dfs: Dict[Timeframe, pd.DataFrame],
|
|
primary_tf: Timeframe,
|
|
confirmation_tfs: Sequence[Timeframe],
|
|
artifact_dir=None,
|
|
) -> float:
|
|
"""Stake multiplier for a taken setup. Always ``1.0`` at this stage.
|
|
|
|
The guard order is already final — no artifact directory, a missing
|
|
artifact file, an artifact built for a different ``FEATURE_VERSION``,
|
|
or ANY exception all yield the neutral ``1.0``. Artifact evaluation
|
|
lands in a later step; until then this is wiring, not a decision.
|
|
"""
|
|
try:
|
|
if not artifact_dir:
|
|
return 1.0
|
|
artifact = load_artifact(artifact_path_for(primary_tf, artifact_dir))
|
|
if artifact is None:
|
|
return 1.0
|
|
if artifact.get("feature_version") != FEATURE_VERSION:
|
|
logger.debug(
|
|
"position_sizing: artifact feature_version %r != %d — neutral",
|
|
artifact.get("feature_version"), FEATURE_VERSION)
|
|
return 1.0
|
|
# Step 1: evaluation not implemented yet.
|
|
return 1.0
|
|
except Exception:
|
|
logger.debug("position_sizing.stake_multiplier failed", exc_info=True)
|
|
return 1.0
|