adds feature to backfill daily change
This commit is contained in:
+47
-3
@@ -30,6 +30,7 @@ class Holding extends Model
|
|||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'splits_synced_at' => 'datetime',
|
'splits_synced_at' => 'datetime',
|
||||||
|
'first_transaction_date' => 'datetime'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
@@ -133,7 +134,7 @@ class Holding extends Model
|
|||||||
|
|
||||||
public function scopePortfolio($query, $portfolio)
|
public function scopePortfolio($query, $portfolio)
|
||||||
{
|
{
|
||||||
return $query->where('portfolio_id', $portfolio);
|
return $query->where('holdings.portfolio_id', $portfolio);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeSymbol($query, $symbol)
|
public function scopeSymbol($query, $symbol)
|
||||||
@@ -220,6 +221,49 @@ class Holding extends Model
|
|||||||
|
|
||||||
$this->save();
|
$this->save();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use App\Interfaces\MarketData\MarketDataInterface;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
||||||
@@ -74,7 +77,8 @@ class Portfolio extends Model
|
|||||||
return $this->users()->firstWhere('owner', 1)?->id;
|
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
|
// make sure we don't remove owner access
|
||||||
$user_id[$model->owner_id ?? auth()->user()->id] = ['owner' => true];
|
$user_id[$model->owner_id ?? auth()->user()->id] = ['owner' => true];
|
||||||
|
|
||||||
@@ -86,4 +90,68 @@ class Portfolio extends Model
|
|||||||
// save
|
// save
|
||||||
$model->users()->sync($user_id);
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,14 @@ class TransactionFactory extends Factory
|
|||||||
public function yearsAgo(): static
|
public function yearsAgo(): static
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
'date' => $this->faker->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'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Holding;
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use App\Models\DailyChange;
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
class SyncDailyChangeTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public function test_can_sync_daily_change_history(): void
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user