Skip to main content

kestrel_chartkit/engine/
market_state.rs

1#[cfg(feature = "serde")]
2use serde::{Deserialize, Serialize};
3
4use crate::model::MarketRegime;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7#[cfg_attr(
8    feature = "serde",
9    derive(Serialize, Deserialize),
10    serde(rename_all = "snake_case")
11)]
12pub enum VolatilityRegime {
13    High,
14    Normal,
15    Low,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19#[cfg_attr(
20    feature = "serde",
21    derive(Serialize, Deserialize),
22    serde(rename_all = "snake_case")
23)]
24pub enum OpeningType {
25    InsideValue,
26    OutsideValue,
27    Unknown,
28}
29
30/// Permission grade for one side of the playbook (plan Anhang F/Anhang G "Permission als
31/// eigene, dem Playbook vorgelagerte Ebene"). A `Preferred`/`Neutral` state never *mandates*
32/// a trade — see "Bias ≠ Signal" in the plan; it only says which scenarios are in scope.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34#[cfg_attr(
35    feature = "serde",
36    derive(Serialize, Deserialize),
37    serde(rename_all = "snake_case")
38)]
39pub enum PlaybookState {
40    Preferred,
41    Neutral,
42    Suppressed,
43    NotAllowed,
44}
45
46/// Leithierarchie state (plan Anhang F): `Market State → Expected Playbook → Location →
47/// Trigger → Trade Geometry → Historical Edge`. `regime` reuses `model::MarketRegime`
48/// (`BullishExpansion`/`BearishExpansion`/`Consolidation`/`Transition`) instead of a
49/// second, competing Bull/Bear/Balance enum.
50#[derive(Debug, Clone, Copy, PartialEq)]
51#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
52pub struct MarketStateOutput {
53    pub regime: MarketRegime,
54    pub volatility_regime: VolatilityRegime,
55    /// 0.0..1.0, analog `trend_quality` in `indicator::swing_structure`.
56    pub trend_stability: f64,
57    /// 0.0..1.0. Placeholder heuristic in [`super::pipeline::build_market_state_and_playbook`]:
58    /// a fixed 0.8/0.2 based only on `regime`, not a measured probability.
59    pub balance_probability: f64,
60    /// Opening Range size vs. the last ~20 sessions, 0.0..1.0. Placeholder heuristic in
61    /// [`super::pipeline::build_market_state_and_playbook`]: always 0.5 (neutral), not derived
62    /// from actual session history.
63    pub opening_range_percentile: f64,
64    pub opening_type: OpeningType,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
69pub struct ExpectedPlaybook {
70    pub continuation_long: PlaybookState,
71    pub continuation_short: PlaybookState,
72    pub reversal_long: PlaybookState,
73    pub reversal_short: PlaybookState,
74}
75
76/// Derive the Expected-Playbook permission grades from regime + trend stability. Follows
77/// "Bias ≠ Signal" (plan Anhang F, achtes Video): a regime only limits which scenarios are
78/// preferred/allowed, it never forces a trade — the lower-timeframe trigger still decides.
79pub fn derive_playbook(regime: MarketRegime, trend_stability: f64) -> ExpectedPlaybook {
80    let strong = trend_stability >= 0.5;
81    match regime {
82        MarketRegime::BullishExpansion => ExpectedPlaybook {
83            continuation_long: if strong {
84                PlaybookState::Preferred
85            } else {
86                PlaybookState::Neutral
87            },
88            continuation_short: PlaybookState::NotAllowed,
89            reversal_long: PlaybookState::NotAllowed,
90            reversal_short: PlaybookState::Suppressed,
91        },
92        MarketRegime::BearishExpansion => ExpectedPlaybook {
93            continuation_long: PlaybookState::NotAllowed,
94            continuation_short: if strong {
95                PlaybookState::Preferred
96            } else {
97                PlaybookState::Neutral
98            },
99            reversal_long: PlaybookState::Suppressed,
100            reversal_short: PlaybookState::NotAllowed,
101        },
102        MarketRegime::Consolidation => ExpectedPlaybook {
103            continuation_long: PlaybookState::NotAllowed,
104            continuation_short: PlaybookState::NotAllowed,
105            reversal_long: PlaybookState::Preferred,
106            reversal_short: PlaybookState::Preferred,
107        },
108        // "Strukturbruch ≠ Trendwechsel" (plan Anhang D, achtes Video): a broken trend is
109        // neutral/transitional until re-acceptance confirms a new direction, so nothing gets
110        // `Preferred` yet.
111        MarketRegime::Transition => ExpectedPlaybook {
112            continuation_long: PlaybookState::Suppressed,
113            continuation_short: PlaybookState::Suppressed,
114            reversal_long: PlaybookState::Neutral,
115            reversal_short: PlaybookState::Neutral,
116        },
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn strong_bull_regime_prefers_continuation_long_only() {
126        let pb = derive_playbook(MarketRegime::BullishExpansion, 0.8);
127        assert_eq!(pb.continuation_long, PlaybookState::Preferred);
128        assert_eq!(pb.continuation_short, PlaybookState::NotAllowed);
129        assert_eq!(pb.reversal_long, PlaybookState::NotAllowed);
130    }
131
132    #[test]
133    fn balance_regime_prefers_both_reversal_sides() {
134        let pb = derive_playbook(MarketRegime::Consolidation, 0.9);
135        assert_eq!(pb.reversal_long, PlaybookState::Preferred);
136        assert_eq!(pb.reversal_short, PlaybookState::Preferred);
137        assert_eq!(pb.continuation_long, PlaybookState::NotAllowed);
138    }
139
140    #[test]
141    fn transition_regime_never_prefers_anything() {
142        let pb = derive_playbook(MarketRegime::Transition, 1.0);
143        assert_ne!(pb.continuation_long, PlaybookState::Preferred);
144        assert_ne!(pb.continuation_short, PlaybookState::Preferred);
145        assert_ne!(pb.reversal_long, PlaybookState::Preferred);
146        assert_ne!(pb.reversal_short, PlaybookState::Preferred);
147    }
148}