Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 887aaed6f2 | |||
| 7e8ed754ff | |||
| 9769a0c84d | |||
| 964239e67d | |||
| 5e3758bd53 | |||
| c146fffb5b | |||
| 38c81251e8 | |||
| a7c2bfc7da | |||
| 7be2231eb3 | |||
| 0b604f1ad0 | |||
| 250078c987 | |||
| d1d002a4f7 | |||
| edefd90aaa | |||
| b21c75a1f8 | |||
| a2f951f5c9 | |||
| 858c12280a | |||
| 525a1cd1c1 | |||
| b693f757f8 | |||
| 7dbcbc6c82 | |||
| 5b3f47f3e1 | |||
| 99eb268cf3 | |||
| 18e4fd0403 | |||
| ebb83749ef | |||
| 8acc1e18f8 | |||
| 28a1c375bd | |||
| 775330ff16 | |||
| 1f54452007 | |||
| be5dd4a25b | |||
| 0445a20a76 | |||
| 4e654b5edb | |||
| 9e1c321f05 | |||
| 6fff06ff90 | |||
| f7d8f1113c | |||
| bcda6e0ebd | |||
| 042b079861 | |||
| dc7beb2877 | |||
| 637c39e201 | |||
| 083a4e8d63 | |||
| f760e9070a | |||
| 9a384935bf | |||
| d669227ddc | |||
| c3329d6cd5 | |||
| 7a810c4a4c | |||
| 3a95a37a0c | |||
| f814485615 | |||
| 65a1debc4e | |||
| 1499bb7cf2 | |||
| 902122dbfe | |||
| f0f9dcb169 | |||
| c8a2c83820 | |||
| 2d3e524f1c | |||
| f017f5aef5 | |||
| a56a622846 | |||
| b9633389fa | |||
| 6e2fdbd684 | |||
| 80a2b89769 | |||
| 1610507d57 | |||
| 1dab14d298 | |||
| cbc01565a2 | |||
| 53b65e1bbb | |||
| 1a141abc7c | |||
| 096b69686d | |||
| c21c102536 | |||
| c87bb14830 | |||
| a3279e54fa | |||
| 1bce289132 | |||
| f3d3e56aa8 | |||
| 14448c10e8 | |||
| 665ffdda48 | |||
| fe7d4c5d4a | |||
| 4d54fddf80 | |||
| aaf1d37b14 | |||
| 18b5ef27d2 | |||
| 26f0681ad1 | |||
| dde41fbeb8 | |||
| 6f69b9dd90 | |||
| a2bf80054c | |||
| 60fb526fab | |||
| 18abff85a3 | |||
| 183f148bb4 | |||
| 528a308d36 | |||
| 9c72e90ce2 | |||
| 8b086f4f49 | |||
| f02f4b4d37 | |||
| 74829541d6 | |||
| 6546b89d6b | |||
| 9ed07d4b65 | |||
| 0da5ed03af | |||
| db50bf02d7 | |||
| 1bc48915bd | |||
| 9d3729ea73 | |||
| f3b90d3862 | |||
| ea306df484 | |||
| 1038a53bac | |||
| 305f1d1857 | |||
| 0393087168 | |||
| 8687f98b39 | |||
| 2291fc3936 | |||
| 3a85c6e23b | |||
| 09ee5208d4 | |||
| 1f076c0b54 | |||
| baeee4997a | |||
| bb40bceb4c | |||
| c46bb85012 | |||
| cc53448c34 | |||
| 15a7074574 | |||
| 35e9b3750d | |||
| 68cc5b28a5 | |||
| 5c0adfab27 | |||
| b7bfd9c830 | |||
| 04328eaeab | |||
| bb3c475f22 | |||
| 31ecdf4292 | |||
| 39c91c1aa4 | |||
| cd69980f1f | |||
| 67a9169eb3 | |||
| f99fcc60b9 | |||
| 4d3f05e2ba | |||
| 2deea1ed7f | |||
| 89030c96b6 | |||
| 59a18b8bc6 | |||
| 49260abaa2 | |||
| 4ce09c2f79 | |||
| 02eedcc875 | |||
| 8ead05432e | |||
| 90b7e0c54e | |||
| 058ef57d8f | |||
| cc66e65f2d | |||
| 873cf5487e | |||
| a708609f8b | |||
| 7ccd8c4b04 | |||
| 2e09237671 | |||
| a12084537d | |||
| a55aca21b1 | |||
| e57f4a1ecd | |||
| 17cb63e86e | |||
| 5a392a578d | |||
| be42736223 | |||
| 2e5d66db1b | |||
| fb1eb5b73f | |||
| 28a00b7da0 | |||
| 3e078cfe7d | |||
| ce76684a62 | |||
| 045f4248f3 | |||
| c04f451c0c | |||
| 0a374cfea7 |
@@ -37,5 +37,3 @@ backtest/__pycache__/
|
|||||||
logs/
|
logs/
|
||||||
backtest/research/commit_ladder_2026-08/
|
backtest/research/commit_ladder_2026-08/
|
||||||
backtest/research/llm_news_eval_2026-08/
|
backtest/research/llm_news_eval_2026-08/
|
||||||
|
|
||||||
backtest/research/sizing_2026-09/
|
|
||||||
|
|||||||
@@ -1,354 +0,0 @@
|
|||||||
"""Deterministic advisory trade outcomes from stored closed candles.
|
|
||||||
|
|
||||||
This module measures the hypothetical setup reviewed by the LLM panel. It
|
|
||||||
never changes a signal, panel verdict, order, or daemon state. The model uses
|
|
||||||
the same next-open reanchoring and close-only trailing rules as the backtester,
|
|
||||||
but its evidence is the cleaned mid-candle snapshots stored in signals.db.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import math
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
MODEL = "stored-closed-mid-next-open-trail-v1"
|
|
||||||
MAX_HOLD_BARS = 300
|
|
||||||
REFERENCE_COST = 0.30
|
|
||||||
REALISTIC_COST = 0.65
|
|
||||||
|
|
||||||
_TF_ALIASES = {
|
|
||||||
"M1": "1min", "1MIN": "1min", "1min": "1min",
|
|
||||||
"M5": "5min", "5MIN": "5min", "5min": "5min",
|
|
||||||
"M15": "15min", "15MIN": "15min", "15min": "15min",
|
|
||||||
"M30": "30min", "30MIN": "30min", "30min": "30min",
|
|
||||||
"H1": "1h", "1H": "1h", "1h": "1h",
|
|
||||||
"H4": "4h", "4H": "4h", "4h": "4h",
|
|
||||||
"D1": "1day", "1D": "1day", "1d": "1day",
|
|
||||||
"1DAY": "1day", "1day": "1day",
|
|
||||||
}
|
|
||||||
|
|
||||||
_TF_SECONDS = {
|
|
||||||
"1min": 60,
|
|
||||||
"5min": 5 * 60,
|
|
||||||
"15min": 15 * 60,
|
|
||||||
"30min": 30 * 60,
|
|
||||||
"1h": 60 * 60,
|
|
||||||
"4h": 4 * 60 * 60,
|
|
||||||
"1day": 24 * 60 * 60,
|
|
||||||
}
|
|
||||||
|
|
||||||
_NY_TZ = ZoneInfo("America/New_York")
|
|
||||||
|
|
||||||
|
|
||||||
def _is_forex_weekend(now: datetime) -> bool:
|
|
||||||
"""NY-anchored XAU weekend closure; test-locked to the signal copies."""
|
|
||||||
ny = now.astimezone(_NY_TZ)
|
|
||||||
weekday = ny.weekday()
|
|
||||||
if weekday == 4:
|
|
||||||
return ny.hour >= 17
|
|
||||||
if weekday == 5:
|
|
||||||
return True
|
|
||||||
if weekday == 6:
|
|
||||||
return ny.hour < 18
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_timeframe(value: Any) -> str:
|
|
||||||
text = str(getattr(value, "value", value) or "")
|
|
||||||
return _TF_ALIASES.get(text, _TF_ALIASES.get(text.upper(), text))
|
|
||||||
|
|
||||||
|
|
||||||
def timeframe_value(mapping: Any, timeframe: Any) -> Any:
|
|
||||||
"""Read a timeframe-keyed snapshot regardless of enum/storage alias."""
|
|
||||||
if not isinstance(mapping, dict):
|
|
||||||
return None
|
|
||||||
wanted = normalize_timeframe(timeframe)
|
|
||||||
for key, value in mapping.items():
|
|
||||||
if normalize_timeframe(key) == wanted:
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _finite(value: Any) -> Optional[float]:
|
|
||||||
try:
|
|
||||||
number = float(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
return number if math.isfinite(number) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _timestamp(value: Any) -> Optional[datetime]:
|
|
||||||
try:
|
|
||||||
return datetime.fromisoformat(str(value).replace(" ", "T"))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def initial_outcome(
|
|
||||||
setup: Optional[Dict[str, Any]],
|
|
||||||
indicators: Optional[Dict[str, Any]],
|
|
||||||
timeframe: Any,
|
|
||||||
) -> Tuple[str, Dict[str, Any]]:
|
|
||||||
"""Freeze the setup and deterministic evaluation policy."""
|
|
||||||
if not setup:
|
|
||||||
return "NOT_APPLICABLE", {
|
|
||||||
"model": MODEL,
|
|
||||||
"status": "NOT_APPLICABLE",
|
|
||||||
"reason": "panel row has no trade setup",
|
|
||||||
}
|
|
||||||
|
|
||||||
tf = normalize_timeframe(timeframe)
|
|
||||||
analysis = timeframe_value(indicators, tf)
|
|
||||||
signal_bar = analysis.get("timestamp") if isinstance(analysis, dict) else None
|
|
||||||
direction = str(setup.get("direction", ""))
|
|
||||||
entry = _finite(setup.get("entry"))
|
|
||||||
stop = _finite(setup.get("stop_loss"))
|
|
||||||
tp1 = _finite(setup.get("take_profit_1"))
|
|
||||||
arm = _finite(setup.get("trail_arm_r"))
|
|
||||||
distance = _finite(setup.get("trail_dist_r"))
|
|
||||||
risk = abs(entry - stop) if entry is not None and stop is not None else 0.0
|
|
||||||
if (direction not in ("BUY", "SELL") or not signal_bar
|
|
||||||
or _timestamp(signal_bar) is None or risk <= 0 or tp1 is None
|
|
||||||
or arm is None or distance is None or arm < 0 or distance < 0):
|
|
||||||
return "UNRESOLVABLE", {
|
|
||||||
"model": MODEL,
|
|
||||||
"status": "UNRESOLVABLE",
|
|
||||||
"reason": "invalid or incomplete setup/outcome seed",
|
|
||||||
}
|
|
||||||
|
|
||||||
state = {
|
|
||||||
"model": MODEL,
|
|
||||||
"status": "PENDING",
|
|
||||||
"timeframe": tf,
|
|
||||||
"signal_bar_at": str(signal_bar),
|
|
||||||
"setup": {
|
|
||||||
"direction": direction,
|
|
||||||
"reported_entry": entry,
|
|
||||||
"stop_distance": risk,
|
|
||||||
"tp1_distance": abs(tp1 - entry),
|
|
||||||
"strategy": setup.get("strategy"),
|
|
||||||
"setup_pattern": setup.get("setup_pattern"),
|
|
||||||
},
|
|
||||||
"policy": {
|
|
||||||
"entry": "next_primary_bar_open_reanchored",
|
|
||||||
"max_hold_bars": MAX_HOLD_BARS,
|
|
||||||
"trail_arm_r": arm,
|
|
||||||
"trail_dist_r": distance,
|
|
||||||
"tp1_is_advisory": arm > 0,
|
|
||||||
"reference_cost": REFERENCE_COST,
|
|
||||||
"realistic_cost": REALISTIC_COST,
|
|
||||||
},
|
|
||||||
"progress": {
|
|
||||||
"last_processed_bar": str(signal_bar),
|
|
||||||
"entry_at": None,
|
|
||||||
"entry_price": None,
|
|
||||||
"initial_stop": None,
|
|
||||||
"current_stop": None,
|
|
||||||
"peak": None,
|
|
||||||
"bars_held": 0,
|
|
||||||
"mfe_r": 0.0,
|
|
||||||
"mae_r": 0.0,
|
|
||||||
},
|
|
||||||
"result": None,
|
|
||||||
}
|
|
||||||
return "PENDING", state
|
|
||||||
|
|
||||||
|
|
||||||
def _valid_bars(bars: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
||||||
valid = []
|
|
||||||
for bar in bars:
|
|
||||||
stamp = _timestamp(bar.get("timestamp")) if isinstance(bar, dict) else None
|
|
||||||
values = [_finite(bar.get(key)) for key in ("open", "high", "low", "close")]
|
|
||||||
if stamp is None or any(value is None for value in values):
|
|
||||||
continue
|
|
||||||
valid.append({
|
|
||||||
"timestamp": str(bar["timestamp"]),
|
|
||||||
"_timestamp": stamp,
|
|
||||||
"open": values[0], "high": values[1],
|
|
||||||
"low": values[2], "close": values[3],
|
|
||||||
})
|
|
||||||
valid.sort(key=lambda item: item["_timestamp"])
|
|
||||||
return valid
|
|
||||||
|
|
||||||
|
|
||||||
def _has_continuity(
|
|
||||||
bars: List[Dict[str, Any]], last: datetime, timeframe: str
|
|
||||||
) -> bool:
|
|
||||||
"""Reject missing tradable bars while allowing scheduled XAU closures."""
|
|
||||||
seconds = _TF_SECONDS.get(normalize_timeframe(timeframe))
|
|
||||||
if seconds is None:
|
|
||||||
return False
|
|
||||||
step = timedelta(seconds=seconds)
|
|
||||||
previous = last
|
|
||||||
seen = {last}
|
|
||||||
for bar in bars:
|
|
||||||
stamp = bar["_timestamp"]
|
|
||||||
if stamp <= previous or stamp in seen:
|
|
||||||
continue
|
|
||||||
expected = previous + step
|
|
||||||
while expected < stamp:
|
|
||||||
ny = expected.astimezone(_NY_TZ)
|
|
||||||
daily_maintenance = ny.weekday() < 4 and ny.hour == 17
|
|
||||||
if not (_is_forex_weekend(expected) or daily_maintenance):
|
|
||||||
return False
|
|
||||||
expected += step
|
|
||||||
if expected != stamp:
|
|
||||||
return False
|
|
||||||
previous = stamp
|
|
||||||
seen.add(stamp)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _finish(
|
|
||||||
state: Dict[str, Any], reason: str, exit_at: str, exit_price: float
|
|
||||||
) -> None:
|
|
||||||
setup = state["setup"]
|
|
||||||
progress = state["progress"]
|
|
||||||
entry = float(progress["entry_price"])
|
|
||||||
risk = float(setup["stop_distance"])
|
|
||||||
pnl = ((exit_price - entry) if setup["direction"] == "BUY"
|
|
||||||
else (entry - exit_price))
|
|
||||||
gross_r = pnl / risk
|
|
||||||
state["status"] = "RESOLVED"
|
|
||||||
state["result"] = {
|
|
||||||
"exit_at": exit_at,
|
|
||||||
"exit_price": exit_price,
|
|
||||||
"exit_reason": reason,
|
|
||||||
"bars_held": progress["bars_held"],
|
|
||||||
"gross_r": gross_r,
|
|
||||||
"net_r_030": (pnl - REFERENCE_COST) / risk,
|
|
||||||
"net_r_065": (pnl - REALISTIC_COST) / risk,
|
|
||||||
"mfe_r": progress["mfe_r"],
|
|
||||||
"mae_r": progress["mae_r"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def advance_outcome(
|
|
||||||
state: Dict[str, Any], bars: Iterable[Dict[str, Any]]
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Advance one pending outcome with overlapping closed-bar snapshots."""
|
|
||||||
if state.get("status") != "PENDING":
|
|
||||||
return state
|
|
||||||
try:
|
|
||||||
progress = state["progress"]
|
|
||||||
setup = state["setup"]
|
|
||||||
policy = state["policy"]
|
|
||||||
last = _timestamp(progress["last_processed_bar"])
|
|
||||||
signal_bar = _timestamp(state["signal_bar_at"])
|
|
||||||
if last is None or signal_bar is None:
|
|
||||||
raise ValueError("invalid progress timestamp")
|
|
||||||
|
|
||||||
buy = setup["direction"] == "BUY"
|
|
||||||
risk = float(setup["stop_distance"])
|
|
||||||
trail_arm = float(policy["trail_arm_r"])
|
|
||||||
trail_distance = float(policy["trail_dist_r"])
|
|
||||||
|
|
||||||
valid_bars = _valid_bars(bars)
|
|
||||||
has_future = any(bar["_timestamp"] > last for bar in valid_bars)
|
|
||||||
has_overlap = any(bar["_timestamp"] == last for bar in valid_bars)
|
|
||||||
if has_future and not has_overlap:
|
|
||||||
state["status"] = "UNRESOLVABLE"
|
|
||||||
state["result"] = None
|
|
||||||
state["reason"] = "stored candle snapshots lost continuity"
|
|
||||||
return state
|
|
||||||
if has_future and not _has_continuity(valid_bars, last,
|
|
||||||
state["timeframe"]):
|
|
||||||
state["status"] = "UNRESOLVABLE"
|
|
||||||
state["result"] = None
|
|
||||||
state["reason"] = "stored candle snapshots contain an internal gap"
|
|
||||||
return state
|
|
||||||
|
|
||||||
for bar in valid_bars:
|
|
||||||
stamp = bar["_timestamp"]
|
|
||||||
if stamp <= signal_bar or stamp <= last:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if progress["entry_price"] is None:
|
|
||||||
entry = bar["open"]
|
|
||||||
initial_stop = entry - risk if buy else entry + risk
|
|
||||||
progress.update({
|
|
||||||
"entry_at": bar["timestamp"],
|
|
||||||
"entry_price": entry,
|
|
||||||
"initial_stop": initial_stop,
|
|
||||||
"current_stop": initial_stop,
|
|
||||||
"peak": entry,
|
|
||||||
})
|
|
||||||
|
|
||||||
entry = float(progress["entry_price"])
|
|
||||||
current_stop = float(progress["current_stop"])
|
|
||||||
initial_stop = float(progress["initial_stop"])
|
|
||||||
progress["bars_held"] += 1
|
|
||||||
progress["last_processed_bar"] = bar["timestamp"]
|
|
||||||
last = stamp
|
|
||||||
|
|
||||||
if buy:
|
|
||||||
mfe = (bar["high"] - entry) / risk
|
|
||||||
mae = (entry - bar["low"]) / risk
|
|
||||||
stop_hit = bar["low"] <= current_stop
|
|
||||||
else:
|
|
||||||
mfe = (entry - bar["low"]) / risk
|
|
||||||
mae = (bar["high"] - entry) / risk
|
|
||||||
stop_hit = bar["high"] >= current_stop
|
|
||||||
progress["mfe_r"] = max(float(progress["mfe_r"]), max(0.0, mfe))
|
|
||||||
progress["mae_r"] = max(float(progress["mae_r"]), max(0.0, mae))
|
|
||||||
|
|
||||||
if stop_hit:
|
|
||||||
exit_price = current_stop
|
|
||||||
if (buy and bar["open"] < current_stop) or \
|
|
||||||
(not buy and bar["open"] > current_stop):
|
|
||||||
exit_price = bar["open"]
|
|
||||||
reason = "SL" if current_stop == initial_stop else "TRAIL"
|
|
||||||
_finish(state, reason, bar["timestamp"], exit_price)
|
|
||||||
return state
|
|
||||||
|
|
||||||
if trail_arm <= 0:
|
|
||||||
target = entry + setup["tp1_distance"] if buy \
|
|
||||||
else entry - setup["tp1_distance"]
|
|
||||||
target_hit = bar["high"] >= target if buy else bar["low"] <= target
|
|
||||||
if target_hit:
|
|
||||||
exit_price = target
|
|
||||||
if (buy and bar["open"] > target) or \
|
|
||||||
(not buy and bar["open"] < target):
|
|
||||||
exit_price = bar["open"]
|
|
||||||
_finish(state, "TP1", bar["timestamp"], exit_price)
|
|
||||||
return state
|
|
||||||
else:
|
|
||||||
favorable = bar["high"] if buy else bar["low"]
|
|
||||||
peak = max(float(progress["peak"]), favorable) if buy \
|
|
||||||
else min(float(progress["peak"]), favorable)
|
|
||||||
progress["peak"] = peak
|
|
||||||
excursion = ((peak - entry) if buy else (entry - peak)) / risk
|
|
||||||
if excursion >= trail_arm:
|
|
||||||
level = peak - trail_distance * risk if buy \
|
|
||||||
else peak + trail_distance * risk
|
|
||||||
progress["current_stop"] = max(current_stop, level) if buy \
|
|
||||||
else min(current_stop, level)
|
|
||||||
|
|
||||||
if progress["bars_held"] >= int(policy["max_hold_bars"]):
|
|
||||||
_finish(state, "TIMEOUT", bar["timestamp"], bar["close"])
|
|
||||||
return state
|
|
||||||
return state
|
|
||||||
except Exception as exc:
|
|
||||||
# Still fail-safe (never raises into a scan), but no longer
|
|
||||||
# SILENT: a real defect here used to be indistinguishable from a
|
|
||||||
# legitimate weekend/downtime gap, discoverable only by grepping
|
|
||||||
# advisory_outcome_json. Callers persist the state either way.
|
|
||||||
logger.exception(
|
|
||||||
"advisory outcome evaluator failed for the %s outcome seeded "
|
|
||||||
"at %s — recorded UNRESOLVABLE",
|
|
||||||
state.get("timeframe"), state.get("signal_bar_at"))
|
|
||||||
state["status"] = "UNRESOLVABLE"
|
|
||||||
state["result"] = None
|
|
||||||
state["reason"] = f"outcome evaluator error: {type(exc).__name__}"
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_r(state: Dict[str, Any]) -> Optional[float]:
|
|
||||||
result = state.get("result")
|
|
||||||
value = result.get("net_r_065") if isinstance(result, dict) else None
|
|
||||||
return _finite(value)
|
|
||||||
+112
-591
@@ -7,13 +7,6 @@ SignalReport from their own angle, then a synthesizer chair weighs the
|
|||||||
takes and issues a consensus verdict (AGREE / NEUTRAL / OPPOSE with
|
takes and issues a consensus verdict (AGREE / NEUTRAL / OPPOSE with
|
||||||
confidence and a short rationale).
|
confidence and a short rationale).
|
||||||
|
|
||||||
Verdict source (2026-09-03, owner decision): all four personas always
|
|
||||||
run and are logged, but by default (``agent_panel.verdict_source =
|
|
||||||
"macro"``) the verdict shown to consumers is the macro persona's own
|
|
||||||
stance, and the synthesizer call is skipped (4 LLM calls per panel
|
|
||||||
instead of 5); ``"chair"`` restores the synthesizer + OPPOSE-guard path
|
|
||||||
below.
|
|
||||||
|
|
||||||
Two invariants, both load-bearing (CLAUDE.md rules 1 and 4):
|
Two invariants, both load-bearing (CLAUDE.md rules 1 and 4):
|
||||||
|
|
||||||
- ADVISORY / DISPLAY-ONLY. The verdict is written onto SignalReport
|
- ADVISORY / DISPLAY-ONLY. The verdict is written onto SignalReport
|
||||||
@@ -32,39 +25,17 @@ layers bound the spend on top of that cap:
|
|||||||
|
|
||||||
- ``max_panels_per_day`` (config, default 20) — a UTC-day budget of
|
- ``max_panels_per_day`` (config, default 20) — a UTC-day budget of
|
||||||
panel *attempts*, mirroring the client's request cap.
|
panel *attempts*, mirroring the client's request cap.
|
||||||
- A reuse TTL (default 3600 s) keyed on the complete reviewed evidence
|
- A reuse TTL (default 3600 s) keyed on (symbol, timeframe, direction):
|
||||||
payload, ignoring only the bar timestamp. Changed price, setup, news,
|
a persisting signal re-serves its cached verdict across bars instead
|
||||||
warnings, indicators, or candles always trigger a fresh review (subject
|
of re-spending. Without this, one sticky M5 dashboard signal would
|
||||||
to the daily budget); genuinely identical evidence can be re-served.
|
burn 5 calls per 5-minute bar — 1,440/day against the 500 cap.
|
||||||
|
|
||||||
Prompts are STATIC module constants (DeepSeek prefix-cache pricing —
|
Prompts are STATIC module constants (DeepSeek prefix-cache pricing —
|
||||||
see news_engine.py); all variable content travels in the user payload.
|
see news_engine.py); all variable content travels in the user payload.
|
||||||
|
|
||||||
Payload enrichment (2026-08-15/23, owner-approved, advisory-only): the
|
|
||||||
technical persona receives strategy/pattern context, raw indicator VALUES
|
|
||||||
(with a units legend in the prompt), and ATR-NORMALIZED candles per timeframe
|
|
||||||
(code does the arithmetic — raw prices are the model's weakest input;
|
|
||||||
see backtest/research/llm_news_eval_2026-08-06.md). Sized by config
|
|
||||||
``candle_bars_primary`` (default 20) / ``candle_bars_confirm``
|
|
||||||
(default 10); ``candles_enabled: false`` reverts to the pre-enrichment
|
|
||||||
payload. Prompts forbid outside/training-data knowledge and cap notes
|
|
||||||
at 15 words / rationales at 30 (output tokens are the pricey side).
|
|
||||||
|
|
||||||
Macro and contrarian receive only code-scored directional news; contrarian
|
|
||||||
also receives the timestamped closed-market snapshot, complete setup, and
|
|
||||||
visible indicators. Risk receives the stamped trailing policy and strategy
|
|
||||||
cost assumption, never the candle range mislabeled as a spread.
|
|
||||||
|
|
||||||
Verdict logging writes the verdict, creation time, cache provenance, payload
|
|
||||||
hash, and a pending deterministic advisory outcome onto the scan's signals.db
|
|
||||||
row via signal_log's rowid hand-off. No field is read by live behavior.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime as _dt
|
import datetime as _dt
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
@@ -73,7 +44,6 @@ from dataclasses import dataclass, field
|
|||||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from app_config import get_app_config
|
from app_config import get_app_config
|
||||||
from config import Timeframe
|
|
||||||
from llm_client import DEFAULT_TIMEOUT, get_llm_client
|
from llm_client import DEFAULT_TIMEOUT, get_llm_client
|
||||||
|
|
||||||
if TYPE_CHECKING: # type hints only — keep the coupling one-way
|
if TYPE_CHECKING: # type hints only — keep the coupling one-way
|
||||||
@@ -85,13 +55,6 @@ logger = logging.getLogger(__name__)
|
|||||||
DEFAULT_MIN_QUALITY = "B"
|
DEFAULT_MIN_QUALITY = "B"
|
||||||
DEFAULT_MAX_PANELS_PER_DAY = 20
|
DEFAULT_MAX_PANELS_PER_DAY = 20
|
||||||
DEFAULT_REUSE_TTL_SECONDS = 3600.0
|
DEFAULT_REUSE_TTL_SECONDS = 3600.0
|
||||||
DEFAULT_CANDLE_BARS_PRIMARY = 20
|
|
||||||
DEFAULT_CANDLE_BARS_CONFIRM = 10
|
|
||||||
DEFAULT_VERDICT_SOURCE = "macro"
|
|
||||||
|
|
||||||
# Maps a persona's stance straight onto the verdict vocabulary when the
|
|
||||||
# macro take stands in for the chair.
|
|
||||||
_STANCE_TO_VERDICT = {"support": "AGREE", "neutral": "NEUTRAL", "oppose": "OPPOSE"}
|
|
||||||
|
|
||||||
# Quality ranks for the min_quality gate. Deliberately a local copy —
|
# Quality ranks for the min_quality gate. Deliberately a local copy —
|
||||||
# telegram_bot's ranking is private and dashboard_publisher must not
|
# telegram_bot's ranking is private and dashboard_publisher must not
|
||||||
@@ -115,11 +78,9 @@ _TAKE_CONTRACT = """\
|
|||||||
Your review is advisory research on an already-generated signal; you are
|
Your review is advisory research on an already-generated signal; you are
|
||||||
one voice on a panel, not the decision maker.
|
one voice on a panel, not the decision maker.
|
||||||
Hard rules: use ONLY the data in the JSON you receive — do not invent
|
Hard rules: use ONLY the data in the JSON you receive — do not invent
|
||||||
numbers, events, or price levels. Your training data about gold, the
|
numbers, events, or price levels. If the evidence your role needs is
|
||||||
markets, and current events is stale and OFF-LIMITS: never use outside
|
thin or missing, answer "neutral" with confidence below 30, unless your
|
||||||
knowledge; if a fact is not in the JSON, treat it as unknown. If the
|
role's instructions say otherwise.
|
||||||
evidence your role needs is thin or missing, answer "neutral" with
|
|
||||||
confidence below 30, unless your role's instructions say otherwise.
|
|
||||||
Confidence scale (0-100): 80-100 strong one-sided evidence; 50-79 solid
|
Confidence scale (0-100): 80-100 strong one-sided evidence; 50-79 solid
|
||||||
case with some doubt; 30-49 mixed evidence; below 30 little basis to
|
case with some doubt; 30-49 mixed evidence; below 30 little basis to
|
||||||
judge.
|
judge.
|
||||||
@@ -130,7 +91,7 @@ not "H4"; describe what an indicator MEANS ("the price has climbed
|
|||||||
unusually fast"), never its name or number.
|
unusually fast"), never its name or number.
|
||||||
Reply with ONLY JSON:
|
Reply with ONLY JSON:
|
||||||
{"stance": "support"|"oppose"|"neutral", "confidence": <0-100>,
|
{"stance": "support"|"oppose"|"neutral", "confidence": <0-100>,
|
||||||
"note": "<max 15 words>"}
|
"note": "<max 25 words>"}
|
||||||
"stance" says whether YOUR analysis supports the proposed trade
|
"stance" says whether YOUR analysis supports the proposed trade
|
||||||
direction, opposes it, or is genuinely mixed; "confidence" is how sure
|
direction, opposes it, or is genuinely mixed; "confidence" is how sure
|
||||||
you are of your stance; "note" is your single most important point.
|
you are of your stance; "note" is your single most important point.
|
||||||
@@ -140,11 +101,9 @@ _MACRO_SYSTEM = """\
|
|||||||
You are the macro analyst on a gold (XAU/USD) trade-review panel. You
|
You are the macro analyst on a gold (XAU/USD) trade-review panel. You
|
||||||
receive JSON: a proposed trade brief ("brief": direction, timeframe,
|
receive JSON: a proposed trade brief ("brief": direction, timeframe,
|
||||||
quality, price, regime) plus the current fundamental picture — recent
|
quality, price, regime) plus the current fundamental picture — recent
|
||||||
directional "headlines", today's high-impact "events" (released: true
|
"headlines", today's high-impact "events" (released: true means the
|
||||||
means the number is out), and optional aggregate "sentiment" for gold.
|
number is out), a news "sentiment" score for gold, and an optional
|
||||||
Neutral and unrelated headlines were removed by code. Each remaining
|
"news_summary" blurb.
|
||||||
headline has a code-computed "gold_influence" and score; trust that
|
|
||||||
direction and do not reinterpret it from the wording alone.
|
|
||||||
Judge ONE thing: does the macro backdrop support the proposed direction?
|
Judge ONE thing: does the macro backdrop support the proposed direction?
|
||||||
Weigh released surprises over stale headlines; an imminent unreleased
|
Weigh released surprises over stale headlines; an imminent unreleased
|
||||||
high-impact event is a reason for lower confidence, not a direction.
|
high-impact event is a reason for lower confidence, not a direction.
|
||||||
@@ -166,32 +125,15 @@ and a soft jobs report -> {"stance": "support", "confidence": 75,
|
|||||||
_TECH_SYSTEM = """\
|
_TECH_SYSTEM = """\
|
||||||
You are the technical analyst on a gold (XAU/USD) trade-review panel.
|
You are the technical analyst on a gold (XAU/USD) trade-review panel.
|
||||||
You receive JSON: a proposed trade brief ("brief") plus the technical
|
You receive JSON: a proposed trade brief ("brief") plus the technical
|
||||||
evidence — the setup "strategy" and optional "setup_pattern",
|
evidence — per-"indicators" signal/confidence, multi-timeframe
|
||||||
per-"indicators" signal/confidence/value, multi-timeframe "mtf" signals
|
"mtf" signals and scores, "key_levels" versus the current price, and an
|
||||||
and scores, "key_levels" versus the current price, an "exhaustion"
|
"exhaustion" count of consecutive same-direction bars flagged by the
|
||||||
count of consecutive same-direction bars flagged by the engine, and
|
engine.
|
||||||
optionally "candles".
|
|
||||||
Indicator "value" units: rsi 0-100 (above 70 overbought, below 30
|
|
||||||
oversold); adx 0-100 (above 25 = strong trend); macd = histogram in
|
|
||||||
dollars (momentum); roc = percent; bollinger = position 0-1 (0 = lower
|
|
||||||
band, 1 = upper band, values may extend outside the bands); keltner =
|
|
||||||
dollar distance from the mid-line; ema_trend = -1 to +1 alignment score;
|
|
||||||
divergence and candlestick = pattern counts. RSI is interpreted in the
|
|
||||||
supplied market regime, not as an automatic reversal at 70/30.
|
|
||||||
"candles" is per timeframe: {"ref_close", "atr", "bars"} — bars are
|
|
||||||
oldest-first, each [open, high, low, close] expressed in ATR units
|
|
||||||
relative to ref_close: 0.0 = the latest close, +1.0 = one ATR above it,
|
|
||||||
-2.0 = two ATR below it. Read the SHAPE (drift, swings, wicks); never
|
|
||||||
quote these as prices.
|
|
||||||
Judge ONE thing: is the technical picture internally coherent for the
|
Judge ONE thing: is the technical picture internally coherent for the
|
||||||
proposed direction? Look for indicators fighting each other, the signal
|
proposed direction? Look for indicators fighting each other, the signal
|
||||||
fighting the regime (e.g. a BUY in trending_down), higher timeframes
|
fighting the regime (e.g. a BUY in trending_down), higher timeframes
|
||||||
disagreeing, price pressed into a key level, an exhausted move being
|
disagreeing, price pressed into a key level, or an exhausted move being
|
||||||
chased, or the candle path contradicting the indicator readings.
|
chased.
|
||||||
For XAU_CP, the named completed reversal/continuation pattern is the
|
|
||||||
setup trigger and may lead lagging indicators; indicator lag alone is
|
|
||||||
not sufficient reason to oppose it. Judge whether the pattern and the
|
|
||||||
remaining evidence are coherent together.
|
|
||||||
Stance rules:
|
Stance rules:
|
||||||
- support: the indicators, higher timeframes, and regime mostly agree
|
- support: the indicators, higher timeframes, and regime mostly agree
|
||||||
with the proposed direction and none of the conflicts above appear.
|
with the proposed direction and none of the conflicts above appear.
|
||||||
@@ -209,22 +151,19 @@ BUY and exhaustion 0 -> {"stance": "support", "confidence": 70,
|
|||||||
_RISK_SYSTEM = """\
|
_RISK_SYSTEM = """\
|
||||||
You are the risk manager on a gold (XAU/USD) trade-review panel. You
|
You are the risk manager on a gold (XAU/USD) trade-review panel. You
|
||||||
receive JSON: a proposed trade brief ("brief") plus the risk facts —
|
receive JSON: a proposed trade brief ("brief") plus the risk facts —
|
||||||
the trade "setup" (strategy, optional setup pattern, entry, stop,
|
the trade "setup" (entry, stop_loss, take_profit_1/2, risk_reward,
|
||||||
advisory targets, risk, strategy cost assumption, and trailing policy),
|
risk_dollars), the "volatility" regime, engine "warnings", the calendar
|
||||||
the "volatility" regime, engine "warnings", and calendar "event_risk".
|
"event_risk" level, and the assumed "spread".
|
||||||
When trailing.enabled is true, TP1/TP2 are reference levels rather than
|
|
||||||
hard exits: profit can run beyond them, and the stop starts ratcheting
|
|
||||||
after arm_r using distance_r. Never evaluate that setup as a static TP1
|
|
||||||
trade. "round_trip_cost" is a strategy assumption, not a broker or data-
|
|
||||||
provider quote.
|
|
||||||
Judge ONE thing: is this setup's risk acceptable as constructed? Check
|
Judge ONE thing: is this setup's risk acceptable as constructed? Check
|
||||||
stop distance versus volatility, cost as a fraction of risk, trailing
|
reward-to-risk adequacy, stop distance versus the volatility regime
|
||||||
geometry, and event landmines about to hit.
|
(a tight stop in extreme vol dies to noise; a huge stop in low vol is
|
||||||
|
oversized), spread as a fraction of the stop, and event landmines about
|
||||||
|
to hit.
|
||||||
Stance rules:
|
Stance rules:
|
||||||
- support: the trailing opportunity justifies the risk, the stop fits
|
- support: the likely reward clearly justifies the risk, the stop fits
|
||||||
the current volatility, and no event landmine is imminent.
|
the current volatility, and no event landmine is imminent.
|
||||||
- oppose: a stop/trailing policy mismatched to volatility, strategy cost
|
- oppose: reward too thin for the risk, a stop mismatched to the
|
||||||
eating a big share of the stop, an imminent
|
volatility, spread eating a big share of the stop, an imminent
|
||||||
high-impact event — or no setup at all (a missing setup means the
|
high-impact event — or no setup at all (a missing setup means the
|
||||||
signal graded too low for one; oppose unless the rest is
|
signal graded too low for one; oppose unless the rest is
|
||||||
exceptional).
|
exceptional).
|
||||||
@@ -232,21 +171,17 @@ Stance rules:
|
|||||||
attractive.
|
attractive.
|
||||||
Example: RR 1.4 with volatility extreme and a high-impact event due ->
|
Example: RR 1.4 with volatility extreme and a high-impact event due ->
|
||||||
{"stance": "oppose", "confidence": 65,
|
{"stance": "oppose", "confidence": 65,
|
||||||
"note": "The stop and trailing plan do not suit wild price swings before major news."}
|
"note": "The possible profit is small for such wild price swings, and big economic news is due soon."}
|
||||||
Example: trailing enabled with normal volatility, low cost versus risk,
|
Example: RR 2.4 with normal volatility and no events due ->
|
||||||
and no events due ->
|
|
||||||
{"stance": "support", "confidence": 70,
|
{"stance": "support", "confidence": 70,
|
||||||
"note": "The stop and trailing plan fit calm conditions, with low costs and no major news."}
|
"note": "The possible profit is more than double the possible loss, and conditions look calm."}
|
||||||
""" + _TAKE_CONTRACT
|
""" + _TAKE_CONTRACT
|
||||||
|
|
||||||
_CONTRARIAN_SYSTEM = """\
|
_CONTRARIAN_SYSTEM = """\
|
||||||
You are the contrarian on a gold (XAU/USD) trade-review panel — the
|
You are the contrarian on a gold (XAU/USD) trade-review panel — the
|
||||||
designated skeptic. You receive JSON: a proposed trade brief ("brief"),
|
designated skeptic. You receive JSON: a proposed trade brief ("brief"),
|
||||||
the timestamped latest-closed-bar "market_snapshot", the complete trade
|
the engine's own "bullish_reasons" and "bearish_reasons", its
|
||||||
"setup" and strategy context, visible primary-timeframe "indicators",
|
"warnings", and an optional "news_summary".
|
||||||
filtered directional "news", the engine's bullish/bearish reasons, and
|
|
||||||
its warnings. Neutral and unrelated news was removed by code; trust each
|
|
||||||
headline's code-computed gold_influence instead of reinterpreting it.
|
|
||||||
Your job: construct the strongest honest case AGAINST the proposed
|
Your job: construct the strongest honest case AGAINST the proposed
|
||||||
trade, then score how damaging that case is. Use "oppose" only when the
|
trade, then score how damaging that case is. Use "oppose" only when the
|
||||||
counter-case is genuinely strong, "neutral" when it is real but modest,
|
counter-case is genuinely strong, "neutral" when it is real but modest,
|
||||||
@@ -263,15 +198,9 @@ JSON: the proposed trade "direction" and the panel's "takes" — one per
|
|||||||
analyst ("persona", "stance", "confidence", "note").
|
analyst ("persona", "stance", "confidence", "note").
|
||||||
Weigh the takes into ONE consensus on whether to act on the signal:
|
Weigh the takes into ONE consensus on whether to act on the signal:
|
||||||
"AGREE" (the panel backs the trade), "OPPOSE" (the panel is against it),
|
"AGREE" (the panel backs the trade), "OPPOSE" (the panel is against it),
|
||||||
or "NEUTRAL" (genuinely split or low conviction). The risk manager and
|
or "NEUTRAL" (genuinely split or low conviction). Weigh a high-
|
||||||
contrarian have intentionally defensive roles; their objections are not
|
confidence oppose from any single analyst seriously — one strong veto
|
||||||
automatic vetoes: "OPPOSE" requires that the macro analyst opposes, or
|
can outweigh two lukewarm supports. Do not average blindly.
|
||||||
that the technical analyst opposes while the macro analyst is not
|
|
||||||
supportive. When only the defensive roles object, or the macro analyst
|
|
||||||
supports the trade, the most the panel can say is "NEUTRAL". Do not
|
|
||||||
count repeated versions
|
|
||||||
of the same warning as independent evidence. Weigh role relevance,
|
|
||||||
evidence quality, and confidence rather than averaging blindly.
|
|
||||||
Base the verdict and rationale ONLY on the takes given — do not add
|
Base the verdict and rationale ONLY on the takes given — do not add
|
||||||
outside analysis.
|
outside analysis.
|
||||||
Confidence scale (0-100): 80-100 the panel clearly leans one way; 50-79
|
Confidence scale (0-100): 80-100 the panel clearly leans one way; 50-79
|
||||||
@@ -284,7 +213,7 @@ possible profit is too small and the price has already climbed
|
|||||||
unusually fast. Only the news backdrop supports buying."
|
unusually fast. Only the news backdrop supports buying."
|
||||||
Reply with ONLY JSON:
|
Reply with ONLY JSON:
|
||||||
{"verdict": "AGREE"|"NEUTRAL"|"OPPOSE", "confidence": <0-100>,
|
{"verdict": "AGREE"|"NEUTRAL"|"OPPOSE", "confidence": <0-100>,
|
||||||
"rationale": "<max 30 words>"}
|
"rationale": "<max 40 words>"}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -305,22 +234,10 @@ class PanelVerdict:
|
|||||||
rationale: str
|
rationale: str
|
||||||
takes: List[PersonaTake] = field(default_factory=list)
|
takes: List[PersonaTake] = field(default_factory=list)
|
||||||
created_ts: float = 0.0
|
created_ts: float = 0.0
|
||||||
payload_hash: str = ""
|
|
||||||
source: str = "chair" # "macro" | "chair" — which path produced this
|
|
||||||
|
|
||||||
|
|
||||||
# ── Payload builders (pure functions; llm_client json-dumps sorted) ──
|
# ── Payload builders (pure functions; llm_client json-dumps sorted) ──
|
||||||
|
|
||||||
def _finite(x: Any) -> Optional[float]:
|
|
||||||
"""float(x) or None for non-numeric / non-finite (NaN/inf breaks the
|
|
||||||
strict-JSON wire format the client sends)."""
|
|
||||||
try:
|
|
||||||
f = float(x)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
return f if math.isfinite(f) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _direction(report: "SignalReport") -> Optional[str]:
|
def _direction(report: "SignalReport") -> Optional[str]:
|
||||||
"""BUY/SELL the panel should review; None = nothing to review."""
|
"""BUY/SELL the panel should review; None = nothing to review."""
|
||||||
if report.trade_setup is not None:
|
if report.trade_setup is not None:
|
||||||
@@ -348,190 +265,50 @@ def _brief(report: "SignalReport") -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _primary_indicator_payload(report: "SignalReport") -> Dict[str, Dict[str, Any]]:
|
|
||||||
indicators: Dict[str, Dict[str, Any]] = {}
|
|
||||||
analysis = report.primary_analysis
|
|
||||||
if analysis is None:
|
|
||||||
return indicators
|
|
||||||
for name, ind in analysis.indicators.items():
|
|
||||||
if name.startswith("_") or ind.weight == 0:
|
|
||||||
continue
|
|
||||||
entry: Dict[str, Any] = {
|
|
||||||
"signal": ind.signal.name,
|
|
||||||
"confidence": round(ind.confidence),
|
|
||||||
}
|
|
||||||
value = _finite(getattr(ind, "value", None))
|
|
||||||
if value is not None:
|
|
||||||
entry["value"] = round(value, 2)
|
|
||||||
indicators[name] = entry
|
|
||||||
return indicators
|
|
||||||
|
|
||||||
|
|
||||||
def _strategy_context(report: "SignalReport") -> Dict[str, Any]:
|
|
||||||
setup = report.trade_setup
|
|
||||||
code = getattr(setup, "strategy", None) if setup is not None else None
|
|
||||||
meanings = {
|
|
||||||
"XAU_ATR": "gold ATR setup produced by the scored technical signal",
|
|
||||||
"XAU_PB": "trend-continuation pullback to EMA21 or Keltner mid-line",
|
|
||||||
"XAU_CP": "completed classical chart-pattern entry",
|
|
||||||
"GENERIC": "swing and ATR setup produced by the scored technical signal",
|
|
||||||
}
|
|
||||||
out: Dict[str, Any] = {
|
|
||||||
"code": code or "NO_SETUP",
|
|
||||||
"meaning": meanings.get(code, "no executable trade setup was produced"),
|
|
||||||
}
|
|
||||||
pattern = getattr(setup, "setup_pattern", None) if setup is not None else None
|
|
||||||
if pattern:
|
|
||||||
out["setup_pattern"] = str(pattern)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _trade_setup_payload(report: "SignalReport") -> Optional[Dict[str, Any]]:
|
|
||||||
setup = report.trade_setup
|
|
||||||
if setup is None:
|
|
||||||
return None
|
|
||||||
entry = _finite(getattr(setup, "entry_price", None))
|
|
||||||
stop = _finite(getattr(setup, "stop_loss", None))
|
|
||||||
risk = abs(entry - stop) if entry is not None and stop is not None else None
|
|
||||||
cost = _finite(getattr(setup, "round_trip_cost", None))
|
|
||||||
arm = _finite(getattr(setup, "trail_arm_r", None))
|
|
||||||
distance = _finite(getattr(setup, "trail_dist_r", None))
|
|
||||||
out: Dict[str, Any] = {
|
|
||||||
"direction": getattr(setup, "direction", None),
|
|
||||||
"strategy": _strategy_context(report),
|
|
||||||
"entry": round(entry, 2) if entry is not None else None,
|
|
||||||
"stop_loss": round(stop, 2) if stop is not None else None,
|
|
||||||
"take_profit_1": round(float(setup.take_profit_1), 2),
|
|
||||||
"take_profit_2": round(float(setup.take_profit_2), 2),
|
|
||||||
"risk_reward_to_advisory_tp1": round(float(setup.risk_reward), 2),
|
|
||||||
"risk_price_distance": round(risk, 2) if risk is not None else None,
|
|
||||||
"round_trip_cost": round(cost, 2) if cost is not None else None,
|
|
||||||
"trailing": {
|
|
||||||
"enabled": bool(arm is not None and arm > 0),
|
|
||||||
"arm_r": round(arm, 2) if arm is not None else None,
|
|
||||||
"distance_r": round(distance, 2) if distance is not None else None,
|
|
||||||
"tp1_tp2_are_advisory": bool(arm is not None and arm > 0),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
analysis = report.primary_analysis
|
|
||||||
atr = _finite((analysis.key_levels or {}).get("atr")) \
|
|
||||||
if analysis is not None else None
|
|
||||||
if atr is not None and atr > 0 and risk is not None:
|
|
||||||
out["atr"] = round(atr, 2)
|
|
||||||
out["stop_distance_atr"] = round(risk / atr, 2)
|
|
||||||
if risk and cost is not None:
|
|
||||||
out["cost_to_risk_pct"] = round(100.0 * cost / risk, 2)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _market_snapshot(report: "SignalReport") -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"price": round(report.current_price, 2),
|
|
||||||
"bar_timestamp": report.timestamp,
|
|
||||||
"basis": "latest_closed_candle",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _headline_gold_score(headline: Any) -> Optional[float]:
|
|
||||||
"""Deterministic XAU/USD influence from an already-scored headline."""
|
|
||||||
by_currency = getattr(headline, "sentiment_by_currency", None) or {}
|
|
||||||
xau = _finite(by_currency.get("XAU"))
|
|
||||||
usd = _finite(by_currency.get("USD"))
|
|
||||||
if xau is not None or usd is not None:
|
|
||||||
score = (xau or 0.0) - (usd or 0.0)
|
|
||||||
else:
|
|
||||||
mentioned = set(getattr(headline, "currencies_mentioned", None) or [])
|
|
||||||
raw = _finite(getattr(headline, "sentiment_score", None))
|
|
||||||
if raw is None:
|
|
||||||
return None
|
|
||||||
if "XAU" in mentioned and "USD" in mentioned:
|
|
||||||
return None # pair direction is ambiguous without per-currency scores
|
|
||||||
if "XAU" in mentioned:
|
|
||||||
score = raw
|
|
||||||
elif "USD" in mentioned:
|
|
||||||
score = -raw
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
return max(-1.0, min(1.0, score)) if math.isfinite(score) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _directional_news_payload(
|
|
||||||
context: Optional["FundamentalContext"], limit: int = 10
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
if context is None:
|
|
||||||
return []
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for headline in context.news_headlines:
|
|
||||||
score = _headline_gold_score(headline)
|
|
||||||
if score is None or abs(score) < 0.1:
|
|
||||||
continue
|
|
||||||
timestamp = getattr(headline, "timestamp", None)
|
|
||||||
out.append({
|
|
||||||
"headline": str(getattr(headline, "headline", "")),
|
|
||||||
"published_at": (timestamp.isoformat()
|
|
||||||
if hasattr(timestamp, "isoformat") else str(timestamp or "")),
|
|
||||||
"gold_influence": "bullish" if score > 0 else "bearish",
|
|
||||||
"score": round(score, 3),
|
|
||||||
})
|
|
||||||
if len(out) >= limit:
|
|
||||||
break
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _aggregate_sentiment_payload(report: "SignalReport") -> Optional[Dict[str, Any]]:
|
|
||||||
score = _finite(report.news_sentiment)
|
|
||||||
if score is None or abs(score) < 0.1:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"gold_influence": "bullish" if score > 0 else "bearish",
|
|
||||||
"score": round(score, 3),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _macro_payload(report: "SignalReport",
|
def _macro_payload(report: "SignalReport",
|
||||||
context: Optional["FundamentalContext"]) -> Dict[str, Any]:
|
context: Optional["FundamentalContext"]) -> Dict[str, Any]:
|
||||||
|
headlines: List[str] = []
|
||||||
events: List[Dict[str, Any]] = []
|
events: List[Dict[str, Any]] = []
|
||||||
if context is not None:
|
if context is not None:
|
||||||
|
headlines = [h.headline for h in context.news_headlines[:10]]
|
||||||
events = [
|
events = [
|
||||||
{"event": e.event, "currency": e.currency,
|
{"event": e.event, "currency": e.currency,
|
||||||
"time": e.time.isoformat(), "actual": e.actual,
|
"actual": e.actual, "forecast": e.forecast,
|
||||||
"forecast": e.forecast, "previous": e.previous,
|
|
||||||
"surprise": e.surprise,
|
|
||||||
"minutes_until": round(e.minutes_until, 1),
|
|
||||||
"released": not e.is_upcoming}
|
"released": not e.is_upcoming}
|
||||||
for e in context.events_today if e.is_high_impact
|
for e in context.events_today if e.is_high_impact
|
||||||
][:8]
|
][:8]
|
||||||
payload: Dict[str, Any] = {
|
return {
|
||||||
"brief": _brief(report),
|
"brief": _brief(report),
|
||||||
"headlines": _directional_news_payload(context),
|
"headlines": headlines,
|
||||||
"events": events,
|
"events": events,
|
||||||
|
"sentiment": round(report.news_sentiment, 3),
|
||||||
|
"news_summary": report.news_summary,
|
||||||
}
|
}
|
||||||
sentiment = _aggregate_sentiment_payload(report)
|
|
||||||
if sentiment is not None:
|
|
||||||
payload["sentiment"] = sentiment
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _technical_payload(report: "SignalReport",
|
def _technical_payload(report: "SignalReport") -> Dict[str, Any]:
|
||||||
candles: Optional[Dict[str, Any]] = None
|
indicators: Dict[str, Dict[str, Any]] = {}
|
||||||
) -> Dict[str, Any]:
|
|
||||||
key_levels: Dict[str, float] = {}
|
key_levels: Dict[str, float] = {}
|
||||||
analysis = report.primary_analysis
|
analysis = report.primary_analysis
|
||||||
if analysis is not None:
|
if analysis is not None:
|
||||||
|
# Same visibility filter as main.report_to_dict: private series
|
||||||
|
# ride as _-prefixed pseudo-indicators, weight-0 = context-only.
|
||||||
|
indicators = {
|
||||||
|
name: {"signal": ind.signal.name,
|
||||||
|
"confidence": round(ind.confidence)}
|
||||||
|
for name, ind in analysis.indicators.items()
|
||||||
|
if not name.startswith("_") and ind.weight != 0
|
||||||
|
}
|
||||||
key_levels = {k: round(v, 2) for k, v in analysis.key_levels.items()}
|
key_levels = {k: round(v, 2) for k, v in analysis.key_levels.items()}
|
||||||
payload: Dict[str, Any] = {
|
return {
|
||||||
"brief": _brief(report),
|
"brief": _brief(report),
|
||||||
"strategy": _strategy_context(report),
|
"indicators": indicators,
|
||||||
"indicators": _primary_indicator_payload(report),
|
|
||||||
"mtf": {tf: {"signal": sig.name,
|
"mtf": {tf: {"signal": sig.name,
|
||||||
"score": round(report.mtf_scores.get(tf, 0.0), 1)}
|
"score": round(report.mtf_scores.get(tf, 0.0), 1)}
|
||||||
for tf, sig in report.mtf_signals.items()},
|
for tf, sig in report.mtf_signals.items()},
|
||||||
"key_levels": key_levels,
|
"key_levels": key_levels,
|
||||||
"exhaustion": report.exhaustion_count,
|
"exhaustion": report.exhaustion_count,
|
||||||
}
|
}
|
||||||
if candles:
|
|
||||||
payload["candles"] = candles
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _risk_payload(report: "SignalReport") -> Dict[str, Any]:
|
def _risk_payload(report: "SignalReport") -> Dict[str, Any]:
|
||||||
@@ -541,103 +318,32 @@ def _risk_payload(report: "SignalReport") -> Dict[str, Any]:
|
|||||||
"warnings": list(report.warnings),
|
"warnings": list(report.warnings),
|
||||||
"event_risk": report.event_risk,
|
"event_risk": report.event_risk,
|
||||||
"event_description": report.event_description,
|
"event_description": report.event_description,
|
||||||
|
"spread": round(report.spread_estimate, 2),
|
||||||
|
}
|
||||||
|
ts = report.trade_setup
|
||||||
|
if ts is not None:
|
||||||
|
payload["setup"] = {
|
||||||
|
"direction": ts.direction,
|
||||||
|
"entry": round(ts.entry_price, 2),
|
||||||
|
"stop_loss": round(ts.stop_loss, 2),
|
||||||
|
"take_profit_1": round(ts.take_profit_1, 2),
|
||||||
|
"take_profit_2": round(ts.take_profit_2, 2),
|
||||||
|
"risk_reward": round(ts.risk_reward, 2),
|
||||||
|
"risk_dollars": round(ts.risk_pips * ts.pip_value, 2),
|
||||||
}
|
}
|
||||||
setup = _trade_setup_payload(report)
|
|
||||||
if setup is not None:
|
|
||||||
payload["setup"] = setup
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _contrarian_payload(
|
def _contrarian_payload(report: "SignalReport") -> Dict[str, Any]:
|
||||||
report: "SignalReport", context: Optional["FundamentalContext"] = None
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
return {
|
return {
|
||||||
"brief": _brief(report),
|
"brief": _brief(report),
|
||||||
"market_snapshot": _market_snapshot(report),
|
|
||||||
"setup": _trade_setup_payload(report),
|
|
||||||
"indicators": _primary_indicator_payload(report),
|
|
||||||
"news": _directional_news_payload(context),
|
|
||||||
"bullish_reasons": report.bullish_reasons[:3],
|
"bullish_reasons": report.bullish_reasons[:3],
|
||||||
"bearish_reasons": report.bearish_reasons[:3],
|
"bearish_reasons": report.bearish_reasons[:3],
|
||||||
"warnings": list(report.warnings),
|
"warnings": list(report.warnings),
|
||||||
|
"news_summary": report.news_summary,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Candle context (ATR-normalized; technical persona only) ─────────
|
|
||||||
#
|
|
||||||
# Raw prices are the LLM's weakest input modality (measured in-repo:
|
|
||||||
# numeric-comparison misreads, backtest/research/llm_news_eval_2026-08-06.md),
|
|
||||||
# so code does the arithmetic and the model reads a normalized SHAPE:
|
|
||||||
# each bar's [o,h,l,c] as offsets from the latest close in ATR units.
|
|
||||||
# A panel-private provider singleton keeps the TTL cache warm across
|
|
||||||
# attaches; any failure degrades to "no candles", never a failed panel.
|
|
||||||
|
|
||||||
_TF_BY_VALUE = {tf.value: tf for tf in Timeframe}
|
|
||||||
|
|
||||||
# Patchable for tests (mirrors the llm_client._client stub pattern).
|
|
||||||
_dp: Any = None
|
|
||||||
|
|
||||||
|
|
||||||
def _panel_provider() -> Any:
|
|
||||||
global _dp
|
|
||||||
if _dp is None:
|
|
||||||
from config import PlatformConfig
|
|
||||||
from providers import make_data_provider
|
|
||||||
_dp = make_data_provider(PlatformConfig())
|
|
||||||
return _dp
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_candles(df: Any, atr: float, bars: int) -> Dict[str, Any]:
|
|
||||||
"""Last `bars` closed candles as ATR-unit offsets from the final
|
|
||||||
close: 0.0 = latest close, +1.0 = one ATR above it. Oldest-first."""
|
|
||||||
tail = df.tail(bars)
|
|
||||||
ref = float(tail["Close"].iloc[-1])
|
|
||||||
out = []
|
|
||||||
for _, row in tail.iterrows():
|
|
||||||
out.append([round((float(row["Open"]) - ref) / atr, 1),
|
|
||||||
round((float(row["High"]) - ref) / atr, 1),
|
|
||||||
round((float(row["Low"]) - ref) / atr, 1),
|
|
||||||
round((float(row["Close"]) - ref) / atr, 1)])
|
|
||||||
return {"ref_close": round(ref, 2), "atr": round(float(atr), 2),
|
|
||||||
"bars": out}
|
|
||||||
|
|
||||||
|
|
||||||
def _candle_context(report: "SignalReport",
|
|
||||||
settings: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Best-effort {"<tf>": _normalize_candles(...)} per analysis TF;
|
|
||||||
None = omit the key from the payload. NEVER raises."""
|
|
||||||
try:
|
|
||||||
if not settings.get("candles_enabled", True):
|
|
||||||
return None
|
|
||||||
confirmations = list(report.confirmation_analyses or [])
|
|
||||||
analyses = [report.primary_analysis] + confirmations
|
|
||||||
counts = [int(settings.get("candle_bars_primary",
|
|
||||||
DEFAULT_CANDLE_BARS_PRIMARY))] + [
|
|
||||||
int(settings.get("candle_bars_confirm",
|
|
||||||
DEFAULT_CANDLE_BARS_CONFIRM))
|
|
||||||
] * len(confirmations)
|
|
||||||
dp = _panel_provider()
|
|
||||||
out: Dict[str, Any] = {}
|
|
||||||
for analysis, n in zip(analyses, counts):
|
|
||||||
if analysis is None:
|
|
||||||
continue
|
|
||||||
atr = _finite(getattr(
|
|
||||||
(analysis.indicators or {}).get("atr"), "value", None))
|
|
||||||
if not atr or atr <= 0:
|
|
||||||
continue
|
|
||||||
tf = _TF_BY_VALUE.get(analysis.timeframe)
|
|
||||||
if tf is None:
|
|
||||||
continue
|
|
||||||
df = dp.fetch_ohlcv(report.symbol, tf)
|
|
||||||
if df is None or len(df) < 2:
|
|
||||||
continue
|
|
||||||
out[analysis.timeframe] = _normalize_candles(df, atr, n)
|
|
||||||
return out or None
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("agent panel: candle context unavailable: %s", e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ── Orchestration ────────────────────────────────────────────────────
|
# ── Orchestration ────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Panel-private pool: the 4 persona calls run concurrently WITHOUT
|
# Panel-private pool: the 4 persona calls run concurrently WITHOUT
|
||||||
@@ -677,69 +383,12 @@ def _ask_persona(persona: str, system: str,
|
|||||||
return _validate_take(persona, reply)
|
return _validate_take(persona, reply)
|
||||||
|
|
||||||
|
|
||||||
def _panel_jobs(
|
|
||||||
report: "SignalReport",
|
|
||||||
context: Optional["FundamentalContext"],
|
|
||||||
candles: Optional[Dict[str, Any]],
|
|
||||||
) -> Tuple[Tuple[str, str, Dict[str, Any]], ...]:
|
|
||||||
return (
|
|
||||||
("macro", _MACRO_SYSTEM, _macro_payload(report, context)),
|
|
||||||
("technical", _TECH_SYSTEM, _technical_payload(report, candles)),
|
|
||||||
("risk", _RISK_SYSTEM, _risk_payload(report)),
|
|
||||||
("contrarian", _CONTRARIAN_SYSTEM,
|
|
||||||
_contrarian_payload(report, context)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# NOTE (2026-09 audit): the TTL reuse key differs from the exact key
|
|
||||||
# only by the contrarian's bar timestamp, and every other block (price,
|
|
||||||
# ATR-normalized candles, indicator readings, drifting entry/stop) moves
|
|
||||||
# each closed bar — so TTL_REUSE never fired live (30/30 attaches MISS).
|
|
||||||
# That is the documented, test-locked decision ("changed evidence never
|
|
||||||
# inherits a direction-only cached verdict"); spend is bounded by
|
|
||||||
# max_panels_per_day instead. Relaxing the identity is an owner call.
|
|
||||||
|
|
||||||
|
|
||||||
def _panel_payload_hash(
|
|
||||||
jobs: Tuple[Tuple[str, str, Dict[str, Any]], ...],
|
|
||||||
ignore_bar_timestamp: bool = False,
|
|
||||||
) -> str:
|
|
||||||
client = get_llm_client()
|
|
||||||
personas = []
|
|
||||||
for persona, system, payload in jobs:
|
|
||||||
payload_copy = json.loads(json.dumps(payload, allow_nan=False))
|
|
||||||
if ignore_bar_timestamp and persona == "contrarian":
|
|
||||||
snapshot = payload_copy.get("market_snapshot")
|
|
||||||
if isinstance(snapshot, dict):
|
|
||||||
snapshot.pop("bar_timestamp", None)
|
|
||||||
personas.append({
|
|
||||||
"persona": persona, "system": system, "payload": payload_copy,
|
|
||||||
})
|
|
||||||
envelope = {
|
|
||||||
"contract": "agent-panel-input-v2",
|
|
||||||
"model": getattr(client, "_model", client.__class__.__name__),
|
|
||||||
"personas": personas,
|
|
||||||
"synthesizer_system": _SYNTH_SYSTEM,
|
|
||||||
}
|
|
||||||
raw = json.dumps(
|
|
||||||
envelope, sort_keys=True, separators=(",", ":"),
|
|
||||||
ensure_ascii=True, allow_nan=False,
|
|
||||||
).encode("utf-8")
|
|
||||||
return "sha256:" + hashlib.sha256(raw).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def run_panel(report: "SignalReport",
|
def run_panel(report: "SignalReport",
|
||||||
context: Optional["FundamentalContext"] = None,
|
context: Optional["FundamentalContext"] = None,
|
||||||
candles: Optional[Dict[str, Any]] = None,
|
|
||||||
jobs: Optional[Tuple[Tuple[str, str, Dict[str, Any]], ...]] = None,
|
|
||||||
verdict_source: str = DEFAULT_VERDICT_SOURCE,
|
|
||||||
) -> Optional[PanelVerdict]:
|
) -> Optional[PanelVerdict]:
|
||||||
"""Run 4 personas, then either take the macro persona's stance as the
|
"""Run 4 personas + synthesizer; None = no verdict (fail-safe).
|
||||||
verdict (``verdict_source="macro"``, default — the synthesizer is not
|
|
||||||
called) or weigh all four takes through the synthesizer chair
|
|
||||||
(``"chair"``); None = no verdict (fail-safe).
|
|
||||||
|
|
||||||
Requires ≥2 valid persona takes before spending a synthesizer
|
Requires ≥2 valid persona takes before spending the synthesizer
|
||||||
request; the synthesizer's reply is validated strictly and there is
|
request; the synthesizer's reply is validated strictly and there is
|
||||||
deliberately NO programmatic fallback verdict — a panel that could
|
deliberately NO programmatic fallback verdict — a panel that could
|
||||||
not really convene reports nothing rather than something invented.
|
not really convene reports nothing rather than something invented.
|
||||||
@@ -747,8 +396,12 @@ def run_panel(report: "SignalReport",
|
|||||||
direction = _direction(report)
|
direction = _direction(report)
|
||||||
if direction is None:
|
if direction is None:
|
||||||
return None
|
return None
|
||||||
jobs = jobs or _panel_jobs(report, context, candles)
|
jobs = (
|
||||||
payload_hash = _panel_payload_hash(jobs)
|
("macro", _MACRO_SYSTEM, _macro_payload(report, context)),
|
||||||
|
("technical", _TECH_SYSTEM, _technical_payload(report)),
|
||||||
|
("risk", _RISK_SYSTEM, _risk_payload(report)),
|
||||||
|
("contrarian", _CONTRARIAN_SYSTEM, _contrarian_payload(report)),
|
||||||
|
)
|
||||||
futures = [_pool.submit(_ask_persona, name, system, payload)
|
futures = [_pool.submit(_ask_persona, name, system, payload)
|
||||||
for name, system, payload in jobs]
|
for name, system, payload in jobs]
|
||||||
# The HTTP timeout inside the client bounds each call; the margin
|
# The HTTP timeout inside the client bounds each call; the margin
|
||||||
@@ -758,29 +411,12 @@ def run_panel(report: "SignalReport",
|
|||||||
done, _ = _fwait(futures, timeout=client_timeout + 8.0)
|
done, _ = _fwait(futures, timeout=client_timeout + 8.0)
|
||||||
takes = [f.result() for f in done if not f.exception()]
|
takes = [f.result() for f in done if not f.exception()]
|
||||||
takes = [t for t in takes if t is not None]
|
takes = [t for t in takes if t is not None]
|
||||||
if not takes:
|
|
||||||
logger.debug("agent panel: 0/4 persona takes — no verdict")
|
|
||||||
return None
|
|
||||||
order = {"macro": 0, "technical": 1, "risk": 2, "contrarian": 3}
|
|
||||||
takes.sort(key=lambda t: order.get(t.persona, 9))
|
|
||||||
if verdict_source == "macro":
|
|
||||||
macro = next((t for t in takes if t.persona == "macro"), None)
|
|
||||||
if macro is not None:
|
|
||||||
return PanelVerdict(
|
|
||||||
verdict=_STANCE_TO_VERDICT[macro.stance],
|
|
||||||
confidence=macro.confidence, rationale=macro.note[:240],
|
|
||||||
takes=takes, created_ts=time.time(),
|
|
||||||
payload_hash=payload_hash, source="macro")
|
|
||||||
# Macro take missing (timed out / invalid) — fall back to the chair.
|
|
||||||
# Quorum guards the CHAIR only: it weighs several voices, so one lone
|
|
||||||
# take is not worth a synthesizer request. The macro path above needs
|
|
||||||
# only its own take and must never be gated by it — that gate used to
|
|
||||||
# sit before the macro branch and silently dropped a good macro
|
|
||||||
# verdict whenever the other three personas were slow.
|
|
||||||
if len(takes) < 2:
|
if len(takes) < 2:
|
||||||
logger.debug("agent panel: %d/4 persona takes — skipping synthesizer",
|
logger.debug("agent panel: %d/4 persona takes — skipping synthesizer",
|
||||||
len(takes))
|
len(takes))
|
||||||
return None
|
return None
|
||||||
|
order = {"macro": 0, "technical": 1, "risk": 2, "contrarian": 3}
|
||||||
|
takes.sort(key=lambda t: order.get(t.persona, 9))
|
||||||
reply = get_llm_client().chat_json(_SYNTH_SYSTEM, {
|
reply = get_llm_client().chat_json(_SYNTH_SYSTEM, {
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
"takes": [{"persona": t.persona, "stance": t.stance,
|
"takes": [{"persona": t.persona, "stance": t.stance,
|
||||||
@@ -794,47 +430,9 @@ def run_panel(report: "SignalReport",
|
|||||||
if verdict not in _VALID_VERDICTS or confidence is None:
|
if verdict not in _VALID_VERDICTS or confidence is None:
|
||||||
return None
|
return None
|
||||||
rationale = str(reply.get("rationale", "")).strip()[:240]
|
rationale = str(reply.get("rationale", "")).strip()[:240]
|
||||||
verdict, confidence, rationale = _apply_oppose_guard(
|
|
||||||
verdict, confidence, rationale, takes)
|
|
||||||
return PanelVerdict(verdict=verdict, confidence=confidence,
|
return PanelVerdict(verdict=verdict, confidence=confidence,
|
||||||
rationale=rationale, takes=takes,
|
rationale=rationale, takes=takes,
|
||||||
created_ts=time.time(), payload_hash=payload_hash,
|
created_ts=time.time())
|
||||||
source="chair")
|
|
||||||
|
|
||||||
|
|
||||||
# Deterministic backstop for the chair (ADVISORY-ONLY, no gate reads it).
|
|
||||||
#
|
|
||||||
# Evidence (2026-09 signal-log audit, 133 live verdicts, 107 with a
|
|
||||||
# replayed outcome): the contrarian and risk personas opposed 117/133
|
|
||||||
# alerts each and the technical persona 88/133 — they object to the
|
|
||||||
# trend extension the engine already discounted — so the chair said
|
|
||||||
# OPPOSE 73% of the time while the OPPOSED trades made +30.1R (PF 2.6).
|
|
||||||
# The macro persona was the only informative voice: macro=support
|
|
||||||
# trades won 88% (+0.56R mean, n=69); macro=oppose won 25%; macro=neutral
|
|
||||||
# 38%. With this rule the OPPOSE bucket becomes the weakest one
|
|
||||||
# (n=29, +0.08R mean, 41% win) instead of the best. Small sample, one
|
|
||||||
# bullish episode — re-score with analyze_signal_log --stored-candles
|
|
||||||
# after more history before tightening further.
|
|
||||||
#
|
|
||||||
# Rule: OPPOSE stands only if the macro analyst opposes, or the technical
|
|
||||||
# analyst opposes while the macro analyst is not supportive. Otherwise
|
|
||||||
# the verdict is NEUTRAL (confidence capped below 50).
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_oppose_guard(verdict: str, confidence: float, rationale: str,
|
|
||||||
takes: List[PersonaTake]):
|
|
||||||
if verdict != "OPPOSE":
|
|
||||||
return verdict, confidence, rationale
|
|
||||||
stance = {t.persona: t.stance for t in takes}
|
|
||||||
macro = stance.get("macro")
|
|
||||||
technical = stance.get("technical")
|
|
||||||
if macro == "oppose":
|
|
||||||
return verdict, confidence, rationale
|
|
||||||
if technical == "oppose" and macro != "support":
|
|
||||||
return verdict, confidence, rationale
|
|
||||||
note = ("The big-picture view does not object, so the panel stays "
|
|
||||||
"neutral rather than against. ")
|
|
||||||
return "NEUTRAL", min(float(confidence), 49.0), (note + rationale)[:240]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Cache, budget, attach ────────────────────────────────────────────
|
# ── Cache, budget, attach ────────────────────────────────────────────
|
||||||
@@ -842,16 +440,13 @@ def _apply_oppose_guard(verdict: str, confidence: float, rationale: str,
|
|||||||
_state_lock = threading.Lock()
|
_state_lock = threading.Lock()
|
||||||
# Exact result per reviewed bar: repeated attaches on the same signal
|
# Exact result per reviewed bar: repeated attaches on the same signal
|
||||||
# (re-scan, multi-consumer) are free.
|
# (re-scan, multi-consumer) are free.
|
||||||
_cache: "OrderedDict[Tuple[str, str, str, str, str, str], PanelVerdict]" = OrderedDict()
|
_cache: "OrderedDict[Tuple[str, str, str, str], PanelVerdict]" = OrderedDict()
|
||||||
_CACHE_MAX = 32
|
_CACHE_MAX = 32
|
||||||
# Reuse only when the full prompt + evidence payload is byte-equivalent.
|
# Reuse layer: a persisting signal (same symbol/tf/direction, newer bar)
|
||||||
# Direction-only reuse served stale warnings/setups to materially changed
|
# re-serves its last verdict for reuse_ttl_seconds instead of re-spending
|
||||||
# signals; the daily panel budget remains the hard spend bound. Keyed by
|
# 5 requests per bar (see module docstring for the M5 dashboard math).
|
||||||
# (reuse_hash, verdict_source) so a macro-mode verdict is never served to
|
_last_by_dir: Dict[Tuple[str, str, str], PanelVerdict] = {}
|
||||||
# a chair-mode attach (and vice versa).
|
# UTC-day budget of panel attempts (mirrors llm_client._under_cap).
|
||||||
_last_by_payload: Dict[Tuple[str, str], PanelVerdict] = {}
|
|
||||||
_inflight: Dict[Any, threading.Event] = {}
|
|
||||||
# Per-process UTC-day budget of panel attempts (mirrors llm_client._under_cap).
|
|
||||||
_panel_day: Optional[_dt.date] = None
|
_panel_day: Optional[_dt.date] = None
|
||||||
_panel_count: int = 0
|
_panel_count: int = 0
|
||||||
_warned_budget = False
|
_warned_budget = False
|
||||||
@@ -862,10 +457,7 @@ def reset_panel_state() -> None:
|
|||||||
global _panel_day, _panel_count, _warned_budget
|
global _panel_day, _panel_count, _warned_budget
|
||||||
with _state_lock:
|
with _state_lock:
|
||||||
_cache.clear()
|
_cache.clear()
|
||||||
_last_by_payload.clear()
|
_last_by_dir.clear()
|
||||||
for event in _inflight.values():
|
|
||||||
event.set()
|
|
||||||
_inflight.clear()
|
|
||||||
_panel_day = None
|
_panel_day = None
|
||||||
_panel_count = 0
|
_panel_count = 0
|
||||||
_warned_budget = False
|
_warned_budget = False
|
||||||
@@ -901,34 +493,13 @@ def _under_panel_budget(cap: int) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _write_fields(
|
def _write_fields(report: "SignalReport", v: PanelVerdict) -> None:
|
||||||
report: "SignalReport", v: PanelVerdict, cache_status: str
|
|
||||||
) -> None:
|
|
||||||
report.panel_verdict = v.verdict
|
report.panel_verdict = v.verdict
|
||||||
report.panel_confidence = float(v.confidence)
|
report.panel_confidence = float(v.confidence)
|
||||||
report.panel_rationale = v.rationale
|
report.panel_rationale = v.rationale
|
||||||
report.panel_takes = [
|
report.panel_takes = [
|
||||||
{"persona": t.persona, "stance": t.stance,
|
{"persona": t.persona, "stance": t.stance,
|
||||||
"confidence": t.confidence, "note": t.note} for t in v.takes]
|
"confidence": t.confidence, "note": t.note} for t in v.takes]
|
||||||
report.panel_created_at = _dt.datetime.fromtimestamp(
|
|
||||||
v.created_ts, tz=_dt.timezone.utc).isoformat()
|
|
||||||
report.panel_cache_status = cache_status
|
|
||||||
report.panel_payload_hash = v.payload_hash
|
|
||||||
report.panel_verdict_source = v.source
|
|
||||||
|
|
||||||
|
|
||||||
def _log_panel_verdict(report: "SignalReport") -> None:
|
|
||||||
"""Persist the verdict onto the scan's signals.db row (fail-safe).
|
|
||||||
|
|
||||||
The row is inserted inside analyze_pair — BEFORE the panel attaches
|
|
||||||
post-hoc — so panel columns arrive via this update. A report that was
|
|
||||||
never logged (backtest replay, logging disabled) is a silent no-op.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from signal_log import log_panel_update
|
|
||||||
log_panel_update(report)
|
|
||||||
except Exception:
|
|
||||||
logger.debug("agent panel: verdict logging failed", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def attach_panel(report: "SignalReport",
|
def attach_panel(report: "SignalReport",
|
||||||
@@ -959,12 +530,31 @@ def attach_panel(report: "SignalReport",
|
|||||||
if (_QUALITY_RANK.get(report.signal_quality, -1)
|
if (_QUALITY_RANK.get(report.signal_quality, -1)
|
||||||
< _QUALITY_RANK.get(min_quality, 2)):
|
< _QUALITY_RANK.get(min_quality, 2)):
|
||||||
return False
|
return False
|
||||||
verdict_source = str(settings.get("verdict_source",
|
|
||||||
DEFAULT_VERDICT_SOURCE))
|
|
||||||
direction = _direction(report)
|
direction = _direction(report)
|
||||||
if direction is None:
|
if direction is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
analysis = report.primary_analysis
|
||||||
|
tf = analysis.timeframe if analysis else ""
|
||||||
|
exact_key = (report.symbol, tf, report.timestamp, direction)
|
||||||
|
dir_key = (report.symbol, tf, direction)
|
||||||
|
ttl = float(settings.get("reuse_ttl_seconds",
|
||||||
|
DEFAULT_REUSE_TTL_SECONDS))
|
||||||
|
with _state_lock:
|
||||||
|
hit = _cache.get(exact_key)
|
||||||
|
if hit is None:
|
||||||
|
prior = _last_by_dir.get(dir_key)
|
||||||
|
if prior is not None and time.time() - prior.created_ts < ttl:
|
||||||
|
hit = prior
|
||||||
|
if hit is not None:
|
||||||
|
_write_fields(report, hit)
|
||||||
|
return True
|
||||||
|
|
||||||
|
cap = int(settings.get("max_panels_per_day",
|
||||||
|
DEFAULT_MAX_PANELS_PER_DAY))
|
||||||
|
if not _under_panel_budget(cap):
|
||||||
|
return False
|
||||||
|
|
||||||
if context is None:
|
if context is None:
|
||||||
# Best-effort; the news engine's class-level 600 s caches make
|
# Best-effort; the news engine's class-level 600 s caches make
|
||||||
# this nearly free when warm in both bot and dashboard.
|
# this nearly free when warm in both bot and dashboard.
|
||||||
@@ -977,85 +567,16 @@ def attach_panel(report: "SignalReport",
|
|||||||
logger.debug("agent panel: news context unavailable: %s", e)
|
logger.debug("agent panel: news context unavailable: %s", e)
|
||||||
context = None
|
context = None
|
||||||
|
|
||||||
candles = _candle_context(report, settings)
|
verdict = run_panel(report, context)
|
||||||
jobs = _panel_jobs(report, context, candles)
|
|
||||||
payload_hash = _panel_payload_hash(jobs)
|
|
||||||
reuse_hash = _panel_payload_hash(jobs, ignore_bar_timestamp=True)
|
|
||||||
analysis = report.primary_analysis
|
|
||||||
tf = analysis.timeframe if analysis else ""
|
|
||||||
exact_key = (report.symbol, tf, report.timestamp, direction,
|
|
||||||
payload_hash, verdict_source)
|
|
||||||
reuse_key = (reuse_hash, verdict_source)
|
|
||||||
ttl = float(settings.get("reuse_ttl_seconds",
|
|
||||||
DEFAULT_REUSE_TTL_SECONDS))
|
|
||||||
inflight_key = reuse_key if ttl > 0 else payload_hash
|
|
||||||
hit = None
|
|
||||||
cache_status = ""
|
|
||||||
leader = False
|
|
||||||
event: Optional[threading.Event] = None
|
|
||||||
with _state_lock:
|
|
||||||
hit = _cache.get(exact_key)
|
|
||||||
if hit is not None:
|
|
||||||
cache_status = "EXACT_HIT"
|
|
||||||
elif ttl > 0:
|
|
||||||
prior = _last_by_payload.get(reuse_key)
|
|
||||||
if prior is not None and time.time() - prior.created_ts < ttl:
|
|
||||||
hit = prior
|
|
||||||
cache_status = "TTL_REUSE"
|
|
||||||
if hit is None:
|
|
||||||
event = _inflight.get(inflight_key)
|
|
||||||
if event is None:
|
|
||||||
event = threading.Event()
|
|
||||||
_inflight[inflight_key] = event
|
|
||||||
leader = True
|
|
||||||
if hit is not None:
|
|
||||||
_write_fields(report, hit, cache_status)
|
|
||||||
_log_panel_verdict(report)
|
|
||||||
return True
|
|
||||||
|
|
||||||
if not leader:
|
|
||||||
# A successful leader can spend one client timeout on personas
|
|
||||||
# and another on synthesis; followers must cover both phases.
|
|
||||||
client_timeout = getattr(
|
|
||||||
get_llm_client(), "_timeout", DEFAULT_TIMEOUT)
|
|
||||||
timeout = 2.0 * client_timeout + 12.0
|
|
||||||
if event is None or not event.wait(timeout=timeout):
|
|
||||||
return False
|
|
||||||
with _state_lock:
|
|
||||||
hit = _cache.get(exact_key)
|
|
||||||
cache_status = "EXACT_HIT"
|
|
||||||
if hit is None and ttl > 0:
|
|
||||||
hit = _last_by_payload.get(reuse_key)
|
|
||||||
cache_status = "TTL_REUSE"
|
|
||||||
if hit is None:
|
|
||||||
return False
|
|
||||||
_write_fields(report, hit, cache_status)
|
|
||||||
_log_panel_verdict(report)
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
cap = int(settings.get("max_panels_per_day",
|
|
||||||
DEFAULT_MAX_PANELS_PER_DAY))
|
|
||||||
if not _under_panel_budget(cap):
|
|
||||||
return False
|
|
||||||
|
|
||||||
verdict = run_panel(report, context, candles=candles, jobs=jobs,
|
|
||||||
verdict_source=verdict_source)
|
|
||||||
if verdict is None:
|
if verdict is None:
|
||||||
return False
|
return False
|
||||||
_write_fields(report, verdict, "MISS")
|
_write_fields(report, verdict)
|
||||||
with _state_lock:
|
with _state_lock:
|
||||||
_cache[exact_key] = verdict
|
_cache[exact_key] = verdict
|
||||||
while len(_cache) > _CACHE_MAX:
|
while len(_cache) > _CACHE_MAX:
|
||||||
_cache.popitem(last=False)
|
_cache.popitem(last=False)
|
||||||
_last_by_payload[reuse_key] = verdict
|
_last_by_dir[dir_key] = verdict
|
||||||
_log_panel_verdict(report)
|
|
||||||
return True
|
return True
|
||||||
finally:
|
|
||||||
with _state_lock:
|
|
||||||
current = _inflight.pop(inflight_key, None)
|
|
||||||
if current is not None:
|
|
||||||
current.set()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("agent panel: attach failed — signal unaffected")
|
logger.exception("agent panel: attach failed — signal unaffected")
|
||||||
return False
|
return False
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+23
-158
@@ -86,14 +86,10 @@ _VALID_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
|||||||
_SCANNABLE_TFS = ("M5", "M15", "H1", "H4")
|
_SCANNABLE_TFS = ("M5", "M15", "H1", "H4")
|
||||||
|
|
||||||
_KNOWN_TOP = {"telegram", "data_providers", "scanning", "pairs", "log_level",
|
_KNOWN_TOP = {"telegram", "data_providers", "scanning", "pairs", "log_level",
|
||||||
"oanda", "deepseek", "agent_panel", "market_outlook",
|
"oanda", "deepseek", "agent_panel"}
|
||||||
"violent_watch", "signal_log"}
|
|
||||||
_KNOWN_OANDA = {
|
_KNOWN_OANDA = {
|
||||||
# "account_id" is TOLERATED but ignored: it belonged to the auto-
|
"account_id", "api_token", "base_url", "lot_size",
|
||||||
# execution daemon removed 2026-09-03 (last commit 31002cb). The candle
|
"quality_gate", "max_hold_bars",
|
||||||
# provider needs only api_token + base_url; older config.json files
|
|
||||||
# still carry the key and must keep loading.
|
|
||||||
"account_id", "api_token", "base_url",
|
|
||||||
}
|
}
|
||||||
_KNOWN_DEEPSEEK = {
|
_KNOWN_DEEPSEEK = {
|
||||||
"api_key", "model", "enabled", "timeout_seconds", "daily_request_cap",
|
"api_key", "model", "enabled", "timeout_seconds", "daily_request_cap",
|
||||||
@@ -101,34 +97,11 @@ _KNOWN_DEEPSEEK = {
|
|||||||
_KNOWN_AGENT_PANEL = {
|
_KNOWN_AGENT_PANEL = {
|
||||||
"enabled", "telegram", "dashboard", "min_quality",
|
"enabled", "telegram", "dashboard", "min_quality",
|
||||||
"max_panels_per_day", "reuse_ttl_seconds",
|
"max_panels_per_day", "reuse_ttl_seconds",
|
||||||
"candles_enabled", "candle_bars_primary", "candle_bars_confirm",
|
|
||||||
"verdict_source",
|
|
||||||
}
|
}
|
||||||
_KNOWN_MARKET_OUTLOOK = {"enabled", "telegram", "max_per_day", "candles_bars"}
|
|
||||||
_KNOWN_VIOLENT_WATCH = {"enabled", "alert_line", "headsup",
|
|
||||||
"max_headsup_per_day", "headsup_min_level"}
|
|
||||||
_KNOWN_SIGNAL_LOG = {"enabled", "path", "log_blocked_scans"}
|
|
||||||
_KNOWN_TELEGRAM = {"token", "chat_id", "channel_id", "alert_min_quality"}
|
_KNOWN_TELEGRAM = {"token", "chat_id", "channel_id", "alert_min_quality"}
|
||||||
_KNOWN_SCANNING = {"pairs", "timeframes"}
|
_KNOWN_SCANNING = {"pairs", "timeframes"}
|
||||||
_KNOWN_PAIR_KEYS = {"blocked_hours_utc"}
|
_KNOWN_PAIR_KEYS = {"blocked_hours_utc"}
|
||||||
|
|
||||||
_MARKET_OUTLOOK_DEFAULTS = {
|
|
||||||
"enabled": False,
|
|
||||||
"telegram": False,
|
|
||||||
"max_per_day": 12,
|
|
||||||
"candles_bars": 20,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Violent-move watch (display-only heads-up) — dark by default.
|
|
||||||
_VIOLENT_WATCH_DEFAULTS = {
|
|
||||||
"enabled": False,
|
|
||||||
"alert_line": True,
|
|
||||||
"headsup": True,
|
|
||||||
"max_headsup_per_day": 12,
|
|
||||||
"headsup_min_level": "hot",
|
|
||||||
}
|
|
||||||
_VALID_WATCH_LEVELS = ("watch", "hot")
|
|
||||||
|
|
||||||
|
|
||||||
class AppConfigError(Exception):
|
class AppConfigError(Exception):
|
||||||
"""config.json is present but invalid — fail fast at boot."""
|
"""config.json is present but invalid — fail fast at boot."""
|
||||||
@@ -150,11 +123,6 @@ class AppConfig:
|
|||||||
oanda_settings: Dict[str, Any] = field(default_factory=dict)
|
oanda_settings: Dict[str, Any] = field(default_factory=dict)
|
||||||
deepseek_settings: Dict[str, Any] = field(default_factory=dict)
|
deepseek_settings: Dict[str, Any] = field(default_factory=dict)
|
||||||
agent_panel_settings: Dict[str, Any] = field(default_factory=dict)
|
agent_panel_settings: Dict[str, Any] = field(default_factory=dict)
|
||||||
market_outlook_settings: Dict[str, Any] = field(
|
|
||||||
default_factory=lambda: dict(_MARKET_OUTLOOK_DEFAULTS))
|
|
||||||
violent_watch_settings: Dict[str, Any] = field(
|
|
||||||
default_factory=lambda: dict(_VIOLENT_WATCH_DEFAULTS))
|
|
||||||
signal_log_settings: Dict[str, Any] = field(default_factory=dict)
|
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
|
|
||||||
|
|
||||||
@@ -274,6 +242,20 @@ def load_app_config(path: str = CONFIG_PATH) -> AppConfig:
|
|||||||
|
|
||||||
oanda = raw.get("oanda", {})
|
oanda = raw.get("oanda", {})
|
||||||
_check_keys(oanda, _KNOWN_OANDA, '"oanda"')
|
_check_keys(oanda, _KNOWN_OANDA, '"oanda"')
|
||||||
|
if "lot_size" in oanda:
|
||||||
|
ls = oanda["lot_size"]
|
||||||
|
if not isinstance(ls, int) or isinstance(ls, bool) or ls < 1:
|
||||||
|
raise AppConfigError(
|
||||||
|
"config.json: oanda.lot_size must be a positive integer")
|
||||||
|
if "quality_gate" in oanda and oanda["quality_gate"] not in _VALID_QUALITIES:
|
||||||
|
raise AppConfigError(
|
||||||
|
f'config.json: oanda.quality_gate "{oanda["quality_gate"]}" '
|
||||||
|
f"not in {_VALID_QUALITIES}")
|
||||||
|
if "max_hold_bars" in oanda:
|
||||||
|
mhb = oanda["max_hold_bars"]
|
||||||
|
if not isinstance(mhb, int) or isinstance(mhb, bool) or mhb < 1:
|
||||||
|
raise AppConfigError(
|
||||||
|
"config.json: oanda.max_hold_bars must be a positive integer")
|
||||||
app.oanda_settings = dict(oanda)
|
app.oanda_settings = dict(oanda)
|
||||||
|
|
||||||
deepseek = raw.get("deepseek", {})
|
deepseek = raw.get("deepseek", {})
|
||||||
@@ -322,73 +304,8 @@ def load_app_config(path: str = CONFIG_PATH) -> AppConfig:
|
|||||||
raise AppConfigError(
|
raise AppConfigError(
|
||||||
"config.json: agent_panel.reuse_ttl_seconds must be a "
|
"config.json: agent_panel.reuse_ttl_seconds must be a "
|
||||||
"non-negative number")
|
"non-negative number")
|
||||||
if "candles_enabled" in panel \
|
|
||||||
and not isinstance(panel["candles_enabled"], bool):
|
|
||||||
raise AppConfigError(
|
|
||||||
"config.json: agent_panel.candles_enabled must be true or false")
|
|
||||||
for key in ("candle_bars_primary", "candle_bars_confirm"):
|
|
||||||
if key in panel:
|
|
||||||
n = panel[key]
|
|
||||||
if isinstance(n, bool) or not isinstance(n, int) \
|
|
||||||
or not 1 <= n <= 100:
|
|
||||||
raise AppConfigError(
|
|
||||||
f"config.json: agent_panel.{key} must be an integer "
|
|
||||||
"between 1 and 100")
|
|
||||||
if "verdict_source" in panel and panel["verdict_source"] not in \
|
|
||||||
("macro", "chair"):
|
|
||||||
raise AppConfigError(
|
|
||||||
'config.json: agent_panel.verdict_source must be "macro" or '
|
|
||||||
'"chair"')
|
|
||||||
app.agent_panel_settings = dict(panel)
|
app.agent_panel_settings = dict(panel)
|
||||||
|
|
||||||
outlook = raw.get("market_outlook", {})
|
|
||||||
_check_keys(outlook, _KNOWN_MARKET_OUTLOOK, '"market_outlook"')
|
|
||||||
for flag in ("enabled", "telegram"):
|
|
||||||
if flag in outlook and not isinstance(outlook[flag], bool):
|
|
||||||
raise AppConfigError(
|
|
||||||
f"config.json: market_outlook.{flag} must be true or false")
|
|
||||||
for key in ("max_per_day", "candles_bars"):
|
|
||||||
if key in outlook:
|
|
||||||
value = outlook[key]
|
|
||||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
||||||
raise AppConfigError(
|
|
||||||
f"config.json: market_outlook.{key} must be a positive integer")
|
|
||||||
app.market_outlook_settings.update(outlook)
|
|
||||||
|
|
||||||
watch = raw.get("violent_watch", {})
|
|
||||||
_check_keys(watch, _KNOWN_VIOLENT_WATCH, '"violent_watch"')
|
|
||||||
for flag in ("enabled", "alert_line", "headsup"):
|
|
||||||
if flag in watch and not isinstance(watch[flag], bool):
|
|
||||||
raise AppConfigError(
|
|
||||||
f"config.json: violent_watch.{flag} must be true or false")
|
|
||||||
if "max_headsup_per_day" in watch:
|
|
||||||
cap = watch["max_headsup_per_day"]
|
|
||||||
if isinstance(cap, bool) or not isinstance(cap, int) or cap < 0:
|
|
||||||
raise AppConfigError(
|
|
||||||
"config.json: violent_watch.max_headsup_per_day must be a "
|
|
||||||
"non-negative integer")
|
|
||||||
if ("headsup_min_level" in watch
|
|
||||||
and watch["headsup_min_level"] not in _VALID_WATCH_LEVELS):
|
|
||||||
raise AppConfigError(
|
|
||||||
f'config.json: violent_watch.headsup_min_level '
|
|
||||||
f'"{watch["headsup_min_level"]}" not in {_VALID_WATCH_LEVELS}')
|
|
||||||
app.violent_watch_settings.update(watch)
|
|
||||||
|
|
||||||
slog = raw.get("signal_log", {})
|
|
||||||
_check_keys(slog, _KNOWN_SIGNAL_LOG, '"signal_log"')
|
|
||||||
if "enabled" in slog and not isinstance(slog["enabled"], bool):
|
|
||||||
raise AppConfigError(
|
|
||||||
"config.json: signal_log.enabled must be true or false")
|
|
||||||
if "path" in slog and (not isinstance(slog["path"], str)
|
|
||||||
or not slog["path"]):
|
|
||||||
raise AppConfigError(
|
|
||||||
"config.json: signal_log.path must be a non-empty string")
|
|
||||||
if ("log_blocked_scans" in slog
|
|
||||||
and not isinstance(slog["log_blocked_scans"], bool)):
|
|
||||||
raise AppConfigError(
|
|
||||||
"config.json: signal_log.log_blocked_scans must be true or false")
|
|
||||||
app.signal_log_settings = dict(slog)
|
|
||||||
|
|
||||||
level = raw.get("log_level", app.log_level)
|
level = raw.get("log_level", app.log_level)
|
||||||
if level not in _VALID_LOG_LEVELS:
|
if level not in _VALID_LOG_LEVELS:
|
||||||
raise AppConfigError(
|
raise AppConfigError(
|
||||||
@@ -462,6 +379,12 @@ def provider_api_key(
|
|||||||
|
|
||||||
# ── Oanda resolution helpers (env → config.json → default) ───────────
|
# ── Oanda resolution helpers (env → config.json → default) ───────────
|
||||||
|
|
||||||
|
def oanda_account_id(app: Optional[AppConfig] = None) -> str:
|
||||||
|
app = app or get_app_config()
|
||||||
|
return os.environ.get("OANDA_ACCOUNT_ID") or str(
|
||||||
|
app.oanda_settings.get("account_id", ""))
|
||||||
|
|
||||||
|
|
||||||
def oanda_api_token(app: Optional[AppConfig] = None) -> str:
|
def oanda_api_token(app: Optional[AppConfig] = None) -> str:
|
||||||
app = app or get_app_config()
|
app = app or get_app_config()
|
||||||
return os.environ.get("OANDA_API_TOKEN") or str(
|
return os.environ.get("OANDA_API_TOKEN") or str(
|
||||||
@@ -482,64 +405,6 @@ def deepseek_api_key(app: Optional[AppConfig] = None) -> str:
|
|||||||
app.deepseek_settings.get("api_key", ""))
|
app.deepseek_settings.get("api_key", ""))
|
||||||
|
|
||||||
|
|
||||||
# ── AI market-outlook resolution helper ──────────────────────────────
|
|
||||||
|
|
||||||
def market_outlook_enabled(app: Optional[AppConfig] = None) -> bool:
|
|
||||||
"""Whether the advisory outlook feature is enabled (env wins)."""
|
|
||||||
env = os.environ.get("MARKET_OUTLOOK_ENABLED")
|
|
||||||
if env:
|
|
||||||
return env.strip().lower() in ("1", "true", "yes", "on")
|
|
||||||
app = app or get_app_config()
|
|
||||||
return bool(app.market_outlook_settings.get("enabled", False))
|
|
||||||
|
|
||||||
|
|
||||||
# ── Violent-move watch resolution helper ─────────────────────────────
|
|
||||||
|
|
||||||
def violent_watch_enabled(app: Optional[AppConfig] = None) -> bool:
|
|
||||||
"""Whether the display-only violent-move watch is on (env wins)."""
|
|
||||||
env = os.environ.get("VIOLENT_WATCH_ENABLED")
|
|
||||||
if env:
|
|
||||||
return env.strip().lower() in ("1", "true", "yes", "on")
|
|
||||||
app = app or get_app_config()
|
|
||||||
return bool(app.violent_watch_settings.get("enabled", False))
|
|
||||||
|
|
||||||
|
|
||||||
# ── Signal-log resolution helpers (env → config.json → default) ──────
|
|
||||||
|
|
||||||
def signal_log_enabled(app: Optional[AppConfig] = None) -> bool:
|
|
||||||
"""Default ON — signal history is the platform's audit trail."""
|
|
||||||
env = os.environ.get("SIGNAL_LOG_ENABLED")
|
|
||||||
if env:
|
|
||||||
return env.strip().lower() in ("1", "true", "yes", "on")
|
|
||||||
app = app or get_app_config()
|
|
||||||
return bool(app.signal_log_settings.get("enabled", True))
|
|
||||||
|
|
||||||
|
|
||||||
def signal_log_blocked_scans(app: Optional[AppConfig] = None) -> bool:
|
|
||||||
"""Whether weekend/day-filter early returns are logged (as minimal
|
|
||||||
rows without news/candles). Default ON — they are liveness evidence;
|
|
||||||
turn off to keep the history table to real scans only."""
|
|
||||||
env = os.environ.get("SIGNAL_LOG_BLOCKED_SCANS")
|
|
||||||
if env:
|
|
||||||
return env.strip().lower() in ("1", "true", "yes", "on")
|
|
||||||
app = app or get_app_config()
|
|
||||||
return bool(app.signal_log_settings.get("log_blocked_scans", True))
|
|
||||||
|
|
||||||
|
|
||||||
def signal_log_path(app: Optional[AppConfig] = None) -> str:
|
|
||||||
"""Absolute path to the SQLite signal-history DB. A relative config
|
|
||||||
value resolves against the repo root so CWD never matters."""
|
|
||||||
env = os.environ.get("SIGNAL_LOG_PATH")
|
|
||||||
if env:
|
|
||||||
path = env
|
|
||||||
else:
|
|
||||||
app = app or get_app_config()
|
|
||||||
path = str(app.signal_log_settings.get("path", "logs/signals.db"))
|
|
||||||
if not os.path.isabs(path):
|
|
||||||
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), path)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
# ── PlatformConfig overlay ────────────────────────────────────────────
|
# ── PlatformConfig overlay ────────────────────────────────────────────
|
||||||
|
|
||||||
def apply_pair_overrides(
|
def apply_pair_overrides(
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ from config import ( # noqa: E402
|
|||||||
from providers import BaseDataProvider, make_data_provider # noqa: E402
|
from providers import BaseDataProvider, make_data_provider # noqa: E402
|
||||||
from engine import IndicatorSignal, TechnicalAnalysis, TechnicalEngine # noqa: E402
|
from engine import IndicatorSignal, TechnicalAnalysis, TechnicalEngine # noqa: E402
|
||||||
from signal_aggregator import SignalAggregator, SignalReport, TradeSetup # noqa: E402
|
from signal_aggregator import SignalAggregator, SignalReport, TradeSetup # noqa: E402
|
||||||
import position_sizing # noqa: E402
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
# GPU shim — CuPy if available AND CUDA runtime works, else NumPy.
|
# GPU shim — CuPy if available AND CUDA runtime works, else NumPy.
|
||||||
@@ -1075,13 +1074,6 @@ class Trade:
|
|||||||
indicators_neutral: int = 0
|
indicators_neutral: int = 0
|
||||||
indicators_total: int = 0 # total scored indicators
|
indicators_total: int = 0 # total scored indicators
|
||||||
|
|
||||||
# RESEARCH (--export-features only): decision-time feature vector
|
|
||||||
# (position_sizing.FEATURE_ORDER) and the stake multiplier the setup
|
|
||||||
# carried. Empty / 1.0 on every normal run, so the default book is
|
|
||||||
# untouched.
|
|
||||||
features: Dict[str, float] = field(default_factory=dict)
|
|
||||||
stake_multiplier: float = 1.0
|
|
||||||
|
|
||||||
# Filled on exit:
|
# Filled on exit:
|
||||||
exit_time: Optional[pd.Timestamp] = None
|
exit_time: Optional[pd.Timestamp] = None
|
||||||
exit_price: Optional[float] = None
|
exit_price: Optional[float] = None
|
||||||
@@ -1462,11 +1454,6 @@ class BacktestConfig:
|
|||||||
# afterwards in a fast ordered pass. Results are identical to the
|
# afterwards in a fast ordered pass. Results are identical to the
|
||||||
# sequential path by construction.
|
# sequential path by construction.
|
||||||
workers: int = 0
|
workers: int = 0
|
||||||
# RESEARCH: also compute position_sizing.build_features() at each
|
|
||||||
# entry and append them to --export-csv. Off by default — the extra
|
|
||||||
# columns would break comparison against the pinned 63-column books,
|
|
||||||
# and the feature build costs time per candidate.
|
|
||||||
export_features: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
def _is_xau_blocked_by_calendar(ts: pd.Timestamp, cfg: PlatformConfig) -> Optional[str]:
|
def _is_xau_blocked_by_calendar(ts: pd.Timestamp, cfg: PlatformConfig) -> Optional[str]:
|
||||||
@@ -1623,17 +1610,6 @@ def _replay_span(span: Tuple[int, int]):
|
|||||||
adj_tp1 = entry_price - tp1_distance
|
adj_tp1 = entry_price - tp1_distance
|
||||||
adj_tp2 = entry_price - tp2_distance
|
adj_tp2 = entry_price - tp2_distance
|
||||||
|
|
||||||
# RESEARCH: decision-time features (same frames the aggregator
|
|
||||||
# just saw, so live == backtest by construction). Deliberately
|
|
||||||
# NOT wrapped in try/except — build_features already swallows its
|
|
||||||
# own failures, so anything escaping here is a real bug.
|
|
||||||
feats: Dict[str, float] = {}
|
|
||||||
if bt_cfg.export_features:
|
|
||||||
feats = position_sizing.build_features(
|
|
||||||
report, ts_setup, slice_dfs, bt_cfg.primary_tf,
|
|
||||||
list(bt_cfg.confirmation_tfs),
|
|
||||||
)
|
|
||||||
|
|
||||||
candidates.append((pos, Trade(
|
candidates.append((pos, Trade(
|
||||||
pair=bt_cfg.pair,
|
pair=bt_cfg.pair,
|
||||||
entry_time=entry_time,
|
entry_time=entry_time,
|
||||||
@@ -1659,10 +1635,6 @@ def _replay_span(span: Tuple[int, int]):
|
|||||||
indicators_opposing=oppose,
|
indicators_opposing=oppose,
|
||||||
indicators_neutral=neutral,
|
indicators_neutral=neutral,
|
||||||
indicators_total=total,
|
indicators_total=total,
|
||||||
features=feats,
|
|
||||||
stake_multiplier=float(ts_setup.stake_multiplier
|
|
||||||
if ts_setup.stake_multiplier is not None
|
|
||||||
else 1.0),
|
|
||||||
trail_arm_r=(platform_cfg.signals.pb_trail_arm_r
|
trail_arm_r=(platform_cfg.signals.pb_trail_arm_r
|
||||||
if tag == "XAU_PB" else 0.0),
|
if tag == "XAU_PB" else 0.0),
|
||||||
trail_dist_r=(platform_cfg.signals.pb_trail_dist_r
|
trail_dist_r=(platform_cfg.signals.pb_trail_dist_r
|
||||||
@@ -2185,17 +2157,11 @@ def print_trade_reasoning(trades: List[Trade], limit: int = 5) -> None:
|
|||||||
print(f" MFE / MAE: {t.mfe_r:.2f}R / {t.mae_r:.2f}R")
|
print(f" MFE / MAE: {t.mfe_r:.2f}R / {t.mae_r:.2f}R")
|
||||||
|
|
||||||
|
|
||||||
def trades_to_dataframe(trades: List[Trade],
|
def trades_to_dataframe(trades: List[Trade]) -> pd.DataFrame:
|
||||||
include_features: bool = False) -> pd.DataFrame:
|
|
||||||
"""Export trades for downstream analysis (e.g. parquet/CSV).
|
"""Export trades for downstream analysis (e.g. parquet/CSV).
|
||||||
|
|
||||||
Includes per-instructions: entry reasoning, indicator-by-indicator
|
Includes per-instructions: entry reasoning, indicator-by-indicator
|
||||||
signal/value/agreement, and overall agreement count.
|
signal/value/agreement, and overall agreement count.
|
||||||
|
|
||||||
`include_features` (research, --export-features) appends
|
|
||||||
stake_multiplier, r_sized and the position_sizing.FEATURE_ORDER
|
|
||||||
columns AFTER the existing ones. Default off so the column set stays
|
|
||||||
byte-identical to the pinned books.
|
|
||||||
"""
|
"""
|
||||||
rows = []
|
rows = []
|
||||||
for t in trades:
|
for t in trades:
|
||||||
@@ -2234,22 +2200,8 @@ def trades_to_dataframe(trades: List[Trade],
|
|||||||
row[f"ind_{name}_value"] = b["value"]
|
row[f"ind_{name}_value"] = b["value"]
|
||||||
row[f"ind_{name}_conf"] = b["confidence"]
|
row[f"ind_{name}_conf"] = b["confidence"]
|
||||||
row[f"ind_{name}_agrees"] = b["agrees"]
|
row[f"ind_{name}_agrees"] = b["agrees"]
|
||||||
if include_features:
|
|
||||||
row["stake_multiplier"] = t.stake_multiplier
|
|
||||||
row["r_sized"] = t.r_multiple * t.stake_multiplier
|
|
||||||
for name in position_sizing.FEATURE_ORDER:
|
|
||||||
row[name] = t.features.get(name, float("nan"))
|
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
df = pd.DataFrame(rows)
|
return pd.DataFrame(rows)
|
||||||
if include_features and len(df.columns):
|
|
||||||
# A later trade can introduce an indicator column the first trade
|
|
||||||
# lacked (pandas orders columns by first appearance), which would
|
|
||||||
# land AFTER the extras. Force the extras to the tail so the default
|
|
||||||
# export is always a strict prefix of the feature export.
|
|
||||||
extras = ["stake_multiplier", "r_sized", *position_sizing.FEATURE_ORDER]
|
|
||||||
base_cols = [c for c in df.columns if c not in extras]
|
|
||||||
df = df[base_cols + [c for c in extras if c in df.columns]]
|
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
# ═════════════════════════════════════════════════════════════════════
|
# ═════════════════════════════════════════════════════════════════════
|
||||||
@@ -2311,11 +2263,6 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
|
|||||||
help="Round-trip cost in price units per trade (default: pair's "
|
help="Round-trip cost in price units per trade (default: pair's "
|
||||||
"default_spread from PAIR_INFO — XAU $0.30; 0 disables)")
|
"default_spread from PAIR_INFO — XAU $0.30; 0 disables)")
|
||||||
p.add_argument("--export-csv", help="Optional path to dump per-trade CSV (incl. indicator breakdown)")
|
p.add_argument("--export-csv", help="Optional path to dump per-trade CSV (incl. indicator breakdown)")
|
||||||
p.add_argument("--export-features", action="store_true",
|
|
||||||
help="Append the 65 decision-time f_* feature columns "
|
|
||||||
"(+ stake_multiplier, r_sized) to --export-csv; "
|
|
||||||
"research flag, default off so pinned 63-column "
|
|
||||||
"books stay comparable")
|
|
||||||
p.add_argument("--detail", action="store_true",
|
p.add_argument("--detail", action="store_true",
|
||||||
help="Print full per-trade reasoning (indicators, signals, agreement, why entered)")
|
help="Print full per-trade reasoning (indicators, signals, agreement, why entered)")
|
||||||
p.add_argument("--workers", type=int, default=0,
|
p.add_argument("--workers", type=int, default=0,
|
||||||
@@ -2469,13 +2416,6 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
spread=spread,
|
spread=spread,
|
||||||
workers=args.workers,
|
workers=args.workers,
|
||||||
fixed_rr_tp=args.fixed_rr_tp or 0.0,
|
fixed_rr_tp=args.fixed_rr_tp or 0.0,
|
||||||
export_features=args.export_features,
|
|
||||||
)
|
|
||||||
if args.export_features:
|
|
||||||
logger.info(
|
|
||||||
f"--export-features ON: appending stake_multiplier, r_sized and "
|
|
||||||
f"{len(position_sizing.FEATURE_ORDER)} f_* feature columns to the "
|
|
||||||
f"CSV export (research flag; book itself is unchanged)"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -2490,7 +2430,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
print_trade_reasoning(trades, limit=args.reasoning_samples)
|
print_trade_reasoning(trades, limit=args.reasoning_samples)
|
||||||
|
|
||||||
if args.export_csv:
|
if args.export_csv:
|
||||||
df = trades_to_dataframe(trades, include_features=args.export_features)
|
df = trades_to_dataframe(trades)
|
||||||
df.to_csv(args.export_csv, index=False)
|
df.to_csv(args.export_csv, index=False)
|
||||||
logger.info(f"Exported {len(df)} trades → {args.export_csv}")
|
logger.info(f"Exported {len(df)} trades → {args.export_csv}")
|
||||||
|
|
||||||
|
|||||||
@@ -57,13 +57,3 @@ Win rate over rolling 20-trade windows (check for edge decay)
|
|||||||
Critical extra
|
Critical extra
|
||||||
"Do not trade" log: setups flagged but skipped, and what happened after
|
"Do not trade" log: setups flagged but skipped, and what happened after
|
||||||
|
|
||||||
|
|
||||||
--- 2026-09-06: decision-time feature export ---
|
|
||||||
- `--export-features` appends stake_multiplier, r_sized and the 65 `f_*` decision-time
|
|
||||||
features (position_sizing.FEATURE_ORDER) AFTER the 63 pinned columns; default export is
|
|
||||||
byte-identical to the pinned books. Use backtest/research/book_report.py for weekday /
|
|
||||||
hour x weekday / session / streak / rolling-WR / feature-quintile (both halves) tables,
|
|
||||||
backtest/research/burst_map.py for the burst-by-hour map, and
|
|
||||||
backtest/research/sizing_model.py for the walk-forward + shuffle-control harness.
|
|
||||||
- Any sizing report must show the FLAT book and the SIZED book side by side and the
|
|
||||||
label-shuffle null; the 2026-09-06 campaign verdict (nothing ships) is the reference.
|
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
# AI signal enrichment — analysis + build record (2026-08-15)
|
|
||||||
|
|
||||||
Owner questions:
|
|
||||||
1. If the AI panel also received indicator values and (limited) candle data, would the system be more accurate?
|
|
||||||
2. If AI reviewed every candle alongside the local score system, would prediction accuracy justify the token burn — or is there a middle ground?
|
|
||||||
|
|
||||||
## What the panel already received (pre-change baseline)
|
|
||||||
|
|
||||||
The four personas (`agent_panel.py`) already got a *distilled* picture:
|
|
||||||
per-indicator signal+confidence, MTF signals/scores, key levels,
|
|
||||||
exhaustion count, full trade-setup math, warnings, headlines/events/
|
|
||||||
sentiment. Missing: raw indicator **values** and any **candle** data.
|
|
||||||
|
|
||||||
Cost guardrails already in place: 5 requests/panel, `max_panels_per_day`
|
|
||||||
20 (≤100 requests/day), 3600 s reuse-TTL, per-bar exact cache, shared
|
|
||||||
500 req/day DeepSeek cap, static prompts on the ~50× prefix cache.
|
|
||||||
|
|
||||||
## Evidence used
|
|
||||||
|
|
||||||
- **LLM is strong at text, weak at raw numerics** (measured in-repo,
|
|
||||||
`llm_news_eval_2026-08-06.md`): headline sentiment 100% (78/78) vs
|
|
||||||
keyword scorer 31%; but ~11% of event-direction calls misread close
|
|
||||||
numeric comparisons until code pre-computed the comparison
|
|
||||||
(`vs_forecast`) and the model only mapped it to doctrine.
|
|
||||||
- **The book any filter would act on** (M5 canonical,
|
|
||||||
`oanda_m5_12mo_065_nopb_20260814.csv`): 577 trades, +106.82R,
|
|
||||||
PF 1.51, win rate 52.9%; A-grade 59.5%, B-grade 51.4% (+67.8R of the
|
|
||||||
total), C-grade 43.8% (−0.23R, 16 trades). Average edge ≈ 0.185R/trade.
|
|
||||||
|
|
||||||
## Answer 1 — enrich the existing panel? YES (cheap, safe)
|
|
||||||
|
|
||||||
- A compact candle is ~15–25 tokens; 20 primary-TF + 10 confirm-TF bars
|
|
||||||
≈ 500–1,500 tokens per persona call → cents/day at the 20-panel cap.
|
|
||||||
**Tokens are not the constraint; the request cap is.**
|
|
||||||
- Measured after the build (dry-test wire log): enriched technical
|
|
||||||
payload with 20 candles ≈ **~380 tokens**; replies ≈ 25–35 tokens
|
|
||||||
under the new 15-word note cap.
|
|
||||||
- Caveats: (a) the panel is advisory-only — richer payloads improve the
|
|
||||||
human's context, not the book's P&L; (b) raw prices are the model's
|
|
||||||
weakest input, so candles are sent **ATR-normalized** (code does the
|
|
||||||
arithmetic, LLM reads the shape) — the same pattern that fixed the
|
|
||||||
event-direction misreads.
|
|
||||||
|
|
||||||
## Answer 2 — AI on every candle? NO
|
|
||||||
|
|
||||||
- **Feasibility**: M5+M15+H1+H4 ≈ 414 bars/day → one call/bar nearly
|
|
||||||
exhausts the shared 500/day cap (news needs it too); a panel per bar
|
|
||||||
≈ 2,070/day = 4× over. Most of that spend reviews HOLD bars.
|
|
||||||
- **Parity (rule 4)**: an AI gate cannot be replayed by the backtester
|
|
||||||
(news off; no recorded verdicts) — every pinned book becomes
|
|
||||||
non-comparable, and model-version drift silently mutates the strategy.
|
|
||||||
- **Edge**: the engine already compresses 380 bars into 9 tuned
|
|
||||||
indicators + regime + MTF validated over 5 years; an LLM's generic
|
|
||||||
prior on raw candles won't beat fitted thresholds, and the margin it
|
|
||||||
would filter is ~0.185R/trade. B-grade holds +67.8R at 51.4% win —
|
|
||||||
a blunt veto is net-negative.
|
|
||||||
|
|
||||||
## The middle ground (adopted)
|
|
||||||
|
|
||||||
1. **Enrich payloads** (built today, see below) — near-zero cost, no
|
|
||||||
rule-1 exposure.
|
|
||||||
2. **Log panel verdicts to signals.db** (built today) — creates the
|
|
||||||
"does OPPOSE predict losers?" dataset with zero behavior change.
|
|
||||||
3. **After 4–8 weeks**: join logged verdicts to outcomes; simulate an
|
|
||||||
OPPOSE-veto against the pinned book offline. Only then consider any
|
|
||||||
gating, as a full rule-1 revalidation.
|
|
||||||
|
|
||||||
## Build record (2026-08-15, owner-approved)
|
|
||||||
|
|
||||||
- `agent_panel.py`
|
|
||||||
- Technical persona payload now carries indicator **values** (units
|
|
||||||
legend added to the prompt) and **ATR-normalized candles** per TF:
|
|
||||||
`{"ref_close", "atr", "bars": [[o,h,l,c]…]}` oldest-first, each price
|
|
||||||
as `(price − ref_close) / atr`, 1 decimal. Window: 20 bars primary /
|
|
||||||
10 per confirmation TF.
|
|
||||||
- New config keys (validated in `app_config.py`, in
|
|
||||||
`config.json.example`): `candles_enabled` (default true),
|
|
||||||
`candle_bars_primary` (20), `candle_bars_confirm` (10).
|
|
||||||
- Panel-private provider singleton (`_dp`, patchable in tests);
|
|
||||||
TTL cache keeps fetches near-free; any failure → candles silently
|
|
||||||
omitted, panel unaffected.
|
|
||||||
- Prompt hardening: training data declared stale and OFF-LIMITS;
|
|
||||||
"if a fact is not in the JSON, treat it as unknown"; thin evidence
|
|
||||||
→ neutral < 30 (unchanged); note cap 25→**15 words**, rationale
|
|
||||||
40→**30 words** (output-token savings).
|
|
||||||
- `attach_panel` now calls `signal_log.log_panel_update(report)` after
|
|
||||||
every successful attach (fresh, exact-cache, and reuse-TTL paths).
|
|
||||||
- `signal_log.py`
|
|
||||||
- `log()` returns the rowid; `log_signal` stashes it on the report as
|
|
||||||
`_signal_log_rowid`; new `log_panel_update()` fills the 4 panel
|
|
||||||
columns on that row post-hoc. No rowid (backtest replay, logging
|
|
||||||
disabled, other process) → silent no-op. Fail-safe contract kept.
|
|
||||||
- Tests: 381 → **388 passed** (7 new: candle normalization math, payload
|
|
||||||
values/candles, context windows + ATR gating + disable flag, attach
|
|
||||||
injection (technical persona only), prompt-contract assertions,
|
|
||||||
panel row update, rowid hand-off + no-op paths). Existing attach tests
|
|
||||||
stub `ap._dp` to stay off the network.
|
|
||||||
- **Live dry test** (`panel_payload_drytest_2026-08-15.py`, fabricated
|
|
||||||
numbers only, 13 real DeepSeek requests total): **7/7 probes pass** —
|
|
||||||
coherent-bullish → support@70; bearish-vs-BUY → oppose@85; genuinely
|
|
||||||
mixed → neutral@30; empty payload → neutral@20 (contract); no invented
|
|
||||||
numbers in any note; all notes ≤ 15 words; synthesizer AGREE with a
|
|
||||||
21-word rationale. Raw wire log:
|
|
||||||
`backtest/research/panel_payload_drytest_2026-08-15.jsonl`.
|
|
||||||
- Notable: an early P3 variant with H1 SELL against a BUY brief got
|
|
||||||
oppose@60 — *correct* per the prompt (higher-TF disagreement is a
|
|
||||||
listed conflict), so the probe was tightened to a genuinely mixed
|
|
||||||
scenario. Documented so nobody "fixes" the prompt for it later.
|
|
||||||
|
|
||||||
## What this deliberately did NOT change
|
|
||||||
|
|
||||||
- No gate reads panel fields; the panel remains advisory-only.
|
|
||||||
- No backtest-path change (`analyze_from_dataframes` untouched; parity
|
|
||||||
test still locks panel fields empty in replay).
|
|
||||||
- No verdict-based filtering — that decision waits for the signals.db
|
|
||||||
dataset (4–8 weeks) and an offline veto replay against the pinned book.
|
|
||||||
@@ -1,581 +0,0 @@
|
|||||||
"""Full descriptive report of a backtest trade book (research only).
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python backtest/research/book_report.py --book BOOK.csv --bar-minutes N \
|
|
||||||
[--split YYYY-MM-DD|median] [--parquet-4h PATH] [--parquet-1d PATH] \
|
|
||||||
[--out DIR]
|
|
||||||
|
|
||||||
Prints (and writes to DIR/report.txt) headline metrics for the whole book
|
|
||||||
and for each half, group tables (weekday / decision hour / session / quality
|
|
||||||
/ regime / vol_regime / direction / strategy / exit_reason / indicators_agree),
|
|
||||||
win-loss streaks, a rolling win-rate summary, and a quantile-bucket screen of
|
|
||||||
every numeric decision-time feature the book carries (`f_*` / `x_*` columns
|
|
||||||
plus score, confidence, rr_planned, indicators_agree/oppose and `ind_*_value`).
|
|
||||||
|
|
||||||
The screen is deliberately half-split: a feature only "passes" when the
|
|
||||||
top-bucket-minus-bottom-bucket mean-R gap has the SAME SIGN in both halves of
|
|
||||||
the book and both extreme buckets are populated in both halves. Bin edges are
|
|
||||||
fit on the WHOLE book (so the halves share edges and stay comparable) — that
|
|
||||||
leaks a little cross-half information into the edges themselves, which is why
|
|
||||||
the sign test, not the size of the gap, is the thing to trust.
|
|
||||||
|
|
||||||
Everything here is measurement, not simulation: it never re-runs the signal
|
|
||||||
engine, it only describes a book that gpu_backtest.py already produced.
|
|
||||||
Analysis tooling only — never imported by the live code.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
SESSIONS = (
|
|
||||||
("asian", 3, 6),
|
|
||||||
("london", 7, 12),
|
|
||||||
("overlap", 13, 16),
|
|
||||||
("ny", 17, 22),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Numeric non-`f_`/`x_` columns that are known at DECISION time and are
|
|
||||||
# therefore legal screen candidates. bars_held / r_multiple / mfe_r / mae_r
|
|
||||||
# are post-entry outcomes and must never appear here.
|
|
||||||
BASE_SCREEN_COLS = ("score", "confidence", "rr_planned",
|
|
||||||
"indicators_agree", "indicators_oppose")
|
|
||||||
|
|
||||||
|
|
||||||
def session_of(hour: int) -> str:
|
|
||||||
for name, lo, hi in SESSIONS:
|
|
||||||
if lo <= hour <= hi:
|
|
||||||
return name
|
|
||||||
return "off"
|
|
||||||
|
|
||||||
|
|
||||||
def feature_columns(df: pd.DataFrame) -> list[str]:
|
|
||||||
"""Decision-time feature columns carried by this book, generically."""
|
|
||||||
extra = [c for c in df.columns if c.startswith(("f_", "x_"))]
|
|
||||||
base = [c for c in BASE_SCREEN_COLS if c in df.columns]
|
|
||||||
ind = [c for c in df.columns
|
|
||||||
if c.startswith("ind_") and c.endswith("_value")]
|
|
||||||
out, seen = [], set()
|
|
||||||
for c in extra + base + ind:
|
|
||||||
if c not in seen:
|
|
||||||
seen.add(c)
|
|
||||||
out.append(c)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# ───────────────────────────── loading ──────────────────────────────
|
|
||||||
|
|
||||||
def load_book(csv: str, bar_minutes: int = 5,
|
|
||||||
split: str = "median") -> pd.DataFrame:
|
|
||||||
"""Read a gpu_backtest trade book and add the decision-time columns.
|
|
||||||
|
|
||||||
`decision_time` = entry_time − one bar: entries fill at the NEXT bar's
|
|
||||||
open, so the bar the signal actually decided on closed one bar earlier.
|
|
||||||
That is the clock blocked_hours_utc and every session filter evaluate.
|
|
||||||
`half` is 1 before the split instant and 2 at/after it.
|
|
||||||
"""
|
|
||||||
df = pd.read_csv(csv)
|
|
||||||
for col in ("entry_time", "exit_time"):
|
|
||||||
if col in df.columns:
|
|
||||||
df[col] = pd.to_datetime(df[col], utc=True, format="mixed")
|
|
||||||
df["decision_time"] = df["entry_time"] - pd.Timedelta(minutes=bar_minutes)
|
|
||||||
df["decision_hour"] = df["decision_time"].dt.hour
|
|
||||||
df["weekday"] = df["decision_time"].dt.weekday
|
|
||||||
df["session"] = df["decision_hour"].map(session_of)
|
|
||||||
df = df.sort_values("entry_time", kind="mergesort").reset_index(drop=True)
|
|
||||||
df["half"] = _halves(df["entry_time"], split)
|
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
def split_instant(entry_time: pd.Series, split: str) -> pd.Timestamp:
|
|
||||||
if split == "median":
|
|
||||||
return entry_time.median()
|
|
||||||
return pd.Timestamp(split, tz="UTC")
|
|
||||||
|
|
||||||
|
|
||||||
def _halves(entry_time: pd.Series, split: str) -> pd.Series:
|
|
||||||
cut = split_instant(entry_time, split)
|
|
||||||
return np.where(entry_time < cut, 1, 2)
|
|
||||||
|
|
||||||
|
|
||||||
# ───────────────────────────── metrics ──────────────────────────────
|
|
||||||
|
|
||||||
def base_metrics(r: pd.Series, df: pd.DataFrame | None = None) -> dict:
|
|
||||||
"""Headline stats for a series of R multiples.
|
|
||||||
|
|
||||||
max_dd uses the gpu_backtest.compute_metrics convention: a 0.0 is
|
|
||||||
prepended to the cumulative-R curve so an opening losing streak counts
|
|
||||||
as drawdown from flat, not from an already-negative first trade.
|
|
||||||
pf/expectancy split on r > 0 / r < 0 (exact zeros are neither).
|
|
||||||
"""
|
|
||||||
r = pd.Series(r, dtype="float64").dropna()
|
|
||||||
n = int(len(r))
|
|
||||||
if n == 0:
|
|
||||||
return {"n": 0, "total_r": 0.0, "mean_r": 0.0, "median_r": 0.0,
|
|
||||||
"wr": 0.0, "pf": 0.0, "max_dd": 0.0, "sharpe": 0.0,
|
|
||||||
"expectancy": 0.0, "expectancy_alt": 0.0, "recovery": 0.0,
|
|
||||||
"avg_bars": float("nan"), "avg_mfe": float("nan"),
|
|
||||||
"avg_mae": float("nan")}
|
|
||||||
wins, losses = r[r > 0], r[r < 0]
|
|
||||||
gross_win = float(wins.sum())
|
|
||||||
gross_loss = float(-losses.sum())
|
|
||||||
cum = np.concatenate(([0.0], np.cumsum(r.to_numpy())))
|
|
||||||
max_dd = float((np.maximum.accumulate(cum) - cum).max())
|
|
||||||
std = float(r.std(ddof=0))
|
|
||||||
mean_r = float(r.mean())
|
|
||||||
wr = len(wins) / n
|
|
||||||
lr = len(losses) / n
|
|
||||||
avg_win = float(wins.mean()) if len(wins) else 0.0
|
|
||||||
avg_loss = float(-losses.mean()) if len(losses) else 0.0
|
|
||||||
expectancy_alt = wr * avg_win - lr * avg_loss
|
|
||||||
# Identity check: the textbook expectancy form must equal the plain mean
|
|
||||||
# whenever no trade is exactly flat (and it does even then, since a 0 R
|
|
||||||
# trade contributes 0 to both sides).
|
|
||||||
assert math.isclose(expectancy_alt, mean_r, rel_tol=1e-9, abs_tol=1e-9), (
|
|
||||||
f"expectancy mismatch: {expectancy_alt} vs mean {mean_r}")
|
|
||||||
out = {
|
|
||||||
"n": n,
|
|
||||||
"total_r": float(r.sum()),
|
|
||||||
"mean_r": mean_r,
|
|
||||||
"median_r": float(r.median()),
|
|
||||||
"wr": wr * 100.0,
|
|
||||||
"pf": (gross_win / gross_loss) if gross_loss > 0 else float("inf"),
|
|
||||||
"max_dd": max_dd,
|
|
||||||
"sharpe": (mean_r / std) if std > 0 else 0.0,
|
|
||||||
"expectancy": mean_r,
|
|
||||||
"expectancy_alt": expectancy_alt,
|
|
||||||
"recovery": (float(r.sum()) / max_dd) if max_dd > 0 else float("inf"),
|
|
||||||
"avg_bars": float("nan"),
|
|
||||||
"avg_mfe": float("nan"),
|
|
||||||
"avg_mae": float("nan"),
|
|
||||||
}
|
|
||||||
if df is not None:
|
|
||||||
for key, col in (("avg_bars", "bars_held"), ("avg_mfe", "mfe_r"),
|
|
||||||
("avg_mae", "mae_r")):
|
|
||||||
if col in df.columns:
|
|
||||||
out[key] = float(pd.to_numeric(df[col],
|
|
||||||
errors="coerce").mean())
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _pf(r: pd.Series) -> float:
|
|
||||||
"""Profit factor, hour_analysis-compatible (loss side is r <= 0)."""
|
|
||||||
pos = r[r > 0].sum()
|
|
||||||
neg = -r[r <= 0].sum()
|
|
||||||
return float(pos / max(neg, 1e-9))
|
|
||||||
|
|
||||||
|
|
||||||
def group_table(df: pd.DataFrame, by, min_n: int = 10) -> pd.DataFrame:
|
|
||||||
"""Per-group P&L with a half-1 / half-2 sign-stability flag."""
|
|
||||||
keys = [by] if isinstance(by, str) else list(by)
|
|
||||||
rows = []
|
|
||||||
for key, sub in df.groupby(keys, dropna=False, sort=True):
|
|
||||||
r = sub["r_multiple"]
|
|
||||||
h1, h2 = sub[sub["half"] == 1]["r_multiple"], sub[sub["half"] == 2]["r_multiple"]
|
|
||||||
m1 = float(h1.mean()) if len(h1) else float("nan")
|
|
||||||
m2 = float(h2.mean()) if len(h2) else float("nan")
|
|
||||||
stable = bool(len(h1) >= min_n and len(h2) >= min_n
|
|
||||||
and np.sign(m1) != 0 and np.sign(m1) == np.sign(m2))
|
|
||||||
rec = {}
|
|
||||||
# pandas >= 2.2 hands back a 1-tuple even for a single group key.
|
|
||||||
vals = key if isinstance(key, tuple) else (key,)
|
|
||||||
for k, v in zip(keys, vals):
|
|
||||||
rec[k] = v
|
|
||||||
rec.update({
|
|
||||||
"n": int(len(sub)),
|
|
||||||
"total_r": round(float(r.sum()), 2),
|
|
||||||
"mean_r": round(float(r.mean()), 3),
|
|
||||||
"wr": round(float((r > 0).mean() * 100), 2),
|
|
||||||
"pf": round(_pf(r), 2),
|
|
||||||
"n_h1": int(len(h1)),
|
|
||||||
"total_r_h1": round(float(h1.sum()), 2),
|
|
||||||
"mean_r_h1": round(m1, 3) if len(h1) else float("nan"),
|
|
||||||
"n_h2": int(len(h2)),
|
|
||||||
"total_r_h2": round(float(h2.sum()), 2),
|
|
||||||
"mean_r_h2": round(m2, 3) if len(h2) else float("nan"),
|
|
||||||
"sign_stable": stable,
|
|
||||||
})
|
|
||||||
rows.append(rec)
|
|
||||||
out = pd.DataFrame(rows)
|
|
||||||
if not out.empty:
|
|
||||||
out = out.set_index(keys)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def streaks(df: pd.DataFrame) -> dict:
|
|
||||||
"""Longest win/loss runs and the longest stretch under water (in trades)."""
|
|
||||||
d = df.sort_values("entry_time", kind="mergesort")
|
|
||||||
r = d["r_multiple"].to_numpy(dtype="float64")
|
|
||||||
best_w = best_l = cur_w = cur_l = 0
|
|
||||||
for x in r:
|
|
||||||
if x > 0:
|
|
||||||
cur_w, cur_l = cur_w + 1, 0
|
|
||||||
elif x < 0:
|
|
||||||
cur_l, cur_w = cur_l + 1, 0
|
|
||||||
else:
|
|
||||||
cur_w = cur_l = 0
|
|
||||||
best_w, best_l = max(best_w, cur_w), max(best_l, cur_l)
|
|
||||||
cum = np.concatenate(([0.0], np.cumsum(r)))
|
|
||||||
peak = np.maximum.accumulate(cum)
|
|
||||||
flat, longest = 0, 0
|
|
||||||
for i in range(1, len(cum)):
|
|
||||||
flat = 0 if cum[i] >= peak[i] else flat + 1
|
|
||||||
longest = max(longest, flat)
|
|
||||||
return {"max_win_streak": best_w, "max_loss_streak": best_l,
|
|
||||||
"longest_flat_trades": longest}
|
|
||||||
|
|
||||||
|
|
||||||
def rolling_wr(df: pd.DataFrame, window: int = 20) -> pd.Series:
|
|
||||||
d = df.sort_values("entry_time", kind="mergesort")
|
|
||||||
s = (d["r_multiple"] > 0).astype(float).rolling(window).mean() * 100
|
|
||||||
s.index = d["entry_time"].to_numpy()
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
def equity_curve(df: pd.DataFrame) -> pd.DataFrame:
|
|
||||||
d = df.sort_values("entry_time", kind="mergesort")
|
|
||||||
cum = d["r_multiple"].cumsum()
|
|
||||||
peak = pd.concat([pd.Series([0.0]), cum]).cummax().iloc[1:]
|
|
||||||
peak.index = cum.index
|
|
||||||
return pd.DataFrame({"entry_time": d["entry_time"].to_numpy(),
|
|
||||||
"cum_r": cum.to_numpy(),
|
|
||||||
"drawdown": (peak - cum).to_numpy()})
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────── quantile bucket screen ─────────────────────
|
|
||||||
|
|
||||||
def quantile_buckets(df: pd.DataFrame, col: str, q: int = 5,
|
|
||||||
min_n: int = 10) -> pd.DataFrame:
|
|
||||||
"""Equal-frequency buckets of `col` with per-half mean R.
|
|
||||||
|
|
||||||
Bin edges are fit on the WHOLE book (both halves share edges).
|
|
||||||
Result carries gap_h1 / gap_h2 / sign_stable in `.attrs` and as
|
|
||||||
repeated columns so the CSV is self-describing.
|
|
||||||
"""
|
|
||||||
v = pd.to_numeric(df.get(col), errors="coerce")
|
|
||||||
d = df.loc[v.notna()].copy()
|
|
||||||
d["_v"] = v.loc[v.notna()]
|
|
||||||
empty = pd.DataFrame(columns=["bin", "lo", "hi", "n", "mean_r", "pf", "wr",
|
|
||||||
"n_h1", "mean_r_h1", "n_h2", "mean_r_h2",
|
|
||||||
"gap_h1", "gap_h2", "sign_stable"])
|
|
||||||
empty.attrs = {"feature": col, "gap_h1": float("nan"),
|
|
||||||
"gap_h2": float("nan"), "sign_stable": False, "n": 0,
|
|
||||||
"mode": "empty"}
|
|
||||||
if d.empty:
|
|
||||||
return empty
|
|
||||||
|
|
||||||
nuniq = int(d["_v"].nunique())
|
|
||||||
if nuniq < 3:
|
|
||||||
mode = "values"
|
|
||||||
d["_bin"] = d["_v"]
|
|
||||||
order = sorted(d["_v"].unique())
|
|
||||||
edges = {b: (float(b), float(b)) for b in order}
|
|
||||||
else:
|
|
||||||
mode = "qcut"
|
|
||||||
binned = pd.qcut(d["_v"], q, duplicates="drop")
|
|
||||||
d["_bin"] = binned
|
|
||||||
order = list(binned.cat.categories)
|
|
||||||
edges = {b: (float(b.left), float(b.right)) for b in order}
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
for b in order:
|
|
||||||
sub = d[d["_bin"] == b]
|
|
||||||
if sub.empty:
|
|
||||||
continue
|
|
||||||
r = sub["r_multiple"]
|
|
||||||
h1 = sub[sub["half"] == 1]["r_multiple"]
|
|
||||||
h2 = sub[sub["half"] == 2]["r_multiple"]
|
|
||||||
lo, hi = edges[b]
|
|
||||||
rows.append({
|
|
||||||
"bin": str(b), "lo": round(lo, 4), "hi": round(hi, 4),
|
|
||||||
"n": int(len(sub)), "mean_r": round(float(r.mean()), 3),
|
|
||||||
"pf": round(_pf(r), 2),
|
|
||||||
"wr": round(float((r > 0).mean() * 100), 2),
|
|
||||||
"n_h1": int(len(h1)),
|
|
||||||
"mean_r_h1": round(float(h1.mean()), 3) if len(h1) else float("nan"),
|
|
||||||
"n_h2": int(len(h2)),
|
|
||||||
"mean_r_h2": round(float(h2.mean()), 3) if len(h2) else float("nan"),
|
|
||||||
})
|
|
||||||
out = pd.DataFrame(rows)
|
|
||||||
if out.empty:
|
|
||||||
return empty
|
|
||||||
|
|
||||||
bot, top = out.iloc[0], out.iloc[-1]
|
|
||||||
gap_h1 = float(top["mean_r_h1"] - bot["mean_r_h1"])
|
|
||||||
gap_h2 = float(top["mean_r_h2"] - bot["mean_r_h2"])
|
|
||||||
populated = min(bot["n_h1"], bot["n_h2"], top["n_h1"], top["n_h2"]) >= min_n
|
|
||||||
signs_ok = (not math.isnan(gap_h1) and not math.isnan(gap_h2)
|
|
||||||
and np.sign(gap_h1) != 0
|
|
||||||
and np.sign(gap_h1) == np.sign(gap_h2))
|
|
||||||
sign_stable = bool(populated and signs_ok and len(out) >= 2)
|
|
||||||
out["gap_h1"] = round(gap_h1, 3) if not math.isnan(gap_h1) else float("nan")
|
|
||||||
out["gap_h2"] = round(gap_h2, 3) if not math.isnan(gap_h2) else float("nan")
|
|
||||||
out["sign_stable"] = sign_stable
|
|
||||||
out.attrs = {"feature": col, "gap_h1": gap_h1, "gap_h2": gap_h2,
|
|
||||||
"sign_stable": sign_stable, "n": int(len(d)), "mode": mode}
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def feature_screen(df: pd.DataFrame, q: int = 5, min_n: int = 10,
|
|
||||||
columns: Iterable[str] | None = None) -> pd.DataFrame:
|
|
||||||
"""Bucket screen over every decision-time numeric feature in the book."""
|
|
||||||
cols = list(columns) if columns is not None else feature_columns(df)
|
|
||||||
rows = []
|
|
||||||
for c in cols:
|
|
||||||
bk = quantile_buckets(df, c, q=q, min_n=min_n)
|
|
||||||
a = bk.attrs
|
|
||||||
stable = bool(a.get("sign_stable"))
|
|
||||||
g1, g2 = a.get("gap_h1", float("nan")), a.get("gap_h2", float("nan"))
|
|
||||||
min_abs = (min(abs(g1), abs(g2)) if stable else 0.0)
|
|
||||||
rows.append({"feature": c,
|
|
||||||
"gap_h1": round(g1, 3) if g1 == g1 else float("nan"),
|
|
||||||
"gap_h2": round(g2, 3) if g2 == g2 else float("nan"),
|
|
||||||
"min_abs_gap": round(float(min_abs), 3),
|
|
||||||
"sign_stable": stable,
|
|
||||||
"n": int(a.get("n", 0)),
|
|
||||||
"bins": int(len(bk))})
|
|
||||||
out = pd.DataFrame(rows)
|
|
||||||
if out.empty:
|
|
||||||
return out
|
|
||||||
return out.sort_values(["min_abs_gap", "n"],
|
|
||||||
ascending=[False, False]).reset_index(drop=True)
|
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────── higher-timeframe extension ──────────────────
|
|
||||||
|
|
||||||
def wilder_atr(bars: pd.DataFrame, period: int = 14) -> pd.Series:
|
|
||||||
h, l, c = bars["High"], bars["Low"], bars["Close"]
|
|
||||||
tr = pd.concat([h - l, (h - c.shift()).abs(), (l - c.shift()).abs()],
|
|
||||||
axis=1).max(axis=1)
|
|
||||||
return tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
|
|
||||||
|
|
||||||
|
|
||||||
def _ext_series(parquet: str, minutes: int) -> pd.DataFrame:
|
|
||||||
bars = pd.read_parquet(parquet).sort_index()
|
|
||||||
bars = bars[~bars.index.duplicated(keep="last")]
|
|
||||||
ext = ((bars["Close"] - bars["Close"].rolling(20).mean())
|
|
||||||
/ wilder_atr(bars, 14))
|
|
||||||
out = pd.DataFrame({
|
|
||||||
"close_time": bars.index + pd.Timedelta(minutes=minutes),
|
|
||||||
"ext": ext.to_numpy(),
|
|
||||||
}).dropna().reset_index(drop=True)
|
|
||||||
# Books parse to datetime64[us, UTC] (format="mixed"), parquet indexes are
|
|
||||||
# [ns, UTC]; merge_asof refuses mismatched resolutions.
|
|
||||||
out["close_time"] = out["close_time"].astype("datetime64[ns, UTC]")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def add_htf_extension(df: pd.DataFrame, parquet_4h: str | None,
|
|
||||||
parquet_1d: str | None) -> pd.DataFrame:
|
|
||||||
"""Add direction-signed H4 / D1 stretch-from-mean in ATR units.
|
|
||||||
|
|
||||||
ext = (close − 20-bar SMA of close) / Wilder ATR(14) on that timeframe,
|
|
||||||
taken from the LAST higher-TF bar whose CLOSE time (bar label + one bar)
|
|
||||||
is <= decision_time — i.e. only information the live scan could have had.
|
|
||||||
Signed +1 for BUY, −1 for SELL, so positive always means "extended in the
|
|
||||||
direction of the trade".
|
|
||||||
"""
|
|
||||||
out = df.copy()
|
|
||||||
sign = np.where(out["direction"].astype(str).str.upper().str.startswith("B"),
|
|
||||||
1.0, -1.0)
|
|
||||||
for col, parquet, minutes in (("x_h4_ext_atr", parquet_4h, 240),
|
|
||||||
("x_d1_ext_atr", parquet_1d, 1440)):
|
|
||||||
if not parquet:
|
|
||||||
continue
|
|
||||||
right = _ext_series(parquet, minutes)
|
|
||||||
left = out[["decision_time"]].reset_index()
|
|
||||||
left["decision_time"] = left["decision_time"].astype(
|
|
||||||
"datetime64[ns, UTC]")
|
|
||||||
left = left.sort_values("decision_time", kind="mergesort")
|
|
||||||
merged = pd.merge_asof(left, right, left_on="decision_time",
|
|
||||||
right_on="close_time", direction="backward")
|
|
||||||
merged["_stale_h"] = ((merged["decision_time"] - merged["close_time"])
|
|
||||||
.dt.total_seconds() / 3600.0)
|
|
||||||
merged = merged.set_index("index").reindex(out.index)
|
|
||||||
out[col] = merged["ext"].to_numpy() * sign
|
|
||||||
out.attrs[f"{col}_max_stale_h"] = float(
|
|
||||||
merged["_stale_h"].max()) if len(merged) else float("nan")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────── reporting ─────────────────────────────
|
|
||||||
|
|
||||||
def _fmt(v, nd=3):
|
|
||||||
if isinstance(v, float):
|
|
||||||
if math.isinf(v):
|
|
||||||
return "inf"
|
|
||||||
if math.isnan(v):
|
|
||||||
return "n/a"
|
|
||||||
return f"{v:.{nd}f}"
|
|
||||||
return str(v)
|
|
||||||
|
|
||||||
|
|
||||||
def _metrics_block(name: str, m: dict) -> list[str]:
|
|
||||||
return [
|
|
||||||
f" {name:<10} n={m['n']:<5} totalR={_fmt(m['total_r'], 2):>8} "
|
|
||||||
f"meanR={_fmt(m['mean_r'])} medR={_fmt(m['median_r'])} "
|
|
||||||
f"WR={_fmt(m['wr'], 2)}% PF={_fmt(m['pf'], 2)}",
|
|
||||||
f" {'':<10} maxDD={_fmt(m['max_dd'], 2)} recovery={_fmt(m['recovery'], 2)}"
|
|
||||||
f" sharpe={_fmt(m['sharpe'])} expectancy={_fmt(m['expectancy'])}"
|
|
||||||
f" (wr·avgW − lr·avgL = {_fmt(m['expectancy_alt'])})",
|
|
||||||
f" {'':<10} avg bars={_fmt(m['avg_bars'], 1)} "
|
|
||||||
f"avg MFE={_fmt(m['avg_mfe'])}R avg MAE={_fmt(m['avg_mae'])}R",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def build_report(df: pd.DataFrame, book: str, bar_minutes: int, split: str,
|
|
||||||
out_dir: str, q: int = 5, min_n: int = 10) -> str:
|
|
||||||
lines: list[str] = []
|
|
||||||
add = lines.append
|
|
||||||
cut = split_instant(df["entry_time"], split)
|
|
||||||
add("=" * 78)
|
|
||||||
add(f"BOOK REPORT {os.path.basename(book)}")
|
|
||||||
add("=" * 78)
|
|
||||||
add(f"trades : {len(df)}")
|
|
||||||
add(f"entry window : {df['entry_time'].min()} -> {df['entry_time'].max()}")
|
|
||||||
add(f"bar minutes : {bar_minutes} (decision_time = entry_time − 1 bar)")
|
|
||||||
add(f"split rule : {split} -> cut at {cut} "
|
|
||||||
f"(half 1 = before, half 2 = at/after)")
|
|
||||||
add(f"half sizes : h1={int((df['half'] == 1).sum())} "
|
|
||||||
f"h2={int((df['half'] == 2).sum())}")
|
|
||||||
add("session map : asian 3-6, london 7-12, overlap 13-16, ny 17-22, "
|
|
||||||
"else off (on DECISION hour, UTC)")
|
|
||||||
add("bucket edges : quantile bins are fit on the WHOLE book; the two "
|
|
||||||
"halves share the same edges.")
|
|
||||||
feats = feature_columns(df)
|
|
||||||
add(f"screen features : {len(feats)} "
|
|
||||||
f"({sum(1 for c in feats if c.startswith(('f_', 'x_')))} f_/x_ columns)")
|
|
||||||
for k, v in df.attrs.items():
|
|
||||||
add(f"htf staleness : {k} = {_fmt(v, 1)} h (max age of the matched bar)")
|
|
||||||
|
|
||||||
add("")
|
|
||||||
add("── HEADLINE METRICS ──")
|
|
||||||
lines.extend(_metrics_block("full", base_metrics(df["r_multiple"], df)))
|
|
||||||
for h in (1, 2):
|
|
||||||
sub = df[df["half"] == h]
|
|
||||||
lines.extend(_metrics_block(f"half {h}",
|
|
||||||
base_metrics(sub["r_multiple"], sub)))
|
|
||||||
|
|
||||||
group_specs = [
|
|
||||||
("weekday", "weekday"), ("decision_hour", "decision_hour"),
|
|
||||||
("session", "session"), ("quality", "quality"), ("regime", "regime"),
|
|
||||||
("vol_regime", "vol_regime"), ("direction", "direction"),
|
|
||||||
("strategy", "strategy"), ("exit_reason", "exit_reason"),
|
|
||||||
("indicators_agree", "indicators_agree"),
|
|
||||||
]
|
|
||||||
tables = {}
|
|
||||||
for name, col in group_specs:
|
|
||||||
if col not in df.columns:
|
|
||||||
continue
|
|
||||||
t = group_table(df, col, min_n=min_n)
|
|
||||||
tables[name] = t
|
|
||||||
add("")
|
|
||||||
add(f"── BY {name.upper()} ──")
|
|
||||||
add(t.to_string() if not t.empty else " (empty)")
|
|
||||||
|
|
||||||
# (weekday, decision_hour) pivots
|
|
||||||
add("")
|
|
||||||
add("── weekday x decision_hour : TOTAL R ──")
|
|
||||||
wh = group_table(df, ["weekday", "decision_hour"], min_n=min_n)
|
|
||||||
if not wh.empty:
|
|
||||||
piv_r = wh["total_r"].unstack("decision_hour")
|
|
||||||
piv_n = wh["n"].unstack("decision_hour")
|
|
||||||
add(piv_r.fillna(0).round(2).to_string())
|
|
||||||
add("")
|
|
||||||
add("── weekday x decision_hour : N ──")
|
|
||||||
add(piv_n.fillna(0).astype(int).to_string())
|
|
||||||
tables["weekday_hour"] = wh
|
|
||||||
piv_r.to_csv(os.path.join(out_dir, "groups_weekday_hour_total_r.csv"))
|
|
||||||
piv_n.to_csv(os.path.join(out_dir, "groups_weekday_hour_n.csv"))
|
|
||||||
|
|
||||||
st = streaks(df)
|
|
||||||
add("")
|
|
||||||
add("── STREAKS ──")
|
|
||||||
add(f" max consecutive wins : {st['max_win_streak']}")
|
|
||||||
add(f" max consecutive losses : {st['max_loss_streak']}")
|
|
||||||
add(f" longest flat stretch : {st['longest_flat_trades']} trades "
|
|
||||||
f"between equity highs")
|
|
||||||
|
|
||||||
rw = rolling_wr(df, 20).dropna()
|
|
||||||
add("")
|
|
||||||
add("── ROLLING WIN RATE (20 trades) ──")
|
|
||||||
if len(rw):
|
|
||||||
add(f" min={_fmt(rw.min(), 2)}% median={_fmt(rw.median(), 2)}% "
|
|
||||||
f"max={_fmt(rw.max(), 2)}% n={len(rw)}")
|
|
||||||
add(" last 5: " + ", ".join(
|
|
||||||
f"{ts:%Y-%m-%d %H:%M}={v:.2f}%" for ts, v in rw.tail(5).items()))
|
|
||||||
else:
|
|
||||||
add(" (fewer than 20 trades)")
|
|
||||||
|
|
||||||
scr = feature_screen(df, q=q, min_n=min_n)
|
|
||||||
add("")
|
|
||||||
add("── FEATURE SCREEN (top 25 by min |gap| across halves) ──")
|
|
||||||
add(" gap = mean R(top bucket) − mean R(bottom bucket), per half; "
|
|
||||||
"min_abs_gap is 0 unless the sign is stable.")
|
|
||||||
add(scr.head(25).to_string(index=False) if not scr.empty else " (none)")
|
|
||||||
|
|
||||||
stable = scr[scr["sign_stable"]].head(10) if not scr.empty else scr
|
|
||||||
add("")
|
|
||||||
add(f"── BUCKET TABLES FOR THE TOP {len(stable)} SIGN-STABLE FEATURES ──")
|
|
||||||
if stable.empty:
|
|
||||||
add(" (no feature has a sign-stable top-vs-bottom gap)")
|
|
||||||
for feat in stable["feature"]:
|
|
||||||
bk = quantile_buckets(df, feat, q=q, min_n=min_n)
|
|
||||||
add("")
|
|
||||||
add(f" · {feat} (mode={bk.attrs['mode']}, n={bk.attrs['n']}, "
|
|
||||||
f"gap_h1={_fmt(bk.attrs['gap_h1'])}, "
|
|
||||||
f"gap_h2={_fmt(bk.attrs['gap_h2'])})")
|
|
||||||
add(bk.to_string(index=False))
|
|
||||||
bk.to_csv(os.path.join(out_dir, f"buckets_{feat}.csv"), index=False)
|
|
||||||
|
|
||||||
# CSV artefacts
|
|
||||||
for name, t in tables.items():
|
|
||||||
t.to_csv(os.path.join(out_dir, f"groups_{name}.csv"))
|
|
||||||
scr.to_csv(os.path.join(out_dir, "feature_screen.csv"), index=False)
|
|
||||||
equity_curve(df).to_csv(os.path.join(out_dir, "equity.csv"), index=False)
|
|
||||||
all_buckets = []
|
|
||||||
for c in feats:
|
|
||||||
bk = quantile_buckets(df, c, q=q, min_n=min_n)
|
|
||||||
if bk.empty:
|
|
||||||
continue
|
|
||||||
bk = bk.copy()
|
|
||||||
bk.insert(0, "feature", c)
|
|
||||||
all_buckets.append(bk)
|
|
||||||
if all_buckets:
|
|
||||||
pd.concat(all_buckets, ignore_index=True).to_csv(
|
|
||||||
os.path.join(out_dir, "buckets.csv"), index=False)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
||||||
ap.add_argument("--book", required=True)
|
|
||||||
ap.add_argument("--bar-minutes", type=int, required=True)
|
|
||||||
ap.add_argument("--split", default="median",
|
|
||||||
help="'median' (default) or YYYY-MM-DD")
|
|
||||||
ap.add_argument("--parquet-4h", default=None)
|
|
||||||
ap.add_argument("--parquet-1d", default=None)
|
|
||||||
ap.add_argument("--q", type=int, default=5)
|
|
||||||
ap.add_argument("--min-n", type=int, default=10)
|
|
||||||
ap.add_argument("--out", default="backtest/research/book_report_out")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
os.makedirs(args.out, exist_ok=True)
|
|
||||||
df = load_book(args.book, args.bar_minutes, args.split)
|
|
||||||
if args.parquet_4h or args.parquet_1d:
|
|
||||||
df = add_htf_extension(df, args.parquet_4h, args.parquet_1d)
|
|
||||||
text = build_report(df, args.book, args.bar_minutes, args.split,
|
|
||||||
args.out, q=args.q, min_n=args.min_n)
|
|
||||||
print(text)
|
|
||||||
with open(os.path.join(args.out, "report.txt"), "w") as fh:
|
|
||||||
fh.write(text + "\n")
|
|
||||||
print(f"\n[written] {os.path.join(args.out, 'report.txt')}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
"""What do the system's OWN indicators read right before a violent bar? Analysis tooling only.
|
|
||||||
|
|
||||||
Uses backtest.gpu_backtest.precompute_indicators (the same series the backtester feeds the
|
|
||||||
engine) on 3 years of bars; every state is taken at t-1 (known before the bar). Reports burst
|
|
||||||
rate (range >= 3x prior ATR14) for the NEXT bar and within the NEXT HOUR by indicator state,
|
|
||||||
by regime, by volatility regime, by weekday x hour, day of month, calendar proximity — each in
|
|
||||||
both halves — and whether the state predicts the burst's DIRECTION.
|
|
||||||
|
|
||||||
python backtest/research/burst_indicator_map.py PARQUET BAR_MINUTES [--start --end]
|
|
||||||
"""
|
|
||||||
import argparse, sys
|
|
||||||
from pathlib import Path
|
|
||||||
import numpy as np, pandas as pd
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]; sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "backtest"))
|
|
||||||
from gpu_backtest import precompute_indicators
|
|
||||||
from config import TechnicalConfig
|
|
||||||
|
|
||||||
CAL = ROOT / "backtest" / "cache" / "calendar.parquet"
|
|
||||||
|
|
||||||
|
|
||||||
def wilder_atr(df, period=14):
|
|
||||||
pc = df["Close"].shift(1)
|
|
||||||
tr = pd.concat([df["High"] - df["Low"], (df["High"] - pc).abs(), (df["Low"] - pc).abs()], axis=1).max(axis=1)
|
|
||||||
return tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
|
|
||||||
|
|
||||||
|
|
||||||
def rate_table(d, col, target, half, order=None):
|
|
||||||
base1 = d.loc[half == 1, target].mean(); base2 = d.loc[half == 2, target].mean()
|
|
||||||
g = d.groupby(col, observed=True)
|
|
||||||
out = pd.DataFrame({"n": g.size(),
|
|
||||||
"h1_lift": d[half == 1].groupby(col, observed=True)[target].mean() / base1,
|
|
||||||
"h2_lift": d[half == 2].groupby(col, observed=True)[target].mean() / base2,
|
|
||||||
"rate%": 100 * g[target].mean()})
|
|
||||||
if order is not None: out = out.reindex([o for o in order if o in out.index])
|
|
||||||
out["stable"] = np.where((out.h1_lift > 1.15) & (out.h2_lift > 1.15), "HIGH", np.where((out.h1_lift < 0.85) & (out.h2_lift < 0.85), "low", ""))
|
|
||||||
return out.round(2)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(); ap.add_argument("parquet"); ap.add_argument("bar_minutes", type=int)
|
|
||||||
ap.add_argument("--start", default="2023-09-05"); ap.add_argument("--end", default="2026-09-05")
|
|
||||||
a = ap.parse_args(); bm = a.bar_minutes
|
|
||||||
px = pd.read_parquet(a.parquet)
|
|
||||||
px = px[(px.index >= pd.Timestamp(a.start, tz="UTC") - pd.Timedelta(days=30)) & (px.index < pd.Timestamp(a.end, tz="UTC"))]
|
|
||||||
ic = precompute_indicators(px, TechnicalConfig())
|
|
||||||
d = pd.DataFrame(index=px.index)
|
|
||||||
atr_prev = wilder_atr(px).shift(1)
|
|
||||||
rng = px["High"] - px["Low"]
|
|
||||||
d["burst"] = (rng >= 3.0 * atr_prev).astype(int)
|
|
||||||
H = max(1, 60 // bm)
|
|
||||||
d["next_hour"] = d["burst"][::-1].rolling(H, min_periods=1).max()[::-1].astype(int)
|
|
||||||
d["next_bar"] = d["burst"]
|
|
||||||
d["bdir"] = np.sign(px["Close"] - px["Open"])
|
|
||||||
# states at t-1
|
|
||||||
sh = lambda arr: pd.Series(np.asarray(arr, dtype=float), index=px.index).shift(1)
|
|
||||||
rsi = sh(ic.rsi); adx = sh(ic.adx); adxs = sh(ic.adx_slope); hist = sh(ic.macd_hist); bbp = sh(ic.bb_position)
|
|
||||||
bbw = sh(ic.bb_band_width); bbaw = sh(ic.bb_avg_width); kcsq = sh(ic.kc_squeeze); roc = sh(ic.roc)
|
|
||||||
ef, em, es = sh(ic.ema_fast), sh(ic.ema_medium), sh(ic.ema_slow); et = sh(ic.ema_trend) if ic.ema_trend is not None else None
|
|
||||||
close1 = px["Close"].shift(1); atr1 = sh(ic.atr)
|
|
||||||
kcu, kcl = sh(ic.kc_upper), sh(ic.kc_lower)
|
|
||||||
d["rsi"] = pd.cut(rsi, [0, 30, 40, 50, 60, 70, 100], labels=["<30", "30-40", "40-50", "50-60", "60-70", ">=70"])
|
|
||||||
d["adx"] = pd.cut(adx, [0, 20, 25, 35, 50, 200], labels=["<20", "20-25", "25-35", "35-50", ">=50"])
|
|
||||||
d["adx_slope"] = pd.cut(adxs, [-1e9, -3, 0, 3, 1e9], labels=["falling<-3", "-3..0", "0..3", "rising>3"])
|
|
||||||
hist_prev = hist.shift(1)
|
|
||||||
d["macd"] = np.select([(hist > 0) & (hist > hist_prev), (hist > 0), (hist < 0) & (hist < hist_prev), (hist < 0)],
|
|
||||||
["pos&rising", "pos&fading", "neg&falling", "neg&fading"], default="flat")
|
|
||||||
d["bb_pos"] = pd.cut(bbp, [-1e9, 0, 0.2, 0.8, 1.0, 1e9], labels=["<0 (below)", "0-0.2", "0.2-0.8", "0.8-1", ">1 (above)"])
|
|
||||||
d["bb_squeeze"] = np.where(bbw < 0.75 * bbaw, "squeeze", "normal")
|
|
||||||
d["kc_squeeze"] = np.where(kcsq > 0, "TTM squeeze", "no")
|
|
||||||
d["kc_pos"] = np.select([close1 > kcu, close1 < kcl], ["above KC", "below KC"], default="inside")
|
|
||||||
align = np.sign(close1 - ef) + np.sign(ef - em) + np.sign(em - es) + (np.sign(es - et) if et is not None else 0)
|
|
||||||
d["ema_align"] = pd.cut(align, [-5, -3.5, -0.5, 0.5, 3.5, 5], labels=["fully bear(-4)", "mostly bear", "mixed", "mostly bull", "fully bull(+4)"])
|
|
||||||
ribbon = (pd.concat([ef, em, es], axis=1).max(axis=1) - pd.concat([ef, em, es], axis=1).min(axis=1)) / atr1
|
|
||||||
d["ribbon"] = np.where(ribbon < 1.0, "compressed(<1 ATR)", "expanded")
|
|
||||||
ema_dir = np.where(align > 0, 1, np.where(align < 0, -1, 0))
|
|
||||||
d["regime"] = np.select([(adx >= 25) & (ema_dir > 0), (adx >= 25) & (ema_dir < 0), adx >= 25, adxs > 3.0],
|
|
||||||
["trending_up", "trending_down", "trending(mixed)", "transitioning"], default="ranging")
|
|
||||||
atr_pct = atr1.rolling(50).apply(lambda w: (w[-1] > w).mean() * 100, raw=True)
|
|
||||||
d["vol_regime"] = pd.cut(atr_pct, [-1, 30, 60, 80, 101], labels=["low", "normal", "high", "extreme"])
|
|
||||||
d["roc"] = pd.cut(roc, [-1e9, -1, -0.3, 0.3, 1, 1e9], labels=["<-1%", "-1..-0.3", "-0.3..0.3", "0.3..1", ">1%"])
|
|
||||||
d["ema200_dist"] = pd.cut((close1 - et) / et * 100 if et is not None else pd.Series(np.nan, index=px.index),
|
|
||||||
[-1e9, -2.5, -0.5, 0.5, 2.5, 1e9], labels=["<-2.5%", "-2.5..-0.5", "near(±0.5%)", "0.5..2.5", ">2.5%"])
|
|
||||||
# time
|
|
||||||
ct = px.index + pd.Timedelta(minutes=bm)
|
|
||||||
d["hour"] = ct.hour; d["weekday"] = ct.weekday; d["dom"] = pd.cut(ct.day, [0, 5, 10, 15, 20, 25, 31], labels=["1-5", "6-10", "11-15", "16-20", "21-25", "26-31"])
|
|
||||||
d["first_friday"] = np.where((ct.weekday == 4) & (ct.day <= 7), "first Friday", "other")
|
|
||||||
# calendar proximity (high-impact USD)
|
|
||||||
cal = pd.read_parquet(CAL); cal["ts"] = pd.to_datetime(cal.dateline, unit="s", utc=True)
|
|
||||||
hi = np.sort(cal[(cal.currency == "USD") & (cal.impactName.str.lower() == "high")].ts.values.astype("datetime64[ns]"))
|
|
||||||
t = ct.values.astype("datetime64[ns]")
|
|
||||||
nxt = np.searchsorted(hi, t) # next event at/after bar close
|
|
||||||
mins_to = np.where(nxt < len(hi), (hi[np.minimum(nxt, len(hi) - 1)] - t) / np.timedelta64(1, "m"), 1e9)
|
|
||||||
prv = nxt - 1
|
|
||||||
mins_since = np.where(prv >= 0, (t - hi[np.maximum(prv, 0)]) / np.timedelta64(1, "m"), 1e9)
|
|
||||||
d["calendar"] = np.select([mins_since <= 30, mins_to <= 15, mins_to <= 60, mins_to <= 180], ["0-30m AFTER hi-impact", "next 15m", "15-60m ahead", "1-3h ahead"], default="none within 3h")
|
|
||||||
d = d[d.index >= pd.Timestamp(a.start, tz="UTC")].dropna(subset=["rsi", "adx", "bb_pos", "vol_regime"])
|
|
||||||
half = pd.Series(np.where(np.arange(len(d)) < len(d) // 2, 1, 2), index=d.index)
|
|
||||||
print(f"bars {len(d)} base next_bar {100*d.next_bar.mean():.3f}% next_hour {100*d.next_hour.mean():.2f}% (lift = rate / base; 'HIGH' = >1.15x in BOTH halves, 'low' = <0.85x in both)")
|
|
||||||
groups = ["calendar", "regime", "vol_regime", "rsi", "adx", "adx_slope", "macd", "bb_pos", "bb_squeeze", "kc_squeeze", "kc_pos", "ema_align", "ribbon", "roc", "ema200_dist", "dom", "first_friday"]
|
|
||||||
for target in ("next_bar", "next_hour"):
|
|
||||||
print(f"\n################ TARGET: {target} ################")
|
|
||||||
for g in groups:
|
|
||||||
print(f"-- {g}"); print(rate_table(d, g, target, half).to_string())
|
|
||||||
# weekday x hour top cells (next_bar)
|
|
||||||
piv = d.pivot_table(index="weekday", columns="hour", values="next_bar", aggfunc="mean") * 100
|
|
||||||
base = 100 * d.next_bar.mean()
|
|
||||||
cells = piv.stack().sort_values(ascending=False)
|
|
||||||
print(f"\n################ TOP 15 weekday x close-hour cells (next-bar burst pct, base {base:.2f}) ################")
|
|
||||||
names = {0: "Mon", 1: "Tue", 2: "Wed", 3: "Thu", 4: "Fri", 6: "Sun"}
|
|
||||||
for (wd, h), v in cells.head(15).items():
|
|
||||||
n = int(((d.weekday == wd) & (d.hour == h)).sum())
|
|
||||||
print(f" {names.get(wd, wd)} {h:02d}:xx {v:.2f}% ({v/base:.1f}x, n={n})")
|
|
||||||
# direction: does the state predict the burst direction?
|
|
||||||
b = d[d.burst == 1]
|
|
||||||
print("\n################ BURST DIRECTION vs prior state (share of bursts going UP) ################")
|
|
||||||
for g, up_states in (("ema_align", None), ("rsi", None), ("macd", None), ("regime", None)):
|
|
||||||
print(f"-- {g}: " + ", ".join(f"{k}: {100*(v>0).mean():.0f}% up (n={len(v)})" for k, v in b.groupby(g, observed=True).bdir if len(v) >= 20))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,445 +0,0 @@
|
|||||||
"""Where and when gold moves violently — burst map of the raw bars.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python backtest/research/burst_map.py --parquet PATH --bar-minutes N \
|
|
||||||
[--k 3,4,5] [--book BOOK.csv] [--within-bars 12] \
|
|
||||||
[--start YYYY-MM-DD --end YYYY-MM-DD] [--out DIR]
|
|
||||||
|
|
||||||
A "burst" is one bar whose range is >= K x the PRIOR bar's Wilder ATR(14) —
|
|
||||||
the same definition burst_profile.py uses (that module computes it inline
|
|
||||||
inside burst_table() and exposes no reusable helper, so the formula is
|
|
||||||
replicated here bar-for-bar: TR = max(H-L, |H-Cprev|, |L-Cprev|), ATR =
|
|
||||||
TR.ewm(alpha=1/14, min_periods=14, adjust=False).mean(), ratio =
|
|
||||||
(H-L) / ATR.shift(1)).
|
|
||||||
|
|
||||||
Reports:
|
|
||||||
* burst frequency by (weekday, UTC bar-CLOSE hour), plus mean |1 h| and
|
|
||||||
|4 h| absolute moves in $ and in ATR units on the same grid;
|
|
||||||
* post-burst drift in the burst's own direction over 6/12/24/48 bars,
|
|
||||||
overall and by session bucket — does a violent bar continue or snap back;
|
|
||||||
* the largest trailing-60-minute close-to-close moves, de-overlapped;
|
|
||||||
* if a trade book is given: how trades that were decided just after a burst,
|
|
||||||
or that were OPEN when one printed, performed against the rest.
|
|
||||||
|
|
||||||
Every ATR-unit number here is normalised by the PRIOR bar's ATR — the same
|
|
||||||
denominator as the burst test, so a "4x burst that ran another 4 ATR" reads
|
|
||||||
directly. All hours are UTC and are the bar's CLOSE hour (label + one bar),
|
|
||||||
matching the clock the live gates evaluate.
|
|
||||||
|
|
||||||
Analysis tooling only — never imported by the live code.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
try: # package import (backtest.research.burst_map)
|
|
||||||
from backtest.research.book_report import base_metrics, load_book, session_of
|
|
||||||
except ImportError: # run directly as a script from anywhere
|
|
||||||
from book_report import base_metrics, load_book, session_of
|
|
||||||
|
|
||||||
SLOT_UNKNOWN = ""
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────── bar primitives ─────────────────────────
|
|
||||||
|
|
||||||
def load_bars(parquet: str, start: str | None = None,
|
|
||||||
end: str | None = None) -> pd.DataFrame:
|
|
||||||
"""Read a cached OHLCV parquet, de-duplicated and oldest-first."""
|
|
||||||
df = pd.read_parquet(parquet).sort_index()
|
|
||||||
df = df[~df.index.duplicated(keep="last")]
|
|
||||||
if start:
|
|
||||||
df = df[df.index >= pd.Timestamp(start, tz="UTC")]
|
|
||||||
if end:
|
|
||||||
df = df[df.index <= pd.Timestamp(end, tz="UTC")]
|
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
def wilder_atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
|
||||||
"""Wilder ATR — identical to the inline formula in burst_profile.py."""
|
|
||||||
h, l, c = df["High"], df["Low"], df["Close"]
|
|
||||||
tr = pd.concat([h - l, (h - c.shift()).abs(), (l - c.shift()).abs()],
|
|
||||||
axis=1).max(axis=1)
|
|
||||||
return tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
|
|
||||||
|
|
||||||
|
|
||||||
def burst_ratio(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
|
||||||
"""Bar range expressed in PRIOR-bar ATRs."""
|
|
||||||
return (df["High"] - df["Low"]) / wilder_atr(df, period).shift(1)
|
|
||||||
|
|
||||||
|
|
||||||
def burst_flags(df: pd.DataFrame, k: float, period: int = 14) -> pd.Series:
|
|
||||||
"""True where the bar's range is >= k x the prior bar's Wilder ATR(14)."""
|
|
||||||
return (burst_ratio(df, period) >= k).fillna(False)
|
|
||||||
|
|
||||||
|
|
||||||
def burst_dir(df: pd.DataFrame) -> pd.Series:
|
|
||||||
"""+1 up bar, −1 down bar, 0 doji (sign of Close − Open)."""
|
|
||||||
return np.sign(df["Close"] - df["Open"]).astype(float)
|
|
||||||
|
|
||||||
|
|
||||||
def close_times(df: pd.DataFrame, bar_minutes: int) -> pd.DatetimeIndex:
|
|
||||||
"""Bar CLOSE timestamps (the parquet index labels bar OPEN time)."""
|
|
||||||
return df.index + pd.Timedelta(minutes=bar_minutes)
|
|
||||||
|
|
||||||
|
|
||||||
# ───────────────────── when does gold move? tables ──────────────────
|
|
||||||
|
|
||||||
def hour_weekday_table(df: pd.DataFrame, k: float, bar_minutes: int = 5,
|
|
||||||
ks: tuple[float, ...] | None = None) -> dict:
|
|
||||||
"""Burst frequency and absolute-move size on a (weekday, close-hour) grid.
|
|
||||||
|
|
||||||
Returns a dict of DataFrames (several tables are wanted, one frame each):
|
|
||||||
burst_pct : weekday rows x close-hour cols, % of bars that are >= k x
|
|
||||||
bursts (one frame; per-k frames live in `burst_pct_by_k`)
|
|
||||||
absmove : (weekday, close-hour) rows; bars, mean |1 h move| and
|
|
||||||
|4 h move| in $ and in prior-bar ATR units
|
|
||||||
by_hour : the same stats collapsed to close-hour
|
|
||||||
by_weekday : the same stats collapsed to weekday
|
|
||||||
"""
|
|
||||||
ks = tuple(ks) if ks else (k,)
|
|
||||||
ct = close_times(df, bar_minutes)
|
|
||||||
hour = pd.Series(ct.hour, index=df.index)
|
|
||||||
wd = pd.Series(ct.weekday, index=df.index)
|
|
||||||
atr_prev = wilder_atr(df).shift(1)
|
|
||||||
|
|
||||||
per_hour = max(1, int(round(60 / bar_minutes)))
|
|
||||||
c = df["Close"]
|
|
||||||
mv1 = (c - c.shift(per_hour)).abs()
|
|
||||||
mv4 = (c - c.shift(4 * per_hour)).abs()
|
|
||||||
stats = pd.DataFrame({
|
|
||||||
"weekday": wd, "hour": hour,
|
|
||||||
"abs1h_usd": mv1, "abs4h_usd": mv4,
|
|
||||||
"abs1h_atr": mv1 / atr_prev, "abs4h_atr": mv4 / atr_prev,
|
|
||||||
})
|
|
||||||
for kk in ks:
|
|
||||||
stats[f"burst_{kk:g}x"] = burst_flags(df, kk).astype(float) * 100.0
|
|
||||||
|
|
||||||
agg = {"abs1h_usd": "mean", "abs4h_usd": "mean",
|
|
||||||
"abs1h_atr": "mean", "abs4h_atr": "mean"}
|
|
||||||
agg.update({f"burst_{kk:g}x": "mean" for kk in ks})
|
|
||||||
|
|
||||||
grid = stats.groupby(["weekday", "hour"]).agg(agg)
|
|
||||||
grid.insert(0, "bars", stats.groupby(["weekday", "hour"]).size())
|
|
||||||
by_hour = stats.groupby("hour").agg(agg)
|
|
||||||
by_hour.insert(0, "bars", stats.groupby("hour").size())
|
|
||||||
by_wd = stats.groupby("weekday").agg(agg)
|
|
||||||
by_wd.insert(0, "bars", stats.groupby("weekday").size())
|
|
||||||
|
|
||||||
pct_by_k = {kk: grid[f"burst_{kk:g}x"].unstack("hour") for kk in ks}
|
|
||||||
return {
|
|
||||||
"burst_pct": pct_by_k[k if k in pct_by_k else ks[0]].round(3),
|
|
||||||
"burst_pct_by_k": {kk: v.round(3) for kk, v in pct_by_k.items()},
|
|
||||||
"absmove": grid.round(3),
|
|
||||||
"by_hour": by_hour.round(3),
|
|
||||||
"by_weekday": by_wd.round(3),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def post_burst_drift(df: pd.DataFrame, k: float,
|
|
||||||
horizons: tuple[int, ...] = (6, 12, 24, 48),
|
|
||||||
bar_minutes: int = 5) -> pd.DataFrame:
|
|
||||||
"""Signed drift IN the burst's direction over the next h bars, in ATRs.
|
|
||||||
|
|
||||||
A positive mean means violent bars continue; negative means they snap
|
|
||||||
back. Split by the session bucket of the burst bar's close hour.
|
|
||||||
"""
|
|
||||||
flags = burst_flags(df, k)
|
|
||||||
d = burst_dir(df)
|
|
||||||
atr_prev = wilder_atr(df).shift(1)
|
|
||||||
ct = close_times(df, bar_minutes)
|
|
||||||
bucket = pd.Series([session_of(h) for h in ct.hour], index=df.index)
|
|
||||||
c = df["Close"]
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
for h in horizons:
|
|
||||||
fwd = (c.shift(-h) - c) * d / atr_prev
|
|
||||||
sub = fwd[flags].dropna()
|
|
||||||
buckets = bucket[flags].reindex(sub.index)
|
|
||||||
rows.append({"horizon_bars": h, "bucket": "ALL", "n": int(len(sub)),
|
|
||||||
"mean_atr": round(float(sub.mean()), 4) if len(sub) else float("nan"),
|
|
||||||
"median_atr": round(float(sub.median()), 4) if len(sub) else float("nan"),
|
|
||||||
"pct_positive": round(float((sub > 0).mean() * 100), 2) if len(sub) else float("nan")})
|
|
||||||
for b, s in sub.groupby(buckets):
|
|
||||||
rows.append({"horizon_bars": h, "bucket": b, "n": int(len(s)),
|
|
||||||
"mean_atr": round(float(s.mean()), 4),
|
|
||||||
"median_atr": round(float(s.median()), 4),
|
|
||||||
"pct_positive": round(float((s > 0).mean() * 100), 2)})
|
|
||||||
return pd.DataFrame(rows)
|
|
||||||
|
|
||||||
|
|
||||||
def slot_label(weekday: int, hour: int, day: int) -> str:
|
|
||||||
"""Time-slot label for a big move.
|
|
||||||
|
|
||||||
NOTE: there is no economic-calendar file in this repo, so these are
|
|
||||||
TIME SLOTS, not confirmed events — "payroll window" means "the clock
|
|
||||||
slot US payrolls normally print in", nothing more.
|
|
||||||
"""
|
|
||||||
if weekday == 4 and day <= 7 and hour in (12, 13):
|
|
||||||
return "first-Friday 12:30 slot (payroll-report window)"
|
|
||||||
if weekday == 2 and hour in (18, 19):
|
|
||||||
return "Wednesday 18:00 slot (central-bank window)"
|
|
||||||
if hour in (12, 13):
|
|
||||||
return "12:30 slot (US data window)"
|
|
||||||
if hour == 14:
|
|
||||||
return "14:00 slot"
|
|
||||||
if hour in (21, 22, 23):
|
|
||||||
return "settlement/after-hours"
|
|
||||||
if hour in (0, 1, 2):
|
|
||||||
return "Asia open"
|
|
||||||
return SLOT_UNKNOWN
|
|
||||||
|
|
||||||
|
|
||||||
def largest_moves(df: pd.DataFrame, n: int = 40, window_minutes: int = 60,
|
|
||||||
bar_minutes: int = 5) -> pd.DataFrame:
|
|
||||||
"""The n biggest trailing-`window_minutes` close-to-close moves.
|
|
||||||
|
|
||||||
Overlapping windows are de-duplicated greedily: the largest move wins and
|
|
||||||
anything whose close time is within +/- `window_minutes` of an already
|
|
||||||
kept move is dropped, so one violent hour contributes one row.
|
|
||||||
"""
|
|
||||||
bars = max(1, int(round(window_minutes / bar_minutes)))
|
|
||||||
c = df["Close"]
|
|
||||||
move = c - c.shift(bars)
|
|
||||||
atr_prev = wilder_atr(df).shift(1)
|
|
||||||
ct = close_times(df, bar_minutes)
|
|
||||||
tbl = pd.DataFrame({
|
|
||||||
"close_time": ct, "move_usd": move.to_numpy(),
|
|
||||||
"move_atr": (move / atr_prev).to_numpy(),
|
|
||||||
}).dropna()
|
|
||||||
tbl["abs_usd"] = tbl["move_usd"].abs()
|
|
||||||
tbl = tbl.sort_values("abs_usd", ascending=False)
|
|
||||||
|
|
||||||
keep, taken = [], []
|
|
||||||
gap = pd.Timedelta(minutes=window_minutes)
|
|
||||||
for _, row in tbl.iterrows():
|
|
||||||
t = row["close_time"]
|
|
||||||
if any(abs(t - u) <= gap for u in taken):
|
|
||||||
continue
|
|
||||||
taken.append(t)
|
|
||||||
keep.append(row)
|
|
||||||
if len(keep) >= n:
|
|
||||||
break
|
|
||||||
out = pd.DataFrame(keep).reset_index(drop=True)
|
|
||||||
if out.empty:
|
|
||||||
return out
|
|
||||||
out["weekday"] = out["close_time"].dt.weekday
|
|
||||||
out["hour"] = out["close_time"].dt.hour
|
|
||||||
out["direction"] = np.where(out["move_usd"] > 0, "UP", "DOWN")
|
|
||||||
out["slot"] = [slot_label(w, h, t.day) for w, h, t
|
|
||||||
in zip(out["weekday"], out["hour"], out["close_time"])]
|
|
||||||
out["move_usd"] = out["move_usd"].round(2)
|
|
||||||
out["move_atr"] = out["move_atr"].round(2)
|
|
||||||
return out[["close_time", "weekday", "hour", "direction", "move_usd",
|
|
||||||
"move_atr", "slot"]]
|
|
||||||
|
|
||||||
|
|
||||||
# ───────────────────────── book <-> burst join ──────────────────────
|
|
||||||
|
|
||||||
def _stats(r: pd.Series) -> dict:
|
|
||||||
m = base_metrics(r)
|
|
||||||
return {"n": m["n"], "total_r": round(m["total_r"], 2),
|
|
||||||
"mean_r": round(m["mean_r"], 3), "wr": round(m["wr"], 2),
|
|
||||||
"pf": round(m["pf"], 2) if math.isfinite(m["pf"]) else float("inf")}
|
|
||||||
|
|
||||||
|
|
||||||
def join_book_to_bursts(book_df: pd.DataFrame, bars: pd.DataFrame, k: float,
|
|
||||||
within_bars: int = 12,
|
|
||||||
bar_minutes: int = 5) -> dict:
|
|
||||||
"""Split a trade book by its relationship to burst bars.
|
|
||||||
|
|
||||||
(a) `after_burst` : decision_time falls within `within_bars` bars after
|
|
||||||
the most recent burst's close (bar distance is counted POSITIONALLY on
|
|
||||||
the bar index, so weekend gaps cannot inflate it).
|
|
||||||
(b) `burst_during` : at least one burst closed inside [entry, exit).
|
|
||||||
(c) `burst_agrees` / `burst_opposes`: of (b), whether the FIRST burst that
|
|
||||||
printed during the trade pushed with or against the trade direction.
|
|
||||||
"""
|
|
||||||
ct = close_times(bars, bar_minutes)
|
|
||||||
ct_arr = ct.to_numpy()
|
|
||||||
flags = burst_flags(bars, k).to_numpy()
|
|
||||||
bdir = burst_dir(bars).to_numpy()
|
|
||||||
burst_pos = np.flatnonzero(flags)
|
|
||||||
|
|
||||||
# Last burst position at or before each bar (−1 where none yet).
|
|
||||||
last_burst = np.where(flags, np.arange(len(flags)), -1)
|
|
||||||
last_burst = np.maximum.accumulate(last_burst)
|
|
||||||
|
|
||||||
dec = book_df["decision_time"].astype("datetime64[ns, UTC]").to_numpy()
|
|
||||||
ent = book_df["entry_time"].astype("datetime64[ns, UTC]").to_numpy()
|
|
||||||
exi = book_df["exit_time"].astype("datetime64[ns, UTC]").to_numpy()
|
|
||||||
|
|
||||||
# Position of the last bar that had CLOSED by decision_time.
|
|
||||||
idx = np.searchsorted(ct_arr, dec, side="right") - 1
|
|
||||||
valid = idx >= 0
|
|
||||||
lb = np.where(valid, last_burst[np.clip(idx, 0, len(flags) - 1)], -1)
|
|
||||||
bars_since = np.where((lb >= 0) & valid, idx - lb, np.iinfo(np.int32).max)
|
|
||||||
after = pd.Series((bars_since >= 0) & (bars_since <= within_bars),
|
|
||||||
index=book_df.index)
|
|
||||||
|
|
||||||
# Bursts printing inside the open trade.
|
|
||||||
bct = ct_arr[burst_pos] if len(burst_pos) else np.array([], dtype=ct_arr.dtype)
|
|
||||||
lo = np.searchsorted(bct, ent, side="left")
|
|
||||||
hi = np.searchsorted(bct, exi, side="left")
|
|
||||||
during = pd.Series(hi > lo, index=book_df.index)
|
|
||||||
trade_sign = np.where(
|
|
||||||
book_df["direction"].astype(str).str.upper().str.startswith("B"),
|
|
||||||
1.0, -1.0)
|
|
||||||
has = hi > lo
|
|
||||||
first_dir = np.zeros(len(book_df), dtype=float)
|
|
||||||
if len(burst_pos) and has.any():
|
|
||||||
# hi > lo guarantees lo[has] indexes a real burst.
|
|
||||||
first_dir[has] = bdir[burst_pos[lo[has]]]
|
|
||||||
agrees = pd.Series(has & (first_dir * trade_sign > 0),
|
|
||||||
index=book_df.index)
|
|
||||||
opposes = pd.Series(has & (first_dir * trade_sign < 0),
|
|
||||||
index=book_df.index)
|
|
||||||
|
|
||||||
r = book_df["r_multiple"]
|
|
||||||
out = {
|
|
||||||
"k": k, "within_bars": within_bars,
|
|
||||||
"bursts_in_window": int(len(burst_pos)),
|
|
||||||
"after_burst": _stats(r[after]),
|
|
||||||
"not_after_burst": _stats(r[~after]),
|
|
||||||
"burst_during": _stats(r[during]),
|
|
||||||
"no_burst_during": _stats(r[~during]),
|
|
||||||
"burst_agrees": _stats(r[agrees]),
|
|
||||||
"burst_opposes": _stats(r[opposes]),
|
|
||||||
"median_bars_since_burst": float(np.median(
|
|
||||||
bars_since[bars_since < np.iinfo(np.int32).max])) if (
|
|
||||||
bars_since < np.iinfo(np.int32).max).any() else float("nan"),
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────── reporting ─────────────────────────────
|
|
||||||
|
|
||||||
def _fmt_stats(name: str, s: dict) -> str:
|
|
||||||
pf = "inf" if math.isinf(s["pf"]) else f"{s['pf']:.2f}"
|
|
||||||
return (f" {name:<18} n={s['n']:<5} totalR={s['total_r']:>8.2f} "
|
|
||||||
f"meanR={s['mean_r']:>7.3f} WR={s['wr']:>6.2f}% PF={pf}")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
||||||
ap.add_argument("--parquet", required=True)
|
|
||||||
ap.add_argument("--bar-minutes", type=int, required=True)
|
|
||||||
ap.add_argument("--k", default="3,4,5",
|
|
||||||
help="burst thresholds in prior-bar ATRs (default 3,4,5)")
|
|
||||||
ap.add_argument("--book", default=None)
|
|
||||||
ap.add_argument("--book-bar-minutes", type=int, default=None,
|
|
||||||
help="bar minutes of the BOOK (default: --bar-minutes)")
|
|
||||||
ap.add_argument("--within-bars", type=int, default=12)
|
|
||||||
ap.add_argument("--start", default=None)
|
|
||||||
ap.add_argument("--end", default=None)
|
|
||||||
ap.add_argument("--n-moves", type=int, default=40)
|
|
||||||
ap.add_argument("--out", default="backtest/research/burst_map_out")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
ks = [float(x) for x in args.k.split(",") if x.strip()]
|
|
||||||
os.makedirs(args.out, exist_ok=True)
|
|
||||||
bars = load_bars(args.parquet, args.start, args.end)
|
|
||||||
bm = args.bar_minutes
|
|
||||||
primary = ks[0]
|
|
||||||
|
|
||||||
lines: list[str] = []
|
|
||||||
add = lines.append
|
|
||||||
add("=" * 78)
|
|
||||||
add(f"BURST MAP {os.path.basename(args.parquet)}")
|
|
||||||
add("=" * 78)
|
|
||||||
add(f"bars : {len(bars)} ({bars.index[0]} -> {bars.index[-1]}"
|
|
||||||
f", labels = bar OPEN time)")
|
|
||||||
add(f"bar minutes : {bm} hours below are bar CLOSE hours (UTC)")
|
|
||||||
add(f"burst definition : range >= K x PRIOR bar Wilder ATR(14); "
|
|
||||||
f"K = {', '.join(f'{k:g}' for k in ks)}")
|
|
||||||
add("ATR units : always normalised by the PRIOR bar's ATR")
|
|
||||||
for k in ks:
|
|
||||||
f = burst_flags(bars, k)
|
|
||||||
add(f" K={k:g}: {int(f.sum())} burst bars "
|
|
||||||
f"({float(f.mean()) * 100:.3f}% of bars)")
|
|
||||||
|
|
||||||
tabs = hour_weekday_table(bars, primary, bm, ks=tuple(ks))
|
|
||||||
add("")
|
|
||||||
add(f"── BURST % BY (weekday, close-hour), K={primary:g} ──")
|
|
||||||
add(tabs["burst_pct"].fillna(0).round(2).to_string())
|
|
||||||
add("")
|
|
||||||
add("── MEAN ABSOLUTE MOVE BY CLOSE-HOUR (all weekdays) ──")
|
|
||||||
add(tabs["by_hour"].to_string())
|
|
||||||
add("")
|
|
||||||
add("── MEAN ABSOLUTE MOVE BY WEEKDAY ──")
|
|
||||||
add(tabs["by_weekday"].to_string())
|
|
||||||
|
|
||||||
add("")
|
|
||||||
add(f"── POST-BURST DRIFT (in the burst's own direction), K={primary:g} ──")
|
|
||||||
drift = post_burst_drift(bars, primary, bar_minutes=bm)
|
|
||||||
add(drift.to_string(index=False))
|
|
||||||
|
|
||||||
add("")
|
|
||||||
add(f"── LARGEST {args.n_moves} TRAILING-60-MIN MOVES (de-overlapped) ──")
|
|
||||||
add(" slot labels are CLOCK SLOTS — no economic calendar exists in "
|
|
||||||
"this repo, so no event is confirmed.")
|
|
||||||
moves = largest_moves(bars, n=args.n_moves, bar_minutes=bm)
|
|
||||||
add(moves.to_string(index=False))
|
|
||||||
|
|
||||||
join_rows = []
|
|
||||||
if args.book:
|
|
||||||
bbm = args.book_bar_minutes or bm
|
|
||||||
book = load_book(args.book, bbm)
|
|
||||||
add("")
|
|
||||||
add(f"── BOOK JOIN {os.path.basename(args.book)} "
|
|
||||||
f"({len(book)} trades, book bar = {bbm} min) ──")
|
|
||||||
first, last = close_times(bars, bm)[0], close_times(bars, bm)[-1]
|
|
||||||
outside = int(((book["decision_time"] < first)
|
|
||||||
| (book["exit_time"] > last)).sum())
|
|
||||||
if outside:
|
|
||||||
add(f" !! {outside} trade(s) start before / end after the bar "
|
|
||||||
f"window ({first} .. {last}) — their burst context is "
|
|
||||||
f"partial. Widen --start/--end or use a longer parquet.")
|
|
||||||
add(" CAUTION: group (b) is survivorship-shaped — a trade only sees "
|
|
||||||
"a burst if it survived long enough to still be open, so its high "
|
|
||||||
"PF is mostly 'winners last longer', not a burst edge.")
|
|
||||||
for k in ks:
|
|
||||||
j = join_book_to_bursts(book, bars, k, args.within_bars, bm)
|
|
||||||
add(f" K={k:g} ({j['bursts_in_window']} bursts in window; "
|
|
||||||
f"median bars since last burst = "
|
|
||||||
f"{j['median_bars_since_burst']:.0f})")
|
|
||||||
add(f" (a) decided within {args.within_bars} bars of a burst")
|
|
||||||
add(_fmt_stats("after_burst", j["after_burst"]))
|
|
||||||
add(_fmt_stats("not_after_burst", j["not_after_burst"]))
|
|
||||||
add(" (b) a burst printed while the trade was open")
|
|
||||||
add(_fmt_stats("burst_during", j["burst_during"]))
|
|
||||||
add(_fmt_stats("no_burst_during", j["no_burst_during"]))
|
|
||||||
add(" (c) of (b), first burst vs the trade's direction")
|
|
||||||
add(_fmt_stats("burst_agrees", j["burst_agrees"]))
|
|
||||||
add(_fmt_stats("burst_opposes", j["burst_opposes"]))
|
|
||||||
for grp in ("after_burst", "not_after_burst", "burst_during",
|
|
||||||
"no_burst_during", "burst_agrees", "burst_opposes"):
|
|
||||||
join_rows.append({"k": k, "group": grp,
|
|
||||||
"within_bars": args.within_bars,
|
|
||||||
**j[grp]})
|
|
||||||
|
|
||||||
text = "\n".join(lines)
|
|
||||||
print(text)
|
|
||||||
with open(os.path.join(args.out, "burst_map.txt"), "w") as fh:
|
|
||||||
fh.write(text + "\n")
|
|
||||||
pd.concat([v.assign(k=kk) for kk, v in tabs["burst_pct_by_k"].items()]
|
|
||||||
).to_csv(os.path.join(args.out, "hour_weekday_burst_pct.csv"))
|
|
||||||
tabs["absmove"].to_csv(os.path.join(args.out, "hour_weekday_absmove.csv"))
|
|
||||||
tabs["by_hour"].to_csv(os.path.join(args.out, "by_hour.csv"))
|
|
||||||
tabs["by_weekday"].to_csv(os.path.join(args.out, "by_weekday.csv"))
|
|
||||||
pd.concat([post_burst_drift(bars, k, bar_minutes=bm).assign(k=k)
|
|
||||||
for k in ks]).to_csv(
|
|
||||||
os.path.join(args.out, "post_burst_drift.csv"), index=False)
|
|
||||||
moves.to_csv(os.path.join(args.out, "largest_moves.csv"), index=False)
|
|
||||||
if join_rows:
|
|
||||||
pd.DataFrame(join_rows).to_csv(
|
|
||||||
os.path.join(args.out, "book_join.csv"), index=False)
|
|
||||||
print(f"\n[written] {os.path.join(args.out, 'burst_map.txt')}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
"""Are there signs BEFORE a violent bar? Analysis tooling only — never imported by the live code.
|
|
||||||
|
|
||||||
For every bar t, features are computed from bars <= t-1 only. Targets:
|
|
||||||
next_bar : bar t itself is a burst (range >= K x prior Wilder ATR14)
|
|
||||||
next_hour : any burst in bars t .. t+H-1
|
|
||||||
Reports per-quintile burst rates (lift vs base rate) in both halves, the same
|
|
||||||
within/outside the scheduled-data hours, and an out-of-sample logistic model
|
|
||||||
(fit on half 1, scored on half 2): AUC + lift in the top 5% of scores.
|
|
||||||
|
|
||||||
python backtest/research/burst_precursors.py PARQUET BAR_MINUTES [--k 3] [--start ... --end ...]
|
|
||||||
"""
|
|
||||||
import argparse, sys
|
|
||||||
import numpy as np, pandas as pd
|
|
||||||
|
|
||||||
|
|
||||||
def wilder_atr(df, period=14):
|
|
||||||
pc = df["Close"].shift(1)
|
|
||||||
tr = pd.concat([df["High"] - df["Low"], (df["High"] - pc).abs(), (df["Low"] - pc).abs()], axis=1).max(axis=1)
|
|
||||||
return tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
|
|
||||||
|
|
||||||
|
|
||||||
def build(df, bar_minutes, k):
|
|
||||||
d = df.copy()
|
|
||||||
rng = d["High"] - d["Low"]
|
|
||||||
atr_prev = wilder_atr(d).shift(1) # ATR known at t-1
|
|
||||||
d["burst"] = (rng >= k * atr_prev).astype(int)
|
|
||||||
H = max(1, 60 // bar_minutes)
|
|
||||||
# burst within next H bars (t..t+H-1)
|
|
||||||
d["next_hour"] = d["burst"][::-1].rolling(H, min_periods=1).max()[::-1].astype(int)
|
|
||||||
d["next_bar"] = d["burst"]
|
|
||||||
close_t = d.index + pd.Timedelta(minutes=bar_minutes)
|
|
||||||
d["hour"] = close_t.hour
|
|
||||||
r1 = rng.shift(1)
|
|
||||||
d["squeeze6"] = r1.rolling(6).mean() / atr_prev
|
|
||||||
d["squeeze12"] = r1.rolling(12).mean() / atr_prev
|
|
||||||
d["last_bar_range"] = r1 / atr_prev
|
|
||||||
sma = d["Close"].shift(1).rolling(20).mean(); sd = d["Close"].shift(1).rolling(20).std(ddof=0)
|
|
||||||
bbw = (4 * sd / sma)
|
|
||||||
d["bbw_pct500"] = bbw.rolling(500).rank(pct=True)
|
|
||||||
v1 = d["Volume"].shift(1)
|
|
||||||
d["vol_ratio"] = v1.rolling(3).mean() / v1.rolling(50).mean()
|
|
||||||
d["drift12"] = (d["Close"].shift(1) - d["Close"].shift(13)).abs() / atr_prev
|
|
||||||
d["atr_pct500"] = atr_prev.rolling(500).rank(pct=True)
|
|
||||||
b = d["burst"].shift(1).fillna(0).astype(int)
|
|
||||||
since = np.zeros(len(d)); cnt = 200
|
|
||||||
bv = b.values
|
|
||||||
for i in range(len(d)):
|
|
||||||
cnt = 0 if bv[i] == 1 else min(200, cnt + 1)
|
|
||||||
since[i] = cnt
|
|
||||||
d["bars_since_burst"] = since
|
|
||||||
return d.dropna(subset=["squeeze6", "squeeze12", "bbw_pct500", "vol_ratio", "drift12", "atr_pct500"])
|
|
||||||
|
|
||||||
|
|
||||||
FEATS = ["squeeze6", "squeeze12", "last_bar_range", "bbw_pct500", "vol_ratio", "drift12", "atr_pct500", "bars_since_burst"]
|
|
||||||
|
|
||||||
|
|
||||||
def quintile_table(d, feat, target, half):
|
|
||||||
q = pd.qcut(d[feat], 5, labels=False, duplicates="drop")
|
|
||||||
base1 = d.loc[half == 1, target].mean(); base2 = d.loc[half == 2, target].mean()
|
|
||||||
rows = []
|
|
||||||
for b in sorted(q.dropna().unique()):
|
|
||||||
m = q == b
|
|
||||||
lo, hi = d.loc[m, feat].min(), d.loc[m, feat].max()
|
|
||||||
r1 = d.loc[m & (half == 1), target].mean(); r2 = d.loc[m & (half == 2), target].mean()
|
|
||||||
rows.append((int(b), lo, hi, int(m.sum()), 100 * r1, r1 / base1, 100 * r2, r2 / base2))
|
|
||||||
t = pd.DataFrame(rows, columns=["bin", "lo", "hi", "n", "h1_rate%", "h1_lift", "h2_rate%", "h2_lift"])
|
|
||||||
return t.round(3)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(); ap.add_argument("parquet"); ap.add_argument("bar_minutes", type=int)
|
|
||||||
ap.add_argument("--k", type=float, default=3.0); ap.add_argument("--start"); ap.add_argument("--end")
|
|
||||||
a = ap.parse_args()
|
|
||||||
df = pd.read_parquet(a.parquet)
|
|
||||||
if a.start: df = df[df.index >= pd.Timestamp(a.start, tz="UTC")]
|
|
||||||
if a.end: df = df[df.index < pd.Timestamp(a.end, tz="UTC")]
|
|
||||||
d = build(df, a.bar_minutes, a.k)
|
|
||||||
half = pd.Series(np.where(np.arange(len(d)) < len(d) // 2, 1, 2), index=d.index)
|
|
||||||
print(f"bars {len(d)} K={a.k} base rate next_bar {100*d.next_bar.mean():.3f}% next_hour {100*d.next_hour.mean():.2f}%")
|
|
||||||
print(f"half 1 ends {d.index[len(d)//2 - 1]}")
|
|
||||||
# 1. the clock
|
|
||||||
hr = d.groupby("hour")[["next_bar", "next_hour"]].mean() * 100
|
|
||||||
hr["lift_next_bar"] = hr["next_bar"] / (100 * d.next_bar.mean())
|
|
||||||
print("\n== THE CLOCK (bar-close hour UTC): burst rate % and lift ==")
|
|
||||||
print(hr.round(2).T.to_string())
|
|
||||||
sched = d["hour"].isin([1, 12, 13])
|
|
||||||
print(f"\nscheduled hours (01,12,13) hold {100*d.loc[sched,'next_bar'].sum()/d.next_bar.sum():.1f}% of bursts in {100*sched.mean():.1f}% of bars")
|
|
||||||
# 2. per-feature quintiles, both halves
|
|
||||||
for target in ("next_bar", "next_hour"):
|
|
||||||
print(f"\n== TARGET {target}: quintile burst rate (lift vs base) — ALL hours ==")
|
|
||||||
for f in FEATS:
|
|
||||||
t = quintile_table(d, f, target, half)
|
|
||||||
print(f"-- {f}: lifts h1 {list(t.h1_lift)} h2 {list(t.h2_lift)} (bins lo..hi {t.lo.iloc[0]:.2f}..{t.hi.iloc[-1]:.2f})")
|
|
||||||
# 3. same, OUTSIDE the scheduled hours (does anything predict the unscheduled ones?)
|
|
||||||
u = d[~sched]; hu = half[~sched]
|
|
||||||
print(f"\n== next_bar lifts OUTSIDE scheduled hours (base {100*u.next_bar.mean():.3f}%) ==")
|
|
||||||
for f in FEATS:
|
|
||||||
t = quintile_table(u, f, "next_bar", hu)
|
|
||||||
print(f"-- {f}: h1 {list(t.h1_lift)} h2 {list(t.h2_lift)}")
|
|
||||||
# 4. OOS logistic model: fit half 1, score half 2
|
|
||||||
try:
|
|
||||||
from sklearn.linear_model import LogisticRegression
|
|
||||||
from sklearn.metrics import roc_auc_score
|
|
||||||
X = d[FEATS].copy()
|
|
||||||
for h in range(24): X[f"h{h}"] = (d["hour"] == h).astype(float)
|
|
||||||
Xc = d[FEATS].copy() # clock-free variant
|
|
||||||
for name, XX in (("features+clock", X), ("features only", Xc), ("clock only", X[[f"h{h}" for h in range(24)]])):
|
|
||||||
mu, sd = XX[half == 1].mean(), XX[half == 1].std().replace(0, 1)
|
|
||||||
Z = (XX - mu) / sd
|
|
||||||
for target in ("next_bar", "next_hour"):
|
|
||||||
m = LogisticRegression(C=0.1, max_iter=2000, class_weight="balanced").fit(Z[half == 1], d.loc[half == 1, target])
|
|
||||||
p = m.predict_proba(Z[half == 2])[:, 1]; y = d.loc[half == 2, target]
|
|
||||||
auc = roc_auc_score(y, p)
|
|
||||||
top = p >= np.quantile(p, 0.95)
|
|
||||||
print(f"OOS {name:15s} {target:9s}: AUC {auc:.3f} | top-5% of scores: burst rate {100*y[top].mean():.2f}% vs base {100*y.mean():.2f}% (lift {y[top].mean()/y.mean():.2f}x), captures {100*y[top].sum()/y.sum():.1f}% of bursts")
|
|
||||||
except ImportError:
|
|
||||||
print("sklearn missing — OOS model skipped")
|
|
||||||
# 5. direction: does the burst continue the prior 12-bar drift?
|
|
||||||
bt = d[d.burst == 1]
|
|
||||||
prior = np.sign(bt["Close"].shift(1) - bt["Close"].shift(13)) if False else np.sign(d["Close"].shift(1) - d["Close"].shift(13))[d.burst == 1]
|
|
||||||
bdir = np.sign(bt["Close"] - bt["Open"])
|
|
||||||
agree = (prior == bdir)[(prior != 0) & (bdir != 0)]
|
|
||||||
print(f"\nburst direction == prior-hour drift direction: {100*agree.mean():.1f}% of {len(agree)} bursts (50% = no information)")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
"""Do scheduled economic events move gold violently, and do they hurt OPEN trades? Analysis tooling only.
|
|
||||||
|
|
||||||
Needs backtest/cache/calendar.parquet (ff_calendar_scraper.py). Uses USD events by impact.
|
|
||||||
python backtest/research/calendar_impact.py PARQUET_BARS BAR_MINUTES BOOK.csv ARM DIST [--start --end]
|
|
||||||
"""
|
|
||||||
import argparse, sys
|
|
||||||
from pathlib import Path
|
|
||||||
import numpy as np, pandas as pd
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
||||||
from open_trade_bursts import wilder_atr, simulate, r_of, stats, K, COST
|
|
||||||
|
|
||||||
CAL = Path(__file__).resolve().parent.parent / "cache" / "calendar.parquet"
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(); ap.add_argument("parquet"); ap.add_argument("bar_minutes", type=int)
|
|
||||||
ap.add_argument("book"); ap.add_argument("arm", type=float); ap.add_argument("dist", type=float)
|
|
||||||
ap.add_argument("--start", default="2023-09-05"); ap.add_argument("--end", default="2026-09-05")
|
|
||||||
a = ap.parse_args(); bm = a.bar_minutes
|
|
||||||
cal = pd.read_parquet(CAL)
|
|
||||||
cal["ts"] = pd.to_datetime(cal["dateline"], unit="s", utc=True)
|
|
||||||
cal = cal[(cal.ts >= pd.Timestamp(a.start, tz="UTC")) & (cal.ts < pd.Timestamp(a.end, tz="UTC"))]
|
|
||||||
usd = cal[cal.currency == "USD"].copy()
|
|
||||||
usd["impact"] = usd["impactName"].str.lower()
|
|
||||||
print(f"calendar: {len(cal)} events in window, USD {len(usd)}: " + ", ".join(f"{k}={v}" for k, v in usd.impact.value_counts().items()))
|
|
||||||
px = pd.read_parquet(a.parquet); px = px[(px.index >= pd.Timestamp(a.start, tz="UTC")) & (px.index < pd.Timestamp(a.end, tz="UTC"))]
|
|
||||||
atr_prev = wilder_atr(px).shift(1)
|
|
||||||
rng = px["High"] - px["Low"]; burst = (rng >= K * atr_prev).fillna(False)
|
|
||||||
close = px["Close"]; idx = px.index
|
|
||||||
H1 = 60 // bm
|
|
||||||
move60 = (close.shift(-H1) - close).abs() / atr_prev # |move| over the next hour from bar OPEN-ish (label)
|
|
||||||
move15 = (close.shift(-max(1, 15 // bm)) - close).abs() / atr_prev
|
|
||||||
burst60 = burst[::-1].rolling(H1, min_periods=1).max()[::-1].astype(bool)
|
|
||||||
# event -> bar containing it (label <= ts < label + bm)
|
|
||||||
def bar_pos(ts):
|
|
||||||
p = idx.searchsorted(ts, side="right") - 1
|
|
||||||
return p if 0 <= p < len(idx) and (ts - idx[p]) < pd.Timedelta(minutes=bm) else -1
|
|
||||||
usd["pos"] = [bar_pos(t) for t in usd.ts]
|
|
||||||
usd = usd[usd.pos >= 0]
|
|
||||||
usd["bar_range_atr"] = rng.values[usd.pos] / atr_prev.values[usd.pos]
|
|
||||||
usd["burst_in_60m"] = burst60.values[usd.pos]
|
|
||||||
usd["move60_atr"] = move60.values[usd.pos]; usd["move60_usd"] = (close.shift(-H1) - close).abs().values[usd.pos]
|
|
||||||
usd["move15_atr"] = move15.values[usd.pos]
|
|
||||||
# baseline: all bars, and bars in the same close-hours without any USD high/medium event within the hour
|
|
||||||
chour = (idx + pd.Timedelta(minutes=bm)).hour
|
|
||||||
ev_bars = set()
|
|
||||||
for p in usd[usd.impact.isin(["high", "medium"])].pos: ev_bars.update(range(max(0, p - H1), min(len(idx), p + H1)))
|
|
||||||
quiet = ~np.isin(np.arange(len(idx)), list(ev_bars))
|
|
||||||
def base_row(mask, label):
|
|
||||||
return dict(group=label, n=int(mask.sum()), bar_range_atr=float((rng / atr_prev)[mask].mean()), burst_in_60m_pct=100 * float(burst60[mask].mean()),
|
|
||||||
move15_atr=float(move15[mask].mean()), move60_atr=float(move60[mask].mean()), move60_usd=float((close.shift(-H1) - close).abs()[mask].mean()))
|
|
||||||
rows = [base_row(np.ones(len(idx), bool), "ALL bars"), base_row(quiet & np.isin(chour, [12, 13, 14]), "12-14 UTC bars with NO USD event")]
|
|
||||||
for imp in ("high", "medium", "low"):
|
|
||||||
g = usd[usd.impact == imp]
|
|
||||||
rows.append(dict(group=f"USD {imp}-impact events", n=len(g), bar_range_atr=g.bar_range_atr.mean(), burst_in_60m_pct=100 * g.burst_in_60m.mean(),
|
|
||||||
move15_atr=g.move15_atr.mean(), move60_atr=g.move60_atr.mean(), move60_usd=g.move60_usd.mean()))
|
|
||||||
print("\n== HOW MUCH GOLD MOVES AROUND USD EVENTS (event bar = the bar containing the release) ==")
|
|
||||||
print(pd.DataFrame(rows).round(2).to_string(index=False))
|
|
||||||
hi = usd[usd.impact == "high"]
|
|
||||||
top = hi.groupby("name").agg(n=("ts", "size"), bar_range_atr=("bar_range_atr", "mean"), burst60_pct=("burst_in_60m", lambda s: 100 * s.mean()),
|
|
||||||
move60_usd=("move60_usd", "mean"), move60_atr=("move60_atr", "mean")).sort_values("n", ascending=False)
|
|
||||||
print("\n== HIGH-IMPACT USD EVENTS BY NAME (n >= 6) ==")
|
|
||||||
print(top[top.n >= 6].round(2).to_string())
|
|
||||||
# what share of ALL bursts sit within ±30 min of a high/medium USD event?
|
|
||||||
near = np.zeros(len(idx), bool)
|
|
||||||
for p in usd[usd.impact.isin(["high", "medium"])].pos: near[max(0, p - 30 // bm):min(len(idx), p + 30 // bm + 1)] = True
|
|
||||||
nearh = np.zeros(len(idx), bool)
|
|
||||||
for p in hi.pos: nearh[max(0, p - 30 // bm):min(len(idx), p + 30 // bm + 1)] = True
|
|
||||||
print(f"\nshare of ALL violent bars within ±30 min of a USD high-impact event: {100*burst[nearh].sum()/burst.sum():.1f}% (those bars are {100*nearh.mean():.1f}% of time)")
|
|
||||||
print(f"share within ±30 min of a USD high- or medium-impact event: {100*burst[near].sum()/burst.sum():.1f}% (bars {100*near.mean():.1f}% of time)")
|
|
||||||
# open trades through high-impact events
|
|
||||||
book = pd.read_csv(a.book); book["entry_time"] = pd.to_datetime(book.entry_time, utc=True, format="mixed")
|
|
||||||
bars = dict(O=px.Open.values, H=px.High.values, L=px.Low.values, C=px.Close.values, burst=burst.values, chour=chour.values)
|
|
||||||
pos = idx.get_indexer(book.entry_time); ok = pos >= 0; book = book[ok].reset_index(drop=True); pos = pos[ok]
|
|
||||||
ev_pos = np.sort(hi.pos.values)
|
|
||||||
# flat mask: exit at the open of the bar that CONTAINS a high-impact event... use the bar before (exit ~5-15 min before)
|
|
||||||
flat_mask = np.zeros(len(idx), bool); tight_mask = np.zeros(len(idx), bool)
|
|
||||||
for p in ev_pos:
|
|
||||||
if p - 1 >= 0: flat_mask[p - 1] = True
|
|
||||||
tight_mask[max(0, p - 1):min(len(idx), p + H1)] = True
|
|
||||||
res = {}
|
|
||||||
for v, kw in (("base", {}), ("mask_flat", dict(flat_mask=flat_mask)), ("mask_tight", dict(tight_mask=tight_mask))):
|
|
||||||
rs, fl, thru = [], [], []
|
|
||||||
for i, t in book.iterrows():
|
|
||||||
xp, held, reason, flag = simulate(bars, pos[i], t.direction, t.entry, t.sl, a.arm, a.dist, v, **kw)
|
|
||||||
rs.append(r_of(t.direction, t.entry, t.sl, xp)); fl.append(flag)
|
|
||||||
if v == "base":
|
|
||||||
j0, j1 = pos[i], pos[i] + held
|
|
||||||
k = ev_pos[(ev_pos > j0) & (ev_pos <= j1)]
|
|
||||||
thru.append(k[0] if len(k) else -1)
|
|
||||||
res[v] = (np.array(rs), np.array(fl))
|
|
||||||
if v == "base": book["ev_pos"] = thru
|
|
||||||
base = res["base"][0]; half = np.where(np.arange(len(book)) < len(book) // 2, 1, 2)
|
|
||||||
hit = book.ev_pos.values >= 0
|
|
||||||
print(f"\n== OPEN TRADES THAT HELD THROUGH A HIGH-IMPACT USD EVENT ({a.book.split('/')[-1]}) ==")
|
|
||||||
print(f"trades through an event: {hit.sum()} of {len(book)} ({100*hit.mean():.1f}%) | mean R {base[hit].mean():+.3f} vs {base[~hit].mean():+.3f} for the rest | WR {100*(base[hit]>0).mean():.0f}% vs {100*(base[~hit]>0).mean():.0f}%")
|
|
||||||
if hit.sum():
|
|
||||||
d = np.where(book.direction == "BUY", 1.0, -1.0)
|
|
||||||
m15 = np.array([ (close.shift(-max(1,15//bm)).values[p] - close.values[p]) if p >= 0 else np.nan for p in book.ev_pos])
|
|
||||||
with_ = hit & (d * m15 > 0); against = hit & (d * m15 < 0)
|
|
||||||
print(f" event's first 15-min move WITH the trade: n={with_.sum()} mean R {base[with_].mean():+.3f} | AGAINST: n={against.sum()} mean R {base[against].mean():+.3f} (WR {100*(base[against]>0).mean():.0f}%)")
|
|
||||||
print("\n== PROTECTION AROUND HIGH-IMPACT USD EVENTS ==")
|
|
||||||
b = stats(base)
|
|
||||||
for v, (r, f) in res.items():
|
|
||||||
s = stats(r); aff = f == 1
|
|
||||||
d1 = r[half == 1].sum() - base[half == 1].sum(); d2 = r[half == 2].sum() - base[half == 2].sum()
|
|
||||||
print(f"{v:11s} total {s['total']:+8.2f} Δ {s['total']-b['total']:+7.2f} PF {s['pf']:.2f} DD {s['dd']:.2f} affected {aff.sum():4d} (affected: base {base[aff].sum():+.2f} -> {r[aff].sum():+.2f}) | Δ h1 {d1:+.2f} / h2 {d2:+.2f}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
# News sentiment vs. price — does the news score carry information? — 2026-09-04
|
|
||||||
|
|
||||||
**Source evidence:** `logs/signals.db` 2026-08-13 → 2026-09-03 (14,683 rows, 13,947 with
|
|
||||||
a price; candle snapshots from 08-18, full-shape headline blobs from 08-19), read with
|
|
||||||
`mode=ro`. A continuous H1/M15/M5 candle stream was rebuilt from the stored 14-bar
|
|
||||||
`candles_json` snapshots (H1: 293 bars, 08-18 05:00 → 09-03 22:00). Headlines were
|
|
||||||
resolved through `news_snapshots` (4,054 distinct headlines, 2,024 gold-tagged).
|
|
||||||
Outcomes are the repo's own advisory model (`advisory_r_multiple` on the 144 `RESOLVED`
|
|
||||||
rows, R at $0.65). One-year context from the Oanda H1 parquet
|
|
||||||
(`backtest/cache/oanda_XAU_USD_1h_20250814_20260814.parquet`). No backtest: the
|
|
||||||
backtester runs with news off, and no headline history exists beyond these 3 weeks, so a
|
|
||||||
replay cannot test news. The same check reruns with
|
|
||||||
`python analyze_signal_log.py --stored-candles --news`.
|
|
||||||
|
|
||||||
Question asked by the owner: the log reads bullish most of the time — what did the
|
|
||||||
indicators and the news say when gold fell, and is news scoring a waste?
|
|
||||||
|
|
||||||
## Findings
|
|
||||||
|
|
||||||
**The numeric news score is a rear-view mirror.** `news_sentiment` (= mean gold-headline
|
|
||||||
score − mean dollar-headline score over the last 12 h) tracks what gold did over the
|
|
||||||
*previous* day and says nothing stable about the next one.
|
|
||||||
|
|
||||||
| hourly mean sentiment vs. gold return (n≈293 h) | Pearson |
|
|
||||||
|---|---|
|
|
||||||
| past 24 h | **+0.67** |
|
|
||||||
| past 8 h / 4 h / 1 h | +0.32 / +0.20 / +0.07 |
|
|
||||||
| next 1 h / 4 h / 8 h / 24 h | +0.05 / +0.10 / +0.21 / +0.14 |
|
|
||||||
| next 8 h, first half (08-18→25) / second half (08-26→09-03) | **−0.37 / +0.12** |
|
|
||||||
| next 24 h, first half / second half | −0.12 / −0.09 |
|
|
||||||
| daily mean vs same-day / previous-day / next-day move | +0.39 / +0.37 / **+0.03** |
|
|
||||||
| *change* in sentiment vs next 4 h / 8 h / 24 h | −0.09 / −0.03 / −0.01 |
|
|
||||||
|
|
||||||
Partial correlation with the next 8 h after removing the past-24 h return is +0.26 pooled
|
|
||||||
but flips sign between halves. Alternative aggregates (gold-specific score only, dropping
|
|
||||||
price-report headlines, only headlines < 2 h old) show the same shape: past-24 h
|
|
||||||
correlation 0.59–0.82, no stable forward correlation. A naive "hold in the direction of
|
|
||||||
sentiment" rule made +$272 over 279 hours vs +$120 buy-and-hold, but trading the *change*
|
|
||||||
in sentiment lost −$200: the gain is trend persistence (sentiment ≈ trailing return), not
|
|
||||||
news.
|
|
||||||
|
|
||||||
**Why the log reads bullish.** 62% of rows have sentiment ≥ +0.1, 21% ≤ −0.1. Component
|
|
||||||
means: gold headlines +0.158, dollar headlines +0.048. The window was a 16% August rally,
|
|
||||||
so the average headline was bullish because price had been rising. Of the gold-tagged
|
|
||||||
headlines, 42% merely restate the chart (price-up reports 489, mean +0.37; price-down 366,
|
|
||||||
mean −0.18) and 18% are long-horizon forecast pieces ("$5,400 target", "$50,000/oz"; 60%
|
|
||||||
positive, 21% of all positive score mass). The per-headline LLM labels are correct — every
|
|
||||||
regex-flagged "mis-score" was a correctly scored "yields fall → gold up" headline — which
|
|
||||||
matches the 78/78 golden set of 2026-08-06. The averaging, the 12 h window and the
|
|
||||||
headline universe are what destroy the information.
|
|
||||||
|
|
||||||
**What news and indicators said on the way down** (top $4,697 on 08-25 00:00 UTC, low
|
|
||||||
$4,283 on 09-02 03:00; an 8.8% drop in a week):
|
|
||||||
|
|
||||||
| moment | price | news sentiment | engine (M15) |
|
|
||||||
|---|---|---|---|
|
|
||||||
| top, 08-25 00:00 | 4,697 | +0.30, rising to **+0.44** two hours after the top (window high) | ranging, composite +5.9; 9 BUY setups that day: −7.4R, 11% win |
|
|
||||||
| 08-26/27 | −$60 | still +0.15…+0.37; first bearish hour 08-26 20:00, **44 h after the top**, price already −$102 | trending_down 134/138 rows, composite −4.6/−7.8; SELL setups 0/3 on 08-27 |
|
|
||||||
| 08-28 Warsh speech 13:00–16:00 | −$122 in 3 h, −$140 day | +0.24…+0.30 overnight, **0.00 at 13:00**, −0.17 only at 16:00; summary all morning: "a hawkish tone could pressure gold, dovish hints may lift it" | composite −6.2; SELL setups 4/4, +3.3R |
|
|
||||||
| 09-01 | −$128 day | −0.04…−0.20, "gold under pressure" all day (correct, describing the move) | trending_down 275 rows, composite −12.2, 51 SELL rows; SELL setups 7/7, +3.5R |
|
|
||||||
| low, 09-02 03:00 | 4,283 | −0.16 at the low, −0.19 six hours later; first bullish hour 09-02 19:00, **16 h after the low**, price already +$108 | SELL setups at the bottom 0/2, −2.05R |
|
|
||||||
|
|
||||||
Sentiment peaked at the top and troughed at the bottom. The engine also lagged (it is
|
|
||||||
trend-following) but flipped regime and composite within hours; its SELL setups won on the
|
|
||||||
trend days and lost at the turns, the same as its BUYs.
|
|
||||||
|
|
||||||
**Outcomes** (144 RESOLVED, +37.8R): BUY 121 / +43.2R / 70% win; SELL 23 / −5.4R / 48%.
|
|
||||||
|
|
||||||
| direction × sentiment bucket (≥+0.15 bull, ≤−0.05 bear) | n | R | mean | win |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| BUY, bullish news | 81 | +32.0 | +0.40 | 73% |
|
|
||||||
| BUY, neutral | 34 | +10.6 | +0.31 | 68% |
|
|
||||||
| BUY, bearish news | 6 | +0.65 | +0.11 | 50% |
|
|
||||||
| SELL, bearish news | 14 | +0.13 | +0.01 | 71% |
|
|
||||||
| SELL, neutral | 5 | −1.46 | −0.29 | 20% |
|
|
||||||
| SELL, bullish news | 4 | −4.08 | −1.02 | 0% |
|
|
||||||
|
|
||||||
The splits are the trend confound again ("BUY with bullish news" = "BUY in an uptrend").
|
|
||||||
Cleanest counter-example: 08-25, the most bullish news day of the drop period (+0.25),
|
|
||||||
9 BUYs at the top lost −7.4R. corr(sentiment, R): BUY +0.10, SELL −0.45 (n=23).
|
|
||||||
|
|
||||||
**Macro persona** (reads the same headlines plus high-impact events): macro=support 51
|
|
||||||
BUYs → +32.6R, 90% win; macro=neutral 16 → −3.2R, 38%. Within bullish-sentiment BUYs:
|
|
||||||
support 42 → +25.2R (88%) vs neutral 7 → −2.9R (29%). On 08-25 all 7 panel rows were
|
|
||||||
macro=neutral ("awaiting PCE, mixed") while the numeric aggregate said +0.25 bullish.
|
|
||||||
Confounded with regime (64 of 84 supports were trending_up) and n≈16 on the informative
|
|
||||||
side. Not enough to promote or demote; the standing rule (re-score at ~300 outcomes)
|
|
||||||
applies.
|
|
||||||
|
|
||||||
**Event risk** cannot be evaluated here: only 4 event windows in the sample
|
|
||||||
(08-19 17:00, 08-26 12:00, 08-28 13:00, 09-01 13:00), none produced a setup. The standing
|
|
||||||
evidence remains the 2026-07-11 audit (event gates would cost +4.6…+7.2R/yr).
|
|
||||||
|
|
||||||
**One-year context:** daily moves ≤ −$100 happened 17 times in the year to 2026-08-14;
|
|
||||||
15 of 47 rolling 3-week windows contained a ≥ 8% drawdown. This sample is one ordinary
|
|
||||||
rally-then-drop regime.
|
|
||||||
|
|
||||||
**Simulated 4 h window + neutral drop** (the change shipped with this note, on 11,136
|
|
||||||
full-shape scans): never leaves a scan empty (median 19 directional headlines, 8
|
|
||||||
gold-specific; worst hour 02:00 UTC has < 3 gold headlines 28% of the time), the aggregate
|
|
||||||
correlates 0.78 with the old one and becomes bolder (share |score| ≥ 0.3 rises 17% → 41%),
|
|
||||||
with the same lack of forward information (next-24 h corr +0.20; next-8 h halves
|
|
||||||
−0.22 / −0.10).
|
|
||||||
|
|
||||||
## Method
|
|
||||||
|
|
||||||
- Hourly join convention: a scan in hour H (boundary + 10 s) sees the bar closed at H,
|
|
||||||
i.e. bar H's open; forward returns run from bar H's open, past returns end there.
|
|
||||||
- Correlations are Pearson on hourly means (no scipy); halves split at 2026-08-26 00:00.
|
|
||||||
- Headline classes by regex on the title (price-up / price-down / forecast-style); the
|
|
||||||
gold-specific score is `score_by_currency.XAU` where present, else `score`.
|
|
||||||
- Outcome buckets use the representative row's `news_sentiment`; macro stance from
|
|
||||||
`panel_takes_json`.
|
|
||||||
- Everything above reproduces from the DB alone; nothing depends on live feeds.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
- `sentiment_affects_signals` and `event_risk_caps_signals` stay `False`. No gate is
|
|
||||||
built in either direction (follow or fade): 3 weeks, one regime.
|
|
||||||
- The headline labelling is not the problem; the pool and the averaging are. Shipped the
|
|
||||||
same day: `headline_max_age_seconds` 12 h → 4 h with undated items dropped, and
|
|
||||||
`get_fundamental_context` drops headlines whose every relevant score is inside ±0.1
|
|
||||||
(`_is_neutral_headline`). The market-summary prompt now writes for someone who has
|
|
||||||
never traded, sends headlines newest-first with the platform's own gold direction, and
|
|
||||||
caps at 400 chars. **`news_sentiment` rows from the deploy date onward are a mean over
|
|
||||||
directional ≤ 4 h headlines — log comparisons must split at that date.**
|
|
||||||
- Keep the macro persona running and logging; re-check with `--news` and the persona
|
|
||||||
scorecard at ~300 outcomes.
|
|
||||||
- Refuted hypothesis, do not re-propose without new data: "news sentiment leads price".
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
"""What do violent bars do to OPEN trades, and can we protect them? Analysis tooling only.
|
|
||||||
|
|
||||||
Replays every trade of a book on the raw bars with the backtester's exit model (SL-first,
|
|
||||||
gap-through at open, close-only trail ratchet, 300-bar timeout, $0.65 cost) and compares:
|
|
||||||
base : reproduce the book (validation: must match r_multiple for almost every trade)
|
|
||||||
flat_data : exit at the OPEN of the first bar whose close-hour enters {12,13} UTC (US data)
|
|
||||||
flat_asia : same for close-hour {0,1} UTC (Asian open)
|
|
||||||
tight_data : entering {12,13}, hold a protective stop 0.5R below the best price for the window
|
|
||||||
tight_asia : same for {0,1}
|
|
||||||
react_exit : a burst bar (range >= 3x prior ATR14) closes AGAINST the trade -> exit next open
|
|
||||||
react_tight : after an opposing burst, protective stop 0.5R below the best price for the rest
|
|
||||||
Plus a damage table: what happens to a trade when the first burst hits it (with vs against).
|
|
||||||
|
|
||||||
python backtest/research/open_trade_bursts.py BOOK.csv PARQUET BAR_MINUTES ARM DIST
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import numpy as np, pandas as pd
|
|
||||||
|
|
||||||
COST = 0.65; MAX_BARS = 300; K = 3.0; PROT = 0.5
|
|
||||||
|
|
||||||
|
|
||||||
def wilder_atr(df, period=14):
|
|
||||||
pc = df["Close"].shift(1)
|
|
||||||
tr = pd.concat([df["High"] - df["Low"], (df["High"] - pc).abs(), (df["Low"] - pc).abs()], axis=1).max(axis=1)
|
|
||||||
return tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
|
|
||||||
|
|
||||||
|
|
||||||
def simulate(bars, i0, direction, entry, sl, arm, dist, variant, hours_flat=(), hours_tight=(), flat_mask=None, tight_mask=None):
|
|
||||||
"""bars: dict of numpy arrays; i0 = index of the entry bar. Returns (exit_price, bars_held, reason, flag)."""
|
|
||||||
O, H, L, C, burst, chour = bars["O"], bars["H"], bars["L"], bars["C"], bars["burst"], bars["chour"]
|
|
||||||
d = 1.0 if direction == "BUY" else -1.0
|
|
||||||
risk = abs(entry - sl); stop = sl; peak = entry; flag = 0
|
|
||||||
n = len(O); tight = False; tight_until = -1; exit_next = False
|
|
||||||
for j in range(i0, min(n, i0 + MAX_BARS)):
|
|
||||||
if exit_next:
|
|
||||||
return O[j], j - i0, "REACT", flag
|
|
||||||
h = chour[j]
|
|
||||||
in_flat = h in hours_flat; in_tight = h in hours_tight
|
|
||||||
if variant.startswith("flat") and in_flat:
|
|
||||||
return O[j], j - i0, "FLAT", 1
|
|
||||||
if variant == "mask_flat" and flat_mask is not None and flat_mask[j]:
|
|
||||||
return O[j], j - i0, "FLAT", 1
|
|
||||||
if variant.startswith("tight") and in_tight:
|
|
||||||
tight = True; tight_until = j; flag = 1
|
|
||||||
if variant == "mask_tight" and tight_mask is not None and tight_mask[j]:
|
|
||||||
tight = True; tight_until = j; flag = 1
|
|
||||||
# stop check (SL first, gap-through at open)
|
|
||||||
if d > 0:
|
|
||||||
if O[j] <= stop: return O[j], j - i0, "SL", flag
|
|
||||||
if L[j] <= stop: return stop, j - i0, "SL", flag
|
|
||||||
else:
|
|
||||||
if O[j] >= stop: return O[j], j - i0, "SL", flag
|
|
||||||
if H[j] >= stop: return stop, j - i0, "SL", flag
|
|
||||||
# update peak / trail at bar close
|
|
||||||
peak = max(peak, H[j]) if d > 0 else min(peak, L[j])
|
|
||||||
fav = d * (peak - entry)
|
|
||||||
if fav >= arm * risk:
|
|
||||||
stop = max(stop, peak - dist * risk) if d > 0 else min(stop, peak + dist * risk)
|
|
||||||
if tight and (variant == "react_tight" or j <= tight_until or in_tight):
|
|
||||||
stop = max(stop, peak - PROT * risk) if d > 0 else min(stop, peak + PROT * risk)
|
|
||||||
if variant.startswith("react") and burst[j] and d * (C[j] - O[j]) < 0:
|
|
||||||
flag = 1
|
|
||||||
if variant == "react_exit": exit_next = True
|
|
||||||
else: tight = True
|
|
||||||
j = min(n - 1, i0 + MAX_BARS - 1)
|
|
||||||
return C[j], j - i0, "TIMEOUT", flag
|
|
||||||
|
|
||||||
|
|
||||||
def r_of(direction, entry, sl, exit_price):
|
|
||||||
d = 1.0 if direction == "BUY" else -1.0
|
|
||||||
return (d * (exit_price - entry) - COST) / abs(entry - sl)
|
|
||||||
|
|
||||||
|
|
||||||
def stats(r):
|
|
||||||
r = np.asarray(r); eq = np.concatenate([[0.0], np.cumsum(r)]); dd = (np.maximum.accumulate(eq) - eq).max()
|
|
||||||
pf = r[r > 0].sum() / max(-r[r < 0].sum(), 1e-9)
|
|
||||||
return dict(n=len(r), total=r.sum(), mean=r.mean(), wr=100 * (r > 0).mean(), pf=pf, dd=dd)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
book_csv, pq, bm, arm, dist = sys.argv[1], sys.argv[2], int(sys.argv[3]), float(sys.argv[4]), float(sys.argv[5])
|
|
||||||
book = pd.read_csv(book_csv); book["entry_time"] = pd.to_datetime(book["entry_time"], utc=True, format="mixed")
|
|
||||||
px = pd.read_parquet(pq)
|
|
||||||
atr_prev = wilder_atr(px).shift(1)
|
|
||||||
bars = dict(O=px["Open"].values, H=px["High"].values, L=px["Low"].values, C=px["Close"].values,
|
|
||||||
burst=((px["High"] - px["Low"]) >= K * atr_prev).fillna(False).values,
|
|
||||||
chour=(px.index + pd.Timedelta(minutes=bm)).hour.values)
|
|
||||||
idx = pd.Index(px.index)
|
|
||||||
pos = idx.get_indexer(book["entry_time"])
|
|
||||||
ok = pos >= 0
|
|
||||||
print(f"{book_csv}: {len(book)} trades, {ok.sum()} matched to bars; arm {arm} dist {dist}")
|
|
||||||
book = book[ok].reset_index(drop=True); pos = pos[ok]
|
|
||||||
half = np.where(np.arange(len(book)) < len(book) // 2, 1, 2)
|
|
||||||
variants = {"base": {}, "flat_data": dict(hours_flat=(12, 13)), "flat_asia": dict(hours_flat=(0, 1)),
|
|
||||||
"tight_data": dict(hours_tight=(12, 13)), "tight_asia": dict(hours_tight=(0, 1)),
|
|
||||||
"react_exit": {}, "react_tight": {}}
|
|
||||||
results = {}
|
|
||||||
for v, kw in variants.items():
|
|
||||||
rs, flags = [], []
|
|
||||||
for i, t in book.iterrows():
|
|
||||||
xp, held, reason, flag = simulate(bars, pos[i], t.direction, t.entry, t.sl, arm, dist, v, **kw)
|
|
||||||
rs.append(r_of(t.direction, t.entry, t.sl, xp)); flags.append(flag)
|
|
||||||
results[v] = (np.array(rs), np.array(flags))
|
|
||||||
base = results["base"][0]
|
|
||||||
match = np.isclose(base, book["r_multiple"].values, atol=0.02).mean()
|
|
||||||
print(f"validation: replay reproduces the book's r_multiple (±0.02) on {100*match:.1f}% of trades; "
|
|
||||||
f"sum replay {base.sum():+.2f} vs book {book.r_multiple.sum():+.2f}")
|
|
||||||
print("\n== PROTECTION VARIANTS (whole book; 'affected' = trades where the rule fired) ==")
|
|
||||||
print(f"{'variant':12s} {'n':>5s} {'affected':>8s} {'total R':>9s} {'Δ vs base':>9s} {'PF':>5s} {'maxDD':>6s} {'WR%':>5s} | affected only: base R -> variant R | Δ h1 / Δ h2")
|
|
||||||
b = stats(base)
|
|
||||||
for v, (r, f) in results.items():
|
|
||||||
s = stats(r); aff = f == 1
|
|
||||||
d1 = r[half == 1].sum() - base[half == 1].sum(); d2 = r[half == 2].sum() - base[half == 2].sum()
|
|
||||||
print(f"{v:12s} {s['n']:5d} {aff.sum():8d} {s['total']:+9.2f} {s['total']-b['total']:+9.2f} {s['pf']:5.2f} {s['dd']:6.2f} {s['wr']:5.1f} | "
|
|
||||||
f"{base[aff].sum():+8.2f} -> {r[aff].sum():+8.2f} (n={aff.sum()}) | {d1:+.2f} / {d2:+.2f}")
|
|
||||||
# damage table: first burst while open (base path), with vs against
|
|
||||||
print("\n== DAMAGE: first violent bar that hits an OPEN trade (base exits) ==")
|
|
||||||
rows = []
|
|
||||||
for i, t in book.iterrows():
|
|
||||||
d = 1.0 if t.direction == "BUY" else -1.0; risk = abs(t.entry - t.sl)
|
|
||||||
xp, held, reason, _ = simulate(bars, pos[i], t.direction, t.entry, t.sl, arm, dist, "base")
|
|
||||||
j0 = pos[i]; j1 = j0 + held
|
|
||||||
for j in range(j0 + 1, j1 + 1): # bursts after the entry bar, up to and including the exit bar
|
|
||||||
if bars["burst"][j]:
|
|
||||||
against = d * (bars["C"][j] - bars["O"][j]) < 0
|
|
||||||
mtm_before = (d * (bars["C"][j - 1] - t.entry) - COST) / risk
|
|
||||||
mtm_after = (d * (bars["C"][j] - t.entry) - COST) / risk
|
|
||||||
rows.append(dict(against=against, bars_open=j - j0, mtm_before=mtm_before, mtm_after=mtm_after,
|
|
||||||
final=r_of(t.direction, t.entry, t.sl, xp), ended_here=(j == j1), hour=bars["chour"][j],
|
|
||||||
sched=bars["chour"][j] in (0, 1, 12, 13)))
|
|
||||||
break
|
|
||||||
dmg = pd.DataFrame(rows)
|
|
||||||
if len(dmg):
|
|
||||||
g = dmg.groupby("against").agg(n=("final", "size"), bars_open_med=("bars_open", "median"),
|
|
||||||
mtm_before=("mtm_before", "mean"), mtm_after=("mtm_after", "mean"),
|
|
||||||
final=("final", "mean"), ended_on_that_bar_pct=("ended_here", lambda s: 100 * s.mean()),
|
|
||||||
in_sched_hours_pct=("sched", lambda s: 100 * s.mean()))
|
|
||||||
print(g.round(3).to_string())
|
|
||||||
ag = dmg[dmg.against]
|
|
||||||
print(f"\nopposing bursts: {len(ag)} trades ({100*len(ag)/len(book):.1f}% of the book); "
|
|
||||||
f"mean R just before {ag.mtm_before.mean():+.3f} -> just after {ag.mtm_after.mean():+.3f} -> final {ag.final.mean():+.3f}; "
|
|
||||||
f"{100*(ag.final<0).mean():.0f}% end as losers; {100*ag.ended_here.mean():.0f}% are stopped ON that bar")
|
|
||||||
print(f" those with an open PROFIT before the burst (mtm>0): n={int((ag.mtm_before>0).sum())}, final mean {ag[ag.mtm_before>0].final.mean():+.3f}, "
|
|
||||||
f"{100*(ag[ag.mtm_before>0].final<0).mean():.0f}% end as losers")
|
|
||||||
print(f" by hour of the opposing burst (n, mean final R): " + ", ".join(f"{h:02d}:{n}/{m:+.2f}" for h, n, m in
|
|
||||||
ag.groupby("hour").final.agg(["size", "mean"]).reset_index().itertuples(index=False)))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{"probe": "P1 coherent-bullish", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 38.5, "confidence": 62, "price": 2000.0, "market_regime": "trending_up", "volatility_regime": "normal", "mtf_agreement": true}, "indicators": {"ema_trend": {"signal": "STRONG_BUY", "confidence": 90, "value": 1.0}, "rsi": {"signal": "BUY", "confidence": 65, "value": 58.0}, "macd": {"signal": "STRONG_BUY", "confidence": 80, "value": 1.25}, "bollinger": {"signal": "BUY", "confidence": 60, "value": 0.7}, "adx": {"signal": "BUY", "confidence": 70, "value": 32.0}, "roc": {"signal": "BUY", "confidence": 55, "value": 0.35}, "keltner": {"signal": "BUY", "confidence": 65, "value": 3.1}, "candlestick": {"signal": "BUY", "confidence": 60, "value": 2.0}}, "mtf": {"5min": {"signal": "BUY", "score": 30.0}, "1h": {"signal": "STRONG_BUY", "score": 45.0}}, "key_levels": {"resistance": 2012.0, "support": 1985.0}, "exhaustion": 0, "candles": {"15min": {"ref_close": 2000.0, "atr": 5.0, "bars": [[-2.4, -2.2, -2.4, -2.3], [-2.3, -2.1, -2.3, -2.2], [-2.2, -2.0, -2.2, -2.0], [-2.0, -1.9, -2.1, -1.9], [-1.9, -1.8, -2.0, -1.8], [-1.8, -1.6, -1.8, -1.7], [-1.7, -1.5, -1.7, -1.6], [-1.6, -1.4, -1.6, -1.4], [-1.4, -1.3, -1.5, -1.3], [-1.3, -1.2, -1.4, -1.2], [-1.2, -1.0, -1.2, -1.1], [-1.1, -0.9, -1.1, -1.0], [-1.0, -0.8, -1.0, -0.8], [-0.8, -0.7, -0.9, -0.7], [-0.7, -0.6, -0.8, -0.6], [-0.6, -0.4, -0.6, -0.5], [-0.5, -0.3, -0.5, -0.4], [-0.4, -0.2, -0.4, -0.2], [-0.2, -0.1, -0.3, -0.1], [-0.1, 0.0, -0.2, 0.0]]}}}, "raw_reply": {"stance": "support", "confidence": 70, "note": "The trend is up and all signs agree, so buying fits the flow."}}
|
|
||||||
{"probe": "P2 bearish-vs-BUY-brief", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 38.5, "confidence": 62, "price": 2000.0, "market_regime": "trending_down", "volatility_regime": "normal", "mtf_agreement": false}, "indicators": {"ema_trend": {"signal": "STRONG_SELL", "confidence": 85, "value": 0.1}, "rsi": {"signal": "SELL", "confidence": 70, "value": 74.0}, "macd": {"signal": "STRONG_SELL", "confidence": 80, "value": -1.4}, "bollinger": {"signal": "SELL", "confidence": 60, "value": 0.95}, "adx": {"signal": "SELL", "confidence": 70, "value": 35.0}, "roc": {"signal": "SELL", "confidence": 60, "value": -0.5}, "keltner": {"signal": "SELL", "confidence": 65, "value": -2.8}, "candlestick": {"signal": "SELL", "confidence": 55, "value": -2.0}}, "mtf": {"5min": {"signal": "SELL", "score": -28.0}, "1h": {"signal": "STRONG_SELL", "score": -41.0}}, "key_levels": {"resistance": 2012.0, "support": 1985.0}, "exhaustion": 3, "candles": {"15min": {"ref_close": 2000.0, "atr": 5.0, "bars": [[2.4, 2.4, 2.2, 2.3], [2.3, 2.3, 2.1, 2.2], [2.2, 2.2, 2.0, 2.0], [2.0, 2.1, 1.9, 1.9], [1.9, 2.0, 1.8, 1.8], [1.8, 1.8, 1.6, 1.7], [1.7, 1.7, 1.5, 1.6], [1.6, 1.6, 1.4, 1.4], [1.4, 1.5, 1.3, 1.3], [1.3, 1.4, 1.2, 1.2], [1.2, 1.2, 1.0, 1.1], [1.1, 1.1, 0.9, 1.0], [1.0, 1.0, 0.8, 0.8], [0.8, 0.9, 0.7, 0.7], [0.7, 0.8, 0.6, 0.6], [0.6, 0.6, 0.4, 0.5], [0.5, 0.5, 0.3, 0.4], [0.4, 0.4, 0.2, 0.2], [0.2, 0.3, 0.1, 0.1], [0.1, 0.2, -0.0, 0.0]]}}}, "raw_reply": {"stance": "oppose", "confidence": 85, "note": "The price is falling and every indicator says sell, so buying fights the trend."}}
|
|
||||||
{"probe": "P3 conflicting", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 12.0, "confidence": 35, "price": 2000.0, "market_regime": "ranging", "volatility_regime": "normal", "mtf_agreement": false}, "indicators": {"ema_trend": {"signal": "BUY", "confidence": 55, "value": 0.6}, "rsi": {"signal": "NEUTRAL", "confidence": 40, "value": 51.0}, "macd": {"signal": "SELL", "confidence": 55, "value": -0.3}, "bollinger": {"signal": "NEUTRAL", "confidence": 35, "value": 0.5}, "adx": {"signal": "NEUTRAL", "confidence": 40, "value": 18.0}, "roc": {"signal": "SELL", "confidence": 50, "value": -0.1}, "keltner": {"signal": "BUY", "confidence": 50, "value": 0.8}, "candlestick": {"signal": "NEUTRAL", "confidence": 30, "value": 0.0}}, "mtf": {"5min": {"signal": "NEUTRAL", "score": 4.0}, "1h": {"signal": "NEUTRAL", "score": -3.0}}, "key_levels": {}, "exhaustion": 0, "candles": {"15min": {"ref_close": 2000.0, "atr": 5.0, "bars": [[0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0]]}}}, "raw_reply": {"stance": "neutral", "confidence": 30, "note": "Mixed signals with no clear direction, so cannot strongly support or oppose."}}
|
|
||||||
{"probe": "P4 empty-evidence", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 38.5, "confidence": 62, "price": 2000.0, "market_regime": "trending_up", "volatility_regime": "normal", "mtf_agreement": true}, "indicators": {}, "mtf": {}, "key_levels": {}, "exhaustion": 0}, "raw_reply": {"stance": "neutral", "confidence": 20, "note": "Not enough data to judge the trade direction."}}
|
|
||||||
{"probe": "P5 hallucination-trap", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 38.5, "confidence": 62, "price": 2000.0, "market_regime": "trending_up", "volatility_regime": "normal", "mtf_agreement": true}, "indicators": {"ema_trend": {"signal": "STRONG_BUY", "confidence": 90, "value": 1.0}, "rsi": {"signal": "BUY", "confidence": 65, "value": 58.0}, "macd": {"signal": "STRONG_BUY", "confidence": 80, "value": 1.25}, "bollinger": {"signal": "BUY", "confidence": 60, "value": 0.7}, "adx": {"signal": "BUY", "confidence": 70, "value": 32.0}, "roc": {"signal": "BUY", "confidence": 55, "value": 0.35}, "keltner": {"signal": "BUY", "confidence": 65, "value": 3.1}, "candlestick": {"signal": "BUY", "confidence": 60, "value": 2.0}}, "mtf": {"5min": {"signal": "BUY", "score": 30.0}, "1h": {"signal": "STRONG_BUY", "score": 45.0}}, "key_levels": {}, "exhaustion": 0}, "raw_reply": {"stance": "support", "confidence": 70, "note": "The trend is up and all signs agree, so buying fits the flow."}}
|
|
||||||
{"probe": "P6 output-discipline", "payload": {}, "raw_reply": null}
|
|
||||||
{"probe": "P7 synthesizer", "payload": {"direction": "BUY", "takes": [{"persona": "macro", "stance": "support", "confidence": 70, "note": "evidence aligned"}, {"persona": "technical", "stance": "support", "confidence": 80, "note": "evidence aligned"}, {"persona": "risk", "stance": "support", "confidence": 65, "note": "evidence aligned"}, {"persona": "contrarian", "stance": "support", "confidence": 60, "note": "evidence aligned"}]}, "raw_reply": {"verdict": "AGREE", "confidence": 85, "rationale": "All four analysts support buying, with high confidence and consistent evidence. No one opposes, so the panel clearly backs the trade."}}
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
panel_payload_drytest_2026-08-15.py — live DeepSeek validation of the
|
|
||||||
enriched agent-panel payloads + hardened prompts (2026-08-15).
|
|
||||||
|
|
||||||
Spends ~6 REAL DeepSeek requests (against the shared 500/day cap) with
|
|
||||||
FABRICATED payloads — made-up prices/indicators, no real market data —
|
|
||||||
to verify the new technical-persona contract end to end:
|
|
||||||
|
|
||||||
P1 coherent bullish evidence -> technical persona SUPPORTS
|
|
||||||
P2 bearish evidence vs BUY brief -> technical persona OPPOSES
|
|
||||||
P3 conflicting evidence -> NEUTRAL or low confidence
|
|
||||||
P4 empty evidence -> NEUTRAL with confidence < 30
|
|
||||||
(the anti-hallucination contract)
|
|
||||||
P5 hallucination trap -> note invents no price levels
|
|
||||||
P6 output discipline -> <=20-word notes, exact JSON keys
|
|
||||||
P7 synthesizer over 4 supports -> AGREE verdict
|
|
||||||
|
|
||||||
Usage: venv/bin/python backtest/research/panel_payload_drytest_2026-08-15.py
|
|
||||||
Exit code 0 = all probes passed. Raw replies land in
|
|
||||||
backtest/research/panel_drytest_2026-08-15.jsonl
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
sys.path.insert(0, str(REPO_ROOT))
|
|
||||||
|
|
||||||
import agent_panel as ap # noqa: E402
|
|
||||||
from app_config import deepseek_api_key, get_app_config # noqa: E402
|
|
||||||
from llm_client import get_llm_client # noqa: E402
|
|
||||||
|
|
||||||
ARTIFACT = Path(__file__).with_suffix(".jsonl")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Fabrication helpers (all numbers are invented) ───────────────────
|
|
||||||
|
|
||||||
def _staircase(start: float, step: float, n: int) -> pd.DataFrame:
|
|
||||||
"""n synthetic bars drifting `step` per bar; last close = start+n*step."""
|
|
||||||
rows, price = [], start
|
|
||||||
for _ in range(n):
|
|
||||||
o, c = price, price + step
|
|
||||||
rows.append({"Open": o, "Close": c,
|
|
||||||
"High": max(o, c) + abs(step) * 0.4,
|
|
||||||
"Low": min(o, c) - abs(step) * 0.4})
|
|
||||||
price = c
|
|
||||||
return pd.DataFrame(rows)
|
|
||||||
|
|
||||||
|
|
||||||
def _choppy(center: float, n: int) -> pd.DataFrame:
|
|
||||||
rows, price, sign = [], center, 1
|
|
||||||
for _ in range(n):
|
|
||||||
step = sign * 1.5
|
|
||||||
o, c = price, price + step
|
|
||||||
rows.append({"Open": o, "Close": c,
|
|
||||||
"High": max(o, c) + 0.6, "Low": min(o, c) - 0.6})
|
|
||||||
price, sign = c, -sign
|
|
||||||
return pd.DataFrame(rows)
|
|
||||||
|
|
||||||
|
|
||||||
_FAKE_PRICE = 2000.00 # deliberately made-up
|
|
||||||
_FAKE_ATR = 5.0
|
|
||||||
|
|
||||||
_BRIEF_BUY = {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY",
|
|
||||||
"quality": "A", "score": 38.5, "confidence": 62,
|
|
||||||
"price": _FAKE_PRICE, "market_regime": "trending_up",
|
|
||||||
"volatility_regime": "normal", "mtf_agreement": True}
|
|
||||||
|
|
||||||
_BULL_INDICATORS = {
|
|
||||||
"ema_trend": {"signal": "STRONG_BUY", "confidence": 90, "value": 1.0},
|
|
||||||
"rsi": {"signal": "BUY", "confidence": 65, "value": 58.0},
|
|
||||||
"macd": {"signal": "STRONG_BUY", "confidence": 80, "value": 1.25},
|
|
||||||
"bollinger": {"signal": "BUY", "confidence": 60, "value": 0.7},
|
|
||||||
"adx": {"signal": "BUY", "confidence": 70, "value": 32.0},
|
|
||||||
"roc": {"signal": "BUY", "confidence": 55, "value": 0.35},
|
|
||||||
"keltner": {"signal": "BUY", "confidence": 65, "value": 3.1},
|
|
||||||
"candlestick": {"signal": "BUY", "confidence": 60, "value": 2.0},
|
|
||||||
}
|
|
||||||
|
|
||||||
_BEAR_INDICATORS = {
|
|
||||||
"ema_trend": {"signal": "STRONG_SELL", "confidence": 85, "value": 0.1},
|
|
||||||
"rsi": {"signal": "SELL", "confidence": 70, "value": 74.0},
|
|
||||||
"macd": {"signal": "STRONG_SELL", "confidence": 80, "value": -1.4},
|
|
||||||
"bollinger": {"signal": "SELL", "confidence": 60, "value": 0.95},
|
|
||||||
"adx": {"signal": "SELL", "confidence": 70, "value": 35.0},
|
|
||||||
"roc": {"signal": "SELL", "confidence": 60, "value": -0.5},
|
|
||||||
"keltner": {"signal": "SELL", "confidence": 65, "value": -2.8},
|
|
||||||
"candlestick": {"signal": "SELL", "confidence": 55, "value": -2.0},
|
|
||||||
}
|
|
||||||
|
|
||||||
_MIXED_INDICATORS = {
|
|
||||||
"ema_trend": {"signal": "BUY", "confidence": 55, "value": 0.6},
|
|
||||||
"rsi": {"signal": "NEUTRAL", "confidence": 40, "value": 51.0},
|
|
||||||
"macd": {"signal": "SELL", "confidence": 55, "value": -0.3},
|
|
||||||
"bollinger": {"signal": "NEUTRAL", "confidence": 35, "value": 0.5},
|
|
||||||
"adx": {"signal": "NEUTRAL", "confidence": 40, "value": 18.0},
|
|
||||||
"roc": {"signal": "SELL", "confidence": 50, "value": -0.1},
|
|
||||||
"keltner": {"signal": "BUY", "confidence": 50, "value": 0.8},
|
|
||||||
"candlestick": {"signal": "NEUTRAL", "confidence": 30, "value": 0.0},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _tech_payload(indicators, mtf, candles_df=None, key_levels=None,
|
|
||||||
brief=None, exhaustion=0):
|
|
||||||
payload = {"brief": brief or dict(_BRIEF_BUY),
|
|
||||||
"indicators": indicators, "mtf": mtf,
|
|
||||||
"key_levels": key_levels or {}, "exhaustion": exhaustion}
|
|
||||||
if candles_df is not None:
|
|
||||||
# the REAL production normalizer, not a hand-rolled copy
|
|
||||||
payload["candles"] = {
|
|
||||||
"15min": ap._normalize_candles(candles_df, _FAKE_ATR,
|
|
||||||
len(candles_df))}
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
# ── Probe runner ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_RESULTS = []
|
|
||||||
|
|
||||||
|
|
||||||
def _record(probe, payload, reply, take, passed, detail):
|
|
||||||
_RESULTS.append({"probe": probe, "pass": bool(passed),
|
|
||||||
"detail": detail,
|
|
||||||
"stance": getattr(take, "stance", None),
|
|
||||||
"confidence": getattr(take, "confidence", None),
|
|
||||||
"note": getattr(take, "note", None)})
|
|
||||||
with ARTIFACT.open("a") as fh:
|
|
||||||
fh.write(json.dumps({"probe": probe, "payload": payload,
|
|
||||||
"raw_reply": reply}, default=str) + "\n")
|
|
||||||
mark = "PASS" if passed else "FAIL"
|
|
||||||
print(f"[{mark}] {probe}: {detail}")
|
|
||||||
if take is not None:
|
|
||||||
print(f" -> {take.stance} @ {take.confidence:.0f} "
|
|
||||||
f"| {take.note}")
|
|
||||||
|
|
||||||
|
|
||||||
def _ask(payload):
|
|
||||||
"""Real API call through the production client + production
|
|
||||||
validation (the same path run_panel uses)."""
|
|
||||||
reply = get_llm_client().chat_json(ap._TECH_SYSTEM, payload)
|
|
||||||
return reply, ap._validate_take("technical", reply)
|
|
||||||
|
|
||||||
|
|
||||||
def _want(probe: str) -> bool:
|
|
||||||
"""--only P3 runs just that probe (saves requests on re-runs)."""
|
|
||||||
if "--only" not in sys.argv:
|
|
||||||
return True
|
|
||||||
return sys.argv[sys.argv.index("--only") + 1] == probe
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
app = get_app_config()
|
|
||||||
if not deepseek_api_key(app):
|
|
||||||
print("ABORT: no DeepSeek API key (env DEEPSEEK_API_KEY / "
|
|
||||||
"config.json deepseek.api_key).")
|
|
||||||
return 2
|
|
||||||
if app.deepseek_settings.get("enabled", True) is False:
|
|
||||||
print("ABORT: deepseek.enabled is false in config.json.")
|
|
||||||
return 2
|
|
||||||
if ARTIFACT.exists() and "--only" not in sys.argv:
|
|
||||||
ARTIFACT.unlink() # full run = fresh wire log; --only appends
|
|
||||||
|
|
||||||
mtf_agree = {"5min": {"signal": "BUY", "score": 30.0},
|
|
||||||
"1h": {"signal": "STRONG_BUY", "score": 45.0}}
|
|
||||||
levels = {"resistance": 2012.0, "support": 1985.0}
|
|
||||||
|
|
||||||
# P1 — coherent bullish, with rising ATR staircase (last close 2000)
|
|
||||||
if _want("P1"):
|
|
||||||
p1 = _tech_payload(_BULL_INDICATORS, mtf_agree,
|
|
||||||
candles_df=_staircase(_FAKE_PRICE - 20 * 0.6,
|
|
||||||
0.6, 20),
|
|
||||||
key_levels=levels)
|
|
||||||
reply, take = _ask(p1)
|
|
||||||
_record("P1 coherent-bullish", p1, reply, take,
|
|
||||||
take is not None and take.stance == "support"
|
|
||||||
and take.confidence >= 50,
|
|
||||||
"expect support @ conf>=50")
|
|
||||||
|
|
||||||
# P2 — bearish everything against a BUY brief, falling staircase
|
|
||||||
if _want("P2"):
|
|
||||||
brief2 = dict(_BRIEF_BUY, market_regime="trending_down",
|
|
||||||
mtf_agreement=False)
|
|
||||||
mtf_sell = {"5min": {"signal": "SELL", "score": -28.0},
|
|
||||||
"1h": {"signal": "STRONG_SELL", "score": -41.0}}
|
|
||||||
p2 = _tech_payload(_BEAR_INDICATORS, mtf_sell, brief=brief2,
|
|
||||||
candles_df=_staircase(_FAKE_PRICE + 20 * 0.6,
|
|
||||||
-0.6, 20),
|
|
||||||
key_levels=levels, exhaustion=3)
|
|
||||||
reply, take = _ask(p2)
|
|
||||||
_record("P2 bearish-vs-BUY-brief", p2, reply, take,
|
|
||||||
take is not None and take.stance == "oppose",
|
|
||||||
"expect oppose (BUY brief fights all evidence)")
|
|
||||||
|
|
||||||
# P3 — genuinely mixed evidence: MTF neutral both ways, ranging
|
|
||||||
# regime, choppy candles. (An H1 SELL against a BUY is a *listed
|
|
||||||
# conflict* in the prompt — oppose is correct there, not mixed.)
|
|
||||||
if _want("P3"):
|
|
||||||
brief3 = dict(_BRIEF_BUY, market_regime="ranging", score=12.0,
|
|
||||||
confidence=35, mtf_agreement=False)
|
|
||||||
mtf_mixed = {"5min": {"signal": "NEUTRAL", "score": 4.0},
|
|
||||||
"1h": {"signal": "NEUTRAL", "score": -3.0}}
|
|
||||||
p3 = _tech_payload(_MIXED_INDICATORS, mtf_mixed, brief=brief3,
|
|
||||||
candles_df=_choppy(_FAKE_PRICE, 20))
|
|
||||||
reply, take = _ask(p3)
|
|
||||||
_record("P3 conflicting", p3, reply, take,
|
|
||||||
take is not None and (take.stance == "neutral"
|
|
||||||
or take.confidence < 60),
|
|
||||||
"expect neutral or conf<60 on genuinely mixed evidence")
|
|
||||||
|
|
||||||
# P4 — empty evidence: contract demands neutral @ conf<30
|
|
||||||
if _want("P4"):
|
|
||||||
p4 = _tech_payload({}, {}, key_levels=None)
|
|
||||||
reply, take = _ask(p4)
|
|
||||||
_record("P4 empty-evidence", p4, reply, take,
|
|
||||||
take is not None and take.stance == "neutral"
|
|
||||||
and take.confidence < 30,
|
|
||||||
"contract: thin/missing evidence -> neutral, conf<30")
|
|
||||||
|
|
||||||
# P5 — hallucination trap: no key_levels, no candles; the note must
|
|
||||||
# not cite any 3+-digit number absent from the payload
|
|
||||||
if _want("P5"):
|
|
||||||
p5 = _tech_payload(_BULL_INDICATORS, mtf_agree, key_levels=None)
|
|
||||||
reply, take = _ask(p5)
|
|
||||||
invented = []
|
|
||||||
if take is not None:
|
|
||||||
payload_blob = json.dumps(p5)
|
|
||||||
for num in re.findall(r"\d{3,}", take.note):
|
|
||||||
if num not in payload_blob:
|
|
||||||
invented.append(num)
|
|
||||||
_record("P5 hallucination-trap", p5, reply, take,
|
|
||||||
take is not None and not invented,
|
|
||||||
f"invented numbers in note: {invented or 'none'}")
|
|
||||||
|
|
||||||
# P6 — output discipline across the persona replies gathered above
|
|
||||||
notes = [r for r in _RESULTS if r["note"]]
|
|
||||||
wordy = [r["probe"] for r in notes if len(r["note"].split()) > 20]
|
|
||||||
_record("P6 output-discipline", {}, None, None,
|
|
||||||
notes and not wordy,
|
|
||||||
f"notes >20 words: {wordy or 'none'} "
|
|
||||||
f"(cap is 15; 20 = fail threshold)")
|
|
||||||
|
|
||||||
# P7 — synthesizer over four fabricated SUPPORT takes
|
|
||||||
if _want("P7"):
|
|
||||||
takes = [{"persona": p, "stance": "support", "confidence": c,
|
|
||||||
"note": "evidence aligned"}
|
|
||||||
for p, c in (("macro", 70), ("technical", 80),
|
|
||||||
("risk", 65), ("contrarian", 60))]
|
|
||||||
synth_payload = {"direction": "BUY", "takes": takes}
|
|
||||||
reply = get_llm_client().chat_json(ap._SYNTH_SYSTEM, synth_payload)
|
|
||||||
verdict = reply.get("verdict") if isinstance(reply, dict) else None
|
|
||||||
rationale = (reply or {}).get("rationale", "")
|
|
||||||
_record("P7 synthesizer", synth_payload, reply, None,
|
|
||||||
verdict == "AGREE" and len(str(rationale).split()) <= 40,
|
|
||||||
f"expect AGREE + <=40-word rationale; got {verdict!r} "
|
|
||||||
f"({len(str(rationale).split())} words)")
|
|
||||||
|
|
||||||
n_pass = sum(1 for r in _RESULTS if r["pass"])
|
|
||||||
n_api = sum(1 for r in _RESULTS if r["probe"] != "P6 output-discipline")
|
|
||||||
print(f"\n{n_pass}/{len(_RESULTS)} probes passed "
|
|
||||||
f"({n_api} API requests spent; "
|
|
||||||
f"raw wire log: {ARTIFACT.name})")
|
|
||||||
return 0 if n_pass == len(_RESULTS) else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
# Signal-log audit → prediction-quality campaign — 2026-09-03
|
|
||||||
|
|
||||||
**Source evidence:** `logs/signals.db` 2026-08-13 → 2026-09-03 (14,683 rows, candle
|
|
||||||
snapshots from 08-18), replayed with the repo's own advisory outcome model
|
|
||||||
(`analyze_signal_log.py --stored-candles`; the replay reproduces
|
|
||||||
`advisory_outcome.py` to 5e-17 on the 49 panel-seeded rows), cross-checked
|
|
||||||
against the pinned Oanda books (`backtest/oanda_*_065*.csv`).
|
|
||||||
|
|
||||||
## Live-log findings that motivated the campaign
|
|
||||||
|
|
||||||
| View (3 weeks, $0.65 rt) | n | WR | R | PF |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| every unique setup event | 144 | 67% | +37.8 | 1.93 |
|
|
||||||
| opportunities (first per tf+direction, 60 min) | 66 | 67% | +18.3 | 1.92 |
|
|
||||||
| one-position-per-TF (5-bar cooldown) | 32 | 62% | +6.6 | 1.53 |
|
|
||||||
| direction BUY / SELL | 121 / 23 | 70% / 48% | +43.2 / −5.4 | 2.53 / 0.56 |
|
|
||||||
|
|
||||||
- Two rally days (08-19, 08-21) contribute essentially all of the R.
|
|
||||||
- SELL is negative in every view; in the books SELL is the weakest bucket on
|
|
||||||
every TF (H1 SELL −16.7R/5yr PF 0.77, −11.5R OOS PF 0.30; M15 SELL −11.7R OOS;
|
|
||||||
M5 SELL in `transitioning` −7.9R/12mo PF 0.21, −5.2R/5yr PF 0.49).
|
|
||||||
- Bollinger is anti-predictive on all four TFs (forward-return hit 0.26–0.50 at
|
|
||||||
12 bars) and "opposes" 93% of taken book trades; divergence is inverted on
|
|
||||||
M15/H1 live and trades where it opposed outperform in the books.
|
|
||||||
- Refuted live hypotheses (books disagree): ADX<20 gate, exhaustion gate,
|
|
||||||
NEAR-EMA200 gate, EXTREME-volatility gate, capping SL/ATR.
|
|
||||||
|
|
||||||
## Method (rule 1)
|
|
||||||
|
|
||||||
Harness: `backtest/gpu_backtest.py --pair XAUUSD --spread 0.65` on the CPU
|
|
||||||
backend (CuPy present but the GPU was not reachable; the M5 12mo control
|
|
||||||
reproduces the pinned `oanda_m5_12mo_065_nopb_20260814.csv` trade-for-trade
|
|
||||||
except the two trades on the end date, which `--end` excludes). Every candidate
|
|
||||||
is compared with a CONTROL produced on the same harness, never with the pinned
|
|
||||||
book directly. Windows (cache start = start − 62 days):
|
|
||||||
|
|
||||||
| window | `--start` | `--end` |
|
|
||||||
|---|---|---|
|
|
||||||
| 12mo (M15/H1) | 2025-08-04 | 2026-08-04 |
|
|
||||||
| 12mo (M5 canonical) | 2025-08-14 | 2026-08-14 |
|
|
||||||
| 5yr | 2021-08-01 | 2026-08-04 |
|
|
||||||
| OOS | 2024-06-01 | 2025-03-30 |
|
|
||||||
|
|
||||||
Acceptance: positive total R in both 12mo halves, PF ≥ 1.3, OOS not worse
|
|
||||||
than control, and a candidate that removes a net-positive bucket for a PF-only
|
|
||||||
gain is rejected (big-candle precedent). Runner:
|
|
||||||
`<scratchpad>/campaign/run_campaign.sh`; summary: `summarize.py`.
|
|
||||||
|
|
||||||
Candidates (all `--set`, no code):
|
|
||||||
|
|
||||||
- **B1 SELL gating** — H1 `xau_strategy.sell_min_quality=[]` (block all H1
|
|
||||||
shorts); M5 `xau_strategy.sell_blocked_regimes=["transitioning"]`; M15
|
|
||||||
`xau_strategy.sell_min_quality=["B"]`.
|
|
||||||
- **B2 divergence off** — `technical.disabled_indicators=["divergence"]` on
|
|
||||||
M15 and H1 (M5 divergence-down was rejected 2026-08-04; not re-run).
|
|
||||||
- **B3 Bollinger weight 0 in trending regimes** — full
|
|
||||||
`technical.regime_weights_override` with bollinger 0.0 in
|
|
||||||
trending_up/trending_down/trending, ranging/transitioning unchanged; M5, M15, H1.
|
|
||||||
|
|
||||||
## Results
|
|
||||||
|
|
||||||
Every control reproduced its pinned book: M15 12mo (`ctl_m15_12mo`) is exact
|
|
||||||
at 397/+73.93R/PF 1.53; all three H1 controls are exact; the M5 12mo control
|
|
||||||
(`base_m5_12mo`) is exact except for the two trades that fall on the end
|
|
||||||
date, which `--end` excludes on this harness (see Method above).
|
|
||||||
|
|
||||||
| book | n | R | PF | DD | H1 | H2 | sellN | sellR | ΔR vs control | ΔPF |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| b1_h1_nosell_12mo | 91 | +22.2 | 1.79 | 4.1 | +20.6 | +1.6 | 0 | +0.0 | -1.8 | +0.25 |
|
|
||||||
| b1_h1_nosell_5yr | 335 | +49.1 | 1.46 | 13.2 | -5.0 | +54.1 | 0 | +0.0 | +16.7 | +0.28 |
|
|
||||||
| b1_h1_nosell_oos | 78 | +16.7 | 1.74 | 3.6 | +7.3 | +9.4 | 0 | +0.0 | +11.5 | +0.61 |
|
|
||||||
| b1_m15_sellB_12mo | 341 | +74.6 | 1.62 | 10.8 | +68.4 | +6.2 | 82 | +9.1 | +0.7 | +0.09 |
|
|
||||||
| b1_m15_sellB_5yr | 1378 | +119.1 | 1.22 | 41.4 | -21.3 | +140.3 | 335 | +3.7 | -8.9 | +0.02 |
|
|
||||||
| b1_m15_sellB_oos | 272 | +47.1 | 1.51 | 13.8 | +12.3 | +34.8 | 59 | -3.4 | +8.3 | +0.15 |
|
|
||||||
| b1_m5_selltrans_12mo | 557 | +112.2 | 1.56 | 9.9 | +55.9 | +56.4 | 75 | +14.8 | +7.9 | +0.06 |
|
|
||||||
| b1_m5_selltrans_5yr | 889 | +133.7 | 1.41 | 12.8 | -8.3 | +142.0 | 88 | +9.0 | +5.3 | +0.03 |
|
|
||||||
| b1_m5_selltrans_oos | 86 | +9.4 | 1.33 | 8.2 | -1.4 | +10.9 | 4 | +1.5 | +0.0 | +0.00 |
|
|
||||||
| b2_h1_nodiv_12mo | 149 | +28.6 | 1.62 | 6.5 | +23.6 | +5.0 | 49 | +5.5 | +4.6 | +0.07 |
|
|
||||||
| b2_h1_nodiv_5yr | 567 | +40.5 | 1.20 | 14.1 | -5.2 | +45.6 | 203 | -18.2 | +8.1 | +0.02 |
|
|
||||||
| b2_h1_nodiv_oos | 119 | +7.6 | 1.17 | 7.7 | -1.9 | +9.5 | 31 | -14.3 | +2.4 | +0.04 |
|
|
||||||
| b2_m15_nodiv_12mo | 439 | +81.3 | 1.51 | 13.1 | +72.3 | +8.9 | 147 | +7.0 | +7.4 | -0.02 |
|
|
||||||
| b2_m15_nodiv_5yr | 1771 | +139.9 | 1.20 | 47.4 | -16.2 | +156.1 | 578 | +11.7 | +11.9 | -0.01 |
|
|
||||||
| b2_m15_nodiv_oos | 341 | +52.3 | 1.43 | 17.1 | +15.6 | +36.7 | 93 | -10.0 | +13.5 | +0.07 |
|
|
||||||
| b3_h1_bb0_12mo | 154 | +31.2 | 1.64 | 9.4 | +22.8 | +8.4 | 52 | +6.7 | +7.3 | +0.09 |
|
|
||||||
| b3_h1_bb0_5yr | 583 | +37.3 | 1.18 | 18.3 | -7.4 | +44.7 | 206 | -20.4 | +4.9 | +0.00 |
|
|
||||||
| b3_h1_bb0_oos | 122 | +5.6 | 1.12 | 8.7 | -2.0 | +7.6 | 32 | -13.8 | +0.4 | -0.01 |
|
|
||||||
| b3_m15_bb0_12mo | 439 | +49.9 | 1.29 | 19.4 | +61.6 | -11.8 | 151 | +0.3 | -24.0 | -0.23 |
|
|
||||||
| b3_m15_bb0_5yr | 1790 | +126.6 | 1.18 | 42.3 | -1.8 | +128.5 | 592 | +17.4 | -1.3 | -0.03 |
|
|
||||||
| b3_m15_bb0_oos | 345 | +55.5 | 1.47 | 12.4 | +18.2 | +37.3 | 94 | -8.4 | +16.7 | +0.11 |
|
|
||||||
| b3_m5_bb0_12mo | 621 | +84.0 | 1.35 | 16.6 | +49.3 | +34.7 | 104 | +9.7 | -20.4 | -0.14 |
|
|
||||||
| b3_m5_bb0_5yr | 987 | +113.0 | 1.31 | 15.3 | -7.7 | +120.6 | 134 | +12.0 | -15.5 | -0.08 |
|
|
||||||
| b3_m5_bb0_oos | 93 | +10.6 | 1.35 | 5.6 | -2.9 | +13.5 | 9 | +3.7 | +1.2 | +0.01 |
|
|
||||||
| b12_h1_nosell_nodiv_12mo | 100 | +23.1 | 1.75 | 4.6 | +21.8 | +1.3 | 0 | +0.0 | -0.8 | +0.20 |
|
|
||||||
| b12_h1_nosell_nodiv_5yr | 364 | +58.7 | 1.52 | 12.9 | -4.1 | +62.8 | 0 | +0.0 | +26.3 | +0.34 |
|
|
||||||
| b12_h1_nosell_nodiv_oos | 88 | +21.9 | 1.92 | 4.8 | +9.4 | +12.5 | 0 | +0.0 | +16.8 | +0.79 |
|
|
||||||
| base_m5_12mo | 575 | +104.3 | 1.50 | 10.7 | +50.8 | +53.5 | 93 | +6.9 | — | — |
|
|
||||||
| ctl_h1_12mo | 138 | +24.0 | 1.55 | 8.0 | +19.4 | +4.6 | 47 | +1.8 | — | — |
|
|
||||||
| ctl_h1_5yr | 515 | +32.4 | 1.18 | 15.2 | -5.9 | +38.3 | 180 | -16.7 | — | — |
|
|
||||||
| ctl_h1_oos | 104 | +5.1 | 1.13 | 7.5 | -0.9 | +6.1 | 26 | -11.5 | — | — |
|
|
||||||
| ctl_m15_12mo | 397 | +73.9 | 1.53 | 10.8 | +66.7 | +7.2 | 138 | +8.5 | — | — |
|
|
||||||
| ctl_m15_5yr | 1578 | +128.0 | 1.21 | 35.8 | -3.4 | +131.4 | 535 | +12.6 | — | — |
|
|
||||||
| ctl_m15_oos | 299 | +38.8 | 1.36 | 15.5 | +5.9 | +32.9 | 86 | -11.7 | — | — |
|
|
||||||
| ctl_m5_5yr | 911 | +128.4 | 1.39 | 12.8 | -8.3 | +136.7 | 110 | +3.8 | — | — |
|
|
||||||
| ctl_m5_oos | 86 | +9.4 | 1.33 | 8.2 | -1.4 | +10.9 | 4 | +1.5 | — | — |
|
|
||||||
|
|
||||||
Raw table (n / R / PF / WR% / DD / H1 / H2 / PF-half-1 / PF-half-2 /
|
|
||||||
sellN / sellR / ΔR / ΔPF / Δn vs control; control and `base_m5_12mo`
|
|
||||||
rows carry no delta):
|
|
||||||
|
|
||||||
```
|
|
||||||
book n R PF WR% DD H1 H2 PF1 PF2 sellN sellR | ΔR ΔPF Δn
|
|
||||||
b1_h1_nosell_12mo 91 +22.2 1.79 62.6 4.1 +20.6 +1.6 2.10 1.18 0 +0.0 | -1.8 +0.25 -47
|
|
||||||
b1_h1_nosell_5yr 335 +49.1 1.46 58.2 13.2 -5.0 +54.1 0.87 1.78 0 +0.0 | +16.7 +0.28 -180
|
|
||||||
b1_h1_nosell_oos 78 +16.7 1.74 62.8 3.6 +7.3 +9.4 1.67 1.81 0 +0.0 | +11.5 +0.61 -26
|
|
||||||
b1_m15_sellB_12mo 341 +74.6 1.62 62.8 10.8 +68.4 +6.2 2.15 1.10 82 +9.1 | +0.7 +0.09 -56
|
|
||||||
b1_m15_sellB_5yr 1378 +119.1 1.22 57.0 41.4 -21.3 +140.3 0.91 1.46 335 +3.7 | -8.9 +0.02 -200
|
|
||||||
b1_m15_sellB_oos 272 +47.1 1.51 60.7 13.8 +12.3 +34.8 1.24 1.85 59 -3.4 | +8.3 +0.15 -27
|
|
||||||
b1_m5_selltrans_12mo 557 +112.2 1.56 53.7 9.9 +55.9 +56.4 1.54 1.58 75 +14.8 | +7.9 +0.06 -18
|
|
||||||
b1_m5_selltrans_5yr 889 +133.7 1.41 51.5 12.8 -8.3 +142.0 0.84 1.52 88 +9.0 | +5.3 +0.03 -22
|
|
||||||
b1_m5_selltrans_oos 86 +9.4 1.33 53.5 8.2 -1.4 +10.9 0.91 1.95 4 +1.5 | +0.0 +0.00 +0
|
|
||||||
b2_h1_nodiv_12mo 149 +28.6 1.62 62.4 6.5 +23.6 +5.0 1.93 1.24 49 +5.5 | +4.6 +0.07 +11
|
|
||||||
b2_h1_nodiv_5yr 567 +40.5 1.20 56.3 14.1 -5.2 +45.6 0.93 1.37 203 -18.2 | +8.1 +0.02 +52
|
|
||||||
b2_h1_nodiv_oos 119 +7.6 1.17 56.3 7.7 -1.9 +9.5 0.91 1.44 31 -14.3 | +2.4 +0.04 +15
|
|
||||||
b2_m15_nodiv_12mo 439 +81.3 1.51 60.8 13.1 +72.3 +8.9 1.90 1.11 147 +7.0 | +7.4 -0.02 +42
|
|
||||||
b2_m15_nodiv_5yr 1771 +139.9 1.20 55.8 47.4 -16.2 +156.1 0.94 1.39 578 +11.7 | +11.9 -0.01 +193
|
|
||||||
b2_m15_nodiv_oos 341 +52.3 1.43 59.2 17.1 +15.6 +36.7 1.23 1.68 93 -10.0 | +13.5 +0.07 +42
|
|
||||||
b3_h1_bb0_12mo 154 +31.2 1.64 60.4 9.4 +22.8 +8.4 1.89 1.36 52 +6.7 | +7.3 +0.09 +16
|
|
||||||
b3_h1_bb0_5yr 583 +37.3 1.18 54.5 18.3 -7.4 +44.7 0.90 1.35 206 -20.4 | +4.9 +0.00 +68
|
|
||||||
b3_h1_bb0_oos 122 +5.6 1.12 55.7 8.7 -2.0 +7.6 0.91 1.33 32 -13.8 | +0.4 -0.01 +18
|
|
||||||
b3_m15_bb0_12mo 439 +49.9 1.29 59.0 19.4 +61.6 -11.8 1.77 0.87 151 +0.3 | -24.0 -0.23 +42
|
|
||||||
b3_m15_bb0_5yr 1790 +126.6 1.18 56.1 42.3 -1.8 +128.5 0.99 1.31 592 +17.4 | -1.3 -0.03 +212
|
|
||||||
b3_m15_bb0_oos 345 +55.5 1.47 61.2 12.4 +18.2 +37.3 1.29 1.66 94 -8.4 | +16.7 +0.11 +46
|
|
||||||
b3_m5_bb0_12mo 621 +84.0 1.35 51.4 16.6 +49.3 +34.7 1.43 1.28 104 +9.7 | -20.4 -0.14 +46
|
|
||||||
b3_m5_bb0_5yr 987 +113.0 1.31 50.7 15.3 -7.7 +120.6 0.85 1.38 134 +12.0 | -15.5 -0.08 +76
|
|
||||||
b3_m5_bb0_oos 93 +10.6 1.35 54.8 5.6 -2.9 +13.5 0.85 2.17 9 +3.7 | +1.2 +0.01 +7
|
|
||||||
b12_h1_nosell_nodiv_12mo 100 +23.1 1.75 63.0 4.6 +21.8 +1.3 2.00 1.15 0 +0.0 | -0.8 +0.20 -38
|
|
||||||
b12_h1_nosell_nodiv_5yr 364 +58.7 1.52 59.6 12.9 -4.1 +62.8 0.90 1.87 0 +0.0 | +26.3 +0.34 -151
|
|
||||||
b12_h1_nosell_nodiv_oos 88 +21.9 1.92 63.6 4.8 +9.4 +12.5 1.93 1.92 0 +0.0 | +16.8 +0.79 -16
|
|
||||||
base_m5_12mo 575 +104.3 1.50 52.9 10.7 +50.8 +53.5 1.46 1.53 93 +6.9
|
|
||||||
ctl_h1_12mo 138 +24.0 1.55 60.9 8.0 +19.4 +4.6 1.84 1.22 47 +1.8
|
|
||||||
ctl_h1_5yr 515 +32.4 1.18 55.7 15.2 -5.9 +38.3 0.91 1.34 180 -16.7
|
|
||||||
ctl_h1_oos 104 +5.1 1.13 56.7 7.5 -0.9 +6.1 0.95 1.32 26 -11.5
|
|
||||||
ctl_m15_12mo 397 +73.9 1.53 62.2 10.8 +66.7 +7.2 1.95 1.10 138 +8.5
|
|
||||||
ctl_m15_5yr 1578 +128.0 1.21 56.5 35.8 -3.4 +131.4 0.99 1.37 535 +12.6
|
|
||||||
ctl_m15_oos 299 +38.8 1.36 59.2 15.5 +5.9 +32.9 1.10 1.66 86 -11.7
|
|
||||||
ctl_m5_5yr 911 +128.4 1.39 51.3 12.8 -8.3 +136.7 0.84 1.48 110 +3.8
|
|
||||||
ctl_m5_oos 86 +9.4 1.33 53.5 8.2 -1.4 +10.9 0.91 1.95 4 +1.5
|
|
||||||
```
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
- **SHIPPED** — M5 `xau_strategy.sell_blocked_regimes=["transitioning"]`
|
|
||||||
(b1_m5_selltrans): 12mo +7.9R, 5yr +5.3R, OOS unchanged (no such trades
|
|
||||||
in that window); positive in both 12mo halves on every window. H1
|
|
||||||
`xau_strategy.sell_min_quality=[]` (b1_h1_nosell, all H1 shorts blocked):
|
|
||||||
5yr +16.7R and OOS +11.5R more than pay for the 12mo cost of -1.8R; H1
|
|
||||||
shorts were -16.7R/5yr and -11.5R/OOS in the control. Both land in
|
|
||||||
`pair_signals/xauusd.py`.
|
|
||||||
- **REJECTED** — M15 `sell_min_quality=["B"]` (b1_m15_sellB): 5yr -8.9R
|
|
||||||
with max DD widening 35.8 -> 41.4R for a marginal +0.7R on 12mo; fails
|
|
||||||
the drawdown clause. All three B3 Bollinger-weight-0-in-trending-regimes
|
|
||||||
runs (b3_h1_bb0, b3_m15_bb0, b3_m5_bb0): negative on M5 (-20.4R/12mo,
|
|
||||||
-15.5R/5yr) and M15 (-24.0R/12mo, PF 1.29 -> still below the 12mo H2
|
|
||||||
going negative at -11.8R); H1's B3 5yr gain (+4.9R) doesn't clear the
|
|
||||||
bar once M5/M15 fail outright — Bollinger's constant-oppose vote stays
|
|
||||||
a load-bearing quality bar, consistent with the 2026-08-04 audit.
|
|
||||||
- **DECLINED** — B2-M15 (`technical.disabled_indicators=["divergence"]`
|
|
||||||
on M15, b2_m15_nodiv): R improves on every window (+7.4/+11.9/+13.5R)
|
|
||||||
but 5yr max DD widens 35.8 -> 47.4R while PF stays flat (1.21 -> 1.20) —
|
|
||||||
more risk for no risk-adjusted gain; owner call.
|
|
||||||
- **PARKED** — B2-H1 (`technical.disabled_indicators=["divergence"]` on
|
|
||||||
H1, b2_h1_nodiv, standalone: +4.6/+8.1/+2.4R, PF 1.62/1.20/1.17). The
|
|
||||||
combined run with the H1 no-short override (b12_h1_nosell_nodiv) passes
|
|
||||||
rule 1 and beats the no-shorts override alone on both 5yr and OOS: 12mo
|
|
||||||
100/+23.1R PF 1.75 (halves +21.8/+1.3), 5yr 364/+58.7R PF 1.52, OOS
|
|
||||||
88/+21.9R PF 1.92. Not shipped anyway: it would route through
|
|
||||||
`technical.disabled_indicators`, which CLAUDE.md rule 8 reserves for
|
|
||||||
experiments, not production paths. Owner decision; recommended vehicle
|
|
||||||
is an H1-only `regime_weights_override` with divergence weight 0 plus a
|
|
||||||
fresh validation run under that mechanism.
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
# Signal-log live performance evaluation — 2026-08-18
|
|
||||||
|
|
||||||
**Tool:** `analyze_signal_log.py` (new, repo root). Read-only on
|
|
||||||
`logs/signals.db` (sqlite `immutable=1`); replays every logged trade
|
|
||||||
setup through the backtester's own `simulate_trades` on the RAW Oanda
|
|
||||||
price path (next-open fill, SL/TP distances preserved, SL-first ties,
|
|
||||||
gap-at-open fills, close-only trail ratchet with the per-setup stamped
|
|
||||||
trail params). Reproduce:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python analyze_signal_log.py --csv backtest/signal_log_eval_20260818.csv
|
|
||||||
```
|
|
||||||
|
|
||||||
## Window & sample
|
|
||||||
|
|
||||||
- Log window: **2026-08-13 14:31 → 2026-08-18 14:13 UTC (5 days)** —
|
|
||||||
the entire signal_log history to date.
|
|
||||||
- 2,654 rows (telegram 2,180 / dashboard 474); M5 1,308, M15 1,189,
|
|
||||||
H1 119, H4 38.
|
|
||||||
- 39 rows carry a `trade_setup` → **17 unique signal events** after
|
|
||||||
dedup (cross-source double-logs + persisting-signal re-fires collapse
|
|
||||||
on (tf, direction, entry, SL, strategy) with a 60-min gap).
|
|
||||||
- **No real executions exist** (`oanda_state.json` empty, no
|
|
||||||
`oanda_daemon` rows) — every outcome below is a hypothetical replayed
|
|
||||||
fill, not a broker fill.
|
|
||||||
- Cost: dual convention — **$0.65 rt headline**, $0.30 comparative
|
|
||||||
(derived analytically, R is linear in cost).
|
|
||||||
|
|
||||||
## Trade replay results
|
|
||||||
|
|
||||||
| First logged | TF | Signal | Grade | Strategy | Fill | Exit | Bars | R@0.65 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| 08-13 15:30 | 5min | SELL | B | GENERIC | 4365.39 | TRAIL | 19 | −0.20 |
|
|
||||||
| 08-13 18:00 | 15min | SELL | B | GENERIC | 4356.05 | TRAIL | 44 | +0.91 |
|
|
||||||
| 08-14 07:45 | 5min | BUY | B | XAU_CP | 4339.72 | TRAIL | 60 | +2.56 |
|
|
||||||
| 08-14 12:15 | 15min | BUY | B | GENERIC | 4371.91 | TRAIL | 27 | +0.06 |
|
|
||||||
| 08-14 12:25 | 5min | STRONG_BUY | A | GENERIC | 4379.12 | TRAIL | 277 | +0.20 |
|
|
||||||
| 08-14 12:30 | 5min | STRONG_BUY | A | GENERIC | 4379.87 | TRAIL | 276 | +0.16 |
|
|
||||||
| 08-14 12:30 | 15min | BUY | B | GENERIC | 4379.87 | TRAIL | 63 | +0.18 |
|
|
||||||
| 08-17 08:00 | 1h | BUY | C | GENERIC | 4405.19 | TRAIL | 18 | −0.13 |
|
|
||||||
| 08-17 14:15 | 5min | STRONG_BUY | A | GENERIC | 4412.35 | TRAIL | 46 | −0.10 |
|
|
||||||
| 08-17 14:20 | 5min | STRONG_BUY | A | GENERIC | 4418.55 | TRAIL | 125 | −0.23 |
|
|
||||||
| 08-17 14:25 | 5min | STRONG_BUY | A | GENERIC | 4424.10 | SL | 151 | −1.02 |
|
|
||||||
| 08-17 14:30 | 15min | BUY | A | GENERIC | 4418.23 | SL | 17 | −1.03 |
|
|
||||||
| 08-17 14:45 | 15min | STRONG_BUY | A | GENERIC | 4417.49 | SL | 16 | −1.03 |
|
|
||||||
| 08-17 15:00 | 15min | STRONG_BUY | A | GENERIC | 4417.48 | SL | 15 | −1.03 |
|
|
||||||
| 08-17 15:00 | 5min | NEUTRAL | C | GENERIC | 4417.48 | TRAIL | 117 | −0.16 |
|
|
||||||
| 08-17 15:15 | 15min | STRONG_BUY | A | GENERIC | 4421.30 | SL | 14 | −1.04 |
|
|
||||||
| 08-17 15:30 | 15min | BUY | B | GENERIC | 4425.70 | SL | 85 | −1.02 |
|
|
||||||
|
|
||||||
**Aggregate: 17 closed, 6 wins (35%), total −2.91R @0.65 / −2.67R
|
|
||||||
@0.30, PF 0.58.** By grade: A n=9 → −5.12R; B n=6 → +2.50R; C n=2 →
|
|
||||||
−0.30R.
|
|
||||||
|
|
||||||
Reading: the book is two regimes. Everything through the 08-14 rally
|
|
||||||
made money (+4.7R across 8 events, incl. the XAU_CP +2.56R); the 08-17
|
|
||||||
afternoon BUY/STRONG_BUY cluster chased the spike to ~4425 and got
|
|
||||||
stopped as price faded — 9 events, −7.6R combined. One bad afternoon
|
|
||||||
dominates the 5-day sample. Notable: a NEUTRAL-graded row carried a
|
|
||||||
setup (post-gate CP/PB-style path) and lost small.
|
|
||||||
|
|
||||||
## Advisory panel scorecard
|
|
||||||
|
|
||||||
Only **7 verdicts** logged so far (2 NEUTRAL, 5 OPPOSE, 0 AGREE) — the
|
|
||||||
panel's min-quality gate + daily budget keep it dark on most scans.
|
|
||||||
|
|
||||||
| Row | When | Signal | Verdict | Outcome basis | Impact |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| #1358 | 08-17 07:40 M5 | BUY B | NEUTRAL @55% | +4h move | −$8.19 in dir (pass was right) |
|
|
||||||
| #1361 | 08-17 07:45 M5 | BUY A | NEUTRAL @55% | +4h move | −$12.43 in dir (pass was right) |
|
|
||||||
| #1550 | 08-17 14:15 M5 | STRONG_BUY A | OPPOSE @80% | replay | saved 0.10R |
|
|
||||||
| #1553 | 08-17 14:25 M5 | STRONG_BUY A | OPPOSE @80% | replay | saved 1.02R |
|
|
||||||
| #1555 | 08-17 14:30 M15 | BUY A | OPPOSE @75% | replay | saved 1.03R |
|
|
||||||
| #1561 | 08-17 14:45 M15 | STRONG_BUY A | OPPOSE @75% | replay | saved 1.03R |
|
|
||||||
| #1588 | 08-17 15:30 M15 | BUY B | OPPOSE @75% | replay | saved 1.02R |
|
|
||||||
|
|
||||||
**Every OPPOSE was correct: ≈ +4.2R would have been lost taking those
|
|
||||||
five trades.** The panel's rationale ("price climbed too fast, profit
|
|
||||||
too small") correctly identified the 08-17 exhaustion spike that the
|
|
||||||
composite scored A. Both NEUTRAL passes also preceded adverse moves.
|
|
||||||
Caveat: n=7, one market episode, zero AGREE verdicts — the panel has
|
|
||||||
only proven itself as a veto so far, never as a confirmation.
|
|
||||||
|
|
||||||
## Directional hit-rate (non-setup signals)
|
|
||||||
|
|
||||||
27 deduped non-setup BUY/SELL rows, forward close-to-close on M5:
|
|
||||||
|
|
||||||
| Horizon | Hit rate | Mean move in direction |
|
|
||||||
|---|---|---|
|
|
||||||
| +1h | 48% | −$0.72 |
|
|
||||||
| +4h | 44% | −$2.30 |
|
|
||||||
| +24h | 7% | −$27.45 |
|
|
||||||
|
|
||||||
The 24h figure is dominated by the 08-17 buy-cluster reversal; not
|
|
||||||
independently meaningful at n=27.
|
|
||||||
|
|
||||||
## Method notes / caveats
|
|
||||||
|
|
||||||
- **Small sample**: 5 days, 17 events, one trending episode + one
|
|
||||||
reversal. Directional read only; do not tune anything on this.
|
|
||||||
- Entry model = next bar open after the scan boundary (live scans run
|
|
||||||
at boundary+10s), SL/TP distances preserved from the logged setup —
|
|
||||||
identical to the backtester replay loop.
|
|
||||||
- Cross-source dedup verified: telegram+dashboard double-logs and
|
|
||||||
5-min re-fires of a persisting signal collapse to one event each.
|
|
||||||
- The stray repo-root `signals.db` (828 rows, Aug 13–14, pre-panel
|
|
||||||
schema) is an early duplicate subset of `logs/signals.db` — excluded.
|
|
||||||
- Tests: Module 25 in `test_all.py` was only added on 2026-09-03 (the
|
|
||||||
2026-08-18 claim of 7 tests was never true at HEAD). It now covers
|
|
||||||
boundary flooring, clustering (shared `signal_log.setup_key`), trade
|
|
||||||
building, the stored-candle replay books, surface parity, WAL-visible
|
|
||||||
loading (`mode=ro`; `immutable=1` missed the newest rows) and hit-rate
|
|
||||||
signing.
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
# Stake-sizing campaign — 2026-09-05 (3-year Oanda window, $0.65)
|
|
||||||
|
|
||||||
## Purpose and owner decisions
|
|
||||||
Owner asked (2026-09-05): predict direction and choose order size better; protect against
|
|
||||||
trend exhaustion at signal time; map hours/days where institutions move gold violently;
|
|
||||||
backtests ≤ 3 years. Decisions taken in planning:
|
|
||||||
- Order size = a per-signal RELATIVE stake ("Stake: 0.7× normal"), not account-based units.
|
|
||||||
- Exhaustion protection = RISK LESS, DON'T SKIP (every hard exhaustion block tested since
|
|
||||||
2026-07 removed net-positive trades — the winners are the stretched runs).
|
|
||||||
- Model only M5 and M15; H1/H4 measured descriptively.
|
|
||||||
- scikit-learn / lightgbm allowed in ./venv for research only; the shipped artefact is
|
|
||||||
numpy-only JSON. Neither library enters requirements.txt.
|
|
||||||
- No tuned threshold, gate or exit changes (rule 1). Sizing is a post-decision overlay computed
|
|
||||||
by `position_sizing.build_features` / `stake_multiplier` INSIDE `analyze_from_dataframes`, so
|
|
||||||
live and backtest use the same call on the same objects (rule 4).
|
|
||||||
|
|
||||||
## Harness
|
|
||||||
`backtest/gpu_backtest.py --pair XAUUSD --start 2023-09-05 --end 2026-09-05 --spread 0.65
|
|
||||||
--workers 12`, pair-factory config (no --set/--quality/--strategies/--confirm-tfs), NumPy CPU
|
|
||||||
backend (CuPy cannot locate CUDA_PATH on this box; the 2026-09-03 pinned books were also CPU).
|
|
||||||
Cache: `oanda_XAU_USD_{5min,15min,1h,4h}_20230705_20260905.parquet`, `…_1d_20221110_20260905`.
|
|
||||||
Feature books add `--export-features` (65 `f_*` columns + `stake_multiplier` + `r_sized`; the
|
|
||||||
default export stays the pinned 63-column shape). Outputs in `backtest/research/sizing_2026-09/`
|
|
||||||
(gitignored).
|
|
||||||
|
|
||||||
## Baseline (control books, unmodified backtester, 2026-09-05 23:36–23:41 UTC)
|
|
||||||
| TF | trades | total R | WR | PF | max DD |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| M5 | 857 | +161.32 | 53.7% | 1.53 | 9.85 |
|
|
||||||
| M15 | 1086 | +148.69 | 59.1% | 1.37 | 15.47 |
|
|
||||||
| H1 | 255 | +70.52 | 64.3% | 1.98 | 4.43 |
|
|
||||||
| H4 | 113 | +22.68 | 58.4% | 1.63 | 5.39 |
|
|
||||||
|
|
||||||
### Behaviour-neutrality checks (2026-09-05 23:57 UTC)
|
|
||||||
- 12-month M5 canonical window (2025-08-14 → 2026-08-14, $0.65) re-run on the modified tree:
|
|
||||||
`book_diff` vs `backtest/oanda_m5_12mo_065_nopb_20260903.csv` = ΔR +0.0, Δtrades +0, and the
|
|
||||||
two CSVs have the SAME MD5 (`a28fed4becd056466c26d6a947e1a0e5`). Record-keeping fields,
|
|
||||||
`SizingConfig`, and the `--export-features` hook change nothing when the flag is off.
|
|
||||||
- Feature books: the 63 shared columns are value-identical to the control books on every TF
|
|
||||||
(`assert_frame_equal`). A column-ORDER quirk was found and fixed the same day: pandas orders
|
|
||||||
export columns by first appearance, so when the first trade of a book lacks an indicator
|
|
||||||
(the 3-year M5 book's first trade had no candlestick pattern) that indicator's columns were
|
|
||||||
appended after the extras. `trades_to_dataframe` now forces the extras to the tail; test
|
|
||||||
extended with that exact case (435 tests green).
|
|
||||||
|
|
||||||
## Pre-registered acceptance rule (written 2026-09-05, BEFORE any model fit)
|
|
||||||
A per-timeframe stake-multiplier model may ship only if, evaluated on OUT-OF-SAMPLE quarters
|
|
||||||
only (all calendar quarters after a 12-month burn-in, each predicted by a model trained
|
|
||||||
exclusively on trades whose entry time is ≥ 300 primary bars before that quarter's start,
|
|
||||||
expanding window, standardisation fit on train only), ALL of the following hold:
|
|
||||||
1. Sized total R > flat total R in BOTH halves of the OOS period (split at the median OOS
|
|
||||||
entry time).
|
|
||||||
2. Sized total R > flat total R over the full OOS period.
|
|
||||||
3. Sized max drawdown (0-prepended cumulative sized-R curve, as `compute_metrics`) ≤ flat max
|
|
||||||
drawdown × 1.10.
|
|
||||||
4. Sized OOS book has PF ≥ 1.3 and positive total R in both halves (the rule-1 floor).
|
|
||||||
5. Real OOS lift > the 95th percentile of the lift distribution from 50 label-shuffle refits
|
|
||||||
(R permuted within training windows only; test labels and walk-forward geometry unchanged).
|
|
||||||
6. Mean multiplier over OOS trades within 1.00 ± 0.05; every multiplier in [0.5, 1.5].
|
|
||||||
Plus the mechanical check: the `--sizing` full-window replay reproduces the flat book's
|
|
||||||
entry/exit/`r_multiple` columns exactly (sizing changes no entries).
|
|
||||||
Model family and size map (fixed): primary = ridge regression on winsorised R (clip to
|
|
||||||
[−1.5, +4]); secondary = L2 logistic on R>0 (ships only if the primary fails and it passes);
|
|
||||||
simplicity baseline = one-feature model on `f_is_buy` (the shipped model must beat its OOS
|
|
||||||
lift, else the simpler one ships if it passes); upper bound = lightgbm on all `f_*` (never
|
|
||||||
ships; if its OOS lift is inside the shuffle null, stop); ablation = exhaustion-block-only model.
|
|
||||||
Map: `m = 0.5 + 1.0 × clip((z − q10)/(q90 − q10), 0, 1)` with q10/q90 from TRAIN predictions,
|
|
||||||
times a normaliser making the mean train multiplier 1.0, clipped to [0.5, 1.5], rounded to 0.05.
|
|
||||||
`SHIPPED_FEATURES` (≤ 12 per TF, ≥ 2 from the exhaustion block) are chosen from the Stage-B
|
|
||||||
sign-stable screen on the 3-year feature books and recorded below BEFORE the first fit; any
|
|
||||||
later change restarts the shuffle control. If any clause fails on a timeframe, that timeframe
|
|
||||||
ships nothing. No widening the clip, changing the split, dropping the purge, re-picking
|
|
||||||
features or adding quarters to rescue a result.
|
|
||||||
|
|
||||||
## Pre-registered feature lists
|
|
||||||
Derived MECHANICALLY (script in the session log, saved as `sizing_2026-09/preregistered_features.json`):
|
|
||||||
sign-stable `f_*` features from the Stage-B screen (top-minus-bottom quintile mean-R gap has the
|
|
||||||
same sign in both halves, both halves ≥ 10 trades per bucket), zero-variance columns removed,
|
|
||||||
ranked by min(|gap_h1|, |gap_h2|), top 12; the ≥ 2-from-the-exhaustion-block clause was already
|
|
||||||
satisfied on both TFs. Written 2026-09-06 00:05 UTC, before any Stage-D fit.
|
|
||||||
- **M5 (29 sign-stable candidates):** f_sl_atr, f_confidence, f_rsi, f_mtf_c1, f_swing_dist_atr,
|
|
||||||
f_ret_48_atr, f_tag_no_pullback, f_rsi_dir, f_first_friday, f_mtf_agreement,
|
|
||||||
f_exhaustion_count, f_ret_24_atr. Exhaustion-block members: f_swing_dist_atr,
|
|
||||||
f_tag_no_pullback, f_exhaustion_count (all three run POSITIVE in both halves).
|
|
||||||
- **M15 (37 sign-stable candidates):** f_rsi, f_bb_pos, f_day_of_month, f_tag_ema200_near,
|
|
||||||
f_atr_pct, f_is_buy, f_confidence, f_close_pos_in_bar, f_tag_weak_pullback, f_tag_exhaustion,
|
|
||||||
f_penalty_total, f_ret_6_atr. Exhaustion-block members: f_tag_exhaustion, f_penalty_total
|
|
||||||
(both NEGATIVE in both halves; f_tag_weak_pullback and f_tag_ema200_near also negative).
|
|
||||||
Exhaustion-only ablation list (fixed by the plan): f_swing_dist_atr, f_ema21_dist_atr,
|
|
||||||
f_ema200_dist_atr, f_exhaustion_count, f_penalty_total, f_tag_bb_overextended,
|
|
||||||
f_tag_rsi_extended, f_tag_no_pullback, f_tag_exhaustion.
|
|
||||||
|
|
||||||
## Stage B — book breakdown (3-year feature books)
|
|
||||||
Tool: `backtest/research/book_report.py` (outputs in `sizing_2026-09/report_{5min,15min,1h,4h}/`).
|
|
||||||
Halves = median TRADE split (M5 cut 2025-12-12 because trade density doubled in the last year;
|
|
||||||
M15 cut 2025-04-25; H1 2025-04-09).
|
|
||||||
|
|
||||||
**M5 (857 trades, +161.3R, PF 1.53, DD 9.85; halves +51.0R PF 1.33 / +110.3R PF 1.74; max 11
|
|
||||||
wins / 8 losses in a row; longest flat stretch 88 trades).** Mix: GENERIC 469, XAU_CP 388, BUY
|
|
||||||
765 / SELL 92 (SELLs +11.8R, positive both halves). Grade: A +0.26R/trade (227) > B +0.17 (608)
|
|
||||||
> C −0.01 (21). Session (decision hour): NY 17–22 best (+0.31R/trade, PF 1.88), overlap 13–16
|
|
||||||
weakest (+0.08, PF 1.22) — both stable. Weekday: Wed and Fri best and stable; Mon/Tue flip sign.
|
|
||||||
Feature screen: 29 sign-stable `f_*` features. **Every stretch measure is POSITIVE**: wider
|
|
||||||
stop in ATR (+0.22/+0.23 gap), higher RSI (+0.20/+0.19), farther from the last swing
|
|
||||||
(+0.18/+0.27), 48-bar run-up (+0.18/+0.20), the `no_pullback` tag (+0.17/+0.28), more exhaustion
|
|
||||||
tags (+0.12/+0.24). Only ATR-relative-to-500-bar-mean is negative (−0.11/−0.12: entries during
|
|
||||||
a volatility spike do worse). "Risk less when stretched" has NO support on M5 — a model that
|
|
||||||
honours the data will size stretched M5 trades UP, not down.
|
|
||||||
|
|
||||||
**M15 (1086 trades, +148.7R, PF 1.37, DD 15.47; halves +65.8R / +82.9R; max 12 wins / 8 losses;
|
|
||||||
flat stretch 131 trades).** All GENERIC; BUY 739 (+158.4R, PF 1.62) vs SELL 347 (−9.7R, PF 0.93;
|
|
||||||
−19.2R in half 1, +9.4R in half 2). Grade NOT monotone: C +0.45R/trade (82, PF 2.41) > A +0.14
|
|
||||||
(481) > B +0.08 (516) — stable in both halves (matches the 2026-08-04 quant review). Session:
|
|
||||||
Asian and NY ≈ +0.18–0.20R/trade, London and overlap ≈ +0.09–0.10 (all stable). Weekday:
|
|
||||||
Wednesday flat/negative in both halves (229 trades, −0.6R); Thu/Fri flip sign between halves.
|
|
||||||
Feature screen: 37 sign-stable features. Here the exhaustion block runs NEGATIVE: `exhaustion`
|
|
||||||
tag (−0.19/−0.10), `weak_pullback` tag (−0.24/−0.11), `ema200_near` tag (−0.17/−0.32), penalty
|
|
||||||
total (−0.16/−0.10), ATR percentile (−0.20/−0.15); while raw RSI (+0.31/+0.24) and Bollinger
|
|
||||||
position (+0.36/+0.22) are positive. So on M15 "stretched by the aggregator's own penalty
|
|
||||||
logic" is bad while "strong momentum reading" is good — the sizing model gets both.
|
|
||||||
|
|
||||||
**H1 (255 trades, +70.5R, PF 1.98, DD 4.43; halves +28.7R PF 1.79 / +41.9R PF 2.17)** and
|
|
||||||
**H4 (113 trades, +22.7R, PF 1.63, DD 5.39)** are descriptive only. H1 is all-BUY by
|
|
||||||
construction (shorts blocked 2026-09-03); Mon/Thu strongest, Friday the only losing weekday
|
|
||||||
(−1.4R/50, flips between halves); 03:00 UTC decision hour is the best single hour (+20.0R/40,
|
|
||||||
stable) and 04:00–05:00 negative.
|
|
||||||
|
|
||||||
**Labelling quirk (pre-existing, not fixed):** 33 M5 trades carry the backtester `strategy`
|
|
||||||
tag GENERIC while their setup strategy is XAU_ATR (`f_strat_atr = 1`); the CSV tag and the
|
|
||||||
setup's own string disagree for the ATR path. Labels only — R is unaffected.
|
|
||||||
|
|
||||||
**Stop-trigger check: PASSED** (both TFs have many sign-stable features) → proceed to Stage D.
|
|
||||||
|
|
||||||
## Stage C — institutional burst / time map
|
|
||||||
Tool: `backtest/research/burst_map.py` on the 3-year raw bars (`burst_5min/`, `burst_15min/`
|
|
||||||
under `sizing_2026-09/`). Burst = bar range ≥ K × prior Wilder ATR(14). Base rate at K=3:
|
|
||||||
0.86 % of M5 bars (1840), 1.19 % of M15 bars (846).
|
|
||||||
|
|
||||||
**Where the violent bars cluster (bar-CLOSE hour, UTC, K=3 burst % of bars):**
|
|
||||||
- 12:00–13:00 (US data window): M5 2.4 % / 2.3 %, M15 3.7 % / 5.0 % — 3–4× the base rate;
|
|
||||||
Friday 12:00–13:00 is the densest weekday cell on M5 (3.4 % / 3.1 %). Largest mean 1-hour
|
|
||||||
dollar moves are at close-hours 13–15 (M5: $7.8 / $9.4 / $8.5 vs ≈$5 elsewhere).
|
|
||||||
- 01:00 (Asia open): M5 2.9 %, M15 3.3 %. Also 05:00 (Tokyo/London hand-over) ≈1.2 % on M5.
|
|
||||||
- 22:00–23:00 (post-settlement thin book): ≈1.3–1.4 % on M5; Sunday reopen 22–23 h is the most
|
|
||||||
burst-prone slot of all (2.4–3.0 % of bars) but the calendar already blocks Sunday entries
|
|
||||||
before 18:00 NY.
|
|
||||||
- Quietest hours: 03:00–04:00 and 16:00–17:00 (0.14–0.3 %).
|
|
||||||
|
|
||||||
**Post-burst drift (in the burst's own direction, ATR units):** ≈ 0 at every horizon on both
|
|
||||||
TFs (M5 6/12/24/48-bar means −0.01/−0.01/+0.09/+0.05; M15 −0.08/+0.14/+0.18/+0.17; % positive
|
|
||||||
46–51 %). London-session bursts on M5 show a mild short-horizon reversal (42 % positive at 6
|
|
||||||
bars). No continuation or reversal edge worth a rule.
|
|
||||||
|
|
||||||
**Do bursts hurt the system's entries? No.** Trades DECIDED within 12 bars after a K=3 burst:
|
|
||||||
M5 149 trades, mean +0.199R, PF 1.62 vs +0.186R / PF 1.52 for the rest; M15 242 trades,
|
|
||||||
+0.160R / PF 1.47 vs +0.130R / PF 1.34. At K=4/5 the after-burst group is better still
|
|
||||||
(M5 +0.276R PF 1.98; M15 +0.259R PF 1.80). This re-confirms the 2026-07-30 big-candle-gate
|
|
||||||
rejection with 3 years of data: expansion bars are part of the entry signal, not a danger.
|
|
||||||
(Group (b) "burst while open" is survivorship-shaped — winners live longer — and is not evidence.)
|
|
||||||
|
|
||||||
**The 40 largest 1-hour moves** are almost all one episode (2026-01-28 → 2026-02-06, incl. a
|
|
||||||
−$394 hour at 15:45 UTC on 2026-01-29, and 2026-03-23), not recurring clock slots; only 3 of 40
|
|
||||||
fall in the 12:30 data slot and 1 in the Wednesday 18:00 central-bank slot (slot labels only —
|
|
||||||
no economic calendar file exists in the repo). Violent moves are episodic regime events; the
|
|
||||||
recurring, predictable clustering is the 12:00–15:00 UTC US-data window and the 01:00 Asia open.
|
|
||||||
|
|
||||||
**Use in this campaign:** `f_bars_since_burst` and the hour/session features are already in the
|
|
||||||
feature set, so Stage D can weigh them as a stake input; no entry gate is proposed (rule 1).
|
|
||||||
|
|
||||||
## Stage D — walk-forward results (2026-09-06 00:20 UTC)
|
|
||||||
Driver: `backtest/research/sizing_model.py` (numpy closed-form ridge + Newton logistic; lightgbm
|
|
||||||
4.7 + scikit-learn 1.9 installed in ./venv for the upper bound only; NOT in requirements.txt).
|
|
||||||
Outputs: `sizing_2026-09/sm_{m5,m15}_{ridge,logistic}/` (summary.txt, oos_trades.csv,
|
|
||||||
quarters.csv, coefs_by_quarter.csv, shuffle_lifts.csv). Validation of the driver: planted
|
|
||||||
synthetic effect recovered (lift +116.7R, shuffle rank 100 %, all clauses PASS); pure-noise
|
|
||||||
book correctly FAILS (rank 32 %); ridge matches `lstsq` to 2e-16; logistic gradient < 3e-15.
|
|
||||||
|
|
||||||
**M5 (5 OOS quarters 2025Q3→2026Q3, 619 trades — 2024Q4–2025Q2 skipped by the pre-registered
|
|
||||||
min-train rule because only 96/119/142 trades precede them; 2026Q1 alone is +92R of the +125R
|
|
||||||
flat OOS):**
|
|
||||||
| model | flat R | sized R | lift | PF | DD | mean m | shuffle p95 | rank | verdict |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| ridge (primary) | +125.41 | +121.98 | −3.43 | 1.63 | 10.69 | 0.891 | +13.69 | 48 % | FAIL (1,2,5,6) |
|
|
||||||
| logistic | +125.41 | +128.40 | +2.99 | 1.66 | 8.70 | 0.900 | +14.39 | 62 % | FAIL |
|
|
||||||
| isbuy baseline | +125.41 | +123.22 | −2.20 | 1.55 | 11.92 | 1.000 | +7.05 | 4 % | FAIL |
|
|
||||||
| exhaustion-only | +125.41 | +118.03 | −7.38 | 1.59 | 10.88 | 0.918 | +14.36 | 28 % | FAIL |
|
|
||||||
| lightgbm (all 65) | +125.41 | +116.05 | −9.36 | 1.51 | 11.64 | 1.007 | +7.60 | 10 % | FAIL |
|
|
||||||
Ridge weights (standardised, 5 quarters): `f_tag_no_pullback` +0.018 sign-stable 5/5 — the
|
|
||||||
"no pullback" flag INCREASES the stake; `f_swing_dist_atr` and `f_exhaustion_count` ≈ 0 and
|
|
||||||
sign-unstable. Exhaustion-only sizing LOSES 7.4R. **A clean null: nothing in the 65 features
|
|
||||||
predicts M5 trade outcomes beyond noise, linear or non-linear.**
|
|
||||||
|
|
||||||
**M15 (8 OOS quarters 2024Q4→2026Q3, 762 trades; flat +115.93R PF 1.42 DD 13.73):**
|
|
||||||
| model | sized R | lift | PF | DD | mean m | shuffle p95 | rank | clauses |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| ridge (primary) | +142.04 | +26.11 | 1.53 | 14.46 | 0.980 | +28.50 | 88 % | 1,2,3,4,6 PASS · **5 FAIL** |
|
|
||||||
| logistic | +144.78 | +28.84 | 1.53 | 13.97 | 1.002 | +31.89 | 92 % | 1,2,3,4,6 PASS · **5 FAIL** |
|
|
||||||
| isbuy baseline | +139.38 | +23.45 | 1.50 | 16.44 | 1.007 | +24.47 | 58 % | FAIL (3, 5) |
|
|
||||||
| exhaustion-only | +123.83 | +7.90 | 1.45 | 14.85 | 1.010 | +19.77 | 70 % | FAIL |
|
|
||||||
| lightgbm (all 65) | +123.87 | +7.94 | 1.44 | 14.74 | 0.996 | +17.66 | 60 % | FAIL |
|
|
||||||
Ridge weights sign-stable in 8/8 quarters: `f_tag_exhaustion` −0.022, `f_tag_ema200_near`
|
|
||||||
−0.033 (both SHRINK the stake — the "risk less when stretched" direction), `f_rsi` +0.028,
|
|
||||||
`f_bb_pos` +0.030, `f_is_buy` +0.029, `f_close_pos_in_bar` +0.022, `f_ret_6_atr` +0.022;
|
|
||||||
λ = 1000 every quarter (heavy shrinkage — the score is weak and the map is essentially a rank
|
|
||||||
transform). Sized R beat flat in 6 of 8 quarters and in both halves, BUT the label-shuffle
|
|
||||||
null has p95 = +28.5R: a random re-weighting of these 12 pre-screened features produces the
|
|
||||||
same lift 12 % of the time, and the one-feature BUY/SELL baseline already delivers +23.5R of
|
|
||||||
the +26.1R. **The model's information beyond "buys are better than sells" (≈ +2.7R over 762
|
|
||||||
trades) is indistinguishable from noise.**
|
|
||||||
|
|
||||||
## Verdict (pre-registered rule applied, no rescue attempted)
|
|
||||||
**FAIL on both timeframes → no stake multiplier ships.** No artifact, no `sizing.enabled`, no
|
|
||||||
alert line, no `--sizing` backtester flag. What remains in the tree is behaviour-neutral
|
|
||||||
research plumbing: `position_sizing.build_features` (65 decision-time features; the multiplier
|
|
||||||
path returns 1.0 and no artifact exists), `SignalReport.penalty_tags/penalty_total/
|
|
||||||
momentum_fading` (record-keeping), `TradeSetup.stake_multiplier` (None; reserved), the
|
|
||||||
`--export-features` flag, `book_report.py`, `burst_map.py`, `sizing_model.py`, and Module 29
|
|
||||||
tests. `SizingConfig` was REMOVED again before merge (rule 8: nothing reads it).
|
|
||||||
|
|
||||||
**What the owner should take from this (plain language):**
|
|
||||||
1. On the 5-minute chart there is nothing to size on. Stretched trades are the good ones; the
|
|
||||||
system's exhaustion flags carry no usable information about the outcome; a flexible model
|
|
||||||
given all 65 facts does worse than flat sizing out of sample.
|
|
||||||
2. On the 15-minute chart the only robust sizing signal is BUY vs SELL, which the gates already
|
|
||||||
act on. The exhaustion flag does point the "risk less" way in every one of 8 unseen
|
|
||||||
quarters, but the money it would make (≈ +3R per year) is inside noise at 1,086 trades.
|
|
||||||
3. "Predict direction better" with a learned second opinion is closed at this sample size:
|
|
||||||
the strongest non-linear model (all features, gradient boosting) fails on both charts.
|
|
||||||
|
|
||||||
**Open avenues (not rescues — each needs a NEW pre-registration):**
|
|
||||||
- Re-run the M15 exhaustion-tag sizing when the book has ≈ 2× the trades (≈ 2028) or on a
|
|
||||||
sealed forward sample; the sign is stable, the magnitude is not proven.
|
|
||||||
- The shuffle null is wide because the size map rank-stretches a weak score to 0.5–1.5×; a
|
|
||||||
narrower map (e.g. 0.75–1.25×) would shrink both lift and null — pre-register before testing.
|
|
||||||
- Session and weekday structure (M5: NY best / overlap weakest; M15: Wednesday flat) is stable
|
|
||||||
across halves but was rejected as a gate repeatedly; as a *size* input it was inside the
|
|
||||||
12-feature model and did not survive the null.
|
|
||||||
|
|
||||||
## Addendum 2026-09-06 — violent bars: signs before them, and protecting OPEN trades
|
|
||||||
Owner follow-up: "we might have an order running for hours before a violent move; we want
|
|
||||||
protection." Tools: `backtest/research/burst_precursors.py` (signs before a burst) and
|
|
||||||
`backtest/research/open_trade_bursts.py` (exact replay of every book trade on raw bars with the
|
|
||||||
backtester's exit model — reproduces `r_multiple` on 100 % of trades — plus protection variants).
|
|
||||||
Outputs: `sizing_2026-09/burst_precursors_{m5,m15}.txt`, `open_trade_bursts_{m5,m15}.txt`.
|
|
||||||
|
|
||||||
**Signs before a burst (both TFs, both halves stable):** the clock (close-hours 01, 12, 13 UTC
|
|
||||||
hold ~40 % of bursts in 13 % of bars); tick activity in the last 3 bars vs 50-bar mean (top
|
|
||||||
quintile ≈ 2× rate); the last bars already wider than normal (top quintile ≈ 2.2×; the
|
|
||||||
tightest quintile ≈ 0.3–0.5× — NOT calm-before-storm at bar scale); a move already under way
|
|
||||||
over the past hour (≈ 1.6×); a burst minutes ago (≈ 1.4×). Out of sample (fit half 1, score
|
|
||||||
half 2) a logistic model reaches AUC 0.75–0.77 for the next bar; the top 5 % of moments carry a
|
|
||||||
3.4–5.5 % burst chance vs 0.8–1.1 % base, and 24 % vs 9 % (M5) for "within the next hour".
|
|
||||||
Burst direction continues the prior hour's drift 61 % of the time. Signs say "ripe", not "now".
|
|
||||||
|
|
||||||
**Damage to open trades (base exits):** M5 — 164 trades (19 %) meet an opposing burst while
|
|
||||||
open: mean open R +0.42 just before → −0.06 just after → final +0.01; 43 % are stopped on that
|
|
||||||
bar; 54 % end as losers. M15 — 246 trades (23 %): +0.27 → −0.20 → final −0.12; 39 % stopped on
|
|
||||||
the bar. Trades already in profit before the burst still finish positive on average (M5 +0.53,
|
|
||||||
M15 +0.27). Bursts WITH the trade (n 176 / 246) finish at +1.11 / +0.83R — they are where the
|
|
||||||
winners come from.
|
|
||||||
|
|
||||||
**Protection variants — ALL negative on both TFs, both halves:**
|
|
||||||
| variant | M5 ΔR (h1/h2) | M15 ΔR (h1/h2) |
|
|
||||||
|---|---|---|
|
|
||||||
| flatten before US-data hours (12–13 UTC) | −31.9 (−13.6/−18.3) | −103.0 (−67.4/−35.6) |
|
|
||||||
| flatten before Asian open (00–01 UTC) | −38.7 (−22.0/−16.7) | −73.0 (−42.0/−30.9) |
|
|
||||||
| protective 0.5R stop through the data hours | −29.6 (−12.3/−17.3) | −82.3 (−55.5/−26.8) |
|
|
||||||
| protective 0.5R stop through the Asian open | −19.2 (−9.5/−9.7) | −35.1 (−17.3/−17.8) |
|
|
||||||
| exit next open after an opposing burst | −19.1 (−6.5/−12.5) | −6.9 (−11.9/+5.0) |
|
|
||||||
| 0.5R protective stop after an opposing burst | −9.0 (−3.0/−6.0) | −8.3 (−15.5/+7.3) |
|
|
||||||
The existing stop/trail already contains the opposing-burst damage (≈ 0.4–0.5R per hit); every
|
|
||||||
pre-emptive rule removes far more from the with-trade bursts than it saves. **Verdict: no
|
|
||||||
open-trade protection rule ships; do not re-propose flatten/tighten-before-window or
|
|
||||||
react-to-burst exits without a new mechanism.** Display-only "violent-move conditions" note on
|
|
||||||
alerts is the only harmless use of the precursor signs.
|
|
||||||
|
|
||||||
## Addendum 2026-09-06 — calendar events, indicator states and time slots before violent bars
|
|
||||||
Tools: `backtest/research/calendar_impact.py` (needs `backtest/cache/calendar.parquet`, scraped
|
|
||||||
2023-09-01→2026-09-10 with `ff_calendar_scraper.py`: 15,124 events, 1,055 high-impact USD in
|
|
||||||
window) and `backtest/research/burst_indicator_map.py` (states from the backtester's own
|
|
||||||
`precompute_indicators`, taken at t−1). Outputs `sizing_2026-09/calendar_impact_{m5,m15}.txt`,
|
|
||||||
`burst_indicator_map_{m5,m15}.txt`. Burst = range ≥ 3× prior ATR14; lifts quoted are stable in
|
|
||||||
BOTH halves on both TFs unless noted.
|
|
||||||
|
|
||||||
**Calendar.** The M5 bar containing a high-impact USD release averages 4.4× normal range; 54 %
|
|
||||||
of such releases produce a burst within the hour (base 9 %). By event: Non-Farm Payrolls (with
|
|
||||||
Unemployment Rate / Avg Hourly Earnings) 97 %, 9.3× bar, $11.9 mean 1-h move; CPI 91 %, 8.3×,
|
|
||||||
$9.0; FOMC decision/statement 87.5 %, 7.1×, $18.2 (press conference a further $20 hour); FOMC
|
|
||||||
projections 92 %; Retail Sales 64 %; PPI 54 %; Core PCE 50 %; Unemployment Claims 44 %; GDP
|
|
||||||
45–58 %; ISM PMIs 26 %; Powell speaks 33 %; Trump speaks 21 %; UoM sentiment ≈ 5 %. 20 % (M5)
|
|
||||||
/ 31 % (M15) of ALL bursts fall within ±30 min of a high-impact USD release (3.7–4.2 % of time);
|
|
||||||
in the 30 min AFTER one, next-bar burst odds are 8–17 % (7–16× base); 15 min before, 2–3 %.
|
|
||||||
Open trades that held through a high-impact release did BETTER (M5 +0.41R vs +0.16R; M15 +0.27
|
|
||||||
vs +0.07), even when the first 15-min move went against them (M5 +0.09, M15 +0.22). Flattening
|
|
||||||
5 min before a release: −5.4R (M5) / −59.3R (M15); 0.5R protective stop through the hour:
|
|
||||||
−2.4R / −43.1R. **Events are the biggest single cause of violent bars and still not a reason
|
|
||||||
to touch an open trade.**
|
|
||||||
|
|
||||||
**Indicator states before a burst (next-bar lift, M5 / M15).** Price OUTSIDE the Bollinger
|
|
||||||
bands: below 2.9× / 1.8–2.9×, above 2.1× / 2.2×, middle 0.2–0.8 band 0.7×. RSI < 30: 2.6–2.9× /
|
|
||||||
1.8–2.2×; RSI ≥ 70: 1.9–2.1× / 1.6–1.9×; RSI 40–60: 0.7× (calmest). Close outside the Keltner
|
|
||||||
channel: 1.4–2.2×; inside 0.76×. ADX RISING > 3: 1.4–1.5× / 1.2×; ADX FALLING < −3: 0.5–0.6×
|
|
||||||
(safest state); the ADX level itself carries nothing. MACD histogram negative and still falling
|
|
||||||
1.3× / 1.2–1.4×; histogram fading (either sign) 0.8×. Fully bearish 4-EMA stack 1.3–1.4× /
|
|
||||||
1.3×; "mostly bull" 0.8× on M5. Regime: transitioning 1.4–1.6× / 1.5–1.6×, ranging 0.88×.
|
|
||||||
Volatility regime: extreme 1.5–1.6× / 1.4–1.9×, low 0.6–0.8×. ROC beyond ±0.3 %: 1.2–1.9×
|
|
||||||
(>1 %: 1.3–10×, n small). Bollinger-width squeeze: 0.59× on M5 (LESS likely next bar — narrow
|
|
||||||
bands are not a warning); TTM squeeze (bands inside Keltner) 1.6× on M15 only. EMA200 distance,
|
|
||||||
day of month, first-Friday-as-a-day: nothing stable (first Friday matters only through its
|
|
||||||
12:30 slot).
|
|
||||||
|
|
||||||
**Time slots (M5 next-bar burst %, base 0.86 %):** Fri 01:xx 3.4 %, Fri 12:xx 3.4 %, Tue/Wed
|
|
||||||
01:xx 3.2 %, Thu 12:xx 3.1 %, Fri 13:xx 3.1 %, Sun 22:xx reopen 3.0 %, Thu 01:xx 2.7 %, Wed
|
|
||||||
12:xx 2.6 %, Sun 23:xx 2.4 %, Tue 00:xx 2.3 % (all 2.5–3.9× base). Quietest: 03–04 and 16–17.
|
|
||||||
|
|
||||||
**Direction of the burst (share going UP):** fully-bull stack 62 % (M5) / 50 % (M15); fully-bear
|
|
||||||
stack 26 % / 29 %; RSI < 30 → 22 % / 30 % up (i.e. 70–78 % DOWN); RSI ≥ 70 → 64 % / 52 % up;
|
|
||||||
MACD neg & falling → 30 % / 34 % up; trending_down regime → 26 % / 26 % up; trending_up → 60 % /
|
|
||||||
46 % up. Bearish states foretell a DOWN burst reliably on both TFs; bullish states foretell an
|
|
||||||
UP burst on M5 only.
|
|
||||||
|
|
||||||
**Reading:** violent bars arrive when price is already at an extreme with momentum accelerating
|
|
||||||
(outside the bands, RSI extreme, ADX rising, MACD still falling, transitioning regime, extreme
|
|
||||||
volatility) and/or a release is minutes away — "the storm is already brewing", not "calm before
|
|
||||||
the storm". Calm mid-band bars with fading momentum and falling ADX are the safest. None of it
|
|
||||||
changes trade handling (protection tests above); it is display material for a warning line.
|
|
||||||
|
|
||||||
## Addendum 2026-09-06 — violent-move WATCH rule (display-only feature, `violent_watch.py`)
|
|
||||||
Owner asked for a warning. Delivery: a line on signal alerts + a standalone heads-up to the owner
|
|
||||||
(once per hour per trigger set, daily cap), dark by default (`violent_watch` config block).
|
|
||||||
The first draft rule ("hot" = calendar | ≥3 indicator readings | clock & ≥2 readings) was
|
|
||||||
evaluated bar-by-bar on 3 years (`backtest/research/watch_rule_eval.py`, scratch variant
|
|
||||||
sweep): it flagged 27 % of bars hot at only ≈2× odds and would have sent ≈13 heads-ups/day on
|
|
||||||
M5 — rejected. Sweep of 11 variants (M5 / M15, both halves):
|
|
||||||
| hot definition | bars hot | next-bar lift h1/h2 | next-hour rate | hot hours/day |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| calendar only (hi-impact USD ±30 min) | 3.4 % | 6.7×/4.4× · 9.1×/6.4× | 31 % · 28 % | 2.6 |
|
|
||||||
| clock slot only | 18.8 % | 3.1×/2.0× · 3.6×/2.6× | 16 % · 13 % | 5.3 |
|
|
||||||
| ≥2 price-extreme readings | 14.6 % | 2.3×/2.2× · 1.8×/2.0× | 11 % · 6 % | 10.7 |
|
|
||||||
| **calendar ∨ (clock ∧ ≥1 price-extreme reading)** | **8.6 %** | **4.9×/3.1× · 5.9×/4.2×** | **21 % · 17 %** | **4.1 · 3.5** |
|
|
||||||
| draft (cal ∨ readings≥3 ∨ clock∧readings≥2) | 26.9 % | 2.4×/2.0× · 2.4×/2.1× | 13 % · 8 % | 13.0 |
|
|
||||||
**Shipped rule:** price-extreme readings = outside the Bollinger bands, RSI < 30 or ≥ 70, close
|
|
||||||
outside the Keltner channel (the neutral readings — ADX rising, extreme volatility, transitioning
|
|
||||||
— are dropped from the level logic; they are common and only ≈1.5×). `hot` = high-impact USD
|
|
||||||
release within 30 min or 30 min after, OR a hot clock slot with ≥ 1 price-extreme reading; `watch`
|
|
||||||
= clock slot alone or ≥ 2 price-extreme readings (≈2× odds). Direction sentence from the bear/bull
|
|
||||||
polarity of the readings plus MACD acceleration and trend regime ("DOWN" on both TFs, "UP" on M5
|
|
||||||
only). Heads-up only at `hot`. Expected ≈4 hot hours/day → ≤4 heads-ups/day after per-hour dedup.
|
|
||||||
@@ -1,961 +0,0 @@
|
|||||||
"""Stage-D stake-sizing model driver (2026-09 campaign).
|
|
||||||
|
|
||||||
Walk-forward fit + evaluation of a per-signal stake multiplier on a trade
|
|
||||||
book exported by ``gpu_backtest.py --export-features`` (63 base columns +
|
|
||||||
``stake_multiplier``/``r_sized`` + 65 ``f_*`` decision-time features).
|
|
||||||
|
|
||||||
The protocol implemented here is the one pre-registered in
|
|
||||||
``backtest/research/sizing_campaign_2026-09.md`` and is NOT to be varied to
|
|
||||||
rescue a result:
|
|
||||||
|
|
||||||
* expanding walk-forward, 12-month burn-in, calendar-quarter refits, a
|
|
||||||
``purge_bars × bar_minutes`` gap between the end of train and the start of
|
|
||||||
the test quarter, standardisation fit on train only;
|
|
||||||
* models: ridge on winsorised R (primary), L2 logistic on ``R > 0``
|
|
||||||
(secondary), a one-feature ``f_is_buy`` simplicity baseline, an
|
|
||||||
exhaustion-block-only ablation, and an optional lightgbm upper bound;
|
|
||||||
* size map ``m = round_0.05(clip(normaliser × (0.5 + clip((z−lo)/(hi−lo),0,1)),
|
|
||||||
0.5, 1.5))`` with ``lo``/``hi``/``normaliser`` calibrated on TRAIN
|
|
||||||
predictions only;
|
|
||||||
* a label-shuffle null (training labels permuted, test labels untouched);
|
|
||||||
* six acceptance clauses, printed PASS/FAIL individually.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python backtest/research/sizing_model.py --book BOOK.csv --bar-minutes 5 \
|
|
||||||
--features f_a,f_b,... [--model ridge|logistic] [--out DIR] \
|
|
||||||
[--n-shuffle 50] [--skip-lgbm] \
|
|
||||||
[--write-artifact PATH --tf 5min --confirm-tfs 15min,1h]
|
|
||||||
|
|
||||||
Analysis tooling only — never imported by the live code.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import warnings
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, List, Optional, Sequence, Tuple
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
if str(PROJECT_ROOT) not in sys.path:
|
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
from position_sizing import FEATURE_ORDER, FEATURE_VERSION # noqa: E402 (read-only import)
|
|
||||||
|
|
||||||
# ── Pre-registered constants (flag any change loudly) ──────────────────
|
|
||||||
WINSOR_LO, WINSOR_HI = -1.5, 4.0
|
|
||||||
LAMBDA_GRID: Tuple[float, ...] = (1.0, 10.0, 100.0, 1000.0)
|
|
||||||
CV_FOLDS = 5
|
|
||||||
MIN_TRAIN = 150
|
|
||||||
MAP_Q_LO, MAP_Q_HI = 0.10, 0.90
|
|
||||||
MAP_OUT_LO, MAP_OUT_HI = 0.5, 1.5
|
|
||||||
MAP_CLIP_LO, MAP_CLIP_HI = 0.5, 1.5
|
|
||||||
MAP_ROUND_TO = 0.05
|
|
||||||
MEAN_MULT_TOL = 0.05
|
|
||||||
DD_TOL = 1.10
|
|
||||||
PF_FLOOR = 1.3
|
|
||||||
NEWTON_MAX_ITER = 50
|
|
||||||
NEWTON_TOL = 1e-8
|
|
||||||
|
|
||||||
#: The exhaustion-block ablation's fixed feature list.
|
|
||||||
EXHAUSTION_FEATURES: Tuple[str, ...] = (
|
|
||||||
"f_swing_dist_atr",
|
|
||||||
"f_ema21_dist_atr",
|
|
||||||
"f_ema200_dist_atr",
|
|
||||||
"f_exhaustion_count",
|
|
||||||
"f_penalty_total",
|
|
||||||
"f_tag_bb_overextended",
|
|
||||||
"f_tag_rsi_extended",
|
|
||||||
"f_tag_no_pullback",
|
|
||||||
"f_tag_exhaustion",
|
|
||||||
)
|
|
||||||
ISBUY_FEATURES: Tuple[str, ...] = ("f_is_buy",)
|
|
||||||
|
|
||||||
#: lightgbm never ships; running the full shuffle null through 200-tree fits
|
|
||||||
#: costs minutes for a number nobody acts on, so its null is capped here.
|
|
||||||
#: DEVIATION FROM THE PROTOCOL CONSTANT — printed in the summary when used.
|
|
||||||
LGBM_MAX_SHUFFLE = 10
|
|
||||||
|
|
||||||
LGBM_PARAMS = dict(
|
|
||||||
n_estimators=200,
|
|
||||||
learning_rate=0.03,
|
|
||||||
num_leaves=7,
|
|
||||||
min_child_samples=30,
|
|
||||||
subsample=0.8,
|
|
||||||
colsample_bytree=0.8,
|
|
||||||
reg_lambda=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
_LINES: List[str] = []
|
|
||||||
|
|
||||||
|
|
||||||
def say(msg: str = "") -> None:
|
|
||||||
"""Print and tee into the summary buffer."""
|
|
||||||
print(msg)
|
|
||||||
_LINES.append(msg)
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
# Book loading
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
def load_book(path: Path) -> pd.DataFrame:
|
|
||||||
"""Read a feature book, parse ``entry_time`` UTC, drop NaN-R rows."""
|
|
||||||
df = pd.read_csv(path)
|
|
||||||
if "entry_time" not in df.columns or "r_multiple" not in df.columns:
|
|
||||||
raise SystemExit(f"{path}: not a trade book (no entry_time/r_multiple)")
|
|
||||||
df["entry_time"] = pd.to_datetime(df["entry_time"], utc=True, format="mixed")
|
|
||||||
n0 = len(df)
|
|
||||||
bad = ~np.isfinite(pd.to_numeric(df["r_multiple"], errors="coerce").to_numpy(float))
|
|
||||||
if bad.any():
|
|
||||||
say(f"WARNING: dropping {int(bad.sum())}/{n0} rows with non-finite r_multiple")
|
|
||||||
df = df.loc[~bad].copy()
|
|
||||||
df["r_multiple"] = df["r_multiple"].astype(float)
|
|
||||||
df = df.sort_values("entry_time", kind="mergesort").reset_index(drop=True)
|
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_features(df: pd.DataFrame, names: Sequence[str], label: str) -> List[str]:
|
|
||||||
"""Validate a requested feature list against the book and FEATURE_ORDER."""
|
|
||||||
missing = [n for n in names if n not in df.columns]
|
|
||||||
if missing:
|
|
||||||
raise SystemExit(f"{label}: columns absent from book: {', '.join(missing)}")
|
|
||||||
unknown = [n for n in names if n.startswith("f_") and n not in FEATURE_ORDER]
|
|
||||||
if unknown:
|
|
||||||
say(f"NOTE: {label}: {', '.join(unknown)} not in position_sizing.FEATURE_ORDER")
|
|
||||||
return list(names)
|
|
||||||
|
|
||||||
|
|
||||||
def sha256_file(path: Path) -> str:
|
|
||||||
h = hashlib.sha256()
|
|
||||||
with open(path, "rb") as fh:
|
|
||||||
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
||||||
h.update(chunk)
|
|
||||||
return h.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def git_commit() -> str:
|
|
||||||
try:
|
|
||||||
return subprocess.check_output(
|
|
||||||
["git", "rev-parse", "HEAD"], cwd=PROJECT_ROOT, text=True).strip()
|
|
||||||
except Exception: # pragma: no cover - research tool
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
# Walk-forward geometry
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
def quarter_starts(first: pd.Timestamp, last: pd.Timestamp) -> List[pd.Timestamp]:
|
|
||||||
"""Every Jan/Apr/Jul/Oct 1 00:00 UTC in ``[first, last]`` inclusive."""
|
|
||||||
out: List[pd.Timestamp] = []
|
|
||||||
year = first.year
|
|
||||||
while True:
|
|
||||||
for month in (1, 4, 7, 10):
|
|
||||||
ts = pd.Timestamp(year=year, month=month, day=1, tz="UTC")
|
|
||||||
if ts < first:
|
|
||||||
continue
|
|
||||||
if ts > last:
|
|
||||||
return out
|
|
||||||
out.append(ts)
|
|
||||||
year += 1
|
|
||||||
|
|
||||||
|
|
||||||
def quarter_label(ts: pd.Timestamp) -> str:
|
|
||||||
return f"{ts.year}Q{(ts.month - 1) // 3 + 1}"
|
|
||||||
|
|
||||||
|
|
||||||
def build_folds(times: np.ndarray, n_folds: int) -> List[np.ndarray]:
|
|
||||||
"""Contiguous-by-time index blocks (rows are already time-sorted)."""
|
|
||||||
n = len(times)
|
|
||||||
edges = np.linspace(0, n, n_folds + 1).astype(int)
|
|
||||||
return [np.arange(edges[i], edges[i + 1]) for i in range(n_folds)
|
|
||||||
if edges[i + 1] > edges[i]]
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
# Standardisation
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
def fit_standardiser(X: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
||||||
"""Return (mean, scale, keep) from a training matrix.
|
|
||||||
|
|
||||||
NaNs are ignored when computing the mean (they are later filled with it).
|
|
||||||
``scale`` is the population std with zeros replaced by 1.0; ``keep`` is
|
|
||||||
False for zero-variance-in-train columns, which are dropped from the fit
|
|
||||||
and carry a coefficient of 0.
|
|
||||||
"""
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore", category=RuntimeWarning)
|
|
||||||
mean = np.nanmean(X, axis=0)
|
|
||||||
std = np.nanstd(X, axis=0)
|
|
||||||
mean = np.where(np.isfinite(mean), mean, 0.0)
|
|
||||||
std = np.where(np.isfinite(std), std, 0.0)
|
|
||||||
keep = std > 0
|
|
||||||
scale = np.where(keep, std, 1.0)
|
|
||||||
return mean, scale, keep
|
|
||||||
|
|
||||||
|
|
||||||
def apply_standardiser(X: np.ndarray, mean: np.ndarray, scale: np.ndarray) -> np.ndarray:
|
|
||||||
"""Fill NaN with the TRAIN mean, then standardise."""
|
|
||||||
Z = np.array(X, dtype=float, copy=True)
|
|
||||||
bad = ~np.isfinite(Z)
|
|
||||||
if bad.any():
|
|
||||||
Z[bad] = np.take(mean, np.where(bad)[1])
|
|
||||||
return (Z - mean) / scale
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
# Models (numpy only)
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
def _solve(A: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
||||||
try:
|
|
||||||
return np.linalg.solve(A, b)
|
|
||||||
except np.linalg.LinAlgError: # pragma: no cover - degenerate design
|
|
||||||
return np.linalg.lstsq(A, b, rcond=None)[0]
|
|
||||||
|
|
||||||
|
|
||||||
def fit_ridge(X: np.ndarray, y: np.ndarray, lam: float) -> Tuple[np.ndarray, float]:
|
|
||||||
"""Closed-form ridge with an UNPENALISED intercept.
|
|
||||||
|
|
||||||
Centring X and y is exactly equivalent to appending a 1-column carrying
|
|
||||||
zero penalty. Returns ``(coef, intercept)`` with ``pred = intercept + X @ coef``.
|
|
||||||
"""
|
|
||||||
if X.shape[1] == 0:
|
|
||||||
return np.zeros(0), float(y.mean()) if len(y) else 0.0
|
|
||||||
xm = X.mean(axis=0)
|
|
||||||
ym = float(y.mean())
|
|
||||||
Xc = X - xm
|
|
||||||
w = _solve(Xc.T @ Xc + lam * np.eye(X.shape[1]), Xc.T @ (y - ym))
|
|
||||||
return w, ym - float(xm @ w)
|
|
||||||
|
|
||||||
|
|
||||||
def fit_logistic(X: np.ndarray, y: np.ndarray, lam: float,
|
|
||||||
max_iter: int = NEWTON_MAX_ITER,
|
|
||||||
tol: float = NEWTON_TOL) -> Tuple[np.ndarray, float, float]:
|
|
||||||
"""L2 logistic via Newton-Raphson, intercept unpenalised.
|
|
||||||
|
|
||||||
Returns ``(coef, intercept, grad_norm)`` where ``grad_norm`` is the
|
|
||||||
infinity-norm of the penalised gradient at the returned point.
|
|
||||||
"""
|
|
||||||
n, p = X.shape
|
|
||||||
A = np.hstack([np.ones((n, 1)), X])
|
|
||||||
pen = np.zeros(p + 1)
|
|
||||||
pen[1:] = lam
|
|
||||||
beta = np.zeros(p + 1)
|
|
||||||
# Warm-start the intercept at the empirical log-odds.
|
|
||||||
frac = float(np.clip(y.mean(), 1e-6, 1 - 1e-6))
|
|
||||||
beta[0] = math.log(frac / (1 - frac))
|
|
||||||
grad = np.zeros(p + 1)
|
|
||||||
for _ in range(max_iter):
|
|
||||||
eta = A @ beta
|
|
||||||
prob = 1.0 / (1.0 + np.exp(-np.clip(eta, -35, 35)))
|
|
||||||
grad = A.T @ (y - prob) - pen * beta
|
|
||||||
W = np.clip(prob * (1 - prob), 1e-10, None)
|
|
||||||
H = A.T @ (A * W[:, None]) + np.diag(pen)
|
|
||||||
step = _solve(H, grad)
|
|
||||||
beta = beta + step
|
|
||||||
if np.max(np.abs(step)) < tol:
|
|
||||||
break
|
|
||||||
eta = A @ beta
|
|
||||||
prob = 1.0 / (1.0 + np.exp(-np.clip(eta, -35, 35)))
|
|
||||||
grad = A.T @ (y - prob) - pen * beta
|
|
||||||
return beta[1:], float(beta[0]), float(np.max(np.abs(grad)))
|
|
||||||
|
|
||||||
|
|
||||||
def predict_linear(X: np.ndarray, coef: np.ndarray, intercept: float) -> np.ndarray:
|
|
||||||
if X.shape[1] == 0:
|
|
||||||
return np.full(len(X), intercept, dtype=float)
|
|
||||||
return intercept + X @ coef
|
|
||||||
|
|
||||||
|
|
||||||
def sigmoid(z: np.ndarray) -> np.ndarray:
|
|
||||||
return 1.0 / (1.0 + np.exp(-np.clip(z, -35, 35)))
|
|
||||||
|
|
||||||
|
|
||||||
def log_loss(y: np.ndarray, p: np.ndarray) -> float:
|
|
||||||
p = np.clip(p, 1e-12, 1 - 1e-12)
|
|
||||||
return float(-np.mean(y * np.log(p) + (1 - y) * np.log(1 - p)))
|
|
||||||
|
|
||||||
|
|
||||||
def choose_lambda(X: np.ndarray, y: np.ndarray, kind: str,
|
|
||||||
grid: Sequence[float] = LAMBDA_GRID,
|
|
||||||
n_folds: int = CV_FOLDS) -> Tuple[float, Dict[float, float]]:
|
|
||||||
"""5-fold time-contiguous CV inside the training window."""
|
|
||||||
folds = build_folds(np.arange(len(y)), n_folds)
|
|
||||||
if X.shape[1] == 0 or len(folds) < 2:
|
|
||||||
return float(grid[0]), {}
|
|
||||||
scores: Dict[float, float] = {}
|
|
||||||
for lam in grid:
|
|
||||||
vals: List[float] = []
|
|
||||||
for hold in folds:
|
|
||||||
mask = np.ones(len(y), dtype=bool)
|
|
||||||
mask[hold] = False
|
|
||||||
if mask.sum() < 2:
|
|
||||||
continue
|
|
||||||
if kind == "logistic":
|
|
||||||
ytr = y[mask]
|
|
||||||
if ytr.min() == ytr.max(): # single-class fold
|
|
||||||
p = np.full(len(hold), float(np.clip(ytr.mean(), 1e-6, 1 - 1e-6)))
|
|
||||||
else:
|
|
||||||
c, b, _ = fit_logistic(X[mask], ytr, lam)
|
|
||||||
p = sigmoid(predict_linear(X[hold], c, b))
|
|
||||||
vals.append(log_loss(y[hold], p))
|
|
||||||
else:
|
|
||||||
c, b = fit_ridge(X[mask], y[mask], lam)
|
|
||||||
resid = y[hold] - predict_linear(X[hold], c, b)
|
|
||||||
vals.append(float(np.mean(resid ** 2)))
|
|
||||||
scores[float(lam)] = float(np.mean(vals)) if vals else float("inf")
|
|
||||||
best = min(scores, key=lambda k: scores[k])
|
|
||||||
return best, scores
|
|
||||||
|
|
||||||
|
|
||||||
def lgbm_available() -> bool:
|
|
||||||
try:
|
|
||||||
import lightgbm # noqa: F401
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def fit_lgbm(X: np.ndarray, y: np.ndarray, seed: int):
|
|
||||||
import lightgbm as lgb
|
|
||||||
model = lgb.LGBMRegressor(random_state=seed, verbose=-1, **LGBM_PARAMS)
|
|
||||||
model.fit(X, y)
|
|
||||||
return model
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
# Size map
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
def round_to(x, step: float = MAP_ROUND_TO) -> np.ndarray:
|
|
||||||
"""Round to the nearest ``step`` and kill float dust (0.05 → 2 dp)."""
|
|
||||||
decimals = max(0, int(round(-math.log10(step)))) + 1
|
|
||||||
return np.round(np.round(np.asarray(x, dtype=float) / step) * step, decimals)
|
|
||||||
|
|
||||||
|
|
||||||
def calibrate_map(z_train: np.ndarray) -> Dict[str, float]:
|
|
||||||
"""lo/hi/normaliser from TRAIN predictions only."""
|
|
||||||
lo = float(np.quantile(z_train, MAP_Q_LO))
|
|
||||||
hi = float(np.quantile(z_train, MAP_Q_HI))
|
|
||||||
if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo:
|
|
||||||
return {"lo": lo, "hi": hi, "normalizer": 1.0, "degenerate": True}
|
|
||||||
raw = MAP_OUT_LO + (MAP_OUT_HI - MAP_OUT_LO) * np.clip((z_train - lo) / (hi - lo), 0, 1)
|
|
||||||
mean_raw = float(np.mean(raw))
|
|
||||||
norm = 1.0 / mean_raw if mean_raw > 0 else 1.0
|
|
||||||
return {"lo": lo, "hi": hi, "normalizer": norm, "degenerate": False}
|
|
||||||
|
|
||||||
|
|
||||||
def apply_map(z: np.ndarray, cal: Dict[str, float]) -> np.ndarray:
|
|
||||||
z = np.asarray(z, dtype=float)
|
|
||||||
if cal.get("degenerate"):
|
|
||||||
return np.ones(len(z))
|
|
||||||
lo, hi, norm = cal["lo"], cal["hi"], cal["normalizer"]
|
|
||||||
raw = MAP_OUT_LO + (MAP_OUT_HI - MAP_OUT_LO) * np.clip((z - lo) / (hi - lo), 0, 1)
|
|
||||||
return round_to(np.clip(raw * norm, MAP_CLIP_LO, MAP_CLIP_HI))
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
# Metrics
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
def max_drawdown(r: np.ndarray) -> float:
|
|
||||||
"""0-prepended cumulative-R drawdown (gpu_backtest.compute_metrics)."""
|
|
||||||
r = np.asarray(r, dtype=float)
|
|
||||||
if len(r) == 0:
|
|
||||||
return 0.0
|
|
||||||
cum = np.concatenate(([0.0], np.cumsum(r)))
|
|
||||||
return float((np.maximum.accumulate(cum) - cum).max())
|
|
||||||
|
|
||||||
|
|
||||||
def profit_factor(r: np.ndarray) -> float:
|
|
||||||
r = np.asarray(r, dtype=float)
|
|
||||||
gp = float(r[r > 0].sum()) if (r > 0).any() else 0.0
|
|
||||||
gl = float(-r[r < 0].sum()) if (r < 0).any() else 0.0
|
|
||||||
return gp / gl if gl > 0 else float("inf")
|
|
||||||
|
|
||||||
|
|
||||||
def multiplier_hist(m: np.ndarray) -> pd.DataFrame:
|
|
||||||
"""Counts in 0.1-wide bins from 0.5 to 1.5.
|
|
||||||
|
|
||||||
``np.histogram`` already closes the final bin on the right, so an
|
|
||||||
exactly-1.5 multiplier lands in [1.4, 1.5] once and the counts sum to
|
|
||||||
``len(m)``.
|
|
||||||
"""
|
|
||||||
edges = np.round(np.arange(MAP_OUT_LO, MAP_OUT_HI + 1e-9, 0.1), 2)
|
|
||||||
counts, _ = np.histogram(np.asarray(m, dtype=float), bins=edges)
|
|
||||||
counts = counts.astype(int)
|
|
||||||
return pd.DataFrame({
|
|
||||||
"bin_lo": edges[:-1], "bin_hi": edges[1:], "count": counts,
|
|
||||||
"pct": np.where(len(m), counts / max(len(m), 1) * 100, 0.0),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
# Walk-forward driver
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
def walk_forward(df: pd.DataFrame, features: Sequence[str], model: str,
|
|
||||||
bar_minutes: int, burn_in_months: int, purge_bars: int,
|
|
||||||
seed: int, y_train_override: Optional[np.ndarray] = None,
|
|
||||||
verbose: bool = True) -> Dict:
|
|
||||||
"""One full expanding walk-forward pass.
|
|
||||||
|
|
||||||
``y_train_override`` (full-book length, aligned to ``df``) replaces the
|
|
||||||
TRAINING labels only — this is how the shuffle null is run. Test labels
|
|
||||||
always come from the real ``r_multiple``.
|
|
||||||
"""
|
|
||||||
times = df["entry_time"].to_numpy()
|
|
||||||
r_all = df["r_multiple"].to_numpy(float)
|
|
||||||
r_train_src = r_all if y_train_override is None else np.asarray(y_train_override, float)
|
|
||||||
X_all = df[list(features)].apply(pd.to_numeric, errors="coerce").to_numpy(float) \
|
|
||||||
if features else np.zeros((len(df), 0))
|
|
||||||
|
|
||||||
book_start = df["entry_time"].iloc[0]
|
|
||||||
book_end = df["entry_time"].iloc[-1]
|
|
||||||
burn_end = book_start + pd.DateOffset(months=burn_in_months)
|
|
||||||
purge = pd.Timedelta(minutes=purge_bars * bar_minutes)
|
|
||||||
|
|
||||||
qs = [q for q in quarter_starts(book_start, book_end) if q >= burn_end]
|
|
||||||
rows: List[Dict] = []
|
|
||||||
per_q: List[Dict] = []
|
|
||||||
coef_rows: List[Dict] = []
|
|
||||||
skipped: List[str] = []
|
|
||||||
dropped_seen: set = set()
|
|
||||||
|
|
||||||
for q_start in qs:
|
|
||||||
q_end = q_start + pd.DateOffset(months=3)
|
|
||||||
train_mask = df["entry_time"] < (q_start - purge)
|
|
||||||
test_mask = (df["entry_time"] >= q_start) & (df["entry_time"] < q_end)
|
|
||||||
n_tr, n_te = int(train_mask.sum()), int(test_mask.sum())
|
|
||||||
if n_tr < MIN_TRAIN or n_te == 0:
|
|
||||||
skipped.append(f"{quarter_label(q_start)} (train={n_tr}, test={n_te})")
|
|
||||||
continue
|
|
||||||
|
|
||||||
tr = np.flatnonzero(train_mask.to_numpy())
|
|
||||||
te = np.flatnonzero(test_mask.to_numpy())
|
|
||||||
Xtr_raw, Xte_raw = X_all[tr], X_all[te]
|
|
||||||
mean, scale, keep = fit_standardiser(Xtr_raw)
|
|
||||||
dropped = [features[i] for i in np.flatnonzero(~keep)]
|
|
||||||
if verbose and dropped:
|
|
||||||
tag = (quarter_label(q_start), tuple(dropped))
|
|
||||||
if tag not in dropped_seen:
|
|
||||||
dropped_seen.add(tag)
|
|
||||||
say(f" WARNING {quarter_label(q_start)}: zero variance in train, "
|
|
||||||
f"dropped from fit (coef 0): {', '.join(dropped)}")
|
|
||||||
Xtr = apply_standardiser(Xtr_raw, mean, scale)[:, keep]
|
|
||||||
Xte = apply_standardiser(Xte_raw, mean, scale)[:, keep]
|
|
||||||
|
|
||||||
r_tr = r_train_src[tr]
|
|
||||||
lam: Optional[float] = None
|
|
||||||
if model == "logistic":
|
|
||||||
y_tr = (r_tr > 0).astype(float)
|
|
||||||
lam, _ = choose_lambda(Xtr, y_tr, "logistic")
|
|
||||||
coef, b, _ = fit_logistic(Xtr, y_tr, lam)
|
|
||||||
z_tr = sigmoid(predict_linear(Xtr, coef, b))
|
|
||||||
z_te = sigmoid(predict_linear(Xte, coef, b))
|
|
||||||
elif model == "lgbm":
|
|
||||||
y_tr = np.clip(r_tr, WINSOR_LO, WINSOR_HI)
|
|
||||||
gbm = fit_lgbm(Xtr, y_tr, seed)
|
|
||||||
z_tr = np.asarray(gbm.predict(Xtr), dtype=float)
|
|
||||||
z_te = np.asarray(gbm.predict(Xte), dtype=float)
|
|
||||||
coef, b = np.zeros(int(keep.sum())), 0.0
|
|
||||||
else: # ridge / isbuy / exhaustion
|
|
||||||
y_tr = np.clip(r_tr, WINSOR_LO, WINSOR_HI)
|
|
||||||
lam, _ = choose_lambda(Xtr, y_tr, "ridge")
|
|
||||||
coef, b = fit_ridge(Xtr, y_tr, lam)
|
|
||||||
z_tr = predict_linear(Xtr, coef, b)
|
|
||||||
z_te = predict_linear(Xte, coef, b)
|
|
||||||
|
|
||||||
cal = calibrate_map(z_tr)
|
|
||||||
m_te = apply_map(z_te, cal)
|
|
||||||
|
|
||||||
if model != "lgbm":
|
|
||||||
full_coef = np.zeros(len(features))
|
|
||||||
full_coef[np.flatnonzero(keep)] = coef
|
|
||||||
for name, c in zip(features, full_coef):
|
|
||||||
coef_rows.append({"quarter": quarter_label(q_start), "feature": name,
|
|
||||||
"coef_std": float(c), "lambda": lam,
|
|
||||||
"dropped": name in dropped})
|
|
||||||
coef_rows.append({"quarter": quarter_label(q_start), "feature": "_intercept",
|
|
||||||
"coef_std": float(b), "lambda": lam, "dropped": False})
|
|
||||||
|
|
||||||
r_te = r_all[te]
|
|
||||||
for i, idx in enumerate(te):
|
|
||||||
rows.append({
|
|
||||||
"entry_time": df["entry_time"].iloc[idx],
|
|
||||||
"r_multiple": float(r_te[i]),
|
|
||||||
"mult": float(m_te[i]),
|
|
||||||
"r_sized": float(r_te[i] * m_te[i]),
|
|
||||||
"quarter": quarter_label(q_start),
|
|
||||||
"z": float(z_te[i]),
|
|
||||||
})
|
|
||||||
per_q.append({
|
|
||||||
"quarter": quarter_label(q_start),
|
|
||||||
"q_start": q_start, "n_train": n_tr, "n_test": n_te,
|
|
||||||
"flat_r": float(r_te.sum()), "sized_r": float((r_te * m_te).sum()),
|
|
||||||
"mean_m": float(m_te.mean()), "lambda": lam,
|
|
||||||
"map_lo": cal["lo"], "map_hi": cal["hi"], "normalizer": cal["normalizer"],
|
|
||||||
})
|
|
||||||
|
|
||||||
oos = pd.DataFrame(rows)
|
|
||||||
return {"oos": oos, "quarters": pd.DataFrame(per_q),
|
|
||||||
"coefs": pd.DataFrame(coef_rows), "skipped": skipped,
|
|
||||||
"quarter_starts": qs}
|
|
||||||
|
|
||||||
|
|
||||||
def evaluate(oos: pd.DataFrame) -> Dict:
|
|
||||||
"""Headline OOS numbers for one model's walk-forward output."""
|
|
||||||
if oos.empty:
|
|
||||||
return {"n": 0, "flat_r": 0.0, "sized_r": 0.0, "lift": 0.0}
|
|
||||||
r = oos["r_multiple"].to_numpy(float)
|
|
||||||
rs = oos["r_sized"].to_numpy(float)
|
|
||||||
m = oos["mult"].to_numpy(float)
|
|
||||||
med = oos["entry_time"].median()
|
|
||||||
first = (oos["entry_time"] < med).to_numpy()
|
|
||||||
second = ~first
|
|
||||||
return {
|
|
||||||
"n": int(len(oos)),
|
|
||||||
"flat_r": float(r.sum()), "sized_r": float(rs.sum()),
|
|
||||||
"lift": float(rs.sum() - r.sum()),
|
|
||||||
"flat_pf": profit_factor(r), "sized_pf": profit_factor(rs),
|
|
||||||
"flat_dd": max_drawdown(r), "sized_dd": max_drawdown(rs),
|
|
||||||
"mean_m": float(m.mean()), "min_m": float(m.min()), "max_m": float(m.max()),
|
|
||||||
"median_time": med,
|
|
||||||
"n_h1": int(first.sum()), "n_h2": int(second.sum()),
|
|
||||||
"flat_h1": float(r[first].sum()), "flat_h2": float(r[second].sum()),
|
|
||||||
"sized_h1": float(rs[first].sum()), "sized_h2": float(rs[second].sum()),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def acceptance(ev: Dict, real_lift: float, shuffle_p95: float) -> Dict:
|
|
||||||
"""The six pre-registered clauses."""
|
|
||||||
c1 = ev["sized_h1"] > ev["flat_h1"] and ev["sized_h2"] > ev["flat_h2"]
|
|
||||||
c2 = ev["sized_r"] > ev["flat_r"]
|
|
||||||
c3 = ev["sized_dd"] <= DD_TOL * ev["flat_dd"]
|
|
||||||
c4 = (ev["sized_pf"] >= PF_FLOOR) and ev["sized_h1"] > 0 and ev["sized_h2"] > 0
|
|
||||||
c5 = real_lift > shuffle_p95 if np.isfinite(shuffle_p95) else False
|
|
||||||
c6 = (abs(ev["mean_m"] - 1.0) <= MEAN_MULT_TOL
|
|
||||||
and ev["min_m"] >= MAP_CLIP_LO - 1e-9 and ev["max_m"] <= MAP_CLIP_HI + 1e-9)
|
|
||||||
clauses = {"1_both_halves": bool(c1), "2_overall": bool(c2), "3_drawdown": bool(c3),
|
|
||||||
"4_pf_and_halves": bool(c4), "5_beats_shuffle": bool(c5),
|
|
||||||
"6_multiplier_bounds": bool(c6)}
|
|
||||||
clauses["overall"] = bool(all(clauses.values()))
|
|
||||||
return clauses
|
|
||||||
|
|
||||||
|
|
||||||
def shuffle_null(df: pd.DataFrame, features: Sequence[str], model: str,
|
|
||||||
bar_minutes: int, burn_in_months: int, purge_bars: int,
|
|
||||||
n_shuffle: int, seed: int) -> np.ndarray:
|
|
||||||
"""Lift distribution under permuted TRAINING labels (test labels real)."""
|
|
||||||
rng = np.random.default_rng(seed)
|
|
||||||
r_all = df["r_multiple"].to_numpy(float)
|
|
||||||
lifts: List[float] = []
|
|
||||||
for _ in range(n_shuffle):
|
|
||||||
r_perm = r_all[rng.permutation(len(r_all))]
|
|
||||||
res = walk_forward(df, features, model, bar_minutes, burn_in_months,
|
|
||||||
purge_bars, seed, y_train_override=r_perm, verbose=False)
|
|
||||||
ev = evaluate(res["oos"])
|
|
||||||
lifts.append(ev["lift"])
|
|
||||||
return np.asarray(lifts, dtype=float)
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
# Final fit + artifact
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
def final_fit(df: pd.DataFrame, features: Sequence[str], model: str,
|
|
||||||
seed: int) -> Dict:
|
|
||||||
"""Fit on ALL trades; calibrate the map on in-sample predictions."""
|
|
||||||
X_raw = df[list(features)].apply(pd.to_numeric, errors="coerce").to_numpy(float)
|
|
||||||
r = df["r_multiple"].to_numpy(float)
|
|
||||||
mean, scale, keep = fit_standardiser(X_raw)
|
|
||||||
X = apply_standardiser(X_raw, mean, scale)[:, keep]
|
|
||||||
if model == "logistic":
|
|
||||||
y = (r > 0).astype(float)
|
|
||||||
lam, _ = choose_lambda(X, y, "logistic")
|
|
||||||
coef, b, gnorm = fit_logistic(X, y, lam)
|
|
||||||
z = sigmoid(predict_linear(X, coef, b))
|
|
||||||
target = "r_positive"
|
|
||||||
else:
|
|
||||||
y = np.clip(r, WINSOR_LO, WINSOR_HI)
|
|
||||||
lam, _ = choose_lambda(X, y, "ridge")
|
|
||||||
coef, b = fit_ridge(X, y, lam)
|
|
||||||
gnorm = float("nan")
|
|
||||||
z = predict_linear(X, coef, b)
|
|
||||||
target = "r_winsorised"
|
|
||||||
full_coef = np.zeros(len(features))
|
|
||||||
full_coef[np.flatnonzero(keep)] = coef
|
|
||||||
cal = calibrate_map(z)
|
|
||||||
return {"mean": mean, "scale": scale, "keep": keep, "coef": full_coef,
|
|
||||||
"intercept": float(b), "lambda": float(lam), "z": z,
|
|
||||||
"mult": apply_map(z, cal), "map": cal, "target": target,
|
|
||||||
"grad_norm": gnorm}
|
|
||||||
|
|
||||||
|
|
||||||
def predict_multiplier(feature_dict: Dict[str, float], artifact: Dict) -> float:
|
|
||||||
"""Runtime formula: standardise → linear score → size map → 0.05 grid."""
|
|
||||||
names = artifact["features"]
|
|
||||||
mean = artifact["mean"]
|
|
||||||
scale = artifact["scale"]
|
|
||||||
coef = artifact["coef"]
|
|
||||||
z = float(artifact["intercept"])
|
|
||||||
for i, name in enumerate(names):
|
|
||||||
raw = feature_dict.get(name, mean[i])
|
|
||||||
if raw is None or not np.isfinite(float(raw)):
|
|
||||||
raw = mean[i]
|
|
||||||
z += float(coef[i]) * (float(raw) - float(mean[i])) / float(scale[i])
|
|
||||||
if artifact["model"] == "logistic":
|
|
||||||
z = 1.0 / (1.0 + math.exp(-max(-35.0, min(35.0, z))))
|
|
||||||
mp = artifact["map"]
|
|
||||||
if mp["hi"] <= mp["lo"]:
|
|
||||||
return 1.0
|
|
||||||
frac = min(1.0, max(0.0, (z - mp["lo"]) / (mp["hi"] - mp["lo"])))
|
|
||||||
raw_m = mp["out_lo"] + (mp["out_hi"] - mp["out_lo"]) * frac
|
|
||||||
val = min(mp["clip_hi"], max(mp["clip_lo"], raw_m * mp["normalizer"]))
|
|
||||||
step = mp["round_to"]
|
|
||||||
return float(round(round(val / step) * step, 2))
|
|
||||||
|
|
||||||
|
|
||||||
def build_artifact(fit: Dict, features: Sequence[str], model: str, df: pd.DataFrame,
|
|
||||||
ev: Dict, clauses: Dict, real_lift: float, shuffle: np.ndarray,
|
|
||||||
n_shuffle: int, book: Path, tf: str, confirm_tfs: List[str],
|
|
||||||
burn_in_months: int, purge_bars: int, spread: float) -> Dict:
|
|
||||||
return {
|
|
||||||
"schema_version": 1,
|
|
||||||
"feature_version": FEATURE_VERSION,
|
|
||||||
"pair": "XAU/USD",
|
|
||||||
"primary_tf": tf,
|
|
||||||
"confirmation_tfs": confirm_tfs,
|
|
||||||
"model": model,
|
|
||||||
"target": fit["target"],
|
|
||||||
"features": list(features),
|
|
||||||
"mean": [float(v) for v in fit["mean"]],
|
|
||||||
"scale": [float(v) for v in fit["scale"]],
|
|
||||||
"coef": [float(v) for v in fit["coef"]],
|
|
||||||
"intercept": float(fit["intercept"]),
|
|
||||||
"map": {
|
|
||||||
"kind": "clipped_linear_on_quantiles",
|
|
||||||
"lo": float(fit["map"]["lo"]), "hi": float(fit["map"]["hi"]),
|
|
||||||
"out_lo": MAP_OUT_LO, "out_hi": MAP_OUT_HI,
|
|
||||||
"normalizer": float(fit["map"]["normalizer"]),
|
|
||||||
"clip_lo": MAP_CLIP_LO, "clip_hi": MAP_CLIP_HI,
|
|
||||||
"round_to": MAP_ROUND_TO,
|
|
||||||
},
|
|
||||||
"training": {
|
|
||||||
"window": [df["entry_time"].iloc[0].isoformat(),
|
|
||||||
df["entry_time"].iloc[-1].isoformat()],
|
|
||||||
"spread": spread,
|
|
||||||
"n_train_trades": int(len(df)),
|
|
||||||
"n_oos_trades": int(ev.get("n", 0)),
|
|
||||||
"burn_in_months": burn_in_months,
|
|
||||||
"purge_bars": purge_bars,
|
|
||||||
"refit": "calendar_quarter",
|
|
||||||
"final_fit": "full_window",
|
|
||||||
"lambda": float(fit["lambda"]),
|
|
||||||
"git_commit": git_commit(),
|
|
||||||
"book_sha256": sha256_file(book),
|
|
||||||
},
|
|
||||||
"acceptance": {
|
|
||||||
**{k: v for k, v in clauses.items()},
|
|
||||||
"flat_r": ev.get("flat_r"), "sized_r": ev.get("sized_r"),
|
|
||||||
"sized_pf": ev.get("sized_pf"), "flat_dd": ev.get("flat_dd"),
|
|
||||||
"sized_dd": ev.get("sized_dd"),
|
|
||||||
"sized_h1": ev.get("sized_h1"), "sized_h2": ev.get("sized_h2"),
|
|
||||||
"flat_h1": ev.get("flat_h1"), "flat_h2": ev.get("flat_h2"),
|
|
||||||
"real_lift": float(real_lift),
|
|
||||||
"shuffle_p95_lift": float(np.percentile(shuffle, 95)) if len(shuffle) else None,
|
|
||||||
"n_shuffle": int(n_shuffle),
|
|
||||||
"mean_multiplier": ev.get("mean_m"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
# Reporting
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
def fmt(x, nd=2, width=0) -> str:
|
|
||||||
if x is None:
|
|
||||||
return "n/a".rjust(width)
|
|
||||||
if isinstance(x, float) and not np.isfinite(x):
|
|
||||||
return ("inf" if x > 0 else "-inf").rjust(width)
|
|
||||||
s = f"{x:.{nd}f}" if isinstance(x, (int, float, np.floating)) else str(x)
|
|
||||||
return s.rjust(width) if width else s
|
|
||||||
|
|
||||||
|
|
||||||
def print_quarters(q: pd.DataFrame) -> None:
|
|
||||||
say(f"{'quarter':<9}{'n_tr':>6}{'n_te':>6}{'flat R':>10}{'sized R':>10}"
|
|
||||||
f"{'mean m':>9}{'lambda':>9}")
|
|
||||||
for _, row in q.iterrows():
|
|
||||||
say(f"{row['quarter']:<9}{int(row['n_train']):>6}{int(row['n_test']):>6}"
|
|
||||||
f"{fmt(row['flat_r'], 2, 10)}{fmt(row['sized_r'], 2, 10)}"
|
|
||||||
f"{fmt(row['mean_m'], 3, 9)}"
|
|
||||||
f"{('n/a' if row['lambda'] is None else fmt(row['lambda'], 0)):>9}")
|
|
||||||
|
|
||||||
|
|
||||||
def print_hist(m: np.ndarray) -> None:
|
|
||||||
h = multiplier_hist(m)
|
|
||||||
say(f"{'bin':<12}{'count':>8}{'pct':>8}")
|
|
||||||
for _, row in h.iterrows():
|
|
||||||
bar = "#" * int(round(row["pct"] / 2))
|
|
||||||
say(f"{row['bin_lo']:.1f}–{row['bin_hi']:.1f}{'':<4}{int(row['count']):>8}"
|
|
||||||
f"{row['pct']:>7.1f}% {bar}")
|
|
||||||
|
|
||||||
|
|
||||||
def print_coef_signs(coefs: pd.DataFrame, label: str) -> None:
|
|
||||||
"""Mean standardised coefficient + sign stability across quarters."""
|
|
||||||
if coefs.empty:
|
|
||||||
return
|
|
||||||
d = coefs[coefs["feature"] != "_intercept"]
|
|
||||||
say(f" standardised coefficients across quarters ({label}):")
|
|
||||||
say(f" {'feature':<24}{'mean':>10}{'min':>10}{'max':>10}{'+q':>5}{'-q':>5} sign-stable")
|
|
||||||
for name, grp in d.groupby("feature", sort=False):
|
|
||||||
c = grp["coef_std"].to_numpy(float)
|
|
||||||
pos, neg = int((c > 0).sum()), int((c < 0).sum())
|
|
||||||
stable = "yes" if (pos == 0 or neg == 0) and (pos + neg) > 0 else "no"
|
|
||||||
say(f" {name:<24}{c.mean():>10.4f}{c.min():>10.4f}{c.max():>10.4f}"
|
|
||||||
f"{pos:>5}{neg:>5} {stable}")
|
|
||||||
|
|
||||||
|
|
||||||
def print_comparison(table: List[Dict]) -> None:
|
|
||||||
say(f"{'model':<12}{'n':>6}{'flat R':>10}{'sized R':>10}{'lift':>9}"
|
|
||||||
f"{'PF':>7}{'DD':>8}{'mean m':>8}{'p95 null':>10}{'rank%':>7} pass")
|
|
||||||
for row in table:
|
|
||||||
say(f"{row['model']:<12}{row['n']:>6}{fmt(row['flat_r'], 2, 10)}"
|
|
||||||
f"{fmt(row['sized_r'], 2, 10)}{fmt(row['lift'], 2, 9)}"
|
|
||||||
f"{fmt(row['sized_pf'], 2, 7)}{fmt(row['sized_dd'], 2, 8)}"
|
|
||||||
f"{fmt(row['mean_m'], 3, 8)}{fmt(row['shuffle_p95'], 2, 10)}"
|
|
||||||
f"{fmt(row['rank_pct'], 0, 7)} {'PASS' if row['pass'] else 'FAIL'}")
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
# main
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
def main() -> None:
|
|
||||||
ap = argparse.ArgumentParser(description=__doc__,
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
||||||
ap.add_argument("--book", required=True, type=Path)
|
|
||||||
ap.add_argument("--bar-minutes", required=True, type=int)
|
|
||||||
ap.add_argument("--features", required=True,
|
|
||||||
help="comma-separated f_* column names for the main model")
|
|
||||||
ap.add_argument("--model", default="ridge", choices=("ridge", "logistic"))
|
|
||||||
ap.add_argument("--burn-in-months", type=int, default=12)
|
|
||||||
ap.add_argument("--purge-bars", type=int, default=300)
|
|
||||||
ap.add_argument("--n-shuffle", type=int, default=50)
|
|
||||||
ap.add_argument("--seed", type=int, default=20260905)
|
|
||||||
ap.add_argument("--skip-lgbm", action="store_true")
|
|
||||||
ap.add_argument("--out", type=Path, default=None)
|
|
||||||
ap.add_argument("--write-artifact", type=Path, default=None)
|
|
||||||
ap.add_argument("--tf", default=None, help="primary TF label for the artifact")
|
|
||||||
ap.add_argument("--confirm-tfs", default="15min,1h")
|
|
||||||
ap.add_argument("--spread", type=float, default=0.65,
|
|
||||||
help="cost the book was produced at (recorded in the artifact)")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
if args.write_artifact and not args.tf:
|
|
||||||
raise SystemExit("--write-artifact requires --tf")
|
|
||||||
|
|
||||||
say("=" * 96)
|
|
||||||
say("Stage-D stake-sizing walk-forward — RESEARCH ONLY")
|
|
||||||
say("=" * 96)
|
|
||||||
say(f"book : {args.book}")
|
|
||||||
df = load_book(args.book)
|
|
||||||
say(f"trades : {len(df)} "
|
|
||||||
f"({df['entry_time'].iloc[0]} → {df['entry_time'].iloc[-1]})")
|
|
||||||
say(f"bar minutes : {args.bar_minutes} purge = {args.purge_bars} bars = "
|
|
||||||
f"{args.purge_bars * args.bar_minutes} min "
|
|
||||||
f"({args.purge_bars * args.bar_minutes / 1440:.2f} d)")
|
|
||||||
say(f"burn-in : {args.burn_in_months} months "
|
|
||||||
f"(train-only until {df['entry_time'].iloc[0] + pd.DateOffset(months=args.burn_in_months)})")
|
|
||||||
say(f"model / seed : {args.model} / {args.seed} shuffles: {args.n_shuffle}")
|
|
||||||
say(f"winsor / lambda : [{WINSOR_LO}, {WINSOR_HI}] / {list(LAMBDA_GRID)} "
|
|
||||||
f"CV folds {CV_FOLDS} (time-contiguous)")
|
|
||||||
say(f"map : m = round_{MAP_ROUND_TO}(clip(norm × (0.5 + clip((z−lo)/(hi−lo),0,1)"
|
|
||||||
f"), {MAP_CLIP_LO}, {MAP_CLIP_HI})) lo/hi = train q{MAP_Q_LO}/q{MAP_Q_HI}")
|
|
||||||
say("")
|
|
||||||
|
|
||||||
main_feats = resolve_features(df, [f.strip() for f in args.features.split(",") if f.strip()],
|
|
||||||
"--features")
|
|
||||||
say(f"main features ({len(main_feats)}): {', '.join(main_feats)}")
|
|
||||||
|
|
||||||
specs: List[Tuple[str, List[str], str]] = [(args.model, main_feats, args.model)]
|
|
||||||
for label, feats in (("isbuy", ISBUY_FEATURES), ("exhaustion", EXHAUSTION_FEATURES)):
|
|
||||||
have = [f for f in feats if f in df.columns]
|
|
||||||
if len(have) != len(feats):
|
|
||||||
say(f"NOTE: {label} baseline missing "
|
|
||||||
f"{', '.join(f for f in feats if f not in df.columns)} — using {len(have)}")
|
|
||||||
if have:
|
|
||||||
specs.append((label, have, "ridge"))
|
|
||||||
use_lgbm = (not args.skip_lgbm) and lgbm_available()
|
|
||||||
if not args.skip_lgbm and not use_lgbm:
|
|
||||||
say("lightgbm not installed — upper bound skipped")
|
|
||||||
if use_lgbm:
|
|
||||||
all_f = [c for c in df.columns if c.startswith("f_")]
|
|
||||||
specs.append(("lgbm", all_f, "lgbm"))
|
|
||||||
say(f"lightgbm upper bound: {len(all_f)} f_* columns")
|
|
||||||
say("")
|
|
||||||
|
|
||||||
results: Dict[str, Dict] = {}
|
|
||||||
table: List[Dict] = []
|
|
||||||
for label, feats, kind in specs:
|
|
||||||
say("-" * 96)
|
|
||||||
say(f"MODEL: {label} ({kind}, {len(feats)} feature(s))")
|
|
||||||
say("-" * 96)
|
|
||||||
res = walk_forward(df, feats, kind, args.bar_minutes, args.burn_in_months,
|
|
||||||
args.purge_bars, args.seed, verbose=True)
|
|
||||||
if res["skipped"]:
|
|
||||||
say(f" skipped quarters: {'; '.join(res['skipped'])}")
|
|
||||||
if res["oos"].empty:
|
|
||||||
say(" no OOS quarters — nothing to evaluate")
|
|
||||||
continue
|
|
||||||
ev = evaluate(res["oos"])
|
|
||||||
n_sh = args.n_shuffle if kind != "lgbm" else min(args.n_shuffle, LGBM_MAX_SHUFFLE)
|
|
||||||
if kind == "lgbm" and n_sh != args.n_shuffle:
|
|
||||||
say(f" NOTE (protocol deviation, flagged): lgbm shuffle null capped at "
|
|
||||||
f"{n_sh} repeats (never ships)")
|
|
||||||
sh = shuffle_null(df, feats, kind, args.bar_minutes, args.burn_in_months,
|
|
||||||
args.purge_bars, n_sh, args.seed) if n_sh > 0 else np.array([])
|
|
||||||
p95 = float(np.percentile(sh, 95)) if len(sh) else float("nan")
|
|
||||||
rank = float((sh < ev["lift"]).mean() * 100) if len(sh) else float("nan")
|
|
||||||
cl = acceptance(ev, ev["lift"], p95)
|
|
||||||
|
|
||||||
say(f" OOS quarters : {len(res['quarters'])} "
|
|
||||||
f"({res['quarters']['quarter'].iloc[0]} → {res['quarters']['quarter'].iloc[-1]}), "
|
|
||||||
f"{ev['n']} trades")
|
|
||||||
say(f" flat R : {ev['flat_r']:+.2f} PF {fmt(ev['flat_pf'])} "
|
|
||||||
f"maxDD {ev['flat_dd']:.2f}")
|
|
||||||
say(f" sized R : {ev['sized_r']:+.2f} PF {fmt(ev['sized_pf'])} "
|
|
||||||
f"maxDD {ev['sized_dd']:.2f}")
|
|
||||||
say(f" lift : {ev['lift']:+.2f} R")
|
|
||||||
say(f" halves (split {ev['median_time']}): "
|
|
||||||
f"h1 flat {ev['flat_h1']:+.2f} / sized {ev['sized_h1']:+.2f} (n={ev['n_h1']}) | "
|
|
||||||
f"h2 flat {ev['flat_h2']:+.2f} / sized {ev['sized_h2']:+.2f} (n={ev['n_h2']})")
|
|
||||||
say(f" multiplier : mean {ev['mean_m']:.3f} min {ev['min_m']:.2f} "
|
|
||||||
f"max {ev['max_m']:.2f}")
|
|
||||||
say(f" shuffle null : n={len(sh)} mean {fmt(float(sh.mean()) if len(sh) else None)} "
|
|
||||||
f"p95 {fmt(p95)} real-lift percentile rank {fmt(rank, 0)}%")
|
|
||||||
say(" acceptance:")
|
|
||||||
for k, v in cl.items():
|
|
||||||
if k == "overall":
|
|
||||||
continue
|
|
||||||
say(f" {k:<22} {'PASS' if v else 'FAIL'}")
|
|
||||||
say(f" {'OVERALL':<22} {'PASS' if cl['overall'] else 'FAIL'}")
|
|
||||||
say(" per-quarter:")
|
|
||||||
print_quarters(res["quarters"])
|
|
||||||
print_coef_signs(res["coefs"], label)
|
|
||||||
say("")
|
|
||||||
|
|
||||||
results[label] = {"res": res, "ev": ev, "shuffle": sh, "p95": p95,
|
|
||||||
"rank": rank, "clauses": cl, "features": feats, "kind": kind}
|
|
||||||
table.append({"model": label, "n": ev["n"], "flat_r": ev["flat_r"],
|
|
||||||
"sized_r": ev["sized_r"], "lift": ev["lift"],
|
|
||||||
"sized_pf": ev["sized_pf"], "sized_dd": ev["sized_dd"],
|
|
||||||
"mean_m": ev["mean_m"], "shuffle_p95": p95, "rank_pct": rank,
|
|
||||||
"pass": cl["overall"]})
|
|
||||||
|
|
||||||
if args.model not in results:
|
|
||||||
raise SystemExit("the main model produced no OOS quarters — check burn-in / "
|
|
||||||
"book window / MIN_TRAIN")
|
|
||||||
|
|
||||||
say("=" * 96)
|
|
||||||
say("COMPARISON")
|
|
||||||
say("=" * 96)
|
|
||||||
print_comparison(table)
|
|
||||||
say("")
|
|
||||||
say(f"OOS multiplier histogram ({args.model}):")
|
|
||||||
print_hist(results[args.model]["res"]["oos"]["mult"].to_numpy())
|
|
||||||
say("")
|
|
||||||
|
|
||||||
main_label = args.model
|
|
||||||
beats = None
|
|
||||||
if main_label in results and "isbuy" in results:
|
|
||||||
beats = results[main_label]["ev"]["lift"] > results["isbuy"]["ev"]["lift"]
|
|
||||||
say(f"beats isbuy baseline: {'yes' if beats else 'no'} "
|
|
||||||
f"({results[main_label]['ev']['lift']:+.2f} R vs "
|
|
||||||
f"{results['isbuy']['ev']['lift']:+.2f} R)")
|
|
||||||
say(f"OVERALL ACCEPTANCE ({main_label}): "
|
|
||||||
f"{'PASS' if results[main_label]['clauses']['overall'] else 'FAIL'}")
|
|
||||||
say("")
|
|
||||||
|
|
||||||
# ── artifact ──────────────────────────────────────────────────────
|
|
||||||
artifact = None
|
|
||||||
if args.write_artifact:
|
|
||||||
say("-" * 96)
|
|
||||||
say("FINAL FIT (all trades) + ARTIFACT")
|
|
||||||
say("-" * 96)
|
|
||||||
fit = final_fit(df, main_feats, args.model, args.seed)
|
|
||||||
say(f" lambda (CV on full window): {fit['lambda']:g}")
|
|
||||||
if np.isfinite(fit["grad_norm"]):
|
|
||||||
say(f" logistic |grad|_inf at convergence: {fit['grad_norm']:.3e}")
|
|
||||||
say(f" map lo/hi/normaliser: {fit['map']['lo']:.5f} / {fit['map']['hi']:.5f} / "
|
|
||||||
f"{fit['map']['normalizer']:.5f}")
|
|
||||||
artifact = build_artifact(
|
|
||||||
fit, main_feats, args.model, df, results[main_label]["ev"],
|
|
||||||
results[main_label]["clauses"], results[main_label]["ev"]["lift"],
|
|
||||||
results[main_label]["shuffle"], args.n_shuffle, args.book, args.tf,
|
|
||||||
[t.strip() for t in args.confirm_tfs.split(",") if t.strip()],
|
|
||||||
args.burn_in_months, args.purge_bars, args.spread)
|
|
||||||
# Self-check: the runtime formula must reproduce the in-sample map exactly.
|
|
||||||
rows = df[main_feats].to_dict("records")
|
|
||||||
repro = np.array([predict_multiplier(r, artifact) for r in rows])
|
|
||||||
exact = bool(np.array_equal(repro, fit["mult"]))
|
|
||||||
say(f" predict_multiplier reproduces in-sample multipliers exactly: "
|
|
||||||
f"{'YES' if exact else 'NO'} (max |diff| "
|
|
||||||
f"{float(np.max(np.abs(repro - fit['mult']))):.3e})")
|
|
||||||
if not exact:
|
|
||||||
raise SystemExit("predict_multiplier does not reproduce the final fit — abort")
|
|
||||||
args.write_artifact.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
args.write_artifact.write_text(json.dumps(artifact, indent=2, default=str))
|
|
||||||
say(f" wrote {args.write_artifact}")
|
|
||||||
say("")
|
|
||||||
|
|
||||||
# ── outputs ───────────────────────────────────────────────────────
|
|
||||||
if args.out:
|
|
||||||
out = args.out
|
|
||||||
out.mkdir(parents=True, exist_ok=True)
|
|
||||||
main_res = results[main_label]
|
|
||||||
main_res["res"]["oos"].to_csv(out / "oos_trades.csv", index=False)
|
|
||||||
main_res["res"]["quarters"].to_csv(out / "quarters.csv", index=False)
|
|
||||||
coefs = pd.concat(
|
|
||||||
[r["res"]["coefs"].assign(model=lbl) for lbl, r in results.items()
|
|
||||||
if not r["res"]["coefs"].empty],
|
|
||||||
ignore_index=True) if results else pd.DataFrame()
|
|
||||||
coefs.to_csv(out / "coefs_by_quarter.csv", index=False)
|
|
||||||
pd.DataFrame({
|
|
||||||
"model": np.repeat([lbl for lbl in results], [len(r["shuffle"]) for r in results.values()]),
|
|
||||||
"lift": np.concatenate([r["shuffle"] for r in results.values()])
|
|
||||||
if results else np.array([]),
|
|
||||||
}).to_csv(out / "shuffle_lifts.csv", index=False)
|
|
||||||
multiplier_hist(main_res["res"]["oos"]["mult"].to_numpy()).to_csv(
|
|
||||||
out / "multiplier_hist.csv", index=False)
|
|
||||||
payload = {
|
|
||||||
"book": str(args.book), "book_sha256": sha256_file(args.book),
|
|
||||||
"git_commit": git_commit(), "bar_minutes": args.bar_minutes,
|
|
||||||
"model": args.model, "features": main_feats, "seed": args.seed,
|
|
||||||
"n_shuffle": args.n_shuffle, "burn_in_months": args.burn_in_months,
|
|
||||||
"purge_bars": args.purge_bars,
|
|
||||||
"beats_isbuy": beats,
|
|
||||||
"models": {
|
|
||||||
lbl: {
|
|
||||||
"kind": r["kind"], "features": r["features"],
|
|
||||||
"evaluation": {k: (None if isinstance(v, float) and not np.isfinite(v)
|
|
||||||
else (str(v) if isinstance(v, pd.Timestamp) else v))
|
|
||||||
for k, v in r["ev"].items()},
|
|
||||||
"shuffle": {"n": int(len(r["shuffle"])),
|
|
||||||
"mean": float(r["shuffle"].mean()) if len(r["shuffle"]) else None,
|
|
||||||
"p95": None if not np.isfinite(r["p95"]) else r["p95"],
|
|
||||||
"real_lift_percentile": None if not np.isfinite(r["rank"])
|
|
||||||
else r["rank"]},
|
|
||||||
"acceptance": r["clauses"],
|
|
||||||
} for lbl, r in results.items()
|
|
||||||
},
|
|
||||||
"artifact": artifact,
|
|
||||||
}
|
|
||||||
(out / "results.json").write_text(json.dumps(payload, indent=2, default=str))
|
|
||||||
(out / "summary.txt").write_text("\n".join(_LINES) + "\n")
|
|
||||||
print(f"\nwrote outputs to {out}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"""Evaluate the violent-move WATCH rule (as shipped in violent_watch.py) bar by bar on history. Analysis tooling only.
|
|
||||||
Level per bar from: calendar (high-impact USD within 30 min ahead / 30 min after), clock slots, indicator readings.
|
|
||||||
Reports share of bars per level, burst rate next bar / next hour per level (both halves), and hot-hours per day.
|
|
||||||
python backtest/research/watch_rule_eval.py PARQUET BAR_MINUTES
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
import numpy as np, pandas as pd
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]; sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "backtest"))
|
|
||||||
from gpu_backtest import precompute_indicators
|
|
||||||
from config import TechnicalConfig
|
|
||||||
|
|
||||||
|
|
||||||
def wilder_atr(df, period=14):
|
|
||||||
pc = df["Close"].shift(1)
|
|
||||||
tr = pd.concat([df["High"] - df["Low"], (df["High"] - pc).abs(), (df["Low"] - pc).abs()], axis=1).max(axis=1)
|
|
||||||
return tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
pq, bm = sys.argv[1], int(sys.argv[2])
|
|
||||||
px = pd.read_parquet(pq); px = px[(px.index >= pd.Timestamp("2023-08-05", tz="UTC")) & (px.index < pd.Timestamp("2026-09-05", tz="UTC"))]
|
|
||||||
ic = precompute_indicators(px, TechnicalConfig())
|
|
||||||
sh = lambda a: pd.Series(np.asarray(a, dtype=float), index=px.index).shift(1)
|
|
||||||
rsi, adxs, hist, bbp, kcu, kcl = sh(ic.rsi), sh(ic.adx_slope), sh(ic.macd_hist), sh(ic.bb_position), sh(ic.kc_upper), sh(ic.kc_lower)
|
|
||||||
ef, em, es, et, adx, atr1 = sh(ic.ema_fast), sh(ic.ema_medium), sh(ic.ema_slow), sh(ic.ema_trend), sh(ic.adx), sh(ic.atr)
|
|
||||||
c1 = px["Close"].shift(1); hp = hist.shift(1)
|
|
||||||
atr_prev = wilder_atr(px).shift(1); rng = px["High"] - px["Low"]
|
|
||||||
burst = (rng >= 3.0 * atr_prev).astype(int); H = max(1, 60 // bm)
|
|
||||||
next_hour = burst[::-1].rolling(H, min_periods=1).max()[::-1].astype(int)
|
|
||||||
align = np.sign(c1 - ef) + np.sign(ef - em) + np.sign(em - es) + np.sign(es - et)
|
|
||||||
regime = np.select([(adx >= 25) & (align > 0), (adx >= 25) & (align < 0), adx >= 25, adxs > 3.0], ["trending_up", "trending_down", "trending", "transitioning"], default="ranging")
|
|
||||||
atr_pct = atr1.rolling(50).apply(lambda w: (w[-1] > w).mean() * 100, raw=True); extreme = atr_pct > 80
|
|
||||||
# readings
|
|
||||||
bear = ((bbp < 0).astype(int) + (rsi < 30).astype(int) + (c1 < kcl).astype(int) + ((hist < 0) & (hist < hp) & (hp < 0)).astype(int))
|
|
||||||
bull = ((bbp > 1).astype(int) + (rsi >= 70).astype(int) + (c1 > kcu).astype(int) + ((hist > 0) & (hist > hp) & (hp > 0)).astype(int))
|
|
||||||
neutral = (adxs > 3).astype(int) + extreme.astype(int) + (regime == "transitioning").astype(int)
|
|
||||||
readings = bear + bull + neutral
|
|
||||||
# clock (now = bar close time)
|
|
||||||
ct = px.index + pd.Timedelta(minutes=bm); wd = ct.weekday; hr = ct.hour
|
|
||||||
clock = ((wd <= 4) & np.isin(hr, [12, 13, 14])) | (hr == 1) | ((wd == 6) & np.isin(hr, [22, 23]))
|
|
||||||
# calendar
|
|
||||||
cal = pd.read_parquet(ROOT / "backtest/cache/calendar.parquet"); cal["ts"] = pd.to_datetime(cal.dateline, unit="s", utc=True)
|
|
||||||
hi = np.sort(cal[(cal.currency == "USD") & (cal.impactName.str.lower() == "high")].ts.values.astype("datetime64[ns]"))
|
|
||||||
t = ct.values.astype("datetime64[ns]"); nx = np.searchsorted(hi, t)
|
|
||||||
to_next = np.where(nx < len(hi), (hi[np.minimum(nx, len(hi) - 1)] - t) / np.timedelta64(1, "m"), 1e9)
|
|
||||||
since = np.where(nx - 1 >= 0, (t - hi[np.maximum(nx - 1, 0)]) / np.timedelta64(1, "m"), 1e9)
|
|
||||||
calendar = ((to_next > 0) & (to_next <= 30)) | (since <= 30)
|
|
||||||
# FINAL RULE (2026-09-06, chosen from the variant sweep recorded in sizing_campaign_2026-09.md):
|
|
||||||
# extreme = price-at-extreme readings only (outside Bollinger, RSI beyond 30/70, outside Keltner)
|
|
||||||
# hot = calendar | (clock & extreme >= 1) -> ~8.6% of bars, next-bar odds 3-5x normal, ~4 hot hours/day
|
|
||||||
# watch = clock | extreme >= 2 -> ~2x normal
|
|
||||||
extreme = ((bbp < 0) | (bbp > 1)).astype(int) + ((rsi < 30) | (rsi >= 70)).astype(int) + ((c1 < kcl) | (c1 > kcu)).astype(int)
|
|
||||||
level = np.where(calendar | (clock & (extreme >= 1)), "hot", np.where(clock | (extreme >= 2), "watch", "none"))
|
|
||||||
d = pd.DataFrame({"level": level, "burst": burst.values, "next_hour": next_hour.values, "readings": readings.values, "clock": clock, "calendar": calendar}, index=px.index)
|
|
||||||
d = d[d.index >= pd.Timestamp("2023-09-05", tz="UTC")]
|
|
||||||
half = np.where(np.arange(len(d)) < len(d) // 2, 1, 2)
|
|
||||||
b1, b2 = d.burst[half == 1].mean(), d.burst[half == 2].mean(); h1, h2 = d.next_hour[half == 1].mean(), d.next_hour[half == 2].mean()
|
|
||||||
print(f"{Path(pq).name}: {len(d)} bars; base next-bar {100*d.burst.mean():.2f}%, next-hour {100*d.next_hour.mean():.1f}%")
|
|
||||||
rows = []
|
|
||||||
for lv in ("none", "watch", "hot"):
|
|
||||||
m = d.level == lv
|
|
||||||
rows.append(dict(level=lv, share_of_bars=100 * m.mean(), next_bar_pct=100 * d.burst[m].mean(), lift_h1=d.burst[m & (half == 1)].mean() / b1, lift_h2=d.burst[m & (half == 2)].mean() / b2,
|
|
||||||
next_hour_pct=100 * d.next_hour[m].mean(), hour_lift_h1=d.next_hour[m & (half == 1)].mean() / h1, hour_lift_h2=d.next_hour[m & (half == 2)].mean() / h2,
|
|
||||||
share_of_all_bursts=100 * d.burst[m].sum() / d.burst.sum()))
|
|
||||||
print(pd.DataFrame(rows).round(2).to_string(index=False))
|
|
||||||
hot = d[d.level == "hot"]
|
|
||||||
kinds = pd.Series(np.where(hot.calendar, "calendar", "clock+extreme reading"), index=hot.index)
|
|
||||||
print("hot bars by cause:", (100 * kinds.value_counts(normalize=True)).round(1).to_dict())
|
|
||||||
hot_hours = hot.index.floor("h").unique()
|
|
||||||
days = pd.Series(hot_hours.date).value_counts()
|
|
||||||
print(f"distinct HOT hours per calendar day: mean {days.mean():.1f}, median {days.median():.0f}, max {days.max()} (≈ heads-up messages/day before the daily cap)")
|
|
||||||
hb = d.burst.groupby(d.index.floor("h")).max(); hh = pd.Series(1, index=hot_hours)
|
|
||||||
print(f"of all hours with a violent bar, the hour was flagged HOT at its start or during: {100*hb[hb==1].index.isin(hot_hours).mean():.1f}%")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
+6
-24
@@ -26,8 +26,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"oanda": {
|
"oanda": {
|
||||||
|
"account_id": "REPLACE_WITH_ACCOUNT_ID",
|
||||||
"api_token": "REPLACE_WITH_API_TOKEN",
|
"api_token": "REPLACE_WITH_API_TOKEN",
|
||||||
"base_url": "https://api-fxpractice.oanda.com"
|
"base_url": "https://api-fxpractice.oanda.com",
|
||||||
|
"lot_size": 1,
|
||||||
|
"quality_gate": "C",
|
||||||
|
"max_hold_bars": 300
|
||||||
},
|
},
|
||||||
"deepseek": {
|
"deepseek": {
|
||||||
"api_key": "REPLACE_WITH_YOUR_DEEPSEEK_API_KEY_OR_EMPTY",
|
"api_key": "REPLACE_WITH_YOUR_DEEPSEEK_API_KEY_OR_EMPTY",
|
||||||
@@ -42,29 +46,7 @@
|
|||||||
"dashboard": false,
|
"dashboard": false,
|
||||||
"min_quality": "B",
|
"min_quality": "B",
|
||||||
"max_panels_per_day": 20,
|
"max_panels_per_day": 20,
|
||||||
"reuse_ttl_seconds": 3600,
|
"reuse_ttl_seconds": 3600
|
||||||
"candles_enabled": true,
|
|
||||||
"candle_bars_primary": 20,
|
|
||||||
"candle_bars_confirm": 10,
|
|
||||||
"verdict_source": "macro"
|
|
||||||
},
|
|
||||||
"market_outlook": {
|
|
||||||
"enabled": false,
|
|
||||||
"telegram": false,
|
|
||||||
"max_per_day": 12,
|
|
||||||
"candles_bars": 20
|
|
||||||
},
|
|
||||||
"violent_watch": {
|
|
||||||
"enabled": false,
|
|
||||||
"alert_line": true,
|
|
||||||
"headsup": true,
|
|
||||||
"max_headsup_per_day": 12,
|
|
||||||
"headsup_min_level": "hot"
|
|
||||||
},
|
|
||||||
"signal_log": {
|
|
||||||
"enabled": true,
|
|
||||||
"path": "logs/signals.db",
|
|
||||||
"log_blocked_scans": true
|
|
||||||
},
|
},
|
||||||
"log_level": "INFO"
|
"log_level": "INFO"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,8 @@ class SignalStrength(Enum):
|
|||||||
XAUUSD = "XAU/USD"
|
XAUUSD = "XAU/USD"
|
||||||
|
|
||||||
# price_precision: decimals for SL/TP rounding and display.
|
# price_precision: decimals for SL/TP rounding and display.
|
||||||
# default_spread: $0.30 round-trip cost retained for TD-era cross-book
|
# default_spread: round-trip cost per trade in price units, charged by
|
||||||
# comparability and used when the backtester receives no --spread override.
|
# the backtester when --spread is not given.
|
||||||
# Oanda's measured round trip is $0.65-$0.72; research reports headline it
|
|
||||||
# separately with --spread 0.65.
|
|
||||||
PAIR_INFO = {
|
PAIR_INFO = {
|
||||||
XAUUSD: {"display": "XAU/USD", "pip_value": 0.10,
|
XAUUSD: {"display": "XAU/USD", "pip_value": 0.10,
|
||||||
"price_precision": 2, "default_spread": 0.30},
|
"price_precision": 2, "default_spread": 0.30},
|
||||||
@@ -112,10 +110,7 @@ class SignalConfig:
|
|||||||
tp_tightening_factor: float = 1.0
|
tp_tightening_factor: float = 1.0
|
||||||
|
|
||||||
# ── Trade geometry ────────────────────────────────────────────
|
# ── Trade geometry ────────────────────────────────────────────
|
||||||
# Deliberately $0.30 for TD-era cross-book comparability. Oanda's measured
|
# Round-trip transaction cost estimate in price units (XAU ~$0.30).
|
||||||
# round trip is $0.65-$0.72, reported separately in re-pin campaigns.
|
|
||||||
# This is also a LIVE gate input: increasing it to $0.65 raises the TP1
|
|
||||||
# floor from about $9 to $19.50, so it requires rule-1 revalidation.
|
|
||||||
round_trip_cost: float = 0.30
|
round_trip_cost: float = 0.30
|
||||||
# Spread gate: reject setups whose TP1 distance is below
|
# Spread gate: reject setups whose TP1 distance is below
|
||||||
# round_trip_cost × this multiple (caps cost at ~3% of target).
|
# round_trip_cost × this multiple (caps cost at ~3% of target).
|
||||||
@@ -172,12 +167,8 @@ class NewsConfig:
|
|||||||
# into the danger window while a stale cache says it's clear.
|
# into the danger window while a stale cache says it's clear.
|
||||||
context_cache_ttl_seconds: int = 600
|
context_cache_ttl_seconds: int = 600
|
||||||
# Maximum age of RSS headlines accepted for scoring. Older headlines
|
# Maximum age of RSS headlines accepted for scoring. Older headlines
|
||||||
# (and headlines with no parsable publish time) are discarded before
|
# are discarded before keyword analysis and LLM enrichment.
|
||||||
# keyword analysis and LLM enrichment. 12 h → 4 h on 2026-09-04: the
|
headline_max_age_seconds: int = 43200 # 12 hours
|
||||||
# 3-week signal log showed the 12 h mean was a trailing echo of the
|
|
||||||
# previous day's price move (see backtest/research/
|
|
||||||
# news_sentiment_value_2026-09-04.md). Display/warning-only either way.
|
|
||||||
headline_max_age_seconds: int = 14400 # 4 hours
|
|
||||||
|
|
||||||
rss_feeds: List[str] = field(default_factory=lambda: [
|
rss_feeds: List[str] = field(default_factory=lambda: [
|
||||||
"https://investinglive.com/feed/",
|
"https://investinglive.com/feed/",
|
||||||
|
|||||||
+19
-46
@@ -11,9 +11,9 @@ can only ever mean a stale page.
|
|||||||
Independence rules (owner requirement — the telegram bot must be
|
Independence rules (owner requirement — the telegram bot must be
|
||||||
immune to anything on the web side):
|
immune to anything on the web side):
|
||||||
* Runs as its own process; never imported by, and never imports,
|
* Runs as its own process; never imported by, and never imports,
|
||||||
`telegram_bot` (telegram_bot resolves credentials at import). The
|
`telegram_bot` / `oanda_daemon` (telegram_bot resolves credentials
|
||||||
confirmation map and boundary math are therefore duplicated here
|
at import). The confirmation map and boundary math are therefore
|
||||||
as local constants.
|
duplicated here as local constants.
|
||||||
* The only shared-code dependency beyond the standard signal path is
|
* The only shared-code dependency beyond the standard signal path is
|
||||||
the additive `NewsEngine.get_week_events` accessor, which no
|
the additive `NewsEngine.get_week_events` accessor, which no
|
||||||
production signal path calls.
|
production signal path calls.
|
||||||
@@ -50,13 +50,11 @@ from logging.handlers import TimedRotatingFileHandler
|
|||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from agent_panel import attach_panel
|
from agent_panel import attach_panel
|
||||||
from app_config import get_app_config, violent_watch_enabled
|
|
||||||
from config import PlatformConfig, Timeframe, XAUUSD
|
from config import PlatformConfig, Timeframe, XAUUSD
|
||||||
from main import report_to_dict
|
from main import report_to_dict
|
||||||
from news_engine import NewsEngine
|
from news_engine import NewsEngine
|
||||||
from pair_signals import produce_signal
|
from pair_signals import produce_signal
|
||||||
from providers import make_data_provider
|
from providers import make_data_provider
|
||||||
from violent_watch import attach_watch
|
|
||||||
import providers.base as _providers_base
|
import providers.base as _providers_base
|
||||||
|
|
||||||
logger = logging.getLogger("dashboard")
|
logger = logging.getLogger("dashboard")
|
||||||
@@ -84,7 +82,7 @@ SUMMARY_PHP_FILE = "news_summary.php"
|
|||||||
# Repo files copied into the out dir at daemon start (git pull + restart
|
# Repo files copied into the out dir at daemon start (git pull + restart
|
||||||
# updates them). The PHP endpoint reads news.json in place, so it lives
|
# updates them). The PHP endpoint reads news.json in place, so it lives
|
||||||
# next to the page.
|
# next to the page.
|
||||||
STATIC_FILES = [PAGE_FILE, "dashboard.css", "dashboard.js", SUMMARY_PHP_FILE]
|
STATIC_FILES = [PAGE_FILE, SUMMARY_PHP_FILE]
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════
|
||||||
@@ -238,8 +236,8 @@ def due_timeframes(wake_dt: datetime) -> List[Timeframe]:
|
|||||||
|
|
||||||
|
|
||||||
def seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
|
def seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
|
||||||
# Same math as telegram_bot.py — kept local so the dashboard never
|
# Same math as telegram_bot.py / oanda_daemon.py — kept local so the
|
||||||
# imports bot modules (independence rules above).
|
# dashboard never imports bot modules (independence rules above).
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
total_minutes = now.hour * 60 + now.minute
|
total_minutes = now.hour * 60 + now.minute
|
||||||
elapsed_minutes = total_minutes % interval_minutes
|
elapsed_minutes = total_minutes % interval_minutes
|
||||||
@@ -324,7 +322,6 @@ class DashboardPublisher:
|
|||||||
def __init__(self, out_dir: str):
|
def __init__(self, out_dir: str):
|
||||||
self.out_dir = out_dir
|
self.out_dir = out_dir
|
||||||
self.snapshots: Dict[str, dict] = {}
|
self.snapshots: Dict[str, dict] = {}
|
||||||
self._reports = {}
|
|
||||||
self.news_payload: Optional[dict] = None
|
self.news_payload: Optional[dict] = None
|
||||||
self._dp = None
|
self._dp = None
|
||||||
self._news_engine: Optional[NewsEngine] = None
|
self._news_engine: Optional[NewsEngine] = None
|
||||||
@@ -361,12 +358,13 @@ class DashboardPublisher:
|
|||||||
try:
|
try:
|
||||||
report = produce_signal(XAUUSD, tf, self._provider(),
|
report = produce_signal(XAUUSD, tf, self._provider(),
|
||||||
CONFIRMATIONS[tf])
|
CONFIRMATIONS[tf])
|
||||||
# Display-only violent-move watch (dark by default). Never
|
# Advisory AI panel, post-hoc and display-only. Off unless
|
||||||
# raises and never touches the signal — only watch_line.
|
# config.json agent_panel.dashboard is true. The reuse-TTL
|
||||||
if violent_watch_enabled():
|
# cache + max_panels_per_day budget bound the spend against
|
||||||
attach_watch(report, tf,
|
# this process's own DeepSeek cap (a persisting M5 signal
|
||||||
get_app_config().violent_watch_settings)
|
# re-serves its cached verdict instead of re-running every
|
||||||
self._reports[tf.value] = report
|
# bar); a fresh panel adds ≤~25 s before this TF publishes.
|
||||||
|
attach_panel(report, surface="dashboard")
|
||||||
self.snapshots[tf.value] = {
|
self.snapshots[tf.value] = {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"scanned_at": datetime.now(timezone.utc).isoformat(),
|
"scanned_at": datetime.now(timezone.utc).isoformat(),
|
||||||
@@ -378,41 +376,16 @@ class DashboardPublisher:
|
|||||||
logger.info(f"{tf.value}: {report.final_signal.name} "
|
logger.info(f"{tf.value}: {report.final_signal.name} "
|
||||||
f"q={report.signal_quality or '-'} "
|
f"q={report.signal_quality or '-'} "
|
||||||
f"({time.time() - started:.1f}s)")
|
f"({time.time() - started:.1f}s)")
|
||||||
except Exception:
|
except Exception as e:
|
||||||
# Keep the last good report; the page shows it with an
|
# Keep the last good report; the page shows it with an
|
||||||
# error badge instead of going blank.
|
# error badge instead of going blank.
|
||||||
logger.exception(f"{tf.value} scan failed")
|
logger.exception(f"{tf.value} scan failed")
|
||||||
self._reports.pop(tf.value, None)
|
|
||||||
entry = self.snapshots.get(tf.value) or _empty_tf_entry()
|
entry = self.snapshots.get(tf.value) or _empty_tf_entry()
|
||||||
entry["status"] = "error"
|
entry["status"] = "error"
|
||||||
# The public snapshot carries a stable message; exception
|
entry["error"] = f"{type(e).__name__}: {e}"
|
||||||
# detail remains in dashboard.log for the operator.
|
|
||||||
entry["error"] = "Scan unavailable"
|
|
||||||
entry["error_at"] = datetime.now(timezone.utc).isoformat()
|
entry["error_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
self.snapshots[tf.value] = entry
|
self.snapshots[tf.value] = entry
|
||||||
|
|
||||||
def attach_panels(self, tfs: List[Timeframe]) -> bool:
|
|
||||||
"""Add advisory panels after publishing the technical snapshot.
|
|
||||||
|
|
||||||
A panel is display-only and can take tens of seconds on a cold LLM
|
|
||||||
request. Keeping its report object locally lets the page receive the
|
|
||||||
candle-close signal first, then a second atomic snapshot only when a
|
|
||||||
panel produced a result.
|
|
||||||
"""
|
|
||||||
updated = False
|
|
||||||
for tf in tfs:
|
|
||||||
report = self._reports.get(tf.value)
|
|
||||||
if report is None:
|
|
||||||
continue
|
|
||||||
if not attach_panel(report, surface="dashboard"):
|
|
||||||
continue
|
|
||||||
entry = self.snapshots.get(tf.value)
|
|
||||||
if entry is None or entry.get("status") != "ok":
|
|
||||||
continue
|
|
||||||
entry["report"] = report_to_webapp_dict(report)
|
|
||||||
updated = True
|
|
||||||
return updated
|
|
||||||
|
|
||||||
def refresh_news(self) -> None:
|
def refresh_news(self) -> None:
|
||||||
try:
|
try:
|
||||||
ne = self._news()
|
ne = self._news()
|
||||||
@@ -439,10 +412,10 @@ class DashboardPublisher:
|
|||||||
def run_cycle(self, tfs: List[Timeframe]) -> None:
|
def run_cycle(self, tfs: List[Timeframe]) -> None:
|
||||||
for tf in tfs: # sequential — see module docstring (cache dedup)
|
for tf in tfs: # sequential — see module docstring (cache dedup)
|
||||||
self.scan_tf(tf)
|
self.scan_tf(tf)
|
||||||
# Technical signals land before any optional slow work. News and the
|
# Snapshot lands BEFORE the news refresh: when the news TTL has
|
||||||
# advisory panel are display-only and may take 20-30 seconds cold.
|
# expired, refresh_news does FF + 4 RSS fetches (and possibly
|
||||||
self.publish_snapshot()
|
# DeepSeek calls) — 20-30 s that must not keep fresh signals off
|
||||||
if self.attach_panels(tfs):
|
# the page.
|
||||||
self.publish_snapshot()
|
self.publish_snapshot()
|
||||||
self.refresh_news()
|
self.refresh_news()
|
||||||
self.publish_news()
|
self.publish_news()
|
||||||
|
|||||||
@@ -100,11 +100,6 @@ the public page, accepted with these guards baked into the PHP — the id must
|
|||||||
match a headline in the published news.json (arbitrary text can never reach
|
match a headline in the published news.json (arbitrary text can never reach
|
||||||
the LLM), each headline's summary is cached forever in `ai/` (≤1 API call per
|
the LLM), each headline's summary is cached forever in `ai/` (≤1 API call per
|
||||||
headline, ever), and a file-based daily cap fails **closed** on I/O errors.
|
headline, ever), and a file-based daily cap fails **closed** on I/O errors.
|
||||||
Since 2026-09-04 the engine publishes only headlines up to 4 h old whose
|
|
||||||
score leans one way (neutral items are dropped), so a headline leaves the
|
|
||||||
whitelist sooner: a dialog left open past that point returns `unknown_id`
|
|
||||||
("This headline has left the live feed"); summaries already cached in `ai/`
|
|
||||||
stay reachable.
|
|
||||||
|
|
||||||
Requirements: `php` + the curl extension (`php -m | grep curl`).
|
Requirements: `php` + the curl extension (`php -m | grep curl`).
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,13 @@ box(31, 33.2, 38, 5.2, "SignalReport",
|
|||||||
band(1, 13.2, 98, 17.2, "DELIVERY SURFACES", "#4C8F50")
|
band(1, 13.2, 98, 17.2, "DELIVERY SURFACES", "#4C8F50")
|
||||||
box(2.5, 15.0, 12, 12.6, "main.py",
|
box(2.5, 15.0, 12, 12.6, "main.py",
|
||||||
["CLI report", "--detail", "--json"], DEL_FC, DEL_EC)
|
["CLI report", "--detail", "--json"], DEL_FC, DEL_EC)
|
||||||
|
box(16, 15.0, 24, 12.6, "oanda_daemon.py + executor",
|
||||||
|
["practice auto-execution",
|
||||||
|
"book-parity rules: no broker TP,",
|
||||||
|
"5-bar cooldown, once-per-bar",
|
||||||
|
"trail, OPEN_ONLY fills,",
|
||||||
|
"boot reconcile · orders → Oanda"],
|
||||||
|
DEL_FC, DEL_EC, title_fs=7.8)
|
||||||
box(42.5, 15.0, 31, 12.6, "dashboard_publisher.py → Apache",
|
box(42.5, 15.0, 31, 12.6, "dashboard_publisher.py → Apache",
|
||||||
["independent daemon · scans at candle closes",
|
["independent daemon · scans at candle closes",
|
||||||
"atomic publish to /var/www/trade:",
|
"atomic publish to /var/www/trade:",
|
||||||
@@ -163,6 +170,7 @@ arrow((55, 69.8), (55, 63.1), label="warn-only context", lx=0, ly=1.0)
|
|||||||
arrow((86, 69.8), (86, 67.3), ls=(0, (3, 2)), lw=1.1) # config → core
|
arrow((86, 69.8), (86, 67.3), ls=(0, (3, 2)), lw=1.1) # config → core
|
||||||
arrow((50, 40.5), (50, 38.8)) # core → report
|
arrow((50, 40.5), (50, 38.8)) # core → report
|
||||||
arrow((36, 34.5), (8.5, 27.95), conn="arc3,rad=0.12") # report → cli
|
arrow((36, 34.5), (8.5, 27.95), conn="arc3,rad=0.12") # report → cli
|
||||||
|
arrow((40, 33.2), (28, 27.95), conn="arc3,rad=0.06") # report → daemon
|
||||||
arrow((60, 33.2), (58, 27.95)) # report → dashboard
|
arrow((60, 33.2), (58, 27.95)) # report → dashboard
|
||||||
arrow((66, 34.5), (86.5, 27.95), conn="arc3,rad=-0.12") # report → telegram
|
arrow((66, 34.5), (86.5, 27.95), conn="arc3,rad=-0.12") # report → telegram
|
||||||
|
|
||||||
@@ -173,6 +181,7 @@ arrow((95, 86.2), (95, 27.95), conn="arc3,rad=0.15", color=HAUL,
|
|||||||
ax.text(99.2, 57, "alerts / commands", fontsize=6.4, color=MUT,
|
ax.text(99.2, 57, "alerts / commands", fontsize=6.4, color=MUT,
|
||||||
rotation=90, ha="center", va="center")
|
rotation=90, ha="center", va="center")
|
||||||
# browser click → news_summary.php → DeepSeek
|
# browser click → news_summary.php → DeepSeek
|
||||||
|
# (oanda_daemon's order flow is noted inside its box — no long arc)
|
||||||
arrow((66, 27.95), (73, 86.2), conn="arc3,rad=-0.18", color=HAUL,
|
arrow((66, 27.95), (73, 86.2), conn="arc3,rad=-0.18", color=HAUL,
|
||||||
lw=1.2, ls=(0, (4, 2)),
|
lw=1.2, ls=(0, (4, 2)),
|
||||||
label="on-demand AI summary\n(browser click → PHP)", lx=2.5, ly=11.6)
|
label="on-demand AI summary\n(browser click → PHP)", lx=2.5, ly=11.6)
|
||||||
|
|||||||
@@ -86,8 +86,6 @@
|
|||||||
<li><b>Grade (A+, A, B, C):</b> the bot's own mark for how clean the setup is. A+ is best. It never sends anything worse than C.</li>
|
<li><b>Grade (A+, A, B, C):</b> the bot's own mark for how clean the setup is. A+ is best. It never sends anything worse than C.</li>
|
||||||
<li><b>Confidence and the indicator list:</b> extra detail for curious people. You don't need to understand it to trade the signal.</li>
|
<li><b>Confidence and the indicator list:</b> extra detail for curious people. You don't need to understand it to trade the signal.</li>
|
||||||
<li><b>⚠️ Warning lines:</b> things the bot noticed, like high volatility. They are "be aware," not "don't trade."</li>
|
<li><b>⚠️ Warning lines:</b> things the bot noticed, like high volatility. They are "be aware," not "don't trade."</li>
|
||||||
<li><b>🌪 Violent-move watch:</b> now and then the bot adds a line saying the next hour could produce very big candles — usually because an important American economic number is about to come out, or the market is in an hour that is historically wild, or the price has stretched a long way from normal. It is only a "hold on tight" note. It does not change the signal, and it is not a reason to close or move your stop loss early — the bot was tested doing exactly that and it lost money. Your stop loss is already your protection; leave it where it is.</li>
|
|
||||||
<li><b>📰 News line:</b> a short plain-English note on what is moving the gold price right now and why, written from the last few hours of headlines. It is background information, not a reason to trade.</li>
|
|
||||||
<li><b>The trade setup at the bottom is the only part you must act on.</b></li>
|
<li><b>The trade setup at the bottom is the only part you must act on.</b></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -136,8 +134,6 @@ The message says: <i>"once price hits 4,159, trail SL $33 behind best price."</i
|
|||||||
<ul>
|
<ul>
|
||||||
<li><b>H1 is forgiving.</b> You have up to an hour to act on a message, and small delays barely matter. On the 5-minute signals, being two minutes late already costs real money — they are not for manual beginners.</li>
|
<li><b>H1 is forgiving.</b> You have up to an hour to act on a message, and small delays barely matter. On the 5-minute signals, being two minutes late already costs real money — they are not for manual beginners.</li>
|
||||||
<li><b>H1 trades are held longer</b> — often overnight, sometimes over days. That is normal. Your stop loss is working for you while you sleep.</li>
|
<li><b>H1 trades are held longer</b> — often overnight, sometimes over days. That is normal. Your stop loss is working for you while you sleep.</li>
|
||||||
<li><b>H1 is BUY-only.</b> All H1 SELL signals are blocked (since 2026-09-03) because they were a losing side on this timeframe — expect to see BUY signals here, never SELL.</li>
|
|
||||||
<li><b>M5 also holds back some SELL signals</b> — specifically while the market is in a choppy "transitioning" phase, since those shorts tested poorly.</li>
|
|
||||||
<li><b>Never take the same signal on two timeframes.</b> They are usually the same gold move seen at different zoom levels. Taking both doubles your risk, not your profit.</li>
|
<li><b>Never take the same signal on two timeframes.</b> They are usually the same gold move seen at different zoom levels. Taking both doubles your risk, not your profit.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|||||||
+13
-14
@@ -69,8 +69,7 @@
|
|||||||
<tr><td><b>Score / confidence</b></td><td>The regime-weighted composite of all nine indicators. Bigger absolute score = stronger consensus.</td></tr>
|
<tr><td><b>Score / confidence</b></td><td>The regime-weighted composite of all nine indicators. Bigger absolute score = stronger consensus.</td></tr>
|
||||||
<tr><td><b>MTF aligned</b></td><td>The two confirmation timeframes agree with the trade direction. Signals without alignment are filtered out on M5.</td></tr>
|
<tr><td><b>MTF aligned</b></td><td>The two confirmation timeframes agree with the trade direction. Signals without alignment are filtered out on M5.</td></tr>
|
||||||
<tr><td><b>Warnings ⚠️</b></td><td>Context, not veto. Backtesting shows warned trades perform about the same — strong momentum often triggers warnings.</td></tr>
|
<tr><td><b>Warnings ⚠️</b></td><td>Context, not veto. Backtesting shows warned trades perform about the same — strong momentum often triggers warnings.</td></tr>
|
||||||
<tr><td><b>Violent-move watch 🌪</b></td><td>An occasional extra line saying the next hour looks unusually likely to produce a very large candle — because a high-impact US release is due (or just landed), because the clock is in a historically wild slot, or because price is stretched outside its bands. It is a heads-up about <i>volatility</i>, not a direction call, and it <b>never</b> changes the signal, the grade or the trade setup — the same alert would be sent without it. Do not close or tighten an open trade because of it: every such protective action was tested over three years and lost money. Your stop is the protection.</td></tr>
|
<tr><td><b>Market context 📰</b></td><td>An AI-written one-liner summarizing today's macro backdrop (headlines + high-impact releases) and its likely pull on gold. Informational only — it never blocks or grades a signal.</td></tr>
|
||||||
<tr><td><b>Market context 📰</b></td><td>An AI-written note in plain English on what is moving the gold price right now, why it matters, and the next report to watch. It is built only from headlines published in the last 4 hours that lean one way (neutral items are dropped) plus today's high-impact releases. Informational only — it never blocks or grades a signal.</td></tr>
|
|
||||||
<tr><td><b>SL / TP1 / RR</b></td><td>Stop loss sits behind the nearest swing level with an ATR buffer. TP1 is the nearest opposing level. <b>1R</b> = the entry-to-SL distance = the amount you risk.</td></tr>
|
<tr><td><b>SL / TP1 / RR</b></td><td>Stop loss sits behind the nearest swing level with an ATR buffer. TP1 is the nearest opposing level. <b>1R</b> = the entry-to-SL distance = the amount you risk.</td></tr>
|
||||||
<tr><td><b>Trail plan 🔄</b></td><td>The real exit. Once price moves +0.6R in your favor, move the stop to trail 0.75R behind the best price and update it at each candle close. <b>TP1 is advisory — let winners run.</b></td></tr>
|
<tr><td><b>Trail plan 🔄</b></td><td>The real exit. Once price moves +0.6R in your favor, move the stop to trail 0.75R behind the best price and update it at each candle close. <b>TP1 is advisory — let winners run.</b></td></tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -85,27 +84,27 @@
|
|||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<h2>4 · Choosing your timeframe</h2>
|
<h2>4 · Choosing your timeframe</h2>
|
||||||
<p>Twelve-month Oanda-candle backtest (Aug 2025 – Aug 2026, $0.65 round-trip cost, entry at next candle open):</p>
|
<p>Twelve-month backtest (Jul 2025 – Jul 2026, real spreads, entry at next candle open):</p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th></th><th>M5</th><th>M15</th><th>H1</th></tr>
|
<tr><th></th><th>M5</th><th>M15</th><th>H1</th></tr>
|
||||||
<tr><td>Trades / week</td><td>~11.3</td><td>~7.6</td><td>~2.7</td></tr>
|
<tr><td>Trades / week</td><td>~10</td><td>~9.5</td><td>~3</td></tr>
|
||||||
<tr><td>Total result (12 mo)</td><td>+106.8R</td><td>+73.9R</td><td>+24.0R</td></tr>
|
<tr><td>Total result (12 mo)</td><td>+135.6R</td><td>+67.2R</td><td>+49.9R</td></tr>
|
||||||
<tr><td>Win rate / profit factor</td><td>52.9% / 1.51</td><td>62.2% / 1.53</td><td>60.9% / 1.55</td></tr>
|
<tr><td>Win rate / profit factor</td><td>59.7% / 1.81</td><td>55.8% / 1.40</td><td>59.4% / 1.94</td></tr>
|
||||||
<tr><td>Typical (median) hold</td><td>2.4 h</td><td>7 h</td><td>15.5 h</td></tr>
|
<tr><td>Typical (median) hold</td><td>2.4 h</td><td>6 h</td><td>12.5 h</td></tr>
|
||||||
<tr><td>90% of trades close within</td><td>10.5 h</td><td>29.8 h</td><td>3.2 days</td></tr>
|
<tr><td>90% of trades close within</td><td>16 h</td><td>40 h</td><td>3.7 days</td></tr>
|
||||||
<tr><td>Typical stop distance</td><td>~$21</td><td>~$32</td><td>~$52</td></tr>
|
<tr><td>Typical stop distance</td><td>~$19</td><td>~$29</td><td>~$44</td></tr>
|
||||||
<tr><td>Worst drawdown</td><td>10.7R</td><td>10.8R</td><td>8.0R</td></tr>
|
<tr><td>Worst drawdown</td><td>11.2R</td><td>9.4R</td><td>9.5R</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<ul>
|
<ul>
|
||||||
<li><b>M5 — intraday, execution-critical.</b> Highest total edge, but stops are tight: a couple of dollars of slippage or a late entry eats a meaningful share of every trade. Suitable only if you can act within a minute or two of the alert (or automate). Trades open and close within a session; you sleep flat. SELL signals are held back while the market regime reads "transitioning" — a validated filter, not a bug if you notice fewer shorts there.</li>
|
<li><b>M5 — intraday, execution-critical.</b> Highest total edge, but stops are tight: a couple of dollars of slippage or a late entry eats a meaningful share of every trade. Suitable only if you can act within a minute or two of the alert (or automate). Trades open and close within a session; you sleep flat.</li>
|
||||||
<li><b>M15 — the middle path.</b> Same frequency as M5, wider stops, up to 15 minutes to act. About half your trades will still be open overnight.</li>
|
<li><b>M15 — the middle path.</b> Same frequency as M5, wider stops, up to 15 minutes to act. About half your trades will still be open overnight.</li>
|
||||||
<li><b>H1 — swing trading around a normal life.</b> Best quality per trade and very forgiving of slow manual execution (you have up to an hour). The costs: positions held for days, through news and overnight sessions, and a <b>lumpy</b> equity curve — one quarter of the test year was negative and most of the profit came in a single quarter. Judge it on quarters, not weeks. H1 no longer issues SELL signals at all (blocked since 2026-09-03) — it is BUY-only on this timeframe.</li>
|
<li><b>H1 — swing trading around a normal life.</b> Best quality per trade and very forgiving of slow manual execution (you have up to an hour). The costs: positions held for days, through news and overnight sessions, and a <b>lumpy</b> equity curve — one quarter of the test year was negative and most of the profit came in a single quarter. Judge it on quarters, not weeks.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="callout"><b>If you trade manually from the Telegram alerts, H1 (or M15) is the honest choice.</b> M5's backtested edge assumes near-instant fills that a human copying signals rarely achieves.</div>
|
<div class="callout"><b>If you trade manually from the Telegram alerts, H1 (or M15) is the honest choice.</b> M5's backtested edge assumes near-instant fills that a human copying signals rarely achieves.</div>
|
||||||
|
|
||||||
<h2>5 · What to expect (and what not to)</h2>
|
<h2>5 · What to expect (and what not to)</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><b>You will lose roughly 38–47% of trades.</b> A 53–62% win rate means losing streaks of 5+ are routine over a year. The system's edge is the average across hundreds of trades, not any single one.</li>
|
<li><b>You will lose ~40% of trades.</b> A 56–60% win rate means losing streaks of 5+ are routine over a year. The system's edge is the average across hundreds of trades, not any single one.</li>
|
||||||
<li><b>Stops hit fast, winners take time.</b> Losing trades die in roughly half the time winners take. An early stop-out is the system doing its job.</li>
|
<li><b>Stops hit fast, winners take time.</b> Losing trades die in roughly half the time winners take. An early stop-out is the system doing its job.</li>
|
||||||
<li><b>Drawdowns of ~10R are normal.</b> At 1% risk per trade, expect to be ~10% down from a peak at some point in a normal year. This is why risking more than 1% per trade is how accounts die.</li>
|
<li><b>Drawdowns of ~10R are normal.</b> At 1% risk per trade, expect to be ~10% down from a peak at some point in a normal year. This is why risking more than 1% per trade is how accounts die.</li>
|
||||||
<li><b>Backtest ≠ live.</b> Results above charge real spreads and realistic fills, but cannot model your personal latency, broker slippage, or requotes. Assume live results will be somewhat worse — especially on M5.</li>
|
<li><b>Backtest ≠ live.</b> Results above charge real spreads and realistic fills, but cannot model your personal latency, broker slippage, or requotes. Assume live results will be somewhat worse — especially on M5.</li>
|
||||||
@@ -131,7 +130,7 @@
|
|||||||
<tr><td><code>/scan</code></td><td>Run an immediate analysis of the primary timeframe.</td></tr>
|
<tr><td><code>/scan</code></td><td>Run an immediate analysis of the primary timeframe.</td></tr>
|
||||||
<tr><td><code>/autoscan</code></td><td>Toggle automatic scanning (M5 / M15 / H1 / H4 at each candle close).</td></tr>
|
<tr><td><code>/autoscan</code></td><td>Toggle automatic scanning (M5 / M15 / H1 / H4 at each candle close).</td></tr>
|
||||||
<tr><td><code>/status</code></td><td>Uptime, auto-scan state, WebSocket price-monitor state.</td></tr>
|
<tr><td><code>/status</code></td><td>Uptime, auto-scan state, WebSocket price-monitor state.</td></tr>
|
||||||
<tr><td><code>/news</code></td><td>Net XAU/USD news score from the last 4 hours of headlines (neutral headlines are removed before averaging; informational only — never gates signals).</td></tr>
|
<tr><td><code>/news</code></td><td>Net XAU/USD news sentiment score (informational only — never gates signals).</td></tr>
|
||||||
<tr><td><code>/cache</code> · <code>/clearcache</code></td><td>Inspect / reset the market-data cache.</td></tr>
|
<tr><td><code>/cache</code> · <code>/clearcache</code></td><td>Inspect / reset the market-data cache.</td></tr>
|
||||||
<tr><td><code>/help</code></td><td>Command reference.</td></tr>
|
<tr><td><code>/help</code></td><td>Command reference.</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Design philosophy:
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -32,13 +32,6 @@ class IndicatorSignal:
|
|||||||
confidence: float # 0-100
|
confidence: float # 0-100
|
||||||
description: str
|
description: str
|
||||||
weight: float = 1.0
|
weight: float = 1.0
|
||||||
# RECORD-KEEPING ONLY (stamped by _compute_composite; nothing reads
|
|
||||||
# them back). effective_weight = weight x regime multiplier actually
|
|
||||||
# used, weighted_score = its contribution to the composite. Both stay
|
|
||||||
# None for indicators the composite skipped (weight 0, `_`-prefixed
|
|
||||||
# private series) and for analyses that never reached _compute_composite.
|
|
||||||
effective_weight: Optional[float] = None
|
|
||||||
weighted_score: Optional[float] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -57,46 +50,6 @@ class TechnicalAnalysis:
|
|||||||
pattern_signals: List[str] = field(default_factory=list)
|
pattern_signals: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
# NOTE: ROC is directionally redundant with MACD/Keltner/EMA
|
|
||||||
# (0.8-0.94 correlation), but zeroing its weight was backtested
|
|
||||||
# 2026-07 and LOST 13R — the redundant vote effectively raises
|
|
||||||
# the composite bar and the thresholds are calibrated with it.
|
|
||||||
# Don't remove without retuning score thresholds.
|
|
||||||
_TRENDING_WEIGHTS = {
|
|
||||||
"ema_trend": 2.0, "rsi": 1.0, "macd": 2.0,
|
|
||||||
"bollinger": 0.5, "keltner": 1.5, "roc": 1.5,
|
|
||||||
"candlestick": 0.8, "divergence": 1.0, "adx": 0.8,
|
|
||||||
}
|
|
||||||
_DEFAULT_REGIME_WEIGHTS = {
|
|
||||||
"trending_up": _TRENDING_WEIGHTS,
|
|
||||||
"trending_down": _TRENDING_WEIGHTS,
|
|
||||||
"trending": _TRENDING_WEIGHTS,
|
|
||||||
"ranging": {
|
|
||||||
"ema_trend": 0.3, "rsi": 1.5, "macd": 1.5,
|
|
||||||
"bollinger": 1.5, "keltner": 2.0, "roc": 0.5,
|
|
||||||
"candlestick": 1.0, "divergence": 2.5, "adx": 0.5,
|
|
||||||
},
|
|
||||||
"transitioning": {
|
|
||||||
"ema_trend": 1.5, "rsi": 1.0, "macd": 2.5,
|
|
||||||
"bollinger": 1.0, "keltner": 2.5, "roc": 2.0,
|
|
||||||
"candlestick": 1.0, "divergence": 2.0, "adx": 1.0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def regime_weight_table(config: TechnicalConfig) -> Dict[str, Dict[str, float]]:
|
|
||||||
"""The regime -> indicator weight multipliers _compute_composite runs
|
|
||||||
with (``regime_weights_override`` wins, else the gold-optimized
|
|
||||||
defaults above).
|
|
||||||
|
|
||||||
Pure and side-effect free: ONE source of truth shared by the composite
|
|
||||||
and by signal_aggregator.engine_config_fingerprint. Returns a copy, so
|
|
||||||
a caller can never mutate the shipped table."""
|
|
||||||
override = getattr(config, "regime_weights_override", None)
|
|
||||||
table = override if override else _DEFAULT_REGIME_WEIGHTS
|
|
||||||
return {regime: dict(weights) for regime, weights in table.items()}
|
|
||||||
|
|
||||||
|
|
||||||
class TechnicalEngine:
|
class TechnicalEngine:
|
||||||
"""
|
"""
|
||||||
Core technical analysis engine.
|
Core technical analysis engine.
|
||||||
@@ -1128,9 +1081,35 @@ class TechnicalEngine:
|
|||||||
total_weight = 0.0
|
total_weight = 0.0
|
||||||
total_confidence = 0.0
|
total_confidence = 0.0
|
||||||
|
|
||||||
# Override weights if provided, else gold-optimized defaults
|
# Use override weights if provided, else gold-optimized defaults
|
||||||
# (regime_weight_table is the single source of truth).
|
if self.config.regime_weights_override:
|
||||||
regime_weights = regime_weight_table(self.config)
|
regime_weights = self.config.regime_weights_override
|
||||||
|
else:
|
||||||
|
# NOTE: ROC is directionally redundant with MACD/Keltner/EMA
|
||||||
|
# (0.8-0.94 correlation), but zeroing its weight was backtested
|
||||||
|
# 2026-07 and LOST 13R — the redundant vote effectively raises
|
||||||
|
# the composite bar and the thresholds are calibrated with it.
|
||||||
|
# Don't remove without retuning score thresholds.
|
||||||
|
_trending = {
|
||||||
|
"ema_trend": 2.0, "rsi": 1.0, "macd": 2.0,
|
||||||
|
"bollinger": 0.5, "keltner": 1.5, "roc": 1.5,
|
||||||
|
"candlestick": 0.8, "divergence": 1.0, "adx": 0.8,
|
||||||
|
}
|
||||||
|
regime_weights = {
|
||||||
|
"trending_up": _trending,
|
||||||
|
"trending_down": _trending,
|
||||||
|
"trending": _trending,
|
||||||
|
"ranging": {
|
||||||
|
"ema_trend": 0.3, "rsi": 1.5, "macd": 1.5,
|
||||||
|
"bollinger": 1.5, "keltner": 2.0, "roc": 0.5,
|
||||||
|
"candlestick": 1.0, "divergence": 2.5, "adx": 0.5,
|
||||||
|
},
|
||||||
|
"transitioning": {
|
||||||
|
"ema_trend": 1.5, "rsi": 1.0, "macd": 2.5,
|
||||||
|
"bollinger": 1.0, "keltner": 2.5, "roc": 2.0,
|
||||||
|
"candlestick": 1.0, "divergence": 2.0, "adx": 1.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
active_regime_weights = regime_weights.get(result.market_regime, {})
|
active_regime_weights = regime_weights.get(result.market_regime, {})
|
||||||
|
|
||||||
@@ -1144,9 +1123,6 @@ class TechnicalEngine:
|
|||||||
|
|
||||||
signal_score = ind.signal.value
|
signal_score = ind.signal.value
|
||||||
weighted_score = signal_score * weight * (ind.confidence / 100.0)
|
weighted_score = signal_score * weight * (ind.confidence / 100.0)
|
||||||
# Record-keeping stamp (no maths depends on these two lines).
|
|
||||||
ind.effective_weight = float(weight)
|
|
||||||
ind.weighted_score = float(weighted_score)
|
|
||||||
|
|
||||||
total_score += weighted_score
|
total_score += weighted_score
|
||||||
total_weight += weight
|
total_weight += weight
|
||||||
|
|||||||
+1
-13
@@ -90,11 +90,7 @@ class DeepSeekClient:
|
|||||||
if self._count_day != today:
|
if self._count_day != today:
|
||||||
self._count_day = today
|
self._count_day = today
|
||||||
self._count = 0
|
self._count = 0
|
||||||
# Clear ALL warn-once kinds, not just "cap": the network/
|
self._warned.discard("cap")
|
||||||
# http_*/parse flags used to be set once and never reset,
|
|
||||||
# so in a daemon that runs for weeks the second and every
|
|
||||||
# later outage logged at DEBUG only.
|
|
||||||
self._warned.clear()
|
|
||||||
if self._count >= self._daily_cap:
|
if self._count >= self._daily_cap:
|
||||||
self._warn_once(
|
self._warn_once(
|
||||||
"cap", "DeepSeek daily request cap (%d) exhausted — "
|
"cap", "DeepSeek daily request cap (%d) exhausted — "
|
||||||
@@ -153,14 +149,6 @@ class DeepSeekClient:
|
|||||||
"expected JSON object — ignoring")
|
"expected JSON object — ignoring")
|
||||||
else:
|
else:
|
||||||
self.last_ok = time.time()
|
self.last_ok = time.time()
|
||||||
# A recovered call re-arms the failure warnings so the NEXT
|
|
||||||
# outage is visible at WARNING again. "cap" keeps its own
|
|
||||||
# UTC-day reset in _under_cap. (Taken under the lock; safe
|
|
||||||
# here because chat_json holds no lock — _under_cap has
|
|
||||||
# already returned.)
|
|
||||||
if self._warned - {"cap"}:
|
|
||||||
with self._lock:
|
|
||||||
self._warned = {k for k in self._warned if k == "cap"}
|
|
||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
# Force UTF-8 output on Windows so box-drawing chars render
|
# Force UTF-8 output on Windows so box-drawing chars render
|
||||||
if hasattr(sys.stdout, "reconfigure"):
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
@@ -22,12 +21,9 @@ if hasattr(sys.stderr, "reconfigure"):
|
|||||||
sys.stderr.reconfigure(encoding="utf-8")
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
from agent_panel import attach_panel
|
from agent_panel import attach_panel
|
||||||
from app_config import get_app_config, violent_watch_enabled
|
|
||||||
from config import PlatformConfig, Timeframe, SignalStrength, XAUUSD
|
from config import PlatformConfig, Timeframe, SignalStrength, XAUUSD
|
||||||
from market_outlook import OutlookResult, maybe_run_outlook
|
|
||||||
from pair_signals import PAIR_PRODUCERS
|
from pair_signals import PAIR_PRODUCERS
|
||||||
from signal_aggregator import SignalAggregator, SignalReport
|
from signal_aggregator import SignalAggregator, SignalReport
|
||||||
from violent_watch import attach_watch
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════
|
||||||
@@ -162,8 +158,6 @@ def print_signal_report(report: SignalReport, detail: bool = False):
|
|||||||
if report.warnings:
|
if report.warnings:
|
||||||
for w in report.warnings:
|
for w in report.warnings:
|
||||||
print(f" {Colors.YELLOW}⚠{Colors.RESET} {w}")
|
print(f" {Colors.YELLOW}⚠{Colors.RESET} {w}")
|
||||||
if report.watch_line:
|
|
||||||
print(f" {Colors.YELLOW}🌪{Colors.RESET} {report.watch_line}")
|
|
||||||
if report.event_description and report.event_risk != "clear":
|
if report.event_description and report.event_risk != "clear":
|
||||||
print(f" {Colors.YELLOW}📅{Colors.RESET} {report.event_description}")
|
print(f" {Colors.YELLOW}📅{Colors.RESET} {report.event_description}")
|
||||||
if report.news_description and report.news_description != "Neutral news sentiment":
|
if report.news_description and report.news_description != "Neutral news sentiment":
|
||||||
@@ -171,9 +165,7 @@ def print_signal_report(report: SignalReport, detail: bool = False):
|
|||||||
if report.news_summary:
|
if report.news_summary:
|
||||||
print(f" {Colors.MAGENTA}🧠{Colors.RESET} {report.news_summary}")
|
print(f" {Colors.MAGENTA}🧠{Colors.RESET} {report.news_summary}")
|
||||||
if report.panel_verdict:
|
if report.panel_verdict:
|
||||||
panel_label = ("AI macro view"
|
print(f" {Colors.CYAN}🤖{Colors.RESET} AI Panel: "
|
||||||
if report.panel_verdict_source == "macro" else "AI Panel")
|
|
||||||
print(f" {Colors.CYAN}🤖{Colors.RESET} {panel_label}: "
|
|
||||||
f"{Colors.BOLD}{report.panel_verdict}{Colors.RESET} "
|
f"{Colors.BOLD}{report.panel_verdict}{Colors.RESET} "
|
||||||
f"({report.panel_confidence:.0f}%) — {report.panel_rationale}")
|
f"({report.panel_confidence:.0f}%) — {report.panel_rationale}")
|
||||||
for t in report.panel_takes:
|
for t in report.panel_takes:
|
||||||
@@ -205,24 +197,6 @@ def print_signal_report(report: SignalReport, detail: bool = False):
|
|||||||
print(f" {label:>20} {color}{val:.3f}{Colors.RESET}")
|
print(f" {label:>20} {color}{val:.3f}{Colors.RESET}")
|
||||||
|
|
||||||
|
|
||||||
def print_market_outlook(outlook: Optional[OutlookResult]) -> None:
|
|
||||||
"""Render the independent advisory outlook for explicit CLI use."""
|
|
||||||
print(f"\n {Colors.BOLD}AI Market Outlook{Colors.RESET}")
|
|
||||||
if outlook is None:
|
|
||||||
print(f" {Colors.GRAY}Unavailable (disabled, market closed, or no LLM response).{Colors.RESET}")
|
|
||||||
return
|
|
||||||
forming = {
|
|
||||||
"buy": "possible buy forming",
|
|
||||||
"sell": "possible sell forming",
|
|
||||||
"none": "no clear setup forming",
|
|
||||||
}[outlook.setup_forming]
|
|
||||||
print(f" {outlook.bias.title()} · {forming} · {outlook.confidence:.0f}%")
|
|
||||||
if outlook.watch:
|
|
||||||
print(f" Watch: {outlook.watch}")
|
|
||||||
if outlook.note:
|
|
||||||
print(f" {Colors.GRAY}{outlook.note}{Colors.RESET}")
|
|
||||||
|
|
||||||
|
|
||||||
def report_to_dict(report: SignalReport) -> dict:
|
def report_to_dict(report: SignalReport) -> dict:
|
||||||
d = {
|
d = {
|
||||||
"symbol": report.symbol,
|
"symbol": report.symbol,
|
||||||
@@ -247,10 +221,6 @@ def report_to_dict(report: SignalReport) -> dict:
|
|||||||
"panel_confidence": round(report.panel_confidence, 1),
|
"panel_confidence": round(report.panel_confidence, 1),
|
||||||
"panel_rationale": report.panel_rationale,
|
"panel_rationale": report.panel_rationale,
|
||||||
"panel_takes": report.panel_takes,
|
"panel_takes": report.panel_takes,
|
||||||
"panel_created_at": report.panel_created_at,
|
|
||||||
"panel_cache_status": report.panel_cache_status,
|
|
||||||
"panel_payload_hash": report.panel_payload_hash,
|
|
||||||
"watch_line": report.watch_line,
|
|
||||||
"bullish_reasons": report.bullish_reasons,
|
"bullish_reasons": report.bullish_reasons,
|
||||||
"bearish_reasons": report.bearish_reasons,
|
"bearish_reasons": report.bearish_reasons,
|
||||||
"warnings": report.warnings,
|
"warnings": report.warnings,
|
||||||
@@ -267,8 +237,6 @@ def report_to_dict(report: SignalReport) -> dict:
|
|||||||
"risk_pips": round(ts.risk_pips, 1),
|
"risk_pips": round(ts.risk_pips, 1),
|
||||||
"reward_pips": round(ts.reward_pips, 1),
|
"reward_pips": round(ts.reward_pips, 1),
|
||||||
"strategy": ts.strategy,
|
"strategy": ts.strategy,
|
||||||
"setup_pattern": ts.setup_pattern,
|
|
||||||
"round_trip_cost": ts.round_trip_cost,
|
|
||||||
"trail_arm_r": ts.trail_arm_r,
|
"trail_arm_r": ts.trail_arm_r,
|
||||||
"trail_dist_r": ts.trail_dist_r,
|
"trail_dist_r": ts.trail_dist_r,
|
||||||
}
|
}
|
||||||
@@ -315,9 +283,6 @@ Examples:
|
|||||||
parser.add_argument("--panel", action="store_true",
|
parser.add_argument("--panel", action="store_true",
|
||||||
help="Run the LLM agent panel on the result "
|
help="Run the LLM agent panel on the result "
|
||||||
"(advisory only; needs a DeepSeek key)")
|
"(advisory only; needs a DeepSeek key)")
|
||||||
parser.add_argument("--outlook", action="store_true",
|
|
||||||
help="Run the hourly AI market outlook now "
|
|
||||||
"(advisory only; needs a DeepSeek key)")
|
|
||||||
parser.add_argument("--json", action="store_true",
|
parser.add_argument("--json", action="store_true",
|
||||||
help="Output as JSON")
|
help="Output as JSON")
|
||||||
parser.add_argument("--verbose", "-v", action="store_true",
|
parser.add_argument("--verbose", "-v", action="store_true",
|
||||||
@@ -371,43 +336,19 @@ def build_config(args) -> PlatformConfig:
|
|||||||
def run_live(args, config: PlatformConfig):
|
def run_live(args, config: PlatformConfig):
|
||||||
print(f"{Colors.GRAY} Fetching market data and running analysis...{Colors.RESET}\n")
|
print(f"{Colors.GRAY} Fetching market data and running analysis...{Colors.RESET}\n")
|
||||||
aggregator = SignalAggregator(config)
|
aggregator = SignalAggregator(config)
|
||||||
report = aggregator.analyze_pair(symbol=config.symbol, log_source="cli")
|
report = aggregator.analyze_pair(symbol=config.symbol)
|
||||||
|
|
||||||
# Explicit user intent: force bypasses the enabled/surface/quality
|
# Explicit user intent: force bypasses the enabled/surface/quality
|
||||||
# toggles, but the daily panel budget and LLM cap still apply.
|
# toggles, but the daily panel budget and LLM cap still apply.
|
||||||
if getattr(args, "panel", False):
|
if getattr(args, "panel", False):
|
||||||
attach_panel(report, surface="cli", force=True)
|
attach_panel(report, surface="cli", force=True)
|
||||||
|
|
||||||
# Display-only violent-move watch (dark by default; never touches
|
|
||||||
# the signal, only report.watch_line).
|
|
||||||
if violent_watch_enabled():
|
|
||||||
attach_watch(report, config.primary_timeframe,
|
|
||||||
get_app_config().violent_watch_settings)
|
|
||||||
|
|
||||||
outlook = None
|
|
||||||
if getattr(args, "outlook", False):
|
|
||||||
outlook = maybe_run_outlook(surface="cli", force=True)
|
|
||||||
|
|
||||||
if args.json:
|
if args.json:
|
||||||
payload = report_to_dict(report)
|
print(json.dumps(report_to_dict(report), indent=2))
|
||||||
if getattr(args, "outlook", False):
|
|
||||||
payload["market_outlook"] = (
|
|
||||||
None if outlook is None else {
|
|
||||||
"bias": outlook.bias,
|
|
||||||
"setup_forming": outlook.setup_forming,
|
|
||||||
"confidence": outlook.confidence,
|
|
||||||
"watch": outlook.watch,
|
|
||||||
"note": outlook.note,
|
|
||||||
"created_ts": outlook.created_ts,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
print(json.dumps(payload, indent=2))
|
|
||||||
return
|
return
|
||||||
|
|
||||||
print_header(f"{config.display_name} SIGNAL INTELLIGENCE")
|
print_header(f"{config.display_name} SIGNAL INTELLIGENCE")
|
||||||
print_signal_report(report, detail=args.detail)
|
print_signal_report(report, detail=args.detail)
|
||||||
if getattr(args, "outlook", False):
|
|
||||||
print_market_outlook(outlook)
|
|
||||||
print(f"\n{Colors.CYAN}{'═' * 80}{Colors.RESET}")
|
print(f"\n{Colors.CYAN}{'═' * 80}{Colors.RESET}")
|
||||||
print(f"{Colors.GRAY} Disclaimer: For educational/research purposes only.{Colors.RESET}")
|
print(f"{Colors.GRAY} Disclaimer: For educational/research purposes only.{Colors.RESET}")
|
||||||
print(f"{Colors.CYAN}{'═' * 80}{Colors.RESET}\n")
|
print(f"{Colors.CYAN}{'═' * 80}{Colors.RESET}\n")
|
||||||
|
|||||||
@@ -1,442 +0,0 @@
|
|||||||
"""
|
|
||||||
ForexBot2 — AI market outlook (advisory hourly assessment)
|
|
||||||
===========================================================
|
|
||||||
Builds a broad XAU/USD market brief and asks DeepSeek whether a good setup
|
|
||||||
appears to be forming. The result is written to the signal-history database
|
|
||||||
and may be displayed by consumers, but it is ADVISORY-ONLY: it never changes
|
|
||||||
signal scoring, gates, grades, or trade setup generation.
|
|
||||||
|
|
||||||
The outlook runs outside the replayable signal path. Individual candle/news
|
|
||||||
failures produce a partial brief; an unavailable LLM, malformed reply, or
|
|
||||||
locked database never reaches a scan caller as an exception.
|
|
||||||
|
|
||||||
The static prompt is intentional: DeepSeek prices byte-identical prompt
|
|
||||||
prefixes at its cache-hit rate, so timestamps and all other changing evidence
|
|
||||||
live only in the JSON user payload.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import datetime as _dt
|
|
||||||
import logging
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from agent_panel import _normalize_candles
|
|
||||||
from app_config import (get_app_config, market_outlook_enabled,
|
|
||||||
signal_log_path)
|
|
||||||
from config import PlatformConfig, Timeframe, XAUUSD
|
|
||||||
from engine import TechnicalEngine
|
|
||||||
from llm_client import get_llm_client
|
|
||||||
from providers import base as _provider_base
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
DEFAULT_MAX_PER_DAY = 12
|
|
||||||
DEFAULT_CANDLE_BARS = 20
|
|
||||||
_OUTLOOK_TFS = (Timeframe.M15, Timeframe.H1, Timeframe.H4)
|
|
||||||
_VALID_BIASES = ("bullish", "bearish", "neutral")
|
|
||||||
_VALID_SETUPS = ("buy", "sell", "none")
|
|
||||||
|
|
||||||
|
|
||||||
# STATIC prompt: repeated prompt prefixes are much cheaper with DeepSeek's
|
|
||||||
# server-side cache. Variable market evidence belongs exclusively in payload.
|
|
||||||
SYSTEM_PROMPT = """\
|
|
||||||
You are writing an advisory market outlook for gold (XAU/USD). You receive a
|
|
||||||
JSON market brief covering recent candle shape, indicator states, price
|
|
||||||
levels, news, and recent technical signals. Assess what may be forming soon;
|
|
||||||
you are not a signal generator and must not provide an entry price, stop, or
|
|
||||||
target.
|
|
||||||
|
|
||||||
Gold doctrine: hawkish Fed / rate hikes / rising real yields / strong
|
|
||||||
dollar are gold-bearish; easing / rate cuts / safe-haven flows /
|
|
||||||
geopolitical stress / weak dollar are gold-bullish.
|
|
||||||
|
|
||||||
Indicator "value" units: rsi 0-100 (above 70 overbought, below 30
|
|
||||||
oversold); adx 0-100 (above 25 = strong trend); macd = histogram in
|
|
||||||
dollars (momentum); roc = percent; bollinger = position 0-1 (0 = lower
|
|
||||||
band, 1 = upper band); keltner = dollar distance from the mid-line;
|
|
||||||
ema_trend = 0-1 alignment score; divergence and candlestick = pattern
|
|
||||||
counts. "candles" is per timeframe: {"ref_close", "atr", "bars"}; bars
|
|
||||||
are oldest-first [open, high, low, close] in ATR units relative to the
|
|
||||||
latest close. Read their shape, never quote them as prices.
|
|
||||||
|
|
||||||
Hard rules: use ONLY the JSON provided. Do not invent numbers, events, or
|
|
||||||
price levels. Your training data about gold, markets, and current events is
|
|
||||||
stale and OFF-LIMITS: never use outside knowledge; if a fact is not in the
|
|
||||||
JSON, treat it as unknown. If evidence is thin or missing, answer "neutral"
|
|
||||||
with confidence below 30. Use plain everyday English for someone unfamiliar
|
|
||||||
with markets: short simple sentences, no acronyms or jargon. Keep "watch"
|
|
||||||
and "note" to at most 25 words each.
|
|
||||||
|
|
||||||
Reply with ONLY JSON:
|
|
||||||
{"bias": "bullish"|"bearish"|"neutral",
|
|
||||||
"setup_forming": "buy"|"sell"|"none",
|
|
||||||
"confidence": 0-100,
|
|
||||||
"watch": "<what to watch for, max 25 words>",
|
|
||||||
"note": "<plain-English summary, max 25 words>"}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class OutlookResult:
|
|
||||||
bias: str
|
|
||||||
setup_forming: str
|
|
||||||
confidence: float
|
|
||||||
watch: str
|
|
||||||
note: str
|
|
||||||
created_ts: float
|
|
||||||
|
|
||||||
|
|
||||||
def _finite(x: Any) -> Optional[float]:
|
|
||||||
"""float(x) or None for non-numeric / non-finite JSON values."""
|
|
||||||
try:
|
|
||||||
value = float(x)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
return value if math.isfinite(value) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _round_finite(x: Any, digits: int) -> Optional[float]:
|
|
||||||
value = _finite(x)
|
|
||||||
return round(value, digits) if value is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
def _json_safe(value: Any) -> Any:
|
|
||||||
"""Replace any accidental NaN/inf nested in provider data with null."""
|
|
||||||
if isinstance(value, dict):
|
|
||||||
return {str(key): _json_safe(item) for key, item in value.items()}
|
|
||||||
if isinstance(value, (list, tuple)):
|
|
||||||
return [_json_safe(item) for item in value]
|
|
||||||
if isinstance(value, bool) or value is None or isinstance(value, str):
|
|
||||||
return value
|
|
||||||
if isinstance(value, (int, float)):
|
|
||||||
return _finite(value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _clamp_confidence(raw: Any) -> Optional[float]:
|
|
||||||
value = _finite(raw)
|
|
||||||
return max(0.0, min(100.0, value)) if value is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
def _word_limit(text: Any, words: int = 25) -> str:
|
|
||||||
"""Bound a response field by words as well as a practical char limit."""
|
|
||||||
clean = str(text or "").strip()
|
|
||||||
return " ".join(clean.split()[:words])[:200]
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_outlook(reply: Any) -> Optional[OutlookResult]:
|
|
||||||
"""Validate only model replies that fully match the requested contract."""
|
|
||||||
if not isinstance(reply, dict):
|
|
||||||
return None
|
|
||||||
bias = reply.get("bias")
|
|
||||||
setup_forming = reply.get("setup_forming")
|
|
||||||
confidence = _clamp_confidence(reply.get("confidence"))
|
|
||||||
if (bias not in _VALID_BIASES or setup_forming not in _VALID_SETUPS
|
|
||||||
or confidence is None):
|
|
||||||
return None
|
|
||||||
return OutlookResult(
|
|
||||||
bias=bias,
|
|
||||||
setup_forming=setup_forming,
|
|
||||||
confidence=confidence,
|
|
||||||
watch=_word_limit(reply.get("watch")),
|
|
||||||
note=_word_limit(reply.get("note")),
|
|
||||||
created_ts=time.time(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Data collection ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_dp: Any = None # Patchable singleton, mirroring agent_panel._dp.
|
|
||||||
|
|
||||||
|
|
||||||
def _outlook_provider() -> Any:
|
|
||||||
"""Lazy provider singleton so its closed-bar cache stays warm hourly."""
|
|
||||||
global _dp
|
|
||||||
if _dp is None:
|
|
||||||
from providers import make_data_provider
|
|
||||||
_dp = make_data_provider(PlatformConfig())
|
|
||||||
return _dp
|
|
||||||
|
|
||||||
|
|
||||||
def _indicator_payload(analysis: Any) -> Dict[str, Dict[str, Any]]:
|
|
||||||
"""Visible scored indicators with finite, compact values only."""
|
|
||||||
out: Dict[str, Dict[str, Any]] = {}
|
|
||||||
for name, indicator in (getattr(analysis, "indicators", None) or {}).items():
|
|
||||||
if name.startswith("_") or getattr(indicator, "weight", 0) == 0:
|
|
||||||
continue
|
|
||||||
entry: Dict[str, Any] = {
|
|
||||||
"signal": getattr(getattr(indicator, "signal", None), "name", ""),
|
|
||||||
"confidence": _round_finite(
|
|
||||||
getattr(indicator, "confidence", None), 1),
|
|
||||||
"value": _round_finite(getattr(indicator, "value", None), 2),
|
|
||||||
}
|
|
||||||
out[name] = entry
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _analysis_payload(analyses: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""Pure formatter for the technical portion of the market brief."""
|
|
||||||
indicators: Dict[str, Any] = {}
|
|
||||||
market_regime: Dict[str, Optional[str]] = {}
|
|
||||||
volatility_regime: Dict[str, Optional[str]] = {}
|
|
||||||
key_levels: Dict[str, Dict[str, Optional[float]]] = {}
|
|
||||||
for tf in _OUTLOOK_TFS:
|
|
||||||
name = tf.value
|
|
||||||
analysis = analyses.get(name)
|
|
||||||
indicators[name] = _indicator_payload(analysis)
|
|
||||||
market_regime[name] = getattr(analysis, "market_regime", None)
|
|
||||||
volatility_regime[name] = getattr(analysis, "volatility_regime", None)
|
|
||||||
key_levels[name] = {
|
|
||||||
level: _round_finite(value, 2)
|
|
||||||
for level, value in (getattr(analysis, "key_levels", None) or {}).items()
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"indicators": indicators,
|
|
||||||
"market_regime": market_regime,
|
|
||||||
"volatility_regime": volatility_regime,
|
|
||||||
"key_levels": key_levels,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _empty_news_payload() -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"headlines": None,
|
|
||||||
"events": None,
|
|
||||||
"gold_sentiment": None,
|
|
||||||
"remaining_high_impact_events": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _news_payload() -> Dict[str, Any]:
|
|
||||||
"""Collect current news without allowing any news failure to block us."""
|
|
||||||
try:
|
|
||||||
from news_engine import NewsEngine
|
|
||||||
|
|
||||||
news = NewsEngine(PlatformConfig().news)
|
|
||||||
context = news.get_fundamental_context()
|
|
||||||
headlines = [str(getattr(h, "headline", ""))
|
|
||||||
for h in (context.news_headlines or [])[:10]
|
|
||||||
if getattr(h, "headline", None)]
|
|
||||||
events = [
|
|
||||||
{
|
|
||||||
"event": getattr(event, "event", None),
|
|
||||||
"currency": getattr(event, "currency", None),
|
|
||||||
"actual": getattr(event, "actual", None),
|
|
||||||
"forecast": getattr(event, "forecast", None),
|
|
||||||
"released": not event.is_upcoming,
|
|
||||||
}
|
|
||||||
for event in (context.events_today or [])
|
|
||||||
if event.is_high_impact
|
|
||||||
][:8]
|
|
||||||
gold_sentiment, _ = news.get_pair_sentiment(XAUUSD, context)
|
|
||||||
remaining = sum(
|
|
||||||
1 for event in news.get_week_events()
|
|
||||||
if event.is_high_impact and event.is_upcoming
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"headlines": headlines,
|
|
||||||
"events": events,
|
|
||||||
"gold_sentiment": _round_finite(gold_sentiment, 2),
|
|
||||||
"remaining_high_impact_events": remaining,
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("market outlook: news context unavailable: %s", exc)
|
|
||||||
return _empty_news_payload()
|
|
||||||
|
|
||||||
|
|
||||||
def _recent_signals() -> List[Dict[str, Any]]:
|
|
||||||
"""Read the latest signal history through SQLite's immutable URI."""
|
|
||||||
try:
|
|
||||||
path = os.path.abspath(signal_log_path())
|
|
||||||
# mode=ro (not immutable=1): immutable ignores the WAL, so the
|
|
||||||
# newest rows written by the live loggers were invisible here.
|
|
||||||
uri = f"file:{path}?mode=ro"
|
|
||||||
con = sqlite3.connect(uri, uri=True)
|
|
||||||
try:
|
|
||||||
rows = con.execute(
|
|
||||||
"SELECT logged_at, timeframe, signal, quality, panel_verdict "
|
|
||||||
"FROM signals ORDER BY id DESC LIMIT 10").fetchall()
|
|
||||||
finally:
|
|
||||||
con.close()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"logged_at": row[0],
|
|
||||||
"timeframe": row[1],
|
|
||||||
"signal": row[2],
|
|
||||||
"quality": row[3],
|
|
||||||
"panel_verdict": row[4],
|
|
||||||
}
|
|
||||||
for row in reversed(rows)
|
|
||||||
]
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("market outlook: signal history unavailable: %s", exc)
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _build_payload(
|
|
||||||
analyses: Dict[str, Any],
|
|
||||||
candles: Dict[str, Any],
|
|
||||||
news: Dict[str, Any],
|
|
||||||
recent_signals: List[Dict[str, Any]],
|
|
||||||
as_of: _dt.datetime,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Pure market-brief formatter. Every numeric field is finite or null."""
|
|
||||||
technical = _analysis_payload(analyses)
|
|
||||||
return _json_safe({
|
|
||||||
"as_of": as_of.astimezone(_dt.timezone.utc).isoformat(),
|
|
||||||
"candles": candles,
|
|
||||||
**technical,
|
|
||||||
"news": news,
|
|
||||||
"recent_signals": recent_signals,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_payload(settings: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""Fetch closed bars and turn them into a structured market brief."""
|
|
||||||
analyses: Dict[str, Any] = {}
|
|
||||||
candles: Dict[str, Any] = {tf.value: None for tf in _OUTLOOK_TFS}
|
|
||||||
bars = int(settings.get("candles_bars", DEFAULT_CANDLE_BARS))
|
|
||||||
provider = _outlook_provider()
|
|
||||||
engine = TechnicalEngine(PlatformConfig().technical)
|
|
||||||
|
|
||||||
for tf in _OUTLOOK_TFS:
|
|
||||||
try:
|
|
||||||
df = provider.fetch_ohlcv(XAUUSD, tf)
|
|
||||||
analysis = engine.analyze(df, XAUUSD, tf.value)
|
|
||||||
analyses[tf.value] = analysis
|
|
||||||
atr = _finite(getattr(
|
|
||||||
(getattr(analysis, "indicators", None) or {}).get("atr"),
|
|
||||||
"value", None))
|
|
||||||
if atr is not None and atr > 0 and df is not None and len(df) >= 2:
|
|
||||||
candles[tf.value] = _normalize_candles(df, atr, bars)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("market outlook: %s data unavailable: %s", tf.value, exc)
|
|
||||||
|
|
||||||
return _build_payload(
|
|
||||||
analyses=analyses,
|
|
||||||
candles=candles,
|
|
||||||
news=_news_payload(),
|
|
||||||
recent_signals=_recent_signals(),
|
|
||||||
as_of=_dt.datetime.now(_dt.timezone.utc),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Orchestration, hourly dedup, and request budget ──────────────────
|
|
||||||
|
|
||||||
_state_lock = threading.Lock()
|
|
||||||
_last_run_ts: Optional[float] = None
|
|
||||||
_last_run_hour: Optional[int] = None # UTC hour bucket of the last attempt
|
|
||||||
_outlook_day: Optional[_dt.date] = None
|
|
||||||
_outlook_count = 0
|
|
||||||
_warned_budget = False
|
|
||||||
|
|
||||||
|
|
||||||
def _skip(reason: str) -> None:
|
|
||||||
"""Heartbeat for every silent early return — a dark outlook feature
|
|
||||||
(zero rows for 15 days, 2026-08/09) must be visible in the log."""
|
|
||||||
logger.info("market outlook: skipped (%s)", reason)
|
|
||||||
|
|
||||||
|
|
||||||
def reset_outlook_state() -> None:
|
|
||||||
"""Drop hourly and daily state (tests / deployment config reloads)."""
|
|
||||||
global _last_run_ts, _last_run_hour, _outlook_day, _outlook_count
|
|
||||||
global _warned_budget
|
|
||||||
with _state_lock:
|
|
||||||
_last_run_ts = None
|
|
||||||
_last_run_hour = None
|
|
||||||
_outlook_day = None
|
|
||||||
_outlook_count = 0
|
|
||||||
_warned_budget = False
|
|
||||||
|
|
||||||
|
|
||||||
def _begin_attempt(cap: int, force: bool) -> bool:
|
|
||||||
"""Atomically apply hourly dedup, then reserve a daily attempt.
|
|
||||||
|
|
||||||
Hourly dedup is by UTC hour BUCKET, not ``now - last < 3600``: the
|
|
||||||
H1 loop calls this at boundary+10 s+scan time, so consecutive hours
|
|
||||||
were routinely 3,590 s apart and every other hour was dropped."""
|
|
||||||
global _last_run_ts, _last_run_hour, _outlook_day, _outlook_count
|
|
||||||
global _warned_budget
|
|
||||||
now = time.time()
|
|
||||||
hour_bucket = int(now // 3600)
|
|
||||||
today = _dt.datetime.now(_dt.timezone.utc).date()
|
|
||||||
with _state_lock:
|
|
||||||
if not force and _last_run_hour == hour_bucket:
|
|
||||||
_skip(f"hour bucket {hour_bucket} already attempted")
|
|
||||||
return False
|
|
||||||
if _outlook_day != today:
|
|
||||||
_outlook_day = today
|
|
||||||
_outlook_count = 0
|
|
||||||
_warned_budget = False
|
|
||||||
if _outlook_count >= cap:
|
|
||||||
if not _warned_budget:
|
|
||||||
_warned_budget = True
|
|
||||||
logger.warning(
|
|
||||||
"market outlook: daily budget (%d) exhausted — no more "
|
|
||||||
"outlooks until UTC midnight", cap)
|
|
||||||
else:
|
|
||||||
_skip("daily budget exhausted")
|
|
||||||
return False
|
|
||||||
_outlook_count += 1
|
|
||||||
_last_run_ts = now
|
|
||||||
_last_run_hour = hour_bucket
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def run_outlook(settings: Dict[str, Any],
|
|
||||||
surface: str = "live") -> Optional[OutlookResult]:
|
|
||||||
"""Build, ask, validate, and persist one advisory outlook."""
|
|
||||||
try:
|
|
||||||
payload = _collect_payload(settings)
|
|
||||||
reply = get_llm_client().chat_json(SYSTEM_PROMPT, payload)
|
|
||||||
result = _validate_outlook(reply)
|
|
||||||
if result is None:
|
|
||||||
logger.warning(
|
|
||||||
"market outlook: no outlook produced (%s)",
|
|
||||||
"LLM returned nothing — cap/timeout/disabled" if reply is None
|
|
||||||
else "reply failed validation")
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
from signal_log import log_outlook
|
|
||||||
log_outlook(result, surface=surface, payload=payload)
|
|
||||||
except Exception:
|
|
||||||
logger.debug("market outlook: persistence unavailable", exc_info=True)
|
|
||||||
return result
|
|
||||||
except Exception:
|
|
||||||
logger.warning("market outlook: run failed", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def maybe_run_outlook(surface: str, force: bool = False) -> Optional[OutlookResult]:
|
|
||||||
"""Best-effort public entry point. It NEVER raises to a scan caller.
|
|
||||||
|
|
||||||
``force`` is for explicit CLI use: it bypasses enabled, surface, and
|
|
||||||
hourly gates, but not the independent outlook budget or shared LLM cap.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
settings = get_app_config().market_outlook_settings
|
|
||||||
if not force:
|
|
||||||
if not market_outlook_enabled():
|
|
||||||
_skip("disabled")
|
|
||||||
return None
|
|
||||||
if surface == "telegram" and not settings.get("telegram", False):
|
|
||||||
_skip("telegram surface off")
|
|
||||||
return None
|
|
||||||
if _provider_base._is_forex_weekend():
|
|
||||||
_skip("weekend")
|
|
||||||
return None
|
|
||||||
cap = int(settings.get("max_per_day", DEFAULT_MAX_PER_DAY))
|
|
||||||
if cap < 1:
|
|
||||||
_skip("max_per_day < 1")
|
|
||||||
return None
|
|
||||||
if not _begin_attempt(cap, force):
|
|
||||||
return None
|
|
||||||
return run_outlook(settings, surface=surface)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("market outlook: unavailable", exc_info=True)
|
|
||||||
return None
|
|
||||||
+13
-99
@@ -11,7 +11,6 @@ A perfect technical setup can be destroyed by an unexpected NFP print.
|
|||||||
import hashlib
|
import hashlib
|
||||||
import html
|
import html
|
||||||
import logging
|
import logging
|
||||||
import math
|
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -80,25 +79,13 @@ Reply with ONLY JSON:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
_LLM_SUMMARY_SYSTEM = """\
|
_LLM_SUMMARY_SYSTEM = """\
|
||||||
You write a short news note about the gold price for someone who has
|
You are a forex/gold market-context writer. You receive JSON with recent
|
||||||
never traded. You receive JSON with recent "headlines" (newest first;
|
"headlines" and today's high-impact "events" (released: true means the
|
||||||
each carries "gold": "up" or "down", the platform's own reading of how
|
number is out). Write a 1-2 sentence plain-English summary of the
|
||||||
that story pushes the gold price — trust it) and today's high-impact
|
current macro backdrop for a GOLD (XAU/USD) trader: the dominant theme
|
||||||
"events" (released: true means the number is out).
|
and its likely pull on gold. No advice, no hedging boilerplate, max 45
|
||||||
Say three things, in this order: what is happening to gold right now,
|
words.
|
||||||
why it matters for the gold price, and the next scheduled report to
|
|
||||||
watch if one is listed. Use only the headlines and events given; if
|
|
||||||
they are thin, say so plainly.
|
|
||||||
Style: plain everyday English, short simple sentences, no acronyms, no
|
|
||||||
abbreviations, no trader words. Write "the US central bank", not "the
|
|
||||||
Fed"; "interest rates", not "yields" or "hawkish"; "a report on US
|
|
||||||
prices", not "CPI" or "PCE"; "gold", never "XAU". No advice, no hedging
|
|
||||||
boilerplate. 2-3 sentences, max 55 words.
|
|
||||||
Reply with ONLY JSON: {"summary": "..."}
|
Reply with ONLY JSON: {"summary": "..."}
|
||||||
|
|
||||||
Examples:
|
|
||||||
{"summary": "Gold is falling today. The US central bank hinted it may raise interest rates, which makes the dollar stronger, and a stronger dollar usually pushes gold down. The next thing to watch is Thursday's report on US prices."}
|
|
||||||
{"summary": "Gold is climbing. Fresh fighting in the Middle East has people moving money into gold as a safe place, and a weaker dollar is helping too. No major US reports are due today."}
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -228,48 +215,6 @@ class FundamentalContext:
|
|||||||
llm_summary: str = "" # DeepSeek market-context blurb (display-only)
|
llm_summary: str = "" # DeepSeek market-context blurb (display-only)
|
||||||
|
|
||||||
|
|
||||||
# Headlines whose every relevant score is inside ±NEUTRAL_HEADLINE_THRESHOLD
|
|
||||||
# lean neither way: they carry no direction for display and only dilute the
|
|
||||||
# per-currency mean. Same threshold agent_panel uses for its macro payload.
|
|
||||||
NEUTRAL_HEADLINE_THRESHOLD = 0.1
|
|
||||||
|
|
||||||
|
|
||||||
def _is_neutral_headline(h) -> bool:
|
|
||||||
"""True when no per-currency score on the headline leans either way.
|
|
||||||
|
|
||||||
A headline whose overall score is 0.0 but whose gold-specific score is
|
|
||||||
-0.7 is NOT neutral. Missing, empty or non-finite scores count as
|
|
||||||
neutral (they cannot be shown as leaning one way)."""
|
|
||||||
by_currency = getattr(h, "sentiment_by_currency", None) or {}
|
|
||||||
overall = getattr(h, "sentiment_score", None)
|
|
||||||
values = []
|
|
||||||
for currency in (getattr(h, "currencies_mentioned", None) or []):
|
|
||||||
value = by_currency.get(currency, overall)
|
|
||||||
if isinstance(value, (int, float)) and math.isfinite(value):
|
|
||||||
values.append(abs(float(value)))
|
|
||||||
return not values or max(values) < NEUTRAL_HEADLINE_THRESHOLD
|
|
||||||
|
|
||||||
|
|
||||||
def _gold_direction_label(h) -> str:
|
|
||||||
""""up" / "down" / "flat" for the market-summary payload — the same
|
|
||||||
XAU-first, USD-inverted reading agent_panel uses for its macro persona."""
|
|
||||||
by_currency = getattr(h, "sentiment_by_currency", None) or {}
|
|
||||||
xau = by_currency.get("XAU")
|
|
||||||
usd = by_currency.get("USD")
|
|
||||||
if isinstance(xau, (int, float)) and math.isfinite(xau):
|
|
||||||
score = float(xau)
|
|
||||||
elif isinstance(usd, (int, float)) and math.isfinite(usd):
|
|
||||||
score = -float(usd)
|
|
||||||
else:
|
|
||||||
raw = getattr(h, "sentiment_score", None)
|
|
||||||
score = float(raw) if isinstance(raw, (int, float)) and math.isfinite(raw) else 0.0
|
|
||||||
if score > 0:
|
|
||||||
return "up"
|
|
||||||
if score < 0:
|
|
||||||
return "down"
|
|
||||||
return "flat"
|
|
||||||
|
|
||||||
|
|
||||||
class NewsEngine:
|
class NewsEngine:
|
||||||
"""
|
"""
|
||||||
Economic calendar and news sentiment analyzer.
|
Economic calendar and news sentiment analyzer.
|
||||||
@@ -430,17 +375,6 @@ class NewsEngine:
|
|||||||
# 2. News sentiment
|
# 2. News sentiment
|
||||||
try:
|
try:
|
||||||
headlines = self._fetch_news_sentiment()
|
headlines = self._fetch_news_sentiment()
|
||||||
# Drop headlines that lean neither way (2026-09-04). Applied
|
|
||||||
# here rather than in the fetch so the class-level cache and
|
|
||||||
# the feed-health counts still describe what the feeds sent;
|
|
||||||
# every consumer (aggregate score, summary, dashboard, panel,
|
|
||||||
# outlook, signal log) reads context.news_headlines.
|
|
||||||
kept = [h for h in headlines if not _is_neutral_headline(h)]
|
|
||||||
if len(kept) != len(headlines):
|
|
||||||
logger.debug(
|
|
||||||
f"News: dropped {len(headlines) - len(kept)} neutral "
|
|
||||||
f"headlines, {len(kept)} kept")
|
|
||||||
headlines = kept
|
|
||||||
context.news_headlines = headlines
|
context.news_headlines = headlines
|
||||||
|
|
||||||
# Aggregate sentiment per currency (per-currency score where
|
# Aggregate sentiment per currency (per-currency score where
|
||||||
@@ -811,7 +745,6 @@ class NewsEngine:
|
|||||||
headlines = []
|
headlines = []
|
||||||
seen_titles = set()
|
seen_titles = set()
|
||||||
ok_feeds = 0
|
ok_feeds = 0
|
||||||
undated = 0
|
|
||||||
|
|
||||||
for feed_url in self.config.rss_feeds:
|
for feed_url in self.config.rss_feeds:
|
||||||
host = feed_url.split("/")[2] if "//" in feed_url else feed_url
|
host = feed_url.split("/")[2] if "//" in feed_url else feed_url
|
||||||
@@ -837,14 +770,8 @@ class NewsEngine:
|
|||||||
pub_date_el.text if pub_date_el is not None else None
|
pub_date_el.text if pub_date_el is not None else None
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only recent headlines (configurable: headline_max_age_seconds).
|
# Only recent headlines (configurable: headline_max_age_seconds)
|
||||||
# An item without a parsable pubDate cannot be proven
|
if pub_date and (datetime.now(timezone.utc) - pub_date).total_seconds() > self.config.headline_max_age_seconds:
|
||||||
# fresh, so it is dropped too — it used to be stamped
|
|
||||||
# "now" and never aged out (2026-09-04).
|
|
||||||
if pub_date is None:
|
|
||||||
undated += 1
|
|
||||||
continue
|
|
||||||
if (datetime.now(timezone.utc) - pub_date).total_seconds() > self.config.headline_max_age_seconds:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Dedup within and across feeds so a syndicated story
|
# Dedup within and across feeds so a syndicated story
|
||||||
@@ -900,8 +827,6 @@ class NewsEngine:
|
|||||||
f"rss:{host}", False, f"{type(e).__name__}: {e}")
|
f"rss:{host}", False, f"{type(e).__name__}: {e}")
|
||||||
logger.debug(f"Failed to fetch RSS from {feed_url}: {e}")
|
logger.debug(f"Failed to fetch RSS from {feed_url}: {e}")
|
||||||
|
|
||||||
if undated:
|
|
||||||
logger.debug(f"RSS: dropped {undated} headlines without a publish time")
|
|
||||||
self._record_source(
|
self._record_source(
|
||||||
"headlines", ok_feeds > 0,
|
"headlines", ok_feeds > 0,
|
||||||
detail="all feeds failed" if ok_feeds == 0 else "",
|
detail="all feeds failed" if ok_feeds == 0 else "",
|
||||||
@@ -1061,11 +986,9 @@ class NewsEngine:
|
|||||||
return text or None
|
return text or None
|
||||||
|
|
||||||
def _llm_market_summary(self, context: FundamentalContext) -> str:
|
def _llm_market_summary(self, context: FundamentalContext) -> str:
|
||||||
"""2-3 sentence plain-language news note for alerts; "" when
|
"""1-2 sentence macro blurb for alerts; "" when unavailable.
|
||||||
unavailable. Headlines go newest-first with the platform's own
|
Cached (success OR failure) for the context-cache TTL so at most
|
||||||
gold direction so the note agrees with the score badges. Cached
|
one call is made per window."""
|
||||||
(success OR failure) for the context-cache TTL so at most one
|
|
||||||
call is made per window."""
|
|
||||||
with NewsEngine._cache_lock:
|
with NewsEngine._cache_lock:
|
||||||
ts, cached = NewsEngine._llm_summary_cache
|
ts, cached = NewsEngine._llm_summary_cache
|
||||||
if time.time() - ts < self.config.context_cache_ttl_seconds:
|
if time.time() - ts < self.config.context_cache_ttl_seconds:
|
||||||
@@ -1076,17 +999,8 @@ class NewsEngine:
|
|||||||
client = get_llm_client()
|
client = get_llm_client()
|
||||||
if not client.available:
|
if not client.available:
|
||||||
return ""
|
return ""
|
||||||
oldest = datetime.min.replace(tzinfo=timezone.utc)
|
|
||||||
recent = sorted(
|
|
||||||
context.news_headlines,
|
|
||||||
key=lambda h: getattr(h, "timestamp", None) or oldest,
|
|
||||||
reverse=True,
|
|
||||||
)[:15]
|
|
||||||
result = client.chat_json(_LLM_SUMMARY_SYSTEM, {
|
result = client.chat_json(_LLM_SUMMARY_SYSTEM, {
|
||||||
"headlines": [
|
"headlines": [h.headline for h in context.news_headlines[:15]],
|
||||||
{"headline": h.headline, "gold": _gold_direction_label(h)}
|
|
||||||
for h in recent
|
|
||||||
],
|
|
||||||
"events": [
|
"events": [
|
||||||
{"event": e.event, "currency": e.currency,
|
{"event": e.event, "currency": e.currency,
|
||||||
"actual": e.actual, "forecast": e.forecast,
|
"actual": e.actual, "forecast": e.forecast,
|
||||||
@@ -1096,7 +1010,7 @@ class NewsEngine:
|
|||||||
})
|
})
|
||||||
summary = ""
|
summary = ""
|
||||||
if result:
|
if result:
|
||||||
summary = str(result.get("summary", "")).strip()[:400]
|
summary = str(result.get("summary", "")).strip()[:300]
|
||||||
with NewsEngine._cache_lock:
|
with NewsEngine._cache_lock:
|
||||||
NewsEngine._llm_summary_cache = (time.time(), summary)
|
NewsEngine._llm_summary_cache = (time.time(), summary)
|
||||||
return summary
|
return summary
|
||||||
|
|||||||
+854
@@ -0,0 +1,854 @@
|
|||||||
|
"""
|
||||||
|
ForexBot2 — Oanda Automated Trading Daemon
|
||||||
|
============================================
|
||||||
|
Autonomous trading process for XAU/USD on an Oanda practice account.
|
||||||
|
Runs candle-boundary scan loops (M5/M15/H1/H4), executes trades via
|
||||||
|
the Oanda v20 API, and manages trailing stops at bar close — matching
|
||||||
|
the backtested bar-close ratchet model.
|
||||||
|
|
||||||
|
Book-parity notes (matching backtest/gpu_backtest.py):
|
||||||
|
- No broker-side take-profit: in the trailing model TP1/TP2 are
|
||||||
|
advisory; the only exits are SL (broker), TRAIL (daemon ratchet),
|
||||||
|
and TIMEOUT (max_hold_bars). A hard TP would cap winners the trail
|
||||||
|
lets run and diverge the live book from the backtest.
|
||||||
|
- Entry cooldown is 5 bars per timeframe (the backtester's only
|
||||||
|
sequential rule) — not a fingerprint block.
|
||||||
|
- The trail ratchets at bar CLOSE only, and each closed bar is
|
||||||
|
processed exactly once (last_bar_ts guard), so weekends and feed
|
||||||
|
lag can't double-count bars or inflate the hold counter.
|
||||||
|
|
||||||
|
Divergence from the backtest book (accepted for demo safety): one
|
||||||
|
position total. While a trade is open, other signals are logged and
|
||||||
|
skipped, so skipped entries can be compared against the book later.
|
||||||
|
|
||||||
|
Usage: python oanda_daemon.py
|
||||||
|
Config: env vars OANDA_ACCOUNT_ID, OANDA_API_TOKEN, OANDA_BASE_URL
|
||||||
|
or config.json "oanda" section (env wins).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from app_config import (
|
||||||
|
get_app_config, oanda_account_id, oanda_api_token, oanda_base_url,
|
||||||
|
)
|
||||||
|
from config import PAIR_INFO, PlatformConfig, Timeframe, XAUUSD
|
||||||
|
from signal_aggregator import SignalAggregator, SignalReport, SignalStrength
|
||||||
|
from pair_signals import PAIR_PRODUCERS
|
||||||
|
from providers.oanda import OandaDataProvider
|
||||||
|
from oanda_executor import OandaExecutor, OandaAPIError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Constants ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SYMBOL = XAUUSD
|
||||||
|
INSTRUMENT = "XAU_USD"
|
||||||
|
|
||||||
|
SCAN_CONFIRMATIONS = {
|
||||||
|
Timeframe.M5: [Timeframe.M15, Timeframe.H1],
|
||||||
|
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
|
||||||
|
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
|
||||||
|
Timeframe.H4: [Timeframe.H1, Timeframe.D1],
|
||||||
|
}
|
||||||
|
|
||||||
|
TF_INTERVAL_MINUTES = {
|
||||||
|
Timeframe.M5: 5, Timeframe.M15: 15, Timeframe.M30: 30,
|
||||||
|
Timeframe.H1: 60, Timeframe.H4: 240, Timeframe.D1: 1440,
|
||||||
|
}
|
||||||
|
|
||||||
|
SCAN_TIMEFRAMES = [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]
|
||||||
|
|
||||||
|
QUALITY_RANK = {"A+": 4, "A": 3, "B": 2, "C": 1, "D": 0, "F": -1}
|
||||||
|
|
||||||
|
# Matches the backtester's --cooldown-bars default (the only sequential
|
||||||
|
# rule in the replay): no new entry within 5 bars of the previous entry
|
||||||
|
# on the same timeframe.
|
||||||
|
COOLDOWN_BARS = 5
|
||||||
|
|
||||||
|
# How often the heartbeat confirms the process is alive and the account
|
||||||
|
# is still reachable — independent of the candle-boundary loops, so a
|
||||||
|
# wedged scan or mid-run credential expiry still surfaces in the log.
|
||||||
|
HEARTBEAT_SECONDS = 3600
|
||||||
|
|
||||||
|
STATE_FILE = os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)), "oanda_state.json")
|
||||||
|
|
||||||
|
# ── Config resolution (env → config.json → default) ──────────────────
|
||||||
|
|
||||||
|
def _oanda_setting_int(env_key: str, json_key: str, default: int) -> int:
|
||||||
|
env = os.environ.get(env_key)
|
||||||
|
if env:
|
||||||
|
try:
|
||||||
|
return int(env)
|
||||||
|
except ValueError:
|
||||||
|
raise SystemExit(
|
||||||
|
f"{env_key}={env!r} is not an integer — refusing to start")
|
||||||
|
try:
|
||||||
|
return int(get_app_config().oanda_settings.get(json_key, default))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise SystemExit(
|
||||||
|
f"config.json oanda.{json_key} is not an integer — "
|
||||||
|
f"refusing to start")
|
||||||
|
|
||||||
|
|
||||||
|
def _oanda_setting_str(env_key: str, json_key: str, default: str) -> str:
|
||||||
|
return (os.environ.get(env_key)
|
||||||
|
or str(get_app_config().oanda_settings.get(json_key, default)))
|
||||||
|
|
||||||
|
# ── Logging setup ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _setup_logging(level: str = "INFO") -> None:
|
||||||
|
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
log_file = os.path.join(log_dir, "daemon.log")
|
||||||
|
|
||||||
|
fmt = logging.Formatter(
|
||||||
|
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
file_handler = TimedRotatingFileHandler(
|
||||||
|
log_file, when="h", interval=3, backupCount=240, utc=True)
|
||||||
|
file_handler.setFormatter(fmt)
|
||||||
|
|
||||||
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
console_handler.setFormatter(fmt)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, level.upper(), logging.INFO),
|
||||||
|
handlers=[file_handler, console_handler],
|
||||||
|
force=True)
|
||||||
|
|
||||||
|
# ── Boundary timing (same as telegram_bot.py) ─────────────────────────
|
||||||
|
|
||||||
|
def _seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
total_minutes = now.hour * 60 + now.minute
|
||||||
|
elapsed_minutes = total_minutes % interval_minutes
|
||||||
|
elapsed_seconds = elapsed_minutes * 60 + now.second + now.microsecond / 1e6
|
||||||
|
interval_seconds = interval_minutes * 60
|
||||||
|
remaining = interval_seconds - elapsed_seconds + buffer
|
||||||
|
# NOTE: remaining > buffer always (elapsed < interval by construction);
|
||||||
|
# a call at the exact boundary instant targets the NEXT boundary —
|
||||||
|
# steady-state loops wake at boundary+buffer, so only a process
|
||||||
|
# started in the boundary microsecond skips one scan (2026-08-04
|
||||||
|
# audit I42; the old `remaining <= buffer` guard was unreachable).
|
||||||
|
return remaining
|
||||||
|
|
||||||
|
# ── Trade state persistence ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def _load_state() -> Dict:
|
||||||
|
if os.path.exists(STATE_FILE):
|
||||||
|
try:
|
||||||
|
with open(STATE_FILE, encoding="utf-8") as fh:
|
||||||
|
return json.load(fh)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
logger.warning("Could not load state file, starting fresh")
|
||||||
|
return {"trades": {}, "last_entries": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_state(state: Dict) -> None:
|
||||||
|
try:
|
||||||
|
tmp = STATE_FILE + ".tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(state, fh, indent=2)
|
||||||
|
os.replace(tmp, STATE_FILE)
|
||||||
|
except OSError:
|
||||||
|
logger.exception("Failed to save state file")
|
||||||
|
|
||||||
|
# ── Trading Daemon ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TradingDaemon:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
account_id = oanda_account_id()
|
||||||
|
api_token = oanda_api_token()
|
||||||
|
base_url = oanda_base_url()
|
||||||
|
lot_size = _oanda_setting_int("OANDA_LOT_SIZE", "lot_size", 1)
|
||||||
|
self._quality_gate = _oanda_setting_str(
|
||||||
|
"OANDA_QUALITY_GATE", "quality_gate", "C").strip()
|
||||||
|
if self._quality_gate not in QUALITY_RANK:
|
||||||
|
# Fail CLOSED: an unrecognized gate must never silently fall
|
||||||
|
# back to the most permissive rank (QUALITY_RANK.get default).
|
||||||
|
raise SystemExit(
|
||||||
|
f"OANDA_QUALITY_GATE/quality_gate {self._quality_gate!r} "
|
||||||
|
f"is not one of {sorted(QUALITY_RANK)} — refusing to start")
|
||||||
|
self._max_hold_bars = _oanda_setting_int(
|
||||||
|
"OANDA_MAX_HOLD_BARS", "max_hold_bars", 300)
|
||||||
|
self._price_precision = PAIR_INFO[SYMBOL]["price_precision"]
|
||||||
|
|
||||||
|
if not account_id or not api_token:
|
||||||
|
msg = (
|
||||||
|
"Missing Oanda credentials. Set OANDA_ACCOUNT_ID and "
|
||||||
|
"OANDA_API_TOKEN env vars or config.json oanda section.")
|
||||||
|
logger.critical(msg)
|
||||||
|
raise SystemExit(msg)
|
||||||
|
|
||||||
|
self._provider = OandaDataProvider(
|
||||||
|
PlatformConfig(), api_token, base_url)
|
||||||
|
self._executor = OandaExecutor(
|
||||||
|
account_id, api_token, base_url, lot_size,
|
||||||
|
price_precision=self._price_precision)
|
||||||
|
self._thread_pool = ThreadPoolExecutor(max_workers=4)
|
||||||
|
|
||||||
|
self._state = _load_state()
|
||||||
|
self._state.setdefault("trades", {})
|
||||||
|
self._state.setdefault("last_entries", {})
|
||||||
|
self._state_lock = threading.Lock()
|
||||||
|
# Serializes the position-check + order-placement critical section
|
||||||
|
# across concurrent TF loops (all four fire at aligned boundaries).
|
||||||
|
self._entry_lock = threading.Lock()
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
# ── Main entry point ───────────────────────────────────────────
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
loop.set_exception_handler(self._asyncio_exception_handler)
|
||||||
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
loop.add_signal_handler(sig, self._request_shutdown)
|
||||||
|
|
||||||
|
logger.info("=== Oanda Trading Daemon starting ===")
|
||||||
|
logger.info(
|
||||||
|
"Quality gate: %s | Max hold: %d bars | Cooldown: %d bars | "
|
||||||
|
"Timeframes: %s",
|
||||||
|
self._quality_gate, self._max_hold_bars, COOLDOWN_BARS,
|
||||||
|
"/".join(tf.name for tf in SCAN_TIMEFRAMES))
|
||||||
|
|
||||||
|
# Credentials first: with a bad token every later call fails, so
|
||||||
|
# refuse to start before touching positions or spawning loops.
|
||||||
|
self._verify_account_access()
|
||||||
|
|
||||||
|
self._discover_existing_positions()
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
for tf in SCAN_TIMEFRAMES:
|
||||||
|
interval = TF_INTERVAL_MINUTES[tf]
|
||||||
|
tasks.append(asyncio.create_task(
|
||||||
|
self._candle_loop(tf, interval)))
|
||||||
|
tasks.append(asyncio.create_task(self._heartbeat_loop()))
|
||||||
|
|
||||||
|
logger.info("Candle-boundary loops started")
|
||||||
|
try:
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._shutdown()
|
||||||
|
|
||||||
|
def _verify_account_access(self) -> None:
|
||||||
|
"""Boot-time credential check. A 4xx here means the token/account
|
||||||
|
id is wrong — that never fixes itself, so refuse to start rather
|
||||||
|
than run a daemon that can't place or manage trades. A connection
|
||||||
|
failure or 5xx is a transient outage: log it and continue, since
|
||||||
|
broker-side stops already protect any open position and the
|
||||||
|
hourly heartbeat will keep reporting account health."""
|
||||||
|
try:
|
||||||
|
self._executor.get_account_summary()
|
||||||
|
except OandaAPIError as e:
|
||||||
|
if 400 <= e.status_code < 500:
|
||||||
|
logger.critical(
|
||||||
|
"Oanda rejected the account credentials (HTTP %d): "
|
||||||
|
"%s — refusing to start", e.status_code, e)
|
||||||
|
raise SystemExit(
|
||||||
|
f"Oanda rejected the account credentials "
|
||||||
|
f"(HTTP {e.status_code}): {e}")
|
||||||
|
logger.error(
|
||||||
|
"Failed to fetch account summary at boot (HTTP %d): %s",
|
||||||
|
e.status_code, e)
|
||||||
|
|
||||||
|
def _asyncio_exception_handler(self, loop, context) -> None:
|
||||||
|
exc = context.get("exception")
|
||||||
|
if exc is not None:
|
||||||
|
logger.critical(
|
||||||
|
"Uncaught asyncio exception: %s",
|
||||||
|
context.get("message", ""), exc_info=exc)
|
||||||
|
else:
|
||||||
|
logger.critical(
|
||||||
|
"Uncaught asyncio error: %s", context.get("message", ""))
|
||||||
|
|
||||||
|
def _request_shutdown(self) -> None:
|
||||||
|
logger.info("Shutdown requested")
|
||||||
|
self._running = False
|
||||||
|
for task in asyncio.all_tasks():
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
def _shutdown(self) -> None:
|
||||||
|
with self._state_lock:
|
||||||
|
_save_state(self._state)
|
||||||
|
self._thread_pool.shutdown(wait=False)
|
||||||
|
logger.info("=== Oanda Trading Daemon stopped ===")
|
||||||
|
|
||||||
|
# ── Position discovery (restart recovery) ──────────────────────
|
||||||
|
|
||||||
|
def _discover_existing_positions(self) -> None:
|
||||||
|
self._reconcile_positions("boot discovery")
|
||||||
|
|
||||||
|
def _reconcile_positions(self, reason: str) -> None:
|
||||||
|
"""Re-sync tracked state with the broker's open trades. Safe to
|
||||||
|
call mid-run (lock-guarded); used at boot and whenever a fill or
|
||||||
|
placement error suggests state and broker may have diverged."""
|
||||||
|
try:
|
||||||
|
open_trades = self._executor.get_open_trades(INSTRUMENT)
|
||||||
|
except OandaAPIError:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to query open trades (%s) — reconcile skipped",
|
||||||
|
reason)
|
||||||
|
return
|
||||||
|
|
||||||
|
live_ids = {t["id"] for t in open_trades}
|
||||||
|
|
||||||
|
with self._state_lock:
|
||||||
|
saved = self._state.get("trades", {})
|
||||||
|
|
||||||
|
stale = [tid for tid in saved if tid not in live_ids]
|
||||||
|
for tid in stale:
|
||||||
|
logger.info(
|
||||||
|
"Stale trade %s in state file (closed on Oanda), "
|
||||||
|
"removing", tid)
|
||||||
|
del saved[tid]
|
||||||
|
|
||||||
|
for trade in open_trades:
|
||||||
|
tid = trade["id"]
|
||||||
|
if tid in saved:
|
||||||
|
logger.info(
|
||||||
|
"Recovered trade %s from state: %s %s @ %.2f, SL %.2f",
|
||||||
|
tid, saved[tid].get("direction"), INSTRUMENT,
|
||||||
|
saved[tid].get("entry"), saved[tid].get("current_sl"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
direction = ("BUY" if float(trade.get("currentUnits", 0)) > 0
|
||||||
|
else "SELL")
|
||||||
|
entry = float(trade.get("price", 0))
|
||||||
|
sl_order = trade.get("stopLossOrder") or {}
|
||||||
|
sl_price = sl_order.get("price")
|
||||||
|
if sl_price is None:
|
||||||
|
# No broker-side stop at all: risk is undefined, so the
|
||||||
|
# trailing model cannot manage this trade. Scream — a
|
||||||
|
# naked position needs a human (or a policy) to set a
|
||||||
|
# stop; fabricating one here would invent trading
|
||||||
|
# behavior the books never validated.
|
||||||
|
logger.critical(
|
||||||
|
"Adopted trade %s (%s @ %.2f) has NO STOP LOSS on "
|
||||||
|
"the broker — it will NOT be trailed. Set a stop "
|
||||||
|
"manually or close it.", tid, direction, entry)
|
||||||
|
sl = entry
|
||||||
|
risk = 0.0
|
||||||
|
else:
|
||||||
|
sl = float(sl_price)
|
||||||
|
risk = abs(entry - sl)
|
||||||
|
|
||||||
|
saved[tid] = {
|
||||||
|
"tf": "H1",
|
||||||
|
"direction": direction,
|
||||||
|
"entry": entry,
|
||||||
|
"sl_initial": sl,
|
||||||
|
"current_sl": sl,
|
||||||
|
"risk": risk,
|
||||||
|
"peak": entry,
|
||||||
|
"trail_armed": False,
|
||||||
|
"trail_arm_r": 0.6,
|
||||||
|
"trail_dist_r": 0.75,
|
||||||
|
"bars_held": 0,
|
||||||
|
"last_bar_ts": None,
|
||||||
|
"signal_bar_ts": None,
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
"Adopted unknown trade %s: %s @ %.2f, SL %.2f, risk %.2f "
|
||||||
|
"(conservative defaults)",
|
||||||
|
tid, direction, entry, sl, risk)
|
||||||
|
|
||||||
|
self._state["trades"] = saved
|
||||||
|
_save_state(self._state)
|
||||||
|
logger.info(
|
||||||
|
"Position reconcile (%s): %d active trade(s)", reason, len(saved))
|
||||||
|
|
||||||
|
# ── Candle-boundary loop ───────────────────────────────────────
|
||||||
|
|
||||||
|
async def _candle_loop(self, tf: Timeframe, interval_minutes: int) -> None:
|
||||||
|
label = tf.value
|
||||||
|
try:
|
||||||
|
while self._running:
|
||||||
|
wait = _seconds_until_boundary(interval_minutes, buffer=10)
|
||||||
|
logger.info("%s loop: next scan in %.0fs", label, wait)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
logger.info("%s loop: firing scan", label)
|
||||||
|
try:
|
||||||
|
await self._scan_and_manage(tf)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("%s loop: cycle failed", label)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
logger.info("%s loop stopped", label)
|
||||||
|
|
||||||
|
async def _scan_and_manage(self, tf: Timeframe) -> None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
# Trade management FIRST: the SL ratchet must not wait on (or be
|
||||||
|
# starved by) the signal scan — news fetches can take seconds and
|
||||||
|
# a failed scan must never cost an open trade its trail update.
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
self._thread_pool, self._manage_open_trades, tf)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("%s: trade management failed", tf.value)
|
||||||
|
|
||||||
|
report = await loop.run_in_executor(
|
||||||
|
self._thread_pool, self._run_signal, tf)
|
||||||
|
|
||||||
|
if not self._should_execute(report, tf):
|
||||||
|
return
|
||||||
|
|
||||||
|
await loop.run_in_executor(
|
||||||
|
self._thread_pool, self._execute_trade, report, tf)
|
||||||
|
|
||||||
|
def _manage_open_trades(self, tf: Timeframe) -> None:
|
||||||
|
self._check_closed_trades()
|
||||||
|
self._manage_trailing(tf)
|
||||||
|
|
||||||
|
# ── Heartbeat ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _heartbeat_once(self) -> None:
|
||||||
|
with self._state_lock:
|
||||||
|
n = len(self._state.get("trades", {}))
|
||||||
|
try:
|
||||||
|
self._executor.get_account_summary()
|
||||||
|
except OandaAPIError as e:
|
||||||
|
logger.error("HEARTBEAT: account summary failed: %s", e)
|
||||||
|
# Logged unconditionally — even on a failed summary call the
|
||||||
|
# heartbeat line itself must reach the log, so a wedged process
|
||||||
|
# doesn't look identical to a healthy but silent one.
|
||||||
|
logger.info("HEARTBEAT: alive | tracked trades: %d", n)
|
||||||
|
|
||||||
|
async def _heartbeat_loop(self) -> None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
while self._running:
|
||||||
|
await asyncio.sleep(HEARTBEAT_SECONDS)
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
self._thread_pool, self._heartbeat_once)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("heartbeat loop: cycle failed")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
logger.info("heartbeat loop stopped")
|
||||||
|
|
||||||
|
# ── Signal pipeline ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _run_signal(self, tf: Timeframe) -> SignalReport:
|
||||||
|
conf_tfs = SCAN_CONFIRMATIONS.get(
|
||||||
|
tf, [Timeframe.M15, Timeframe.H4])
|
||||||
|
factory = PAIR_PRODUCERS.get(SYMBOL)
|
||||||
|
if factory is not None:
|
||||||
|
config = factory(tf, conf_tfs)
|
||||||
|
else:
|
||||||
|
config = PlatformConfig()
|
||||||
|
config.primary_timeframe = tf
|
||||||
|
config.confirmation_timeframes = conf_tfs
|
||||||
|
|
||||||
|
aggregator = SignalAggregator(config)
|
||||||
|
aggregator.data_provider = self._provider
|
||||||
|
report = aggregator.analyze_pair(symbol=SYMBOL)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"%s scan: signal=%s score=%.1f conf=%.0f quality=%s "
|
||||||
|
"regime=%s vol=%s price=%.2f spread=%.4f",
|
||||||
|
tf.value,
|
||||||
|
report.final_signal.name, report.final_score,
|
||||||
|
report.final_confidence, report.signal_quality,
|
||||||
|
report.market_regime, report.volatility_regime,
|
||||||
|
report.current_price,
|
||||||
|
self._provider.get_spread(SYMBOL))
|
||||||
|
|
||||||
|
# Stale-data/suppression warnings must be visible at WARNING;
|
||||||
|
# routine session-filter notices go to DEBUG — the M5 loop fires
|
||||||
|
# 288x/day and would drown the log at WARNING otherwise.
|
||||||
|
for w in report.warnings:
|
||||||
|
if "STALE" in w.upper() or "FAIL" in w.upper():
|
||||||
|
logger.warning("%s scan: %s", tf.value, w)
|
||||||
|
else:
|
||||||
|
logger.debug("%s scan: %s", tf.value, w)
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
# ── Execution decision ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def _should_execute(self, report: SignalReport, tf: Timeframe) -> bool:
|
||||||
|
if report.final_signal == SignalStrength.NEUTRAL:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if report.trade_setup is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
quality_rank = QUALITY_RANK.get(report.signal_quality, -1)
|
||||||
|
gate_rank = QUALITY_RANK.get(self._quality_gate, 1)
|
||||||
|
if quality_rank < gate_rank:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._in_cooldown(tf):
|
||||||
|
logger.info(
|
||||||
|
"%s: actionable signal (%s %s) but inside %d-bar entry "
|
||||||
|
"cooldown, skipping",
|
||||||
|
tf.value, report.trade_setup.direction,
|
||||||
|
report.signal_quality, COOLDOWN_BARS)
|
||||||
|
return False
|
||||||
|
|
||||||
|
with self._state_lock:
|
||||||
|
if self._state.get("trades"):
|
||||||
|
logger.info(
|
||||||
|
"%s: actionable signal (%s %s) but position already "
|
||||||
|
"open, skipping",
|
||||||
|
tf.value, report.trade_setup.direction,
|
||||||
|
report.signal_quality)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _in_cooldown(self, tf: Timeframe) -> bool:
|
||||||
|
"""Backtest parity: no entry within COOLDOWN_BARS bars of the
|
||||||
|
previous entry on the same timeframe (wall-clock approximation)."""
|
||||||
|
with self._state_lock:
|
||||||
|
last = self._state.get("last_entries", {}).get(tf.value)
|
||||||
|
if not last:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
last_ts = datetime.fromisoformat(last)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
interval_s = TF_INTERVAL_MINUTES[tf] * 60
|
||||||
|
elapsed = (datetime.now(timezone.utc) - last_ts).total_seconds()
|
||||||
|
return elapsed < COOLDOWN_BARS * interval_s
|
||||||
|
|
||||||
|
# ── Trade execution ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _execute_trade(self, report: SignalReport, tf: Timeframe) -> None:
|
||||||
|
ts = report.trade_setup
|
||||||
|
if ts is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# All four TF loops can fire at an aligned boundary (e.g. :00) and
|
||||||
|
# pass _should_execute before any of them has recorded a fill —
|
||||||
|
# re-check under the entry lock so only one order goes out.
|
||||||
|
with self._entry_lock:
|
||||||
|
with self._state_lock:
|
||||||
|
if self._state.get("trades"):
|
||||||
|
logger.info(
|
||||||
|
"%s: position opened by a concurrent scan, skipping",
|
||||||
|
tf.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
# NO take-profit on the order: the backtested trailing model
|
||||||
|
# exits via SL/TRAIL/TIMEOUT only — a hard TP would cap
|
||||||
|
# winners and diverge the live book from the backtest.
|
||||||
|
try:
|
||||||
|
result = self._executor.place_market_order(
|
||||||
|
instrument=INSTRUMENT,
|
||||||
|
direction=ts.direction,
|
||||||
|
stop_loss=ts.stop_loss,
|
||||||
|
)
|
||||||
|
except OandaAPIError:
|
||||||
|
# The order is never retried (a timeout after a server-side
|
||||||
|
# fill would double-up on retry), but the server MAY have
|
||||||
|
# filled before the error reached us — reconcile so a live
|
||||||
|
# position can't stay untracked until the next restart.
|
||||||
|
logger.exception(
|
||||||
|
"Failed to place %s order for %s %s — reconciling "
|
||||||
|
"broker positions",
|
||||||
|
ts.direction, SYMBOL, tf.value)
|
||||||
|
self._reconcile_positions("order placement error")
|
||||||
|
return
|
||||||
|
|
||||||
|
fill_tx = result.get("orderFillTransaction")
|
||||||
|
if not fill_tx:
|
||||||
|
logger.warning(
|
||||||
|
"Order not filled for %s %s", SYMBOL, tf.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
trade_opened = fill_tx.get("tradeOpened", {})
|
||||||
|
trade_id = trade_opened.get("tradeID")
|
||||||
|
if not trade_id:
|
||||||
|
# A fill with no tradeOpened means the order interacted
|
||||||
|
# with an existing broker position (tradesClosed /
|
||||||
|
# tradeReduced on a netting account) — state and broker
|
||||||
|
# have diverged. Surface it and re-sync instead of
|
||||||
|
# silently forgetting a real fill.
|
||||||
|
logger.critical(
|
||||||
|
"Order FILLED but opened no trade (netting close/"
|
||||||
|
"reduce?) — fill: closed=%s reduced=%s. Reconciling.",
|
||||||
|
fill_tx.get("tradesClosed"), fill_tx.get("tradeReduced"))
|
||||||
|
self._reconcile_positions("fill without tradeOpened")
|
||||||
|
return
|
||||||
|
|
||||||
|
fill_price = float(fill_tx.get("price", ts.entry_price))
|
||||||
|
risk = abs(fill_price - ts.stop_loss)
|
||||||
|
|
||||||
|
trail_arm = ts.trail_arm_r if ts.trail_arm_r is not None else 0.6
|
||||||
|
trail_dist = ts.trail_dist_r if ts.trail_dist_r is not None else 0.75
|
||||||
|
|
||||||
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
|
trade_state = {
|
||||||
|
"tf": tf.value,
|
||||||
|
"direction": ts.direction,
|
||||||
|
"entry": fill_price,
|
||||||
|
"sl_initial": ts.stop_loss,
|
||||||
|
"current_sl": ts.stop_loss,
|
||||||
|
"tp1": ts.take_profit_1,
|
||||||
|
"tp2": ts.take_profit_2,
|
||||||
|
"risk": risk,
|
||||||
|
"peak": fill_price,
|
||||||
|
"trail_armed": False,
|
||||||
|
"trail_arm_r": trail_arm,
|
||||||
|
"trail_dist_r": trail_dist,
|
||||||
|
"bars_held": 0,
|
||||||
|
# The signal bar is already closed; the first bar this
|
||||||
|
# trade lives through is the one that just started. Guard
|
||||||
|
# so a lagging feed can't trail on the signal bar itself.
|
||||||
|
"signal_bar_ts": report.timestamp,
|
||||||
|
"last_bar_ts": None,
|
||||||
|
"strategy": ts.strategy,
|
||||||
|
"quality": report.signal_quality,
|
||||||
|
"rr": ts.risk_reward,
|
||||||
|
"opened_at": now_iso,
|
||||||
|
}
|
||||||
|
|
||||||
|
with self._state_lock:
|
||||||
|
self._state["trades"][trade_id] = trade_state
|
||||||
|
self._state["last_entries"][tf.value] = now_iso
|
||||||
|
_save_state(self._state)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"TRADE OPENED: %s %s @ %.2f | SL %.2f | TP1 %.2f (advisory) | "
|
||||||
|
"risk $%.2f | RR %.2f | quality %s | strategy %s | TF %s | "
|
||||||
|
"trail arm %.2fR dist %.2fR",
|
||||||
|
ts.direction, SYMBOL, fill_price, ts.stop_loss,
|
||||||
|
ts.take_profit_1,
|
||||||
|
risk, ts.risk_reward, report.signal_quality,
|
||||||
|
ts.strategy, tf.value, trail_arm, trail_dist)
|
||||||
|
|
||||||
|
# ── Trailing stop management ───────────────────────────────────
|
||||||
|
|
||||||
|
def _manage_trailing(self, tf: Timeframe) -> None:
|
||||||
|
# Copy the per-trade dicts too: the loop mutates info (peak,
|
||||||
|
# bars_held, last_bar_ts) and must not touch live state outside
|
||||||
|
# the lock — another thread may be serializing it in _save_state.
|
||||||
|
with self._state_lock:
|
||||||
|
trades = {tid: dict(info)
|
||||||
|
for tid, info in self._state.get("trades", {}).items()}
|
||||||
|
|
||||||
|
for trade_id, info in trades.items():
|
||||||
|
if info.get("tf") != tf.value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = self._provider.fetch_ohlcv(SYMBOL, tf)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to fetch candle data for trail update")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
logger.warning(
|
||||||
|
"%s: no candle data for trail update of trade %s",
|
||||||
|
tf.value, trade_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
bar_ts = df.index[-1].isoformat()
|
||||||
|
|
||||||
|
# Process each closed bar exactly once. Weekends and feed lag
|
||||||
|
# re-serve the same last bar; without this guard bars_held
|
||||||
|
# inflates (an M5 trade would "time out" over a weekend) and
|
||||||
|
# the peak gets double-counted.
|
||||||
|
if bar_ts == info.get("last_bar_ts"):
|
||||||
|
continue
|
||||||
|
sig_ts = info.get("signal_bar_ts")
|
||||||
|
if sig_ts:
|
||||||
|
try:
|
||||||
|
if df.index[-1] <= pd.Timestamp(sig_ts):
|
||||||
|
continue
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
info["last_bar_ts"] = bar_ts
|
||||||
|
|
||||||
|
info["bars_held"] = info.get("bars_held", 0) + 1
|
||||||
|
|
||||||
|
if info["bars_held"] >= self._max_hold_bars:
|
||||||
|
logger.info(
|
||||||
|
"TIMEOUT: trade %s held %d bars, closing",
|
||||||
|
trade_id, info["bars_held"])
|
||||||
|
try:
|
||||||
|
self._executor.close_trade(trade_id)
|
||||||
|
except OandaAPIError:
|
||||||
|
# Keep tracking — the trade is still live on the
|
||||||
|
# broker (incl. a 200 whose close order was cancelled,
|
||||||
|
# e.g. MARKET_HALTED — close_trade raises on no-fill);
|
||||||
|
# we retry the close at the next bar.
|
||||||
|
logger.exception(
|
||||||
|
"Failed to close timed-out trade %s — will retry",
|
||||||
|
trade_id)
|
||||||
|
with self._state_lock:
|
||||||
|
if trade_id in self._state["trades"]:
|
||||||
|
self._state["trades"][trade_id] = info
|
||||||
|
_save_state(self._state)
|
||||||
|
continue
|
||||||
|
with self._state_lock:
|
||||||
|
self._state["trades"].pop(trade_id, None)
|
||||||
|
_save_state(self._state)
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_bar = df.iloc[-1]
|
||||||
|
bar_high = float(last_bar["High"])
|
||||||
|
bar_low = float(last_bar["Low"])
|
||||||
|
|
||||||
|
if info.get("risk", 0) <= 0:
|
||||||
|
# Adopted naked position (no broker SL → risk undefined):
|
||||||
|
# never manageable by the trailing model — keep it loud
|
||||||
|
# instead of silently skipping forever.
|
||||||
|
logger.warning(
|
||||||
|
"Trade %s has risk<=0 (no stop loss) — trailing "
|
||||||
|
"skipped; set a stop manually", trade_id)
|
||||||
|
|
||||||
|
new_sl = self._compute_trail(info, bar_high, bar_low)
|
||||||
|
|
||||||
|
if new_sl is not None:
|
||||||
|
# Round to instrument precision and track exactly what the
|
||||||
|
# broker holds, so tracked vs broker SL can never drift.
|
||||||
|
new_sl = round(new_sl, self._price_precision)
|
||||||
|
if new_sl != info["current_sl"]:
|
||||||
|
try:
|
||||||
|
self._executor.modify_trade_sl(trade_id, new_sl)
|
||||||
|
info["current_sl"] = new_sl
|
||||||
|
except OandaAPIError:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to update trailing SL for trade %s",
|
||||||
|
trade_id)
|
||||||
|
|
||||||
|
with self._state_lock:
|
||||||
|
# Write back ONLY if the trade is still tracked — another
|
||||||
|
# loop's _check_closed_trades may have popped it while we
|
||||||
|
# iterated (broker-side close). Re-inserting would create
|
||||||
|
# a ghost that blocks new entries.
|
||||||
|
if trade_id in self._state["trades"]:
|
||||||
|
self._state["trades"][trade_id] = info
|
||||||
|
_save_state(self._state)
|
||||||
|
|
||||||
|
def _compute_trail(
|
||||||
|
self, info: Dict, bar_high: float, bar_low: float
|
||||||
|
) -> Optional[float]:
|
||||||
|
buy = info["direction"] == "BUY"
|
||||||
|
risk = info["risk"]
|
||||||
|
if risk <= 0:
|
||||||
|
return None
|
||||||
|
arm = info.get("trail_arm_r", 0.6)
|
||||||
|
dist = info.get("trail_dist_r", 0.75)
|
||||||
|
if arm <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
fav = bar_high if buy else bar_low
|
||||||
|
if buy:
|
||||||
|
info["peak"] = max(info.get("peak", info["entry"]), fav)
|
||||||
|
else:
|
||||||
|
info["peak"] = min(info.get("peak", info["entry"]), fav)
|
||||||
|
|
||||||
|
exc = ((info["peak"] - info["entry"]) if buy
|
||||||
|
else (info["entry"] - info["peak"])) / risk
|
||||||
|
|
||||||
|
if exc >= arm:
|
||||||
|
if buy:
|
||||||
|
lvl = info["peak"] - dist * risk
|
||||||
|
cur_sl = info.get("current_sl", info["sl_initial"])
|
||||||
|
if lvl > cur_sl:
|
||||||
|
info["trail_armed"] = True
|
||||||
|
return lvl
|
||||||
|
else:
|
||||||
|
lvl = info["peak"] + dist * risk
|
||||||
|
cur_sl = info.get("current_sl", info["sl_initial"])
|
||||||
|
if lvl < cur_sl:
|
||||||
|
info["trail_armed"] = True
|
||||||
|
return lvl
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── Closed-trade detection ─────────────────────────────────────
|
||||||
|
|
||||||
|
def _check_closed_trades(self) -> None:
|
||||||
|
with self._state_lock:
|
||||||
|
tracked = set(self._state.get("trades", {}).keys())
|
||||||
|
|
||||||
|
if not tracked:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
open_trades = self._executor.get_open_trades(INSTRUMENT)
|
||||||
|
except OandaAPIError:
|
||||||
|
logger.exception("Failed to query open trades")
|
||||||
|
return
|
||||||
|
|
||||||
|
live_ids = {t["id"] for t in open_trades}
|
||||||
|
closed = tracked - live_ids
|
||||||
|
|
||||||
|
for tid in closed:
|
||||||
|
with self._state_lock:
|
||||||
|
info = self._state["trades"].pop(tid, {})
|
||||||
|
entry = info.get("entry", 0)
|
||||||
|
direction = info.get("direction", "?")
|
||||||
|
risk = info.get("risk", 1)
|
||||||
|
sl = info.get("current_sl", 0)
|
||||||
|
was_trailed = info.get("trail_armed", False)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"TRADE CLOSED BY BROKER: %s %s %s | entry %.2f | "
|
||||||
|
"SL was %.2f | risk $%.2f | trailed=%s | bars_held=%d",
|
||||||
|
tid, direction, SYMBOL, entry, sl, risk,
|
||||||
|
was_trailed, info.get("bars_held", 0))
|
||||||
|
|
||||||
|
if closed:
|
||||||
|
with self._state_lock:
|
||||||
|
_save_state(self._state)
|
||||||
|
|
||||||
|
# ── Uncaught exception handlers ───────────────────────────────────────
|
||||||
|
|
||||||
|
def _excepthook(exc_type, exc_value, exc_tb):
|
||||||
|
if issubclass(exc_type, KeyboardInterrupt):
|
||||||
|
sys.__excepthook__(exc_type, exc_value, exc_tb)
|
||||||
|
return
|
||||||
|
logger.critical(
|
||||||
|
"Uncaught exception", exc_info=(exc_type, exc_value, exc_tb))
|
||||||
|
|
||||||
|
def _threading_excepthook(args):
|
||||||
|
logger.critical(
|
||||||
|
"Uncaught exception in thread %s",
|
||||||
|
args.thread.name if args.thread else "?",
|
||||||
|
exc_info=(args.exc_type, args.exc_value, args.exc_traceback))
|
||||||
|
|
||||||
|
# ── Entry point ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
_setup_logging(
|
||||||
|
os.environ.get("OANDA_LOG_LEVEL") or get_app_config().log_level)
|
||||||
|
sys.excepthook = _excepthook
|
||||||
|
threading.excepthook = _threading_excepthook
|
||||||
|
|
||||||
|
daemon = TradingDaemon()
|
||||||
|
asyncio.run(daemon.run())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""
|
||||||
|
ForexBot2 — Oanda v20 Order Executor
|
||||||
|
======================================
|
||||||
|
Handles all Oanda account operations: market orders, trade queries,
|
||||||
|
SL/TP modification, and trade closure. Uses raw ``requests`` against
|
||||||
|
the Oanda v20 REST API — no extra dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OandaAPIError(Exception):
|
||||||
|
"""Raised when an Oanda API call fails."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: int = 0, body: str = ""):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
self.body = body
|
||||||
|
|
||||||
|
|
||||||
|
class OandaExecutor:
|
||||||
|
"""Oanda v20 order execution and trade management."""
|
||||||
|
|
||||||
|
MAX_RETRIES = 1
|
||||||
|
RETRY_DELAY = 2.0
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
account_id: str,
|
||||||
|
api_token: str,
|
||||||
|
base_url: str = "https://api-fxpractice.oanda.com",
|
||||||
|
lot_size: int = 1,
|
||||||
|
price_precision: int = 2,
|
||||||
|
):
|
||||||
|
self._account_id = account_id
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
self._lot_size = lot_size
|
||||||
|
self._price_precision = price_precision
|
||||||
|
self._session = requests.Session()
|
||||||
|
self._session.headers.update({
|
||||||
|
"Authorization": f"Bearer {api_token}",
|
||||||
|
"Accept-Datetime-Format": "RFC3339",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Order Placement ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def place_market_order(
|
||||||
|
self,
|
||||||
|
instrument: str,
|
||||||
|
direction: str,
|
||||||
|
stop_loss: float,
|
||||||
|
take_profit: Optional[float] = None,
|
||||||
|
units: Optional[int] = None,
|
||||||
|
) -> Dict:
|
||||||
|
if units is None:
|
||||||
|
units = self._lot_size
|
||||||
|
signed_units = str(units) if direction.upper() == "BUY" else str(-units)
|
||||||
|
|
||||||
|
order_body = {
|
||||||
|
"order": {
|
||||||
|
"type": "MARKET",
|
||||||
|
"instrument": instrument,
|
||||||
|
"units": signed_units,
|
||||||
|
"timeInForce": "FOK",
|
||||||
|
# The book model is one position total; on a netting
|
||||||
|
# account an opposite-side order would silently CLOSE an
|
||||||
|
# existing (possibly untracked) position instead of opening
|
||||||
|
# the signal trade. OPEN_ONLY makes that case fail loudly
|
||||||
|
# (order cancelled) so the divergence surfaces instead of
|
||||||
|
# executing an unintended close.
|
||||||
|
"positionFill": "OPEN_ONLY",
|
||||||
|
"stopLossOnFill": {
|
||||||
|
"price": self._fmt_price(stop_loss),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if take_profit is not None:
|
||||||
|
order_body["order"]["takeProfitOnFill"] = {
|
||||||
|
"price": self._fmt_price(take_profit),
|
||||||
|
}
|
||||||
|
|
||||||
|
url = f"{self._base_url}/v3/accounts/{self._account_id}/orders"
|
||||||
|
# Never retry order placement: a timeout after the server already
|
||||||
|
# filled would open a SECOND position on retry. Fail loudly instead;
|
||||||
|
# the next candle boundary re-evaluates.
|
||||||
|
resp = self._request_with_retry("POST", url, json=order_body, retries=0)
|
||||||
|
data = self._parse_json(resp)
|
||||||
|
|
||||||
|
fill_tx = data.get("orderFillTransaction")
|
||||||
|
if fill_tx:
|
||||||
|
trade_id = fill_tx.get("tradeOpened", {}).get("tradeID", "unknown")
|
||||||
|
fill_price = float(fill_tx.get("price", 0))
|
||||||
|
logger.info(
|
||||||
|
"ORDER FILLED: %s %s %s units @ %.2f — trade %s, SL %.2f%s",
|
||||||
|
direction.upper(), instrument, signed_units, fill_price,
|
||||||
|
trade_id, stop_loss,
|
||||||
|
f", TP {take_profit:.2f}" if take_profit else "")
|
||||||
|
else:
|
||||||
|
cancel_tx = data.get("orderCancelTransaction", {})
|
||||||
|
reason = cancel_tx.get("reason", "unknown")
|
||||||
|
logger.warning(
|
||||||
|
"ORDER NOT FILLED: %s %s — reason: %s",
|
||||||
|
direction.upper(), instrument, reason)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
# ── Trade Queries ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_open_trades(self, instrument: Optional[str] = None) -> List[Dict]:
|
||||||
|
url = f"{self._base_url}/v3/accounts/{self._account_id}/openTrades"
|
||||||
|
resp = self._request_with_retry("GET", url)
|
||||||
|
trades = self._parse_json(resp).get("trades", [])
|
||||||
|
if instrument:
|
||||||
|
trades = [t for t in trades if t.get("instrument") == instrument]
|
||||||
|
return trades
|
||||||
|
|
||||||
|
def get_trade(self, trade_id: str) -> Dict:
|
||||||
|
url = (f"{self._base_url}/v3/accounts/{self._account_id}"
|
||||||
|
f"/trades/{trade_id}")
|
||||||
|
resp = self._request_with_retry("GET", url)
|
||||||
|
return self._parse_json(resp).get("trade", {})
|
||||||
|
|
||||||
|
def get_account_summary(self) -> Dict:
|
||||||
|
url = f"{self._base_url}/v3/accounts/{self._account_id}/summary"
|
||||||
|
resp = self._request_with_retry("GET", url)
|
||||||
|
account = self._parse_json(resp).get("account", {})
|
||||||
|
logger.info(
|
||||||
|
"Account: balance=%.2f, NAV=%.2f, unrealizedPL=%.2f, openTrades=%s",
|
||||||
|
float(account.get("balance", 0)),
|
||||||
|
float(account.get("NAV", 0)),
|
||||||
|
float(account.get("unrealizedPL", 0)),
|
||||||
|
account.get("openTradeCount", 0))
|
||||||
|
return account
|
||||||
|
|
||||||
|
# ── Trade Modification ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def modify_trade_sl(self, trade_id: str, new_sl: float) -> Dict:
|
||||||
|
url = (f"{self._base_url}/v3/accounts/{self._account_id}"
|
||||||
|
f"/trades/{trade_id}/orders")
|
||||||
|
body = {"stopLoss": {"price": self._fmt_price(new_sl)}}
|
||||||
|
resp = self._request_with_retry("PUT", url, json=body)
|
||||||
|
logger.info(
|
||||||
|
"TRAIL UPDATE: trade %s — new SL %.2f", trade_id, new_sl)
|
||||||
|
return self._parse_json(resp)
|
||||||
|
|
||||||
|
def modify_trade_tp(self, trade_id: str, new_tp: float) -> Dict:
|
||||||
|
url = (f"{self._base_url}/v3/accounts/{self._account_id}"
|
||||||
|
f"/trades/{trade_id}/orders")
|
||||||
|
body = {"takeProfit": {"price": self._fmt_price(new_tp)}}
|
||||||
|
resp = self._request_with_retry("PUT", url, json=body)
|
||||||
|
logger.info("TP UPDATE: trade %s — new TP %.2f", trade_id, new_tp)
|
||||||
|
return self._parse_json(resp)
|
||||||
|
|
||||||
|
def close_trade(self, trade_id: str) -> Dict:
|
||||||
|
url = (f"{self._base_url}/v3/accounts/{self._account_id}"
|
||||||
|
f"/trades/{trade_id}/close")
|
||||||
|
resp = self._request_with_retry("PUT", url, json={"units": "ALL"})
|
||||||
|
data = self._parse_json(resp)
|
||||||
|
close_tx = data.get("orderFillTransaction")
|
||||||
|
if not close_tx:
|
||||||
|
# A 200 whose close order was created but NOT filled (e.g.
|
||||||
|
# cancelled with MARKET_HALTED during the settlement pause)
|
||||||
|
# means the position is STILL OPEN. Raising keeps the caller
|
||||||
|
# tracking it and retrying — silently returning here would
|
||||||
|
# orphan a live position.
|
||||||
|
reason = data.get("orderCancelTransaction", {}).get(
|
||||||
|
"reason", "no orderFillTransaction in response")
|
||||||
|
raise OandaAPIError(
|
||||||
|
f"Close of trade {trade_id} did not fill: {reason}",
|
||||||
|
resp.status_code, str(data)[:300])
|
||||||
|
close_price = float(close_tx.get("price", 0))
|
||||||
|
pl = float(close_tx.get("pl", 0))
|
||||||
|
logger.info(
|
||||||
|
"TRADE CLOSED: trade %s @ %.2f — PL %.2f",
|
||||||
|
trade_id, close_price, pl)
|
||||||
|
return data
|
||||||
|
|
||||||
|
# ── Internals ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_json(resp: requests.Response) -> Dict:
|
||||||
|
try:
|
||||||
|
return resp.json()
|
||||||
|
except ValueError as e:
|
||||||
|
raise OandaAPIError(
|
||||||
|
f"Oanda returned a non-JSON response (HTTP {resp.status_code})",
|
||||||
|
resp.status_code, resp.text[:300]) from e
|
||||||
|
|
||||||
|
def _fmt_price(self, price: float) -> str:
|
||||||
|
# Oanda rejects prices with more decimals than the instrument's
|
||||||
|
# displayPrecision (XAU_USD: sub-cent precision is invalid).
|
||||||
|
return f"{price:.{self._price_precision}f}"
|
||||||
|
|
||||||
|
def _request_with_retry(
|
||||||
|
self, method: str, url: str, retries: Optional[int] = None, **kwargs
|
||||||
|
) -> requests.Response:
|
||||||
|
if retries is None:
|
||||||
|
retries = self.MAX_RETRIES
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(1 + retries):
|
||||||
|
try:
|
||||||
|
resp = self._session.request(method, url, timeout=15, **kwargs)
|
||||||
|
if resp.status_code >= 500:
|
||||||
|
raise OandaAPIError(
|
||||||
|
f"Oanda server error {resp.status_code}",
|
||||||
|
resp.status_code, resp.text)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
raise OandaAPIError(
|
||||||
|
f"Oanda API error {resp.status_code}: {resp.text}",
|
||||||
|
resp.status_code, resp.text)
|
||||||
|
return resp
|
||||||
|
except (requests.ConnectionError, requests.Timeout,
|
||||||
|
OandaAPIError) as e:
|
||||||
|
last_err = e
|
||||||
|
if isinstance(e, OandaAPIError) and 400 <= e.status_code < 500:
|
||||||
|
raise
|
||||||
|
if attempt < retries:
|
||||||
|
logger.warning(
|
||||||
|
"Oanda request failed (attempt %d/%d): %s — retrying in %.0fs",
|
||||||
|
attempt + 1, 1 + retries, e,
|
||||||
|
self.RETRY_DELAY)
|
||||||
|
time.sleep(self.RETRY_DELAY)
|
||||||
|
raise OandaAPIError(f"Oanda request failed after retries: {last_err}")
|
||||||
@@ -30,7 +30,6 @@ def produce_signal(
|
|||||||
primary_tf: Timeframe,
|
primary_tf: Timeframe,
|
||||||
data_provider: BaseDataProvider,
|
data_provider: BaseDataProvider,
|
||||||
confirmation_tfs: Optional[List[Timeframe]] = None,
|
confirmation_tfs: Optional[List[Timeframe]] = None,
|
||||||
log_source: str = "dashboard",
|
|
||||||
) -> SignalReport:
|
) -> SignalReport:
|
||||||
config_factory = PAIR_PRODUCERS.get(symbol)
|
config_factory = PAIR_PRODUCERS.get(symbol)
|
||||||
if config_factory is None:
|
if config_factory is None:
|
||||||
@@ -42,7 +41,7 @@ def produce_signal(
|
|||||||
config = config_factory(primary_tf, confirmation_tfs)
|
config = config_factory(primary_tf, confirmation_tfs)
|
||||||
aggregator = SignalAggregator(config)
|
aggregator = SignalAggregator(config)
|
||||||
aggregator.data_provider = data_provider
|
aggregator.data_provider = data_provider
|
||||||
return aggregator.analyze_pair(symbol=symbol, log_source=log_source)
|
return aggregator.analyze_pair(symbol=symbol)
|
||||||
|
|
||||||
|
|
||||||
from pair_signals import xauusd as _xau_mod
|
from pair_signals import xauusd as _xau_mod
|
||||||
|
|||||||
@@ -141,49 +141,6 @@ validated price of riding trends, exits confirmed NOT the loss
|
|||||||
driver: exit-management accounts for 1-2% of lost R vs 50%
|
driver: exit-management accounts for 1-2% of lost R vs 50%
|
||||||
wrong-direction / 49% entry-timing).
|
wrong-direction / 49% entry-timing).
|
||||||
|
|
||||||
M5 override 6 — SELL block in transitioning regime (validated
|
|
||||||
2026-09-03, signal-log campaign, `backtest/research/
|
|
||||||
signal_log_campaign_2026-09.md`, $0.65 round trip, same CPU harness
|
|
||||||
as its control): sell_blocked_regimes=["transitioning"]. Live
|
|
||||||
evidence: M5 SELLs in transitioning were -7.9R/12mo PF 0.21 and
|
|
||||||
-5.2R/5yr PF 0.49 in the pinned books; the live signal log showed the
|
|
||||||
same pocket -7.9R/PF 0.56 over 3 weeks. Blocking: 12mo (2025-08-14 ->
|
|
||||||
2026-08-14) 557 trades +112.21R PF 1.56 (halves +55.9/+56.4, PF
|
|
||||||
1.54/1.58) vs control 575/+104.34R PF 1.50; 5yr (2021-08-01 ->
|
|
||||||
2026-08-04) 889/+133.7R PF 1.41 vs 911/+128.4R PF 1.39; OOS
|
|
||||||
2024-06-01 -> 2025-03-30 unchanged (86/+9.45R PF 1.33 — no such
|
|
||||||
trades in that window). Removed trades: 18 in 12mo (-7.9R), 22 in
|
|
||||||
5yr (-5.3R).
|
|
||||||
|
|
||||||
H1 override — all SELL entries blocked (validated 2026-09-03, same
|
|
||||||
campaign): sell_min_quality=[] (gate 6e in signal_aggregator.py
|
|
||||||
downgrades any actionable-grade SELL whose quality is not in this
|
|
||||||
list to D, so an empty list downgrades every H1 short). H1 shorts
|
|
||||||
were a structural loser: -16.7R over the 5yr control window and
|
|
||||||
-11.5R OOS. Blocking: 12mo (2025-08-04 -> 2026-08-04) 91/+22.2R
|
|
||||||
PF 1.79 (halves +20.6/+1.6) vs control 138/+24.0R PF 1.55 (12mo cost
|
|
||||||
-1.8R); 5yr 335/+49.1R PF 1.46 vs 515/+32.4R PF 1.18; OOS 78/+16.7R
|
|
||||||
PF 1.74 vs 104/+5.1R PF 1.13. New reference books:
|
|
||||||
backtest/oanda_h1_{12mo,5yr,oos}_065_20260903.csv,
|
|
||||||
backtest/oanda_m5_12mo_065_nopb_20260903.csv,
|
|
||||||
backtest/oanda_m5_5yr_065_nopb_20260903.csv.
|
|
||||||
|
|
||||||
Rejected/parked in the same campaign (see the memo for the full
|
|
||||||
table): M15 sell_min_quality=["B"] (5yr -8.9R, max DD 35.8 -> 41.4R
|
|
||||||
— REJECTED); Bollinger weight 0 in trending regimes on M5, M15, H1
|
|
||||||
(M5 -20.4R/12mo, -15.5R/5yr; M15 -24.0R/12mo — REJECTED across the
|
|
||||||
board); divergence disabled on M15 (R improves +7.4/+11.9/+13.5R
|
|
||||||
across windows but 5yr max DD 35.8 -> 47.4R with PF flat — DECLINED,
|
|
||||||
owner call); divergence disabled on H1 (+4.6/+8.1/+2.4R, PF
|
|
||||||
1.62/1.20/1.17) — PARKED: it passes rule 1 in combination with the
|
|
||||||
H1 no-short override (b12_h1_nosell_nodiv: 12mo 100/+23.1R PF 1.75,
|
|
||||||
halves +21.8/+1.3; 5yr 364/+58.7R PF 1.52; OOS 88/+21.9R PF 1.92 —
|
|
||||||
better than the no-shorts override alone on both 5yr and OOS), but
|
|
||||||
shipping it would route through technical.disabled_indicators, which
|
|
||||||
CLAUDE.md rule 8 reserves for experiments; owner decision, recommended
|
|
||||||
vehicle is an H1-only regime_weights_override with divergence weight
|
|
||||||
0 plus a fresh validation run.
|
|
||||||
|
|
||||||
M5 override 5 — EMA partial-alignment squash (validated 2026-08-04,
|
M5 override 5 — EMA partial-alignment squash (validated 2026-08-04,
|
||||||
Oanda bars, $0.65 real spread): ema_partial_neutral True on M5 —
|
Oanda bars, $0.65 real spread): ema_partial_neutral True on M5 —
|
||||||
partial 4-EMA ribbon alignment (2-3 of 4 checks) scores NEUTRAL
|
partial 4-EMA ribbon alignment (2-3 of 4 checks) scores NEUTRAL
|
||||||
@@ -259,9 +216,6 @@ def make_config(
|
|||||||
# Wider trailing distance on M5 only — see module docstring.
|
# Wider trailing distance on M5 only — see module docstring.
|
||||||
config.signals.trail_arm_r = 0.60
|
config.signals.trail_arm_r = 0.60
|
||||||
config.signals.trail_dist_r = 0.85
|
config.signals.trail_dist_r = 0.85
|
||||||
# Block SELL entries while the regime is "transitioning" on M5
|
|
||||||
# only — see module docstring (M5 override 6).
|
|
||||||
config.xau_strategy.sell_blocked_regimes = ["transitioning"]
|
|
||||||
if primary_tf == Timeframe.M15:
|
if primary_tf == Timeframe.M15:
|
||||||
# 1R minimum target on M15 only — see module docstring.
|
# 1R minimum target on M15 only — see module docstring.
|
||||||
config.signals.tp_floor_rr = 1.0
|
config.signals.tp_floor_rr = 1.0
|
||||||
@@ -272,10 +226,4 @@ def make_config(
|
|||||||
# Later trailing-stop arm on M15 only — see module docstring.
|
# Later trailing-stop arm on M15 only — see module docstring.
|
||||||
config.signals.trail_arm_r = 0.70
|
config.signals.trail_arm_r = 0.70
|
||||||
config.signals.trail_dist_r = 0.75
|
config.signals.trail_dist_r = 0.75
|
||||||
if primary_tf == Timeframe.H1:
|
|
||||||
# Block every H1 SELL — the SELL quality gate (6e in
|
|
||||||
# signal_aggregator.py) downgrades any actionable-grade short
|
|
||||||
# to D when its grade is not in this list; an empty list means
|
|
||||||
# no grade qualifies. H1-only — see module docstring.
|
|
||||||
config.xau_strategy.sell_min_quality = []
|
|
||||||
return config
|
return config
|
||||||
|
|||||||
@@ -1,622 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -3,8 +3,6 @@ ForexBot2 — Tiingo data provider
|
|||||||
=================================
|
=================================
|
||||||
OHLCV candles from Tiingo's FX endpoint
|
OHLCV candles from Tiingo's FX endpoint
|
||||||
(`/tiingo/fx/{ticker}/prices`, resampled mid-price bars, no volume).
|
(`/tiingo/fx/{ticker}/prices`, resampled mid-price bars, no volume).
|
||||||
Tiingo is streaming-only in production: its REST candles remain available
|
|
||||||
for comparison research, but it is not a designated candle fallback.
|
|
||||||
All shared behavior (cache, validation, boundary guarantees, range
|
All shared behavior (cache, validation, boundary guarantees, range
|
||||||
pagination) lives in `BaseDataProvider`; this module only maps
|
pagination) lives in `BaseDataProvider`; this module only maps
|
||||||
symbols/resample frequencies, sizes the request window, and parses
|
symbols/resample frequencies, sizes the request window, and parses
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
backfill_signal_outcomes.py — seed and advance advisory outcomes for every
|
|
||||||
logged trade setup (not only panel rows), one per advisory event.
|
|
||||||
|
|
||||||
Pass A (offline, no network): assign setup_key / advisory_event_id to all
|
|
||||||
legacy setup rows, mark same-setup re-logs within 60 min as DUPLICATE, and
|
|
||||||
seed a PENDING outcome on each canonical row that has none. Existing
|
|
||||||
(panel-seeded) outcomes are never overwritten.
|
|
||||||
|
|
||||||
Pass B: advance PENDING rows from the closed-candle streams already stored
|
|
||||||
in the DB (union across every snapshot of the timeframe — no provider).
|
|
||||||
|
|
||||||
Pass C (--reseed-unresolvable): bounded re-seed of continuity-broken rows,
|
|
||||||
then Pass B again.
|
|
||||||
|
|
||||||
Safe beside the live writers: short per-row transactions with a busy
|
|
||||||
timeout. Read-only preview with --dry-run.
|
|
||||||
|
|
||||||
python scripts/backfill_signal_outcomes.py --dry-run
|
|
||||||
python scripts/backfill_signal_outcomes.py
|
|
||||||
python scripts/backfill_signal_outcomes.py --reseed-unresolvable
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
|
|
||||||
import app_config # noqa: E402
|
|
||||||
from signal_log import SignalLogger # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def _status_counts(con: sqlite3.Connection) -> dict:
|
|
||||||
return dict(con.execute(
|
|
||||||
"SELECT COALESCE(advisory_outcome_status, 'NULL'), COUNT(*)"
|
|
||||||
" FROM signals WHERE setup_json IS NOT NULL AND setup_json != 'null'"
|
|
||||||
" GROUP BY 1").fetchall())
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None) -> int:
|
|
||||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
|
|
||||||
ap.add_argument("--db", default=None, help="defaults to signal_log.path")
|
|
||||||
ap.add_argument("--dry-run", action="store_true")
|
|
||||||
ap.add_argument("--reseed-unresolvable", action="store_true")
|
|
||||||
ap.add_argument("--symbol", default="XAU/USD")
|
|
||||||
args = ap.parse_args(argv)
|
|
||||||
path = args.db or app_config.signal_log_path()
|
|
||||||
if not os.path.exists(path):
|
|
||||||
print(f"no database at {path}")
|
|
||||||
return 1
|
|
||||||
if args.dry_run:
|
|
||||||
con = sqlite3.connect(f"file:{os.path.abspath(path)}?mode=ro", uri=True)
|
|
||||||
print("setup rows by outcome status:", _status_counts(con))
|
|
||||||
have = {r[1] for r in con.execute("PRAGMA table_info(signals)")}
|
|
||||||
if "setup_key" in have:
|
|
||||||
n_keyless = con.execute(
|
|
||||||
"SELECT COUNT(*) FROM signals WHERE setup_json IS NOT NULL"
|
|
||||||
" AND setup_json != 'null' AND setup_key IS NULL").fetchone()[0]
|
|
||||||
print(f"setup rows without setup_key: {n_keyless}")
|
|
||||||
else:
|
|
||||||
print("setup_key column not present yet (schema migrates on"
|
|
||||||
" the first non-dry run)")
|
|
||||||
con.close()
|
|
||||||
return 0
|
|
||||||
lg = SignalLogger(path)
|
|
||||||
try:
|
|
||||||
print("before:", _status_counts(lg._conn))
|
|
||||||
print("pass A (seed/dedup):", lg.backfill_seeds())
|
|
||||||
total = 0
|
|
||||||
for _ in range(50):
|
|
||||||
n = lg.resolve_from_store(args.symbol)
|
|
||||||
total += n
|
|
||||||
if n == 0:
|
|
||||||
break
|
|
||||||
print(f"pass B (advance from stored bars): {total} rows changed")
|
|
||||||
if args.reseed_unresolvable:
|
|
||||||
reseeded = lg.reseed_unresolvable()
|
|
||||||
print(f"pass C (re-seed): {len(reseeded)} rows re-seeded")
|
|
||||||
for _ in range(50):
|
|
||||||
if lg.resolve_from_store(args.symbol) == 0:
|
|
||||||
break
|
|
||||||
print("after:", _status_counts(lg._conn))
|
|
||||||
finally:
|
|
||||||
lg.close()
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Scratchpad — Oanda vs Tiingo candle parity check
|
|
||||||
=================================================
|
|
||||||
Fetches the last N closed XAU/USD candles from Oanda and Tiingo for
|
|
||||||
M5/M15/H1 via the normal provider stack (make_data_provider →
|
|
||||||
fetch_ohlcv: forming-candle drop + validate/clean included, i.e. exactly
|
|
||||||
what the signal pipeline consumes) and reports how well they line up:
|
|
||||||
|
|
||||||
* last-bar timestamp per provider (feed-lag check)
|
|
||||||
* timestamp mismatches (bars present in one feed only)
|
|
||||||
* per-bar OHLC diff table for the common tail
|
|
||||||
* max/mean |diff| per column, with the Close diff also in % of price
|
|
||||||
|
|
||||||
Both feeds are mid-price (Oanda mid of MBA candles; Tiingo resampled
|
|
||||||
mid), so small diffs are expected — this measures how small.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
venv/bin/python scripts/compare_oanda_tiingo.py [--n 10]
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from config import PlatformConfig, Timeframe, XAUUSD
|
|
||||||
from providers import make_data_provider
|
|
||||||
|
|
||||||
TIMEFRAMES = [Timeframe.M5, Timeframe.M15, Timeframe.H1]
|
|
||||||
OHLC = ["Open", "High", "Low", "Close"]
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_tail(provider, tf, n):
|
|
||||||
"""Last n closed bars from one provider, or None on failure."""
|
|
||||||
try:
|
|
||||||
df = provider.fetch_ohlcv(symbol=XAUUSD, timeframe=tf)
|
|
||||||
except Exception as e: # scratchpad: report, don't crash
|
|
||||||
print(f" !! {provider.name} {tf.value} fetch failed: {e}")
|
|
||||||
return None
|
|
||||||
return df.tail(n)
|
|
||||||
|
|
||||||
|
|
||||||
def compare_tf(tf, oanda_df, tiingo_df, n):
|
|
||||||
print(f"\n{'=' * 78}\n{tf.value} — last {n} closed bars\n{'=' * 78}")
|
|
||||||
if oanda_df is None or tiingo_df is None:
|
|
||||||
print(" skipped (a provider failed)")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f" Oanda last bar: {oanda_df.index[-1]} "
|
|
||||||
f"Tiingo last bar: {tiingo_df.index[-1]}")
|
|
||||||
|
|
||||||
common = oanda_df.index.intersection(tiingo_df.index)
|
|
||||||
only_o = oanda_df.index.difference(tiingo_df.index)
|
|
||||||
only_t = tiingo_df.index.difference(oanda_df.index)
|
|
||||||
if len(only_o):
|
|
||||||
print(f" bars only in Oanda : {[str(t) for t in only_o]}")
|
|
||||||
if len(only_t):
|
|
||||||
print(f" bars only in Tiingo: {[str(t) for t in only_t]}")
|
|
||||||
if not len(common):
|
|
||||||
print(" NO common timestamps — feeds are on different bar grids!")
|
|
||||||
return
|
|
||||||
|
|
||||||
o = oanda_df.loc[common, OHLC]
|
|
||||||
t = tiingo_df.loc[common, OHLC]
|
|
||||||
diff = o - t
|
|
||||||
|
|
||||||
print(f"\n {len(common)}/{n} bars share timestamps "
|
|
||||||
f"(window {common[0]} → {common[-1]})\n")
|
|
||||||
|
|
||||||
# Per-bar Close table — the number that matters most.
|
|
||||||
hdr = f" {'bar close (UTC)':<20}{'Oanda C':>12}{'Tiingo C':>12}{'ΔC ($)':>10}"
|
|
||||||
print(hdr + f"{'ΔO':>8}{'ΔH':>8}{'ΔL':>8}")
|
|
||||||
for ts in common:
|
|
||||||
row = (f" {str(ts):<20}{o.loc[ts, 'Close']:>12.2f}"
|
|
||||||
f"{t.loc[ts, 'Close']:>12.2f}{diff.loc[ts, 'Close']:>10.3f}")
|
|
||||||
row += (f"{diff.loc[ts, 'Open']:>8.3f}{diff.loc[ts, 'High']:>8.3f}"
|
|
||||||
f"{diff.loc[ts, 'Low']:>8.3f}")
|
|
||||||
print(row)
|
|
||||||
|
|
||||||
print("\n summary over common bars:")
|
|
||||||
for col in OHLC:
|
|
||||||
d = diff[col].abs()
|
|
||||||
print(f" {col:<6} max |Δ| = {d.max():8.4f} mean |Δ| = {d.mean():8.4f}")
|
|
||||||
px = o["Close"].iloc[-1]
|
|
||||||
dc = diff["Close"].abs()
|
|
||||||
print(f" Close Δ as % of price: max {dc.max() / px * 100:.4f}% "
|
|
||||||
f"mean {dc.mean() / px * 100:.4f}%")
|
|
||||||
exact = (diff[OHLC].abs() < 1e-9).all(axis=1).sum()
|
|
||||||
print(f" bars with identical OHLC: {exact}/{len(common)}")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--n", type=int, default=10,
|
|
||||||
help="bars per timeframe to compare (default 10)")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
cfg = PlatformConfig()
|
|
||||||
oanda = make_data_provider(cfg, "oanda")
|
|
||||||
tiingo = make_data_provider(cfg, "tiingo")
|
|
||||||
|
|
||||||
for tf in TIMEFRAMES:
|
|
||||||
o_df = fetch_tail(oanda, tf, args.n)
|
|
||||||
t_df = fetch_tail(tiingo, tf, args.n)
|
|
||||||
compare_tf(tf, o_df, t_df, args.n)
|
|
||||||
|
|
||||||
print("\nDone. Note: Oanda=mid of MBA candles, Tiingo=resampled mid — "
|
|
||||||
"sub-$1 diffs on gold are normal feed divergence, not a bug.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Phase 1 — historical candle divergence study (data source change test)
|
|
||||||
=======================================================================
|
|
||||||
Fetches the RAW closed-bar series for XAU/USD from two providers over a
|
|
||||||
multi-month window via the normal provider stack (`fetch_range`, which
|
|
||||||
paginates under the per-request bar cap and drops the forming candle),
|
|
||||||
then quantifies how the feeds diverge:
|
|
||||||
|
|
||||||
* coverage: % shared timestamps, feed-only bars, where they cluster
|
|
||||||
* OHLC |Δ| distribution (mean/median/p90/p95/p99/max, $ and bias)
|
|
||||||
* |ΔClose| vs mean ATR14 (the scale the signal engine cares about)
|
|
||||||
* threshold exceedance (how many bars differ by more than X $)
|
|
||||||
* bar-direction agreement (sign of close−open)
|
|
||||||
* close-return correlation on consecutive bars
|
|
||||||
* per-hour and per-month drift
|
|
||||||
|
|
||||||
Both feeds are mid-price (Oanda mid of MBA candles; Tiingo resampled mid;
|
|
||||||
Twelve Data its own mid). Frames are cached to parquet so re-runs are
|
|
||||||
instant; pass --no-cache to force a fresh fetch.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
venv/bin/python scripts/feed_divergence_study.py --a oanda --b tiingo \
|
|
||||||
--tfs M15,H1,H4 --days 365
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from config import PlatformConfig, Timeframe, XAUUSD
|
|
||||||
from providers import make_data_provider
|
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
|
||||||
CACHE_DIR = REPO / "backtest" / "cache"
|
|
||||||
RESEARCH_DIR = REPO / "backtest" / "research"
|
|
||||||
|
|
||||||
_TF_LOOKUP = {t.value: t for t in Timeframe}
|
|
||||||
_TF_LOOKUP.update({
|
|
||||||
"M1": Timeframe.M1, "M5": Timeframe.M5, "M15": Timeframe.M15,
|
|
||||||
"M30": Timeframe.M30, "H1": Timeframe.H1, "H4": Timeframe.H4,
|
|
||||||
"D1": Timeframe.D1, "W1": Timeframe.W1,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def wilder_atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
|
||||||
hl = df["High"] - df["Low"]
|
|
||||||
hc = (df["High"] - df["Close"].shift(1)).abs()
|
|
||||||
lc = (df["Low"] - df["Close"].shift(1)).abs()
|
|
||||||
tr = pd.concat([hl, hc, lc], axis=1).max(axis=1)
|
|
||||||
return tr.ewm(alpha=1.0 / period, adjust=False).mean()
|
|
||||||
|
|
||||||
|
|
||||||
def fmt_stats(name: str, s: pd.Series) -> str:
|
|
||||||
a = s.abs()
|
|
||||||
return (f" {name:<6} mean ${a.mean():.3f} med ${a.median():.3f} "
|
|
||||||
f"p90 ${a.quantile(0.90):.3f} p95 ${a.quantile(0.95):.3f} "
|
|
||||||
f"p99 ${a.quantile(0.99):.3f} max ${a.max():.2f} "
|
|
||||||
f"bias {s.mean():+.4f}")
|
|
||||||
|
|
||||||
|
|
||||||
def cache_path(name: str, tf: Timeframe, start: pd.Timestamp,
|
|
||||||
end: pd.Timestamp) -> Path:
|
|
||||||
return (CACHE_DIR /
|
|
||||||
f"{name}_XAU_USD_{tf.value}_{start:%Y%m%d}_{end:%Y%m%d}.parquet")
|
|
||||||
|
|
||||||
|
|
||||||
def load_or_fetch(provider, name: str, tf: Timeframe, start, end,
|
|
||||||
no_cache: bool):
|
|
||||||
cp = cache_path(name, tf, start, end)
|
|
||||||
if not no_cache and cp.exists():
|
|
||||||
df = pd.read_parquet(cp)
|
|
||||||
print(f" [{name}] loaded {len(df):,} bars from cache "
|
|
||||||
f"{cp.name}")
|
|
||||||
return df
|
|
||||||
t0 = time.time()
|
|
||||||
print(f" [{name}] fetching {tf.value} {start.date()} → {end.date()} ...")
|
|
||||||
df = provider.fetch_range(XAUUSD, tf, start, end)
|
|
||||||
print(f" [{name}] fetched {len(df):,} bars in {time.time() - t0:.1f}s")
|
|
||||||
df.to_parquet(cp)
|
|
||||||
print(f" [{name}] cached → {cp.name}")
|
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
def compare(tf: Timeframe, a_name: str, b_name: str, a: pd.DataFrame,
|
|
||||||
b: pd.DataFrame):
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print(f"{tf.value} — {a_name.upper()} vs {b_name.upper()}, XAU/USD")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
common = a.index.intersection(b.index)
|
|
||||||
a_only = a.index.difference(b.index)
|
|
||||||
b_only = b.index.difference(a.index)
|
|
||||||
|
|
||||||
print(f"\nCOVERAGE")
|
|
||||||
print(f" {a_name} bars : {len(a):>8,} head {a.index[0]} tail {a.index[-1]}")
|
|
||||||
print(f" {b_name} bars : {len(b):>8,} head {b.index[0]} tail {b.index[-1]}")
|
|
||||||
print(f" shared : {len(common):>8,} "
|
|
||||||
f"({100 * len(common) / max(len(a.index.union(b.index)), 1):.2f}% of union)")
|
|
||||||
print(f" {a_name}-only : {len(a_only):>8,} "
|
|
||||||
f"({100 * len(a_only) / max(len(a), 1):.2f}% of {a_name})")
|
|
||||||
print(f" {b_name}-only : {len(b_only):>8,} "
|
|
||||||
f"({100 * len(b_only) / max(len(b), 1):.2f}% of {b_name})")
|
|
||||||
|
|
||||||
if len(common) == 0:
|
|
||||||
print(" !! no shared timestamps — feeds on different bar grids")
|
|
||||||
return
|
|
||||||
|
|
||||||
for label, idx in ((f"{a_name}-only", a_only),
|
|
||||||
(f"{b_name}-only", b_only)):
|
|
||||||
if not len(idx):
|
|
||||||
continue
|
|
||||||
by_date = pd.Series(1, index=idx).groupby(idx.date).sum()
|
|
||||||
top = by_date.sort_values(ascending=False).head(6)
|
|
||||||
print(f" {label} clusters on: "
|
|
||||||
+ ", ".join(f"{d} ({n})" for d, n in top.items()))
|
|
||||||
|
|
||||||
x = a.loc[common]
|
|
||||||
y = b.loc[common]
|
|
||||||
|
|
||||||
atr = wilder_atr(x).dropna()
|
|
||||||
mean_price = x["Close"].mean()
|
|
||||||
print(f"\nSCALE REFERENCES ({a_name} feed)")
|
|
||||||
print(f" mean close : ${mean_price:,.2f}")
|
|
||||||
print(f" mean bar range: ${(x['High'] - x['Low']).mean():.3f}")
|
|
||||||
print(f" mean ATR14 : ${atr.mean():.3f} median ${atr.median():.3f}")
|
|
||||||
|
|
||||||
print(f"\nPER-BAR DIFFS ({a_name} − {b_name}, $)")
|
|
||||||
diffs = {}
|
|
||||||
for col in ("Open", "High", "Low", "Close"):
|
|
||||||
d = x[col] - y[col]
|
|
||||||
diffs[col] = d
|
|
||||||
print(fmt_stats(col, d))
|
|
||||||
|
|
||||||
dclose = diffs["Close"].abs()
|
|
||||||
print(f"\n |ΔClose| as % of mean ATR14 : "
|
|
||||||
f"mean {100 * dclose.mean() / atr.mean():.1f}% "
|
|
||||||
f"p95 {100 * dclose.quantile(0.95) / atr.mean():.1f}%")
|
|
||||||
|
|
||||||
print(f"\nTHRESHOLD EXCEEDANCE (|ΔClose|)")
|
|
||||||
for thr in (0.10, 0.30, 0.65, 1.00, 2.00, 5.00):
|
|
||||||
pct = 100 * (dclose > thr).mean()
|
|
||||||
print(f" > ${thr:<5.2f}: {pct:6.2f}% of bars ({(dclose > thr).sum():,})")
|
|
||||||
|
|
||||||
# direction agreement
|
|
||||||
a_dir = np.sign(x["Close"] - x["Open"])
|
|
||||||
b_dir = np.sign(y["Close"] - y["Open"])
|
|
||||||
both_nonflat = (a_dir != 0) & (b_dir != 0)
|
|
||||||
dir_agree = 100 * (a_dir[both_nonflat] == b_dir[both_nonflat]).mean()
|
|
||||||
print(f"\nAGREEMENT")
|
|
||||||
print(f" bar direction (sign of close−open) agree: {dir_agree:.2f}%"
|
|
||||||
f" (n={both_nonflat.sum():,})")
|
|
||||||
|
|
||||||
step = pd.Series(common, index=common).diff().median()
|
|
||||||
a_ret = x["Close"].pct_change()
|
|
||||||
b_ret = y["Close"].pct_change()
|
|
||||||
step_ok = pd.Series(common, index=common).diff().le(step + pd.Timedelta(seconds=1))
|
|
||||||
corr = a_ret[step_ok].corr(b_ret[step_ok])
|
|
||||||
print(f" close-return correlation (consecutive bars): {corr:.5f}")
|
|
||||||
|
|
||||||
# by-hour
|
|
||||||
hours = common.hour
|
|
||||||
rows = []
|
|
||||||
for h in range(24):
|
|
||||||
m = hours == h
|
|
||||||
if not m.any():
|
|
||||||
continue
|
|
||||||
rows.append({"hour_utc": h, "bars": int(m.sum()),
|
|
||||||
"mean_abs_dclose": dclose[m].mean(),
|
|
||||||
"p95_abs_dclose": dclose[m].quantile(0.95)})
|
|
||||||
by_hour = pd.DataFrame(rows)
|
|
||||||
print(f"\nBY UTC HOUR (mean |ΔClose|, $)")
|
|
||||||
print(by_hour.to_string(index=False,
|
|
||||||
float_format=lambda v: f"{v:.3f}"))
|
|
||||||
|
|
||||||
print(f"\nBY MONTH (mean |ΔClose|, $)")
|
|
||||||
months = dclose.groupby(common.tz_localize(None).to_period("M")).mean()
|
|
||||||
for p, v in months.items():
|
|
||||||
print(f" {p}: {v:.3f}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"tf": tf.value, "a": a_name, "b": b_name,
|
|
||||||
"a_bars": len(a), "b_bars": len(b), "common": len(common),
|
|
||||||
"a_only": len(a_only), "b_only": len(b_only),
|
|
||||||
"dir_agree_pct": round(dir_agree, 3),
|
|
||||||
"ret_corr": round(corr, 5),
|
|
||||||
"mean_abs_dclose": dclose.mean(),
|
|
||||||
"p95_abs_dclose": dclose.quantile(0.95),
|
|
||||||
"max_abs_dclose": dclose.max(),
|
|
||||||
"dclose_pct_atr_mean": 100 * dclose.mean() / atr.mean(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--a", required=True)
|
|
||||||
ap.add_argument("--b", required=True)
|
|
||||||
ap.add_argument("--tfs", default="M15,H1,H4",
|
|
||||||
help="comma-separated timeframe values (default M15,H1,H4)")
|
|
||||||
ap.add_argument("--days", type=int, default=365)
|
|
||||||
ap.add_argument("--end", default=None, help="UTC end date (default now)")
|
|
||||||
ap.add_argument("--no-cache", action="store_true")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
tfs = []
|
|
||||||
for s in args.tfs.split(","):
|
|
||||||
s = s.strip()
|
|
||||||
tf = _TF_LOOKUP.get(s)
|
|
||||||
if tf is None:
|
|
||||||
sys.exit(f"unknown timeframe '{s}'; use one of "
|
|
||||||
f"{sorted(_TF_LOOKUP)}")
|
|
||||||
tfs.append(tf)
|
|
||||||
|
|
||||||
end = pd.Timestamp(args.end, tz="UTC") if args.end \
|
|
||||||
else pd.Timestamp.now(tz="UTC")
|
|
||||||
start = end - pd.Timedelta(days=args.days)
|
|
||||||
|
|
||||||
cfg = PlatformConfig()
|
|
||||||
pa = make_data_provider(cfg, args.a)
|
|
||||||
pb = make_data_provider(cfg, args.b)
|
|
||||||
|
|
||||||
summary = []
|
|
||||||
for tf in tfs:
|
|
||||||
print(f"\n{'─' * 80}\nWINDOW {start.date()} → {end.date()} "
|
|
||||||
f"({args.days} days), {tf.value}")
|
|
||||||
try:
|
|
||||||
a = load_or_fetch(pa, args.a, tf, start, end, args.no_cache)
|
|
||||||
except Exception as e:
|
|
||||||
print(f" !! {args.a} {tf.value} fetch failed: {e}")
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
b = load_or_fetch(pb, args.b, tf, start, end, args.no_cache)
|
|
||||||
except Exception as e:
|
|
||||||
print(f" !! {args.b} {tf.value} fetch failed: {e}")
|
|
||||||
continue
|
|
||||||
r = compare(tf, args.a, args.b, a, b)
|
|
||||||
if r:
|
|
||||||
summary.append(r)
|
|
||||||
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print("SUMMARY TABLE")
|
|
||||||
print("=" * 80)
|
|
||||||
sdf = pd.DataFrame(summary)
|
|
||||||
print(sdf.to_string(index=False,
|
|
||||||
float_format=lambda v: f"{v:.4f}"))
|
|
||||||
out = RESEARCH_DIR / f"feed_divergence_{args.a}_vs_{args.b}.csv"
|
|
||||||
if len(sdf):
|
|
||||||
sdf.to_csv(out, index=False, float_format="%.6f")
|
|
||||||
print(f"\nSummary written to {out}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
label_scan_outcomes.py — batch driver for the scan_outcomes table.
|
|
||||||
|
|
||||||
Labels model-free forward returns (1/4/24 h, $ and ATR-normalized, plus
|
|
||||||
MFE/MAE, a 3xATR burst flag and a completeness flag) for every logged scan
|
|
||||||
whose 24 h window has closed, from the closed-candle streams already
|
|
||||||
stored in the DB. No provider, no network.
|
|
||||||
|
|
||||||
The live loggers do the same work opportunistically at the end of every
|
|
||||||
scan (time-boxed to ~2 s); this script is the catch-up pass for a backlog
|
|
||||||
— a freshly imported database, or rows whose bars only arrived later.
|
|
||||||
|
|
||||||
Idempotent: a row is rewritten only when its numbers change, so a settled
|
|
||||||
database costs one read pass. Safe beside the live writers (short per-row
|
|
||||||
transactions with a busy timeout). Read-only preview with --dry-run.
|
|
||||||
|
|
||||||
python scripts/label_scan_outcomes.py --dry-run
|
|
||||||
python scripts/label_scan_outcomes.py
|
|
||||||
python scripts/label_scan_outcomes.py --db logs/signals.db --budget 5000
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
|
|
||||||
import app_config # noqa: E402
|
|
||||||
from signal_log import SCAN_LABEL_MIN_AGE_H, SignalLogger # noqa: E402
|
|
||||||
|
|
||||||
CHUNK = 2000
|
|
||||||
|
|
||||||
|
|
||||||
def _counts(con: sqlite3.Connection, symbol: str,
|
|
||||||
labelled: bool = True) -> dict:
|
|
||||||
"""Candidate / labelled / complete / pending counts (read-only).
|
|
||||||
|
|
||||||
The age filter uses logged_at (always an ISO string) as a proxy for
|
|
||||||
the bar close — the labeller itself parses the real bar label."""
|
|
||||||
scans = ("SELECT COUNT(*) FROM signals s WHERE s.symbol = ?"
|
|
||||||
" AND s.indicators_json IS NOT NULL AND s.price > 0")
|
|
||||||
due = (scans + " AND s.logged_at <"
|
|
||||||
f" datetime('now', '-{int(SCAN_LABEL_MIN_AGE_H)} hours')")
|
|
||||||
out = {
|
|
||||||
"scan rows with indicators": con.execute(scans, (symbol,)).fetchone()[0],
|
|
||||||
"of those, window elapsed": con.execute(due, (symbol,)).fetchone()[0],
|
|
||||||
}
|
|
||||||
if labelled:
|
|
||||||
out["labelled"] = con.execute(
|
|
||||||
"SELECT COUNT(*) FROM scan_outcomes").fetchone()[0]
|
|
||||||
out["complete"] = con.execute(
|
|
||||||
"SELECT COUNT(*) FROM scan_outcomes WHERE complete = 1"
|
|
||||||
).fetchone()[0]
|
|
||||||
out["pending (labelled, incomplete)"] = con.execute(
|
|
||||||
"SELECT COUNT(*) FROM scan_outcomes WHERE complete = 0"
|
|
||||||
).fetchone()[0]
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None) -> int:
|
|
||||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
|
|
||||||
ap.add_argument("--db", default=None, help="defaults to signal_log.path")
|
|
||||||
ap.add_argument("--dry-run", action="store_true",
|
|
||||||
help="read-only candidate counts, writes nothing")
|
|
||||||
ap.add_argument("--budget", type=int, default=100000,
|
|
||||||
help="maximum rows to write in this run")
|
|
||||||
ap.add_argument("--symbol", default="XAU/USD")
|
|
||||||
args = ap.parse_args(argv)
|
|
||||||
path = args.db or app_config.signal_log_path()
|
|
||||||
if not os.path.exists(path):
|
|
||||||
print(f"no database at {path}")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if args.dry_run:
|
|
||||||
con = sqlite3.connect(f"file:{os.path.abspath(path)}?mode=ro", uri=True)
|
|
||||||
try:
|
|
||||||
have = {r[0] for r in con.execute(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table'")}
|
|
||||||
if "scan_outcomes" not in have:
|
|
||||||
print("scan_outcomes table not present yet (created on the"
|
|
||||||
" first non-dry run / next logged scan)")
|
|
||||||
for key, value in _counts(con, args.symbol,
|
|
||||||
labelled="scan_outcomes" in have).items():
|
|
||||||
print(f" {key}: {value}")
|
|
||||||
finally:
|
|
||||||
con.close()
|
|
||||||
return 0
|
|
||||||
|
|
||||||
lg = SignalLogger(path)
|
|
||||||
try:
|
|
||||||
print("before:", _counts(lg._conn, args.symbol))
|
|
||||||
written = 0
|
|
||||||
previous_complete = -1
|
|
||||||
while written < args.budget:
|
|
||||||
n = lg.label_scan_outcomes(
|
|
||||||
budget=min(CHUNK, args.budget - written), time_budget=120.0)
|
|
||||||
written += n
|
|
||||||
complete = lg._conn.execute(
|
|
||||||
"SELECT COUNT(*) FROM scan_outcomes WHERE complete = 1"
|
|
||||||
).fetchone()[0]
|
|
||||||
# Stop on a pass that wrote nothing, or one that could not
|
|
||||||
# complete a single further row (only re-labelling stragglers).
|
|
||||||
if n == 0 or complete == previous_complete:
|
|
||||||
break
|
|
||||||
previous_complete = complete
|
|
||||||
print(f"labelled (rows written): {written}")
|
|
||||||
print("after:", _counts(lg._conn, args.symbol))
|
|
||||||
finally:
|
|
||||||
lg.close()
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
migrate_signal_log_news.py — move inline news blobs into news_snapshots.
|
|
||||||
|
|
||||||
Legacy rows store the full RSS headline list (~33 KB) and calendar events
|
|
||||||
(~1.2 KB) inline on EVERY scan row; the same blob repeats ~5× because the
|
|
||||||
news caches outlive a candle. This script interns each distinct blob once
|
|
||||||
(content hash), stamps `headlines_hash` / `events_hash` on the row and
|
|
||||||
NULLs the inline columns. Readers use the `signals_with_news` view or
|
|
||||||
`signal_log.read_news`.
|
|
||||||
|
|
||||||
Idempotent and resumable (batched transactions). Run --vacuum with BOTH
|
|
||||||
live writers (telegram bot, dashboard publisher) stopped — VACUUM needs
|
|
||||||
exclusive access and ~1× the final size of free disk.
|
|
||||||
|
|
||||||
python scripts/migrate_signal_log_news.py --dry-run
|
|
||||||
python scripts/migrate_signal_log_news.py
|
|
||||||
python scripts/migrate_signal_log_news.py --vacuum # bots stopped
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import sys
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
|
|
||||||
import app_config # noqa: E402
|
|
||||||
from signal_log import SignalLogger # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def _hash(text: str) -> str:
|
|
||||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def migrate(con: sqlite3.Connection, batch: int = 500) -> dict:
|
|
||||||
"""Intern legacy inline blobs; returns counters. Caller owns the
|
|
||||||
connection (schema must already exist — open a SignalLogger once)."""
|
|
||||||
stats = {"rows": 0, "headlines_interned": 0, "events_interned": 0}
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
while True:
|
|
||||||
rows = con.execute(
|
|
||||||
"SELECT id, headlines_json, events_json FROM signals"
|
|
||||||
" WHERE (headlines_json IS NOT NULL AND headlines_hash IS NULL)"
|
|
||||||
" OR (events_json IS NOT NULL AND events_hash IS NULL)"
|
|
||||||
" ORDER BY id LIMIT ?", (batch,)).fetchall()
|
|
||||||
if not rows:
|
|
||||||
break
|
|
||||||
con.execute("BEGIN IMMEDIATE")
|
|
||||||
try:
|
|
||||||
for rowid, headlines, events in rows:
|
|
||||||
sets, params = [], []
|
|
||||||
if headlines is not None:
|
|
||||||
h = _hash(headlines)
|
|
||||||
cur = con.execute(
|
|
||||||
"INSERT OR IGNORE INTO news_snapshots"
|
|
||||||
" (hash, kind, body, first_seen, last_seen)"
|
|
||||||
" VALUES (?, 'headlines', ?, ?, ?)",
|
|
||||||
(h, headlines, now, now))
|
|
||||||
stats["headlines_interned"] += cur.rowcount
|
|
||||||
sets += ["headlines_hash=?", "headlines_json=NULL"]
|
|
||||||
params.append(h)
|
|
||||||
if events is not None:
|
|
||||||
h = _hash(events)
|
|
||||||
cur = con.execute(
|
|
||||||
"INSERT OR IGNORE INTO news_snapshots"
|
|
||||||
" (hash, kind, body, first_seen, last_seen)"
|
|
||||||
" VALUES (?, 'events', ?, ?, ?)",
|
|
||||||
(h, events, now, now))
|
|
||||||
stats["events_interned"] += cur.rowcount
|
|
||||||
sets += ["events_hash=?", "events_json=NULL"]
|
|
||||||
params.append(h)
|
|
||||||
params.append(rowid)
|
|
||||||
con.execute(
|
|
||||||
f"UPDATE signals SET {', '.join(sets)} WHERE id=?",
|
|
||||||
params)
|
|
||||||
stats["rows"] += 1
|
|
||||||
con.commit()
|
|
||||||
except Exception:
|
|
||||||
con.rollback()
|
|
||||||
raise
|
|
||||||
return stats
|
|
||||||
|
|
||||||
|
|
||||||
def prune_orphans(con: sqlite3.Connection) -> int:
|
|
||||||
cur = con.execute(
|
|
||||||
"DELETE FROM news_snapshots WHERE hash NOT IN"
|
|
||||||
" (SELECT headlines_hash FROM signals WHERE headlines_hash IS NOT NULL"
|
|
||||||
" UNION SELECT events_hash FROM signals WHERE events_hash IS NOT NULL)")
|
|
||||||
con.commit()
|
|
||||||
return cur.rowcount
|
|
||||||
|
|
||||||
|
|
||||||
def prune_news_older_than(con: sqlite3.Connection, days: int) -> int:
|
|
||||||
"""Drop the news reference (not the row) from rows older than N days,
|
|
||||||
then delete snapshots nothing references any more."""
|
|
||||||
# logged_at is ISO8601 with a 'T' and a +00:00 offset; SQLite's
|
|
||||||
# datetime() returns 'YYYY-MM-DD HH:MM:SS'. Comparing them as raw
|
|
||||||
# strings put every row from the cutoff CALENDAR DAY on the wrong
|
|
||||||
# side ('T' > ' '), so a full day never pruned. Normalise to the
|
|
||||||
# same shape first — all logged_at values are UTC (see _iso()).
|
|
||||||
cur = con.execute(
|
|
||||||
"UPDATE signals SET headlines_hash=NULL, events_hash=NULL"
|
|
||||||
" WHERE substr(replace(logged_at, 'T', ' '), 1, 19)"
|
|
||||||
" < datetime('now', ?)", (f"-{int(days)} days",))
|
|
||||||
con.commit()
|
|
||||||
prune_orphans(con)
|
|
||||||
return cur.rowcount
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None) -> int:
|
|
||||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
|
|
||||||
ap.add_argument("--db", default=None)
|
|
||||||
ap.add_argument("--dry-run", action="store_true")
|
|
||||||
ap.add_argument("--batch", type=int, default=500)
|
|
||||||
ap.add_argument("--prune-news-older-than", type=int, default=None,
|
|
||||||
metavar="DAYS")
|
|
||||||
ap.add_argument("--prune-orphans", action="store_true")
|
|
||||||
ap.add_argument("--vacuum", action="store_true",
|
|
||||||
help="checkpoint + VACUUM (all writers must be stopped)")
|
|
||||||
ap.add_argument("--no-backup", action="store_true")
|
|
||||||
args = ap.parse_args(argv)
|
|
||||||
path = args.db or app_config.signal_log_path()
|
|
||||||
if not os.path.exists(path):
|
|
||||||
print(f"no database at {path}")
|
|
||||||
return 1
|
|
||||||
size0 = os.path.getsize(path)
|
|
||||||
if args.dry_run:
|
|
||||||
con = sqlite3.connect(f"file:{os.path.abspath(path)}?mode=ro", uri=True)
|
|
||||||
n = con.execute(
|
|
||||||
"SELECT COUNT(*) FROM signals WHERE headlines_json IS NOT NULL"
|
|
||||||
" OR events_json IS NOT NULL").fetchone()[0]
|
|
||||||
d = con.execute(
|
|
||||||
"SELECT COUNT(DISTINCT headlines_json) FROM signals"
|
|
||||||
" WHERE headlines_json IS NOT NULL").fetchone()[0]
|
|
||||||
print(f"{n} rows carry inline news blobs ({d} distinct headline "
|
|
||||||
f"blobs); file size {size0 / 1e6:.1f} MB")
|
|
||||||
con.close()
|
|
||||||
return 0
|
|
||||||
if not args.no_backup:
|
|
||||||
backup = f"{path}.pre-news-{datetime.now(timezone.utc):%Y%m%d}"
|
|
||||||
if not os.path.exists(backup):
|
|
||||||
src = sqlite3.connect(path)
|
|
||||||
dst = sqlite3.connect(backup)
|
|
||||||
src.backup(dst)
|
|
||||||
dst.close()
|
|
||||||
src.close()
|
|
||||||
print(f"backup written: {backup}")
|
|
||||||
lg = SignalLogger(path) # applies the additive schema
|
|
||||||
lg.close()
|
|
||||||
con = sqlite3.connect(path)
|
|
||||||
con.execute("PRAGMA busy_timeout=10000")
|
|
||||||
try:
|
|
||||||
print("migrate:", migrate(con, batch=args.batch))
|
|
||||||
if args.prune_news_older_than is not None:
|
|
||||||
print("rows de-referenced:",
|
|
||||||
prune_news_older_than(con, args.prune_news_older_than))
|
|
||||||
if args.prune_orphans:
|
|
||||||
print("orphan snapshots removed:", prune_orphans(con))
|
|
||||||
if args.vacuum:
|
|
||||||
try:
|
|
||||||
con.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
||||||
con.execute("VACUUM")
|
|
||||||
except sqlite3.OperationalError as exc:
|
|
||||||
print(f"VACUUM failed ({exc}) — stop the bot and dashboard "
|
|
||||||
"publisher first, then re-run with --vacuum")
|
|
||||||
return 2
|
|
||||||
finally:
|
|
||||||
con.close()
|
|
||||||
print(f"size {size0 / 1e6:.1f} MB -> {os.path.getsize(path) / 1e6:.1f} MB"
|
|
||||||
+ ("" if args.vacuum else " (run --vacuum to reclaim space)"))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Phase 4 — live shadow scan (data source change test)
|
|
||||||
=====================================================
|
|
||||||
Runs TWO data providers side-by-side at every candle close (M5/M15/H1)
|
|
||||||
and logs how their signals diverge, so the feed switch can be validated
|
|
||||||
under real fetch depths / caching / boundary timing for ≥2 weeks.
|
|
||||||
|
|
||||||
Unlike the history studies, this uses the LIVE fetch path (fetch_ohlcv,
|
|
||||||
forming-candle drop, freshness checks) for both providers, but calls
|
|
||||||
`analyze_from_dataframes(use_news=False)` directly so the (provider-
|
|
||||||
independent) news/DeepSeek budget is not burned on shadow scans. The
|
|
||||||
weekend filter is applied here (skip the whole cycle), and the session/
|
|
||||||
day/hour gates run inside analyze_from_dataframes as in the backtest.
|
|
||||||
|
|
||||||
No orders, no Telegram, no signal-log writes — output is a side-by-side
|
|
||||||
CSV plus a rolling agreement tally.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
venv/bin/python scripts/shadow_scan.py --a oanda --b tiingo \
|
|
||||||
--tfs M5,M15,H1 --out logs/shadow_oanda_tiingo.csv --once
|
|
||||||
# production (2+ weeks):
|
|
||||||
nohup venv/bin/python scripts/shadow_scan.py --a oanda --b tiingo \
|
|
||||||
--out logs/shadow_oanda_tiingo.csv >> logs/shadow_scan.log 2>&1 &
|
|
||||||
|
|
||||||
CSV columns (one row per TF per boundary):
|
|
||||||
ts, tf, a_<field>, b_<field> for direction/grade/setup/tag/entry/sl/tp1,
|
|
||||||
a_price, b_price, a_spread, b_spread, a_warnings, b_warnings,
|
|
||||||
dir_agree, grade_agree, setup_agree
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import csv
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from config import PlatformConfig, Timeframe, XAUUSD
|
|
||||||
from pair_signals.xauusd import make_config
|
|
||||||
from providers import make_data_provider
|
|
||||||
from providers.base import _is_forex_weekend
|
|
||||||
from signal_aggregator import SignalAggregator, SignalReport
|
|
||||||
|
|
||||||
_TF_LOOKUP = {"M5": Timeframe.M5, "M15": Timeframe.M15, "H1": Timeframe.H1,
|
|
||||||
"H4": Timeframe.H4}
|
|
||||||
_TF_MINUTES = {Timeframe.M5: 5, Timeframe.M15: 15, Timeframe.H1: 60,
|
|
||||||
Timeframe.H4: 240}
|
|
||||||
_CONFIRMS = {Timeframe.M5: [Timeframe.M15, Timeframe.H1],
|
|
||||||
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
|
|
||||||
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
|
|
||||||
Timeframe.H4: [Timeframe.H1, Timeframe.D1]}
|
|
||||||
|
|
||||||
FIELDS = ["direction", "grade", "setup", "tag", "entry", "sl", "tp1"]
|
|
||||||
|
|
||||||
|
|
||||||
def seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
elapsed = (now.minute % interval_minutes) * 60 + now.second \
|
|
||||||
+ now.microsecond / 1e6
|
|
||||||
return interval_minutes * 60 - elapsed + buffer
|
|
||||||
|
|
||||||
|
|
||||||
def due_timeframes(wake: datetime, tfs) -> list:
|
|
||||||
minutes = wake.hour * 60 + wake.minute
|
|
||||||
floored = minutes - (minutes % 5)
|
|
||||||
return [tf for tf in tfs if floored % _TF_MINUTES[tf] == 0]
|
|
||||||
|
|
||||||
|
|
||||||
def tag_of(report) -> str:
|
|
||||||
fb = report.bullish_reasons[0] if report.bullish_reasons else ""
|
|
||||||
fbe = report.bearish_reasons[0] if report.bearish_reasons else ""
|
|
||||||
if "XAU PULLBACK" in fb or "XAU PULLBACK" in fbe:
|
|
||||||
return "XAU_PB"
|
|
||||||
if "XAU CHART PATTERN" in fb or "XAU CHART PATTERN" in fbe:
|
|
||||||
return "XAU_CP"
|
|
||||||
if "XAU STRATEGY" in fb:
|
|
||||||
return "XAU"
|
|
||||||
return "GENERIC"
|
|
||||||
|
|
||||||
|
|
||||||
def scan(provider, symbol, tf) -> SignalReport:
|
|
||||||
confirms = _CONFIRMS[tf]
|
|
||||||
cfg = make_config(tf, confirms)
|
|
||||||
agg = SignalAggregator(cfg)
|
|
||||||
agg.data_provider = provider
|
|
||||||
dfs = {}
|
|
||||||
for t in [tf] + confirms:
|
|
||||||
dfs[t] = provider.fetch_ohlcv(symbol, t)
|
|
||||||
return agg.analyze_from_dataframes(symbol, dfs, use_news=False)
|
|
||||||
|
|
||||||
|
|
||||||
def extract(provider, report) -> dict:
|
|
||||||
s = report.trade_setup
|
|
||||||
return {
|
|
||||||
"direction": report.final_signal.value,
|
|
||||||
"grade": report.signal_quality,
|
|
||||||
"setup": 1 if s is not None else 0,
|
|
||||||
"tag": tag_of(report) if s else "",
|
|
||||||
"entry": round(s.entry_price, 3) if s else "",
|
|
||||||
"sl": round(s.stop_loss, 3) if s else "",
|
|
||||||
"tp1": round(s.take_profit_1, 3) if s else "",
|
|
||||||
"price": round(report.current_price, 3),
|
|
||||||
"spread": round(provider.get_spread(XAUUSD), 3),
|
|
||||||
"warnings": " | ".join(report.warnings)[:200],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--a", default="oanda")
|
|
||||||
ap.add_argument("--b", default="tiingo")
|
|
||||||
ap.add_argument("--tfs", default="M5,M15,H1")
|
|
||||||
ap.add_argument("--out", default="logs/shadow_scan.csv")
|
|
||||||
ap.add_argument("--once", action="store_true",
|
|
||||||
help="one immediate scan cycle, then exit")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
tfs = [_TF_LOOKUP[s.strip()] for s in args.tfs.split(",")]
|
|
||||||
pa = make_data_provider(PlatformConfig(), args.a)
|
|
||||||
pb = make_data_provider(PlatformConfig(), args.b)
|
|
||||||
|
|
||||||
out_path = Path(args.out)
|
|
||||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
new_file = not out_path.exists()
|
|
||||||
tally = {"cycles": 0, "dir": 0, "grade": 0, "setup": 0}
|
|
||||||
|
|
||||||
with open(out_path, "a", newline="") as fh:
|
|
||||||
w = csv.writer(fh)
|
|
||||||
if new_file:
|
|
||||||
w.writerow(["ts", "tf"] + [f"a_{x}" for x in FIELDS + ["price", "spread", "warnings"]]
|
|
||||||
+ [f"b_{x}" for x in FIELDS + ["price", "spread", "warnings"]]
|
|
||||||
+ ["dir_agree", "grade_agree", "setup_agree"])
|
|
||||||
|
|
||||||
while True:
|
|
||||||
if args.once:
|
|
||||||
wake = datetime.now(timezone.utc)
|
|
||||||
else:
|
|
||||||
wait = seconds_until_boundary(5, buffer=10)
|
|
||||||
time.sleep(wait)
|
|
||||||
wake = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
if _is_forex_weekend():
|
|
||||||
if args.once:
|
|
||||||
print("weekend — nothing to scan")
|
|
||||||
return
|
|
||||||
continue
|
|
||||||
|
|
||||||
for tf in due_timeframes(wake, tfs):
|
|
||||||
try:
|
|
||||||
ra = scan(pa, XAUUSD, tf)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[{wake:%H:%M:%S}] {args.a} {tf.value} FAILED: {e}")
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
rb = scan(pb, XAUUSD, tf)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[{wake:%H:%M:%S}] {args.b} {tf.value} FAILED: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
ea, eb = extract(pa, ra), extract(pb, rb)
|
|
||||||
da = ea["direction"] == eb["direction"]
|
|
||||||
ga = ea["grade"] == eb["grade"]
|
|
||||||
sa = ea["setup"] == eb["setup"]
|
|
||||||
tally["cycles"] += 1
|
|
||||||
tally["dir"] += da; tally["grade"] += ga; tally["setup"] += sa
|
|
||||||
|
|
||||||
row = [wake.isoformat(), tf.value]
|
|
||||||
row += [ea[x] for x in FIELDS + ["price", "spread", "warnings"]]
|
|
||||||
row += [eb[x] for x in FIELDS + ["price", "spread", "warnings"]]
|
|
||||||
row += [int(da), int(ga), int(sa)]
|
|
||||||
w.writerow(row)
|
|
||||||
fh.flush()
|
|
||||||
|
|
||||||
print(f"[{wake:%Y-%m-%d %H:%M:%S}] {tf.value:<5} "
|
|
||||||
f"{args.a}={ea['direction']:+d}/{ea['grade'] or '-':>2} "
|
|
||||||
f"{args.b}={eb['direction']:+d}/{eb['grade'] or '-':>2} "
|
|
||||||
f"dir={'OK' if da else 'FLIP'} "
|
|
||||||
f"| Δprice {abs(ea['price']-eb['price']):.2f} "
|
|
||||||
f"| agree dir {tally['dir']}/{tally['cycles']} "
|
|
||||||
f"grade {tally['grade']}/{tally['cycles']} "
|
|
||||||
f"setup {tally['setup']}/{tally['cycles']}")
|
|
||||||
|
|
||||||
if args.once:
|
|
||||||
print("shadow scan complete (--once)")
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Phase 2 — signal-level replay parity (data source change test)
|
|
||||||
===============================================================
|
|
||||||
Replays the REAL decision core (`SignalAggregator.analyze_from_dataframes`,
|
|
||||||
news off, use_news=False) over the same window for two providers and
|
|
||||||
compares the *decisions* bar-by-bar: direction, composite/final score,
|
|
||||||
quality grade, gate/setup presence, strategy tag, and entry/SL/TP levels.
|
|
||||||
|
|
||||||
This turns the Phase-1 price-diff stats into flip rates. Feeds are read
|
|
||||||
from the Phase-1 parquet cache (scripts/feed_divergence_study.py writes
|
|
||||||
them); pass --no-cache-fetch only if you want to fetch fresh via
|
|
||||||
fetch_range first.
|
|
||||||
|
|
||||||
Slicing matches gpu_backtest.py exactly: decision at close of bar `pos`,
|
|
||||||
each TF sliced to bars fully closed by that decision time, primary at the
|
|
||||||
shared grid, so the two feeds are compared on identical timestamps.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
venv/bin/python scripts/signal_parity_study.py --a oanda --b tiingo \
|
|
||||||
--tf M15 --stride 8 --days 365
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from config import Timeframe, XAUUSD
|
|
||||||
from pair_signals.xauusd import make_config
|
|
||||||
from signal_aggregator import SignalAggregator
|
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
|
||||||
CACHE_DIR = REPO / "backtest" / "cache"
|
|
||||||
RESEARCH_DIR = REPO / "backtest" / "research"
|
|
||||||
|
|
||||||
BAR_SECONDS = {
|
|
||||||
Timeframe.M1: 60, Timeframe.M5: 300, Timeframe.M15: 900,
|
|
||||||
Timeframe.M30: 1800, Timeframe.H1: 3600, Timeframe.H4: 14400,
|
|
||||||
Timeframe.D1: 86400, Timeframe.W1: 604800,
|
|
||||||
}
|
|
||||||
_TF_LOOKUP = {t.value: t for t in Timeframe}
|
|
||||||
_TF_LOOKUP.update({"M1": Timeframe.M1, "M5": Timeframe.M5,
|
|
||||||
"M15": Timeframe.M15, "M30": Timeframe.M30,
|
|
||||||
"H1": Timeframe.H1, "H4": Timeframe.H4,
|
|
||||||
"D1": Timeframe.D1, "W1": Timeframe.W1})
|
|
||||||
_CONFIRMS = {
|
|
||||||
Timeframe.M5: [Timeframe.M15, Timeframe.H1],
|
|
||||||
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
|
|
||||||
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
|
|
||||||
Timeframe.H4: [Timeframe.H1, Timeframe.D1],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def load_cached(name: str, tf: Timeframe, start: pd.Timestamp,
|
|
||||||
end: pd.Timestamp) -> pd.DataFrame:
|
|
||||||
cp = CACHE_DIR / f"{name}_XAU_USD_{tf.value}_{start:%Y%m%d}_{end:%Y%m%d}.parquet"
|
|
||||||
if not cp.exists():
|
|
||||||
sys.exit(f"missing cache {cp}; run scripts/feed_divergence_study.py "
|
|
||||||
f"--a oanda --b {name} --tfs {tf.value} --days ... first")
|
|
||||||
return pd.read_parquet(cp)
|
|
||||||
|
|
||||||
|
|
||||||
def tag_of(report) -> str:
|
|
||||||
first_bull = report.bullish_reasons[0] if report.bullish_reasons else ""
|
|
||||||
first_bear = report.bearish_reasons[0] if report.bearish_reasons else ""
|
|
||||||
if "XAU PULLBACK" in first_bull or "XAU PULLBACK" in first_bear:
|
|
||||||
return "XAU_PB"
|
|
||||||
if "XAU CHART PATTERN" in first_bull or "XAU CHART PATTERN" in first_bear:
|
|
||||||
return "XAU_CP"
|
|
||||||
if "XAU STRATEGY" in first_bull:
|
|
||||||
return "XAU"
|
|
||||||
return "GENERIC"
|
|
||||||
|
|
||||||
|
|
||||||
def decide(agg, dfs, tss, stride, warmup) -> list:
|
|
||||||
"""Replay decisions over the shared grid, returning one row per bar."""
|
|
||||||
primary = dfs[agg.config.primary_timeframe]
|
|
||||||
dur = pd.Timedelta(seconds=BAR_SECONDS[agg.config.primary_timeframe])
|
|
||||||
|
|
||||||
# positions of sampled bars in the primary frame
|
|
||||||
primary_pos = primary.index.get_indexer(tss)
|
|
||||||
rows = []
|
|
||||||
for i in range(0, len(tss), stride):
|
|
||||||
ts = tss[i]
|
|
||||||
pos = int(primary_pos[i])
|
|
||||||
if pos < warmup:
|
|
||||||
continue
|
|
||||||
slices = {}
|
|
||||||
for tf, full in dfs.items():
|
|
||||||
cutoff = ts # decision_time = ts + dur; bars closed by ts
|
|
||||||
cut = int(full.index.searchsorted(cutoff, side="right"))
|
|
||||||
if cut <= 0:
|
|
||||||
continue
|
|
||||||
slices[tf] = full.iloc[:cut]
|
|
||||||
if agg.config.primary_timeframe not in slices:
|
|
||||||
continue
|
|
||||||
r = agg.analyze_from_dataframes(XAUUSD, slices, use_news=False)
|
|
||||||
ts_setup = r.trade_setup
|
|
||||||
rows.append({
|
|
||||||
"ts": ts,
|
|
||||||
"direction": r.final_signal.value,
|
|
||||||
"final_score": r.final_score,
|
|
||||||
"primary_score": r.primary_score,
|
|
||||||
"grade": r.signal_quality,
|
|
||||||
"has_setup": ts_setup is not None,
|
|
||||||
"tag": tag_of(r) if ts_setup else "",
|
|
||||||
"entry": ts_setup.entry_price if ts_setup else np.nan,
|
|
||||||
"sl": ts_setup.stop_loss if ts_setup else np.nan,
|
|
||||||
"tp1": ts_setup.take_profit_1 if ts_setup else np.nan,
|
|
||||||
"tp2": ts_setup.take_profit_2 if ts_setup else np.nan,
|
|
||||||
})
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--a", required=True)
|
|
||||||
ap.add_argument("--b", required=True)
|
|
||||||
ap.add_argument("--tf", default="M15")
|
|
||||||
ap.add_argument("--stride", type=int, default=1,
|
|
||||||
help="sample every Nth shared bar (default 1)")
|
|
||||||
ap.add_argument("--days", type=int, default=365)
|
|
||||||
ap.add_argument("--end", default=None)
|
|
||||||
ap.add_argument("--warmup", type=int, default=300)
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
tf = _TF_LOOKUP.get(args.tf)
|
|
||||||
if tf is None:
|
|
||||||
sys.exit(f"unknown tf {args.tf}")
|
|
||||||
confirms = _CONFIRMS[tf]
|
|
||||||
tfs = [tf] + confirms
|
|
||||||
|
|
||||||
end = pd.Timestamp(args.end, tz="UTC") if args.end \
|
|
||||||
else pd.Timestamp.now(tz="UTC")
|
|
||||||
start = end - pd.Timedelta(days=args.days)
|
|
||||||
|
|
||||||
cfg = make_config(tf, confirms)
|
|
||||||
agg = SignalAggregator(cfg)
|
|
||||||
|
|
||||||
a_dfs, b_dfs = {}, {}
|
|
||||||
for t in tfs:
|
|
||||||
a_dfs[t] = load_cached(args.a, t, start, end)
|
|
||||||
b_dfs[t] = load_cached(args.b, t, start, end)
|
|
||||||
|
|
||||||
# shared grid = primary timestamps present in both feeds
|
|
||||||
shared = a_dfs[tf].index.intersection(b_dfs[tf].index)
|
|
||||||
|
|
||||||
t0 = time.time()
|
|
||||||
print(f"replaying {len(shared)} shared {tf.value} bars "
|
|
||||||
f"(stride {args.stride} → ~{len(shared)//args.stride}) on {args.a}...")
|
|
||||||
a_rows = decide(agg, a_dfs, shared, args.stride, args.warmup)
|
|
||||||
print(f" {args.a}: {len(a_rows)} decisions in {time.time()-t0:.1f}s")
|
|
||||||
t0 = time.time()
|
|
||||||
b_rows = decide(agg, b_dfs, shared, args.stride, args.warmup)
|
|
||||||
print(f" {args.b}: {len(b_rows)} decisions in {time.time()-t0:.1f}s")
|
|
||||||
|
|
||||||
A = pd.DataFrame(a_rows).set_index("ts")
|
|
||||||
B = pd.DataFrame(b_rows).set_index("ts")
|
|
||||||
common = A.index.intersection(B.index)
|
|
||||||
A, B = A.loc[common], B.loc[common]
|
|
||||||
n = len(common)
|
|
||||||
|
|
||||||
print(f"\n{'=' * 78}")
|
|
||||||
print(f"SIGNAL-LEVEL PARITY — {tf.value} — {args.a} vs {args.b} "
|
|
||||||
f"({n:,} bars)")
|
|
||||||
print("=" * 78)
|
|
||||||
|
|
||||||
def pct(x): return f"{100 * x:.2f}%"
|
|
||||||
|
|
||||||
same_dir = (A["direction"] == B["direction"])
|
|
||||||
same_grade = (A["grade"] == B["grade"])
|
|
||||||
same_setup = (A["has_setup"] == B["has_setup"])
|
|
||||||
same_all = same_dir & same_grade & same_setup
|
|
||||||
|
|
||||||
print(f"\nIDENTITY")
|
|
||||||
print(f" same direction : {pct(same_dir.mean())}")
|
|
||||||
print(f" same grade : {pct(same_grade.mean())}")
|
|
||||||
print(f" same setup presence : {pct(same_setup.mean())}")
|
|
||||||
print(f" same dir+grade+setup : {pct(same_all.mean())}")
|
|
||||||
|
|
||||||
both_sig = (A["direction"] != 0) & (B["direction"] != 0)
|
|
||||||
flips = both_sig & (A["direction"] != B["direction"])
|
|
||||||
opp = flips & (A["direction"] == -B["direction"])
|
|
||||||
print(f"\nDIRECTION")
|
|
||||||
print(f" bars both actionable : {both_sig.sum():,} "
|
|
||||||
f"({pct(both_sig.mean())})")
|
|
||||||
print(f" direction flips : {flips.sum():,} "
|
|
||||||
f"({pct(flips.sum()/max(both_sig.sum(),1))} of actionable)")
|
|
||||||
print(f" opposite-sign flips : {opp.sum():,}")
|
|
||||||
|
|
||||||
grade_a = set(A.loc[A["has_setup"], "grade"])
|
|
||||||
print(f"\nGRADE (of {args.a} setups, grades seen: {sorted(grade_a)})")
|
|
||||||
changed = A.loc[both_sig, "grade"] != B.loc[both_sig, "grade"]
|
|
||||||
print(f" grade changed on actionable bars: "
|
|
||||||
f"{changed.sum():,} ({pct(changed.mean())})")
|
|
||||||
|
|
||||||
# score deltas on shared bars
|
|
||||||
ds = (A["final_score"] - B["final_score"]).abs()
|
|
||||||
dp = (A["primary_score"] - B["primary_score"]).abs()
|
|
||||||
print(f"\nSCORE Δ ({args.a} − {args.b})")
|
|
||||||
print(f" final_score mean {ds.mean():6.2f} p95 {ds.quantile(.95):6.2f} "
|
|
||||||
f"max {ds.max():6.2f}")
|
|
||||||
print(f" primary_score mean {dp.mean():6.2f} p95 {dp.quantile(.95):6.2f} "
|
|
||||||
f"max {dp.max():6.2f}")
|
|
||||||
|
|
||||||
# setup price diffs on bars where BOTH generated a setup
|
|
||||||
both_setup = A["has_setup"] & B["has_setup"]
|
|
||||||
if both_setup.any():
|
|
||||||
print(f"\nSETUP LEVELS (both-setup bars: {both_setup.sum():,})")
|
|
||||||
for col, label in (("entry", "entry"), ("sl", "SL"), ("tp1", "TP1"),
|
|
||||||
("tp2", "TP2")):
|
|
||||||
d = (A.loc[both_setup, col] - B.loc[both_setup, col]).abs()
|
|
||||||
print(f" {label:<6} mean ${d.mean():6.3f} p95 ${d.quantile(.95):7.3f} "
|
|
||||||
f"max ${d.max():7.2f}")
|
|
||||||
|
|
||||||
# tag disagreement on both-setup bars
|
|
||||||
if both_setup.any():
|
|
||||||
same_tag = (A.loc[both_setup, "tag"] == B.loc[both_setup, "tag"])
|
|
||||||
print(f" same strategy tag : {pct(same_tag.mean())}")
|
|
||||||
|
|
||||||
# where do flips cluster (hour)?
|
|
||||||
flip_hours = A.loc[flips].index.hour.value_counts().sort_index()
|
|
||||||
if len(flip_hours):
|
|
||||||
print(f"\nFLIPS BY UTC HOUR")
|
|
||||||
for h, c in flip_hours.items():
|
|
||||||
print(f" {h:02d}: {c}")
|
|
||||||
|
|
||||||
out = {
|
|
||||||
"tf": tf.value, "a": args.a, "b": args.b, "bars": n,
|
|
||||||
"same_dir": same_dir.mean(), "same_grade": same_grade.mean(),
|
|
||||||
"same_setup": same_setup.mean(), "same_all": same_all.mean(),
|
|
||||||
"flip_rate_actionable": flips.sum() / max(both_sig.sum(), 1),
|
|
||||||
"mean_abs_final_score_delta": ds.mean(),
|
|
||||||
"mean_abs_primary_score_delta": dp.mean(),
|
|
||||||
}
|
|
||||||
print(f"\n{out}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
+9
-228
@@ -5,11 +5,9 @@ Combines multi-timeframe technical analysis with optional news context
|
|||||||
into a graded actionable signal.
|
into a graded actionable signal.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
@@ -21,9 +19,8 @@ _NY_TZ = ZoneInfo("America/New_York")
|
|||||||
from chart_patterns import detect_chart_pattern
|
from chart_patterns import detect_chart_pattern
|
||||||
from config import PlatformConfig, SignalStrength, Timeframe
|
from config import PlatformConfig, SignalStrength, Timeframe
|
||||||
from providers import BaseDataProvider, make_data_provider
|
from providers import BaseDataProvider, make_data_provider
|
||||||
from engine import TechnicalEngine, TechnicalAnalysis, regime_weight_table
|
from engine import TechnicalEngine, TechnicalAnalysis
|
||||||
from news_engine import NewsEngine, FundamentalContext
|
from news_engine import NewsEngine, FundamentalContext
|
||||||
import signal_log
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -58,22 +55,14 @@ class TradeSetup:
|
|||||||
risk_pips: float
|
risk_pips: float
|
||||||
reward_pips: float
|
reward_pips: float
|
||||||
pip_value: float
|
pip_value: float
|
||||||
# Entry archetype — selects trail params and explains the setup to
|
# Entry archetype ("GENERIC", "XAU_PB") — selects trail params.
|
||||||
# advisory consumers. setup_pattern is populated only by XAU_CP.
|
|
||||||
strategy: str = "GENERIC"
|
strategy: str = "GENERIC"
|
||||||
setup_pattern: Optional[str] = None
|
|
||||||
# Strategy cost assumption, not a broker/data-provider quote.
|
|
||||||
round_trip_cost: Optional[float] = None
|
|
||||||
# Trailing exit params stamped from the PRODUCING config, so per-TF
|
# Trailing exit params stamped from the PRODUCING config, so per-TF
|
||||||
# factory overrides (e.g. M15 arm 0.70, M5 dist 0.85) reach every
|
# factory overrides (e.g. M15 arm 0.70, M5 dist 0.85) reach every
|
||||||
# consumer. None = legacy/unstamped setup — formatters fall back to
|
# consumer. None = legacy/unstamped setup — formatters fall back to
|
||||||
# platform defaults; 0.0 means trailing is genuinely disabled.
|
# platform defaults; 0.0 means trailing is genuinely disabled.
|
||||||
trail_arm_r: Optional[float] = None
|
trail_arm_r: Optional[float] = None
|
||||||
trail_dist_r: Optional[float] = None
|
trail_dist_r: Optional[float] = None
|
||||||
# Post-decision stake multiplier from position_sizing (advisory
|
|
||||||
# overlay). None = unsized — every gate/exit path ignores it, and
|
|
||||||
# the backtester's R accounting is unaffected unless it opts in.
|
|
||||||
stake_multiplier: Optional[float] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -107,16 +96,6 @@ class SignalReport:
|
|||||||
panel_confidence: float = 0.0
|
panel_confidence: float = 0.0
|
||||||
panel_rationale: str = ""
|
panel_rationale: str = ""
|
||||||
panel_takes: List[Dict] = field(default_factory=list)
|
panel_takes: List[Dict] = field(default_factory=list)
|
||||||
panel_created_at: str = ""
|
|
||||||
panel_cache_status: str = ""
|
|
||||||
panel_payload_hash: str = ""
|
|
||||||
panel_verdict_source: str = "" # "macro" / "chair" / ""
|
|
||||||
|
|
||||||
# Violent-move watch line (advisory/DISPLAY-ONLY). Stamped post-hoc
|
|
||||||
# by surfaces via violent_watch.attach_watch — never populated
|
|
||||||
# inside analyze_from_dataframes (backtest parity) and never read by
|
|
||||||
# any gate, grade or setup generator.
|
|
||||||
watch_line: str = ""
|
|
||||||
|
|
||||||
final_signal: SignalStrength = SignalStrength.NEUTRAL
|
final_signal: SignalStrength = SignalStrength.NEUTRAL
|
||||||
final_score: float = 0.0
|
final_score: float = 0.0
|
||||||
@@ -129,22 +108,12 @@ class SignalReport:
|
|||||||
bullish_reasons: List[str] = field(default_factory=list)
|
bullish_reasons: List[str] = field(default_factory=list)
|
||||||
bearish_reasons: List[str] = field(default_factory=list)
|
bearish_reasons: List[str] = field(default_factory=list)
|
||||||
warnings: List[str] = field(default_factory=list)
|
warnings: List[str] = field(default_factory=list)
|
||||||
# Warnings the XAU_CP acceptance removed from `warnings` (metadata
|
|
||||||
# only — logged so the gates a CP trade bypassed stay auditable).
|
|
||||||
cleaned_warnings: List[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
current_price: float = 0.0
|
current_price: float = 0.0
|
||||||
spread_estimate: float = 0.0
|
spread_estimate: float = 0.0
|
||||||
market_regime: str = ""
|
market_regime: str = ""
|
||||||
volatility_regime: str = ""
|
volatility_regime: str = ""
|
||||||
exhaustion_count: int = 0
|
exhaustion_count: int = 0
|
||||||
# Record-keeping only (no gate reads these): the penalty tags that
|
|
||||||
# fired in _compute_final_signal (tag -> RAW pre-decay amount), the
|
|
||||||
# decayed total actually subtracted from confidence, and whether the
|
|
||||||
# momentum-fading half of the exhaustion block was true.
|
|
||||||
penalty_tags: Dict[str, float] = field(default_factory=dict)
|
|
||||||
penalty_total: float = 0.0
|
|
||||||
momentum_fading: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class SignalAggregator:
|
class SignalAggregator:
|
||||||
@@ -173,14 +142,8 @@ class SignalAggregator:
|
|||||||
self,
|
self,
|
||||||
symbol: Optional[str] = None,
|
symbol: Optional[str] = None,
|
||||||
context: Optional[FundamentalContext] = None,
|
context: Optional[FundamentalContext] = None,
|
||||||
log_source: Optional[str] = None,
|
|
||||||
) -> SignalReport:
|
) -> SignalReport:
|
||||||
"""Live-mode entry: fetch all needed timeframes then delegate.
|
"""Live-mode entry: fetch all needed timeframes then delegate."""
|
||||||
|
|
||||||
Every report produced here (including weekend/day-filter early
|
|
||||||
returns) is persisted to the signal-history DB via _log_signal —
|
|
||||||
fail-safe, and never reached by the backtester, which calls
|
|
||||||
analyze_from_dataframes directly (live == backtest parity)."""
|
|
||||||
if symbol is None:
|
if symbol is None:
|
||||||
symbol = self.config.symbol
|
symbol = self.config.symbol
|
||||||
|
|
||||||
@@ -201,7 +164,6 @@ class SignalAggregator:
|
|||||||
report.trade_setup = None
|
report.trade_setup = None
|
||||||
report.warnings.append("WEEKEND FILTER: XAU/USD market closed — no liquidity")
|
report.warnings.append("WEEKEND FILTER: XAU/USD market closed — no liquidity")
|
||||||
report.action_recommendation = "NO TRADE — Weekend (market closed)"
|
report.action_recommendation = "NO TRADE — Weekend (market closed)"
|
||||||
self._log_signal(report, log_source, blocked=True)
|
|
||||||
return report
|
return report
|
||||||
|
|
||||||
if xau_cfg.allowed_weekdays and now_utc.weekday() not in xau_cfg.allowed_weekdays:
|
if xau_cfg.allowed_weekdays and now_utc.weekday() not in xau_cfg.allowed_weekdays:
|
||||||
@@ -219,7 +181,6 @@ class SignalAggregator:
|
|||||||
f"DAY FILTER: {day_names.get(now_utc.weekday(), '?')} not in allowed days ({allowed})"
|
f"DAY FILTER: {day_names.get(now_utc.weekday(), '?')} not in allowed days ({allowed})"
|
||||||
)
|
)
|
||||||
report.action_recommendation = f"NO TRADE — Only trading {allowed}"
|
report.action_recommendation = f"NO TRADE — Only trading {allowed}"
|
||||||
self._log_signal(report, log_source, blocked=True)
|
|
||||||
return report
|
return report
|
||||||
|
|
||||||
timeframes = [self.config.primary_timeframe] + list(
|
timeframes = [self.config.primary_timeframe] + list(
|
||||||
@@ -297,97 +258,8 @@ class SignalAggregator:
|
|||||||
f"{symbol}: live session filter errored — filter SKIPPED this scan"
|
f"{symbol}: live session filter errored — filter SKIPPED this scan"
|
||||||
)
|
)
|
||||||
|
|
||||||
self._log_signal(report, log_source, dfs)
|
|
||||||
return report
|
return report
|
||||||
|
|
||||||
def _log_signal(
|
|
||||||
self, report: SignalReport, source: Optional[str],
|
|
||||||
dataframes: Optional[Dict[Timeframe, pd.DataFrame]] = None,
|
|
||||||
blocked: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""Persist the scan to the signal-history DB (signal_log.py).
|
|
||||||
|
|
||||||
Fail-safe by contract — a logging failure must never break a
|
|
||||||
live scan. The news context re-fetch rides the NewsEngine's
|
|
||||||
class-level caches, so it costs nothing within a scan and
|
|
||||||
matches the context the signal just used; if it fails, the row
|
|
||||||
is still written, just without the news JSON blobs. ``blocked``
|
|
||||||
marks weekend/day early returns: no news fetch, no snapshots —
|
|
||||||
a ~1 KB liveness row (or nothing, per signal_log.log_blocked_scans)."""
|
|
||||||
context = None
|
|
||||||
if not blocked:
|
|
||||||
try:
|
|
||||||
context = self._get_news_engine().get_fundamental_context()
|
|
||||||
except Exception:
|
|
||||||
logger.debug("signal log: news context unavailable",
|
|
||||||
exc_info=True)
|
|
||||||
extra = {"scan_kind": "blocked" if blocked else "scan"}
|
|
||||||
for key, fingerprint in (
|
|
||||||
("config_fingerprint", self.gate_config_fingerprint),
|
|
||||||
("engine_fingerprint", self.engine_config_fingerprint)):
|
|
||||||
try:
|
|
||||||
extra[key] = fingerprint()
|
|
||||||
except Exception:
|
|
||||||
logger.debug("signal log: %s unavailable", key, exc_info=True)
|
|
||||||
signal_log.log_signal(
|
|
||||||
report,
|
|
||||||
context=context,
|
|
||||||
source=source or "live",
|
|
||||||
timeframe=self.config.primary_timeframe.value,
|
|
||||||
dataframes=dataframes,
|
|
||||||
extra=extra,
|
|
||||||
)
|
|
||||||
|
|
||||||
def gate_config_fingerprint(self) -> str:
|
|
||||||
"""Short hash of the gate configuration this process runs with.
|
|
||||||
|
|
||||||
Logged on every row so two surfaces disagreeing on the same bar
|
|
||||||
(2026-08-19: the dashboard process applied a different blocked-
|
|
||||||
hours list than the bot for a day) is diagnosable from the DB."""
|
|
||||||
xs = self.config.xau_strategy
|
|
||||||
bt = self.config.backtest
|
|
||||||
payload = {
|
|
||||||
"primary": self.config.primary_timeframe.value,
|
|
||||||
"confirm": [tf.value for tf in self.config.confirmation_timeframes],
|
|
||||||
"blocked_hours": list(xs.blocked_hours_utc),
|
|
||||||
"sell_blocked_hours": list(xs.sell_blocked_hours_utc),
|
|
||||||
"allowed_weekdays": list(xs.allowed_weekdays),
|
|
||||||
"blocked_regimes": list(xs.blocked_regimes),
|
|
||||||
"sell_blocked_regimes": list(xs.sell_blocked_regimes),
|
|
||||||
"sell_min_quality": list(xs.sell_min_quality),
|
|
||||||
"sell_vol_regimes": list(xs.sell_vol_regimes),
|
|
||||||
"big_candle": xs.big_candle_atr_multiple,
|
|
||||||
"cp": bool(xs.cp_enabled),
|
|
||||||
"pb": bool(xs.m5_pullback_enabled),
|
|
||||||
"session": [bt.session_filter, bt.session_start_hour,
|
|
||||||
bt.session_end_hour],
|
|
||||||
"regime_filter": bool(bt.regime_filter),
|
|
||||||
}
|
|
||||||
return hashlib.sha1(
|
|
||||||
json.dumps(payload, sort_keys=True, default=str).encode("utf-8")
|
|
||||||
).hexdigest()[:12]
|
|
||||||
|
|
||||||
def engine_config_fingerprint(self) -> str:
|
|
||||||
"""Short hash of the SCORING configuration this process runs with.
|
|
||||||
|
|
||||||
gate_config_fingerprint covers the post-grade gates; this covers
|
|
||||||
what produced the composite in the first place — indicator
|
|
||||||
parameters, signal thresholds/geometry, the regime weight
|
|
||||||
multipliers actually in force (override included) and the
|
|
||||||
timeframe set. Logged on every row so indicator-weight research
|
|
||||||
can tell which rows share a scoring configuration."""
|
|
||||||
payload = {
|
|
||||||
"technical": asdict(self.config.technical),
|
|
||||||
"signals": asdict(self.config.signals),
|
|
||||||
"regime_weights": regime_weight_table(self.config.technical),
|
|
||||||
"primary": self.config.primary_timeframe.value,
|
|
||||||
"confirm": [tf.value for tf in self.config.confirmation_timeframes],
|
|
||||||
"display_name": self.config.display_name,
|
|
||||||
}
|
|
||||||
return hashlib.sha1(
|
|
||||||
json.dumps(payload, sort_keys=True, default=str).encode("utf-8")
|
|
||||||
).hexdigest()[:12]
|
|
||||||
|
|
||||||
def _apply_regime_filter_live(
|
def _apply_regime_filter_live(
|
||||||
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
|
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -748,7 +620,6 @@ class SignalAggregator:
|
|||||||
reward_pips=1.5 * sl_dist / pip_value,
|
reward_pips=1.5 * sl_dist / pip_value,
|
||||||
pip_value=pip_value,
|
pip_value=pip_value,
|
||||||
strategy="XAU_PB",
|
strategy="XAU_PB",
|
||||||
round_trip_cost=self.config.signals.round_trip_cost,
|
|
||||||
trail_arm_r=self.config.signals.pb_trail_arm_r,
|
trail_arm_r=self.config.signals.pb_trail_arm_r,
|
||||||
trail_dist_r=self.config.signals.pb_trail_dist_r,
|
trail_dist_r=self.config.signals.pb_trail_dist_r,
|
||||||
)
|
)
|
||||||
@@ -830,9 +701,9 @@ class SignalAggregator:
|
|||||||
close_h = None
|
close_h = None
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"{report.symbol}: bar-close hour underivable — "
|
f"{report.symbol}: bar-close hour underivable — "
|
||||||
f"CP SELL hour gate FAILS CLOSED (short rejected)"
|
f"SELL hour gate skipped"
|
||||||
)
|
)
|
||||||
if close_h is None or close_h in cfg.sell_blocked_hours_utc:
|
if close_h in cfg.sell_blocked_hours_utc:
|
||||||
_reject("sell_hour")
|
_reject("sell_hour")
|
||||||
return
|
return
|
||||||
if self.config.primary_timeframe == Timeframe.M5:
|
if self.config.primary_timeframe == Timeframe.M5:
|
||||||
@@ -886,8 +757,6 @@ class SignalAggregator:
|
|||||||
reward_pips=height / pip_value,
|
reward_pips=height / pip_value,
|
||||||
pip_value=pip_value,
|
pip_value=pip_value,
|
||||||
strategy="XAU_CP",
|
strategy="XAU_CP",
|
||||||
setup_pattern=pat["pattern"],
|
|
||||||
round_trip_cost=self.config.signals.round_trip_cost,
|
|
||||||
trail_arm_r=self.config.signals.trail_arm_r,
|
trail_arm_r=self.config.signals.trail_arm_r,
|
||||||
trail_dist_r=self.config.signals.trail_dist_r,
|
trail_dist_r=self.config.signals.trail_dist_r,
|
||||||
)
|
)
|
||||||
@@ -903,82 +772,8 @@ class SignalAggregator:
|
|||||||
f"height {height / atr:.1f} ATR")
|
f"height {height / atr:.1f} ATR")
|
||||||
target = report.bullish_reasons if is_buy else report.bearish_reasons
|
target = report.bullish_reasons if is_buy else report.bearish_reasons
|
||||||
target.insert(0, marker)
|
target.insert(0, marker)
|
||||||
self._clean_accepted_cp_warnings(report)
|
|
||||||
_reject("accepted")
|
_reject("accepted")
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _clean_accepted_cp_warnings(report: SignalReport) -> None:
|
|
||||||
"""Drop superseded composite-path warnings after XAU_CP accepts.
|
|
||||||
|
|
||||||
This is metadata-only. Pattern detection, gates, confidence,
|
|
||||||
exhaustion state, setup geometry, and signal selection are already
|
|
||||||
final when this runs.
|
|
||||||
"""
|
|
||||||
stale_prefixes = (
|
|
||||||
"SPREAD GATE:",
|
|
||||||
"EXHAUSTION BLOCK",
|
|
||||||
"EXHAUSTED TREND FILTER:",
|
|
||||||
"MTF OPPOSITION:",
|
|
||||||
"SELL FILTER: quality",
|
|
||||||
"SELL FILTER: volatility",
|
|
||||||
"SELL FILTER: regime",
|
|
||||||
"SELL FILTER: ",
|
|
||||||
"M5 FILTER: |score|",
|
|
||||||
"M5 FILTER: no MTF agreement",
|
|
||||||
"M5 FILTER: no confirmation TF",
|
|
||||||
"M5 FILTER: SELL quality",
|
|
||||||
"M5 FILTER: SELL blocked",
|
|
||||||
)
|
|
||||||
extension_prefixes = (
|
|
||||||
"TREND EXHAUSTION",
|
|
||||||
"NO PULLBACK",
|
|
||||||
"FAR FROM EMA200",
|
|
||||||
"Extended — RSI",
|
|
||||||
"Weak pullback",
|
|
||||||
)
|
|
||||||
filtered: List[str] = []
|
|
||||||
dropped: List[str] = []
|
|
||||||
for warning in report.warnings:
|
|
||||||
if warning.startswith(stale_prefixes):
|
|
||||||
dropped.append(warning)
|
|
||||||
continue
|
|
||||||
if (warning.startswith("REGIME FILTER: '")
|
|
||||||
and warning.endswith("blocked for XAU")):
|
|
||||||
dropped.append(warning)
|
|
||||||
continue
|
|
||||||
if (warning.startswith("M5 FILTER: '")
|
|
||||||
and "volatility" in warning):
|
|
||||||
dropped.append(warning)
|
|
||||||
continue
|
|
||||||
filtered.append(warning)
|
|
||||||
|
|
||||||
extension_items = [
|
|
||||||
(index, warning)
|
|
||||||
for index, warning in enumerate(filtered)
|
|
||||||
if warning.startswith(extension_prefixes)
|
|
||||||
]
|
|
||||||
if not extension_items:
|
|
||||||
report.warnings[:] = filtered
|
|
||||||
report.cleaned_warnings.extend(dropped)
|
|
||||||
return
|
|
||||||
first_index = extension_items[0][0]
|
|
||||||
priority = {prefix: rank for rank, prefix in enumerate(extension_prefixes)}
|
|
||||||
strongest = min(
|
|
||||||
(warning for _, warning in extension_items),
|
|
||||||
key=lambda warning: next(
|
|
||||||
rank for prefix, rank in priority.items()
|
|
||||||
if warning.startswith(prefix)),
|
|
||||||
)
|
|
||||||
kept = [
|
|
||||||
warning for warning in filtered
|
|
||||||
if not warning.startswith(extension_prefixes)
|
|
||||||
]
|
|
||||||
kept.insert(first_index, strongest)
|
|
||||||
dropped.extend(
|
|
||||||
warning for _, warning in extension_items if warning != strongest)
|
|
||||||
report.warnings[:] = kept
|
|
||||||
report.cleaned_warnings.extend(dropped)
|
|
||||||
|
|
||||||
# ── Public: bar-replayable entry point ─────────────────────────
|
# ── Public: bar-replayable entry point ─────────────────────────
|
||||||
|
|
||||||
def analyze_from_dataframes(
|
def analyze_from_dataframes(
|
||||||
@@ -1210,16 +1005,11 @@ class SignalAggregator:
|
|||||||
close_h = None
|
close_h = None
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"{report.symbol}: bar-close hour underivable — "
|
f"{report.symbol}: bar-close hour underivable — "
|
||||||
f"SELL hour gate FAILS CLOSED (short blocked)"
|
f"SELL hour gate skipped"
|
||||||
)
|
)
|
||||||
# Fail CLOSED: an underivable close hour blocks the short
|
if close_h in self.config.xau_strategy.sell_blocked_hours_utc:
|
||||||
# (`None in [15, 17]` used to pass silently). Never fires
|
|
||||||
# on parseable timestamps, so pinned books are unchanged.
|
|
||||||
if (close_h is None
|
|
||||||
or close_h in self.config.xau_strategy.sell_blocked_hours_utc):
|
|
||||||
hour_txt = "??" if close_h is None else f"{close_h:02}"
|
|
||||||
report.warnings.append(
|
report.warnings.append(
|
||||||
f"SELL FILTER: {hour_txt}:00 UTC entry blocked for shorts"
|
f"SELL FILTER: {close_h:02}:00 UTC entry blocked for shorts"
|
||||||
)
|
)
|
||||||
report.signal_quality = "D"
|
report.signal_quality = "D"
|
||||||
report.trade_setup = None
|
report.trade_setup = None
|
||||||
@@ -1370,8 +1160,6 @@ class SignalAggregator:
|
|||||||
risk_pips=round(sl_distance / pip_value, 1),
|
risk_pips=round(sl_distance / pip_value, 1),
|
||||||
reward_pips=round(tp_distance / pip_value, 1),
|
reward_pips=round(tp_distance / pip_value, 1),
|
||||||
pip_value=pip_value,
|
pip_value=pip_value,
|
||||||
strategy="XAU_ATR",
|
|
||||||
round_trip_cost=self.config.signals.round_trip_cost,
|
|
||||||
trail_arm_r=self.config.signals.trail_arm_r,
|
trail_arm_r=self.config.signals.trail_arm_r,
|
||||||
trail_dist_r=self.config.signals.trail_dist_r,
|
trail_dist_r=self.config.signals.trail_dist_r,
|
||||||
)
|
)
|
||||||
@@ -1592,12 +1380,6 @@ class SignalAggregator:
|
|||||||
if total_penalty >= MAX_PENALTY:
|
if total_penalty >= MAX_PENALTY:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Record-keeping for position_sizing / signal-log forensics.
|
|
||||||
# Purely additive — nothing below reads these fields.
|
|
||||||
report.penalty_tags = {name: float(amount) for name, amount in penalties}
|
|
||||||
report.penalty_total = float(total_penalty)
|
|
||||||
report.momentum_fading = bool(momentum_fading)
|
|
||||||
|
|
||||||
confidence -= total_penalty
|
confidence -= total_penalty
|
||||||
confidence = max(0.0, min(95.0, confidence))
|
confidence = max(0.0, min(95.0, confidence))
|
||||||
|
|
||||||
@@ -1792,7 +1574,6 @@ class SignalAggregator:
|
|||||||
risk_pips=risk_pips,
|
risk_pips=risk_pips,
|
||||||
reward_pips=reward_pips,
|
reward_pips=reward_pips,
|
||||||
pip_value=pip_value,
|
pip_value=pip_value,
|
||||||
round_trip_cost=self.config.signals.round_trip_cost,
|
|
||||||
trail_arm_r=self.config.signals.trail_arm_r,
|
trail_arm_r=self.config.signals.trail_arm_r,
|
||||||
trail_dist_r=self.config.signals.trail_dist_r,
|
trail_dist_r=self.config.signals.trail_dist_r,
|
||||||
)
|
)
|
||||||
|
|||||||
-1644
File diff suppressed because it is too large
Load Diff
@@ -1,351 +0,0 @@
|
|||||||
/* Gold Desk — XAU/USD dashboard styles
|
|
||||||
*
|
|
||||||
* Builds on Bootstrap 5.3 color modes (data-bs-theme). Only app-specific
|
|
||||||
* tokens and components live here; layout, typography, tables, badges,
|
|
||||||
* cards, alerts, modals, etc. come from Bootstrap.
|
|
||||||
*/
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--xau-font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
||||||
--xau-font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Consolas, Menlo, monospace;
|
|
||||||
--xau-gold: #f5b301;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Forced dark theme (owner decision 2026-08-16): dark background, pure
|
|
||||||
* white text (no gray), vivid red/green for data accents. Loaded after
|
|
||||||
* Bootstrap so these overrides win. */
|
|
||||||
[data-bs-theme="dark"] {
|
|
||||||
--bs-body-bg: #0a0e14;
|
|
||||||
--bs-secondary-bg: #111823;
|
|
||||||
--bs-tertiary-bg: #0e141d;
|
|
||||||
--bs-body-color: #ffffff;
|
|
||||||
--bs-emphasis-color: #ffffff;
|
|
||||||
--bs-heading-color: #ffffff;
|
|
||||||
--bs-secondary-color: rgba(255, 255, 255, 0.92);
|
|
||||||
--bs-tertiary-color: rgba(255, 255, 255, 0.8);
|
|
||||||
--bs-border-color: rgba(255, 255, 255, 0.14);
|
|
||||||
--bs-card-bg: #0e141d;
|
|
||||||
--bs-card-border-color: rgba(255, 255, 255, 0.12);
|
|
||||||
--bs-modal-bg: #0e141d;
|
|
||||||
--bs-success: #22c55e;
|
|
||||||
--bs-success-rgb: 34, 197, 94;
|
|
||||||
--bs-success-text-emphasis: #4ade80;
|
|
||||||
--bs-success-bg-subtle: rgba(34, 197, 94, 0.16);
|
|
||||||
--bs-success-border-subtle: rgba(34, 197, 94, 0.45);
|
|
||||||
--bs-danger: #ef4444;
|
|
||||||
--bs-danger-rgb: 239, 68, 68;
|
|
||||||
--bs-danger-text-emphasis: #f87171;
|
|
||||||
--bs-danger-bg-subtle: rgba(239, 68, 68, 0.16);
|
|
||||||
--bs-danger-border-subtle: rgba(239, 68, 68, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
font-family: var(--xau-font-sans);
|
|
||||||
letter-spacing: -0.005em;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
background-color: var(--bs-body-bg);
|
|
||||||
color: var(--bs-body-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.num, .xau-price, .xau-symbol {
|
|
||||||
font-family: var(--xau-font-mono);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xau-symbol {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--xau-gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xau-price {
|
|
||||||
line-height: 1;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-dot {
|
|
||||||
display: inline-block;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--bs-success);
|
|
||||||
margin-right: 5px;
|
|
||||||
animation: pulse 2.4s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-dot.closed {
|
|
||||||
background: var(--bs-warning);
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
50% { opacity: 0.35; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* signal-coloured accents */
|
|
||||||
.sig-buy { --xau-signal: var(--bs-success); }
|
|
||||||
.sig-sell { --xau-signal: var(--bs-danger); }
|
|
||||||
.sig-neutral { --xau-signal: var(--bs-secondary); }
|
|
||||||
|
|
||||||
.border-signal {
|
|
||||||
border-left: 3px solid var(--xau-signal, var(--bs-secondary)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-signal {
|
|
||||||
color: var(--xau-signal, var(--bs-secondary)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tf-name {
|
|
||||||
font-family: var(--xau-font-mono);
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: var(--bs-primary);
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tf-selector {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tf-select {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0.7rem 0.8rem;
|
|
||||||
border: 1px solid var(--bs-border-color);
|
|
||||||
border-radius: var(--bs-border-radius);
|
|
||||||
background: var(--bs-tertiary-bg);
|
|
||||||
color: var(--bs-body-color);
|
|
||||||
text-align: left;
|
|
||||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tf-select:hover {
|
|
||||||
border-color: rgba(var(--bs-primary-rgb), 0.65);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tf-select[aria-pressed="true"] {
|
|
||||||
border-color: var(--xau-gold);
|
|
||||||
background: rgba(245, 179, 1, 0.09);
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(245, 179, 1, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tf-select .tf-name {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-card {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-signal {
|
|
||||||
font-family: var(--xau-font-mono);
|
|
||||||
font-size: clamp(1.55rem, 3vw, 2.35rem);
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-meta {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-reasons > div + div {
|
|
||||||
margin-top: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* score bar: zero-centered diverging with midpoint tick */
|
|
||||||
.scorebar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scorebar .gauge-container {
|
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 60px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: var(--bs-secondary-bg);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scorebar .gauge-container::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 2px;
|
|
||||||
background: var(--bs-secondary);
|
|
||||||
transform: translateX(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scorebar .fill {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
width: var(--score-width);
|
|
||||||
transition: width 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scorebar .fill.pos {
|
|
||||||
left: 50%;
|
|
||||||
background: var(--bs-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scorebar .fill.neg {
|
|
||||||
right: 50%;
|
|
||||||
background: var(--bs-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scorebar .val {
|
|
||||||
width: 58px;
|
|
||||||
text-align: right;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scorebar .lbl {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--bs-secondary);
|
|
||||||
width: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* rail chips in hero */
|
|
||||||
.rail-chip {
|
|
||||||
border: 1px solid var(--bs-border-color);
|
|
||||||
border-radius: var(--bs-border-radius);
|
|
||||||
background: var(--bs-tertiary-bg);
|
|
||||||
color: var(--bs-body-color);
|
|
||||||
font-family: var(--xau-font-mono);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
padding: 0.3rem 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-chip:hover, .rail-chip[aria-current="true"] {
|
|
||||||
border-color: var(--xau-gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-chip:focus-visible, .tf-select:focus-visible, .wire-item:focus-visible,
|
|
||||||
.cal-event:focus-visible {
|
|
||||||
outline: 2px solid var(--xau-gold);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* detail dialog sections */
|
|
||||||
.d-stat .k {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--bs-secondary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.d-stat .v {
|
|
||||||
font-size: 1.15rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.d-table {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-table {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.d-table .ind-k {
|
|
||||||
font-family: var(--xau-font-mono);
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wbar {
|
|
||||||
display: block;
|
|
||||||
height: 4px;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: var(--bs-secondary-bg);
|
|
||||||
margin-top: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wbar > i {
|
|
||||||
display: block;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: var(--bs-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* news */
|
|
||||||
.wire-item {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
border: 0;
|
|
||||||
border-radius: var(--bs-border-radius);
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
text-align: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-item:hover {
|
|
||||||
background: rgba(var(--bs-primary-rgb), 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cal-event {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
text-align: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cal-event:hover {
|
|
||||||
color: var(--bs-primary);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-score {
|
|
||||||
font-family: var(--xau-font-mono);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 2px 7px;
|
|
||||||
border-radius: 5px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* calendar highlight for next upcoming event */
|
|
||||||
.cal-next-up td:first-child {
|
|
||||||
box-shadow: inset 4px 0 0 var(--bs-primary);
|
|
||||||
padding-left: 0.75rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* reduced motion */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.live-dot { animation: none; }
|
|
||||||
.scorebar .fill { transition: none; }
|
|
||||||
* { transition: none !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 575.98px) {
|
|
||||||
.tf-selector {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-card .card-body {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-chip {
|
|
||||||
flex: 1 1 calc(50% - 0.5rem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1430
-114
File diff suppressed because it is too large
Load Diff
@@ -1,968 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
/* Gold Desk — XAU/USD dashboard frontend
|
|
||||||
*
|
|
||||||
* Reads snapshot.json + news.json published by dashboard_publisher.py and
|
|
||||||
* renders a responsive, keyboard-accessible dashboard. Uses Bootstrap 5 for
|
|
||||||
* layout/components/modals and a small custom CSS file for app-specific
|
|
||||||
* visuals (score bar, signal colours, mono fonts).
|
|
||||||
*
|
|
||||||
* This file is intentionally self-contained: it mirrors a few constants from
|
|
||||||
* the Python signal pipeline so the HTML does not depend on telegram_bot.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const IND_ORDER = ["ema_trend", "rsi", "macd", "bollinger", "adx", "roc", "keltner", "divergence", "candlestick"];
|
|
||||||
const IND_SHORT = {
|
|
||||||
ema_trend: "EMA", rsi: "RSI", macd: "MACD", bollinger: "BB",
|
|
||||||
adx: "ADX", roc: "ROC", keltner: "KC", divergence: "Div", candlestick: "Cndl"
|
|
||||||
};
|
|
||||||
const ARROW = { STRONG_BUY: "⇈", BUY: "↑", NEUTRAL: "→", SELL: "↓", STRONG_SELL: "⇊" };
|
|
||||||
const TF_LABEL = { "5min": "M5", "15min": "M15", "1h": "H1", "4h": "H4" };
|
|
||||||
const TF_ORDER = ["5min", "15min", "1h", "4h"];
|
|
||||||
const TF_SECONDS = { "5min": 300, "15min": 900, "1h": 3600, "4h": 14400 };
|
|
||||||
const FALLBACK_POLL_MS = 60000;
|
|
||||||
const RETRY_MS = 15000;
|
|
||||||
const HIGH_IMPACT_PREF = "goldDeskHighImpactOnly";
|
|
||||||
const TIMEFRAME_PREF = "goldDeskSelectedTimeframe";
|
|
||||||
|
|
||||||
const LEVEL_LABEL = {
|
|
||||||
ema_fast: "EMA fast", ema_medium: "EMA medium", ema_slow: "EMA slow", ema_trend: "EMA trend",
|
|
||||||
bb_upper: "Bollinger upper", bb_mid: "Bollinger mid", bb_lower: "Bollinger lower",
|
|
||||||
kc_upper: "Keltner upper", kc_mid: "Keltner mid", kc_lower: "Keltner lower",
|
|
||||||
swing_high_1: "Swing high 1", swing_high_2: "Swing high 2", swing_high_3: "Swing high 3",
|
|
||||||
swing_low_1: "Swing low 1", swing_low_2: "Swing low 2", swing_low_3: "Swing low 3"
|
|
||||||
};
|
|
||||||
|
|
||||||
let snapshot = null, news = null;
|
|
||||||
let clockOffset = 0;
|
|
||||||
let lastPollOk = null;
|
|
||||||
let lastSnapText = null, lastNewsText = null;
|
|
||||||
let refetchTimer = null;
|
|
||||||
let fallbackTimer = null;
|
|
||||||
let pollInFlight = false;
|
|
||||||
let pollQueued = false;
|
|
||||||
let selectedTf = "5min";
|
|
||||||
|
|
||||||
let detailTf = null;
|
|
||||||
let ndState = null;
|
|
||||||
const aiMemo = new Map();
|
|
||||||
const aiErr = new Map();
|
|
||||||
const aiPending = new Set();
|
|
||||||
|
|
||||||
let tfModal = null;
|
|
||||||
let newsModal = null;
|
|
||||||
let tfModalOpen = false;
|
|
||||||
let newsModalOpen = false;
|
|
||||||
|
|
||||||
const $ = id => document.getElementById(id);
|
|
||||||
|
|
||||||
/* ── helpers ──────────────────────────────────────────────────── */
|
|
||||||
const esc = s => String(s ?? "").replace(/[&<>"']/g,
|
|
||||||
c => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[c]));
|
|
||||||
|
|
||||||
const fmt$ = v => v == null ? "—" : Number(v).toLocaleString("en-US",
|
|
||||||
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
||||||
|
|
||||||
const sigClass = s => s?.includes("BUY") ? "buy" : s?.includes("SELL") ? "sell" : "neutral";
|
|
||||||
const sigLabel = s => (s || "NEUTRAL").replace("_", " ");
|
|
||||||
const nowServer = () => Date.now() / 1000 + clockOffset;
|
|
||||||
|
|
||||||
function localGet(key) {
|
|
||||||
try { return window.localStorage.getItem(key); } catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function localSet(key, value) {
|
|
||||||
try { window.localStorage.setItem(key, value); } catch { /* storage is optional */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function announce(message) {
|
|
||||||
const live = $("dashboard-live");
|
|
||||||
if (live) live.textContent = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtCountdown(secs) {
|
|
||||||
if (secs == null || !isFinite(secs)) return "--:--";
|
|
||||||
secs = Math.max(0, Math.floor(secs));
|
|
||||||
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60;
|
|
||||||
const mm = String(m).padStart(2, "0"), ss = String(s).padStart(2, "0");
|
|
||||||
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtAge(iso) {
|
|
||||||
if (!iso) return "";
|
|
||||||
const secs = nowServer() - Date.parse(iso) / 1000;
|
|
||||||
if (secs < 90) return `${Math.max(0, Math.round(secs))}s ago`;
|
|
||||||
if (secs < 5400) return `${Math.round(secs / 60)}m ago`;
|
|
||||||
if (secs < 172800) return `${Math.round(secs / 3600)}h ago`;
|
|
||||||
return `${Math.round(secs / 86400)}d ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const signed = (v, dp = 2) => v == null ? "—" : (v >= 0 ? "+" : "") + Number(v).toFixed(dp);
|
|
||||||
const badgeCls = s => s === "buy" ? "bg-success-subtle text-success-emphasis border-success-subtle"
|
|
||||||
: s === "sell" ? "bg-danger-subtle text-danger-emphasis border-danger-subtle"
|
|
||||||
: "bg-secondary-subtle text-secondary-emphasis border-secondary-subtle";
|
|
||||||
|
|
||||||
/* ── theme ────────────────────────────────────────────────────── */
|
|
||||||
// Dark theme is forced site-wide (owner decision 2026-08-16): dark
|
|
||||||
// background, pure-white text, red/green data accents.
|
|
||||||
document.documentElement.setAttribute("data-bs-theme", "dark");
|
|
||||||
|
|
||||||
/* ── hero ─────────────────────────────────────────────────────── */
|
|
||||||
function renderHero() {
|
|
||||||
if (!snapshot) return;
|
|
||||||
let price = null;
|
|
||||||
for (const tf of TF_ORDER) {
|
|
||||||
const p = snapshot.timeframes[tf]?.report?.current_price;
|
|
||||||
if (p) { price = p; break; }
|
|
||||||
}
|
|
||||||
$("hero-price").textContent = price ? fmt$(price) : "—";
|
|
||||||
|
|
||||||
$("hero-rail").innerHTML = TF_ORDER.map(tf => {
|
|
||||||
const r = snapshot.timeframes[tf]?.report;
|
|
||||||
const sig = r?.signal || null;
|
|
||||||
const cls = sigClass(sig);
|
|
||||||
const arrow = sig ? (ARROW[sig] || "→") : "·";
|
|
||||||
const score = r?.score != null ? r.score.toFixed(0) : "–";
|
|
||||||
const textColor = cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary";
|
|
||||||
return `<button type="button" class="rail-chip" data-select-tf="${tf}"
|
|
||||||
aria-current="${tf === selectedTf ? "true" : "false"}"
|
|
||||||
title="${esc(sigLabel(sig))} (score ${esc(score)}) — select ${TF_LABEL[tf]} workspace">
|
|
||||||
<span class="tf-name">${TF_LABEL[tf]}</span>
|
|
||||||
<span class="${textColor} fw-bold">${arrow}</span>
|
|
||||||
<span>${esc(score)}</span>
|
|
||||||
</button>`;
|
|
||||||
}).join("");
|
|
||||||
|
|
||||||
const closed = snapshot.market_closed;
|
|
||||||
$("hero-status").innerHTML =
|
|
||||||
`<span class="live-dot${closed ? " closed" : ""}"></span>${closed ? "CLOSED" : "OPEN"}`;
|
|
||||||
$("banner-closed").classList.toggle("d-none", !closed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── market context strip ─────────────────────────────────────── */
|
|
||||||
function renderContext() {
|
|
||||||
if (!news) return;
|
|
||||||
$("context").classList.remove("d-none");
|
|
||||||
$("ctx-blurb").innerHTML = news.llm_summary
|
|
||||||
? `<strong>Market context:</strong> ${esc(news.llm_summary)}`
|
|
||||||
: `<strong>Market context:</strong> <span class="text-secondary">no summary available</span>`;
|
|
||||||
|
|
||||||
let chips = "";
|
|
||||||
for (const cur of ["XAU", "USD"]) {
|
|
||||||
const v = news.sentiment?.[cur];
|
|
||||||
if (v == null) continue;
|
|
||||||
const cls = v > 0.05 ? "text-bg-success" : v < -0.05 ? "text-bg-danger" : "text-bg-secondary";
|
|
||||||
chips += `<span class="badge ${cls}" title="Mean headline sentiment for ${cur} (−1…+1)">
|
|
||||||
${cur} news <span class="num">${v >= 0 ? "+" : ""}${v.toFixed(2)}</span></span>`;
|
|
||||||
}
|
|
||||||
let risk = "clear", riskDesc = "No significant events";
|
|
||||||
if (snapshot) {
|
|
||||||
for (const tf of TF_ORDER) {
|
|
||||||
const r = snapshot.timeframes[tf]?.report;
|
|
||||||
if (r?.event_risk && r.event_risk !== "clear") { risk = r.event_risk; riskDesc = r.event_description; break; }
|
|
||||||
if (r?.event_description) riskDesc = r.event_description;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (news.high_impact_warning) { risk = "danger"; riskDesc = news.warning_message || riskDesc; }
|
|
||||||
const riskCls = risk === "clear" ? "text-bg-success"
|
|
||||||
: risk === "danger" ? "text-bg-danger"
|
|
||||||
: "text-bg-warning";
|
|
||||||
chips += `<span class="badge ${riskCls}" title="${esc(riskDesc)}">event risk: ${risk.toUpperCase()}</span>`;
|
|
||||||
$("ctx-chips").innerHTML = chips;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── timeframe cards ──────────────────────────────────────────── */
|
|
||||||
function scoreBar(label, score) {
|
|
||||||
const v = Math.max(-100, Math.min(100, score ?? 0));
|
|
||||||
const width = (Math.abs(v) / 2).toFixed(1);
|
|
||||||
return `<div class="scorebar">
|
|
||||||
<span class="lbl">${label}</span>
|
|
||||||
<div class="gauge-container" aria-hidden="true">
|
|
||||||
<i class="fill ${v >= 0 ? "pos" : "neg"}" style="--score-width:${width}%"></i>
|
|
||||||
</div>
|
|
||||||
<span class="val num ${v > 0 ? "text-success" : v < 0 ? "text-danger" : ""}">${v >= 0 ? "+" : ""}${v.toFixed(1)}</span>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupCard(ts) {
|
|
||||||
if (!ts) return "";
|
|
||||||
const dir = ts.direction === "BUY" ? "buy" : "sell";
|
|
||||||
const dirIcon = ts.direction === "BUY" ? "bi-arrow-up-circle-fill" : "bi-arrow-down-circle-fill";
|
|
||||||
const trail = (ts.trail_arm_r != null && ts.trail_dist_r != null)
|
|
||||||
? `arm ${ts.trail_arm_r}R · dist ${ts.trail_dist_r}R` : "platform default";
|
|
||||||
return `<div class="card border-signal sig-${dir} bg-body-tertiary">
|
|
||||||
<div class="card-body py-2 px-3">
|
|
||||||
<div class="d-flex align-items-baseline gap-2 mb-2">
|
|
||||||
<span class="fw-bold text-signal sig-${dir}"><i class="bi ${dirIcon} me-1"></i>${ts.direction}</span>
|
|
||||||
<span class="small text-secondary font-monospace">${esc(ts.strategy || "")}</span>
|
|
||||||
<span class="small text-secondary ms-auto font-monospace">R:R ${(+ts.risk_reward).toFixed(1)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive"><table class="table table-sm table-borderless mb-0">
|
|
||||||
<tbody><tr><td class="text-secondary"><i class="bi bi-box-arrow-in-right me-1"></i>Entry</td><td class="num">${fmt$(ts.entry)}</td>
|
|
||||||
<td class="text-secondary ps-4"><i class="bi bi-flag-fill me-1 text-success"></i>TP1</td><td class="num text-success">${fmt$(ts.take_profit_1)}</td></tr>
|
|
||||||
<tr><td class="text-secondary"><i class="bi bi-shield-x me-1 text-danger"></i>Stop</td><td class="num text-danger">${fmt$(ts.stop_loss)}</td>
|
|
||||||
<td class="text-secondary ps-4"><i class="bi bi-flag-fill me-1 text-success"></i>TP2</td><td class="num text-success">${fmt$(ts.take_profit_2)}</td></tr>
|
|
||||||
<tr><td class="text-secondary"><i class="bi bi-signpost-split me-1"></i>Trail</td><td class="num" colspan="3">${esc(trail)}</td></tr></tbody>
|
|
||||||
</table></div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function workspacePanel(tf) {
|
|
||||||
const entry = snapshot?.timeframes?.[tf] || {};
|
|
||||||
const r = entry.report;
|
|
||||||
if (!r) {
|
|
||||||
return `<article class="card workspace-card border-signal sig-neutral" id="workspace-panel" aria-labelledby="workspace-panel-heading">
|
|
||||||
<div class="card-body py-4 text-center text-secondary">
|
|
||||||
<h3 class="tf-name mb-2" id="workspace-panel-heading">${TF_LABEL[tf]}</h3>
|
|
||||||
${entry.status === "error"
|
|
||||||
? `<span class="badge text-bg-danger">SCAN UNAVAILABLE</span><div class="small mt-2">Showing again when the next scan succeeds.</div>`
|
|
||||||
: "Warming up — no completed scan for this timeframe yet."}
|
|
||||||
</div>
|
|
||||||
</article>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cls = sigClass(r.signal);
|
|
||||||
const textCls = cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary";
|
|
||||||
const qHi = r.quality === "A+" || r.quality === "A";
|
|
||||||
const pa = r.primary_analysis;
|
|
||||||
const meta = [r.market_regime, r.volatility_regime && r.volatility_regime + " volatility",
|
|
||||||
r.mtf_agreement ? "MTF aligned" : "MTF mixed"].filter(Boolean).join(" · ");
|
|
||||||
const facts = `<div class="row row-cols-2 row-cols-md-4 g-2 mt-1">
|
|
||||||
${tile("Confidence", r.confidence != null ? Math.round(r.confidence) + "%" : "—")}
|
|
||||||
${tile("Quality", esc(r.quality || "—"), qHi ? "text-primary" : "")}
|
|
||||||
${tile("Event risk", esc((r.event_risk || "—").toUpperCase()), r.event_risk === "clear" ? "text-success" : r.event_risk ? "text-warning" : "")}
|
|
||||||
${tile("MTF", r.mtf_agreement ? "aligned" : "mixed", r.mtf_agreement ? "text-success" : "")}
|
|
||||||
</div>`;
|
|
||||||
const setup = r.trade_setup
|
|
||||||
? setupCard(r.trade_setup)
|
|
||||||
: `<div class="text-secondary small">No setup published. The signal has not cleared its setup gate.</div>`;
|
|
||||||
return `<article class="card workspace-card border-signal sig-${cls}" id="workspace-panel" aria-labelledby="workspace-panel-heading">
|
|
||||||
<div class="card-body p-3 p-lg-4">
|
|
||||||
<div class="row g-4">
|
|
||||||
<div class="col-lg-7 workspace-meta">
|
|
||||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3">
|
|
||||||
<div>
|
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap mb-2">
|
|
||||||
<h3 class="tf-name mb-0" id="workspace-panel-heading">${TF_LABEL[tf]} signal</h3>
|
|
||||||
<span class="badge ${badgeCls(cls)}">${esc(sigLabel(r.signal))}</span>
|
|
||||||
${r.quality ? `<span class="badge ${qHi ? "text-bg-primary" : "text-bg-secondary"}">quality ${esc(r.quality)}</span>` : ""}
|
|
||||||
${entry.status === "error" ? `<span class="badge text-bg-danger">STALE</span>` : ""}
|
|
||||||
</div>
|
|
||||||
<div class="workspace-signal ${textCls}">${esc(r.action || sigLabel(r.signal))}</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" data-open-detail="${tf}">
|
|
||||||
Full analysis <i class="bi bi-arrow-up-right ms-1"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
${scoreBar("score", pa ? pa.composite_score : r.score)}
|
|
||||||
<div class="small text-secondary mt-2">${esc(meta)}</div>
|
|
||||||
${facts}
|
|
||||||
<div class="workspace-reasons mt-3 pt-3 border-top">${listBlock(r) || "<div class=\"small text-secondary\">No supporting reasons recorded on this scan.</div>"}</div>
|
|
||||||
<div class="small text-secondary mt-3">${entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : ""} · closes in <span class="num" data-close="${entry.next_close_epoch || ""}" data-interval="${TF_SECONDS[tf]}">--:--</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-5">
|
|
||||||
<div class="small text-uppercase text-secondary fw-bold mb-2">Trade plan</div>
|
|
||||||
${setup}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCards() {
|
|
||||||
if (!snapshot) return;
|
|
||||||
if (!TF_LABEL[selectedTf]) selectedTf = "5min";
|
|
||||||
const selector = TF_ORDER.map(tf => {
|
|
||||||
const entry = snapshot.timeframes[tf] || {};
|
|
||||||
const r = entry.report;
|
|
||||||
const cls = r ? sigClass(r.signal) : "neutral";
|
|
||||||
const arrow = r ? (ARROW[r.signal] || "→") : "·";
|
|
||||||
const score = r?.score != null ? signed(r.score, 0) : "—";
|
|
||||||
const signal = r ? sigLabel(r.signal) : entry.status === "error" ? "UNAVAILABLE" : "WARMING";
|
|
||||||
return `<button type="button" class="tf-select" data-select-tf="${tf}" aria-pressed="${tf === selectedTf}">
|
|
||||||
<span class="d-flex align-items-center justify-content-between gap-2">
|
|
||||||
<span class="tf-name">${TF_LABEL[tf]}</span>
|
|
||||||
<span class="${cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary"} fw-bold">${arrow}</span>
|
|
||||||
</span>
|
|
||||||
<span class="small ${cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary"}">${esc(signal)}</span>
|
|
||||||
<span class="small num float-end">${esc(score)}</span>
|
|
||||||
</button>`;
|
|
||||||
}).join("");
|
|
||||||
$("tf-grid").innerHTML = `<div class="tf-selector">${selector}</div>${workspacePanel(selectedTf)}`;
|
|
||||||
tick();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── timeframe detail dialog ──────────────────────────────────── */
|
|
||||||
const tile = (k, v, cls = "") =>
|
|
||||||
`<div class="col"><div class="card d-stat bg-body-tertiary h-100"><div class="card-body p-2">
|
|
||||||
<span class="k">${esc(k)}</span>
|
|
||||||
<span class="v ${cls}">${v}</span>
|
|
||||||
</div></div></div>`;
|
|
||||||
|
|
||||||
const SEC_ICON = {
|
|
||||||
"At a glance": "bi-speedometer2",
|
|
||||||
"Trade setup": "bi-bullseye",
|
|
||||||
"Why this signal": "bi-lightbulb",
|
|
||||||
"Multi-timeframe confirmation": "bi-layers",
|
|
||||||
"News context": "bi-newspaper",
|
|
||||||
"AI panel (advisory)": "bi-robot",
|
|
||||||
"Scan detail": "bi-clock-history",
|
|
||||||
"Score": "bi-graph-up",
|
|
||||||
"From the source": "bi-quote",
|
|
||||||
"AI gold-impact summary": "bi-stars",
|
|
||||||
"Timing": "bi-alarm",
|
|
||||||
"Numbers": "bi-calculator"
|
|
||||||
};
|
|
||||||
const sec = (title, html) => {
|
|
||||||
if (!html) return "";
|
|
||||||
const icon = SEC_ICON[title];
|
|
||||||
return `<section class="mb-4"><h6 class="text-uppercase text-secondary small fw-bold mb-2 d-flex align-items-center gap-2">${icon ? `<i class="bi ${icon} text-primary"></i>` : ""}<span>${esc(title)}</span><span class="flex-fill border-bottom"></span></h6>${html}</section>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
function indTable(src) {
|
|
||||||
const rows = IND_ORDER.map(name => {
|
|
||||||
const i = src?.[name];
|
|
||||||
const k = `<td class="ind-k">${IND_SHORT[name]}</td>`;
|
|
||||||
if (!i) return `<tr>${k}<td class="text-secondary">— absent</td>
|
|
||||||
<td class="num text-end">—</td><td class="num text-end">—</td><td class="num text-end">—</td>
|
|
||||||
<td class="text-secondary small">no signal this scan</td></tr>`;
|
|
||||||
const w = i.weight == null ? null : +i.weight;
|
|
||||||
return `<tr>${k}
|
|
||||||
<td class="fw-bold ${sigClass(i.signal) === "buy" ? "text-success" : sigClass(i.signal) === "sell" ? "text-danger" : "text-secondary"}">${esc(sigLabel(i.signal))}</td>
|
|
||||||
<td class="num text-end">${i.value != null ? (+i.value).toFixed(2) : "—"}</td>
|
|
||||||
<td class="num text-end">${i.confidence != null ? Math.round(i.confidence) : "—"}</td>
|
|
||||||
<td class="num text-end">${w != null ? w.toFixed(1) : "—"}${w != null
|
|
||||||
? `<i class="wbar"><i style="width:${Math.min(100, w / 2 * 100)}%"></i></i>` : ""}</td>
|
|
||||||
<td class="text-secondary small">${esc(i.description || "")}</td></tr>`;
|
|
||||||
}).join("");
|
|
||||||
return `<div class="detail-table"><table class="table table-sm d-table"><thead><tr>
|
|
||||||
<th>Indicator</th><th>Signal</th><th class="text-end">Value</th><th class="text-end">Conf</th>
|
|
||||||
<th class="text-end">Weight</th><th>Reading</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ladder(levels, price) {
|
|
||||||
if (!levels) return "";
|
|
||||||
const atr = levels.atr;
|
|
||||||
const rows = Object.keys(LEVEL_LABEL)
|
|
||||||
.filter(k => levels[k] != null)
|
|
||||||
.map(k => ({ label: LEVEL_LABEL[k], v: +levels[k] }));
|
|
||||||
if (!rows.length) return "";
|
|
||||||
if (price != null) rows.push({ here: true, v: +price });
|
|
||||||
rows.sort((a, b) => b.v - a.v);
|
|
||||||
const body = rows.map(row => {
|
|
||||||
if (row.here) return `<tr class="table-primary fw-bold"><td><i class="bi bi-geo-alt-fill me-1"></i>current price</td>
|
|
||||||
<td class="num text-end">${fmt$(row.v)}</td><td class="num text-end">—</td><td class="small text-secondary">spot</td></tr>`;
|
|
||||||
const d = price != null ? row.v - price : null;
|
|
||||||
const cls = d == null ? "" : d > 0 ? "text-danger" : "text-success";
|
|
||||||
const icon = d == null ? "" : d > 0 ? "bi-arrow-up-short" : "bi-arrow-down-short";
|
|
||||||
return `<tr>
|
|
||||||
<td class="text-secondary">${esc(row.label)}</td>
|
|
||||||
<td class="num text-end ${cls}">${fmt$(row.v)}</td>
|
|
||||||
<td class="num text-end ${cls}">${d == null ? "" : signed(d) + (atr ? ` · ${signed(d / atr, 2)} ATR` : "")}</td>
|
|
||||||
<td class="small ${cls}">${d == null ? "" : `<i class="bi ${icon}"></i>${d > 0 ? "resistance" : "support"}`}</td></tr>`;
|
|
||||||
}).join("");
|
|
||||||
return `<div class="detail-table"><table class="table table-sm ladder"><thead><tr><th>Level</th><th class="text-end">Price</th>
|
|
||||||
<th class="text-end">vs. spot</th><th>Role</th></tr></thead><tbody>${body}</tbody></table></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function analysisBlock(a, price) {
|
|
||||||
if (!a) return "";
|
|
||||||
const pats = a.pattern_signals?.length
|
|
||||||
? `<div class="d-flex flex-wrap gap-1 mb-2">${a.pattern_signals.map(p =>
|
|
||||||
`<span class="badge text-bg-primary bg-opacity-10">${esc(p)}</span>`).join("")}</div>` : "";
|
|
||||||
const atr = a.atr_context
|
|
||||||
? `<div class="small text-secondary mb-2">${esc(a.atr_context.description ||
|
|
||||||
"ATR " + (+a.atr_context.value).toFixed(2))}</div>` : "";
|
|
||||||
const lv = ladder(a.key_levels, price);
|
|
||||||
return indTable(a.indicators) + atr + pats +
|
|
||||||
(lv ? `<div class="small text-secondary fw-semibold mt-2 mb-1">Key levels</div>${lv}` : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function listBlock(r) {
|
|
||||||
const group = (title, arr, cls, icon) => arr?.length
|
|
||||||
? `<div class="small text-secondary fw-semibold mt-2 mb-1">${title} (${arr.length})</div>
|
|
||||||
<div class="small">${arr.map(t => `<div class="${cls}"><i class="bi ${icon} me-1"></i>${esc(t)}</div>`).join("")}</div>` : "";
|
|
||||||
// Violent-move watch: display-only advisory line, stamped post-hoc by
|
|
||||||
// the publisher. Empty unless the feature is enabled.
|
|
||||||
const watch = r.watch_line
|
|
||||||
? `<div class="small text-warning fw-semibold mt-2">${esc(r.watch_line)}</div>`
|
|
||||||
: "";
|
|
||||||
return watch
|
|
||||||
+ group("Warnings", r.warnings, "text-warning", "bi-exclamation-triangle-fill")
|
|
||||||
+ group("Bullish", r.bullish_reasons, "text-success", "bi-arrow-up-circle-fill")
|
|
||||||
+ group("Bearish", r.bearish_reasons, "text-danger", "bi-arrow-down-circle-fill");
|
|
||||||
}
|
|
||||||
|
|
||||||
function detailHTML(tf) {
|
|
||||||
const entry = snapshot?.timeframes?.[tf] || {};
|
|
||||||
const r = entry.report;
|
|
||||||
if (!r) return `<div class="text-center text-secondary py-4">${entry.status === "error"
|
|
||||||
? `Scan error — ${esc(entry.error || "unknown")}`
|
|
||||||
: "Warming up — no completed scan for this timeframe yet."}</div>`;
|
|
||||||
|
|
||||||
const pa = r.primary_analysis;
|
|
||||||
const price = r.current_price;
|
|
||||||
const cls = sigClass(r.signal);
|
|
||||||
const textCls = cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary";
|
|
||||||
const borderCls = cls === "buy" ? "border-start border-success border-4"
|
|
||||||
: cls === "sell" ? "border-start border-danger border-4"
|
|
||||||
: "border-start border-secondary border-4";
|
|
||||||
|
|
||||||
let out = `<div class="card ${borderCls} mb-3"><div class="card-body">
|
|
||||||
<div class="fw-bold fs-5 ${textCls}">${esc(r.action || sigLabel(r.signal))}</div>
|
|
||||||
${r.news_description || r.event_description
|
|
||||||
? `<div class="small text-secondary">${esc([r.event_description, r.news_description].filter(Boolean).join(" · "))}</div>`
|
|
||||||
: ""}
|
|
||||||
</div></div>`;
|
|
||||||
|
|
||||||
const ns = r.news_sentiment;
|
|
||||||
out += sec("At a glance", `<div class="row row-cols-2 row-cols-md-3 row-cols-lg-4 g-2">
|
|
||||||
${tile("Signal", esc(sigLabel(r.signal)), textCls)}
|
|
||||||
${tile("Score", signed(r.score, 1), textCls)}
|
|
||||||
${tile("Confidence", r.confidence != null ? Math.round(r.confidence) + "%" : "—")}
|
|
||||||
${tile("Quality", esc(r.quality || "—"))}
|
|
||||||
${tile("Regime", esc(r.market_regime || "—"))}
|
|
||||||
${tile("Volatility", esc(r.volatility_regime || "—"))}
|
|
||||||
${tile("Event risk", esc((r.event_risk || "—").toUpperCase()),
|
|
||||||
r.event_risk === "clear" ? "text-success" : r.event_risk ? "text-warning" : "")}
|
|
||||||
${tile("News sentiment", ns != null ? signed(ns, 2) : "—",
|
|
||||||
ns > 0.05 ? "text-success" : ns < -0.05 ? "text-danger" : "")}
|
|
||||||
${tile("MTF", r.mtf_agreement ? "aligned" : "mixed", r.mtf_agreement ? "text-success" : "")}
|
|
||||||
${tile("Primary score", signed(r.primary_score, 1), sigClass(r.primary_signal) === "buy" ? "text-success" : sigClass(r.primary_signal) === "sell" ? "text-danger" : "")}
|
|
||||||
${tile("Exhaustion", esc(r.exhaustion_count ?? "—"), r.exhaustion_count ? "text-warning" : "")}
|
|
||||||
</div>`);
|
|
||||||
|
|
||||||
out += sec("Trade setup", r.trade_setup
|
|
||||||
? setupCard(r.trade_setup)
|
|
||||||
: `<div class="text-secondary small">No setup published for this timeframe — the engine only emits entry/stop/target once a signal clears its quality gate.</div>`);
|
|
||||||
|
|
||||||
out += sec("Why this signal", listBlock(r) || `<div class="text-secondary small">No reasons recorded on this scan.</div>`);
|
|
||||||
|
|
||||||
out += sec(`${TF_LABEL[tf]} indicators (primary)`,
|
|
||||||
analysisBlock(pa, price) || indTable(r.indicators) +
|
|
||||||
(r.key_levels ? `<div class="small text-secondary fw-semibold mt-2 mb-1">Key levels</div>${ladder(r.key_levels, price)}` : ""));
|
|
||||||
|
|
||||||
const mtf = Object.entries(r.mtf_signals || {});
|
|
||||||
if (mtf.length) {
|
|
||||||
const chips = mtf.map(([k, sig]) => {
|
|
||||||
const s = r.mtf_scores?.[k];
|
|
||||||
const sc = sigClass(sig);
|
|
||||||
return `<span class="badge ${badgeCls(sc)}">${esc(TF_LABEL[k] || k)}
|
|
||||||
<span class="${sc === "buy" ? "text-success" : sc === "sell" ? "text-danger" : "text-secondary"}">${esc(sigLabel(sig))}</span>
|
|
||||||
<span class="num">${s != null ? signed(s, 1) : ""}</span></span>`;
|
|
||||||
}).join("");
|
|
||||||
const confs = (r.confirmation_analyses || []).filter(Boolean).map(a => {
|
|
||||||
const lbl = TF_LABEL[a.timeframe] || a.timeframe;
|
|
||||||
const sc = sigClass(a.composite_signal);
|
|
||||||
return `<details data-dkey="conf-${esc(a.timeframe)}">
|
|
||||||
<summary><strong>${esc(lbl)}</strong>
|
|
||||||
<span class="${sc === "buy" ? "text-success" : sc === "sell" ? "text-danger" : "text-secondary"} fw-bold">${esc(sigLabel(a.composite_signal))}</span>
|
|
||||||
<span class="text-secondary small">${signed(a.composite_score, 1)} · conf ${a.confidence != null ? Math.round(a.confidence) : "—"} · ${esc(a.market_regime || "")}</span></summary>
|
|
||||||
${analysisBlock(a, price)}</details>`;
|
|
||||||
}).join("");
|
|
||||||
out += sec("Multi-timeframe confirmation",
|
|
||||||
`<div class="d-flex flex-wrap gap-2 align-items-center mb-2">${chips}
|
|
||||||
<span class="fw-bold ${r.mtf_agreement ? "text-success" : "text-secondary"}">${r.mtf_agreement ? "✓ aligned" : "✗ mixed"}</span></div>${confs}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (r.news_summary || r.news_description) {
|
|
||||||
out += sec("News context", `<div class="text-secondary small">
|
|
||||||
${r.news_description ? `<strong>${esc(r.news_description)}</strong>${ns != null ? ` (${signed(ns, 2)})` : ""}<br>` : ""}
|
|
||||||
${esc(r.news_summary || "")}</div>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (r.panel_verdict) {
|
|
||||||
const vcls = r.panel_verdict === "AGREE" ? "text-success"
|
|
||||||
: r.panel_verdict === "OPPOSE" ? "text-danger" : "text-secondary";
|
|
||||||
const takes = (r.panel_takes || []).map(t =>
|
|
||||||
`<tr><td class="ind-k">${esc(t.persona)}</td>
|
|
||||||
<td>${esc(t.stance)}</td>
|
|
||||||
<td class="num text-end">${t.confidence != null ? Math.round(t.confidence) : "—"}</td>
|
|
||||||
<td class="small text-secondary">${esc(t.note || "")}</td></tr>`).join("");
|
|
||||||
out += sec("AI panel (advisory)", `<div class="text-secondary small mb-2">
|
|
||||||
<strong class="${vcls}">${esc(r.panel_verdict)}</strong> ·
|
|
||||||
conf ${Math.round(r.panel_confidence || 0)}%<br>
|
|
||||||
${esc(r.panel_rationale || "")}</div>
|
|
||||||
${takes ? `<div class="detail-table"><table class="table table-sm d-table"><tbody>${takes}</tbody></table></div>` : ""}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
out += sec("Scan detail", `<div class="detail-table"><table class="table table-sm d-table"><tbody>
|
|
||||||
<tr><td class="ind-k">Instrument</td><td colspan="5">${esc(r.display_name || r.symbol || "—")}</td></tr>
|
|
||||||
<tr><td class="ind-k">Candle</td><td colspan="5" class="num">${esc(r.timestamp || "—")}</td></tr>
|
|
||||||
<tr><td class="ind-k">Scanned</td><td colspan="5">${esc(entry.scanned_at || "—")}
|
|
||||||
(${fmtAge(entry.scanned_at)}${entry.scan_seconds ? `, took ${entry.scan_seconds}s` : ""})</td></tr>
|
|
||||||
<tr><td class="ind-k">Status</td><td colspan="5">${esc(entry.status || "—")}${entry.error
|
|
||||||
? ` — <span class="text-danger">${esc(entry.error)}</span>
|
|
||||||
${entry.error_at ? `(${fmtAge(entry.error_at)})` : ""}` : ""}</td></tr>
|
|
||||||
</tbody></table></div>`);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDetail() {
|
|
||||||
if (!detailTf || !tfModalOpen) return;
|
|
||||||
const entry = snapshot?.timeframes?.[detailTf] || {};
|
|
||||||
const r = entry.report;
|
|
||||||
const openKeys = new Set([...document.querySelectorAll("#detail-body details[open][data-dkey]")]
|
|
||||||
.map(d => d.dataset.dkey));
|
|
||||||
const scroll = $("detail-body").scrollTop;
|
|
||||||
|
|
||||||
$("detail-title").textContent = TF_LABEL[detailTf] || detailTf;
|
|
||||||
$("detail-price").textContent = r?.current_price ? fmt$(r.current_price) : "—";
|
|
||||||
$("detail-sub").innerHTML = `${snapshot?.symbol ? esc(snapshot.symbol) + " · " : ""}closes in
|
|
||||||
<span class="num" data-close="${entry.next_close_epoch || ""}" data-interval="${TF_SECONDS[detailTf]}">--:--</span>`;
|
|
||||||
$("detail-body").innerHTML = detailHTML(detailTf);
|
|
||||||
|
|
||||||
document.querySelectorAll("#detail-body details[data-dkey]").forEach(d => {
|
|
||||||
if (openKeys.has(d.dataset.dkey)) d.open = true;
|
|
||||||
});
|
|
||||||
$("detail-body").scrollTop = scroll;
|
|
||||||
tick();
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDetail(tf) {
|
|
||||||
if (!TF_LABEL[tf]) return;
|
|
||||||
detailTf = tf;
|
|
||||||
tfModal.show();
|
|
||||||
renderDetail();
|
|
||||||
$("detail-body").scrollTop = 0;
|
|
||||||
$("detail-close").focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
$("detail-close")?.addEventListener("click", () => tfModal.hide());
|
|
||||||
|
|
||||||
/* ── calendar ─────────────────────────────────────────────────── */
|
|
||||||
function renderCalendar() {
|
|
||||||
const body = $("cal-body");
|
|
||||||
if (!news) return;
|
|
||||||
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || "local time";
|
|
||||||
$("calendar-note").textContent = `times local (${zone}) · USD events${news.generated_at ? " · updated " + fmtAge(news.generated_at) : ""}`;
|
|
||||||
const hiOnly = $("hi-only").checked;
|
|
||||||
let events = (news.events || []);
|
|
||||||
if (hiOnly) events = events.filter(e => e.impact === "high");
|
|
||||||
if (!events.length) {
|
|
||||||
body.innerHTML = `<tr><td colspan="7" class="text-secondary text-center py-3">${
|
|
||||||
news.calendar_available ? "No matching events this week." : "Calendar unavailable — source could not be reached."}</td></tr>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const now = nowServer();
|
|
||||||
let nextUpMarked = false, lastDay = "", rows = "";
|
|
||||||
for (const e of events) {
|
|
||||||
const t = Date.parse(e.time) / 1000;
|
|
||||||
const d = new Date(t * 1000);
|
|
||||||
const day = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
|
||||||
if (day !== lastDay) {
|
|
||||||
rows += `<tr class="table-group-divider"><td colspan="7" class="text-primary small fw-bold text-uppercase">${esc(day)}</td></tr>`;
|
|
||||||
lastDay = day;
|
|
||||||
}
|
|
||||||
const past = t < now;
|
|
||||||
const nextUp = !past && !nextUpMarked && (nextUpMarked = true);
|
|
||||||
const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
||||||
const surp = e.surprise
|
|
||||||
? `<span class="${e.surprise === "positive" ? "text-success" : e.surprise === "negative" ? "text-danger" : "text-secondary"}" title="actual ${e.surprise === "positive" ? "above" : e.surprise === "negative" ? "below" : "matches"} forecast (numeric only — not gold direction)">${e.surprise === "positive" ? "▲" : e.surprise === "negative" ? "▼" : "="}</span>` : "";
|
|
||||||
rows += `<tr class="${past ? "text-secondary" : ""}${nextUp ? " cal-next-up" : ""}">
|
|
||||||
<td class="num text-nowrap">${esc(time)}</td>
|
|
||||||
<td class="text-secondary">${esc(e.currency)}</td>
|
|
||||||
<td><span class="badge ${e.impact === "high" ? "text-bg-danger" : e.impact === "medium" ? "text-bg-warning" : "text-bg-secondary"}">${e.impact === "medium" ? "Med" : e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}</span></td>
|
|
||||||
<td><button type="button" class="cal-event" data-ev-key="${esc(evKey(e))}" aria-haspopup="dialog">${esc(e.event)}</button></td>
|
|
||||||
<td class="num text-end">${esc(e.forecast ?? "—")}</td>
|
|
||||||
<td class="num text-end">${esc(e.previous ?? "—")}</td>
|
|
||||||
<td class="num text-end">${esc(e.actual ?? "—")} ${surp}</td></tr>`;
|
|
||||||
}
|
|
||||||
body.innerHTML = rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── newswire ─────────────────────────────────────────────────── */
|
|
||||||
function renderWireTotal() {
|
|
||||||
const box = $("wire-total");
|
|
||||||
const scored = (news?.headlines || []).filter(h => typeof h.score === "number");
|
|
||||||
if (!scored.length) { box.classList.add("d-none"); return; }
|
|
||||||
box.classList.remove("d-none");
|
|
||||||
|
|
||||||
const sum = scored.reduce((a, h) => a + h.score, 0);
|
|
||||||
const mean = sum / scored.length;
|
|
||||||
const up = scored.filter(h => h.score > 0.05).length;
|
|
||||||
const dn = scored.filter(h => h.score < -0.05).length;
|
|
||||||
const cls = mean > 0.05 ? "text-success" : mean < -0.05 ? "text-danger" : "text-secondary";
|
|
||||||
const dir = mean > 0.05 ? "BULLISH" : mean < -0.05 ? "BEARISH" : "NEUTRAL";
|
|
||||||
const wrapCls = mean > 0.05 ? "bg-success-subtle border-success-subtle"
|
|
||||||
: mean < -0.05 ? "bg-danger-subtle border-danger-subtle"
|
|
||||||
: "bg-body-tertiary";
|
|
||||||
|
|
||||||
box.className = `card mb-3 ${wrapCls}`;
|
|
||||||
box.innerHTML = `<div class="card-body p-2">
|
|
||||||
<div class="d-flex align-items-baseline gap-2 flex-wrap">
|
|
||||||
<span class="small text-secondary text-uppercase fw-bold">Total news score</span>
|
|
||||||
<span class="display-6 fw-bold ${cls}">${signed(mean, 2)}</span>
|
|
||||||
<span class="fw-bold ${cls}">${dir}</span>
|
|
||||||
<span class="ms-auto text-end small text-secondary">${scored.length} headlines · sum ${signed(sum, 1)}<br>
|
|
||||||
${up} bullish · ${dn} bearish</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
box.title = `Mean sentiment of all ${scored.length} scored headlines (−1…+1). Sum ${signed(sum, 1)}. Only headlines from the last 4 hours that lean one way are published (neutral ones are removed before scoring).`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderWire() {
|
|
||||||
if (!news) return;
|
|
||||||
const items = (news.headlines || []).slice(0, 40);
|
|
||||||
$("wire-note").textContent = news.generated_at ? `updated ${fmtAge(news.generated_at)}` : "";
|
|
||||||
renderWireTotal();
|
|
||||||
if (!items.length) {
|
|
||||||
$("wire").innerHTML = `<div class="text-secondary small py-2">No gold or dollar headlines in the last 4 hours.</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const item = h => {
|
|
||||||
const s = h.score;
|
|
||||||
const cls = s == null || Math.abs(s) < 0.05 ? "zero" : s > 0 ? "pos" : "neg";
|
|
||||||
const scoreCls = cls === "pos" ? "bg-success-subtle text-success"
|
|
||||||
: cls === "neg" ? "bg-danger-subtle text-danger"
|
|
||||||
: "bg-body-tertiary text-secondary";
|
|
||||||
const scoreTxt = s == null ? "·" : (s > 0 ? "+" : "") + s.toFixed(1);
|
|
||||||
let host = "";
|
|
||||||
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch {}
|
|
||||||
const content = `<span class="wire-score ${scoreCls}" title="Headline sentiment ${esc(String(s ?? "n/a"))} (−1…+1)${h.currencies?.length ? " · " + esc(h.currencies.join(", ")) : ""}">${scoreTxt}</span>
|
|
||||||
<span class="small text-secondary flex-fill">${esc(h.headline)}</span>
|
|
||||||
<span class="small text-secondary text-nowrap">${esc(h.publisher || host)}${h.timestamp ? " · " + fmtAge(h.timestamp) : ""}</span>`;
|
|
||||||
if (!h.id) return `<div class="wire-item gap-2 align-items-baseline py-1 px-1">${content}</div>`;
|
|
||||||
return `<button type="button" class="wire-item gap-2 align-items-baseline py-1 px-1" data-news-id="${h.id}" aria-haspopup="dialog">
|
|
||||||
${content}
|
|
||||||
</button>`;
|
|
||||||
};
|
|
||||||
const head = items.slice(0, 12).map(item).join("");
|
|
||||||
const rest = items.slice(12);
|
|
||||||
const moreOpen = $("wire")?.querySelector("details.more")?.open ?? false;
|
|
||||||
$("wire").innerHTML = head + (rest.length
|
|
||||||
? `<details class="more mt-1"${moreOpen ? " open" : ""}><summary class="small text-secondary">${rest.length} more headlines…</summary>
|
|
||||||
<div class="mt-1">${rest.map(item).join("")}</div></details>` : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── news / calendar detail dialog ────────────────────────────── */
|
|
||||||
const evKey = e => `${e.time}|${e.currency}|${e.event}`;
|
|
||||||
const findHeadline = id => (news?.headlines || []).find(h => h.id === id);
|
|
||||||
const findEvent = key => (news?.events || []).find(e => evKey(e) === key);
|
|
||||||
|
|
||||||
const AI_ERR_MSG = {
|
|
||||||
unknown_id: "This headline has left the live feed — no summary available.",
|
|
||||||
cap_exceeded: "Daily AI budget reached — try again tomorrow.",
|
|
||||||
no_key: "AI summaries are not configured on this server."
|
|
||||||
};
|
|
||||||
|
|
||||||
async function fetchAiSummary(id) {
|
|
||||||
aiPending.add(id);
|
|
||||||
try {
|
|
||||||
const res = await fetch("news_summary.php?id=" + encodeURIComponent(id));
|
|
||||||
const data = await res.json().catch(() => null);
|
|
||||||
if (res.ok && data?.summary) aiMemo.set(id, data);
|
|
||||||
else aiErr.set(id, data?.error || `http_${res.status}`);
|
|
||||||
} catch {
|
|
||||||
aiErr.set(id, "network");
|
|
||||||
} finally {
|
|
||||||
aiPending.delete(id);
|
|
||||||
if (ndState?.kind === "wire" && ndState.id === id) renderNewsDlg();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreTiles(h) {
|
|
||||||
const cls = v => v > 0.05 ? "text-success" : v < -0.05 ? "text-danger" : "text-secondary";
|
|
||||||
let tiles = tile("Overall sentiment", signed(h.score, 2), cls(h.score));
|
|
||||||
const xau = h.score_by_currency?.XAU;
|
|
||||||
if (xau != null) tiles += tile("Gold (XAU) effect", signed(xau, 2), cls(xau));
|
|
||||||
for (const [cur, v] of Object.entries(h.score_by_currency || {})) {
|
|
||||||
if (cur !== "XAU") tiles += tile(cur, signed(v, 2), cls(v));
|
|
||||||
}
|
|
||||||
let out = `<div class="row row-cols-2 row-cols-md-3 g-2">${tiles}</div>`;
|
|
||||||
if (h.keywords?.length) {
|
|
||||||
out += `<div class="small text-secondary mt-2">Matched keywords: ${esc(h.keywords.join(", "))}</div>`;
|
|
||||||
}
|
|
||||||
out += `<div class="small text-secondary">Scores range −1 (bearish) to +1 (bullish); the gold score flips monetary-policy keywords (hawkish = dollar-bullish, gold-bearish).</div>`;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function aiSection(id) {
|
|
||||||
if (aiMemo.has(id)) {
|
|
||||||
const m = aiMemo.get(id);
|
|
||||||
return `<div class="text-secondary small">${esc(m.summary)}</div>` + (m.generated_at
|
|
||||||
? `<div class="small text-secondary">generated ${fmtAge(m.generated_at)}</div>` : "");
|
|
||||||
}
|
|
||||||
if (aiPending.has(id)) return `<div class="text-secondary small">Generating gold-impact summary…</div>`;
|
|
||||||
if (aiErr.has(id)) {
|
|
||||||
return `<div class="text-secondary small">${esc(AI_ERR_MSG[aiErr.get(id)] || "AI summary unavailable right now.")}</div>`;
|
|
||||||
}
|
|
||||||
return `<div class="text-secondary small">Generating gold-impact summary…</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function wireDlgHTML() {
|
|
||||||
const fresh = findHeadline(ndState.id);
|
|
||||||
const h = fresh || ndState.snap;
|
|
||||||
if (!h) return "";
|
|
||||||
let host = "";
|
|
||||||
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch {}
|
|
||||||
const when = h.timestamp
|
|
||||||
? `${new Date(h.timestamp).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })} · ${fmtAge(h.timestamp)}` : "";
|
|
||||||
let out = `<div class="fs-5 fw-semibold mb-1">${esc(h.headline)}</div>
|
|
||||||
<div class="small text-secondary mb-3">${esc(h.publisher || host)}${when ? " · " + when : ""}</div>`;
|
|
||||||
if (!fresh) out += `<div class="small text-secondary mb-2">No longer in the live feed — showing saved copy.</div>`;
|
|
||||||
out += sec("Score", scoreTiles(h));
|
|
||||||
if (h.description) out += sec("From the source", `<div class="text-secondary small">${esc(h.description)}</div>`);
|
|
||||||
out += sec("AI gold-impact summary", aiSection(ndState.id));
|
|
||||||
if (h.link && /^https?:\/\//.test(h.link)) {
|
|
||||||
out += `<a class="btn btn-outline-primary" href="${esc(h.link)}" target="_blank" rel="noopener noreferrer">Read full article ↗</a>`;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calDlgHTML() {
|
|
||||||
const fresh = findEvent(ndState.key);
|
|
||||||
const e = fresh || ndState.snap;
|
|
||||||
if (!e) return "";
|
|
||||||
const t = Date.parse(e.time) / 1000;
|
|
||||||
const when = new Date(e.time).toLocaleString(undefined, { dateStyle: "full", timeStyle: "short" });
|
|
||||||
const impact = `<span class="badge ${e.impact === "high" ? "text-bg-danger" : e.impact === "medium" ? "text-bg-warning" : "text-bg-secondary"}">${e.impact === "medium" ? "Med" : e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}</span>`;
|
|
||||||
let out = `<div class="fs-5 fw-semibold mb-1">${esc(e.event)}</div>
|
|
||||||
<div class="small text-secondary mb-3">${esc(e.currency)} · ${when}</div>`;
|
|
||||||
const timing = t > nowServer()
|
|
||||||
? `releases in <span class="num" data-close="${t}">--:--</span>`
|
|
||||||
: `released ${fmtAge(e.time)}`;
|
|
||||||
out += sec("Timing", `<div class="text-secondary small">${impact} impact · ${timing}</div>`);
|
|
||||||
const surpTxt = { positive: "actual above forecast", negative: "actual below forecast", inline: "actual matches forecast" }[e.surprise];
|
|
||||||
out += sec("Numbers", `<div class="row row-cols-3 g-2">${
|
|
||||||
tile("Forecast", esc(e.forecast ?? "—")) +
|
|
||||||
tile("Previous", esc(e.previous ?? "—")) +
|
|
||||||
tile("Actual", esc(e.actual ?? "—"), e.surprise === "positive" ? "text-success" : e.surprise === "negative" ? "text-danger" : "")
|
|
||||||
}</div>` + (surpTxt
|
|
||||||
? `<div class="small text-secondary mt-2">${surpTxt} (numeric only — not gold direction)</div>` : ""));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNewsDlg() {
|
|
||||||
if (!ndState || !newsModalOpen) return;
|
|
||||||
const scroll = $("nd-body").scrollTop;
|
|
||||||
$("nd-title").textContent = ndState.kind === "wire" ? "Newswire" : "Economic calendar";
|
|
||||||
$("nd-body").innerHTML = ndState.kind === "wire" ? wireDlgHTML() : calDlgHTML();
|
|
||||||
$("nd-body").scrollTop = scroll;
|
|
||||||
tick();
|
|
||||||
}
|
|
||||||
|
|
||||||
function openNewsDetail(id) {
|
|
||||||
const h = findHeadline(id);
|
|
||||||
if (!h) return;
|
|
||||||
ndState = { kind: "wire", id, snap: h };
|
|
||||||
aiErr.delete(id);
|
|
||||||
newsModal.show();
|
|
||||||
if (!aiMemo.has(id) && !aiPending.has(id)) fetchAiSummary(id);
|
|
||||||
renderNewsDlg();
|
|
||||||
$("nd-body").scrollTop = 0;
|
|
||||||
$("nd-close").focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCalDetail(key) {
|
|
||||||
const e = findEvent(key);
|
|
||||||
if (!e) return;
|
|
||||||
ndState = { kind: "cal", key, snap: e };
|
|
||||||
newsModal.show();
|
|
||||||
renderNewsDlg();
|
|
||||||
$("nd-body").scrollTop = 0;
|
|
||||||
$("nd-close").focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
$("nd-close")?.addEventListener("click", () => newsModal.hide());
|
|
||||||
|
|
||||||
/* ── polling ──────────────────────────────────────────────────── */
|
|
||||||
async function fetchJSON(name) {
|
|
||||||
const res = await fetch(name, { cache: "no-cache" });
|
|
||||||
if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`);
|
|
||||||
const dateHdr = res.headers.get("Date");
|
|
||||||
const text = await res.text();
|
|
||||||
return { text, serverNow: dateHdr ? Date.parse(dateHdr) / 1000 : null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function tfFingerprint(entry) {
|
|
||||||
if (!entry) return "";
|
|
||||||
return JSON.stringify({
|
|
||||||
status: entry.status,
|
|
||||||
scanned_at: entry.scanned_at,
|
|
||||||
error: entry.error,
|
|
||||||
error_at: entry.error_at,
|
|
||||||
report: entry.report
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleRefresh(delay) {
|
|
||||||
clearTimeout(refetchTimer);
|
|
||||||
refetchTimer = setTimeout(() => {
|
|
||||||
refetchTimer = null;
|
|
||||||
poll();
|
|
||||||
}, Math.max(0, delay));
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFallbackRefresh() {
|
|
||||||
clearTimeout(fallbackTimer);
|
|
||||||
fallbackTimer = setTimeout(() => {
|
|
||||||
fallbackTimer = null;
|
|
||||||
poll();
|
|
||||||
scheduleFallbackRefresh();
|
|
||||||
}, FALLBACK_POLL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleBoundaryRefresh() {
|
|
||||||
if (!snapshot) return;
|
|
||||||
const nextScan = Math.min(...TF_ORDER.map(tf =>
|
|
||||||
snapshot.timeframes[tf]?.next_scan_epoch || Infinity));
|
|
||||||
if (!isFinite(nextScan)) return;
|
|
||||||
const delay = (nextScan - nowServer() + 2) * 1000;
|
|
||||||
scheduleRefresh(delay > 0 ? delay : RETRY_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function poll() {
|
|
||||||
if (pollInFlight) {
|
|
||||||
pollQueued = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pollInFlight = true;
|
|
||||||
try {
|
|
||||||
const [snap, nw] = await Promise.all([
|
|
||||||
fetchJSON("snapshot.json"),
|
|
||||||
fetchJSON("news.json").catch(() => null)
|
|
||||||
]);
|
|
||||||
const priorSnapshot = snapshot;
|
|
||||||
let snapChanged = false;
|
|
||||||
let selectedChanged = false;
|
|
||||||
let cardsChanged = false;
|
|
||||||
let detailChanged = false;
|
|
||||||
if (snap.text !== lastSnapText) {
|
|
||||||
const incoming = JSON.parse(snap.text);
|
|
||||||
if (!priorSnapshot || incoming.server_time_epoch >= priorSnapshot.server_time_epoch) {
|
|
||||||
snapChanged = true;
|
|
||||||
selectedChanged = tfFingerprint(priorSnapshot?.timeframes?.[selectedTf]) !==
|
|
||||||
tfFingerprint(incoming.timeframes?.[selectedTf]);
|
|
||||||
// renderCards() redraws the selector for EVERY timeframe, so it
|
|
||||||
// must react to any of them changing — not just the selected one.
|
|
||||||
// Gating it on selectedChanged left the other buttons showing a
|
|
||||||
// stale arrow/score (up to 4 h on H4) while renderHero's rail,
|
|
||||||
// which redraws unconditionally, already showed the new value.
|
|
||||||
cardsChanged = TF_ORDER.some(tf =>
|
|
||||||
tfFingerprint(priorSnapshot?.timeframes?.[tf]) !==
|
|
||||||
tfFingerprint(incoming.timeframes?.[tf]));
|
|
||||||
detailChanged = detailTf != null && tfFingerprint(priorSnapshot?.timeframes?.[detailTf]) !==
|
|
||||||
tfFingerprint(incoming.timeframes?.[detailTf]);
|
|
||||||
snapshot = incoming;
|
|
||||||
lastSnapText = snap.text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const newsChanged = nw != null && nw.text !== lastNewsText;
|
|
||||||
if (newsChanged) { news = JSON.parse(nw.text); lastNewsText = nw.text; }
|
|
||||||
clockOffset = (snap.serverNow ?? snapshot.server_time_epoch) - Date.now() / 1000;
|
|
||||||
lastPollOk = new Date();
|
|
||||||
$("banner-fail").classList.add("d-none");
|
|
||||||
|
|
||||||
if (snapChanged) {
|
|
||||||
renderHero();
|
|
||||||
if (cardsChanged) renderCards();
|
|
||||||
if (detailChanged) renderDetail();
|
|
||||||
if (selectedChanged) {
|
|
||||||
const signal = snapshot.timeframes[selectedTf]?.report?.signal;
|
|
||||||
if (signal) announce(`${TF_LABEL[selectedTf]} signal updated: ${sigLabel(signal)}.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (newsChanged) {
|
|
||||||
renderCalendar();
|
|
||||||
renderWire();
|
|
||||||
renderNewsDlg();
|
|
||||||
}
|
|
||||||
if (snapChanged || newsChanged) renderContext();
|
|
||||||
tick();
|
|
||||||
scheduleBoundaryRefresh();
|
|
||||||
} catch (err) {
|
|
||||||
const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : "";
|
|
||||||
const b = $("banner-fail");
|
|
||||||
b.textContent = `Dashboard data is temporarily unavailable.${last} Retrying…`;
|
|
||||||
b.classList.remove("d-none");
|
|
||||||
scheduleRefresh(RETRY_MS);
|
|
||||||
} finally {
|
|
||||||
pollInFlight = false;
|
|
||||||
if (pollQueued) {
|
|
||||||
pollQueued = false;
|
|
||||||
poll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function tick() {
|
|
||||||
if (!snapshot) return;
|
|
||||||
const now = nowServer();
|
|
||||||
document.querySelectorAll("[data-close]").forEach(el => {
|
|
||||||
const t = parseFloat(el.dataset.close);
|
|
||||||
if (!t) return;
|
|
||||||
let secs = t - now;
|
|
||||||
if (secs < 0) {
|
|
||||||
const interval = parseFloat(el.dataset.interval);
|
|
||||||
secs = interval > 0 ? ((secs % interval) + interval) % interval : 0;
|
|
||||||
}
|
|
||||||
el.textContent = fmtCountdown(secs);
|
|
||||||
});
|
|
||||||
const m5 = snapshot.timeframes["5min"]?.next_close_epoch;
|
|
||||||
if (m5) {
|
|
||||||
let secs = m5 - now;
|
|
||||||
if (secs < 0) secs = ((secs % TF_SECONDS["5min"]) + TF_SECONDS["5min"]) % TF_SECONDS["5min"];
|
|
||||||
$("hero-cd").textContent = fmtCountdown(secs);
|
|
||||||
}
|
|
||||||
const pubEpoch = snapshot.server_time_epoch;
|
|
||||||
if (pubEpoch) {
|
|
||||||
const age = now - pubEpoch;
|
|
||||||
const el = $("hero-updated");
|
|
||||||
const stale = !snapshot.market_closed && age > 900;
|
|
||||||
el.textContent = (stale ? "STALE — published " : "published ") +
|
|
||||||
fmtAge(new Date(pubEpoch * 1000).toISOString());
|
|
||||||
el.classList.toggle("text-warning", stale);
|
|
||||||
el.classList.toggle("fw-semibold", stale);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── boot ─────────────────────────────────────────────────────── */
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
tfModal = new bootstrap.Modal($("tf-detail"));
|
|
||||||
newsModal = new bootstrap.Modal($("news-detail"));
|
|
||||||
const storedTf = localGet(TIMEFRAME_PREF);
|
|
||||||
if (TF_LABEL[storedTf]) selectedTf = storedTf;
|
|
||||||
$("hi-only").checked = localGet(HIGH_IMPACT_PREF) === "true";
|
|
||||||
|
|
||||||
$("tf-detail")?.addEventListener("shown.bs.modal", () => { tfModalOpen = true; renderDetail(); });
|
|
||||||
$("tf-detail")?.addEventListener("hidden.bs.modal", () => { tfModalOpen = false; detailTf = null; });
|
|
||||||
$("news-detail")?.addEventListener("shown.bs.modal", () => { newsModalOpen = true; renderNewsDlg(); });
|
|
||||||
$("news-detail")?.addEventListener("hidden.bs.modal", () => { newsModalOpen = false; ndState = null; });
|
|
||||||
|
|
||||||
const selectTf = tf => {
|
|
||||||
if (!TF_LABEL[tf]) return;
|
|
||||||
const changed = selectedTf !== tf;
|
|
||||||
selectedTf = tf;
|
|
||||||
localSet(TIMEFRAME_PREF, tf);
|
|
||||||
renderHero();
|
|
||||||
renderCards();
|
|
||||||
if (changed) announce(`${TF_LABEL[tf]} workspace selected.`);
|
|
||||||
};
|
|
||||||
$("hero-rail")?.addEventListener("click", e => {
|
|
||||||
const button = e.target.closest("[data-select-tf]");
|
|
||||||
if (button) selectTf(button.dataset.selectTf);
|
|
||||||
});
|
|
||||||
$("tf-grid")?.addEventListener("click", e => {
|
|
||||||
const detail = e.target.closest("[data-open-detail]");
|
|
||||||
if (detail) return openDetail(detail.dataset.openDetail);
|
|
||||||
const button = e.target.closest("[data-select-tf]");
|
|
||||||
if (button) selectTf(button.dataset.selectTf);
|
|
||||||
});
|
|
||||||
$("wire")?.addEventListener("click", e => {
|
|
||||||
const button = e.target.closest("[data-news-id]");
|
|
||||||
if (button) openNewsDetail(button.dataset.newsId);
|
|
||||||
});
|
|
||||||
$("cal-body")?.addEventListener("click", e => {
|
|
||||||
const button = e.target.closest("[data-ev-key]");
|
|
||||||
if (button) openCalDetail(button.dataset.evKey);
|
|
||||||
});
|
|
||||||
$("hi-only")?.addEventListener("change", () => {
|
|
||||||
localSet(HIGH_IMPACT_PREF, String($("hi-only").checked));
|
|
||||||
renderCalendar();
|
|
||||||
});
|
|
||||||
|
|
||||||
poll();
|
|
||||||
scheduleFallbackRefresh();
|
|
||||||
setInterval(tick, 1000);
|
|
||||||
});
|
|
||||||
+34
-57
@@ -51,10 +51,7 @@ In 1-2 plain-English sentences (max 45 words) explain what this news
|
|||||||
means for the GOLD (XAU/USD) price and WHY it is bullish, bearish, or
|
means for the GOLD (XAU/USD) price and WHY it is bullish, bearish, or
|
||||||
neutral. If your reading clearly contradicts xau_score, add "(the
|
neutral. If your reading clearly contradicts xau_score, add "(the
|
||||||
automated score disagrees)". Never write the words "xau_score" or
|
automated score disagrees)". Never write the words "xau_score" or
|
||||||
"score" — a general reader sees this text. Write for someone who has
|
"score" — a general reader sees this text.
|
||||||
never traded: short simple sentences, no acronyms or abbreviations
|
|
||||||
(say "the US central bank", not "the Fed"; "interest rates", not
|
|
||||||
"yields").
|
|
||||||
Gold doctrine: hawkish Fed / rate hikes / rising real yields / strong
|
Gold doctrine: hawkish Fed / rate hikes / rising real yields / strong
|
||||||
dollar are gold-bearish; easing / rate cuts / safe-haven flows /
|
dollar are gold-bearish; easing / rate cuts / safe-haven flows /
|
||||||
geopolitical stress / weak dollar are gold-bullish.
|
geopolitical stress / weak dollar are gold-bullish.
|
||||||
@@ -73,12 +70,7 @@ function respond(int $status, array $obj, string $cache = 'no-store'): void
|
|||||||
function atomic_write(string $path, string $data): bool
|
function atomic_write(string $path, string $data): bool
|
||||||
{
|
{
|
||||||
$tmp = $path . '.tmp.' . getmypid();
|
$tmp = $path . '.tmp.' . getmypid();
|
||||||
$written = @file_put_contents($tmp, $data);
|
if (@file_put_contents($tmp, $data) === false) {
|
||||||
// A SHORT write (disk full / quota) returns a byte count, NOT false.
|
|
||||||
// Renaming that into place would publish a truncated JSON body which
|
|
||||||
// the cache-first branch above then serves forever.
|
|
||||||
if ($written === false || $written !== strlen($data)) {
|
|
||||||
@unlink($tmp);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!@rename($tmp, $path)) {
|
if (!@rename($tmp, $path)) {
|
||||||
@@ -125,14 +117,44 @@ if ($hit === null) {
|
|||||||
'message' => 'Headline is no longer in the feed.']);
|
'message' => 'Headline is no longer in the feed.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 4. API key ──────────────────────────────────────────────────────
|
// ── 4. daily cap (fail closed) ──────────────────────────────────────
|
||||||
|
$dailyCap = (int)(getenv('AI_SUMMARY_DAILY_CAP') ?: 150);
|
||||||
|
$capFh = @fopen(CAP_FILE, 'c+');
|
||||||
|
if ($capFh === false || !flock($capFh, LOCK_EX)) {
|
||||||
|
respond(503, ['error' => 'cap_io']);
|
||||||
|
}
|
||||||
|
$today = gmdate('Y-m-d');
|
||||||
|
$cap = json_decode((string)stream_get_contents($capFh), true);
|
||||||
|
if (!is_array($cap) || ($cap['day'] ?? '') !== $today) {
|
||||||
|
$cap = ['day' => $today, 'count' => 0];
|
||||||
|
}
|
||||||
|
if ($cap['count'] >= $dailyCap) {
|
||||||
|
flock($capFh, LOCK_UN);
|
||||||
|
fclose($capFh);
|
||||||
|
respond(429, ['error' => 'cap_exceeded']);
|
||||||
|
}
|
||||||
|
// Increment BEFORE the LLM call — attempts count against the cap
|
||||||
|
// (same semantics as llm_client._under_cap).
|
||||||
|
$cap['count']++;
|
||||||
|
rewind($capFh);
|
||||||
|
if (ftruncate($capFh, 0) === false
|
||||||
|
|| fwrite($capFh, json_encode($cap)) === false) {
|
||||||
|
flock($capFh, LOCK_UN);
|
||||||
|
fclose($capFh);
|
||||||
|
respond(503, ['error' => 'cap_io']);
|
||||||
|
}
|
||||||
|
fflush($capFh);
|
||||||
|
flock($capFh, LOCK_UN);
|
||||||
|
fclose($capFh);
|
||||||
|
|
||||||
|
// ── 5. API key ──────────────────────────────────────────────────────
|
||||||
$keyFile = getenv('DEEPSEEK_KEY_FILE') ?: '/etc/gold-dashboard/deepseek.key';
|
$keyFile = getenv('DEEPSEEK_KEY_FILE') ?: '/etc/gold-dashboard/deepseek.key';
|
||||||
$apiKey = trim((string)@file_get_contents($keyFile));
|
$apiKey = trim((string)@file_get_contents($keyFile));
|
||||||
if ($apiKey === '') {
|
if ($apiKey === '') {
|
||||||
respond(503, ['error' => 'no_key']);
|
respond(503, ['error' => 'no_key']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 5. per-id stampede lock, then re-check the cache ────────────────
|
// ── 6. per-id stampede lock, then re-check the cache ────────────────
|
||||||
$lockFh = @fopen(CACHE_DIR . '/.ht_lock_' . $id, 'c');
|
$lockFh = @fopen(CACHE_DIR . '/.ht_lock_' . $id, 'c');
|
||||||
if ($lockFh !== false) {
|
if ($lockFh !== false) {
|
||||||
flock($lockFh, LOCK_EX); // blocks while a concurrent winner generates
|
flock($lockFh, LOCK_EX); // blocks while a concurrent winner generates
|
||||||
@@ -147,51 +169,6 @@ if ($lockFh !== false) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$releaseLock = function () use (&$lockFh) {
|
|
||||||
if ($lockFh !== false) {
|
|
||||||
@flock($lockFh, LOCK_UN);
|
|
||||||
@fclose($lockFh);
|
|
||||||
$lockFh = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 6. daily cap (fail closed) ──────────────────────────────────────
|
|
||||||
// Counted HERE, not before the lock: a burst of clicks on one headline
|
|
||||||
// makes exactly one DeepSeek call, and the losers are served from the
|
|
||||||
// re-checked cache above — charging them against the budget (and
|
|
||||||
// charging a request that then found no key) could exhaust the whole
|
|
||||||
// 150/day allowance with zero LLM calls made.
|
|
||||||
$dailyCap = (int)(getenv('AI_SUMMARY_DAILY_CAP') ?: 150);
|
|
||||||
$capFh = @fopen(CAP_FILE, 'c+');
|
|
||||||
if ($capFh === false || !flock($capFh, LOCK_EX)) {
|
|
||||||
$releaseLock();
|
|
||||||
respond(503, ['error' => 'cap_io']);
|
|
||||||
}
|
|
||||||
$today = gmdate('Y-m-d');
|
|
||||||
$cap = json_decode((string)stream_get_contents($capFh), true);
|
|
||||||
if (!is_array($cap) || ($cap['day'] ?? '') !== $today) {
|
|
||||||
$cap = ['day' => $today, 'count' => 0];
|
|
||||||
}
|
|
||||||
if ($cap['count'] >= $dailyCap) {
|
|
||||||
flock($capFh, LOCK_UN);
|
|
||||||
fclose($capFh);
|
|
||||||
$releaseLock();
|
|
||||||
respond(429, ['error' => 'cap_exceeded']);
|
|
||||||
}
|
|
||||||
// Increment BEFORE the LLM call — attempts count against the cap
|
|
||||||
// (same semantics as llm_client._under_cap).
|
|
||||||
$cap['count']++;
|
|
||||||
rewind($capFh);
|
|
||||||
if (ftruncate($capFh, 0) === false
|
|
||||||
|| fwrite($capFh, json_encode($cap)) === false) {
|
|
||||||
flock($capFh, LOCK_UN);
|
|
||||||
fclose($capFh);
|
|
||||||
$releaseLock();
|
|
||||||
respond(503, ['error' => 'cap_io']);
|
|
||||||
}
|
|
||||||
fflush($capFh);
|
|
||||||
flock($capFh, LOCK_UN);
|
|
||||||
fclose($capFh);
|
|
||||||
|
|
||||||
// ── 7. DeepSeek call (exact chat_json body shape) ───────────────────
|
// ── 7. DeepSeek call (exact chat_json body shape) ───────────────────
|
||||||
$userPayload = json_encode([
|
$userPayload = json_encode([
|
||||||
|
|||||||
+10
-164
@@ -50,7 +50,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, List, Optional, Set
|
from typing import Dict, List, Optional, Set
|
||||||
|
|
||||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
|
||||||
from telegram.constants import ParseMode
|
from telegram.constants import ParseMode
|
||||||
@@ -68,16 +68,14 @@ from config import (
|
|||||||
)
|
)
|
||||||
from app_config import (
|
from app_config import (
|
||||||
get_app_config, telegram_token, telegram_chat_id,
|
get_app_config, telegram_token, telegram_chat_id,
|
||||||
telegram_channel_id, violent_watch_enabled,
|
telegram_channel_id,
|
||||||
)
|
)
|
||||||
from providers import BaseDataProvider, make_data_provider
|
from providers import BaseDataProvider, make_data_provider
|
||||||
from agent_panel import attach_panel, panel_health
|
from agent_panel import attach_panel, panel_health
|
||||||
from llm_client import get_llm_client
|
from llm_client import get_llm_client
|
||||||
from market_outlook import maybe_run_outlook
|
|
||||||
from news_engine import FundamentalContext, NewsEngine
|
from news_engine import FundamentalContext, NewsEngine
|
||||||
from pair_signals import PAIR_PRODUCERS
|
from pair_signals import PAIR_PRODUCERS
|
||||||
from signal_aggregator import SignalAggregator, SignalReport
|
from signal_aggregator import SignalAggregator, SignalReport
|
||||||
from violent_watch import ViolentWatch, attach_watch, should_send_headsup
|
|
||||||
|
|
||||||
_APP = get_app_config()
|
_APP = get_app_config()
|
||||||
|
|
||||||
@@ -342,10 +340,7 @@ def format_signal_message(report: SignalReport) -> str:
|
|||||||
f"📊 Regime: {regime_str} · Vol: {vol_str}",
|
f"📊 Regime: {regime_str} · Vol: {vol_str}",
|
||||||
]
|
]
|
||||||
if report.action_recommendation:
|
if report.action_recommendation:
|
||||||
# Pattern identifiers use underscores, which legacy Telegram Markdown
|
lines.append(f"_{_escape_md(report.action_recommendation)}_")
|
||||||
# can misparse even when escaped inside this italicized action line.
|
|
||||||
action = report.action_recommendation.replace("_", " ")
|
|
||||||
lines.append(f"_{_escape_md(action)}_")
|
|
||||||
|
|
||||||
if report.trade_setup and sig not in (SignalStrength.NEUTRAL,):
|
if report.trade_setup and sig not in (SignalStrength.NEUTRAL,):
|
||||||
ts = report.trade_setup
|
ts = report.trade_setup
|
||||||
@@ -411,19 +406,13 @@ def format_signal_message(report: SignalReport) -> str:
|
|||||||
extra.append(f"📋 {ind_summary}")
|
extra.append(f"📋 {ind_summary}")
|
||||||
for w in report.warnings:
|
for w in report.warnings:
|
||||||
extra.append(f"⚠️ {_escape_md(w)}")
|
extra.append(f"⚠️ {_escape_md(w)}")
|
||||||
# Display-only violent-move watch (violent_watch.attach_watch stamps
|
|
||||||
# it post-hoc; it is empty unless the feature is enabled).
|
|
||||||
if report.watch_line:
|
|
||||||
extra.append(_escape_md(report.watch_line))
|
|
||||||
if report.news_summary:
|
if report.news_summary:
|
||||||
extra.append(f"📰 {_escape_md(report.news_summary)}")
|
extra.append(f"📰 {_escape_md(report.news_summary)}")
|
||||||
if report.panel_verdict:
|
if report.panel_verdict:
|
||||||
mark = {"AGREE": "✅", "OPPOSE": "⛔", "NEUTRAL": "➖"}.get(
|
mark = {"AGREE": "✅", "OPPOSE": "⛔", "NEUTRAL": "➖"}.get(
|
||||||
report.panel_verdict, "")
|
report.panel_verdict, "")
|
||||||
panel_label = ("AI macro view"
|
|
||||||
if report.panel_verdict_source == "macro" else "AI Panel")
|
|
||||||
extra.append(
|
extra.append(
|
||||||
f"🤖 {panel_label} {mark} *{_escape_md(report.panel_verdict)}* "
|
f"🤖 AI Panel {mark} *{_escape_md(report.panel_verdict)}* "
|
||||||
f"({report.panel_confidence:.0f}%) — "
|
f"({report.panel_confidence:.0f}%) — "
|
||||||
f"{_escape_md(report.panel_rationale)}")
|
f"{_escape_md(report.panel_rationale)}")
|
||||||
if report.panel_takes:
|
if report.panel_takes:
|
||||||
@@ -547,7 +536,7 @@ def _run_signal_sync(
|
|||||||
config.confirmation_timeframes = confirmation_tfs
|
config.confirmation_timeframes = confirmation_tfs
|
||||||
aggregator = SignalAggregator(config)
|
aggregator = SignalAggregator(config)
|
||||||
aggregator.data_provider = _get_shared_dp()
|
aggregator.data_provider = _get_shared_dp()
|
||||||
return aggregator.analyze_pair(symbol=symbol, log_source="telegram")
|
return aggregator.analyze_pair(symbol=symbol)
|
||||||
|
|
||||||
|
|
||||||
async def run_signal(
|
async def run_signal(
|
||||||
@@ -556,50 +545,9 @@ async def run_signal(
|
|||||||
confirmation_tfs: Optional[List[Timeframe]] = None,
|
confirmation_tfs: Optional[List[Timeframe]] = None,
|
||||||
) -> SignalReport:
|
) -> SignalReport:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
report = await loop.run_in_executor(
|
return await loop.run_in_executor(
|
||||||
_executor, _run_signal_sync, symbol, timeframe, confirmation_tfs
|
_executor, _run_signal_sync, symbol, timeframe, confirmation_tfs
|
||||||
)
|
)
|
||||||
_remember_scan(symbol, timeframe, report)
|
|
||||||
return report
|
|
||||||
|
|
||||||
|
|
||||||
# Most recent report per (symbol, timeframe) keyed by the closed-bar epoch
|
|
||||||
# it was computed for. Closed-bar inputs are identical within a bar, so a
|
|
||||||
# rescan inside the same bar (the WS level-breach handler fired up to
|
|
||||||
# ~5 min apart all day, logging 3.5 M15 rows per candle — 2026-09 audit)
|
|
||||||
# can reuse it instead of re-fetching, re-scoring and re-logging.
|
|
||||||
_last_scan: Dict[str, tuple] = {}
|
|
||||||
_TF_SECONDS_CACHE = {
|
|
||||||
Timeframe.M1: 60, Timeframe.M5: 300, Timeframe.M15: 900,
|
|
||||||
Timeframe.M30: 1800, Timeframe.H1: 3600, Timeframe.H4: 14400,
|
|
||||||
Timeframe.D1: 86400,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _bar_epoch(tf: Timeframe, now: Optional[float] = None) -> int:
|
|
||||||
"""Epoch of the most recent candle boundary for ``tf`` (UTC-anchored,
|
|
||||||
the same grid _seconds_until_boundary sleeps to)."""
|
|
||||||
secs = _TF_SECONDS_CACHE.get(tf, 900)
|
|
||||||
t = int(time.time() if now is None else now)
|
|
||||||
return t - t % secs
|
|
||||||
|
|
||||||
|
|
||||||
def _remember_scan(symbol: str, tf: Timeframe, report: SignalReport) -> None:
|
|
||||||
_last_scan[f"{symbol}_{tf.value}"] = (_bar_epoch(tf), report)
|
|
||||||
|
|
||||||
|
|
||||||
async def _cached_or_scan(
|
|
||||||
symbol: str,
|
|
||||||
timeframe: Timeframe,
|
|
||||||
confirmation_tfs: Optional[List[Timeframe]] = None,
|
|
||||||
) -> SignalReport:
|
|
||||||
"""run_signal, unless this bar already has a report for the key."""
|
|
||||||
cached = _last_scan.get(f"{symbol}_{timeframe.value}")
|
|
||||||
if cached is not None and cached[0] == _bar_epoch(timeframe):
|
|
||||||
logger.info(
|
|
||||||
f"{symbol} {timeframe.value}: reusing this bar's scan — no rescan")
|
|
||||||
return cached[1]
|
|
||||||
return await run_signal(symbol, timeframe, confirmation_tfs)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Alert gating (quality threshold + dedup) ─────────────────────────
|
# ── Alert gating (quality threshold + dedup) ─────────────────────────
|
||||||
@@ -620,22 +568,17 @@ def _passes_quality(quality: str) -> bool:
|
|||||||
return _QUALITY_RANK.get(quality, 0) >= _QUALITY_RANK.get(_alert_min_quality, 1)
|
return _QUALITY_RANK.get(quality, 0) >= _QUALITY_RANK.get(_alert_min_quality, 1)
|
||||||
|
|
||||||
|
|
||||||
_last_alert_quality: Dict[str, str] = {} # key → grade of last ALERTED signal
|
|
||||||
|
|
||||||
|
|
||||||
def _alert_fingerprint(report: SignalReport) -> tuple:
|
def _alert_fingerprint(report: SignalReport) -> tuple:
|
||||||
"""Alert identity for dedup: signal and setup direction.
|
"""Alert identity for dedup: signal, quality, setup direction.
|
||||||
|
|
||||||
Entry movement is checked separately in _should_alert against the
|
Entry movement is checked separately in _should_alert against the
|
||||||
last ALERTED entry/risk (a stable reference). Deriving a price bucket
|
last ALERTED entry/risk (a stable reference). Deriving a price bucket
|
||||||
from the CURRENT SL distance here made the fingerprint churn on
|
from the CURRENT SL distance here made the fingerprint churn on
|
||||||
normal ATR/anchor drift — near-duplicate alerts every candle. The
|
normal ATR/anchor drift — near-duplicate alerts every candle."""
|
||||||
grade is deliberately NOT part of the identity (2026-09 audit: an
|
|
||||||
A↔B flicker on an unchanged idea re-sent the full alert ~4× in 45
|
|
||||||
min); a grade UPGRADE re-alerts via _should_alert instead."""
|
|
||||||
setup = report.trade_setup
|
setup = report.trade_setup
|
||||||
return (
|
return (
|
||||||
report.final_signal.name,
|
report.final_signal.name,
|
||||||
|
report.signal_quality,
|
||||||
setup.direction if setup else None,
|
setup.direction if setup else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -655,12 +598,6 @@ def _should_alert(key: str, report: SignalReport) -> bool:
|
|||||||
return True
|
return True
|
||||||
if time.time() - _last_alert_ts.get(key, 0) >= _ALERT_REARM_SECONDS:
|
if time.time() - _last_alert_ts.get(key, 0) >= _ALERT_REARM_SECONDS:
|
||||||
return True
|
return True
|
||||||
# Same idea at a HIGHER grade than last alerted → worth re-sending;
|
|
||||||
# a downgrade or flicker back is not.
|
|
||||||
last_q = _last_alert_quality.get(key)
|
|
||||||
if last_q is not None and (_QUALITY_RANK.get(report.signal_quality, 0)
|
|
||||||
> _QUALITY_RANK.get(last_q, 0)):
|
|
||||||
return True
|
|
||||||
setup = report.trade_setup
|
setup = report.trade_setup
|
||||||
last = _last_alert_setup.get(key)
|
last = _last_alert_setup.get(key)
|
||||||
if setup is not None and last is not None:
|
if setup is not None and last is not None:
|
||||||
@@ -673,7 +610,6 @@ def _should_alert(key: str, report: SignalReport) -> bool:
|
|||||||
def _commit_alert(key: str, report: SignalReport) -> None:
|
def _commit_alert(key: str, report: SignalReport) -> None:
|
||||||
_last_alert_fp[key] = _alert_fingerprint(report)
|
_last_alert_fp[key] = _alert_fingerprint(report)
|
||||||
_last_alert_ts[key] = time.time()
|
_last_alert_ts[key] = time.time()
|
||||||
_last_alert_quality[key] = report.signal_quality
|
|
||||||
setup = report.trade_setup
|
setup = report.trade_setup
|
||||||
if setup is not None:
|
if setup is not None:
|
||||||
_last_alert_setup[key] = (
|
_last_alert_setup[key] = (
|
||||||
@@ -758,33 +694,6 @@ def _seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
|
|||||||
return remaining
|
return remaining
|
||||||
|
|
||||||
|
|
||||||
# ── Violent-move watch (display-only) ────────────────────────────────
|
|
||||||
# Per-process heads-up budget: {"day": date, "count": int, "sent": {...}}.
|
|
||||||
_watch_state: Dict[str, Any] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _format_headsup(report: SignalReport, w: "ViolentWatch") -> str:
|
|
||||||
"""Standalone owner heads-up. Advisory only — it never asks anyone
|
|
||||||
to touch an open trade: every protective action tested lost money
|
|
||||||
(sizing_campaign_2026-09.md), so the stop stays the protection."""
|
|
||||||
lines = [
|
|
||||||
f"🌪 *Heads-up — {_escape_md(report.display_name)}*: "
|
|
||||||
f"violent-move conditions for the next hour.",
|
|
||||||
_escape_md(" · ".join(w.triggers)),
|
|
||||||
]
|
|
||||||
if w.likely_direction == "down":
|
|
||||||
lines.append("If a big move comes, it has usually gone DOWN from "
|
|
||||||
"readings like these.")
|
|
||||||
elif w.likely_direction == "up":
|
|
||||||
lines.append("If a big move comes, it has usually gone UP from "
|
|
||||||
"readings like these.")
|
|
||||||
lines.append(
|
|
||||||
"If you have a trade open, expect big bars. Your stop is the "
|
|
||||||
"protection — past tests show closing or tightening early loses "
|
|
||||||
"money.")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
async def _scan_timeframe(
|
async def _scan_timeframe(
|
||||||
bot,
|
bot,
|
||||||
tf: Timeframe,
|
tf: Timeframe,
|
||||||
@@ -811,38 +720,6 @@ async def _scan_timeframe(
|
|||||||
|
|
||||||
_update_level_cache(reports)
|
_update_level_cache(reports)
|
||||||
|
|
||||||
# Violent-move watch: DISPLAY-ONLY. Runs before the alert loop so
|
|
||||||
# the stamped line is present when format_signal_message renders,
|
|
||||||
# and can never break an alert (whole block is best-effort).
|
|
||||||
try:
|
|
||||||
vw_settings = get_app_config().violent_watch_settings
|
|
||||||
if violent_watch_enabled():
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
for report in reports:
|
|
||||||
w = attach_watch(report, tf, vw_settings, now)
|
|
||||||
if not (tf == _HEADSUP_TF
|
|
||||||
and vw_settings.get("headsup", True)
|
|
||||||
and OWNER_ID is not None):
|
|
||||||
continue
|
|
||||||
min_level = vw_settings.get("headsup_min_level", "hot")
|
|
||||||
if w.level == "hot" or (min_level == "watch"
|
|
||||||
and w.level == "watch"):
|
|
||||||
ok, why = should_send_headsup(
|
|
||||||
_watch_state, now, w.kinds,
|
|
||||||
int(vw_settings.get("max_headsup_per_day", 12)))
|
|
||||||
if ok:
|
|
||||||
await _send_alert(bot, OWNER_ID,
|
|
||||||
_format_headsup(report, w))
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
f"violent watch: heads-up skipped ({why})")
|
|
||||||
else:
|
|
||||||
logger.debug(
|
|
||||||
f"violent watch: {report.symbol} {tf.value} "
|
|
||||||
f"level={w.level} (min {min_level}) — no heads-up")
|
|
||||||
except Exception:
|
|
||||||
logger.exception("violent watch failed")
|
|
||||||
|
|
||||||
candidates = [
|
candidates = [
|
||||||
r for r in reports
|
r for r in reports
|
||||||
if r.final_signal != SignalStrength.NEUTRAL # same gate as breach path
|
if r.final_signal != SignalStrength.NEUTRAL # same gate as breach path
|
||||||
@@ -872,28 +749,6 @@ async def _scan_timeframe(
|
|||||||
if delivered:
|
if delivered:
|
||||||
_commit_alert(key, report)
|
_commit_alert(key, report)
|
||||||
|
|
||||||
# This independent advisory call runs only after the H1 scan completes.
|
|
||||||
# Offloading the LLM request keeps the event loop responsive.
|
|
||||||
if tf == Timeframe.H1:
|
|
||||||
try:
|
|
||||||
outlook = await asyncio.get_running_loop().run_in_executor(
|
|
||||||
_executor, maybe_run_outlook, "telegram")
|
|
||||||
settings = get_app_config().market_outlook_settings
|
|
||||||
if (outlook is not None and settings.get("telegram", False)
|
|
||||||
and OWNER_ID is not None):
|
|
||||||
forming = {
|
|
||||||
"buy": "possible buy forming",
|
|
||||||
"sell": "possible sell forming",
|
|
||||||
"none": "no clear setup forming",
|
|
||||||
}[outlook.setup_forming]
|
|
||||||
message = (
|
|
||||||
f"🔭 AI outlook: {outlook.bias}, {forming} "
|
|
||||||
f"({outlook.confidence:.0f}%) — {_escape_md(outlook.note)}"
|
|
||||||
)
|
|
||||||
await _send_alert(bot, OWNER_ID, message)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Hourly AI market outlook failed")
|
|
||||||
|
|
||||||
|
|
||||||
# ── WebSocket level-breach monitor ──────────────────────────────────
|
# ── WebSocket level-breach monitor ──────────────────────────────────
|
||||||
|
|
||||||
@@ -990,7 +845,7 @@ async def _handle_breach(bot, pair_symbol: str, price: float, level_name: str) -
|
|||||||
logger.info(f"Level breach: {display} crossed {label} at {price} — rescanning M15")
|
logger.info(f"Level breach: {display} crossed {label} at {price} — rescanning M15")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
report = await _cached_or_scan(pair_symbol, Timeframe.M15)
|
report = await run_signal(pair_symbol, Timeframe.M15)
|
||||||
_update_level_cache([report])
|
_update_level_cache([report])
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"Breach scan failed for {display}")
|
logger.exception(f"Breach scan failed for {display}")
|
||||||
@@ -1164,15 +1019,6 @@ _AUTOSCAN_LOOPS = [
|
|||||||
(tf, _TF_INTERVAL_MINUTES[tf]) for tf in _APP.scan_timeframes
|
(tf, _TF_INTERVAL_MINUTES[tf]) for tf in _APP.scan_timeframes
|
||||||
]
|
]
|
||||||
|
|
||||||
# The violent-move heads-up is evaluated on the FASTEST scanned loop
|
|
||||||
# only — it is a "next hour" warning, so once per fastest candle is the
|
|
||||||
# most frequent it can be, and every slower loop would just repeat it.
|
|
||||||
_HEADSUP_TF: Optional[Timeframe] = min(
|
|
||||||
_APP.scan_timeframes,
|
|
||||||
key=lambda tf: _TF_INTERVAL_MINUTES.get(tf, 10 ** 6),
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_ws_task: Optional[asyncio.Task] = None
|
_ws_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
|||||||
+874
-4055
File diff suppressed because it is too large
Load Diff
@@ -1,426 +0,0 @@
|
|||||||
"""
|
|
||||||
ForexBot2 — violent-move watch (DISPLAY-ONLY heads-up)
|
|
||||||
=======================================================
|
|
||||||
Flags the conditions under which a "violent bar" (range >= 3x the prior
|
|
||||||
ATR14) is unusually likely, so a human reading an alert knows the next
|
|
||||||
hour may be wild. It is a WARNING LINE and a standalone heads-up
|
|
||||||
message — nothing here ever changes a signal, grade, gate, trade setup
|
|
||||||
or backtest result.
|
|
||||||
|
|
||||||
Hard independence rule (mirrors market_outlook.py / agent_panel.py):
|
|
||||||
this module is NEVER imported by `signal_aggregator`, `engine`, or
|
|
||||||
anything under `backtest/`. Surfaces (telegram_bot, main, the dashboard
|
|
||||||
publisher) attach the result POST-HOC via `attach_watch`, exactly the
|
|
||||||
way panels are attached.
|
|
||||||
|
|
||||||
Evidence — 3-year study, backtest/research/sizing_campaign_2026-09.md
|
|
||||||
(addenda):
|
|
||||||
* A violent bar is a bar whose range >= 3x the prior ATR14.
|
|
||||||
* Hot clock slots run about 3-4x the normal rate: bars closing
|
|
||||||
12:00-14:59 UTC on weekdays, the 01:xx UTC hour on any weekday, and
|
|
||||||
the Sunday reopen (22:xx-23:xx UTC).
|
|
||||||
* The 30 minutes after a high-impact US release run about 10x normal.
|
|
||||||
Share of releases followed by a violent bar within the hour: NFP
|
|
||||||
97%, CPI 91%, FOMC 88%, retail sales 64%, PPI 54%, PCE 50%,
|
|
||||||
jobless claims 44%.
|
|
||||||
* Indicator readings taken BEFORE the bar each lift the odds 1.3-3x:
|
|
||||||
price outside the Bollinger bands, RSI < 30 or >= 70, close outside
|
|
||||||
the Keltner channel, a rising ADX (slope > 3), volatility regime
|
|
||||||
"extreme", market regime "transitioning", and MACD histogram
|
|
||||||
negative and accelerating down (the engine emits STRONG_SELL for
|
|
||||||
exactly that case).
|
|
||||||
* Only the three PRICE-EXTREME readings (Bollinger, RSI, Keltner)
|
|
||||||
earn a level. A bar-by-bar sweep of the 3-year window showed the
|
|
||||||
first draft — which let any two readings raise the level — flagged
|
|
||||||
27% of all bars "hot" at merely ~2x odds (~13 heads-ups/day). The
|
|
||||||
shipped rule (calendar, or a clock slot plus one price extreme)
|
|
||||||
flags 8.6% of bars: next-bar odds 3-5x normal, ~20% chance within
|
|
||||||
the hour, about 4 hot hours a day. The remaining readings (ADX
|
|
||||||
slope, volatility regime, regime transition, MACD acceleration) are
|
|
||||||
CONTEXT: they are printed once a level exists, and never create one.
|
|
||||||
* Direction: bearish readings preceded a DOWN burst 70-78% of the
|
|
||||||
time on BOTH M5 and M15. Bullish readings preceded an UP burst only
|
|
||||||
~60% of the time and only on M5 — so an "up" call is made on M5
|
|
||||||
alone.
|
|
||||||
* Open trades are NOT to be touched. Every protective action tested
|
|
||||||
(early close, tightened stop, partial exit) lost money; the stop
|
|
||||||
already IS the protection. The heads-up says so explicitly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
from config import SignalStrength, Timeframe
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Calendar window either side of a high-impact US release (minutes).
|
|
||||||
RELEASE_WINDOW_MINUTES = 30
|
|
||||||
# Hot clock slots (UTC), from the 3-year bar study.
|
|
||||||
US_DATA_HOURS = (12, 13, 14)
|
|
||||||
ASIAN_OPEN_HOUR = 1
|
|
||||||
SUNDAY_REOPEN_HOURS = (22, 23)
|
|
||||||
# ADX slope above this counts as "trend strength rising".
|
|
||||||
ADX_SLOPE_HOT = 3.0
|
|
||||||
|
|
||||||
_LEVEL_NONE = "none"
|
|
||||||
_LEVEL_WATCH = "watch"
|
|
||||||
_LEVEL_HOT = "hot"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ViolentWatch:
|
|
||||||
"""One scan's violent-move assessment. Advisory/display-only."""
|
|
||||||
level: str = _LEVEL_NONE # "none" | "watch" | "hot"
|
|
||||||
triggers: List[str] = field(default_factory=list) # plain language
|
|
||||||
kinds: List[str] = field(default_factory=list) # calendar/clock/extreme
|
|
||||||
readings: int = 0 # PRICE-EXTREME readings (0..3)
|
|
||||||
likely_direction: str = "" # "down" | "up" | ""
|
|
||||||
|
|
||||||
def line(self) -> str:
|
|
||||||
"""One-line alert text; "" when nothing fired."""
|
|
||||||
if self.level == _LEVEL_NONE or not self.triggers:
|
|
||||||
return ""
|
|
||||||
if self.level == _LEVEL_HOT:
|
|
||||||
head = "🌪 Violent-move watch (HOT): "
|
|
||||||
odds = " — odds of a violent bar about 3–5× normal"
|
|
||||||
else:
|
|
||||||
head = "🌪 Violent-move watch (watch): "
|
|
||||||
odds = " — about 2× normal"
|
|
||||||
tail = ""
|
|
||||||
if self.likely_direction == "down":
|
|
||||||
tail = " — if one comes it has usually gone DOWN"
|
|
||||||
elif self.likely_direction == "up":
|
|
||||||
tail = " — if one comes it has usually gone UP"
|
|
||||||
return head + " · ".join(self.triggers) + odds + tail
|
|
||||||
|
|
||||||
|
|
||||||
# ── Calendar ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _is_high_impact_usd(event: Any) -> bool:
|
|
||||||
"""USD + high impact, tolerating either the `impact` string or the
|
|
||||||
`is_high_impact` property (EconomicEvent exposes both)."""
|
|
||||||
try:
|
|
||||||
if str(getattr(event, "currency", "")).upper() != "USD":
|
|
||||||
return False
|
|
||||||
impact = getattr(event, "impact", None)
|
|
||||||
if isinstance(impact, str):
|
|
||||||
return impact.strip().lower() == "high"
|
|
||||||
return bool(getattr(event, "is_high_impact", False))
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _calendar_trigger(events: Optional[List[Any]],
|
|
||||||
now: datetime) -> Optional[str]:
|
|
||||||
"""Soonest high-impact USD release inside +/-30 min of `now`."""
|
|
||||||
if not events:
|
|
||||||
return None
|
|
||||||
window = timedelta(minutes=RELEASE_WINDOW_MINUTES)
|
|
||||||
best: Optional[Tuple[float, str]] = None
|
|
||||||
for ev in events or []:
|
|
||||||
if not _is_high_impact_usd(ev):
|
|
||||||
continue
|
|
||||||
when = getattr(ev, "time", None)
|
|
||||||
if not isinstance(when, datetime) or when.tzinfo is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
delta = when - now
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
name = str(getattr(ev, "event", "") or "US release").strip()
|
|
||||||
if timedelta(0) < delta <= window:
|
|
||||||
minutes = max(1, int(round(delta.total_seconds() / 60)))
|
|
||||||
text = f"US high-impact release in {minutes} min ({name})"
|
|
||||||
rank = abs(delta.total_seconds())
|
|
||||||
elif -window <= delta < timedelta(0):
|
|
||||||
minutes = max(1, int(round(-delta.total_seconds() / 60)))
|
|
||||||
text = f"US release just out ({name}, {minutes} min ago)"
|
|
||||||
rank = abs(delta.total_seconds())
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
if best is None or rank < best[0]:
|
|
||||||
best = (rank, text)
|
|
||||||
return None if best is None else best[1]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Clock ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _clock_trigger(now: datetime) -> Optional[str]:
|
|
||||||
"""Hot clock slot for `now` (UTC), or None."""
|
|
||||||
try:
|
|
||||||
moment = now.astimezone(timezone.utc) if now.tzinfo else now
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
weekday, hour = moment.weekday(), moment.hour # Mon=0 .. Sun=6
|
|
||||||
if weekday <= 4 and hour in US_DATA_HOURS:
|
|
||||||
return "US data hours (12:00–15:00 UTC)"
|
|
||||||
if weekday <= 4 and hour == ASIAN_OPEN_HOUR:
|
|
||||||
return "Asian open hour (01:00 UTC)"
|
|
||||||
if weekday == 6 and hour in SUNDAY_REOPEN_HOURS:
|
|
||||||
return "Sunday reopen"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ── Indicator readings ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _indicator_value(analysis: Any, name: str) -> Optional[float]:
|
|
||||||
try:
|
|
||||||
ind = (analysis.indicators or {}).get(name)
|
|
||||||
if ind is None:
|
|
||||||
return None
|
|
||||||
value = float(ind.value)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
if value != value: # NaN
|
|
||||||
return None
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _indicator_signal(analysis: Any, name: str) -> Optional[SignalStrength]:
|
|
||||||
try:
|
|
||||||
ind = (analysis.indicators or {}).get(name)
|
|
||||||
return None if ind is None else ind.signal
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _keltner_side(analysis: Any) -> Optional[str]:
|
|
||||||
""""above" / "below" / None — is the close outside the channel?
|
|
||||||
|
|
||||||
The engine's keltner indicator carries `value = close - kc_mid` and
|
|
||||||
stashes kc_upper/kc_lower/kc_mid in `key_levels` (engine.py
|
|
||||||
_compute_keltner). Comparing the value against the band half-widths
|
|
||||||
is therefore exact AND survives the squeeze branches, which
|
|
||||||
overwrite the indicator's signal/description but still publish the
|
|
||||||
same levels. The description text is only a fallback for analyses
|
|
||||||
that were built without key_levels.
|
|
||||||
"""
|
|
||||||
value = _indicator_value(analysis, "keltner")
|
|
||||||
levels = getattr(analysis, "key_levels", None) or {}
|
|
||||||
try:
|
|
||||||
upper = levels.get("kc_upper")
|
|
||||||
lower = levels.get("kc_lower")
|
|
||||||
mid = levels.get("kc_mid")
|
|
||||||
if value is not None and None not in (upper, lower, mid):
|
|
||||||
if value > float(upper) - float(mid):
|
|
||||||
return "above"
|
|
||||||
if value < float(lower) - float(mid):
|
|
||||||
return "below"
|
|
||||||
return None
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
desc = str((analysis.indicators or {})["keltner"].description).lower()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
if "above keltner upper" in desc:
|
|
||||||
return "above"
|
|
||||||
if "below keltner lower" in desc:
|
|
||||||
return "below"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_readings(analysis: Any) -> Tuple[List[str], List[str], int, int]:
|
|
||||||
"""(price_extreme_phrases, context_phrases, bearish, bullish).
|
|
||||||
|
|
||||||
Only the PRICE-EXTREME group earns a level (see the module
|
|
||||||
docstring); the context group is printed alongside once a level
|
|
||||||
exists but can never create one. Both groups feed direction.
|
|
||||||
"""
|
|
||||||
extreme: List[str] = []
|
|
||||||
context: List[str] = []
|
|
||||||
bear = bull = 0
|
|
||||||
if analysis is None:
|
|
||||||
return extreme, context, bear, bull
|
|
||||||
|
|
||||||
# ── Price extremes (level-bearing) ──
|
|
||||||
bb = _indicator_value(analysis, "bollinger")
|
|
||||||
if bb is not None:
|
|
||||||
if bb < 0:
|
|
||||||
extreme.append("price below the Bollinger bands")
|
|
||||||
bear += 1
|
|
||||||
elif bb > 1:
|
|
||||||
extreme.append("price above the Bollinger bands")
|
|
||||||
bull += 1
|
|
||||||
|
|
||||||
rsi = _indicator_value(analysis, "rsi")
|
|
||||||
if rsi is not None:
|
|
||||||
if rsi < 30:
|
|
||||||
extreme.append(f"RSI {rsi:.0f}, oversold extreme")
|
|
||||||
bear += 1
|
|
||||||
elif rsi >= 70:
|
|
||||||
extreme.append(f"RSI {rsi:.0f}, overbought extreme")
|
|
||||||
bull += 1
|
|
||||||
|
|
||||||
kc_side = _keltner_side(analysis)
|
|
||||||
if kc_side is not None:
|
|
||||||
extreme.append("price outside the Keltner channel")
|
|
||||||
if kc_side == "above":
|
|
||||||
bull += 1
|
|
||||||
else:
|
|
||||||
bear += 1
|
|
||||||
|
|
||||||
# ── Context (never raises the level) ──
|
|
||||||
slope = getattr(analysis, "_adx_slope", None)
|
|
||||||
try:
|
|
||||||
if slope is not None and float(slope) > ADX_SLOPE_HOT:
|
|
||||||
context.append("trend strength rising")
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if str(getattr(analysis, "volatility_regime", "")) == "extreme":
|
|
||||||
context.append("volatility extreme")
|
|
||||||
|
|
||||||
regime = str(getattr(analysis, "market_regime", ""))
|
|
||||||
if regime == "transitioning":
|
|
||||||
context.append("regime transitioning")
|
|
||||||
|
|
||||||
macd = _indicator_signal(analysis, "macd")
|
|
||||||
if macd == SignalStrength.STRONG_SELL:
|
|
||||||
context.append("downside momentum accelerating")
|
|
||||||
bear += 1
|
|
||||||
elif macd == SignalStrength.STRONG_BUY:
|
|
||||||
context.append("upside momentum accelerating")
|
|
||||||
bull += 1
|
|
||||||
|
|
||||||
# Regime contributes direction only — it has no phrase of its own
|
|
||||||
# beyond "regime transitioning" above.
|
|
||||||
if regime == "trending_down":
|
|
||||||
bear += 1
|
|
||||||
elif regime == "trending_up":
|
|
||||||
bull += 1
|
|
||||||
|
|
||||||
return extreme, context, bear, bull
|
|
||||||
|
|
||||||
|
|
||||||
# ── Assessment ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def assess(report: Any,
|
|
||||||
now: datetime,
|
|
||||||
events: Optional[List[Any]] = None,
|
|
||||||
primary_tf: Optional[Timeframe] = None) -> ViolentWatch:
|
|
||||||
"""Score violent-move conditions. Every input may be missing or
|
|
||||||
malformed — the answer then degrades to "no trigger". Never raises."""
|
|
||||||
triggers: List[str] = []
|
|
||||||
kinds: List[str] = []
|
|
||||||
|
|
||||||
calendar = _calendar_trigger(events, now)
|
|
||||||
if calendar:
|
|
||||||
triggers.append(calendar)
|
|
||||||
kinds.append("calendar")
|
|
||||||
|
|
||||||
clock = _clock_trigger(now)
|
|
||||||
if clock:
|
|
||||||
triggers.append(clock)
|
|
||||||
kinds.append("clock")
|
|
||||||
|
|
||||||
analysis = getattr(report, "primary_analysis", None)
|
|
||||||
extreme_phrases, context_phrases, bear, bull = _collect_readings(analysis)
|
|
||||||
triggers.extend(extreme_phrases)
|
|
||||||
readings = len(extreme_phrases)
|
|
||||||
if readings >= 1:
|
|
||||||
kinds.append("extreme")
|
|
||||||
|
|
||||||
# Level rule (bar-by-bar validated, see module docstring): only
|
|
||||||
# price extremes and the clock/calendar create a level.
|
|
||||||
if calendar or (clock and readings >= 1):
|
|
||||||
level = _LEVEL_HOT
|
|
||||||
elif clock or readings >= 2:
|
|
||||||
level = _LEVEL_WATCH
|
|
||||||
else:
|
|
||||||
level = _LEVEL_NONE
|
|
||||||
|
|
||||||
# Context readings are colour, printed only once a level exists.
|
|
||||||
if level != _LEVEL_NONE:
|
|
||||||
triggers.extend(context_phrases)
|
|
||||||
|
|
||||||
direction = ""
|
|
||||||
if bear >= 2 and bear > bull:
|
|
||||||
direction = "down"
|
|
||||||
elif bull >= 2 and bull > bear:
|
|
||||||
# Bullish readings only earned a call on M5 in the study.
|
|
||||||
tf_value = getattr(primary_tf, "value", primary_tf)
|
|
||||||
if tf_value == Timeframe.M5.value:
|
|
||||||
direction = "up"
|
|
||||||
|
|
||||||
return ViolentWatch(level=level, triggers=triggers, kinds=kinds,
|
|
||||||
readings=readings, likely_direction=direction)
|
|
||||||
|
|
||||||
|
|
||||||
def _log_watch(report: Any, watch: "ViolentWatch") -> None:
|
|
||||||
"""Persist the assessment onto the scan's signals.db row (fail-safe).
|
|
||||||
|
|
||||||
The row is inserted inside analyze_pair — BEFORE a surface attaches
|
|
||||||
the watch — so these columns arrive by post-hoc update, exactly like
|
|
||||||
the panel verdict. Level and kinds are written even when the display
|
|
||||||
line is off: evaluating the watch needs the assessment, not the text.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from signal_log import log_watch_update
|
|
||||||
log_watch_update(report, watch)
|
|
||||||
except Exception:
|
|
||||||
logger.debug("violent watch: logging failed", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def attach_watch(report: Any,
|
|
||||||
primary_tf: Optional[Timeframe],
|
|
||||||
settings: Dict[str, Any],
|
|
||||||
now: Optional[datetime] = None) -> ViolentWatch:
|
|
||||||
"""Stamp `report.watch_line` with the display line (advisory only).
|
|
||||||
|
|
||||||
Contract: NEVER raises. A broken watch must not cost an alert send
|
|
||||||
or a dashboard publish — a failure logs and returns level "none".
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
settings = settings or {}
|
|
||||||
if not settings.get("enabled", False):
|
|
||||||
return ViolentWatch()
|
|
||||||
moment = now or datetime.now(timezone.utc)
|
|
||||||
events: Optional[List[Any]] = None
|
|
||||||
try:
|
|
||||||
# Lazy import keeps this module import-light and free of any
|
|
||||||
# signal-path dependency. The calendar rides NewsEngine's
|
|
||||||
# class-level cache, so a warm cache costs no network I/O.
|
|
||||||
from config import PlatformConfig
|
|
||||||
from news_engine import NewsEngine
|
|
||||||
events = NewsEngine(PlatformConfig().news).get_week_events(["USD"])
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("violent watch: calendar unavailable (%s)", exc)
|
|
||||||
watch = assess(report, moment, events, primary_tf)
|
|
||||||
if settings.get("alert_line", True):
|
|
||||||
report.watch_line = watch.line()
|
|
||||||
_log_watch(report, watch)
|
|
||||||
return watch
|
|
||||||
except Exception:
|
|
||||||
logger.exception("violent watch: assessment failed")
|
|
||||||
return ViolentWatch()
|
|
||||||
|
|
||||||
|
|
||||||
def should_send_headsup(state: Dict[str, Any],
|
|
||||||
now: datetime,
|
|
||||||
kinds: List[str],
|
|
||||||
max_per_day: int) -> Tuple[bool, str]:
|
|
||||||
"""Rate-limit the standalone heads-up message.
|
|
||||||
|
|
||||||
`state` is a mutable dict owned by the caller:
|
|
||||||
{"day": date, "count": int, "sent": {(hour_iso, kinds): True}}
|
|
||||||
One message per UTC hour per trigger combination, capped per UTC day.
|
|
||||||
"""
|
|
||||||
day = now.date()
|
|
||||||
if state.get("day") != day:
|
|
||||||
state["day"] = day
|
|
||||||
state["count"] = 0
|
|
||||||
state["sent"] = {}
|
|
||||||
sent = state.setdefault("sent", {})
|
|
||||||
if int(state.get("count", 0)) >= int(max_per_day):
|
|
||||||
return False, "daily cap"
|
|
||||||
hour_bucket = now.replace(minute=0, second=0, microsecond=0).isoformat()
|
|
||||||
key = (hour_bucket, tuple(sorted(kinds)))
|
|
||||||
if key in sent:
|
|
||||||
return False, "already sent this hour for these triggers"
|
|
||||||
sent[key] = True
|
|
||||||
state["count"] = int(state.get("count", 0)) + 1
|
|
||||||
return True, "ok"
|
|
||||||
Reference in New Issue
Block a user