Finance-Robinhood

 view release on metacpan or  search on metacpan

lib/Finance/Robinhood.pm  view on Meta::CPAN

package Finance::Robinhood;
use 5.012;
use strict;
use warnings;
use Carp;
our $VERSION = "0.21";
use Moo;
use HTTP::Tiny '0.056';
use JSON::Tiny qw[decode_json];
use Try::Tiny;
use strictures 2;
use namespace::clean;
our $DEBUG = !1;
require Data::Dump if $DEBUG;
our $DEV = !1;
#
use lib '../../lib';
use Finance::Robinhood::Account;
use Finance::Robinhood::Instrument;
use Finance::Robinhood::Fundamentals;
use Finance::Robinhood::Order;
use Finance::Robinhood::Position;
use Finance::Robinhood::Quote;
use Finance::Robinhood::Watchlist;
use Finance::Robinhood::Portfolio;
#
has token => (is => 'ro', writer => '_set_token');
#
my $base = 'https://api.robinhood.com/';

# Different endpoints we can call for the API
my %endpoints = (
                'accounts'               => 'accounts/',
                'accounts/positions'     => 'accounts/%s/positions/',
                'portfolios'             => 'portfolios/',
                'portfolios/historicals' => 'portfolios/historicals/',
                'ach_deposit_schedules'  => 'ach/deposit_schedules/',
                'ach_iav_auth'           => 'ach/iav/auth/',
                'ach_relationships'      => 'ach/relationships/',
                'ach_transfers'          => 'ach/transfers/',
                'applications'           => 'applications/',
                'dividends'              => 'dividends/',
                'document_requests'      => 'upload/document_requests/',
                'documents'              => 'documents/',
                'documents/download' => 'documents/%s/download/?redirect=%s',
                'fundamentals'       => 'fundamentals/',
                'instruments'        => 'instruments/',
                'login'              => 'api-token-auth/',
                'logout'             => 'api-token-logout/',
                'margin_upgrades'    => 'margin/upgrades/',
                'markets'            => 'markets/',
                'notifications'      => 'notifications/',
                'notifications/devices' => 'notifications/devices/',
                'cards'                 => 'midlands/notifications/stack/',
                'cards/dismiss' => 'midlands/notifications/stack/%s/dismiss/',
                'orders'        => 'orders/',
                'password_reset'          => 'password_reset/',
                'password_reset/request'  => 'password_reset/request/',
                'quote'                   => 'quote/',
                'quotes'                  => 'quotes/',
                'quotes/historicals'      => 'quotes/historicals/',
                'user'                    => 'user/',
                'user/id'                 => 'user/id/',
                'user/additional_info'    => 'user/additional_info/',
                'user/basic_info'         => 'user/basic_info/',
                'user/employment'         => 'user/employment/',
                'user/investment_profile' => 'user/investment_profile/',
                'user/identity_mismatch'  => 'user/identity_mismatch',
                'watchlists'              => 'watchlists/',
                'watchlists/bulk_add'     => 'watchlists/%s/bulk_add/'
);

sub endpoint {
    $endpoints{$_[0]} ?
        ($DEV > 10 ?
             'http://brokeback.dev.robinhood.com/'
         : 'https://api.robinhood.com/'
        )
        . $endpoints{+shift}
        : ();
}
#
# Send a username and password to Robinhood to get back a token.
#
my ($client, $res);
my %headers = (
         'Accept' => '*/*',
         'Accept-Language' =>
             'en;q=1, fr;q=0.9, de;q=0.8, ja;q=0.7, nl;q=0.6, it;q=0.5',
         'Content-Type' => 'application/x-www-form-urlencoded; charset=utf-8',
         'X-Robinhood-API-Version' => '1.120.0',
         'User-Agent'              => 'Robinhood/2357 (Android/2.19.0)'
);
sub errors { shift; carp shift; }

sub login {
    my ($self, $username, $password) = @_;

    # Make API Call
    my ($status, $data, $raw)
        = _send_request(undef, 'POST',
                        Finance::Robinhood::endpoint('login'),
                        {username => $username,
                         password => $password
                        }
        );

    # Make sure we have a token.
    if ($status != 200 || !defined($data->{token})) {
        $self->errors(join ' ', @{$data->{non_field_errors}});
        return !1;
    }

    # Set the token we just received.
    return $self->_set_token($data->{token});
}

sub logout {
    my ($self) = @_;

    # Make API Call
    my ($status, $rt, $raw)
        = $self->_send_request('POST',
                               Finance::Robinhood::endpoint('logout'));
    return $status == 200 ?

        # The old token is now invalid, so we might as well delete it
        $self->_set_token(())
        : ();
}

sub forgot_password {
    my $self = shift if ref $_[0] && ref $_[0] eq __PACKAGE__;
    my ($email) = @_;

    # Make API Call
    my ($status, $rt, $raw)
        = _send_request(undef, 'POST',
                       Finance::Robinhood::endpoint('password_reset/request'),
                       {email => $email});
    return $status == 200;
}

sub change_password {
    my $self = shift if ref $_[0] && ref $_[0] eq __PACKAGE__;
    my ($user, $password, $token) = @_;

    # Make API Call
    my ($status, $rt, $raw)
        = _send_request(undef, 'POST',
                        Finance::Robinhood::endpoint('password_reset'),
                        {username => $user,
                         password => $password,
                         token    => $token
                        }
        );
    return $status == 200;
}

sub user_info {
    my ($self) = @_;
    my ($status, $data, $raw)
        = $self->_send_request('GET', Finance::Robinhood::endpoint('user'));
    return $status == 200 ?
        map { $_ => $data->{$_} } qw[email id last_name first_name username]
        : ();
}

sub user_id {
    my ($self) = @_;
    my ($status, $data, $raw)
        = $self->_send_request('GET',
                               Finance::Robinhood::endpoint('user/id'));
    return $status == 200 ? $data->{id} : ();
}

sub basic_info {
    my ($self) = @_;
    my ($status, $data, $raw)
        = $self->_send_request('GET',
                             Finance::Robinhood::endpoint('user/basic_info'));
    return $status != 200 ?
        ()
        : ((map { $_ => _2_datetime(delete $data->{$_}) }
                qw[date_of_birth updated_at]
           ),
           map { m[url] ? () : ($_ => $data->{$_}) } keys %$data
        );
}

sub additional_info {
    my ($self) = @_;
    my ($status, $data, $raw)
        = $self->_send_request('GET',
                               Finance::Robinhood::endpoint(
                                                       'user/additional_info')
        );
    return $status != 200 ?
        ()
        : ((map { $_ => _2_datetime(delete $data->{$_}) } qw[updated_at]),
           map { m[user] ? () : ($_ => $data->{$_}) } keys %$data);
}

sub employment_info {
    my ($self) = @_;
    my ($status, $data, $raw)
        = $self->_send_request('GET',
                             Finance::Robinhood::endpoint('user/employment'));
    return $status != 200 ?
        ()
        : ((map { $_ => _2_datetime(delete $data->{$_}) } qw[updated_at]),
           map { m[user] ? () : ($_ => $data->{$_}) } keys %$data);
}

sub investment_profile {
    my ($self) = @_;
    my ($status, $data, $raw)
        = $self->_send_request('GET',
                               Finance::Robinhood::endpoint(
                                                    'user/investment_profile')
        );
    return $status != 200 ?
        ()
        : ((map { $_ => _2_datetime(delete $data->{$_}) } qw[updated_at]),
           map { m[user] ? () : ($_ => $data->{$_}) } keys %$data);
}

sub identity_mismatch {
    my ($self) = @_;
    my ($status, $data, $raw)
        = $self->_send_request('GET',
                               Finance::Robinhood::endpoint(
                                                     'user/identity_mismatch')
        );
    return $status == 200 ? $self->_paginate($data) : ();
}

sub accounts {
    my ($self) = @_;

    # TODO: Deal with next and previous results? Multiple accounts?
    my $return = $self->_send_request('GET',
                                      Finance::Robinhood::endpoint('accounts')
    );
    return $self->_paginate($return, 'Finance::Robinhood::Account');
}
#
# Returns the porfillo summery of an account by url.
#
sub portfolios {
    my ($self) = @_;

    # TODO: Deal with next and previous results? Multiple portfolios?
    my $return =
        $self->_send_request('GET',
                             Finance::Robinhood::endpoint('portfolios'));
    return $self->_paginate($return, 'Finance::Robinhood::Portfolio');
}

sub instrument {

#my $msft      = Finance::Robinhood::instrument('MSFT');
#my $msft      = $rh->instrument('MSFT');
#my ($results) = $rh->instrument({query  => 'FREE'});
#my ($results) = $rh->instrument({cursor => 'cD04NjQ5'});
#my $msft      = $rh->instrument({id     => '50810c35-d215-4866-9758-0ada4ac79ffa'});
    my $self = shift if ref $_[0] && ref $_[0] eq __PACKAGE__;
    my ($type) = @_;
    my $result = _send_request($self, 'GET',
                               Finance::Robinhood::endpoint('instruments')
                                   . (  !defined $type ? ''
                                      : !ref $type     ? '?query=' . $type
                                      : ref $type eq 'HASH'
                                          && defined $type->{cursor}
                                      ? '?cursor=' . $type->{cursor}
                                      : ref $type eq 'HASH'
                                          && defined $type->{query}
                                      ? '?query=' . $type->{query}
                                      : ref $type eq 'HASH'
                                          && defined $type->{id}
                                      ? $type->{id} . '/'
                                      : ''
                                   )
    );
    $result // return !1;

    #ddx $result;
    my $retval = ();
    if (defined $type && !ref $type) {
        ($retval) = map { Finance::Robinhood::Instrument->new($_) }
            grep { $_->{symbol} eq $type } @{$result->{results}};
    }
    elsif (defined $type && ref $type eq 'HASH' && defined $type->{id}) {
        $retval = Finance::Robinhood::Instrument->new($result);
    }
    else {
        my ($prev, $next);
        {
            $result->{previous} =~ m[\?cursor=(.+)]
                if defined $result->{previous};
            $prev = $1 // ();
        }
        {
            $result->{next} =~ m[\?cursor=(.+)] if defined $result->{next};
            $next = $1 // ();
        }
        $retval = {results => [map { Finance::Robinhood::Instrument->new($_) }
                                   @{$result->{results}}
                   ],
                   previous => $prev,
                   next     => $next
        };
    }
    return $retval;
}

sub quote {
    my $self = ref $_[0] ? shift : ();    # might be undef but that's okay
                                          #if (scalar @_ > 1 or wantarray) {
    my $return =
        _send_request($self, 'GET',
              Finance::Robinhood::endpoint('quotes') . '?symbols=' . join ',',
              @_);
    return _paginate($self, $return, 'Finance::Robinhood::Quote');

    #}
    #my $quote =
    #    _send_request($self, 'GET',
    #                  Finance::Robinhood::endpoint('quotes') . shift . '/');
    #return $quote ?
    #    Finance::Robinhood::Quote->new($quote)
    #    : ();
}

sub fundamentals {
    my $self = ref $_[0] ? shift : ();    # might be undef but that's okay
                                          #if (scalar @_ > 1 or wantarray) {
    my $return =
        _send_request($self,
                      'GET',
                      Finance::Robinhood::endpoint('fundamentals')
                          . '?symbols='
                          . join ',',
                      @_
        );
    return _paginate($self, $return, 'Finance::Robinhood::Fundamentals');

    #}
    #my $quote =
    #    _send_request($self, 'GET',
    #                  Finance::Robinhood::endpoint('quotes') . shift . '/');
    #return $quote ?
    #    Finance::Robinhood::Quote->new($quote)
    #    : ();
}

sub historicals {
    my $self = ref $_[0] ? shift : ();    # might be undef but that's okay
    my ($symbol, $interval, $span, $bounds) = @_;
    my %fields = (interval => $interval,
                  span     => $span,
                  bounds   => $bounds
    );
    my $fields = join '&', map { $_ . '=' . $fields{$_} }
        grep { defined $fields{$_} } keys %fields;
    my ($status, $data, $raw)
        = _send_request($self,
                        'GET',
                        Finance::Robinhood::endpoint('quotes/historicals')
                            . "$symbol/"
                            . ($fields ? "?$fields" : '')
        );
    return if $status != 200;
    for (@{$data->{historicals}}) {
        $_->{begins_at} = _2_datetime($_->{begins_at});
    }
    return $data->{historicals};
}

sub locate_order {
    my ($self, $order_id) = @_;
    my $result = $self->_send_request('GET',
                    Finance::Robinhood::endpoint('orders') . $order_id . '/');
    return $result ?
        Finance::Robinhood::Order->new(rh => $self, %$result)
        : ();
}

sub list_orders {
    my ($self, $type) = @_;
    my $result = $self->_send_request(
            'GET',
            Finance::Robinhood::endpoint('orders')
                . (
                ref $type
                    && ref $type eq 'HASH'
                    && defined $type->{cursor} ? '?cursor=' . $type->{cursor}
                : ref $type && ref $type eq 'HASH' && defined $type->{'since'}
                ? '?updated_at[gte]=' . $type->{'since'}
                : ref $type
                    && ref $type eq 'HASH'
                    && defined $type->{'instrument'}
                    && 'Finance::Robinhood::Instrument' eq
                    ref $type->{'instrument'}
                ? '?instrument=' . $type->{'instrument'}->url
                : ''
                )
    );
    $result // return !1;
    return () if !$result;
    return $self->_paginate($result, 'Finance::Robinhood::Order');
}

# Methods under construction
sub cards {
    return shift->_send_request('GET', Finance::Robinhood::endpoint('cards'));
}

sub dividends {
    return
        shift->_send_request('GET',
                             Finance::Robinhood::endpoint('dividends'));
}

sub notifications {
    return
        shift->_send_request('GET',
                             Finance::Robinhood::endpoint('notifications'));
}

sub notifications_devices {
    return
        shift->_send_request('GET',
                             Finance::Robinhood::endpoint(
                                                      'notifications/devices')
        );
}

sub create_watchlist {
    my ($self, $name) = @_;
    my ($status, $result)
        = $self->_send_request('POST',
                               Finance::Robinhood::endpoint('watchlists'),
                               {name => $name});
    return $status == 201
        ?
        Finance::Robinhood::Watchlist->new(rh => $self, %$result)
        : ();
}

sub delete_watchlist {
    my ($self, $watchlist) = @_;
    my ($status, $result, $response)
        = $self->_send_request('DELETE',
                               Finance::Robinhood::endpoint('watchlists')
                                   . (ref $watchlist ?
                                          $watchlist->name()
                                      : $watchlist
                                   )
                                   . '/'
        );
    return $status == 204;
}

sub watchlists {
    my ($self, $cursor) = @_;
    my $result = $self->_send_request('GET',
                                      Finance::Robinhood::endpoint(
                                                                 'watchlists')
                                          . (
                                            ref $cursor
                                                && ref $cursor eq 'HASH'
                                                && defined $cursor->{cursor}
                                            ?
                                                '?cursor=' . $cursor->{cursor}
                                            : ''
                                          )
    );
    $result // return !1;
    return () if !$result;
    return $self->_paginate($result, 'Finance::Robinhood::Watchlist');
}

sub watchlist {
    my ($self, $name) = @_;
    my ($status, $result)
        = $self->_send_request('GET',
                       Finance::Robinhood::endpoint('watchlists') . "$name/");
    return $status == 200 ?
        Finance::Robinhood::Watchlist->new(name => $name,
                                           rh   => $self,
                                           %$result
        )
        : ();
}

sub markets {
    my $self = ref $_[0] ? shift : ();    # might be undef but that's okay
    my ($symbol, $interval, $span) = @_;
    my $result = _send_request(undef, 'GET',
                               Finance::Robinhood::endpoint('markets'));
    return _paginate($self, $result, 'Finance::Robinhood::Market');
}

# TESTING!
# @GET("/documents/{id}/download/?redirect=False")
#    Observable<DocumentDownloadResponse> getDocumentDownloadUrl(@Path("id") String str);
sub documents_download {
    my ($s, $id, $redirect) = @_;
    warn Finance::Robinhood::endpoint('documents/download');
    my $result =
        _send_request($s, 'GET',
                   sprintf Finance::Robinhood::endpoint('documents/download'),
                   $id, $redirect ? 'True' : 'False');

    #return _paginate( $self, $result, 'Finance::Robinhood::Market' );
    $result;
}

# ---------------- Private Helper Functions --------------- //
# Send request to API.
#
sub _paginate {    # Paginates results
    my ($self, $res, $class) = @_;
    my ($prev)
        = defined $res->{previous} ?
        ($res->{previous} =~ m[\?cursor=(.+)$])
        : ();
    my ($next)
        = defined $res->{next} ? ($res->{next} =~ m[\?cursor=(.+)$]) : ();
    return {
        results => (
            defined $class ?
                [
                map {
                    $class->new(%$_, ($self ? (rh => $self) : ()), raw => $_)
                } grep {defined} @{$res->{results}}
                ]
            : $res->{results}
        ),
        previous => $prev,
        next     => $next
    };
}

sub _send_request {

    # TODO: Expose errors (400:{detail=>'Not enough shares to sell'}, etc.)
    my ($self, $verb, $url, $data) = @_;

    # Make sure we have a token.
    if (defined $self && !defined($self->token)) {
        carp
            'No API token set. Please authorize by using ->login($user, $pass) or passing a token to ->new(...).';
        return !1;
    }

    # Setup request client.
    $client = HTTP::Tiny->new(agent => 'Finance::Robinhood/' . $VERSION . ' ')
        if !defined $client;
    $url =~ s|\+|%2B|g;

    # Make API call.
    if ($DEBUG) {
        warn "$verb $url";
        require Data::Dump;
        Data::Dump::ddx($verb, $url,
                        {headers => {%headers,
                                     ($self && defined $self->token()
                                      ? (Authorization => 'Token '
                                          . $self->token())
                                      : ()
                                     )



( run in 1.156 second using v1.01-cache-2.11-cpan-8dfa8b56332 )