mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-05 13:35:21 +02:00
Refactor TimeSeries
artifacts (#651)
* Reindent TimeSeries classes * Fix spacing in time series tests * Remove trend tests where current is nil I think if we've gotten this far with a nil value for current, there's a data integrity problem. If we allow this, we'll have to be very defensive in our code. Best to raise and fix early. * Reindent Money class * Refactor TimeSeries artifacts * Use as_json in TimeSeries * Bring back tests for trends where current is nil * Bring back trend test * Correctly enumerate trend test * Use favorable_direction for trend_styles helper * Make trend public in TimeSeries::Value * Allow nil current values in trends I think I might've gotten it wrong before, nils might appear in trends if values are unavailable for snapshots * Clean up TimeSeries::Trend * Skip trend values same class validations if any values are nil * Refactor Money * Remove object parsing in TimeSeries::Value We're only every passing hashes
This commit is contained in:
parent
fe2a2ac3f9
commit
fc3ade392a
9 changed files with 258 additions and 206 deletions
|
@ -1,83 +1,57 @@
|
|||
|
||||
class TimeSeries
|
||||
attr_reader :type
|
||||
DIRECTIONS = %w[ up down ].freeze
|
||||
|
||||
def self.from_collection(collection, value_method, options = {})
|
||||
data = collection.map do |obj|
|
||||
{ date: obj.date, value: obj.public_send(value_method), original: obj }
|
||||
end
|
||||
new(data, options)
|
||||
attr_reader :values, :favorable_direction
|
||||
|
||||
def self.from_collection(collection, value_method)
|
||||
collection.map do |obj|
|
||||
{
|
||||
date: obj.date,
|
||||
value: obj.public_send(value_method),
|
||||
original: obj
|
||||
}
|
||||
end.then { |data| new(data) }
|
||||
end
|
||||
|
||||
def initialize(data, favorable_direction: "up")
|
||||
@favorable_direction = (favorable_direction.presence_in(DIRECTIONS) || "up").inquiry
|
||||
@values = initialize_values data
|
||||
end
|
||||
|
||||
def first
|
||||
values.first
|
||||
end
|
||||
|
||||
def last
|
||||
values.last
|
||||
end
|
||||
|
||||
def on(date)
|
||||
values.find { |v| v.date == date }
|
||||
end
|
||||
|
||||
def trend
|
||||
TimeSeries::Trend.new \
|
||||
current: last&.value,
|
||||
previous: first&.value,
|
||||
series: self
|
||||
end
|
||||
|
||||
# `as_json` returns the data shape used by D3 charts
|
||||
def as_json
|
||||
{
|
||||
values: values.map(&:as_json),
|
||||
trend: trend.as_json,
|
||||
favorable_direction: favorable_direction
|
||||
}.as_json
|
||||
end
|
||||
|
||||
private
|
||||
def initialize_values(data)
|
||||
[ nil, *data ].each_cons(2).map do |previous, current|
|
||||
TimeSeries::Value.new **current,
|
||||
previous_value: previous.try(:[], :value),
|
||||
series: self
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(data, options = {})
|
||||
@type = options[:type] || :normal
|
||||
initialize_series_data(data)
|
||||
end
|
||||
|
||||
def values
|
||||
@values ||= add_trends_to_series
|
||||
end
|
||||
|
||||
def first
|
||||
values.first
|
||||
end
|
||||
|
||||
def last
|
||||
values.last
|
||||
end
|
||||
|
||||
def on(date)
|
||||
values.find { |v| v.date == date }
|
||||
end
|
||||
|
||||
def trend
|
||||
TimeSeries::Trend.new(
|
||||
current: last&.value,
|
||||
previous: first&.value,
|
||||
type: @type
|
||||
)
|
||||
end
|
||||
|
||||
# Data shape that frontend expects for D3 charts
|
||||
def to_json(*_args)
|
||||
{
|
||||
values: values.map do |v|
|
||||
{
|
||||
date: v.date,
|
||||
value: JSON.parse(v.value.to_json),
|
||||
trend: {
|
||||
type: v.trend.type,
|
||||
direction: v.trend.direction,
|
||||
value: JSON.parse(v.trend.value.to_json),
|
||||
percent: v.trend.percent
|
||||
}
|
||||
}
|
||||
end,
|
||||
trend: {
|
||||
type: @type,
|
||||
direction: trend.direction,
|
||||
value: JSON.parse(trend.value.to_json),
|
||||
percent: trend.percent
|
||||
},
|
||||
type: @type
|
||||
}.to_json
|
||||
end
|
||||
|
||||
private
|
||||
def initialize_series_data(data)
|
||||
@series_data = data.nil? || data.empty? ? [] : data.map { |d| TimeSeries::Value.new(d) }.sort_by(&:date)
|
||||
end
|
||||
|
||||
def add_trends_to_series
|
||||
[ nil, *@series_data ].each_cons(2).map do |previous, current|
|
||||
unless current.trend
|
||||
current.trend = TimeSeries::Trend.new(
|
||||
current: current.value,
|
||||
previous: previous&.value,
|
||||
type: @type
|
||||
)
|
||||
end
|
||||
current
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,48 +1,93 @@
|
|||
class TimeSeries::Trend
|
||||
attr_reader :type
|
||||
include ActiveModel::Validations
|
||||
|
||||
# Tells us whether an increasing/decreasing trend is good or bad (i.e. a liability decreasing is good)
|
||||
TYPES = %i[normal inverse].freeze
|
||||
attr_reader :current, :previous
|
||||
|
||||
def initialize(current: nil, previous: nil, type: :normal)
|
||||
validate_data_types(current, previous)
|
||||
validate_type(type)
|
||||
delegate :favorable_direction, to: :series
|
||||
|
||||
validate :values_must_be_of_same_type, :values_must_be_of_known_type
|
||||
|
||||
def initialize(current:, previous:, series: nil)
|
||||
@current = current
|
||||
@previous = previous
|
||||
@type = type
|
||||
@series = series
|
||||
|
||||
validate!
|
||||
end
|
||||
|
||||
def direction
|
||||
return "flat" if @previous.nil? || @current == @previous
|
||||
return "up" if @current && @current > @previous
|
||||
"down"
|
||||
if previous.nil? || current == previous
|
||||
"flat"
|
||||
elsif current && current > previous
|
||||
"up"
|
||||
else
|
||||
"down"
|
||||
end.inquiry
|
||||
end
|
||||
|
||||
def value
|
||||
return Money.new(0) if @previous.nil? && @current.is_a?(Money)
|
||||
return 0 if @previous.nil?
|
||||
@current - @previous
|
||||
if previous.nil?
|
||||
current.is_a?(Money) ? Money.new(0) : 0
|
||||
else
|
||||
current - previous
|
||||
end
|
||||
end
|
||||
|
||||
def percent
|
||||
return 0.0 if @previous.nil?
|
||||
return Float::INFINITY if @previous == 0
|
||||
((extract_numeric(@current) - extract_numeric(@previous)).abs / extract_numeric(@previous).abs.to_f * 100).round(1).to_f
|
||||
if previous.nil?
|
||||
0.0
|
||||
elsif previous.zero?
|
||||
Float::INFINITY
|
||||
else
|
||||
change = (current_amount - previous_amount).abs
|
||||
base = previous_amount.abs.to_f
|
||||
|
||||
(change / base * 100).round(1).to_f
|
||||
end
|
||||
end
|
||||
|
||||
def as_json
|
||||
{
|
||||
favorable_direction: favorable_direction,
|
||||
direction: direction,
|
||||
value: value,
|
||||
percent: percent
|
||||
}.as_json
|
||||
end
|
||||
|
||||
private
|
||||
def validate_type(type)
|
||||
raise ArgumentError, "Invalid type" unless TYPES.include?(type)
|
||||
attr_reader :series
|
||||
|
||||
def values_must_be_of_same_type
|
||||
unless current.class == previous.class || [ previous, current ].any?(&:nil?)
|
||||
errors.add :current, "must be of the same type as previous"
|
||||
errors.add :previous, "must be of the same type as current"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_data_types(current, previous)
|
||||
return if previous.nil? || current.nil?
|
||||
raise ArgumentError, "Current and previous values must be of the same type" unless current.class == previous.class
|
||||
raise ArgumentError, "Current and previous values must be of type Money or Numeric" unless current.is_a?(Money) || current.is_a?(Numeric)
|
||||
def values_must_be_of_known_type
|
||||
unless current.is_a?(Money) || current.is_a?(Numeric) || current.nil?
|
||||
errors.add :current, "must be of type Money, Numeric, or nil"
|
||||
end
|
||||
|
||||
unless previous.is_a?(Money) || previous.is_a?(Numeric) || previous.nil?
|
||||
errors.add :previous, "must be of type Money, Numeric, or nil"
|
||||
end
|
||||
end
|
||||
|
||||
def current_amount
|
||||
extract_numeric current
|
||||
end
|
||||
|
||||
def previous_amount
|
||||
extract_numeric previous
|
||||
end
|
||||
|
||||
def extract_numeric(obj)
|
||||
return obj.amount if obj.is_a? Money
|
||||
obj
|
||||
if obj.is_a? Money
|
||||
obj.amount
|
||||
else
|
||||
obj
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,32 +1,46 @@
|
|||
class TimeSeries::Value
|
||||
include Comparable
|
||||
include Comparable
|
||||
include ActiveModel::Validations
|
||||
|
||||
attr_accessor :trend
|
||||
attr_reader :value, :date, :original
|
||||
attr_reader :value, :date, :original, :trend
|
||||
|
||||
def initialize(obj)
|
||||
@original = obj.fetch(:original, obj)
|
||||
validates :date, presence: true
|
||||
validate :value_must_be_of_known_type
|
||||
|
||||
if obj.is_a?(Hash)
|
||||
@date = obj[:date]
|
||||
@value = obj[:value]
|
||||
else
|
||||
@date = obj.date
|
||||
@value = obj.value
|
||||
end
|
||||
def initialize(date:, value:, original: nil, series: nil, previous_value: nil)
|
||||
@date, @value, @original, @series = date, value, original, series
|
||||
@trend = create_trend previous_value
|
||||
|
||||
validate_input
|
||||
validate!
|
||||
end
|
||||
|
||||
def <=>(other)
|
||||
result = date <=> other.date
|
||||
result = value <=> other.value if result == 0
|
||||
result
|
||||
end
|
||||
|
||||
def as_json
|
||||
{
|
||||
date: date,
|
||||
value: value.as_json,
|
||||
trend: trend.as_json
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :series
|
||||
|
||||
def create_trend(previous_value)
|
||||
TimeSeries::Trend.new \
|
||||
current: value,
|
||||
previous: previous_value,
|
||||
series: series
|
||||
end
|
||||
|
||||
def <=>(other)
|
||||
result = date <=> other.date
|
||||
result = value <=> other.value if result == 0
|
||||
result
|
||||
def value_must_be_of_known_type
|
||||
unless value.is_a?(Money) || value.is_a?(Numeric)
|
||||
errors.add :value, "must be a Money or Numeric"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def validate_input
|
||||
raise ArgumentError, "Date is required" unless @date.is_a?(Date)
|
||||
raise ArgumentError, "Money or Numeric value is required" unless @value.is_a?(Money) || @value.is_a?(Numeric)
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue