Files
investbrain/app/Models/Holding.php
T

553 lines
23 KiB
PHP
Raw Normal View History

2024-08-10 13:30:19 -05:00
<?php
2025-01-28 17:33:54 -06:00
declare(strict_types=1);
2024-08-10 13:30:19 -05:00
namespace App\Models;
2025-04-09 19:25:15 -05:00
use App\Traits\HasMarketData;
2024-08-17 18:40:50 -05:00
use Illuminate\Database\Eloquent\Concerns\HasUuids;
2024-08-10 13:30:19 -05:00
use Illuminate\Database\Eloquent\Factories\HasFactory;
2025-01-28 17:14:49 -06:00
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
2025-04-09 19:25:15 -05:00
use Illuminate\Support\Collection;
2025-01-28 17:14:49 -06:00
use Illuminate\Support\Facades\DB;
2024-08-10 13:30:19 -05:00
class Holding extends Model
{
use HasFactory;
2025-04-09 19:25:15 -05:00
use HasMarketData;
2024-08-17 18:40:50 -05:00
use HasUuids;
2024-08-10 13:30:19 -05:00
protected $fillable = [
'portfolio_id',
'symbol',
'quantity',
'average_cost_basis',
'total_cost_basis',
2024-08-21 20:42:32 -05:00
'realized_gain_dollars',
2024-08-10 13:30:19 -05:00
'dividends_earned',
'splits_synced_at',
2025-01-28 17:14:49 -06:00
'reinvest_dividends',
2024-08-10 13:30:19 -05:00
];
protected $casts = [
2025-04-09 19:25:15 -05:00
'reinvest_dividends' => 'boolean',
2024-08-10 13:30:19 -05:00
'splits_synced_at' => 'datetime',
2024-10-18 20:46:22 -05:00
'first_transaction_date' => 'datetime',
2025-04-09 19:25:15 -05:00
'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',
2024-08-10 13:30:19 -05:00
];
/**
2024-08-17 21:33:09 -05:00
* Related transactions for holding
2024-08-10 13:30:19 -05:00
*
* @return void
*/
2025-01-28 17:14:49 -06:00
public function transactions()
2024-08-10 13:30:19 -05:00
{
2024-08-30 20:58:00 -05:00
return $this->hasManyThrough(Transaction::class, Portfolio::class, 'id', 'portfolio_id', 'portfolio_id', 'id')->orderBy('date', 'DESC');
2024-08-10 13:30:19 -05:00
}
/**
2024-08-17 21:33:09 -05:00
* Related dividends for holding
2024-08-10 13:30:19 -05:00
*
* @return void
*/
2025-01-28 17:14:49 -06:00
public function dividends()
2024-08-10 13:30:19 -05:00
{
2024-08-27 21:17:54 -05:00
return $this->hasMany(Dividend::class, 'symbol', 'symbol')
2025-04-09 19:25:15 -05:00
->select(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount', 'dividends.dividend_amount_base'])
2025-01-28 17:14:49 -06:00
->selectRaw("SUM(
CASE WHEN transaction_type = 'BUY'
AND transactions.symbol = dividends.symbol
2024-08-27 21:17:54 -05:00
AND transactions.portfolio_id = '$this->portfolio_id'
AND date(dividends.date) >= date(transactions.date)
2024-08-27 21:17:54 -05:00
THEN transactions.quantity
ELSE 0 END
) AS purchased")
2025-01-28 17:14:49 -06:00
->selectRaw("SUM(
2024-08-27 21:17:54 -05:00
CASE WHEN transaction_type = 'SELL'
AND transactions.symbol = dividends.symbol
AND transactions.portfolio_id = '$this->portfolio_id'
AND date(dividends.date) >= date(transactions.date)
2024-08-27 21:17:54 -05:00
THEN transactions.quantity
ELSE 0 END
) AS sold")
2025-01-28 17:14:49 -06:00
->selectRaw("SUM(
(CASE WHEN transaction_type = 'BUY'
AND transactions.symbol = dividends.symbol
2024-10-28 21:17:53 -05:00
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
2024-10-28 21:17:53 -05:00
AND transactions.portfolio_id = '$this->portfolio_id'
AND date(transactions.date) <= date(dividends.date)
THEN transactions.quantity ELSE 0 END)
* dividends.dividend_amount
2024-10-28 21:17:53 -05:00
) AS total_received")
2025-04-09 19:25:15 -05:00
->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")
2025-01-28 17:14:49 -06:00
->join('transactions', 'transactions.symbol', 'dividends.symbol')
2025-04-09 19:25:15 -05:00
->groupBy(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount', 'dividends.dividend_amount_base'])
2025-01-28 17:14:49 -06:00
->orderBy('dividends.date', 'DESC')
->where('dividends.date', '>=', function ($query) {
$query->selectRaw('min(transactions.date)')
->from('transactions')
->whereRaw("transactions.portfolio_id = '$this->portfolio_id'")
->whereRaw("transactions.symbol = '$this->symbol'");
})
2025-03-10 21:17:24 -05:00
->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)
2025-04-09 19:25:15 -05:00
) * dividends.dividend_amount_base > 0");
2024-08-10 13:30:19 -05:00
}
/**
2024-08-17 21:33:09 -05:00
* Related portfolio for holding
2024-08-10 13:30:19 -05:00
*
* @return void
*/
2025-01-28 17:14:49 -06:00
public function portfolio()
2024-08-10 13:30:19 -05:00
{
return $this->belongsTo(Portfolio::class);
}
/**
2024-08-17 21:33:09 -05:00
* Related splits for holding
2024-08-10 13:30:19 -05:00
*
* @return void
*/
2025-01-28 17:14:49 -06:00
public function splits()
2024-08-10 13:30:19 -05:00
{
2024-08-27 21:17:54 -05:00
return $this->hasMany(Split::class, 'symbol', 'symbol')
->orderBy('date', 'DESC');
2024-08-10 13:30:19 -05:00
}
/**
* Related chats for holding
*
* @return void
*/
public function chats()
{
2024-11-03 08:41:14 -06:00
return $this->morphMany(AiChat::class, 'chatable')->where('user_id', auth()->user()->id);
}
2024-08-28 15:19:20 -05:00
public function scopeWithMarketData($query)
{
2024-08-28 23:32:01 -05:00
return $query->withAggregate('market_data', 'name')
2025-01-28 17:14:49 -06:00
->withAggregate('market_data', 'market_value')
2025-04-09 19:25:15 -05:00
->withAggregate('market_data', 'market_value_base')
2025-01-28 17:14:49 -06:00
->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');
2024-08-28 15:19:20 -05:00
}
2025-04-09 19:25:15 -05:00
/**
* Calculate performance for holding in its local currency
*/
2024-08-29 18:46:21 -05:00
public function scopeWithPerformance($query)
{
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')
2025-03-10 21:17:24 -05:00
->selectRaw('COALESCE(((market_data.market_value - holdings.average_cost_basis) / NULLIF(holdings.average_cost_basis, 0)) * 100, 0) AS market_gain_percent');
2024-08-29 18:46:21 -05:00
}
2024-08-10 13:30:19 -05:00
public function scopePortfolio($query, $portfolio)
{
2024-09-11 22:00:37 -05:00
return $query->where('holdings.portfolio_id', $portfolio);
2024-08-10 13:30:19 -05:00
}
2024-08-27 21:17:54 -05:00
public function scopeSymbol($query, $symbol)
{
2024-08-28 22:06:47 -05:00
return $query->where('holdings.symbol', $symbol);
2024-08-27 21:17:54 -05:00
}
2025-01-28 17:14:49 -06:00
public function scopeWithoutWishlists($query)
{
return $query->whereHas('portfolio', function ($query) {
2025-01-28 17:14:49 -06:00
$query->where('portfolios.wishlist', 0);
});
2024-08-10 13:30:19 -05:00
}
public function scopeMyHoldings($query, $userId = null)
2024-08-10 13:30:19 -05:00
{
2025-01-28 17:14:49 -06:00
return $query->whereHas('portfolio', function ($query) use ($userId) {
$query->whereRelation('users', 'id', $userId ?? auth()->user()->id);
2024-08-10 13:30:19 -05:00
});
}
2025-04-09 19:25:15 -05:00
/**
* Scope which returns collection of performance metrics for holdings
*
* @param string $currency Allows casting to specified currency
*/
public function scopeGetPortfolioMetrics($query, $currency = null): Collection
{
$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
2024-08-10 13:30:19 -05:00
{
2025-04-09 19:25:15 -05:00
$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
2025-07-12 00:40:37 -05:00
WHEN
transactions.transaction_type = 'BUY'
OR SUM(transactions.cost_basis_base) = 0
THEN
COALESCE(cr.rate, 1)
2025-04-09 19:25:15 -05:00
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');
}
);
2024-08-10 13:30:19 -05:00
}
2024-08-30 20:22:28 -05:00
2024-08-30 21:58:38 -05:00
public function syncTransactionsAndDividends()
2024-08-30 20:22:28 -05:00
{
// pull existing transaction data
$query = Transaction::where([
2025-01-28 17:14:49 -06:00
'portfolio_id' => $this->portfolio_id,
2025-04-09 19:25:15 -05:00
'transactions.symbol' => $this->symbol,
2025-03-10 21:17:24 -05:00
])->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")
2025-04-09 19:25:15 -05:00
->selectRaw("SUM(CASE WHEN transaction_type = 'SELL' THEN (sale_price - cost_basis) * quantity ELSE 0 END) AS realized_gain_dollars")
2025-03-10 21:17:24 -05:00
->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN (quantity * cost_basis) ELSE 0 END) AS total_cost_basis")
2025-01-28 17:14:49 -06:00
->first();
2024-08-30 20:22:28 -05:00
2025-04-09 19:25:15 -05:00
$total_quantity = round($query->qty_purchases - $query->qty_sales, 4);
2024-09-06 23:15:43 -05:00
$average_cost_basis = (
2025-01-28 17:14:49 -06:00
$query->qty_purchases > 0
&& $total_quantity > 0
2025-03-10 21:17:24 -05:00
) ? $query->total_cost_basis / $query->qty_purchases
: 0;
2024-08-30 20:22:28 -05:00
// update holding
$this->fill([
'quantity' => $total_quantity,
'average_cost_basis' => $average_cost_basis,
'total_cost_basis' => $total_quantity * $average_cost_basis,
2025-04-09 19:25:15 -05:00
'realized_gain_dollars' => $query->realized_gain_dollars ?? 0,
2025-01-28 17:14:49 -06:00
'dividends_earned' => $this->dividends->sum('total_received'),
2024-08-30 20:22:28 -05:00
]);
$this->save();
}
2024-08-10 13:30:19 -05:00
2025-01-28 17:14:49 -06:00
public function qtyOwned(?\Illuminate\Support\Carbon $date = null)
2024-10-18 20:46:22 -05:00
{
2025-01-28 17:14:49 -06:00
if ($date == null) {
$date = now();
}
2024-10-18 20:46:22 -05:00
$transactions = $this->transactions->where('date', '<=', $date);
$purchases = $transactions->where('transaction_type', 'BUY')->sum('quantity');
$sales = $transactions->where('transaction_type', 'SELL')->sum('quantity');
return $purchases - $sales;
}
2025-04-09 19:25:15 -05:00
/**
* Method that enables calculating daily performance for a given holding
*
* @return void
*/
2024-09-11 22:00:37 -05:00
public function dailyPerformance(
2025-01-28 17:14:49 -06:00
?\Illuminate\Support\Carbon $start_date = null,
?\Illuminate\Support\Carbon $end_date = null,
2024-09-11 22:00:37 -05:00
) {
2025-01-28 17:14:49 -06:00
if ($start_date == null) {
$start_date = now();
}
if ($end_date == null) {
$end_date = now();
}
2024-09-11 22:00:37 -05:00
2025-03-10 21:17:24 -05:00
// MySQL default interval
2025-01-28 17:14:49 -06:00
$date_interval = 'DATE_ADD(date, INTERVAL 1 DAY)';
2025-03-10 21:17:24 -05:00
$castNumberType = 'decimal';
2024-09-11 22:00:37 -05:00
2025-03-10 21:17:24 -05:00
// Use SQLite interval grammar
2024-09-11 22:00:37 -05:00
if (config('database.default') === 'sqlite') {
2025-01-28 17:14:49 -06:00
2024-09-11 22:00:37 -05:00
$date_interval = "date(date, '+1 day')";
2025-03-10 21:17:24 -05:00
}
// Default CTE time series query (for MySQL and SQLite)
$timeSeriesQuery = DB::table(DB::raw("(
WITH RECURSIVE date_series AS (
2025-04-09 19:25:15 -05:00
SELECT '{$start_date->toDateString()}' AS date
2025-03-10 21:17:24 -05:00
UNION ALL
SELECT $date_interval
FROM date_series
2025-04-09 19:25:15 -05:00
WHERE date < '{$end_date->toDateString()}'
2025-03-10 21:17:24 -05:00
)
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(
2025-04-09 19:25:15 -05:00
date '{$start_date->toDateString()}',
date '{$end_date->toDateString()}',
2025-03-10 21:17:24 -05:00
interval '1 day'
) as date_series"));
$castNumberType = 'numeric';
}
// Set MySQL-like query CTE max iterations
if (config('database.default') === 'mysql') {
2024-10-28 21:55:56 -05:00
// MySQL default
$max_recursion_var_name = 'cte_max_recursion_depth';
// Determine if running MySQL or MariaDB
$versionString = Arr::get(
DB::select('SELECT VERSION() as version;'),
2025-02-25 20:16:13 -06:00
'0', new \stdClass
)->version;
if (stripos($versionString, 'MariaDB') !== false) {
$max_recursion_var_name = 'max_recursive_iterations'; // Must be MariaDB
}
DB::statement("SET $max_recursion_var_name=1000000;");
2024-09-11 22:00:37 -05:00
}
2025-03-10 21:17:24 -05:00
// 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
2025-01-28 17:14:49 -06:00
->select([
'date_series.date',
DB::raw("
2025-03-10 21:17:24 -05:00
{$quantityQuery} AS owned
"),
2025-01-28 17:14:49 -06:00
DB::raw("
2025-03-10 21:17:24 -05:00
CASE
WHEN ({$quantityQuery}) = 0 THEN 0
ELSE SUM(CASE
2025-04-09 19:25:15 -05:00
WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis_base
2025-03-10 21:17:24 -05:00
ELSE 0
END)
END AS cost_basis
"),
2025-04-09 19:25:15 -05:00
DB::raw("COALESCE(SUM(CASE WHEN transaction_type = 'SELL' THEN ((sale_price_base - cost_basis_base) * quantity) ELSE 0 END), 0) AS realized_gains"),
2025-01-28 17:14:49 -06:00
])
->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');
2024-09-11 22:00:37 -05:00
}
2024-10-31 17:04:59 -05:00
public function getFormattedTransactions()
{
$formattedTransactions = '';
2025-01-28 17:14:49 -06:00
foreach ($this->transactions->sortByDesc('date') as $transaction) {
2025-04-09 19:25:15 -05:00
$formattedTransactions .= ' * '.$transaction->date->toDateString()
2025-01-28 17:14:49 -06:00
.' '.$transaction->transaction_type
.' '.$transaction->quantity
.' @ '.$transaction->cost_basis
2024-10-31 17:04:59 -05:00
." each \n\n";
}
2025-01-28 17:14:49 -06:00
2024-10-31 17:04:59 -05:00
return $formattedTransactions;
}
2025-01-28 17:14:49 -06:00
}