From 2075d8273cc858a3d52a2a7c71a1dd3f91ea675b Mon Sep 17 00:00:00 2001 From: hackerESQ Date: Thu, 12 Sep 2024 21:05:01 -0500 Subject: [PATCH] adds finnhub market data provider --- .env.example | 1 + README.md | 2 +- .../MarketData/AlphaVantageMarketData.php | 8 +- app/Interfaces/MarketData/FakeMarketData.php | 8 +- .../MarketData/FinnhubMarketData.php | 106 ++++++++++++++++++ app/Interfaces/MarketData/YahooMarketData.php | 8 +- app/Providers/AppServiceProvider.php | 13 ++- composer.json | 9 +- composer.lock | 70 +++++++++++- config/finnhub.php | 5 + config/investbrain.php | 9 +- 11 files changed, 213 insertions(+), 26 deletions(-) create mode 100644 app/Interfaces/MarketData/FinnhubMarketData.php create mode 100644 config/finnhub.php diff --git a/.env.example b/.env.example index 972661a..ac57ff0 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,7 @@ MAIL_FROM_NAME="${APP_NAME}" MARKET_DATA_PROVIDER=yahoo MARKET_DATA_REFRESH=30 ALPHAVANTAGE_API_KEY= +FINNHUB_API_KEY= AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= diff --git a/README.md b/README.md index 1d79b43..54a216e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Investbrain helps you manage and track the performance of your investments. ## Under the hood -Investbrain is a Laravel PHP web application that leverages Livewire, Mary UI, and Tailwind for its frontend. Most databases should work, including MySQL and SQLite. Out of the box, we feature two market data providers: Yahoo Finance and Alpha Vantage. But we also offer an extensible market data provider interface for intrepid developers to create their own! Finally, of course we have robust support for i18n, a11y, and dark mode. +Investbrain is a Laravel PHP web application that leverages Livewire, Mary UI, and Tailwind for its frontend. Most databases should work, including MySQL and SQLite. Out of the box, we feature two market data providers: [Yahoo Finance](https://finance.yahoo.com/), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), and [Alpha Vantage](https://www.alphavantage.co/support/). But we also offer an extensible market data provider interface for intrepid developers to create their own! Finally, of course we have robust support for i18n, a11y, and dark mode. ## Installation diff --git a/app/Interfaces/MarketData/AlphaVantageMarketData.php b/app/Interfaces/MarketData/AlphaVantageMarketData.php index f388c3f..485c875 100644 --- a/app/Interfaces/MarketData/AlphaVantageMarketData.php +++ b/app/Interfaces/MarketData/AlphaVantageMarketData.php @@ -15,7 +15,7 @@ class AlphaVantageMarketData implements MarketDataInterface return $this->quote($symbol)->isNotEmpty(); } - public function quote($symbol): Collection + public function quote(String $symbol): Collection { $quote = Alphavantage::core()->quoteEndpoint($symbol); $quote = Arr::get($quote, 'Global Quote', []); @@ -49,7 +49,7 @@ class AlphaVantageMarketData implements MarketDataInterface ]); } - public function dividends($symbol, $startDate, $endDate): Collection + public function dividends(String $symbol, $startDate, $endDate): Collection { $dividends = Alphavantage::fundamentals()->dividends($symbol); @@ -67,7 +67,7 @@ class AlphaVantageMarketData implements MarketDataInterface }); } - public function splits($symbol, $startDate, $endDate): Collection + public function splits(String $symbol, $startDate, $endDate): Collection { $splits = Alphavantage::fundamentals()->splits($symbol); @@ -86,7 +86,7 @@ class AlphaVantageMarketData implements MarketDataInterface }); } - public function history($symbol, $startDate, $endDate): Collection + public function history(String $symbol, $startDate, $endDate): Collection { $history = Alphavantage::timeSeries()->daily($symbol, 'full'); diff --git a/app/Interfaces/MarketData/FakeMarketData.php b/app/Interfaces/MarketData/FakeMarketData.php index e99bb62..19cf4c0 100644 --- a/app/Interfaces/MarketData/FakeMarketData.php +++ b/app/Interfaces/MarketData/FakeMarketData.php @@ -13,7 +13,7 @@ class FakeMarketData implements MarketDataInterface return true; } - public function quote($symbol): Collection + public function quote(String $symbol): Collection { return collect([ @@ -31,7 +31,7 @@ class FakeMarketData implements MarketDataInterface ]); } - public function dividends($symbol, $startDate, $endDate): Collection + public function dividends(String $symbol, $startDate, $endDate): Collection { return collect([ @@ -53,7 +53,7 @@ class FakeMarketData implements MarketDataInterface ]); } - public function splits($symbol, $startDate, $endDate): Collection + public function splits(String $symbol, $startDate, $endDate): Collection { return collect([ @@ -65,7 +65,7 @@ class FakeMarketData implements MarketDataInterface ]); } - public function history($symbol, $startDate, $endDate): Collection + public function history(String $symbol, $startDate, $endDate): Collection { $numDays = Carbon::parse($startDate)->diffInDays($endDate, true); diff --git a/app/Interfaces/MarketData/FinnhubMarketData.php b/app/Interfaces/MarketData/FinnhubMarketData.php new file mode 100644 index 0000000..55024fb --- /dev/null +++ b/app/Interfaces/MarketData/FinnhubMarketData.php @@ -0,0 +1,106 @@ +client = new \Finnhub\Api\DefaultApi( + new \GuzzleHttp\Client(), + \Finnhub\Configuration::getDefaultConfiguration()->setApiKey('token', config('finnhub.key')) + ); + } + public function exists(String $symbol): Bool + { + + return $this->quote($symbol)->isNotEmpty(); + } + + public function quote($symbol): Collection + { + + + $quote = $this->client->quote($symbol); + + $fundamental = cache()->tags(['quote', 'finnhub', $symbol])->remember( + 'symbol-'.$symbol, + 1440, + function () use ($symbol) { + return $this->client->companyBasicFinancials($symbol, "all"); + } + ); + + if (empty($fundamental)) return collect(); + + return collect([ + 'name' => Arr::get($fundamental, 'metric.name'), + 'symbol' => $symbol, + 'market_value' => Arr::get($quote, 'c'), + 'fifty_two_week_high' => Arr::get($fundamental, 'metric.52WeekHigh'), + 'fifty_two_week_low' => Arr::get($fundamental, 'metric.52WeekLow'), + 'forward_pe' => Arr::get($fundamental, 'metric.forwardPE'), // confirm + 'trailing_pe' => Arr::get($fundamental, 'metric.trailingPE'), // confirm + 'market_cap' => Arr::get($fundamental, 'metric.marketCapitalization'), // confirm + 'book_value' => Arr::get($fundamental, 'metric.bookValuePerShare'), // confirm + 'last_dividend_date' => Arr::get($fundamental, 'metric.lastDivDate'), // confirm + 'dividend_yield' => Arr::get($fundamental, 'metric.dividendYield'), // confirm + ]); + } + + public function dividends($symbol, $startDate, $endDate): Collection + { + $dividends = $this->client->stockDividends($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d')); + + return collect($dividends)->map(function($dividend) use ($symbol) { + + return [ + 'symbol' => $symbol, + 'date' => Carbon::parse(Arr::get($dividend, 'date')) + ->format('Y-m-d H:i:s'), + 'dividend_amount' => Arr::get($dividend, 'amount'), + ]; + }); + } + + public function splits($symbol, $startDate, $endDate): Collection + { + + $splits = $this->client->stockSplits($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d')); + + return collect($splits)->map(function($split) use ($symbol) { + + return [ + 'symbol' => $symbol, + 'date' => Carbon::parse(Arr::get($split, 'date')) + ->format('Y-m-d H:i:s'), + 'split_amount' => Arr::get($split, 'toFactor') / Arr::get($split, 'fromFactor'), + ]; + }); + } + + public function history($symbol, $startDate, $endDate): Collection + { + + $history = $this->client->stockCandles($symbol, "D", $startDate->timestamp, $endDate->timestamp); + + $timestamps = Arr::get($history, 't', []); + $closes = Arr::get($history, 'c', []); + + return collect($timestamps)->mapWithKeys(function ($timestamp, $index) use ($symbol, $closes) { + $date = Carbon::createFromTimestamp($timestamp)->format('Y-m-d'); + return [ $date => [ + 'symbol' => $symbol, + 'date' => $date, + 'close' => (float) $closes[$index], + ]]; + }); + } +} \ No newline at end of file diff --git a/app/Interfaces/MarketData/YahooMarketData.php b/app/Interfaces/MarketData/YahooMarketData.php index 77c8773..8ea9a69 100644 --- a/app/Interfaces/MarketData/YahooMarketData.php +++ b/app/Interfaces/MarketData/YahooMarketData.php @@ -22,7 +22,7 @@ class YahooMarketData implements MarketDataInterface return $this->quote($symbol)->isNotEmpty(); } - public function quote($symbol): Collection + public function quote(String $symbol): Collection { $quote = $this->client->getQuote($symbol); @@ -44,7 +44,7 @@ class YahooMarketData implements MarketDataInterface ]); } - public function dividends($symbol, $startDate, $endDate): Collection + public function dividends(String $symbol, $startDate, $endDate): Collection { return collect($this->client->getHistoricalDividendData($symbol, $startDate, $endDate)) @@ -58,7 +58,7 @@ class YahooMarketData implements MarketDataInterface }); } - public function splits($symbol, $startDate, $endDate): Collection + public function splits(String $symbol, $startDate, $endDate): Collection { return collect($this->client->getHistoricalSplitData($symbol, $startDate, $endDate)) @@ -73,7 +73,7 @@ class YahooMarketData implements MarketDataInterface }); } - public function history($symbol, $startDate, $endDate): Collection + public function history(String $symbol, $startDate, $endDate): Collection { return collect($this->client->getHistoricalQuoteData($symbol, ApiClient::INTERVAL_1_DAY, $startDate, $endDate)) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 633cf46..ab93c3b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,15 +11,16 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { + if (!in_array( + $interface = config('investbrain.default', 'yahoo'), + array_keys(config('investbrain.interfaces', [])) + )) { + throw new \Exception("Error: '$interface' is not a valid market data interface."); + } - $market_data = config( - "investbrain." . - config('investbrain.default', 'yahoo') - ); - $this->app->bind( \App\Interfaces\MarketData\MarketDataInterface::class, - $market_data + config("investbrain.interfaces.$interface") ); } diff --git a/composer.json b/composer.json index 7b4b141..e326f71 100644 --- a/composer.json +++ b/composer.json @@ -6,6 +6,7 @@ "license": "CC-BY-NC 4.0", "require": { "php": "^8.2", + "finnhub/client": "dev-master", "laravel/framework": "^11.9", "laravel/jetstream": "^5.1", "laravel/sanctum": "^4.0", @@ -27,6 +28,12 @@ "nunomaduro/collision": "^8.0", "phpunit/phpunit": "^11.0.1" }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/hackerESQ/finnhub-php" + } + ], "autoload": { "files": [ "app/Support/Helpers.php" @@ -73,6 +80,6 @@ "php-http/discovery": true } }, - "minimum-stability": "stable", + "minimum-stability": "dev", "prefer-stable": true } diff --git a/composer.lock b/composer.lock index 6347651..54caa85 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "40816463478b2d00c6a7c18e71aa7e7c", + "content-hash": "94cd2d270d276ef1634b6b44dc12145e", "packages": [ { "name": "bacon/bacon-qr-code", @@ -902,6 +902,68 @@ }, "time": "2023-11-17T15:01:25+00:00" }, + { + "name": "finnhub/client", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/hackerESQ/finnhub-php.git", + "reference": "1f1b35a0c0a6a68f9a791e3ac5cdb6f44ff69d80" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hackerESQ/finnhub-php/zipball/1f1b35a0c0a6a68f9a791e3ac5cdb6f44ff69d80", + "reference": "1f1b35a0c0a6a68f9a791e3ac5cdb6f44ff69d80", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "guzzlehttp/guzzle": "^7.2", + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.12", + "phpunit/phpunit": "^8.0 || ^9.0" + }, + "default-branch": true, + "type": "library", + "autoload": { + "psr-4": { + "Finnhub\\": "lib/" + } + }, + "autoload-dev": { + "psr-4": { + "Finnhub\\Test\\": "test/" + } + }, + "license": [ + "unlicense" + ], + "authors": [ + { + "name": "OpenAPI-Generator contributors", + "homepage": "https://openapi-generator.tech" + } + ], + "description": "Official Finnhub stock API PHP library. https://finnhub.io/", + "homepage": "https://openapi-generator.tech", + "keywords": [ + "api", + "openapi", + "openapi-generator", + "openapitools", + "php", + "rest", + "sdk" + ], + "support": { + "source": "https://github.com/hackerESQ/finnhub-php/tree/master" + }, + "time": "2024-09-13T01:29:18+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.3.0", @@ -9805,8 +9867,10 @@ } ], "aliases": [], - "minimum-stability": "stable", - "stability-flags": [], + "minimum-stability": "dev", + "stability-flags": { + "finnhub/client": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/config/finnhub.php b/config/finnhub.php new file mode 100644 index 0000000..d675564 --- /dev/null +++ b/config/finnhub.php @@ -0,0 +1,5 @@ + env('FINNHUB_API_KEY'), +]; diff --git a/config/investbrain.php b/config/investbrain.php index 58e9a19..c41c692 100644 --- a/config/investbrain.php +++ b/config/investbrain.php @@ -6,9 +6,12 @@ return [ 'default' => env('MARKET_DATA_PROVIDER', 'yahoo'), - 'yahoo' => App\Interfaces\MarketData\YahooMarketData::class, - 'alphavantage' => App\Interfaces\MarketData\AlphaVantageMarketData::class, - 'fake' => App\Interfaces\MarketData\FakeMarketData::class, + 'interfaces' => [ + 'yahoo' => App\Interfaces\MarketData\YahooMarketData::class, + 'alphavantage' => App\Interfaces\MarketData\AlphaVantageMarketData::class, + 'finnhub' => App\Interfaces\MarketData\FinnhubMarketData::class, + 'fake' => App\Interfaces\MarketData\FakeMarketData::class, + ], 'self_hosted' => env('SELF_HOSTED', true) ]; \ No newline at end of file