File Coverage

blib/lib/App/Chart/Series.pm
Criterion Covered Total %
statement 21 23 91.3
branch n/a
condition n/a
subroutine 8 8 100.0
pod n/a
total 29 31 93.5


line stmt bran cond sub pod time code
1             # Copyright 2007, 2008, 2009, 2010, 2011, 2016 Kevin Ryde
2              
3             # This file is part of Chart.
4             #
5             # Chart is free software; you can redistribute it and/or modify it under the
6             # terms of the GNU General Public License as published by the Free Software
7             # Foundation; either version 3, or (at your option) any later version.
8             #
9             # Chart is distributed in the hope that it will be useful, but WITHOUT ANY
10             # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11             # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12             # details.
13             #
14             # You should have received a copy of the GNU General Public License along
15             # with Chart. If not, see <http://www.gnu.org/licenses/>.
16              
17             package App::Chart::Series;
18 22     22   6698 use 5.010;
  22         71  
19 22     22   119 use strict;
  22         44  
  22         452  
20 22     22   101 use warnings;
  22         38  
  22         541  
21 22     22   106 use Carp;
  22         41  
  22         1485  
22 22     22   130 use List::Util qw(min max);
  22         43  
  22         1806  
23 22     22   6126 use Locale::TextDomain ('App-Chart');
  22         327619  
  22         119  
24 22     22   96964 use Set::IntSpan::Fast 1.10; # 1.10 for contains_all_range
  22         138124  
  22         560  
25              
26 22     22   6571 use App::Chart;
  0            
  0            
27              
28             # uncomment this to run the ### lines
29             #use Devel::Comments '###';
30              
31             # defaults
32             use constant { default_linestyle => 'Line',
33             dividends => [],
34             splits => [],
35             annotations => [],
36             parameter_info => [],
37             minimum => undef,
38             maximum => undef,
39             hlines => [],
40             units => '',
41             };
42              
43             sub new {
44             #### Series new: @_
45             my $class = shift;
46             my $self = bless { fill_set => Set::IntSpan::Fast->new,
47             @_ }, $class;
48             if (my $parent = $self->{'parent'}) {
49             $self->{'arrays'} ||= { map {; $_ => [] }
50             keys %{$parent->{'arrays'}} };
51             $self->{'array_aliases'} ||= $parent->{'array_aliases'};
52             }
53             $self->{'array_aliases'} ||= {};
54             return $self;
55             }
56              
57             sub name {
58             my ($self) = @_;
59             return $self->{'name'};
60             }
61             sub parent {
62             my ($self) = @_;
63             return $self->{'parent'};
64             }
65              
66             sub timebase {
67             my ($self) = @_;
68             return ($self->{'timebase'} || $self->{'parent'}->timebase);
69             }
70              
71             sub decimals {
72             my ($self) = @_;
73             if (exists $self->{'decimals'}) { return $self->{'decimals'}; }
74             return $self->{'parent'}->decimals;
75             }
76              
77             sub symbol {
78             my ($self) = @_;
79             if (exists $self->{'symbol'}) { return $self->{'symbol'}; }
80             if (my $parent = $self->{'parent'}) { return $parent->symbol; }
81             return undef;
82             }
83              
84             sub hi {
85             my ($self) = @_;
86             if (exists $self->{'hi'}) { return $self->{'hi'}; }
87             return $self->{'parent'}->hi;
88             }
89              
90             sub values_array {
91             my ($self) = @_;
92             return $self->array('values');
93             }
94             sub array {
95             my ($self, $aname) = @_;
96             return ($self->{'arrays'}->{$aname}
97             || do { my $alias = $self->{'array_aliases'}->{$aname};
98             $alias && $self->{'arrays'}->{$alias} });
99             # croak "No such array in $self: $aname");
100             }
101             sub array_names {
102             my ($self) = @_;
103             return keys %{$self->{'arrays'}};
104             }
105              
106             sub fill {
107             my ($self, $lo, $hi) = @_;
108             if ($lo > $hi) { croak "Series fill lo>hi ($lo, $hi)\n"; }
109             if ($hi < 0) { return; } # nothing
110             {
111             my $sh = $self->hi;
112             $lo = min ($sh, max (0, $lo));
113             $hi = min ($sh, $hi);
114             }
115              
116             my $got_set = $self->{'fill_set'};
117             if ($got_set->contains_all_range ($lo, $hi)) { return; } # covered already
118              
119             my $want_set = Set::IntSpan::Fast->new;
120             $want_set->add_range ($lo, $hi);
121             $want_set = $want_set->diff ($got_set);
122             ### fill: "$lo-$hi of ".ref($self)
123             ### want: $want_set->as_string." on top of ".$got_set->as_string
124              
125             # merge now so don't repeat if error in code below
126             $got_set->merge ($want_set);
127             ### merge to: $got_set->as_string
128              
129             my $values = $self->values_array;
130             my @array_names = $self->array_names;
131             if ($self->array('volumes') && $self->array('volumes') != $values) {
132             @array_names = grep {$_ ne 'volumes'} @array_names;
133             }
134             if ($self->array('openints') && $self->array('openints') != $values) {
135             @array_names = grep {$_ ne 'openints'} @array_names;
136             }
137             my @arrays = map {$self->array($_)} @array_names;
138              
139             my $method = ($self->can('fill_part')
140             || ($self->can('proc') && 'fill_part_from_proc')
141             || die "Series $self has no fill method");
142              
143             my $iter = $want_set->iterate_runs;
144             while (my ($lo, $hi) = $iter->()) {
145             ### do: "$method $lo, $hi"
146             $self->$method ($lo, $hi);
147              
148             foreach my $array (@arrays) {
149             $self->{'fill_high'} = App::Chart::max_maybe ($self->{'fill_high'},
150             @{$array}[$lo .. $hi]);
151             $self->{'fill_low'} = App::Chart::min_maybe ($self->{'fill_low'},
152             @{$array}[$lo .. $hi]);
153             }
154             ### merge to: $got_set->as_string
155             }
156             }
157              
158             sub range {
159             my ($self, $lo, $hi, @array_names) = @_;
160             ### Series range: "$lo $hi of @array_names"
161             $lo = max ($lo, 0);
162             if ($hi < $lo) { return; } # eg. lo==-5 hi==-1, no data before 0
163             $self->fill ($lo, $hi);
164             my $arrays_hash = $self->{'arrays'}; # hash
165             if (! @array_names) {
166             @array_names = $self->range_default_names;
167             }
168             my @arefs = @{$arrays_hash}{@array_names}; # hash slice
169              
170             require List::MoreUtils;
171             return List::MoreUtils::minmax
172             (grep {defined} map { @{$_}[$lo .. $hi] } @arefs);
173             }
174             sub range_default_names {
175             my ($self) = @_;
176             return keys %{$self->{'arrays'}}; # all arrays
177             }
178              
179             sub linestyle {
180             my ($self, $newval) = @_;
181             if (@_ >= 2) {
182             $self->{'linestyle'} = $newval;
183             } elsif ($self->{'linestyle'}) {
184             return $self->{'linestyle'};
185             } else {
186             return $self->default_linestyle;
187             }
188             }
189             sub linestyle_class {
190             my ($self) = @_;
191             my $linestyle = $self->linestyle // return undef;
192             return "App::Chart::Gtk2::LineStyle::$linestyle";
193             }
194              
195             # Return (LOWER UPPER) which is a suggested initial Y-axis page range to
196             # show for dates LO to HI. This is for use both with
197             # App::Chart::Series::OHLCVI and also any other series type without its own
198             # specific style.
199             #
200             # As described in "Main Window" in the manual, the aim is to scale according
201             # to apparent volatility, so that daily range or daily close-to-close change
202             # are some modest fraction of the initial page. In particular if the series
203             # is just going sideways it's not zoomed out to try to fill the whole
204             # screen. The absolute price level is not used, so say bond prices which
205             # hover near 100 still get scaled out to make typical daily changes of 0.1
206             # or whatever visible.
207             #
208             sub initial_range {
209             my ($self, $lo, $hi) = @_;
210             ### Series initial_range: "$lo to $hi $self"
211             $lo = max ($lo, 0);
212             $hi = max ($hi, 0);
213             $self->fill ($lo, $hi);
214             my $highs = $self->array('highs') // [];
215             my $lows = $self->array('lows') // $highs;
216             my $values = $self->values_array;
217             my @diffs = ();
218              
219             my $timebase = $self->timebase;
220             my $latest;
221             if ($self->units eq 'price'
222             && (my $symbol = $self->symbol)) {
223             $latest = defined $symbol && App::Chart::Latest->get($symbol);
224             }
225             if ($latest
226             && defined $latest->{'high'}
227             && defined $latest->{'low'}
228             && defined (my $last_iso = $latest->{'last_date'})) {
229             my $last_t = $timebase->from_iso_floor ($last_iso);
230             if ($last_t >= $lo && $last_t <= $hi) {
231             push @diffs, $latest->{'high'} - $latest->{'low'};
232             }
233             }
234              
235             # high to low ranges in ohlcv
236             if ($highs != $lows) {
237             foreach my $i ($lo .. $hi) {
238             if (defined $highs->[$i] && defined $lows->[$i]) {
239             my $diff = CORE::abs ($highs->[$i] - $lows->[$i]);
240             if ($diff != 0) {
241             push @diffs, $diff;
242             }
243             }
244             }
245             }
246              
247             # ENHANCE-ME: look at all parts of a multi-line like macd, bollinger,
248             # guppy, etc
249             # (if (= 2 (array-rank array))
250             # # macd, not quite right
251             # (set! array (make-shared-array-column array 0)))
252              
253             # close to close ranges
254             {
255             my $prev;
256             foreach my $i ($lo .. $hi) {
257             my $value = $values->[$i];
258             if (! defined $value) { next; }
259              
260             if (defined $prev) {
261             my $diff = CORE::abs ($value - $prev);
262             if ($diff != 0) {
263             push @diffs, $diff;
264             }
265             }
266             $prev = $value;
267             }
268             }
269              
270             if (! @diffs) {
271             ### no diffs for initial range: "$lo $hi"
272             my ($l, $h) = $self->range ($lo, $hi);
273             if (defined $l) {
274             # for just a single close value pretend 20% around the value
275             return $h * 0.8, $l / 0.8;
276             }
277             return;
278             }
279              
280             # page will show 25x the median range
281             @diffs = sort {$a <=> $b} @diffs;
282             my $page = 25 * $diffs[CORE::int ($#diffs / 2)];
283             ### initial page by: "25*median is $page"
284              
285             # make page no more than twice upper,lower data range, so the
286             # data is not less than half the window
287             { my ($l, $h) = $self->range ($lo, $hi);
288             if (defined $l) {
289             ### series range: "$l to $h"
290             $page = min ($page, 2 * ($h - $l));
291             }
292             }
293             ### shrink to use minimum half window: $page
294              
295             # make page no smaller than last 1/2 of data, so that's visible
296             { my ($l, $h) = $self->range (CORE::int (($lo + $hi) / 2), $hi);
297             if ($l) { $page = max ($page, $h - $l); }
298             }
299             ### expand so last half data visible: $page
300              
301             my ($l, $h);
302             my $accumulate = sub {
303             my ($value) = @_;
304             ### accumulate: $value
305             if (! defined $value) { return 1; }
306             my $new_l = defined $l ? min ($value, $l) : $value;
307             my $new_h = defined $h ? max ($value, $h) : $value;
308             if ($new_h - $new_l <= $page) {
309             $l = $new_l;
310             $h = $new_h;
311             }
312             if (! defined $l) {
313             $l = $new_l;
314             $h = $new_l + $page;
315             }
316             return 0;
317             };
318             if ($latest) {
319             if (defined (my $quote_iso = $latest->{'quote_date'})) {
320             my $quote_t = $timebase->from_iso_floor ($quote_iso);
321             if ($quote_t >= $lo && $quote_t <= $hi) {
322             $accumulate->($latest->{'bid'});
323             $accumulate->($latest->{'offer'});
324             }
325             }
326             if (defined (my $last_iso = $latest->{'last_date'})) {
327             my $last_t = $timebase->from_iso_floor ($last_iso);
328             if ($last_t >= $lo && $last_t <= $hi) {
329             $accumulate->($latest->{'last'});
330             $accumulate->($latest->{'high'});
331             $accumulate->($latest->{'low'});
332             }
333             }
334             }
335             for (my $i = $hi; $i >= $lo; $i--) {
336             foreach my $value ($values->[$i], $highs->[$i], $lows->[$i]) {
337             $accumulate->($value);
338             }
339             }
340              
341             my $extra = ($page - ($h - $l)) / 2;
342             $l -= $extra;
343             $h += $extra;
344             ### initial range decided: "$l $h $self"
345             return ($l, $h);
346             }
347              
348             # # don't go below `datatype-minimum' (if present), so long as
349             # # the actual data respects that minimum
350             # (and-let* ((minimum (datatype-minimum datatype))
351             # (actual-min (apply min-maybe lst-closes))
352             # ( (>= actual-min minimum)))
353             # (set! lower (max lower minimum)))
354              
355             sub filled_low {
356             my ($self) = @_;
357             return $self->{'fill_low'};
358             }
359             sub filled_high {
360             my ($self) = @_;
361             return $self->{'fill_high'};
362             }
363              
364             sub find_before {
365             my ($self, $before, $n) = @_;
366             ### Series find_before(): "before=$before n=$n"
367             if ($n <= 0) { return $before; } # asking for no points before
368              
369             my $values = $self->values_array;
370             my $chunk = $n;
371              
372             my $i = $before - 1;
373             for (;;) {
374             $chunk *= 2;
375             my $pre = $i - $chunk;
376             $self->fill ($pre, $i);
377              
378             for ( ; $i >= $pre; $i--) {
379             if ($i < 0) {
380             ### not found, return 0
381             return 0;
382             }
383             if (defined $values->[$i]) {
384             $n--;
385             if ($n <= 0) {
386             ### find_before() found: $i
387             return $i;
388             }
389             }
390             }
391             }
392             }
393              
394             # return pos where there's a value somwhere $pos > $after, or $after if no more
395             sub find_after {
396             my ($self, $after, $n) = @_;
397             ### Series find_after(): "$after n=".($n//'undef')
398             if ($n <= 0) { return $after; } # asking for no points after
399              
400             my $values = $self->values_array;
401             my $hi = $self->hi;
402             my $chunk = $n;
403              
404             my $i = $after + 1;
405             $i = max ($i, 0);
406             for (;;) {
407             $chunk *= 2;
408             my $post = $i + $chunk;
409             $self->fill ($i, $post);
410             for ( ; $i <= $post; $i++) {
411             if ($i > $hi) { return $hi; }
412             if (defined $values->[$i]) {
413             $n--;
414             if ($n <= 0) {
415             ### find_after() found: $i
416             return $i;
417             }
418             }
419             }
420             }
421             }
422              
423             #------------------------------------------------------------------------------
424              
425             sub AUTOLOAD {
426             our $AUTOLOAD;
427             ### Series AUTOLOAD $AUTOLOAD
428             my $name = $AUTOLOAD;
429             $name =~ s/(.*):://;
430             if (my $subr = __PACKAGE__->can($name)) {
431             { no strict; *$name = $subr; }
432             goto &$subr;
433             }
434             croak "App::Chart::Series unknown function '$name'";
435             }
436              
437             sub can {
438             my ($self_or_class, $name) = @_;
439             ### Series can(): "$self_or_class '$name'"
440              
441             return $self_or_class->SUPER::can($name) || do {
442             if ($name =~ /^GT_/p) {
443             require App::Chart::Series::GT;
444             my $type = "I:${^POSTMATCH}";
445             return sub { App::Chart::Series::GT->new ($type, @_) };
446             }
447             if ($name =~ /^TA_/p) {
448             require App::Chart::Series::TA;
449             my $type = ${^POSTMATCH};
450             return sub { App::Chart::Series::TA->new ($type, @_) };
451             }
452             require Module::Util;
453             my $class = "App::Chart::Series::Derived::\u$name";
454             Module::Util::find_installed($class)
455             || return undef; # no such plugin
456              
457             # if (DEBUG) { print " func $name class $class\n";
458             # if (eval { Module::Load::load ($class); 1 }) {
459             # no strict 'refs';
460             # say " loads ok, new() is ", \&{"${class}::new"};
461             # } else {
462             # say " didn't load -- $@";
463             # }
464             # }
465              
466             require Module::Load;
467             Module::Load::load ($class);
468             return sub { $class->new (@_) };
469             };
470             }
471              
472             # avoid going through AUTOLOAD for destroy
473             sub DESTROY {
474             }
475              
476             use overload
477             '0+' => sub { croak 'Cannot use App::Chart::Series as a number' },
478             'bool' => sub { 1 },
479             '!' => sub { 0 },
480             '""' => sub { $_[0] },
481             '@{}' => sub { $_[0]->fill(0,$_[0]->hi); $_[0]->values_array },
482             '+' => \&_overload_add,
483             '-' => \&sub,
484             '*' => \&_overload_mul,
485             '/' => \&div,
486             'neg' => \&neg,
487             '**' => \&pow,
488             'abs' => \&abs,
489             'cos' => \&cos,
490             'exp' => \&exp,
491             'int' => \&int,
492             'log' => \&log,
493             'sin' => \&sin,
494             'sqrt' => \&sqrt;
495              
496             sub _func {
497             my ($series, $subr) = @_;
498             require App::Chart::Series::Func;
499             return App::Chart::Series::Func->new ($series, $subr);
500             }
501              
502             sub neg { $_[0]->_func (sub { - $_[0] }) }
503             sub abs { $_[0]->_func (sub { CORE::abs $_[0] }) }
504             sub cos { $_[0]->_func (sub { CORE::cos $_[0] }) }
505             sub exp { $_[0]->_func (sub { CORE::exp $_[0] }) }
506             sub int { $_[0]->_func (sub { CORE::int $_[0] }) }
507             sub log { $_[0]->_func (sub { CORE::log $_[0] }) }
508             sub sin { $_[0]->_func (sub { CORE::sin $_[0] }) }
509             sub sqrt { $_[0]->_func (sub { CORE::sqrt $_[0] }) }
510              
511             sub _overload_add {
512             my ($x, $y) = @_;
513             if (ref $y) {
514             # series + series
515             require App::Chart::Series::AddSub;
516             return App::Chart::Series::AddSub->new ($x, $y);
517             } else {
518             # series + number
519             return $x->_func (sub{ $_[0] + $y });
520             }
521             }
522             sub sub {
523             my ($x, $y, $swap) = @_;
524             if (ref $y) {
525             # series - series
526             require App::Chart::Series::AddSub;
527             return App::Chart::Series::AddSub->new (($swap ? ($y, $x) : ($x, $y)),
528             negate => 1);
529             } else {
530             # series - number, or number - series
531             return $x->_func ($swap
532             ? sub{ $y - $_[0] }
533             : sub{ $_[0] - $y });
534             }
535             }
536             sub _overload_mul {
537             my ($x, $y) = @_;
538             return $x->mul($y);
539             }
540             sub div {
541             my ($x, $y, $swap) = @_;
542             if (ref $y) { croak 'Can only divide a App::Chart::Series by a constant'; }
543             if ($swap) {
544             croak "Not implemented";
545             } else {
546             return $x * (1/$y);
547             }
548             }
549             sub pow {
550             my ($series, $power, $swap) = @_;
551             if (ref $power) {
552             croak __('Can only raise App::Chart::Series to a scalar power');
553             }
554             return $series->_func ($swap
555             ? sub{ $power ** $_[0] }
556             : sub{ $_[0] ** $power });
557             }
558              
559             1;
560             __END__
561              
562             =for stopwords undef openint indices undefs delisted autoloads ie
563              
564             =head1 NAME
565              
566             App::Chart::Series -- series data object
567              
568             =head1 SYNOPSIS
569              
570             use App::Chart::Series;
571              
572             =head1 DESCRIPTION
573              
574             A C<App::Chart::Series> object holds a data series. It basically holds an
575             array or multiple arrays, of values indexed from 0 up to C<< $series->hi >>.
576             Portions of the arrays are filled on request with the C<fill> method, so
577             just some of a big series can be read from disk or calculated.
578              
579             Array elements with no value for a given date are undef. The arrays may be
580             shorter than C<< $series->hi >> when no data near the end. And for instance
581             the "openint" array of futures open interest is always empty for ordinary
582             shares.
583              
584             Array indices are values in a timebase, see L<App::Chart::Timebase>, so 0 is
585             some starting date, perhaps a particular day, or a whole month or week. A
586             fixed sequence like this with undefs for public holidays or delisted times
587             makes it easy to fill portions without knowing how much there might be
588             altogether in the database, but skipping undefs all the time when
589             calculating is a bit tedious.
590              
591             C<App::Chart::Series> itself is just a base class, with various methods
592             common to series objects. Objects are only actually created by subclasses
593             such as C<App::Chart::Series::Database>.
594              
595             Derived series can be made with autoloads for the derived modules, such as
596             C<< $series->SMA(10) >> to calculate a simple moving average. But maybe the
597             way that works will change, since in a chained calculation the full data
598             arrays of intermediate parts don't need to be kept, it's just algorithms or
599             transformations that need to be combined.
600              
601             =head1 FUNCTIONS
602              
603             =over 4
604              
605             =item C<< $series->timebase() >>
606              
607             Return the C<App::Chart::Timebase> object which is the basis for C<$series>
608             (see L<App::Chart::Timebase>).
609              
610             =item C<< $series->decimals() >>
611              
612             Return the number of decimal places which should normally be shown for
613             values in C<$series>. For example in a database price series this might be
614             2 to show dollars and cents, but for a series of trading volumes it would be
615             0.
616              
617             This is only an intended accuracy to display (or minimum accuracy), not a
618             limit on the accuracy of the values in C<$series>.
619              
620             =item C<< $series->symbol() >>
621              
622             Return the stock or commodity symbol for the data in this series, or
623             C<undef> if it's not associated with a symbol at all.
624              
625             =item C<< $series->hi() >>
626              
627             Return the maximum index into the series arrays, ie. the arrays can be
628             filled with data from 0 up to C<< $series->hi >> inclusive.
629              
630             =item C<< $series->fill ($lo, $hi) >>
631              
632             Ask for data to be available for the arrays from C<$lo> to C<$hi> inclusive.
633             This might read the database, or make a data calculation, etc, if it hasn't
634             already been done for the range.
635              
636             If C<$lo> or C<$hi> are outside the actual available range (ie. C<$lo>
637             negative and/or C<$hi> above C<< $series->hi >>), then just the actual
638             available parts are loaded and the excess ignored.
639              
640             =item C<< $series->range ($lo, $hi) >>
641              
642             Return two values C<($lower, $upper)> which is the range of values taken by
643             C<$series> between timebase values C<$lo> and C<$hi>, inclusive. If there's
644             no data at all in that range the return is an empty list C<()>.
645              
646             =item C<< $series->initial_range ($lo, $hi) >>
647              
648             Return two values C<($lower, $upper)> which is a good price range
649             (vertically) to display for the data between points C<$lo> and C<$hi>. If
650             there's no data in that range the return is an empty list C<()>.
651              
652             =back
653              
654             =head1 HOME PAGE
655              
656             L<http://user42.tuxfamily.org/chart/index.html>
657              
658             =head1 LICENCE
659              
660             Copyright 2007, 2008, 2009, 2010, 2011, 2016 Kevin Ryde
661              
662             Chart is free software; you can redistribute it and/or modify it under the
663             terms of the GNU General Public License as published by the Free Software
664             Foundation; either version 3, or (at your option) any later version.
665              
666             Chart is distributed in the hope that it will be useful, but WITHOUT ANY
667             WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
668             FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
669             details.
670              
671             You should have received a copy of the GNU General Public License along with
672             Chart; see the file F<COPYING>. Failing that, see
673             L<http://www.gnu.org/licenses/>.
674              
675             =cut