diff --git a/app/Models/Holding.php b/app/Models/Holding.php index 5e94787..0fba5bc 100644 --- a/app/Models/Holding.php +++ b/app/Models/Holding.php @@ -30,6 +30,7 @@ class Holding extends Model protected $casts = [ 'splits_synced_at' => 'datetime', + 'first_transaction_date' => 'datetime' ]; protected $attributes = [ @@ -133,7 +134,7 @@ class Holding extends Model public function scopePortfolio($query, $portfolio) { - return $query->where('portfolio_id', $portfolio); + return $query->where('holdings.portfolio_id', $portfolio); } public function scopeSymbol($query, $symbol) @@ -220,6 +221,49 @@ class Holding extends Model $this->save(); } -} - \ No newline at end of file + public function dailyPerformance( + \Illuminate\Support\Carbon $start_date = null, + \Illuminate\Support\Carbon $end_date = null, + ) { + if ($start_date == null) $start_date = now(); + if ($end_date == null) $end_date = now(); + + $date_interval = "DATE_ADD(date, INTERVAL 1 DAY)"; + + if (config('database.default') === 'sqlite') { + $date_interval = "date(date, '+1 day')"; + } + + return DB::table(DB::raw("( + WITH RECURSIVE date_series AS ( + SELECT '{$start_date->format('Y-m-d')}' AS date + UNION ALL + SELECT $date_interval + FROM date_series + WHERE date < '{$end_date->format('Y-m-d')}' + ) + SELECT date_series.date + FROM date_series + ) as date_series") + ) + ->select([ + 'date_series.date', + DB::raw(" + COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END), 0) - + COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END), 0) AS `owned` + "), + DB::raw("COALESCE(SUM(CASE WHEN transaction_type = 'BUY' THEN (quantity * cost_basis) ELSE 0 END), 0) 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`") + ]) + ->leftJoin('transactions', function ($join) { + $join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date') + ->where('transactions.symbol', '=', $this->symbol) + ->where('transactions.portfolio_id', '=', $this->portfolio_id); + }) + ->groupBy('date_series.date') + ->orderBy('date_series.date') + ->get() + ->keyBy('date'); + } +} \ No newline at end of file diff --git a/app/Models/Portfolio.php b/app/Models/Portfolio.php index 4ebcdee..fae8ed8 100644 --- a/app/Models/Portfolio.php +++ b/app/Models/Portfolio.php @@ -2,7 +2,10 @@ namespace App\Models; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\DB; use Illuminate\Database\Eloquent\Model; +use App\Interfaces\MarketData\MarketDataInterface; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -74,7 +77,8 @@ class Portfolio extends Model return $this->users()->firstWhere('owner', 1)?->id; } - public static function syncUsers(self $model) { + public static function syncUsers(self $model) + { // make sure we don't remove owner access $user_id[$model->owner_id ?? auth()->user()->id] = ['owner' => true]; @@ -86,4 +90,68 @@ class Portfolio extends Model // save $model->users()->sync($user_id); } + + public function syncDailyChanges(): void + { + $holdings = $this->holdings() + ->join('transactions', function($join) { + $join->on('transactions.symbol', '=', 'holdings.symbol') + ->where('transactions.portfolio_id', '=', $this->id); + }) + ->select('holdings.*', DB::raw('min(transactions.date) as first_transaction_date')) // get first transaction date + ->groupBy('holdings.symbol') + ->get(); + + $total_performance = []; + + $holdings->each(function($holding) use (&$total_performance) { + + $all_history = app(MarketDataInterface::class)->history('ACME', $holding->first_transaction_date, now()); + $quantity = $holding->dailyPerformance($holding->first_transaction_date, now()); + + $dividends = $holding->dividends->keyBy(function ($dividend, $key) { + return $dividend['date']->format('Y-m-d'); + })->toArray(); + + $dividends_earned = 0; + $daily_performance = []; + + $all_history->sortBy('date')->each(function ($history, $date) use ($quantity, $dividends, &$daily_performance, &$dividends_earned) { + + $close = Arr::get($history, 'close', 0); + $total_market_value = $quantity->get($date)->owned * $close; + $dividend = Arr::get($dividends, $date, []); + $dividends_earned += $quantity->get($date)->owned * Arr::get($dividend, 'dividend_amount', 0); + + $daily_performance[$date] = [ + 'date' => $date, + 'portfolio_id' => $this->id, + 'total_market_value' => $total_market_value, + 'total_cost_basis' => $quantity->get($date)->cost_basis, + 'total_gain' => $total_market_value - $quantity->get($date)->cost_basis, + 'realized_gains' => $quantity->get($date)->realized_gains, + 'total_dividends_earned' => $dividends_earned + ]; + }); + + foreach ($daily_performance as $date => $performance) { + if (!isset($total_performance[$date])) { + + $total_performance[$date] = $performance; + + } 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']; + } + } + }); + + $this->daily_change()->delete(); + + DailyChange::insert($total_performance); + } } diff --git a/database/factories/TransactionFactory.php b/database/factories/TransactionFactory.php index da972c0..7a5ea48 100644 --- a/database/factories/TransactionFactory.php +++ b/database/factories/TransactionFactory.php @@ -39,7 +39,14 @@ class TransactionFactory extends Factory public function yearsAgo(): static { return $this->state(fn (array $attributes) => [ - 'date' => $this->faker->date('Y-m-d', '-3 years'), + 'date' => $this->faker->dateTimeBetween('-5 years', '-3 years')->format('Y-m-d'), + ]); + } + + public function lastMonth(): static + { + return $this->state(fn (array $attributes) => [ + 'date' => now()->subMonth()->format('Y-m-d'), ]); } diff --git a/tests/SyncDailyChangeTest.php b/tests/SyncDailyChangeTest.php new file mode 100644 index 0000000..3379fc7 --- /dev/null +++ b/tests/SyncDailyChangeTest.php @@ -0,0 +1,119 @@ +actingAs($user = User::factory()->create()); + + $portfolio = Portfolio::factory()->create(); + + Transaction::factory(5)->buy()->yearsAgo()->portfolio($portfolio->id)->symbol('ACME')->create(); + Transaction::factory()->sell()->lastMonth()->portfolio($portfolio->id)->symbol('ACME')->create(); + Transaction::factory(5)->buy()->yearsAgo()->portfolio($portfolio->id)->symbol('AAPL')->create(); + Transaction::factory(5)->buy()->yearsAgo()->portfolio($portfolio->id)->symbol('GOOG')->create(); + Transaction::factory(5)->buy()->yearsAgo()->portfolio($portfolio->id)->symbol('FOO')->create(); + Transaction::factory(5)->buy()->yearsAgo()->portfolio($portfolio->id)->symbol('BAR')->create(); + + $portfolio->syncDailyChanges(); + + $count_of_daily_changes = $portfolio->daily_change()->count('date'); + $days_between_now_and_first_trans = (int) now()->diffInDays($portfolio->transactions()->min('date'), true) + 1; + + $this->assertEquals($count_of_daily_changes, $days_between_now_and_first_trans); + } + + /** + */ + public function test_sales_are_captured_as_realized_gains(): void + { + $this->actingAs($user = User::factory()->create()); + + $portfolio = Portfolio::factory()->create(); + + Transaction::factory(5)->buy()->yearsAgo()->portfolio($portfolio->id)->symbol('ACME')->create(); + $sale_transaction = Transaction::factory()->sell()->lastMonth()->portfolio($portfolio->id)->symbol('ACME')->create(); + Transaction::factory(5)->buy()->yearsAgo()->portfolio($portfolio->id)->symbol('AAPL')->create(); + Transaction::factory(5)->buy()->yearsAgo()->portfolio($portfolio->id)->symbol('GOOG')->create(); + Transaction::factory(5)->buy()->yearsAgo()->portfolio($portfolio->id)->symbol('FOO')->create(); + Transaction::factory(5)->buy()->yearsAgo()->portfolio($portfolio->id)->symbol('BAR')->create(); + + $portfolio->syncDailyChanges(); + + $daily_change = DailyChange::query() + ->portfolio($portfolio->id) + ->whereDate('date', $sale_transaction->date) + ->first(); + + $realized_gain = ($sale_transaction->sale_price - $sale_transaction->cost_basis) * $sale_transaction->quantity; + + $this->assertEqualsWithDelta($daily_change->realized_gains, $realized_gain, 0.01); + + $day_before = DailyChange::query() + ->portfolio($portfolio->id) + ->whereDate('date', $sale_transaction->date->subDays(1)) + ->first(); + + $this->assertEquals($day_before->realized_gains, 0); + + $day_after = DailyChange::query() + ->portfolio($portfolio->id) + ->whereDate('date', $sale_transaction->date->addDays(1)) + ->first(); + + $this->assertEqualsWithDelta($day_after->realized_gains, $realized_gain, 0.01); + } + + public function test_dividends_captured_in_daily_change_sync(): void + { + $this->actingAs($user = User::factory()->create()); + + $portfolio = Portfolio::factory()->create(); + + Transaction::factory(5)->buy()->yearsAgo()->portfolio($portfolio->id)->symbol('ACME')->create(); + + Artisan::call('refresh:dividend-data'); + + $portfolio->syncDailyChanges(); + + $holding = Holding::query()->portfolio($portfolio->id)->symbol('ACME')->first(); + $dividends = $holding->dividends()->get()->sortBy('date'); + + $first_dividend_change = DailyChange::query() + ->portfolio($portfolio->id) + ->whereDate('date', $dividends->first()->date) + ->first(); + + $owned = $dividends->first()->purchased - $dividends->first()->sold; + + $this->assertEqualsWithDelta($dividends->first()->dividend_amount * $owned, $first_dividend_change->total_dividends_earned, 0.01); + + $last_dividend_change = DailyChange::query() + ->portfolio($portfolio->id) + ->whereDate('date', $dividends->last()->date) + ->first(); + + $total_dividends = $dividends->reduce(function (?float $carry, $dividend) { + return $carry + ($dividend['dividend_amount'] * ($dividend['purchased'] - $dividend['sold'])); + }); + + $owned = $dividends->last()->purchased - $dividends->last()->sold; + + $this->assertEqualsWithDelta($total_dividends, $last_dividend_change->total_dividends_earned, 0.01); + } +}