diff --git a/app/Interfaces/MarketData/Types/Quote.php b/app/Interfaces/MarketData/Types/Quote.php index ae49470..dbf92c2 100644 --- a/app/Interfaces/MarketData/Types/Quote.php +++ b/app/Interfaces/MarketData/Types/Quote.php @@ -9,9 +9,11 @@ use Illuminate\Support\Carbon; class Quote extends MarketDataType { - public function setName(string $name): self + public function setName($name): self { - $this->items['name'] = (string) $name; + if (! empty($name)) { + $this->items['name'] = (string) $name; + } return $this; } diff --git a/app/Models/Dividend.php b/app/Models/Dividend.php index d8fa182..24ecd2f 100644 --- a/app/Models/Dividend.php +++ b/app/Models/Dividend.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; class Dividend extends Model @@ -109,22 +110,28 @@ class Dividend extends Model public static function syncHoldings(string $symbol): void { // group by holdings - $dividends = self::select(['holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount']) - ->selectRaw(' - (COALESCE(CASE WHEN transactions.transaction_type = "BUY" - AND date(transactions.date) <= date(dividends.date) - THEN transactions.quantity ELSE 0 END, 0) - - COALESCE(CASE WHEN transactions.transaction_type = "SELL" - AND date(transactions.date) <= date(dividends.date) - THEN transactions.quantity ELSE 0 END, 0)) - * dividends.dividend_amount - AS total_received - ') - ->join('transactions', 'transactions.symbol', '=', 'dividends.symbol') + $subQuery = self::select([ + 'holdings.portfolio_id', + 'dividends.date', + 'dividends.symbol', + 'dividends.dividend_amount', + ])->selectRaw(" + (COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY' + AND date(transactions.date) <= date(dividends.date) + THEN transactions.quantity ELSE 0 END), 0) + - COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL' + AND date(transactions.date) <= date(dividends.date) + THEN transactions.quantity ELSE 0 END), 0)) + * dividends.dividend_amount + AS total_received + ")->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', 'total_received') - ->havingRaw('total_received > 0') + ->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount'); + + $dividends = DB::table(DB::raw("({$subQuery->toSql()}) as sub")) + ->mergeBindings($subQuery->getQuery()) + ->where('total_received', '>', 0) ->get(); // iterate through holdings and update diff --git a/app/Models/Holding.php b/app/Models/Holding.php index 831f877..fd3098f 100644 --- a/app/Models/Holding.php +++ b/app/Models/Holding.php @@ -100,7 +100,25 @@ class Holding extends Model ->whereRaw("transactions.portfolio_id = '$this->portfolio_id'") ->whereRaw("transactions.symbol = '$this->symbol'"); }) - ->having('total_received', '>', 0); + ->havingRaw("SUM( + (CASE + WHEN transaction_type = 'BUY' + AND transactions.symbol = dividends.symbol + AND transactions.portfolio_id = '$this->portfolio_id' + AND transactions.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 transactions.date <= dividends.date + THEN transactions.quantity + ELSE 0 + END) + ) * dividends.dividend_amount > 0"); } /** @@ -148,7 +166,7 @@ class Holding extends Model { return $query->selectRaw('COALESCE(market_data.market_value * holdings.quantity, 0) AS total_market_value') ->selectRaw('COALESCE((market_data.market_value - holdings.average_cost_basis) * holdings.quantity, 0) AS market_gain_dollars') - ->selectRaw('COALESCE(((market_data.market_value - holdings.average_cost_basis) / holdings.average_cost_basis) * 100, 0) AS market_gain_percent'); + ->selectRaw('COALESCE(((market_data.market_value - holdings.average_cost_basis) / NULLIF(holdings.average_cost_basis, 0)) * 100, 0) AS market_gain_percent'); } public function scopePortfolio($query, $portfolio) @@ -192,10 +210,10 @@ class Holding extends Model $query = Transaction::where([ 'portfolio_id' => $this->portfolio_id, '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 = "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`') + ])->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 = '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); @@ -203,9 +221,8 @@ class Holding extends Model $average_cost_basis = ( $query->qty_purchases > 0 && $total_quantity > 0 - ) - ? $query->total_cost_basis / $query->qty_purchases - : 0; + ) ? $query->total_cost_basis / $query->qty_purchases + : 0; // update holding $this->fill([ @@ -247,12 +264,44 @@ class Holding extends Model $end_date = now(); } + // MySQL default interval $date_interval = 'DATE_ADD(date, INTERVAL 1 DAY)'; + $castNumberType = 'decimal'; + // Use SQLite interval grammar if (config('database.default') === 'sqlite') { $date_interval = "date(date, '+1 day')"; - } else { + } + + // 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 + 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")); + + // PGSql time series query + if (config('database.default') === 'pgsql') { + + $timeSeriesQuery = DB::table(DB::raw(" + generate_series( + date '{$start_date->format('Y-m-d')}', + date '{$end_date->format('Y-m-d')}', + interval '1 day' + ) as date_series")); + + $castNumberType = 'numeric'; + } + + // Set MySQL-like query CTE max iterations + if (config('database.default') === 'mysql') { // MySQL default $max_recursion_var_name = 'cte_max_recursion_depth'; @@ -269,39 +318,29 @@ class Holding extends Model DB::statement("SET $max_recursion_var_name=1000000;"); } - 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") - ) + // Extracted query for counting QTY owned + $quantityQuery = "ROUND(CAST(COALESCE( + SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END) + - SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END), + 0 + ) AS {$castNumberType}), 3)"; + + return $timeSeriesQuery ->select([ 'date_series.date', DB::raw(" - ROUND( - 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), 3) AS `owned` - "), + {$quantityQuery} AS owned + "), DB::raw(" - COALESCE(CASE - WHEN ( - ROUND( - 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), 3) - ) = 0 THEN 0 - ELSE SUM(CASE - WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis - ELSE 0 - END) - 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`"), + CASE + WHEN ({$quantityQuery}) = 0 THEN 0 + ELSE SUM(CASE + WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis + 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"), ]) ->leftJoin('transactions', function ($join) { $join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date') diff --git a/app/Models/Portfolio.php b/app/Models/Portfolio.php index eac4e61..ed5eff5 100644 --- a/app/Models/Portfolio.php +++ b/app/Models/Portfolio.php @@ -211,6 +211,11 @@ class Portfolio extends Model if (! empty($total_performance)) { DB::transaction(function () use ($total_performance) { + // delete old history + $firstDate = array_keys($total_performance)[0]; + $this->daily_change()->where('date', '<', $firstDate)->delete(); + + // upsert new history $this->daily_change()->upsert( $total_performance, ['date', 'portfolio_id'], diff --git a/app/Models/Split.php b/app/Models/Split.php index 106b7d2..4a948f9 100644 --- a/app/Models/Split.php +++ b/app/Models/Split.php @@ -101,7 +101,7 @@ class Split extends Model ->where([ 'splits.symbol' => $symbol, ]) - ->whereDate('splits.date', '>', DB::raw('IFNULL(holdings.splits_synced_at, "0000-00-00")')) + ->whereDate('splits.date', '>', DB::raw("COALESCE(holdings.splits_synced_at, '1901-01-01')")) ->where('holdings.quantity', '>', 0) ->join('holdings', 'splits.symbol', 'holdings.symbol') ->orderBy('splits.date', 'ASC') @@ -115,8 +115,8 @@ class Split extends Model 'portfolio_id' => $split->portfolio_id, ]) ->whereDate('transactions.date', '<', $split->date->format('Y-m-d')) - ->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') + ->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'); if ($qty_owned > 0) { diff --git a/app/Support/Spotlight.php b/app/Support/Spotlight.php index f10912f..aef62a6 100644 --- a/app/Support/Spotlight.php +++ b/app/Support/Spotlight.php @@ -19,7 +19,7 @@ class Spotlight } $portfolios = $request->user()->portfolios() - ->where('title', 'LIKE', '%'.$request->input('search').'%') + ->whereFullText('title', $request->input('search')) ->limit(5) ->get(); $portfolios->each(function ($portfolio) use ($results) { @@ -35,8 +35,8 @@ class Spotlight $holdings = $request->user()->holdings() ->where('holdings.quantity', '>', 0) ->where(function ($query) use ($request) { - return $query->where('holdings.symbol', 'LIKE', '%'.$request->input('search').'%') - ->orWhere('market_data.name', 'LIKE', '%'.$request->input('search').'%'); + return $query->whereFullText('holdings.symbol', $request->input('search')) + ->orWhereFullText('market_data.name', $request->input('search')); }) ->limit(5) ->get(); diff --git a/database/migrations/2021_01_30_102537_create_portfolios_table.php b/database/migrations/2021_01_30_102537_create_portfolios_table.php index ffa1828..a0b2ac5 100644 --- a/database/migrations/2021_01_30_102537_create_portfolios_table.php +++ b/database/migrations/2021_01_30_102537_create_portfolios_table.php @@ -17,7 +17,7 @@ return new class extends Migration { Schema::create('portfolios', function (Blueprint $table) { $table->uuid('id')->primary(); - $table->string('title'); + $table->string('title')->when(config('database.default') != 'sqlite', fn ($ctx) => $ctx->fulltext()); $table->text('notes')->nullable(); $table->boolean('wishlist')->default(false); $table->timestamps(); diff --git a/database/migrations/2021_02_25_041221_create_market_data_table.php b/database/migrations/2021_02_25_041221_create_market_data_table.php index c6504f6..687649f 100644 --- a/database/migrations/2021_02_25_041221_create_market_data_table.php +++ b/database/migrations/2021_02_25_041221_create_market_data_table.php @@ -18,8 +18,8 @@ class CreateMarketDataTable extends Migration public function up() { Schema::create('market_data', function (Blueprint $table) { - $table->string('symbol', 15)->primary(); - $table->string('name')->nullable(); + $table->string('symbol', 25)->primary(); + $table->string('name')->nullable()->when(config('database.default') != 'sqlite', fn ($ctx) => $ctx->fulltext()); $table->float('market_value', 12, 4)->nullable(); $table->float('fifty_two_week_low', 12, 4)->nullable(); $table->float('fifty_two_week_high', 12, 4)->nullable(); diff --git a/database/migrations/2021_02_25_041236_create_dividends_table.php b/database/migrations/2021_02_25_041236_create_dividends_table.php index 3a50b05..48d4eaa 100644 --- a/database/migrations/2021_02_25_041236_create_dividends_table.php +++ b/database/migrations/2021_02_25_041236_create_dividends_table.php @@ -18,11 +18,9 @@ class CreateDividendsTable extends Migration Schema::create('dividends', function (Blueprint $table) { $table->uuid('id')->primary(); $table->date('date'); - $table->string('symbol', 15); + $table->string('symbol', 25); $table->float('dividend_amount', 12, 4); $table->timestamps(); - - $table->foreign('symbol')->references('symbol')->on('market_data'); }); } diff --git a/database/migrations/2021_02_25_041246_create_splits_table.php b/database/migrations/2021_02_25_041246_create_splits_table.php index c4e9f9b..e504a9b 100644 --- a/database/migrations/2021_02_25_041246_create_splits_table.php +++ b/database/migrations/2021_02_25_041246_create_splits_table.php @@ -18,11 +18,9 @@ class CreateSplitsTable extends Migration Schema::create('splits', function (Blueprint $table) { $table->uuid('id')->primary(); $table->date('date'); - $table->string('symbol', 15); + $table->string('symbol', 25); $table->float('split_amount', 12, 4); $table->timestamps(); - - $table->foreign('symbol')->references('symbol')->on('market_data'); }); } diff --git a/database/migrations/2021_02_25_041257_create_transactions_table.php b/database/migrations/2021_02_25_041257_create_transactions_table.php index 7ce5703..ce2c454 100644 --- a/database/migrations/2021_02_25_041257_create_transactions_table.php +++ b/database/migrations/2021_02_25_041257_create_transactions_table.php @@ -18,7 +18,7 @@ class CreateTransactionsTable extends Migration { Schema::create('transactions', function (Blueprint $table) { $table->uuid('id')->primary(); - $table->string('symbol', 15); + $table->string('symbol', 25); $table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade'); $table->string('transaction_type', 15); $table->float('quantity', 12, 4); @@ -27,8 +27,6 @@ class CreateTransactionsTable extends Migration $table->boolean('split')->default(false); $table->date('date'); $table->timestamps(); - - $table->foreign('symbol')->references('symbol')->on('market_data'); }); } diff --git a/database/migrations/2021_09_06_014744_create_holdings_table.php b/database/migrations/2021_09_06_014744_create_holdings_table.php index 1adda27..ffeafa6 100644 --- a/database/migrations/2021_09_06_014744_create_holdings_table.php +++ b/database/migrations/2021_09_06_014744_create_holdings_table.php @@ -19,7 +19,7 @@ class CreateHoldingsTable extends Migration Schema::create('holdings', function (Blueprint $table) { $table->uuid('id')->primary(); $table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade'); - $table->string('symbol', 15); + $table->string('symbol', 25)->when(config('database.default') != 'sqlite', fn ($ctx) => $ctx->fulltext()); $table->float('quantity', 12, 4); $table->float('average_cost_basis', 12, 4)->default(0); $table->float('total_cost_basis', 12, 4)->default(0); @@ -27,8 +27,6 @@ class CreateHoldingsTable extends Migration $table->float('dividends_earned', 12, 4)->default(0); $table->timestamp('splits_synced_at')->nullable(); $table->timestamps(); - - $table->foreign('symbol')->references('symbol')->on('market_data'); }); } diff --git a/resources/views/livewire/holding-dividends-list.blade.php b/resources/views/livewire/holding-dividends-list.blade.php index 2ede256..1178f81 100644 --- a/resources/views/livewire/holding-dividends-list.blade.php +++ b/resources/views/livewire/holding-dividends-list.blade.php @@ -3,16 +3,16 @@ use App\Models\Holding; use Livewire\Volt\Component; -new class extends Component { - +new class extends Component +{ // props public Holding $holding; protected $listeners = [ 'transaction-updated' => '$refresh', - 'transaction-saved' => '$refresh' + 'transaction-saved' => '$refresh', ]; - + // methods }; ?> diff --git a/resources/views/livewire/manage-transaction-form.blade.php b/resources/views/livewire/manage-transaction-form.blade.php index 8b89c4b..44bff1a 100644 --- a/resources/views/livewire/manage-transaction-form.blade.php +++ b/resources/views/livewire/manage-transaction-form.blade.php @@ -1,33 +1,38 @@ ['required', 'string', new SymbolValidationRule], 'transaction_type' => 'required|string|in:BUY,SELL', 'portfolio_id' => 'required|exists:portfolios,id', - '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()->format('Y-m-d')], 'quantity' => [ - 'required', - 'numeric', - 'min:0', - new QuantityValidationRule($this->portfolio, $this->symbol, $this->transaction_type, $this->date) + 'required', + 'numeric', + 'min:0', + new QuantityValidationRule($this->portfolio, $this->symbol, $this->transaction_type, $this->date), ], 'cost_basis' => 'exclude_if:transaction_type,SELL|min:0|numeric', 'sale_price' => 'exclude_if:transaction_type,BUY|min:0|numeric', ]; } - public function mount() + public function mount() { if (isset($this->transaction)) { @@ -59,7 +64,7 @@ new class extends Component { $this->quantity = $this->transaction->quantity; $this->cost_basis = $this->transaction->cost_basis; $this->sale_price = $this->transaction->sale_price; - + } else { $this->transaction_type = 'BUY'; $this->portfolio_id = isset($this->portfolio) ? $this->portfolio->id : ''; @@ -70,9 +75,8 @@ new class extends Component { public function update() { $this->authorize('fullAccess', $this->portfolio); - + $this->transaction->update($this->validate()); - // $this->transaction->owner_id = auth()->user()->id; $this->transaction->save(); $this->success(__('Transaction updated')); @@ -83,7 +87,7 @@ new class extends Component { public function save() { - if (!isset($this->portfolio)) { + if (! isset($this->portfolio)) { $this->portfolio = Portfolio::find($this->portfolio_id); } @@ -107,6 +111,11 @@ new class extends Component { $this->success(__('Transaction deleted'), redirectTo: route('holding.show', ['portfolio' => $this->portfolio->id, 'symbol' => $this->symbol])); } + + public function updatedSymbol($value) + { + $this->symbol = strtoupper($value); + } }; ?>