basic model scaffolding

This commit is contained in:
hackerESQ
2024-08-10 13:30:19 -05:00
parent a7acb2f346
commit 94c8850cd5
6 changed files with 732 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Models;
use App\Traits\HasCompositePrimaryKey;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class DailyChange extends Model
{
use HasFactory, HasCompositePrimaryKey;
public $timestamps = false;
/**
* The primary key of the table.
*
* @var string
*/
protected $primaryKey = ['date', 'portfolio_id'];
/**
* Table name for the model
*
* @var string
*/
protected $table = 'daily_change';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'portfolio_id',
'date',
'total_market_value',
'total_cost_basis',
'total_gain_loss',
'total_dividends',
'realized_gains',
'notes',
];
/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'date' => 'datetime',
];
public function scopeMyDailyChanges($query)
{
return $query->where('user_id', auth()->user()->id);
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Interfaces\MarketData\MarketDataInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Dividend extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'symbol',
'date',
'dividend_amount',
];
/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'date' => 'datetime',
'first_date' => 'datetime',
'last_date' => 'datetime',
];
/**
* Syncs all holdings of symbol with dividend data
*
* @param array|self $model
* @return void
*/
public static function syncHoldings(mixed $model)
{
// check if we got an array, if yes then lets create a dummy model
if (is_array($model)) {
$model = (new self)->fill($model);
}
// pull dividend data joined with holdings/transactions
$dividends = self::where([
'dividends.symbol' => $model->symbol,
])->select(['holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount'])
->selectRaw('@purchased:=(SELECT coalesce(SUM(quantity),0) FROM transactions WHERE transactions.transaction_type = "BUY" AND transactions.symbol = dividends.symbol AND date(transactions.date) <= date(dividends.date) AND holdings.portfolio_id = transactions.portfolio_id ) AS `purchased`')
->selectRaw('@sold:=(SELECT coalesce(SUM(quantity),0) FROM transactions WHERE transactions.transaction_type = "SELL" AND transactions.symbol = dividends.symbol AND date(transactions.date) <= date(dividends.date) AND holdings.portfolio_id = transactions.portfolio_id ) AS `sold`')
->selectRaw('@owned:=(@purchased - @sold) AS `owned`')
->selectRaw('@dividends_received:=(@owned * dividends.dividend_amount) AS `dividends_received`')
->join('transactions', 'transactions.symbol', 'dividends.symbol')
->join('holdings', 'transactions.portfolio_id', 'holdings.portfolio_id')
->groupBy(['holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount'])
->get();
// iterate through holdings and update
Holding::where(['symbol' => $model->symbol])
->get()
->each(function ($holding) use ($dividends) {
$holding->update([
'dividends_earned' => $dividends->where('portfolio_id', $holding->portfolio_id)->sum('dividends_received')
]);
});
}
/**
* Grab new dividend data
*
* @param string $symbol
* @return void
*/
public static function getDividendData(string $symbol)
{
$dividends_meta = self::where(['symbol' => $symbol])
->selectRaw('COUNT(symbol) as total_dividends')
->selectRaw('MAX(date) as last_date')
->get()
->first();
// assume we need to populate ALL dividend data
$start_date = new \DateTime('@0');
$end_date = now();
// nope, refresh forward looking only
if ( $dividends_meta->total_dividends ) {
$start_date = $dividends_meta->last_date->addHours(48);
$end_date = now();
}
// get some data
if ($dividend_data = collect() && $start_date && $end_date) {
$dividend_data = app(MarketDataInterface::class)->dividends($symbol, $start_date, $end_date);
}
// ah, we found some dividends...
if ($dividend_data->isNotEmpty()) {
// create mass insert
foreach ($dividend_data as $index => $dividend){
$dividend_data[$index] = [...$dividend, ...['updated_at' => now(), 'created_at' => now()]];
}
// insert records
(new self)->insert($dividend_data->toArray());
// sync to holdings
self::syncHoldings($dividend_data->last());
// sync most last dividend date in market data
$market_data = MarketData::symbol($symbol)->first();
$dividend_data_latest_date = $dividend_data->sortByDesc('date')->first()['date'];
if ($market_data->dividend_date < $dividend_data_latest_date) {
$market_data->update(['dividend_date' => $dividend_data_latest_date]); // why is this set to latest date?
}
}
return $dividend_data;
}
public function marketData() {
return $this->belongsTo(MarketData::class, 'symbol', 'symbol');
}
public function holdings() {
return $this->hasMany(Holding::class, 'symbol', 'symbol');
}
public function transactions() {
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace App\Models;
use App\Models\Dividend;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Holding extends Model
{
use HasFactory;
protected $with = [];
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'portfolio_id',
'symbol',
'quantity',
'average_cost_basis',
'total_cost_basis',
'realized_gain_loss_dollars',
'dividends_earned',
'splits_synced_at',
'dividends_synced_at'
];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'splits_synced_at' => 'datetime',
'dividends_synced_at' => 'datetime',
];
/**
* get market data for holding
*
* @return void
*/
public function market_data()
{
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
}
/**
* get related transactions for holding
*
* @return void
*/
public function transactions()
{
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
}
/**
* get related dividends for holding
*
* @return void
*/
public function dividends()
{
return $this->hasMany(Dividend::class, 'symbol', 'symbol');
}
/**
* get related portfolio for holding
*
* @return void
*/
public function portfolio()
{
return $this->belongsTo(Portfolio::class);
}
/**
* get related splits for holding
*
* @return void
*/
public function splits()
{
return $this->hasMany(Split::class, 'symbol', 'symbol');
}
public function scopePortfolio($query, $portfolio)
{
return $query->where('portfolio_id', $portfolio);
}
public function scopeWithoutWishlists($query) {
return $query->join('portfolios', 'portfolios.id', 'holdings.portfolio_id')
->where('portfolios.wishlist', 0);
}
public function scopeMyHoldings($query)
{
return $query->whereHas('portfolio', function($query) {
$query->whereRelation('users', 'id', auth()->user()->id);
});
}
public function scopeGetPortfolioMetrics($query)
{
$query->selectRaw('SUM(holdings.dividends_earned) AS total_dividends_earned')
->selectRaw('SUM(holdings.realized_gain_loss_dollars) AS realized_gain_loss_dollars')
->selectRaw('@total_market_value:=SUM(holdings.quantity * market_data.market_value) AS total_market_value')
->selectRaw('@sum_total_cost_basis:=SUM(holdings.total_cost_basis) AS total_cost_basis')
->selectRaw('@total_gain_loss_dollars:=(@total_market_value - @sum_total_cost_basis) AS total_gain_loss_dollars')
->selectRaw('(@total_gain_loss_dollars / @sum_total_cost_basis) * 100 AS total_gain_loss_percent')
->join('market_data', 'market_data.symbol', 'holdings.symbol');
// =(VLOOKUP(if(today or end of year),'Daily Change'!$A:$B,2,false) - VLOOKUP(first of year),'Daily Change'!$A:$C,3,false)) / (SUMIFS(transactions.cost_basis_lot,transactions.date,"<"&date(left(D19,4)+1,1,1),transactions.type,"Buy")-SUMIFS(transactions.cost_basis_lot,transactions.date,"<"&date(left(D19,4)+1,1,1),transactions.type,"Sell"))-1
}
public function scopeSymbol($query, $symbol)
{
return $query->where('symbol', $symbol);
}
public function refreshDividends()
{
return Dividend::getDividendData($this->attributes['symbol']);
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Interfaces\MarketData\MarketDataInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class MarketData extends Model
{
use HasFactory;
protected $primaryKey = 'symbol';
protected $keyType = 'string';
public $incrementing = false;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'symbol',
'name',
'market_value',
'fifty_two_week_high',
'fifty_two_week_low',
];
public static function setSplitsHoldingSynced($symbol)
{
$market_data = self::where('symbol', $symbol)->get()->first();
$market_data->splits_synced_to_holdings_at = now();
$market_data->save();
}
public function refreshMarketData()
{
return static::getMarketData($this->attributes['symbol']);
}
public static function getMarketData($symbol)
{
$market_data = self::firstOrNew(['symbol' => $symbol]);
// check if new or stale
if (!$market_data->exists || now()->diffInMinutes($market_data->updated_at) >= config('market_data.refresh')) {
// get quote
$quote = app(MarketDataInterface::class)->quote($symbol);
// fill data
$market_data->fill($quote->toArray());
// save with timestamps updated
$market_data->touch();
}
return $market_data;
}
public function holdings()
{
return $this->hasMany(Holding::class, 'symbol', 'symbol');
}
public function scopeSymbol($query, $symbol)
{
return $query->where('symbol', $symbol);
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
namespace App\Models;
use App\Models\Transaction;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Eloquent\Model;
use App\Interfaces\MarketData\MarketDataInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Split extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'symbol',
'date',
'split_amount',
];
/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'date' => 'datetime',
'first_date' => 'datetime',
'last_date' => 'datetime',
];
/**
* Syncs all transactions of symbol with split data
*
* @param string $symbol
* @return void
*/
public static function syncToTransactions($symbol)
{
// get relevant split data
$splits = self::where([
'splits.symbol' => $symbol,
])
->whereDate('transactions.date', '>', DB::raw('IFNULL(market_data.splits_synced_to_holdings_at, "0000-00-00")'))
->select([
'splits.date',
'splits.symbol',
'splits.split_amount',
'transactions.portfolio_id'
])
->join('transactions', 'transactions.symbol', 'splits.symbol')
->join('market_data', 'transactions.symbol', 'market_data.symbol')
->orderBy('splits.date', 'ASC')
->get();
foreach($splits as $split) {
$qty_owned = Transaction::where([
'symbol' => $split->symbol,
'portfolio_id' => $split->portfolio_id
])
->whereDate('transactions.date', '<', $split->date->format('Y-m-d'))
->sum('quantity');
if ($qty_owned > 0) {
Transaction::create([
'symbol' => $split->symbol,
'portfolio_id' => $split->portfolio_id,
'transaction_type' => 'BUY',
'date' => $split->date,
'quantity' => ($qty_owned * $split->split_amount) - $qty_owned,
'cost_basis' => 0,
'split' => true,
'created_at' => now(),
'updated_at' => now()
]);
}
}
// update market data with latest date
MarketData::setSplitsHoldingSynced($symbol);
}
/**
* Grab new split data
*
* @param string $symbol
* @param \DateTimeInterface|null $start_date
* @return void
*/
public static function getSplitData(string $symbol)
{
// dates for split data
$splits_meta = self::where(['symbol' => $symbol])
->selectRaw('COUNT(symbol) as total_splits')
->selectRaw('MIN(date) as first_date')
->selectRaw('MAX(date) as last_date')
->get()
->first();
// assume need to populate all split data because it didnt exist before
$start_date = new \DateTime('@0');
$end_date = now();
// nope, need to populate newer split data
if ($splits_meta->total_splits) {
$start_date = $splits_meta->last_date->addHours(48);
$end_date = now();
}
// get some data
if ($split_data = collect() && $start_date && $end_date) {
$split_data = app(MarketDataInterface::class)->splits($symbol, $start_date, $end_date);
}
if ($split_data->isNotEmpty()) {
// insert records
(new self)->insert($split_data->toArray());
}
// sync to transactions
self::syncToTransactions($symbol);
return $split_data;
}
public function holdings() {
return $this->hasMany(Holding::class, 'symbol', 'symbol');
}
public function transactions() {
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
}
}
+167
View File
@@ -0,0 +1,167 @@
<?php
namespace App\Models;
use App\Models\MarketData;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Transaction extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'symbol',
'date',
'portfolio_id',
'transaction_type',
'quantity',
'cost_basis',
'sale_price',
'split'
];
/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'date' => 'datetime',
'first_date' => 'datetime',
'last_date' => 'datetime',
'split' => 'boolean',
];
/**
*
* @return void
*/
protected static function boot()
{
parent::boot();
static::saved(function ($model) {
static::syncHolding($model);
});
static::deleted(function ($model) {
static::syncHolding($model);
});
}
public static function syncHolding($model) {
// get the holding for a symbol and portfolio (or create one)
$holding = Holding::firstOrNew([
'portfolio_id' => $model->portfolio_id,
'symbol' => $model->symbol
], [
'portfolio_id' => $model->portfolio_id,
'symbol' => $model->symbol,
'quantity' => $model->quantity,
'average_cost_basis' => $model->cost_basis,
'total_cost_basis' => $model->quantity * $model->cost_basis,
]);
// pull existing transaction data
$query = self::where([
'portfolio_id' => $model->portfolio_id,
'symbol' => $model->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 `cost_basis`')
->selectRaw('SUM(CASE WHEN transaction_type = "SELL" THEN ((sale_price - cost_basis) * quantity) ELSE 0 END) AS `realized_gains`')
->first();
$total_quantity = $query->qty_purchases - $query->qty_sales;
$average_cost_basis = $query->qty_purchases > 0
? $query->cost_basis / $query->qty_purchases
: 0;
// update holding
$holding->fill([
'quantity' => $total_quantity,
'average_cost_basis' => $average_cost_basis,
'total_cost_basis' => $total_quantity * $average_cost_basis,
'realized_gain_loss_dollars' => $query->realized_gains,
]);
$holding->save();
// load market data while we're here
$model->refreshMarketData();
// sync dividends to holding
$model->syncDividendsToHolding();
}
public function setSymbolAttribute($value)
{
$this->attributes['symbol'] = strtoupper($value);
}
/**
* get market data for transaction
*
* @return void
*/
public function market_data()
{
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
}
/**
* get portfolio for transaction
*
* @return void
*/
public function portfolio()
{
return $this->belongsTo(Portfolio::class);
}
public function scopePortfolio($query, $portfolio)
{
return $query->where('portfolio_id', $portfolio);
}
public function scopeSymbol($query, $symbol)
{
return $query->where('symbol', $symbol);
}
public function scopeMyTransactions()
{
return $this->whereHas('portfolio', function ($query) {
return $query->whereRelation('users', 'id', auth()->user()->id);
});
}
public function refreshMarketData()
{
return MarketData::getMarketData($this->attributes['symbol']);
}
public function syncDividendsToHolding()
{
return Dividend::syncHoldings(['symbol' => $this->attributes['symbol']]);
}
public function refreshDividends()
{
return Dividend::getDividendData($this->attributes['symbol']);
}
}