File size: 7,400 Bytes
ef20ebe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
"""
Deterministic Buy/Hold/Sell policy applied to Moirai-MoE's forecast
output (spec sections 31-34, 93, 111-113). This module contains NO
predictive model of its own, hidden or otherwise β€” only arithmetic over
a forecast object produced by moirai_model.py. Nothing in this file
imports sklearn/xgboost/lightgbm/catboost or any other classifier.
"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Optional

import numpy as np

import config as cfg
from calibration import ProbabilityCalibrator


@dataclass
class Decision:
    action: str  # "BUY" | "SELL" | "HOLD"
    expected_return: float
    expected_edge: float
    p10_return: float
    p90_return: float
    prob_favorable: float          # the probability actually used to gate this decision (calibrated, if a calibrator was supplied)
    raw_prob_favorable: float      # the same event's probability BEFORE calibration -- always shown, never hidden (spec: "do not hide weak signals")
    calibrated: bool               # whether a calibrator was actually applied
    interval_width_pct: float
    rationale: str
    hold_reason: str = None
    reject_stage: str = None  # structured category for diagnostics/counting:
    # "wide_interval" | "no_edge" | "low_confidence_buy" | "low_confidence_sell" | None (BUY/SELL taken)


def _prob_exceeds(levels: list, values: np.ndarray, threshold: float) -> float:
    """P(true return > threshold), estimated by linear interpolation on
    the model's own empirical quantile function β€” not a fabricated
    number (spec section 32-33)."""
    order = np.argsort(levels)
    lv = np.asarray(levels)[order]
    vv = np.asarray(values)[order]
    if threshold <= vv[0]:
        return 1.0
    if threshold >= vv[-1]:
        return 0.0
    p_below = float(np.interp(threshold, vv, lv))
    return 1.0 - p_below


def decide(forecast, price_now: float, dc: cfg.DecisionConfig = None,
           cost_estimate_frac: float = None,
           calibrator: Optional[ProbabilityCalibrator] = None) -> Decision:
    """`forecast` is a QuantileForecast (see moirai_model.py) with
    .quantile_levels (list of floats incl. 0.1/0.5/0.9) and .quantiles
    of shape (horizon, len(levels)).

    `calibrator`, if given, is applied to prob_favorable/
    prob_favorable_sell BEFORE either is compared against
    dc.min_probability -- real EURUSD 1h testing showed raw quantile-
    derived probabilities of 0.70-0.88 against a realized win rate
    around 0.50, i.e. the raw number is not trustworthy on its own (see
    calibration.py). None (the default) reproduces the previous,
    uncalibrated behavior exactly -- calibration is opt-in via an
    explicitly-supplied, already-fit ProbabilityCalibrator (see
    walk_forward.py for the only leakage-safe way to obtain one)."""
    dc = dc or cfg.DecisionConfig()
    levels = list(forecast.quantile_levels)
    step_prices = np.asarray(forecast.quantiles)[-1]  # final horizon step
    step_returns = step_prices / price_now - 1.0

    p10 = float(step_returns[levels.index(0.1)])
    p50 = float(step_returns[levels.index(0.5)])
    p90 = float(step_returns[levels.index(0.9)])

    cost = cost_estimate_frac if cost_estimate_frac is not None else dc.cost_bps / 10_000
    interval_width_pct = p90 - p10
    raw_prob_favorable = _prob_exceeds(levels, step_returns, threshold=cost)  # P(return > +cost) -- the BUY-side confidence
    # SELL is profitable when return < -cost (shorting profits from a
    # price drop), which is NOT the same event as "return <= +cost" --
    # that includes the whole middle range between -cost and +cost too,
    # so it isn't "1 - prob_favorable" reused. This is its own directional
    # probability, symmetric to prob_favorable but mirrored around
    # -cost instead of +cost, exactly the way min_probability's own
    # definition ("P(return favorable) required to act", config.py) is
    # meant to apply to either direction, not just BUY.
    raw_prob_favorable_sell = 1.0 - _prob_exceeds(levels, step_returns, threshold=-cost)  # P(return < -cost)

    if calibrator is not None:
        prob_favorable = calibrator.apply(raw_prob_favorable)
        prob_favorable_sell = calibrator.apply(raw_prob_favorable_sell)
        calibrated = True
    else:
        prob_favorable = raw_prob_favorable
        prob_favorable_sell = raw_prob_favorable_sell
        calibrated = False

    hold_reason = None
    reject_stage = None
    if interval_width_pct > dc.max_interval_width_pct:
        action = "HOLD"
        hold_reason = "forecast interval too wide relative to expected move (section 93)"
        reject_stage = "wide_interval"
    elif (p50 - cost) > dc.buy_threshold and prob_favorable >= dc.min_probability:
        action = "BUY"
    elif (p50 + cost) < dc.sell_threshold and prob_favorable_sell >= dc.min_probability:
        action = "SELL"
    else:
        action = "HOLD"
        # Distinguish "p50 never even cleared a directional threshold" from
        # "it cleared one, but confidence didn't clear min_probability" --
        # these are very different diagnoses (a fast/low-volatility
        # timeframe where p50 rarely moves enough to matter, vs. a
        # timeframe with real moves but noisy/unreliable ones) and were
        # previously indistinguishable from outside this function, which
        # made "why are there almost no trades" unanswerable without this
        # split (spec section 98: errors/holds must say what failed).
        buy_edge_ok = (p50 - cost) > dc.buy_threshold
        sell_edge_ok = (p50 + cost) < dc.sell_threshold
        if buy_edge_ok:
            hold_reason = "expected edge below threshold after costs β€” buy-side edge present but confidence below min_probability (section 93)"
            reject_stage = "low_confidence_buy"
        elif sell_edge_ok:
            hold_reason = "expected edge below threshold after costs β€” sell-side edge present but confidence below min_probability (section 93)"
            reject_stage = "low_confidence_sell"
        else:
            hold_reason = "expected edge below threshold after costs β€” no directional edge either way (section 93)"
            reject_stage = "no_edge"

    # Report whichever probability is actually relevant to the action taken
    # -- for a SELL, "P(return>cost)" reads as near-zero and looks like the
    # decision has no confidence behind it, when the real (and high)
    # confidence is in the mirrored SELL-side event. BUY/HOLD keep the
    # original P(return>cost) framing.
    if action == "SELL":
        prob_shown, raw_prob_shown, prob_label = prob_favorable_sell, raw_prob_favorable_sell, "P(return<-cost)"
    else:
        prob_shown, raw_prob_shown, prob_label = prob_favorable, raw_prob_favorable, "P(return>cost)"

    cal_note = f"; calibrated (raw={raw_prob_shown:.2f})" if calibrated else ""
    rationale = (
        f"P50 return {p50:+.4%} vs cost {cost:.4%}; {prob_label}={prob_shown:.2f}{cal_note}; "
        f"interval[P10,P90]=[{p10:+.4%},{p90:+.4%}]"
    )
    return Decision(
        action=action, expected_return=p50, expected_edge=p50 - cost,
        p10_return=p10, p90_return=p90, prob_favorable=prob_shown,
        raw_prob_favorable=raw_prob_shown, calibrated=calibrated,
        interval_width_pct=interval_width_pct, rationale=rationale, hold_reason=hold_reason,
        reject_stage=reject_stage,
    )