Skip to main content

kestrel_chartkit/indicator/
twap.rs

1#[cfg(feature = "serde")]
2use serde::{Deserialize, Serialize};
3
4use crate::model::{Bar, Source};
5
6use super::{Indicator, IndicatorOutput};
7
8/// How the bars since the anchor are weighted into the average.
9///
10/// The two answer different questions and the published descriptions of "TWAP" do not always say
11/// which one they mean, so it is named here rather than assumed.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13#[cfg_attr(
14    feature = "serde",
15    derive(Serialize, Deserialize),
16    serde(rename_all = "snake_case")
17)]
18pub enum TwapWeighting {
19    /// Every bar counts once, whatever span it covers. The plain mean of the source since the
20    /// anchor — which is what a "TWAP" drawn over a bar series usually is. The default.
21    #[default]
22    PerBar,
23    /// Every price is weighted by how long it stood, measured as the gap to the next observation.
24    /// On evenly spaced bars this equals [`TwapWeighting::PerBar`]; on gappy or irregular data it
25    /// does not, and that difference is the reason the option exists.
26    ByDuration,
27}
28
29/// Where the average starts over.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31#[cfg_attr(
32    feature = "serde",
33    derive(Serialize, Deserialize),
34    serde(rename_all = "snake_case")
35)]
36pub enum TwapAnchor {
37    /// Never: one average over the whole series.
38    Continuous,
39    /// At the start of every UTC day, shifted by the given number of seconds.
40    Daily { start_offset_seconds: i64 },
41    /// At one fixed point in time. Bars before it produce no output.
42    ManualTimestamp(i64),
43}
44
45/// Time Weighted Average Price since an anchor.
46///
47/// The average of a chosen price source over the bars since the anchor, weighted as
48/// [`TwapWeighting`] says. It is **not** a VWAP: no volume enters this at any point, and a bar
49/// that traded a thousand contracts counts exactly as much as one that traded ten. It is also not
50/// an execution algorithm — this is a line on a chart, not a schedule for working an order.
51///
52/// Under [`TwapWeighting::ByDuration`] a price is taken to hold until the next bar arrives, so
53/// its weight is the gap to that bar. The current bar's own price has not stood for any time yet
54/// and therefore carries no weight; with a single bar since the anchor there is nothing to weight
55/// at all, and the value is that bar's source price.
56///
57/// Gaps are what separates the two weightings: a missing hour makes the preceding price count for
58/// that hour under `ByDuration`, and count once under `PerBar`. Neither is wrong, and neither is
59/// guessed at here.
60///
61/// Output: `value`, in the price units of the series. First output: the first bar at or after the
62/// anchor. [`Indicator::reset`] clears the average.
63#[derive(Debug, Clone)]
64pub struct AnchoredTwap {
65    anchor: TwapAnchor,
66    source: Source,
67    weighting: TwapWeighting,
68    sum: f64,
69    weight: f64,
70    bars: usize,
71    previous: Option<(i64, f64)>,
72    current_period: Option<i64>,
73}
74
75impl AnchoredTwap {
76    pub fn new(anchor: TwapAnchor, source: Source, weighting: TwapWeighting) -> Self {
77        Self {
78            anchor,
79            source,
80            weighting,
81            sum: 0.0,
82            weight: 0.0,
83            bars: 0,
84            previous: None,
85            current_period: None,
86        }
87    }
88
89    pub fn with_defaults() -> Self {
90        Self::new(
91            TwapAnchor::Daily {
92                start_offset_seconds: 0,
93            },
94            Source::Close,
95            TwapWeighting::PerBar,
96        )
97    }
98
99    fn restart(&mut self) {
100        self.sum = 0.0;
101        self.weight = 0.0;
102        self.bars = 0;
103        self.previous = None;
104    }
105}
106
107impl Indicator for AnchoredTwap {
108    fn name(&self) -> &str {
109        "twap"
110    }
111
112    fn warmup_period(&self) -> usize {
113        0
114    }
115
116    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
117        match self.anchor {
118            TwapAnchor::Continuous => {}
119            TwapAnchor::Daily {
120                start_offset_seconds,
121            } => {
122                let period = (bar.timestamp - start_offset_seconds).div_euclid(86_400);
123                if self.current_period != Some(period) {
124                    self.current_period = Some(period);
125                    self.restart();
126                }
127            }
128            TwapAnchor::ManualTimestamp(anchor) => {
129                if bar.timestamp < anchor {
130                    return None;
131                }
132            }
133        }
134
135        let price = self.source.extract(bar);
136        if !price.is_finite() {
137            return None;
138        }
139
140        match self.weighting {
141            TwapWeighting::PerBar => {
142                self.sum += price;
143                self.bars += 1;
144            }
145            TwapWeighting::ByDuration => {
146                if let Some((previous_timestamp, previous_price)) = self.previous {
147                    let duration = (bar.timestamp - previous_timestamp).max(0) as f64;
148                    self.sum += previous_price * duration;
149                    self.weight += duration;
150                }
151            }
152        }
153        self.previous = Some((bar.timestamp, price));
154
155        let value = match self.weighting {
156            TwapWeighting::PerBar => self.sum / self.bars as f64,
157            // Nothing has stood for any time yet: the single observation is the average.
158            TwapWeighting::ByDuration if self.weight <= 0.0 => price,
159            TwapWeighting::ByDuration => self.sum / self.weight,
160        };
161        Some(IndicatorOutput::new(value))
162    }
163
164    fn reset(&mut self) {
165        self.restart();
166        self.current_period = None;
167    }
168}