moved indicators to rust
This commit is contained in:
parent
c106a719e8
commit
6df32dc18b
27 changed files with 6113 additions and 1 deletions
243
apps/stock/core/src/api/indicators.rs
Normal file
243
apps/stock/core/src/api/indicators.rs
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
use napi_derive::napi;
|
||||||
|
use napi::{bindgen_prelude::*};
|
||||||
|
use serde_json;
|
||||||
|
use crate::indicators::{
|
||||||
|
SMA, EMA, RSI, MACD, BollingerBands, Stochastic, ATR,
|
||||||
|
Indicator, IncrementalIndicator, IndicatorResult, PriceData
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Convert JS array to Vec<f64>
|
||||||
|
fn js_array_to_vec(arr: Vec<f64>) -> Vec<f64> {
|
||||||
|
arr
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
pub struct TechnicalIndicators {}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
impl TechnicalIndicators {
|
||||||
|
#[napi(constructor)]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate Simple Moving Average
|
||||||
|
#[napi]
|
||||||
|
pub fn calculate_sma(&self, values: Vec<f64>, period: u32) -> Result<Vec<f64>> {
|
||||||
|
match SMA::calculate_series(&values, period as usize) {
|
||||||
|
Ok(result) => Ok(result),
|
||||||
|
Err(e) => Err(Error::from_reason(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate Exponential Moving Average
|
||||||
|
#[napi]
|
||||||
|
pub fn calculate_ema(&self, values: Vec<f64>, period: u32) -> Result<Vec<f64>> {
|
||||||
|
match EMA::calculate_series(&values, period as usize) {
|
||||||
|
Ok(result) => Ok(result),
|
||||||
|
Err(e) => Err(Error::from_reason(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate Relative Strength Index
|
||||||
|
#[napi]
|
||||||
|
pub fn calculate_rsi(&self, values: Vec<f64>, period: u32) -> Result<Vec<f64>> {
|
||||||
|
match RSI::calculate_series(&values, period as usize) {
|
||||||
|
Ok(result) => Ok(result),
|
||||||
|
Err(e) => Err(Error::from_reason(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate MACD - returns JSON string
|
||||||
|
#[napi]
|
||||||
|
pub fn calculate_macd(
|
||||||
|
&self,
|
||||||
|
values: Vec<f64>,
|
||||||
|
fast_period: u32,
|
||||||
|
slow_period: u32,
|
||||||
|
signal_period: u32
|
||||||
|
) -> Result<String> {
|
||||||
|
match MACD::calculate_series(&values, fast_period as usize, slow_period as usize, signal_period as usize) {
|
||||||
|
Ok((macd, signal, histogram)) => {
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"macd": macd,
|
||||||
|
"signal": signal,
|
||||||
|
"histogram": histogram
|
||||||
|
});
|
||||||
|
Ok(result.to_string())
|
||||||
|
}
|
||||||
|
Err(e) => Err(Error::from_reason(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate Bollinger Bands - returns JSON string
|
||||||
|
#[napi]
|
||||||
|
pub fn calculate_bollinger_bands(
|
||||||
|
&self,
|
||||||
|
values: Vec<f64>,
|
||||||
|
period: u32,
|
||||||
|
std_dev: f64
|
||||||
|
) -> Result<String> {
|
||||||
|
match BollingerBands::calculate_series(&values, period as usize, std_dev) {
|
||||||
|
Ok((middle, upper, lower)) => {
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"middle": middle,
|
||||||
|
"upper": upper,
|
||||||
|
"lower": lower
|
||||||
|
});
|
||||||
|
Ok(result.to_string())
|
||||||
|
}
|
||||||
|
Err(e) => Err(Error::from_reason(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate Stochastic Oscillator - returns JSON string
|
||||||
|
#[napi]
|
||||||
|
pub fn calculate_stochastic(
|
||||||
|
&self,
|
||||||
|
high: Vec<f64>,
|
||||||
|
low: Vec<f64>,
|
||||||
|
close: Vec<f64>,
|
||||||
|
k_period: u32,
|
||||||
|
d_period: u32,
|
||||||
|
smooth_k: u32
|
||||||
|
) -> Result<String> {
|
||||||
|
match Stochastic::calculate_series(
|
||||||
|
&high,
|
||||||
|
&low,
|
||||||
|
&close,
|
||||||
|
k_period as usize,
|
||||||
|
d_period as usize,
|
||||||
|
smooth_k as usize
|
||||||
|
) {
|
||||||
|
Ok((k, d)) => {
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"k": k,
|
||||||
|
"d": d
|
||||||
|
});
|
||||||
|
Ok(result.to_string())
|
||||||
|
}
|
||||||
|
Err(e) => Err(Error::from_reason(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate Average True Range
|
||||||
|
#[napi]
|
||||||
|
pub fn calculate_atr(
|
||||||
|
&self,
|
||||||
|
high: Vec<f64>,
|
||||||
|
low: Vec<f64>,
|
||||||
|
close: Vec<f64>,
|
||||||
|
period: u32
|
||||||
|
) -> Result<Vec<f64>> {
|
||||||
|
match ATR::calculate_series(&high, &low, &close, period as usize) {
|
||||||
|
Ok(result) => Ok(result),
|
||||||
|
Err(e) => Err(Error::from_reason(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Incremental indicator calculator for streaming data
|
||||||
|
#[napi]
|
||||||
|
pub struct IncrementalSMA {
|
||||||
|
indicator: SMA,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
impl IncrementalSMA {
|
||||||
|
#[napi(constructor)]
|
||||||
|
pub fn new(period: u32) -> Result<Self> {
|
||||||
|
match SMA::new(period as usize) {
|
||||||
|
Ok(indicator) => Ok(Self { indicator }),
|
||||||
|
Err(e) => Err(Error::from_reason(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
pub fn update(&mut self, value: f64) -> Result<Option<f64>> {
|
||||||
|
match self.indicator.update(value) {
|
||||||
|
Ok(result) => Ok(result),
|
||||||
|
Err(e) => Err(Error::from_reason(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
pub fn current(&self) -> Option<f64> {
|
||||||
|
self.indicator.current()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.indicator.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
pub fn is_ready(&self) -> bool {
|
||||||
|
self.indicator.is_ready()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Incremental EMA calculator
|
||||||
|
#[napi]
|
||||||
|
pub struct IncrementalEMA {
|
||||||
|
indicator: EMA,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
impl IncrementalEMA {
|
||||||
|
#[napi(constructor)]
|
||||||
|
pub fn new(period: u32) -> Result<Self> {
|
||||||
|
match EMA::new(period as usize) {
|
||||||
|
Ok(indicator) => Ok(Self { indicator }),
|
||||||
|
Err(e) => Err(Error::from_reason(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
pub fn update(&mut self, value: f64) -> Result<Option<f64>> {
|
||||||
|
match self.indicator.update(value) {
|
||||||
|
Ok(result) => Ok(result),
|
||||||
|
Err(e) => Err(Error::from_reason(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
pub fn current(&self) -> Option<f64> {
|
||||||
|
self.indicator.current()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.indicator.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Incremental RSI calculator
|
||||||
|
#[napi]
|
||||||
|
pub struct IncrementalRSI {
|
||||||
|
indicator: RSI,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
impl IncrementalRSI {
|
||||||
|
#[napi(constructor)]
|
||||||
|
pub fn new(period: u32) -> Result<Self> {
|
||||||
|
match RSI::new(period as usize) {
|
||||||
|
Ok(indicator) => Ok(Self { indicator }),
|
||||||
|
Err(e) => Err(Error::from_reason(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
pub fn update(&mut self, value: f64) -> Result<Option<f64>> {
|
||||||
|
match self.indicator.update(value) {
|
||||||
|
Ok(result) => Ok(result),
|
||||||
|
Err(e) => Err(Error::from_reason(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
pub fn current(&self) -> Option<f64> {
|
||||||
|
self.indicator.current()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
mod indicators;
|
||||||
|
|
||||||
|
pub use indicators::{TechnicalIndicators, IncrementalSMA, IncrementalEMA, IncrementalRSI};
|
||||||
|
|
||||||
use napi_derive::napi;
|
use napi_derive::napi;
|
||||||
use napi::{bindgen_prelude::*, JsObject};
|
use napi::{bindgen_prelude::*, JsObject};
|
||||||
use crate::{
|
use crate::{
|
||||||
|
|
|
||||||
250
apps/stock/core/src/indicators/atr.rs
Normal file
250
apps/stock/core/src/indicators/atr.rs
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
use super::{Indicator, IncrementalIndicator, IndicatorResult, IndicatorError, PriceData};
|
||||||
|
use super::common::RollingWindow;
|
||||||
|
|
||||||
|
/// Average True Range (ATR) Indicator
|
||||||
|
///
|
||||||
|
/// Measures volatility by calculating the average of true ranges over a period
|
||||||
|
/// True Range = max(High - Low, |High - Previous Close|, |Low - Previous Close|)
|
||||||
|
pub struct ATR {
|
||||||
|
period: usize,
|
||||||
|
atr_value: Option<f64>,
|
||||||
|
prev_close: Option<f64>,
|
||||||
|
true_ranges: RollingWindow<f64>,
|
||||||
|
sum: f64,
|
||||||
|
initialized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ATR {
|
||||||
|
pub fn new(period: usize) -> Result<Self, IndicatorError> {
|
||||||
|
if period == 0 {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"Period must be greater than 0".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
period,
|
||||||
|
atr_value: None,
|
||||||
|
prev_close: None,
|
||||||
|
true_ranges: RollingWindow::new(period),
|
||||||
|
sum: 0.0,
|
||||||
|
initialized: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate True Range
|
||||||
|
fn calculate_true_range(high: f64, low: f64, prev_close: Option<f64>) -> f64 {
|
||||||
|
let high_low = high - low;
|
||||||
|
|
||||||
|
match prev_close {
|
||||||
|
Some(prev) => {
|
||||||
|
let high_close = (high - prev).abs();
|
||||||
|
let low_close = (low - prev).abs();
|
||||||
|
high_low.max(high_close).max(low_close)
|
||||||
|
}
|
||||||
|
None => high_low,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate ATR for a series of price data
|
||||||
|
pub fn calculate_series(
|
||||||
|
high: &[f64],
|
||||||
|
low: &[f64],
|
||||||
|
close: &[f64],
|
||||||
|
period: usize
|
||||||
|
) -> Result<Vec<f64>, IndicatorError> {
|
||||||
|
if high.len() != low.len() || high.len() != close.len() {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"High, low, and close arrays must have the same length".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if high.len() < period + 1 {
|
||||||
|
return Err(IndicatorError::InsufficientData {
|
||||||
|
required: period + 1,
|
||||||
|
actual: high.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut true_ranges = Vec::with_capacity(high.len() - 1);
|
||||||
|
|
||||||
|
// Calculate true ranges
|
||||||
|
for i in 1..high.len() {
|
||||||
|
let tr = Self::calculate_true_range(high[i], low[i], Some(close[i - 1]));
|
||||||
|
true_ranges.push(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut atr_values = Vec::with_capacity(true_ranges.len() - period + 1);
|
||||||
|
|
||||||
|
// Calculate initial ATR as SMA of first period true ranges
|
||||||
|
let initial_atr: f64 = true_ranges[0..period].iter().sum::<f64>() / period as f64;
|
||||||
|
atr_values.push(initial_atr);
|
||||||
|
|
||||||
|
// Calculate subsequent ATRs using Wilder's smoothing
|
||||||
|
let mut atr = initial_atr;
|
||||||
|
for i in period..true_ranges.len() {
|
||||||
|
// Wilder's smoothing: ATR = ((n-1) * ATR + TR) / n
|
||||||
|
atr = ((period - 1) as f64 * atr + true_ranges[i]) / period as f64;
|
||||||
|
atr_values.push(atr);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(atr_values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Indicator for ATR {
|
||||||
|
fn calculate(&mut self, data: &PriceData) -> Result<IndicatorResult, IndicatorError> {
|
||||||
|
if data.high.len() != data.low.len() || data.high.len() != data.close.len() {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"Price data arrays must have the same length".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let atr_values = Self::calculate_series(
|
||||||
|
&data.high,
|
||||||
|
&data.low,
|
||||||
|
&data.close,
|
||||||
|
self.period
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(IndicatorResult::Series(atr_values))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.atr_value = None;
|
||||||
|
self.prev_close = None;
|
||||||
|
self.true_ranges.clear();
|
||||||
|
self.sum = 0.0;
|
||||||
|
self.initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.initialized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IncrementalIndicator for ATR {
|
||||||
|
fn update(&mut self, value: f64) -> Result<Option<f64>, IndicatorError> {
|
||||||
|
// For single value update, we assume it's the close price
|
||||||
|
// In real usage, you'd need to provide high/low/close separately
|
||||||
|
self.update_hlc(value, value, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current(&self) -> Option<f64> {
|
||||||
|
self.atr_value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ATR {
|
||||||
|
/// Update with high, low, close values
|
||||||
|
pub fn update_hlc(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, IndicatorError> {
|
||||||
|
if let Some(prev_close) = self.prev_close {
|
||||||
|
let tr = Self::calculate_true_range(high, low, Some(prev_close));
|
||||||
|
|
||||||
|
if !self.initialized {
|
||||||
|
// Still building initial window
|
||||||
|
self.true_ranges.push(tr);
|
||||||
|
self.sum += tr;
|
||||||
|
|
||||||
|
if self.true_ranges.is_full() {
|
||||||
|
// Calculate initial ATR
|
||||||
|
let initial_atr = self.sum / self.period as f64;
|
||||||
|
self.atr_value = Some(initial_atr);
|
||||||
|
self.initialized = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update ATR using Wilder's smoothing
|
||||||
|
if let Some(current_atr) = self.atr_value {
|
||||||
|
let new_atr = ((self.period - 1) as f64 * current_atr + tr) / self.period as f64;
|
||||||
|
self.atr_value = Some(new_atr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.prev_close = Some(close);
|
||||||
|
Ok(self.atr_value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get ATR as a percentage of price
|
||||||
|
pub fn atr_percent(&self, price: f64) -> Option<f64> {
|
||||||
|
self.atr_value.map(|atr| (atr / price) * 100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate stop loss based on ATR multiple
|
||||||
|
pub fn calculate_stop_loss(&self, entry_price: f64, is_long: bool, atr_multiple: f64) -> Option<f64> {
|
||||||
|
self.atr_value.map(|atr| {
|
||||||
|
if is_long {
|
||||||
|
entry_price - (atr * atr_multiple)
|
||||||
|
} else {
|
||||||
|
entry_price + (atr * atr_multiple)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_atr_calculation() {
|
||||||
|
let high = vec![
|
||||||
|
48.70, 48.72, 48.90, 48.87, 48.82,
|
||||||
|
49.05, 49.20, 49.35, 49.92, 50.19,
|
||||||
|
50.12, 49.66, 49.88, 50.19, 50.36
|
||||||
|
];
|
||||||
|
let low = vec![
|
||||||
|
47.79, 48.14, 48.39, 48.37, 48.24,
|
||||||
|
48.64, 48.94, 48.86, 49.50, 49.87,
|
||||||
|
49.20, 48.90, 49.43, 49.73, 49.26
|
||||||
|
];
|
||||||
|
let close = vec![
|
||||||
|
48.16, 48.61, 48.75, 48.63, 48.74,
|
||||||
|
49.03, 49.07, 49.32, 49.91, 50.13,
|
||||||
|
49.53, 49.50, 49.75, 50.03, 50.29
|
||||||
|
];
|
||||||
|
|
||||||
|
let atr_values = ATR::calculate_series(&high, &low, &close, 14).unwrap();
|
||||||
|
|
||||||
|
assert!(!atr_values.is_empty());
|
||||||
|
|
||||||
|
// ATR should always be positive
|
||||||
|
for atr in &atr_values {
|
||||||
|
assert!(*atr > 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_true_range_calculation() {
|
||||||
|
// Test case where high-low is largest
|
||||||
|
let tr = ATR::calculate_true_range(50.0, 45.0, Some(47.0));
|
||||||
|
assert_eq!(tr, 5.0);
|
||||||
|
|
||||||
|
// Test case where high-prev_close is largest
|
||||||
|
let tr = ATR::calculate_true_range(50.0, 48.0, Some(45.0));
|
||||||
|
assert_eq!(tr, 5.0);
|
||||||
|
|
||||||
|
// Test case where prev_close-low is largest
|
||||||
|
let tr = ATR::calculate_true_range(48.0, 45.0, Some(50.0));
|
||||||
|
assert_eq!(tr, 5.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_incremental_atr() {
|
||||||
|
let mut atr = ATR::new(5).unwrap();
|
||||||
|
|
||||||
|
// Need at least one previous close
|
||||||
|
assert_eq!(atr.update_hlc(10.0, 9.0, 9.5).unwrap(), None);
|
||||||
|
|
||||||
|
// Build up window
|
||||||
|
assert_eq!(atr.update_hlc(10.2, 9.1, 9.8).unwrap(), None);
|
||||||
|
assert_eq!(atr.update_hlc(10.5, 9.3, 10.0).unwrap(), None);
|
||||||
|
assert_eq!(atr.update_hlc(10.3, 9.5, 9.7).unwrap(), None);
|
||||||
|
assert_eq!(atr.update_hlc(10.1, 9.2, 9.6).unwrap(), None);
|
||||||
|
|
||||||
|
// Should have ATR value now
|
||||||
|
let atr_value = atr.update_hlc(10.0, 9.0, 9.5).unwrap();
|
||||||
|
assert!(atr_value.is_some());
|
||||||
|
assert!(atr_value.unwrap() > 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
256
apps/stock/core/src/indicators/bollinger_bands.rs
Normal file
256
apps/stock/core/src/indicators/bollinger_bands.rs
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
use super::{Indicator, IncrementalIndicator, IndicatorResult, IndicatorError, PriceData};
|
||||||
|
use super::sma::SMA;
|
||||||
|
use super::common::RollingWindow;
|
||||||
|
|
||||||
|
/// Bollinger Bands Indicator
|
||||||
|
///
|
||||||
|
/// Middle Band = SMA(n)
|
||||||
|
/// Upper Band = SMA(n) + (k × σ)
|
||||||
|
/// Lower Band = SMA(n) - (k × σ)
|
||||||
|
/// where σ is the standard deviation and k is typically 2
|
||||||
|
pub struct BollingerBands {
|
||||||
|
period: usize,
|
||||||
|
std_dev_multiplier: f64,
|
||||||
|
sma: SMA,
|
||||||
|
window: RollingWindow<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BollingerBands {
|
||||||
|
pub fn new(period: usize, std_dev_multiplier: f64) -> Result<Self, IndicatorError> {
|
||||||
|
if period == 0 {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"Period must be greater than 0".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if std_dev_multiplier <= 0.0 {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"Standard deviation multiplier must be positive".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
period,
|
||||||
|
std_dev_multiplier,
|
||||||
|
sma: SMA::new(period)?,
|
||||||
|
window: RollingWindow::new(period),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard Bollinger Bands with 20 period and 2 standard deviations
|
||||||
|
pub fn standard() -> Result<Self, IndicatorError> {
|
||||||
|
Self::new(20, 2.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate standard deviation
|
||||||
|
fn calculate_std_dev(values: &[f64], mean: f64) -> f64 {
|
||||||
|
if values.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let variance = values.iter()
|
||||||
|
.map(|x| (*x - mean).powi(2))
|
||||||
|
.sum::<f64>() / values.len() as f64;
|
||||||
|
|
||||||
|
variance.sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate Bollinger Bands for a series of values
|
||||||
|
pub fn calculate_series(
|
||||||
|
values: &[f64],
|
||||||
|
period: usize,
|
||||||
|
std_dev_multiplier: f64
|
||||||
|
) -> Result<(Vec<f64>, Vec<f64>, Vec<f64>), IndicatorError> {
|
||||||
|
if period == 0 {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"Period must be greater than 0".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if values.len() < period {
|
||||||
|
return Err(IndicatorError::InsufficientData {
|
||||||
|
required: period,
|
||||||
|
actual: values.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate SMA (middle band)
|
||||||
|
let middle_band = SMA::calculate_series(values, period)?;
|
||||||
|
let mut upper_band = Vec::with_capacity(middle_band.len());
|
||||||
|
let mut lower_band = Vec::with_capacity(middle_band.len());
|
||||||
|
|
||||||
|
// Calculate bands
|
||||||
|
for i in 0..middle_band.len() {
|
||||||
|
// Get the window of values for this position
|
||||||
|
let start_idx = i;
|
||||||
|
let end_idx = i + period;
|
||||||
|
let window = &values[start_idx..end_idx];
|
||||||
|
|
||||||
|
// Calculate standard deviation
|
||||||
|
let std_dev = Self::calculate_std_dev(window, middle_band[i]);
|
||||||
|
let band_width = std_dev * std_dev_multiplier;
|
||||||
|
|
||||||
|
upper_band.push(middle_band[i] + band_width);
|
||||||
|
lower_band.push(middle_band[i] - band_width);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((middle_band, upper_band, lower_band))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate the current bandwidth (distance between upper and lower bands)
|
||||||
|
pub fn bandwidth(&self) -> Option<f64> {
|
||||||
|
if let Some(values) = self.get_current_bands() {
|
||||||
|
Some(values.upper - values.lower)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate %B (percent b) - position of price relative to bands
|
||||||
|
/// %B = (Price - Lower Band) / (Upper Band - Lower Band)
|
||||||
|
pub fn percent_b(&self, price: f64) -> Option<f64> {
|
||||||
|
if let Some(values) = self.get_current_bands() {
|
||||||
|
let width = values.upper - values.lower;
|
||||||
|
if width > 0.0 {
|
||||||
|
Some((price - values.lower) / width)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_current_bands(&self) -> Option<BollingerBandsValues> {
|
||||||
|
if let Some(middle) = self.sma.current() {
|
||||||
|
if self.window.is_full() {
|
||||||
|
let values = self.window.as_slice();
|
||||||
|
let std_dev = Self::calculate_std_dev(&values, middle);
|
||||||
|
let band_width = std_dev * self.std_dev_multiplier;
|
||||||
|
|
||||||
|
Some(BollingerBandsValues {
|
||||||
|
middle,
|
||||||
|
upper: middle + band_width,
|
||||||
|
lower: middle - band_width,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Indicator for BollingerBands {
|
||||||
|
fn calculate(&mut self, data: &PriceData) -> Result<IndicatorResult, IndicatorError> {
|
||||||
|
let values = &data.close;
|
||||||
|
|
||||||
|
let (middle, upper, lower) = Self::calculate_series(
|
||||||
|
values,
|
||||||
|
self.period,
|
||||||
|
self.std_dev_multiplier
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(IndicatorResult::BollingerBands {
|
||||||
|
middle,
|
||||||
|
upper,
|
||||||
|
lower,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.sma.reset();
|
||||||
|
self.window.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.sma.is_ready() && self.window.is_full()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IncrementalIndicator for BollingerBands {
|
||||||
|
fn update(&mut self, value: f64) -> Result<Option<f64>, IndicatorError> {
|
||||||
|
// Update window
|
||||||
|
self.window.push(value);
|
||||||
|
|
||||||
|
// Update SMA
|
||||||
|
let _sma_result = self.sma.update(value)?;
|
||||||
|
|
||||||
|
// Return bandwidth if ready
|
||||||
|
if self.is_ready() {
|
||||||
|
Ok(self.bandwidth())
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current(&self) -> Option<f64> {
|
||||||
|
self.bandwidth()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structure to hold all Bollinger Bands values
|
||||||
|
pub struct BollingerBandsValues {
|
||||||
|
pub middle: f64,
|
||||||
|
pub upper: f64,
|
||||||
|
pub lower: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BollingerBands {
|
||||||
|
/// Get all current band values
|
||||||
|
pub fn current_values(&self) -> Option<BollingerBandsValues> {
|
||||||
|
self.get_current_bands()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bollinger_bands_calculation() {
|
||||||
|
let values = vec![
|
||||||
|
20.0, 21.0, 22.0, 23.0, 24.0,
|
||||||
|
25.0, 24.0, 23.0, 22.0, 21.0,
|
||||||
|
20.0, 21.0, 22.0, 23.0, 24.0,
|
||||||
|
25.0, 24.0, 23.0, 22.0, 21.0
|
||||||
|
];
|
||||||
|
|
||||||
|
let (middle, upper, lower) = BollingerBands::calculate_series(&values, 5, 2.0).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(middle.len(), 16);
|
||||||
|
assert_eq!(upper.len(), 16);
|
||||||
|
assert_eq!(lower.len(), 16);
|
||||||
|
|
||||||
|
// Upper band should always be above middle
|
||||||
|
for i in 0..middle.len() {
|
||||||
|
assert!(upper[i] > middle[i]);
|
||||||
|
assert!(lower[i] < middle[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_percent_b() {
|
||||||
|
let mut bb = BollingerBands::standard().unwrap();
|
||||||
|
|
||||||
|
// Create some test data
|
||||||
|
for i in 0..25 {
|
||||||
|
let _ = bb.update(20.0 + (i as f64 % 5.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Price at upper band should give %B ≈ 1.0
|
||||||
|
if let Some(bands) = bb.current_values() {
|
||||||
|
let percent_b = bb.percent_b(bands.upper).unwrap();
|
||||||
|
assert!((percent_b - 1.0).abs() < 1e-10);
|
||||||
|
|
||||||
|
// Price at lower band should give %B ≈ 0.0
|
||||||
|
let percent_b = bb.percent_b(bands.lower).unwrap();
|
||||||
|
assert!(percent_b.abs() < 1e-10);
|
||||||
|
|
||||||
|
// Price at middle band should give %B ≈ 0.5
|
||||||
|
let percent_b = bb.percent_b(bands.middle).unwrap();
|
||||||
|
assert!((percent_b - 0.5).abs() < 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
apps/stock/core/src/indicators/common.rs
Normal file
142
apps/stock/core/src/indicators/common.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
/// Common types and utilities for indicators
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PriceData {
|
||||||
|
pub open: Vec<f64>,
|
||||||
|
pub high: Vec<f64>,
|
||||||
|
pub low: Vec<f64>,
|
||||||
|
pub close: Vec<f64>,
|
||||||
|
pub volume: Vec<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PriceData {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
open: Vec::new(),
|
||||||
|
high: Vec::new(),
|
||||||
|
low: Vec::new(),
|
||||||
|
close: Vec::new(),
|
||||||
|
volume: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.close.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.close.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get typical price (high + low + close) / 3
|
||||||
|
pub fn typical_prices(&self) -> Vec<f64> {
|
||||||
|
self.high.iter()
|
||||||
|
.zip(&self.low)
|
||||||
|
.zip(&self.close)
|
||||||
|
.map(|((h, l), c)| (h + l + c) / 3.0)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get true range for ATR calculation
|
||||||
|
pub fn true_ranges(&self) -> Vec<f64> {
|
||||||
|
if self.len() < 2 {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ranges = Vec::with_capacity(self.len() - 1);
|
||||||
|
for i in 1..self.len() {
|
||||||
|
let high_low = self.high[i] - self.low[i];
|
||||||
|
let high_close = (self.high[i] - self.close[i - 1]).abs();
|
||||||
|
let low_close = (self.low[i] - self.close[i - 1]).abs();
|
||||||
|
ranges.push(high_low.max(high_close).max(low_close));
|
||||||
|
}
|
||||||
|
ranges
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum IndicatorResult {
|
||||||
|
Single(f64),
|
||||||
|
Multiple(Vec<f64>),
|
||||||
|
Series(Vec<f64>),
|
||||||
|
MACD {
|
||||||
|
macd: Vec<f64>,
|
||||||
|
signal: Vec<f64>,
|
||||||
|
histogram: Vec<f64>,
|
||||||
|
},
|
||||||
|
BollingerBands {
|
||||||
|
middle: Vec<f64>,
|
||||||
|
upper: Vec<f64>,
|
||||||
|
lower: Vec<f64>,
|
||||||
|
},
|
||||||
|
Stochastic {
|
||||||
|
k: Vec<f64>,
|
||||||
|
d: Vec<f64>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum IndicatorError {
|
||||||
|
InsufficientData { required: usize, actual: usize },
|
||||||
|
InvalidParameter(String),
|
||||||
|
CalculationError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for IndicatorError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
IndicatorError::InsufficientData { required, actual } => {
|
||||||
|
write!(f, "Insufficient data: required {}, got {}", required, actual)
|
||||||
|
}
|
||||||
|
IndicatorError::InvalidParameter(msg) => write!(f, "Invalid parameter: {}", msg),
|
||||||
|
IndicatorError::CalculationError(msg) => write!(f, "Calculation error: {}", msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for IndicatorError {}
|
||||||
|
|
||||||
|
/// Rolling window for incremental calculations
|
||||||
|
pub struct RollingWindow<T> {
|
||||||
|
window: VecDeque<T>,
|
||||||
|
capacity: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Clone> RollingWindow<T> {
|
||||||
|
pub fn new(capacity: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
window: VecDeque::with_capacity(capacity),
|
||||||
|
capacity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, value: T) {
|
||||||
|
if self.window.len() >= self.capacity {
|
||||||
|
self.window.pop_front();
|
||||||
|
}
|
||||||
|
self.window.push_back(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_full(&self) -> bool {
|
||||||
|
self.window.len() >= self.capacity
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.window.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = &T> {
|
||||||
|
self.window.iter()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_slice(&self) -> Vec<T> {
|
||||||
|
self.window.iter().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.window.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
213
apps/stock/core/src/indicators/ema.rs
Normal file
213
apps/stock/core/src/indicators/ema.rs
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
use super::{Indicator, IncrementalIndicator, IndicatorResult, IndicatorError, PriceData};
|
||||||
|
|
||||||
|
/// Exponential Moving Average (EMA) Indicator
|
||||||
|
///
|
||||||
|
/// Calculates the exponentially weighted moving average
|
||||||
|
/// giving more weight to recent prices
|
||||||
|
pub struct EMA {
|
||||||
|
period: usize,
|
||||||
|
alpha: f64,
|
||||||
|
value: Option<f64>,
|
||||||
|
initialized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EMA {
|
||||||
|
pub fn new(period: usize) -> Result<Self, IndicatorError> {
|
||||||
|
if period == 0 {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"Period must be greater than 0".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate smoothing factor (alpha)
|
||||||
|
// Common formula: 2 / (period + 1)
|
||||||
|
let alpha = 2.0 / (period as f64 + 1.0);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
period,
|
||||||
|
alpha,
|
||||||
|
value: None,
|
||||||
|
initialized: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create EMA with custom smoothing factor
|
||||||
|
pub fn with_alpha(alpha: f64) -> Result<Self, IndicatorError> {
|
||||||
|
if alpha <= 0.0 || alpha > 1.0 {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"Alpha must be between 0 and 1".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate equivalent period for reference
|
||||||
|
let period = ((2.0 / alpha) - 1.0) as usize;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
period,
|
||||||
|
alpha,
|
||||||
|
value: None,
|
||||||
|
initialized: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate EMA for a series of values
|
||||||
|
pub fn calculate_series(values: &[f64], period: usize) -> Result<Vec<f64>, IndicatorError> {
|
||||||
|
if period == 0 {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"Period must be greater than 0".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if values.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let alpha = 2.0 / (period as f64 + 1.0);
|
||||||
|
let mut result = Vec::with_capacity(values.len());
|
||||||
|
|
||||||
|
// Start with first value as initial EMA
|
||||||
|
let mut ema = values[0];
|
||||||
|
result.push(ema);
|
||||||
|
|
||||||
|
// Calculate EMA for remaining values
|
||||||
|
for i in 1..values.len() {
|
||||||
|
ema = alpha * values[i] + (1.0 - alpha) * ema;
|
||||||
|
result.push(ema);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alternative initialization using SMA of first N values
|
||||||
|
pub fn calculate_series_sma_init(values: &[f64], period: usize) -> Result<Vec<f64>, IndicatorError> {
|
||||||
|
if period == 0 {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"Period must be greater than 0".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if values.len() < period {
|
||||||
|
return Err(IndicatorError::InsufficientData {
|
||||||
|
required: period,
|
||||||
|
actual: values.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let alpha = 2.0 / (period as f64 + 1.0);
|
||||||
|
let mut result = Vec::with_capacity(values.len() - period + 1);
|
||||||
|
|
||||||
|
// Calculate initial SMA
|
||||||
|
let initial_sma: f64 = values[0..period].iter().sum::<f64>() / period as f64;
|
||||||
|
let mut ema = initial_sma;
|
||||||
|
result.push(ema);
|
||||||
|
|
||||||
|
// Calculate EMA for remaining values
|
||||||
|
for i in period..values.len() {
|
||||||
|
ema = alpha * values[i] + (1.0 - alpha) * ema;
|
||||||
|
result.push(ema);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Indicator for EMA {
|
||||||
|
fn calculate(&mut self, data: &PriceData) -> Result<IndicatorResult, IndicatorError> {
|
||||||
|
let values = &data.close;
|
||||||
|
|
||||||
|
if values.is_empty() {
|
||||||
|
return Err(IndicatorError::InsufficientData {
|
||||||
|
required: 1,
|
||||||
|
actual: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset and calculate from scratch
|
||||||
|
self.reset();
|
||||||
|
let ema_values = Self::calculate_series(values, self.period)?;
|
||||||
|
|
||||||
|
// Update internal state with last value
|
||||||
|
if let Some(&last) = ema_values.last() {
|
||||||
|
self.value = Some(last);
|
||||||
|
self.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(IndicatorResult::Series(ema_values))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.value = None;
|
||||||
|
self.initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.initialized && self.value.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IncrementalIndicator for EMA {
|
||||||
|
fn update(&mut self, value: f64) -> Result<Option<f64>, IndicatorError> {
|
||||||
|
match self.value {
|
||||||
|
Some(prev_ema) => {
|
||||||
|
// Update EMA: EMA = α × Price + (1 - α) × Previous EMA
|
||||||
|
let new_ema = self.alpha * value + (1.0 - self.alpha) * prev_ema;
|
||||||
|
self.value = Some(new_ema);
|
||||||
|
self.initialized = true;
|
||||||
|
Ok(Some(new_ema))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// First value becomes the initial EMA
|
||||||
|
self.value = Some(value);
|
||||||
|
self.initialized = true;
|
||||||
|
Ok(Some(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current(&self) -> Option<f64> {
|
||||||
|
self.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ema_calculation() {
|
||||||
|
let values = vec![10.0, 11.0, 12.0, 13.0, 14.0, 15.0];
|
||||||
|
let result = EMA::calculate_series(&values, 3).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.len(), 6);
|
||||||
|
assert!((result[0] - 10.0).abs() < 1e-10); // First value
|
||||||
|
|
||||||
|
// Verify EMA calculation
|
||||||
|
let alpha = 2.0 / 4.0; // 0.5
|
||||||
|
let expected_ema2 = alpha * 11.0 + (1.0 - alpha) * 10.0; // 10.5
|
||||||
|
assert!((result[1] - expected_ema2).abs() < 1e-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_incremental_ema() {
|
||||||
|
let mut ema = EMA::new(3).unwrap();
|
||||||
|
|
||||||
|
// First value
|
||||||
|
assert_eq!(ema.update(10.0).unwrap(), Some(10.0));
|
||||||
|
|
||||||
|
// Second value: EMA = 0.5 * 12 + 0.5 * 10 = 11
|
||||||
|
assert_eq!(ema.update(12.0).unwrap(), Some(11.0));
|
||||||
|
|
||||||
|
// Third value: EMA = 0.5 * 14 + 0.5 * 11 = 12.5
|
||||||
|
assert_eq!(ema.update(14.0).unwrap(), Some(12.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ema_with_sma_init() {
|
||||||
|
let values = vec![2.0, 4.0, 6.0, 8.0, 10.0, 12.0];
|
||||||
|
let result = EMA::calculate_series_sma_init(&values, 3).unwrap();
|
||||||
|
|
||||||
|
// Initial SMA = (2 + 4 + 6) / 3 = 4
|
||||||
|
assert_eq!(result.len(), 4);
|
||||||
|
assert!((result[0] - 4.0).abs() < 1e-10);
|
||||||
|
}
|
||||||
|
}
|
||||||
229
apps/stock/core/src/indicators/macd.rs
Normal file
229
apps/stock/core/src/indicators/macd.rs
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
use super::{Indicator, IncrementalIndicator, IndicatorResult, IndicatorError, PriceData};
|
||||||
|
use super::ema::EMA;
|
||||||
|
|
||||||
|
/// Moving Average Convergence Divergence (MACD) Indicator
|
||||||
|
///
|
||||||
|
/// MACD = Fast EMA - Slow EMA
|
||||||
|
/// Signal = EMA of MACD
|
||||||
|
/// Histogram = MACD - Signal
|
||||||
|
pub struct MACD {
|
||||||
|
fast_period: usize,
|
||||||
|
slow_period: usize,
|
||||||
|
signal_period: usize,
|
||||||
|
fast_ema: EMA,
|
||||||
|
slow_ema: EMA,
|
||||||
|
signal_ema: EMA,
|
||||||
|
macd_value: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MACD {
|
||||||
|
pub fn new(fast_period: usize, slow_period: usize, signal_period: usize) -> Result<Self, IndicatorError> {
|
||||||
|
if fast_period == 0 || slow_period == 0 || signal_period == 0 {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"All periods must be greater than 0".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if fast_period >= slow_period {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"Fast period must be less than slow period".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
fast_period,
|
||||||
|
slow_period,
|
||||||
|
signal_period,
|
||||||
|
fast_ema: EMA::new(fast_period)?,
|
||||||
|
slow_ema: EMA::new(slow_period)?,
|
||||||
|
signal_ema: EMA::new(signal_period)?,
|
||||||
|
macd_value: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard MACD with 12, 26, 9 periods
|
||||||
|
pub fn standard() -> Result<Self, IndicatorError> {
|
||||||
|
Self::new(12, 26, 9)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate MACD for a series of values
|
||||||
|
pub fn calculate_series(
|
||||||
|
values: &[f64],
|
||||||
|
fast_period: usize,
|
||||||
|
slow_period: usize,
|
||||||
|
signal_period: usize
|
||||||
|
) -> Result<(Vec<f64>, Vec<f64>, Vec<f64>), IndicatorError> {
|
||||||
|
if fast_period >= slow_period {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"Fast period must be less than slow period".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if values.len() < slow_period {
|
||||||
|
return Err(IndicatorError::InsufficientData {
|
||||||
|
required: slow_period,
|
||||||
|
actual: values.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate EMAs
|
||||||
|
let fast_ema = EMA::calculate_series(values, fast_period)?;
|
||||||
|
let slow_ema = EMA::calculate_series(values, slow_period)?;
|
||||||
|
|
||||||
|
// Calculate MACD line
|
||||||
|
let mut macd_line = Vec::with_capacity(slow_ema.len());
|
||||||
|
for i in 0..slow_ema.len() {
|
||||||
|
// Align indices - slow EMA starts later
|
||||||
|
let fast_idx = i + (slow_period - fast_period);
|
||||||
|
macd_line.push(fast_ema[fast_idx] - slow_ema[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate signal line (EMA of MACD)
|
||||||
|
let signal_line = if macd_line.len() >= signal_period {
|
||||||
|
EMA::calculate_series(&macd_line, signal_period)?
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate histogram
|
||||||
|
let mut histogram = Vec::with_capacity(signal_line.len());
|
||||||
|
for i in 0..signal_line.len() {
|
||||||
|
// Align indices
|
||||||
|
let macd_idx = i + (macd_line.len() - signal_line.len());
|
||||||
|
histogram.push(macd_line[macd_idx] - signal_line[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((macd_line, signal_line, histogram))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Indicator for MACD {
|
||||||
|
fn calculate(&mut self, data: &PriceData) -> Result<IndicatorResult, IndicatorError> {
|
||||||
|
let values = &data.close;
|
||||||
|
|
||||||
|
let (macd, signal, histogram) = Self::calculate_series(
|
||||||
|
values,
|
||||||
|
self.fast_period,
|
||||||
|
self.slow_period,
|
||||||
|
self.signal_period
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(IndicatorResult::MACD {
|
||||||
|
macd,
|
||||||
|
signal,
|
||||||
|
histogram,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.fast_ema.reset();
|
||||||
|
self.slow_ema.reset();
|
||||||
|
self.signal_ema.reset();
|
||||||
|
self.macd_value = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.fast_ema.is_ready() && self.slow_ema.is_ready() && self.signal_ema.is_ready()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IncrementalIndicator for MACD {
|
||||||
|
fn update(&mut self, value: f64) -> Result<Option<f64>, IndicatorError> {
|
||||||
|
// Update both EMAs
|
||||||
|
let fast_result = self.fast_ema.update(value)?;
|
||||||
|
let slow_result = self.slow_ema.update(value)?;
|
||||||
|
|
||||||
|
// Calculate MACD if both EMAs are ready
|
||||||
|
if let (Some(fast), Some(slow)) = (fast_result, slow_result) {
|
||||||
|
let macd = fast - slow;
|
||||||
|
self.macd_value = Some(macd);
|
||||||
|
|
||||||
|
// Update signal line
|
||||||
|
if let Some(signal) = self.signal_ema.update(macd)? {
|
||||||
|
// Return histogram value
|
||||||
|
Ok(Some(macd - signal))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current(&self) -> Option<f64> {
|
||||||
|
// Return current histogram value
|
||||||
|
if let (Some(fast), Some(slow), Some(signal)) = (
|
||||||
|
self.fast_ema.current(),
|
||||||
|
self.slow_ema.current(),
|
||||||
|
self.signal_ema.current()
|
||||||
|
) {
|
||||||
|
let macd = fast - slow;
|
||||||
|
Some(macd - signal)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structure to hold all MACD values for incremental updates
|
||||||
|
pub struct MACDValues {
|
||||||
|
pub macd: f64,
|
||||||
|
pub signal: f64,
|
||||||
|
pub histogram: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MACD {
|
||||||
|
/// Get all current MACD values
|
||||||
|
pub fn current_values(&self) -> Option<MACDValues> {
|
||||||
|
if let (Some(fast), Some(slow), Some(signal)) = (
|
||||||
|
self.fast_ema.current(),
|
||||||
|
self.slow_ema.current(),
|
||||||
|
self.signal_ema.current()
|
||||||
|
) {
|
||||||
|
let macd = fast - slow;
|
||||||
|
Some(MACDValues {
|
||||||
|
macd,
|
||||||
|
signal,
|
||||||
|
histogram: macd - signal,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_macd_calculation() {
|
||||||
|
let values = vec![
|
||||||
|
10.0, 10.5, 11.0, 11.5, 12.0, 12.5, 13.0, 13.5,
|
||||||
|
14.0, 14.5, 15.0, 14.5, 14.0, 13.5, 13.0, 12.5,
|
||||||
|
12.0, 11.5, 11.0, 10.5, 10.0, 10.5, 11.0, 11.5,
|
||||||
|
12.0, 12.5, 13.0, 13.5, 14.0, 14.5
|
||||||
|
];
|
||||||
|
|
||||||
|
let (macd, signal, histogram) = MACD::calculate_series(&values, 12, 26, 9).unwrap();
|
||||||
|
|
||||||
|
// Should have values after slow period
|
||||||
|
assert!(!macd.is_empty());
|
||||||
|
assert!(!signal.is_empty());
|
||||||
|
assert!(!histogram.is_empty());
|
||||||
|
|
||||||
|
// Histogram should equal MACD - Signal
|
||||||
|
for i in 0..histogram.len() {
|
||||||
|
let expected = macd[macd.len() - histogram.len() + i] - signal[i];
|
||||||
|
assert!((histogram[i] - expected).abs() < 1e-10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_standard_macd() {
|
||||||
|
let macd = MACD::standard().unwrap();
|
||||||
|
assert_eq!(macd.fast_period, 12);
|
||||||
|
assert_eq!(macd.slow_period, 26);
|
||||||
|
assert_eq!(macd.signal_period, 9);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/stock/core/src/indicators/mod.rs
Normal file
40
apps/stock/core/src/indicators/mod.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
// Technical Analysis Indicators Library
|
||||||
|
pub mod sma;
|
||||||
|
pub mod ema;
|
||||||
|
pub mod rsi;
|
||||||
|
pub mod macd;
|
||||||
|
pub mod bollinger_bands;
|
||||||
|
pub mod stochastic;
|
||||||
|
pub mod atr;
|
||||||
|
pub mod common;
|
||||||
|
|
||||||
|
// Re-export commonly used types and traits
|
||||||
|
pub use common::{IndicatorResult, IndicatorError, PriceData};
|
||||||
|
pub use sma::SMA;
|
||||||
|
pub use ema::EMA;
|
||||||
|
pub use rsi::RSI;
|
||||||
|
pub use macd::MACD;
|
||||||
|
pub use bollinger_bands::BollingerBands;
|
||||||
|
pub use stochastic::Stochastic;
|
||||||
|
pub use atr::ATR;
|
||||||
|
|
||||||
|
/// Trait that all indicators must implement
|
||||||
|
pub trait Indicator {
|
||||||
|
/// Calculate the indicator value(s) for the given data
|
||||||
|
fn calculate(&mut self, data: &PriceData) -> Result<IndicatorResult, IndicatorError>;
|
||||||
|
|
||||||
|
/// Reset the indicator state
|
||||||
|
fn reset(&mut self);
|
||||||
|
|
||||||
|
/// Check if the indicator has enough data to produce valid results
|
||||||
|
fn is_ready(&self) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trait for indicators that can be calculated incrementally
|
||||||
|
pub trait IncrementalIndicator: Indicator {
|
||||||
|
/// Update the indicator with a new data point
|
||||||
|
fn update(&mut self, value: f64) -> Result<Option<f64>, IndicatorError>;
|
||||||
|
|
||||||
|
/// Get the current value without updating
|
||||||
|
fn current(&self) -> Option<f64>;
|
||||||
|
}
|
||||||
223
apps/stock/core/src/indicators/rsi.rs
Normal file
223
apps/stock/core/src/indicators/rsi.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
use super::{Indicator, IncrementalIndicator, IndicatorResult, IndicatorError, PriceData};
|
||||||
|
use super::common::RollingWindow;
|
||||||
|
|
||||||
|
/// Relative Strength Index (RSI) Indicator
|
||||||
|
///
|
||||||
|
/// Measures momentum by comparing the magnitude of recent gains to recent losses
|
||||||
|
/// RSI = 100 - (100 / (1 + RS))
|
||||||
|
/// where RS = Average Gain / Average Loss
|
||||||
|
pub struct RSI {
|
||||||
|
period: usize,
|
||||||
|
avg_gain: f64,
|
||||||
|
avg_loss: f64,
|
||||||
|
prev_value: Option<f64>,
|
||||||
|
window: RollingWindow<f64>,
|
||||||
|
initialized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RSI {
|
||||||
|
pub fn new(period: usize) -> Result<Self, IndicatorError> {
|
||||||
|
if period == 0 {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"Period must be greater than 0".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
period,
|
||||||
|
avg_gain: 0.0,
|
||||||
|
avg_loss: 0.0,
|
||||||
|
prev_value: None,
|
||||||
|
window: RollingWindow::new(period + 1),
|
||||||
|
initialized: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate RSI for a series of values
|
||||||
|
pub fn calculate_series(values: &[f64], period: usize) -> Result<Vec<f64>, IndicatorError> {
|
||||||
|
if period == 0 {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"Period must be greater than 0".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if values.len() <= period {
|
||||||
|
return Err(IndicatorError::InsufficientData {
|
||||||
|
required: period + 1,
|
||||||
|
actual: values.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = Vec::with_capacity(values.len() - period);
|
||||||
|
let mut gains = Vec::with_capacity(values.len() - 1);
|
||||||
|
let mut losses = Vec::with_capacity(values.len() - 1);
|
||||||
|
|
||||||
|
// Calculate price changes
|
||||||
|
for i in 1..values.len() {
|
||||||
|
let change = values[i] - values[i - 1];
|
||||||
|
if change > 0.0 {
|
||||||
|
gains.push(change);
|
||||||
|
losses.push(0.0);
|
||||||
|
} else {
|
||||||
|
gains.push(0.0);
|
||||||
|
losses.push(-change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate initial averages using SMA
|
||||||
|
let initial_avg_gain: f64 = gains[0..period].iter().sum::<f64>() / period as f64;
|
||||||
|
let initial_avg_loss: f64 = losses[0..period].iter().sum::<f64>() / period as f64;
|
||||||
|
|
||||||
|
// Calculate first RSI
|
||||||
|
let rs = if initial_avg_loss > 0.0 {
|
||||||
|
initial_avg_gain / initial_avg_loss
|
||||||
|
} else {
|
||||||
|
100.0 // If no losses, RSI is 100
|
||||||
|
};
|
||||||
|
result.push(100.0 - (100.0 / (1.0 + rs)));
|
||||||
|
|
||||||
|
// Calculate remaining RSIs using EMA smoothing
|
||||||
|
let mut avg_gain = initial_avg_gain;
|
||||||
|
let mut avg_loss = initial_avg_loss;
|
||||||
|
let alpha = 1.0 / period as f64;
|
||||||
|
|
||||||
|
for i in period..gains.len() {
|
||||||
|
// Wilder's smoothing method
|
||||||
|
avg_gain = (avg_gain * (period - 1) as f64 + gains[i]) / period as f64;
|
||||||
|
avg_loss = (avg_loss * (period - 1) as f64 + losses[i]) / period as f64;
|
||||||
|
|
||||||
|
let rs = if avg_loss > 0.0 {
|
||||||
|
avg_gain / avg_loss
|
||||||
|
} else {
|
||||||
|
100.0
|
||||||
|
};
|
||||||
|
result.push(100.0 - (100.0 / (1.0 + rs)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_rsi(&self) -> f64 {
|
||||||
|
if self.avg_loss == 0.0 {
|
||||||
|
100.0
|
||||||
|
} else {
|
||||||
|
let rs = self.avg_gain / self.avg_loss;
|
||||||
|
100.0 - (100.0 / (1.0 + rs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Indicator for RSI {
|
||||||
|
fn calculate(&mut self, data: &PriceData) -> Result<IndicatorResult, IndicatorError> {
|
||||||
|
let values = &data.close;
|
||||||
|
|
||||||
|
if values.len() <= self.period {
|
||||||
|
return Err(IndicatorError::InsufficientData {
|
||||||
|
required: self.period + 1,
|
||||||
|
actual: values.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let rsi_values = Self::calculate_series(values, self.period)?;
|
||||||
|
Ok(IndicatorResult::Series(rsi_values))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.avg_gain = 0.0;
|
||||||
|
self.avg_loss = 0.0;
|
||||||
|
self.prev_value = None;
|
||||||
|
self.window.clear();
|
||||||
|
self.initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.initialized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IncrementalIndicator for RSI {
|
||||||
|
fn update(&mut self, value: f64) -> Result<Option<f64>, IndicatorError> {
|
||||||
|
self.window.push(value);
|
||||||
|
|
||||||
|
if let Some(prev) = self.prev_value {
|
||||||
|
let change = value - prev;
|
||||||
|
let gain = if change > 0.0 { change } else { 0.0 };
|
||||||
|
let loss = if change < 0.0 { -change } else { 0.0 };
|
||||||
|
|
||||||
|
if !self.initialized && self.window.len() > self.period {
|
||||||
|
// Initialize using first period values
|
||||||
|
let values = self.window.as_slice();
|
||||||
|
let mut sum_gain = 0.0;
|
||||||
|
let mut sum_loss = 0.0;
|
||||||
|
|
||||||
|
for i in 1..=self.period {
|
||||||
|
let change = values[i] - values[i - 1];
|
||||||
|
if change > 0.0 {
|
||||||
|
sum_gain += change;
|
||||||
|
} else {
|
||||||
|
sum_loss += -change;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.avg_gain = sum_gain / self.period as f64;
|
||||||
|
self.avg_loss = sum_loss / self.period as f64;
|
||||||
|
self.initialized = true;
|
||||||
|
} else if self.initialized {
|
||||||
|
// Update using Wilder's smoothing
|
||||||
|
self.avg_gain = (self.avg_gain * (self.period - 1) as f64 + gain) / self.period as f64;
|
||||||
|
self.avg_loss = (self.avg_loss * (self.period - 1) as f64 + loss) / self.period as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.prev_value = Some(value);
|
||||||
|
|
||||||
|
if self.initialized {
|
||||||
|
Ok(Some(self.calculate_rsi()))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current(&self) -> Option<f64> {
|
||||||
|
if self.initialized {
|
||||||
|
Some(self.calculate_rsi())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rsi_calculation() {
|
||||||
|
let values = vec![
|
||||||
|
44.0, 44.25, 44.38, 44.38, 44.88, 45.05,
|
||||||
|
45.25, 45.38, 45.75, 46.03, 46.23, 46.08,
|
||||||
|
46.03, 45.85, 46.25, 46.38, 46.50
|
||||||
|
];
|
||||||
|
|
||||||
|
let result = RSI::calculate_series(&values, 14).unwrap();
|
||||||
|
assert_eq!(result.len(), 3);
|
||||||
|
|
||||||
|
// RSI should be between 0 and 100
|
||||||
|
for rsi in &result {
|
||||||
|
assert!(*rsi >= 0.0 && *rsi <= 100.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rsi_extremes() {
|
||||||
|
// All gains - RSI should be close to 100
|
||||||
|
let increasing = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
|
||||||
|
let result = RSI::calculate_series(&increasing, 5).unwrap();
|
||||||
|
assert!(result.last().unwrap() > &95.0);
|
||||||
|
|
||||||
|
// All losses - RSI should be close to 0
|
||||||
|
let decreasing = vec![8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0];
|
||||||
|
let result = RSI::calculate_series(&decreasing, 5).unwrap();
|
||||||
|
assert!(result.last().unwrap() < &5.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
apps/stock/core/src/indicators/sma.rs
Normal file
139
apps/stock/core/src/indicators/sma.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
use super::{Indicator, IncrementalIndicator, IndicatorResult, IndicatorError, PriceData};
|
||||||
|
use super::common::RollingWindow;
|
||||||
|
|
||||||
|
/// Simple Moving Average (SMA) Indicator
|
||||||
|
///
|
||||||
|
/// Calculates the arithmetic mean of the last N periods
|
||||||
|
pub struct SMA {
|
||||||
|
period: usize,
|
||||||
|
window: RollingWindow<f64>,
|
||||||
|
sum: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SMA {
|
||||||
|
pub fn new(period: usize) -> Result<Self, IndicatorError> {
|
||||||
|
if period == 0 {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"Period must be greater than 0".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
period,
|
||||||
|
window: RollingWindow::new(period),
|
||||||
|
sum: 0.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate SMA for a series of values
|
||||||
|
pub fn calculate_series(values: &[f64], period: usize) -> Result<Vec<f64>, IndicatorError> {
|
||||||
|
if period == 0 {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"Period must be greater than 0".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if values.len() < period {
|
||||||
|
return Err(IndicatorError::InsufficientData {
|
||||||
|
required: period,
|
||||||
|
actual: values.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = Vec::with_capacity(values.len() - period + 1);
|
||||||
|
|
||||||
|
// Calculate first SMA
|
||||||
|
let mut sum: f64 = values[0..period].iter().sum();
|
||||||
|
result.push(sum / period as f64);
|
||||||
|
|
||||||
|
// Calculate remaining SMAs using sliding window
|
||||||
|
for i in period..values.len() {
|
||||||
|
sum = sum - values[i - period] + values[i];
|
||||||
|
result.push(sum / period as f64);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Indicator for SMA {
|
||||||
|
fn calculate(&mut self, data: &PriceData) -> Result<IndicatorResult, IndicatorError> {
|
||||||
|
let values = &data.close;
|
||||||
|
|
||||||
|
if values.len() < self.period {
|
||||||
|
return Err(IndicatorError::InsufficientData {
|
||||||
|
required: self.period,
|
||||||
|
actual: values.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let sma_values = Self::calculate_series(values, self.period)?;
|
||||||
|
Ok(IndicatorResult::Series(sma_values))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.window.clear();
|
||||||
|
self.sum = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.window.is_full()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IncrementalIndicator for SMA {
|
||||||
|
fn update(&mut self, value: f64) -> Result<Option<f64>, IndicatorError> {
|
||||||
|
// If window is full, subtract the oldest value from sum
|
||||||
|
if self.window.is_full() {
|
||||||
|
if let Some(oldest) = self.window.iter().next() {
|
||||||
|
self.sum -= oldest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new value
|
||||||
|
self.window.push(value);
|
||||||
|
self.sum += value;
|
||||||
|
|
||||||
|
// Calculate SMA if we have enough data
|
||||||
|
if self.window.is_full() {
|
||||||
|
Ok(Some(self.sum / self.period as f64))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current(&self) -> Option<f64> {
|
||||||
|
if self.window.is_full() {
|
||||||
|
Some(self.sum / self.period as f64)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sma_calculation() {
|
||||||
|
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
|
||||||
|
let result = SMA::calculate_series(&values, 3).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.len(), 8);
|
||||||
|
assert!((result[0] - 2.0).abs() < 1e-10); // (1+2+3)/3 = 2
|
||||||
|
assert!((result[1] - 3.0).abs() < 1e-10); // (2+3+4)/3 = 3
|
||||||
|
assert!((result[7] - 9.0).abs() < 1e-10); // (8+9+10)/3 = 9
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_incremental_sma() {
|
||||||
|
let mut sma = SMA::new(3).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(sma.update(1.0).unwrap(), None);
|
||||||
|
assert_eq!(sma.update(2.0).unwrap(), None);
|
||||||
|
assert_eq!(sma.update(3.0).unwrap(), Some(2.0));
|
||||||
|
assert_eq!(sma.update(4.0).unwrap(), Some(3.0));
|
||||||
|
assert_eq!(sma.update(5.0).unwrap(), Some(4.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
297
apps/stock/core/src/indicators/stochastic.rs
Normal file
297
apps/stock/core/src/indicators/stochastic.rs
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
use super::{Indicator, IncrementalIndicator, IndicatorResult, IndicatorError, PriceData};
|
||||||
|
use super::sma::SMA;
|
||||||
|
use super::common::RollingWindow;
|
||||||
|
|
||||||
|
/// Stochastic Oscillator Indicator
|
||||||
|
///
|
||||||
|
/// %K = 100 × (Close - Lowest Low) / (Highest High - Lowest Low)
|
||||||
|
/// %D = SMA of %K
|
||||||
|
pub struct Stochastic {
|
||||||
|
k_period: usize,
|
||||||
|
d_period: usize,
|
||||||
|
smooth_k: usize,
|
||||||
|
high_window: RollingWindow<f64>,
|
||||||
|
low_window: RollingWindow<f64>,
|
||||||
|
close_window: RollingWindow<f64>,
|
||||||
|
k_sma: SMA,
|
||||||
|
d_sma: SMA,
|
||||||
|
k_values: RollingWindow<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stochastic {
|
||||||
|
pub fn new(k_period: usize, d_period: usize, smooth_k: usize) -> Result<Self, IndicatorError> {
|
||||||
|
if k_period == 0 || d_period == 0 {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"K and D periods must be greater than 0".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
k_period,
|
||||||
|
d_period,
|
||||||
|
smooth_k,
|
||||||
|
high_window: RollingWindow::new(k_period),
|
||||||
|
low_window: RollingWindow::new(k_period),
|
||||||
|
close_window: RollingWindow::new(k_period),
|
||||||
|
k_sma: SMA::new(smooth_k.max(1))?,
|
||||||
|
d_sma: SMA::new(d_period)?,
|
||||||
|
k_values: RollingWindow::new(d_period),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard Fast Stochastic (14, 3)
|
||||||
|
pub fn fast() -> Result<Self, IndicatorError> {
|
||||||
|
Self::new(14, 3, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard Slow Stochastic (14, 3, 3)
|
||||||
|
pub fn slow() -> Result<Self, IndicatorError> {
|
||||||
|
Self::new(14, 3, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate raw %K value
|
||||||
|
fn calculate_raw_k(high: &[f64], low: &[f64], close: f64) -> Option<f64> {
|
||||||
|
if high.is_empty() || low.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let highest = high.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||||
|
let lowest = low.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||||
|
|
||||||
|
let range = highest - lowest;
|
||||||
|
if range > 0.0 {
|
||||||
|
Some(100.0 * (close - lowest) / range)
|
||||||
|
} else {
|
||||||
|
Some(50.0) // If no range, return middle value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate Stochastic for a series of price data
|
||||||
|
pub fn calculate_series(
|
||||||
|
high: &[f64],
|
||||||
|
low: &[f64],
|
||||||
|
close: &[f64],
|
||||||
|
k_period: usize,
|
||||||
|
d_period: usize,
|
||||||
|
smooth_k: usize,
|
||||||
|
) -> Result<(Vec<f64>, Vec<f64>), IndicatorError> {
|
||||||
|
if high.len() != low.len() || high.len() != close.len() {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"High, low, and close arrays must have the same length".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if high.len() < k_period {
|
||||||
|
return Err(IndicatorError::InsufficientData {
|
||||||
|
required: k_period,
|
||||||
|
actual: high.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate raw %K values
|
||||||
|
let mut raw_k_values = Vec::with_capacity(high.len() - k_period + 1);
|
||||||
|
for i in k_period - 1..high.len() {
|
||||||
|
let start = i + 1 - k_period;
|
||||||
|
if let Some(k) = Self::calculate_raw_k(
|
||||||
|
&high[start..=i],
|
||||||
|
&low[start..=i],
|
||||||
|
close[i]
|
||||||
|
) {
|
||||||
|
raw_k_values.push(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth %K if requested
|
||||||
|
let k_values = if smooth_k > 1 {
|
||||||
|
SMA::calculate_series(&raw_k_values, smooth_k)?
|
||||||
|
} else {
|
||||||
|
raw_k_values
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate %D (SMA of %K)
|
||||||
|
let d_values = if k_values.len() >= d_period {
|
||||||
|
SMA::calculate_series(&k_values, d_period)?
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((k_values, d_values))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Indicator for Stochastic {
|
||||||
|
fn calculate(&mut self, data: &PriceData) -> Result<IndicatorResult, IndicatorError> {
|
||||||
|
if data.high.len() != data.low.len() || data.high.len() != data.close.len() {
|
||||||
|
return Err(IndicatorError::InvalidParameter(
|
||||||
|
"Price data arrays must have the same length".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (k_values, d_values) = Self::calculate_series(
|
||||||
|
&data.high,
|
||||||
|
&data.low,
|
||||||
|
&data.close,
|
||||||
|
self.k_period,
|
||||||
|
self.d_period,
|
||||||
|
self.smooth_k,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(IndicatorResult::Stochastic {
|
||||||
|
k: k_values,
|
||||||
|
d: d_values,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.high_window.clear();
|
||||||
|
self.low_window.clear();
|
||||||
|
self.close_window.clear();
|
||||||
|
self.k_sma.reset();
|
||||||
|
self.d_sma.reset();
|
||||||
|
self.k_values.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.high_window.is_full() && self.d_sma.is_ready()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IncrementalIndicator for Stochastic {
|
||||||
|
fn update(&mut self, value: f64) -> Result<Option<f64>, IndicatorError> {
|
||||||
|
// For incremental updates, we assume value is close price
|
||||||
|
// In real usage, you'd need to provide high/low/close separately
|
||||||
|
self.high_window.push(value);
|
||||||
|
self.low_window.push(value);
|
||||||
|
self.close_window.push(value);
|
||||||
|
|
||||||
|
if self.high_window.is_full() {
|
||||||
|
// Calculate raw %K
|
||||||
|
if let Some(raw_k) = Self::calculate_raw_k(
|
||||||
|
&self.high_window.as_slice(),
|
||||||
|
&self.low_window.as_slice(),
|
||||||
|
value
|
||||||
|
) {
|
||||||
|
// Smooth %K if needed
|
||||||
|
let k_value = if self.smooth_k > 1 {
|
||||||
|
self.k_sma.update(raw_k)?
|
||||||
|
} else {
|
||||||
|
Some(raw_k)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(k) = k_value {
|
||||||
|
self.k_values.push(k);
|
||||||
|
|
||||||
|
// Calculate %D
|
||||||
|
if let Some(d) = self.d_sma.update(k)? {
|
||||||
|
return Ok(Some(d));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current(&self) -> Option<f64> {
|
||||||
|
self.d_sma.current()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structure to hold Stochastic values
|
||||||
|
pub struct StochasticValues {
|
||||||
|
pub k: f64,
|
||||||
|
pub d: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stochastic {
|
||||||
|
/// Get current %K and %D values
|
||||||
|
pub fn current_values(&self) -> Option<StochasticValues> {
|
||||||
|
if let (Some(&k), Some(d)) = (self.k_values.iter().last(), self.d_sma.current()) {
|
||||||
|
Some(StochasticValues { k, d })
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update with separate high, low, close values
|
||||||
|
pub fn update_hlc(&mut self, high: f64, low: f64, close: f64) -> Result<Option<StochasticValues>, IndicatorError> {
|
||||||
|
self.high_window.push(high);
|
||||||
|
self.low_window.push(low);
|
||||||
|
self.close_window.push(close);
|
||||||
|
|
||||||
|
if self.high_window.is_full() {
|
||||||
|
if let Some(raw_k) = Self::calculate_raw_k(
|
||||||
|
&self.high_window.as_slice(),
|
||||||
|
&self.low_window.as_slice(),
|
||||||
|
close
|
||||||
|
) {
|
||||||
|
let k_value = if self.smooth_k > 1 {
|
||||||
|
self.k_sma.update(raw_k)?
|
||||||
|
} else {
|
||||||
|
Some(raw_k)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(k) = k_value {
|
||||||
|
self.k_values.push(k);
|
||||||
|
|
||||||
|
if let Some(d) = self.d_sma.update(k)? {
|
||||||
|
return Ok(Some(StochasticValues { k, d }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stochastic_calculation() {
|
||||||
|
let high = vec![
|
||||||
|
127.01, 127.62, 126.59, 127.35, 128.17,
|
||||||
|
128.43, 127.37, 126.42, 126.90, 126.85,
|
||||||
|
125.65, 125.72, 127.16, 127.72, 127.69
|
||||||
|
];
|
||||||
|
let low = vec![
|
||||||
|
125.36, 126.16, 124.93, 126.09, 126.82,
|
||||||
|
126.48, 126.03, 124.83, 126.39, 125.72,
|
||||||
|
124.56, 124.57, 125.07, 126.86, 126.63
|
||||||
|
];
|
||||||
|
let close = vec![
|
||||||
|
125.36, 126.16, 124.93, 126.09, 126.82,
|
||||||
|
126.48, 126.03, 124.83, 126.39, 125.72,
|
||||||
|
124.56, 124.57, 125.07, 126.86, 126.63
|
||||||
|
];
|
||||||
|
|
||||||
|
let (k_values, d_values) = Stochastic::calculate_series(&high, &low, &close, 14, 3, 1).unwrap();
|
||||||
|
|
||||||
|
assert!(!k_values.is_empty());
|
||||||
|
assert!(!d_values.is_empty());
|
||||||
|
|
||||||
|
// %K should be between 0 and 100
|
||||||
|
for k in &k_values {
|
||||||
|
assert!(*k >= 0.0 && *k <= 100.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stochastic_extremes() {
|
||||||
|
// When close is at highest high, %K should be 100
|
||||||
|
let high = vec![10.0; 14];
|
||||||
|
let low = vec![5.0; 14];
|
||||||
|
let mut close = vec![7.5; 14];
|
||||||
|
close[13] = 10.0; // Last close at high
|
||||||
|
|
||||||
|
let (k_values, _) = Stochastic::calculate_series(&high, &low, &close, 14, 3, 1).unwrap();
|
||||||
|
assert!((k_values[0] - 100.0).abs() < 1e-10);
|
||||||
|
|
||||||
|
// When close is at lowest low, %K should be 0
|
||||||
|
close[13] = 5.0; // Last close at low
|
||||||
|
let (k_values, _) = Stochastic::calculate_series(&high, &low, &close, 14, 3, 1).unwrap();
|
||||||
|
assert!(k_values[0].abs() < 1e-10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ pub mod risk;
|
||||||
pub mod positions;
|
pub mod positions;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod analytics;
|
pub mod analytics;
|
||||||
|
pub mod indicators;
|
||||||
|
|
||||||
// Re-export commonly used types
|
// Re-export commonly used types
|
||||||
pub use positions::{Position, PositionUpdate, TradeRecord, ClosedTrade};
|
pub use positions::{Position, PositionUpdate, TradeRecord, ClosedTrade};
|
||||||
|
|
|
||||||
293
apps/stock/orchestrator/docs/architecture-improvements.md
Normal file
293
apps/stock/orchestrator/docs/architecture-improvements.md
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
# Orchestrator Architecture Improvements
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The orchestrator has been refactored to use the new Rust-based Technical Analysis library and improve separation of concerns. The architecture now follows a modular design with clear responsibilities for each component.
|
||||||
|
|
||||||
|
## Key Components
|
||||||
|
|
||||||
|
### 1. Technical Analysis (Rust Core)
|
||||||
|
- **Location**: `apps/stock/core/src/indicators/`
|
||||||
|
- **Purpose**: High-performance indicator calculations
|
||||||
|
- **Features**:
|
||||||
|
- 7 indicators: SMA, EMA, RSI, MACD, Bollinger Bands, Stochastic, ATR
|
||||||
|
- Both batch and incremental calculations
|
||||||
|
- Thread-safe, zero-copy implementations
|
||||||
|
- NAPI bindings for TypeScript access
|
||||||
|
|
||||||
|
### 2. Indicator Management
|
||||||
|
- **Component**: `IndicatorManager`
|
||||||
|
- **Responsibilities**:
|
||||||
|
- Price history management
|
||||||
|
- Indicator calculation and caching
|
||||||
|
- Incremental indicator updates
|
||||||
|
- Cross-indicator analysis (crossovers, etc.)
|
||||||
|
|
||||||
|
### 3. Position Management
|
||||||
|
- **Component**: `PositionManager`
|
||||||
|
- **Responsibilities**:
|
||||||
|
- Track open positions
|
||||||
|
- Calculate P&L (realized and unrealized)
|
||||||
|
- Position sizing algorithms
|
||||||
|
- Performance metrics tracking
|
||||||
|
|
||||||
|
### 4. Risk Management
|
||||||
|
- **Component**: `RiskManager`
|
||||||
|
- **Responsibilities**:
|
||||||
|
- Enforce position limits
|
||||||
|
- Monitor drawdown
|
||||||
|
- Calculate risk metrics (VaR, Sharpe ratio)
|
||||||
|
- Daily loss limits
|
||||||
|
- Position sizing based on risk
|
||||||
|
|
||||||
|
### 5. Signal Management
|
||||||
|
- **Component**: `SignalManager`
|
||||||
|
- **Responsibilities**:
|
||||||
|
- Rule-based signal generation
|
||||||
|
- Signal aggregation (weighted, majority, etc.)
|
||||||
|
- Signal filtering
|
||||||
|
- Historical signal tracking
|
||||||
|
|
||||||
|
## Architecture Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ TypeScript Layer │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Strategy │ │ Signal │ │ Risk │ │
|
||||||
|
│ │ Engine │──│ Manager │──│ Manager │ │
|
||||||
|
│ └──────┬──────┘ └──────────────┘ └──────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────┴──────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Indicator │ │ Position │ │ Market │ │
|
||||||
|
│ │ Manager │ │ Manager │ │ Data │ │
|
||||||
|
│ └──────┬──────┘ └──────────────┘ └──────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
├─────────┼─────────────────────────────────────────────────┤
|
||||||
|
│ │ NAPI Bindings │
|
||||||
|
├─────────┼─────────────────────────────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────┴──────────────────────────────────────────┐ │
|
||||||
|
│ │ Rust Core (Technical Analysis) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌─────┐ ┌─────┐ ┌─────┐ ┌──────┐ ┌────────┐ │ │
|
||||||
|
│ │ │ SMA │ │ EMA │ │ RSI │ │ MACD │ │ Bollinger│ │ │
|
||||||
|
│ │ └─────┘ └─────┘ └─────┘ └──────┘ └────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌───────────┐ ┌─────┐ ┌──────────────────┐ │ │
|
||||||
|
│ │ │ Stochastic│ │ ATR │ │ Common Utilities │ │ │
|
||||||
|
│ │ └───────────┘ └─────┘ └──────────────────┘ │ │
|
||||||
|
│ └──────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Strategy Implementation Pattern
|
||||||
|
|
||||||
|
### Before (Monolithic)
|
||||||
|
```typescript
|
||||||
|
class SimpleStrategy extends BaseStrategy {
|
||||||
|
// Everything mixed together
|
||||||
|
updateIndicators() { /* calculate MAs inline */ }
|
||||||
|
generateSignal() { /* risk checks, position sizing, signal logic */ }
|
||||||
|
onOrderFilled() { /* position tracking inline */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (Modular)
|
||||||
|
```typescript
|
||||||
|
class AdvancedStrategy extends BaseStrategy {
|
||||||
|
private indicatorManager: IndicatorManager;
|
||||||
|
private positionManager: PositionManager;
|
||||||
|
private riskManager: RiskManager;
|
||||||
|
private signalManager: SignalManager;
|
||||||
|
|
||||||
|
updateIndicators(data) {
|
||||||
|
// Delegate to specialized manager
|
||||||
|
this.indicatorManager.updatePrice(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
generateSignal(data) {
|
||||||
|
// 1. Get indicators
|
||||||
|
const indicators = this.indicatorManager.prepareIndicators(symbol);
|
||||||
|
|
||||||
|
// 2. Check risk
|
||||||
|
const riskCheck = this.riskManager.checkNewPosition(...);
|
||||||
|
|
||||||
|
// 3. Generate signal
|
||||||
|
const signal = this.signalManager.generateSignal(...);
|
||||||
|
|
||||||
|
// 4. Size position
|
||||||
|
const size = this.positionManager.calculatePositionSize(...);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
### 1. Performance
|
||||||
|
- Rust indicators are 10-100x faster than JavaScript
|
||||||
|
- Efficient memory usage with rolling windows
|
||||||
|
- Parallel computation support
|
||||||
|
|
||||||
|
### 2. Maintainability
|
||||||
|
- Clear separation of concerns
|
||||||
|
- Reusable components
|
||||||
|
- Easy to test individual pieces
|
||||||
|
- Consistent interfaces
|
||||||
|
|
||||||
|
### 3. Flexibility
|
||||||
|
- Strategies can mix and match components
|
||||||
|
- Easy to add new indicators
|
||||||
|
- Multiple position sizing methods
|
||||||
|
- Configurable risk limits
|
||||||
|
|
||||||
|
### 4. Reliability
|
||||||
|
- Type-safe interfaces
|
||||||
|
- Error handling at each layer
|
||||||
|
- Comprehensive logging
|
||||||
|
- Performance metrics tracking
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### Converting Existing Strategies
|
||||||
|
|
||||||
|
1. **Replace inline calculations with IndicatorManager**:
|
||||||
|
```typescript
|
||||||
|
// Old
|
||||||
|
const sma = this.calculateSMA(prices, period);
|
||||||
|
|
||||||
|
// New
|
||||||
|
const sma = this.indicatorManager.getSMA(symbol, period);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use PositionManager for tracking**:
|
||||||
|
```typescript
|
||||||
|
// Old
|
||||||
|
this.positions.set(symbol, quantity);
|
||||||
|
|
||||||
|
// New
|
||||||
|
this.positionManager.updatePosition(trade);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add RiskManager checks**:
|
||||||
|
```typescript
|
||||||
|
// New - check before trading
|
||||||
|
const riskCheck = this.riskManager.checkNewPosition(...);
|
||||||
|
if (!riskCheck.allowed) return null;
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Use SignalManager for rules**:
|
||||||
|
```typescript
|
||||||
|
// Setup rules once
|
||||||
|
this.signalManager.addRule(CommonRules.goldenCross('sma20', 'sma50'));
|
||||||
|
|
||||||
|
// Generate signals
|
||||||
|
const signal = this.signalManager.generateSignal(symbol, timestamp, indicators);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Strategies
|
||||||
|
|
||||||
|
### 1. SimpleMovingAverageCrossoverV2
|
||||||
|
- Uses IndicatorManager for MA calculations
|
||||||
|
- PositionManager for sizing
|
||||||
|
- Clean separation of indicator updates and signal generation
|
||||||
|
|
||||||
|
### 2. IndicatorBasedStrategy
|
||||||
|
- Demonstrates incremental indicators
|
||||||
|
- Uses SignalGenerator for multi-indicator signals
|
||||||
|
- Shows batch analysis capabilities
|
||||||
|
|
||||||
|
### 3. AdvancedMultiIndicatorStrategy
|
||||||
|
- Full integration of all managers
|
||||||
|
- Multiple signal rules with aggregation
|
||||||
|
- Risk-based position sizing
|
||||||
|
- Stop loss and take profit management
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### Immediate Improvements
|
||||||
|
1. ✅ Implement TA library in Rust
|
||||||
|
2. ✅ Create manager components
|
||||||
|
3. ✅ Refactor existing strategies
|
||||||
|
4. ✅ Add comprehensive tests
|
||||||
|
|
||||||
|
### Future Enhancements
|
||||||
|
1. **More Indicators**:
|
||||||
|
- Ichimoku Cloud
|
||||||
|
- Fibonacci retracements
|
||||||
|
- Volume-weighted indicators
|
||||||
|
|
||||||
|
2. **Advanced Risk Management**:
|
||||||
|
- Portfolio optimization
|
||||||
|
- Correlation analysis
|
||||||
|
- Dynamic position sizing
|
||||||
|
|
||||||
|
3. **Machine Learning Integration**:
|
||||||
|
- Feature extraction from indicators
|
||||||
|
- Signal strength prediction
|
||||||
|
- Adaptive rule weights
|
||||||
|
|
||||||
|
4. **Performance Optimization**:
|
||||||
|
- GPU acceleration for backtesting
|
||||||
|
- Distributed indicator calculation
|
||||||
|
- Real-time streaming optimizations
|
||||||
|
|
||||||
|
## Configuration Examples
|
||||||
|
|
||||||
|
### Basic Strategy
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
strategy: 'SimpleMovingAverageCrossoverV2',
|
||||||
|
params: {
|
||||||
|
fastPeriod: 10,
|
||||||
|
slowPeriod: 20,
|
||||||
|
positionSizePct: 0.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced Strategy
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
strategy: 'AdvancedMultiIndicatorStrategy',
|
||||||
|
params: {
|
||||||
|
// Indicators
|
||||||
|
fastMA: 20,
|
||||||
|
slowMA: 50,
|
||||||
|
rsiPeriod: 14,
|
||||||
|
|
||||||
|
// Risk
|
||||||
|
riskPerTrade: 0.02,
|
||||||
|
maxPositions: 5,
|
||||||
|
maxDrawdown: 0.2,
|
||||||
|
|
||||||
|
// Signals
|
||||||
|
signalAggregation: 'weighted',
|
||||||
|
minSignalStrength: 0.6,
|
||||||
|
|
||||||
|
// Position sizing
|
||||||
|
positionSizing: 'risk',
|
||||||
|
useATRStops: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run the new indicator tests:
|
||||||
|
```bash
|
||||||
|
bun run test:indicators
|
||||||
|
```
|
||||||
|
|
||||||
|
Run strategy tests:
|
||||||
|
```bash
|
||||||
|
bun test src/strategies
|
||||||
|
```
|
||||||
|
|
||||||
|
Run examples:
|
||||||
|
```bash
|
||||||
|
bun run example:indicators
|
||||||
|
```
|
||||||
463
apps/stock/orchestrator/docs/rust-core-enhancements.md
Normal file
463
apps/stock/orchestrator/docs/rust-core-enhancements.md
Normal file
|
|
@ -0,0 +1,463 @@
|
||||||
|
# Rust Core Enhancement Roadmap
|
||||||
|
|
||||||
|
## Missing Components & Potential Additions
|
||||||
|
|
||||||
|
### 1. **Order Management System (OMS)**
|
||||||
|
Currently missing a comprehensive order lifecycle management system.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Suggested additions to orders module
|
||||||
|
pub struct OrderManager {
|
||||||
|
active_orders: DashMap<String, Order>,
|
||||||
|
order_history: Vec<OrderEvent>,
|
||||||
|
order_routes: HashMap<String, OrderRoute>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum OrderEvent {
|
||||||
|
Submitted { order_id: String, timestamp: DateTime<Utc> },
|
||||||
|
Acknowledged { order_id: String, broker_id: String },
|
||||||
|
PartialFill { order_id: String, fill: Fill },
|
||||||
|
Filled { order_id: String, avg_price: f64 },
|
||||||
|
Cancelled { order_id: String, reason: String },
|
||||||
|
Rejected { order_id: String, reason: String },
|
||||||
|
Modified { order_id: String, changes: OrderModification },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct OrderModification {
|
||||||
|
quantity: Option<f64>,
|
||||||
|
price: Option<f64>,
|
||||||
|
stop_price: Option<f64>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **Advanced Order Types**
|
||||||
|
Current order types are basic. Missing:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum OrderType {
|
||||||
|
// Existing
|
||||||
|
Market,
|
||||||
|
Limit { price: f64 },
|
||||||
|
Stop { stop_price: f64 },
|
||||||
|
StopLimit { stop_price: f64, limit_price: f64 },
|
||||||
|
|
||||||
|
// Missing
|
||||||
|
Iceberg { visible_quantity: f64, total_quantity: f64 },
|
||||||
|
TWAP { duration: Duration, slices: u32 },
|
||||||
|
VWAP { duration: Duration, participation_rate: f64 },
|
||||||
|
PeggedToMidpoint { offset: f64 },
|
||||||
|
TrailingStop { trail_amount: f64, trail_percent: Option<f64> },
|
||||||
|
OCO { order1: Box<Order>, order2: Box<Order> }, // One-Cancels-Other
|
||||||
|
Bracket { entry: Box<Order>, stop_loss: Box<Order>, take_profit: Box<Order> },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **Portfolio Management**
|
||||||
|
No portfolio-level analytics or optimization.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod portfolio {
|
||||||
|
pub struct Portfolio {
|
||||||
|
positions: HashMap<String, Position>,
|
||||||
|
cash_balance: f64,
|
||||||
|
margin_used: f64,
|
||||||
|
buying_power: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PortfolioAnalytics {
|
||||||
|
pub fn calculate_beta(&self, benchmark: &str) -> f64;
|
||||||
|
pub fn calculate_correlation_matrix(&self) -> Matrix<f64>;
|
||||||
|
pub fn calculate_var(&self, confidence: f64, horizon: Duration) -> f64;
|
||||||
|
pub fn calculate_sharpe_ratio(&self, risk_free_rate: f64) -> f64;
|
||||||
|
pub fn calculate_sortino_ratio(&self, mar: f64) -> f64;
|
||||||
|
pub fn calculate_max_drawdown(&self) -> DrawdownInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PortfolioOptimizer {
|
||||||
|
pub fn optimize_weights(&self, constraints: Constraints) -> HashMap<String, f64>;
|
||||||
|
pub fn calculate_efficient_frontier(&self, points: usize) -> Vec<(f64, f64)>;
|
||||||
|
pub fn black_litterman(&self, views: Views) -> HashMap<String, f64>;
|
||||||
|
pub fn risk_parity(&self) -> HashMap<String, f64>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. **Market Data Enhancements**
|
||||||
|
Missing Level 2 data, options data, and advanced market data types.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum MarketDataType {
|
||||||
|
// Existing
|
||||||
|
Quote(Quote),
|
||||||
|
Trade(Trade),
|
||||||
|
Bar(Bar),
|
||||||
|
|
||||||
|
// Missing
|
||||||
|
Level2 { bids: Vec<PriceLevel>, asks: Vec<PriceLevel> },
|
||||||
|
Imbalance { buy_quantity: f64, sell_quantity: f64, ref_price: f64 },
|
||||||
|
AuctionData { indicative_price: f64, indicative_volume: f64 },
|
||||||
|
OptionQuote {
|
||||||
|
strike: f64,
|
||||||
|
expiry: DateTime<Utc>,
|
||||||
|
call_bid: f64,
|
||||||
|
call_ask: f64,
|
||||||
|
put_bid: f64,
|
||||||
|
put_ask: f64,
|
||||||
|
implied_vol: f64,
|
||||||
|
},
|
||||||
|
Greeks {
|
||||||
|
delta: f64,
|
||||||
|
gamma: f64,
|
||||||
|
theta: f64,
|
||||||
|
vega: f64,
|
||||||
|
rho: f64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. **Execution Algorithms**
|
||||||
|
No implementation of common execution algorithms.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod execution_algos {
|
||||||
|
pub trait ExecutionAlgorithm {
|
||||||
|
fn generate_child_orders(&mut self, parent: &Order, market_state: &MarketState) -> Vec<Order>;
|
||||||
|
fn on_fill(&mut self, fill: &Fill);
|
||||||
|
fn on_market_update(&mut self, update: &MarketUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TWAPAlgorithm {
|
||||||
|
duration: Duration,
|
||||||
|
slice_interval: Duration,
|
||||||
|
randomization: f64, // Add randomness to avoid detection
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct VWAPAlgorithm {
|
||||||
|
historical_volume_curve: Vec<f64>,
|
||||||
|
participation_rate: f64,
|
||||||
|
min_slice_size: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ImplementationShortfall {
|
||||||
|
urgency: f64,
|
||||||
|
risk_aversion: f64,
|
||||||
|
arrival_price: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Iceberg {
|
||||||
|
visible_size: f64,
|
||||||
|
refresh_strategy: RefreshStrategy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. **Options Support**
|
||||||
|
No options trading infrastructure.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod options {
|
||||||
|
pub struct OptionContract {
|
||||||
|
underlying: String,
|
||||||
|
strike: f64,
|
||||||
|
expiry: DateTime<Utc>,
|
||||||
|
option_type: OptionType,
|
||||||
|
multiplier: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum OptionType {
|
||||||
|
Call,
|
||||||
|
Put,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct OptionPricer {
|
||||||
|
pub fn black_scholes(&self, params: BSParams) -> OptionPrice;
|
||||||
|
pub fn binomial(&self, params: BinomialParams) -> OptionPrice;
|
||||||
|
pub fn monte_carlo(&self, params: MCParams, simulations: u32) -> OptionPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct OptionGreeks {
|
||||||
|
pub fn calculate_greeks(&self, contract: &OptionContract, market: &MarketData) -> Greeks;
|
||||||
|
pub fn calculate_implied_volatility(&self, price: f64) -> f64;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct OptionStrategy {
|
||||||
|
legs: Vec<OptionLeg>,
|
||||||
|
pub fn calculate_payoff(&self, underlying_price: f64) -> f64;
|
||||||
|
pub fn calculate_breakeven(&self) -> Vec<f64>;
|
||||||
|
pub fn max_profit(&self) -> Option<f64>;
|
||||||
|
pub fn max_loss(&self) -> Option<f64>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. **Machine Learning Integration**
|
||||||
|
No ML feature generation or model integration.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod ml {
|
||||||
|
pub struct FeatureEngine {
|
||||||
|
indicators: Vec<Box<dyn Indicator>>,
|
||||||
|
lookback_periods: Vec<usize>,
|
||||||
|
|
||||||
|
pub fn generate_features(&self, data: &MarketData) -> FeatureMatrix;
|
||||||
|
pub fn calculate_feature_importance(&self) -> HashMap<String, f64>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait MLModel {
|
||||||
|
fn predict(&self, features: &FeatureMatrix) -> Prediction;
|
||||||
|
fn update(&mut self, features: &FeatureMatrix, outcome: &Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ModelEnsemble {
|
||||||
|
models: Vec<Box<dyn MLModel>>,
|
||||||
|
weights: Vec<f64>,
|
||||||
|
|
||||||
|
pub fn predict(&self, features: &FeatureMatrix) -> Prediction;
|
||||||
|
pub fn update_weights(&mut self, performance: &ModelPerformance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. **Backtesting Engine Enhancements**
|
||||||
|
Current backtesting is basic. Missing:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod backtesting {
|
||||||
|
pub struct BacktestEngine {
|
||||||
|
// Slippage models
|
||||||
|
slippage_model: Box<dyn SlippageModel>,
|
||||||
|
|
||||||
|
// Market impact models (you have this but not integrated)
|
||||||
|
market_impact: Box<dyn MarketImpactModel>,
|
||||||
|
|
||||||
|
// Multi-asset synchronization
|
||||||
|
clock_sync: ClockSynchronizer,
|
||||||
|
|
||||||
|
// Walk-forward analysis
|
||||||
|
walk_forward: WalkForwardConfig,
|
||||||
|
|
||||||
|
// Monte Carlo simulation
|
||||||
|
monte_carlo: MonteCarloConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BacktestMetrics {
|
||||||
|
// Return metrics
|
||||||
|
total_return: f64,
|
||||||
|
annualized_return: f64,
|
||||||
|
volatility: f64,
|
||||||
|
|
||||||
|
// Risk metrics
|
||||||
|
sharpe_ratio: f64,
|
||||||
|
sortino_ratio: f64,
|
||||||
|
calmar_ratio: f64,
|
||||||
|
max_drawdown: f64,
|
||||||
|
var_95: f64,
|
||||||
|
cvar_95: f64,
|
||||||
|
|
||||||
|
// Trading metrics
|
||||||
|
win_rate: f64,
|
||||||
|
profit_factor: f64,
|
||||||
|
avg_win_loss_ratio: f64,
|
||||||
|
expectancy: f64,
|
||||||
|
|
||||||
|
// Execution metrics
|
||||||
|
avg_slippage: f64,
|
||||||
|
total_commission: f64,
|
||||||
|
turnover: f64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. **Real-time Monitoring & Alerts**
|
||||||
|
No monitoring or alerting system.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod monitoring {
|
||||||
|
pub struct Monitor {
|
||||||
|
rules: Vec<MonitorRule>,
|
||||||
|
alert_channels: Vec<Box<dyn AlertChannel>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum MonitorRule {
|
||||||
|
PositionLimit { symbol: String, max_size: f64 },
|
||||||
|
DrawdownAlert { threshold: f64 },
|
||||||
|
VolumeSpike { symbol: String, threshold: f64 },
|
||||||
|
SpreadWidening { symbol: String, max_spread: f64 },
|
||||||
|
LatencyAlert { max_latency: Duration },
|
||||||
|
ErrorRate { max_errors_per_minute: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait AlertChannel {
|
||||||
|
fn send_alert(&self, alert: Alert) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. **Data Persistence Layer**
|
||||||
|
No built-in data storage/retrieval.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod persistence {
|
||||||
|
pub trait DataStore {
|
||||||
|
fn save_market_data(&self, data: &MarketUpdate) -> Result<(), Error>;
|
||||||
|
fn load_market_data(&self, symbol: &str, range: DateRange) -> Result<Vec<MarketUpdate>, Error>;
|
||||||
|
|
||||||
|
fn save_order(&self, order: &Order) -> Result<(), Error>;
|
||||||
|
fn load_order_history(&self, filter: OrderFilter) -> Result<Vec<Order>, Error>;
|
||||||
|
|
||||||
|
fn save_trade(&self, trade: &TradeRecord) -> Result<(), Error>;
|
||||||
|
fn load_trades(&self, filter: TradeFilter) -> Result<Vec<TradeRecord>, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TimeSeriesDB {
|
||||||
|
// QuestDB or TimescaleDB adapter
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Cache {
|
||||||
|
// Redis adapter for hot data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11. **Strategy Development Framework**
|
||||||
|
Missing strategy templates and utilities.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod strategy_framework {
|
||||||
|
pub trait Strategy {
|
||||||
|
fn on_start(&mut self);
|
||||||
|
fn on_market_data(&mut self, data: &MarketUpdate) -> Vec<Signal>;
|
||||||
|
fn on_fill(&mut self, fill: &Fill);
|
||||||
|
fn on_end_of_day(&mut self);
|
||||||
|
fn get_parameters(&self) -> StrategyParameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StrategyOptimizer {
|
||||||
|
pub fn optimize_parameters(
|
||||||
|
&self,
|
||||||
|
strategy: &dyn Strategy,
|
||||||
|
data: &HistoricalData,
|
||||||
|
objective: ObjectiveFunction
|
||||||
|
) -> OptimalParameters;
|
||||||
|
|
||||||
|
pub fn walk_forward_analysis(&self, windows: Vec<DateRange>) -> WalkForwardResults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12. **Compliance & Regulation**
|
||||||
|
No compliance checks or audit trails.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod compliance {
|
||||||
|
pub struct ComplianceEngine {
|
||||||
|
rules: Vec<ComplianceRule>,
|
||||||
|
audit_log: AuditLog,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum ComplianceRule {
|
||||||
|
NoBuyDuringRestricted { restricted_periods: Vec<DateRange> },
|
||||||
|
MaxOrdersPerDay { limit: u32 },
|
||||||
|
MinOrderInterval { duration: Duration },
|
||||||
|
RestrictedSymbols { symbols: HashSet<String> },
|
||||||
|
MaxLeverageRatio { ratio: f64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AuditLog {
|
||||||
|
pub fn log_order(&self, order: &Order, metadata: AuditMetadata);
|
||||||
|
pub fn log_trade(&self, trade: &Trade, metadata: AuditMetadata);
|
||||||
|
pub fn generate_report(&self, period: DateRange) -> ComplianceReport;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13. **Advanced Indicators**
|
||||||
|
Missing many common indicators.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod indicators {
|
||||||
|
// Additional indicators to add:
|
||||||
|
- Ichimoku Cloud
|
||||||
|
- Parabolic SAR
|
||||||
|
- Fibonacci Retracements
|
||||||
|
- Pivot Points
|
||||||
|
- Money Flow Index
|
||||||
|
- Williams %R
|
||||||
|
- Commodity Channel Index (CCI)
|
||||||
|
- On Balance Volume (OBV)
|
||||||
|
- Accumulation/Distribution Line
|
||||||
|
- Chaikin Money Flow
|
||||||
|
- TRIX
|
||||||
|
- Keltner Channels
|
||||||
|
- Donchian Channels
|
||||||
|
- Average Directional Index (ADX)
|
||||||
|
- Aroon Indicator
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14. **Network & Connectivity**
|
||||||
|
No network resilience or multi-venue support.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod connectivity {
|
||||||
|
pub struct ConnectionManager {
|
||||||
|
venues: HashMap<String, VenueConnection>,
|
||||||
|
fallback_routes: HashMap<String, Vec<String>>,
|
||||||
|
heartbeat_monitor: HeartbeatMonitor,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct VenueConnection {
|
||||||
|
primary: Connection,
|
||||||
|
backup: Option<Connection>,
|
||||||
|
latency_stats: LatencyStats,
|
||||||
|
pub fn send_order(&self, order: &Order) -> Result<String, Error>;
|
||||||
|
pub fn cancel_order(&self, order_id: &str) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 15. **Performance Profiling**
|
||||||
|
No built-in performance monitoring.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod profiling {
|
||||||
|
pub struct PerformanceProfiler {
|
||||||
|
metrics: DashMap<String, PerformanceMetric>,
|
||||||
|
|
||||||
|
pub fn record_latency(&self, operation: &str, duration: Duration);
|
||||||
|
pub fn record_throughput(&self, operation: &str, count: u64);
|
||||||
|
pub fn get_report(&self) -> PerformanceReport;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Priority Recommendations
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
1. **Order Management System** - Critical for proper order lifecycle tracking
|
||||||
|
2. **Portfolio Analytics** - Essential for multi-asset strategies
|
||||||
|
3. **Execution Algorithms** - TWAP/VWAP for better execution
|
||||||
|
4. **Advanced Order Types** - Bracket orders, trailing stops
|
||||||
|
5. **Backtesting Enhancements** - Proper slippage and impact modeling
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
1. **Options Support** - If trading options
|
||||||
|
2. **ML Integration** - Feature generation framework
|
||||||
|
3. **Monitoring & Alerts** - Real-time system health
|
||||||
|
4. **Data Persistence** - Proper storage layer
|
||||||
|
5. **More Indicators** - Based on strategy needs
|
||||||
|
|
||||||
|
### Low Priority
|
||||||
|
1. **Compliance Engine** - Unless regulatory requirements
|
||||||
|
2. **Multi-venue Support** - Unless using multiple brokers
|
||||||
|
3. **Advanced Market Data** - Level 2, imbalance data
|
||||||
|
|
||||||
|
## Implementation Approach
|
||||||
|
|
||||||
|
1. **Modular Design**: Each component should be optional and pluggable
|
||||||
|
2. **Trait-Based**: Continue using traits for extensibility
|
||||||
|
3. **Performance First**: Maintain the current performance focus
|
||||||
|
4. **Backward Compatible**: Don't break existing APIs
|
||||||
|
5. **Incremental**: Add features based on actual needs
|
||||||
|
|
||||||
|
The core is solid, but these additions would make it a comprehensive institutional-grade trading system!
|
||||||
212
apps/stock/orchestrator/docs/technical-indicators.md
Normal file
212
apps/stock/orchestrator/docs/technical-indicators.md
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
# Technical Analysis Library Documentation
|
||||||
|
|
||||||
|
The stock-bot orchestrator includes a high-performance Technical Analysis (TA) library implemented in Rust with TypeScript bindings. This provides efficient calculation of common technical indicators for trading strategies.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The TA library consists of:
|
||||||
|
1. **Rust Core**: High-performance indicator calculations in `apps/stock/core/src/indicators/`
|
||||||
|
2. **NAPI Bindings**: TypeScript interfaces exposed through `@stock-bot/core`
|
||||||
|
3. **TypeScript Wrapper**: Convenient API in `orchestrator/src/indicators/TechnicalAnalysis.ts`
|
||||||
|
|
||||||
|
## Available Indicators
|
||||||
|
|
||||||
|
### Simple Moving Average (SMA)
|
||||||
|
```typescript
|
||||||
|
const sma = ta.sma(prices, period);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exponential Moving Average (EMA)
|
||||||
|
```typescript
|
||||||
|
const ema = ta.ema(prices, period);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Relative Strength Index (RSI)
|
||||||
|
```typescript
|
||||||
|
const rsi = ta.rsi(prices, period); // Returns values 0-100
|
||||||
|
```
|
||||||
|
|
||||||
|
### MACD (Moving Average Convergence Divergence)
|
||||||
|
```typescript
|
||||||
|
const macd = ta.macd(prices, fastPeriod, slowPeriod, signalPeriod);
|
||||||
|
// Returns: { macd: number[], signal: number[], histogram: number[] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bollinger Bands
|
||||||
|
```typescript
|
||||||
|
const bb = ta.bollingerBands(prices, period, stdDev);
|
||||||
|
// Returns: { upper: number[], middle: number[], lower: number[] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stochastic Oscillator
|
||||||
|
```typescript
|
||||||
|
const stoch = ta.stochastic(high, low, close, kPeriod, dPeriod, smoothK);
|
||||||
|
// Returns: { k: number[], d: number[] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Average True Range (ATR)
|
||||||
|
```typescript
|
||||||
|
const atr = ta.atr(high, low, close, period);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic Indicator Calculation
|
||||||
|
```typescript
|
||||||
|
import { TechnicalAnalysis } from '../src/indicators/TechnicalAnalysis';
|
||||||
|
|
||||||
|
const ta = new TechnicalAnalysis();
|
||||||
|
const prices = [100, 102, 101, 103, 105, 104, 106];
|
||||||
|
|
||||||
|
// Calculate 5-period SMA
|
||||||
|
const sma5 = ta.sma(prices, 5);
|
||||||
|
console.log('SMA:', sma5);
|
||||||
|
|
||||||
|
// Get latest value
|
||||||
|
const latestSMA = TechnicalAnalysis.latest(sma5);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Incremental Indicators for Streaming Data
|
||||||
|
```typescript
|
||||||
|
import { IncrementalIndicators } from '../src/indicators/TechnicalAnalysis';
|
||||||
|
|
||||||
|
const indicators = new IncrementalIndicators();
|
||||||
|
|
||||||
|
// Create indicators
|
||||||
|
indicators.createSMA('fast', 10);
|
||||||
|
indicators.createSMA('slow', 20);
|
||||||
|
indicators.createRSI('rsi', 14);
|
||||||
|
|
||||||
|
// Update with new price
|
||||||
|
const newPrice = 105.50;
|
||||||
|
const fastSMA = indicators.update('fast', newPrice);
|
||||||
|
const slowSMA = indicators.update('slow', newPrice);
|
||||||
|
const rsi = indicators.update('rsi', newPrice);
|
||||||
|
|
||||||
|
// Get current values
|
||||||
|
const currentRSI = indicators.current('rsi');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signal Generation
|
||||||
|
```typescript
|
||||||
|
import { SignalGenerator } from '../src/indicators/TechnicalAnalysis';
|
||||||
|
|
||||||
|
const generator = new SignalGenerator();
|
||||||
|
const signal = generator.generateSignals(
|
||||||
|
'AAPL',
|
||||||
|
{
|
||||||
|
close: closePrices,
|
||||||
|
high: highPrices,
|
||||||
|
low: lowPrices,
|
||||||
|
volume: volumes
|
||||||
|
},
|
||||||
|
Date.now()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (signal.action === 'BUY' && signal.strength > 0.7) {
|
||||||
|
// Strong buy signal
|
||||||
|
console.log(`Buy signal: ${signal.reason}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Crossover Detection
|
||||||
|
```typescript
|
||||||
|
// Detect when fast MA crosses above slow MA
|
||||||
|
if (TechnicalAnalysis.crossover(fastMA, slowMA)) {
|
||||||
|
console.log('Bullish crossover detected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect when fast MA crosses below slow MA
|
||||||
|
if (TechnicalAnalysis.crossunder(fastMA, slowMA)) {
|
||||||
|
console.log('Bearish crossover detected');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Strategy Integration
|
||||||
|
|
||||||
|
Example strategy using multiple indicators:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BaseStrategy } from '../BaseStrategy';
|
||||||
|
import { TechnicalAnalysis } from '../../indicators/TechnicalAnalysis';
|
||||||
|
|
||||||
|
export class MultiIndicatorStrategy extends BaseStrategy {
|
||||||
|
private ta = new TechnicalAnalysis();
|
||||||
|
private priceHistory: number[] = [];
|
||||||
|
|
||||||
|
onMarketData(data: any): Order | null {
|
||||||
|
this.priceHistory.push(data.close);
|
||||||
|
|
||||||
|
if (this.priceHistory.length < 50) return null;
|
||||||
|
|
||||||
|
// Calculate indicators
|
||||||
|
const rsi = this.ta.rsi(this.priceHistory, 14);
|
||||||
|
const macd = this.ta.macd(this.priceHistory);
|
||||||
|
const bb = this.ta.bollingerBands(this.priceHistory);
|
||||||
|
|
||||||
|
// Get latest values
|
||||||
|
const currentRSI = TechnicalAnalysis.latest(rsi);
|
||||||
|
const currentPrice = data.close;
|
||||||
|
const bbLower = TechnicalAnalysis.latest(bb.lower);
|
||||||
|
|
||||||
|
// Generate signals
|
||||||
|
if (currentRSI < 30 && currentPrice < bbLower) {
|
||||||
|
// Oversold + price below lower band = BUY
|
||||||
|
return this.createOrder('market', 'buy', this.positionSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
1. **Batch vs Incremental**: Use batch calculations for backtesting, incremental for live trading
|
||||||
|
2. **Memory Management**: The Rust implementation uses efficient rolling windows
|
||||||
|
3. **Thread Safety**: All Rust indicators are thread-safe
|
||||||
|
4. **Error Handling**: Invalid parameters return errors rather than panicking
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run the indicator tests:
|
||||||
|
```bash
|
||||||
|
bun run test:indicators
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the usage examples:
|
||||||
|
```bash
|
||||||
|
bun run example:indicators
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extending the Library
|
||||||
|
|
||||||
|
To add a new indicator:
|
||||||
|
|
||||||
|
1. Create Rust implementation in `apps/stock/core/src/indicators/[indicator_name].rs`
|
||||||
|
2. Implement `Indicator` and optionally `IncrementalIndicator` traits
|
||||||
|
3. Add NAPI bindings in `apps/stock/core/src/api/indicators.rs`
|
||||||
|
4. Update TypeScript definitions in `apps/stock/core/index.d.ts`
|
||||||
|
5. Add wrapper methods in `orchestrator/src/indicators/TechnicalAnalysis.ts`
|
||||||
|
6. Write tests and examples
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Momentum Indicators
|
||||||
|
- RSI < 30: Oversold
|
||||||
|
- RSI > 70: Overbought
|
||||||
|
- MACD crossover: Trend change
|
||||||
|
|
||||||
|
### Volatility Indicators
|
||||||
|
- Bollinger Band squeeze: Low volatility
|
||||||
|
- ATR increase: Higher volatility
|
||||||
|
|
||||||
|
### Trend Indicators
|
||||||
|
- Price > SMA200: Long-term uptrend
|
||||||
|
- EMA crossovers: Short-term trend changes
|
||||||
|
|
||||||
|
### Combined Signals
|
||||||
|
Best results often come from combining multiple indicators:
|
||||||
|
- RSI oversold + MACD bullish crossover
|
||||||
|
- Price at Bollinger lower band + Stochastic oversold
|
||||||
|
- Volume confirmation with price indicators
|
||||||
218
apps/stock/orchestrator/examples/indicator-usage.ts
Normal file
218
apps/stock/orchestrator/examples/indicator-usage.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
/**
|
||||||
|
* Examples of using the Rust-based Technical Analysis library
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TechnicalIndicators, IncrementalSMA, IncrementalEMA, IncrementalRSI } from '@stock-bot/core';
|
||||||
|
import { TechnicalAnalysis, IncrementalIndicators, SignalGenerator } from '../src/indicators/TechnicalAnalysis';
|
||||||
|
|
||||||
|
// Example 1: Basic indicator calculations
|
||||||
|
async function basicIndicatorExample() {
|
||||||
|
console.log('=== Basic Indicator Example ===');
|
||||||
|
|
||||||
|
const ta = new TechnicalAnalysis();
|
||||||
|
|
||||||
|
// Sample price data
|
||||||
|
const prices = [
|
||||||
|
100, 102, 101, 103, 105, 104, 106, 108, 107, 109,
|
||||||
|
111, 110, 112, 114, 113, 115, 117, 116, 118, 120
|
||||||
|
];
|
||||||
|
|
||||||
|
// Calculate various indicators
|
||||||
|
const sma10 = ta.sma(prices, 10);
|
||||||
|
const ema10 = ta.ema(prices, 10);
|
||||||
|
const rsi14 = ta.rsi(prices, 14);
|
||||||
|
|
||||||
|
console.log(`SMA(10): ${sma10.map(v => v.toFixed(2)).join(', ')}`);
|
||||||
|
console.log(`EMA(10): ${ema10.map(v => v.toFixed(2)).join(', ')}`);
|
||||||
|
console.log(`RSI(14): ${rsi14.map(v => v.toFixed(2)).join(', ')}`);
|
||||||
|
|
||||||
|
// Latest values
|
||||||
|
console.log(`\nLatest SMA: ${TechnicalAnalysis.latest(sma10)?.toFixed(2)}`);
|
||||||
|
console.log(`Latest EMA: ${TechnicalAnalysis.latest(ema10)?.toFixed(2)}`);
|
||||||
|
console.log(`Latest RSI: ${TechnicalAnalysis.latest(rsi14)?.toFixed(2)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example 2: Real-time streaming indicators
|
||||||
|
async function streamingIndicatorExample() {
|
||||||
|
console.log('\n=== Streaming Indicator Example ===');
|
||||||
|
|
||||||
|
const manager = new IncrementalIndicators();
|
||||||
|
|
||||||
|
// Create indicators
|
||||||
|
manager.createSMA('sma_fast', 5);
|
||||||
|
manager.createSMA('sma_slow', 10);
|
||||||
|
manager.createEMA('ema', 10);
|
||||||
|
manager.createRSI('rsi', 14);
|
||||||
|
|
||||||
|
// Simulate real-time price updates
|
||||||
|
console.log('Processing real-time price updates...');
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const price = 100 + Math.sin(i * 0.3) * 5 + Math.random() * 2;
|
||||||
|
|
||||||
|
const smaFast = manager.update('sma_fast', price);
|
||||||
|
const smaSlow = manager.update('sma_slow', price);
|
||||||
|
const ema = manager.update('ema', price);
|
||||||
|
const rsi = manager.update('rsi', price);
|
||||||
|
|
||||||
|
if (i >= 14) { // Once we have enough data
|
||||||
|
console.log(`Price: ${price.toFixed(2)} | SMA5: ${smaFast?.toFixed(2)} | SMA10: ${smaSlow?.toFixed(2)} | EMA: ${ema?.toFixed(2)} | RSI: ${rsi?.toFixed(2)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example 3: Complex indicators (MACD, Bollinger Bands, Stochastic)
|
||||||
|
async function complexIndicatorExample() {
|
||||||
|
console.log('\n=== Complex Indicator Example ===');
|
||||||
|
|
||||||
|
const ta = new TechnicalAnalysis();
|
||||||
|
|
||||||
|
// Generate more realistic price data
|
||||||
|
const generatePrices = (count: number) => {
|
||||||
|
const prices = { close: [], high: [], low: [], volume: [] } as any;
|
||||||
|
let basePrice = 100;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const change = (Math.random() - 0.5) * 2;
|
||||||
|
basePrice += change;
|
||||||
|
const high = basePrice + Math.random() * 1;
|
||||||
|
const low = basePrice - Math.random() * 1;
|
||||||
|
const close = low + Math.random() * (high - low);
|
||||||
|
|
||||||
|
prices.close.push(close);
|
||||||
|
prices.high.push(high);
|
||||||
|
prices.low.push(low);
|
||||||
|
prices.volume.push(Math.random() * 1000000 + 500000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return prices;
|
||||||
|
};
|
||||||
|
|
||||||
|
const prices = generatePrices(50);
|
||||||
|
|
||||||
|
// Calculate MACD
|
||||||
|
const macd = ta.macd(prices.close);
|
||||||
|
console.log(`MACD Line: ${TechnicalAnalysis.latest(macd.macd)?.toFixed(3)}`);
|
||||||
|
console.log(`Signal Line: ${TechnicalAnalysis.latest(macd.signal)?.toFixed(3)}`);
|
||||||
|
console.log(`Histogram: ${TechnicalAnalysis.latest(macd.histogram)?.toFixed(3)}`);
|
||||||
|
|
||||||
|
// Calculate Bollinger Bands
|
||||||
|
const bb = ta.bollingerBands(prices.close, 20, 2);
|
||||||
|
const currentPrice = prices.close[prices.close.length - 1];
|
||||||
|
const bbPercent = (currentPrice - TechnicalAnalysis.latest(bb.lower)!) /
|
||||||
|
(TechnicalAnalysis.latest(bb.upper)! - TechnicalAnalysis.latest(bb.lower)!);
|
||||||
|
|
||||||
|
console.log(`\nBollinger Bands:`);
|
||||||
|
console.log(`Upper: ${TechnicalAnalysis.latest(bb.upper)?.toFixed(2)}`);
|
||||||
|
console.log(`Middle: ${TechnicalAnalysis.latest(bb.middle)?.toFixed(2)}`);
|
||||||
|
console.log(`Lower: ${TechnicalAnalysis.latest(bb.lower)?.toFixed(2)}`);
|
||||||
|
console.log(`%B: ${(bbPercent * 100).toFixed(2)}%`);
|
||||||
|
|
||||||
|
// Calculate Stochastic
|
||||||
|
const stoch = ta.stochastic(prices.high, prices.low, prices.close, 14, 3, 3);
|
||||||
|
console.log(`\nStochastic:`);
|
||||||
|
console.log(`%K: ${TechnicalAnalysis.latest(stoch.k)?.toFixed(2)}`);
|
||||||
|
console.log(`%D: ${TechnicalAnalysis.latest(stoch.d)?.toFixed(2)}`);
|
||||||
|
|
||||||
|
// Calculate ATR
|
||||||
|
const atr = ta.atr(prices.high, prices.low, prices.close, 14);
|
||||||
|
console.log(`\nATR(14): ${TechnicalAnalysis.latest(atr)?.toFixed(3)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example 4: Trading signal generation
|
||||||
|
async function signalGenerationExample() {
|
||||||
|
console.log('\n=== Signal Generation Example ===');
|
||||||
|
|
||||||
|
const generator = new SignalGenerator();
|
||||||
|
|
||||||
|
// Generate trending market data
|
||||||
|
const generateTrendingPrices = (count: number, trend: 'up' | 'down' | 'sideways') => {
|
||||||
|
const prices = { close: [], high: [], low: [], volume: [] } as any;
|
||||||
|
let basePrice = 100;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const trendComponent = trend === 'up' ? 0.1 : trend === 'down' ? -0.1 : 0;
|
||||||
|
const noise = (Math.random() - 0.5) * 2;
|
||||||
|
basePrice += trendComponent + noise;
|
||||||
|
|
||||||
|
const high = basePrice + Math.random() * 1;
|
||||||
|
const low = basePrice - Math.random() * 1;
|
||||||
|
const close = low + Math.random() * (high - low);
|
||||||
|
|
||||||
|
prices.close.push(close);
|
||||||
|
prices.high.push(high);
|
||||||
|
prices.low.push(low);
|
||||||
|
prices.volume.push(Math.random() * 1000000 + 500000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return prices;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test different market conditions
|
||||||
|
const scenarios = [
|
||||||
|
{ name: 'Uptrend', data: generateTrendingPrices(50, 'up') },
|
||||||
|
{ name: 'Downtrend', data: generateTrendingPrices(50, 'down') },
|
||||||
|
{ name: 'Sideways', data: generateTrendingPrices(50, 'sideways') }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const scenario of scenarios) {
|
||||||
|
const signal = generator.generateSignals('TEST', scenario.data, Date.now());
|
||||||
|
console.log(`\n${scenario.name} Market:`);
|
||||||
|
console.log(`Signal: ${signal.action} (strength: ${signal.strength.toFixed(2)})`);
|
||||||
|
console.log(`Reason: ${signal.reason}`);
|
||||||
|
console.log(`Indicators: RSI=${signal.indicators.rsi?.toFixed(2)}, MACD=${signal.indicators.macd?.toFixed(3)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example 5: Crossover detection
|
||||||
|
async function crossoverExample() {
|
||||||
|
console.log('\n=== Crossover Detection Example ===');
|
||||||
|
|
||||||
|
const ta = new TechnicalAnalysis();
|
||||||
|
|
||||||
|
// Generate price data with clear trend changes
|
||||||
|
const prices: number[] = [];
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
if (i < 30) {
|
||||||
|
prices.push(100 + i * 0.3); // Uptrend
|
||||||
|
} else if (i < 60) {
|
||||||
|
prices.push(109 - (i - 30) * 0.3); // Downtrend
|
||||||
|
} else {
|
||||||
|
prices.push(100 + (i - 60) * 0.2); // Uptrend again
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate moving averages
|
||||||
|
const fastMA = ta.sma(prices, 10);
|
||||||
|
const slowMA = ta.sma(prices, 20);
|
||||||
|
|
||||||
|
// Detect crossovers
|
||||||
|
console.log('Checking for crossovers in the last 10 bars:');
|
||||||
|
for (let i = Math.max(0, fastMA.length - 10); i < fastMA.length; i++) {
|
||||||
|
const fast = fastMA.slice(0, i + 1);
|
||||||
|
const slow = slowMA.slice(0, i + 1);
|
||||||
|
|
||||||
|
if (TechnicalAnalysis.crossover(fast, slow)) {
|
||||||
|
console.log(`Bullish crossover at index ${i + 20}`);
|
||||||
|
} else if (TechnicalAnalysis.crossunder(fast, slow)) {
|
||||||
|
console.log(`Bearish crossover at index ${i + 20}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run all examples
|
||||||
|
async function runExamples() {
|
||||||
|
try {
|
||||||
|
await basicIndicatorExample();
|
||||||
|
await streamingIndicatorExample();
|
||||||
|
await complexIndicatorExample();
|
||||||
|
await signalGenerationExample();
|
||||||
|
await crossoverExample();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error running examples:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute if running directly
|
||||||
|
if (require.main === module) {
|
||||||
|
runExamples();
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
"build": "bun build src/index.ts --outdir dist --target node",
|
"build": "bun build src/index.ts --outdir dist --target node",
|
||||||
"start": "bun dist/index.js",
|
"start": "bun dist/index.js",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
|
"test:indicators": "bun test tests/indicators.test.ts",
|
||||||
|
"example:indicators": "bun run examples/indicator-usage.ts",
|
||||||
"build:rust": "cd ../core && cargo build --release && napi build --platform --release"
|
"build:rust": "cd ../core && cargo build --release && napi build --platform --release"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
305
apps/stock/orchestrator/src/indicators/TechnicalAnalysis.ts
Normal file
305
apps/stock/orchestrator/src/indicators/TechnicalAnalysis.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
import { TechnicalIndicators, IncrementalSMA, IncrementalEMA, IncrementalRSI, MacdResult, BollingerBandsResult, StochasticResult } from '@stock-bot/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper class for the Rust TA library with TypeScript-friendly interfaces
|
||||||
|
*/
|
||||||
|
export class TechnicalAnalysis {
|
||||||
|
private indicators: TechnicalIndicators;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.indicators = new TechnicalIndicators();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple indicators
|
||||||
|
sma(values: number[], period: number): number[] {
|
||||||
|
return this.indicators.calculateSma(values, period);
|
||||||
|
}
|
||||||
|
|
||||||
|
ema(values: number[], period: number): number[] {
|
||||||
|
return this.indicators.calculateEma(values, period);
|
||||||
|
}
|
||||||
|
|
||||||
|
rsi(values: number[], period: number): number[] {
|
||||||
|
return this.indicators.calculateRsi(values, period);
|
||||||
|
}
|
||||||
|
|
||||||
|
atr(high: number[], low: number[], close: number[], period: number): number[] {
|
||||||
|
return this.indicators.calculateAtr(high, low, close, period);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complex indicators with parsed results
|
||||||
|
macd(values: number[], fastPeriod = 12, slowPeriod = 26, signalPeriod = 9): MacdResult {
|
||||||
|
const result = this.indicators.calculateMacd(values, fastPeriod, slowPeriod, signalPeriod);
|
||||||
|
return JSON.parse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
bollingerBands(values: number[], period = 20, stdDev = 2): BollingerBandsResult {
|
||||||
|
const result = this.indicators.calculateBollingerBands(values, period, stdDev);
|
||||||
|
return JSON.parse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
stochastic(
|
||||||
|
high: number[],
|
||||||
|
low: number[],
|
||||||
|
close: number[],
|
||||||
|
kPeriod = 14,
|
||||||
|
dPeriod = 3,
|
||||||
|
smoothK = 1
|
||||||
|
): StochasticResult {
|
||||||
|
const result = this.indicators.calculateStochastic(high, low, close, kPeriod, dPeriod, smoothK);
|
||||||
|
return JSON.parse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get the latest value from an indicator array
|
||||||
|
static latest(values: number[]): number | undefined {
|
||||||
|
return values[values.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to check for crossovers
|
||||||
|
static crossover(series1: number[], series2: number[]): boolean {
|
||||||
|
if (series1.length < 2 || series2.length < 2) return false;
|
||||||
|
const prev1 = series1[series1.length - 2];
|
||||||
|
const curr1 = series1[series1.length - 1];
|
||||||
|
const prev2 = series2[series2.length - 2];
|
||||||
|
const curr2 = series2[series2.length - 1];
|
||||||
|
return prev1 <= prev2 && curr1 > curr2;
|
||||||
|
}
|
||||||
|
|
||||||
|
static crossunder(series1: number[], series2: number[]): boolean {
|
||||||
|
if (series1.length < 2 || series2.length < 2) return false;
|
||||||
|
const prev1 = series1[series1.length - 2];
|
||||||
|
const curr1 = series1[series1.length - 1];
|
||||||
|
const prev2 = series2[series2.length - 2];
|
||||||
|
const curr2 = series2[series2.length - 1];
|
||||||
|
return prev1 >= prev2 && curr1 < curr2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incremental indicator manager for streaming data
|
||||||
|
*/
|
||||||
|
export class IncrementalIndicators {
|
||||||
|
private indicators: Map<string, IncrementalSMA | IncrementalEMA | IncrementalRSI> = new Map();
|
||||||
|
|
||||||
|
createSMA(key: string, period: number): IncrementalSMA {
|
||||||
|
const indicator = new IncrementalSMA(period);
|
||||||
|
this.indicators.set(key, indicator);
|
||||||
|
return indicator;
|
||||||
|
}
|
||||||
|
|
||||||
|
createEMA(key: string, period: number): IncrementalEMA {
|
||||||
|
const indicator = new IncrementalEMA(period);
|
||||||
|
this.indicators.set(key, indicator);
|
||||||
|
return indicator;
|
||||||
|
}
|
||||||
|
|
||||||
|
createRSI(key: string, period: number): IncrementalRSI {
|
||||||
|
const indicator = new IncrementalRSI(period);
|
||||||
|
this.indicators.set(key, indicator);
|
||||||
|
return indicator;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string): IncrementalSMA | IncrementalEMA | IncrementalRSI | undefined {
|
||||||
|
return this.indicators.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(key: string, value: number): number | null {
|
||||||
|
const indicator = this.indicators.get(key);
|
||||||
|
if (!indicator) {
|
||||||
|
throw new Error(`Indicator ${key} not found`);
|
||||||
|
}
|
||||||
|
return indicator.update(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
current(key: string): number | null {
|
||||||
|
const indicator = this.indicators.get(key);
|
||||||
|
if (!indicator) {
|
||||||
|
throw new Error(`Indicator ${key} not found`);
|
||||||
|
}
|
||||||
|
return indicator.current();
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(key: string): void {
|
||||||
|
const indicator = this.indicators.get(key);
|
||||||
|
if (indicator && 'reset' in indicator) {
|
||||||
|
indicator.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAll(): void {
|
||||||
|
this.indicators.forEach(indicator => {
|
||||||
|
if ('reset' in indicator) {
|
||||||
|
indicator.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signal generator using technical indicators
|
||||||
|
*/
|
||||||
|
export interface TradingSignal {
|
||||||
|
symbol: string;
|
||||||
|
timestamp: number;
|
||||||
|
action: 'BUY' | 'SELL' | 'HOLD';
|
||||||
|
strength: number; // 0-1
|
||||||
|
indicators: Record<string, number>;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SignalGenerator {
|
||||||
|
private ta: TechnicalAnalysis;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.ta = new TechnicalAnalysis();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate signals based on multiple indicators
|
||||||
|
*/
|
||||||
|
generateSignals(
|
||||||
|
symbol: string,
|
||||||
|
prices: {
|
||||||
|
close: number[];
|
||||||
|
high: number[];
|
||||||
|
low: number[];
|
||||||
|
volume: number[];
|
||||||
|
},
|
||||||
|
timestamp: number
|
||||||
|
): TradingSignal {
|
||||||
|
const indicators: Record<string, number> = {};
|
||||||
|
let buySignals = 0;
|
||||||
|
let sellSignals = 0;
|
||||||
|
let totalWeight = 0;
|
||||||
|
const reasons: string[] = [];
|
||||||
|
|
||||||
|
// RSI signals
|
||||||
|
if (prices.close.length >= 14) {
|
||||||
|
const rsi = this.ta.rsi(prices.close, 14);
|
||||||
|
const currentRsi = TechnicalAnalysis.latest(rsi);
|
||||||
|
if (currentRsi !== undefined) {
|
||||||
|
indicators.rsi = currentRsi;
|
||||||
|
if (currentRsi < 30) {
|
||||||
|
buySignals += 2;
|
||||||
|
totalWeight += 2;
|
||||||
|
reasons.push('RSI oversold');
|
||||||
|
} else if (currentRsi > 70) {
|
||||||
|
sellSignals += 2;
|
||||||
|
totalWeight += 2;
|
||||||
|
reasons.push('RSI overbought');
|
||||||
|
} else {
|
||||||
|
totalWeight += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MACD signals
|
||||||
|
if (prices.close.length >= 26) {
|
||||||
|
const macd = this.ta.macd(prices.close);
|
||||||
|
const currentMacd = TechnicalAnalysis.latest(macd.macd);
|
||||||
|
const currentSignal = TechnicalAnalysis.latest(macd.signal);
|
||||||
|
const currentHistogram = TechnicalAnalysis.latest(macd.histogram);
|
||||||
|
|
||||||
|
if (currentMacd !== undefined && currentSignal !== undefined) {
|
||||||
|
indicators.macd = currentMacd;
|
||||||
|
indicators.macdSignal = currentSignal;
|
||||||
|
indicators.macdHistogram = currentHistogram || 0;
|
||||||
|
|
||||||
|
if (TechnicalAnalysis.crossover(macd.macd, macd.signal)) {
|
||||||
|
buySignals += 3;
|
||||||
|
totalWeight += 3;
|
||||||
|
reasons.push('MACD bullish crossover');
|
||||||
|
} else if (TechnicalAnalysis.crossunder(macd.macd, macd.signal)) {
|
||||||
|
sellSignals += 3;
|
||||||
|
totalWeight += 3;
|
||||||
|
reasons.push('MACD bearish crossover');
|
||||||
|
} else {
|
||||||
|
totalWeight += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bollinger Bands signals
|
||||||
|
if (prices.close.length >= 20) {
|
||||||
|
const bb = this.ta.bollingerBands(prices.close, 20, 2);
|
||||||
|
const currentPrice = prices.close[prices.close.length - 1];
|
||||||
|
const currentUpper = TechnicalAnalysis.latest(bb.upper);
|
||||||
|
const currentLower = TechnicalAnalysis.latest(bb.lower);
|
||||||
|
const currentMiddle = TechnicalAnalysis.latest(bb.middle);
|
||||||
|
|
||||||
|
if (currentUpper && currentLower && currentMiddle) {
|
||||||
|
indicators.bbUpper = currentUpper;
|
||||||
|
indicators.bbLower = currentLower;
|
||||||
|
indicators.bbMiddle = currentMiddle;
|
||||||
|
|
||||||
|
const bbPercent = (currentPrice - currentLower) / (currentUpper - currentLower);
|
||||||
|
indicators.bbPercent = bbPercent;
|
||||||
|
|
||||||
|
if (bbPercent < 0.2) {
|
||||||
|
buySignals += 2;
|
||||||
|
totalWeight += 2;
|
||||||
|
reasons.push('Near lower Bollinger Band');
|
||||||
|
} else if (bbPercent > 0.8) {
|
||||||
|
sellSignals += 2;
|
||||||
|
totalWeight += 2;
|
||||||
|
reasons.push('Near upper Bollinger Band');
|
||||||
|
} else {
|
||||||
|
totalWeight += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stochastic signals
|
||||||
|
if (prices.high.length >= 14 && prices.low.length >= 14 && prices.close.length >= 14) {
|
||||||
|
const stoch = this.ta.stochastic(prices.high, prices.low, prices.close, 14, 3, 3);
|
||||||
|
const currentK = TechnicalAnalysis.latest(stoch.k);
|
||||||
|
const currentD = TechnicalAnalysis.latest(stoch.d);
|
||||||
|
|
||||||
|
if (currentK !== undefined && currentD !== undefined) {
|
||||||
|
indicators.stochK = currentK;
|
||||||
|
indicators.stochD = currentD;
|
||||||
|
|
||||||
|
if (currentK < 20 && currentD < 20) {
|
||||||
|
buySignals += 1;
|
||||||
|
totalWeight += 1;
|
||||||
|
reasons.push('Stochastic oversold');
|
||||||
|
} else if (currentK > 80 && currentD > 80) {
|
||||||
|
sellSignals += 1;
|
||||||
|
totalWeight += 1;
|
||||||
|
reasons.push('Stochastic overbought');
|
||||||
|
} else {
|
||||||
|
totalWeight += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine overall signal
|
||||||
|
let action: 'BUY' | 'SELL' | 'HOLD' = 'HOLD';
|
||||||
|
let strength = 0;
|
||||||
|
|
||||||
|
if (totalWeight > 0) {
|
||||||
|
const buyStrength = buySignals / totalWeight;
|
||||||
|
const sellStrength = sellSignals / totalWeight;
|
||||||
|
|
||||||
|
if (buyStrength > 0.5) {
|
||||||
|
action = 'BUY';
|
||||||
|
strength = buyStrength;
|
||||||
|
} else if (sellStrength > 0.5) {
|
||||||
|
action = 'SELL';
|
||||||
|
strength = sellStrength;
|
||||||
|
} else {
|
||||||
|
action = 'HOLD';
|
||||||
|
strength = Math.max(buyStrength, sellStrength);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
symbol,
|
||||||
|
timestamp,
|
||||||
|
action,
|
||||||
|
strength,
|
||||||
|
indicators,
|
||||||
|
reason: reasons.join('; ') || 'No clear signal'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,551 @@
|
||||||
|
import { BaseStrategy, Signal } from '../BaseStrategy';
|
||||||
|
import { MarketData } from '../../types';
|
||||||
|
import { IndicatorManager } from '../indicators/IndicatorManager';
|
||||||
|
import { PositionManager } from '../position/PositionManager';
|
||||||
|
import { RiskManager } from '../risk/RiskManager';
|
||||||
|
import { SignalManager, CommonRules, CommonFilters } from '../signals/SignalManager';
|
||||||
|
import { getLogger } from '@stock-bot/logger';
|
||||||
|
|
||||||
|
const logger = getLogger('AdvancedMultiIndicatorStrategy');
|
||||||
|
|
||||||
|
export interface AdvancedStrategyConfig {
|
||||||
|
// Indicator settings
|
||||||
|
fastMA?: number;
|
||||||
|
slowMA?: number;
|
||||||
|
rsiPeriod?: number;
|
||||||
|
atrPeriod?: number;
|
||||||
|
|
||||||
|
// Risk settings
|
||||||
|
riskPerTrade?: number;
|
||||||
|
maxPositions?: number;
|
||||||
|
maxDrawdown?: number;
|
||||||
|
useATRStops?: boolean;
|
||||||
|
atrMultiplier?: number;
|
||||||
|
|
||||||
|
// Signal settings
|
||||||
|
signalAggregation?: 'weighted' | 'majority' | 'unanimous' | 'threshold';
|
||||||
|
minSignalStrength?: number;
|
||||||
|
minSignalConfidence?: number;
|
||||||
|
|
||||||
|
// Position sizing
|
||||||
|
positionSizing?: 'fixed' | 'risk' | 'kelly' | 'volatility';
|
||||||
|
|
||||||
|
// Other
|
||||||
|
debugMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advanced strategy using multiple indicators, risk management, and signal aggregation
|
||||||
|
*/
|
||||||
|
export class AdvancedMultiIndicatorStrategy extends BaseStrategy {
|
||||||
|
private indicatorManager: IndicatorManager;
|
||||||
|
private positionManager: PositionManager;
|
||||||
|
private riskManager: RiskManager;
|
||||||
|
private signalManager: SignalManager;
|
||||||
|
|
||||||
|
private config: Required<AdvancedStrategyConfig>;
|
||||||
|
private stopLosses: Map<string, number> = new Map();
|
||||||
|
private takeProfits: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
constructor(strategyConfig: any, modeManager?: any, executionService?: any) {
|
||||||
|
super(strategyConfig, modeManager, executionService);
|
||||||
|
|
||||||
|
// Initialize config
|
||||||
|
this.config = {
|
||||||
|
fastMA: 20,
|
||||||
|
slowMA: 50,
|
||||||
|
rsiPeriod: 14,
|
||||||
|
atrPeriod: 14,
|
||||||
|
riskPerTrade: 0.02,
|
||||||
|
maxPositions: 5,
|
||||||
|
maxDrawdown: 0.2,
|
||||||
|
useATRStops: true,
|
||||||
|
atrMultiplier: 2,
|
||||||
|
signalAggregation: 'weighted',
|
||||||
|
minSignalStrength: 0.5,
|
||||||
|
minSignalConfidence: 0.3,
|
||||||
|
positionSizing: 'risk',
|
||||||
|
debugMode: false,
|
||||||
|
...strategyConfig.params
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize managers
|
||||||
|
const initialCapital = strategyConfig.initialCapital || 100000;
|
||||||
|
this.indicatorManager = new IndicatorManager();
|
||||||
|
this.positionManager = new PositionManager(initialCapital);
|
||||||
|
this.riskManager = new RiskManager(initialCapital, {
|
||||||
|
maxPositions: this.config.maxPositions,
|
||||||
|
maxDrawdownPct: this.config.maxDrawdown,
|
||||||
|
maxPositionSizePct: 0.1
|
||||||
|
});
|
||||||
|
|
||||||
|
this.signalManager = new SignalManager({
|
||||||
|
method: this.config.signalAggregation
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup signal rules
|
||||||
|
this.setupSignalRules();
|
||||||
|
|
||||||
|
logger.info('AdvancedMultiIndicatorStrategy initialized:', this.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupSignalRules(): void {
|
||||||
|
// Moving average rules
|
||||||
|
this.signalManager.addRule({
|
||||||
|
name: `MA Crossover (${this.config.fastMA}/${this.config.slowMA})`,
|
||||||
|
condition: (indicators) => {
|
||||||
|
const fast = indicators[`sma${this.config.fastMA}`];
|
||||||
|
const slow = indicators[`sma${this.config.slowMA}`];
|
||||||
|
const prevFast = indicators[`sma${this.config.fastMA}_prev`];
|
||||||
|
const prevSlow = indicators[`sma${this.config.slowMA}_prev`];
|
||||||
|
|
||||||
|
if (!fast || !slow || !prevFast || !prevSlow) return false;
|
||||||
|
|
||||||
|
// Check for crossover
|
||||||
|
const crossover = prevFast <= prevSlow && fast > slow;
|
||||||
|
const crossunder = prevFast >= prevSlow && fast < slow;
|
||||||
|
|
||||||
|
indicators._maCrossDirection = crossover ? 'up' : crossunder ? 'down' : null;
|
||||||
|
return crossover || crossunder;
|
||||||
|
},
|
||||||
|
weight: 1,
|
||||||
|
direction: 'both'
|
||||||
|
});
|
||||||
|
|
||||||
|
// RSI rules
|
||||||
|
this.signalManager.addRules([
|
||||||
|
CommonRules.rsiOversold(30),
|
||||||
|
CommonRules.rsiOverbought(70)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// MACD rules
|
||||||
|
this.signalManager.addRules([
|
||||||
|
CommonRules.macdBullishCross(),
|
||||||
|
CommonRules.macdBearishCross()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Bollinger Band rules
|
||||||
|
this.signalManager.addRules([
|
||||||
|
CommonRules.priceAtLowerBand(),
|
||||||
|
CommonRules.priceAtUpperBand(),
|
||||||
|
CommonRules.bollingerSqueeze()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add filters
|
||||||
|
this.signalManager.addFilter(CommonFilters.minStrength(this.config.minSignalStrength));
|
||||||
|
this.signalManager.addFilter(CommonFilters.minConfidence(this.config.minSignalConfidence));
|
||||||
|
this.signalManager.addFilter(CommonFilters.trendAlignment('sma200'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected updateIndicators(data: MarketData): void {
|
||||||
|
if (data.type !== 'bar') return;
|
||||||
|
|
||||||
|
const { symbol, timestamp, open, high, low, close, volume } = data.data;
|
||||||
|
|
||||||
|
// First time setup for symbol
|
||||||
|
if (!this.indicatorManager.getHistoryLength(symbol)) {
|
||||||
|
this.setupSymbolIndicators(symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update price history
|
||||||
|
this.indicatorManager.updatePrice({
|
||||||
|
symbol,
|
||||||
|
timestamp,
|
||||||
|
open,
|
||||||
|
high,
|
||||||
|
low,
|
||||||
|
close,
|
||||||
|
volume
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update position market values
|
||||||
|
this.updatePositionValues(symbol, close);
|
||||||
|
|
||||||
|
// Check stop losses and take profits
|
||||||
|
this.checkExitConditions(symbol, close);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupSymbolIndicators(symbol: string): void {
|
||||||
|
// Setup incremental indicators
|
||||||
|
this.indicatorManager.setupIncrementalIndicator(symbol, 'fast_sma', {
|
||||||
|
type: 'sma',
|
||||||
|
period: this.config.fastMA
|
||||||
|
});
|
||||||
|
|
||||||
|
this.indicatorManager.setupIncrementalIndicator(symbol, 'slow_sma', {
|
||||||
|
type: 'sma',
|
||||||
|
period: this.config.slowMA
|
||||||
|
});
|
||||||
|
|
||||||
|
this.indicatorManager.setupIncrementalIndicator(symbol, 'rsi', {
|
||||||
|
type: 'rsi',
|
||||||
|
period: this.config.rsiPeriod
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Initialized indicators for ${symbol}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async generateSignal(data: MarketData): Promise<Signal | null> {
|
||||||
|
if (data.type !== 'bar') return null;
|
||||||
|
|
||||||
|
const { symbol, timestamp, close } = data.data;
|
||||||
|
const historyLength = this.indicatorManager.getHistoryLength(symbol);
|
||||||
|
|
||||||
|
// Need enough data
|
||||||
|
if (historyLength < Math.max(this.config.slowMA, 26)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare indicators for signal generation
|
||||||
|
const indicators = this.prepareIndicators(symbol, close);
|
||||||
|
if (!indicators) return null;
|
||||||
|
|
||||||
|
// Check risk before generating signals
|
||||||
|
const currentPositions = this.getCurrentPositionMap();
|
||||||
|
const riskCheck = this.riskManager.checkNewPosition(
|
||||||
|
symbol,
|
||||||
|
100, // Dummy size for check
|
||||||
|
close,
|
||||||
|
currentPositions
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!riskCheck.allowed && !this.positionManager.hasPosition(symbol)) {
|
||||||
|
if (this.config.debugMode) {
|
||||||
|
logger.warn(`Risk check failed for ${symbol}: ${riskCheck.reason}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate trading signal
|
||||||
|
const tradingSignal = this.signalManager.generateSignal(
|
||||||
|
symbol,
|
||||||
|
timestamp,
|
||||||
|
indicators,
|
||||||
|
{ position: this.positionManager.getPositionQuantity(symbol) }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tradingSignal) return null;
|
||||||
|
|
||||||
|
// Log signal if in debug mode
|
||||||
|
if (this.config.debugMode) {
|
||||||
|
logger.info(`Signal generated for ${symbol}:`, {
|
||||||
|
direction: tradingSignal.direction,
|
||||||
|
strength: tradingSignal.strength.toFixed(2),
|
||||||
|
confidence: tradingSignal.confidence.toFixed(2),
|
||||||
|
rules: tradingSignal.rules
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to strategy signal
|
||||||
|
return this.convertToStrategySignal(tradingSignal, symbol, close);
|
||||||
|
}
|
||||||
|
|
||||||
|
private prepareIndicators(symbol: string, currentPrice: number): Record<string, number> | null {
|
||||||
|
const indicators: Record<string, number> = { price: currentPrice };
|
||||||
|
|
||||||
|
// Get moving averages
|
||||||
|
const fastMA = this.indicatorManager.getSMA(symbol, this.config.fastMA);
|
||||||
|
const slowMA = this.indicatorManager.getSMA(symbol, this.config.slowMA);
|
||||||
|
const sma200 = this.indicatorManager.getSMA(symbol, 200);
|
||||||
|
|
||||||
|
if (!fastMA || !slowMA) return null;
|
||||||
|
|
||||||
|
// Current and previous values
|
||||||
|
indicators[`sma${this.config.fastMA}`] = this.indicatorManager.getLatest(fastMA)!;
|
||||||
|
indicators[`sma${this.config.slowMA}`] = this.indicatorManager.getLatest(slowMA)!;
|
||||||
|
|
||||||
|
if (fastMA.length >= 2 && slowMA.length >= 2) {
|
||||||
|
indicators[`sma${this.config.fastMA}_prev`] = fastMA[fastMA.length - 2];
|
||||||
|
indicators[`sma${this.config.slowMA}_prev`] = slowMA[slowMA.length - 2];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sma200) {
|
||||||
|
indicators.sma200 = this.indicatorManager.getLatest(sma200)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RSI
|
||||||
|
const rsi = this.indicatorManager.getRSI(symbol, this.config.rsiPeriod);
|
||||||
|
if (rsi) {
|
||||||
|
indicators.rsi = this.indicatorManager.getLatest(rsi)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MACD
|
||||||
|
const macd = this.indicatorManager.getMACD(symbol);
|
||||||
|
if (macd) {
|
||||||
|
indicators.macd = this.indicatorManager.getLatest(macd.macd)!;
|
||||||
|
indicators.macd_signal = this.indicatorManager.getLatest(macd.signal)!;
|
||||||
|
indicators.macd_histogram = this.indicatorManager.getLatest(macd.histogram)!;
|
||||||
|
|
||||||
|
if (macd.macd.length >= 2) {
|
||||||
|
indicators.macd_prev = macd.macd[macd.macd.length - 2];
|
||||||
|
indicators.macd_signal_prev = macd.signal[macd.signal.length - 2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bollinger Bands
|
||||||
|
const bb = this.indicatorManager.getBollingerBands(symbol);
|
||||||
|
if (bb) {
|
||||||
|
indicators.bb_upper = this.indicatorManager.getLatest(bb.upper)!;
|
||||||
|
indicators.bb_middle = this.indicatorManager.getLatest(bb.middle)!;
|
||||||
|
indicators.bb_lower = this.indicatorManager.getLatest(bb.lower)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ATR for volatility
|
||||||
|
const atr = this.indicatorManager.getATR(symbol, this.config.atrPeriod);
|
||||||
|
if (atr) {
|
||||||
|
indicators.atr = this.indicatorManager.getLatest(atr)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume
|
||||||
|
const priceHistory = this.indicatorManager.getPriceHistory(symbol);
|
||||||
|
if (priceHistory) {
|
||||||
|
indicators.volume = priceHistory.volume[priceHistory.volume.length - 1];
|
||||||
|
const avgVolume = priceHistory.volume.slice(-20).reduce((a, b) => a + b, 0) / 20;
|
||||||
|
indicators.avg_volume = avgVolume;
|
||||||
|
}
|
||||||
|
|
||||||
|
return indicators;
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertToStrategySignal(
|
||||||
|
tradingSignal: TradingSignal,
|
||||||
|
symbol: string,
|
||||||
|
currentPrice: number
|
||||||
|
): Signal | null {
|
||||||
|
const currentPosition = this.positionManager.getPositionQuantity(symbol);
|
||||||
|
|
||||||
|
// Determine action based on signal and current position
|
||||||
|
let type: 'buy' | 'sell' | 'close';
|
||||||
|
let quantity: number;
|
||||||
|
|
||||||
|
if (tradingSignal.direction === 'buy') {
|
||||||
|
if (currentPosition < 0) {
|
||||||
|
// Close short position
|
||||||
|
type = 'buy';
|
||||||
|
quantity = Math.abs(currentPosition);
|
||||||
|
} else if (currentPosition === 0) {
|
||||||
|
// Open long position
|
||||||
|
type = 'buy';
|
||||||
|
quantity = this.calculatePositionSize(symbol, currentPrice, tradingSignal);
|
||||||
|
} else {
|
||||||
|
// Already long
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else if (tradingSignal.direction === 'sell') {
|
||||||
|
if (currentPosition > 0) {
|
||||||
|
// Close long position
|
||||||
|
type = 'sell';
|
||||||
|
quantity = currentPosition;
|
||||||
|
} else if (currentPosition === 0 && false) { // Disable shorting for now
|
||||||
|
// Open short position
|
||||||
|
type = 'sell';
|
||||||
|
quantity = this.calculatePositionSize(symbol, currentPrice, tradingSignal);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
symbol,
|
||||||
|
strength: tradingSignal.strength,
|
||||||
|
reason: tradingSignal.rules.join(', '),
|
||||||
|
metadata: {
|
||||||
|
...tradingSignal.indicators,
|
||||||
|
quantity,
|
||||||
|
confidence: tradingSignal.confidence,
|
||||||
|
rules: tradingSignal.rules
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculatePositionSize(
|
||||||
|
symbol: string,
|
||||||
|
price: number,
|
||||||
|
signal: TradingSignal
|
||||||
|
): number {
|
||||||
|
const accountBalance = this.positionManager.getAccountBalance();
|
||||||
|
|
||||||
|
switch (this.config.positionSizing) {
|
||||||
|
case 'fixed':
|
||||||
|
// Fixed percentage of account
|
||||||
|
const fixedValue = accountBalance * this.config.riskPerTrade * 5;
|
||||||
|
return Math.floor(fixedValue / price);
|
||||||
|
|
||||||
|
case 'risk':
|
||||||
|
// Risk-based sizing with ATR stop
|
||||||
|
const atr = signal.indicators.atr;
|
||||||
|
if (atr && this.config.useATRStops) {
|
||||||
|
const stopDistance = atr * this.config.atrMultiplier;
|
||||||
|
return this.positionManager.calculatePositionSize({
|
||||||
|
accountBalance,
|
||||||
|
riskPerTrade: this.config.riskPerTrade,
|
||||||
|
stopLossDistance: stopDistance
|
||||||
|
}, price);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'kelly':
|
||||||
|
// Kelly criterion based on historical performance
|
||||||
|
const metrics = this.positionManager.getPerformanceMetrics();
|
||||||
|
if (metrics.totalTrades >= 20) {
|
||||||
|
return this.positionManager.calculateKellySize(
|
||||||
|
metrics.winRate / 100,
|
||||||
|
metrics.avgWin,
|
||||||
|
metrics.avgLoss,
|
||||||
|
price
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'volatility':
|
||||||
|
// Volatility-adjusted sizing
|
||||||
|
const atrVol = signal.indicators.atr;
|
||||||
|
if (atrVol) {
|
||||||
|
return this.positionManager.calculatePositionSize({
|
||||||
|
accountBalance,
|
||||||
|
riskPerTrade: this.config.riskPerTrade,
|
||||||
|
volatilityAdjustment: true,
|
||||||
|
atr: atrVol
|
||||||
|
}, price);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default sizing
|
||||||
|
return this.positionManager.calculatePositionSize({
|
||||||
|
accountBalance,
|
||||||
|
riskPerTrade: this.config.riskPerTrade
|
||||||
|
}, price);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePositionValues(symbol: string, currentPrice: number): void {
|
||||||
|
const prices = new Map([[symbol, currentPrice]]);
|
||||||
|
this.positionManager.updateMarketPrices(prices);
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkExitConditions(symbol: string, currentPrice: number): void {
|
||||||
|
const position = this.positionManager.getPosition(symbol);
|
||||||
|
if (!position) return;
|
||||||
|
|
||||||
|
const stopLoss = this.stopLosses.get(symbol);
|
||||||
|
const takeProfit = this.takeProfits.get(symbol);
|
||||||
|
|
||||||
|
// Check stop loss
|
||||||
|
if (stopLoss) {
|
||||||
|
if ((position.quantity > 0 && currentPrice <= stopLoss) ||
|
||||||
|
(position.quantity < 0 && currentPrice >= stopLoss)) {
|
||||||
|
logger.info(`Stop loss triggered for ${symbol} at ${currentPrice}`);
|
||||||
|
this.emit('signal', {
|
||||||
|
type: 'close',
|
||||||
|
symbol,
|
||||||
|
strength: 1,
|
||||||
|
reason: 'Stop loss triggered',
|
||||||
|
metadata: { stopLoss, currentPrice }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check take profit
|
||||||
|
if (takeProfit) {
|
||||||
|
if ((position.quantity > 0 && currentPrice >= takeProfit) ||
|
||||||
|
(position.quantity < 0 && currentPrice <= takeProfit)) {
|
||||||
|
logger.info(`Take profit triggered for ${symbol} at ${currentPrice}`);
|
||||||
|
this.emit('signal', {
|
||||||
|
type: 'close',
|
||||||
|
symbol,
|
||||||
|
strength: 1,
|
||||||
|
reason: 'Take profit triggered',
|
||||||
|
metadata: { takeProfit, currentPrice }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCurrentPositionMap(): Map<string, { quantity: number; value: number }> {
|
||||||
|
const positionMap = new Map();
|
||||||
|
|
||||||
|
for (const position of this.positionManager.getOpenPositions()) {
|
||||||
|
positionMap.set(position.symbol, {
|
||||||
|
quantity: position.quantity,
|
||||||
|
value: Math.abs(position.quantity * (position.currentPrice || position.avgPrice))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return positionMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async onOrderUpdate(update: any): Promise<void> {
|
||||||
|
await super.onOrderUpdate(update);
|
||||||
|
|
||||||
|
if (update.status === 'filled' && update.fills?.length > 0) {
|
||||||
|
for (const fill of update.fills) {
|
||||||
|
const trade = {
|
||||||
|
symbol: update.symbol,
|
||||||
|
side: update.side as 'buy' | 'sell',
|
||||||
|
quantity: fill.quantity,
|
||||||
|
price: fill.price,
|
||||||
|
commission: fill.commission || 0,
|
||||||
|
timestamp: new Date(fill.timestamp)
|
||||||
|
};
|
||||||
|
|
||||||
|
const position = this.positionManager.updatePosition(trade);
|
||||||
|
|
||||||
|
// Update risk manager
|
||||||
|
if (trade.pnl) {
|
||||||
|
this.riskManager.updateAfterTrade(trade.pnl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stop loss and take profit for new positions
|
||||||
|
if (this.config.useATRStops && position.quantity !== 0) {
|
||||||
|
const atr = this.indicatorManager.getATR(update.symbol);
|
||||||
|
if (atr) {
|
||||||
|
const currentATR = this.indicatorManager.getLatest(atr);
|
||||||
|
if (currentATR) {
|
||||||
|
const stopDistance = currentATR * this.config.atrMultiplier;
|
||||||
|
const profitDistance = currentATR * this.config.atrMultiplier * 2;
|
||||||
|
|
||||||
|
if (position.quantity > 0) {
|
||||||
|
this.stopLosses.set(update.symbol, fill.price - stopDistance);
|
||||||
|
this.takeProfits.set(update.symbol, fill.price + profitDistance);
|
||||||
|
} else {
|
||||||
|
this.stopLosses.set(update.symbol, fill.price + stopDistance);
|
||||||
|
this.takeProfits.set(update.symbol, fill.price - profitDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Set stop/take profit for ${update.symbol}: Stop=${this.stopLosses.get(update.symbol)?.toFixed(2)}, TP=${this.takeProfits.get(update.symbol)?.toFixed(2)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear stops if position closed
|
||||||
|
if (position.quantity === 0) {
|
||||||
|
this.stopLosses.delete(update.symbol);
|
||||||
|
this.takeProfits.delete(update.symbol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPerformance(): any {
|
||||||
|
const basePerf = super.getPerformance();
|
||||||
|
const positionMetrics = this.positionManager.getPerformanceMetrics();
|
||||||
|
const riskMetrics = this.riskManager.getMetrics(this.getCurrentPositionMap());
|
||||||
|
const signalStats = this.signalManager.getSignalStats();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...basePerf,
|
||||||
|
...positionMetrics,
|
||||||
|
risk: riskMetrics,
|
||||||
|
signals: signalStats
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily reset for risk metrics
|
||||||
|
onDayEnd(): void {
|
||||||
|
this.riskManager.resetDaily();
|
||||||
|
logger.info('Daily risk metrics reset');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
import { BaseStrategy } from '../BaseStrategy';
|
||||||
|
import { Order } from '../../types';
|
||||||
|
import { TechnicalAnalysis, IncrementalIndicators, SignalGenerator, TradingSignal } from '../../indicators/TechnicalAnalysis';
|
||||||
|
|
||||||
|
interface IndicatorBasedConfig {
|
||||||
|
symbol: string;
|
||||||
|
initialCapital: number;
|
||||||
|
positionSize: number;
|
||||||
|
useRSI?: boolean;
|
||||||
|
useMACD?: boolean;
|
||||||
|
useBollingerBands?: boolean;
|
||||||
|
useStochastic?: boolean;
|
||||||
|
minSignalStrength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example strategy using multiple technical indicators from the Rust TA library
|
||||||
|
*/
|
||||||
|
export class IndicatorBasedStrategy extends BaseStrategy {
|
||||||
|
private ta: TechnicalAnalysis;
|
||||||
|
private incrementalIndicators: IncrementalIndicators;
|
||||||
|
private signalGenerator: SignalGenerator;
|
||||||
|
private priceHistory: {
|
||||||
|
close: number[];
|
||||||
|
high: number[];
|
||||||
|
low: number[];
|
||||||
|
volume: number[];
|
||||||
|
};
|
||||||
|
private readonly lookbackPeriod = 100; // Keep last 100 bars
|
||||||
|
private lastSignal: TradingSignal | null = null;
|
||||||
|
private config: IndicatorBasedConfig;
|
||||||
|
|
||||||
|
constructor(strategyId: string, config: IndicatorBasedConfig) {
|
||||||
|
super(strategyId, config.symbol, config.initialCapital);
|
||||||
|
this.config = {
|
||||||
|
useRSI: true,
|
||||||
|
useMACD: true,
|
||||||
|
useBollingerBands: true,
|
||||||
|
useStochastic: true,
|
||||||
|
minSignalStrength: 0.6,
|
||||||
|
...config
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ta = new TechnicalAnalysis();
|
||||||
|
this.incrementalIndicators = new IncrementalIndicators();
|
||||||
|
this.signalGenerator = new SignalGenerator();
|
||||||
|
|
||||||
|
this.priceHistory = {
|
||||||
|
close: [],
|
||||||
|
high: [],
|
||||||
|
low: [],
|
||||||
|
volume: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize incremental indicators for real-time updates
|
||||||
|
this.incrementalIndicators.createSMA('sma20', 20);
|
||||||
|
this.incrementalIndicators.createSMA('sma50', 50);
|
||||||
|
this.incrementalIndicators.createEMA('ema12', 12);
|
||||||
|
this.incrementalIndicators.createEMA('ema26', 26);
|
||||||
|
this.incrementalIndicators.createRSI('rsi14', 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMarketData(data: any): Order | null {
|
||||||
|
const { timestamp } = data;
|
||||||
|
|
||||||
|
// Update price history
|
||||||
|
if ('close' in data && 'high' in data && 'low' in data) {
|
||||||
|
this.priceHistory.close.push(data.close);
|
||||||
|
this.priceHistory.high.push(data.high);
|
||||||
|
this.priceHistory.low.push(data.low);
|
||||||
|
this.priceHistory.volume.push(data.volume || 0);
|
||||||
|
|
||||||
|
// Trim to lookback period
|
||||||
|
if (this.priceHistory.close.length > this.lookbackPeriod) {
|
||||||
|
this.priceHistory.close.shift();
|
||||||
|
this.priceHistory.high.shift();
|
||||||
|
this.priceHistory.low.shift();
|
||||||
|
this.priceHistory.volume.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update incremental indicators
|
||||||
|
this.incrementalIndicators.update('sma20', data.close);
|
||||||
|
this.incrementalIndicators.update('sma50', data.close);
|
||||||
|
this.incrementalIndicators.update('ema12', data.close);
|
||||||
|
this.incrementalIndicators.update('ema26', data.close);
|
||||||
|
this.incrementalIndicators.update('rsi14', data.close);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need enough data for indicators
|
||||||
|
if (this.priceHistory.close.length < 26) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate trading signals
|
||||||
|
const signal = this.signalGenerator.generateSignals(
|
||||||
|
this.symbol,
|
||||||
|
this.priceHistory,
|
||||||
|
timestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
this.lastSignal = signal;
|
||||||
|
|
||||||
|
// Log signal for debugging
|
||||||
|
if (signal.action !== 'HOLD') {
|
||||||
|
console.log(`[${new Date(timestamp).toISOString()}] Signal: ${signal.action} (strength: ${signal.strength.toFixed(2)}) - ${signal.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if signal is strong enough
|
||||||
|
if (signal.strength < this.config.minSignalStrength) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate orders based on signals and position
|
||||||
|
const currentPosition = this.positions[this.symbol] || 0;
|
||||||
|
|
||||||
|
if (signal.action === 'BUY' && currentPosition <= 0) {
|
||||||
|
// Close short position if any
|
||||||
|
if (currentPosition < 0) {
|
||||||
|
return this.createOrder('market', 'buy', Math.abs(currentPosition));
|
||||||
|
}
|
||||||
|
// Open long position
|
||||||
|
return this.createOrder('market', 'buy', this.config.positionSize);
|
||||||
|
} else if (signal.action === 'SELL' && currentPosition >= 0) {
|
||||||
|
// Close long position if any
|
||||||
|
if (currentPosition > 0) {
|
||||||
|
return this.createOrder('market', 'sell', Math.abs(currentPosition));
|
||||||
|
}
|
||||||
|
// Open short position (if allowed)
|
||||||
|
// return this.createOrder('market', 'sell', this.config.positionSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getState() {
|
||||||
|
const incrementalValues: Record<string, number | null> = {
|
||||||
|
sma20: this.incrementalIndicators.current('sma20'),
|
||||||
|
sma50: this.incrementalIndicators.current('sma50'),
|
||||||
|
ema12: this.incrementalIndicators.current('ema12'),
|
||||||
|
ema26: this.incrementalIndicators.current('ema26'),
|
||||||
|
rsi14: this.incrementalIndicators.current('rsi14')
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...super.getState(),
|
||||||
|
priceHistoryLength: this.priceHistory.close.length,
|
||||||
|
incrementalIndicators: incrementalValues,
|
||||||
|
lastSignal: this.lastSignal,
|
||||||
|
config: this.config
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example of using batch indicator calculation
|
||||||
|
*/
|
||||||
|
analyzeHistoricalData(): void {
|
||||||
|
if (this.priceHistory.close.length < 50) {
|
||||||
|
console.log('Not enough data for historical analysis');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closes = this.priceHistory.close;
|
||||||
|
|
||||||
|
// Calculate various indicators
|
||||||
|
const sma20 = this.ta.sma(closes, 20);
|
||||||
|
const sma50 = this.ta.sma(closes, 50);
|
||||||
|
const rsi = this.ta.rsi(closes, 14);
|
||||||
|
const macd = this.ta.macd(closes);
|
||||||
|
const bb = this.ta.bollingerBands(closes, 20, 2);
|
||||||
|
const atr = this.ta.atr(
|
||||||
|
this.priceHistory.high,
|
||||||
|
this.priceHistory.low,
|
||||||
|
this.priceHistory.close,
|
||||||
|
14
|
||||||
|
);
|
||||||
|
|
||||||
|
// Latest values
|
||||||
|
const currentPrice = closes[closes.length - 1];
|
||||||
|
const currentSMA20 = TechnicalAnalysis.latest(sma20);
|
||||||
|
const currentSMA50 = TechnicalAnalysis.latest(sma50);
|
||||||
|
const currentRSI = TechnicalAnalysis.latest(rsi);
|
||||||
|
const currentATR = TechnicalAnalysis.latest(atr);
|
||||||
|
|
||||||
|
console.log('Historical Analysis:');
|
||||||
|
console.log(`Current Price: ${currentPrice}`);
|
||||||
|
console.log(`SMA20: ${currentSMA20?.toFixed(2)}`);
|
||||||
|
console.log(`SMA50: ${currentSMA50?.toFixed(2)}`);
|
||||||
|
console.log(`RSI: ${currentRSI?.toFixed(2)}`);
|
||||||
|
console.log(`ATR: ${currentATR?.toFixed(2)}`);
|
||||||
|
console.log(`MACD: ${TechnicalAnalysis.latest(macd.macd)?.toFixed(2)}`);
|
||||||
|
console.log(`BB %B: ${((currentPrice - TechnicalAnalysis.latest(bb.lower)!) / (TechnicalAnalysis.latest(bb.upper)! - TechnicalAnalysis.latest(bb.lower)!)).toFixed(2)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,308 @@
|
||||||
|
import { BaseStrategy, Signal } from '../BaseStrategy';
|
||||||
|
import { MarketData } from '../../types';
|
||||||
|
import { IndicatorManager } from '../indicators/IndicatorManager';
|
||||||
|
import { PositionManager, PositionSizingParams } from '../position/PositionManager';
|
||||||
|
import { getLogger } from '@stock-bot/logger';
|
||||||
|
|
||||||
|
const logger = getLogger('SimpleMovingAverageCrossoverV2');
|
||||||
|
|
||||||
|
export interface SMAStrategyConfig {
|
||||||
|
fastPeriod?: number;
|
||||||
|
slowPeriod?: number;
|
||||||
|
positionSizePct?: number;
|
||||||
|
riskPerTrade?: number;
|
||||||
|
useATRStops?: boolean;
|
||||||
|
minHoldingBars?: number;
|
||||||
|
debugInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refactored SMA Crossover Strategy using new TA library
|
||||||
|
*/
|
||||||
|
export class SimpleMovingAverageCrossoverV2 extends BaseStrategy {
|
||||||
|
private indicatorManager: IndicatorManager;
|
||||||
|
private positionManager: PositionManager;
|
||||||
|
|
||||||
|
// Strategy parameters
|
||||||
|
private readonly config: Required<SMAStrategyConfig>;
|
||||||
|
private lastTradeBar = new Map<string, number>();
|
||||||
|
private barCount = new Map<string, number>();
|
||||||
|
private totalSignals = 0;
|
||||||
|
|
||||||
|
constructor(strategyConfig: any, modeManager?: any, executionService?: any) {
|
||||||
|
super(strategyConfig, modeManager, executionService);
|
||||||
|
|
||||||
|
// Initialize config with defaults
|
||||||
|
this.config = {
|
||||||
|
fastPeriod: 10,
|
||||||
|
slowPeriod: 20,
|
||||||
|
positionSizePct: 0.1,
|
||||||
|
riskPerTrade: 0.02,
|
||||||
|
useATRStops: true,
|
||||||
|
minHoldingBars: 1,
|
||||||
|
debugInterval: 20,
|
||||||
|
...strategyConfig.params
|
||||||
|
};
|
||||||
|
|
||||||
|
this.indicatorManager = new IndicatorManager();
|
||||||
|
this.positionManager = new PositionManager(strategyConfig.initialCapital || 100000);
|
||||||
|
|
||||||
|
logger.info(`SimpleMovingAverageCrossoverV2 initialized:`, this.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected updateIndicators(data: MarketData): void {
|
||||||
|
if (data.type !== 'bar') return;
|
||||||
|
|
||||||
|
const { symbol, timestamp } = data.data;
|
||||||
|
const { open, high, low, close, volume } = data.data;
|
||||||
|
|
||||||
|
// Update bar count
|
||||||
|
const currentBar = (this.barCount.get(symbol) || 0) + 1;
|
||||||
|
this.barCount.set(symbol, currentBar);
|
||||||
|
|
||||||
|
// First time seeing this symbol
|
||||||
|
if (!this.indicatorManager.getHistoryLength(symbol)) {
|
||||||
|
logger.info(`📊 Starting to track ${symbol} @ ${close}`);
|
||||||
|
|
||||||
|
// Setup incremental indicators for real-time updates
|
||||||
|
this.indicatorManager.setupIncrementalIndicator(symbol, 'fast_sma', {
|
||||||
|
type: 'sma',
|
||||||
|
period: this.config.fastPeriod
|
||||||
|
});
|
||||||
|
this.indicatorManager.setupIncrementalIndicator(symbol, 'slow_sma', {
|
||||||
|
type: 'sma',
|
||||||
|
period: this.config.slowPeriod
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.config.useATRStops) {
|
||||||
|
this.indicatorManager.setupIncrementalIndicator(symbol, 'atr', {
|
||||||
|
type: 'sma', // Using SMA as proxy for now
|
||||||
|
period: 14
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update price history
|
||||||
|
this.indicatorManager.updatePrice({
|
||||||
|
symbol,
|
||||||
|
timestamp,
|
||||||
|
open,
|
||||||
|
high,
|
||||||
|
low,
|
||||||
|
close,
|
||||||
|
volume
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update position market prices
|
||||||
|
const currentPrices = new Map([[symbol, close]]);
|
||||||
|
this.positionManager.updateMarketPrices(currentPrices);
|
||||||
|
|
||||||
|
// Log when we have enough data
|
||||||
|
const historyLength = this.indicatorManager.getHistoryLength(symbol);
|
||||||
|
if (historyLength === this.config.slowPeriod) {
|
||||||
|
logger.info(`✅ ${symbol} has enough history (${historyLength} bars) to start trading`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async generateSignal(data: MarketData): Promise<Signal | null> {
|
||||||
|
if (data.type !== 'bar') return null;
|
||||||
|
|
||||||
|
const { symbol, timestamp } = data.data;
|
||||||
|
const { close } = data.data;
|
||||||
|
const historyLength = this.indicatorManager.getHistoryLength(symbol);
|
||||||
|
|
||||||
|
// Need enough data for slow MA
|
||||||
|
if (historyLength < this.config.slowPeriod) {
|
||||||
|
if (historyLength % 5 === 0) {
|
||||||
|
logger.debug(`${symbol} - Building history: ${historyLength}/${this.config.slowPeriod} bars`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate indicators
|
||||||
|
const fastMA = this.indicatorManager.getSMA(symbol, this.config.fastPeriod);
|
||||||
|
const slowMA = this.indicatorManager.getSMA(symbol, this.config.slowPeriod);
|
||||||
|
|
||||||
|
if (!fastMA || !slowMA) return null;
|
||||||
|
|
||||||
|
// Get current and previous values
|
||||||
|
const currentFast = this.indicatorManager.getLatest(fastMA);
|
||||||
|
const currentSlow = this.indicatorManager.getLatest(slowMA);
|
||||||
|
|
||||||
|
if (currentFast === null || currentSlow === null) return null;
|
||||||
|
|
||||||
|
// Check for crossovers
|
||||||
|
const goldenCross = this.indicatorManager.checkCrossover(fastMA, slowMA);
|
||||||
|
const deathCross = this.indicatorManager.checkCrossunder(fastMA, slowMA);
|
||||||
|
|
||||||
|
// Get current position
|
||||||
|
const currentPosition = this.positionManager.getPositionQuantity(symbol);
|
||||||
|
const currentBar = this.barCount.get(symbol) || 0;
|
||||||
|
const lastTradeBar = this.lastTradeBar.get(symbol) || 0;
|
||||||
|
const barsSinceLastTrade = lastTradeBar > 0 ? currentBar - lastTradeBar : Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
// Enhanced debugging
|
||||||
|
const maDiff = currentFast - currentSlow;
|
||||||
|
const maDiffPct = (maDiff / currentSlow) * 100;
|
||||||
|
const shouldLog = historyLength % this.config.debugInterval === 0 ||
|
||||||
|
Math.abs(maDiffPct) < 1.0 ||
|
||||||
|
goldenCross ||
|
||||||
|
deathCross;
|
||||||
|
|
||||||
|
if (shouldLog) {
|
||||||
|
const dateStr = new Date(timestamp).toISOString().split('T')[0];
|
||||||
|
logger.info(`${symbol} @ ${dateStr} [Bar ${currentBar}]:`);
|
||||||
|
logger.info(` Price: $${close.toFixed(2)}`);
|
||||||
|
logger.info(` Fast MA (${this.config.fastPeriod}): $${currentFast.toFixed(2)}`);
|
||||||
|
logger.info(` Slow MA (${this.config.slowPeriod}): $${currentSlow.toFixed(2)}`);
|
||||||
|
logger.info(` MA Diff: ${maDiff.toFixed(2)} (${maDiffPct.toFixed(2)}%)`);
|
||||||
|
logger.info(` Position: ${currentPosition} shares`);
|
||||||
|
|
||||||
|
// Show additional indicators if available
|
||||||
|
const rsi = this.indicatorManager.getRSI(symbol);
|
||||||
|
if (rsi) {
|
||||||
|
const currentRSI = this.indicatorManager.getLatest(rsi);
|
||||||
|
logger.info(` RSI: ${currentRSI?.toFixed(2)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (goldenCross) logger.info(` 🟢 GOLDEN CROSS DETECTED!`);
|
||||||
|
if (deathCross) logger.info(` 🔴 DEATH CROSS DETECTED!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check minimum holding period
|
||||||
|
if (barsSinceLastTrade < this.config.minHoldingBars && lastTradeBar > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position sizing parameters
|
||||||
|
const sizingParams: PositionSizingParams = {
|
||||||
|
accountBalance: this.positionManager.getAccountBalance(),
|
||||||
|
riskPerTrade: this.config.riskPerTrade,
|
||||||
|
volatilityAdjustment: this.config.useATRStops
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.config.useATRStops) {
|
||||||
|
const atr = this.indicatorManager.getATR(symbol);
|
||||||
|
if (atr) {
|
||||||
|
sizingParams.atr = this.indicatorManager.getLatest(atr) || undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate signals
|
||||||
|
if (goldenCross) {
|
||||||
|
logger.info(`🟢 Golden cross detected for ${symbol}`);
|
||||||
|
|
||||||
|
if (currentPosition < 0) {
|
||||||
|
// Close short position
|
||||||
|
this.lastTradeBar.set(symbol, currentBar);
|
||||||
|
this.totalSignals++;
|
||||||
|
return {
|
||||||
|
type: 'buy',
|
||||||
|
symbol,
|
||||||
|
strength: 0.8,
|
||||||
|
reason: 'Golden cross - Closing short position',
|
||||||
|
metadata: {
|
||||||
|
fastMA: currentFast,
|
||||||
|
slowMA: currentSlow,
|
||||||
|
crossoverType: 'golden',
|
||||||
|
price: close,
|
||||||
|
quantity: Math.abs(currentPosition)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else if (currentPosition === 0) {
|
||||||
|
// Calculate position size
|
||||||
|
const positionSize = this.positionManager.calculatePositionSize(sizingParams, close);
|
||||||
|
|
||||||
|
logger.info(` Opening long position: ${positionSize} shares`);
|
||||||
|
logger.info(` Account balance: $${sizingParams.accountBalance.toFixed(2)}`);
|
||||||
|
|
||||||
|
this.lastTradeBar.set(symbol, currentBar);
|
||||||
|
this.totalSignals++;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'buy',
|
||||||
|
symbol,
|
||||||
|
strength: 0.8,
|
||||||
|
reason: 'Golden cross - Fast MA crossed above Slow MA',
|
||||||
|
metadata: {
|
||||||
|
fastMA: currentFast,
|
||||||
|
slowMA: currentSlow,
|
||||||
|
crossoverType: 'golden',
|
||||||
|
price: close,
|
||||||
|
quantity: positionSize
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (deathCross && currentPosition > 0) {
|
||||||
|
logger.info(`🔴 Death cross detected for ${symbol}`);
|
||||||
|
|
||||||
|
this.lastTradeBar.set(symbol, currentBar);
|
||||||
|
this.totalSignals++;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'sell',
|
||||||
|
symbol,
|
||||||
|
strength: 0.8,
|
||||||
|
reason: 'Death cross - Fast MA crossed below Slow MA',
|
||||||
|
metadata: {
|
||||||
|
fastMA: currentFast,
|
||||||
|
slowMA: currentSlow,
|
||||||
|
crossoverType: 'death',
|
||||||
|
price: close,
|
||||||
|
quantity: currentPosition
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async onOrderUpdate(update: any): Promise<void> {
|
||||||
|
await super.onOrderUpdate(update);
|
||||||
|
|
||||||
|
// Update position manager with fills
|
||||||
|
if (update.status === 'filled' && update.fills?.length > 0) {
|
||||||
|
for (const fill of update.fills) {
|
||||||
|
this.positionManager.updatePosition({
|
||||||
|
symbol: update.symbol,
|
||||||
|
side: update.side,
|
||||||
|
quantity: fill.quantity,
|
||||||
|
price: fill.price,
|
||||||
|
commission: fill.commission || 0,
|
||||||
|
timestamp: new Date(fill.timestamp)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log performance metrics periodically
|
||||||
|
if (this.totalSignals % 5 === 0) {
|
||||||
|
const metrics = this.positionManager.getPerformanceMetrics();
|
||||||
|
logger.info('📊 Strategy Performance:', {
|
||||||
|
trades: metrics.totalTrades,
|
||||||
|
winRate: `${metrics.winRate.toFixed(2)}%`,
|
||||||
|
totalPnL: `$${metrics.totalPnl.toFixed(2)}`,
|
||||||
|
returnPct: `${metrics.returnPct.toFixed(2)}%`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPerformance(): any {
|
||||||
|
const metrics = this.positionManager.getPerformanceMetrics();
|
||||||
|
return {
|
||||||
|
...super.getPerformance(),
|
||||||
|
...metrics,
|
||||||
|
totalSignals: this.totalSignals,
|
||||||
|
openPositions: this.positionManager.getOpenPositions()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Get current state for debugging
|
||||||
|
getState() {
|
||||||
|
return {
|
||||||
|
config: this.config,
|
||||||
|
totalSignals: this.totalSignals,
|
||||||
|
performance: this.getPerformance(),
|
||||||
|
positions: Array.from(this.positions.entries())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,315 @@
|
||||||
|
import { TechnicalAnalysis, IncrementalIndicators } from '../../indicators/TechnicalAnalysis';
|
||||||
|
import { getLogger } from '@stock-bot/logger';
|
||||||
|
|
||||||
|
const logger = getLogger('IndicatorManager');
|
||||||
|
|
||||||
|
export interface IndicatorConfig {
|
||||||
|
type: 'sma' | 'ema' | 'rsi' | 'macd' | 'bollinger' | 'stochastic' | 'atr';
|
||||||
|
period?: number;
|
||||||
|
fastPeriod?: number;
|
||||||
|
slowPeriod?: number;
|
||||||
|
signalPeriod?: number;
|
||||||
|
stdDev?: number;
|
||||||
|
kPeriod?: number;
|
||||||
|
dPeriod?: number;
|
||||||
|
smoothK?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceData {
|
||||||
|
symbol: string;
|
||||||
|
timestamp: number;
|
||||||
|
open: number;
|
||||||
|
high: number;
|
||||||
|
low: number;
|
||||||
|
close: number;
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages technical indicators for a strategy
|
||||||
|
* Handles both batch and incremental calculations
|
||||||
|
*/
|
||||||
|
export class IndicatorManager {
|
||||||
|
private ta: TechnicalAnalysis;
|
||||||
|
private incrementalIndicators: IncrementalIndicators;
|
||||||
|
private priceHistory: Map<string, {
|
||||||
|
open: number[];
|
||||||
|
high: number[];
|
||||||
|
low: number[];
|
||||||
|
close: number[];
|
||||||
|
volume: number[];
|
||||||
|
}> = new Map();
|
||||||
|
|
||||||
|
private indicatorCache: Map<string, Map<string, any>> = new Map();
|
||||||
|
private maxHistoryLength: number;
|
||||||
|
|
||||||
|
constructor(maxHistoryLength = 500) {
|
||||||
|
this.ta = new TechnicalAnalysis();
|
||||||
|
this.incrementalIndicators = new IncrementalIndicators();
|
||||||
|
this.maxHistoryLength = maxHistoryLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update price history with new data
|
||||||
|
*/
|
||||||
|
updatePrice(data: PriceData): void {
|
||||||
|
const { symbol, open, high, low, close, volume } = data;
|
||||||
|
|
||||||
|
if (!this.priceHistory.has(symbol)) {
|
||||||
|
this.priceHistory.set(symbol, {
|
||||||
|
open: [],
|
||||||
|
high: [],
|
||||||
|
low: [],
|
||||||
|
close: [],
|
||||||
|
volume: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = this.priceHistory.get(symbol)!;
|
||||||
|
|
||||||
|
// Add new data
|
||||||
|
history.open.push(open);
|
||||||
|
history.high.push(high);
|
||||||
|
history.low.push(low);
|
||||||
|
history.close.push(close);
|
||||||
|
history.volume.push(volume);
|
||||||
|
|
||||||
|
// Trim to max length
|
||||||
|
if (history.close.length > this.maxHistoryLength) {
|
||||||
|
history.open.shift();
|
||||||
|
history.high.shift();
|
||||||
|
history.low.shift();
|
||||||
|
history.close.shift();
|
||||||
|
history.volume.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cache for this symbol as data has changed
|
||||||
|
this.indicatorCache.delete(symbol);
|
||||||
|
|
||||||
|
// Update incremental indicators
|
||||||
|
this.updateIncrementalIndicators(symbol, close);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get price history for a symbol
|
||||||
|
*/
|
||||||
|
getPriceHistory(symbol: string) {
|
||||||
|
return this.priceHistory.get(symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of price bars for a symbol
|
||||||
|
*/
|
||||||
|
getHistoryLength(symbol: string): number {
|
||||||
|
const history = this.priceHistory.get(symbol);
|
||||||
|
return history ? history.close.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate SMA
|
||||||
|
*/
|
||||||
|
getSMA(symbol: string, period: number): number[] | null {
|
||||||
|
const cacheKey = `sma_${period}`;
|
||||||
|
return this.getCachedOrCalculate(symbol, cacheKey, () => {
|
||||||
|
const history = this.priceHistory.get(symbol);
|
||||||
|
if (!history || history.close.length < period) return null;
|
||||||
|
return this.ta.sma(history.close, period);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate EMA
|
||||||
|
*/
|
||||||
|
getEMA(symbol: string, period: number): number[] | null {
|
||||||
|
const cacheKey = `ema_${period}`;
|
||||||
|
return this.getCachedOrCalculate(symbol, cacheKey, () => {
|
||||||
|
const history = this.priceHistory.get(symbol);
|
||||||
|
if (!history || history.close.length < period) return null;
|
||||||
|
return this.ta.ema(history.close, period);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate RSI
|
||||||
|
*/
|
||||||
|
getRSI(symbol: string, period: number = 14): number[] | null {
|
||||||
|
const cacheKey = `rsi_${period}`;
|
||||||
|
return this.getCachedOrCalculate(symbol, cacheKey, () => {
|
||||||
|
const history = this.priceHistory.get(symbol);
|
||||||
|
if (!history || history.close.length < period + 1) return null;
|
||||||
|
return this.ta.rsi(history.close, period);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate MACD
|
||||||
|
*/
|
||||||
|
getMACD(symbol: string, fastPeriod = 12, slowPeriod = 26, signalPeriod = 9) {
|
||||||
|
const cacheKey = `macd_${fastPeriod}_${slowPeriod}_${signalPeriod}`;
|
||||||
|
return this.getCachedOrCalculate(symbol, cacheKey, () => {
|
||||||
|
const history = this.priceHistory.get(symbol);
|
||||||
|
if (!history || history.close.length < slowPeriod + signalPeriod) return null;
|
||||||
|
return this.ta.macd(history.close, fastPeriod, slowPeriod, signalPeriod);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate Bollinger Bands
|
||||||
|
*/
|
||||||
|
getBollingerBands(symbol: string, period = 20, stdDev = 2) {
|
||||||
|
const cacheKey = `bb_${period}_${stdDev}`;
|
||||||
|
return this.getCachedOrCalculate(symbol, cacheKey, () => {
|
||||||
|
const history = this.priceHistory.get(symbol);
|
||||||
|
if (!history || history.close.length < period) return null;
|
||||||
|
return this.ta.bollingerBands(history.close, period, stdDev);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate Stochastic
|
||||||
|
*/
|
||||||
|
getStochastic(symbol: string, kPeriod = 14, dPeriod = 3, smoothK = 1) {
|
||||||
|
const cacheKey = `stoch_${kPeriod}_${dPeriod}_${smoothK}`;
|
||||||
|
return this.getCachedOrCalculate(symbol, cacheKey, () => {
|
||||||
|
const history = this.priceHistory.get(symbol);
|
||||||
|
if (!history || history.close.length < kPeriod) return null;
|
||||||
|
return this.ta.stochastic(
|
||||||
|
history.high,
|
||||||
|
history.low,
|
||||||
|
history.close,
|
||||||
|
kPeriod,
|
||||||
|
dPeriod,
|
||||||
|
smoothK
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate ATR
|
||||||
|
*/
|
||||||
|
getATR(symbol: string, period = 14): number[] | null {
|
||||||
|
const cacheKey = `atr_${period}`;
|
||||||
|
return this.getCachedOrCalculate(symbol, cacheKey, () => {
|
||||||
|
const history = this.priceHistory.get(symbol);
|
||||||
|
if (!history || history.close.length < period + 1) return null;
|
||||||
|
return this.ta.atr(history.high, history.low, history.close, period);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get latest value from an indicator
|
||||||
|
*/
|
||||||
|
getLatest(values: number[] | null): number | null {
|
||||||
|
if (!values || values.length === 0) return null;
|
||||||
|
return values[values.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for crossover
|
||||||
|
*/
|
||||||
|
checkCrossover(series1: number[] | null, series2: number[] | null): boolean {
|
||||||
|
if (!series1 || !series2) return false;
|
||||||
|
return TechnicalAnalysis.crossover(series1, series2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for crossunder
|
||||||
|
*/
|
||||||
|
checkCrossunder(series1: number[] | null, series2: number[] | null): boolean {
|
||||||
|
if (!series1 || !series2) return false;
|
||||||
|
return TechnicalAnalysis.crossunder(series1, series2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup incremental indicators
|
||||||
|
*/
|
||||||
|
setupIncrementalIndicator(symbol: string, name: string, config: IndicatorConfig): void {
|
||||||
|
const key = `${symbol}_${name}`;
|
||||||
|
|
||||||
|
switch (config.type) {
|
||||||
|
case 'sma':
|
||||||
|
this.incrementalIndicators.createSMA(key, config.period!);
|
||||||
|
break;
|
||||||
|
case 'ema':
|
||||||
|
this.incrementalIndicators.createEMA(key, config.period!);
|
||||||
|
break;
|
||||||
|
case 'rsi':
|
||||||
|
this.incrementalIndicators.createRSI(key, config.period!);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logger.warn(`Incremental indicator type ${config.type} not supported`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get incremental indicator value
|
||||||
|
*/
|
||||||
|
getIncrementalValue(symbol: string, name: string): number | null {
|
||||||
|
const key = `${symbol}_${name}`;
|
||||||
|
return this.incrementalIndicators.current(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all data for a symbol
|
||||||
|
*/
|
||||||
|
clearSymbol(symbol: string): void {
|
||||||
|
this.priceHistory.delete(symbol);
|
||||||
|
this.indicatorCache.delete(symbol);
|
||||||
|
|
||||||
|
// Reset incremental indicators for this symbol
|
||||||
|
const indicators = this.incrementalIndicators as any;
|
||||||
|
for (const [key, indicator] of indicators.indicators) {
|
||||||
|
if (key.startsWith(`${symbol}_`)) {
|
||||||
|
if ('reset' in indicator) {
|
||||||
|
indicator.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all data
|
||||||
|
*/
|
||||||
|
clearAll(): void {
|
||||||
|
this.priceHistory.clear();
|
||||||
|
this.indicatorCache.clear();
|
||||||
|
this.incrementalIndicators.resetAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCachedOrCalculate<T>(
|
||||||
|
symbol: string,
|
||||||
|
cacheKey: string,
|
||||||
|
calculator: () => T | null
|
||||||
|
): T | null {
|
||||||
|
if (!this.indicatorCache.has(symbol)) {
|
||||||
|
this.indicatorCache.set(symbol, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
const symbolCache = this.indicatorCache.get(symbol)!;
|
||||||
|
|
||||||
|
if (symbolCache.has(cacheKey)) {
|
||||||
|
return symbolCache.get(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = calculator();
|
||||||
|
if (result !== null) {
|
||||||
|
symbolCache.set(cacheKey, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateIncrementalIndicators(symbol: string, price: number): void {
|
||||||
|
// Update all incremental indicators for this symbol
|
||||||
|
const indicators = this.incrementalIndicators as any;
|
||||||
|
for (const [key] of indicators.indicators) {
|
||||||
|
if (key.startsWith(`${symbol}_`)) {
|
||||||
|
try {
|
||||||
|
this.incrementalIndicators.update(key, price);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error updating incremental indicator ${key}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,290 @@
|
||||||
|
import { getLogger } from '@stock-bot/logger';
|
||||||
|
|
||||||
|
const logger = getLogger('PositionManager');
|
||||||
|
|
||||||
|
export interface Position {
|
||||||
|
symbol: string;
|
||||||
|
quantity: number;
|
||||||
|
avgPrice: number;
|
||||||
|
currentPrice?: number;
|
||||||
|
unrealizedPnl?: number;
|
||||||
|
realizedPnl: number;
|
||||||
|
openTime: Date;
|
||||||
|
lastUpdateTime: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Trade {
|
||||||
|
symbol: string;
|
||||||
|
side: 'buy' | 'sell';
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
commission: number;
|
||||||
|
timestamp: Date;
|
||||||
|
pnl?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PositionSizingParams {
|
||||||
|
accountBalance: number;
|
||||||
|
riskPerTrade: number; // As percentage (e.g., 0.02 for 2%)
|
||||||
|
stopLossDistance?: number; // Price distance for stop loss
|
||||||
|
maxPositionSize?: number; // Max % of account in one position
|
||||||
|
volatilityAdjustment?: boolean;
|
||||||
|
atr?: number; // For volatility-based sizing
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages positions and calculates position sizes
|
||||||
|
*/
|
||||||
|
export class PositionManager {
|
||||||
|
private positions: Map<string, Position> = new Map();
|
||||||
|
private trades: Trade[] = [];
|
||||||
|
private accountBalance: number;
|
||||||
|
private initialBalance: number;
|
||||||
|
|
||||||
|
constructor(initialBalance: number = 100000) {
|
||||||
|
this.initialBalance = initialBalance;
|
||||||
|
this.accountBalance = initialBalance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update position with a new trade
|
||||||
|
*/
|
||||||
|
updatePosition(trade: Trade): Position {
|
||||||
|
const { symbol, side, quantity, price, commission } = trade;
|
||||||
|
let position = this.positions.get(symbol);
|
||||||
|
|
||||||
|
if (!position) {
|
||||||
|
// New position
|
||||||
|
position = {
|
||||||
|
symbol,
|
||||||
|
quantity: side === 'buy' ? quantity : -quantity,
|
||||||
|
avgPrice: price,
|
||||||
|
realizedPnl: -commission,
|
||||||
|
openTime: trade.timestamp,
|
||||||
|
lastUpdateTime: trade.timestamp
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const oldQuantity = position.quantity;
|
||||||
|
const newQuantity = side === 'buy'
|
||||||
|
? oldQuantity + quantity
|
||||||
|
: oldQuantity - quantity;
|
||||||
|
|
||||||
|
if (Math.sign(oldQuantity) !== Math.sign(newQuantity) && oldQuantity !== 0) {
|
||||||
|
// Position flip or close
|
||||||
|
const closedQuantity = Math.min(Math.abs(oldQuantity), quantity);
|
||||||
|
const pnl = this.calculatePnl(
|
||||||
|
position.avgPrice,
|
||||||
|
price,
|
||||||
|
closedQuantity,
|
||||||
|
oldQuantity > 0 ? 'sell' : 'buy'
|
||||||
|
);
|
||||||
|
|
||||||
|
position.realizedPnl += pnl - commission;
|
||||||
|
trade.pnl = pnl - commission;
|
||||||
|
|
||||||
|
// Update average price if position continues
|
||||||
|
if (Math.abs(newQuantity) > 0.0001) {
|
||||||
|
position.avgPrice = price;
|
||||||
|
}
|
||||||
|
} else if (Math.sign(oldQuantity) === Math.sign(newQuantity) || oldQuantity === 0) {
|
||||||
|
// Adding to position
|
||||||
|
const totalCost = Math.abs(oldQuantity) * position.avgPrice + quantity * price;
|
||||||
|
const totalQuantity = Math.abs(oldQuantity) + quantity;
|
||||||
|
position.avgPrice = totalCost / totalQuantity;
|
||||||
|
position.realizedPnl -= commission;
|
||||||
|
}
|
||||||
|
|
||||||
|
position.quantity = newQuantity;
|
||||||
|
position.lastUpdateTime = trade.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store or remove position
|
||||||
|
if (Math.abs(position.quantity) < 0.0001) {
|
||||||
|
this.positions.delete(symbol);
|
||||||
|
logger.info(`Closed position for ${symbol}, realized P&L: $${position.realizedPnl.toFixed(2)}`);
|
||||||
|
} else {
|
||||||
|
this.positions.set(symbol, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record trade
|
||||||
|
this.trades.push(trade);
|
||||||
|
|
||||||
|
// Update account balance
|
||||||
|
if (trade.pnl !== undefined) {
|
||||||
|
this.accountBalance += trade.pnl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current position for a symbol
|
||||||
|
*/
|
||||||
|
getPosition(symbol: string): Position | undefined {
|
||||||
|
return this.positions.get(symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get position quantity
|
||||||
|
*/
|
||||||
|
getPositionQuantity(symbol: string): number {
|
||||||
|
const position = this.positions.get(symbol);
|
||||||
|
return position ? position.quantity : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if has position
|
||||||
|
*/
|
||||||
|
hasPosition(symbol: string): boolean {
|
||||||
|
const position = this.positions.get(symbol);
|
||||||
|
return position !== undefined && Math.abs(position.quantity) > 0.0001;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all open positions
|
||||||
|
*/
|
||||||
|
getOpenPositions(): Position[] {
|
||||||
|
return Array.from(this.positions.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update market prices for positions
|
||||||
|
*/
|
||||||
|
updateMarketPrices(prices: Map<string, number>): void {
|
||||||
|
for (const [symbol, position] of this.positions) {
|
||||||
|
const currentPrice = prices.get(symbol);
|
||||||
|
if (currentPrice) {
|
||||||
|
position.currentPrice = currentPrice;
|
||||||
|
position.unrealizedPnl = this.calculatePnl(
|
||||||
|
position.avgPrice,
|
||||||
|
currentPrice,
|
||||||
|
Math.abs(position.quantity),
|
||||||
|
position.quantity > 0 ? 'sell' : 'buy'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate position size based on risk parameters
|
||||||
|
*/
|
||||||
|
calculatePositionSize(params: PositionSizingParams, currentPrice: number): number {
|
||||||
|
const {
|
||||||
|
accountBalance,
|
||||||
|
riskPerTrade,
|
||||||
|
stopLossDistance,
|
||||||
|
maxPositionSize = 0.25,
|
||||||
|
volatilityAdjustment = false,
|
||||||
|
atr
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
let positionSize: number;
|
||||||
|
|
||||||
|
if (stopLossDistance && stopLossDistance > 0) {
|
||||||
|
// Risk-based position sizing
|
||||||
|
const riskAmount = accountBalance * riskPerTrade;
|
||||||
|
positionSize = Math.floor(riskAmount / stopLossDistance);
|
||||||
|
} else if (volatilityAdjustment && atr) {
|
||||||
|
// Volatility-based position sizing
|
||||||
|
const riskAmount = accountBalance * riskPerTrade;
|
||||||
|
const stopDistance = atr * 2; // 2 ATR stop
|
||||||
|
positionSize = Math.floor(riskAmount / stopDistance);
|
||||||
|
} else {
|
||||||
|
// Fixed percentage position sizing
|
||||||
|
const positionValue = accountBalance * riskPerTrade * 10; // Simplified
|
||||||
|
positionSize = Math.floor(positionValue / currentPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply max position size limit
|
||||||
|
const maxShares = Math.floor((accountBalance * maxPositionSize) / currentPrice);
|
||||||
|
positionSize = Math.min(positionSize, maxShares);
|
||||||
|
|
||||||
|
// Ensure minimum position size
|
||||||
|
return Math.max(1, positionSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate Kelly Criterion position size
|
||||||
|
*/
|
||||||
|
calculateKellySize(winRate: number, avgWin: number, avgLoss: number, currentPrice: number): number {
|
||||||
|
if (avgLoss === 0) return 0;
|
||||||
|
|
||||||
|
const b = avgWin / avgLoss;
|
||||||
|
const p = winRate;
|
||||||
|
const q = 1 - p;
|
||||||
|
const kelly = (p * b - q) / b;
|
||||||
|
|
||||||
|
// Apply Kelly fraction (usually 0.25 to be conservative)
|
||||||
|
const kellyFraction = 0.25;
|
||||||
|
const percentageOfCapital = Math.max(0, Math.min(0.25, kelly * kellyFraction));
|
||||||
|
|
||||||
|
const positionValue = this.accountBalance * percentageOfCapital;
|
||||||
|
return Math.max(1, Math.floor(positionValue / currentPrice));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get performance metrics
|
||||||
|
*/
|
||||||
|
getPerformanceMetrics() {
|
||||||
|
const totalTrades = this.trades.length;
|
||||||
|
const winningTrades = this.trades.filter(t => (t.pnl || 0) > 0);
|
||||||
|
const losingTrades = this.trades.filter(t => (t.pnl || 0) < 0);
|
||||||
|
|
||||||
|
const totalPnl = this.trades.reduce((sum, t) => sum + (t.pnl || 0), 0);
|
||||||
|
const unrealizedPnl = Array.from(this.positions.values())
|
||||||
|
.reduce((sum, p) => sum + (p.unrealizedPnl || 0), 0);
|
||||||
|
|
||||||
|
const winRate = totalTrades > 0 ? winningTrades.length / totalTrades : 0;
|
||||||
|
const avgWin = winningTrades.length > 0
|
||||||
|
? winningTrades.reduce((sum, t) => sum + (t.pnl || 0), 0) / winningTrades.length
|
||||||
|
: 0;
|
||||||
|
const avgLoss = losingTrades.length > 0
|
||||||
|
? Math.abs(losingTrades.reduce((sum, t) => sum + (t.pnl || 0), 0) / losingTrades.length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const profitFactor = avgLoss > 0 ? (avgWin * winningTrades.length) / (avgLoss * losingTrades.length) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalTrades,
|
||||||
|
winningTrades: winningTrades.length,
|
||||||
|
losingTrades: losingTrades.length,
|
||||||
|
winRate: winRate * 100,
|
||||||
|
totalPnl,
|
||||||
|
unrealizedPnl,
|
||||||
|
totalEquity: this.accountBalance + unrealizedPnl,
|
||||||
|
avgWin,
|
||||||
|
avgLoss,
|
||||||
|
profitFactor,
|
||||||
|
returnPct: ((this.accountBalance - this.initialBalance) / this.initialBalance) * 100
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get account balance
|
||||||
|
*/
|
||||||
|
getAccountBalance(): number {
|
||||||
|
return this.accountBalance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total equity (balance + unrealized P&L)
|
||||||
|
*/
|
||||||
|
getTotalEquity(): number {
|
||||||
|
const unrealizedPnl = Array.from(this.positions.values())
|
||||||
|
.reduce((sum, p) => sum + (p.unrealizedPnl || 0), 0);
|
||||||
|
return this.accountBalance + unrealizedPnl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculatePnl(
|
||||||
|
entryPrice: number,
|
||||||
|
exitPrice: number,
|
||||||
|
quantity: number,
|
||||||
|
side: 'buy' | 'sell'
|
||||||
|
): number {
|
||||||
|
if (side === 'sell') {
|
||||||
|
return (exitPrice - entryPrice) * quantity;
|
||||||
|
} else {
|
||||||
|
return (entryPrice - exitPrice) * quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
262
apps/stock/orchestrator/src/strategies/risk/RiskManager.ts
Normal file
262
apps/stock/orchestrator/src/strategies/risk/RiskManager.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
import { getLogger } from '@stock-bot/logger';
|
||||||
|
|
||||||
|
const logger = getLogger('RiskManager');
|
||||||
|
|
||||||
|
export interface RiskLimits {
|
||||||
|
maxPositions?: number;
|
||||||
|
maxPositionSizePct?: number; // Max % of account per position
|
||||||
|
maxTotalExposurePct?: number; // Max % of account in all positions
|
||||||
|
maxDailyLossPct?: number; // Max daily loss as % of account
|
||||||
|
maxDrawdownPct?: number; // Max drawdown allowed
|
||||||
|
maxConsecutiveLosses?: number;
|
||||||
|
minWinRate?: number; // Minimum win rate to continue trading
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiskMetrics {
|
||||||
|
currentExposure: number;
|
||||||
|
currentExposurePct: number;
|
||||||
|
dailyPnl: number;
|
||||||
|
dailyPnlPct: number;
|
||||||
|
currentDrawdown: number;
|
||||||
|
currentDrawdownPct: number;
|
||||||
|
maxDrawdown: number;
|
||||||
|
maxDrawdownPct: number;
|
||||||
|
consecutiveLosses: number;
|
||||||
|
volatility: number;
|
||||||
|
sharpeRatio: number;
|
||||||
|
var95: number; // Value at Risk 95%
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiskCheckResult {
|
||||||
|
allowed: boolean;
|
||||||
|
reason?: string;
|
||||||
|
adjustedSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages risk limits and calculates risk metrics
|
||||||
|
*/
|
||||||
|
export class RiskManager {
|
||||||
|
private limits: Required<RiskLimits>;
|
||||||
|
private dailyPnl = 0;
|
||||||
|
private dailyStartBalance: number;
|
||||||
|
private peakBalance: number;
|
||||||
|
private consecutiveLosses = 0;
|
||||||
|
private dailyReturns: number[] = [];
|
||||||
|
private readonly lookbackDays = 30;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private accountBalance: number,
|
||||||
|
limits: RiskLimits = {}
|
||||||
|
) {
|
||||||
|
this.limits = {
|
||||||
|
maxPositions: 10,
|
||||||
|
maxPositionSizePct: 0.1,
|
||||||
|
maxTotalExposurePct: 0.6,
|
||||||
|
maxDailyLossPct: 0.05,
|
||||||
|
maxDrawdownPct: 0.2,
|
||||||
|
maxConsecutiveLosses: 5,
|
||||||
|
minWinRate: 0.3,
|
||||||
|
...limits
|
||||||
|
};
|
||||||
|
|
||||||
|
this.dailyStartBalance = accountBalance;
|
||||||
|
this.peakBalance = accountBalance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a new position is allowed
|
||||||
|
*/
|
||||||
|
checkNewPosition(
|
||||||
|
symbol: string,
|
||||||
|
proposedSize: number,
|
||||||
|
price: number,
|
||||||
|
currentPositions: Map<string, { quantity: number; value: number }>
|
||||||
|
): RiskCheckResult {
|
||||||
|
const proposedValue = Math.abs(proposedSize * price);
|
||||||
|
const proposedPct = proposedValue / this.accountBalance;
|
||||||
|
|
||||||
|
// Check max position size
|
||||||
|
if (proposedPct > this.limits.maxPositionSizePct) {
|
||||||
|
const maxValue = this.accountBalance * this.limits.maxPositionSizePct;
|
||||||
|
const adjustedSize = Math.floor(maxValue / price);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
reason: `Position size reduced from ${proposedSize} to ${adjustedSize} (max ${(this.limits.maxPositionSizePct * 100).toFixed(1)}% per position)`,
|
||||||
|
adjustedSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max positions
|
||||||
|
if (currentPositions.size >= this.limits.maxPositions && !currentPositions.has(symbol)) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `Maximum number of positions (${this.limits.maxPositions}) reached`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check total exposure
|
||||||
|
let totalExposure = proposedValue;
|
||||||
|
for (const [sym, pos] of currentPositions) {
|
||||||
|
if (sym !== symbol) {
|
||||||
|
totalExposure += Math.abs(pos.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalExposurePct = totalExposure / this.accountBalance;
|
||||||
|
if (totalExposurePct > this.limits.maxTotalExposurePct) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `Total exposure would be ${(totalExposurePct * 100).toFixed(1)}% (max ${(this.limits.maxTotalExposurePct * 100).toFixed(1)}%)`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check daily loss limit
|
||||||
|
const dailyLossPct = Math.abs(this.dailyPnl) / this.dailyStartBalance;
|
||||||
|
if (this.dailyPnl < 0 && dailyLossPct >= this.limits.maxDailyLossPct) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `Daily loss limit reached (${(dailyLossPct * 100).toFixed(1)}%)`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check consecutive losses
|
||||||
|
if (this.consecutiveLosses >= this.limits.maxConsecutiveLosses) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `Maximum consecutive losses (${this.limits.maxConsecutiveLosses}) reached`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check drawdown
|
||||||
|
const currentDrawdownPct = (this.peakBalance - this.accountBalance) / this.peakBalance;
|
||||||
|
if (currentDrawdownPct >= this.limits.maxDrawdownPct) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `Maximum drawdown reached (${(currentDrawdownPct * 100).toFixed(1)}%)`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update metrics after a trade
|
||||||
|
*/
|
||||||
|
updateAfterTrade(pnl: number): void {
|
||||||
|
this.dailyPnl += pnl;
|
||||||
|
this.accountBalance += pnl;
|
||||||
|
|
||||||
|
if (pnl < 0) {
|
||||||
|
this.consecutiveLosses++;
|
||||||
|
} else if (pnl > 0) {
|
||||||
|
this.consecutiveLosses = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.accountBalance > this.peakBalance) {
|
||||||
|
this.peakBalance = this.accountBalance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset daily metrics
|
||||||
|
*/
|
||||||
|
resetDaily(): void {
|
||||||
|
// Record daily return
|
||||||
|
const dailyReturn = (this.accountBalance - this.dailyStartBalance) / this.dailyStartBalance;
|
||||||
|
this.dailyReturns.push(dailyReturn);
|
||||||
|
|
||||||
|
// Keep only recent returns
|
||||||
|
if (this.dailyReturns.length > this.lookbackDays) {
|
||||||
|
this.dailyReturns.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dailyPnl = 0;
|
||||||
|
this.dailyStartBalance = this.accountBalance;
|
||||||
|
|
||||||
|
logger.info(`Daily reset - Balance: $${this.accountBalance.toFixed(2)}, Daily return: ${(dailyReturn * 100).toFixed(2)}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate current risk metrics
|
||||||
|
*/
|
||||||
|
getMetrics(currentPositions: Map<string, { quantity: number; value: number }>): RiskMetrics {
|
||||||
|
let currentExposure = 0;
|
||||||
|
for (const pos of currentPositions.values()) {
|
||||||
|
currentExposure += Math.abs(pos.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDrawdown = this.peakBalance - this.accountBalance;
|
||||||
|
const currentDrawdownPct = this.peakBalance > 0 ? currentDrawdown / this.peakBalance : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentExposure,
|
||||||
|
currentExposurePct: currentExposure / this.accountBalance,
|
||||||
|
dailyPnl: this.dailyPnl,
|
||||||
|
dailyPnlPct: this.dailyPnl / this.dailyStartBalance,
|
||||||
|
currentDrawdown,
|
||||||
|
currentDrawdownPct,
|
||||||
|
maxDrawdown: Math.max(currentDrawdown, 0),
|
||||||
|
maxDrawdownPct: Math.max(currentDrawdownPct, 0),
|
||||||
|
consecutiveLosses: this.consecutiveLosses,
|
||||||
|
volatility: this.calculateVolatility(),
|
||||||
|
sharpeRatio: this.calculateSharpeRatio(),
|
||||||
|
var95: this.calculateVaR(0.95)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update risk limits
|
||||||
|
*/
|
||||||
|
updateLimits(newLimits: Partial<RiskLimits>): void {
|
||||||
|
this.limits = { ...this.limits, ...newLimits };
|
||||||
|
logger.info('Risk limits updated:', this.limits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current limits
|
||||||
|
*/
|
||||||
|
getLimits(): Required<RiskLimits> {
|
||||||
|
return { ...this.limits };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate portfolio volatility
|
||||||
|
*/
|
||||||
|
private calculateVolatility(): number {
|
||||||
|
if (this.dailyReturns.length < 2) return 0;
|
||||||
|
|
||||||
|
const mean = this.dailyReturns.reduce((sum, r) => sum + r, 0) / this.dailyReturns.length;
|
||||||
|
const variance = this.dailyReturns.reduce((sum, r) => sum + Math.pow(r - mean, 2), 0) / (this.dailyReturns.length - 1);
|
||||||
|
|
||||||
|
return Math.sqrt(variance) * Math.sqrt(252); // Annualized
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate Sharpe ratio
|
||||||
|
*/
|
||||||
|
private calculateSharpeRatio(riskFreeRate = 0.02): number {
|
||||||
|
if (this.dailyReturns.length < 2) return 0;
|
||||||
|
|
||||||
|
const avgReturn = this.dailyReturns.reduce((sum, r) => sum + r, 0) / this.dailyReturns.length;
|
||||||
|
const annualizedReturn = avgReturn * 252;
|
||||||
|
const volatility = this.calculateVolatility();
|
||||||
|
|
||||||
|
if (volatility === 0) return 0;
|
||||||
|
|
||||||
|
return (annualizedReturn - riskFreeRate) / volatility;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate Value at Risk
|
||||||
|
*/
|
||||||
|
private calculateVaR(confidence: number): number {
|
||||||
|
if (this.dailyReturns.length < 5) return 0;
|
||||||
|
|
||||||
|
const sortedReturns = [...this.dailyReturns].sort((a, b) => a - b);
|
||||||
|
const index = Math.floor((1 - confidence) * sortedReturns.length);
|
||||||
|
|
||||||
|
return Math.abs(sortedReturns[index] * this.accountBalance);
|
||||||
|
}
|
||||||
|
}
|
||||||
469
apps/stock/orchestrator/src/strategies/signals/SignalManager.ts
Normal file
469
apps/stock/orchestrator/src/strategies/signals/SignalManager.ts
Normal file
|
|
@ -0,0 +1,469 @@
|
||||||
|
import { getLogger } from '@stock-bot/logger';
|
||||||
|
|
||||||
|
const logger = getLogger('SignalManager');
|
||||||
|
|
||||||
|
export interface SignalRule {
|
||||||
|
name: string;
|
||||||
|
condition: (indicators: any) => boolean;
|
||||||
|
weight: number;
|
||||||
|
direction: 'buy' | 'sell' | 'both';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignalFilter {
|
||||||
|
name: string;
|
||||||
|
filter: (signal: TradingSignal, context: any) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TradingSignal {
|
||||||
|
symbol: string;
|
||||||
|
timestamp: number;
|
||||||
|
direction: 'buy' | 'sell' | 'neutral';
|
||||||
|
strength: number; // -1 to 1 (-1 = strong sell, 1 = strong buy)
|
||||||
|
confidence: number; // 0 to 1
|
||||||
|
rules: string[]; // Rules that triggered
|
||||||
|
indicators: Record<string, number>;
|
||||||
|
metadata?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignalAggregation {
|
||||||
|
method: 'weighted' | 'majority' | 'unanimous' | 'threshold';
|
||||||
|
threshold?: number; // For threshold method
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages trading signals and rules
|
||||||
|
*/
|
||||||
|
export class SignalManager {
|
||||||
|
private rules: SignalRule[] = [];
|
||||||
|
private filters: SignalFilter[] = [];
|
||||||
|
private signalHistory: TradingSignal[] = [];
|
||||||
|
private maxHistorySize = 1000;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private aggregation: SignalAggregation = { method: 'weighted' }
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a signal rule
|
||||||
|
*/
|
||||||
|
addRule(rule: SignalRule): void {
|
||||||
|
this.rules.push(rule);
|
||||||
|
logger.info(`Added signal rule: ${rule.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add multiple rules
|
||||||
|
*/
|
||||||
|
addRules(rules: SignalRule[]): void {
|
||||||
|
rules.forEach(rule => this.addRule(rule));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a signal filter
|
||||||
|
*/
|
||||||
|
addFilter(filter: SignalFilter): void {
|
||||||
|
this.filters.push(filter);
|
||||||
|
logger.info(`Added signal filter: ${filter.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a rule by name
|
||||||
|
*/
|
||||||
|
removeRule(name: string): void {
|
||||||
|
this.rules = this.rules.filter(r => r.name !== name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate signal based on indicators
|
||||||
|
*/
|
||||||
|
generateSignal(
|
||||||
|
symbol: string,
|
||||||
|
timestamp: number,
|
||||||
|
indicators: Record<string, number>,
|
||||||
|
context: any = {}
|
||||||
|
): TradingSignal | null {
|
||||||
|
const triggeredRules: { rule: SignalRule; triggered: boolean }[] = [];
|
||||||
|
|
||||||
|
// Check each rule
|
||||||
|
for (const rule of this.rules) {
|
||||||
|
try {
|
||||||
|
const triggered = rule.condition(indicators);
|
||||||
|
if (triggered) {
|
||||||
|
triggeredRules.push({ rule, triggered: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error evaluating rule ${rule.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (triggeredRules.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate signals based on method
|
||||||
|
const signal = this.aggregateSignals(symbol, timestamp, indicators, triggeredRules);
|
||||||
|
|
||||||
|
if (!signal) return null;
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
for (const filter of this.filters) {
|
||||||
|
try {
|
||||||
|
if (!filter.filter(signal, context)) {
|
||||||
|
logger.debug(`Signal filtered by ${filter.name}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error applying filter ${filter.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in history
|
||||||
|
this.addToHistory(signal);
|
||||||
|
|
||||||
|
return signal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate multiple rule triggers into a single signal
|
||||||
|
*/
|
||||||
|
private aggregateSignals(
|
||||||
|
symbol: string,
|
||||||
|
timestamp: number,
|
||||||
|
indicators: Record<string, number>,
|
||||||
|
triggeredRules: { rule: SignalRule; triggered: boolean }[]
|
||||||
|
): TradingSignal | null {
|
||||||
|
let buyWeight = 0;
|
||||||
|
let sellWeight = 0;
|
||||||
|
let totalWeight = 0;
|
||||||
|
const rules: string[] = [];
|
||||||
|
|
||||||
|
for (const { rule } of triggeredRules) {
|
||||||
|
rules.push(rule.name);
|
||||||
|
totalWeight += Math.abs(rule.weight);
|
||||||
|
|
||||||
|
if (rule.direction === 'buy' || rule.direction === 'both') {
|
||||||
|
buyWeight += rule.weight;
|
||||||
|
}
|
||||||
|
if (rule.direction === 'sell' || rule.direction === 'both') {
|
||||||
|
sellWeight += rule.weight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let direction: 'buy' | 'sell' | 'neutral' = 'neutral';
|
||||||
|
let strength = 0;
|
||||||
|
let confidence = 0;
|
||||||
|
|
||||||
|
switch (this.aggregation.method) {
|
||||||
|
case 'weighted':
|
||||||
|
const netWeight = buyWeight - sellWeight;
|
||||||
|
strength = totalWeight > 0 ? netWeight / totalWeight : 0;
|
||||||
|
confidence = Math.min(triggeredRules.length / this.rules.length, 1);
|
||||||
|
|
||||||
|
if (Math.abs(strength) > 0.1) {
|
||||||
|
direction = strength > 0 ? 'buy' : 'sell';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'majority':
|
||||||
|
const buyCount = triggeredRules.filter(t =>
|
||||||
|
t.rule.direction === 'buy' || t.rule.direction === 'both'
|
||||||
|
).length;
|
||||||
|
const sellCount = triggeredRules.filter(t =>
|
||||||
|
t.rule.direction === 'sell' || t.rule.direction === 'both'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (buyCount > sellCount) {
|
||||||
|
direction = 'buy';
|
||||||
|
strength = buyCount / triggeredRules.length;
|
||||||
|
} else if (sellCount > buyCount) {
|
||||||
|
direction = 'sell';
|
||||||
|
strength = -sellCount / triggeredRules.length;
|
||||||
|
}
|
||||||
|
confidence = triggeredRules.length / this.rules.length;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'unanimous':
|
||||||
|
const allBuy = triggeredRules.every(t =>
|
||||||
|
t.rule.direction === 'buy' || t.rule.direction === 'both'
|
||||||
|
);
|
||||||
|
const allSell = triggeredRules.every(t =>
|
||||||
|
t.rule.direction === 'sell' || t.rule.direction === 'both'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allBuy && triggeredRules.length >= 2) {
|
||||||
|
direction = 'buy';
|
||||||
|
strength = 1;
|
||||||
|
confidence = 1;
|
||||||
|
} else if (allSell && triggeredRules.length >= 2) {
|
||||||
|
direction = 'sell';
|
||||||
|
strength = -1;
|
||||||
|
confidence = 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'threshold':
|
||||||
|
const threshold = this.aggregation.threshold || 0.7;
|
||||||
|
const avgWeight = totalWeight > 0 ? (buyWeight - sellWeight) / totalWeight : 0;
|
||||||
|
|
||||||
|
if (avgWeight >= threshold) {
|
||||||
|
direction = 'buy';
|
||||||
|
strength = avgWeight;
|
||||||
|
confidence = triggeredRules.length / this.rules.length;
|
||||||
|
} else if (avgWeight <= -threshold) {
|
||||||
|
direction = 'sell';
|
||||||
|
strength = avgWeight;
|
||||||
|
confidence = triggeredRules.length / this.rules.length;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction === 'neutral') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
symbol,
|
||||||
|
timestamp,
|
||||||
|
direction,
|
||||||
|
strength,
|
||||||
|
confidence,
|
||||||
|
rules,
|
||||||
|
indicators
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent signals for a symbol
|
||||||
|
*/
|
||||||
|
getRecentSignals(symbol: string, count = 10): TradingSignal[] {
|
||||||
|
return this.signalHistory
|
||||||
|
.filter(s => s.symbol === symbol)
|
||||||
|
.slice(-count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get signal statistics
|
||||||
|
*/
|
||||||
|
getSignalStats(symbol?: string) {
|
||||||
|
const signals = symbol
|
||||||
|
? this.signalHistory.filter(s => s.symbol === symbol)
|
||||||
|
: this.signalHistory;
|
||||||
|
|
||||||
|
const buySignals = signals.filter(s => s.direction === 'buy');
|
||||||
|
const sellSignals = signals.filter(s => s.direction === 'sell');
|
||||||
|
|
||||||
|
const avgBuyStrength = buySignals.length > 0
|
||||||
|
? buySignals.reduce((sum, s) => sum + s.strength, 0) / buySignals.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const avgSellStrength = sellSignals.length > 0
|
||||||
|
? sellSignals.reduce((sum, s) => sum + Math.abs(s.strength), 0) / sellSignals.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const avgConfidence = signals.length > 0
|
||||||
|
? signals.reduce((sum, s) => sum + s.confidence, 0) / signals.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSignals: signals.length,
|
||||||
|
buySignals: buySignals.length,
|
||||||
|
sellSignals: sellSignals.length,
|
||||||
|
avgBuyStrength,
|
||||||
|
avgSellStrength,
|
||||||
|
avgConfidence,
|
||||||
|
ruleHitRate: this.calculateRuleHitRate()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear signal history
|
||||||
|
*/
|
||||||
|
clearHistory(): void {
|
||||||
|
this.signalHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private addToHistory(signal: TradingSignal): void {
|
||||||
|
this.signalHistory.push(signal);
|
||||||
|
|
||||||
|
if (this.signalHistory.length > this.maxHistorySize) {
|
||||||
|
this.signalHistory.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateRuleHitRate(): Record<string, number> {
|
||||||
|
const ruleHits: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const signal of this.signalHistory) {
|
||||||
|
for (const rule of signal.rules) {
|
||||||
|
ruleHits[rule] = (ruleHits[rule] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hitRate: Record<string, number> = {};
|
||||||
|
for (const [rule, hits] of Object.entries(ruleHits)) {
|
||||||
|
hitRate[rule] = this.signalHistory.length > 0
|
||||||
|
? hits / this.signalHistory.length
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hitRate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common signal rules
|
||||||
|
*/
|
||||||
|
export const CommonRules = {
|
||||||
|
// Moving Average Rules
|
||||||
|
goldenCross: (fastMA: string, slowMA: string): SignalRule => ({
|
||||||
|
name: `Golden Cross (${fastMA}/${slowMA})`,
|
||||||
|
condition: (indicators) => {
|
||||||
|
const fast = indicators[fastMA];
|
||||||
|
const slow = indicators[slowMA];
|
||||||
|
const prevFast = indicators[`${fastMA}_prev`];
|
||||||
|
const prevSlow = indicators[`${slowMA}_prev`];
|
||||||
|
return prevFast <= prevSlow && fast > slow;
|
||||||
|
},
|
||||||
|
weight: 1,
|
||||||
|
direction: 'buy'
|
||||||
|
}),
|
||||||
|
|
||||||
|
deathCross: (fastMA: string, slowMA: string): SignalRule => ({
|
||||||
|
name: `Death Cross (${fastMA}/${slowMA})`,
|
||||||
|
condition: (indicators) => {
|
||||||
|
const fast = indicators[fastMA];
|
||||||
|
const slow = indicators[slowMA];
|
||||||
|
const prevFast = indicators[`${fastMA}_prev`];
|
||||||
|
const prevSlow = indicators[`${slowMA}_prev`];
|
||||||
|
return prevFast >= prevSlow && fast < slow;
|
||||||
|
},
|
||||||
|
weight: 1,
|
||||||
|
direction: 'sell'
|
||||||
|
}),
|
||||||
|
|
||||||
|
// RSI Rules
|
||||||
|
rsiOversold: (threshold = 30): SignalRule => ({
|
||||||
|
name: `RSI Oversold (<${threshold})`,
|
||||||
|
condition: (indicators) => indicators.rsi < threshold,
|
||||||
|
weight: 0.5,
|
||||||
|
direction: 'buy'
|
||||||
|
}),
|
||||||
|
|
||||||
|
rsiOverbought: (threshold = 70): SignalRule => ({
|
||||||
|
name: `RSI Overbought (>${threshold})`,
|
||||||
|
condition: (indicators) => indicators.rsi > threshold,
|
||||||
|
weight: 0.5,
|
||||||
|
direction: 'sell'
|
||||||
|
}),
|
||||||
|
|
||||||
|
// MACD Rules
|
||||||
|
macdBullishCross: (): SignalRule => ({
|
||||||
|
name: 'MACD Bullish Cross',
|
||||||
|
condition: (indicators) => {
|
||||||
|
return indicators.macd_prev < indicators.macd_signal_prev &&
|
||||||
|
indicators.macd > indicators.macd_signal;
|
||||||
|
},
|
||||||
|
weight: 0.8,
|
||||||
|
direction: 'buy'
|
||||||
|
}),
|
||||||
|
|
||||||
|
macdBearishCross: (): SignalRule => ({
|
||||||
|
name: 'MACD Bearish Cross',
|
||||||
|
condition: (indicators) => {
|
||||||
|
return indicators.macd_prev > indicators.macd_signal_prev &&
|
||||||
|
indicators.macd < indicators.macd_signal;
|
||||||
|
},
|
||||||
|
weight: 0.8,
|
||||||
|
direction: 'sell'
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Bollinger Band Rules
|
||||||
|
bollingerSqueeze: (threshold = 0.02): SignalRule => ({
|
||||||
|
name: `Bollinger Squeeze (<${threshold})`,
|
||||||
|
condition: (indicators) => {
|
||||||
|
const bandwidth = (indicators.bb_upper - indicators.bb_lower) / indicators.bb_middle;
|
||||||
|
return bandwidth < threshold;
|
||||||
|
},
|
||||||
|
weight: 0.3,
|
||||||
|
direction: 'both'
|
||||||
|
}),
|
||||||
|
|
||||||
|
priceAtLowerBand: (): SignalRule => ({
|
||||||
|
name: 'Price at Lower Bollinger Band',
|
||||||
|
condition: (indicators) => {
|
||||||
|
const bbPercent = (indicators.price - indicators.bb_lower) /
|
||||||
|
(indicators.bb_upper - indicators.bb_lower);
|
||||||
|
return bbPercent < 0.05;
|
||||||
|
},
|
||||||
|
weight: 0.6,
|
||||||
|
direction: 'buy'
|
||||||
|
}),
|
||||||
|
|
||||||
|
priceAtUpperBand: (): SignalRule => ({
|
||||||
|
name: 'Price at Upper Bollinger Band',
|
||||||
|
condition: (indicators) => {
|
||||||
|
const bbPercent = (indicators.price - indicators.bb_lower) /
|
||||||
|
(indicators.bb_upper - indicators.bb_lower);
|
||||||
|
return bbPercent > 0.95;
|
||||||
|
},
|
||||||
|
weight: 0.6,
|
||||||
|
direction: 'sell'
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common signal filters
|
||||||
|
*/
|
||||||
|
export const CommonFilters = {
|
||||||
|
// Minimum signal strength
|
||||||
|
minStrength: (threshold = 0.5): SignalFilter => ({
|
||||||
|
name: `Min Strength (${threshold})`,
|
||||||
|
filter: (signal) => Math.abs(signal.strength) >= threshold
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Minimum confidence
|
||||||
|
minConfidence: (threshold = 0.3): SignalFilter => ({
|
||||||
|
name: `Min Confidence (${threshold})`,
|
||||||
|
filter: (signal) => signal.confidence >= threshold
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Time of day filter
|
||||||
|
tradingHours: (startHour = 9.5, endHour = 16): SignalFilter => ({
|
||||||
|
name: `Trading Hours (${startHour}-${endHour})`,
|
||||||
|
filter: (signal) => {
|
||||||
|
const date = new Date(signal.timestamp);
|
||||||
|
const hour = date.getUTCHours() + date.getUTCMinutes() / 60;
|
||||||
|
return hour >= startHour && hour < endHour;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Trend alignment
|
||||||
|
trendAlignment: (trendIndicator = 'sma200'): SignalFilter => ({
|
||||||
|
name: `Trend Alignment (${trendIndicator})`,
|
||||||
|
filter: (signal) => {
|
||||||
|
const trend = signal.indicators[trendIndicator];
|
||||||
|
const price = signal.indicators.price;
|
||||||
|
if (!trend || !price) return true;
|
||||||
|
|
||||||
|
// Buy signals only above trend, sell signals only below
|
||||||
|
if (signal.direction === 'buy') {
|
||||||
|
return price > trend;
|
||||||
|
} else if (signal.direction === 'sell') {
|
||||||
|
return price < trend;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Volume confirmation
|
||||||
|
volumeConfirmation: (multiplier = 1.5): SignalFilter => ({
|
||||||
|
name: `Volume Confirmation (${multiplier}x)`,
|
||||||
|
filter: (signal) => {
|
||||||
|
const volume = signal.indicators.volume;
|
||||||
|
const avgVolume = signal.indicators.avg_volume;
|
||||||
|
if (!volume || !avgVolume) return true;
|
||||||
|
|
||||||
|
return volume >= avgVolume * multiplier;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
195
apps/stock/orchestrator/tests/indicators.test.ts
Normal file
195
apps/stock/orchestrator/tests/indicators.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
import { TechnicalIndicators, IncrementalSMA, IncrementalEMA, IncrementalRSI } from '@stock-bot/core';
|
||||||
|
import { TechnicalAnalysis, IncrementalIndicators, SignalGenerator } from '../src/indicators/TechnicalAnalysis';
|
||||||
|
|
||||||
|
describe('Technical Analysis Library', () => {
|
||||||
|
let ta: TechnicalAnalysis;
|
||||||
|
let indicators: TechnicalIndicators;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ta = new TechnicalAnalysis();
|
||||||
|
indicators = new TechnicalIndicators();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Simple Moving Average', () => {
|
||||||
|
it('should calculate SMA correctly', () => {
|
||||||
|
const values = [10, 12, 13, 14, 15, 16, 17, 18, 19, 20];
|
||||||
|
const sma = ta.sma(values, 5);
|
||||||
|
|
||||||
|
expect(sma).toHaveLength(6); // 10 values - 5 period + 1
|
||||||
|
expect(sma[0]).toBeCloseTo(12.8); // (10+12+13+14+15)/5
|
||||||
|
expect(sma[5]).toBeCloseTo(18); // (16+17+18+19+20)/5
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle incremental SMA updates', () => {
|
||||||
|
const incSMA = new IncrementalSMA(3);
|
||||||
|
|
||||||
|
expect(incSMA.update(10)).toBeNull();
|
||||||
|
expect(incSMA.update(12)).toBeNull();
|
||||||
|
expect(incSMA.update(14)).toBeCloseTo(12); // (10+12+14)/3
|
||||||
|
expect(incSMA.update(16)).toBeCloseTo(14); // (12+14+16)/3
|
||||||
|
expect(incSMA.current()).toBeCloseTo(14);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Exponential Moving Average', () => {
|
||||||
|
it('should calculate EMA correctly', () => {
|
||||||
|
const values = [10, 12, 13, 14, 15, 16, 17, 18, 19, 20];
|
||||||
|
const ema = ta.ema(values, 5);
|
||||||
|
|
||||||
|
expect(ema).toHaveLength(6);
|
||||||
|
expect(ema[0]).toBeGreaterThan(0);
|
||||||
|
expect(ema[ema.length - 1]).toBeGreaterThan(ema[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RSI', () => {
|
||||||
|
it('should calculate RSI correctly', () => {
|
||||||
|
const values = [
|
||||||
|
44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42,
|
||||||
|
45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28, 46.00,
|
||||||
|
46.03, 46.41, 46.22, 45.64
|
||||||
|
];
|
||||||
|
const rsi = ta.rsi(values, 14);
|
||||||
|
|
||||||
|
expect(rsi).toHaveLength(7); // 20 values - 14 period + 1
|
||||||
|
expect(rsi[rsi.length - 1]).toBeGreaterThan(0);
|
||||||
|
expect(rsi[rsi.length - 1]).toBeLessThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify overbought/oversold conditions', () => {
|
||||||
|
// Trending up values should give high RSI
|
||||||
|
const uptrend = Array.from({ length: 20 }, (_, i) => 100 + i);
|
||||||
|
const rsiUp = ta.rsi(uptrend, 14);
|
||||||
|
expect(rsiUp[rsiUp.length - 1]).toBeGreaterThan(70);
|
||||||
|
|
||||||
|
// Trending down values should give low RSI
|
||||||
|
const downtrend = Array.from({ length: 20 }, (_, i) => 100 - i);
|
||||||
|
const rsiDown = ta.rsi(downtrend, 14);
|
||||||
|
expect(rsiDown[rsiDown.length - 1]).toBeLessThan(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MACD', () => {
|
||||||
|
it('should calculate MACD components correctly', () => {
|
||||||
|
const values = Array.from({ length: 50 }, (_, i) => 100 + Math.sin(i * 0.1) * 10);
|
||||||
|
const macd = ta.macd(values);
|
||||||
|
|
||||||
|
expect(macd.macd).toHaveLength(39); // 50 - 26 + 1 - 9 + 1
|
||||||
|
expect(macd.signal).toHaveLength(39);
|
||||||
|
expect(macd.histogram).toHaveLength(39);
|
||||||
|
|
||||||
|
// Histogram should be the difference between MACD and signal
|
||||||
|
expect(macd.histogram[0]).toBeCloseTo(macd.macd[0] - macd.signal[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Bollinger Bands', () => {
|
||||||
|
it('should calculate bands correctly', () => {
|
||||||
|
const values = Array.from({ length: 30 }, (_, i) => 100 + Math.random() * 10);
|
||||||
|
const bb = ta.bollingerBands(values, 20, 2);
|
||||||
|
|
||||||
|
expect(bb.middle).toHaveLength(11); // 30 - 20 + 1
|
||||||
|
expect(bb.upper).toHaveLength(11);
|
||||||
|
expect(bb.lower).toHaveLength(11);
|
||||||
|
|
||||||
|
// Upper should be above middle, lower should be below
|
||||||
|
for (let i = 0; i < bb.middle.length; i++) {
|
||||||
|
expect(bb.upper[i]).toBeGreaterThan(bb.middle[i]);
|
||||||
|
expect(bb.lower[i]).toBeLessThan(bb.middle[i]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ATR', () => {
|
||||||
|
it('should calculate ATR correctly', () => {
|
||||||
|
const high = [48.70, 48.72, 48.90, 48.87, 48.82, 49.05, 49.20, 49.35, 49.92, 50.19];
|
||||||
|
const low = [47.79, 48.14, 48.39, 48.37, 48.24, 48.64, 48.94, 48.86, 49.50, 49.87];
|
||||||
|
const close = [48.16, 48.61, 48.75, 48.63, 48.74, 49.03, 49.07, 49.32, 49.91, 50.13];
|
||||||
|
|
||||||
|
const atr = ta.atr(high, low, close, 5);
|
||||||
|
|
||||||
|
expect(atr).toHaveLength(5); // 10 - 5 - 1 + 1
|
||||||
|
expect(atr.every(v => v > 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Stochastic', () => {
|
||||||
|
it('should calculate Stochastic correctly', () => {
|
||||||
|
const high = Array.from({ length: 20 }, () => Math.random() * 10 + 100);
|
||||||
|
const low = Array.from({ length: 20 }, (_, i) => high[i] - Math.random() * 5);
|
||||||
|
const close = Array.from({ length: 20 }, (_, i) => (high[i] + low[i]) / 2);
|
||||||
|
|
||||||
|
const stoch = ta.stochastic(high, low, close, 14, 3, 3);
|
||||||
|
|
||||||
|
expect(stoch.k.length).toBeGreaterThan(0);
|
||||||
|
expect(stoch.d.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// %K and %D should be between 0 and 100
|
||||||
|
expect(stoch.k.every(v => v >= 0 && v <= 100)).toBe(true);
|
||||||
|
expect(stoch.d.every(v => v >= 0 && v <= 100)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Signal Generator', () => {
|
||||||
|
it('should generate trading signals based on indicators', () => {
|
||||||
|
const generator = new SignalGenerator();
|
||||||
|
|
||||||
|
// Create synthetic price data
|
||||||
|
const prices = {
|
||||||
|
close: Array.from({ length: 50 }, (_, i) => 100 + Math.sin(i * 0.2) * 10),
|
||||||
|
high: Array.from({ length: 50 }, (_, i) => 102 + Math.sin(i * 0.2) * 10),
|
||||||
|
low: Array.from({ length: 50 }, (_, i) => 98 + Math.sin(i * 0.2) * 10),
|
||||||
|
volume: Array.from({ length: 50 }, () => 1000000)
|
||||||
|
};
|
||||||
|
|
||||||
|
const signal = generator.generateSignals('TEST', prices, Date.now());
|
||||||
|
|
||||||
|
expect(signal.symbol).toBe('TEST');
|
||||||
|
expect(['BUY', 'SELL', 'HOLD']).toContain(signal.action);
|
||||||
|
expect(signal.strength).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(signal.strength).toBeLessThanOrEqual(1);
|
||||||
|
expect(signal.indicators).toBeDefined();
|
||||||
|
expect(signal.reason).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Incremental Indicators Manager', () => {
|
||||||
|
it('should manage multiple incremental indicators', () => {
|
||||||
|
const manager = new IncrementalIndicators();
|
||||||
|
|
||||||
|
manager.createSMA('fast', 10);
|
||||||
|
manager.createSMA('slow', 20);
|
||||||
|
manager.createRSI('rsi', 14);
|
||||||
|
|
||||||
|
// Update all indicators with same value
|
||||||
|
for (let i = 0; i < 25; i++) {
|
||||||
|
const value = 100 + i;
|
||||||
|
manager.update('fast', value);
|
||||||
|
manager.update('slow', value);
|
||||||
|
manager.update('rsi', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(manager.current('fast')).toBeDefined();
|
||||||
|
expect(manager.current('slow')).toBeDefined();
|
||||||
|
expect(manager.current('rsi')).toBeDefined();
|
||||||
|
|
||||||
|
// RSI should be high for uptrending values
|
||||||
|
const rsiValue = manager.current('rsi');
|
||||||
|
expect(rsiValue).toBeGreaterThan(70);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Crossover Detection', () => {
|
||||||
|
it('should detect crossovers correctly', () => {
|
||||||
|
const series1 = [10, 11, 12, 13, 14];
|
||||||
|
const series2 = [12, 12, 12, 12, 12];
|
||||||
|
|
||||||
|
expect(TechnicalAnalysis.crossover(series1, series2)).toBe(true);
|
||||||
|
expect(TechnicalAnalysis.crossunder(series2, series1)).toBe(true);
|
||||||
|
|
||||||
|
const series3 = [15, 14, 13, 12, 11];
|
||||||
|
expect(TechnicalAnalysis.crossunder(series3, series2)).toBe(true);
|
||||||
|
expect(TechnicalAnalysis.crossover(series2, series3)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -14,7 +14,6 @@ export function BacktestPage() {
|
||||||
error,
|
error,
|
||||||
createBacktest,
|
createBacktest,
|
||||||
cancelBacktest,
|
cancelBacktest,
|
||||||
reset,
|
|
||||||
} = useBacktest();
|
} = useBacktest();
|
||||||
|
|
||||||
// Local state to bridge between the API format and the existing UI components
|
// Local state to bridge between the API format and the existing UI components
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue