research: 3-year stake-sizing campaign + violent-move studies (nothing changes signals)
Walk-forward "second opinion" stake multiplier (meta-labelling on 65 decision-time features, 3yr Oanda books @ $0.65) FAILED the pre-registered rule on M5 (clean null) and M15 (lift inside the label-shuffle null, explained by BUY-vs-SELL). Nothing that changes numbers ships; the 12-month M5 book re-run is byte-identical to the pinned one. Behaviour-neutral plumbing: - position_sizing.build_features (65 features; multiplier path returns 1.0, no artifact) - SignalReport.penalty_tags / penalty_total / momentum_fading (record-keeping) - TradeSetup.stake_multiplier = None (reserved) - gpu_backtest --export-features (default export stays the pinned 63 columns; extras forced to the tail even when the first trade lacks an indicator) Research tools (backtest/research): book_report, burst_map, burst_precursors, open_trade_bursts, calendar_impact, burst_indicator_map, sizing_model, watch_rule_eval. Findings recorded in backtest/research/sizing_campaign_2026-09.md: M5 stretch measures are positive in both halves; every open-trade protection against violent bars loses on both TFs; high-impact USD releases cause 20-31% of violent bars but trades held through them do better; violent bars come when price is already at an extreme, not from calm. Module 29 tests (13). Suite: 435 passed. Claude-Session: https://claude.ai/code/session_01RWV2yLt3YXHHGVYHSQU2EE
This commit is contained in:
@@ -0,0 +1,622 @@
|
||||
"""
|
||||
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 (``LIVE_DEPTH_BARS``). 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, Iterable, 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
|
||||
|
||||
#: Bars each timeframe actually holds in LIVE mode. Any feature whose
|
||||
#: lookback exceeds its timeframe's depth here would be unreproducible
|
||||
#: live, so the windows below stay far inside these numbers.
|
||||
LIVE_DEPTH_BARS: Dict[Timeframe, int] = {
|
||||
Timeframe.M5: 5000,
|
||||
Timeframe.M15: 5000,
|
||||
Timeframe.H1: 1460,
|
||||
Timeframe.H4: 380,
|
||||
Timeframe.D1: 300,
|
||||
}
|
||||
|
||||
#: 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, atr_ok: bool, 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, atr_ok, 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
|
||||
Reference in New Issue
Block a user