diff --git a/app/Actions/ConvertToMarketDataCurrency.php b/app/Actions/ConvertToMarketDataCurrency.php new file mode 100644 index 0000000..6c96316 --- /dev/null +++ b/app/Actions/ConvertToMarketDataCurrency.php @@ -0,0 +1,45 @@ +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); + } +} diff --git a/app/Actions/CopyToBaseCurrency.php b/app/Actions/CopyToBaseCurrency.php new file mode 100644 index 0000000..60acbff --- /dev/null +++ b/app/Actions/CopyToBaseCurrency.php @@ -0,0 +1,24 @@ +getCasts() as $key => $value) { + if ($value === BaseCurrency::class) { + + $model[$key] = $model[Str::beforeLast($key, '_base')]; + } + } + + return $next($model); + } +} diff --git a/app/Actions/EnsureCostBasisAddedToSale.php b/app/Actions/EnsureCostBasisAddedToSale.php new file mode 100644 index 0000000..b0efbe2 --- /dev/null +++ b/app/Actions/EnsureCostBasisAddedToSale.php @@ -0,0 +1,29 @@ +transaction_type == 'SELL') { + + $average_cost_basis = Transaction::where([ + 'portfolio_id' => $model->portfolio_id, + 'symbol' => $model->symbol, + 'transaction_type' => 'BUY', + ])->whereDate('date', '<=', $model->date) + ->average('cost_basis'); + + $model->cost_basis = $average_cost_basis ?? 0; + } + + return $next($model); + } +} diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 99f39d5..bec2cb0 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -9,7 +9,6 @@ use App\Traits\WithTrimStrings; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Validator; use Laravel\Fortify\Contracts\CreatesNewUsers; -use Laravel\Jetstream\Jetstream; class CreateNewUser implements CreatesNewUsers { @@ -32,13 +31,22 @@ class CreateNewUser implements CreatesNewUsers 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], 'password' => $this->passwordRules(), - 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '', + 'terms' => config('investbrain.self_hosted') ? '' : ['accepted', 'required'], ])->validate(); - return User::create([ + $user = User::make([ 'name' => $input['name'], 'email' => $input['email'], 'password' => Hash::make($input['password']), ]); + + // ensure first user is flagged as an admin + if (User::count() === 0) { + $user->admin = true; + } + + $user->save(); + + return $user; } } diff --git a/app/Casts/BaseCurrency.php b/app/Casts/BaseCurrency.php new file mode 100644 index 0000000..4ee1780 --- /dev/null +++ b/app/Casts/BaseCurrency.php @@ -0,0 +1,46 @@ + $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 $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 + ); + } +} diff --git a/app/Console/Commands/CaptureDailyChange.php b/app/Console/Commands/CaptureDailyChange.php index 24bb411..d78a81b 100644 --- a/app/Console/Commands/CaptureDailyChange.php +++ b/app/Console/Commands/CaptureDailyChange.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Console\Commands; +use App\Models\Holding; use App\Models\Portfolio; use Illuminate\Console\Command; @@ -44,23 +45,20 @@ class CaptureDailyChange extends Command $this->line('Capturing daily change for '.$portfolio->title); - $total_cost_basis = $portfolio->holdings->sum('total_cost_basis'); + $metrics = Holding::query() + ->portfolio($portfolio->id) + ->getPortfolioMetrics(config('investbrain.base_currency')); - $total_dividends = $portfolio->holdings->sum('dividends_earned'); - - $realized_gains = $portfolio->holdings->sum('realized_gain_dollars'); - - $total_market_value = $portfolio->holdings->sum(function ($holding) { - return $holding->market_data->market_value * $holding->quantity; - }); + $total_cost_basis = $metrics->get('total_cost_basis'); + $total_market_value = $metrics->get('total_market_value'); $portfolio->daily_change()->create([ 'date' => now(), 'total_market_value' => $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, + 'total_dividends_earned' => $metrics->get('total_dividends_earned'), + 'realized_gains' => $metrics->get('realized_gain_dollars'), ]); }); } diff --git a/app/Console/Commands/RefreshCurrencyData.php b/app/Console/Commands/RefreshCurrencyData.php new file mode 100644 index 0000000..38fd776 --- /dev/null +++ b/app/Console/Commands/RefreshCurrencyData.php @@ -0,0 +1,47 @@ +option('force') ?? false); + } +} diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 5ae664e..5ad2185 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -17,16 +17,14 @@ class DashboardController extends Controller $user = $request->user()->load(['portfolios', 'holdings', 'transactions']); // get portfolio metrics - $metrics = cache()->remember( + $metrics = cache()->tags(['metrics-'.$user->id])->remember( 'dashboard-metrics-'.$user->id, 10, function () { - return - Holding::query() - ->myHoldings() - ->withoutWishlists() - ->withPortfolioMetrics() - ->first(); + return Holding::query() + ->myHoldings() + ->withoutWishlists() + ->getPortfolioMetrics(); } ); diff --git a/app/Http/Controllers/HoldingController.php b/app/Http/Controllers/HoldingController.php index ff725a3..c7f2124 100644 --- a/app/Http/Controllers/HoldingController.php +++ b/app/Http/Controllers/HoldingController.php @@ -21,9 +21,9 @@ class HoldingController extends Controller $query->where('transactions.symbol', $symbol); }, ]) - ->symbol($symbol) - ->portfolio($portfolio->id) - ->firstOrFail(); + ->symbol($symbol) + ->portfolio($portfolio->id) + ->firstOrFail(); $formattedTransactions = $holding->getFormattedTransactions(); diff --git a/app/Http/Controllers/PortfolioController.php b/app/Http/Controllers/PortfolioController.php index 1d86528..9861e8d 100644 --- a/app/Http/Controllers/PortfolioController.php +++ b/app/Http/Controllers/PortfolioController.php @@ -29,14 +29,13 @@ class PortfolioController extends Controller $portfolio->load(['transactions', 'holdings']); // get portfolio metrics - $metrics = cache()->remember( + $metrics = cache()->tags(['metrics-'.$request->user()->id])->remember( 'portfolio-metrics-'.$portfolio->id, 60, function () use ($portfolio) { return Holding::query() ->portfolio($portfolio->id) - ->withPortfolioMetrics() - ->first(); + ->getPortfolioMetrics(); } ); diff --git a/app/Http/Middleware/LocalizationMiddleware.php b/app/Http/Middleware/LocalizationMiddleware.php new file mode 100644 index 0000000..9a13200 --- /dev/null +++ b/app/Http/Middleware/LocalizationMiddleware.php @@ -0,0 +1,37 @@ +user()->getLocale(); + + config(['app.locale' => $locale]); + app('translator')->setLocale(Str::before($locale, '_')); + app('events')->dispatch(new LocaleUpdated($locale)); + + Number::useLocale($locale); + Number::useCurrency(auth()->user()->getCurrency()); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/SetLocale.php b/app/Http/Middleware/SetLocale.php deleted file mode 100644 index de1c440..0000000 --- a/app/Http/Middleware/SetLocale.php +++ /dev/null @@ -1,29 +0,0 @@ -has('locale')) { - session()->put('locale', $request->getPreferredLanguage( - config('app.available_locales') - )); - } - - app()->setLocale(session('locale')); - - return $next($request); - } -} diff --git a/app/Http/Requests/TransactionRequest.php b/app/Http/Requests/TransactionRequest.php index 49a51d0..847a1ef 100644 --- a/app/Http/Requests/TransactionRequest.php +++ b/app/Http/Requests/TransactionRequest.php @@ -30,11 +30,11 @@ class TransactionRequest extends FormRequest 'portfolio_id' => ['required', 'exists:portfolios,id'], 'symbol' => ['required', 'string', new SymbolValidationRule], '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' => [ 'required', 'numeric', - 'min:0', + 'gt:0', new QuantityValidationRule( $this->input('portfolio'), $this->requestOrModelValue('symbol', 'transaction'), diff --git a/app/Imports/Sheets/DailyChangesSheet.php b/app/Imports/Sheets/DailyChangesSheet.php index ce279db..7cdb405 100644 --- a/app/Imports/Sheets/DailyChangesSheet.php +++ b/app/Imports/Sheets/DailyChangesSheet.php @@ -55,7 +55,7 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit 'realized_gains' => $dailyChange['realized_gains'], 'annotation' => $dailyChange['annotation'], 'portfolio_id' => $dailyChange['portfolio_id'], - 'date' => Carbon::parse($dailyChange['date'])->format('Y-m-d'), + 'date' => Carbon::parse($dailyChange['date'])->toDateString(), ]; }); diff --git a/app/Imports/Sheets/TransactionsSheet.php b/app/Imports/Sheets/TransactionsSheet.php index 8cb6166..2682423 100644 --- a/app/Imports/Sheets/TransactionsSheet.php +++ b/app/Imports/Sheets/TransactionsSheet.php @@ -60,7 +60,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit 'sale_price' => $transaction['sale_price'], 'split' => boolval($transaction['split']) ? 1 : 0, 'reinvested_dividend' => boolval($transaction['reinvested_dividend']) ? 1 : 0, - 'date' => Carbon::parse($transaction['date'])->format('Y-m-d'), + 'date' => Carbon::parse($transaction['date'])->toDateString(), ]; }); diff --git a/app/Interfaces/MarketData/AlphaVantageMarketData.php b/app/Interfaces/MarketData/AlphaVantageMarketData.php index 17c4856..50ca069 100644 --- a/app/Interfaces/MarketData/AlphaVantageMarketData.php +++ b/app/Interfaces/MarketData/AlphaVantageMarketData.php @@ -23,23 +23,44 @@ class AlphaVantageMarketData implements MarketDataInterface 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 = Arr::get($quote, 'Global Quote', []); $fundamental = cache()->remember( 'av-symbol-'.$symbol, 1440, - function () use ($symbol) { - return Alphavantage::fundamentals()->overview($symbol); + function () use ($symbol, $search) { + 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([ - 'name' => Arr::get($fundamental, 'Name'), + 'name' => Arr::get($search, '2. name'), 'symbol' => $symbol, - 'market_value' => Arr::get($quote, '05. price'), - 'fifty_two_week_high' => Arr::get($fundamental, '52WeekHigh'), - 'fifty_two_week_low' => Arr::get($fundamental, '52WeekLow'), + 'market_value' => (float) Arr::get($quote, '05. price'), + 'currency' => Arr::get($search, '8. currency'), + 'fifty_two_week_high' => (float) Arr::get($fundamental, '52WeekHigh'), + 'fifty_two_week_low' => (float) Arr::get($fundamental, '52WeekLow'), 'forward_pe' => Arr::get($fundamental, 'ForwardPE'), 'trailing_pe' => Arr::get($fundamental, 'TrailingPE'), 'market_cap' => Arr::get($fundamental, 'MarketCapitalization'), @@ -48,8 +69,20 @@ class AlphaVantageMarketData implements MarketDataInterface ? Arr::get($fundamental, 'DividendDate') : null, 'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None' - ? Arr::get($fundamental, 'DividendYield') + ? Arr::get($fundamental, 'DividendYield') * 100 : 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) { - $date = Carbon::parse($date)->format('Y-m-d'); + $date = Carbon::parse($date)->toDateString(); return [$date => new Ohlc([ 'symbol' => $symbol, diff --git a/app/Interfaces/MarketData/FakeMarketData.php b/app/Interfaces/MarketData/FakeMarketData.php index 9a3b57e..fde26d1 100644 --- a/app/Interfaces/MarketData/FakeMarketData.php +++ b/app/Interfaces/MarketData/FakeMarketData.php @@ -8,6 +8,7 @@ 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 Carbon\CarbonPeriod; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; @@ -25,6 +26,7 @@ class FakeMarketData implements MarketDataInterface return new Quote([ 'name' => 'ACME Company Ltd', 'symbol' => $symbol, + 'currency' => 'USD', 'market_value' => 230.19, 'fifty_two_week_high' => 512.90, 'fifty_two_week_low' => 341.20, @@ -34,6 +36,7 @@ class FakeMarketData implements MarketDataInterface 'book_value' => 4.7, 'last_dividend_date' => now()->subDays(45), 'dividend_yield' => 0.033, + 'meta_data' => [], ]); } @@ -65,7 +68,7 @@ class FakeMarketData implements MarketDataInterface return collect([ new Split([ 'symbol' => $symbol, - 'date' => now()->subMonths(36), + 'date' => now()->subMonths(12), 'split_amount' => 10, ]), ]); @@ -73,16 +76,27 @@ class FakeMarketData implements MarketDataInterface 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([ 'symbol' => $symbol, '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), ]); } diff --git a/app/Interfaces/MarketData/FallbackInterface.php b/app/Interfaces/MarketData/FallbackInterface.php index ff25749..514f2fa 100644 --- a/app/Interfaces/MarketData/FallbackInterface.php +++ b/app/Interfaces/MarketData/FallbackInterface.php @@ -18,9 +18,10 @@ class FallbackInterface foreach ($providers as $provider) { $provider = trim($provider); + $symbol = $arguments[0]; try { - Log::warning("Calling method {$method} ({$provider})"); + Log::info("Calling method {$method} for {$symbol} ({$provider})"); if (! in_array($provider, array_keys(config('investbrain.interfaces', [])))) { @@ -35,17 +36,17 @@ class FallbackInterface $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') { // symbol prob just doesn't exist 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}"); } } diff --git a/app/Interfaces/MarketData/FinnhubMarketData.php b/app/Interfaces/MarketData/FinnhubMarketData.php index 8cd561e..58caf90 100644 --- a/app/Interfaces/MarketData/FinnhubMarketData.php +++ b/app/Interfaces/MarketData/FinnhubMarketData.php @@ -8,6 +8,7 @@ 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 Finnhub\ObjectSerializer; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; @@ -35,32 +36,46 @@ class FinnhubMarketData implements MarketDataInterface { $quote = $this->client->quote($symbol); + if (is_null(Arr::get($quote, 'd'))) { + throw new \Exception('Could not find ticker on Finnhub'); + } + $fundamental = cache()->remember( 'fh-symbol-'.$symbol, 1440, 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([ - 'name' => Arr::get($fundamental, 'metric.name'), + 'name' => Arr::get($fundamental, 'name'), 'symbol' => $symbol, + 'currency' => Arr::get($fundamental, 'currency'), 'market_value' => Arr::get($quote, 'c'), 'fifty_two_week_high' => Arr::get($fundamental, 'metric.52WeekHigh'), 'fifty_two_week_low' => Arr::get($fundamental, 'metric.52WeekLow'), - 'forward_pe' => Arr::get($fundamental, 'metric.forwardPE'), // confirm - 'trailing_pe' => Arr::get($fundamental, 'metric.trailingPE'), // confirm - 'market_cap' => Arr::get($fundamental, 'metric.marketCapitalization'), // confirm - 'book_value' => Arr::get($fundamental, 'metric.bookValuePerShare'), // confirm - 'last_dividend_date' => Arr::get($fundamental, 'metric.lastDivDate'), // confirm - 'dividend_yield' => Arr::get($fundamental, 'metric.dividendYield'), // confirm + 'forward_pe' => Arr::get($fundamental, 'metric.peAnnual'), + 'trailing_pe' => Arr::get($fundamental, 'metric.peTTM'), + 'market_cap' => Arr::get($fundamental, 'metric.marketCapitalization', 0) * 1000000, + 'book_value' => Arr::get($fundamental, 'metric.bookValuePerShareAnnual'), + 'dividend_yield' => Arr::get($fundamental, 'metric.dividendYieldIndicatedAnnual'), + '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 { - $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) { @@ -75,7 +90,7 @@ class FinnhubMarketData implements MarketDataInterface 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) { @@ -96,7 +111,7 @@ class FinnhubMarketData implements MarketDataInterface $closes = Arr::get($history, 'c', []); 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([ 'symbol' => $symbol, diff --git a/app/Interfaces/MarketData/Types/Dividend.php b/app/Interfaces/MarketData/Types/Dividend.php index 1aa34a5..d731d3a 100644 --- a/app/Interfaces/MarketData/Types/Dividend.php +++ b/app/Interfaces/MarketData/Types/Dividend.php @@ -21,7 +21,7 @@ class Dividend extends MarketDataType return $this->items['symbol'] ?? ''; } - public function setDividendAmount($dividendAmount): self + public function setDividendAmount(int|float $dividendAmount): self { $this->items['dividend_amount'] = (float) $dividendAmount; diff --git a/app/Interfaces/MarketData/Types/MarketDataType.php b/app/Interfaces/MarketData/Types/MarketDataType.php index d7907d7..2cb2a56 100644 --- a/app/Interfaces/MarketData/Types/MarketDataType.php +++ b/app/Interfaces/MarketData/Types/MarketDataType.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Interfaces\MarketData\Types; +use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; @@ -12,24 +13,79 @@ class MarketDataType extends Collection public function __construct($items = []) { - foreach ($this->getArrayableItems($items) as $key => $value) { + $items = $this->getArrayableItems($items); - $this->{$key} = $value; + foreach ($items as $key => $value) { + + $this->validateRequiredTypes($key, $value); + + if (! is_null($value)) { + $this->{$key} = $value; + } } } - public function toArray() - { - return $this->items; - } - public function __set($key, $value) { - $this->{'set'.Str::studly($key)}($value); + + $this->{$this->getSetMethodName($key)}($value); } public function __get($key) { 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)); + } } diff --git a/app/Interfaces/MarketData/Types/Ohlc.php b/app/Interfaces/MarketData/Types/Ohlc.php index 0fc0c05..1f6af2f 100644 --- a/app/Interfaces/MarketData/Types/Ohlc.php +++ b/app/Interfaces/MarketData/Types/Ohlc.php @@ -21,7 +21,7 @@ class Ohlc extends MarketDataType return $this->items['symbol'] ?? ''; } - public function setOpen($open): self + public function setOpen(int|float $open): self { $this->items['open'] = (float) $open; @@ -33,7 +33,7 @@ class Ohlc extends MarketDataType return $this->items['open'] ?? 0.0; } - public function setHigh($high): self + public function setHigh(int|float $high): self { $this->items['high'] = (float) $high; @@ -45,7 +45,7 @@ class Ohlc extends MarketDataType return $this->items['high'] ?? 0.0; } - public function setLow($low): self + public function setLow(int|float $low): self { $this->items['low'] = (float) $low; @@ -57,7 +57,7 @@ class Ohlc extends MarketDataType return $this->items['low'] ?? 0.0; } - public function setClose($close): self + public function setClose(int|float $close): self { $this->items['close'] = (float) $close; diff --git a/app/Interfaces/MarketData/Types/Quote.php b/app/Interfaces/MarketData/Types/Quote.php index dbf92c2..6113a2c 100644 --- a/app/Interfaces/MarketData/Types/Quote.php +++ b/app/Interfaces/MarketData/Types/Quote.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Interfaces\MarketData\Types; use DateTime; +use Illuminate\Support\Arr; use Illuminate\Support\Carbon; class Quote extends MarketDataType @@ -35,7 +36,19 @@ class Quote extends MarketDataType 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; @@ -97,6 +110,7 @@ class Quote extends MarketDataType public function setMarketCap($cap): self { + // return $this; $this->items['market_cap'] = (int) $cap; return $this; @@ -119,6 +133,18 @@ class Quote extends MarketDataType 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 { $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; } + + 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']; + } } diff --git a/app/Interfaces/MarketData/Types/Split.php b/app/Interfaces/MarketData/Types/Split.php index 2f95de9..0896cd9 100644 --- a/app/Interfaces/MarketData/Types/Split.php +++ b/app/Interfaces/MarketData/Types/Split.php @@ -21,7 +21,7 @@ class Split extends MarketDataType return $this->items['symbol'] ?? ''; } - public function setSplitAmount($splitAmount): self + public function setSplitAmount(int|float $splitAmount): self { $this->items['split_amount'] = (float) $splitAmount; diff --git a/app/Interfaces/MarketData/YahooMarketData.php b/app/Interfaces/MarketData/YahooMarketData.php index 3261a2f..227a2cc 100644 --- a/app/Interfaces/MarketData/YahooMarketData.php +++ b/app/Interfaces/MarketData/YahooMarketData.php @@ -8,6 +8,7 @@ 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\Support\Carbon; use Illuminate\Support\Collection; use Scheb\YahooFinanceApi\ApiClient; use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance; @@ -34,9 +35,14 @@ class YahooMarketData implements MarketDataInterface $quote = $this->client->getQuote($symbol); + if (is_null($quote?->getRegularMarketPrice())) { + throw new \Exception('Could not find ticker on Yahoo'); + } + return new Quote([ 'name' => $quote?->getLongName() ?? $quote?->getShortName(), 'symbol' => $symbol, + 'currency' => $quote?->getCurrency(), 'market_value' => $quote?->getRegularMarketPrice(), 'fifty_two_week_high' => $quote?->getFiftyTwoWeekHigh(), 'fifty_two_week_low' => $quote?->getFiftyTwoWeekLow(), @@ -46,6 +52,11 @@ class YahooMarketData implements MarketDataInterface 'book_value' => $quote?->getBookValue(), 'last_dividend_date' => $quote?->getDividendDate(), 'dividend_yield' => $quote?->getTrailingAnnualDividendYield() * 100, + 'meta_data' => [ + 'exchange' => $quote?->getExchange(), + 'asset_type' => $quote?->getQuoteType(), + 'source' => 'yahoo', + ], ]); } @@ -84,7 +95,7 @@ class YahooMarketData implements MarketDataInterface return collect($this->client->getHistoricalQuoteData($symbol, ApiClient::INTERVAL_1_DAY, $startDate, $endDate)) ->mapWithKeys(function ($history) use ($symbol) { - $date = $history->getDate()->format('Y-m-d'); + $date = Carbon::parse($history->getDate())->toDateString(); return [$date => new Ohlc([ 'symbol' => $symbol, diff --git a/app/Jobs/BatchInsertNewCurrencyRatesJob.php b/app/Jobs/BatchInsertNewCurrencyRatesJob.php new file mode 100644 index 0000000..ca8d6bf --- /dev/null +++ b/app/Jobs/BatchInsertNewCurrencyRatesJob.php @@ -0,0 +1,41 @@ +updates = $updates; + } + + /** + * Execute the job. + */ + public function handle(): void + { + + $chunks = array_chunk($this->updates, $this->chunk_size); + + foreach ($chunks as $chunk) { + CurrencyRate::insertOrIgnore($chunk); + } + + } +} diff --git a/app/Models/Currency.php b/app/Models/Currency.php new file mode 100644 index 0000000..a168aae --- /dev/null +++ b/app/Models/Currency.php @@ -0,0 +1,100 @@ + + */ + 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; + } +} diff --git a/app/Models/CurrencyRate.php b/app/Models/CurrencyRate.php new file mode 100644 index 0000000..9ad425e --- /dev/null +++ b/app/Models/CurrencyRate.php @@ -0,0 +1,251 @@ + + */ + 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 + BatchInsertNewCurrencyRatesJob::dispatch($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 + */ + public static function timeSeriesRates(string $currency, 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; + } + + [$currency, $adjustment] = self::getCurrencyAliasAdjustments($currency); + + $currencies = Currency::all()->pluck('currency')->toArray(); + + // call api in chunks + $rates = []; + foreach (collect($period)->chunk(500) as $chunk) { + + $chunkRates = Frankfurter::setSymbols($currencies)->timeSeries($chunk->min(), $chunk->max()); + + $rates = array_merge($rates, Arr::get($chunkRates, 'rates', [])); + } + + // loop through each date + $updates = []; + foreach ($period as $date) { + + $skip = false; + + $lookupDate = $date->toDateString(); + + // get rates or find closest valid rate (handles missing weekend rates) + while (! isset($rates[$lookupDate])) { + $lookupDate = Carbon::parse($lookupDate)->subDay(); + + // prevent runaway infinite loops + if ($lookupDate->lessThan($date->copy()->subWeek())) { + + $skip = true; + break; + } + + $lookupDate = $lookupDate->toDateString(); + } + + if ($skip) { + continue; + } + + // make date a string + $date = $date->toDateString(); + + // loop through each rate + foreach ($rates[$lookupDate] as $curr => $rate) { + + // add to updates + $updates[] = [ + 'currency' => $curr, + 'date' => $date, + 'rate' => $rate, + 'updated_at' => now()->toDateTimeString(), + 'created_at' => now()->toDateTimeString(), + ]; + } + } + + // persist + BatchInsertNewCurrencyRatesJob::dispatch($updates); + + return collect($updates) + ->whereBetween('date', [$start, $end ?? now()]) + ->where('currency', $currency) + ->mapWithKeys(fn ($rate) => [ + $rate['date'] => $rate['rate'] * $adjustment, + ]) + ->toArray(); + } + + 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); + } + } + + protected static function getCurrencyAliasAdjustments($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]; + } +} diff --git a/app/Models/DailyChange.php b/app/Models/DailyChange.php index 9b5d95c..01127de 100644 --- a/app/Models/DailyChange.php +++ b/app/Models/DailyChange.php @@ -7,6 +7,7 @@ namespace App\Models; use App\Traits\HasCompositePrimaryKey; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\DB; class DailyChange extends Model { @@ -22,10 +23,6 @@ class DailyChange extends Model 'portfolio_id', 'date', 'total_market_value', - 'total_cost_basis', - 'total_gain', - 'total_dividends_earned', - 'realized_gains', 'notes', ]; @@ -33,11 +30,16 @@ class DailyChange extends Model protected $casts = [ 'date' => 'datetime', + 'total_market_value' => 'float', + 'total_cost_basis' => 'float', + 'total_gain' => 'float', + 'realized_gain_dollars' => 'float', + 'total_dividends_earned' => 'float', ]; public function scopePortfolio($query, $portfolio) { - return $query->where('portfolio_id', $portfolio); + return $query->where('daily_change.portfolio_id', $portfolio); } public function scopeMyDailyChanges() @@ -56,6 +58,141 @@ 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']); + + $totalCostBasisSub = DB::table('transactions as tx1') + ->leftJoin('currency_rates as cr', function ($join) use ($currency) { + $join->on('cr.date', '=', 'tx1.date') + ->where('cr.currency', '=', $currency); + }) + ->select([ + 'tx1.portfolio_id', + 'tx1.date', + 'tx1.symbol', + 'tx1.transaction_type', + 'tx1.quantity', + ]) + ->selectRaw("(CASE + WHEN tx1.transaction_type = 'BUY' + 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 = tx1.symbol + AND buy.portfolio_id = tx1.portfolio_id + AND buy.transaction_type = 'BUY' + AND buy.date <= tx1.date + ) END) + AS rate") + ->selectRaw( + "(CASE + WHEN tx1.transaction_type = 'BUY' + THEN AVG(tx1.cost_basis_base) + ELSE ( + SELECT + AVG(-buy.cost_basis_base) + FROM transactions as buy + WHERE buy.symbol = tx1.symbol + AND buy.portfolio_id = tx1.portfolio_id + AND buy.transaction_type = 'BUY' + AND buy.date <= tx1.date + ) END) + AS cost_basis_base") + ->selectRaw( + "(CASE + WHEN tx1.transaction_type = 'SELL' + THEN tx1.sale_price_base - tx1.cost_basis_base + ELSE 0 END) + * tx1.quantity + * COALESCE(cr.rate, 1) + AS realized_gain_dollars") + ->groupBy([ + 'tx1.portfolio_id', + 'tx1.date', + 'tx1.symbol', + 'tx1.transaction_type', + 'tx1.cost_basis_base', + 'tx1.quantity', + 'cr.rate', + 'tx1.sale_price_base', + ]); + + return $query + ->select(['daily_change.portfolio_id', 'daily_change.date']) + ->leftJoinSub($totalCostBasisSub, 'cost_basis_display', function ($join) { + $join->on('daily_change.date', '>=', 'cost_basis_display.date') + ->whereColumn('daily_change.portfolio_id', '=', 'cost_basis_display.portfolio_id'); + }) + ->leftJoin('currency_rates as cr', function ($join) use ($currency) { + $join->on('cr.date', '=', 'daily_change.date') + ->where('cr.currency', '=', $currency); + }) + ->selectRaw(' + SUM( + cost_basis_display.cost_basis_base + * cost_basis_display.quantity + * cost_basis_display.rate + ) as total_cost_basis') + ->selectRaw('( + daily_change.total_market_value * COALESCE(cr.rate, 1) + ) - SUM( + cost_basis_display.cost_basis_base + * cost_basis_display.quantity + * cost_basis_display.rate + ) as total_gain') + ->selectRaw('( + daily_change.total_market_value * COALESCE(cr.rate, 1) + ) as total_market_value') + ->selectRaw(' + SUM( + cost_basis_display.realized_gain_dollars + ) 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') + ->groupBy([ + 'daily_change.date', + 'cr.rate', + 'daily_change.total_market_value', + 'daily_change.portfolio_id', + ]) + ->orderBy('daily_change.date'); + } + public function portfolio() { return $this->belongsTo(Portfolio::class); diff --git a/app/Models/Dividend.php b/app/Models/Dividend.php index 9584cd3..f340566 100644 --- a/app/Models/Dividend.php +++ b/app/Models/Dividend.php @@ -4,17 +4,24 @@ declare(strict_types=1); namespace App\Models; +use App\Actions\CopyToBaseCurrency; +use App\Casts\BaseCurrency; use App\Interfaces\MarketData\MarketDataInterface; +use App\Traits\HasMarketData; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Arr; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Pipeline; use Illuminate\Support\Str; class Dividend extends Model { use HasFactory; + use HasMarketData; use HasUuids; protected $fillable = [ @@ -26,21 +33,32 @@ class Dividend extends Model protected $hidden = []; protected $casts = [ - 'date' => 'datetime', - 'last_dividend_update' => 'datetime', + 'date' => 'date', + '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'); } - public function transactions() + public function transactions(): HasMany { return $this->hasMany(Transaction::class, 'symbol', 'symbol'); } @@ -84,8 +102,18 @@ class Dividend extends Model // ah, we found some dividends... if ($dividend_data->isNotEmpty()) { + + $market_data = MarketData::getMarketData($symbol); + + // get historic conversion rates + $rate_to_base = CurrencyRate::timeSeriesRates($market_data->currency, $start_date, $end_date); + // create mass insert foreach ($dividend_data as $index => $dividend) { + $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; + $dividend_data[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]]; } @@ -95,9 +123,6 @@ class Dividend extends Model // sync to holdings self::syncHoldings($symbol); - // get market data - $market_data = MarketData::firstOrNew(['symbol' => $symbol]); - // re-invest dividends self::reinvestDividends($dividend_data, $market_data); @@ -127,7 +152,7 @@ class Dividend extends Model ")->join('transactions', 'transactions.symbol', '=', 'dividends.symbol') ->join('holdings', 'transactions.portfolio_id', '=', 'holdings.portfolio_id') ->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")) ->mergeBindings($subQuery->getQuery()) diff --git a/app/Models/Holding.php b/app/Models/Holding.php index fd3098f..ad53740 100644 --- a/app/Models/Holding.php +++ b/app/Models/Holding.php @@ -4,15 +4,18 @@ declare(strict_types=1); namespace App\Models; +use App\Traits\HasMarketData; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; class Holding extends Model { use HasFactory; + use HasMarketData; use HasUuids; protected $fillable = [ @@ -28,21 +31,24 @@ class Holding extends Model ]; protected $casts = [ + 'reinvest_dividends' => 'boolean', 'splits_synced_at' => '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_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 * @@ -61,7 +67,7 @@ class Holding extends Model public function dividends() { 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( CASE WHEN transaction_type = 'BUY' AND transactions.symbol = dividends.symbol @@ -91,8 +97,21 @@ class Holding extends Model THEN transactions.quantity ELSE 0 END) * dividends.dividend_amount ) 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') - ->groupBy(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount']) + ->groupBy(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount', 'dividends.dividend_amount_base']) ->orderBy('dividends.date', 'DESC') ->where('dividends.date', '>=', function ($query) { $query->selectRaw('min(transactions.date)') @@ -118,7 +137,7 @@ class Holding extends Model THEN transactions.quantity ELSE 0 END) - ) * dividends.dividend_amount > 0"); + ) * dividends.dividend_amount_base > 0"); } /** @@ -156,12 +175,16 @@ class Holding extends Model { return $query->withAggregate('market_data', 'name') ->withAggregate('market_data', 'market_value') + ->withAggregate('market_data', 'market_value_base') ->withAggregate('market_data', 'fifty_two_week_low') ->withAggregate('market_data', 'fifty_two_week_high') ->withAggregate('market_data', 'updated_at') ->join('market_data', 'holdings.symbol', 'market_data.symbol'); } + /** + * Calculate performance for holding in its local currency + */ public function scopeWithPerformance($query) { return $query->selectRaw('COALESCE(market_data.market_value * holdings.quantity, 0) AS total_market_value') @@ -193,15 +216,169 @@ 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') - ->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') - ->selectRaw('COALESCE(SUM(holdings.total_cost_basis), 0) AS total_cost_basis') - ->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) - COALESCE(SUM(holdings.total_cost_basis), 0) AS total_gain_dollars') - // ->selectRaw('COALESCE((@total_gain_dollars / @sum_total_cost_basis) * 100,0) AS total_gain_percent') - ->join('market_data', 'market_data.symbol', '=', 'holdings.symbol'); + $result = $query->withPortfolioMetrics($currency)->get(); + + return collect([ + 'total_cost_basis' => $result->sum('total_cost_basis'), + 'total_market_value' => $result->sum('total_market_value'), + 'total_gain_dollars' => $result->sum('total_gain_dollars'), + '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(); + + return $query->select([ + 'holdings.symbol', + 'holdings.portfolio_id', + 'transactions_display.total_cost_basis', + 'transactions_display.realized_gain_dollars', + 'dividends_display.total_dividends_earned', + ]) + ->groupBy([ + 'holdings.symbol', + 'holdings.quantity', + 'holdings.portfolio_id', + 'cr.rate', + 'transactions_display.total_cost_basis', + 'transactions_display.realized_gain_dollars', + '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('( + holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1) + ) - transactions_display.total_cost_basis as total_gain_dollars') + ->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']) + ->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.date', + ]) + ->selectRaw( + "(CASE + WHEN transactions.transaction_type = 'BUY' + 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" + ) + ->selectRaw( + "(CASE + WHEN transactions.transaction_type = 'BUY' + THEN AVG(transactions.cost_basis_base) + ELSE ( + SELECT + AVG(-buy.cost_basis_base) + FROM transactions as buy + WHERE buy.symbol = transactions.symbol + AND buy.portfolio_id = transactions.portfolio_id + AND buy.transaction_type = 'BUY' + AND buy.date <= transactions.date + ) END) + AS cost_basis_base" + ) + ->groupBy([ + 'transactions.symbol', + 'transactions.date', + 'transactions.portfolio_id', + 'transactions.transaction_type', + '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( + "SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN (transactions.sale_price_base - transactions.cost_basis_base) * transactions.quantity * COALESCE(cr.rate, 1) ELSE 0 END) AS realized_gain_dollars" + ) + ->selectRaw( + 'SUM(cost_basis_display.cost_basis_base * cost_basis_display.quantity * cost_basis_display.rate) AS total_cost_basis' + ) + ->groupBy(['transactions.symbol', 'transactions.portfolio_id']), + 'transactions_display', + function ($join) { + $join->on('holdings.symbol', '=', 'transactions_display.symbol') + ->on('holdings.portfolio_id', '=', 'transactions_display.portfolio_id'); + } + ) + ->leftJoinSub( + 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']) + ->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']), + 'dividends_display', + function ($join) { + $join->on('holdings.symbol', '=', 'dividends_display.symbol'); + } + ); } public function syncTransactionsAndDividends() @@ -209,14 +386,14 @@ class Holding extends Model // pull existing transaction data $query = Transaction::where([ '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 = '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 = 'SELL' THEN (quantity * sale_price) ELSE 0 END) AS total_sale_price") ->first(); - $total_quantity = round($query->qty_purchases - $query->qty_sales, 3); + $total_quantity = round($query->qty_purchases - $query->qty_sales, 4); $average_cost_basis = ( $query->qty_purchases > 0 @@ -229,9 +406,7 @@ class Holding extends Model 'quantity' => $total_quantity, 'average_cost_basis' => $average_cost_basis, 'total_cost_basis' => $total_quantity * $average_cost_basis, - 'realized_gain_dollars' => $query->qty_purchases > 0 && $query->total_sale_price > 0 - ? $query->total_sale_price - ($query->qty_sales * ($query->total_cost_basis / $query->qty_purchases)) - : 0, + 'realized_gain_dollars' => $query->realized_gain_dollars ?? 0, 'dividends_earned' => $this->dividends->sum('total_received'), ]); @@ -253,6 +428,11 @@ class Holding extends Model return $purchases - $sales; } + /** + * Method that enables calculating daily performance for a given holding + * + * @return void + */ public function dailyPerformance( ?\Illuminate\Support\Carbon $start_date = null, ?\Illuminate\Support\Carbon $end_date = null, @@ -277,11 +457,11 @@ class Holding extends Model // Default CTE time series query (for MySQL and SQLite) $timeSeriesQuery = DB::table(DB::raw("( WITH RECURSIVE date_series AS ( - SELECT '{$start_date->format('Y-m-d')}' AS date + SELECT '{$start_date->toDateString()}' AS date UNION ALL SELECT $date_interval FROM date_series - WHERE date < '{$end_date->format('Y-m-d')}' + WHERE date < '{$end_date->toDateString()}' ) SELECT date_series.date FROM date_series @@ -292,8 +472,8 @@ class Holding extends Model $timeSeriesQuery = DB::table(DB::raw(" generate_series( - date '{$start_date->format('Y-m-d')}', - date '{$end_date->format('Y-m-d')}', + date '{$start_date->toDateString()}', + date '{$end_date->toDateString()}', interval '1 day' ) as date_series")); @@ -335,12 +515,12 @@ class Holding extends Model CASE WHEN ({$quantityQuery}) = 0 THEN 0 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 END) 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) { $join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date') @@ -357,7 +537,7 @@ class Holding extends Model { $formattedTransactions = ''; foreach ($this->transactions->sortByDesc('date') as $transaction) { - $formattedTransactions .= ' * '.$transaction->date->format('Y-m-d') + $formattedTransactions .= ' * '.$transaction->date->toDateString() .' '.$transaction->transaction_type .' '.$transaction->quantity .' @ '.$transaction->cost_basis diff --git a/app/Models/MarketData.php b/app/Models/MarketData.php index a16521d..618c8b7 100644 --- a/app/Models/MarketData.php +++ b/app/Models/MarketData.php @@ -4,9 +4,12 @@ declare(strict_types=1); namespace App\Models; +use App\Actions\CopyToBaseCurrency; +use App\Casts\BaseCurrency; use App\Interfaces\MarketData\MarketDataInterface; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Pipeline; class MarketData extends Model { @@ -21,7 +24,9 @@ class MarketData extends Model protected $fillable = [ 'symbol', 'name', + 'currency', 'market_value', + 'market_value_base', 'fifty_two_week_high', 'fifty_two_week_low', 'forward_pe', @@ -29,21 +34,40 @@ class MarketData extends Model 'market_cap', 'book_value', 'last_dividend_date', + 'last_dividend_amount', 'dividend_yield', + 'meta_data', ]; protected $casts = [ - 'last_dividend_date' => 'datetime', 'market_value' => 'float', + 'market_value_base' => BaseCurrency::class, 'fifty_two_week_high' => 'float', 'fifty_two_week_low' => 'float', 'forward_pe' => 'float', 'trailing_pe' => 'float', - 'market_cap' => 'float', + 'market_cap' => 'integer', 'book_value' => 'float', + 'last_dividend_date' => 'datetime', + 'last_dividend_amount' => '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() { return $this->hasMany(Holding::class, 'symbol', 'symbol'); @@ -54,7 +78,7 @@ class MarketData extends Model return $query->where('symbol', $symbol); } - public static function getMarketData($symbol, $force = false) + public static function getMarketData($symbol, $force = false): self { $market_data = self::firstOrNew([ 'symbol' => $symbol, diff --git a/app/Models/Portfolio.php b/app/Models/Portfolio.php index ed5eff5..4f42002 100644 --- a/app/Models/Portfolio.php +++ b/app/Models/Portfolio.php @@ -136,6 +136,9 @@ class Portfolio extends Model } } + /** + * Writes daily change history for a portfolio to the database + */ public function syncDailyChanges(): void { $holdings = $this->holdings() @@ -147,11 +150,9 @@ class Portfolio extends Model ->groupBy(['holdings.symbol', 'holdings.portfolio_id']) ->get(); - $dividends = Dividend::whereIn('symbol', $holdings->pluck('symbol'))->get(); - $total_performance = []; - $holdings->each(function ($holding) use (&$total_performance, $dividends) { + $holdings->each(function ($holding) use (&$total_performance) { $period = CarbonPeriod::create( $holding->first_transaction_date, @@ -160,34 +161,25 @@ class Portfolio extends Model : now() ); - $holding->setRelation('dividends', $dividends->where('symbol', $holding->symbol)); - $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()); + $currency_rates = CurrencyRate::timeSeriesRates($holding->market_data->currency, $holding->first_transaction_date, now()); - $dividends_earned = 0; $holding_performance = []; foreach ($period as $date) { - $date = $date->format('Y-m-d'); + $date = $date->toDateString(); $close = $this->getMostRecentCloseData($all_history, $date); $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()) { + $holding_performance[$date] = [ 'date' => $date, 'portfolio_id' => $this->id, - 'total_market_value' => $total_market_value, - '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, + 'total_market_value' => $total_market_value * (1 / Arr::get($currency_rates, $date, 1)), ]; } } @@ -200,10 +192,6 @@ class Portfolio extends Model } else { $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,10 +209,6 @@ class Portfolio extends Model ['date', 'portfolio_id'], [ 'total_market_value', - 'total_cost_basis', - 'total_gain', - 'realized_gains', - 'total_dividends_earned', ] ); }); @@ -239,7 +223,7 @@ class Portfolio extends Model $i++; - $date = Carbon::parse($date)->subDay()->format('Y-m-d'); + $date = Carbon::parse($date)->subDay()->toDateString(); return $this->getMostRecentCloseData($history, $date, $i); } diff --git a/app/Models/Split.php b/app/Models/Split.php index 954075a..00df574 100644 --- a/app/Models/Split.php +++ b/app/Models/Split.php @@ -5,15 +5,18 @@ declare(strict_types=1); namespace App\Models; use App\Interfaces\MarketData\MarketDataInterface; +use App\Traits\HasMarketData; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; class Split extends Model { use HasFactory; + use HasMarketData; use HasUuids; protected $fillable = [ @@ -29,12 +32,12 @@ class Split extends Model 'last_date' => 'datetime', ]; - public function holdings() + public function holdings(): HasMany { return $this->hasMany(Holding::class, 'symbol', 'symbol'); } - public function transactions() + public function transactions(): HasMany { return $this->hasMany(Transaction::class, 'symbol', 'symbol'); } @@ -114,7 +117,7 @@ class Split extends Model 'symbol' => $split->symbol, '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) - SUM(CASE WHEN transaction_type = 'SELL' THEN quantity ELSE 0 END) AS qty_owned") ->value('qty_owned'); diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index b734633..4a7ce35 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -4,18 +4,24 @@ declare(strict_types=1); namespace App\Models; +use App\Actions\ConvertToMarketDataCurrency; +use App\Actions\CopyToBaseCurrency; +use App\Actions\EnsureCostBasisAddedToSale; +use App\Casts\BaseCurrency; +use App\Traits\HasMarketData; use Illuminate\Contracts\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Pipeline; class Transaction extends Model { use HasFactory; + use HasMarketData; use HasUuids; protected $fillable = [ @@ -23,6 +29,7 @@ class Transaction extends Model 'date', 'portfolio_id', 'transaction_type', + 'currency', 'quantity', 'cost_basis', 'sale_price', @@ -36,6 +43,11 @@ class Transaction extends Model 'date' => 'datetime', 'split' => '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() @@ -44,18 +56,19 @@ class Transaction extends Model static::saving(function ($transaction) { - if ($transaction->transaction_type == 'SELL') { - - $transaction->ensureCostBasisIsAddedToSale(); - } + $transaction = Pipeline::send($transaction) + ->through([ + ConvertToMarketDataCurrency::class, + EnsureCostBasisAddedToSale::class, + CopyToBaseCurrency::class, + ]) + ->then(fn (Transaction $transaction) => $transaction); }); static::saved(function ($transaction) { $transaction->syncToHolding(); - $transaction->refreshMarketData(); - cache()->forget('portfolio-metrics-'.$transaction->portfolio_id); }); @@ -77,16 +90,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 * @@ -141,28 +144,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 */ @@ -187,8 +168,8 @@ class Transaction extends Model 'portfolio_id' => $this->portfolio_id, 'symbol' => $this->symbol, 'quantity' => $this->quantity, - 'average_cost_basis' => $this->cost_basis, - 'total_cost_basis' => $this->quantity * $this->cost_basis, + 'average_cost_basis' => $this->cost_basis_base, + 'total_cost_basis' => $this->quantity * $this->cost_basis_base, 'splits_synced_at' => now(), ])->syncTransactionsAndDividends(); } diff --git a/app/Models/User.php b/app/Models/User.php index 167204f..03d76a4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Arr; use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Jetstream\HasProfilePhoto; use Laravel\Sanctum\HasApiTokens; @@ -31,6 +32,7 @@ class User extends Authenticatable implements MustVerifyEmail 'name', 'email', 'password', + 'options', ]; protected $hidden = [ @@ -50,6 +52,8 @@ class User extends Authenticatable implements MustVerifyEmail return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'admin' => 'boolean', + 'options' => 'json', ]; } @@ -82,4 +86,16 @@ class User extends Authenticatable implements MustVerifyEmail ELSE COALESCE(market_data.market_value - transactions.cost_basis, 0) 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'); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 68135d3..45663c8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,7 +5,10 @@ declare(strict_types=1); namespace App\Providers; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Arr; +use Illuminate\Support\Number; use Illuminate\Support\ServiceProvider; +use NumberFormatter; class AppServiceProvider extends ServiceProvider { @@ -26,5 +29,28 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { 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); + }); } } diff --git a/app/Providers/VoltServiceProvider.php b/app/Providers/VoltServiceProvider.php index 049ece5..dd92d72 100644 --- a/app/Providers/VoltServiceProvider.php +++ b/app/Providers/VoltServiceProvider.php @@ -23,8 +23,13 @@ class VoltServiceProvider extends ServiceProvider public function boot(): void { Volt::mount([ - config('livewire.view_path', resource_path('views/livewire')), - resource_path('views/pages'), + // config('livewire.view_path', resource_path('views/livewire')), + resource_path('views/components'), + resource_path('views/profile'), + resource_path('views/holding'), + resource_path('views/transaction'), + resource_path('views/portfolio'), + resource_path('views/auth'), ]); } } diff --git a/app/Rules/QuantityValidationRule.php b/app/Rules/QuantityValidationRule.php index 3539cd4..b0be87f 100644 --- a/app/Rules/QuantityValidationRule.php +++ b/app/Rules/QuantityValidationRule.php @@ -5,8 +5,9 @@ declare(strict_types=1); namespace App\Rules; use App\Models\Portfolio; -use Illuminate\Contracts\Validation\ValidationRule; +use App\Models\Transaction; use Illuminate\Support\Carbon; +use Illuminate\Contracts\Validation\ValidationRule; class QuantityValidationRule implements ValidationRule { @@ -20,12 +21,7 @@ class QuantityValidationRule implements ValidationRule protected ?string $symbol, protected ?string $transactionType, protected string|Carbon|null $date - ) { - $this->portfolio = $portfolio; - $this->symbol = $symbol; - $this->transactionType = $transactionType; - $this->date = $date; - } + ) { } /** * Validate the attribute. @@ -39,21 +35,21 @@ class QuantityValidationRule implements ValidationRule if ($this->transactionType == 'SELL') { - $purchase_qty = $this->portfolio->transactions() + $purchase_qty = (float) $this->portfolio->transactions() ->symbol($this->symbol) ->buy() - ->beforeDate($this->date) + ->whereDate('date', '<', $this->date) ->sum('quantity'); - $sales_qty = $this->portfolio->transactions() + $sales_qty = (float) $this->portfolio->transactions() ->symbol($this->symbol) ->sell() - ->beforeDate($this->date) + ->whereDate('date', '<', $this->date) ->sum('quantity'); $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.')); } } diff --git a/app/Support/Helpers.php b/app/Support/Helpers.php index b159202..abbff9f 100644 --- a/app/Support/Helpers.php +++ b/app/Support/Helpers.php @@ -2,16 +2,15 @@ declare(strict_types=1); -// if (!function_exists('formatMoney')) { -// /** -// * Returns a formatted string for currency -// * -// * @param int|float $amount -// * -// * */ -// function formatMoney(int|float $amount) { -// $formatter = new NumberFormatter('en_US', NumberFormatter::CURRENCY); +use App\Models\Currency; -// return $formatter->formatCurrency((float) $amount, 'USD'); -// } -// } +if (! function_exists('currency')) { + + // /** + // * Returns an instance of the currency model + // * */ + // function currency(): Currency + // { + // return new Currency; + // } +} diff --git a/app/Traits/HasMarketData.php b/app/Traits/HasMarketData.php new file mode 100644 index 0000000..da0559e --- /dev/null +++ b/app/Traits/HasMarketData.php @@ -0,0 +1,43 @@ +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') + ); + } +} diff --git a/app/View/Components/AppLayout.php b/app/View/Components/AppLayout.php index 7dc76d2..6249c73 100644 --- a/app/View/Components/AppLayout.php +++ b/app/View/Components/AppLayout.php @@ -21,11 +21,11 @@ class AppLayout extends Component - + - - + + @livewire('partials.side-bar') @@ -34,7 +34,7 @@ class AppLayout extends Component {{ $slot }} - + @if(session('toast'))