Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c691ee922a | |||
| 3eb9bad840 | |||
| 370f7bb54b | |||
| 62bf6797e6 | |||
| c4e3645145 | |||
| 69e4d0fb3a | |||
| 20c2cb37cc | |||
| d2bb065822 | |||
| 0c00f28d97 | |||
| 5eab00ee33 | |||
| 56064ad84e | |||
| c96ff0e45f | |||
| 33e0df5ae2 | |||
| a5a333f784 | |||
| 89b5505e1d | |||
| 60923b3c93 | |||
| 17e5d8b665 | |||
| bd9c828c68 | |||
| f72cd6f5a7 | |||
| 3593697cce | |||
| d53e71dcd5 | |||
| 71e79cfb40 | |||
| 38a65f99c9 | |||
| 26e54fb357 | |||
| 224ed104b9 | |||
| 2702fe27e4 | |||
| dd21227f8f | |||
| 1ef8dd9378 | |||
| eae345f243 | |||
| 6d6f968f42 | |||
| 261c848ffd | |||
| 9bcc80078e |
@@ -193,6 +193,7 @@ Just to be safe, we recommend backing up your portfolios before using these comm
|
||||
| refresh:market-data | Refreshes market data with your configured market data provider. |
|
||||
| refresh:dividend-data | Refreshes dividend data with your configured market data provider. Will also re-calculate your total dividends earned for each holding. |
|
||||
| refresh:split-data | Refreshes splits data with your configured market data provider. Will also create new transactions to account for any splits. |
|
||||
| refresh:currency-data | Grabs the latest daily currency exchange rate data and persists to the database. |
|
||||
| capture:daily-change | Captures a snapshot of each portfolio's daily performance. |
|
||||
| sync:daily-change | Re-calculates daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) |
|
||||
| sync:holdings | Re-calculates performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\Currency;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ConvertToMarketDataCurrency
|
||||
{
|
||||
public function __invoke(Model $model, callable $next)
|
||||
{
|
||||
if (is_null($model?->market_data)) {
|
||||
|
||||
$model->loadMarketData();
|
||||
}
|
||||
|
||||
if (! is_null($model->currency) && $model->currency !== $model->market_data->currency) {
|
||||
|
||||
// convert to market data currency
|
||||
$model->cost_basis = Currency::convert(
|
||||
value: $model->cost_basis,
|
||||
from: $model->currency,
|
||||
to: $model->market_data->currency,
|
||||
date: $model->date
|
||||
);
|
||||
|
||||
if ($model->transaction_type == 'SELL') {
|
||||
|
||||
$model->sale_price = Currency::convert(
|
||||
value: $model->sale_price,
|
||||
from: $model->currency,
|
||||
to: $model->market_data->currency,
|
||||
date: $model->date
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// currency cannot be saved to the database - we already know market_data.currency anyway
|
||||
unset($model->currency);
|
||||
|
||||
return $next($model);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Casts\BaseCurrency;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CopyToBaseCurrency
|
||||
{
|
||||
public function __invoke(Model $model, callable $next)
|
||||
{
|
||||
foreach ($model->getCasts() as $key => $value) {
|
||||
if ($value === BaseCurrency::class) {
|
||||
|
||||
$model[$key] = $model[Str::beforeLast($key, '_base')];
|
||||
}
|
||||
}
|
||||
|
||||
return $next($model);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class EnsureCostBasisAddedToSale
|
||||
{
|
||||
public function __invoke(Model $model, callable $next)
|
||||
{
|
||||
// cost basis is required for sales to calculate realized gains
|
||||
if ($model->transaction_type == 'SELL') {
|
||||
|
||||
$average_cost_basis = Transaction::where([
|
||||
'portfolio_id' => $model->portfolio_id,
|
||||
'symbol' => $model->symbol,
|
||||
'transaction_type' => 'BUY',
|
||||
])->whereDate('date', '<=', $model->date)
|
||||
->average('cost_basis');
|
||||
|
||||
$model->cost_basis = $average_cost_basis ?? 0;
|
||||
}
|
||||
|
||||
return $next($model);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
use function Illuminate\Support\defer;
|
||||
|
||||
class EnsureDailyChangeIsSynced
|
||||
{
|
||||
public function __invoke(Model $model, callable $next)
|
||||
{
|
||||
if (config('app.env') != 'testing') {
|
||||
|
||||
$cacheKey = 'daily_change_synced'.$model->portfolio_id;
|
||||
|
||||
if (
|
||||
! Cache::has($cacheKey)
|
||||
&& $model->date->lessThan(now())
|
||||
&& ($model->date->lessThan($model->portfolio->daily_change()->min('date') ?? now())
|
||||
|| $model->date->lessThan($model->portfolio->transactions()->where('id', '!=', $model->id)->max('date') ?? now())
|
||||
)
|
||||
) {
|
||||
defer(fn () => $model->portfolio->syncDailyChanges());
|
||||
|
||||
Cache::put($cacheKey, now(), now()->addMinutes(5));
|
||||
}
|
||||
}
|
||||
|
||||
return $next($model);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ use App\Traits\WithTrimStrings;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
@@ -32,13 +31,22 @@ class CreateNewUser implements CreatesNewUsers
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
|
||||
'password' => $this->passwordRules(),
|
||||
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
|
||||
'terms' => config('investbrain.self_hosted') ? '' : ['accepted', 'required'],
|
||||
])->validate();
|
||||
|
||||
return User::create([
|
||||
$user = User::make([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
]);
|
||||
|
||||
// ensure first user is flagged as an admin
|
||||
if (User::count() === 0) {
|
||||
$user->admin = true;
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Casts;
|
||||
|
||||
use App\Models\Currency;
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BaseCurrency implements CastsAttributes
|
||||
{
|
||||
/**
|
||||
* Cast the given value to user's display currency
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
|
||||
{
|
||||
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the given value for storage in base currency
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
|
||||
{
|
||||
|
||||
// for market data and transactions the `currency` attribute is available...
|
||||
// but for dividends and other types, need to make sure `market_data` is loaded
|
||||
if (is_null($model?->currency)) {
|
||||
|
||||
$model->loadMarketData();
|
||||
}
|
||||
|
||||
return Currency::convert(
|
||||
(float) $value,
|
||||
$model?->currency ?? $model->market_data?->currency,
|
||||
config('investbrain.base_currency'),
|
||||
$model?->date
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Holding;
|
||||
use App\Models\Portfolio;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
@@ -44,23 +45,20 @@ class CaptureDailyChange extends Command
|
||||
|
||||
$this->line('Capturing daily change for '.$portfolio->title);
|
||||
|
||||
$total_cost_basis = $portfolio->holdings->sum('total_cost_basis');
|
||||
$metrics = Holding::query()
|
||||
->portfolio($portfolio->id)
|
||||
->getPortfolioMetrics(config('investbrain.base_currency'));
|
||||
|
||||
$total_dividends = $portfolio->holdings->sum('dividends_earned');
|
||||
|
||||
$realized_gains = $portfolio->holdings->sum('realized_gain_dollars');
|
||||
|
||||
$total_market_value = $portfolio->holdings->sum(function ($holding) {
|
||||
return $holding->market_data->market_value * $holding->quantity;
|
||||
});
|
||||
$total_cost_basis = $metrics->get('total_cost_basis');
|
||||
$total_market_value = $metrics->get('total_market_value');
|
||||
|
||||
$portfolio->daily_change()->create([
|
||||
'date' => now(),
|
||||
'total_market_value' => $total_market_value,
|
||||
'total_cost_basis' => $total_cost_basis,
|
||||
'total_gain' => $total_market_value - $total_cost_basis,
|
||||
'total_dividends_earned' => $total_dividends,
|
||||
'realized_gains' => $realized_gains,
|
||||
'total_dividends_earned' => $metrics->get('total_dividends_earned'),
|
||||
'realized_gains' => $metrics->get('realized_gain_dollars'),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\CurrencyRate;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RefreshCurrencyData extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'refresh:currency-data
|
||||
{--force : Refresh of currency data}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Refresh currency data from data provider';
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
|
||||
CurrencyRate::refreshCurrencyData($this->option('force') ?? false);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Exports\Sheets\ConfigSheet;
|
||||
use App\Exports\Sheets\DailyChangesSheet;
|
||||
use App\Exports\Sheets\PortfoliosSheet;
|
||||
use App\Exports\Sheets\TransactionsSheet;
|
||||
@@ -24,6 +25,7 @@ class BackupExport implements WithMultipleSheets
|
||||
new PortfoliosSheet($this->empty),
|
||||
new TransactionsSheet($this->empty),
|
||||
new DailyChangesSheet($this->empty),
|
||||
new ConfigSheet($this->empty),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exports\Sheets;
|
||||
|
||||
use App\Models\Holding;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
|
||||
class ConfigSheet implements FromCollection, WithHeadings, WithTitle
|
||||
{
|
||||
public function __construct(
|
||||
public bool $empty = false
|
||||
) {}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'Key',
|
||||
'Value',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function collection()
|
||||
{
|
||||
$configs = collect();
|
||||
|
||||
if ($this->empty) {
|
||||
return $configs;
|
||||
}
|
||||
|
||||
// collect user settings
|
||||
$configs->push([
|
||||
'key' => 'name',
|
||||
'value' => auth()->user()->name,
|
||||
], [
|
||||
'key' => 'locale',
|
||||
'value' => auth()->user()->getLocale(),
|
||||
], [
|
||||
'key' => 'display_currency',
|
||||
'value' => auth()->user()->getCurrency(),
|
||||
]);
|
||||
|
||||
// reinvested holdings
|
||||
Holding::myHoldings()->where('reinvest_dividends', true)->get()->each(function ($holding) use (&$configs) {
|
||||
$configs->push([
|
||||
'key' => 'reinvested_dividends',
|
||||
'value' => $holding->id,
|
||||
]);
|
||||
});
|
||||
|
||||
return $configs;
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return 'Config';
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
|
||||
*/
|
||||
public function collection()
|
||||
{
|
||||
return $this->empty ? collect() : DailyChange::myDailyChanges()->get();
|
||||
return $this->empty ? collect() : DailyChange::myDailyChanges()->withDailyPerformance()->get();
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
|
||||
@@ -25,6 +25,7 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
|
||||
'Quantity',
|
||||
'Cost Basis',
|
||||
'Sale Price',
|
||||
'Currency',
|
||||
'Split',
|
||||
'Reinvested Dividend',
|
||||
'Date',
|
||||
@@ -38,7 +39,30 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
|
||||
*/
|
||||
public function collection()
|
||||
{
|
||||
return $this->empty ? collect() : Transaction::myTransactions()->get();
|
||||
if ($this->empty) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Transaction::myTransactions()
|
||||
->withMarketData()
|
||||
->get()
|
||||
->map(function ($transaction) {
|
||||
return [
|
||||
'id' => $transaction->id,
|
||||
'symbol' => $transaction->symbol,
|
||||
'portfolio_id' => $transaction->portfolio_id,
|
||||
'transaction_type' => $transaction->transaction_type,
|
||||
'quantity' => $transaction->quantity,
|
||||
'cost_basis' => $transaction->cost_basis,
|
||||
'sale_price' => $transaction->sale_price,
|
||||
'currency' => $transaction->market_data_currency,
|
||||
'split' => $transaction->split,
|
||||
'reinvested_dividend' => $transaction->reinvested_dividend,
|
||||
'date' => $transaction->date,
|
||||
'created_at' => $transaction->created_at,
|
||||
'updated_at' => $transaction->updated_at,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
|
||||
@@ -18,6 +18,7 @@ class TransactionController extends ApiController
|
||||
|
||||
$filters->setQuery(Transaction::query());
|
||||
$filters->setScopes(['myTransactions']);
|
||||
$filters->setEagerRelations(['market_data']);
|
||||
$filters->setSearchableColumns(['symbol']);
|
||||
|
||||
return TransactionResource::collection($filters->paginated());
|
||||
|
||||
@@ -17,16 +17,14 @@ class DashboardController extends Controller
|
||||
$user = $request->user()->load(['portfolios', 'holdings', 'transactions']);
|
||||
|
||||
// get portfolio metrics
|
||||
$metrics = cache()->remember(
|
||||
$metrics = cache()->tags(['metrics-'.$user->id])->remember(
|
||||
'dashboard-metrics-'.$user->id,
|
||||
10,
|
||||
function () {
|
||||
return
|
||||
Holding::query()
|
||||
->myHoldings()
|
||||
->withoutWishlists()
|
||||
->withPortfolioMetrics()
|
||||
->first();
|
||||
return Holding::query()
|
||||
->myHoldings()
|
||||
->withoutWishlists()
|
||||
->getPortfolioMetrics();
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ class HoldingController extends Controller
|
||||
$query->where('transactions.symbol', $symbol);
|
||||
},
|
||||
])
|
||||
->symbol($symbol)
|
||||
->portfolio($portfolio->id)
|
||||
->firstOrFail();
|
||||
->symbol($symbol)
|
||||
->portfolio($portfolio->id)
|
||||
->firstOrFail();
|
||||
|
||||
$formattedTransactions = $holding->getFormattedTransactions();
|
||||
|
||||
|
||||
@@ -29,14 +29,13 @@ class PortfolioController extends Controller
|
||||
$portfolio->load(['transactions', 'holdings']);
|
||||
|
||||
// get portfolio metrics
|
||||
$metrics = cache()->remember(
|
||||
$metrics = cache()->tags(['metrics-'.$request->user()->id])->remember(
|
||||
'portfolio-metrics-'.$portfolio->id,
|
||||
60,
|
||||
function () use ($portfolio) {
|
||||
return Holding::query()
|
||||
->portfolio($portfolio->id)
|
||||
->withPortfolioMetrics()
|
||||
->first();
|
||||
->getPortfolioMetrics();
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Number;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Foundation\Events\LocaleUpdated;
|
||||
|
||||
class LocalizationMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
if (Auth::check()) {
|
||||
|
||||
$locale = auth()->user()->getLocale();
|
||||
|
||||
config(['app.locale' => $locale]);
|
||||
app('translator')->setLocale(Str::before($locale, '_'));
|
||||
app('events')->dispatch(new LocaleUpdated($locale));
|
||||
|
||||
Number::useLocale($locale);
|
||||
Number::useCurrency(auth()->user()->getCurrency());
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SetLocale
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
if (! session()->has('locale')) {
|
||||
session()->put('locale', $request->getPreferredLanguage(
|
||||
config('app.available_locales')
|
||||
));
|
||||
}
|
||||
|
||||
app()->setLocale(session('locale'));
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -30,11 +30,11 @@ class TransactionRequest extends FormRequest
|
||||
'portfolio_id' => ['required', 'exists:portfolios,id'],
|
||||
'symbol' => ['required', 'string', new SymbolValidationRule],
|
||||
'transaction_type' => ['required', 'string', 'in:BUY,SELL'],
|
||||
'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()->toDateString()],
|
||||
'quantity' => [
|
||||
'required',
|
||||
'numeric',
|
||||
'min:0',
|
||||
'gt:0',
|
||||
new QuantityValidationRule(
|
||||
$this->input('portfolio'),
|
||||
$this->requestOrModelValue('symbol', 'transaction'),
|
||||
@@ -42,6 +42,7 @@ class TransactionRequest extends FormRequest
|
||||
$this->requestOrModelValue('date', 'transaction')
|
||||
),
|
||||
],
|
||||
'currency' => ['required', 'exists:currencies,currency'],
|
||||
'cost_basis' => ['exclude_if:transaction_type,SELL', 'min:0', 'numeric'],
|
||||
'sale_price' => ['exclude_if:transaction_type,BUY', 'min:0', 'numeric'],
|
||||
];
|
||||
@@ -50,6 +51,7 @@ class TransactionRequest extends FormRequest
|
||||
$rules['portfolio_id'][0] = 'sometimes';
|
||||
$rules['symbol'][0] = 'sometimes';
|
||||
$rules['transaction_type'][0] = 'sometimes';
|
||||
$rules['currency'][0] = 'sometimes';
|
||||
$rules['date'][0] = 'sometimes';
|
||||
$rules['quantity'][0] = 'sometimes';
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ class HoldingResource extends JsonResource
|
||||
'portfolio_id' => $this->portfolio_id,
|
||||
'symbol' => $this->symbol,
|
||||
'quantity' => $this->quantity,
|
||||
'currency' => $this->market_data->currency,
|
||||
'reinvest_dividends' => $this->reinvest_dividends,
|
||||
'average_cost_basis' => $this->average_cost_basis,
|
||||
'total_cost_basis' => $this->total_cost_basis,
|
||||
|
||||
@@ -22,6 +22,7 @@ class TransactionResource extends JsonResource
|
||||
'portfolio_id' => $this->portfolio_id,
|
||||
'transaction_type' => $this->transaction_type,
|
||||
'quantity' => $this->quantity,
|
||||
'currency' => $this->market_data->currency,
|
||||
'cost_basis' => $this->cost_basis,
|
||||
'sale_price' => $this->sale_price,
|
||||
'split' => $this->split,
|
||||
|
||||
@@ -22,6 +22,10 @@ class UserResource extends JsonResource
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'profile_photo_url' => $this->profile_photo_url,
|
||||
'options' => [
|
||||
'display_currency' => $this->getCurrency(),
|
||||
'locale' => $this->getLocale(),
|
||||
],
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Console\Commands\RefreshDividendData;
|
||||
use App\Console\Commands\RefreshMarketData;
|
||||
use App\Console\Commands\SyncDailyChange;
|
||||
use App\Console\Commands\SyncHoldingData;
|
||||
use App\Imports\Sheets\ConfigSheet;
|
||||
use App\Imports\Sheets\DailyChangesSheet;
|
||||
use App\Imports\Sheets\PortfoliosSheet;
|
||||
use App\Imports\Sheets\TransactionsSheet;
|
||||
@@ -69,6 +70,7 @@ class BackupImport implements WithEvents, WithMultipleSheets
|
||||
'Portfolios' => new PortfoliosSheet($this->backupImportModel),
|
||||
'Transactions' => new TransactionsSheet($this->backupImportModel),
|
||||
'Daily Changes' => new DailyChangesSheet($this->backupImportModel),
|
||||
'Config' => new ConfigSheet($this->backupImportModel),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Imports\Sheets;
|
||||
|
||||
use App\Models\BackupImport;
|
||||
use App\Models\Holding;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||
use Maatwebsite\Excel\Events\BeforeSheet;
|
||||
|
||||
class ConfigSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
|
||||
{
|
||||
public function __construct(
|
||||
public BackupImport $backupImport
|
||||
) {}
|
||||
|
||||
public function registerEvents(): array
|
||||
{
|
||||
return [
|
||||
BeforeSheet::class => function (BeforeSheet $event) {
|
||||
DB::commit();
|
||||
$this->backupImport->update([
|
||||
'message' => __('Importing configurations...'),
|
||||
]);
|
||||
DB::beginTransaction();
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public function collection(Collection $configs)
|
||||
{
|
||||
foreach ($configs as $config) {
|
||||
|
||||
switch ($config['key']) {
|
||||
case 'name':
|
||||
$this->backupImport->user->setAttribute('name', $config['value']);
|
||||
$this->backupImport->user->save();
|
||||
break;
|
||||
|
||||
case 'locale':
|
||||
$this->backupImport->user->setOption('locale', $config['value']);
|
||||
$this->backupImport->user->save();
|
||||
break;
|
||||
|
||||
case 'display_currency':
|
||||
$this->backupImport->user->setOption('display_currency', $config['value']);
|
||||
$this->backupImport->user->save();
|
||||
break;
|
||||
|
||||
case 'reinvest_dividends':
|
||||
|
||||
Holding::myHoldings()->where('id', $config['value'])->update([
|
||||
'reinvest_dividends' => true,
|
||||
]);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'key' => ['required', 'string'],
|
||||
'value' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
||||
BeforeSheet::class => function (BeforeSheet $event) {
|
||||
DB::commit();
|
||||
$this->backupImport->update([
|
||||
'message' => __('Importing daily changes...'),
|
||||
'message' => __('Preparing to import daily changes...'),
|
||||
]);
|
||||
DB::beginTransaction();
|
||||
},
|
||||
@@ -40,22 +40,23 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
||||
|
||||
public function collection(Collection $dailyChanges)
|
||||
{
|
||||
$dailyChanges->chunk($this->batchSize())->each(function ($chunk) {
|
||||
$totalBatches = count($dailyChanges) / $this->batchSize();
|
||||
|
||||
$dailyChanges->chunk($this->batchSize())->each(function ($chunk, $index) use ($totalBatches) {
|
||||
|
||||
$this->validatePortfolioAccess($chunk);
|
||||
|
||||
$this->backupImport->update([
|
||||
'message' => __('Importing daily changes (Batch :currentBatch of :totalBatches)...', ['currentBatch' => $index + 1, 'totalBatches' => $totalBatches]),
|
||||
]);
|
||||
|
||||
// have to cast to native values
|
||||
$chunk = $chunk->map(function ($dailyChange) {
|
||||
|
||||
return [
|
||||
'total_market_value' => $dailyChange['total_market_value'],
|
||||
'total_cost_basis' => $dailyChange['total_cost_basis'],
|
||||
'total_gain' => $dailyChange['total_gain'],
|
||||
'total_dividends_earned' => $dailyChange['total_dividends_earned'],
|
||||
'realized_gains' => $dailyChange['realized_gains'],
|
||||
'annotation' => $dailyChange['annotation'],
|
||||
'portfolio_id' => $dailyChange['portfolio_id'],
|
||||
'date' => Carbon::parse($dailyChange['date'])->format('Y-m-d'),
|
||||
'date' => Carbon::parse($dailyChange['date'])->toDateString(),
|
||||
];
|
||||
});
|
||||
|
||||
@@ -63,11 +64,6 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
||||
$chunk->toArray(),
|
||||
['portfolio_id', 'date'],
|
||||
[
|
||||
'total_market_value',
|
||||
'total_cost_basis',
|
||||
'total_gain',
|
||||
'total_dividends_earned',
|
||||
'realized_gains',
|
||||
'annotation',
|
||||
'portfolio_id',
|
||||
'date',
|
||||
@@ -86,11 +82,6 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
||||
return [
|
||||
'portfolio_id' => ['required', 'uuid'],
|
||||
'date' => ['required', 'date'],
|
||||
'total_market_value' => ['sometimes', 'nullable', 'numeric'],
|
||||
'total_cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
||||
'total_gain' => ['sometimes', 'nullable', 'numeric'],
|
||||
'total_dividends_earned' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
||||
'realized_gains' => ['sometimes', 'nullable', 'numeric'],
|
||||
'annotation' => ['sometimes', 'nullable', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace App\Imports\Sheets;
|
||||
|
||||
use App\Imports\ValidatesPortfolioAccess;
|
||||
use App\Models\BackupImport;
|
||||
use App\Models\Currency;
|
||||
use App\Models\CurrencyRate;
|
||||
use App\Models\Holding;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -33,7 +35,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
||||
BeforeSheet::class => function (BeforeSheet $event) {
|
||||
DB::commit();
|
||||
$this->backupImport->update([
|
||||
'message' => __('Importing transactions...'),
|
||||
'message' => __('Preparing to import transactions...'),
|
||||
]);
|
||||
DB::beginTransaction();
|
||||
},
|
||||
@@ -43,13 +45,37 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
||||
public function collection(Collection $transactions)
|
||||
{
|
||||
|
||||
$transactions->chunk($this->batchSize())->each(function ($chunk) {
|
||||
// if has any transactions not in base currency, need to sync timeseries conversion rates
|
||||
if ($transactions->where('currency', '!=', config('investbrain.base_currency'))->isNotEmpty()) {
|
||||
|
||||
CurrencyRate::timeSeriesRates('', $transactions->min('date'));
|
||||
}
|
||||
|
||||
$totalBatches = count($transactions) / $this->batchSize();
|
||||
|
||||
// chunk transactions
|
||||
$transactions->chunk($this->batchSize())->each(function ($chunk, $index) use ($totalBatches) {
|
||||
|
||||
$this->backupImport->update([
|
||||
'message' => __('Importing transactions (Batch :currentBatch of :totalBatches)...', ['currentBatch' => $index + 1, 'totalBatches' => $totalBatches]),
|
||||
]);
|
||||
|
||||
$this->validatePortfolioAccess($chunk);
|
||||
|
||||
// have to cast to native values
|
||||
$chunk = $chunk->map(function ($transaction) {
|
||||
|
||||
$date = Carbon::parse($transaction['date'])->toDateString();
|
||||
|
||||
// if transaction not in base currency, need to convert
|
||||
if ($transaction['currency'] == config('investbrain.base_currency')) {
|
||||
$cost_basis_base = $transaction['cost_basis'] ?? 0;
|
||||
$sale_price_base = $transaction['sale_price'];
|
||||
} else {
|
||||
$cost_basis_base = Currency::convert($transaction['cost_basis'], $transaction['currency'], date: $date);
|
||||
$sale_price_base = Currency::convert($transaction['sale_price'], $transaction['currency'], date: $date);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $transaction['transaction_id'] ?? Str::uuid()->toString(),
|
||||
'symbol' => strtoupper($transaction['symbol']),
|
||||
@@ -58,9 +84,11 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
||||
'quantity' => $transaction['quantity'],
|
||||
'cost_basis' => $transaction['cost_basis'] ?? 0,
|
||||
'sale_price' => $transaction['sale_price'],
|
||||
'cost_basis_base' => $cost_basis_base,
|
||||
'sale_price_base' => $sale_price_base,
|
||||
'split' => boolval($transaction['split']) ? 1 : 0,
|
||||
'reinvested_dividend' => boolval($transaction['reinvested_dividend']) ? 1 : 0,
|
||||
'date' => Carbon::parse($transaction['date'])->format('Y-m-d'),
|
||||
'date' => $date,
|
||||
];
|
||||
});
|
||||
|
||||
@@ -81,7 +109,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
||||
]
|
||||
);
|
||||
|
||||
// stub out related holdings
|
||||
// get unique symbol/portfolio id combination and stub out related holdings
|
||||
$chunk->unique(fn ($item) => $item['symbol'].$item['portfolio_id'])
|
||||
->each(function ($holding) {
|
||||
|
||||
@@ -112,6 +140,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
||||
'transaction_type' => ['required', 'in:BUY,SELL'],
|
||||
'date' => ['required', 'date'],
|
||||
'quantity' => ['required', 'min:0', 'numeric'],
|
||||
'currency' => ['required', 'string'],
|
||||
'split' => ['sometimes', 'nullable', 'boolean'],
|
||||
'reinvested_dividend' => ['sometimes', 'nullable', 'boolean'],
|
||||
'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
||||
|
||||
@@ -11,13 +11,13 @@ trait ValidatesPortfolioAccess
|
||||
public function validatePortfolioAccess($collection)
|
||||
{
|
||||
|
||||
$uniquePortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id');
|
||||
$countPortfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id)
|
||||
->whereIn('id', $uniquePortfolios)
|
||||
$importingPortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id');
|
||||
$portfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id)
|
||||
->whereIn('id', $importingPortfolios)
|
||||
->count();
|
||||
|
||||
if (
|
||||
$countPortfoliosWithAccess < $uniquePortfolios->count()
|
||||
$importingPortfolios->count() > $portfoliosWithAccess
|
||||
) {
|
||||
throw new \Exception(__('You do not have access to that portfolio.'));
|
||||
}
|
||||
|
||||
@@ -23,23 +23,44 @@ class AlphaVantageMarketData implements MarketDataInterface
|
||||
|
||||
public function quote(string $symbol): Quote
|
||||
{
|
||||
|
||||
$search = Alphavantage::core()->search($symbol);
|
||||
$search = Arr::get($search, 'bestMatches.0', null);
|
||||
|
||||
if (Arr::get($search, '9. matchScore') !== '1.0000') {
|
||||
throw new \Exception('Could not find ticker on Alphavantage');
|
||||
}
|
||||
|
||||
$quote = Alphavantage::core()->quoteEndpoint($symbol);
|
||||
$quote = Arr::get($quote, 'Global Quote', []);
|
||||
|
||||
$fundamental = cache()->remember(
|
||||
'av-symbol-'.$symbol,
|
||||
1440,
|
||||
function () use ($symbol) {
|
||||
return Alphavantage::fundamentals()->overview($symbol);
|
||||
function () use ($symbol, $search) {
|
||||
if (Arr::get($search, '3. type') === 'Equity') {
|
||||
|
||||
$fundamental = (array) Alphavantage::fundamentals()->overview($symbol);
|
||||
} else {
|
||||
|
||||
$fundamental = (array) Alphavantage::fundamentals()->etfProfile($symbol);
|
||||
|
||||
Arr::set($fundamental, 'DividendYield', Arr::get($fundamental, 'dividend_yield'));
|
||||
Arr::set($fundamental, 'MarketCapitalization', Arr::get($fundamental, 'net_assets'));
|
||||
Arr::set($fundamental, 'InceptionDate', Arr::get($fundamental, 'inception_date'));
|
||||
}
|
||||
|
||||
return $fundamental;
|
||||
}
|
||||
);
|
||||
|
||||
return new Quote([
|
||||
'name' => Arr::get($fundamental, 'Name'),
|
||||
'name' => Arr::get($search, '2. name'),
|
||||
'symbol' => $symbol,
|
||||
'market_value' => Arr::get($quote, '05. price'),
|
||||
'fifty_two_week_high' => Arr::get($fundamental, '52WeekHigh'),
|
||||
'fifty_two_week_low' => Arr::get($fundamental, '52WeekLow'),
|
||||
'market_value' => (float) Arr::get($quote, '05. price'),
|
||||
'currency' => Arr::get($search, '8. currency'),
|
||||
'fifty_two_week_high' => (float) Arr::get($fundamental, '52WeekHigh'),
|
||||
'fifty_two_week_low' => (float) Arr::get($fundamental, '52WeekLow'),
|
||||
'forward_pe' => Arr::get($fundamental, 'ForwardPE'),
|
||||
'trailing_pe' => Arr::get($fundamental, 'TrailingPE'),
|
||||
'market_cap' => Arr::get($fundamental, 'MarketCapitalization'),
|
||||
@@ -48,8 +69,20 @@ class AlphaVantageMarketData implements MarketDataInterface
|
||||
? Arr::get($fundamental, 'DividendDate')
|
||||
: null,
|
||||
'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None'
|
||||
? Arr::get($fundamental, 'DividendYield')
|
||||
? Arr::get($fundamental, 'DividendYield') * 100
|
||||
: null,
|
||||
'meta_data' => [
|
||||
'industry' => Arr::get($fundamental, 'Industry'),
|
||||
'country' => Arr::get($search, '4. region'),
|
||||
'exchange' => Arr::get($fundamental, 'Exchange'),
|
||||
'description' => Arr::get($fundamental, 'Description'),
|
||||
'asset_type' => Arr::get($search, '3. type'),
|
||||
'sector' => Arr::get($fundamental, 'Sector'),
|
||||
'first_trade_year' => Arr::get($fundamental, 'InceptionDate')
|
||||
? Carbon::parse(Arr::get($fundamental, 'InceptionDate'))->format('Y')
|
||||
: null,
|
||||
'source' => 'alphavantage',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -107,7 +140,7 @@ class AlphaVantageMarketData implements MarketDataInterface
|
||||
})
|
||||
->mapWithKeys(function ($history, $date) use ($symbol) {
|
||||
|
||||
$date = Carbon::parse($date)->format('Y-m-d');
|
||||
$date = Carbon::parse($date)->toDateString();
|
||||
|
||||
return [$date => new Ohlc([
|
||||
'symbol' => $symbol,
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Interfaces\MarketData\Types\Dividend;
|
||||
use App\Interfaces\MarketData\Types\Ohlc;
|
||||
use App\Interfaces\MarketData\Types\Quote;
|
||||
use App\Interfaces\MarketData\Types\Split;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
@@ -25,6 +26,7 @@ class FakeMarketData implements MarketDataInterface
|
||||
return new Quote([
|
||||
'name' => 'ACME Company Ltd',
|
||||
'symbol' => $symbol,
|
||||
'currency' => 'USD',
|
||||
'market_value' => 230.19,
|
||||
'fifty_two_week_high' => 512.90,
|
||||
'fifty_two_week_low' => 341.20,
|
||||
@@ -34,6 +36,7 @@ class FakeMarketData implements MarketDataInterface
|
||||
'book_value' => 4.7,
|
||||
'last_dividend_date' => now()->subDays(45),
|
||||
'dividend_yield' => 0.033,
|
||||
'meta_data' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -65,7 +68,7 @@ class FakeMarketData implements MarketDataInterface
|
||||
return collect([
|
||||
new Split([
|
||||
'symbol' => $symbol,
|
||||
'date' => now()->subMonths(36),
|
||||
'date' => now()->subMonths(12),
|
||||
'split_amount' => 10,
|
||||
]),
|
||||
]);
|
||||
@@ -73,16 +76,27 @@ class FakeMarketData implements MarketDataInterface
|
||||
|
||||
public function history(string $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
$numDays = Carbon::parse($startDate)->diffInDays($endDate, true);
|
||||
$endDate = now()->isBefore(Carbon::parse(config('investbrain.daily_change_time_of_day')))
|
||||
? now()->subDay()
|
||||
: now();
|
||||
|
||||
for ($i = 0; $i < $numDays; $i++) {
|
||||
$days = CarbonPeriod::create($startDate, $endDate)->filter('isWeekday');
|
||||
|
||||
$date = now()->subDays($i)->format('Y-m-d');
|
||||
$countOfDays = $days->count();
|
||||
|
||||
foreach ($days as $index => $date) {
|
||||
|
||||
$date = $date->toDateString();
|
||||
|
||||
$series[$date] = new Ohlc([
|
||||
'symbol' => $symbol,
|
||||
'date' => $date,
|
||||
'close' => rand(150, 400),
|
||||
'open' => rand(150, 400),
|
||||
'high' => rand(150, 400),
|
||||
'low' => rand(150, 400),
|
||||
'close' => $index == $countOfDays - 1
|
||||
? 230.19 // most recent close should match current market value
|
||||
: rand(150, 400),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,10 @@ class FallbackInterface
|
||||
foreach ($providers as $provider) {
|
||||
|
||||
$provider = trim($provider);
|
||||
$symbol = $arguments[0];
|
||||
|
||||
try {
|
||||
Log::warning("Calling method {$method} ({$provider})");
|
||||
Log::info("Calling method {$method} for {$symbol} ({$provider})");
|
||||
|
||||
if (! in_array($provider, array_keys(config('investbrain.interfaces', [])))) {
|
||||
|
||||
@@ -35,17 +36,17 @@ class FallbackInterface
|
||||
|
||||
$this->latest_error = $e->getMessage();
|
||||
|
||||
Log::warning("Failed calling method {$method} ({$provider}): {$this->latest_error}");
|
||||
Log::error("Failed calling method {$method} for {$symbol} ({$provider}): {$this->latest_error}");
|
||||
}
|
||||
}
|
||||
|
||||
// don't need to throw error if calling exists
|
||||
// don't need to throw error if calling exists method...
|
||||
if ($method == 'exists') {
|
||||
|
||||
// symbol prob just doesn't exist
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new \Exception("Could not get market data: {$this->latest_error}");
|
||||
throw new \Exception("Could not get market data calling method {$method}: {$this->latest_error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Interfaces\MarketData\Types\Dividend;
|
||||
use App\Interfaces\MarketData\Types\Ohlc;
|
||||
use App\Interfaces\MarketData\Types\Quote;
|
||||
use App\Interfaces\MarketData\Types\Split;
|
||||
use Finnhub\ObjectSerializer;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -35,32 +36,46 @@ class FinnhubMarketData implements MarketDataInterface
|
||||
{
|
||||
$quote = $this->client->quote($symbol);
|
||||
|
||||
if (is_null(Arr::get($quote, 'd'))) {
|
||||
throw new \Exception('Could not find ticker on Finnhub');
|
||||
}
|
||||
|
||||
$fundamental = cache()->remember(
|
||||
'fh-symbol-'.$symbol,
|
||||
1440,
|
||||
function () use ($symbol) {
|
||||
return $this->client->companyBasicFinancials($symbol, 'all');
|
||||
|
||||
return array_merge(
|
||||
(array) ObjectSerializer::sanitizeForSerialization($this->client->companyProfile2($symbol)),
|
||||
(array) ObjectSerializer::sanitizeForSerialization($this->client->companyBasicFinancials($symbol, 'all')),
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return new Quote([
|
||||
'name' => Arr::get($fundamental, 'metric.name'),
|
||||
'name' => Arr::get($fundamental, 'name'),
|
||||
'symbol' => $symbol,
|
||||
'currency' => Arr::get($fundamental, 'currency'),
|
||||
'market_value' => Arr::get($quote, 'c'),
|
||||
'fifty_two_week_high' => Arr::get($fundamental, 'metric.52WeekHigh'),
|
||||
'fifty_two_week_low' => Arr::get($fundamental, 'metric.52WeekLow'),
|
||||
'forward_pe' => Arr::get($fundamental, 'metric.forwardPE'), // confirm
|
||||
'trailing_pe' => Arr::get($fundamental, 'metric.trailingPE'), // confirm
|
||||
'market_cap' => Arr::get($fundamental, 'metric.marketCapitalization'), // confirm
|
||||
'book_value' => Arr::get($fundamental, 'metric.bookValuePerShare'), // confirm
|
||||
'last_dividend_date' => Arr::get($fundamental, 'metric.lastDivDate'), // confirm
|
||||
'dividend_yield' => Arr::get($fundamental, 'metric.dividendYield'), // confirm
|
||||
'forward_pe' => Arr::get($fundamental, 'metric.peAnnual'),
|
||||
'trailing_pe' => Arr::get($fundamental, 'metric.peTTM'),
|
||||
'market_cap' => Arr::get($fundamental, 'metric.marketCapitalization', 0) * 1000000,
|
||||
'book_value' => Arr::get($fundamental, 'metric.bookValuePerShareAnnual'),
|
||||
'dividend_yield' => Arr::get($fundamental, 'metric.dividendYieldIndicatedAnnual'),
|
||||
'meta_data' => [
|
||||
'country' => Arr::get($fundamental, 'country'),
|
||||
'exchange' => Arr::get($fundamental, 'exchange'),
|
||||
'first_trade_year' => Arr::get($fundamental, 'ipo') ? Carbon::parse(Arr::get($fundamental, 'ipo'))->format('Y') : null,
|
||||
'source' => 'finnhub',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function dividends($symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
$dividends = $this->client->stockDividends($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d'));
|
||||
$dividends = $this->client->stockDividends($symbol, $startDate->toDateString(), $endDate->toDateString());
|
||||
|
||||
return collect($dividends)->map(function ($dividend) use ($symbol) {
|
||||
|
||||
@@ -75,7 +90,7 @@ class FinnhubMarketData implements MarketDataInterface
|
||||
public function splits($symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
|
||||
$splits = $this->client->stockSplits($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d'));
|
||||
$splits = $this->client->stockSplits($symbol, $startDate->toDateString(), $endDate->toDateString());
|
||||
|
||||
return collect($splits)->map(function ($split) use ($symbol) {
|
||||
|
||||
@@ -96,7 +111,7 @@ class FinnhubMarketData implements MarketDataInterface
|
||||
$closes = Arr::get($history, 'c', []);
|
||||
|
||||
return collect($timestamps)->mapWithKeys(function ($timestamp, $index) use ($symbol, $closes) {
|
||||
$date = Carbon::createFromTimestamp($timestamp)->format('Y-m-d');
|
||||
$date = Carbon::createFromTimestamp($timestamp)->toDateString();
|
||||
|
||||
return [$date => new Ohlc([
|
||||
'symbol' => $symbol,
|
||||
|
||||
@@ -21,7 +21,7 @@ class Dividend extends MarketDataType
|
||||
return $this->items['symbol'] ?? '';
|
||||
}
|
||||
|
||||
public function setDividendAmount($dividendAmount): self
|
||||
public function setDividendAmount(int|float $dividendAmount): self
|
||||
{
|
||||
$this->items['dividend_amount'] = (float) $dividendAmount;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Interfaces\MarketData\Types;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -12,24 +13,79 @@ class MarketDataType extends Collection
|
||||
public function __construct($items = [])
|
||||
{
|
||||
|
||||
foreach ($this->getArrayableItems($items) as $key => $value) {
|
||||
$items = $this->getArrayableItems($items);
|
||||
|
||||
$this->{$key} = $value;
|
||||
foreach ($items as $key => $value) {
|
||||
|
||||
$this->validateRequiredTypes($key, $value);
|
||||
|
||||
if (! is_null($value)) {
|
||||
$this->{$key} = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function toArray()
|
||||
{
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
public function __set($key, $value)
|
||||
{
|
||||
$this->{'set'.Str::studly($key)}($value);
|
||||
|
||||
$this->{$this->getSetMethodName($key)}($value);
|
||||
}
|
||||
|
||||
public function __get($key)
|
||||
{
|
||||
return $this->items[$key] ?? null;
|
||||
}
|
||||
|
||||
protected function getSetMethodName($key): string
|
||||
{
|
||||
return 'set'.Str::studly($key);
|
||||
}
|
||||
|
||||
protected function validateRequiredTypes($key, $value, $type = null): void
|
||||
{
|
||||
$method = new \ReflectionMethod($this, $this->getSetMethodName($key));
|
||||
$params = $method->getParameters();
|
||||
|
||||
// no required type
|
||||
if (is_null($type) && is_null($type = $params[0]->getType())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// can`t validate a mixed type
|
||||
if ($type == 'mixed') {
|
||||
return;
|
||||
}
|
||||
|
||||
// has a union type, let's iterate
|
||||
if ($type instanceof \ReflectionUnionType) {
|
||||
|
||||
foreach ($type->getTypes() as $subType) {
|
||||
$expected[] = $subType;
|
||||
|
||||
try {
|
||||
$this->validateRequiredTypes($key, $value, $subType);
|
||||
|
||||
return;
|
||||
} catch (\InvalidArgumentException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check type
|
||||
if ($type instanceof \ReflectionNamedType) {
|
||||
$expected = $type->getName();
|
||||
|
||||
if (get_debug_type($value) == $expected || ($type->allowsNull() && $value === null)) {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (class_exists($expected) && is_subclass_of(get_debug_type($value), $expected)) {
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException("Invalid type for {$key}. Expected ".implode('|', array_map(fn ($t) => $t, Arr::wrap($expected))).' but got '.get_debug_type($value));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class Ohlc extends MarketDataType
|
||||
return $this->items['symbol'] ?? '';
|
||||
}
|
||||
|
||||
public function setOpen($open): self
|
||||
public function setOpen(int|float $open): self
|
||||
{
|
||||
$this->items['open'] = (float) $open;
|
||||
|
||||
@@ -33,7 +33,7 @@ class Ohlc extends MarketDataType
|
||||
return $this->items['open'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setHigh($high): self
|
||||
public function setHigh(int|float $high): self
|
||||
{
|
||||
$this->items['high'] = (float) $high;
|
||||
|
||||
@@ -45,7 +45,7 @@ class Ohlc extends MarketDataType
|
||||
return $this->items['high'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setLow($low): self
|
||||
public function setLow(int|float $low): self
|
||||
{
|
||||
$this->items['low'] = (float) $low;
|
||||
|
||||
@@ -57,7 +57,7 @@ class Ohlc extends MarketDataType
|
||||
return $this->items['low'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setClose($close): self
|
||||
public function setClose(int|float $close): self
|
||||
{
|
||||
$this->items['close'] = (float) $close;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Interfaces\MarketData\Types;
|
||||
|
||||
use DateTime;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class Quote extends MarketDataType
|
||||
@@ -35,7 +36,19 @@ class Quote extends MarketDataType
|
||||
return $this->items['symbol'] ?? '';
|
||||
}
|
||||
|
||||
public function setMarketValue($marketValue): self
|
||||
public function setCurrency(string $currency): self
|
||||
{
|
||||
$this->items['currency'] = strtoupper((string) $currency);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCurrency(): string
|
||||
{
|
||||
return $this->items['currency'] ?? '';
|
||||
}
|
||||
|
||||
public function setMarketValue(int|float $marketValue): self
|
||||
{
|
||||
$this->items['market_value'] = (float) $marketValue;
|
||||
|
||||
@@ -97,6 +110,7 @@ class Quote extends MarketDataType
|
||||
|
||||
public function setMarketCap($cap): self
|
||||
{
|
||||
// return $this;
|
||||
$this->items['market_cap'] = (int) $cap;
|
||||
|
||||
return $this;
|
||||
@@ -119,6 +133,18 @@ class Quote extends MarketDataType
|
||||
return $this->items['book_value'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setLastDividendAmount($value): self
|
||||
{
|
||||
$this->items['last_dividend_amount'] = (float) $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastDividendAmount(): float
|
||||
{
|
||||
return $this->items['last_dividend_amount'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setLastDividendDate(mixed $date): self
|
||||
{
|
||||
$this->items['last_dividend_date'] = is_null($date) ? null : Carbon::parse($date)->format('Y-m-d H:i:s');
|
||||
@@ -142,4 +168,28 @@ class Quote extends MarketDataType
|
||||
{
|
||||
return $this->items['dividend_yield'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setMetaData(array $meta_data): self
|
||||
{
|
||||
$defaults = [
|
||||
'sector' => null,
|
||||
'industry' => null,
|
||||
'country' => null,
|
||||
'exchange' => null,
|
||||
'description' => null,
|
||||
'asset_type' => null,
|
||||
'first_trade_year' => null,
|
||||
'source' => null,
|
||||
];
|
||||
|
||||
// merges the NEW values with highest priority over previous values and defaults
|
||||
$this->items['meta_data'] = array_merge($defaults, $this->items['meta_data'] ?? [], Arr::skipEmptyValues($meta_data));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMetaData(): array
|
||||
{
|
||||
return $this->items['meta_data'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class Split extends MarketDataType
|
||||
return $this->items['symbol'] ?? '';
|
||||
}
|
||||
|
||||
public function setSplitAmount($splitAmount): self
|
||||
public function setSplitAmount(int|float $splitAmount): self
|
||||
{
|
||||
$this->items['split_amount'] = (float) $splitAmount;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Interfaces\MarketData\Types\Dividend;
|
||||
use App\Interfaces\MarketData\Types\Ohlc;
|
||||
use App\Interfaces\MarketData\Types\Quote;
|
||||
use App\Interfaces\MarketData\Types\Split;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Scheb\YahooFinanceApi\ApiClient;
|
||||
use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance;
|
||||
@@ -34,9 +35,14 @@ class YahooMarketData implements MarketDataInterface
|
||||
|
||||
$quote = $this->client->getQuote($symbol);
|
||||
|
||||
if (is_null($quote?->getRegularMarketPrice())) {
|
||||
throw new \Exception('Could not find ticker on Yahoo');
|
||||
}
|
||||
|
||||
return new Quote([
|
||||
'name' => $quote?->getLongName() ?? $quote?->getShortName(),
|
||||
'symbol' => $symbol,
|
||||
'currency' => $quote?->getCurrency(),
|
||||
'market_value' => $quote?->getRegularMarketPrice(),
|
||||
'fifty_two_week_high' => $quote?->getFiftyTwoWeekHigh(),
|
||||
'fifty_two_week_low' => $quote?->getFiftyTwoWeekLow(),
|
||||
@@ -46,6 +52,11 @@ class YahooMarketData implements MarketDataInterface
|
||||
'book_value' => $quote?->getBookValue(),
|
||||
'last_dividend_date' => $quote?->getDividendDate(),
|
||||
'dividend_yield' => $quote?->getTrailingAnnualDividendYield() * 100,
|
||||
'meta_data' => [
|
||||
'exchange' => $quote?->getExchange(),
|
||||
'asset_type' => $quote?->getQuoteType(),
|
||||
'source' => 'yahoo',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -84,7 +95,7 @@ class YahooMarketData implements MarketDataInterface
|
||||
return collect($this->client->getHistoricalQuoteData($symbol, ApiClient::INTERVAL_1_DAY, $startDate, $endDate))
|
||||
->mapWithKeys(function ($history) use ($symbol) {
|
||||
|
||||
$date = $history->getDate()->format('Y-m-d');
|
||||
$date = Carbon::parse($history->getDate())->toDateString();
|
||||
|
||||
return [$date => new Ohlc([
|
||||
'symbol' => $symbol,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\CurrencyRate;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class QueuedCurrencyRateInsertJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
public $tries = 3;
|
||||
|
||||
public function __construct(
|
||||
protected array $chunk
|
||||
) {
|
||||
$this->chunk = $chunk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
|
||||
CurrencyRate::insertOrIgnore($this->chunk);
|
||||
}
|
||||
}
|
||||
@@ -50,4 +50,9 @@ class BackupImport extends Model
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Number;
|
||||
|
||||
class Currency extends Model
|
||||
{
|
||||
protected $hidden = [];
|
||||
|
||||
protected $primaryKey = 'currency';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $fillable = [
|
||||
'currency',
|
||||
'label',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public static function forHumans(int|float $number, ?string $currency = null, ?string $locale = null): string
|
||||
{
|
||||
$symbol = Number::currencySymbol($currency, $locale);
|
||||
|
||||
return $symbol.Number::forHumans($number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of supported currencies
|
||||
*
|
||||
* @param bool|null $withAliases Whether to include aliases in list of currencies
|
||||
*/
|
||||
public static function list(?bool $withAliases = true): Collection
|
||||
{
|
||||
$aliases = $withAliases ? collect(config('investbrain.currency_aliases'))->map(function ($value, $currency) {
|
||||
return [
|
||||
'currency' => $currency,
|
||||
'label' => $value['label'],
|
||||
];
|
||||
})->values() : collect();
|
||||
|
||||
return $aliases->merge(self::get()->map->only(['currency', 'label']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts between supported currencies
|
||||
*
|
||||
* @param string|null $to (defaults to base currency)
|
||||
*/
|
||||
public static function convert(?float $value, string $from, ?string $to = null, mixed $date = null): float
|
||||
{
|
||||
if (empty($value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Assume converting to base
|
||||
if (empty($to)) {
|
||||
$to = config('investbrain.base_currency');
|
||||
}
|
||||
|
||||
// Get rate
|
||||
[$from, $to] = [
|
||||
cache()->remember($from.'_rate_'.$date, 10, function () use ($from, $date) {
|
||||
return CurrencyRate::historic($from, $date);
|
||||
}),
|
||||
cache()->remember($to.'_rate_'.$date, 10, function () use ($to, $date) {
|
||||
return CurrencyRate::historic($to, $date);
|
||||
}),
|
||||
];
|
||||
|
||||
// get from rate
|
||||
$rate_to_base = 1 / $from;
|
||||
|
||||
// get value in base currency
|
||||
$base_currency_value = $value * $rate_to_base;
|
||||
|
||||
return (float) $base_currency_value * $to;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Jobs\QueuedCurrencyRateInsertJob;
|
||||
use Carbon\CarbonInterface;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Investbrain\Frankfurter\Frankfurter;
|
||||
|
||||
class CurrencyRate extends Model
|
||||
{
|
||||
protected $hidden = [];
|
||||
|
||||
protected $primaryKey = 'currency';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $fillable = [
|
||||
'date',
|
||||
'currency',
|
||||
'rate',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'rate' => 'float',
|
||||
'date' => 'date',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public static function current(string $currency): float
|
||||
{
|
||||
return (float) self::historic($currency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historic rate for symbol
|
||||
*/
|
||||
public static function historic(string $currency, mixed $date = null): float
|
||||
{
|
||||
// No need to convert
|
||||
if ($currency === config('investbrain.base_currency')) {
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// If we don't need historic, let's use current rate
|
||||
if (empty($date)) {
|
||||
|
||||
$date = now();
|
||||
}
|
||||
|
||||
// Make sure we have a Carbon date
|
||||
$date = Carbon::parse($date);
|
||||
|
||||
// Handle aliases
|
||||
[$currency, $adjustment] = self::getCurrencyAliasAdjustments($currency);
|
||||
|
||||
// Get or create historic rate
|
||||
$rate = self::select('rate')
|
||||
->whereDate('date', $date->toDateString())
|
||||
->where(['currency' => $currency])
|
||||
->firstOr(function () use ($date, $currency) {
|
||||
|
||||
$currencies = Currency::all()->pluck('currency')->toArray();
|
||||
|
||||
$rates = Frankfurter::setSymbols($currencies)->historical($date);
|
||||
|
||||
$date = Arr::get($rates, 'date');
|
||||
|
||||
$updates = Arr::map(Arr::get($rates, 'rates', []), function ($rate, $curr) use ($date) {
|
||||
|
||||
return [
|
||||
'currency' => $curr,
|
||||
'date' => $date,
|
||||
'rate' => $rate,
|
||||
'updated_at' => now()->toDateTimeString(),
|
||||
'created_at' => now()->toDateTimeString(),
|
||||
];
|
||||
});
|
||||
|
||||
// persist
|
||||
self::chunkInsert($updates);
|
||||
|
||||
return new CurrencyRate(Arr::first($updates, fn ($update) => $update['currency'] == $currency) ?? ['rate' => 1]);
|
||||
});
|
||||
|
||||
return (float) $rate->rate * $adjustment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rates for range of dates
|
||||
*
|
||||
* @return array<string, float>
|
||||
*/
|
||||
public static function timeSeriesRates(string $currency, mixed $start = null, mixed $end = null): array
|
||||
{
|
||||
if (empty($start)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$end = $end ?? now();
|
||||
|
||||
dump('Creating period');
|
||||
|
||||
$period = CarbonPeriod::create($start, $end);
|
||||
|
||||
// No need to send network request - just generate 1s
|
||||
if ($currency === config('investbrain.base_currency')) {
|
||||
|
||||
dump('same curr');
|
||||
|
||||
$dateRange = [];
|
||||
foreach ($period as $date) {
|
||||
|
||||
$dateRange[$date->toDateString()] = 1;
|
||||
}
|
||||
|
||||
return $dateRange;
|
||||
}
|
||||
|
||||
dump('diff curr');
|
||||
|
||||
[$currency, $adjustment] = self::getCurrencyAliasAdjustments($currency);
|
||||
|
||||
$currencies = Currency::all()->pluck('currency')->toArray();
|
||||
|
||||
dump('got currencies');
|
||||
|
||||
// call api in chunks
|
||||
foreach (collect($period)->chunk(500) as $chunk) {
|
||||
|
||||
dump('calling frankf time series');
|
||||
|
||||
$chunkRates = Frankfurter::setSymbols($currencies)->timeSeries($chunk->min(), $chunk->max());
|
||||
|
||||
$rates = Arr::get($chunkRates, 'rates', []);
|
||||
|
||||
// loop through each date
|
||||
$updates = [];
|
||||
|
||||
foreach ($chunk as $date) {
|
||||
|
||||
$lookupDate = self::getNearestPastDate($date, $rates);
|
||||
|
||||
if (is_null($lookupDate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// loop through each rate
|
||||
foreach ($rates[$lookupDate->toDateString()] as $curr => $rate) {
|
||||
|
||||
// add to updates
|
||||
$updates[] = [
|
||||
'currency' => $curr,
|
||||
'date' => $date->toDateString(),
|
||||
'rate' => $rate,
|
||||
'updated_at' => now()->toDateTimeString(),
|
||||
'created_at' => now()->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
dump('inserting');
|
||||
|
||||
// persist
|
||||
self::chunkInsert($updates);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
dump('done');
|
||||
|
||||
return collect($updates)
|
||||
->whereBetween('date', [$start, $end ?? now()])
|
||||
->where('currency', $currency)
|
||||
->mapWithKeys(fn ($rate) => [
|
||||
$rate['date'] => $rate['rate'] * $adjustment,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
private static function getNearestPastDate(CarbonInterface $date, array $rates): ?CarbonInterface
|
||||
{
|
||||
|
||||
$datesWithRates = array_keys($rates);
|
||||
sort($datesWithRates);
|
||||
|
||||
// get rates or find closest valid rate (handles missing weekend rates)
|
||||
while (! isset($rates[$date->toDateString()])) {
|
||||
|
||||
// is this the start of a range that falls on a weekend?
|
||||
if ($date->lessThan($first_date = Carbon::parse($datesWithRates[0]))) {
|
||||
|
||||
$date = $first_date;
|
||||
break;
|
||||
}
|
||||
|
||||
// try the day before then
|
||||
$date = Carbon::parse($date)->subDay();
|
||||
|
||||
// prevent runaway infinite loops
|
||||
if ($date->lessThan($date->copy()->subWeek())) {
|
||||
|
||||
$date = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $date;
|
||||
}
|
||||
|
||||
public static function refreshCurrencyData($force = false): void
|
||||
{
|
||||
$currencies = Currency::all()->pluck('currency')->toArray();
|
||||
|
||||
$rates = Frankfurter::setBaseCurrency(config('investbrain.base_currency'))
|
||||
->setSymbols($currencies)
|
||||
->latest();
|
||||
|
||||
$updates = [];
|
||||
foreach (Arr::get($rates, 'rates', []) as $currency => $rate) {
|
||||
|
||||
// update currency
|
||||
$updates[] = [
|
||||
'date' => now()->toDateString(),
|
||||
'currency' => $currency,
|
||||
'rate' => $rate,
|
||||
];
|
||||
}
|
||||
|
||||
// nothing to update
|
||||
if (empty($updates)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($force) {
|
||||
|
||||
// force overwrite existing rates
|
||||
CurrencyRate::upsert($updates, ['currency', 'date'], ['rate']);
|
||||
} else {
|
||||
|
||||
// only insert new rates
|
||||
CurrencyRate::insertOrIgnore($updates);
|
||||
}
|
||||
}
|
||||
|
||||
public static function chunkInsert(array $updates): void
|
||||
{
|
||||
|
||||
$chunks = array_chunk($updates, 500);
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
|
||||
QueuedCurrencyRateInsertJob::dispatch($chunk);
|
||||
}
|
||||
}
|
||||
|
||||
protected static function getCurrencyAliasAdjustments($currency)
|
||||
{
|
||||
$adjustment = 1;
|
||||
|
||||
if (array_key_exists($currency, config('investbrain.currency_aliases', []))) {
|
||||
|
||||
$config = config('investbrain.currency_aliases.'.$currency);
|
||||
|
||||
$adjustment = $config['adjustment'];
|
||||
$currency = $config['alias_of'];
|
||||
}
|
||||
|
||||
return [$currency, $adjustment];
|
||||
}
|
||||
}
|
||||
+142
-5
@@ -7,6 +7,7 @@ namespace App\Models;
|
||||
use App\Traits\HasCompositePrimaryKey;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DailyChange extends Model
|
||||
{
|
||||
@@ -22,10 +23,6 @@ class DailyChange extends Model
|
||||
'portfolio_id',
|
||||
'date',
|
||||
'total_market_value',
|
||||
'total_cost_basis',
|
||||
'total_gain',
|
||||
'total_dividends_earned',
|
||||
'realized_gains',
|
||||
'notes',
|
||||
];
|
||||
|
||||
@@ -33,11 +30,16 @@ class DailyChange extends Model
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'datetime',
|
||||
'total_market_value' => 'float',
|
||||
'total_cost_basis' => 'float',
|
||||
'total_gain' => 'float',
|
||||
'realized_gain_dollars' => 'float',
|
||||
'total_dividends_earned' => 'float',
|
||||
];
|
||||
|
||||
public function scopePortfolio($query, $portfolio)
|
||||
{
|
||||
return $query->where('portfolio_id', $portfolio);
|
||||
return $query->where('daily_change.portfolio_id', $portfolio);
|
||||
}
|
||||
|
||||
public function scopeMyDailyChanges()
|
||||
@@ -56,6 +58,141 @@ class DailyChange extends Model
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeWithDailyPerformance($query)
|
||||
{
|
||||
$currency = auth()->user()?->getCurrency() ?? config('investbrain.base_currency');
|
||||
|
||||
$dividendSub = DB::table('holdings')
|
||||
->join('dividends', 'dividends.symbol', '=', 'holdings.symbol')
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join->on('cr.date', '=', 'dividends.date')
|
||||
->where('cr.currency', '=', $currency);
|
||||
})
|
||||
->join('transactions as tx', function ($join) {
|
||||
$join->on('tx.symbol', '=', 'holdings.symbol')
|
||||
->on('tx.portfolio_id', '=', 'holdings.portfolio_id')
|
||||
->whereColumn('tx.date', '<=', 'dividends.date');
|
||||
})
|
||||
->select(['holdings.portfolio_id', 'dividends.date'])
|
||||
->selectRaw("
|
||||
((CASE WHEN tx.transaction_type = 'BUY'
|
||||
THEN tx.quantity ELSE 0 END)
|
||||
- (CASE WHEN tx.transaction_type = 'SELL'
|
||||
THEN tx.quantity ELSE 0 END))
|
||||
* SUM(
|
||||
dividends.dividend_amount_base
|
||||
* COALESCE(cr.rate, 1)
|
||||
)
|
||||
AS total_dividends_earned")
|
||||
->groupBy(['holdings.portfolio_id', 'dividends.date', 'tx.transaction_type', 'tx.quantity']);
|
||||
|
||||
$totalCostBasisSub = DB::table('transactions as tx1')
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join->on('cr.date', '=', 'tx1.date')
|
||||
->where('cr.currency', '=', $currency);
|
||||
})
|
||||
->select([
|
||||
'tx1.portfolio_id',
|
||||
'tx1.date',
|
||||
'tx1.symbol',
|
||||
'tx1.transaction_type',
|
||||
'tx1.quantity',
|
||||
])
|
||||
->selectRaw("(CASE
|
||||
WHEN tx1.transaction_type = 'BUY'
|
||||
THEN COALESCE(cr.rate, 1)
|
||||
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 = tx1.symbol
|
||||
AND buy.portfolio_id = tx1.portfolio_id
|
||||
AND buy.transaction_type = 'BUY'
|
||||
AND buy.date <= tx1.date
|
||||
) END)
|
||||
AS rate")
|
||||
->selectRaw(
|
||||
"(CASE
|
||||
WHEN tx1.transaction_type = 'BUY'
|
||||
THEN AVG(tx1.cost_basis_base)
|
||||
ELSE (
|
||||
SELECT
|
||||
AVG(-buy.cost_basis_base)
|
||||
FROM transactions as buy
|
||||
WHERE buy.symbol = tx1.symbol
|
||||
AND buy.portfolio_id = tx1.portfolio_id
|
||||
AND buy.transaction_type = 'BUY'
|
||||
AND buy.date <= tx1.date
|
||||
) END)
|
||||
AS cost_basis_base")
|
||||
->selectRaw(
|
||||
"(CASE
|
||||
WHEN tx1.transaction_type = 'SELL'
|
||||
THEN tx1.sale_price_base - tx1.cost_basis_base
|
||||
ELSE 0 END)
|
||||
* tx1.quantity
|
||||
* COALESCE(cr.rate, 1)
|
||||
AS realized_gain_dollars")
|
||||
->groupBy([
|
||||
'tx1.portfolio_id',
|
||||
'tx1.date',
|
||||
'tx1.symbol',
|
||||
'tx1.transaction_type',
|
||||
'tx1.cost_basis_base',
|
||||
'tx1.quantity',
|
||||
'cr.rate',
|
||||
'tx1.sale_price_base',
|
||||
]);
|
||||
|
||||
return $query
|
||||
->select(['daily_change.date', 'daily_change.portfolio_id'])
|
||||
->leftJoinSub($totalCostBasisSub, 'cost_basis_display', function ($join) {
|
||||
$join->on('daily_change.date', '>=', 'cost_basis_display.date')
|
||||
->whereColumn('daily_change.portfolio_id', '=', 'cost_basis_display.portfolio_id');
|
||||
})
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join->on('cr.date', '=', 'daily_change.date')
|
||||
->where('cr.currency', '=', $currency);
|
||||
})
|
||||
->selectRaw('
|
||||
SUM(
|
||||
cost_basis_display.cost_basis_base
|
||||
* cost_basis_display.quantity
|
||||
* cost_basis_display.rate
|
||||
) as total_cost_basis')
|
||||
->selectRaw('(
|
||||
daily_change.total_market_value * COALESCE(cr.rate, 1)
|
||||
) - SUM(
|
||||
cost_basis_display.cost_basis_base
|
||||
* cost_basis_display.quantity
|
||||
* cost_basis_display.rate
|
||||
) as total_gain')
|
||||
->selectRaw('(
|
||||
daily_change.total_market_value * COALESCE(cr.rate, 1)
|
||||
) as total_market_value')
|
||||
->selectRaw('
|
||||
SUM(
|
||||
cost_basis_display.realized_gain_dollars
|
||||
) as realized_gain_dollars')
|
||||
->selectSub(function ($query) use ($dividendSub) {
|
||||
$query->fromSub($dividendSub, 'd')
|
||||
->selectRaw('SUM(d.total_dividends_earned)')
|
||||
->whereColumn('d.date', '<=', 'daily_change.date')
|
||||
->whereColumn('d.portfolio_id', '=', 'daily_change.portfolio_id');
|
||||
}, 'total_dividends_earned')
|
||||
->groupBy([
|
||||
'daily_change.date',
|
||||
'cr.rate',
|
||||
'daily_change.total_market_value',
|
||||
'daily_change.portfolio_id',
|
||||
])
|
||||
->orderBy('daily_change.date');
|
||||
}
|
||||
|
||||
public function portfolio()
|
||||
{
|
||||
return $this->belongsTo(Portfolio::class);
|
||||
|
||||
+60
-15
@@ -4,17 +4,24 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Actions\CopyToBaseCurrency;
|
||||
use App\Casts\BaseCurrency;
|
||||
use App\Interfaces\MarketData\MarketDataInterface;
|
||||
use App\Traits\HasMarketData;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Pipeline;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Dividend extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasMarketData;
|
||||
use HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
@@ -26,21 +33,32 @@ class Dividend extends Model
|
||||
protected $hidden = [];
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'datetime',
|
||||
'last_dividend_update' => 'datetime',
|
||||
'date' => 'date',
|
||||
'last_dividend_update' => 'date',
|
||||
'dividend_amount' => 'float',
|
||||
'dividend_amount_base' => BaseCurrency::class,
|
||||
];
|
||||
|
||||
public function marketData()
|
||||
protected static function boot()
|
||||
{
|
||||
return $this->belongsTo(MarketData::class, 'symbol', 'symbol');
|
||||
parent::boot();
|
||||
|
||||
static::saving(function ($dividend) {
|
||||
|
||||
$dividend = Pipeline::send($dividend)
|
||||
->through([
|
||||
CopyToBaseCurrency::class,
|
||||
])
|
||||
->then(fn (Dividend $dividend) => $dividend);
|
||||
});
|
||||
}
|
||||
|
||||
public function holdings()
|
||||
public function holdings(): HasMany
|
||||
{
|
||||
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
||||
}
|
||||
|
||||
public function transactions()
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
|
||||
}
|
||||
@@ -68,7 +86,7 @@ class Dividend extends Model
|
||||
// nope, refresh forward looking only
|
||||
if ($dividends_meta->total_dividends) {
|
||||
|
||||
$start_date = $dividends_meta->last_dividend_update->addHours(24);
|
||||
$start_date = $dividends_meta->last_dividend_update;
|
||||
}
|
||||
|
||||
// skip refresh if there's already recent data
|
||||
@@ -77,34 +95,61 @@ class Dividend extends Model
|
||||
return;
|
||||
}
|
||||
|
||||
// get some data
|
||||
if ($dividend_data = collect() && $start_date && $end_date) {
|
||||
$dividend_data = app(MarketDataInterface::class)->dividends($symbol, $start_date, $end_date);
|
||||
dump('1. getting div data for '.$symbol);
|
||||
try {
|
||||
|
||||
// get some data
|
||||
if ($dividend_data = collect() && $start_date && $end_date) {
|
||||
$dividend_data = app(MarketDataInterface::class)->dividends($symbol, $start_date, $end_date);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
dump('exception: '.$e->getMessage());
|
||||
}
|
||||
|
||||
dump('2. got div data for '.$symbol);
|
||||
|
||||
// ah, we found some dividends...
|
||||
if ($dividend_data->isNotEmpty()) {
|
||||
|
||||
$dividend_data = $dividend_data->sortBy('date');
|
||||
|
||||
dump('3. getting mkt data for '.$symbol);
|
||||
|
||||
$market_data = MarketData::getMarketData($symbol);
|
||||
|
||||
dump('4. got market data for '.$symbol);
|
||||
|
||||
// todo: use this for start_date - $dividend_data->first()->get('date')
|
||||
|
||||
// get historic conversion rates
|
||||
$rate_to_base = CurrencyRate::timeSeriesRates($market_data->currency, $dividend_data->first()->get('date'), $end_date);
|
||||
|
||||
dump('5. got time series for '.$symbol);
|
||||
// create mass insert
|
||||
foreach ($dividend_data as $index => $dividend) {
|
||||
$rate_to_base_date = 1 / Arr::get($rate_to_base, Carbon::parse(Arr::get($dividend, 'date'))->toDateString(), 1);
|
||||
|
||||
$dividend['dividend_amount_base'] = $dividend['dividend_amount'] * $rate_to_base_date;
|
||||
|
||||
$dividend_data[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
|
||||
}
|
||||
|
||||
// insert records
|
||||
(new self)->insert($dividend_data->toArray());
|
||||
(new self)->insertOrIgnore($dividend_data->toArray());
|
||||
|
||||
dump('6. inserted for '.$symbol);
|
||||
// sync to holdings
|
||||
self::syncHoldings($symbol);
|
||||
|
||||
// get market data
|
||||
$market_data = MarketData::firstOrNew(['symbol' => $symbol]);
|
||||
|
||||
// re-invest dividends
|
||||
self::reinvestDividends($dividend_data, $market_data);
|
||||
|
||||
// sync last dividend amount to market data table
|
||||
$market_data->last_dividend_amount = $dividend_data->sortByDesc('date')->first()['dividend_amount'];
|
||||
$market_data->save();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static function syncHoldings(string $symbol): void
|
||||
@@ -127,7 +172,7 @@ class Dividend extends Model
|
||||
")->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');
|
||||
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'dividends.dividend_amount_base');
|
||||
|
||||
$dividends = DB::table(DB::raw("({$subQuery->toSql()}) as sub"))
|
||||
->mergeBindings($subQuery->getQuery())
|
||||
|
||||
+215
-35
@@ -4,15 +4,18 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasMarketData;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Holding extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasMarketData;
|
||||
use HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
@@ -28,21 +31,24 @@ class Holding extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'reinvest_dividends' => 'boolean',
|
||||
'splits_synced_at' => 'datetime',
|
||||
'first_transaction_date' => 'datetime',
|
||||
'reinvest_dividends' => 'boolean',
|
||||
'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',
|
||||
];
|
||||
|
||||
/**
|
||||
* Market data for holding
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function market_data()
|
||||
{
|
||||
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
|
||||
}
|
||||
|
||||
/**
|
||||
* Related transactions for holding
|
||||
*
|
||||
@@ -61,7 +67,7 @@ class Holding extends Model
|
||||
public function dividends()
|
||||
{
|
||||
return $this->hasMany(Dividend::class, 'symbol', 'symbol')
|
||||
->select(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount'])
|
||||
->select(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount', 'dividends.dividend_amount_base'])
|
||||
->selectRaw("SUM(
|
||||
CASE WHEN transaction_type = 'BUY'
|
||||
AND transactions.symbol = dividends.symbol
|
||||
@@ -91,8 +97,21 @@ class Holding extends Model
|
||||
THEN transactions.quantity ELSE 0 END)
|
||||
* dividends.dividend_amount
|
||||
) AS total_received")
|
||||
->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")
|
||||
->join('transactions', 'transactions.symbol', 'dividends.symbol')
|
||||
->groupBy(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount'])
|
||||
->groupBy(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount', 'dividends.dividend_amount_base'])
|
||||
->orderBy('dividends.date', 'DESC')
|
||||
->where('dividends.date', '>=', function ($query) {
|
||||
$query->selectRaw('min(transactions.date)')
|
||||
@@ -118,7 +137,7 @@ class Holding extends Model
|
||||
THEN transactions.quantity
|
||||
ELSE 0
|
||||
END)
|
||||
) * dividends.dividend_amount > 0");
|
||||
) * dividends.dividend_amount_base > 0");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,12 +175,16 @@ class Holding extends Model
|
||||
{
|
||||
return $query->withAggregate('market_data', 'name')
|
||||
->withAggregate('market_data', 'market_value')
|
||||
->withAggregate('market_data', 'market_value_base')
|
||||
->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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate performance for holding in its local currency
|
||||
*/
|
||||
public function scopeWithPerformance($query)
|
||||
{
|
||||
return $query->selectRaw('COALESCE(market_data.market_value * holdings.quantity, 0) AS total_market_value')
|
||||
@@ -193,15 +216,169 @@ class Holding extends Model
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeWithPortfolioMetrics($query)
|
||||
/**
|
||||
* Scope which returns collection of performance metrics for holdings
|
||||
*
|
||||
* @param string $currency Allows casting to specified currency
|
||||
*/
|
||||
public function scopeGetPortfolioMetrics($query, $currency = null): Collection
|
||||
{
|
||||
return $query->selectRaw('COALESCE(SUM(holdings.dividends_earned), 0) AS total_dividends_earned')
|
||||
->selectRaw('COALESCE(SUM(holdings.realized_gain_dollars), 0) AS realized_gain_dollars')
|
||||
->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) AS total_market_value')
|
||||
->selectRaw('COALESCE(SUM(holdings.total_cost_basis), 0) AS total_cost_basis')
|
||||
->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) - COALESCE(SUM(holdings.total_cost_basis), 0) AS total_gain_dollars')
|
||||
// ->selectRaw('COALESCE((@total_gain_dollars / @sum_total_cost_basis) * 100,0) AS total_gain_percent')
|
||||
->join('market_data', 'market_data.symbol', '=', 'holdings.symbol');
|
||||
$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
|
||||
{
|
||||
$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
|
||||
WHEN transactions.transaction_type = 'BUY'
|
||||
THEN COALESCE(cr.rate, 1)
|
||||
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');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public function syncTransactionsAndDividends()
|
||||
@@ -209,14 +386,14 @@ class Holding extends Model
|
||||
// pull existing transaction data
|
||||
$query = Transaction::where([
|
||||
'portfolio_id' => $this->portfolio_id,
|
||||
'symbol' => $this->symbol,
|
||||
'transactions.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 = 'SELL' THEN (sale_price - cost_basis) * quantity ELSE 0 END) AS realized_gain_dollars")
|
||||
->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);
|
||||
$total_quantity = round($query->qty_purchases - $query->qty_sales, 4);
|
||||
|
||||
$average_cost_basis = (
|
||||
$query->qty_purchases > 0
|
||||
@@ -229,9 +406,7 @@ class Holding extends Model
|
||||
'quantity' => $total_quantity,
|
||||
'average_cost_basis' => $average_cost_basis,
|
||||
'total_cost_basis' => $total_quantity * $average_cost_basis,
|
||||
'realized_gain_dollars' => $query->qty_purchases > 0 && $query->total_sale_price > 0
|
||||
? $query->total_sale_price - ($query->qty_sales * ($query->total_cost_basis / $query->qty_purchases))
|
||||
: 0,
|
||||
'realized_gain_dollars' => $query->realized_gain_dollars ?? 0,
|
||||
'dividends_earned' => $this->dividends->sum('total_received'),
|
||||
]);
|
||||
|
||||
@@ -253,6 +428,11 @@ class Holding extends Model
|
||||
return $purchases - $sales;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method that enables calculating daily performance for a given holding
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function dailyPerformance(
|
||||
?\Illuminate\Support\Carbon $start_date = null,
|
||||
?\Illuminate\Support\Carbon $end_date = null,
|
||||
@@ -277,11 +457,11 @@ class Holding extends Model
|
||||
// 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
|
||||
SELECT '{$start_date->toDateString()}' AS date
|
||||
UNION ALL
|
||||
SELECT $date_interval
|
||||
FROM date_series
|
||||
WHERE date < '{$end_date->format('Y-m-d')}'
|
||||
WHERE date < '{$end_date->toDateString()}'
|
||||
)
|
||||
SELECT date_series.date
|
||||
FROM date_series
|
||||
@@ -292,8 +472,8 @@ class Holding extends Model
|
||||
|
||||
$timeSeriesQuery = DB::table(DB::raw("
|
||||
generate_series(
|
||||
date '{$start_date->format('Y-m-d')}',
|
||||
date '{$end_date->format('Y-m-d')}',
|
||||
date '{$start_date->toDateString()}',
|
||||
date '{$end_date->toDateString()}',
|
||||
interval '1 day'
|
||||
) as date_series"));
|
||||
|
||||
@@ -335,12 +515,12 @@ class Holding extends Model
|
||||
CASE
|
||||
WHEN ({$quantityQuery}) = 0 THEN 0
|
||||
ELSE SUM(CASE
|
||||
WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis
|
||||
WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis_base
|
||||
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"),
|
||||
DB::raw("COALESCE(SUM(CASE WHEN transaction_type = 'SELL' THEN ((sale_price_base - cost_basis_base) * quantity) ELSE 0 END), 0) AS realized_gains"),
|
||||
])
|
||||
->leftJoin('transactions', function ($join) {
|
||||
$join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date')
|
||||
@@ -357,7 +537,7 @@ class Holding extends Model
|
||||
{
|
||||
$formattedTransactions = '';
|
||||
foreach ($this->transactions->sortByDesc('date') as $transaction) {
|
||||
$formattedTransactions .= ' * '.$transaction->date->format('Y-m-d')
|
||||
$formattedTransactions .= ' * '.$transaction->date->toDateString()
|
||||
.' '.$transaction->transaction_type
|
||||
.' '.$transaction->quantity
|
||||
.' @ '.$transaction->cost_basis
|
||||
|
||||
@@ -4,9 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Actions\CopyToBaseCurrency;
|
||||
use App\Casts\BaseCurrency;
|
||||
use App\Interfaces\MarketData\MarketDataInterface;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Pipeline;
|
||||
|
||||
class MarketData extends Model
|
||||
{
|
||||
@@ -21,7 +24,9 @@ class MarketData extends Model
|
||||
protected $fillable = [
|
||||
'symbol',
|
||||
'name',
|
||||
'currency',
|
||||
'market_value',
|
||||
'market_value_base',
|
||||
'fifty_two_week_high',
|
||||
'fifty_two_week_low',
|
||||
'forward_pe',
|
||||
@@ -29,21 +34,40 @@ class MarketData extends Model
|
||||
'market_cap',
|
||||
'book_value',
|
||||
'last_dividend_date',
|
||||
'last_dividend_amount',
|
||||
'dividend_yield',
|
||||
'meta_data',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_dividend_date' => 'datetime',
|
||||
'market_value' => 'float',
|
||||
'market_value_base' => BaseCurrency::class,
|
||||
'fifty_two_week_high' => 'float',
|
||||
'fifty_two_week_low' => 'float',
|
||||
'forward_pe' => 'float',
|
||||
'trailing_pe' => 'float',
|
||||
'market_cap' => 'float',
|
||||
'market_cap' => 'integer',
|
||||
'book_value' => 'float',
|
||||
'last_dividend_date' => 'datetime',
|
||||
'last_dividend_amount' => 'float',
|
||||
'dividend_yield' => 'float',
|
||||
'meta_data' => 'json',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::saving(function ($market_data) {
|
||||
|
||||
$market_data = Pipeline::send($market_data)
|
||||
->through([
|
||||
CopyToBaseCurrency::class,
|
||||
])
|
||||
->then(fn (MarketData $market_data) => $market_data);
|
||||
});
|
||||
}
|
||||
|
||||
public function holdings()
|
||||
{
|
||||
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
||||
@@ -54,7 +78,7 @@ class MarketData extends Model
|
||||
return $query->where('symbol', $symbol);
|
||||
}
|
||||
|
||||
public static function getMarketData($symbol, $force = false)
|
||||
public static function getMarketData($symbol, $force = false): self
|
||||
{
|
||||
$market_data = self::firstOrNew([
|
||||
'symbol' => $symbol,
|
||||
|
||||
@@ -136,6 +136,9 @@ class Portfolio extends Model
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes daily change history for a portfolio to the database
|
||||
*/
|
||||
public function syncDailyChanges(): void
|
||||
{
|
||||
$holdings = $this->holdings()
|
||||
@@ -147,11 +150,9 @@ class Portfolio extends Model
|
||||
->groupBy(['holdings.symbol', 'holdings.portfolio_id'])
|
||||
->get();
|
||||
|
||||
$dividends = Dividend::whereIn('symbol', $holdings->pluck('symbol'))->get();
|
||||
|
||||
$total_performance = [];
|
||||
|
||||
$holdings->each(function ($holding) use (&$total_performance, $dividends) {
|
||||
$holdings->each(function ($holding) use (&$total_performance) {
|
||||
|
||||
$period = CarbonPeriod::create(
|
||||
$holding->first_transaction_date,
|
||||
@@ -160,34 +161,25 @@ class Portfolio extends Model
|
||||
: now()
|
||||
);
|
||||
|
||||
$holding->setRelation('dividends', $dividends->where('symbol', $holding->symbol));
|
||||
|
||||
$daily_performance = $holding->dailyPerformance($holding->first_transaction_date, now());
|
||||
$dividends = $holding->dividends->keyBy(function ($dividend, $key) {
|
||||
return $dividend['date']->format('Y-m-d');
|
||||
});
|
||||
$all_history = app(MarketDataInterface::class)->history($holding->symbol, $holding->first_transaction_date, now());
|
||||
$currency_rates = CurrencyRate::timeSeriesRates($holding->market_data->currency, $holding->first_transaction_date, now());
|
||||
|
||||
$dividends_earned = 0;
|
||||
$holding_performance = [];
|
||||
|
||||
foreach ($period as $date) {
|
||||
$date = $date->format('Y-m-d');
|
||||
$date = $date->toDateString();
|
||||
|
||||
$close = $this->getMostRecentCloseData($all_history, $date);
|
||||
|
||||
$total_market_value = $daily_performance->get($date)->owned * $close;
|
||||
$dividends_earned += $daily_performance->get($date)->owned * ($dividends->get($date)?->dividend_amount ?? 0);
|
||||
|
||||
if (Carbon::parse($date)->isWeekday()) {
|
||||
|
||||
$holding_performance[$date] = [
|
||||
'date' => $date,
|
||||
'portfolio_id' => $this->id,
|
||||
'total_market_value' => $total_market_value,
|
||||
'total_cost_basis' => $daily_performance->get($date)->cost_basis,
|
||||
'total_gain' => $total_market_value - $daily_performance->get($date)->cost_basis,
|
||||
'realized_gains' => $daily_performance->get($date)->realized_gains,
|
||||
'total_dividends_earned' => $dividends_earned,
|
||||
'total_market_value' => $total_market_value * (1 / Arr::get($currency_rates, $date, 1)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -200,10 +192,6 @@ class Portfolio extends Model
|
||||
} 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'];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -221,10 +209,6 @@ class Portfolio extends Model
|
||||
['date', 'portfolio_id'],
|
||||
[
|
||||
'total_market_value',
|
||||
'total_cost_basis',
|
||||
'total_gain',
|
||||
'realized_gains',
|
||||
'total_dividends_earned',
|
||||
]
|
||||
);
|
||||
});
|
||||
@@ -239,7 +223,7 @@ class Portfolio extends Model
|
||||
|
||||
$i++;
|
||||
|
||||
$date = Carbon::parse($date)->subDay()->format('Y-m-d');
|
||||
$date = Carbon::parse($date)->subDay()->toDateString();
|
||||
|
||||
return $this->getMostRecentCloseData($history, $date, $i);
|
||||
}
|
||||
|
||||
@@ -5,15 +5,18 @@ declare(strict_types=1);
|
||||
namespace App\Models;
|
||||
|
||||
use App\Interfaces\MarketData\MarketDataInterface;
|
||||
use App\Traits\HasMarketData;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Split extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasMarketData;
|
||||
use HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
@@ -29,12 +32,12 @@ class Split extends Model
|
||||
'last_date' => 'datetime',
|
||||
];
|
||||
|
||||
public function holdings()
|
||||
public function holdings(): HasMany
|
||||
{
|
||||
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
||||
}
|
||||
|
||||
public function transactions()
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
|
||||
}
|
||||
@@ -73,7 +76,7 @@ class Split extends Model
|
||||
if ($split_data->isNotEmpty()) {
|
||||
|
||||
// insert records
|
||||
(new self)->insert($split_data->map(function ($split) {
|
||||
(new self)->insertOrIgnore($split_data->map(function ($split) {
|
||||
|
||||
return [...$split, ...['id' => Str::uuid()->toString()]];
|
||||
})->toArray());
|
||||
@@ -114,7 +117,7 @@ class Split extends Model
|
||||
'symbol' => $split->symbol,
|
||||
'portfolio_id' => $split->portfolio_id,
|
||||
])
|
||||
->whereDate('transactions.date', '<', $split->date->format('Y-m-d'))
|
||||
->whereDate('transactions.date', '<', $split->date->toDateString())
|
||||
->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');
|
||||
|
||||
+29
-40
@@ -4,18 +4,25 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Actions\ConvertToMarketDataCurrency;
|
||||
use App\Actions\CopyToBaseCurrency;
|
||||
use App\Actions\EnsureCostBasisAddedToSale;
|
||||
use App\Actions\EnsureDailyChangeIsSynced;
|
||||
use App\Casts\BaseCurrency;
|
||||
use App\Traits\HasMarketData;
|
||||
use Illuminate\Contracts\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Pipeline;
|
||||
|
||||
class Transaction extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasMarketData;
|
||||
use HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
@@ -23,6 +30,7 @@ class Transaction extends Model
|
||||
'date',
|
||||
'portfolio_id',
|
||||
'transaction_type',
|
||||
'currency',
|
||||
'quantity',
|
||||
'cost_basis',
|
||||
'sale_price',
|
||||
@@ -36,6 +44,11 @@ class Transaction extends Model
|
||||
'date' => 'datetime',
|
||||
'split' => 'boolean',
|
||||
'reinvested_dividend' => 'boolean',
|
||||
'quantity' => 'float',
|
||||
'cost_basis' => 'float',
|
||||
'sale_price' => 'float',
|
||||
'cost_basis_base' => BaseCurrency::class,
|
||||
'sale_price_base' => BaseCurrency::class,
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
@@ -44,17 +57,24 @@ class Transaction extends Model
|
||||
|
||||
static::saving(function ($transaction) {
|
||||
|
||||
if ($transaction->transaction_type == 'SELL') {
|
||||
|
||||
$transaction->ensureCostBasisIsAddedToSale();
|
||||
}
|
||||
$transaction = Pipeline::send($transaction)
|
||||
->through([
|
||||
ConvertToMarketDataCurrency::class,
|
||||
EnsureCostBasisAddedToSale::class,
|
||||
CopyToBaseCurrency::class,
|
||||
])
|
||||
->then(fn (Transaction $transaction) => $transaction);
|
||||
});
|
||||
|
||||
static::saved(function ($transaction) {
|
||||
|
||||
$transaction->syncToHolding();
|
||||
|
||||
$transaction->refreshMarketData();
|
||||
$transaction = Pipeline::send($transaction)
|
||||
->through([
|
||||
EnsureDailyChangeIsSynced::class,
|
||||
])
|
||||
->then(fn (Transaction $transaction) => $transaction);
|
||||
|
||||
cache()->forget('portfolio-metrics-'.$transaction->portfolio_id);
|
||||
});
|
||||
@@ -77,16 +97,6 @@ class Transaction extends Model
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Related market data for transaction
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function market_data(): HasOne
|
||||
{
|
||||
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
|
||||
}
|
||||
|
||||
/**
|
||||
* Related portfolio
|
||||
*
|
||||
@@ -101,6 +111,7 @@ class Transaction extends Model
|
||||
{
|
||||
return $query->withAggregate('market_data', 'name')
|
||||
->withAggregate('market_data', 'market_value')
|
||||
->withAggregate('market_data', 'currency')
|
||||
->withAggregate('market_data', 'fifty_two_week_low')
|
||||
->withAggregate('market_data', 'fifty_two_week_high')
|
||||
->withAggregate('market_data', 'updated_at')
|
||||
@@ -141,28 +152,6 @@ class Transaction extends Model
|
||||
});
|
||||
}
|
||||
|
||||
public function refreshMarketData(): void
|
||||
{
|
||||
MarketData::getMarketData($this->attributes['symbol']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes average cost basis to a sale transaction
|
||||
*/
|
||||
public function ensureCostBasisIsAddedToSale(): Transaction
|
||||
{
|
||||
$average_cost_basis = Transaction::where([
|
||||
'portfolio_id' => $this->portfolio_id,
|
||||
'symbol' => $this->symbol,
|
||||
'transaction_type' => 'BUY',
|
||||
])->whereDate('date', '<=', $this->date)
|
||||
->average('cost_basis');
|
||||
|
||||
$this->cost_basis = $average_cost_basis ?? 0;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs the holding related to this transaction
|
||||
*/
|
||||
@@ -187,8 +176,8 @@ class Transaction extends Model
|
||||
'portfolio_id' => $this->portfolio_id,
|
||||
'symbol' => $this->symbol,
|
||||
'quantity' => $this->quantity,
|
||||
'average_cost_basis' => $this->cost_basis,
|
||||
'total_cost_basis' => $this->quantity * $this->cost_basis,
|
||||
'average_cost_basis' => $this->cost_basis_base,
|
||||
'total_cost_basis' => $this->quantity * $this->cost_basis_base,
|
||||
'splits_synced_at' => now(),
|
||||
])->syncTransactionsAndDividends();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Arr;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Jetstream\HasProfilePhoto;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
@@ -31,6 +32,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -50,6 +52,8 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'admin' => 'boolean',
|
||||
'options' => 'json',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -82,4 +86,26 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
ELSE COALESCE(market_data.market_value - transactions.cost_basis, 0)
|
||||
END AS gain_dollars');
|
||||
}
|
||||
|
||||
public function getCurrency(): string
|
||||
{
|
||||
return Arr::get($this->options, 'display_currency') ?? config('investbrain.base_currency');
|
||||
}
|
||||
|
||||
public function getLocale(): string
|
||||
{
|
||||
$available_locales = Arr::pluck(config('app.available_locales'), 'locale');
|
||||
|
||||
return Arr::get($this->options, 'locale') ?? request()->getPreferredLanguage($available_locales) ?? config('app.locale');
|
||||
}
|
||||
|
||||
public function setOption(mixed $key, string $value): self
|
||||
{
|
||||
|
||||
$options = is_array($key) ? $key : [$key => $value];
|
||||
|
||||
$this->options = array_merge($this->options ?? [], $options);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Number;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use NumberFormatter;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -26,5 +29,28 @@ class AppServiceProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
JsonResource::withoutWrapping();
|
||||
|
||||
Arr::macro('skipEmptyValues', function (array $array) {
|
||||
|
||||
return Arr::mapWithKeys($array, function (mixed $value, mixed $key) {
|
||||
$result = [];
|
||||
if (! empty($value)) {
|
||||
$result[$key] = $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
});
|
||||
});
|
||||
|
||||
Number::macro('currencySymbol', function (?string $currency = null, ?string $locale = null) {
|
||||
|
||||
$currency = $currency ?? Number::defaultCurrency();
|
||||
|
||||
$locale = $locale ?? Number::defaultLocale();
|
||||
|
||||
$formatter = new NumberFormatter($locale."@currency=$currency", NumberFormatter::CURRENCY);
|
||||
|
||||
return $formatter->getSymbol(NumberFormatter::CURRENCY_SYMBOL);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,13 @@ class VoltServiceProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
Volt::mount([
|
||||
config('livewire.view_path', resource_path('views/livewire')),
|
||||
resource_path('views/pages'),
|
||||
// config('livewire.view_path', resource_path('views/livewire')),
|
||||
resource_path('views/components'),
|
||||
resource_path('views/profile'),
|
||||
resource_path('views/holding'),
|
||||
resource_path('views/transaction'),
|
||||
resource_path('views/portfolio'),
|
||||
resource_path('views/auth'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ declare(strict_types=1);
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Models\Portfolio;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class QuantityValidationRule implements ValidationRule
|
||||
{
|
||||
@@ -20,12 +21,7 @@ class QuantityValidationRule implements ValidationRule
|
||||
protected ?string $symbol,
|
||||
protected ?string $transactionType,
|
||||
protected string|Carbon|null $date
|
||||
) {
|
||||
$this->portfolio = $portfolio;
|
||||
$this->symbol = $symbol;
|
||||
$this->transactionType = $transactionType;
|
||||
$this->date = $date;
|
||||
}
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Validate the attribute.
|
||||
@@ -39,21 +35,21 @@ class QuantityValidationRule implements ValidationRule
|
||||
|
||||
if ($this->transactionType == 'SELL') {
|
||||
|
||||
$purchase_qty = $this->portfolio->transactions()
|
||||
$purchase_qty = (float) $this->portfolio->transactions()
|
||||
->symbol($this->symbol)
|
||||
->buy()
|
||||
->beforeDate($this->date)
|
||||
->whereDate('date', '<', $this->date)
|
||||
->sum('quantity');
|
||||
|
||||
$sales_qty = $this->portfolio->transactions()
|
||||
$sales_qty = (float) $this->portfolio->transactions()
|
||||
->symbol($this->symbol)
|
||||
->sell()
|
||||
->beforeDate($this->date)
|
||||
->whereDate('date', '<', $this->date)
|
||||
->sum('quantity');
|
||||
|
||||
$maxQuantity = $purchase_qty - $sales_qty;
|
||||
|
||||
if (round($value, 3) > round($maxQuantity, 3)) {
|
||||
if (round($value, 4) > round($maxQuantity, 4)) {
|
||||
$fail(__('The quantity must not be greater than the available quantity.'));
|
||||
}
|
||||
}
|
||||
|
||||
+11
-12
@@ -2,16 +2,15 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// if (!function_exists('formatMoney')) {
|
||||
// /**
|
||||
// * Returns a formatted string for currency
|
||||
// *
|
||||
// * @param int|float $amount
|
||||
// *
|
||||
// * */
|
||||
// function formatMoney(int|float $amount) {
|
||||
// $formatter = new NumberFormatter('en_US', NumberFormatter::CURRENCY);
|
||||
use App\Models\Currency;
|
||||
|
||||
// return $formatter->formatCurrency((float) $amount, 'USD');
|
||||
// }
|
||||
// }
|
||||
if (! function_exists('currency')) {
|
||||
|
||||
// /**
|
||||
// * Returns an instance of the currency model
|
||||
// * */
|
||||
// function currency(): Currency
|
||||
// {
|
||||
// return new Currency;
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\MarketData;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
trait HasMarketData
|
||||
{
|
||||
/**
|
||||
* Related market data for model
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function market_data(): HasOne
|
||||
{
|
||||
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully loads related market data as relationship (creates if doesn't exist)
|
||||
*/
|
||||
public function loadMarketData(): void
|
||||
{
|
||||
if (is_null($this->market_data)) {
|
||||
|
||||
$this->setRelation('market_data', MarketData::getMarketData($this->attributes['symbol']));
|
||||
}
|
||||
}
|
||||
|
||||
public function scopeNotBaseCurrency($query): void
|
||||
{
|
||||
$query->with('market_data')
|
||||
->whereRelation(
|
||||
'market_data',
|
||||
'currency',
|
||||
'!=',
|
||||
config('investbrain.base_currency')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,11 @@ class AppLayout extends Component
|
||||
|
||||
<x-partials.nav-bar />
|
||||
|
||||
<x-main with-nav full-width>
|
||||
<x-partials.main with-nav full-width>
|
||||
|
||||
<x-slot:sidebar drawer="main-drawer" class="bg-base-100 lg:bg-inherit">
|
||||
|
||||
<x-partials.side-bar />
|
||||
|
||||
@livewire('partials.side-bar')
|
||||
|
||||
</x-slot:sidebar>
|
||||
|
||||
@@ -34,7 +34,7 @@ class AppLayout extends Component
|
||||
{{ $slot }}
|
||||
</x-slot:content>
|
||||
|
||||
</x-main>
|
||||
</x-partials.main>
|
||||
|
||||
@if(session('toast'))
|
||||
<script lang="text/javascript">
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Middleware\SetLocale;
|
||||
use App\Http\Middleware\LocalizationMiddleware;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
@@ -15,7 +15,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
$middleware->append(SetLocale::class);
|
||||
$middleware->appendToGroup('web', LocalizationMiddleware::class);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
//
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"ext-zip": "*",
|
||||
"finnhub/client": "master@dev",
|
||||
"hackeresq/filter-models": "dev-main",
|
||||
"investbrainapp/frankfurter-client": "dev-main",
|
||||
"laravel/framework": "^11.35",
|
||||
"laravel/jetstream": "^5.1",
|
||||
"laravel/sanctum": "^4.0",
|
||||
@@ -41,6 +42,11 @@
|
||||
"no-api": true,
|
||||
"url": "https://github.com/hackeresq/filter-models"
|
||||
},
|
||||
{
|
||||
"type": "vcs",
|
||||
"no-api": true,
|
||||
"url": "https://github.com/investbrainapp/frankfurter-client"
|
||||
},
|
||||
{
|
||||
"type": "vcs",
|
||||
"no-api": true,
|
||||
|
||||
Generated
+244
-207
File diff suppressed because it is too large
Load Diff
+83
-2
@@ -79,14 +79,95 @@ return [
|
||||
| set to any locale for which you plan to have translation strings.
|
||||
|
|
||||
*/
|
||||
'available_locales' => ['en', 'es'],
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
'available_locales' => [
|
||||
[
|
||||
'locale' => 'en_AU',
|
||||
'label' => 'English (Australia)',
|
||||
'flag' => '',
|
||||
],
|
||||
[
|
||||
'locale' => 'en_BE',
|
||||
'label' => 'English (Belgium)',
|
||||
'flag' => '',
|
||||
],
|
||||
[
|
||||
'locale' => 'en_CA',
|
||||
'label' => 'English (Canada)',
|
||||
'flag' => '',
|
||||
],
|
||||
[
|
||||
'locale' => 'en_HK',
|
||||
'label' => 'English (Hong Kong SAR China)',
|
||||
'flag' => '',
|
||||
],
|
||||
[
|
||||
'locale' => 'en_IN',
|
||||
'label' => 'English (India)',
|
||||
'flag' => '',
|
||||
],
|
||||
[
|
||||
'locale' => 'en_IE',
|
||||
'label' => 'English (Ireland)',
|
||||
'flag' => '',
|
||||
],
|
||||
[
|
||||
'locale' => 'en_MT',
|
||||
'label' => 'English (Malta)',
|
||||
'flag' => '',
|
||||
],
|
||||
[
|
||||
'locale' => 'en_NZ',
|
||||
'label' => 'English (New Zealand)',
|
||||
'flag' => '',
|
||||
],
|
||||
[
|
||||
'locale' => 'en_PH',
|
||||
'label' => 'English (Philippines)',
|
||||
'flag' => '',
|
||||
],
|
||||
[
|
||||
'locale' => 'en_SG',
|
||||
'label' => 'English (Singapore)',
|
||||
'flag' => '',
|
||||
],
|
||||
[
|
||||
'locale' => 'en_ZA',
|
||||
'label' => 'English (South Africa)',
|
||||
'flag' => '',
|
||||
],
|
||||
[
|
||||
'locale' => 'en_GB',
|
||||
'label' => 'English (United Kingdom)',
|
||||
'flag' => '',
|
||||
],
|
||||
[
|
||||
'locale' => 'en_US',
|
||||
'label' => 'English (United States)',
|
||||
'flag' => '',
|
||||
],
|
||||
[
|
||||
'locale' => 'es_419',
|
||||
'label' => 'Spanish (Latin America)',
|
||||
'flag' => '',
|
||||
],
|
||||
[
|
||||
'locale' => 'es_ES',
|
||||
'label' => 'Spanish (Spain)',
|
||||
'flag' => '',
|
||||
],
|
||||
[
|
||||
'locale' => 'es_US',
|
||||
'label' => 'Spanish (United States)',
|
||||
'flag' => '',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|
||||
@@ -18,4 +18,12 @@ return [
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
|
||||
'daily_change_time_of_day' => env('DAILY_CHANGE_TIME', '23:00'),
|
||||
|
||||
'base_currency' => env('BASE_CURRENCY', 'USD'),
|
||||
|
||||
'currency_aliases' => [
|
||||
'RMB' => ['alias_of' => 'CNY', 'label' => 'Chinese Yuan (Renminbi)', 'adjustment' => 1],
|
||||
'GBX' => ['alias_of' => 'GBP', 'label' => 'British Sterling Pence', 'adjustment' => 100],
|
||||
'ZAC' => ['alias_of' => 'ZAR', 'label' => 'South Africa Rand Cent', 'adjustment' => 100],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -60,7 +60,6 @@ return [
|
||||
*/
|
||||
|
||||
'features' => [
|
||||
! env('SELF_HOSTED', true) ? Features::termsAndPrivacyPolicy() : null,
|
||||
Features::profilePhotos(),
|
||||
Features::api(),
|
||||
Features::accountDeletion(),
|
||||
|
||||
@@ -41,28 +41,35 @@ class TransactionFactory extends Factory
|
||||
public function yearsAgo(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'date' => $this->faker->dateTimeBetween('-5 years', '-3 years')->format('Y-m-d'),
|
||||
'date' => now()->subYears($this->faker->numberBetween(3, 5))->toDateString(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function lastYear(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'date' => now()->subYear()->format('Y-m-d'),
|
||||
'date' => now()->subYear()->toDateString(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function lastMonth(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'date' => now()->subMonth()->format('Y-m-d'),
|
||||
'date' => now()->subMonth()->toDateString(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function recent(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'date' => $this->faker->dateTimeBetween('-2 weeks', 'now')->format('Y-m-d'),
|
||||
'date' => now()->subDays($this->faker->numberBetween(3, 14))->toDateString(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function date($date): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'date' => $date,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -80,6 +87,27 @@ class TransactionFactory extends Factory
|
||||
]);
|
||||
}
|
||||
|
||||
public function currency($currency): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'currency' => $currency,
|
||||
]);
|
||||
}
|
||||
|
||||
public function costBasis($cost_basis): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'cost_basis' => $cost_basis,
|
||||
]);
|
||||
}
|
||||
|
||||
public function salePrice($sale_price): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'sale_price' => $sale_price,
|
||||
]);
|
||||
}
|
||||
|
||||
public function buy(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
|
||||
@@ -34,6 +34,10 @@ class UserFactory extends Factory
|
||||
'two_factor_recovery_codes' => null,
|
||||
'remember_token' => Str::random(10),
|
||||
'profile_photo_path' => null,
|
||||
'options' => [
|
||||
'display_currency' => 'USD',
|
||||
'locale' => 'en',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -46,4 +50,14 @@ class UserFactory extends Factory
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's currency.
|
||||
*/
|
||||
public function currency($currency): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => array_merge($attributes['options'], [
|
||||
'currency' => $currency,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ return new class extends Migration
|
||||
$table->string('password');
|
||||
$table->rememberToken();
|
||||
$table->string('profile_photo_path', 2048)->nullable();
|
||||
$table->boolean('admin')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
@@ -38,6 +39,5 @@ return new class extends Migration
|
||||
{
|
||||
Schema::dropIfExists('users');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('sessions');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Database\Seeders\MarketDataSeeder;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateMarketDataTable extends Migration
|
||||
@@ -34,10 +32,6 @@ class CreateMarketDataTable extends Migration
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Artisan::call('db:seed', [
|
||||
'--class' => MarketDataSeeder::class,
|
||||
'--force' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,10 +20,6 @@ class CreateDailyChangeTable extends Migration
|
||||
$table->date('date');
|
||||
$table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade');
|
||||
$table->float('total_market_value', 12, 4)->nullable();
|
||||
$table->float('total_cost_basis', 12, 4)->nullable();
|
||||
$table->float('total_gain', 12, 4)->nullable();
|
||||
$table->float('total_dividends_earned', 12, 4)->nullable();
|
||||
$table->float('realized_gains', 12, 4)->nullable();
|
||||
$table->text('annotation')->nullable();
|
||||
|
||||
$table->primary(['date', 'portfolio_id']);
|
||||
|
||||
@@ -21,6 +21,8 @@ class CreateDividendsTable extends Migration
|
||||
$table->string('symbol', 25);
|
||||
$table->float('dividend_amount', 12, 4);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['date', 'symbol']);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ class CreateSplitsTable extends Migration
|
||||
$table->string('symbol', 25);
|
||||
$table->float('split_amount', 12, 4);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['date', 'symbol']);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ class CreateHoldingsTable extends Migration
|
||||
$table->float('total_cost_basis', 12, 4)->default(0);
|
||||
$table->float('realized_gain_dollars', 12, 4)->default(0);
|
||||
$table->float('dividends_earned', 12, 4)->default(0);
|
||||
$table->boolean('reinvest_dividends')->default(false);
|
||||
$table->timestamp('splits_synced_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->boolean('admin')->nullable()->after('profile_photo_path');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('admin');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\CurrencyRate;
|
||||
use App\Models\Transaction;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\MarketDataSeeder;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
/**
|
||||
* Add options column to users table
|
||||
*/
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->json('options')->default(json_encode([
|
||||
'locale' => config('app.locale', 'en'),
|
||||
'display_currency' => config('investbrain.base_currency', 'USD'),
|
||||
]))->after('profile_photo_path');
|
||||
});
|
||||
|
||||
/**
|
||||
* Add _base and currency column to market_data table
|
||||
*/
|
||||
Schema::table('market_data', function (Blueprint $table) {
|
||||
$table->float('market_value_base', 12, 4)->nullable()->after('market_value');
|
||||
$table->string('currency', 3)->default(config('investbrain.base_currency'))->after('market_value');
|
||||
});
|
||||
DB::table('market_data')->update([
|
||||
'market_value_base' => DB::raw('market_value'),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Add _base columns to transactions table
|
||||
*/
|
||||
Schema::table('transactions', function (Blueprint $table) {
|
||||
$table->float('cost_basis_base', 12, 4)->nullable()->after('sale_price');
|
||||
$table->float('sale_price_base', 12, 4)->nullable()->after('cost_basis_base');
|
||||
});
|
||||
DB::table('transactions')->update([
|
||||
'cost_basis_base' => DB::raw('cost_basis'),
|
||||
'sale_price_base' => DB::raw('sale_price'),
|
||||
]);
|
||||
Schema::table('transactions', function (Blueprint $table) {
|
||||
$table->float('cost_basis_base', 12, 4)->nullable(false)->change();
|
||||
});
|
||||
|
||||
/**
|
||||
* Add _base columns to dividends table
|
||||
*/
|
||||
Schema::table('dividends', function (Blueprint $table) {
|
||||
$table->float('dividend_amount_base', 12, 4)->nullable()->after('dividend_amount');
|
||||
});
|
||||
DB::table('dividends')->update([
|
||||
'dividend_amount_base' => DB::raw('dividend_amount'),
|
||||
]);
|
||||
Schema::table('dividends', function (Blueprint $table) {
|
||||
$table->float('dividend_amount_base', 12, 4)->nullable(false)->change();
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates currencies table
|
||||
*/
|
||||
Schema::create('currencies', function (Blueprint $table) {
|
||||
$table->string('currency', 3)->primary(); // ISO 4217
|
||||
$table->string('label');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates currency rates table
|
||||
*/
|
||||
Schema::create('currency_rates', function (Blueprint $table) {
|
||||
$table->date('date');
|
||||
$table->string('currency', 3);
|
||||
$table->float('rate', 12, 4);
|
||||
$table->timestamps();
|
||||
|
||||
$table->primary(['date', 'currency']);
|
||||
});
|
||||
|
||||
if (config('app.env') != 'testing') {
|
||||
|
||||
Artisan::call('db:seed', [
|
||||
'--class' => CurrencySeeder::class,
|
||||
'--force' => true,
|
||||
]);
|
||||
|
||||
CurrencyRate::timeSeriesRates(
|
||||
'', // use fake currency to force
|
||||
Transaction::min('date')
|
||||
);
|
||||
|
||||
CurrencyRate::refreshCurrencyData();
|
||||
|
||||
Artisan::call('db:seed', [
|
||||
'--class' => MarketDataSeeder::class,
|
||||
'--force' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup daily change table
|
||||
*/
|
||||
if (Schema::hasColumn('daily_change', 'total_cost_basis')) {
|
||||
Schema::table('daily_change', function (Blueprint $table) {
|
||||
$table->dropColumn('total_cost_basis');
|
||||
});
|
||||
}
|
||||
if (Schema::hasColumn('daily_change', 'total_gain')) {
|
||||
Schema::table('daily_change', function (Blueprint $table) {
|
||||
$table->dropColumn('total_gain');
|
||||
});
|
||||
}
|
||||
if (Schema::hasColumn('daily_change', 'total_dividends_earned')) {
|
||||
Schema::table('daily_change', function (Blueprint $table) {
|
||||
$table->dropColumn('total_dividends_earned');
|
||||
});
|
||||
}
|
||||
if (Schema::hasColumn('daily_change', 'realized_gains')) {
|
||||
Schema::table('daily_change', function (Blueprint $table) {
|
||||
$table->dropColumn('realized_gains');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('options');
|
||||
});
|
||||
|
||||
Schema::table('market_data', function (Blueprint $table) {
|
||||
$table->dropColumn('currency');
|
||||
$table->dropColumn('market_value_base');
|
||||
});
|
||||
|
||||
Schema::table('transactions', function (Blueprint $table) {
|
||||
$table->dropColumn('cost_basis_base');
|
||||
$table->dropColumn('sale_price_base');
|
||||
});
|
||||
|
||||
Schema::table('dividends', function (Blueprint $table) {
|
||||
$table->dropColumn('dividend_amount_base');
|
||||
});
|
||||
|
||||
Schema::dropIfExists('currencies');
|
||||
|
||||
Schema::dropIfExists('currency_rates');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Currency;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CurrencySeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
|
||||
Currency::insert([
|
||||
['currency' => 'AUD', 'label' => 'Australian Dollar', 'created_at' => now()],
|
||||
['currency' => 'BRL', 'label' => 'Brazilian Real', 'created_at' => now()],
|
||||
['currency' => 'GBP', 'label' => 'British Pound', 'created_at' => now()],
|
||||
['currency' => 'CAD', 'label' => 'Canadian Dollar', 'created_at' => now()],
|
||||
['currency' => 'CNY', 'label' => 'Chinese Yuan', 'created_at' => now()],
|
||||
['currency' => 'CZK', 'label' => 'Czech Koruna', 'created_at' => now()],
|
||||
['currency' => 'DKK', 'label' => 'Danish Krone', 'created_at' => now()],
|
||||
['currency' => 'EUR', 'label' => 'Euro', 'created_at' => now()],
|
||||
['currency' => 'HKD', 'label' => 'Hong Kong Dollar', 'created_at' => now()],
|
||||
['currency' => 'INR', 'label' => 'Indian Rupee', 'created_at' => now()],
|
||||
['currency' => 'JPY', 'label' => 'Japanese Yen', 'created_at' => now()],
|
||||
['currency' => 'NZD', 'label' => 'New Zealand Dollar', 'created_at' => now()],
|
||||
['currency' => 'NOK', 'label' => 'Norwegian Krone', 'created_at' => now()],
|
||||
['currency' => 'SGD', 'label' => 'Singapore Dollar', 'created_at' => now()],
|
||||
['currency' => 'KRW', 'label' => 'South Korean Won', 'created_at' => now()],
|
||||
['currency' => 'ZAR', 'label' => 'South African Rand', 'created_at' => now()],
|
||||
['currency' => 'SEK', 'label' => 'Swedish Krona', 'created_at' => now()],
|
||||
['currency' => 'CHF', 'label' => 'Swiss Franc', 'created_at' => now()],
|
||||
['currency' => 'USD', 'label' => 'United States Dollar', 'created_at' => now()],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,6 @@ class DatabaseSeeder extends Seeder
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
|
||||
@@ -41,22 +41,21 @@ class MarketDataSeeder extends Seeder
|
||||
|
||||
$data = array_combine($header, $row);
|
||||
|
||||
$meta_data = json_decode(base64_decode($data['meta_data']), true);
|
||||
$meta_data['source'] = 'market_data_seeder';
|
||||
|
||||
$this->rows[] = [
|
||||
'symbol' => $data['symbol'],
|
||||
'name' => $data['name'],
|
||||
'meta_data' => json_encode([
|
||||
'country' => $data['country'],
|
||||
'first_trade_year' => $data['first_trade_year'],
|
||||
'sector' => $data['sector'],
|
||||
'industry' => $data['industry'],
|
||||
]),
|
||||
'currency' => $data['currency'],
|
||||
'meta_data' => json_encode($meta_data),
|
||||
];
|
||||
|
||||
$rowCount++;
|
||||
|
||||
if ($rowCount % $chunkSize == 0) {
|
||||
|
||||
$this->bulkInsert($this->rows);
|
||||
DB::table('market_data')->upsert($this->rows, ['symbol'], ['name', 'currency', 'meta_data']);
|
||||
$this->rows = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14465
-34981
File diff suppressed because it is too large
Load Diff
+12
-3
@@ -367,8 +367,11 @@
|
||||
"Import starting...": "Import starting...",
|
||||
"Import is in progress...": "Import is in progress...",
|
||||
"Importing portfolios...": "Importing portfolios...",
|
||||
"Importing transactions...": "Importing transactions...",
|
||||
"Importing daily changes...": "Importing daily changes...",
|
||||
"Preparing to import transactions...": "Preparing to import transactions...",
|
||||
"Importing transactions (Batch :currentBatch of :totalBatches)...": "Importing transactions (Batch :currentBatch of :totalBatches)...",
|
||||
"Preparing to import daily changes...": "Preparing to import daily changes...",
|
||||
"Importing daily changes (Batch :currentBatch of :totalBatches)...": "Importing daily changes (Batch :currentBatch of :totalBatches)...",
|
||||
"Importing configurations...": "Importing configurations...",
|
||||
"Import completed successfully!": "Import completed successfully!",
|
||||
"Your import will continue in the background": "Your import will continue in the background",
|
||||
|
||||
@@ -376,5 +379,11 @@
|
||||
"Hi, how can I help?": "Hi, how can I help?",
|
||||
"Have a question? AI might be able to help...": "Have a question? AI might be able to help...",
|
||||
"Feel free to ask me a question!": "Feel free to ask me a question!",
|
||||
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor."
|
||||
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.",
|
||||
|
||||
"Currency": "Currency",
|
||||
"Locale Options": "Locale Options",
|
||||
"Adjust localization options for your preferred region.": "Adjust localization options for your preferred region.",
|
||||
"Locale": "Locale",
|
||||
"Display Currency": "Display Currency"
|
||||
}
|
||||
+12
-3
@@ -367,8 +367,11 @@
|
||||
"Import starting...": "Iniciando la importación...",
|
||||
"Import is in progress...": "La importación está en progreso...",
|
||||
"Importing portfolios...": "Importando portafolios...",
|
||||
"Importing transactions...": "Importando transacciones...",
|
||||
"Importing daily changes...": "Importando cambios diarios...",
|
||||
"Preparing to import transactions...": "Preparándose para importar transacciones...",
|
||||
"Importing transactions (Batch :currentBatch of :totalBatches)...": "Importando transacciones (Lote :currentBatch de :totalBatches)...",
|
||||
"Preparing to import daily changes...": "Preparing to import cambios diarios...",
|
||||
"Importing daily changes (Batch :currentBatch of :totalBatches)...": "Importando cambios diarios (Lote :currentBatch de :totalBatches)...",
|
||||
"Importing configurations...": "Importando configuraciones...",
|
||||
"Import completed successfully!": "¡La importación se completó con éxito!",
|
||||
"Your import will continue in the background": "La importación continuará en segundo plano",
|
||||
|
||||
@@ -376,5 +379,11 @@
|
||||
"Hi, how can I help?": "Hola, ¿cómo puedo ayudarte?",
|
||||
"Have a question? AI might be able to help...": "¿Tienes una pregunta? La AI podría ayudarte...",
|
||||
"Feel free to ask me a question!": "¡No dudes en hacerme una pregunta!",
|
||||
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Los consejos generados por AI pueden contener errores. Úsalos bajo tu propio riesgo. Consulta siempre a un asesor de inversiones con licencia."
|
||||
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Los consejos generados por AI pueden contener errores. Úsalos bajo tu propio riesgo. Consulta siempre a un asesor de inversiones con licencia.",
|
||||
|
||||
"Currency": "Moneda",
|
||||
"Locale Options": "Opciones de configuración regional",
|
||||
"Adjust localization options for your preferred region.": "Ajusta las opciones de localización para tu región preferida.",
|
||||
"Locale": "Configuración regional",
|
||||
"Display Currency": "Moneda de visualización"
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
<x-input id="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" name="password_confirmation" required autocomplete="new-password" />
|
||||
</div>
|
||||
|
||||
@if (Laravel\Jetstream\Jetstream::hasTermsAndPrivacyPolicyFeature())
|
||||
@if (! config('investbrain.self_hosted'))
|
||||
<div class="mt-4">
|
||||
<label>
|
||||
<div class="flex items-center">
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ new class extends Component {
|
||||
'model' => config('openai.model'),
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => "Today's date is "
|
||||
.now()->format('Y-m-d')
|
||||
.now()->toDateString()
|
||||
.".\n\n".$this->system_prompt],
|
||||
...array_slice($this->messages, -10)
|
||||
],
|
||||
@@ -1,18 +1,18 @@
|
||||
<span
|
||||
class=""
|
||||
style="width:90em;overflow: hidden; white-space: nowrap;"
|
||||
title="{{ Number::currency($low ?? 0) }} - {{ Number::currency($high ?? 0) }}"
|
||||
title="{{ Number::currency($marketData->fifty_two_week_low ?? 0, $marketData->currency) }} - {{ Number::currency($marketData->fifty_two_week_high ?? 0, $marketData->currency) }}"
|
||||
>
|
||||
|
||||
@php
|
||||
// 52-week low must be a non-zero
|
||||
if (empty($low)) {
|
||||
$low = 1;
|
||||
if (empty($marketData->fifty_two_week_low)) {
|
||||
$marketData->fifty_two_week_low = 1;
|
||||
}
|
||||
@endphp
|
||||
|
||||
@for ($x = 0; $x < 10; $x++)
|
||||
@if ((($current - $low) * 100) / ($high - $low) > ($x * 10))
|
||||
@if ((($marketData->market_value - $marketData->fifty_two_week_low) * 100) / ($marketData->fifty_two_week_high - $marketData->fifty_two_week_low) > ($x * 10))
|
||||
|
||||
●
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
}
|
||||
|
||||
this.data.yaxis.labels.formatter = function (value) {
|
||||
return `$${value}`
|
||||
return `{{ Number::currencySymbol(auth()->user()->getCurrency()) }}${value}`
|
||||
}
|
||||
|
||||
this.data.tooltip = {
|
||||
@@ -103,7 +103,7 @@
|
||||
formatter: (value, { series, seriesIndex, dataPointIndex, w }) => {
|
||||
const firstDataPoint = this.data.series[seriesIndex].data[0][1]
|
||||
const percentageChange = ((value - firstDataPoint) / firstDataPoint) * 100;
|
||||
return `$${parseFloat(value.toFixed(2))} (${percentageChange.toFixed(2)}%)`;
|
||||
return `${parseFloat(value.toFixed(2))} (${percentageChange.toFixed(2)}%)`;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
@props([
|
||||
'sidebar' => null,
|
||||
'content' => null,
|
||||
'footer' => null,
|
||||
'fullWidth' => false,
|
||||
'withNav' => false,
|
||||
'collapseText' => 'Collapse',
|
||||
'collapseIcon' => 'o-bars-3-bottom-right',
|
||||
'collapsible' => false,
|
||||
'url' => route('mary.toogle-sidebar', absolute: false),
|
||||
])
|
||||
|
||||
<main class="{{ !$fullWidth ? 'max-w-screen-2xl' : '' }} w-full mx-auto">
|
||||
<div class="drawer {{ $sidebar?->attributes['right'] ? 'drawer-end' : '' }} lg:drawer-open">
|
||||
<input id="{{ $sidebar?->attributes['drawer'] }}" type="checkbox" class="drawer-toggle" />
|
||||
|
||||
<div {{ $content->attributes->class(["drawer-content w-full mx-auto p-5 lg:px-10 lg:py-5"]) }}>
|
||||
{{-- MAIN CONTENT --}}
|
||||
{{ $content }}
|
||||
</div>
|
||||
|
||||
{{-- SIDEBAR --}}
|
||||
@if($sidebar)
|
||||
<div
|
||||
x-data="{
|
||||
collapsed: {{ session('mary-sidebar-collapsed', 'false') }},
|
||||
collapseText: '{{ $collapseText }}',
|
||||
toggle() {
|
||||
this.collapsed = !this.collapsed;
|
||||
fetch('{{ $url }}?collapsed=' + this.collapsed);
|
||||
this.$dispatch('sidebar-toggled', this.collapsed);
|
||||
}
|
||||
}"
|
||||
@menu-sub-clicked="if(collapsed) { toggle() }"
|
||||
@class(["drawer-side z-20 lg:z-auto", "top-0 lg:top-[73px] lg:h-[calc(100vh-73px)]" => $withNav])
|
||||
>
|
||||
<label for="{{ $sidebar?->attributes['drawer'] }}" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
{{-- SIDEBAR CONTENT --}}
|
||||
<div>
|
||||
|
||||
{{ $sidebar }}
|
||||
|
||||
{{-- SIDEBAR COLLAPSE --}}
|
||||
@if($sidebar->attributes['collapsible'])
|
||||
<x-mary-menu class="hidden !bg-inherit lg:block">
|
||||
<x-mary-menu-item
|
||||
@click="toggle"
|
||||
icon="{{ $sidebar->attributes['collapse-icon'] ?? $collapseIcon }}"
|
||||
title="{{ $sidebar->attributes['collapse-text'] ?? $collapseText }}" />
|
||||
</x-mary-menu>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
{{-- END SIDEBAR--}}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{{-- FOOTER --}}
|
||||
@if($footer)
|
||||
<footer {{ $footer?->attributes->class(["mx-auto w-full", "max-w-screen-2xl" => !$fullWidth ]) }}>
|
||||
{{ $footer }}
|
||||
</footer>
|
||||
@endif
|
||||
@@ -1,4 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component
|
||||
{
|
||||
// props
|
||||
|
||||
/**
|
||||
* The component's listeners.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $listeners = [
|
||||
'refresh-navigation-menu' => '$refresh',
|
||||
];
|
||||
|
||||
// methods
|
||||
|
||||
}; ?>
|
||||
<div class="bg-base-100 border-base-300 border-b sticky top-0 z-10">
|
||||
<div class="flex justify-between items-center px-7 py-3 gap-4 mx-auto">
|
||||
<div class="flex flex-0 items-center">
|
||||
|
||||
@@ -1,54 +1,94 @@
|
||||
<x-menu activate-by-route>
|
||||
<?php
|
||||
|
||||
<x-menu-item title="{{ __('Dashboard') }}" icon="o-home" link="{{ route('dashboard') }}" />
|
||||
<x-menu-sub title="{{ __('Portfolios') }}" icon="o-document-duplicate">
|
||||
@foreach (auth()->user()->portfolios as $portfolio)
|
||||
<x-menu-item icon="o-document" link="{{ route('portfolio.show', ['portfolio' => $portfolio->id ]) }}" >
|
||||
<x-slot:title>
|
||||
{{ $portfolio->title }}
|
||||
@if($portfolio->wishlist)
|
||||
<x-badge value="{{ __('Wishlist') }}" class="badge-secondary badge-sm ml-2" />
|
||||
@endif
|
||||
</x-slot:title>
|
||||
|
||||
</x-menu-item>
|
||||
@endforeach
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
<x-menu-item title="{{ __('Create Portfolio') }}" icon="o-document-plus" link="{{ route('portfolio.create') }}" />
|
||||
</x-menu-sub>
|
||||
<x-menu-item title="{{ __('Transactions') }}" icon="o-banknotes" link="{{ route('transaction.index') }}" />
|
||||
{{-- <x-menu-item title="{{ __('Reporting') }}" icon="o-chart-bar-square" link="####" /> --}}
|
||||
new class extends Component
|
||||
{
|
||||
// props
|
||||
|
||||
</x-menu>
|
||||
/**
|
||||
* The component's listeners.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $listeners = [
|
||||
'refresh-navigation-menu' => '$refresh',
|
||||
];
|
||||
|
||||
</div>
|
||||
<div class="px-3">
|
||||
// methods
|
||||
|
||||
<x-section-border />
|
||||
}; ?>
|
||||
|
||||
@php
|
||||
$user = auth()->user();
|
||||
@endphp
|
||||
<div class="
|
||||
flex
|
||||
flex-col
|
||||
!transition-all
|
||||
!duration-100
|
||||
ease-out
|
||||
overflow-x-hidden
|
||||
overflow-y-auto
|
||||
h-screen
|
||||
lg:h-[calc(100vh-73px)]
|
||||
bg-base-100
|
||||
lg:bg-inherit
|
||||
{{ session('mary-sidebar-collapsed') == 'true' ? 'w-[70px] [&>*_summary::after]:hidden [&_.mary-hideable]:hidden [&_.display-when-collapsed]:block [&_.hidden-when-collapsed]:hidden' : null }}
|
||||
{{ session('mary-sidebar-collapsed') != 'true' ? 'w-[270px] [&>*_summary::after]:block [&_.mary-hideable]:block [&_.hidden-when-collapsed]:block [&_.display-when-collapsed]:hidden' : null }}
|
||||
">
|
||||
<div class="flex-1">
|
||||
<x-menu activate-by-route>
|
||||
|
||||
<x-list-item :item="$user" avatar="profile_photo_url" value="name" sub-value="email" no-separator no-hover class="mb-3 !-mt-3 rounded">
|
||||
<x-slot:actions>
|
||||
<x-dropdown>
|
||||
<x-slot:trigger>
|
||||
<x-button icon="o-cog-6-tooth" class="btn-circle btn-ghost btn-xs" />
|
||||
</x-slot:trigger>
|
||||
|
||||
<x-menu-item title="{{ __('Manage Profile') }}" icon="o-user" link="{{ @route('profile.show') }}" />
|
||||
<x-menu-item title="{{ __('API Tokens') }}" icon="o-command-line" link="{{ @route('api-tokens.index') }}" />
|
||||
<x-menu-item title="{{ __('Import / Export Data') }}" icon="o-cloud-arrow-down" link="{{ @route('import-export') }}" />
|
||||
<x-menu-item title="{{ __('Dashboard') }}" icon="o-home" link="{{ route('dashboard') }}" />
|
||||
<x-menu-sub title="{{ __('Portfolios') }}" icon="o-document-duplicate">
|
||||
@foreach (auth()->user()->portfolios as $portfolio)
|
||||
<x-menu-item icon="o-document" link="{{ route('portfolio.show', ['portfolio' => $portfolio->id ]) }}" >
|
||||
<x-slot:title>
|
||||
{{ $portfolio->title }}
|
||||
@if($portfolio->wishlist)
|
||||
<x-badge value="{{ __('Wishlist') }}" class="badge-secondary badge-sm ml-2" />
|
||||
@endif
|
||||
</x-slot:title>
|
||||
|
||||
</x-menu-item>
|
||||
@endforeach
|
||||
|
||||
<x-section-border class="py-1" />
|
||||
<x-menu-item title="{{ __('Create Portfolio') }}" icon="o-document-plus" link="{{ route('portfolio.create') }}" />
|
||||
</x-menu-sub>
|
||||
<x-menu-item title="{{ __('Transactions') }}" icon="o-banknotes" link="{{ route('transaction.index') }}" />
|
||||
{{-- <x-menu-item title="{{ __('Reporting') }}" icon="o-chart-bar-square" link="####" /> --}}
|
||||
|
||||
<x-menu-item title="{{ __('Log Out') }}" icon="o-power" onclick="event.preventDefault(); document.getElementById('logout').submit();" />
|
||||
<form id="logout" action="{{ route('logout') }}" method="POST" style="display: none;">
|
||||
{{ csrf_field() }}
|
||||
</form>
|
||||
</x-menu>
|
||||
|
||||
</x-dropdown>
|
||||
|
||||
</x-slot:actions>
|
||||
</x-list-item>
|
||||
</div>
|
||||
|
||||
<div class="px-3">
|
||||
|
||||
<x-section-border />
|
||||
|
||||
@php
|
||||
$user = auth()->user();
|
||||
@endphp
|
||||
|
||||
<x-list-item :item="$user" avatar="profile_photo_url" value="name" sub-value="email" no-separator no-hover class="mb-3 !-mt-3 rounded">
|
||||
<x-slot:actions>
|
||||
<x-dropdown>
|
||||
<x-slot:trigger>
|
||||
<x-button icon="o-cog-6-tooth" class="btn-circle btn-ghost btn-xs" />
|
||||
</x-slot:trigger>
|
||||
|
||||
<x-menu-item title="{{ __('Manage Profile') }}" icon="o-user" link="{{ @route('profile.show') }}" />
|
||||
<x-menu-item title="{{ __('API Tokens') }}" icon="o-command-line" link="{{ @route('api-tokens.index') }}" />
|
||||
<x-menu-item title="{{ __('Import / Export Data') }}" icon="o-cloud-arrow-down" link="{{ @route('import-export') }}" />
|
||||
|
||||
<x-section-border class="py-1" />
|
||||
|
||||
<x-menu-item title="{{ __('Log Out') }}" icon="o-power" onclick="event.preventDefault(); document.getElementById('logout').submit();" />
|
||||
<form id="logout" action="{{ route('logout') }}" method="POST" style="display: none;">
|
||||
{{ csrf_field() }}
|
||||
</form>
|
||||
|
||||
</x-dropdown>
|
||||
|
||||
</x-slot:actions>
|
||||
</x-list-item>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,3 +1,5 @@
|
||||
@use('App\Models\Currency')
|
||||
|
||||
<x-app-layout>
|
||||
|
||||
@livewire('portfolio-performance-chart', [
|
||||
@@ -7,27 +9,27 @@
|
||||
<div class="grid sm:grid-cols-5 gap-5">
|
||||
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
||||
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Market Gain/Loss') }}</div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->total_gain_dollars) }} </div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_gain_dollars', 0)) }} </div>
|
||||
</x-card>
|
||||
|
||||
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
||||
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Total Cost Basis') }}</div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->total_cost_basis) }} </div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_cost_basis', 0)) }} </div>
|
||||
</x-card>
|
||||
|
||||
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
||||
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Total Market Value') }}</div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->total_market_value) }} </div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_market_value', 0)) }} </div>
|
||||
</x-card>
|
||||
|
||||
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
||||
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Realized Gain/Loss') }}</div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->realized_gain_dollars) }} </div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->get('realized_gain_dollars', 0)) }} </div>
|
||||
</x-card>
|
||||
|
||||
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
||||
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Dividends Earned') }}</div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->total_dividends_earned) }} </div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_dividends_earned', 0)) }} </div>
|
||||
</x-card>
|
||||
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -27,9 +27,9 @@ new class extends Component
|
||||
$owned = ($dividend->purchased - $dividend->sold);
|
||||
@endphp
|
||||
|
||||
{{ Number::currency($dividend->dividend_amount) }}
|
||||
{{ Number::currency($dividend->dividend_amount, $holding->market_data->currency) }}
|
||||
x {{ $owned }}
|
||||
= {{ Number::currency($owned * $dividend->dividend_amount) }}
|
||||
= {{ Number::currency($owned * $dividend->dividend_amount, $holding->market_data->currency) }}
|
||||
|
||||
</x-slot:value>
|
||||
<x-slot:sub-value>
|
||||
+10
-10
@@ -3,27 +3,27 @@
|
||||
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
|
||||
|
||||
}; ?>
|
||||
|
||||
<div>
|
||||
<div class="font-bold text-2xl py-1 flex items-center">
|
||||
{{ Number::currency($holding->market_data->market_value ?? 0) }}
|
||||
{{ Number::currency($holding->market_data->market_value ?? 0, $holding->market_data->currency) }}
|
||||
|
||||
<x-gain-loss-arrow-badge
|
||||
:cost-basis="$holding->average_cost_basis"
|
||||
:market-value="$holding->market_data->market_value"
|
||||
:market-value="$holding->market_data->market_value_base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -34,22 +34,22 @@ new class extends Component {
|
||||
|
||||
<p>
|
||||
<span class="font-bold">{{ __('Average Cost Basis') }}: </span>
|
||||
{{ Number::currency($holding->average_cost_basis ?? 0) }}
|
||||
{{ Number::currency($holding->average_cost_basis ?? 0, $holding->market_data->currency) }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span class="font-bold">{{ __('Total Cost Basis') }}: </span>
|
||||
{{ Number::currency($holding->total_cost_basis ?? 0) }}
|
||||
{{ Number::currency($holding->total_cost_basis ?? 0, $holding->market_data->currency) }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span class="font-bold">{{ __('Realized Gain/Loss') }}: </span>
|
||||
{{ Number::currency($holding->realized_gain_dollars ?? 0) }}
|
||||
{{ Number::currency($holding->realized_gain_dollars ?? 0, $holding->market_data->currency) }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span class="font-bold">{{ __('Dividends Earned') }}: </span>
|
||||
{{ Number::currency($holding->dividends_earned ?? 0) }}
|
||||
{{ Number::currency($holding->dividends_earned ?? 0, $holding->market_data->currency) }}
|
||||
</p>
|
||||
|
||||
<p class="pt-2 text-sm" title="{{ \Carbon\Carbon::parse($holding->market_data->updated_at)->toIso8601String() }}">
|
||||
+19
-20
@@ -1,13 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Holding;
|
||||
use App\Models\Portfolio;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Volt\Component;
|
||||
use App\Models\Currency;
|
||||
|
||||
new class extends Component {
|
||||
|
||||
new class extends Component
|
||||
{
|
||||
// props
|
||||
public Portfolio $portfolio;
|
||||
|
||||
@@ -40,13 +39,13 @@ new class extends Component {
|
||||
{
|
||||
|
||||
$holdings = $this->portfolio
|
||||
->holdings()
|
||||
->withCount(['transactions as num_transactions' => function($query) {
|
||||
return $query->whereRaw('transactions.symbol = holdings.symbol');
|
||||
}])
|
||||
->orderBy(...array_values($this->sortBy))
|
||||
->holdings()
|
||||
->withCount(['transactions as num_transactions' => function ($query) {
|
||||
return $query->whereRaw('transactions.symbol = holdings.symbol');
|
||||
}])
|
||||
->orderBy(...array_values($this->sortBy))
|
||||
// ->where('holdings.quantity', '>', 0)
|
||||
->get();
|
||||
->get();
|
||||
|
||||
return $holdings;
|
||||
}
|
||||
@@ -55,7 +54,6 @@ new class extends Component {
|
||||
{
|
||||
return $this->redirect(route('holding.show', ['portfolio' => $holding['portfolio_id'], 'symbol' => $holding['symbol']]));
|
||||
}
|
||||
|
||||
}; ?>
|
||||
|
||||
|
||||
@@ -66,16 +64,17 @@ new class extends Component {
|
||||
@row-click="$wire.goToHolding($event.detail)"
|
||||
>
|
||||
@scope('cell_average_cost_basis', $row)
|
||||
{{ Number::currency($row->average_cost_basis ?? 0) }}
|
||||
{{ Number::currency($row->average_cost_basis ?? 0, $row->market_data->currency) }}
|
||||
|
||||
@endscope
|
||||
@scope('cell_total_cost_basis', $row)
|
||||
{{ Number::currency($row->total_cost_basis ?? 0) }}
|
||||
{{ Number::currency($row->total_cost_basis ?? 0, $row->market_data->currency) }}
|
||||
@endscope
|
||||
@scope('cell_realized_gain_dollars', $row)
|
||||
{{ Number::currency($row->realized_gain_dollars ?? 0) }}
|
||||
{{ Number::currency($row->realized_gain_dollars ?? 0, $row->market_data->currency) }}
|
||||
@endscope
|
||||
@scope('cell_market_gain_dollars', $row)
|
||||
{{ Number::currency($row->market_gain_dollars ?? 0) }}
|
||||
{{ Number::currency($row->market_gain_dollars ?? 0, $row->market_data->currency) }}
|
||||
@endscope
|
||||
@scope('cell_market_gain_percent', $row)
|
||||
<x-gain-loss-arrow-badge
|
||||
@@ -84,19 +83,19 @@ new class extends Component {
|
||||
/>
|
||||
@endscope
|
||||
@scope('cell_market_data_market_value', $row)
|
||||
{{ Number::currency($row->market_data_market_value ?? 0) }}
|
||||
{{ Number::currency($row->market_data_market_value ?? 0, $row->market_data->currency) }}
|
||||
@endscope
|
||||
@scope('cell_market_data_fifty_two_week_low', $row)
|
||||
{{ Number::currency($row->market_data_fifty_two_week_low ?? 0) }}
|
||||
{{ Number::currency($row->market_data_fifty_two_week_low ?? 0, $row->market_data->currency) }}
|
||||
@endscope
|
||||
@scope('cell_market_data_fifty_two_week_high', $row)
|
||||
{{ Number::currency($row->market_data_fifty_two_week_high ?? 0) }}
|
||||
{{ Number::currency($row->market_data_fifty_two_week_high ?? 0, $row->market_data->currency) }}
|
||||
@endscope
|
||||
@scope('cell_total_market_value', $row)
|
||||
{{ Number::currency($row->total_market_value ?? 0) }}
|
||||
{{ Number::currency($row->total_market_value ?? 0, $row->market_data->currency) }}
|
||||
@endscope
|
||||
@scope('cell_dividends_earned', $row)
|
||||
{{ Number::currency($row->dividends_earned ?? 0) }}
|
||||
{{ Number::currency($row->dividends_earned ?? 0, $row->market_data->currency) }}
|
||||
@endscope
|
||||
@scope('cell_market_data_updated_at', $row)
|
||||
{{ \Carbon\Carbon::parse($row->market_data_updated_at)->diffForHumans() }}
|
||||
@@ -1,3 +1,5 @@
|
||||
@use('App\Models\Currency')
|
||||
|
||||
<x-app-layout>
|
||||
<div x-data>
|
||||
|
||||
@@ -67,48 +69,56 @@
|
||||
|
||||
<x-ib-card title="{{ __('Fundamentals') }}" class="md:col-span-4">
|
||||
|
||||
@if(!empty($holding->market_data->market_cap))
|
||||
<p>
|
||||
<span class="font-bold">{{ __('Market Cap') }}: </span>
|
||||
${{ Number::forHumans($holding->market_data->market_cap ?? 0) }}
|
||||
{{ Currency::forHumans($holding->market_data->market_cap, $holding->market_data->currency) }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if(!empty($holding->market_data->forward_pe))
|
||||
<p>
|
||||
<span class="font-bold">{{ __('Forward PE') }}: </span>
|
||||
{{ $holding->market_data->forward_pe }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if(!empty($holding->market_data->trailing_pe))
|
||||
<p>
|
||||
<span class="font-bold">{{ __('Trailing PE') }}: </span>
|
||||
{{ $holding->market_data->trailing_pe }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<p>
|
||||
<span class="font-bold">{{ __('Book Value') }}: </span>
|
||||
{{ $holding->market_data->book_value }}
|
||||
</p>
|
||||
@if(!empty($holding->market_data->book_value))
|
||||
<p>
|
||||
<span class="font-bold">{{ __('Book Value') }}: </span>
|
||||
{{ Number::currency($holding->market_data->book_value, $holding->market_data->currency) }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<p>
|
||||
<span class="font-bold">{{ __('52 week') }}: </span>
|
||||
|
||||
<x-fifty-two-week-range
|
||||
:low="$holding->market_data->fifty_two_week_low"
|
||||
:high="$holding->market_data->fifty_two_week_high"
|
||||
:current="$holding->market_data->market_value"
|
||||
/>
|
||||
<x-fifty-two-week-range :market-data="$holding->market_data" />
|
||||
</p>
|
||||
|
||||
@if(!empty($holding->market_data->dividend_yield))
|
||||
<p>
|
||||
<span class="font-bold">{{ __('Dividend Yield') }}: </span>
|
||||
{{ Number::percentage(
|
||||
$holding->market_data->dividend_yield ?? 0,
|
||||
$holding->market_data->dividend_yield,
|
||||
$holding->market_data->dividend_yield < 1 ? 2 : 0
|
||||
) }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if(!empty($holding->market_data->last_dividend_date))
|
||||
<p>
|
||||
<span class="font-bold">{{ __('Last Dividend Paid') }}: </span>
|
||||
{{ $holding->market_data?->last_dividend_date?->format('F d, Y') ?? '' }}
|
||||
{{ $holding->market_data->last_dividend_date->format('F d, Y') }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
</x-ib-card>
|
||||
|
||||
@@ -164,7 +174,7 @@
|
||||
</x-ib-card>
|
||||
|
||||
@if(config('services.ai_chat_enabled'))
|
||||
{{-- // TODO: add to system prompt:
|
||||
{{-- // todo: add to system prompt:
|
||||
// Additionally, here is some recent news about {$this->holding->symbol}:
|
||||
// And their latest SEC filings: --}}
|
||||
@livewire('ai-chat-window', [
|
||||
@@ -209,7 +219,7 @@
|
||||
* 52 week high: {$holding->market_data->fifty_two_week_high}
|
||||
* Dividend yield: {$holding->market_data->dividend_yield}
|
||||
|
||||
This data is current as of today's date: " . now()->format('Y-m-d') . ". Based on this current market data, quantity owned, and average cost basis, you should determine if the {$holding->symbol} holding is making or losing money.
|
||||
This data is current as of today's date: " . now()->toDateString() . ". Based on this current market data, quantity owned, and average cost basis, you should determine if the {$holding->symbol} holding is making or losing money.
|
||||
|
||||
Below is the question from the investor. Considering these facts, provide a concise response to the following question (give a direct response). Limit your response to no more than 75 words and consider using a common decision framework. Use github style markdown for any formatting:"
|
||||
])
|
||||
|
||||
+18
-15
@@ -35,16 +35,7 @@ new class extends Component
|
||||
{
|
||||
$filterMethod = collect($this->scopeOptions)->where('id', $this->scope)->first();
|
||||
|
||||
$dailyChangeQuery = DailyChange::myDailyChanges()->selectRaw('
|
||||
date,
|
||||
SUM(total_market_value) as total_market_value,
|
||||
SUM(total_cost_basis) as total_cost_basis,
|
||||
SUM(total_gain) as total_gain
|
||||
/* ,
|
||||
SUM(realized_gains) as realized_gains,
|
||||
SUM(total_dividends_earned) as total_dividends_earned
|
||||
*/
|
||||
');
|
||||
$dailyChangeQuery = DailyChange::withDailyPerformance();
|
||||
|
||||
if (isset($this->portfolio)) {
|
||||
|
||||
@@ -54,18 +45,30 @@ new class extends Component
|
||||
} else {
|
||||
|
||||
// dashboard
|
||||
$dailyChangeQuery->withoutWishlists();
|
||||
$dailyChangeQuery->myDailyChanges()->withoutWishlists();
|
||||
}
|
||||
|
||||
if ($filterMethod['method']) {
|
||||
|
||||
$dailyChangeQuery->whereDate('date', '>=', now()->{$filterMethod['method']}(...$filterMethod['args']));
|
||||
$dailyChangeQuery->whereDate('daily_change.date', '>=', now()->{$filterMethod['method']}(...$filterMethod['args']));
|
||||
}
|
||||
|
||||
$dailyChange = $dailyChangeQuery
|
||||
->orderBy('date')
|
||||
$dailyChange = $dailyChangeQuery->get();
|
||||
|
||||
$dailyChange = $dailyChange
|
||||
->sortBy('date')
|
||||
->groupBy('date')
|
||||
->get();
|
||||
->map(function ($group) {
|
||||
return (object) [
|
||||
'date' => $group->first()->date->toDateString(),
|
||||
'total_market_value' => $group->sum('total_market_value'),
|
||||
'total_cost_basis' => $group->sum('total_cost_basis'),
|
||||
'total_gain' => $group->sum('total_gain'),
|
||||
'realized_gain_dollars' => $group->sum('realized_gain_dollars'),
|
||||
'total_dividends_earned' => $group->sum('total_dividends_earned'),
|
||||
];
|
||||
})
|
||||
->values();
|
||||
|
||||
return [
|
||||
'series' => [
|
||||
@@ -1,3 +1,5 @@
|
||||
@use('App\Models\Currency')
|
||||
|
||||
<x-app-layout>
|
||||
<div x-data>
|
||||
|
||||
@@ -63,30 +65,29 @@
|
||||
|
||||
<div class="grid sm:grid-cols-5 gap-5">
|
||||
|
||||
|
||||
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
||||
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Market Gain/Loss') }}</div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->total_gain_dollars) }} </div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_gain_dollars', 0)) }} </div>
|
||||
</x-card>
|
||||
|
||||
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
||||
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Total Cost Basis') }}</div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->total_cost_basis) }} </div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_cost_basis', 0)) }} </div>
|
||||
</x-card>
|
||||
|
||||
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
||||
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Total Market Value') }}</div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->total_market_value) }} </div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_market_value', 0)) }} </div>
|
||||
</x-card>
|
||||
|
||||
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
||||
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Realized Gain/Loss') }}</div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->realized_gain_dollars) }} </div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->get('realized_gain_dollars', 0)) }} </div>
|
||||
</x-card>
|
||||
|
||||
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
||||
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Dividends Earned') }}</div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->total_dividends_earned) }} </div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_dividends_earned', 0)) }} </div>
|
||||
</x-card>
|
||||
|
||||
</div>
|
||||
@@ -175,7 +176,7 @@
|
||||
|
||||
{$formattedHoldings}
|
||||
|
||||
This data is current as of today's date: " . now()->format('Y-m-d') . ". Based on the current market data, quantity owned, and average cost basis, you can determine the performance of any holding.
|
||||
This data is current as of today's date: " . now()->toDateString() . ". Based on the current market data, quantity owned, and average cost basis, you can determine the performance of any holding.
|
||||
|
||||
Below is the question from the investor. Considering these facts, provide a concise response to the following question (give a direct response). Limit your response to no more than 75 words and consider using a common decision framework. Use github style markdown for any formatting:"
|
||||
])
|
||||
|
||||
+17
-15
@@ -1,15 +1,15 @@
|
||||
<?php
|
||||
|
||||
use Livewire\WithFileUploads;
|
||||
use Livewire\Volt\Component;
|
||||
use Mary\Traits\Toast;
|
||||
use App\Models\BackupImport as BackupImportModel;
|
||||
use App\Imports\BackupImport;
|
||||
use App\Exports\BackupExport;
|
||||
use App\Models\BackupImport as BackupImportModel;
|
||||
use Livewire\Attributes\Rule;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Mary\Traits\Toast;
|
||||
|
||||
new class extends Component {
|
||||
new class extends Component
|
||||
{
|
||||
use Toast;
|
||||
use WithFileUploads;
|
||||
|
||||
@@ -18,23 +18,26 @@ new class extends Component {
|
||||
public $file;
|
||||
|
||||
public bool $importStatusDialog = false;
|
||||
|
||||
public ?BackupImportModel $backupImport = null;
|
||||
|
||||
public int $percent = 10;
|
||||
|
||||
// methods
|
||||
public function import()
|
||||
public function import()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
if (!RateLimiter::attempt('import:'.auth()->user()->id, $perMinute = 3, fn()=>null)) {
|
||||
if (! RateLimiter::attempt('import:'.auth()->user()->id, $perMinute = 3, fn () => null)) {
|
||||
|
||||
$this->error(__('Hang on! You\'re doing that too much.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->backupImport = BackupImportModel::create([
|
||||
'user_id' => auth()->user()->id,
|
||||
'path' => $this->file->getPathname()
|
||||
'path' => $this->file->getPathname(),
|
||||
]);
|
||||
|
||||
$this->importStatusDialog = true;
|
||||
@@ -45,17 +48,17 @@ new class extends Component {
|
||||
{
|
||||
if (Str::contains($this->backupImport?->message, 'portfolios')) {
|
||||
|
||||
$this->percent = (1/2) * 100;
|
||||
$this->percent = (1 / 2) * 100;
|
||||
}
|
||||
|
||||
if (Str::contains($this->backupImport?->message, 'transactions')) {
|
||||
|
||||
$this->percent = (3/4) * 100;
|
||||
$this->percent = (3 / 4) * 100;
|
||||
}
|
||||
|
||||
if (Str::contains($this->backupImport?->message, 'daily changes')) {
|
||||
|
||||
$this->percent = (7/8) * 100;
|
||||
$this->percent = (7 / 8) * 100;
|
||||
}
|
||||
|
||||
if ($this->backupImport?->status == 'failed') {
|
||||
@@ -75,9 +78,8 @@ new class extends Component {
|
||||
|
||||
public function downloadTemplate()
|
||||
{
|
||||
return Excel::download(new BackupExport(empty: true), now()->format('Y_m_d') . '_investbrain_template.xlsx');
|
||||
return Excel::download(new BackupExport(empty: true), now()->format('Y_m_d').'_investbrain_template.xlsx');
|
||||
}
|
||||
|
||||
}; ?>
|
||||
|
||||
<x-forms.form-section submit="import">
|
||||
@@ -87,7 +89,7 @@ new class extends Component {
|
||||
|
||||
<x-slot name="description">
|
||||
{{ __('Upload or recover your Investbrain portfolio and holdings.') }}
|
||||
<strong><a href="#" title="{{ __('Click to download import template.') }}" @click="$wire.downloadTemplate()"> {{ __('Download import template.') }}</a></strong>
|
||||
<span class="text-xs text-secondary"><a href="#" title="{{ __('Click to download import template.') }}" @click="$wire.downloadTemplate()"> {{ __('Download import template.') }}</a></span>
|
||||
</x-slot>
|
||||
|
||||
<x-slot:form>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user