Compare commits

...

79 Commits

Author SHA1 Message Date
hackerESQ eae4422ad8 fix: alphavantage multiply by string 2025-08-26 19:29:10 -05:00
hackerESQ 53d463b8b5 chore: upgrade deps 2025-08-26 19:27:26 -05:00
hackerESQ 827644bb32 fix: yahoo driver 2025-08-26 19:13:00 -05:00
hackerESQ 21e8672a12 feat: add twelve data market data provider 2025-08-26 18:26:12 -05:00
hackerESQ 70910c2f6d docs: adds alpaca 2025-08-25 21:05:42 -05:00
hackerESQ 9ddea4c6e1 fix: add exception for 404 2025-08-25 21:00:49 -05:00
hackerESQ 576b22e4c9 fix: hard code USD 2025-08-25 21:00:49 -05:00
hackerESQ 0035879a87 feat: add alpaca provider 2025-08-25 21:00:49 -05:00
hackerESQ 97298bcd39 Delete holding if no related transactions
resolves #63
2025-08-25 20:23:59 -05:00
hackerESQ 0504058c01 fix: auth tests failing if env shows self hosted 2025-08-25 20:21:22 -05:00
hackerESQ 750ccbd68f fix: locale setting 2025-08-25 19:58:53 -05:00
hackerESQ d815700e58 fix: simplify logo 2025-08-25 19:39:55 -05:00
hackerESQ 9d809bbbe4 test loop once? 2025-08-22 20:49:12 -05:00
hackerESQ 74a26e004f round graph 2025-08-22 20:38:54 -05:00
hackerESQ 65710e2791 dividend earnings not shared between portfolios 2025-08-22 16:37:33 -05:00
hackerESQ ac310735df wip 2025-08-21 21:46:53 -05:00
hackerESQ 5611de0e2e cleanup 2025-08-21 21:09:52 -05:00
hackerESQ 4196539169 cleanup 2025-08-21 21:09:48 -05:00
hackerESQ 08cfcceb6a clean up capture daily change command 2025-08-21 21:04:56 -05:00
hackerESQ e427d5802c wip 2025-08-21 20:54:14 -05:00
hackerESQ fc5cc1fee2 wip 2025-08-21 20:12:59 -05:00
hackerESQ fb3c19d3bf wip 2025-08-21 19:51:48 -05:00
hackerESQ 24aeb72549 temp remove dividends 2025-08-20 18:31:15 -05:00
hackerESQ c799da58e1 qip 2025-08-19 21:54:18 -05:00
hackerESQ e24f932c0f wip 2025-08-19 21:47:27 -05:00
hackerESQ 7e2bf3430e wip 2025-08-19 21:34:35 -05:00
hackerESQ e1c8c2c515 wip 2025-08-11 21:51:54 -05:00
hackerESQ ae1e59ce30 wip 2025-08-11 21:21:16 -05:00
hackerESQ 03089ed1b3 wip 2025-08-11 20:39:54 -05:00
hackerESQ 97b13063d9 wip 2025-08-11 19:58:17 -05:00
hackerESQ 9260de5f25 wip 2025-08-05 21:43:55 -05:00
hackerESQ 505a24bf99 chore: clean up 2025-07-23 21:29:44 -05:00
hackerESQ 0e88b8c6f5 Merge branch 'main' of https://github.com/investbrainapp/investbrain 2025-07-22 21:52:32 -05:00
hackerESQ 519486fe57 fix: settings for user localiation 2025-07-22 21:52:04 -05:00
hackerESQ 4086168515 fix: settings for user localiation 2025-07-22 21:51:54 -05:00
hackerESQ a13bd9f0dc fix: double counting cr 2025-07-22 20:20:59 -05:00
hackerESQ 2c3950b522 fix: holding calculations 2025-07-21 20:36:36 -05:00
hackerESQ 653f54add6 feat: adds today() method 2025-07-21 20:28:57 -05:00
hackerESQ 8e0d792d26 fix: calculate proper cost basis 2025-07-21 20:28:39 -05:00
hackerESQ 81af737204 fix: cost basis calculations on daily change queries 2025-07-17 22:00:30 -05:00
hackerESQ 81845d47f2 fix: cost basis for holding calculations 2025-07-17 20:38:29 -05:00
hackerESQ cf475657cf feat: add version number to docker image 2025-07-16 17:07:25 -05:00
hackerESQ 90a15ceddb fix: set default 2025-07-14 21:20:47 -05:00
hackerESQ 981ce0d62f fix: null coalesce 2025-07-14 21:20:25 -05:00
hackerESQ 154b679464 chore: update yahoo dep 2025-07-14 21:20:08 -05:00
hackerESQ ee51cb7e2a fix: division by zero error 2025-07-12 00:40:37 -05:00
hackerESQ 40120c7027 fix: delay queued currency rates filling 2025-07-11 22:38:09 -05:00
hackerESQ cfd5b8a4f3 feat: default to pgsql 2025-07-11 22:13:16 -05:00
hackerESQ 3b93e328d5 feat: fancy ascii art 2025-07-11 21:43:36 -05:00
hackerESQ 1fd858287d fix: clear and re-create caches 2025-07-11 21:42:11 -05:00
hackerESQ e370f5bbb7 fix: clear cache after every reload 2025-07-11 21:33:58 -05:00
hackerESQ 3e492475c0 fix: migrations failing on mysql 2025-07-09 21:55:32 -05:00
hackerESQ c454e85ad4 fix: date calculations cause failed tests 2025-07-09 19:37:51 -05:00
David Peng 487322abb5 fix: fix postgresql support (#100)
Fix #81
2025-07-09 19:11:25 -05:00
hackerESQ f78c521dc4 fix: add bp.l to test multicurr seed 2025-05-16 21:12:48 -05:00
hackerESQ ff9bcd782f fix: don't queue market data seed 2025-05-16 20:49:29 -05:00
hackerESQ 1ccf515ca2 fix: reorg migrtion 2025-05-16 19:59:39 -05:00
hackerESQ 1b0f9c134c fix: dispatch time series rates 2025-05-16 19:38:58 -05:00
hackerESQ 3589242996 fix: dispatch time series updates 2025-05-16 19:31:44 -05:00
hackerESQ 689aa4d50b fix: multi currency seeders 2025-05-15 20:05:14 -05:00
hackerESQ 26370c03c4 fix: optimize migration to multi-currency 2025-05-03 13:22:45 -05:00
hackerESQ 80b043219a prevent pre-releases from triggering image build 2025-05-02 20:07:38 -05:00
hackerESQ de54b6843d Fix multi-currency imports (#94) 2025-05-02 18:14:06 -05:00
hackerESQ 17e5d8b665 fix: increase chunk size 2025-04-12 10:12:30 -05:00
hackerESQ bd9c828c68 fix: use options prop 2025-04-11 21:49:06 -05:00
hackerESQ f72cd6f5a7 fix: set name attribute 2025-04-11 21:45:58 -05:00
hackerESQ 3593697cce fix: user needs to be set from import job 2025-04-11 21:42:38 -05:00
hackerESQ d53e71dcd5 Update README.md 2025-04-11 21:28:05 -05:00
hackerESQ 71e79cfb40 fix: daily change should be synced when before latest transaction 2025-04-11 21:14:53 -05:00
hackerESQ 38a65f99c9 fixes multi currency tests 2025-04-11 20:57:21 -05:00
hackerESQ 26e54fb357 chore: update deps 2025-04-10 21:36:40 -05:00
hackerESQ 224ed104b9 chore: fix deps 2025-04-10 21:33:18 -05:00
hackerESQ 2702fe27e4 chore: remove dev dep 2025-04-10 21:29:59 -05:00
hackerESQ dd21227f8f Feat: Adds multi currency support to API (#90) 2025-04-10 21:24:44 -05:00
hackerESQ 1ef8dd9378 Feat: Adds multi currency to imports and exports (#89)
* Also adds ability for user to export configurations
2025-04-10 20:47:35 -05:00
hackerESQ eae345f243 Feat: Adds multi currency support (#88) 2025-04-09 19:25:15 -05:00
hackerESQ 6d6f968f42 Merge pull request #76 from investbrainapp/dividend-splits-should-be-unique
fix: add unique constraint to split and dividends
2025-03-19 16:17:01 -05:00
hackerESQ 261c848ffd fix: add unique constraint to split and dividends
to prevent duplicate records
2025-03-19 16:16:38 -05:00
hackerESQ 9bcc80078e Update 2021_09_06_014744_create_holdings_table.php 2025-03-19 15:32:38 -05:00
134 changed files with 18527 additions and 36419 deletions
+3
View File
@@ -24,6 +24,9 @@ OPENAI_ORGANIZATION=
MARKET_DATA_PROVIDER=yahoo MARKET_DATA_PROVIDER=yahoo
ALPHAVANTAGE_API_KEY= ALPHAVANTAGE_API_KEY=
FINNHUB_API_KEY= FINNHUB_API_KEY=
ALPACA_API_KEY=
ALPACA_API_SECRET=
TWELVEDATA_API_SECRET=
# Cadence to refresh market data (in minutes) # Cadence to refresh market data (in minutes)
MARKET_DATA_REFRESH=30 MARKET_DATA_REFRESH=30
+14 -6
View File
@@ -43,7 +43,16 @@ jobs:
- name: Extract version from tag - name: Extract version from tag
id: extract-version id: extract-version
run: | run: |
echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV VERSION="${GITHUB_REF_NAME#v}"
TAGS="investbrainapp/investbrain:${VERSION},ghcr.io/investbrainapp/investbrain:${VERSION}"
# Conditionally add 'latest' tags unless 'pre-release' is in the version
if [[ "${GITHUB_REF_NAME}" != *alpha* && "${GITHUB_REF_NAME}" != *beta* && "${GITHUB_REF_NAME}" != *rc* ]]; then
TAGS="$TAGS,investbrainapp/investbrain:latest,ghcr.io/investbrainapp/investbrain:latest"
fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -51,8 +60,7 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
file: ./docker/Dockerfile file: ./docker/Dockerfile
push: true push: true
tags: | tags: ${{ steps.extract-version.outputs.tags }}
investbrainapp/investbrain:latest build-args: |
investbrainapp/investbrain:${{ env.version }} VERSION=${{ github.ref_name }}
ghcr.io/investbrainapp/investbrain:latest
ghcr.io/investbrainapp/investbrain:${{ env.version }}
+7 -3
View File
@@ -28,7 +28,7 @@ Investbrain is a smart open-source investment tracker that helps you manage, tra
## Under the hood ## Under the hood
Investbrain is a Laravel PHP web application that leverages Livewire and Tailwind for its frontend. Most databases should work, including MySQL and SQLite. Out of the box, we feature three 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! We also offer integrations with OpenAI and Ollama for our ["chat with your holdings"](#chat-with-your-holdings) capability. Finally, of course we have robust support for i18n, a11y, and dark mode. Investbrain is a Laravel PHP web application that leverages Livewire and Tailwind for its frontend. Most databases should work, including MySQL and SQLite. Out of the box, we feature many market data providers. But we also offer an extensible market data provider interface for intrepid developers to create their own! We also offer integrations with OpenAI and Ollama for our ["chat with your holdings"](#chat-with-your-holdings) capability. Finally, of course we have robust support for i18n, a11y, and dark mode.
## Self hosting ## Self hosting
@@ -74,7 +74,7 @@ Always keep in mind the limitations of LLMs. When in doubt, consult a licensed i
## Market data providers ## Market data providers
Investbrain includes an extensible market data provider interface that allows you to retrieve stock market data from multiple providers, such as Yahoo Finance, Alpha Vantage, or Finnhub. The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails. Investbrain includes an extensible market data provider interface that allows you to retrieve stock market data from multiple providers, such as [Yahoo Finance](https://finance.yahoo.com/), [Twelve Data](https://twelvedata.com), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), [Alpaca](https://alpaca.markets/), and [Alpha Vantage](https://www.alphavantage.co/support/). The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails.
### Configuration ### Configuration
@@ -138,9 +138,12 @@ There are several optional configurations available when installing using the re
| APP_URL | The URL where your Investbrain installation will be accessible | http://localhost | | APP_URL | The URL where your Investbrain installation will be accessible | http://localhost |
| APP_PORT | The HTTP port exposed by the NGINX container | 8000 | | APP_PORT | The HTTP port exposed by the NGINX container | 8000 |
| APP_KEY | Must be set during install - encryption key for various security-related functions | `null` | | APP_KEY | Must be set during install - encryption key for various security-related functions | `null` |
| MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `alphavantage`, or `finnhub`) | yahoo | | MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `twelvedata`, `alphavantage`, `alpaca`, or `finnhub`) | yahoo |
| ALPHAVANTAGE_API_KEY | If using the Alpha Vantage provider | `null` | | ALPHAVANTAGE_API_KEY | If using the Alpha Vantage provider | `null` |
| FINNHUB_API_KEY | If using the Finnhub provider | `null` | | FINNHUB_API_KEY | If using the Finnhub provider | `null` |
| ALPACA_API_KEY | If using the Alpaca provider | `null` |
| ALPACA_API_SECRET | If using the Alpaca provider | `null` |
| TWELVEDATA_API_SECRET | If using the Twelve Data provider | `null` |
| MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 | | MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 |
| APP_TIMEZONE | Timezone for the application, including daily change captures | UTC | | APP_TIMEZONE | Timezone for the application, including daily change captures | UTC |
| AI_CHAT_ENABLED | Whether to enable AI chat features | `false` | | AI_CHAT_ENABLED | Whether to enable AI chat features | `false` |
@@ -193,6 +196,7 @@ Just to be safe, we recommend backing up your portfolios before using these comm
| refresh:market-data | Refreshes market data with your configured market data provider. | | refresh:market-data | Refreshes market data with your configured market data provider. |
| refresh:dividend-data | Refreshes dividend data with your configured market data provider. Will also re-calculate your total dividends earned for each holding. | | refresh:dividend-data | Refreshes dividend data with your configured market data provider. Will also re-calculate your total dividends earned for each holding. |
| refresh:split-data | Refreshes splits data with your configured market data provider. Will also create new transactions to account for any splits. | | refresh:split-data | Refreshes splits data with your configured market data provider. Will also create new transactions to account for any splits. |
| refresh:currency-data | Grabs the latest daily currency exchange rate data and persists to the database. |
| capture:daily-change | Captures a snapshot of each portfolio's daily performance. | | capture:daily-change | Captures a snapshot of each portfolio's daily performance. |
| sync:daily-change | Re-calculates daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) | | sync:daily-change | Re-calculates daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) |
| sync:holdings | Re-calculates performance of holdings with related transactions (i.e. dividends, realized gains, etc). | | sync:holdings | Re-calculates performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Actions;
use App\Models\Currency;
use Illuminate\Database\Eloquent\Model;
class ConvertToMarketDataCurrency
{
public function __invoke(Model $model, callable $next)
{
if (is_null($model?->market_data)) {
$model->loadMarketData();
}
if (! is_null($model->currency) && $model->currency !== $model->market_data->currency) {
// convert to market data currency
$model->cost_basis = Currency::convert(
value: $model->cost_basis,
from: $model->currency,
to: $model->market_data->currency,
date: $model->date
);
if ($model->transaction_type == 'SELL') {
$model->sale_price = Currency::convert(
value: $model->sale_price,
from: $model->currency,
to: $model->market_data->currency,
date: $model->date
);
}
}
// currency cannot be saved to the database - we already know market_data.currency anyway
unset($model->currency);
return $next($model);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Actions;
use App\Casts\BaseCurrency;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class CopyToBaseCurrency
{
public function __invoke(Model $model, callable $next)
{
foreach ($model->getCasts() as $key => $value) {
if ($value === BaseCurrency::class) {
$model[$key] = $model[Str::beforeLast($key, '_base')];
}
}
return $next($model);
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Actions;
use App\Models\Transaction;
use Illuminate\Database\Eloquent\Model;
class EnsureCostBasisAddedToSale
{
public function __invoke(Model $model, callable $next)
{
// cost basis is required for sales to calculate realized gains
if ($model->transaction_type == 'SELL') {
$cost_basis = Transaction::where([
'portfolio_id' => $model->portfolio_id,
'symbol' => $model->symbol,
'transaction_type' => 'BUY',
])->whereDate('date', '<=', $model->date)
->selectRaw('SUM(transactions.cost_basis * transactions.quantity) as total_cost_basis')
->selectRaw('SUM(transactions.quantity) as total_quantity')
->first();
$average_cost_basis = empty($cost_basis->total_quantity)
? 0
: $cost_basis->total_cost_basis / $cost_basis->total_quantity;
$model->cost_basis = $average_cost_basis ?? 0;
}
return $next($model);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Actions;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use function Illuminate\Support\defer;
class EnsureDailyChangeIsSynced
{
public function __invoke(Model $model, callable $next)
{
if (config('app.env') != 'testing') {
$cacheKey = 'daily_change_synced'.$model->portfolio_id;
if (
! Cache::has($cacheKey)
&& $model->date->lessThan(now())
&& ($model->date->lessThan($model->portfolio->daily_change()->min('date') ?? now())
|| $model->date->lessThan($model->portfolio->transactions()->where('id', '!=', $model->id)->max('date') ?? now())
)
) {
defer(fn () => $model->portfolio->syncDailyChanges());
Cache::put($cacheKey, now(), now()->addMinutes(5));
}
}
return $next($model);
}
}
+11 -3
View File
@@ -9,7 +9,6 @@ use App\Traits\WithTrimStrings;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers; use Laravel\Fortify\Contracts\CreatesNewUsers;
use Laravel\Jetstream\Jetstream;
class CreateNewUser implements CreatesNewUsers class CreateNewUser implements CreatesNewUsers
{ {
@@ -32,13 +31,22 @@ class CreateNewUser implements CreatesNewUsers
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => $this->passwordRules(), 'password' => $this->passwordRules(),
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '', 'terms' => config('investbrain.self_hosted') ? '' : ['accepted', 'required'],
])->validate(); ])->validate();
return User::create([ $user = User::make([
'name' => $input['name'], 'name' => $input['name'],
'email' => $input['email'], 'email' => $input['email'],
'password' => Hash::make($input['password']), 'password' => Hash::make($input['password']),
]); ]);
// ensure first user is flagged as an admin
if (User::count() === 0) {
$user->admin = true;
}
$user->save();
return $user;
} }
} }
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Casts;
use App\Models\Currency;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class BaseCurrency implements CastsAttributes
{
/**
* Cast the given value to user's display currency
*
* @param array<string, mixed> $attributes
*/
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
{
return (float) $value;
}
/**
* Prepare the given value for storage in base currency
*
* @param array<string, mixed> $attributes
*/
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
{
// for market data and transactions the `currency` attribute is available...
// but for dividends and other types, need to make sure `market_data` is loaded
if (is_null($model?->currency)) {
$model->loadMarketData();
}
return Currency::convert(
(float) $value,
$model?->currency ?? $model->market_data?->currency,
config('investbrain.base_currency'),
$model?->date
);
}
}
+5 -14
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Holding;
use App\Models\Portfolio; use App\Models\Portfolio;
use Illuminate\Console\Command; use Illuminate\Console\Command;
@@ -44,23 +45,13 @@ class CaptureDailyChange extends Command
$this->line('Capturing daily change for '.$portfolio->title); $this->line('Capturing daily change for '.$portfolio->title);
$total_cost_basis = $portfolio->holdings->sum('total_cost_basis'); $metrics = Holding::query()
->portfolio($portfolio->id)
$total_dividends = $portfolio->holdings->sum('dividends_earned'); ->getPortfolioMetrics(config('investbrain.base_currency'));
$realized_gains = $portfolio->holdings->sum('realized_gain_dollars');
$total_market_value = $portfolio->holdings->sum(function ($holding) {
return $holding->market_data->market_value * $holding->quantity;
});
$portfolio->daily_change()->create([ $portfolio->daily_change()->create([
'date' => now(), 'date' => now(),
'total_market_value' => $total_market_value, 'total_market_value' => $metrics->get('total_market_value'),
'total_cost_basis' => $total_cost_basis,
'total_gain' => $total_market_value - $total_cost_basis,
'total_dividends_earned' => $total_dividends,
'realized_gains' => $realized_gains,
]); ]);
}); });
} }
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\CurrencyRate;
use Illuminate\Console\Command;
class RefreshCurrencyData extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'refresh:currency-data
{--force : Refresh of currency data}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Refresh currency data from data provider';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
CurrencyRate::refreshCurrencyData($this->option('force') ?? false);
}
}
+1 -2
View File
@@ -7,7 +7,6 @@ namespace App\Console\Commands;
use App\Models\Holding; use App\Models\Holding;
use App\Models\MarketData; use App\Models\MarketData;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class RefreshMarketData extends Command class RefreshMarketData extends Command
{ {
@@ -61,7 +60,7 @@ class RefreshMarketData extends Command
try { try {
MarketData::getMarketData($holding->symbol, $force); MarketData::getMarketData($holding->symbol, $force);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('Could not refresh '.$holding->symbol.' ('.$e->getMessage().')'); $this->line('Could not refresh '.$holding->symbol.' ('.$e->getMessage().')');
} }
} }
} }
+2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Exports; namespace App\Exports;
use App\Exports\Sheets\ConfigSheet;
use App\Exports\Sheets\DailyChangesSheet; use App\Exports\Sheets\DailyChangesSheet;
use App\Exports\Sheets\PortfoliosSheet; use App\Exports\Sheets\PortfoliosSheet;
use App\Exports\Sheets\TransactionsSheet; use App\Exports\Sheets\TransactionsSheet;
@@ -24,6 +25,7 @@ class BackupExport implements WithMultipleSheets
new PortfoliosSheet($this->empty), new PortfoliosSheet($this->empty),
new TransactionsSheet($this->empty), new TransactionsSheet($this->empty),
new DailyChangesSheet($this->empty), new DailyChangesSheet($this->empty),
new ConfigSheet($this->empty),
]; ];
} }
} }
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Exports\Sheets;
use App\Models\Holding;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithTitle;
class ConfigSheet implements FromCollection, WithHeadings, WithTitle
{
public function __construct(
public bool $empty = false
) {}
public function headings(): array
{
return [
'Key',
'Value',
];
}
/**
* @return \Illuminate\Support\Collection
*/
public function collection()
{
$configs = collect();
if ($this->empty) {
return $configs;
}
// collect user settings
$configs->push([
'key' => 'name',
'value' => auth()->user()->name,
], [
'key' => 'locale',
'value' => auth()->user()->getLocale(),
], [
'key' => 'display_currency',
'value' => auth()->user()->getCurrency(),
]);
// reinvested holdings
$reinvested_holdings = Holding::myHoldings()->where('reinvest_dividends', true)->get(['portfolio_id', 'symbol']);
if ($reinvested_holdings->isNotEmpty()) {
$configs->push([
'key' => 'reinvested_dividends',
'value' => $reinvested_holdings->toJson(),
]);
}
return $configs;
}
public function title(): string
{
return 'Config';
}
}
+2 -3
View File
@@ -22,9 +22,8 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
'Portfolio ID', 'Portfolio ID',
'Total Market Value', 'Total Market Value',
'Total Cost Basis', 'Total Cost Basis',
'Total Gain',
'Total Dividends Earned',
'Realized Gains', 'Realized Gains',
'Total Dividends Earned',
'Annotation', 'Annotation',
]; ];
} }
@@ -34,7 +33,7 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
*/ */
public function collection() public function collection()
{ {
return $this->empty ? collect() : DailyChange::myDailyChanges()->get(); return $this->empty ? collect() : DailyChange::myDailyChanges()->withDailyPerformance()->get();
} }
public function title(): string public function title(): string
+25 -1
View File
@@ -25,6 +25,7 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
'Quantity', 'Quantity',
'Cost Basis', 'Cost Basis',
'Sale Price', 'Sale Price',
'Currency',
'Split', 'Split',
'Reinvested Dividend', 'Reinvested Dividend',
'Date', 'Date',
@@ -38,7 +39,30 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
*/ */
public function collection() public function collection()
{ {
return $this->empty ? collect() : Transaction::myTransactions()->get(); if ($this->empty) {
return collect();
}
return Transaction::myTransactions()
->withMarketData()
->get()
->map(function ($transaction) {
return [
'id' => $transaction->id,
'symbol' => $transaction->symbol,
'portfolio_id' => $transaction->portfolio_id,
'transaction_type' => $transaction->transaction_type,
'quantity' => $transaction->quantity,
'cost_basis' => $transaction->cost_basis,
'sale_price' => $transaction->sale_price,
'currency' => $transaction->market_data_currency,
'split' => $transaction->split,
'reinvested_dividend' => $transaction->reinvested_dividend,
'date' => $transaction->date,
'created_at' => $transaction->created_at,
'updated_at' => $transaction->updated_at,
];
});
} }
public function title(): string public function title(): string
@@ -18,6 +18,7 @@ class TransactionController extends ApiController
$filters->setQuery(Transaction::query()); $filters->setQuery(Transaction::query());
$filters->setScopes(['myTransactions']); $filters->setScopes(['myTransactions']);
$filters->setEagerRelations(['market_data']);
$filters->setSearchableColumns(['symbol']); $filters->setSearchableColumns(['symbol']);
return TransactionResource::collection($filters->paginated()); return TransactionResource::collection($filters->paginated());
+3 -5
View File
@@ -17,16 +17,14 @@ class DashboardController extends Controller
$user = $request->user()->load(['portfolios', 'holdings', 'transactions']); $user = $request->user()->load(['portfolios', 'holdings', 'transactions']);
// get portfolio metrics // get portfolio metrics
$metrics = cache()->remember( $metrics = cache()->tags(['metrics-'.$user->id])->remember(
'dashboard-metrics-'.$user->id, 'dashboard-metrics-'.$user->id,
10, 10,
function () { function () {
return return Holding::query()
Holding::query()
->myHoldings() ->myHoldings()
->withoutWishlists() ->withoutWishlists()
->withPortfolioMetrics() ->getPortfolioMetrics();
->first();
} }
); );
+2 -3
View File
@@ -29,14 +29,13 @@ class PortfolioController extends Controller
$portfolio->load(['transactions', 'holdings']); $portfolio->load(['transactions', 'holdings']);
// get portfolio metrics // get portfolio metrics
$metrics = cache()->remember( $metrics = cache()->tags(['metrics-'.$request->user()->id])->remember(
'portfolio-metrics-'.$portfolio->id, 'portfolio-metrics-'.$portfolio->id,
60, 60,
function () use ($portfolio) { function () use ($portfolio) {
return Holding::query() return Holding::query()
->portfolio($portfolio->id) ->portfolio($portfolio->id)
->withPortfolioMetrics() ->getPortfolioMetrics();
->first();
} }
); );
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Number;
use Illuminate\Support\Str;
class LocalizationMiddleware
{
/**
* Handle an incoming request.
*
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
if (Auth::check()) {
$locale = auth()->user()->getLocale();
app()->setLocale(Str::before($locale, '_'));
Number::useLocale($locale);
Number::useCurrency(auth()->user()->getCurrency());
}
return $next($request);
}
}
-29
View File
@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class SetLocale
{
/**
* Handle an incoming request.
*
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
if (! session()->has('locale')) {
session()->put('locale', $request->getPreferredLanguage(
config('app.available_locales')
));
}
app()->setLocale(session('locale'));
return $next($request);
}
}
+4 -2
View File
@@ -30,11 +30,11 @@ class TransactionRequest extends FormRequest
'portfolio_id' => ['required', 'exists:portfolios,id'], 'portfolio_id' => ['required', 'exists:portfolios,id'],
'symbol' => ['required', 'string', new SymbolValidationRule], 'symbol' => ['required', 'string', new SymbolValidationRule],
'transaction_type' => ['required', 'string', 'in:BUY,SELL'], 'transaction_type' => ['required', 'string', 'in:BUY,SELL'],
'date' => ['required', 'date_format:Y-m-d', 'before_or_equal:'.now()->format('Y-m-d')], 'date' => ['required', 'date_format:Y-m-d', 'before_or_equal:'.now()->toDateString()],
'quantity' => [ 'quantity' => [
'required', 'required',
'numeric', 'numeric',
'min:0', 'gt:0',
new QuantityValidationRule( new QuantityValidationRule(
$this->input('portfolio'), $this->input('portfolio'),
$this->requestOrModelValue('symbol', 'transaction'), $this->requestOrModelValue('symbol', 'transaction'),
@@ -42,6 +42,7 @@ class TransactionRequest extends FormRequest
$this->requestOrModelValue('date', 'transaction') $this->requestOrModelValue('date', 'transaction')
), ),
], ],
'currency' => ['required', 'exists:currencies,currency'],
'cost_basis' => ['exclude_if:transaction_type,SELL', 'min:0', 'numeric'], 'cost_basis' => ['exclude_if:transaction_type,SELL', 'min:0', 'numeric'],
'sale_price' => ['exclude_if:transaction_type,BUY', 'min:0', 'numeric'], 'sale_price' => ['exclude_if:transaction_type,BUY', 'min:0', 'numeric'],
]; ];
@@ -50,6 +51,7 @@ class TransactionRequest extends FormRequest
$rules['portfolio_id'][0] = 'sometimes'; $rules['portfolio_id'][0] = 'sometimes';
$rules['symbol'][0] = 'sometimes'; $rules['symbol'][0] = 'sometimes';
$rules['transaction_type'][0] = 'sometimes'; $rules['transaction_type'][0] = 'sometimes';
$rules['currency'][0] = 'sometimes';
$rules['date'][0] = 'sometimes'; $rules['date'][0] = 'sometimes';
$rules['quantity'][0] = 'sometimes'; $rules['quantity'][0] = 'sometimes';
+1
View File
@@ -21,6 +21,7 @@ class HoldingResource extends JsonResource
'portfolio_id' => $this->portfolio_id, 'portfolio_id' => $this->portfolio_id,
'symbol' => $this->symbol, 'symbol' => $this->symbol,
'quantity' => $this->quantity, 'quantity' => $this->quantity,
'currency' => $this->market_data->currency,
'reinvest_dividends' => $this->reinvest_dividends, 'reinvest_dividends' => $this->reinvest_dividends,
'average_cost_basis' => $this->average_cost_basis, 'average_cost_basis' => $this->average_cost_basis,
'total_cost_basis' => $this->total_cost_basis, 'total_cost_basis' => $this->total_cost_basis,
@@ -22,6 +22,7 @@ class TransactionResource extends JsonResource
'portfolio_id' => $this->portfolio_id, 'portfolio_id' => $this->portfolio_id,
'transaction_type' => $this->transaction_type, 'transaction_type' => $this->transaction_type,
'quantity' => $this->quantity, 'quantity' => $this->quantity,
'currency' => $this->market_data->currency,
'cost_basis' => $this->cost_basis, 'cost_basis' => $this->cost_basis,
'sale_price' => $this->sale_price, 'sale_price' => $this->sale_price,
'split' => $this->split, 'split' => $this->split,
+4
View File
@@ -22,6 +22,10 @@ class UserResource extends JsonResource
'name' => $this->name, 'name' => $this->name,
'email' => $this->email, 'email' => $this->email,
'profile_photo_url' => $this->profile_photo_url, 'profile_photo_url' => $this->profile_photo_url,
'options' => [
'display_currency' => $this->getCurrency(),
'locale' => $this->getLocale(),
],
'created_at' => $this->created_at, 'created_at' => $this->created_at,
'updated_at' => $this->updated_at, 'updated_at' => $this->updated_at,
]; ];
+2
View File
@@ -8,6 +8,7 @@ use App\Console\Commands\RefreshDividendData;
use App\Console\Commands\RefreshMarketData; use App\Console\Commands\RefreshMarketData;
use App\Console\Commands\SyncDailyChange; use App\Console\Commands\SyncDailyChange;
use App\Console\Commands\SyncHoldingData; use App\Console\Commands\SyncHoldingData;
use App\Imports\Sheets\ConfigSheet;
use App\Imports\Sheets\DailyChangesSheet; use App\Imports\Sheets\DailyChangesSheet;
use App\Imports\Sheets\PortfoliosSheet; use App\Imports\Sheets\PortfoliosSheet;
use App\Imports\Sheets\TransactionsSheet; use App\Imports\Sheets\TransactionsSheet;
@@ -69,6 +70,7 @@ class BackupImport implements WithEvents, WithMultipleSheets
'Portfolios' => new PortfoliosSheet($this->backupImportModel), 'Portfolios' => new PortfoliosSheet($this->backupImportModel),
'Transactions' => new TransactionsSheet($this->backupImportModel), 'Transactions' => new TransactionsSheet($this->backupImportModel),
'Daily Changes' => new DailyChangesSheet($this->backupImportModel), 'Daily Changes' => new DailyChangesSheet($this->backupImportModel),
'Config' => new ConfigSheet($this->backupImportModel),
]; ];
} }
} }
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Imports\Sheets;
use App\Models\BackupImport;
use App\Models\Holding;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
use Maatwebsite\Excel\Concerns\ToCollection;
use Maatwebsite\Excel\Concerns\WithEvents;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithValidation;
use Maatwebsite\Excel\Events\BeforeSheet;
class ConfigSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
{
public function __construct(
public BackupImport $backupImport
) {}
public function registerEvents(): array
{
return [
BeforeSheet::class => function (BeforeSheet $event) {
DB::commit();
$this->backupImport->update([
'message' => __('Importing configurations...'),
]);
DB::beginTransaction();
},
];
}
public function collection(Collection $configs)
{
foreach ($configs as $config) {
switch ($config['key']) {
case 'name':
$this->backupImport->user->setAttribute('name', $config['value']);
$this->backupImport->user->save();
break;
case 'locale':
$this->backupImport->user->setOption('locale', $config['value']);
$this->backupImport->user->save();
break;
case 'display_currency':
$this->backupImport->user->setOption('display_currency', $config['value']);
$this->backupImport->user->save();
break;
case 'reinvested_dividends':
if (json_validate($config['value'])) {
foreach (json_decode($config['value'], true) as $reinvest) {
Holding::myHoldings($this->backupImport->user->id)
->where('portfolio_id', $reinvest['portfolio_id'])
->where('symbol', $reinvest['symbol'])
->update([
'reinvest_dividends' => true,
]);
}
}
break;
default:
break;
}
}
}
public function rules(): array
{
return [
'key' => ['required', 'string'],
'value' => ['required', 'string'],
];
}
}
+9 -18
View File
@@ -31,7 +31,7 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
BeforeSheet::class => function (BeforeSheet $event) { BeforeSheet::class => function (BeforeSheet $event) {
DB::commit(); DB::commit();
$this->backupImport->update([ $this->backupImport->update([
'message' => __('Importing daily changes...'), 'message' => __('Preparing to import daily changes...'),
]); ]);
DB::beginTransaction(); DB::beginTransaction();
}, },
@@ -40,22 +40,23 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
public function collection(Collection $dailyChanges) public function collection(Collection $dailyChanges)
{ {
$dailyChanges->chunk($this->batchSize())->each(function ($chunk) { $totalBatches = count($dailyChanges) / $this->batchSize();
$dailyChanges->chunk($this->batchSize())->each(function ($chunk, $index) use ($totalBatches) {
$this->validatePortfolioAccess($chunk); $this->validatePortfolioAccess($chunk);
$this->backupImport->update([
'message' => __('Importing daily changes (Batch :currentBatch of :totalBatches)...', ['currentBatch' => $index + 1, 'totalBatches' => $totalBatches]),
]);
// have to cast to native values // have to cast to native values
$chunk = $chunk->map(function ($dailyChange) { $chunk = $chunk->map(function ($dailyChange) {
return [ return [
'total_market_value' => $dailyChange['total_market_value'],
'total_cost_basis' => $dailyChange['total_cost_basis'],
'total_gain' => $dailyChange['total_gain'],
'total_dividends_earned' => $dailyChange['total_dividends_earned'],
'realized_gains' => $dailyChange['realized_gains'],
'annotation' => $dailyChange['annotation'], 'annotation' => $dailyChange['annotation'],
'portfolio_id' => $dailyChange['portfolio_id'], 'portfolio_id' => $dailyChange['portfolio_id'],
'date' => Carbon::parse($dailyChange['date'])->format('Y-m-d'), 'date' => Carbon::parse($dailyChange['date'])->toDateString(),
]; ];
}); });
@@ -63,11 +64,6 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
$chunk->toArray(), $chunk->toArray(),
['portfolio_id', 'date'], ['portfolio_id', 'date'],
[ [
'total_market_value',
'total_cost_basis',
'total_gain',
'total_dividends_earned',
'realized_gains',
'annotation', 'annotation',
'portfolio_id', 'portfolio_id',
'date', 'date',
@@ -86,11 +82,6 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
return [ return [
'portfolio_id' => ['required', 'uuid'], 'portfolio_id' => ['required', 'uuid'],
'date' => ['required', 'date'], 'date' => ['required', 'date'],
'total_market_value' => ['sometimes', 'nullable', 'numeric'],
'total_cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
'total_gain' => ['sometimes', 'nullable', 'numeric'],
'total_dividends_earned' => ['sometimes', 'nullable', 'min:0', 'numeric'],
'realized_gains' => ['sometimes', 'nullable', 'numeric'],
'annotation' => ['sometimes', 'nullable', 'string'], 'annotation' => ['sometimes', 'nullable', 'string'],
]; ];
} }
+33 -4
View File
@@ -6,6 +6,8 @@ namespace App\Imports\Sheets;
use App\Imports\ValidatesPortfolioAccess; use App\Imports\ValidatesPortfolioAccess;
use App\Models\BackupImport; use App\Models\BackupImport;
use App\Models\Currency;
use App\Models\CurrencyRate;
use App\Models\Holding; use App\Models\Holding;
use App\Models\Transaction; use App\Models\Transaction;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -33,7 +35,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
BeforeSheet::class => function (BeforeSheet $event) { BeforeSheet::class => function (BeforeSheet $event) {
DB::commit(); DB::commit();
$this->backupImport->update([ $this->backupImport->update([
'message' => __('Importing transactions...'), 'message' => __('Preparing to import transactions...'),
]); ]);
DB::beginTransaction(); DB::beginTransaction();
}, },
@@ -43,13 +45,37 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
public function collection(Collection $transactions) public function collection(Collection $transactions)
{ {
$transactions->chunk($this->batchSize())->each(function ($chunk) { // if has any transactions not in base currency, need to sync timeseries conversion rates
if ($transactions->where('currency', '!=', config('investbrain.base_currency'))->isNotEmpty()) {
CurrencyRate::timeSeriesRates('', $transactions->min('date'), $transactions->max('date'));
}
$totalBatches = count($transactions) / $this->batchSize();
// chunk transactions
$transactions->chunk($this->batchSize())->each(function ($chunk, $index) use ($totalBatches) {
$this->backupImport->update([
'message' => __('Importing transactions (Batch :currentBatch of :totalBatches)...', ['currentBatch' => $index + 1, 'totalBatches' => $totalBatches]),
]);
$this->validatePortfolioAccess($chunk); $this->validatePortfolioAccess($chunk);
// have to cast to native values // have to cast to native values
$chunk = $chunk->map(function ($transaction) { $chunk = $chunk->map(function ($transaction) {
$date = Carbon::parse($transaction['date'])->toDateString();
// if transaction not in base currency, need to convert
if ($transaction['currency'] == config('investbrain.base_currency')) {
$cost_basis_base = $transaction['cost_basis'] ?? 0;
$sale_price_base = $transaction['sale_price'];
} else {
$cost_basis_base = Currency::convert($transaction['cost_basis'], $transaction['currency'], date: $date);
$sale_price_base = Currency::convert($transaction['sale_price'], $transaction['currency'], date: $date);
}
return [ return [
'id' => $transaction['transaction_id'] ?? Str::uuid()->toString(), 'id' => $transaction['transaction_id'] ?? Str::uuid()->toString(),
'symbol' => strtoupper($transaction['symbol']), 'symbol' => strtoupper($transaction['symbol']),
@@ -58,9 +84,11 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
'quantity' => $transaction['quantity'], 'quantity' => $transaction['quantity'],
'cost_basis' => $transaction['cost_basis'] ?? 0, 'cost_basis' => $transaction['cost_basis'] ?? 0,
'sale_price' => $transaction['sale_price'], 'sale_price' => $transaction['sale_price'],
'cost_basis_base' => $cost_basis_base,
'sale_price_base' => $sale_price_base,
'split' => boolval($transaction['split']) ? 1 : 0, 'split' => boolval($transaction['split']) ? 1 : 0,
'reinvested_dividend' => boolval($transaction['reinvested_dividend']) ? 1 : 0, 'reinvested_dividend' => boolval($transaction['reinvested_dividend']) ? 1 : 0,
'date' => Carbon::parse($transaction['date'])->format('Y-m-d'), 'date' => $date,
]; ];
}); });
@@ -81,7 +109,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
] ]
); );
// stub out related holdings // get unique symbol/portfolio id combination and stub out related holdings
$chunk->unique(fn ($item) => $item['symbol'].$item['portfolio_id']) $chunk->unique(fn ($item) => $item['symbol'].$item['portfolio_id'])
->each(function ($holding) { ->each(function ($holding) {
@@ -112,6 +140,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
'transaction_type' => ['required', 'in:BUY,SELL'], 'transaction_type' => ['required', 'in:BUY,SELL'],
'date' => ['required', 'date'], 'date' => ['required', 'date'],
'quantity' => ['required', 'min:0', 'numeric'], 'quantity' => ['required', 'min:0', 'numeric'],
'currency' => ['required', 'string'],
'split' => ['sometimes', 'nullable', 'boolean'], 'split' => ['sometimes', 'nullable', 'boolean'],
'reinvested_dividend' => ['sometimes', 'nullable', 'boolean'], 'reinvested_dividend' => ['sometimes', 'nullable', 'boolean'],
'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'], 'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
+4 -4
View File
@@ -11,13 +11,13 @@ trait ValidatesPortfolioAccess
public function validatePortfolioAccess($collection) public function validatePortfolioAccess($collection)
{ {
$uniquePortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id'); $importingPortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id');
$countPortfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id) $portfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id)
->whereIn('id', $uniquePortfolios) ->whereIn('id', $importingPortfolios)
->count(); ->count();
if ( if (
$countPortfoliosWithAccess < $uniquePortfolios->count() $importingPortfolios->count() > $portfoliosWithAccess
) { ) {
throw new \Exception(__('You do not have access to that portfolio.')); throw new \Exception(__('You do not have access to that portfolio.'));
} }
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Interfaces\MarketData;
use App\Interfaces\MarketData\Types\Dividend;
use App\Interfaces\MarketData\Types\Ohlc;
use App\Interfaces\MarketData\Types\Quote;
use App\Interfaces\MarketData\Types\Split;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
class AlpacaMarketData implements MarketDataInterface
{
public PendingRequest $client;
public string $dataBaseUrl = 'https://data.alpaca.markets/';
public string $apiBaseUrl = 'https://api.alpaca.markets/';
public function __construct()
{
$this->client = Http::withOptions([
'headers' => [
'content-type' => 'application/json',
'accept' => 'application/json',
'Apca-Api-Key-Id' => config('alpaca.key'),
'Apca-Api-Secret-Key' => config('alpaca.secret'),
],
]);
}
public function exists(string $symbol): bool
{
return (bool) $this->quote($symbol);
}
public function quote(string $symbol): Quote
{
$response = $this->client->baseUrl($this->dataBaseUrl)->get("v2/stocks/{$symbol}/trades/latest");
$quote = $response->json('trade');
if (is_null(Arr::get($quote, 'p'))) {
throw new \Exception('Could not find ticker on Alpaca');
}
$fundamental = cache()->remember(
'ap-symbol-'.$symbol,
1440,
function () use ($symbol) {
$basic = $this->client->baseUrl($this->apiBaseUrl)->get("v2/assets/{$symbol}")->json();
$fifty_two_week = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
'timeframe' => '12M',
'start' => now()->subWeeks(53)->format('Y-m-d'),
'end' => now()->subWeeks(1)->format('Y-m-d'), // todo: can't query recent SIP data
])->get("v2/stocks/{$symbol}/bars")->json();
return array_merge($fifty_two_week, $basic);
}
);
return new Quote([
'name' => Arr::get($fundamental, 'name'),
'symbol' => $symbol,
'currency' => 'USD', // Alpaca only has US equitities
'market_value' => Arr::get($quote, 'p'),
'fifty_two_week_high' => Arr::get($fundamental, 'bars.0.h'),
'fifty_two_week_low' => Arr::get($fundamental, 'bars.0.l'),
]);
}
public function dividends(string $symbol, $startDate, $endDate): Collection
{
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
'symbols' => $symbol,
'limit' => 1000,
'sort' => 'asc',
'types' => 'cash_dividend',
'start' => $startDate->format('Y-m-d'),
'end' => $endDate->format('Y-m-d'),
])->get('v1/corporate-actions');
$dividends = $response->json('corporate_actions.cash_dividends');
return collect($dividends)
->map(function ($dividend) use ($symbol) {
return new Dividend([
'symbol' => $symbol,
'date' => Carbon::parse(Arr::get($dividend, 'ex_date')),
'dividend_amount' => Arr::get($dividend, 'rate'),
]);
});
}
public function splits(string $symbol, $startDate, $endDate): Collection
{
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
'symbols' => $symbol,
'limit' => 1000,
'sort' => 'asc',
'types' => 'forward_split',
'start' => $startDate->format('Y-m-d'),
'end' => $endDate->format('Y-m-d'),
])->get('v1/corporate-actions');
$splits = $response->json('corporate_actions.forward_splits');
return collect($splits)
->map(function ($split) use ($symbol) {
return new Split([
'symbol' => $symbol,
'date' => Carbon::parse(Arr::get($split, 'ex_date')),
'split_amount' => Arr::get($split, 'new_rate') / Arr::get($split, 'old_rate'),
]);
});
}
public function history(string $symbol, $startDate, $endDate): Collection
{
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
'timeframe' => '1D',
'start' => Carbon::parse($startDate)->format('Y-m-d'),
'end' => Carbon::parse($endDate)->subHours(36)->format('Y-m-d'), // todo: can't query recent SIP data
])->get("v2/stocks/{$symbol}/bars");
$history = $response->json('bars');
return collect($history)
->map(function ($history) use ($symbol) {
$date = Carbon::parse($history['t'])->format('Y-m-d');
return [$date => new Ohlc([
'symbol' => $symbol,
'date' => $date,
'close' => Arr::get($history, 'c'),
])];
});
}
}
@@ -23,23 +23,44 @@ class AlphaVantageMarketData implements MarketDataInterface
public function quote(string $symbol): Quote public function quote(string $symbol): Quote
{ {
$search = Alphavantage::core()->search($symbol);
$search = Arr::get($search, 'bestMatches.0', null);
if (Arr::get($search, '9. matchScore') !== '1.0000') {
throw new \Exception('Could not find ticker on Alphavantage');
}
$quote = Alphavantage::core()->quoteEndpoint($symbol); $quote = Alphavantage::core()->quoteEndpoint($symbol);
$quote = Arr::get($quote, 'Global Quote', []); $quote = Arr::get($quote, 'Global Quote', []);
$fundamental = cache()->remember( $fundamental = cache()->remember(
'av-symbol-'.$symbol, 'av-symbol-'.$symbol,
1440, 1440,
function () use ($symbol) { function () use ($symbol, $search) {
return Alphavantage::fundamentals()->overview($symbol); if (Arr::get($search, '3. type') === 'Equity') {
$fundamental = (array) Alphavantage::fundamentals()->overview($symbol);
} else {
$fundamental = (array) Alphavantage::fundamentals()->etfProfile($symbol);
Arr::set($fundamental, 'DividendYield', Arr::get($fundamental, 'dividend_yield'));
Arr::set($fundamental, 'MarketCapitalization', Arr::get($fundamental, 'net_assets'));
Arr::set($fundamental, 'InceptionDate', Arr::get($fundamental, 'inception_date'));
}
return $fundamental;
} }
); );
return new Quote([ return new Quote([
'name' => Arr::get($fundamental, 'Name'), 'name' => Arr::get($search, '2. name'),
'symbol' => $symbol, 'symbol' => $symbol,
'market_value' => Arr::get($quote, '05. price'), 'market_value' => (float) Arr::get($quote, '05. price'),
'fifty_two_week_high' => Arr::get($fundamental, '52WeekHigh'), 'currency' => Arr::get($search, '8. currency'),
'fifty_two_week_low' => Arr::get($fundamental, '52WeekLow'), 'fifty_two_week_high' => (float) Arr::get($fundamental, '52WeekHigh'),
'fifty_two_week_low' => (float) Arr::get($fundamental, '52WeekLow'),
'forward_pe' => Arr::get($fundamental, 'ForwardPE'), 'forward_pe' => Arr::get($fundamental, 'ForwardPE'),
'trailing_pe' => Arr::get($fundamental, 'TrailingPE'), 'trailing_pe' => Arr::get($fundamental, 'TrailingPE'),
'market_cap' => Arr::get($fundamental, 'MarketCapitalization'), 'market_cap' => Arr::get($fundamental, 'MarketCapitalization'),
@@ -48,8 +69,20 @@ class AlphaVantageMarketData implements MarketDataInterface
? Arr::get($fundamental, 'DividendDate') ? Arr::get($fundamental, 'DividendDate')
: null, : null,
'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None' 'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None'
? Arr::get($fundamental, 'DividendYield') ? ((float) Arr::get($fundamental, 'DividendYield')) * 100
: null, : null,
'meta_data' => [
'industry' => Arr::get($fundamental, 'Industry'),
'country' => Arr::get($search, '4. region'),
'exchange' => Arr::get($fundamental, 'Exchange'),
'description' => Arr::get($fundamental, 'Description'),
'asset_type' => Arr::get($search, '3. type'),
'sector' => Arr::get($fundamental, 'Sector'),
'first_trade_year' => Arr::get($fundamental, 'InceptionDate')
? Carbon::parse(Arr::get($fundamental, 'InceptionDate'))->format('Y')
: null,
'source' => 'alphavantage',
],
]); ]);
} }
@@ -107,7 +140,7 @@ class AlphaVantageMarketData implements MarketDataInterface
}) })
->mapWithKeys(function ($history, $date) use ($symbol) { ->mapWithKeys(function ($history, $date) use ($symbol) {
$date = Carbon::parse($date)->format('Y-m-d'); $date = Carbon::parse($date)->toDateString();
return [$date => new Ohlc([ return [$date => new Ohlc([
'symbol' => $symbol, 'symbol' => $symbol,
+19 -5
View File
@@ -8,6 +8,7 @@ use App\Interfaces\MarketData\Types\Dividend;
use App\Interfaces\MarketData\Types\Ohlc; use App\Interfaces\MarketData\Types\Ohlc;
use App\Interfaces\MarketData\Types\Quote; use App\Interfaces\MarketData\Types\Quote;
use App\Interfaces\MarketData\Types\Split; use App\Interfaces\MarketData\Types\Split;
use Carbon\CarbonPeriod;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -25,6 +26,7 @@ class FakeMarketData implements MarketDataInterface
return new Quote([ return new Quote([
'name' => 'ACME Company Ltd', 'name' => 'ACME Company Ltd',
'symbol' => $symbol, 'symbol' => $symbol,
'currency' => 'USD',
'market_value' => 230.19, 'market_value' => 230.19,
'fifty_two_week_high' => 512.90, 'fifty_two_week_high' => 512.90,
'fifty_two_week_low' => 341.20, 'fifty_two_week_low' => 341.20,
@@ -34,6 +36,7 @@ class FakeMarketData implements MarketDataInterface
'book_value' => 4.7, 'book_value' => 4.7,
'last_dividend_date' => now()->subDays(45), 'last_dividend_date' => now()->subDays(45),
'dividend_yield' => 0.033, 'dividend_yield' => 0.033,
'meta_data' => [],
]); ]);
} }
@@ -65,7 +68,7 @@ class FakeMarketData implements MarketDataInterface
return collect([ return collect([
new Split([ new Split([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => now()->subMonths(36), 'date' => now()->subMonths(12),
'split_amount' => 10, 'split_amount' => 10,
]), ]),
]); ]);
@@ -73,16 +76,27 @@ class FakeMarketData implements MarketDataInterface
public function history(string $symbol, $startDate, $endDate): Collection public function history(string $symbol, $startDate, $endDate): Collection
{ {
$numDays = Carbon::parse($startDate)->diffInDays($endDate, true); $endDate = now()->isBefore(Carbon::parse(config('investbrain.daily_change_time_of_day')))
? now()->subDay()
: now();
for ($i = 0; $i < $numDays; $i++) { $days = CarbonPeriod::create($startDate, $endDate)->filter('isWeekday');
$date = now()->subDays($i)->format('Y-m-d'); $countOfDays = $days->count();
foreach ($days as $index => $date) {
$date = $date->toDateString();
$series[$date] = new Ohlc([ $series[$date] = new Ohlc([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => $date, 'date' => $date,
'close' => rand(150, 400), 'open' => rand(150, 400),
'high' => rand(150, 400),
'low' => rand(150, 400),
'close' => $index == $countOfDays - 1
? 230.19 // most recent close should match current market value
: rand(150, 400),
]); ]);
} }
@@ -18,9 +18,10 @@ class FallbackInterface
foreach ($providers as $provider) { foreach ($providers as $provider) {
$provider = trim($provider); $provider = trim($provider);
$symbol = $arguments[0];
try { try {
Log::warning("Calling method {$method} ({$provider})"); Log::info("Calling method {$method} for {$symbol} ({$provider})");
if (! in_array($provider, array_keys(config('investbrain.interfaces', [])))) { if (! in_array($provider, array_keys(config('investbrain.interfaces', [])))) {
@@ -35,17 +36,17 @@ class FallbackInterface
$this->latest_error = $e->getMessage(); $this->latest_error = $e->getMessage();
Log::warning("Failed calling method {$method} ({$provider}): {$this->latest_error}"); Log::error("Failed calling method {$method} for {$symbol} ({$provider}): {$this->latest_error}");
} }
} }
// don't need to throw error if calling exists // don't need to throw error if calling exists method...
if ($method == 'exists') { if ($method == 'exists') {
// symbol prob just doesn't exist // symbol prob just doesn't exist
return false; return false;
} }
throw new \Exception("Could not get market data: {$this->latest_error}"); throw new \Exception("Could not get market data calling method {$method}: {$this->latest_error}");
} }
} }
+26 -11
View File
@@ -8,6 +8,7 @@ use App\Interfaces\MarketData\Types\Dividend;
use App\Interfaces\MarketData\Types\Ohlc; use App\Interfaces\MarketData\Types\Ohlc;
use App\Interfaces\MarketData\Types\Quote; use App\Interfaces\MarketData\Types\Quote;
use App\Interfaces\MarketData\Types\Split; use App\Interfaces\MarketData\Types\Split;
use Finnhub\ObjectSerializer;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -35,32 +36,46 @@ class FinnhubMarketData implements MarketDataInterface
{ {
$quote = $this->client->quote($symbol); $quote = $this->client->quote($symbol);
if (is_null(Arr::get($quote, 'd'))) {
throw new \Exception('Could not find ticker on Finnhub');
}
$fundamental = cache()->remember( $fundamental = cache()->remember(
'fh-symbol-'.$symbol, 'fh-symbol-'.$symbol,
1440, 1440,
function () use ($symbol) { function () use ($symbol) {
return $this->client->companyBasicFinancials($symbol, 'all');
return array_merge(
(array) ObjectSerializer::sanitizeForSerialization($this->client->companyProfile2($symbol)),
(array) ObjectSerializer::sanitizeForSerialization($this->client->companyBasicFinancials($symbol, 'all')),
);
} }
); );
return new Quote([ return new Quote([
'name' => Arr::get($fundamental, 'metric.name'), 'name' => Arr::get($fundamental, 'name'),
'symbol' => $symbol, 'symbol' => $symbol,
'currency' => Arr::get($fundamental, 'currency'),
'market_value' => Arr::get($quote, 'c'), 'market_value' => Arr::get($quote, 'c'),
'fifty_two_week_high' => Arr::get($fundamental, 'metric.52WeekHigh'), 'fifty_two_week_high' => Arr::get($fundamental, 'metric.52WeekHigh'),
'fifty_two_week_low' => Arr::get($fundamental, 'metric.52WeekLow'), 'fifty_two_week_low' => Arr::get($fundamental, 'metric.52WeekLow'),
'forward_pe' => Arr::get($fundamental, 'metric.forwardPE'), // confirm 'forward_pe' => Arr::get($fundamental, 'metric.peAnnual'),
'trailing_pe' => Arr::get($fundamental, 'metric.trailingPE'), // confirm 'trailing_pe' => Arr::get($fundamental, 'metric.peTTM'),
'market_cap' => Arr::get($fundamental, 'metric.marketCapitalization'), // confirm 'market_cap' => Arr::get($fundamental, 'metric.marketCapitalization', 0) * 1000000,
'book_value' => Arr::get($fundamental, 'metric.bookValuePerShare'), // confirm 'book_value' => Arr::get($fundamental, 'metric.bookValuePerShareAnnual'),
'last_dividend_date' => Arr::get($fundamental, 'metric.lastDivDate'), // confirm 'dividend_yield' => Arr::get($fundamental, 'metric.dividendYieldIndicatedAnnual'),
'dividend_yield' => Arr::get($fundamental, 'metric.dividendYield'), // confirm 'meta_data' => [
'country' => Arr::get($fundamental, 'country'),
'exchange' => Arr::get($fundamental, 'exchange'),
'first_trade_year' => Arr::get($fundamental, 'ipo') ? Carbon::parse(Arr::get($fundamental, 'ipo'))->format('Y') : null,
'source' => 'finnhub',
],
]); ]);
} }
public function dividends($symbol, $startDate, $endDate): Collection public function dividends($symbol, $startDate, $endDate): Collection
{ {
$dividends = $this->client->stockDividends($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d')); $dividends = $this->client->stockDividends($symbol, $startDate->toDateString(), $endDate->toDateString());
return collect($dividends)->map(function ($dividend) use ($symbol) { return collect($dividends)->map(function ($dividend) use ($symbol) {
@@ -75,7 +90,7 @@ class FinnhubMarketData implements MarketDataInterface
public function splits($symbol, $startDate, $endDate): Collection public function splits($symbol, $startDate, $endDate): Collection
{ {
$splits = $this->client->stockSplits($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d')); $splits = $this->client->stockSplits($symbol, $startDate->toDateString(), $endDate->toDateString());
return collect($splits)->map(function ($split) use ($symbol) { return collect($splits)->map(function ($split) use ($symbol) {
@@ -96,7 +111,7 @@ class FinnhubMarketData implements MarketDataInterface
$closes = Arr::get($history, 'c', []); $closes = Arr::get($history, 'c', []);
return collect($timestamps)->mapWithKeys(function ($timestamp, $index) use ($symbol, $closes) { return collect($timestamps)->mapWithKeys(function ($timestamp, $index) use ($symbol, $closes) {
$date = Carbon::createFromTimestamp($timestamp)->format('Y-m-d'); $date = Carbon::createFromTimestamp($timestamp)->toDateString();
return [$date => new Ohlc([ return [$date => new Ohlc([
'symbol' => $symbol, 'symbol' => $symbol,
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Interfaces\MarketData;
use App\Interfaces\MarketData\Types\Dividend;
use App\Interfaces\MarketData\Types\Ohlc;
use App\Interfaces\MarketData\Types\Quote;
use App\Interfaces\MarketData\Types\Split;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
class TwelveDataMarketData implements MarketDataInterface
{
public PendingRequest $client;
public string $apiBaseUrl = 'https://api.twelvedata.com/';
public function __construct()
{
$this->createNewClient();
}
private function createNewClient()
{
$this->client = Http::withOptions([
'headers' => [
'content-type' => 'application/json',
'accept' => 'application/json',
],
])->withQueryParameters([
'apikey' => config('twelvedata.secret'),
]);
}
public function exists(string $symbol): bool
{
return (bool) $this->quote($symbol);
}
public function quote(string $symbol): Quote
{
$response = $this->client
->baseUrl($this->apiBaseUrl)
->withQueryParameters(['symbol' => $symbol])
->get('price');
$quote = $response->json();
if (! isset($quote['price'])) {
throw new \Exception('Could not find ticker on Twelve Data');
}
$current_market_value = Arr::get($quote, 'price');
$fundamental = cache()->remember(
'twelve-data-symbol-'.$symbol,
1440,
function () use ($symbol) {
$this->createNewClient();
$response = $this->client
->baseUrl($this->apiBaseUrl)
->withQueryParameters(['symbol' => $symbol])
->get('quote');
return $response->json();
}
);
return new Quote([
'name' => Arr::get($fundamental, 'name'),
'symbol' => $symbol,
'currency' => Arr::get($fundamental, 'currency'),
'market_value' => (float) $current_market_value,
'fifty_two_week_high' => (float) Arr::get($fundamental, 'fifty_two_week.high'),
'fifty_two_week_low' => (float) Arr::get($fundamental, 'fifty_two_week.low'),
'meta_data' => [
'exchange' => Arr::get($fundamental, 'exchange'),
'source' => 'twelvedata',
],
]);
}
public function dividends(string $symbol, $startDate, $endDate): Collection
{
$response = $this->client
->baseUrl($this->apiBaseUrl)
->withQueryParameters([
'symbol' => $symbol,
'start_date' => Carbon::parse($startDate)->toDateString(),
'end_date' => Carbon::parse($endDate)->toDateString(),
])
->get('dividends');
$dividends = $response->json('dividends');
return collect($dividends)
->map(function ($dividend) use ($symbol) {
return new Dividend([
'symbol' => $symbol,
'date' => Arr::get($dividend, 'ex_date'),
'dividend_amount' => Arr::get($dividend, 'amount'),
]);
});
}
public function splits(string $symbol, $startDate, $endDate): Collection
{
$response = $this->client
->baseUrl($this->apiBaseUrl)
->withQueryParameters([
'symbol' => $symbol,
'start_date' => Carbon::parse($startDate)->toDateString(),
'end_date' => Carbon::parse($endDate)->toDateString(),
])
->get('splits');
$splits = $response->json('splits');
return collect($splits)
->map(function ($split) use ($symbol) {
return new Split([
'symbol' => $symbol,
'date' => Arr::get($split, 'date'),
'split_amount' => Arr::get($split, 'from_factor') / Arr::get($split, 'to_factor'),
]);
});
}
public function history(string $symbol, $startDate, $endDate): Collection
{
$response = $this->client
->baseUrl($this->apiBaseUrl)
->withQueryParameters([
'symbol' => $symbol,
'interval' => '1day',
'start_date' => Carbon::parse($startDate)->toDateString(),
'end_date' => Carbon::parse($endDate)->toDateString(),
])
->get('time_series');
$values = $response->json('values');
return collect($values)
->mapWithKeys(function ($history) use ($symbol) {
$date = Carbon::parse(Arr::get($history, 'datetime'))->toDateString();
return [$date => new Ohlc([
'symbol' => $symbol,
'date' => $date,
'close' => (float) Arr::get($history, 'close'),
])];
});
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ class Dividend extends MarketDataType
return $this->items['symbol'] ?? ''; return $this->items['symbol'] ?? '';
} }
public function setDividendAmount($dividendAmount): self public function setDividendAmount(int|float $dividendAmount): self
{ {
$this->items['dividend_amount'] = (float) $dividendAmount; $this->items['dividend_amount'] = (float) $dividendAmount;
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Interfaces\MarketData\Types; namespace App\Interfaces\MarketData\Types;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -12,24 +13,79 @@ class MarketDataType extends Collection
public function __construct($items = []) public function __construct($items = [])
{ {
foreach ($this->getArrayableItems($items) as $key => $value) { $items = $this->getArrayableItems($items);
foreach ($items as $key => $value) {
$this->validateRequiredTypes($key, $value);
if (! is_null($value)) {
$this->{$key} = $value; $this->{$key} = $value;
} }
} }
public function toArray()
{
return $this->items;
} }
public function __set($key, $value) public function __set($key, $value)
{ {
$this->{'set'.Str::studly($key)}($value);
$this->{$this->getSetMethodName($key)}($value);
} }
public function __get($key) public function __get($key)
{ {
return $this->items[$key] ?? null; return $this->items[$key] ?? null;
} }
protected function getSetMethodName($key): string
{
return 'set'.Str::studly($key);
}
protected function validateRequiredTypes($key, $value, $type = null): void
{
$method = new \ReflectionMethod($this, $this->getSetMethodName($key));
$params = $method->getParameters();
// no required type
if (is_null($type) && is_null($type = $params[0]->getType())) {
return;
}
// can`t validate a mixed type
if ($type == 'mixed') {
return;
}
// has a union type, let's iterate
if ($type instanceof \ReflectionUnionType) {
foreach ($type->getTypes() as $subType) {
$expected[] = $subType;
try {
$this->validateRequiredTypes($key, $value, $subType);
return;
} catch (\InvalidArgumentException) {
}
}
}
// check type
if ($type instanceof \ReflectionNamedType) {
$expected = $type->getName();
if (get_debug_type($value) == $expected || ($type->allowsNull() && $value === null)) {
return;
}
if (class_exists($expected) && is_subclass_of(get_debug_type($value), $expected)) {
return;
}
}
throw new \InvalidArgumentException("Invalid type for {$key}. Expected ".implode('|', array_map(fn ($t) => $t, Arr::wrap($expected))).' but got '.get_debug_type($value));
}
} }
+4 -4
View File
@@ -21,7 +21,7 @@ class Ohlc extends MarketDataType
return $this->items['symbol'] ?? ''; return $this->items['symbol'] ?? '';
} }
public function setOpen($open): self public function setOpen(int|float $open): self
{ {
$this->items['open'] = (float) $open; $this->items['open'] = (float) $open;
@@ -33,7 +33,7 @@ class Ohlc extends MarketDataType
return $this->items['open'] ?? 0.0; return $this->items['open'] ?? 0.0;
} }
public function setHigh($high): self public function setHigh(int|float $high): self
{ {
$this->items['high'] = (float) $high; $this->items['high'] = (float) $high;
@@ -45,7 +45,7 @@ class Ohlc extends MarketDataType
return $this->items['high'] ?? 0.0; return $this->items['high'] ?? 0.0;
} }
public function setLow($low): self public function setLow(int|float $low): self
{ {
$this->items['low'] = (float) $low; $this->items['low'] = (float) $low;
@@ -57,7 +57,7 @@ class Ohlc extends MarketDataType
return $this->items['low'] ?? 0.0; return $this->items['low'] ?? 0.0;
} }
public function setClose($close): self public function setClose(int|float $close): self
{ {
$this->items['close'] = (float) $close; $this->items['close'] = (float) $close;
+51 -1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Interfaces\MarketData\Types; namespace App\Interfaces\MarketData\Types;
use DateTime; use DateTime;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
class Quote extends MarketDataType class Quote extends MarketDataType
@@ -35,7 +36,19 @@ class Quote extends MarketDataType
return $this->items['symbol'] ?? ''; return $this->items['symbol'] ?? '';
} }
public function setMarketValue($marketValue): self public function setCurrency(string $currency): self
{
$this->items['currency'] = strtoupper((string) $currency);
return $this;
}
public function getCurrency(): string
{
return $this->items['currency'] ?? '';
}
public function setMarketValue(int|float $marketValue): self
{ {
$this->items['market_value'] = (float) $marketValue; $this->items['market_value'] = (float) $marketValue;
@@ -97,6 +110,7 @@ class Quote extends MarketDataType
public function setMarketCap($cap): self public function setMarketCap($cap): self
{ {
// return $this;
$this->items['market_cap'] = (int) $cap; $this->items['market_cap'] = (int) $cap;
return $this; return $this;
@@ -119,6 +133,18 @@ class Quote extends MarketDataType
return $this->items['book_value'] ?? 0.0; return $this->items['book_value'] ?? 0.0;
} }
public function setLastDividendAmount($value): self
{
$this->items['last_dividend_amount'] = (float) $value;
return $this;
}
public function getLastDividendAmount(): float
{
return $this->items['last_dividend_amount'] ?? 0.0;
}
public function setLastDividendDate(mixed $date): self public function setLastDividendDate(mixed $date): self
{ {
$this->items['last_dividend_date'] = is_null($date) ? null : Carbon::parse($date)->format('Y-m-d H:i:s'); $this->items['last_dividend_date'] = is_null($date) ? null : Carbon::parse($date)->format('Y-m-d H:i:s');
@@ -142,4 +168,28 @@ class Quote extends MarketDataType
{ {
return $this->items['dividend_yield'] ?? 0.0; return $this->items['dividend_yield'] ?? 0.0;
} }
public function setMetaData(array $meta_data): self
{
$defaults = [
'sector' => null,
'industry' => null,
'country' => null,
'exchange' => null,
'description' => null,
'asset_type' => null,
'first_trade_year' => null,
'source' => null,
];
// merges the NEW values with highest priority over previous values and defaults
$this->items['meta_data'] = array_merge($defaults, $this->items['meta_data'] ?? [], Arr::skipEmptyValues($meta_data));
return $this;
}
public function getMetaData(): array
{
return $this->items['meta_data'];
}
} }
+1 -1
View File
@@ -21,7 +21,7 @@ class Split extends MarketDataType
return $this->items['symbol'] ?? ''; return $this->items['symbol'] ?? '';
} }
public function setSplitAmount($splitAmount): self public function setSplitAmount(int|float $splitAmount): self
{ {
$this->items['split_amount'] = (float) $splitAmount; $this->items['split_amount'] = (float) $splitAmount;
+16 -2
View File
@@ -8,6 +8,7 @@ use App\Interfaces\MarketData\Types\Dividend;
use App\Interfaces\MarketData\Types\Ohlc; use App\Interfaces\MarketData\Types\Ohlc;
use App\Interfaces\MarketData\Types\Quote; use App\Interfaces\MarketData\Types\Quote;
use App\Interfaces\MarketData\Types\Split; use App\Interfaces\MarketData\Types\Split;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Scheb\YahooFinanceApi\ApiClient; use Scheb\YahooFinanceApi\ApiClient;
use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance; use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance;
@@ -20,7 +21,10 @@ class YahooMarketData implements MarketDataInterface
{ {
// create yahoo finance client factory // create yahoo finance client factory
$this->client = YahooFinance::createApiClient(); $this->client = YahooFinance::createApiClient(
clientOptions: ['headers' => ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36']],
cache: app('cache.psr6')
);
} }
public function exists(string $symbol): bool public function exists(string $symbol): bool
@@ -34,9 +38,14 @@ class YahooMarketData implements MarketDataInterface
$quote = $this->client->getQuote($symbol); $quote = $this->client->getQuote($symbol);
if (is_null($quote?->getRegularMarketPrice())) {
throw new \Exception('Could not find ticker on Yahoo');
}
return new Quote([ return new Quote([
'name' => $quote?->getLongName() ?? $quote?->getShortName(), 'name' => $quote?->getLongName() ?? $quote?->getShortName(),
'symbol' => $symbol, 'symbol' => $symbol,
'currency' => $quote?->getCurrency(),
'market_value' => $quote?->getRegularMarketPrice(), 'market_value' => $quote?->getRegularMarketPrice(),
'fifty_two_week_high' => $quote?->getFiftyTwoWeekHigh(), 'fifty_two_week_high' => $quote?->getFiftyTwoWeekHigh(),
'fifty_two_week_low' => $quote?->getFiftyTwoWeekLow(), 'fifty_two_week_low' => $quote?->getFiftyTwoWeekLow(),
@@ -46,6 +55,11 @@ class YahooMarketData implements MarketDataInterface
'book_value' => $quote?->getBookValue(), 'book_value' => $quote?->getBookValue(),
'last_dividend_date' => $quote?->getDividendDate(), 'last_dividend_date' => $quote?->getDividendDate(),
'dividend_yield' => $quote?->getTrailingAnnualDividendYield() * 100, 'dividend_yield' => $quote?->getTrailingAnnualDividendYield() * 100,
'meta_data' => [
'exchange' => $quote?->getExchange(),
'asset_type' => $quote?->getQuoteType(),
'source' => 'yahoo',
],
]); ]);
} }
@@ -84,7 +98,7 @@ class YahooMarketData implements MarketDataInterface
return collect($this->client->getHistoricalQuoteData($symbol, ApiClient::INTERVAL_1_DAY, $startDate, $endDate)) return collect($this->client->getHistoricalQuoteData($symbol, ApiClient::INTERVAL_1_DAY, $startDate, $endDate))
->mapWithKeys(function ($history) use ($symbol) { ->mapWithKeys(function ($history) use ($symbol) {
$date = $history->getDate()->format('Y-m-d'); $date = Carbon::parse($history->getDate())->toDateString();
return [$date => new Ohlc([ return [$date => new Ohlc([
'symbol' => $symbol, 'symbol' => $symbol,
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\CurrencyRate;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class QueuedCurrencyRateInsertJob implements ShouldQueue
{
use Queueable;
/**
* The number of times the job may be attempted.
*/
public $tries = 3;
public function __construct(
protected array $chunk
) {
$this->chunk = $chunk;
}
/**
* Execute the job.
*/
public function handle(): void
{
CurrencyRate::insertOrIgnore($this->chunk);
}
}
+5
View File
@@ -50,4 +50,9 @@ class BackupImport extends Model
'completed_at' => 'datetime', 'completed_at' => 'datetime',
]; ];
} }
public function user()
{
return $this->belongsTo(User::class);
}
} }
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Number;
class Currency extends Model
{
protected $hidden = [];
protected $primaryKey = 'currency';
protected $keyType = 'string';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'currency',
'label',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
public static function forHumans(int|float $number, ?string $currency = null, ?string $locale = null): string
{
$symbol = Number::currencySymbol($currency, $locale);
return $symbol.Number::forHumans($number);
}
/**
* Returns a list of supported currencies
*
* @param bool|null $withAliases Whether to include aliases in list of currencies
*/
public static function list(?bool $withAliases = true): Collection
{
$aliases = $withAliases ? collect(config('investbrain.currency_aliases'))->map(function ($value, $currency) {
return [
'currency' => $currency,
'label' => $value['label'],
];
})->values() : collect();
return $aliases->merge(self::get()->map->only(['currency', 'label']));
}
/**
* Converts between supported currencies
*
* @param string|null $to (defaults to base currency)
*/
public static function convert(?float $value, string $from, ?string $to = null, mixed $date = null): float
{
if (empty($value)) {
return 0;
}
// Assume converting to base
if (empty($to)) {
$to = config('investbrain.base_currency');
}
// Get rate
[$from, $to] = [
cache()->remember($from.'_rate_'.$date, 10, function () use ($from, $date) {
return CurrencyRate::historic($from, $date);
}),
cache()->remember($to.'_rate_'.$date, 10, function () use ($to, $date) {
return CurrencyRate::historic($to, $date);
}),
];
// get from rate
$rate_to_base = 1 / $from;
// get value in base currency
$base_currency_value = $value * $rate_to_base;
return (float) $base_currency_value * $to;
}
}
+298
View File
@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Jobs\QueuedCurrencyRateInsertJob;
use Carbon\CarbonInterface;
use Carbon\CarbonPeriod;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Investbrain\Frankfurter\Frankfurter;
class CurrencyRate extends Model
{
protected $hidden = [];
protected $primaryKey = 'currency';
protected $keyType = 'string';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'date',
'currency',
'rate',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'rate' => 'float',
'date' => 'date',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
public static function current(string $currency): float
{
return (float) self::historic($currency);
}
/**
* Get historic rate for symbol
*/
public static function historic(string $currency, mixed $date = null): float
{
// No need to convert
if ($currency === config('investbrain.base_currency')) {
return 1;
}
// If we don't need historic, let's use current rate
if (empty($date)) {
$date = now();
}
// Make sure we have a Carbon date
$date = Carbon::parse($date);
// Handle aliases
[$currency, $adjustment] = self::getCurrencyAliasAdjustments($currency);
// Get or create historic rate
$rate = self::select('rate')
->whereDate('date', $date->toDateString())
->where(['currency' => $currency])
->firstOr(function () use ($date, $currency) {
$currencies = Currency::all()->pluck('currency')->toArray();
$rates = Frankfurter::setSymbols($currencies)->historical($date);
$date = Arr::get($rates, 'date');
$updates = Arr::map(Arr::get($rates, 'rates', []), function ($rate, $curr) use ($date) {
return [
'currency' => $curr,
'date' => $date,
'rate' => $rate,
'updated_at' => now()->toDateTimeString(),
'created_at' => now()->toDateTimeString(),
];
});
// persist
self::chunkInsert($updates);
return new CurrencyRate(Arr::first($updates, fn ($update) => $update['currency'] == $currency) ?? ['rate' => 1]);
});
return (float) $rate->rate * $adjustment;
}
/**
* Get rates for range of dates
*
* @return array<string, float>
*/
public static function timeSeriesRates(string|array|null $currency = null, mixed $start = null, mixed $end = null): array
{
if (empty($start)) {
return [];
}
$end = $end ?? now();
$period = CarbonPeriod::create($start, $end);
// No need to send network request - just generate 1s
if ($currency === config('investbrain.base_currency')) {
$dateRange = [];
foreach ($period as $date) {
$dateRange[$date->toDateString()] = 1;
}
return $dateRange;
}
if (is_array($currency)) {
$i = 1;
foreach ($currency as $curr) {
dispatch(fn () => self::timeSeriesRates($curr, $start, $end))->delay(now()->addSeconds(30 * $i));
$i++;
}
return [];
}
// handle currency alias
if (! empty($currency)) {
[$currency, $adjustment] = self::getCurrencyAliasAdjustments($currency);
} else {
$currency = Currency::all()->pluck('currency')->toArray();
}
// get rates
$rates = Frankfurter::setSymbols($currency)->timeSeries($period->first(), $period->last());
$rates = collect(Arr::get($rates, 'rates', []))->sortKeys()->toArray();
$datesOnly = array_keys($rates);
// loop through each date
$updates = [];
foreach ($period as $date) {
$lookupDate = self::getNearestPastDate($date, $datesOnly, $rates);
if (is_null($lookupDate)) {
continue;
}
// loop through each rate
foreach ($rates[$lookupDate->toDateString()] as $curr => $rate) {
// add to updates
$updates[] = [
'currency' => $curr,
'date' => $date->toDateString(),
'rate' => $rate,
'updated_at' => now()->toDateTimeString(),
'created_at' => now()->toDateTimeString(),
];
}
}
// persist
self::chunkInsert($updates);
if (is_string($currency)) {
return collect($updates)
->whereBetween('date', [$start, $end ?? now()])
->where('currency', $currency)
->mapWithKeys(fn ($rate) => [
$rate['date'] => $rate['rate'] * ($adjustment ?? 1),
])
->toArray();
}
return [];
}
private static function getNearestPastDate(CarbonInterface $date, array $datesOnly, array $rates): ?CarbonInterface
{
// if no dates, nothing to do...
if (empty($datesOnly)) {
return null;
}
$mutableDate = $date->copy();
$weekAgo = $date->copy()->subWeek();
$firstDate = Carbon::parse($datesOnly[0]);
// get rates or find closest valid rate (handles missing weekend rates)
while (! isset($rates[$mutableDate->toDateString()])) {
// prevent runaway infinite loops
if ($mutableDate->lessThan($weekAgo)) {
return null;
}
// is this the start of a range that falls on a weekend?
if ($mutableDate->lessThan($firstDate)) {
return $firstDate;
}
// try the day before then
$mutableDate = $mutableDate->subDay();
}
return $mutableDate;
}
public static function refreshCurrencyData($force = false): void
{
$currencies = Currency::all()->pluck('currency')->toArray();
$rates = Frankfurter::setBaseCurrency(config('investbrain.base_currency'))
->setSymbols($currencies)
->latest();
$updates = [];
foreach (Arr::get($rates, 'rates', []) as $currency => $rate) {
// update currency
$updates[] = [
'date' => now()->toDateString(),
'currency' => $currency,
'rate' => $rate,
];
}
// nothing to update
if (empty($updates)) {
return;
}
if ($force) {
// force overwrite existing rates
CurrencyRate::upsert($updates, ['currency', 'date'], ['rate']);
} else {
// only insert new rates
CurrencyRate::insertOrIgnore($updates);
}
}
public static function chunkInsert(array $updates): void
{
foreach (array_chunk($updates, 500) as $chunk) {
QueuedCurrencyRateInsertJob::dispatch($chunk);
}
}
protected static function getCurrencyAliasAdjustments(string $currency)
{
$adjustment = 1;
if (array_key_exists($currency, config('investbrain.currency_aliases', []))) {
$config = config('investbrain.currency_aliases.'.$currency);
$adjustment = $config['adjustment'];
$currency = $config['alias_of'];
}
return [$currency, $adjustment];
}
}
+112 -7
View File
@@ -7,6 +7,7 @@ namespace App\Models;
use App\Traits\HasCompositePrimaryKey; use App\Traits\HasCompositePrimaryKey;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class DailyChange extends Model class DailyChange extends Model
{ {
@@ -22,10 +23,6 @@ class DailyChange extends Model
'portfolio_id', 'portfolio_id',
'date', 'date',
'total_market_value', 'total_market_value',
'total_cost_basis',
'total_gain',
'total_dividends_earned',
'realized_gains',
'notes', 'notes',
]; ];
@@ -33,16 +30,21 @@ class DailyChange extends Model
protected $casts = [ protected $casts = [
'date' => 'datetime', 'date' => 'datetime',
'total_market_value' => 'float',
'total_cost_basis' => 'float',
'total_market_gain' => 'float',
'realized_gain_dollars' => 'float',
'total_dividends_earned' => 'float',
]; ];
public function scopePortfolio($query, $portfolio) public function scopePortfolio($query, $portfolio)
{ {
return $query->where('portfolio_id', $portfolio); return $query->where('daily_change.portfolio_id', $portfolio);
} }
public function scopeMyDailyChanges() public function scopeMyDailyChanges($query)
{ {
return $this->whereHas('portfolio', function ($query) { return $query->whereHas('portfolio', function ($query) {
$query->whereHas('users', function ($query) { $query->whereHas('users', function ($query) {
return $query->where('id', auth()->id()); return $query->where('id', auth()->id());
}); });
@@ -56,6 +58,109 @@ class DailyChange extends Model
}); });
} }
public function scopeWithDailyPerformance($query)
{
$currency = auth()->user()?->getCurrency() ?? config('investbrain.base_currency');
$dividendSub = DB::table('holdings')
->join('dividends', 'dividends.symbol', '=', 'holdings.symbol')
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join->on('cr.date', '=', 'dividends.date')
->where('cr.currency', '=', $currency);
})
->join('transactions as tx', function ($join) {
$join->on('tx.symbol', '=', 'holdings.symbol')
->on('tx.portfolio_id', '=', 'holdings.portfolio_id')
->whereColumn('tx.date', '<=', 'dividends.date');
})
->select(['holdings.portfolio_id', 'dividends.date'])
->selectRaw("
((CASE WHEN tx.transaction_type = 'BUY'
THEN tx.quantity ELSE 0 END)
- (CASE WHEN tx.transaction_type = 'SELL'
THEN tx.quantity ELSE 0 END))
* SUM(
dividends.dividend_amount_base
* COALESCE(cr.rate, 1)
)
AS total_dividends_earned")
->groupBy(['holdings.portfolio_id', 'dividends.date', 'tx.transaction_type', 'tx.quantity']);
$transactionTotals = DB::table('transactions')
->select(['transactions.portfolio_id', 'transactions.date'])
->selectRaw("
SUM(
(CASE WHEN transactions.transaction_type = 'BUY' THEN 1 ELSE -1 END)
* transactions.quantity
* transactions.cost_basis_base
* COALESCE(cr.rate, 1)
) AS daily_cost_basis
")
->selectRaw("
SUM(
(CASE
WHEN transactions.transaction_type = 'SELL'
THEN ( transactions.sale_price_base - transactions.cost_basis_base )
* transactions.quantity
* COALESCE(cr.rate, 1)
END)
) AS daily_realized_gains
")
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join
->on(DB::raw('DATE(cr.date)'), '=', DB::raw('DATE(transactions.date)'))
->where('cr.currency', $currency);
})
->groupBy('transactions.portfolio_id', 'transactions.date');
$cumulativeCostBasis = DB::table(DB::raw("({$transactionTotals->toSql()}) AS transaction_totals"))
->mergeBindings($transactionTotals)
->select(['portfolio_id', 'date'])
->selectRaw('SUM(daily_cost_basis) AS cumulative_cost_basis')
->selectRaw('SUM(daily_realized_gains) AS cumulative_realized_gains')
->groupBy('portfolio_id', 'date');
return $query
->select(['daily_change.portfolio_id', 'daily_change.date'])
->selectRaw('daily_change.total_market_value * COALESCE(cr.rate, 1) AS total_market_value')
->selectRaw('SUM(COALESCE(ccb.cumulative_cost_basis, 0)) AS total_cost_basis')
->selectRaw('daily_change.total_market_value * COALESCE(cr.rate, 1)
- SUM(COALESCE(ccb.cumulative_cost_basis, 0))
AS total_market_gain')
->selectRaw('SUM(COALESCE(ccb.cumulative_realized_gains, 0)) AS realized_gain_dollars')
->selectSub(function ($query) use ($dividendSub) {
$query->fromSub($dividendSub, 'd')
->selectRaw('SUM(d.total_dividends_earned)')
->whereColumn('d.date', '<=', 'daily_change.date')
->whereColumn('d.portfolio_id', '=', 'daily_change.portfolio_id');
}, 'total_dividends_earned')
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join
->on(DB::raw('DATE(cr.date)'), '=', DB::raw('DATE(daily_change.date)'))
->where('cr.currency', $currency);
})
->leftJoinSub($cumulativeCostBasis, 'ccb', function ($join) {
$join
->on('ccb.portfolio_id', '=', 'daily_change.portfolio_id')
->whereRaw('ccb.date <= daily_change.date');
})
->groupBy(['daily_change.date', 'daily_change.portfolio_id', 'cr.rate'])
->orderBy('daily_change.date');
}
public function scopeWithMultipleDailyPerformance($query)
{
return DB::table(DB::raw("({$query->toSql()}) AS daily_query"))
->addBinding($query->getQuery()->getBindings(), 'join')
->select('date')
->selectRaw('SUM(total_market_value) AS total_market_value')
->selectRaw('SUM(total_cost_basis) AS total_cost_basis')
->selectRaw('SUM(total_market_gain) AS total_market_gain')
->selectRaw('SUM(realized_gain_dollars) AS realized_gain_dollars')
->selectRaw('SUM(total_dividends_earned) AS total_dividends_earned')
->groupBy('date');
}
public function portfolio() public function portfolio()
{ {
return $this->belongsTo(Portfolio::class); return $this->belongsTo(Portfolio::class);
+45 -14
View File
@@ -4,17 +4,24 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Actions\CopyToBaseCurrency;
use App\Casts\BaseCurrency;
use App\Interfaces\MarketData\MarketDataInterface; use App\Interfaces\MarketData\MarketDataInterface;
use App\Traits\HasMarketData;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Pipeline;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class Dividend extends Model class Dividend extends Model
{ {
use HasFactory; use HasFactory;
use HasMarketData;
use HasUuids; use HasUuids;
protected $fillable = [ protected $fillable = [
@@ -26,21 +33,32 @@ class Dividend extends Model
protected $hidden = []; protected $hidden = [];
protected $casts = [ protected $casts = [
'date' => 'datetime', 'date' => 'date',
'last_dividend_update' => 'datetime', 'last_dividend_update' => 'date',
'dividend_amount' => 'float',
'dividend_amount_base' => BaseCurrency::class,
]; ];
public function marketData() protected static function boot()
{ {
return $this->belongsTo(MarketData::class, 'symbol', 'symbol'); parent::boot();
static::saving(function ($dividend) {
$dividend = Pipeline::send($dividend)
->through([
CopyToBaseCurrency::class,
])
->then(fn (Dividend $dividend) => $dividend);
});
} }
public function holdings() public function holdings(): HasMany
{ {
return $this->hasMany(Holding::class, 'symbol', 'symbol'); return $this->hasMany(Holding::class, 'symbol', 'symbol');
} }
public function transactions() public function transactions(): HasMany
{ {
return $this->hasMany(Transaction::class, 'symbol', 'symbol'); return $this->hasMany(Transaction::class, 'symbol', 'symbol');
} }
@@ -68,7 +86,7 @@ class Dividend extends Model
// nope, refresh forward looking only // nope, refresh forward looking only
if ($dividends_meta->total_dividends) { if ($dividends_meta->total_dividends) {
$start_date = $dividends_meta->last_dividend_update->addHours(24); $start_date = $dividends_meta->last_dividend_update;
} }
// skip refresh if there's already recent data // skip refresh if there's already recent data
@@ -84,20 +102,32 @@ class Dividend extends Model
// ah, we found some dividends... // ah, we found some dividends...
if ($dividend_data->isNotEmpty()) { if ($dividend_data->isNotEmpty()) {
$market_data = MarketData::getMarketData($symbol);
$dividend_data
->chunk(10)
->each(function ($chunk) use ($market_data) {
// get historic conversion rates
$rate_to_base = CurrencyRate::timeSeriesRates($market_data->currency, $chunk->min('date'), $chunk->max('date'));
// create mass insert // create mass insert
foreach ($dividend_data as $index => $dividend) { foreach ($chunk as $index => $dividend) {
$dividend_data[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]]; $rate_to_base_date = 1 / Arr::get($rate_to_base, Carbon::parse(Arr::get($dividend, 'date'))->toDateString(), 1);
$dividend['dividend_amount_base'] = $dividend['dividend_amount'] * $rate_to_base_date;
$chunk[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
} }
// insert records // insert records
(new self)->insert($dividend_data->toArray()); (new self)->insertOrIgnore($chunk->toArray());
});
// sync to holdings // sync to holdings
self::syncHoldings($symbol); self::syncHoldings($symbol);
// get market data
$market_data = MarketData::firstOrNew(['symbol' => $symbol]);
// re-invest dividends // re-invest dividends
self::reinvestDividends($dividend_data, $market_data); self::reinvestDividends($dividend_data, $market_data);
@@ -127,7 +157,7 @@ class Dividend extends Model
")->join('transactions', 'transactions.symbol', '=', 'dividends.symbol') ")->join('transactions', 'transactions.symbol', '=', 'dividends.symbol')
->join('holdings', 'transactions.portfolio_id', '=', 'holdings.portfolio_id') ->join('holdings', 'transactions.portfolio_id', '=', 'holdings.portfolio_id')
->where('dividends.symbol', $symbol) ->where('dividends.symbol', $symbol)
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount'); ->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'dividends.dividend_amount_base');
$dividends = DB::table(DB::raw("({$subQuery->toSql()}) as sub")) $dividends = DB::table(DB::raw("({$subQuery->toSql()}) as sub"))
->mergeBindings($subQuery->getQuery()) ->mergeBindings($subQuery->getQuery())
@@ -161,6 +191,7 @@ class Dividend extends Model
'date' => $dividend['date'], 'date' => $dividend['date'],
'portfolio_id' => $holding->portfolio_id, 'portfolio_id' => $holding->portfolio_id,
'symbol' => $holding->symbol, 'symbol' => $holding->symbol,
'currency' => $holding->market_data->currency,
'transaction_type' => 'BUY', 'transaction_type' => 'BUY',
'reinvested_dividend' => true, 'reinvested_dividend' => true,
'cost_basis' => 0, 'cost_basis' => 0,
+251 -35
View File
@@ -4,15 +4,18 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Traits\HasMarketData;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class Holding extends Model class Holding extends Model
{ {
use HasFactory; use HasFactory;
use HasMarketData;
use HasUuids; use HasUuids;
protected $fillable = [ protected $fillable = [
@@ -28,21 +31,24 @@ class Holding extends Model
]; ];
protected $casts = [ protected $casts = [
'reinvest_dividends' => 'boolean',
'splits_synced_at' => 'datetime', 'splits_synced_at' => 'datetime',
'first_transaction_date' => 'datetime', 'first_transaction_date' => 'datetime',
'reinvest_dividends' => 'boolean', 'quantity' => 'float',
'average_cost_basis' => 'float',
'total_cost_basis' => 'float',
'realized_gain_dollars' => 'float',
'dividends_earned' => 'float',
'total_market_gain_dollars' => 'float',
'market_gain_dollars' => 'float',
'total_market_value' => 'float',
'total_dividends_earned' => 'float',
'market_data_market_value' => 'float',
'market_data_fifty_two_week_low' => 'float',
'market_data_fifty_two_week_high' => 'float',
'market_gain_percent' => 'float',
]; ];
/**
* Market data for holding
*
* @return void
*/
public function market_data()
{
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
}
/** /**
* Related transactions for holding * Related transactions for holding
* *
@@ -61,7 +67,7 @@ class Holding extends Model
public function dividends() public function dividends()
{ {
return $this->hasMany(Dividend::class, 'symbol', 'symbol') return $this->hasMany(Dividend::class, 'symbol', 'symbol')
->select(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount']) ->select(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount', 'dividends.dividend_amount_base'])
->selectRaw("SUM( ->selectRaw("SUM(
CASE WHEN transaction_type = 'BUY' CASE WHEN transaction_type = 'BUY'
AND transactions.symbol = dividends.symbol AND transactions.symbol = dividends.symbol
@@ -91,8 +97,21 @@ class Holding extends Model
THEN transactions.quantity ELSE 0 END) THEN transactions.quantity ELSE 0 END)
* dividends.dividend_amount * dividends.dividend_amount
) AS total_received") ) AS total_received")
->selectRaw("SUM(
(CASE WHEN transaction_type = 'BUY'
AND transactions.symbol = dividends.symbol
AND transactions.portfolio_id = '$this->portfolio_id'
AND date(transactions.date) <= date(dividends.date)
THEN transactions.quantity ELSE 0 END
- CASE WHEN transaction_type = 'SELL'
AND transactions.symbol = dividends.symbol
AND transactions.portfolio_id = '$this->portfolio_id'
AND date(transactions.date) <= date(dividends.date)
THEN transactions.quantity ELSE 0 END)
* dividends.dividend_amount_base
) AS total_received_base")
->join('transactions', 'transactions.symbol', 'dividends.symbol') ->join('transactions', 'transactions.symbol', 'dividends.symbol')
->groupBy(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount']) ->groupBy(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount', 'dividends.dividend_amount_base'])
->orderBy('dividends.date', 'DESC') ->orderBy('dividends.date', 'DESC')
->where('dividends.date', '>=', function ($query) { ->where('dividends.date', '>=', function ($query) {
$query->selectRaw('min(transactions.date)') $query->selectRaw('min(transactions.date)')
@@ -118,7 +137,7 @@ class Holding extends Model
THEN transactions.quantity THEN transactions.quantity
ELSE 0 ELSE 0
END) END)
) * dividends.dividend_amount > 0"); ) * dividends.dividend_amount_base > 0");
} }
/** /**
@@ -156,12 +175,16 @@ class Holding extends Model
{ {
return $query->withAggregate('market_data', 'name') return $query->withAggregate('market_data', 'name')
->withAggregate('market_data', 'market_value') ->withAggregate('market_data', 'market_value')
->withAggregate('market_data', 'market_value_base')
->withAggregate('market_data', 'fifty_two_week_low') ->withAggregate('market_data', 'fifty_two_week_low')
->withAggregate('market_data', 'fifty_two_week_high') ->withAggregate('market_data', 'fifty_two_week_high')
->withAggregate('market_data', 'updated_at') ->withAggregate('market_data', 'updated_at')
->join('market_data', 'holdings.symbol', 'market_data.symbol'); ->join('market_data', 'holdings.symbol', 'market_data.symbol');
} }
/**
* Calculate performance for holding in its local currency
*/
public function scopeWithPerformance($query) public function scopeWithPerformance($query)
{ {
return $query->selectRaw('COALESCE(market_data.market_value * holdings.quantity, 0) AS total_market_value') return $query->selectRaw('COALESCE(market_data.market_value * holdings.quantity, 0) AS total_market_value')
@@ -193,15 +216,197 @@ class Holding extends Model
}); });
} }
public function scopeWithPortfolioMetrics($query) /**
* Scope which returns collection of performance metrics for holdings
*
* @param string $currency Allows casting to specified currency
*/
public function scopeGetPortfolioMetrics($query, $currency = null): Collection
{ {
return $query->selectRaw('COALESCE(SUM(holdings.dividends_earned), 0) AS total_dividends_earned') $result = $query->withPortfolioMetrics($currency)->get();
->selectRaw('COALESCE(SUM(holdings.realized_gain_dollars), 0) AS realized_gain_dollars')
->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) AS total_market_value') return collect([
->selectRaw('COALESCE(SUM(holdings.total_cost_basis), 0) AS total_cost_basis') 'total_cost_basis' => $result->sum('total_cost_basis'),
->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) - COALESCE(SUM(holdings.total_cost_basis), 0) AS total_gain_dollars') 'total_market_value' => $result->sum('total_market_value'),
// ->selectRaw('COALESCE((@total_gain_dollars / @sum_total_cost_basis) * 100,0) AS total_gain_percent') 'total_market_gain_dollars' => $result->sum('total_market_gain_dollars'),
->join('market_data', 'market_data.symbol', '=', 'holdings.symbol'); 'realized_gain_dollars' => $result->sum('realized_gain_dollars'),
'total_dividends_earned' => $result->sum('total_dividends_earned'),
]);
}
/**
* Scope to collect performance metrics for holdings
*
* @param string $currency Allows casting to specified currency
*/
public function scopeWithPortfolioMetrics($query, $currency = null): mixed
{
$currency = $currency ?? auth()->user()->getCurrency();
$cost_basis_sub = DB::table('transactions')
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join
->on('cr.date', '=', 'transactions.date')
->where('cr.currency', '=', $currency);
})
->select([
'transactions.id',
'transactions.symbol',
'transactions.portfolio_id',
])
->leftJoinSub(
DB::table('transactions')
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join
->on('cr.date', '=', 'transactions.date')
->where('cr.currency', '=', $currency);
})
->select([
'transactions.symbol',
'transactions.portfolio_id',
'transactions.quantity',
'transactions.cost_basis_base',
'transactions.date',
])
->selectRaw("
(CASE
WHEN
transactions.transaction_type = 'BUY'
OR SUM(transactions.cost_basis_base) = 0
THEN
COALESCE(cr.rate, 1)
ELSE (
SELECT
SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base)
/ SUM(buy.cost_basis_base)
FROM transactions as buy
LEFT JOIN currency_rates as cr2
ON cr2.date = buy.date
AND cr2.currency = '{$currency}'
WHERE buy.symbol = transactions.symbol
AND buy.portfolio_id = transactions.portfolio_id
AND buy.transaction_type = 'BUY'
AND buy.date <= transactions.date
) END)
AS rate")
->groupBy([
'transactions.id',
'transactions.symbol',
'transactions.date',
'transactions.portfolio_id',
'transactions.transaction_type',
'transactions.cost_basis_base',
'transactions.quantity',
'cr.rate',
]),
'cost_basis_display',
function ($join) {
$join
->on('transactions.symbol', '=', 'cost_basis_display.symbol')
->on(
'transactions.portfolio_id',
'=',
'cost_basis_display.portfolio_id'
)
->on('transactions.date', '=', 'cost_basis_display.date');
}
)
->selectRaw(
"CASE WHEN transactions.transaction_type = 'SELL' THEN (transactions.sale_price_base - transactions.cost_basis_base) * transactions.quantity * COALESCE(cr.rate, 1) END AS realized_gain_dollars"
)
->selectRaw(
"CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.cost_basis_base * transactions.quantity * cost_basis_display.rate END AS total_cost_basis"
)
->selectRaw(
"CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity END AS total_purchases"
)
->groupBy([
'transactions.id',
'transactions.symbol',
'transactions.portfolio_id',
'transactions.cost_basis_base',
'transactions.quantity',
'cost_basis_display.rate',
'cr.rate',
]);
$dividends_sub = DB::table('dividends')
->join('transactions as tx', function ($join) {
$join
->on('tx.symbol', '=', 'dividends.symbol')
->on('tx.date', '<=', 'dividends.date');
})
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join
->on('cr.date', '=', 'dividends.date')
->where('cr.currency', '=', $currency);
})
->select(['dividends.symbol', 'tx.portfolio_id'])
->selectRaw(
"SUM(((CASE WHEN transaction_type = 'BUY' THEN tx.quantity ELSE 0 END) - (CASE WHEN transaction_type = 'SELL' THEN tx.quantity ELSE 0 END)) * dividends.dividend_amount_base * COALESCE(cr.rate, 1)) AS total_dividends_earned"
)
->groupBy(['dividends.symbol', 'tx.portfolio_id']);
return $query->select([
'holdings.symbol',
'holdings.portfolio_id',
'dividends_display.total_dividends_earned',
])
->groupBy([
'holdings.symbol',
'holdings.quantity',
'holdings.portfolio_id',
'cr.rate',
'dividends_display.total_dividends_earned',
'market_data.market_value_base',
])
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join->where('cr.currency', '=', $currency);
if (config('database.default') === 'sqlite') {
$join->whereRaw("strftime('%Y-%m-%d', cr.date) = ?", [
now()->toDateString(),
]);
} else {
$join->on('cr.date', '=', DB::raw("'".now()->toDateString()."'"));
}
})
->leftJoin('market_data', function ($join) {
$join->on('market_data.symbol', '=', 'holdings.symbol');
})
->selectRaw('
holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1)
AS total_market_value
')
->selectRaw('
SUM(transactions_display.realized_gain_dollars)
AS realized_gain_dollars
')
->selectRaw('
(SUM(transactions_display.total_cost_basis) / SUM(transactions_display.total_purchases))
* holdings.quantity
AS total_cost_basis
')
->selectRaw('
(holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1))
- (SUM(transactions_display.total_cost_basis) / SUM(transactions_display.total_purchases))
* holdings.quantity
AS total_market_gain_dollars
')
->leftJoinSub($cost_basis_sub, 'transactions_display',
function ($join) {
$join
->on('holdings.symbol', '=', 'transactions_display.symbol')
->on('holdings.portfolio_id', '=', 'transactions_display.portfolio_id');
}
)
->leftJoinSub($dividends_sub, 'dividends_display',
function ($join) {
$join->on('holdings.symbol', '=', 'dividends_display.symbol') // todo: this isnt limiting to port ids
->on('holdings.portfolio_id', '=', 'dividends_display.portfolio_id');
}
);
} }
public function syncTransactionsAndDividends() public function syncTransactionsAndDividends()
@@ -209,14 +414,22 @@ class Holding extends Model
// pull existing transaction data // pull existing transaction data
$query = Transaction::where([ $query = Transaction::where([
'portfolio_id' => $this->portfolio_id, 'portfolio_id' => $this->portfolio_id,
'symbol' => $this->symbol, 'transactions.symbol' => $this->symbol,
])->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN quantity ELSE 0 END) AS qty_purchases") ])->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN quantity ELSE 0 END) AS qty_purchases")
->selectRaw("SUM(CASE WHEN transaction_type = 'SELL' THEN quantity ELSE 0 END) AS qty_sales") ->selectRaw("SUM(CASE WHEN transaction_type = 'SELL' THEN quantity ELSE 0 END) AS qty_sales")
->selectRaw("SUM(CASE WHEN transaction_type = 'SELL' THEN (sale_price - cost_basis) * quantity ELSE 0 END) AS realized_gain_dollars")
->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN (quantity * cost_basis) ELSE 0 END) AS total_cost_basis") ->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN (quantity * cost_basis) ELSE 0 END) AS total_cost_basis")
->selectRaw("SUM(CASE WHEN transaction_type = 'SELL' THEN (quantity * sale_price) ELSE 0 END) AS total_sale_price")
->first(); ->first();
$total_quantity = round($query->qty_purchases - $query->qty_sales, 3); // delete holding if no transactions
if (empty($query->qty_purchases + $query->qty_sales)) {
$this->delete();
return;
}
$total_quantity = round($query->qty_purchases - $query->qty_sales, 4);
$average_cost_basis = ( $average_cost_basis = (
$query->qty_purchases > 0 $query->qty_purchases > 0
@@ -229,9 +442,7 @@ class Holding extends Model
'quantity' => $total_quantity, 'quantity' => $total_quantity,
'average_cost_basis' => $average_cost_basis, 'average_cost_basis' => $average_cost_basis,
'total_cost_basis' => $total_quantity * $average_cost_basis, 'total_cost_basis' => $total_quantity * $average_cost_basis,
'realized_gain_dollars' => $query->qty_purchases > 0 && $query->total_sale_price > 0 'realized_gain_dollars' => $query->realized_gain_dollars ?? 0,
? $query->total_sale_price - ($query->qty_sales * ($query->total_cost_basis / $query->qty_purchases))
: 0,
'dividends_earned' => $this->dividends->sum('total_received'), 'dividends_earned' => $this->dividends->sum('total_received'),
]); ]);
@@ -253,6 +464,11 @@ class Holding extends Model
return $purchases - $sales; return $purchases - $sales;
} }
/**
* Method that enables calculating daily performance for a given holding
*
* @return void
*/
public function dailyPerformance( public function dailyPerformance(
?\Illuminate\Support\Carbon $start_date = null, ?\Illuminate\Support\Carbon $start_date = null,
?\Illuminate\Support\Carbon $end_date = null, ?\Illuminate\Support\Carbon $end_date = null,
@@ -277,11 +493,11 @@ class Holding extends Model
// Default CTE time series query (for MySQL and SQLite) // Default CTE time series query (for MySQL and SQLite)
$timeSeriesQuery = DB::table(DB::raw("( $timeSeriesQuery = DB::table(DB::raw("(
WITH RECURSIVE date_series AS ( WITH RECURSIVE date_series AS (
SELECT '{$start_date->format('Y-m-d')}' AS date SELECT '{$start_date->toDateString()}' AS date
UNION ALL UNION ALL
SELECT $date_interval SELECT $date_interval
FROM date_series FROM date_series
WHERE date < '{$end_date->format('Y-m-d')}' WHERE date < '{$end_date->toDateString()}'
) )
SELECT date_series.date SELECT date_series.date
FROM date_series FROM date_series
@@ -292,8 +508,8 @@ class Holding extends Model
$timeSeriesQuery = DB::table(DB::raw(" $timeSeriesQuery = DB::table(DB::raw("
generate_series( generate_series(
date '{$start_date->format('Y-m-d')}', date '{$start_date->toDateString()}',
date '{$end_date->format('Y-m-d')}', date '{$end_date->toDateString()}',
interval '1 day' interval '1 day'
) as date_series")); ) as date_series"));
@@ -335,12 +551,12 @@ class Holding extends Model
CASE CASE
WHEN ({$quantityQuery}) = 0 THEN 0 WHEN ({$quantityQuery}) = 0 THEN 0
ELSE SUM(CASE ELSE SUM(CASE
WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis_base
ELSE 0 ELSE 0
END) END)
END AS cost_basis END AS cost_basis
"), "),
DB::raw("COALESCE(SUM(CASE WHEN transaction_type = 'SELL' THEN ((sale_price - cost_basis) * quantity) ELSE 0 END), 0) AS realized_gains"), DB::raw("COALESCE(SUM(CASE WHEN transaction_type = 'SELL' THEN ((sale_price_base - cost_basis_base) * quantity) ELSE 0 END), 0) AS realized_gains"),
]) ])
->leftJoin('transactions', function ($join) { ->leftJoin('transactions', function ($join) {
$join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date') $join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date')
@@ -357,7 +573,7 @@ class Holding extends Model
{ {
$formattedTransactions = ''; $formattedTransactions = '';
foreach ($this->transactions->sortByDesc('date') as $transaction) { foreach ($this->transactions->sortByDesc('date') as $transaction) {
$formattedTransactions .= ' * '.$transaction->date->format('Y-m-d') $formattedTransactions .= ' * '.$transaction->date->toDateString()
.' '.$transaction->transaction_type .' '.$transaction->transaction_type
.' '.$transaction->quantity .' '.$transaction->quantity
.' @ '.$transaction->cost_basis .' @ '.$transaction->cost_basis
+27 -3
View File
@@ -4,9 +4,12 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Actions\CopyToBaseCurrency;
use App\Casts\BaseCurrency;
use App\Interfaces\MarketData\MarketDataInterface; use App\Interfaces\MarketData\MarketDataInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Pipeline;
class MarketData extends Model class MarketData extends Model
{ {
@@ -21,7 +24,9 @@ class MarketData extends Model
protected $fillable = [ protected $fillable = [
'symbol', 'symbol',
'name', 'name',
'currency',
'market_value', 'market_value',
'market_value_base',
'fifty_two_week_high', 'fifty_two_week_high',
'fifty_two_week_low', 'fifty_two_week_low',
'forward_pe', 'forward_pe',
@@ -29,21 +34,40 @@ class MarketData extends Model
'market_cap', 'market_cap',
'book_value', 'book_value',
'last_dividend_date', 'last_dividend_date',
'last_dividend_amount',
'dividend_yield', 'dividend_yield',
'meta_data',
]; ];
protected $casts = [ protected $casts = [
'last_dividend_date' => 'datetime',
'market_value' => 'float', 'market_value' => 'float',
'market_value_base' => BaseCurrency::class,
'fifty_two_week_high' => 'float', 'fifty_two_week_high' => 'float',
'fifty_two_week_low' => 'float', 'fifty_two_week_low' => 'float',
'forward_pe' => 'float', 'forward_pe' => 'float',
'trailing_pe' => 'float', 'trailing_pe' => 'float',
'market_cap' => 'float', 'market_cap' => 'integer',
'book_value' => 'float', 'book_value' => 'float',
'last_dividend_date' => 'datetime',
'last_dividend_amount' => 'float',
'dividend_yield' => 'float', 'dividend_yield' => 'float',
'meta_data' => 'json',
]; ];
protected static function boot()
{
parent::boot();
static::saving(function ($market_data) {
$market_data = Pipeline::send($market_data)
->through([
CopyToBaseCurrency::class,
])
->then(fn (MarketData $market_data) => $market_data);
});
}
public function holdings() public function holdings()
{ {
return $this->hasMany(Holding::class, 'symbol', 'symbol'); return $this->hasMany(Holding::class, 'symbol', 'symbol');
@@ -54,7 +78,7 @@ class MarketData extends Model
return $query->where('symbol', $symbol); return $query->where('symbol', $symbol);
} }
public static function getMarketData($symbol, $force = false) public static function getMarketData($symbol, $force = false): self
{ {
$market_data = self::firstOrNew([ $market_data = self::firstOrNew([
'symbol' => $symbol, 'symbol' => $symbol,
+17 -25
View File
@@ -136,6 +136,9 @@ class Portfolio extends Model
} }
} }
/**
* Writes daily change history for a portfolio to the database
*/
public function syncDailyChanges(): void public function syncDailyChanges(): void
{ {
$holdings = $this->holdings() $holdings = $this->holdings()
@@ -147,11 +150,15 @@ class Portfolio extends Model
->groupBy(['holdings.symbol', 'holdings.portfolio_id']) ->groupBy(['holdings.symbol', 'holdings.portfolio_id'])
->get(); ->get();
$dividends = Dividend::whereIn('symbol', $holdings->pluck('symbol'))->get();
$total_performance = []; $total_performance = [];
$holdings->each(function ($holding) use (&$total_performance, $dividends) { // get unique currencies for holdings
$currency_rates = [];
foreach ($holdings->groupBy('market_data.currency')->keys() as $currency) {
$currency_rates[$currency] = CurrencyRate::timeSeriesRates($currency, $holdings->min('first_transaction_date'), now());
}
$holdings->each(function ($holding) use (&$total_performance, $currency_rates) {
$period = CarbonPeriod::create( $period = CarbonPeriod::create(
$holding->first_transaction_date, $holding->first_transaction_date,
@@ -160,34 +167,24 @@ class Portfolio extends Model
: now() : now()
); );
$holding->setRelation('dividends', $dividends->where('symbol', $holding->symbol));
$daily_performance = $holding->dailyPerformance($holding->first_transaction_date, now()); $daily_performance = $holding->dailyPerformance($holding->first_transaction_date, now());
$dividends = $holding->dividends->keyBy(function ($dividend, $key) {
return $dividend['date']->format('Y-m-d');
});
$all_history = app(MarketDataInterface::class)->history($holding->symbol, $holding->first_transaction_date, now()); $all_history = app(MarketDataInterface::class)->history($holding->symbol, $holding->first_transaction_date, now());
$dividends_earned = 0;
$holding_performance = []; $holding_performance = [];
foreach ($period as $date) { foreach ($period as $date) {
$date = $date->format('Y-m-d'); $date = $date->toDateString();
$close = $this->getMostRecentCloseData($all_history, $date); $close = $this->getMostRecentCloseData($all_history, $date);
$total_market_value = $daily_performance->get($date)->owned * $close; $total_market_value = $daily_performance->get($date)->owned * $close;
$dividends_earned += $daily_performance->get($date)->owned * ($dividends->get($date)?->dividend_amount ?? 0);
if (Carbon::parse($date)->isWeekday()) { if (Carbon::parse($date)->isWeekday()) {
$holding_performance[$date] = [ $holding_performance[$date] = [
'date' => $date, 'date' => $date,
'portfolio_id' => $this->id, 'portfolio_id' => $this->id,
'total_market_value' => $total_market_value, 'total_market_value' => $total_market_value * (1 / Arr::get($currency_rates[$holding->market_data->currency], $date, 1)),
'total_cost_basis' => $daily_performance->get($date)->cost_basis,
'total_gain' => $total_market_value - $daily_performance->get($date)->cost_basis,
'realized_gains' => $daily_performance->get($date)->realized_gains,
'total_dividends_earned' => $dividends_earned,
]; ];
} }
} }
@@ -200,10 +197,6 @@ class Portfolio extends Model
} else { } else {
$total_performance[$date]['total_market_value'] += $performance['total_market_value']; $total_performance[$date]['total_market_value'] += $performance['total_market_value'];
$total_performance[$date]['total_cost_basis'] += $performance['total_cost_basis'];
$total_performance[$date]['total_gain'] += $performance['total_gain'];
$total_performance[$date]['realized_gains'] += $performance['realized_gains'];
$total_performance[$date]['total_dividends_earned'] += $performance['total_dividends_earned'];
} }
} }
}); });
@@ -221,14 +214,13 @@ class Portfolio extends Model
['date', 'portfolio_id'], ['date', 'portfolio_id'],
[ [
'total_market_value', 'total_market_value',
'total_cost_basis',
'total_gain',
'realized_gains',
'total_dividends_earned',
] ]
); );
}); });
} }
cache()->forget('graph-YTD-'.$this->id);
cache()->forget('graph-YTD-'.request()->user()?->id);
} }
protected function getMostRecentCloseData($history, $date, $i = 0, $max_attempts = 5) protected function getMostRecentCloseData($history, $date, $i = 0, $max_attempts = 5)
@@ -239,7 +231,7 @@ class Portfolio extends Model
$i++; $i++;
$date = Carbon::parse($date)->subDay()->format('Y-m-d'); $date = Carbon::parse($date)->subDay()->toDateString();
return $this->getMostRecentCloseData($history, $date, $i); return $this->getMostRecentCloseData($history, $date, $i);
} }
+7 -4
View File
@@ -5,15 +5,18 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Interfaces\MarketData\MarketDataInterface; use App\Interfaces\MarketData\MarketDataInterface;
use App\Traits\HasMarketData;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class Split extends Model class Split extends Model
{ {
use HasFactory; use HasFactory;
use HasMarketData;
use HasUuids; use HasUuids;
protected $fillable = [ protected $fillable = [
@@ -29,12 +32,12 @@ class Split extends Model
'last_date' => 'datetime', 'last_date' => 'datetime',
]; ];
public function holdings() public function holdings(): HasMany
{ {
return $this->hasMany(Holding::class, 'symbol', 'symbol'); return $this->hasMany(Holding::class, 'symbol', 'symbol');
} }
public function transactions() public function transactions(): HasMany
{ {
return $this->hasMany(Transaction::class, 'symbol', 'symbol'); return $this->hasMany(Transaction::class, 'symbol', 'symbol');
} }
@@ -73,7 +76,7 @@ class Split extends Model
if ($split_data->isNotEmpty()) { if ($split_data->isNotEmpty()) {
// insert records // insert records
(new self)->insert($split_data->map(function ($split) { (new self)->insertOrIgnore($split_data->map(function ($split) {
return [...$split, ...['id' => Str::uuid()->toString()]]; return [...$split, ...['id' => Str::uuid()->toString()]];
})->toArray()); })->toArray());
@@ -114,7 +117,7 @@ class Split extends Model
'symbol' => $split->symbol, 'symbol' => $split->symbol,
'portfolio_id' => $split->portfolio_id, 'portfolio_id' => $split->portfolio_id,
]) ])
->whereDate('transactions.date', '<', $split->date->format('Y-m-d')) ->whereDate('transactions.date', '<', $split->date->toDateString())
->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN quantity ELSE 0 END) - ->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN quantity ELSE 0 END) -
SUM(CASE WHEN transaction_type = 'SELL' THEN quantity ELSE 0 END) AS qty_owned") SUM(CASE WHEN transaction_type = 'SELL' THEN quantity ELSE 0 END) AS qty_owned")
->value('qty_owned'); ->value('qty_owned');
+29 -40
View File
@@ -4,18 +4,25 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Actions\ConvertToMarketDataCurrency;
use App\Actions\CopyToBaseCurrency;
use App\Actions\EnsureCostBasisAddedToSale;
use App\Actions\EnsureDailyChangeIsSynced;
use App\Casts\BaseCurrency;
use App\Traits\HasMarketData;
use Illuminate\Contracts\Database\Eloquent\Builder; use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Pipeline;
class Transaction extends Model class Transaction extends Model
{ {
use HasFactory; use HasFactory;
use HasMarketData;
use HasUuids; use HasUuids;
protected $fillable = [ protected $fillable = [
@@ -23,6 +30,7 @@ class Transaction extends Model
'date', 'date',
'portfolio_id', 'portfolio_id',
'transaction_type', 'transaction_type',
'currency',
'quantity', 'quantity',
'cost_basis', 'cost_basis',
'sale_price', 'sale_price',
@@ -36,6 +44,11 @@ class Transaction extends Model
'date' => 'datetime', 'date' => 'datetime',
'split' => 'boolean', 'split' => 'boolean',
'reinvested_dividend' => 'boolean', 'reinvested_dividend' => 'boolean',
'quantity' => 'float',
'cost_basis' => 'float',
'sale_price' => 'float',
'cost_basis_base' => BaseCurrency::class,
'sale_price_base' => BaseCurrency::class,
]; ];
protected static function boot() protected static function boot()
@@ -44,17 +57,24 @@ class Transaction extends Model
static::saving(function ($transaction) { static::saving(function ($transaction) {
if ($transaction->transaction_type == 'SELL') { $transaction = Pipeline::send($transaction)
->through([
$transaction->ensureCostBasisIsAddedToSale(); ConvertToMarketDataCurrency::class,
} EnsureCostBasisAddedToSale::class,
CopyToBaseCurrency::class,
])
->then(fn (Transaction $transaction) => $transaction);
}); });
static::saved(function ($transaction) { static::saved(function ($transaction) {
$transaction->syncToHolding(); $transaction->syncToHolding();
$transaction->refreshMarketData(); $transaction = Pipeline::send($transaction)
->through([
EnsureDailyChangeIsSynced::class,
])
->then(fn (Transaction $transaction) => $transaction);
cache()->forget('portfolio-metrics-'.$transaction->portfolio_id); cache()->forget('portfolio-metrics-'.$transaction->portfolio_id);
}); });
@@ -77,16 +97,6 @@ class Transaction extends Model
); );
} }
/**
* Related market data for transaction
*
* @return void
*/
public function market_data(): HasOne
{
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
}
/** /**
* Related portfolio * Related portfolio
* *
@@ -101,6 +111,7 @@ class Transaction extends Model
{ {
return $query->withAggregate('market_data', 'name') return $query->withAggregate('market_data', 'name')
->withAggregate('market_data', 'market_value') ->withAggregate('market_data', 'market_value')
->withAggregate('market_data', 'currency')
->withAggregate('market_data', 'fifty_two_week_low') ->withAggregate('market_data', 'fifty_two_week_low')
->withAggregate('market_data', 'fifty_two_week_high') ->withAggregate('market_data', 'fifty_two_week_high')
->withAggregate('market_data', 'updated_at') ->withAggregate('market_data', 'updated_at')
@@ -141,28 +152,6 @@ class Transaction extends Model
}); });
} }
public function refreshMarketData(): void
{
MarketData::getMarketData($this->attributes['symbol']);
}
/**
* Writes average cost basis to a sale transaction
*/
public function ensureCostBasisIsAddedToSale(): Transaction
{
$average_cost_basis = Transaction::where([
'portfolio_id' => $this->portfolio_id,
'symbol' => $this->symbol,
'transaction_type' => 'BUY',
])->whereDate('date', '<=', $this->date)
->average('cost_basis');
$this->cost_basis = $average_cost_basis ?? 0;
return $this;
}
/** /**
* Syncs the holding related to this transaction * Syncs the holding related to this transaction
*/ */
@@ -187,8 +176,8 @@ class Transaction extends Model
'portfolio_id' => $this->portfolio_id, 'portfolio_id' => $this->portfolio_id,
'symbol' => $this->symbol, 'symbol' => $this->symbol,
'quantity' => $this->quantity, 'quantity' => $this->quantity,
'average_cost_basis' => $this->cost_basis, 'average_cost_basis' => $this->cost_basis_base,
'total_cost_basis' => $this->quantity * $this->cost_basis, 'total_cost_basis' => $this->quantity * $this->cost_basis_base,
'splits_synced_at' => now(), 'splits_synced_at' => now(),
])->syncTransactionsAndDividends(); ])->syncTransactionsAndDividends();
} }
+26
View File
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Arr;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto; use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
@@ -31,6 +32,7 @@ class User extends Authenticatable implements MustVerifyEmail
'name', 'name',
'email', 'email',
'password', 'password',
'options',
]; ];
protected $hidden = [ protected $hidden = [
@@ -50,6 +52,8 @@ class User extends Authenticatable implements MustVerifyEmail
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'admin' => 'boolean',
'options' => 'json',
]; ];
} }
@@ -82,4 +86,26 @@ class User extends Authenticatable implements MustVerifyEmail
ELSE COALESCE(market_data.market_value - transactions.cost_basis, 0) ELSE COALESCE(market_data.market_value - transactions.cost_basis, 0)
END AS gain_dollars'); END AS gain_dollars');
} }
public function getCurrency(): string
{
return Arr::get($this->options, 'display_currency') ?? config('investbrain.base_currency');
}
public function getLocale(): string
{
$available_locales = Arr::pluck(config('app.available_locales'), 'locale');
return Arr::get($this->options, 'locale') ?? request()->getPreferredLanguage($available_locales) ?? config('app.locale');
}
public function setOption(mixed $key, ?string $value = null): self
{
$options = is_array($key) ? $key : [$key => $value];
$this->options = array_merge($this->options ?? [], $options);
return $this;
}
} }
+26
View File
@@ -5,7 +5,10 @@ declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Arr;
use Illuminate\Support\Number;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use NumberFormatter;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -26,5 +29,28 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void public function boot(): void
{ {
JsonResource::withoutWrapping(); JsonResource::withoutWrapping();
Arr::macro('skipEmptyValues', function (array $array) {
return Arr::mapWithKeys($array, function (mixed $value, mixed $key) {
$result = [];
if (! empty($value)) {
$result[$key] = $value;
}
return $result;
});
});
Number::macro('currencySymbol', function (?string $currency = null, ?string $locale = null) {
$currency = $currency ?? Number::defaultCurrency();
$locale = $locale ?? Number::defaultLocale();
$formatter = new NumberFormatter($locale."@currency=$currency", NumberFormatter::CURRENCY);
return $formatter->getSymbol(NumberFormatter::CURRENCY_SYMBOL);
});
} }
} }
+7 -2
View File
@@ -23,8 +23,13 @@ class VoltServiceProvider extends ServiceProvider
public function boot(): void public function boot(): void
{ {
Volt::mount([ Volt::mount([
config('livewire.view_path', resource_path('views/livewire')), // config('livewire.view_path', resource_path('views/livewire')),
resource_path('views/pages'), resource_path('views/components'),
resource_path('views/profile'),
resource_path('views/holding'),
resource_path('views/transaction'),
resource_path('views/portfolio'),
resource_path('views/auth'),
]); ]);
} }
} }
+8 -12
View File
@@ -5,8 +5,9 @@ declare(strict_types=1);
namespace App\Rules; namespace App\Rules;
use App\Models\Portfolio; use App\Models\Portfolio;
use Illuminate\Contracts\Validation\ValidationRule; use App\Models\Transaction;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Contracts\Validation\ValidationRule;
class QuantityValidationRule implements ValidationRule class QuantityValidationRule implements ValidationRule
{ {
@@ -20,12 +21,7 @@ class QuantityValidationRule implements ValidationRule
protected ?string $symbol, protected ?string $symbol,
protected ?string $transactionType, protected ?string $transactionType,
protected string|Carbon|null $date protected string|Carbon|null $date
) { ) { }
$this->portfolio = $portfolio;
$this->symbol = $symbol;
$this->transactionType = $transactionType;
$this->date = $date;
}
/** /**
* Validate the attribute. * Validate the attribute.
@@ -39,21 +35,21 @@ class QuantityValidationRule implements ValidationRule
if ($this->transactionType == 'SELL') { if ($this->transactionType == 'SELL') {
$purchase_qty = $this->portfolio->transactions() $purchase_qty = (float) $this->portfolio->transactions()
->symbol($this->symbol) ->symbol($this->symbol)
->buy() ->buy()
->beforeDate($this->date) ->whereDate('date', '<', $this->date)
->sum('quantity'); ->sum('quantity');
$sales_qty = $this->portfolio->transactions() $sales_qty = (float) $this->portfolio->transactions()
->symbol($this->symbol) ->symbol($this->symbol)
->sell() ->sell()
->beforeDate($this->date) ->whereDate('date', '<', $this->date)
->sum('quantity'); ->sum('quantity');
$maxQuantity = $purchase_qty - $sales_qty; $maxQuantity = $purchase_qty - $sales_qty;
if (round($value, 3) > round($maxQuantity, 3)) { if (round($value, 4) > round($maxQuantity, 4)) {
$fail(__('The quantity must not be greater than the available quantity.')); $fail(__('The quantity must not be greater than the available quantity.'));
} }
} }
+11 -12
View File
@@ -2,16 +2,15 @@
declare(strict_types=1); declare(strict_types=1);
// if (!function_exists('formatMoney')) { use App\Models\Currency;
// /**
// * Returns a formatted string for currency
// *
// * @param int|float $amount
// *
// * */
// function formatMoney(int|float $amount) {
// $formatter = new NumberFormatter('en_US', NumberFormatter::CURRENCY);
// return $formatter->formatCurrency((float) $amount, 'USD'); if (! function_exists('currency')) {
// }
// } // /**
// * Returns an instance of the currency model
// * */
// function currency(): Currency
// {
// return new Currency;
// }
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Traits;
use App\Models\MarketData;
use Illuminate\Database\Eloquent\Relations\HasOne;
trait HasMarketData
{
/**
* Related market data for model
*
* @return void
*/
public function market_data(): HasOne
{
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
}
/**
* Gracefully loads related market data as relationship (creates if doesn't exist)
*/
public function loadMarketData(): void
{
if (is_null($this->market_data)) {
$this->setRelation('market_data', MarketData::getMarketData($this->attributes['symbol']));
}
}
public function scopeNotBaseCurrency($query): void
{
$query->with('market_data')
->whereRelation(
'market_data',
'currency',
'!=',
config('investbrain.base_currency')
);
}
}
+3 -3
View File
@@ -21,11 +21,11 @@ class AppLayout extends Component
<x-partials.nav-bar /> <x-partials.nav-bar />
<x-main with-nav full-width> <x-partials.main with-nav full-width>
<x-slot:sidebar drawer="main-drawer" class="bg-base-100 lg:bg-inherit"> <x-slot:sidebar drawer="main-drawer" class="bg-base-100 lg:bg-inherit">
<x-partials.side-bar /> @livewire('partials.side-bar')
</x-slot:sidebar> </x-slot:sidebar>
@@ -34,7 +34,7 @@ class AppLayout extends Component
{{ $slot }} {{ $slot }}
</x-slot:content> </x-slot:content>
</x-main> </x-partials.main>
@if(session('toast')) @if(session('toast'))
<script lang="text/javascript"> <script lang="text/javascript">
+2 -2
View File
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Http\Middleware\SetLocale; use App\Http\Middleware\LocalizationMiddleware;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
@@ -15,7 +15,7 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
$middleware->append(SetLocale::class); $middleware->appendToGroup('web', LocalizationMiddleware::class);
}) })
->withExceptions(function (Exceptions $exceptions) { ->withExceptions(function (Exceptions $exceptions) {
// //
+8 -1
View File
@@ -11,6 +11,7 @@
"ext-zip": "*", "ext-zip": "*",
"finnhub/client": "master@dev", "finnhub/client": "master@dev",
"hackeresq/filter-models": "dev-main", "hackeresq/filter-models": "dev-main",
"investbrainapp/frankfurter-client": "dev-main",
"laravel/framework": "^11.35", "laravel/framework": "^11.35",
"laravel/jetstream": "^5.1", "laravel/jetstream": "^5.1",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
@@ -23,8 +24,9 @@
"openai-php/client": "^0.10.3", "openai-php/client": "^0.10.3",
"predis/predis": "^2.2", "predis/predis": "^2.2",
"robsontenorio/mary": "^1.35", "robsontenorio/mary": "^1.35",
"scheb/yahoo-finance-api": "^4.11", "scheb/yahoo-finance-api": "^5.0",
"staudenmeir/eloquent-has-many-deep": "^1.20", "staudenmeir/eloquent-has-many-deep": "^1.20",
"symfony/cache": "^7.3",
"tschucki/alphavantage-laravel": "^0.0" "tschucki/alphavantage-laravel": "^0.0"
}, },
"require-dev": { "require-dev": {
@@ -41,6 +43,11 @@
"no-api": true, "no-api": true,
"url": "https://github.com/hackeresq/filter-models" "url": "https://github.com/hackeresq/filter-models"
}, },
{
"type": "vcs",
"no-api": true,
"url": "https://github.com/investbrainapp/frankfurter-client"
},
{ {
"type": "vcs", "type": "vcs",
"no-api": true, "no-api": true,
Generated
+1020 -581
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
return [
'key' => env('ALPACA_API_KEY'),
'secret' => env('ALPACA_API_SECRET'),
];
+83 -2
View File
@@ -79,14 +79,95 @@ return [
| set to any locale for which you plan to have translation strings. | set to any locale for which you plan to have translation strings.
| |
*/ */
'available_locales' => ['en', 'es'],
'locale' => env('APP_LOCALE', 'en'), 'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
'available_locales' => [
[
'locale' => 'en_AU',
'label' => 'English (Australia)',
'flag' => '',
],
[
'locale' => 'en_BE',
'label' => 'English (Belgium)',
'flag' => '',
],
[
'locale' => 'en_CA',
'label' => 'English (Canada)',
'flag' => '',
],
[
'locale' => 'en_HK',
'label' => 'English (Hong Kong SAR China)',
'flag' => '',
],
[
'locale' => 'en_IN',
'label' => 'English (India)',
'flag' => '',
],
[
'locale' => 'en_IE',
'label' => 'English (Ireland)',
'flag' => '',
],
[
'locale' => 'en_MT',
'label' => 'English (Malta)',
'flag' => '',
],
[
'locale' => 'en_NZ',
'label' => 'English (New Zealand)',
'flag' => '',
],
[
'locale' => 'en_PH',
'label' => 'English (Philippines)',
'flag' => '',
],
[
'locale' => 'en_SG',
'label' => 'English (Singapore)',
'flag' => '',
],
[
'locale' => 'en_ZA',
'label' => 'English (South Africa)',
'flag' => '',
],
[
'locale' => 'en_GB',
'label' => 'English (United Kingdom)',
'flag' => '',
],
[
'locale' => 'en_US',
'label' => 'English (United States)',
'flag' => '',
],
[
'locale' => 'es_419',
'label' => 'Spanish (Latin America)',
'flag' => '',
],
[
'locale' => 'es_ES',
'label' => 'Spanish (Spain)',
'flag' => '',
],
[
'locale' => 'es_US',
'label' => 'Spanish (United States)',
'flag' => '',
],
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Encryption Key | Encryption Key
+10
View File
@@ -11,11 +11,21 @@ return [
'interfaces' => [ 'interfaces' => [
'yahoo' => App\Interfaces\MarketData\YahooMarketData::class, 'yahoo' => App\Interfaces\MarketData\YahooMarketData::class,
'alphavantage' => App\Interfaces\MarketData\AlphaVantageMarketData::class, 'alphavantage' => App\Interfaces\MarketData\AlphaVantageMarketData::class,
'alpaca' => App\Interfaces\MarketData\AlpacaMarketData::class,
'finnhub' => App\Interfaces\MarketData\FinnhubMarketData::class, 'finnhub' => App\Interfaces\MarketData\FinnhubMarketData::class,
'twelvedata' => App\Interfaces\MarketData\TwelveDataMarketData::class,
'fake' => App\Interfaces\MarketData\FakeMarketData::class, 'fake' => App\Interfaces\MarketData\FakeMarketData::class,
], ],
'self_hosted' => env('SELF_HOSTED', true), 'self_hosted' => env('SELF_HOSTED', true),
'daily_change_time_of_day' => env('DAILY_CHANGE_TIME', '23:00'), 'daily_change_time_of_day' => env('DAILY_CHANGE_TIME', '23:00'),
'base_currency' => env('BASE_CURRENCY', 'USD'),
'currency_aliases' => [
'RMB' => ['alias_of' => 'CNY', 'label' => 'Chinese Yuan (Renminbi)', 'adjustment' => 1],
'GBX' => ['alias_of' => 'GBP', 'label' => 'British Sterling Pence', 'adjustment' => 100],
'ZAC' => ['alias_of' => 'ZAR', 'label' => 'South Africa Rand Cent', 'adjustment' => 100],
],
]; ];
-1
View File
@@ -60,7 +60,6 @@ return [
*/ */
'features' => [ 'features' => [
! env('SELF_HOSTED', true) ? Features::termsAndPrivacyPolicy() : null,
Features::profilePhotos(), Features::profilePhotos(),
Features::api(), Features::api(),
Features::accountDeletion(), Features::accountDeletion(),
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
return [
'secret' => env('TWELVEDATA_API_SECRET'),
];
+46 -4
View File
@@ -41,28 +41,49 @@ class TransactionFactory extends Factory
public function yearsAgo(): static public function yearsAgo(): static
{ {
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes) => [
'date' => $this->faker->dateTimeBetween('-5 years', '-3 years')->format('Y-m-d'), 'date' => now()->subYears($this->faker->numberBetween(3, 5))->toDateString(),
]); ]);
} }
public function lastYear(): static public function lastYear(): static
{ {
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes) => [
'date' => now()->subYear()->format('Y-m-d'), 'date' => now()->subYear()->toDateString(),
]); ]);
} }
public function lastMonth(): static public function lastMonth(): static
{ {
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes) => [
'date' => now()->subMonth()->format('Y-m-d'), 'date' => now()->subMonth()->toDateString(),
]);
}
public function sixMonthsAgo(): static
{
return $this->state(fn (array $attributes) => [
'date' => now()->subMonths(6)->toDateString(),
]);
}
public function today(): static
{
return $this->state(fn (array $attributes) => [
'date' => now()->toDateString(),
]); ]);
} }
public function recent(): static public function recent(): static
{ {
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes) => [
'date' => $this->faker->dateTimeBetween('-2 weeks', 'now')->format('Y-m-d'), 'date' => now()->subDays($this->faker->numberBetween(3, 14))->toDateString(),
]);
}
public function date($date): static
{
return $this->state(fn (array $attributes) => [
'date' => $date,
]); ]);
} }
@@ -80,6 +101,27 @@ class TransactionFactory extends Factory
]); ]);
} }
public function currency($currency): static
{
return $this->state(fn (array $attributes) => [
'currency' => $currency,
]);
}
public function costBasis($cost_basis): static
{
return $this->state(fn (array $attributes) => [
'cost_basis' => $cost_basis,
]);
}
public function salePrice($sale_price): static
{
return $this->state(fn (array $attributes) => [
'sale_price' => $sale_price,
]);
}
public function buy(): static public function buy(): static
{ {
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes) => [
+14
View File
@@ -34,6 +34,10 @@ class UserFactory extends Factory
'two_factor_recovery_codes' => null, 'two_factor_recovery_codes' => null,
'remember_token' => Str::random(10), 'remember_token' => Str::random(10),
'profile_photo_path' => null, 'profile_photo_path' => null,
'options' => [
'display_currency' => 'USD',
'locale' => 'en',
],
]; ];
} }
@@ -46,4 +50,14 @@ class UserFactory extends Factory
'email_verified_at' => null, 'email_verified_at' => null,
]); ]);
} }
/**
* Indicate that the model's currency.
*/
public function currency($currency): static
{
return $this->state(fn (array $attributes) => array_merge($attributes['options'], [
'currency' => $currency,
]));
}
} }
@@ -21,6 +21,7 @@ return new class extends Migration
$table->string('password'); $table->string('password');
$table->rememberToken(); $table->rememberToken();
$table->string('profile_photo_path', 2048)->nullable(); $table->string('profile_photo_path', 2048)->nullable();
$table->boolean('admin')->nullable();
$table->timestamps(); $table->timestamps();
}); });
@@ -38,6 +39,5 @@ return new class extends Migration
{ {
Schema::dropIfExists('users'); Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens'); Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
} }
}; };
@@ -2,10 +2,8 @@
declare(strict_types=1); declare(strict_types=1);
use Database\Seeders\MarketDataSeeder;
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
class CreateMarketDataTable extends Migration class CreateMarketDataTable extends Migration
@@ -34,10 +32,6 @@ class CreateMarketDataTable extends Migration
$table->timestamps(); $table->timestamps();
}); });
Artisan::call('db:seed', [
'--class' => MarketDataSeeder::class,
'--force' => true,
]);
} }
/** /**
@@ -20,10 +20,6 @@ class CreateDailyChangeTable extends Migration
$table->date('date'); $table->date('date');
$table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade'); $table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade');
$table->float('total_market_value', 12, 4)->nullable(); $table->float('total_market_value', 12, 4)->nullable();
$table->float('total_cost_basis', 12, 4)->nullable();
$table->float('total_gain', 12, 4)->nullable();
$table->float('total_dividends_earned', 12, 4)->nullable();
$table->float('realized_gains', 12, 4)->nullable();
$table->text('annotation')->nullable(); $table->text('annotation')->nullable();
$table->primary(['date', 'portfolio_id']); $table->primary(['date', 'portfolio_id']);
@@ -21,6 +21,8 @@ class CreateDividendsTable extends Migration
$table->string('symbol', 25); $table->string('symbol', 25);
$table->float('dividend_amount', 12, 4); $table->float('dividend_amount', 12, 4);
$table->timestamps(); $table->timestamps();
$table->unique(['date', 'symbol']);
}); });
} }
@@ -21,6 +21,8 @@ class CreateSplitsTable extends Migration
$table->string('symbol', 25); $table->string('symbol', 25);
$table->float('split_amount', 12, 4); $table->float('split_amount', 12, 4);
$table->timestamps(); $table->timestamps();
$table->unique(['date', 'symbol']);
}); });
} }
@@ -25,7 +25,6 @@ class CreateHoldingsTable extends Migration
$table->float('total_cost_basis', 12, 4)->default(0); $table->float('total_cost_basis', 12, 4)->default(0);
$table->float('realized_gain_dollars', 12, 4)->default(0); $table->float('realized_gain_dollars', 12, 4)->default(0);
$table->float('dividends_earned', 12, 4)->default(0); $table->float('dividends_earned', 12, 4)->default(0);
$table->boolean('reinvest_dividends')->default(false);
$table->timestamp('splits_synced_at')->nullable(); $table->timestamp('splits_synced_at')->nullable();
$table->timestamps(); $table->timestamps();
}); });
@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('admin')->nullable()->after('profile_photo_path');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('admin');
});
}
};
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
use App\Models\CurrencyRate;
use App\Models\Holding;
use App\Models\Transaction;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\MarketDataSeeder;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
/**
* Add options column to users table
*/
Schema::table('users', function (Blueprint $table) {
$locale = config('app.locale', 'en');
$currency = config('investbrain.base_currency', 'USD');
$default = config('database.default') === 'mysql'
? new Expression("(JSON_OBJECT('locale', '{$locale}', 'display_currency', '{$currency}'))")
: json_encode(['locale' => $locale, 'display_currency' => $currency]);
$table->json('options')->default($default)->after('profile_photo_path');
});
/**
* Add _base and currency column to market_data table
*/
Schema::table('market_data', function (Blueprint $table) {
$table->float('market_value_base', 12, 4)->nullable()->after('market_value');
$table->string('currency', 3)->default(config('investbrain.base_currency'))->after('market_value');
});
DB::table('market_data')->update([
'market_value_base' => DB::raw('market_value'),
]);
/**
* Add _base columns to transactions table
*/
Schema::table('transactions', function (Blueprint $table) {
$table->float('cost_basis_base', 12, 4)->nullable()->after('sale_price');
$table->float('sale_price_base', 12, 4)->nullable()->after('cost_basis_base');
});
DB::table('transactions')->update([
'cost_basis_base' => DB::raw('cost_basis'),
'sale_price_base' => DB::raw('sale_price'),
]);
Schema::table('transactions', function (Blueprint $table) {
$table->float('cost_basis_base', 12, 4)->nullable(false)->change();
});
/**
* Add _base columns to dividends table
*/
Schema::table('dividends', function (Blueprint $table) {
$table->float('dividend_amount_base', 12, 4)->nullable()->after('dividend_amount');
});
DB::table('dividends')->update([
'dividend_amount_base' => DB::raw('dividend_amount'),
]);
Schema::table('dividends', function (Blueprint $table) {
$table->float('dividend_amount_base', 12, 4)->nullable(false)->change();
});
/**
* Creates currencies table
*/
Schema::create('currencies', function (Blueprint $table) {
$table->string('currency', 3)->primary(); // ISO 4217
$table->string('label');
$table->timestamps();
});
/**
* Creates currency rates table
*/
Schema::create('currency_rates', function (Blueprint $table) {
$table->date('date');
$table->string('currency', 3);
$table->float('rate', 12, 4);
$table->timestamps();
$table->primary(['date', 'currency']);
});
if (config('app.env') != 'testing') {
Artisan::call('db:seed', [
'--class' => CurrencySeeder::class,
'--force' => true,
]);
Artisan::call('db:seed', [
'--class' => MarketDataSeeder::class,
'--force' => true,
]);
CurrencyRate::timeSeriesRates(
Holding::all()->groupBy('market_data.currency')->keys()->toArray(),
Transaction::min('date')
);
CurrencyRate::refreshCurrencyData();
}
/**
* Cleanup daily change table
*/
if (Schema::hasColumn('daily_change', 'total_cost_basis')) {
Schema::table('daily_change', function (Blueprint $table) {
$table->dropColumn('total_cost_basis');
});
}
if (Schema::hasColumn('daily_change', 'total_gain')) {
Schema::table('daily_change', function (Blueprint $table) {
$table->dropColumn('total_gain');
});
}
if (Schema::hasColumn('daily_change', 'total_dividends_earned')) {
Schema::table('daily_change', function (Blueprint $table) {
$table->dropColumn('total_dividends_earned');
});
}
if (Schema::hasColumn('daily_change', 'realized_gains')) {
Schema::table('daily_change', function (Blueprint $table) {
$table->dropColumn('realized_gains');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('options');
});
Schema::table('market_data', function (Blueprint $table) {
$table->dropColumn('currency');
$table->dropColumn('market_value_base');
});
Schema::table('transactions', function (Blueprint $table) {
$table->dropColumn('cost_basis_base');
$table->dropColumn('sale_price_base');
});
Schema::table('dividends', function (Blueprint $table) {
$table->dropColumn('dividend_amount_base');
});
Schema::dropIfExists('currencies');
Schema::dropIfExists('currency_rates');
}
};
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\Currency;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class CurrencySeeder extends Seeder
{
use WithoutModelEvents;
/**
* Run the database seeds.
*/
public function run(): void
{
Currency::insert([
['currency' => 'AUD', 'label' => 'Australian Dollar', 'created_at' => now()],
['currency' => 'BRL', 'label' => 'Brazilian Real', 'created_at' => now()],
['currency' => 'GBP', 'label' => 'British Pound', 'created_at' => now()],
['currency' => 'CAD', 'label' => 'Canadian Dollar', 'created_at' => now()],
['currency' => 'CNY', 'label' => 'Chinese Yuan', 'created_at' => now()],
['currency' => 'CZK', 'label' => 'Czech Koruna', 'created_at' => now()],
['currency' => 'DKK', 'label' => 'Danish Krone', 'created_at' => now()],
['currency' => 'EUR', 'label' => 'Euro', 'created_at' => now()],
['currency' => 'HKD', 'label' => 'Hong Kong Dollar', 'created_at' => now()],
['currency' => 'INR', 'label' => 'Indian Rupee', 'created_at' => now()],
['currency' => 'JPY', 'label' => 'Japanese Yen', 'created_at' => now()],
['currency' => 'NZD', 'label' => 'New Zealand Dollar', 'created_at' => now()],
['currency' => 'NOK', 'label' => 'Norwegian Krone', 'created_at' => now()],
['currency' => 'SGD', 'label' => 'Singapore Dollar', 'created_at' => now()],
['currency' => 'KRW', 'label' => 'South Korean Won', 'created_at' => now()],
['currency' => 'ZAR', 'label' => 'South African Rand', 'created_at' => now()],
['currency' => 'SEK', 'label' => 'Swedish Krona', 'created_at' => now()],
['currency' => 'CHF', 'label' => 'Swiss Franc', 'created_at' => now()],
['currency' => 'USD', 'label' => 'United States Dollar', 'created_at' => now()],
]);
}
}
-2
View File
@@ -15,8 +15,6 @@ class DatabaseSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
// User::factory(10)->create();
User::factory()->create([ User::factory()->create([
'name' => 'Test User', 'name' => 'Test User',
'email' => 'test@example.com', 'email' => 'test@example.com',
+15 -16
View File
@@ -12,8 +12,6 @@ class MarketDataSeeder extends Seeder
{ {
use WithoutModelEvents; use WithoutModelEvents;
public array $rows = [];
/** /**
* Run the database seeds. * Run the database seeds.
*/ */
@@ -41,30 +39,30 @@ class MarketDataSeeder extends Seeder
$data = array_combine($header, $row); $data = array_combine($header, $row);
$this->rows[] = [ $meta_data = json_decode(base64_decode($data['meta_data']), true);
$meta_data['source'] = 'market_data_seeder';
$rows[] = [
'symbol' => $data['symbol'], 'symbol' => $data['symbol'],
'name' => $data['name'], 'name' => $data['name'],
'meta_data' => json_encode([ 'currency' => $data['currency'],
'country' => $data['country'], 'meta_data' => json_encode($meta_data),
'first_trade_year' => $data['first_trade_year'],
'sector' => $data['sector'],
'industry' => $data['industry'],
]),
]; ];
$rowCount++; $rowCount++;
if ($rowCount % $chunkSize == 0) { if ($rowCount % $chunkSize == 0) {
$this->bulkInsert($rows);
$this->bulkInsert($this->rows); $rows = [];
} }
} }
} }
// final clean up // final clean up
if (! empty($this->rows)) { if (! empty($rows)) {
$this->bulkInsert($this->rows); $this->bulkInsert($rows);
$rows = [];
} }
// Close the CSV file // Close the CSV file
@@ -78,16 +76,17 @@ class MarketDataSeeder extends Seeder
} }
} }
public function bulkInsert(array $rows) private function bulkInsert($rows): void
{ {
try { try {
DB::table('market_data')->insertOrIgnore($rows); DB::table('market_data')->upsert($rows, ['symbol'], ['name', 'currency', 'meta_data']);
$this->rows = [];
} catch (\Throwable $e) { } catch (\Throwable $e) {
throw new \Exception('Error: '.$e->getMessage()); throw new \Exception('Error: '.$e->getMessage());
} }
gc_collect_cycles();
} }
} }
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -11,9 +11,9 @@ services:
- 8000:80 - 8000:80
environment: # You can either use these properties OR an .env file. Do not use both! environment: # You can either use these properties OR an .env file. Do not use both!
APP_URL: "http://localhost:8000" APP_URL: "http://localhost:8000"
DB_CONNECTION: mysql DB_CONNECTION: pgsql
DB_HOST: investbrain-mysql DB_HOST: investbrain-pgsql
DB_PORT: 3306 DB_PORT: 5432
DB_DATABASE: investbrain DB_DATABASE: investbrain
DB_USERNAME: investbrain DB_USERNAME: investbrain
DB_PASSWORD: investbrain DB_PASSWORD: investbrain
@@ -25,7 +25,7 @@ services:
- investbrain-storage:/var/app/storage # You can use a volume... - investbrain-storage:/var/app/storage # You can use a volume...
# - /path/to/storage:/var/app/storage:delegated # ...or you can use a path on host # - /path/to/storage:/var/app/storage:delegated # ...or you can use a path on host
depends_on: depends_on:
- mysql - pgsql
- redis - redis
networks: networks:
- investbrain-network - investbrain-network
@@ -40,22 +40,22 @@ services:
- investbrain-redis:/data - investbrain-redis:/data
networks: networks:
- investbrain-network - investbrain-network
mysql: pgsql:
image: mysql:8.0 image: postgres:15-alpine
container_name: investbrain-mysql container_name: investbrain-pgsql
restart: unless-stopped restart: unless-stopped
ports:
- "5432:5432"
environment: environment:
MYSQL_DATABASE: ${DB_DATABASE:-investbrain} POSTGRES_DB: ${DB_DATABASE:-investbrain}
MYSQL_USER: ${DB_USERNAME:-investbrain} POSTGRES_USER: ${DB_USERNAME:-investbrain}
MYSQL_PASSWORD: ${DB_PASSWORD:-investbrain} POSTGRES_PASSWORD: ${DB_PASSWORD:-investbrain}
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-investbrain} command: postgres -c log_min_messages=error
command:
- --cte-max-recursion-depth=25000
volumes: volumes:
- investbrain-mysql:/var/lib/mysql - investbrain-pgsql:/var/lib/postgresql/data
networks: networks:
- investbrain-network - investbrain-network
volumes: volumes:
investbrain-storage: investbrain-storage:
investbrain-redis: investbrain-redis:
investbrain-mysql: investbrain-pgsql:
+4 -1
View File
@@ -44,6 +44,9 @@ FROM php:8.3-fpm-alpine
# Set the working directory # Set the working directory
WORKDIR /var/app WORKDIR /var/app
ARG VERSION=dev
ENV VERSION=$VERSION
# Copy necessary files from the builder stage # Copy necessary files from the builder stage
COPY --from=builder /var/app /var/app COPY --from=builder /var/app /var/app
COPY --from=builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d COPY --from=builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d
@@ -62,7 +65,7 @@ RUN apk add --no-cache \
bash \ bash \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \ && docker-php-ext-install -j$(nproc) \
gd pgsql zip pdo_mysql mysqli intl gd pgsql zip pdo_mysql pdo_pgsql mysqli intl
# Remove default nginx config # Remove default nginx config
RUN rm -rf /var/www/html \ RUN rm -rf /var/www/html \
+14 -3
View File
@@ -3,7 +3,8 @@
cd /var/app cd /var/app
# Starting Investbrain # Starting Investbrain
echo "CiAgKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioKICAqICBJSUkgICBOICAgTiAgViAgIFYgIEVFRUVFICBTU1NTICBUVFRUVCAgQkJCQkIgICBSUlJSICAgIEFBQUFBICBJSUkgICBOICAgTiAgKgogICogICBJICAgIE5OICBOICBWICAgViAgRSAgICAgIFMgICAgICAgVCAgICBCICAgIEIgIFIgICBSICAgQSAgIEEgICBJICAgIE5OICBOICAqCiAgKiAgIEkgICAgTiBOIE4gIFYgICBWICBFRUVFICAgU1NTUyAgICBUICAgIEJCQkJCICAgUlJSUiAgICBBQUFBQSAgIEkgICAgTiBOIE4gICoKICAqICAgSSAgICBOICBOTiAgViAgIFYgIEUgICAgICAgICAgUyAgIFQgICAgQiAgICBCICBSICBSICAgIEEgICBBICAgSSAgICBOICBOTiAgKgogICogIElJSSAgIE4gICBOICAgVlZWICAgRUVFRUUgIFNTU1MgICAgVCAgICBCQkJCQiAgIFIgICBSICAgQSAgIEEgIElJSSAgIE4gICBOICAqCiAgKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioKICA=" | base64 -d echo "CuKWhOKWliAgICAgICAg4paXIOKWjCAgICAg4paYICAK4paQIOKWm+KWjOKWjOKWjOKWiOKWjOKWm+KWmOKWnOKWmOKWm+KWjOKWm+KWmOKWgOKWjOKWjOKWm+KWjArilp/ilpbilozilozilprilpjilpnilpbiloTilozilpDilpbilpnilozilowg4paI4paM4paM4paM4paMCg==" | base64 -d
printf "%15s$VERSION\n"
echo -e "\n====================== Validating environment... ====================== " echo -e "\n====================== Validating environment... ====================== "
@@ -54,7 +55,6 @@ RETRIES=12
DELAY=5 DELAY=5
run_migrations() { run_migrations() {
sleep $DELAY sleep $DELAY
# php artisan migrate --force
output=$(php artisan migrate --force 2>/dev/null) output=$(php artisan migrate --force 2>/dev/null)
if [[ $? -eq 0 ]]; then if [[ $? -eq 0 ]]; then
echo "$output" echo "$output"
@@ -72,7 +72,18 @@ until run_migrations; do
echo -e "\n > Waiting for database to be ready... retrying in $DELAY seconds. \n" echo -e "\n > Waiting for database to be ready... retrying in $DELAY seconds. \n"
done done
echo -e "\n====================== Cleaning up... ====================== \n"
# Clear caches
echo $(php artisan cache:clear)
echo $(php artisan view:clear)
echo $(php artisan route:clear)
echo $(php artisan event:clear)
# Re-create caches
echo $(php artisan route:cache)
echo $(php artisan event:cache)
echo -e "\n====================== Spinning up Supervisor daemon... ====================== \n" echo -e "\n====================== Spinning up Supervisor daemon... ====================== \n"
exec supervisord -c /etc/supervisor/conf.d/supervisord.conf exec supervisord -c /etc/supervisor/conf.d/supervisord.conf
+12 -3
View File
@@ -367,8 +367,11 @@
"Import starting...": "Import starting...", "Import starting...": "Import starting...",
"Import is in progress...": "Import is in progress...", "Import is in progress...": "Import is in progress...",
"Importing portfolios...": "Importing portfolios...", "Importing portfolios...": "Importing portfolios...",
"Importing transactions...": "Importing transactions...", "Preparing to import transactions...": "Preparing to import transactions...",
"Importing daily changes...": "Importing daily changes...", "Importing transactions (Batch :currentBatch of :totalBatches)...": "Importing transactions (Batch :currentBatch of :totalBatches)...",
"Preparing to import daily changes...": "Preparing to import daily changes...",
"Importing daily changes (Batch :currentBatch of :totalBatches)...": "Importing daily changes (Batch :currentBatch of :totalBatches)...",
"Importing configurations...": "Importing configurations...",
"Import completed successfully!": "Import completed successfully!", "Import completed successfully!": "Import completed successfully!",
"Your import will continue in the background": "Your import will continue in the background", "Your import will continue in the background": "Your import will continue in the background",
@@ -376,5 +379,11 @@
"Hi, how can I help?": "Hi, how can I help?", "Hi, how can I help?": "Hi, how can I help?",
"Have a question? AI might be able to help...": "Have a question? AI might be able to help...", "Have a question? AI might be able to help...": "Have a question? AI might be able to help...",
"Feel free to ask me a question!": "Feel free to ask me a question!", "Feel free to ask me a question!": "Feel free to ask me a question!",
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor." "Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.",
"Currency": "Currency",
"Locale Options": "Locale Options",
"Adjust localization options for your preferred region.": "Adjust localization options for your preferred region.",
"Locale": "Locale",
"Display Currency": "Display Currency"
} }
+12 -3
View File
@@ -367,8 +367,11 @@
"Import starting...": "Iniciando la importación...", "Import starting...": "Iniciando la importación...",
"Import is in progress...": "La importación está en progreso...", "Import is in progress...": "La importación está en progreso...",
"Importing portfolios...": "Importando portafolios...", "Importing portfolios...": "Importando portafolios...",
"Importing transactions...": "Importando transacciones...", "Preparing to import transactions...": "Preparándose para importar transacciones...",
"Importing daily changes...": "Importando cambios diarios...", "Importing transactions (Batch :currentBatch of :totalBatches)...": "Importando transacciones (Lote :currentBatch de :totalBatches)...",
"Preparing to import daily changes...": "Preparing to import cambios diarios...",
"Importing daily changes (Batch :currentBatch of :totalBatches)...": "Importando cambios diarios (Lote :currentBatch de :totalBatches)...",
"Importing configurations...": "Importando configuraciones...",
"Import completed successfully!": "¡La importación se completó con éxito!", "Import completed successfully!": "¡La importación se completó con éxito!",
"Your import will continue in the background": "La importación continuará en segundo plano", "Your import will continue in the background": "La importación continuará en segundo plano",
@@ -376,5 +379,11 @@
"Hi, how can I help?": "Hola, ¿cómo puedo ayudarte?", "Hi, how can I help?": "Hola, ¿cómo puedo ayudarte?",
"Have a question? AI might be able to help...": "¿Tienes una pregunta? La AI podría ayudarte...", "Have a question? AI might be able to help...": "¿Tienes una pregunta? La AI podría ayudarte...",
"Feel free to ask me a question!": "¡No dudes en hacerme una pregunta!", "Feel free to ask me a question!": "¡No dudes en hacerme una pregunta!",
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Los consejos generados por AI pueden contener errores. Úsalos bajo tu propio riesgo. Consulta siempre a un asesor de inversiones con licencia." "Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Los consejos generados por AI pueden contener errores. Úsalos bajo tu propio riesgo. Consulta siempre a un asesor de inversiones con licencia.",
"Currency": "Moneda",
"Locale Options": "Opciones de configuración regional",
"Adjust localization options for your preferred region.": "Ajusta las opciones de localización para tu región preferida.",
"Locale": "Configuración regional",
"Display Currency": "Moneda de visualización"
} }
+1 -1
View File
@@ -31,7 +31,7 @@
<x-input id="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" name="password_confirmation" required autocomplete="new-password" /> <x-input id="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" name="password_confirmation" required autocomplete="new-password" />
</div> </div>
@if (Laravel\Jetstream\Jetstream::hasTermsAndPrivacyPolicyFeature()) @if (! config('investbrain.self_hosted'))
<div class="mt-4"> <div class="mt-4">
<label> <label>
<div class="flex items-center"> <div class="flex items-center">
@@ -73,7 +73,7 @@ new class extends Component {
'model' => config('openai.model'), 'model' => config('openai.model'),
'messages' => [ 'messages' => [
['role' => 'system', 'content' => "Today's date is " ['role' => 'system', 'content' => "Today's date is "
.now()->format('Y-m-d') .now()->toDateString()
.".\n\n".$this->system_prompt], .".\n\n".$this->system_prompt],
...array_slice($this->messages, -10) ...array_slice($this->messages, -10)
], ],
File diff suppressed because one or more lines are too long
@@ -1,18 +1,18 @@
<span <span
class="" class=""
style="width:90em;overflow: hidden; white-space: nowrap;" style="width:90em;overflow: hidden; white-space: nowrap;"
title="{{ Number::currency($low ?? 0) }} - {{ Number::currency($high ?? 0) }}" title="{{ Number::currency($marketData->fifty_two_week_low ?? 0, $marketData->currency) }} - {{ Number::currency($marketData->fifty_two_week_high ?? 0, $marketData->currency) }}"
> >
@php @php
// 52-week low must be a non-zero // 52-week low must be a non-zero
if (empty($low)) { if (empty($marketData->fifty_two_week_low)) {
$low = 1; $marketData->fifty_two_week_low = 1;
} }
@endphp @endphp
@for ($x = 0; $x < 10; $x++) @for ($x = 0; $x < 10; $x++)
@if ((($current - $low) * 100) / ($high - $low) > ($x * 10)) @if ((($marketData->market_value - $marketData->fifty_two_week_low) * 100) / ($marketData->fifty_two_week_high - $marketData->fifty_two_week_low) > ($x * 10))
&#9679; &#9679;
@@ -94,7 +94,7 @@
} }
this.data.yaxis.labels.formatter = function (value) { this.data.yaxis.labels.formatter = function (value) {
return `$${value}` return `{{ Number::currencySymbol(auth()->user()->getCurrency()) }}${value}`
} }
this.data.tooltip = { this.data.tooltip = {
@@ -103,7 +103,7 @@
formatter: (value, { series, seriesIndex, dataPointIndex, w }) => { formatter: (value, { series, seriesIndex, dataPointIndex, w }) => {
const firstDataPoint = this.data.series[seriesIndex].data[0][1] const firstDataPoint = this.data.series[seriesIndex].data[0][1]
const percentageChange = ((value - firstDataPoint) / firstDataPoint) * 100; const percentageChange = ((value - firstDataPoint) / firstDataPoint) * 100;
return `$${parseFloat(value.toFixed(2))} (${percentageChange.toFixed(2)}%)`; return `${parseFloat(value.toFixed(2))} (${percentageChange.toFixed(2)}%)`;
} }
}, },
} }
@@ -0,0 +1,64 @@
@props([
'sidebar' => null,
'content' => null,
'footer' => null,
'fullWidth' => false,
'withNav' => false,
'collapseText' => 'Collapse',
'collapseIcon' => 'o-bars-3-bottom-right',
'collapsible' => false,
'url' => route('mary.toogle-sidebar', absolute: false),
])
<main class="{{ !$fullWidth ? 'max-w-screen-2xl' : '' }} w-full mx-auto">
<div class="drawer {{ $sidebar?->attributes['right'] ? 'drawer-end' : '' }} lg:drawer-open">
<input id="{{ $sidebar?->attributes['drawer'] }}" type="checkbox" class="drawer-toggle" />
<div {{ $content->attributes->class(["drawer-content w-full mx-auto p-5 lg:px-10 lg:py-5"]) }}>
{{-- MAIN CONTENT --}}
{{ $content }}
</div>
{{-- SIDEBAR --}}
@if($sidebar)
<div
x-data="{
collapsed: {{ session('mary-sidebar-collapsed', 'false') }},
collapseText: '{{ $collapseText }}',
toggle() {
this.collapsed = !this.collapsed;
fetch('{{ $url }}?collapsed=' + this.collapsed);
this.$dispatch('sidebar-toggled', this.collapsed);
}
}"
@menu-sub-clicked="if(collapsed) { toggle() }"
@class(["drawer-side z-20 lg:z-auto", "top-0 lg:top-[73px] lg:h-[calc(100vh-73px)]" => $withNav])
>
<label for="{{ $sidebar?->attributes['drawer'] }}" aria-label="close sidebar" class="drawer-overlay"></label>
{{-- SIDEBAR CONTENT --}}
<div>
{{ $sidebar }}
{{-- SIDEBAR COLLAPSE --}}
@if($sidebar->attributes['collapsible'])
<x-mary-menu class="hidden !bg-inherit lg:block">
<x-mary-menu-item
@click="toggle"
icon="{{ $sidebar->attributes['collapse-icon'] ?? $collapseIcon }}"
title="{{ $sidebar->attributes['collapse-text'] ?? $collapseText }}" />
</x-mary-menu>
@endif
</div>
</div>
@endif
{{-- END SIDEBAR--}}
</div>
</main>
{{-- FOOTER --}}
@if($footer)
<footer {{ $footer?->attributes->class(["mx-auto w-full", "max-w-screen-2xl" => !$fullWidth ]) }}>
{{ $footer }}
</footer>
@endif
@@ -1,4 +1,23 @@
<?php
use Livewire\Volt\Component;
new class extends Component
{
// props
/**
* The component's listeners.
*
* @var array
*/
protected $listeners = [
'refresh-navigation-menu' => '$refresh',
];
// methods
}; ?>
<div class="bg-base-100 border-base-300 border-b sticky top-0 z-10"> <div class="bg-base-100 border-base-300 border-b sticky top-0 z-10">
<div class="flex justify-between items-center px-7 py-3 gap-4 mx-auto"> <div class="flex justify-between items-center px-7 py-3 gap-4 mx-auto">
<div class="flex flex-0 items-center"> <div class="flex flex-0 items-center">
@@ -7,7 +26,7 @@
<x-icon name="o-bars-3" class="cursor-pointer" /> <x-icon name="o-bars-3" class="cursor-pointer" />
</label> </label>
<div class="hidden md:block" style="height:3.1em"> <div class="hidden md:block" style="height:2.5em">
<x-application-logo /> <x-application-logo />
</div> </div>
@@ -1,4 +1,41 @@
<x-menu activate-by-route> <?php
use Livewire\Volt\Component;
new class extends Component
{
// props
/**
* The component's listeners.
*
* @var array
*/
protected $listeners = [
'refresh-navigation-menu' => '$refresh',
];
// methods
}; ?>
<div class="
flex
flex-col
!transition-all
!duration-100
ease-out
overflow-x-hidden
overflow-y-auto
h-screen
lg:h-[calc(100vh-73px)]
bg-base-100
lg:bg-inherit
{{ session('mary-sidebar-collapsed') == 'true' ? 'w-[70px] [&>*_summary::after]:hidden [&_.mary-hideable]:hidden [&_.display-when-collapsed]:block [&_.hidden-when-collapsed]:hidden' : null }}
{{ session('mary-sidebar-collapsed') != 'true' ? 'w-[270px] [&>*_summary::after]:block [&_.mary-hideable]:block [&_.hidden-when-collapsed]:block [&_.display-when-collapsed]:hidden' : null }}
">
<div class="flex-1">
<x-menu activate-by-route>
<x-menu-item title="{{ __('Dashboard') }}" icon="o-home" link="{{ route('dashboard') }}" /> <x-menu-item title="{{ __('Dashboard') }}" icon="o-home" link="{{ route('dashboard') }}" />
<x-menu-sub title="{{ __('Portfolios') }}" icon="o-document-duplicate"> <x-menu-sub title="{{ __('Portfolios') }}" icon="o-document-duplicate">
@@ -19,18 +56,19 @@
<x-menu-item title="{{ __('Transactions') }}" icon="o-banknotes" link="{{ route('transaction.index') }}" /> <x-menu-item title="{{ __('Transactions') }}" icon="o-banknotes" link="{{ route('transaction.index') }}" />
{{-- <x-menu-item title="{{ __('Reporting') }}" icon="o-chart-bar-square" link="####" /> --}} {{-- <x-menu-item title="{{ __('Reporting') }}" icon="o-chart-bar-square" link="####" /> --}}
</x-menu> </x-menu>
</div> </div>
<div class="px-3">
<x-section-border /> <div class="px-3">
@php <x-section-border />
@php
$user = auth()->user(); $user = auth()->user();
@endphp @endphp
<x-list-item :item="$user" avatar="profile_photo_url" value="name" sub-value="email" no-separator no-hover class="mb-3 !-mt-3 rounded"> <x-list-item :item="$user" avatar="profile_photo_url" value="name" sub-value="email" no-separator no-hover class="mb-3 !-mt-3 rounded">
<x-slot:actions> <x-slot:actions>
<x-dropdown> <x-dropdown>
<x-slot:trigger> <x-slot:trigger>
@@ -51,4 +89,6 @@
</x-dropdown> </x-dropdown>
</x-slot:actions> </x-slot:actions>
</x-list-item> </x-list-item>
</div>
</div>
+7 -5
View File
@@ -1,3 +1,5 @@
@use('App\Models\Currency')
<x-app-layout> <x-app-layout>
@livewire('portfolio-performance-chart', [ @livewire('portfolio-performance-chart', [
@@ -7,27 +9,27 @@
<div class="grid sm:grid-cols-5 gap-5"> <div class="grid sm:grid-cols-5 gap-5">
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg"> <x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Market Gain/Loss') }}</div> <div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Market Gain/Loss') }}</div>
<div class="font-black text-xl"> {{ Number::currency($metrics->total_gain_dollars) }} </div> <div class="font-black text-xl"> {{ Number::currency($metrics->get('total_market_gain_dollars', 0)) }} </div>
</x-card> </x-card>
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg"> <x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Total Cost Basis') }}</div> <div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Total Cost Basis') }}</div>
<div class="font-black text-xl"> {{ Number::currency($metrics->total_cost_basis) }} </div> <div class="font-black text-xl"> {{ Number::currency($metrics->get('total_cost_basis', 0)) }} </div>
</x-card> </x-card>
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg"> <x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Total Market Value') }}</div> <div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Total Market Value') }}</div>
<div class="font-black text-xl"> {{ Number::currency($metrics->total_market_value) }} </div> <div class="font-black text-xl"> {{ Number::currency($metrics->get('total_market_value', 0)) }} </div>
</x-card> </x-card>
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg"> <x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Realized Gain/Loss') }}</div> <div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Realized Gain/Loss') }}</div>
<div class="font-black text-xl"> {{ Number::currency($metrics->realized_gain_dollars) }} </div> <div class="font-black text-xl"> {{ Number::currency($metrics->get('realized_gain_dollars', 0)) }} </div>
</x-card> </x-card>
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg"> <x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Dividends Earned') }}</div> <div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Dividends Earned') }}</div>
<div class="font-black text-xl"> {{ Number::currency($metrics->total_dividends_earned) }} </div> <div class="font-black text-xl"> {{ Number::currency($metrics->get('total_dividends_earned', 0)) }} </div>
</x-card> </x-card>
</div> </div>
@@ -27,9 +27,9 @@ new class extends Component
$owned = ($dividend->purchased - $dividend->sold); $owned = ($dividend->purchased - $dividend->sold);
@endphp @endphp
{{ Number::currency($dividend->dividend_amount) }} {{ Number::currency($dividend->dividend_amount, $holding->market_data->currency) }}
x {{ $owned }} x {{ $owned }}
= {{ Number::currency($owned * $dividend->dividend_amount) }} = {{ Number::currency($owned * $dividend->dividend_amount, $holding->market_data->currency) }}
</x-slot:value> </x-slot:value>
<x-slot:sub-value> <x-slot:sub-value>
@@ -3,14 +3,14 @@
use App\Models\Holding; use App\Models\Holding;
use Livewire\Volt\Component; use Livewire\Volt\Component;
new class extends Component { new class extends Component
{
// props // props
public Holding $holding; public Holding $holding;
protected $listeners = [ protected $listeners = [
'transaction-updated' => '$refresh', 'transaction-updated' => '$refresh',
'transaction-saved' => '$refresh' 'transaction-saved' => '$refresh',
]; ];
// methods // methods
@@ -19,11 +19,11 @@ new class extends Component {
<div> <div>
<div class="font-bold text-2xl py-1 flex items-center"> <div class="font-bold text-2xl py-1 flex items-center">
{{ Number::currency($holding->market_data->market_value ?? 0) }} {{ Number::currency($holding->market_data->market_value ?? 0, $holding->market_data->currency) }}
<x-gain-loss-arrow-badge <x-gain-loss-arrow-badge
:cost-basis="$holding->average_cost_basis" :cost-basis="$holding->average_cost_basis"
:market-value="$holding->market_data->market_value" :market-value="$holding->market_data->market_value_base"
/> />
</div> </div>
@@ -34,22 +34,22 @@ new class extends Component {
<p> <p>
<span class="font-bold">{{ __('Average Cost Basis') }}: </span> <span class="font-bold">{{ __('Average Cost Basis') }}: </span>
{{ Number::currency($holding->average_cost_basis ?? 0) }} {{ Number::currency($holding->average_cost_basis ?? 0, $holding->market_data->currency) }}
</p> </p>
<p> <p>
<span class="font-bold">{{ __('Total Cost Basis') }}: </span> <span class="font-bold">{{ __('Total Cost Basis') }}: </span>
{{ Number::currency($holding->total_cost_basis ?? 0) }} {{ Number::currency($holding->total_cost_basis ?? 0, $holding->market_data->currency) }}
</p> </p>
<p> <p>
<span class="font-bold">{{ __('Realized Gain/Loss') }}: </span> <span class="font-bold">{{ __('Realized Gain/Loss') }}: </span>
{{ Number::currency($holding->realized_gain_dollars ?? 0) }} {{ Number::currency($holding->realized_gain_dollars ?? 0, $holding->market_data->currency) }}
</p> </p>
<p> <p>
<span class="font-bold">{{ __('Dividends Earned') }}: </span> <span class="font-bold">{{ __('Dividends Earned') }}: </span>
{{ Number::currency($holding->dividends_earned ?? 0) }} {{ Number::currency($holding->dividends_earned ?? 0, $holding->market_data->currency) }}
</p> </p>
<p class="pt-2 text-sm" title="{{ \Carbon\Carbon::parse($holding->market_data->updated_at)->toIso8601String() }}"> <p class="pt-2 text-sm" title="{{ \Carbon\Carbon::parse($holding->market_data->updated_at)->toIso8601String() }}">

Some files were not shown because too many files have changed in this diff Show More