Skip to main content

kestrel_chartkit/engine/
market_context.rs

1#[cfg(feature = "serde")]
2use serde::{Deserialize, Serialize};
3
4use crate::model::MarketRegime;
5
6/// Where the current price sits relative to the Volume Profile (plan Anhang E).
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8#[cfg_attr(
9    feature = "serde",
10    derive(Serialize, Deserialize),
11    serde(rename_all = "snake_case")
12)]
13pub enum VolumeNodeKind {
14    Hvn,
15    Lvn,
16    Neutral,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[cfg_attr(
21    feature = "serde",
22    derive(Serialize, Deserialize),
23    serde(rename_all = "snake_case")
24)]
25pub enum AcceptanceLevel {
26    High,
27    Low,
28    None,
29}
30
31/// Balance/Trend auction-phase lifecycle (plan Anhang F "Bestätigung/Verfeinerung, sechstes
32/// Video" + Anhang G.1 "zyklisch gedacht: Impulse → Balance → Acceptance → Breakout → Impulse").
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 AuctionPhase {
40    InsideBalance,
41    Breakout,
42    Acceptance { duration_bars: u32 },
43    Retest,
44    Expansion,
45}
46
47/// Composite context around price relative to Volume Profile / regime (plan Anhang E,
48/// `MarketContextOutput`). Deliberately kept separate from a future `AuctionOutput`
49/// (Delta/Absorption) since that needs aggressor-tagged trade/tick data, not just `Bar`s —
50/// see the plan's "Offene Datenmodell-Frage".
51#[derive(Debug, Clone, Copy, PartialEq)]
52#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
53pub struct MarketContextOutput {
54    pub regime: MarketRegime,
55    pub distance_to_vpoc_atr: f64,
56    pub current_volume_node: VolumeNodeKind,
57    pub previous_acceptance: AcceptanceLevel,
58    pub auction_phase: AuctionPhase,
59}
60
61/// Classify the current volume node from a Volume-Profile-style density estimate: `density`
62/// is the traded-volume share at the current price bucket (0.0..1.0).
63pub fn classify_volume_node(
64    density: f64,
65    hvn_threshold: f64,
66    lvn_threshold: f64,
67) -> VolumeNodeKind {
68    if density >= hvn_threshold {
69        VolumeNodeKind::Hvn
70    } else if density <= lvn_threshold {
71        VolumeNodeKind::Lvn
72    } else {
73        VolumeNodeKind::Neutral
74    }
75}
76
77#[allow(clippy::too_many_arguments)]
78pub fn build_market_context(
79    regime: MarketRegime,
80    price: f64,
81    vpoc: f64,
82    atr: f64,
83    node: VolumeNodeKind,
84    previous_acceptance: AcceptanceLevel,
85    auction_phase: AuctionPhase,
86) -> MarketContextOutput {
87    let distance_to_vpoc_atr = if atr > 0.0 {
88        (price - vpoc).abs() / atr
89    } else {
90        0.0
91    };
92    MarketContextOutput {
93        regime,
94        distance_to_vpoc_atr,
95        current_volume_node: node,
96        previous_acceptance,
97        auction_phase,
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn volume_node_classification_thresholds() {
107        assert_eq!(classify_volume_node(0.9, 0.7, 0.2), VolumeNodeKind::Hvn);
108        assert_eq!(classify_volume_node(0.1, 0.7, 0.2), VolumeNodeKind::Lvn);
109        assert_eq!(classify_volume_node(0.5, 0.7, 0.2), VolumeNodeKind::Neutral);
110    }
111
112    #[test]
113    fn distance_to_vpoc_is_atr_normalized() {
114        let ctx = build_market_context(
115            MarketRegime::BullishExpansion,
116            110.0,
117            100.0,
118            5.0,
119            VolumeNodeKind::Neutral,
120            AcceptanceLevel::None,
121            AuctionPhase::Expansion,
122        );
123        assert!((ctx.distance_to_vpoc_atr - 2.0).abs() < 1e-9);
124    }
125}