diff --git a/app/Console/Commands/RefreshMarketData.php b/app/Console/Commands/RefreshMarketData.php index 5518581..0c9f9db 100644 --- a/app/Console/Commands/RefreshMarketData.php +++ b/app/Console/Commands/RefreshMarketData.php @@ -7,7 +7,6 @@ namespace App\Console\Commands; use App\Models\Holding; use App\Models\MarketData; use Illuminate\Console\Command; -use Illuminate\Support\Facades\Log; class RefreshMarketData extends Command { @@ -61,7 +60,7 @@ class RefreshMarketData extends Command try { MarketData::getMarketData($holding->symbol, $force); } catch (\Throwable $e) { - Log::error('Could not refresh '.$holding->symbol.' ('.$e->getMessage().')'); + $this->line('Could not refresh '.$holding->symbol.' ('.$e->getMessage().')'); } } } diff --git a/app/Exports/Sheets/ConfigSheet.php b/app/Exports/Sheets/ConfigSheet.php index f01ac32..6ad87c4 100644 --- a/app/Exports/Sheets/ConfigSheet.php +++ b/app/Exports/Sheets/ConfigSheet.php @@ -47,12 +47,13 @@ class ConfigSheet implements FromCollection, WithHeadings, WithTitle ]); // reinvested holdings - Holding::myHoldings()->where('reinvest_dividends', true)->get()->each(function ($holding) use (&$configs) { + $reinvested_holdings = Holding::myHoldings()->where('reinvest_dividends', true)->get(['portfolio_id', 'symbol']); + if ($reinvested_holdings->isNotEmpty()) { $configs->push([ 'key' => 'reinvested_dividends', - 'value' => $holding->id, + 'value' => $reinvested_holdings->toJson(), ]); - }); + } return $configs; } diff --git a/app/Imports/Sheets/ConfigSheet.php b/app/Imports/Sheets/ConfigSheet.php index 4c07b38..bb6d8cc 100644 --- a/app/Imports/Sheets/ConfigSheet.php +++ b/app/Imports/Sheets/ConfigSheet.php @@ -54,11 +54,17 @@ class ConfigSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadi $this->backupImport->user->save(); break; - case 'reinvest_dividends': - - Holding::myHoldings()->where('id', $config['value'])->update([ - 'reinvest_dividends' => true, - ]); + 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: diff --git a/app/Imports/Sheets/TransactionsSheet.php b/app/Imports/Sheets/TransactionsSheet.php index 700a739..30b7029 100644 --- a/app/Imports/Sheets/TransactionsSheet.php +++ b/app/Imports/Sheets/TransactionsSheet.php @@ -48,7 +48,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit // 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')); + CurrencyRate::timeSeriesRates('', $transactions->min('date'), $transactions->max('date')); } $totalBatches = count($transactions) / $this->batchSize(); diff --git a/app/Models/CurrencyRate.php b/app/Models/CurrencyRate.php index a777aae..a324d16 100644 --- a/app/Models/CurrencyRate.php +++ b/app/Models/CurrencyRate.php @@ -111,7 +111,7 @@ class CurrencyRate extends Model * * @return array */ - public static function timeSeriesRates(string $currency, mixed $start = null, mixed $end = null): array + public static function timeSeriesRates(string|array $currency, mixed $start = null, mixed $end = null): array { if (empty($start)) { return []; @@ -134,22 +134,27 @@ class CurrencyRate extends Model [$currency, $adjustment] = self::getCurrencyAliasAdjustments($currency); - $currencies = Currency::all()->pluck('currency')->toArray(); + if (! empty($currency)) { - // call api in chunks - $rates = []; - foreach (collect($period)->chunk(500) as $chunk) { + $currencies = Arr::wrap($currency); - $chunkRates = Frankfurter::setSymbols($currencies)->timeSeries($chunk->min(), $chunk->max()); + } else { - $rates = array_merge($rates, Arr::get($chunkRates, 'rates', [])); + $currencies = Currency::all()->pluck('currency')->toArray(); } + // get rates + $rates = Frankfurter::setSymbols($currencies)->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, $rates); + $lookupDate = self::getNearestPastDate($date, $datesOnly, $rates); if (is_null($lookupDate)) { continue; @@ -181,33 +186,39 @@ class CurrencyRate extends Model ->toArray(); } - private static function getNearestPastDate(CarbonInterface $date, array $rates): ?CarbonInterface + private static function getNearestPastDate(CarbonInterface $date, array $datesOnly, array $rates): ?CarbonInterface { - $datesWithRates = array_keys($rates); - sort($datesWithRates); + + // 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[$date->toDateString()])) { + 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 ($date->lessThan($first_date = Carbon::parse($datesWithRates[0]))) { + if ($mutableDate->lessThan($firstDate)) { - $date = $first_date; - break; + return $firstDate; } // try the day before then - $date = Carbon::parse($date)->subDay(); - - // prevent runaway infinite loops - if ($date->lessThan($date->copy()->subWeek())) { - - $date = null; - break; - } + $mutableDate = $mutableDate->subDay(); } - return $date; + return $mutableDate; } public static function refreshCurrencyData($force = false): void @@ -248,9 +259,7 @@ class CurrencyRate extends Model public static function chunkInsert(array $updates): void { - $chunks = array_chunk($updates, 500); - - foreach ($chunks as $chunk) { + foreach (array_chunk($updates, 500) as $chunk) { QueuedCurrencyRateInsertJob::dispatch($chunk); } diff --git a/app/Models/Dividend.php b/app/Models/Dividend.php index f340566..60bf94e 100644 --- a/app/Models/Dividend.php +++ b/app/Models/Dividend.php @@ -105,20 +105,25 @@ class Dividend extends Model $market_data = MarketData::getMarketData($symbol); - // get historic conversion rates - $rate_to_base = CurrencyRate::timeSeriesRates($market_data->currency, $start_date, $end_date); + $dividend_data + ->chunk(10) + ->each(function ($chunk) use ($market_data) { - // 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); + // get historic conversion rates + $rate_to_base = CurrencyRate::timeSeriesRates($market_data->currency, $chunk->min('date'), $chunk->max('date')); - $dividend['dividend_amount_base'] = $dividend['dividend_amount'] * $rate_to_base_date; + // create mass insert + foreach ($chunk as $index => $dividend) { + $rate_to_base_date = 1 / Arr::get($rate_to_base, Carbon::parse(Arr::get($dividend, 'date'))->toDateString(), 1); - $dividend_data[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]]; - } + $dividend['dividend_amount_base'] = $dividend['dividend_amount'] * $rate_to_base_date; - // insert records - (new self)->insertOrIgnore($dividend_data->toArray()); + $chunk[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]]; + } + + // insert records + (new self)->insertOrIgnore($chunk->toArray()); + }); // sync to holdings self::syncHoldings($symbol); @@ -186,6 +191,7 @@ class Dividend extends Model 'date' => $dividend['date'], 'portfolio_id' => $holding->portfolio_id, 'symbol' => $holding->symbol, + 'currency' => $holding->market_data->currency, 'transaction_type' => 'BUY', 'reinvested_dividend' => true, 'cost_basis' => 0, diff --git a/app/Models/Portfolio.php b/app/Models/Portfolio.php index 4f42002..6a6dc9d 100644 --- a/app/Models/Portfolio.php +++ b/app/Models/Portfolio.php @@ -152,7 +152,12 @@ class Portfolio extends Model $total_performance = []; - $holdings->each(function ($holding) use (&$total_performance) { + // get unique currencies for holdings + 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( $holding->first_transaction_date, @@ -163,7 +168,6 @@ class Portfolio extends Model $daily_performance = $holding->dailyPerformance($holding->first_transaction_date, now()); $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()); $holding_performance = []; @@ -179,7 +183,7 @@ class Portfolio extends Model $holding_performance[$date] = [ 'date' => $date, 'portfolio_id' => $this->id, - 'total_market_value' => $total_market_value * (1 / Arr::get($currency_rates, $date, 1)), + 'total_market_value' => $total_market_value * (1 / Arr::get($currency_rates[$holding->market_data->currency], $date, 1)), ]; } } diff --git a/tests/0000_00_00_import_configs_test.xlsx b/tests/0000_00_00_import_configs_test.xlsx index 3253936..49da7ce 100644 Binary files a/tests/0000_00_00_import_configs_test.xlsx and b/tests/0000_00_00_import_configs_test.xlsx differ diff --git a/tests/ImportExportTest.php b/tests/ImportExportTest.php index 9c87469..e2ee125 100644 --- a/tests/ImportExportTest.php +++ b/tests/ImportExportTest.php @@ -84,7 +84,6 @@ class ImportExportTest extends TestCase ]); $holding = Holding::create([ - 'id' => '9cf8a662-7347-49fb-b9de-0cc1430a8d1f', 'portfolio_id' => '9e792bb8-94e7-4ed3-b8cc-43b50d34c337', 'symbol' => 'ACME', 'quantity' => 0, diff --git a/tests/MultiCurrencyTest.php b/tests/MultiCurrencyTest.php index 99b9d98..bbae3e7 100644 --- a/tests/MultiCurrencyTest.php +++ b/tests/MultiCurrencyTest.php @@ -133,11 +133,8 @@ class MultiCurrencyTest extends TestCase $portfolio = Portfolio::factory()->create(); $transaction = Transaction::factory()->buy()->lastYear()->portfolio($portfolio->id)->symbol('ACME')->create(); - $expected_num_calls = count(collect(CarbonPeriod::create($transaction->date, now()))->chunk(500)); - Frankfurter::expects('setSymbols') - ->andReturnSelf() - ->times($expected_num_calls); + ->andReturnSelf(); Frankfurter::expects('timeSeries') ->andReturn(['rates' => [ now()->subDays(3)->toDateString() => [ @@ -152,8 +149,7 @@ class MultiCurrencyTest extends TestCase now()->toDateString() => [ 'ZZZ' => .01, ], - ]]) - ->times($expected_num_calls); + ]]); CurrencyRate::timeSeriesRates( '', // use fake currency to force @@ -252,11 +248,9 @@ class MultiCurrencyTest extends TestCase }); Frankfurter::expects('setSymbols') - ->andReturnSelf() - ->times(4); + ->andReturnSelf(); Frankfurter::expects('timeSeries') - ->andReturn(['rates' => $results]) - ->times(4); + ->andReturn(['rates' => $results]); CurrencyRate::timeSeriesRates('ZZZ', $start, $end); }