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}