diff --git a/app/Models/DailyChange.php b/app/Models/DailyChange.php new file mode 100644 index 0000000..37e1a37 --- /dev/null +++ b/app/Models/DailyChange.php @@ -0,0 +1,66 @@ + 'datetime', + ]; + + public function scopeMyDailyChanges($query) + { + return $query->where('user_id', auth()->user()->id); + } + +} diff --git a/app/Models/Dividend.php b/app/Models/Dividend.php new file mode 100644 index 0000000..40c2858 --- /dev/null +++ b/app/Models/Dividend.php @@ -0,0 +1,144 @@ + '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'); + } +} diff --git a/app/Models/Holding.php b/app/Models/Holding.php new file mode 100644 index 0000000..a185362 --- /dev/null +++ b/app/Models/Holding.php @@ -0,0 +1,132 @@ + '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']); + } +} + + \ No newline at end of file diff --git a/app/Models/MarketData.php b/app/Models/MarketData.php new file mode 100644 index 0000000..1530618 --- /dev/null +++ b/app/Models/MarketData.php @@ -0,0 +1,74 @@ +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); + } +} \ No newline at end of file diff --git a/app/Models/Split.php b/app/Models/Split.php new file mode 100644 index 0000000..4718f0b --- /dev/null +++ b/app/Models/Split.php @@ -0,0 +1,149 @@ + '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'); + } +} diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php new file mode 100644 index 0000000..a611f04 --- /dev/null +++ b/app/Models/Transaction.php @@ -0,0 +1,167 @@ + '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']); + } +} \ No newline at end of file