Skip to main content

kestrel_chartkit/
artifact.rs

1//! Shared, typed result/artifact models for indicator outputs.
2//!
3//! Individual indicators historically attached auxiliary results (pivots, zones, profile bins,
4//! scenario progress) to [`crate::indicator::IndicatorOutput`] as ad-hoc, indicator-local
5//! `extra: HashMap<String, f64>` keys. [`Artifact`] gives those shapes a shared, typed
6//! representation so consumers can pattern-match on them generically instead of parsing
7//! indicator-specific string keys.
8
9#[cfg(feature = "serde")]
10use serde::{Deserialize, Serialize};
11
12/// A single labeled swing/pivot point.
13#[derive(Debug, Clone, Copy, PartialEq)]
14#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
15pub struct PivotArtifact {
16    pub timestamp: i64,
17    pub price: f64,
18    pub is_high: bool,
19    pub confirmed: bool,
20}
21
22/// A price zone (support/resistance, order block, FVG, liquidity pool, ...).
23///
24/// `from_ts`/`to_ts` bound the zone in time: the first and last bar it was derived from.
25/// A zone without them can only be drawn as a band across the full width, which loses
26/// where it formed — so emitters should fill them whenever they know. `None` means
27/// genuinely unknown, not "now": consumers must not substitute a guess.
28#[derive(Debug, Clone, PartialEq)]
29#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
30pub struct ZoneArtifact {
31    pub kind: String,
32    pub price_top: f64,
33    pub price_bottom: f64,
34    pub strength: f64,
35    pub touches: u32,
36    /// First bar the zone was derived from, if known.
37    pub from_ts: Option<i64>,
38    /// Last bar the zone was derived from, if known.
39    pub to_ts: Option<i64>,
40}
41
42impl ZoneArtifact {
43    /// Builds a zone without time bounds — for emitters that genuinely have none.
44    pub fn new(kind: impl Into<String>, price_top: f64, price_bottom: f64) -> Self {
45        Self {
46            kind: kind.into(),
47            price_top,
48            price_bottom,
49            strength: 0.0,
50            touches: 0,
51            from_ts: None,
52            to_ts: None,
53        }
54    }
55
56    /// Attaches the bar range the zone was derived from.
57    pub fn spanning(mut self, from_ts: i64, to_ts: i64) -> Self {
58        self.from_ts = Some(from_ts);
59        self.to_ts = Some(to_ts);
60        self
61    }
62
63    /// The zone's time bounds, if both are known.
64    pub fn span(&self) -> Option<(i64, i64)> {
65        self.from_ts.zip(self.to_ts)
66    }
67}
68
69/// A single bin/level of a distribution profile (price/volume profile, delta profile, ...).
70#[derive(Debug, Clone, Copy, PartialEq)]
71#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
72pub struct ProfileBin {
73    pub price_low: f64,
74    pub price_high: f64,
75    pub value: f64,
76}
77
78/// A distribution profile made of ordered [`ProfileBin`]s plus its summary levels.
79///
80/// `from_ts`/`to_ts` bound the accumulation window, the same contract as
81/// [`ZoneArtifact`]: `None` means unknown, never "now".
82#[derive(Debug, Clone, PartialEq)]
83#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
84pub struct ProfileArtifact {
85    pub kind: String,
86    pub bins: Vec<ProfileBin>,
87    pub poc: f64,
88    pub value_area_high: f64,
89    pub value_area_low: f64,
90    /// First bar the profile was accumulated from, if known.
91    pub from_ts: Option<i64>,
92    /// Last bar the profile was accumulated from, if known.
93    pub to_ts: Option<i64>,
94}
95
96impl ProfileArtifact {
97    /// Attaches the bar range the profile was accumulated over.
98    pub fn spanning(mut self, from_ts: i64, to_ts: i64) -> Self {
99        self.from_ts = Some(from_ts);
100        self.to_ts = Some(to_ts);
101        self
102    }
103
104    /// The profile's time bounds, if both are known.
105    pub fn span(&self) -> Option<(i64, i64)> {
106        self.from_ts.zip(self.to_ts)
107    }
108}
109
110/// Progress of a multi-stage composite scenario (e.g. Setup -> Watch -> Trigger).
111#[derive(Debug, Clone, PartialEq)]
112#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
113pub struct ScenarioArtifact {
114    pub name: String,
115    pub stage: String,
116    pub progress: f64,
117    pub invalidated: bool,
118}
119
120/// A typed, indicator-emitted result artifact.
121#[derive(Debug, Clone, PartialEq)]
122#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
123pub enum Artifact {
124    Pivot(PivotArtifact),
125    Zone(ZoneArtifact),
126    Profile(ProfileArtifact),
127    Scenario(ScenarioArtifact),
128}
129
130impl From<PivotArtifact> for Artifact {
131    fn from(value: PivotArtifact) -> Self {
132        Artifact::Pivot(value)
133    }
134}
135
136impl From<ZoneArtifact> for Artifact {
137    fn from(value: ZoneArtifact) -> Self {
138        Artifact::Zone(value)
139    }
140}
141
142impl From<ProfileArtifact> for Artifact {
143    fn from(value: ProfileArtifact) -> Self {
144        Artifact::Profile(value)
145    }
146}
147
148impl From<ScenarioArtifact> for Artifact {
149    fn from(value: ScenarioArtifact) -> Self {
150        Artifact::Scenario(value)
151    }
152}
153
154/// Typed container pairing an indicator artifact with optional series provenance.
155///
156/// Ensures price-level artifacts (e.g. pivots, zones, profile POCs) can be persisted
157/// or transferred alongside the exact [`crate::model::SeriesIdentity`] on which they were formed.
158#[derive(Debug, Clone, PartialEq)]
159#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
160pub struct TaggedArtifact {
161    pub artifact: Artifact,
162    pub series_identity: Option<crate::model::SeriesIdentity>,
163}
164
165impl TaggedArtifact {
166    pub fn new(artifact: Artifact) -> Self {
167        Self {
168            artifact,
169            series_identity: None,
170        }
171    }
172
173    pub fn with_series(mut self, identity: crate::model::SeriesIdentity) -> Self {
174        self.series_identity = Some(identity);
175        self
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::model::SeriesIdentity;
183
184    #[test]
185    fn test_artifact_conversions() {
186        let pivot = PivotArtifact {
187            timestamp: 1_000,
188            price: 100.0,
189            is_high: true,
190            confirmed: true,
191        };
192        let artifact: Artifact = pivot.into();
193        assert!(matches!(artifact, Artifact::Pivot(p) if p.price == 100.0));
194
195        let zone = ZoneArtifact {
196            kind: "order_block".to_string(),
197            price_top: 105.0,
198            price_bottom: 100.0,
199            strength: 0.8,
200            touches: 2,
201            from_ts: None,
202            to_ts: None,
203        };
204        let artifact: Artifact = zone.into();
205        assert!(matches!(&artifact, Artifact::Zone(z) if z.touches == 2));
206
207        let series_id = SeriesIdentity::new("ES", "5m");
208        let tagged = TaggedArtifact::new(artifact).with_series(series_id.clone());
209        assert_eq!(tagged.series_identity, Some(series_id));
210    }
211
212    #[test]
213    fn test_zone_span_is_absent_until_set() {
214        let zone = ZoneArtifact::new("order_block", 105.0, 100.0);
215        assert_eq!(zone.span(), None, "unknown bounds must not be guessed");
216
217        let spanned = zone.spanning(1_000, 2_000);
218        assert_eq!(spanned.span(), Some((1_000, 2_000)));
219    }
220
221    #[test]
222    fn test_profile_span_is_absent_until_set() {
223        let profile = ProfileArtifact {
224            kind: "volume_profile".to_string(),
225            bins: Vec::new(),
226            poc: 100.0,
227            value_area_high: 101.0,
228            value_area_low: 99.0,
229            from_ts: None,
230            to_ts: None,
231        };
232        assert_eq!(profile.span(), None);
233        assert_eq!(profile.spanning(10, 20).span(), Some((10, 20)));
234    }
235
236    /// A half-known span stays unknown: a zone with only a start would otherwise be
237    /// drawn as if it ended now.
238    #[test]
239    fn test_a_half_known_span_reads_as_unknown() {
240        let mut zone = ZoneArtifact::new("fvg", 105.0, 100.0);
241        zone.from_ts = Some(1_000);
242        assert_eq!(zone.span(), None);
243    }
244}