amv-dev / yata

Yet Another Technical Analysis library [for Rust]
Apache License 2.0
321 stars 49 forks source link

`Indicator`s use `RegularMethods` instead of `Method` #16

Closed raimundomartins closed 2 years ago

raimundomartins commented 3 years ago

Most Indicators use the RegularMethods type. For me to use an existing indicator with a new Method I make I have to clone the source and add the new method to RegularMethods. Why don't they just use a Method?

Related to this is that by using the RegularMethods you are forcing the usage of Box and possible multiple-calculation of the same thing. If I want to use MACD and Keltner Channels in which one of the methods and its period is the same we are making double calculations. If Indicators take instead &'a dyn Method we would be both generalizing and allowing for more optimizations.

amv-dev commented 3 years ago

Most Indicators use the RegularMethods type. For me to use an existing indicator with a new Method I make I have to clone the source and add the new method to RegularMethods. Why don't they just use a Method?

Yes, it is a real issue for now. But there were bunch of reasons and decisions behind RegularMethods type.

First of all, we can't just use dyn Method, neither immutable, nor mutable because we lose all invariants between whole indicator and method it uses. All the parts inside Indicator must be synchronized. There is a chance that programmer can provide non-sychronized method into Indicator's config. Something like this:

let mut candles = yata::helpers::RandomCandles::default(); // just some candles data

let mut ma1 = EMA::new(5, candles.next().close()); // fast MA

let mut ma2 = EMA::new(10, candles.next().close()); // slow MA
ma2.next(candles.next().close()); // ma2 is totally desynced with ma1

let mut macd = MACD::default();
macd.ma1 = &mut ma1;
macd.ma2 = &mut ma2;
// Now `macd` has totally wrong state inside

Second reason is that sometimes we need one method result to initialize another method inside indicator. Therefore we can't just assign method instance to an indicator's config. For example, MACD has three methods inside. Two of them use original data, but third is based upon other two methods results. So we can't just assign method3 to MACD with &dyn Method.

There are some other reasons for RegularMethods to be part of this library for now. It might be not a good solution, but I don't know better one for now.

Related to this is that by using the RegularMethods you are forcing the usage of Box and possible multiple-calculation of the same thing. If I want to use MACD and Keltner Channels in which one of the methods and its period is the same we are making double calculations. If Indicators take instead &'a dyn Method we would be both generalizing and allowing for more optimizations.

It is not possible even with &dyn Method. Every time you call next on any Method you mutate it's state. So if you want provide single method references to several indicators, you run into big issue with Rust rules of mutability. Both indicators must have mutable reference to method's instance. This is where Rust really helps to avoid mistakes because every time first indicator uses method it modifies it's state. So second indicator will have different state of the same method.

raimundomartins commented 3 years ago

All the parts inside Indicator must be synchronized.

Perhaps in some cases, but who's to say I don't want to try out something out-of-the-box and delay the slow MA by one candle (the opposite of what you suggested)? For me the whole idea of using Rust and not some tradeview script is the liberty to mess around all I want as well as reuse code later for some bot.

Second reason is that sometimes we need one method result to initialize another method inside indicator.

This is a good point, indeed. But the whole initialization feels wrong as it is anyway. I've read that the recommended initial value of EMA is SMA, yet you use the first candle. Plus for MACD, if I choose E/D/TMA it takes much more than period3 periods for the "wrong" initial value of the first candle to fully dissipate. Anyway, for this particular issue, MACD could be generic over a Method<Input = PeriodType, Params = ValueType> like every RegularMethod is, essentially removing the Box. It could even store the Method directly in its fields, instead of a reference. Have you ever considered using generics? (honest question, not a confrontational one)

It is not possible even with &dyn Method.

Yes, this occured to me only a while after I opened the issue. It would require some complex stuff to make this work for probably too little benefit. Withdrawn...

amv-dev commented 3 years ago

the whole initialization feels wrong as it is anyway. I've read that the recommended initial value of EMA is SMA

It is useless recommendation. Just ask yourself: if initial value of EMA is SMA, then what initial value of that SMA?

Initialization of methods and indicators may depend, but this library aims to follow next invariant:

It's like you have "virtual" infinite previous history with constant value.

if I choose E/D/TMA it takes much more than period3 periods for the "wrong" initial value of the first candle to fully dissipate

Exponential moving averages uses all the history it has. There is no such a period, after you can tell "Now it's totally correct". So, again, the same invariant of infinite previous history helps a lot.

MACD could be generic over a Method like every RegularMethod is, essentially removing the Box. It could even store the Method directly in its fields, instead of a reference.

Well... It can... But MACD has three different moving averages. So there must be three different generic parameters for MACD:

struct<M1, M2, M3> MACDInstance 
where M1: Method<Params = ValueType, Input = PeriodType, Output = ValueType>,
M2: Method<Params = ValueType, Input = PeriodType, Output = ValueType>
M3: Method<Params = ValueType, Input = PeriodType, Output = ValueType>
{
// ...
}

It may become a generic hell. There are bunch of other negative side effects of this architecture. F.e. you can't no longer return IndicatorInstance from IndicatorConfig, because of that generic parameters.

raimundomartins commented 3 years ago

It is useless recommendation. Just ask yourself: if initial value of EMA is SMA, then what initial value of that SMA?

Ah, that one is easy and the the core of why initialization feels off to me: it is not initialized until it gets period inputs.

Exponential moving averages uses all the history it has.

Mathematically, yes, but in practice eventually far away periods become meaningless and even get lost in the floating point precision, as I'm sure you're aware.

you can't no longer return IndicatorInstance from IndicatorConfig, because of that generic parameters.

I fail to understand this. You would add verbosity to implementors of IndicatorConfig but it wouldn't be impossible. I think we just have different views on the subject of generics and there are obviously cons and pros for both approaches, so I'll stop pushing that button.

But I'd really love if your crate were more open to extensability! As I see it, this mainly requires separating configuration from initialization (like not assuming Method is ready on a single value). In this particular case, EMA would take the number of periods as config, but MACD would give the initial value. You would then be able to send any &dyn Method. I don't know all of your codebase, so maybe there are reasons not to do this.

Note that my issue is only against enum RegularMethods not type RegularMethod (even if it has a Box which I don't enjoy :P)

amv-dev commented 3 years ago

As I see it, this mainly requires separating configuration from initialization (like not assuming Method is ready on a single value). In this particular case, EMA would take the number of periods as config, but MACD would give the initial value.

I thought about this issue and came to the same decision. So my proposal is to make a trait MovingAverageConfig like this:

pub trait MovingAverageConfig {
  fn init(&self, initial_value: ValueType) -> Result<Box<dyn Method<Params = PeriodType, Input = ValueType, Output = ValueType>>, Error>;
}

Then create enum similar to RegularMethods which will implement MovingAverageConfig:

pub enum MovingAverages {
   SMA(PeriodType),
   WMA(PeriodType,
  // ...
}

impl MovingAverageConfig for MovingAverages {
  fn init(&self, initial_value: ValueType) -> Result<Box<dyn Method<Params = PeriodType, Input = ValueType, Output = ValueType>>, Error> {
    match self {
      Self::SMA(length) => Ok(Box::new(SMA::new(length, initial_value)?),
      Self::WMA(length) => Ok(Box::new(WMA::new(length, initial_value)?),
      // ...
    }
  }
}

And indicators will be generalized over M:

pub struct MACD<M: MovingAverageConfig> {
  // ...
}

On one hand it is pretty the same as it's implemented now. On the other hand, if you need to add your own moving average, you just need to create your own enum (or any other type) and implement MovingAverageConfig for it. Then you can use your own moving averages constructor in any indicator.

raimundomartins commented 3 years ago

Yes, I think something like this is much better! I'm assuming MACD now takes 3 MovingAverageConfig instances, right?

Maybe with a macro it gets simple to even impl MovingAverageConfig for *MA to use in other indicators. Also, I don't know if MovingAverage is the best name as this may apply to other methods. Maybe RegularMethodConfig is better?

amv-dev commented 3 years ago

I've created a new branch. There are lot of changes, but the most important are:

method, RegularMethod and RegularMethods are totally removed.

So if you want to use your own moving average, you need to create some type (probably just an enum) and implement MovingAverageConstructor for it. This is still WIP, but you can try it.

amv-dev commented 2 years ago

Resolved in v0.5