Compare commits
56 Commits
v1.1.4
...
fixes-imports
| 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 | |||
| c4b7d399ea | |||
| ffe53e91c0 | |||
| aeb1b12afe | |||
| fe81ec7ee7 | |||
| f0ecc0fd3d | |||
| 03b75fb683 | |||
| dc93621547 | |||
| 7ab6f79e56 | |||
| 9e48f21c8d | |||
| 10e6de8df4 | |||
| 00fbdec6f1 | |||
| 730903c383 | |||
| 5fc9455908 | |||
| 28e0ad68fc | |||
| ca48d702a7 | |||
| 812b9ed075 | |||
| 93a0595652 | |||
| 8a357e8cab | |||
| 22e12977f8 | |||
| 732cf02317 | |||
| 6dea75651b | |||
| 6cff252813 | |||
| 0d06ca6a04 | |||
| a3f875270b |
+4
-21
@@ -40,13 +40,10 @@ LINKEDIN_CLIENT_SECRET=
|
|||||||
FACEBOOK_CLIENT_ID=
|
FACEBOOK_CLIENT_ID=
|
||||||
FACEBOOK_CLIENT_SECRET=
|
FACEBOOK_CLIENT_SECRET=
|
||||||
|
|
||||||
APP_NAME=Investbrain
|
FILESYSTEM_DISK=local
|
||||||
APP_TIMEZONE=UTC
|
SESSION_DRIVER=redis
|
||||||
APP_ENV=production
|
QUEUE_CONNECTION=redis
|
||||||
APP_DEBUG=true
|
CACHE_STORE=redis
|
||||||
APP_LOCALE=en
|
|
||||||
APP_FALLBACK_LOCALE=en
|
|
||||||
SELF_HOSTED=true
|
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=investbrain-mysql
|
DB_HOST=investbrain-mysql
|
||||||
@@ -55,19 +52,7 @@ DB_DATABASE=investbrain
|
|||||||
DB_USERNAME=investbrain
|
DB_USERNAME=investbrain
|
||||||
DB_PASSWORD=investbrain
|
DB_PASSWORD=investbrain
|
||||||
|
|
||||||
SESSION_DRIVER=redis
|
|
||||||
SESSION_LIFETIME=120
|
|
||||||
SESSION_ENCRYPT=false
|
|
||||||
SESSION_PATH=/
|
|
||||||
SESSION_DOMAIN=null
|
|
||||||
|
|
||||||
FILESYSTEM_DISK=local
|
|
||||||
QUEUE_CONNECTION=redis
|
|
||||||
|
|
||||||
CACHE_STORE=redis
|
|
||||||
|
|
||||||
REDIS_HOST=investbrain-redis
|
REDIS_HOST=investbrain-redis
|
||||||
REDIS_PATH=/tmp/database_server.sock
|
|
||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
@@ -85,5 +70,3 @@ AWS_SECRET_ACCESS_KEY=
|
|||||||
AWS_DEFAULT_REGION=us-east-1
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
AWS_BUCKET=
|
AWS_BUCKET=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
|
||||||
|
|||||||
@@ -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: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: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: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. |
|
| 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: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). |
|
| sync:holdings | Re-calculates performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| ------- | ------------------ |
|
| ------- | ------------------ |
|
||||||
| 1.0.x | :white_check_mark: |
|
| 1.1.x | :white_check_mark: |
|
||||||
|
| 1.0.x | :x: |
|
||||||
| < 1.0.0 | :x: |
|
| < 1.0.0 | :x: |
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|||||||
@@ -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\Hash;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||||
use Laravel\Jetstream\Jetstream;
|
|
||||||
|
|
||||||
class CreateNewUser implements CreatesNewUsers
|
class CreateNewUser implements CreatesNewUsers
|
||||||
{
|
{
|
||||||
@@ -32,13 +31,22 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
|
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
|
||||||
'password' => $this->passwordRules(),
|
'password' => $this->passwordRules(),
|
||||||
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
|
'terms' => config('investbrain.self_hosted') ? '' : ['accepted', 'required'],
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
return User::create([
|
$user = User::make([
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
'email' => $input['email'],
|
'email' => $input['email'],
|
||||||
'password' => Hash::make($input['password']),
|
'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;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Holding;
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
@@ -44,23 +45,20 @@ class CaptureDailyChange extends Command
|
|||||||
|
|
||||||
$this->line('Capturing daily change for '.$portfolio->title);
|
$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');
|
$total_cost_basis = $metrics->get('total_cost_basis');
|
||||||
|
$total_market_value = $metrics->get('total_market_value');
|
||||||
$realized_gains = $portfolio->holdings->sum('realized_gain_dollars');
|
|
||||||
|
|
||||||
$total_market_value = $portfolio->holdings->sum(function ($holding) {
|
|
||||||
return $holding->market_data->market_value * $holding->quantity;
|
|
||||||
});
|
|
||||||
|
|
||||||
$portfolio->daily_change()->create([
|
$portfolio->daily_change()->create([
|
||||||
'date' => now(),
|
'date' => now(),
|
||||||
'total_market_value' => $total_market_value,
|
'total_market_value' => $total_market_value,
|
||||||
'total_cost_basis' => $total_cost_basis,
|
'total_cost_basis' => $total_cost_basis,
|
||||||
'total_gain' => $total_market_value - $total_cost_basis,
|
'total_gain' => $total_market_value - $total_cost_basis,
|
||||||
'total_dividends_earned' => $total_dividends,
|
'total_dividends_earned' => $metrics->get('total_dividends_earned'),
|
||||||
'realized_gains' => $realized_gains,
|
'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;
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use App\Exports\Sheets\ConfigSheet;
|
||||||
use App\Exports\Sheets\DailyChangesSheet;
|
use App\Exports\Sheets\DailyChangesSheet;
|
||||||
use App\Exports\Sheets\PortfoliosSheet;
|
use App\Exports\Sheets\PortfoliosSheet;
|
||||||
use App\Exports\Sheets\TransactionsSheet;
|
use App\Exports\Sheets\TransactionsSheet;
|
||||||
@@ -24,6 +25,7 @@ class BackupExport implements WithMultipleSheets
|
|||||||
new PortfoliosSheet($this->empty),
|
new PortfoliosSheet($this->empty),
|
||||||
new TransactionsSheet($this->empty),
|
new TransactionsSheet($this->empty),
|
||||||
new DailyChangesSheet($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()
|
public function collection()
|
||||||
{
|
{
|
||||||
return $this->empty ? collect() : DailyChange::myDailyChanges()->get();
|
return $this->empty ? collect() : DailyChange::myDailyChanges()->withDailyPerformance()->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function title(): string
|
public function title(): string
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
'Quantity',
|
'Quantity',
|
||||||
'Cost Basis',
|
'Cost Basis',
|
||||||
'Sale Price',
|
'Sale Price',
|
||||||
|
'Currency',
|
||||||
'Split',
|
'Split',
|
||||||
'Reinvested Dividend',
|
'Reinvested Dividend',
|
||||||
'Date',
|
'Date',
|
||||||
@@ -38,7 +39,30 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
*/
|
*/
|
||||||
public function collection()
|
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
|
public function title(): string
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class TransactionController extends ApiController
|
|||||||
|
|
||||||
$filters->setQuery(Transaction::query());
|
$filters->setQuery(Transaction::query());
|
||||||
$filters->setScopes(['myTransactions']);
|
$filters->setScopes(['myTransactions']);
|
||||||
|
$filters->setEagerRelations(['market_data']);
|
||||||
$filters->setSearchableColumns(['symbol']);
|
$filters->setSearchableColumns(['symbol']);
|
||||||
|
|
||||||
return TransactionResource::collection($filters->paginated());
|
return TransactionResource::collection($filters->paginated());
|
||||||
|
|||||||
@@ -17,16 +17,14 @@ class DashboardController extends Controller
|
|||||||
$user = $request->user()->load(['portfolios', 'holdings', 'transactions']);
|
$user = $request->user()->load(['portfolios', 'holdings', 'transactions']);
|
||||||
|
|
||||||
// get portfolio metrics
|
// get portfolio metrics
|
||||||
$metrics = cache()->remember(
|
$metrics = cache()->tags(['metrics-'.$user->id])->remember(
|
||||||
'dashboard-metrics-'.$user->id,
|
'dashboard-metrics-'.$user->id,
|
||||||
10,
|
10,
|
||||||
function () {
|
function () {
|
||||||
return
|
return Holding::query()
|
||||||
Holding::query()
|
|
||||||
->myHoldings()
|
->myHoldings()
|
||||||
->withoutWishlists()
|
->withoutWishlists()
|
||||||
->withPortfolioMetrics()
|
->getPortfolioMetrics();
|
||||||
->first();
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -29,14 +29,13 @@ class PortfolioController extends Controller
|
|||||||
$portfolio->load(['transactions', 'holdings']);
|
$portfolio->load(['transactions', 'holdings']);
|
||||||
|
|
||||||
// get portfolio metrics
|
// get portfolio metrics
|
||||||
$metrics = cache()->remember(
|
$metrics = cache()->tags(['metrics-'.$request->user()->id])->remember(
|
||||||
'portfolio-metrics-'.$portfolio->id,
|
'portfolio-metrics-'.$portfolio->id,
|
||||||
60,
|
60,
|
||||||
function () use ($portfolio) {
|
function () use ($portfolio) {
|
||||||
return Holding::query()
|
return Holding::query()
|
||||||
->portfolio($portfolio->id)
|
->portfolio($portfolio->id)
|
||||||
->withPortfolioMetrics()
|
->getPortfolioMetrics();
|
||||||
->first();
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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'],
|
'portfolio_id' => ['required', 'exists:portfolios,id'],
|
||||||
'symbol' => ['required', 'string', new SymbolValidationRule],
|
'symbol' => ['required', 'string', new SymbolValidationRule],
|
||||||
'transaction_type' => ['required', 'string', 'in:BUY,SELL'],
|
'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' => [
|
'quantity' => [
|
||||||
'required',
|
'required',
|
||||||
'numeric',
|
'numeric',
|
||||||
'min:0',
|
'gt:0',
|
||||||
new QuantityValidationRule(
|
new QuantityValidationRule(
|
||||||
$this->input('portfolio'),
|
$this->input('portfolio'),
|
||||||
$this->requestOrModelValue('symbol', 'transaction'),
|
$this->requestOrModelValue('symbol', 'transaction'),
|
||||||
@@ -42,6 +42,7 @@ class TransactionRequest extends FormRequest
|
|||||||
$this->requestOrModelValue('date', 'transaction')
|
$this->requestOrModelValue('date', 'transaction')
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
'currency' => ['required', 'exists:currencies,currency'],
|
||||||
'cost_basis' => ['exclude_if:transaction_type,SELL', 'min:0', 'numeric'],
|
'cost_basis' => ['exclude_if:transaction_type,SELL', 'min:0', 'numeric'],
|
||||||
'sale_price' => ['exclude_if:transaction_type,BUY', '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['portfolio_id'][0] = 'sometimes';
|
||||||
$rules['symbol'][0] = 'sometimes';
|
$rules['symbol'][0] = 'sometimes';
|
||||||
$rules['transaction_type'][0] = 'sometimes';
|
$rules['transaction_type'][0] = 'sometimes';
|
||||||
|
$rules['currency'][0] = 'sometimes';
|
||||||
$rules['date'][0] = 'sometimes';
|
$rules['date'][0] = 'sometimes';
|
||||||
$rules['quantity'][0] = 'sometimes';
|
$rules['quantity'][0] = 'sometimes';
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class HoldingResource extends JsonResource
|
|||||||
'portfolio_id' => $this->portfolio_id,
|
'portfolio_id' => $this->portfolio_id,
|
||||||
'symbol' => $this->symbol,
|
'symbol' => $this->symbol,
|
||||||
'quantity' => $this->quantity,
|
'quantity' => $this->quantity,
|
||||||
|
'currency' => $this->market_data->currency,
|
||||||
'reinvest_dividends' => $this->reinvest_dividends,
|
'reinvest_dividends' => $this->reinvest_dividends,
|
||||||
'average_cost_basis' => $this->average_cost_basis,
|
'average_cost_basis' => $this->average_cost_basis,
|
||||||
'total_cost_basis' => $this->total_cost_basis,
|
'total_cost_basis' => $this->total_cost_basis,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class TransactionResource extends JsonResource
|
|||||||
'portfolio_id' => $this->portfolio_id,
|
'portfolio_id' => $this->portfolio_id,
|
||||||
'transaction_type' => $this->transaction_type,
|
'transaction_type' => $this->transaction_type,
|
||||||
'quantity' => $this->quantity,
|
'quantity' => $this->quantity,
|
||||||
|
'currency' => $this->market_data->currency,
|
||||||
'cost_basis' => $this->cost_basis,
|
'cost_basis' => $this->cost_basis,
|
||||||
'sale_price' => $this->sale_price,
|
'sale_price' => $this->sale_price,
|
||||||
'split' => $this->split,
|
'split' => $this->split,
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ class UserResource extends JsonResource
|
|||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'email' => $this->email,
|
'email' => $this->email,
|
||||||
'profile_photo_url' => $this->profile_photo_url,
|
'profile_photo_url' => $this->profile_photo_url,
|
||||||
|
'options' => [
|
||||||
|
'display_currency' => $this->getCurrency(),
|
||||||
|
'locale' => $this->getLocale(),
|
||||||
|
],
|
||||||
'created_at' => $this->created_at,
|
'created_at' => $this->created_at,
|
||||||
'updated_at' => $this->updated_at,
|
'updated_at' => $this->updated_at,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Console\Commands\RefreshDividendData;
|
|||||||
use App\Console\Commands\RefreshMarketData;
|
use App\Console\Commands\RefreshMarketData;
|
||||||
use App\Console\Commands\SyncDailyChange;
|
use App\Console\Commands\SyncDailyChange;
|
||||||
use App\Console\Commands\SyncHoldingData;
|
use App\Console\Commands\SyncHoldingData;
|
||||||
|
use App\Imports\Sheets\ConfigSheet;
|
||||||
use App\Imports\Sheets\DailyChangesSheet;
|
use App\Imports\Sheets\DailyChangesSheet;
|
||||||
use App\Imports\Sheets\PortfoliosSheet;
|
use App\Imports\Sheets\PortfoliosSheet;
|
||||||
use App\Imports\Sheets\TransactionsSheet;
|
use App\Imports\Sheets\TransactionsSheet;
|
||||||
@@ -69,6 +70,7 @@ class BackupImport implements WithEvents, WithMultipleSheets
|
|||||||
'Portfolios' => new PortfoliosSheet($this->backupImportModel),
|
'Portfolios' => new PortfoliosSheet($this->backupImportModel),
|
||||||
'Transactions' => new TransactionsSheet($this->backupImportModel),
|
'Transactions' => new TransactionsSheet($this->backupImportModel),
|
||||||
'Daily Changes' => new DailyChangesSheet($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) {
|
BeforeSheet::class => function (BeforeSheet $event) {
|
||||||
DB::commit();
|
DB::commit();
|
||||||
$this->backupImport->update([
|
$this->backupImport->update([
|
||||||
'message' => __('Importing daily changes...'),
|
'message' => __('Preparing to import daily changes...'),
|
||||||
]);
|
]);
|
||||||
DB::beginTransaction();
|
DB::beginTransaction();
|
||||||
},
|
},
|
||||||
@@ -40,22 +40,23 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
|
|
||||||
public function collection(Collection $dailyChanges)
|
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->validatePortfolioAccess($chunk);
|
||||||
|
|
||||||
|
$this->backupImport->update([
|
||||||
|
'message' => __('Importing daily changes (Batch :currentBatch of :totalBatches)...', ['currentBatch' => $index + 1, 'totalBatches' => $totalBatches]),
|
||||||
|
]);
|
||||||
|
|
||||||
// have to cast to native values
|
// have to cast to native values
|
||||||
$chunk = $chunk->map(function ($dailyChange) {
|
$chunk = $chunk->map(function ($dailyChange) {
|
||||||
|
|
||||||
return [
|
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'],
|
'annotation' => $dailyChange['annotation'],
|
||||||
'portfolio_id' => $dailyChange['portfolio_id'],
|
'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(),
|
$chunk->toArray(),
|
||||||
['portfolio_id', 'date'],
|
['portfolio_id', 'date'],
|
||||||
[
|
[
|
||||||
'total_market_value',
|
|
||||||
'total_cost_basis',
|
|
||||||
'total_gain',
|
|
||||||
'total_dividends_earned',
|
|
||||||
'realized_gains',
|
|
||||||
'annotation',
|
'annotation',
|
||||||
'portfolio_id',
|
'portfolio_id',
|
||||||
'date',
|
'date',
|
||||||
@@ -86,11 +82,6 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
return [
|
return [
|
||||||
'portfolio_id' => ['required', 'uuid'],
|
'portfolio_id' => ['required', 'uuid'],
|
||||||
'date' => ['required', 'date'],
|
'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'],
|
'annotation' => ['sometimes', 'nullable', 'string'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ namespace App\Imports\Sheets;
|
|||||||
|
|
||||||
use App\Imports\ValidatesPortfolioAccess;
|
use App\Imports\ValidatesPortfolioAccess;
|
||||||
use App\Models\BackupImport;
|
use App\Models\BackupImport;
|
||||||
|
use App\Models\Currency;
|
||||||
|
use App\Models\CurrencyRate;
|
||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -33,7 +35,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
BeforeSheet::class => function (BeforeSheet $event) {
|
BeforeSheet::class => function (BeforeSheet $event) {
|
||||||
DB::commit();
|
DB::commit();
|
||||||
$this->backupImport->update([
|
$this->backupImport->update([
|
||||||
'message' => __('Importing transactions...'),
|
'message' => __('Preparing to import transactions...'),
|
||||||
]);
|
]);
|
||||||
DB::beginTransaction();
|
DB::beginTransaction();
|
||||||
},
|
},
|
||||||
@@ -43,13 +45,37 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
public function collection(Collection $transactions)
|
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);
|
$this->validatePortfolioAccess($chunk);
|
||||||
|
|
||||||
// have to cast to native values
|
// have to cast to native values
|
||||||
$chunk = $chunk->map(function ($transaction) {
|
$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 [
|
return [
|
||||||
'id' => $transaction['transaction_id'] ?? Str::uuid()->toString(),
|
'id' => $transaction['transaction_id'] ?? Str::uuid()->toString(),
|
||||||
'symbol' => strtoupper($transaction['symbol']),
|
'symbol' => strtoupper($transaction['symbol']),
|
||||||
@@ -58,9 +84,11 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
'quantity' => $transaction['quantity'],
|
'quantity' => $transaction['quantity'],
|
||||||
'cost_basis' => $transaction['cost_basis'] ?? 0,
|
'cost_basis' => $transaction['cost_basis'] ?? 0,
|
||||||
'sale_price' => $transaction['sale_price'],
|
'sale_price' => $transaction['sale_price'],
|
||||||
|
'cost_basis_base' => $cost_basis_base,
|
||||||
|
'sale_price_base' => $sale_price_base,
|
||||||
'split' => boolval($transaction['split']) ? 1 : 0,
|
'split' => boolval($transaction['split']) ? 1 : 0,
|
||||||
'reinvested_dividend' => boolval($transaction['reinvested_dividend']) ? 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'])
|
$chunk->unique(fn ($item) => $item['symbol'].$item['portfolio_id'])
|
||||||
->each(function ($holding) {
|
->each(function ($holding) {
|
||||||
|
|
||||||
@@ -112,6 +140,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
'transaction_type' => ['required', 'in:BUY,SELL'],
|
'transaction_type' => ['required', 'in:BUY,SELL'],
|
||||||
'date' => ['required', 'date'],
|
'date' => ['required', 'date'],
|
||||||
'quantity' => ['required', 'min:0', 'numeric'],
|
'quantity' => ['required', 'min:0', 'numeric'],
|
||||||
|
'currency' => ['required', 'string'],
|
||||||
'split' => ['sometimes', 'nullable', 'boolean'],
|
'split' => ['sometimes', 'nullable', 'boolean'],
|
||||||
'reinvested_dividend' => ['sometimes', 'nullable', 'boolean'],
|
'reinvested_dividend' => ['sometimes', 'nullable', 'boolean'],
|
||||||
'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ trait ValidatesPortfolioAccess
|
|||||||
public function validatePortfolioAccess($collection)
|
public function validatePortfolioAccess($collection)
|
||||||
{
|
{
|
||||||
|
|
||||||
$uniquePortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id');
|
$importingPortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id');
|
||||||
$countPortfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id)
|
$portfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id)
|
||||||
->whereIn('id', $uniquePortfolios)
|
->whereIn('id', $importingPortfolios)
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
$countPortfoliosWithAccess < $uniquePortfolios->count()
|
$importingPortfolios->count() > $portfoliosWithAccess
|
||||||
) {
|
) {
|
||||||
throw new \Exception(__('You do not have access to that portfolio.'));
|
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
|
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 = Alphavantage::core()->quoteEndpoint($symbol);
|
||||||
$quote = Arr::get($quote, 'Global Quote', []);
|
$quote = Arr::get($quote, 'Global Quote', []);
|
||||||
|
|
||||||
$fundamental = cache()->remember(
|
$fundamental = cache()->remember(
|
||||||
'av-symbol-'.$symbol,
|
'av-symbol-'.$symbol,
|
||||||
1440,
|
1440,
|
||||||
function () use ($symbol) {
|
function () use ($symbol, $search) {
|
||||||
return Alphavantage::fundamentals()->overview($symbol);
|
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([
|
return new Quote([
|
||||||
'name' => Arr::get($fundamental, 'Name'),
|
'name' => Arr::get($search, '2. name'),
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'market_value' => Arr::get($quote, '05. price'),
|
'market_value' => (float) Arr::get($quote, '05. price'),
|
||||||
'fifty_two_week_high' => Arr::get($fundamental, '52WeekHigh'),
|
'currency' => Arr::get($search, '8. currency'),
|
||||||
'fifty_two_week_low' => Arr::get($fundamental, '52WeekLow'),
|
'fifty_two_week_high' => (float) Arr::get($fundamental, '52WeekHigh'),
|
||||||
|
'fifty_two_week_low' => (float) Arr::get($fundamental, '52WeekLow'),
|
||||||
'forward_pe' => Arr::get($fundamental, 'ForwardPE'),
|
'forward_pe' => Arr::get($fundamental, 'ForwardPE'),
|
||||||
'trailing_pe' => Arr::get($fundamental, 'TrailingPE'),
|
'trailing_pe' => Arr::get($fundamental, 'TrailingPE'),
|
||||||
'market_cap' => Arr::get($fundamental, 'MarketCapitalization'),
|
'market_cap' => Arr::get($fundamental, 'MarketCapitalization'),
|
||||||
@@ -48,8 +69,20 @@ class AlphaVantageMarketData implements MarketDataInterface
|
|||||||
? Arr::get($fundamental, 'DividendDate')
|
? Arr::get($fundamental, 'DividendDate')
|
||||||
: null,
|
: null,
|
||||||
'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None'
|
'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None'
|
||||||
? Arr::get($fundamental, 'DividendYield')
|
? Arr::get($fundamental, 'DividendYield') * 100
|
||||||
: null,
|
: 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) {
|
->mapWithKeys(function ($history, $date) use ($symbol) {
|
||||||
|
|
||||||
$date = Carbon::parse($date)->format('Y-m-d');
|
$date = Carbon::parse($date)->toDateString();
|
||||||
|
|
||||||
return [$date => new Ohlc([
|
return [$date => new Ohlc([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Interfaces\MarketData\Types\Dividend;
|
|||||||
use App\Interfaces\MarketData\Types\Ohlc;
|
use App\Interfaces\MarketData\Types\Ohlc;
|
||||||
use App\Interfaces\MarketData\Types\Quote;
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
use App\Interfaces\MarketData\Types\Split;
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
|
use Carbon\CarbonPeriod;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ class FakeMarketData implements MarketDataInterface
|
|||||||
return new Quote([
|
return new Quote([
|
||||||
'name' => 'ACME Company Ltd',
|
'name' => 'ACME Company Ltd',
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
|
'currency' => 'USD',
|
||||||
'market_value' => 230.19,
|
'market_value' => 230.19,
|
||||||
'fifty_two_week_high' => 512.90,
|
'fifty_two_week_high' => 512.90,
|
||||||
'fifty_two_week_low' => 341.20,
|
'fifty_two_week_low' => 341.20,
|
||||||
@@ -34,6 +36,7 @@ class FakeMarketData implements MarketDataInterface
|
|||||||
'book_value' => 4.7,
|
'book_value' => 4.7,
|
||||||
'last_dividend_date' => now()->subDays(45),
|
'last_dividend_date' => now()->subDays(45),
|
||||||
'dividend_yield' => 0.033,
|
'dividend_yield' => 0.033,
|
||||||
|
'meta_data' => [],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +68,7 @@ class FakeMarketData implements MarketDataInterface
|
|||||||
return collect([
|
return collect([
|
||||||
new Split([
|
new Split([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => now()->subMonths(36),
|
'date' => now()->subMonths(12),
|
||||||
'split_amount' => 10,
|
'split_amount' => 10,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
@@ -73,16 +76,27 @@ class FakeMarketData implements MarketDataInterface
|
|||||||
|
|
||||||
public function history(string $symbol, $startDate, $endDate): Collection
|
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([
|
$series[$date] = new Ohlc([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => $date,
|
'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) {
|
foreach ($providers as $provider) {
|
||||||
|
|
||||||
$provider = trim($provider);
|
$provider = trim($provider);
|
||||||
|
$symbol = $arguments[0];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Log::warning("Calling method {$method} ({$provider})");
|
Log::info("Calling method {$method} for {$symbol} ({$provider})");
|
||||||
|
|
||||||
if (! in_array($provider, array_keys(config('investbrain.interfaces', [])))) {
|
if (! in_array($provider, array_keys(config('investbrain.interfaces', [])))) {
|
||||||
|
|
||||||
@@ -35,17 +36,17 @@ class FallbackInterface
|
|||||||
|
|
||||||
$this->latest_error = $e->getMessage();
|
$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') {
|
if ($method == 'exists') {
|
||||||
|
|
||||||
// symbol prob just doesn't exist
|
// symbol prob just doesn't exist
|
||||||
return false;
|
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\Ohlc;
|
||||||
use App\Interfaces\MarketData\Types\Quote;
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
use App\Interfaces\MarketData\Types\Split;
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
|
use Finnhub\ObjectSerializer;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@@ -35,32 +36,46 @@ class FinnhubMarketData implements MarketDataInterface
|
|||||||
{
|
{
|
||||||
$quote = $this->client->quote($symbol);
|
$quote = $this->client->quote($symbol);
|
||||||
|
|
||||||
|
if (is_null(Arr::get($quote, 'd'))) {
|
||||||
|
throw new \Exception('Could not find ticker on Finnhub');
|
||||||
|
}
|
||||||
|
|
||||||
$fundamental = cache()->remember(
|
$fundamental = cache()->remember(
|
||||||
'fh-symbol-'.$symbol,
|
'fh-symbol-'.$symbol,
|
||||||
1440,
|
1440,
|
||||||
function () use ($symbol) {
|
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([
|
return new Quote([
|
||||||
'name' => Arr::get($fundamental, 'metric.name'),
|
'name' => Arr::get($fundamental, 'name'),
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
|
'currency' => Arr::get($fundamental, 'currency'),
|
||||||
'market_value' => Arr::get($quote, 'c'),
|
'market_value' => Arr::get($quote, 'c'),
|
||||||
'fifty_two_week_high' => Arr::get($fundamental, 'metric.52WeekHigh'),
|
'fifty_two_week_high' => Arr::get($fundamental, 'metric.52WeekHigh'),
|
||||||
'fifty_two_week_low' => Arr::get($fundamental, 'metric.52WeekLow'),
|
'fifty_two_week_low' => Arr::get($fundamental, 'metric.52WeekLow'),
|
||||||
'forward_pe' => Arr::get($fundamental, 'metric.forwardPE'), // confirm
|
'forward_pe' => Arr::get($fundamental, 'metric.peAnnual'),
|
||||||
'trailing_pe' => Arr::get($fundamental, 'metric.trailingPE'), // confirm
|
'trailing_pe' => Arr::get($fundamental, 'metric.peTTM'),
|
||||||
'market_cap' => Arr::get($fundamental, 'metric.marketCapitalization'), // confirm
|
'market_cap' => Arr::get($fundamental, 'metric.marketCapitalization', 0) * 1000000,
|
||||||
'book_value' => Arr::get($fundamental, 'metric.bookValuePerShare'), // confirm
|
'book_value' => Arr::get($fundamental, 'metric.bookValuePerShareAnnual'),
|
||||||
'last_dividend_date' => Arr::get($fundamental, 'metric.lastDivDate'), // confirm
|
'dividend_yield' => Arr::get($fundamental, 'metric.dividendYieldIndicatedAnnual'),
|
||||||
'dividend_yield' => Arr::get($fundamental, 'metric.dividendYield'), // confirm
|
'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
|
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) {
|
return collect($dividends)->map(function ($dividend) use ($symbol) {
|
||||||
|
|
||||||
@@ -75,7 +90,7 @@ class FinnhubMarketData implements MarketDataInterface
|
|||||||
public function splits($symbol, $startDate, $endDate): Collection
|
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) {
|
return collect($splits)->map(function ($split) use ($symbol) {
|
||||||
|
|
||||||
@@ -96,7 +111,7 @@ class FinnhubMarketData implements MarketDataInterface
|
|||||||
$closes = Arr::get($history, 'c', []);
|
$closes = Arr::get($history, 'c', []);
|
||||||
|
|
||||||
return collect($timestamps)->mapWithKeys(function ($timestamp, $index) use ($symbol, $closes) {
|
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([
|
return [$date => new Ohlc([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class Dividend extends MarketDataType
|
|||||||
return $this->items['symbol'] ?? '';
|
return $this->items['symbol'] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setDividendAmount($dividendAmount): self
|
public function setDividendAmount(int|float $dividendAmount): self
|
||||||
{
|
{
|
||||||
$this->items['dividend_amount'] = (float) $dividendAmount;
|
$this->items['dividend_amount'] = (float) $dividendAmount;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Interfaces\MarketData\Types;
|
namespace App\Interfaces\MarketData\Types;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
@@ -12,24 +13,79 @@ class MarketDataType extends Collection
|
|||||||
public function __construct($items = [])
|
public function __construct($items = [])
|
||||||
{
|
{
|
||||||
|
|
||||||
foreach ($this->getArrayableItems($items) as $key => $value) {
|
$items = $this->getArrayableItems($items);
|
||||||
|
|
||||||
|
foreach ($items as $key => $value) {
|
||||||
|
|
||||||
|
$this->validateRequiredTypes($key, $value);
|
||||||
|
|
||||||
|
if (! is_null($value)) {
|
||||||
$this->{$key} = $value;
|
$this->{$key} = $value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toArray()
|
|
||||||
{
|
|
||||||
return $this->items;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __set($key, $value)
|
public function __set($key, $value)
|
||||||
{
|
{
|
||||||
$this->{'set'.Str::studly($key)}($value);
|
|
||||||
|
$this->{$this->getSetMethodName($key)}($value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __get($key)
|
public function __get($key)
|
||||||
{
|
{
|
||||||
return $this->items[$key] ?? null;
|
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'] ?? '';
|
return $this->items['symbol'] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setOpen($open): self
|
public function setOpen(int|float $open): self
|
||||||
{
|
{
|
||||||
$this->items['open'] = (float) $open;
|
$this->items['open'] = (float) $open;
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ class Ohlc extends MarketDataType
|
|||||||
return $this->items['open'] ?? 0.0;
|
return $this->items['open'] ?? 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setHigh($high): self
|
public function setHigh(int|float $high): self
|
||||||
{
|
{
|
||||||
$this->items['high'] = (float) $high;
|
$this->items['high'] = (float) $high;
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ class Ohlc extends MarketDataType
|
|||||||
return $this->items['high'] ?? 0.0;
|
return $this->items['high'] ?? 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setLow($low): self
|
public function setLow(int|float $low): self
|
||||||
{
|
{
|
||||||
$this->items['low'] = (float) $low;
|
$this->items['low'] = (float) $low;
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ class Ohlc extends MarketDataType
|
|||||||
return $this->items['low'] ?? 0.0;
|
return $this->items['low'] ?? 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setClose($close): self
|
public function setClose(int|float $close): self
|
||||||
{
|
{
|
||||||
$this->items['close'] = (float) $close;
|
$this->items['close'] = (float) $close;
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ declare(strict_types=1);
|
|||||||
namespace App\Interfaces\MarketData\Types;
|
namespace App\Interfaces\MarketData\Types;
|
||||||
|
|
||||||
use DateTime;
|
use DateTime;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class Quote extends MarketDataType
|
class Quote extends MarketDataType
|
||||||
{
|
{
|
||||||
public function setName(string $name): self
|
public function setName($name): self
|
||||||
{
|
{
|
||||||
|
if (! empty($name)) {
|
||||||
$this->items['name'] = (string) $name;
|
$this->items['name'] = (string) $name;
|
||||||
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
@@ -33,7 +36,19 @@ class Quote extends MarketDataType
|
|||||||
return $this->items['symbol'] ?? '';
|
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;
|
$this->items['market_value'] = (float) $marketValue;
|
||||||
|
|
||||||
@@ -95,6 +110,7 @@ class Quote extends MarketDataType
|
|||||||
|
|
||||||
public function setMarketCap($cap): self
|
public function setMarketCap($cap): self
|
||||||
{
|
{
|
||||||
|
// return $this;
|
||||||
$this->items['market_cap'] = (int) $cap;
|
$this->items['market_cap'] = (int) $cap;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
@@ -117,6 +133,18 @@ class Quote extends MarketDataType
|
|||||||
return $this->items['book_value'] ?? 0.0;
|
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
|
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');
|
$this->items['last_dividend_date'] = is_null($date) ? null : Carbon::parse($date)->format('Y-m-d H:i:s');
|
||||||
@@ -140,4 +168,28 @@ class Quote extends MarketDataType
|
|||||||
{
|
{
|
||||||
return $this->items['dividend_yield'] ?? 0.0;
|
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'] ?? '';
|
return $this->items['symbol'] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setSplitAmount($splitAmount): self
|
public function setSplitAmount(int|float $splitAmount): self
|
||||||
{
|
{
|
||||||
$this->items['split_amount'] = (float) $splitAmount;
|
$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\Ohlc;
|
||||||
use App\Interfaces\MarketData\Types\Quote;
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
use App\Interfaces\MarketData\Types\Split;
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Scheb\YahooFinanceApi\ApiClient;
|
use Scheb\YahooFinanceApi\ApiClient;
|
||||||
use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance;
|
use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance;
|
||||||
@@ -34,9 +35,14 @@ class YahooMarketData implements MarketDataInterface
|
|||||||
|
|
||||||
$quote = $this->client->getQuote($symbol);
|
$quote = $this->client->getQuote($symbol);
|
||||||
|
|
||||||
|
if (is_null($quote?->getRegularMarketPrice())) {
|
||||||
|
throw new \Exception('Could not find ticker on Yahoo');
|
||||||
|
}
|
||||||
|
|
||||||
return new Quote([
|
return new Quote([
|
||||||
'name' => $quote?->getLongName() ?? $quote?->getShortName(),
|
'name' => $quote?->getLongName() ?? $quote?->getShortName(),
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
|
'currency' => $quote?->getCurrency(),
|
||||||
'market_value' => $quote?->getRegularMarketPrice(),
|
'market_value' => $quote?->getRegularMarketPrice(),
|
||||||
'fifty_two_week_high' => $quote?->getFiftyTwoWeekHigh(),
|
'fifty_two_week_high' => $quote?->getFiftyTwoWeekHigh(),
|
||||||
'fifty_two_week_low' => $quote?->getFiftyTwoWeekLow(),
|
'fifty_two_week_low' => $quote?->getFiftyTwoWeekLow(),
|
||||||
@@ -46,6 +52,11 @@ class YahooMarketData implements MarketDataInterface
|
|||||||
'book_value' => $quote?->getBookValue(),
|
'book_value' => $quote?->getBookValue(),
|
||||||
'last_dividend_date' => $quote?->getDividendDate(),
|
'last_dividend_date' => $quote?->getDividendDate(),
|
||||||
'dividend_yield' => $quote?->getTrailingAnnualDividendYield() * 100,
|
'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))
|
return collect($this->client->getHistoricalQuoteData($symbol, ApiClient::INTERVAL_1_DAY, $startDate, $endDate))
|
||||||
->mapWithKeys(function ($history) use ($symbol) {
|
->mapWithKeys(function ($history) use ($symbol) {
|
||||||
|
|
||||||
$date = $history->getDate()->format('Y-m-d');
|
$date = Carbon::parse($history->getDate())->toDateString();
|
||||||
|
|
||||||
return [$date => new Ohlc([
|
return [$date => new Ohlc([
|
||||||
'symbol' => $symbol,
|
'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',
|
'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 App\Traits\HasCompositePrimaryKey;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class DailyChange extends Model
|
class DailyChange extends Model
|
||||||
{
|
{
|
||||||
@@ -22,10 +23,6 @@ class DailyChange extends Model
|
|||||||
'portfolio_id',
|
'portfolio_id',
|
||||||
'date',
|
'date',
|
||||||
'total_market_value',
|
'total_market_value',
|
||||||
'total_cost_basis',
|
|
||||||
'total_gain',
|
|
||||||
'total_dividends_earned',
|
|
||||||
'realized_gains',
|
|
||||||
'notes',
|
'notes',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -33,11 +30,16 @@ class DailyChange extends Model
|
|||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'date' => 'datetime',
|
'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)
|
public function scopePortfolio($query, $portfolio)
|
||||||
{
|
{
|
||||||
return $query->where('portfolio_id', $portfolio);
|
return $query->where('daily_change.portfolio_id', $portfolio);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeMyDailyChanges()
|
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()
|
public function portfolio()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Portfolio::class);
|
return $this->belongsTo(Portfolio::class);
|
||||||
|
|||||||
+73
-21
@@ -4,16 +4,24 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Actions\CopyToBaseCurrency;
|
||||||
|
use App\Casts\BaseCurrency;
|
||||||
use App\Interfaces\MarketData\MarketDataInterface;
|
use App\Interfaces\MarketData\MarketDataInterface;
|
||||||
|
use App\Traits\HasMarketData;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Pipeline;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class Dividend extends Model
|
class Dividend extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use HasMarketData;
|
||||||
use HasUuids;
|
use HasUuids;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
@@ -25,21 +33,32 @@ class Dividend extends Model
|
|||||||
protected $hidden = [];
|
protected $hidden = [];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'date' => 'datetime',
|
'date' => 'date',
|
||||||
'last_dividend_update' => 'datetime',
|
'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');
|
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function transactions()
|
public function transactions(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
|
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
|
||||||
}
|
}
|
||||||
@@ -67,7 +86,7 @@ class Dividend extends Model
|
|||||||
// nope, refresh forward looking only
|
// nope, refresh forward looking only
|
||||||
if ($dividends_meta->total_dividends) {
|
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
|
// skip refresh if there's already recent data
|
||||||
@@ -76,55 +95,88 @@ class Dividend extends Model
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dump('1. getting div data for '.$symbol);
|
||||||
|
try {
|
||||||
|
|
||||||
// get some data
|
// get some data
|
||||||
if ($dividend_data = collect() && $start_date && $end_date) {
|
if ($dividend_data = collect() && $start_date && $end_date) {
|
||||||
$dividend_data = app(MarketDataInterface::class)->dividends($symbol, $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...
|
// ah, we found some dividends...
|
||||||
if ($dividend_data->isNotEmpty()) {
|
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
|
// create mass insert
|
||||||
foreach ($dividend_data as $index => $dividend) {
|
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()]];
|
$dividend_data[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
|
||||||
}
|
}
|
||||||
|
|
||||||
// insert records
|
// insert records
|
||||||
(new self)->insert($dividend_data->toArray());
|
(new self)->insertOrIgnore($dividend_data->toArray());
|
||||||
|
|
||||||
|
dump('6. inserted for '.$symbol);
|
||||||
// sync to holdings
|
// sync to holdings
|
||||||
self::syncHoldings($symbol);
|
self::syncHoldings($symbol);
|
||||||
|
|
||||||
// get market data
|
|
||||||
$market_data = MarketData::firstOrNew(['symbol' => $symbol]);
|
|
||||||
|
|
||||||
// re-invest dividends
|
// re-invest dividends
|
||||||
self::reinvestDividends($dividend_data, $market_data);
|
self::reinvestDividends($dividend_data, $market_data);
|
||||||
|
|
||||||
// sync last dividend amount to market data table
|
// sync last dividend amount to market data table
|
||||||
$market_data->last_dividend_amount = $dividend_data->sortByDesc('date')->first()['dividend_amount'];
|
$market_data->last_dividend_amount = $dividend_data->sortByDesc('date')->first()['dividend_amount'];
|
||||||
$market_data->save();
|
$market_data->save();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function syncHoldings(string $symbol): void
|
public static function syncHoldings(string $symbol): void
|
||||||
{
|
{
|
||||||
// group by holdings
|
// group by holdings
|
||||||
$dividends = self::select(['holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount'])
|
$subQuery = self::select([
|
||||||
->selectRaw('
|
'holdings.portfolio_id',
|
||||||
(COALESCE(CASE WHEN transactions.transaction_type = "BUY"
|
'dividends.date',
|
||||||
|
'dividends.symbol',
|
||||||
|
'dividends.dividend_amount',
|
||||||
|
])->selectRaw("
|
||||||
|
(COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY'
|
||||||
AND date(transactions.date) <= date(dividends.date)
|
AND date(transactions.date) <= date(dividends.date)
|
||||||
THEN transactions.quantity ELSE 0 END, 0)
|
THEN transactions.quantity ELSE 0 END), 0)
|
||||||
- COALESCE(CASE WHEN transactions.transaction_type = "SELL"
|
- COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL'
|
||||||
AND date(transactions.date) <= date(dividends.date)
|
AND date(transactions.date) <= date(dividends.date)
|
||||||
THEN transactions.quantity ELSE 0 END, 0))
|
THEN transactions.quantity ELSE 0 END), 0))
|
||||||
* dividends.dividend_amount
|
* dividends.dividend_amount
|
||||||
AS total_received
|
AS total_received
|
||||||
')
|
")->join('transactions', 'transactions.symbol', '=', 'dividends.symbol')
|
||||||
->join('transactions', 'transactions.symbol', '=', 'dividends.symbol')
|
|
||||||
->join('holdings', 'transactions.portfolio_id', '=', 'holdings.portfolio_id')
|
->join('holdings', 'transactions.portfolio_id', '=', 'holdings.portfolio_id')
|
||||||
->where('dividends.symbol', $symbol)
|
->where('dividends.symbol', $symbol)
|
||||||
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'total_received')
|
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'dividends.dividend_amount_base');
|
||||||
->havingRaw('total_received > 0')
|
|
||||||
|
$dividends = DB::table(DB::raw("({$subQuery->toSql()}) as sub"))
|
||||||
|
->mergeBindings($subQuery->getQuery())
|
||||||
|
->where('total_received', '>', 0)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
// iterate through holdings and update
|
// iterate through holdings and update
|
||||||
|
|||||||
+287
-55
@@ -4,14 +4,18 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Traits\HasMarketData;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class Holding extends Model
|
class Holding extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use HasMarketData;
|
||||||
use HasUuids;
|
use HasUuids;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
@@ -27,21 +31,24 @@ class Holding extends Model
|
|||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
'reinvest_dividends' => 'boolean',
|
||||||
'splits_synced_at' => 'datetime',
|
'splits_synced_at' => 'datetime',
|
||||||
'first_transaction_date' => '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
|
* Related transactions for holding
|
||||||
*
|
*
|
||||||
@@ -60,7 +67,7 @@ class Holding extends Model
|
|||||||
public function dividends()
|
public function dividends()
|
||||||
{
|
{
|
||||||
return $this->hasMany(Dividend::class, 'symbol', 'symbol')
|
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(
|
->selectRaw("SUM(
|
||||||
CASE WHEN transaction_type = 'BUY'
|
CASE WHEN transaction_type = 'BUY'
|
||||||
AND transactions.symbol = dividends.symbol
|
AND transactions.symbol = dividends.symbol
|
||||||
@@ -90,8 +97,21 @@ class Holding extends Model
|
|||||||
THEN transactions.quantity ELSE 0 END)
|
THEN transactions.quantity ELSE 0 END)
|
||||||
* dividends.dividend_amount
|
* dividends.dividend_amount
|
||||||
) AS total_received")
|
) 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')
|
->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')
|
->orderBy('dividends.date', 'DESC')
|
||||||
->where('dividends.date', '>=', function ($query) {
|
->where('dividends.date', '>=', function ($query) {
|
||||||
$query->selectRaw('min(transactions.date)')
|
$query->selectRaw('min(transactions.date)')
|
||||||
@@ -99,7 +119,25 @@ class Holding extends Model
|
|||||||
->whereRaw("transactions.portfolio_id = '$this->portfolio_id'")
|
->whereRaw("transactions.portfolio_id = '$this->portfolio_id'")
|
||||||
->whereRaw("transactions.symbol = '$this->symbol'");
|
->whereRaw("transactions.symbol = '$this->symbol'");
|
||||||
})
|
})
|
||||||
->having('total_received', '>', 0);
|
->havingRaw("SUM(
|
||||||
|
(CASE
|
||||||
|
WHEN transaction_type = 'BUY'
|
||||||
|
AND transactions.symbol = dividends.symbol
|
||||||
|
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||||
|
AND transactions.date <= dividends.date
|
||||||
|
THEN transactions.quantity
|
||||||
|
ELSE 0
|
||||||
|
END)
|
||||||
|
-
|
||||||
|
(CASE
|
||||||
|
WHEN transaction_type = 'SELL'
|
||||||
|
AND transactions.symbol = dividends.symbol
|
||||||
|
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||||
|
AND transactions.date <= dividends.date
|
||||||
|
THEN transactions.quantity
|
||||||
|
ELSE 0
|
||||||
|
END)
|
||||||
|
) * dividends.dividend_amount_base > 0");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,17 +175,21 @@ class Holding extends Model
|
|||||||
{
|
{
|
||||||
return $query->withAggregate('market_data', 'name')
|
return $query->withAggregate('market_data', 'name')
|
||||||
->withAggregate('market_data', 'market_value')
|
->withAggregate('market_data', 'market_value')
|
||||||
|
->withAggregate('market_data', 'market_value_base')
|
||||||
->withAggregate('market_data', 'fifty_two_week_low')
|
->withAggregate('market_data', 'fifty_two_week_low')
|
||||||
->withAggregate('market_data', 'fifty_two_week_high')
|
->withAggregate('market_data', 'fifty_two_week_high')
|
||||||
->withAggregate('market_data', 'updated_at')
|
->withAggregate('market_data', 'updated_at')
|
||||||
->join('market_data', 'holdings.symbol', 'market_data.symbol');
|
->join('market_data', 'holdings.symbol', 'market_data.symbol');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate performance for holding in its local currency
|
||||||
|
*/
|
||||||
public function scopeWithPerformance($query)
|
public function scopeWithPerformance($query)
|
||||||
{
|
{
|
||||||
return $query->selectRaw('COALESCE(market_data.market_value * holdings.quantity, 0) AS total_market_value')
|
return $query->selectRaw('COALESCE(market_data.market_value * holdings.quantity, 0) AS total_market_value')
|
||||||
->selectRaw('COALESCE((market_data.market_value - holdings.average_cost_basis) * holdings.quantity, 0) AS market_gain_dollars')
|
->selectRaw('COALESCE((market_data.market_value - holdings.average_cost_basis) * holdings.quantity, 0) AS market_gain_dollars')
|
||||||
->selectRaw('COALESCE(((market_data.market_value - holdings.average_cost_basis) / holdings.average_cost_basis) * 100, 0) AS market_gain_percent');
|
->selectRaw('COALESCE(((market_data.market_value - holdings.average_cost_basis) / NULLIF(holdings.average_cost_basis, 0)) * 100, 0) AS market_gain_percent');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopePortfolio($query, $portfolio)
|
public function scopePortfolio($query, $portfolio)
|
||||||
@@ -174,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')
|
$result = $query->withPortfolioMetrics($currency)->get();
|
||||||
->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')
|
return collect([
|
||||||
->selectRaw('COALESCE(SUM(holdings.total_cost_basis), 0) AS total_cost_basis')
|
'total_cost_basis' => $result->sum('total_cost_basis'),
|
||||||
->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) - COALESCE(SUM(holdings.total_cost_basis), 0) AS total_gain_dollars')
|
'total_market_value' => $result->sum('total_market_value'),
|
||||||
// ->selectRaw('COALESCE((@total_gain_dollars / @sum_total_cost_basis) * 100,0) AS total_gain_percent')
|
'total_gain_dollars' => $result->sum('total_gain_dollars'),
|
||||||
->join('market_data', 'market_data.symbol', '=', 'holdings.symbol');
|
'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()
|
public function syncTransactionsAndDividends()
|
||||||
@@ -190,20 +386,19 @@ class Holding extends Model
|
|||||||
// pull existing transaction data
|
// pull existing transaction data
|
||||||
$query = Transaction::where([
|
$query = Transaction::where([
|
||||||
'portfolio_id' => $this->portfolio_id,
|
'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 = '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 quantity ELSE 0 END) AS qty_sales")
|
||||||
->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN (quantity * cost_basis) ELSE 0 END) AS `total_cost_basis`')
|
->selectRaw("SUM(CASE WHEN transaction_type = 'SELL' THEN (sale_price - cost_basis) * quantity ELSE 0 END) AS realized_gain_dollars")
|
||||||
->selectRaw('SUM(CASE WHEN transaction_type = "SELL" THEN (quantity * sale_price) ELSE 0 END) AS `total_sale_price`')
|
->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN (quantity * cost_basis) ELSE 0 END) AS total_cost_basis")
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
$total_quantity = round($query->qty_purchases - $query->qty_sales, 3);
|
$total_quantity = round($query->qty_purchases - $query->qty_sales, 4);
|
||||||
|
|
||||||
$average_cost_basis = (
|
$average_cost_basis = (
|
||||||
$query->qty_purchases > 0
|
$query->qty_purchases > 0
|
||||||
&& $total_quantity > 0
|
&& $total_quantity > 0
|
||||||
)
|
) ? $query->total_cost_basis / $query->qty_purchases
|
||||||
? $query->total_cost_basis / $query->qty_purchases
|
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// update holding
|
// update holding
|
||||||
@@ -211,9 +406,7 @@ class Holding extends Model
|
|||||||
'quantity' => $total_quantity,
|
'quantity' => $total_quantity,
|
||||||
'average_cost_basis' => $average_cost_basis,
|
'average_cost_basis' => $average_cost_basis,
|
||||||
'total_cost_basis' => $total_quantity * $average_cost_basis,
|
'total_cost_basis' => $total_quantity * $average_cost_basis,
|
||||||
'realized_gain_dollars' => $query->qty_purchases > 0 && $query->total_sale_price > 0
|
'realized_gain_dollars' => $query->realized_gain_dollars ?? 0,
|
||||||
? $query->total_sale_price - ($query->qty_sales * ($query->total_cost_basis / $query->qty_purchases))
|
|
||||||
: 0,
|
|
||||||
'dividends_earned' => $this->dividends->sum('total_received'),
|
'dividends_earned' => $this->dividends->sum('total_received'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -235,6 +428,11 @@ class Holding extends Model
|
|||||||
return $purchases - $sales;
|
return $purchases - $sales;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method that enables calculating daily performance for a given holding
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public function dailyPerformance(
|
public function dailyPerformance(
|
||||||
?\Illuminate\Support\Carbon $start_date = null,
|
?\Illuminate\Support\Carbon $start_date = null,
|
||||||
?\Illuminate\Support\Carbon $end_date = null,
|
?\Illuminate\Support\Carbon $end_date = null,
|
||||||
@@ -246,49 +444,83 @@ class Holding extends Model
|
|||||||
$end_date = now();
|
$end_date = now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MySQL default interval
|
||||||
$date_interval = 'DATE_ADD(date, INTERVAL 1 DAY)';
|
$date_interval = 'DATE_ADD(date, INTERVAL 1 DAY)';
|
||||||
|
$castNumberType = 'decimal';
|
||||||
|
|
||||||
|
// Use SQLite interval grammar
|
||||||
if (config('database.default') === 'sqlite') {
|
if (config('database.default') === 'sqlite') {
|
||||||
|
|
||||||
$date_interval = "date(date, '+1 day')";
|
$date_interval = "date(date, '+1 day')";
|
||||||
} else {
|
|
||||||
|
|
||||||
DB::statement('SET cte_max_recursion_depth=1000000;');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::table(DB::raw("(
|
// Default CTE time series query (for MySQL and SQLite)
|
||||||
|
$timeSeriesQuery = DB::table(DB::raw("(
|
||||||
WITH RECURSIVE date_series AS (
|
WITH RECURSIVE date_series AS (
|
||||||
SELECT '{$start_date->format('Y-m-d')}' AS date
|
SELECT '{$start_date->toDateString()}' AS date
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT $date_interval
|
SELECT $date_interval
|
||||||
FROM date_series
|
FROM date_series
|
||||||
WHERE date < '{$end_date->format('Y-m-d')}'
|
WHERE date < '{$end_date->toDateString()}'
|
||||||
)
|
)
|
||||||
SELECT date_series.date
|
SELECT date_series.date
|
||||||
FROM date_series
|
FROM date_series
|
||||||
) as date_series")
|
) as date_series"));
|
||||||
)
|
|
||||||
|
// PGSql time series query
|
||||||
|
if (config('database.default') === 'pgsql') {
|
||||||
|
|
||||||
|
$timeSeriesQuery = DB::table(DB::raw("
|
||||||
|
generate_series(
|
||||||
|
date '{$start_date->toDateString()}',
|
||||||
|
date '{$end_date->toDateString()}',
|
||||||
|
interval '1 day'
|
||||||
|
) as date_series"));
|
||||||
|
|
||||||
|
$castNumberType = 'numeric';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set MySQL-like query CTE max iterations
|
||||||
|
if (config('database.default') === 'mysql') {
|
||||||
|
|
||||||
|
// MySQL default
|
||||||
|
$max_recursion_var_name = 'cte_max_recursion_depth';
|
||||||
|
|
||||||
|
// Determine if running MySQL or MariaDB
|
||||||
|
$versionString = Arr::get(
|
||||||
|
DB::select('SELECT VERSION() as version;'),
|
||||||
|
'0', new \stdClass
|
||||||
|
)->version;
|
||||||
|
if (stripos($versionString, 'MariaDB') !== false) {
|
||||||
|
$max_recursion_var_name = 'max_recursive_iterations'; // Must be MariaDB
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::statement("SET $max_recursion_var_name=1000000;");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extracted query for counting QTY owned
|
||||||
|
$quantityQuery = "ROUND(CAST(COALESCE(
|
||||||
|
SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END)
|
||||||
|
- SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END),
|
||||||
|
0
|
||||||
|
) AS {$castNumberType}), 3)";
|
||||||
|
|
||||||
|
return $timeSeriesQuery
|
||||||
->select([
|
->select([
|
||||||
'date_series.date',
|
'date_series.date',
|
||||||
DB::raw("
|
DB::raw("
|
||||||
ROUND(
|
{$quantityQuery} AS owned
|
||||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END), 0) -
|
|
||||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END), 0), 3) AS `owned`
|
|
||||||
"),
|
"),
|
||||||
DB::raw("
|
DB::raw("
|
||||||
COALESCE(CASE
|
CASE
|
||||||
WHEN (
|
WHEN ({$quantityQuery}) = 0 THEN 0
|
||||||
ROUND(
|
|
||||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END), 0) -
|
|
||||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END), 0), 3)
|
|
||||||
) = 0 THEN 0
|
|
||||||
ELSE SUM(CASE
|
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
|
ELSE 0
|
||||||
END)
|
END)
|
||||||
END, 0) AS cost_basis
|
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) {
|
->leftJoin('transactions', function ($join) {
|
||||||
$join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date')
|
$join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date')
|
||||||
@@ -305,7 +537,7 @@ class Holding extends Model
|
|||||||
{
|
{
|
||||||
$formattedTransactions = '';
|
$formattedTransactions = '';
|
||||||
foreach ($this->transactions->sortByDesc('date') as $transaction) {
|
foreach ($this->transactions->sortByDesc('date') as $transaction) {
|
||||||
$formattedTransactions .= ' * '.$transaction->date->format('Y-m-d')
|
$formattedTransactions .= ' * '.$transaction->date->toDateString()
|
||||||
.' '.$transaction->transaction_type
|
.' '.$transaction->transaction_type
|
||||||
.' '.$transaction->quantity
|
.' '.$transaction->quantity
|
||||||
.' @ '.$transaction->cost_basis
|
.' @ '.$transaction->cost_basis
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Actions\CopyToBaseCurrency;
|
||||||
|
use App\Casts\BaseCurrency;
|
||||||
use App\Interfaces\MarketData\MarketDataInterface;
|
use App\Interfaces\MarketData\MarketDataInterface;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Pipeline;
|
||||||
|
|
||||||
class MarketData extends Model
|
class MarketData extends Model
|
||||||
{
|
{
|
||||||
@@ -21,7 +24,9 @@ class MarketData extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'symbol',
|
'symbol',
|
||||||
'name',
|
'name',
|
||||||
|
'currency',
|
||||||
'market_value',
|
'market_value',
|
||||||
|
'market_value_base',
|
||||||
'fifty_two_week_high',
|
'fifty_two_week_high',
|
||||||
'fifty_two_week_low',
|
'fifty_two_week_low',
|
||||||
'forward_pe',
|
'forward_pe',
|
||||||
@@ -29,21 +34,40 @@ class MarketData extends Model
|
|||||||
'market_cap',
|
'market_cap',
|
||||||
'book_value',
|
'book_value',
|
||||||
'last_dividend_date',
|
'last_dividend_date',
|
||||||
|
'last_dividend_amount',
|
||||||
'dividend_yield',
|
'dividend_yield',
|
||||||
|
'meta_data',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'last_dividend_date' => 'datetime',
|
|
||||||
'market_value' => 'float',
|
'market_value' => 'float',
|
||||||
|
'market_value_base' => BaseCurrency::class,
|
||||||
'fifty_two_week_high' => 'float',
|
'fifty_two_week_high' => 'float',
|
||||||
'fifty_two_week_low' => 'float',
|
'fifty_two_week_low' => 'float',
|
||||||
'forward_pe' => 'float',
|
'forward_pe' => 'float',
|
||||||
'trailing_pe' => 'float',
|
'trailing_pe' => 'float',
|
||||||
'market_cap' => 'float',
|
'market_cap' => 'integer',
|
||||||
'book_value' => 'float',
|
'book_value' => 'float',
|
||||||
|
'last_dividend_date' => 'datetime',
|
||||||
|
'last_dividend_amount' => 'float',
|
||||||
'dividend_yield' => '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()
|
public function holdings()
|
||||||
{
|
{
|
||||||
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
||||||
@@ -54,7 +78,7 @@ class MarketData extends Model
|
|||||||
return $query->where('symbol', $symbol);
|
return $query->where('symbol', $symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getMarketData($symbol, $force = false)
|
public static function getMarketData($symbol, $force = false): self
|
||||||
{
|
{
|
||||||
$market_data = self::firstOrNew([
|
$market_data = self::firstOrNew([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
|
|||||||
+14
-25
@@ -136,6 +136,9 @@ class Portfolio extends Model
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes daily change history for a portfolio to the database
|
||||||
|
*/
|
||||||
public function syncDailyChanges(): void
|
public function syncDailyChanges(): void
|
||||||
{
|
{
|
||||||
$holdings = $this->holdings()
|
$holdings = $this->holdings()
|
||||||
@@ -147,11 +150,9 @@ class Portfolio extends Model
|
|||||||
->groupBy(['holdings.symbol', 'holdings.portfolio_id'])
|
->groupBy(['holdings.symbol', 'holdings.portfolio_id'])
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$dividends = Dividend::whereIn('symbol', $holdings->pluck('symbol'))->get();
|
|
||||||
|
|
||||||
$total_performance = [];
|
$total_performance = [];
|
||||||
|
|
||||||
$holdings->each(function ($holding) use (&$total_performance, $dividends) {
|
$holdings->each(function ($holding) use (&$total_performance) {
|
||||||
|
|
||||||
$period = CarbonPeriod::create(
|
$period = CarbonPeriod::create(
|
||||||
$holding->first_transaction_date,
|
$holding->first_transaction_date,
|
||||||
@@ -160,34 +161,25 @@ class Portfolio extends Model
|
|||||||
: now()
|
: now()
|
||||||
);
|
);
|
||||||
|
|
||||||
$holding->setRelation('dividends', $dividends->where('symbol', $holding->symbol));
|
|
||||||
|
|
||||||
$daily_performance = $holding->dailyPerformance($holding->first_transaction_date, now());
|
$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());
|
$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 = [];
|
$holding_performance = [];
|
||||||
|
|
||||||
foreach ($period as $date) {
|
foreach ($period as $date) {
|
||||||
$date = $date->format('Y-m-d');
|
$date = $date->toDateString();
|
||||||
|
|
||||||
$close = $this->getMostRecentCloseData($all_history, $date);
|
$close = $this->getMostRecentCloseData($all_history, $date);
|
||||||
|
|
||||||
$total_market_value = $daily_performance->get($date)->owned * $close;
|
$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()) {
|
if (Carbon::parse($date)->isWeekday()) {
|
||||||
|
|
||||||
$holding_performance[$date] = [
|
$holding_performance[$date] = [
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'portfolio_id' => $this->id,
|
'portfolio_id' => $this->id,
|
||||||
'total_market_value' => $total_market_value,
|
'total_market_value' => $total_market_value * (1 / Arr::get($currency_rates, $date, 1)),
|
||||||
'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,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,10 +192,6 @@ class Portfolio extends Model
|
|||||||
} else {
|
} else {
|
||||||
|
|
||||||
$total_performance[$date]['total_market_value'] += $performance['total_market_value'];
|
$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'];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -211,15 +199,16 @@ class Portfolio extends Model
|
|||||||
if (! empty($total_performance)) {
|
if (! empty($total_performance)) {
|
||||||
DB::transaction(function () use ($total_performance) {
|
DB::transaction(function () use ($total_performance) {
|
||||||
|
|
||||||
|
// delete old history
|
||||||
|
$firstDate = array_keys($total_performance)[0];
|
||||||
|
$this->daily_change()->where('date', '<', $firstDate)->delete();
|
||||||
|
|
||||||
|
// upsert new history
|
||||||
$this->daily_change()->upsert(
|
$this->daily_change()->upsert(
|
||||||
$total_performance,
|
$total_performance,
|
||||||
['date', 'portfolio_id'],
|
['date', 'portfolio_id'],
|
||||||
[
|
[
|
||||||
'total_market_value',
|
'total_market_value',
|
||||||
'total_cost_basis',
|
|
||||||
'total_gain',
|
|
||||||
'realized_gains',
|
|
||||||
'total_dividends_earned',
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -234,7 +223,7 @@ class Portfolio extends Model
|
|||||||
|
|
||||||
$i++;
|
$i++;
|
||||||
|
|
||||||
$date = Carbon::parse($date)->subDay()->format('Y-m-d');
|
$date = Carbon::parse($date)->subDay()->toDateString();
|
||||||
|
|
||||||
return $this->getMostRecentCloseData($history, $date, $i);
|
return $this->getMostRecentCloseData($history, $date, $i);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-7
@@ -5,15 +5,18 @@ declare(strict_types=1);
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Interfaces\MarketData\MarketDataInterface;
|
use App\Interfaces\MarketData\MarketDataInterface;
|
||||||
|
use App\Traits\HasMarketData;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class Split extends Model
|
class Split extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use HasMarketData;
|
||||||
use HasUuids;
|
use HasUuids;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
@@ -29,12 +32,12 @@ class Split extends Model
|
|||||||
'last_date' => 'datetime',
|
'last_date' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function holdings()
|
public function holdings(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function transactions()
|
public function transactions(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
|
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
|
||||||
}
|
}
|
||||||
@@ -73,7 +76,7 @@ class Split extends Model
|
|||||||
if ($split_data->isNotEmpty()) {
|
if ($split_data->isNotEmpty()) {
|
||||||
|
|
||||||
// insert records
|
// insert records
|
||||||
(new self)->insert($split_data->map(function ($split) {
|
(new self)->insertOrIgnore($split_data->map(function ($split) {
|
||||||
|
|
||||||
return [...$split, ...['id' => Str::uuid()->toString()]];
|
return [...$split, ...['id' => Str::uuid()->toString()]];
|
||||||
})->toArray());
|
})->toArray());
|
||||||
@@ -101,7 +104,7 @@ class Split extends Model
|
|||||||
->where([
|
->where([
|
||||||
'splits.symbol' => $symbol,
|
'splits.symbol' => $symbol,
|
||||||
])
|
])
|
||||||
->whereDate('splits.date', '>', DB::raw('IFNULL(holdings.splits_synced_at, "0000-00-00")'))
|
->whereDate('splits.date', '>', DB::raw("COALESCE(holdings.splits_synced_at, '1901-01-01')"))
|
||||||
->where('holdings.quantity', '>', 0)
|
->where('holdings.quantity', '>', 0)
|
||||||
->join('holdings', 'splits.symbol', 'holdings.symbol')
|
->join('holdings', 'splits.symbol', 'holdings.symbol')
|
||||||
->orderBy('splits.date', 'ASC')
|
->orderBy('splits.date', 'ASC')
|
||||||
@@ -114,9 +117,9 @@ class Split extends Model
|
|||||||
'symbol' => $split->symbol,
|
'symbol' => $split->symbol,
|
||||||
'portfolio_id' => $split->portfolio_id,
|
'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) -
|
->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')
|
SUM(CASE WHEN transaction_type = 'SELL' THEN quantity ELSE 0 END) AS qty_owned")
|
||||||
->value('qty_owned');
|
->value('qty_owned');
|
||||||
|
|
||||||
if ($qty_owned > 0) {
|
if ($qty_owned > 0) {
|
||||||
|
|||||||
+29
-40
@@ -4,18 +4,25 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Models;
|
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\Contracts\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Pipeline;
|
||||||
|
|
||||||
class Transaction extends Model
|
class Transaction extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use HasMarketData;
|
||||||
use HasUuids;
|
use HasUuids;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
@@ -23,6 +30,7 @@ class Transaction extends Model
|
|||||||
'date',
|
'date',
|
||||||
'portfolio_id',
|
'portfolio_id',
|
||||||
'transaction_type',
|
'transaction_type',
|
||||||
|
'currency',
|
||||||
'quantity',
|
'quantity',
|
||||||
'cost_basis',
|
'cost_basis',
|
||||||
'sale_price',
|
'sale_price',
|
||||||
@@ -36,6 +44,11 @@ class Transaction extends Model
|
|||||||
'date' => 'datetime',
|
'date' => 'datetime',
|
||||||
'split' => 'boolean',
|
'split' => 'boolean',
|
||||||
'reinvested_dividend' => '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()
|
protected static function boot()
|
||||||
@@ -44,17 +57,24 @@ class Transaction extends Model
|
|||||||
|
|
||||||
static::saving(function ($transaction) {
|
static::saving(function ($transaction) {
|
||||||
|
|
||||||
if ($transaction->transaction_type == 'SELL') {
|
$transaction = Pipeline::send($transaction)
|
||||||
|
->through([
|
||||||
$transaction->ensureCostBasisIsAddedToSale();
|
ConvertToMarketDataCurrency::class,
|
||||||
}
|
EnsureCostBasisAddedToSale::class,
|
||||||
|
CopyToBaseCurrency::class,
|
||||||
|
])
|
||||||
|
->then(fn (Transaction $transaction) => $transaction);
|
||||||
});
|
});
|
||||||
|
|
||||||
static::saved(function ($transaction) {
|
static::saved(function ($transaction) {
|
||||||
|
|
||||||
$transaction->syncToHolding();
|
$transaction->syncToHolding();
|
||||||
|
|
||||||
$transaction->refreshMarketData();
|
$transaction = Pipeline::send($transaction)
|
||||||
|
->through([
|
||||||
|
EnsureDailyChangeIsSynced::class,
|
||||||
|
])
|
||||||
|
->then(fn (Transaction $transaction) => $transaction);
|
||||||
|
|
||||||
cache()->forget('portfolio-metrics-'.$transaction->portfolio_id);
|
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
|
* Related portfolio
|
||||||
*
|
*
|
||||||
@@ -101,6 +111,7 @@ class Transaction extends Model
|
|||||||
{
|
{
|
||||||
return $query->withAggregate('market_data', 'name')
|
return $query->withAggregate('market_data', 'name')
|
||||||
->withAggregate('market_data', 'market_value')
|
->withAggregate('market_data', 'market_value')
|
||||||
|
->withAggregate('market_data', 'currency')
|
||||||
->withAggregate('market_data', 'fifty_two_week_low')
|
->withAggregate('market_data', 'fifty_two_week_low')
|
||||||
->withAggregate('market_data', 'fifty_two_week_high')
|
->withAggregate('market_data', 'fifty_two_week_high')
|
||||||
->withAggregate('market_data', 'updated_at')
|
->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
|
* Syncs the holding related to this transaction
|
||||||
*/
|
*/
|
||||||
@@ -187,8 +176,8 @@ class Transaction extends Model
|
|||||||
'portfolio_id' => $this->portfolio_id,
|
'portfolio_id' => $this->portfolio_id,
|
||||||
'symbol' => $this->symbol,
|
'symbol' => $this->symbol,
|
||||||
'quantity' => $this->quantity,
|
'quantity' => $this->quantity,
|
||||||
'average_cost_basis' => $this->cost_basis,
|
'average_cost_basis' => $this->cost_basis_base,
|
||||||
'total_cost_basis' => $this->quantity * $this->cost_basis,
|
'total_cost_basis' => $this->quantity * $this->cost_basis_base,
|
||||||
'splits_synced_at' => now(),
|
'splits_synced_at' => now(),
|
||||||
])->syncTransactionsAndDividends();
|
])->syncTransactionsAndDividends();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
use Laravel\Jetstream\HasProfilePhoto;
|
use Laravel\Jetstream\HasProfilePhoto;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
@@ -31,6 +32,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
|
'options',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
@@ -50,6 +52,8 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
return [
|
return [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'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)
|
ELSE COALESCE(market_data.market_value - transactions.cost_basis, 0)
|
||||||
END AS gain_dollars');
|
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;
|
namespace App\Providers;
|
||||||
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Number;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use NumberFormatter;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -26,5 +29,28 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
JsonResource::withoutWrapping();
|
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
|
public function boot(): void
|
||||||
{
|
{
|
||||||
Volt::mount([
|
Volt::mount([
|
||||||
config('livewire.view_path', resource_path('views/livewire')),
|
// config('livewire.view_path', resource_path('views/livewire')),
|
||||||
resource_path('views/pages'),
|
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;
|
namespace App\Rules;
|
||||||
|
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use App\Models\Transaction;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
|
||||||
class QuantityValidationRule implements ValidationRule
|
class QuantityValidationRule implements ValidationRule
|
||||||
{
|
{
|
||||||
@@ -20,12 +21,7 @@ class QuantityValidationRule implements ValidationRule
|
|||||||
protected ?string $symbol,
|
protected ?string $symbol,
|
||||||
protected ?string $transactionType,
|
protected ?string $transactionType,
|
||||||
protected string|Carbon|null $date
|
protected string|Carbon|null $date
|
||||||
) {
|
) { }
|
||||||
$this->portfolio = $portfolio;
|
|
||||||
$this->symbol = $symbol;
|
|
||||||
$this->transactionType = $transactionType;
|
|
||||||
$this->date = $date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate the attribute.
|
* Validate the attribute.
|
||||||
@@ -39,21 +35,21 @@ class QuantityValidationRule implements ValidationRule
|
|||||||
|
|
||||||
if ($this->transactionType == 'SELL') {
|
if ($this->transactionType == 'SELL') {
|
||||||
|
|
||||||
$purchase_qty = $this->portfolio->transactions()
|
$purchase_qty = (float) $this->portfolio->transactions()
|
||||||
->symbol($this->symbol)
|
->symbol($this->symbol)
|
||||||
->buy()
|
->buy()
|
||||||
->beforeDate($this->date)
|
->whereDate('date', '<', $this->date)
|
||||||
->sum('quantity');
|
->sum('quantity');
|
||||||
|
|
||||||
$sales_qty = $this->portfolio->transactions()
|
$sales_qty = (float) $this->portfolio->transactions()
|
||||||
->symbol($this->symbol)
|
->symbol($this->symbol)
|
||||||
->sell()
|
->sell()
|
||||||
->beforeDate($this->date)
|
->whereDate('date', '<', $this->date)
|
||||||
->sum('quantity');
|
->sum('quantity');
|
||||||
|
|
||||||
$maxQuantity = $purchase_qty - $sales_qty;
|
$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.'));
|
$fail(__('The quantity must not be greater than the available quantity.'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-11
@@ -2,16 +2,15 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
// if (!function_exists('formatMoney')) {
|
use App\Models\Currency;
|
||||||
// /**
|
|
||||||
// * Returns a formatted string for currency
|
|
||||||
// *
|
|
||||||
// * @param int|float $amount
|
|
||||||
// *
|
|
||||||
// * */
|
|
||||||
// function formatMoney(int|float $amount) {
|
|
||||||
// $formatter = new NumberFormatter('en_US', NumberFormatter::CURRENCY);
|
|
||||||
|
|
||||||
// return $formatter->formatCurrency((float) $amount, 'USD');
|
if (! function_exists('currency')) {
|
||||||
// }
|
|
||||||
|
// /**
|
||||||
|
// * Returns an instance of the currency model
|
||||||
|
// * */
|
||||||
|
// function currency(): Currency
|
||||||
|
// {
|
||||||
|
// return new Currency;
|
||||||
// }
|
// }
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class Spotlight
|
|||||||
}
|
}
|
||||||
|
|
||||||
$portfolios = $request->user()->portfolios()
|
$portfolios = $request->user()->portfolios()
|
||||||
->where('title', 'LIKE', '%'.$request->input('search').'%')
|
->whereFullText('title', $request->input('search'))
|
||||||
->limit(5)
|
->limit(5)
|
||||||
->get();
|
->get();
|
||||||
$portfolios->each(function ($portfolio) use ($results) {
|
$portfolios->each(function ($portfolio) use ($results) {
|
||||||
@@ -35,8 +35,8 @@ class Spotlight
|
|||||||
$holdings = $request->user()->holdings()
|
$holdings = $request->user()->holdings()
|
||||||
->where('holdings.quantity', '>', 0)
|
->where('holdings.quantity', '>', 0)
|
||||||
->where(function ($query) use ($request) {
|
->where(function ($query) use ($request) {
|
||||||
return $query->where('holdings.symbol', 'LIKE', '%'.$request->input('search').'%')
|
return $query->whereFullText('holdings.symbol', $request->input('search'))
|
||||||
->orWhere('market_data.name', 'LIKE', '%'.$request->input('search').'%');
|
->orWhereFullText('market_data.name', $request->input('search'));
|
||||||
})
|
})
|
||||||
->limit(5)
|
->limit(5)
|
||||||
->get();
|
->get();
|
||||||
|
|||||||
@@ -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-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-slot:sidebar drawer="main-drawer" class="bg-base-100 lg:bg-inherit">
|
||||||
|
|
||||||
<x-partials.side-bar />
|
@livewire('partials.side-bar')
|
||||||
|
|
||||||
</x-slot:sidebar>
|
</x-slot:sidebar>
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ class AppLayout extends Component
|
|||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</x-slot:content>
|
</x-slot:content>
|
||||||
|
|
||||||
</x-main>
|
</x-partials.main>
|
||||||
|
|
||||||
@if(session('toast'))
|
@if(session('toast'))
|
||||||
<script lang="text/javascript">
|
<script lang="text/javascript">
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Http\Middleware\SetLocale;
|
use App\Http\Middleware\LocalizationMiddleware;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
@@ -15,7 +15,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
$middleware->append(SetLocale::class);
|
$middleware->appendToGroup('web', LocalizationMiddleware::class);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
"finnhub/client": "master@dev",
|
"finnhub/client": "master@dev",
|
||||||
"hackeresq/filter-models": "dev-main",
|
"hackeresq/filter-models": "dev-main",
|
||||||
|
"investbrainapp/frankfurter-client": "dev-main",
|
||||||
"laravel/framework": "^11.35",
|
"laravel/framework": "^11.35",
|
||||||
"laravel/jetstream": "^5.1",
|
"laravel/jetstream": "^5.1",
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
@@ -41,6 +42,11 @@
|
|||||||
"no-api": true,
|
"no-api": true,
|
||||||
"url": "https://github.com/hackeresq/filter-models"
|
"url": "https://github.com/hackeresq/filter-models"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "vcs",
|
||||||
|
"no-api": true,
|
||||||
|
"url": "https://github.com/investbrainapp/frankfurter-client"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "vcs",
|
"type": "vcs",
|
||||||
"no-api": true,
|
"no-api": true,
|
||||||
|
|||||||
Generated
+407
-384
File diff suppressed because it is too large
Load Diff
+85
-3
@@ -79,14 +79,95 @@ return [
|
|||||||
| set to any locale for which you plan to have translation strings.
|
| set to any locale for which you plan to have translation strings.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
'available_locales' => ['en', 'es'],
|
|
||||||
|
|
||||||
'locale' => env('APP_LOCALE', 'en'),
|
'locale' => env('APP_LOCALE', 'en'),
|
||||||
|
|
||||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||||
|
|
||||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
'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
|
| Encryption Key
|
||||||
@@ -100,7 +181,8 @@ return [
|
|||||||
|
|
||||||
'cipher' => 'AES-256-CBC',
|
'cipher' => 'AES-256-CBC',
|
||||||
|
|
||||||
'key' => env('APP_KEY'),
|
'key' => env('APP_KEY')
|
||||||
|
?: when(file_exists(storage_path('app/.key')), fn () => trim(file_get_contents(storage_path('app/.key')))),
|
||||||
|
|
||||||
'previous_keys' => [
|
'previous_keys' => [
|
||||||
...array_filter(
|
...array_filter(
|
||||||
|
|||||||
@@ -18,4 +18,12 @@ return [
|
|||||||
'self_hosted' => env('SELF_HOSTED', true),
|
'self_hosted' => env('SELF_HOSTED', true),
|
||||||
|
|
||||||
'daily_change_time_of_day' => env('DAILY_CHANGE_TIME', '23:00'),
|
'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' => [
|
'features' => [
|
||||||
! env('SELF_HOSTED', true) ? Features::termsAndPrivacyPolicy() : null,
|
|
||||||
Features::profilePhotos(),
|
Features::profilePhotos(),
|
||||||
Features::api(),
|
Features::api(),
|
||||||
Features::accountDeletion(),
|
Features::accountDeletion(),
|
||||||
@@ -77,6 +76,6 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'profile_photo_disk' => 'public',
|
'profile_photo_disk' => env('JETSTREAM_PROFILE_PHOTO_DISK', 'public'),
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
+1
-1
@@ -116,7 +116,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'inject_assets' => true,
|
'inject_assets' => false,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|---------------------------------------------------------------------------
|
|---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ return [
|
|||||||
'processors' => [PsrLogMessageProcessor::class],
|
'processors' => [PsrLogMessageProcessor::class],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'sentry' => [
|
||||||
|
'driver' => 'sentry',
|
||||||
|
'level' => env('LOG_LEVEL', 'error'),
|
||||||
|
],
|
||||||
|
|
||||||
'stderr' => [
|
'stderr' => [
|
||||||
'driver' => 'monolog',
|
'driver' => 'monolog',
|
||||||
'level' => env('LOG_LEVEL', 'debug'),
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
|||||||
@@ -41,28 +41,35 @@ class TransactionFactory extends Factory
|
|||||||
public function yearsAgo(): static
|
public function yearsAgo(): static
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
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
|
public function lastYear(): static
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
'date' => now()->subYear()->format('Y-m-d'),
|
'date' => now()->subYear()->toDateString(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function lastMonth(): static
|
public function lastMonth(): static
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
'date' => now()->subMonth()->format('Y-m-d'),
|
'date' => now()->subMonth()->toDateString(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function recent(): static
|
public function recent(): static
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
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
|
public function buy(): static
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ class UserFactory extends Factory
|
|||||||
'two_factor_recovery_codes' => null,
|
'two_factor_recovery_codes' => null,
|
||||||
'remember_token' => Str::random(10),
|
'remember_token' => Str::random(10),
|
||||||
'profile_photo_path' => null,
|
'profile_photo_path' => null,
|
||||||
|
'options' => [
|
||||||
|
'display_currency' => 'USD',
|
||||||
|
'locale' => 'en',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,4 +50,14 @@ class UserFactory extends Factory
|
|||||||
'email_verified_at' => null,
|
'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->string('password');
|
||||||
$table->rememberToken();
|
$table->rememberToken();
|
||||||
$table->string('profile_photo_path', 2048)->nullable();
|
$table->string('profile_photo_path', 2048)->nullable();
|
||||||
|
$table->boolean('admin')->nullable();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,6 +39,5 @@ return new class extends Migration
|
|||||||
{
|
{
|
||||||
Schema::dropIfExists('users');
|
Schema::dropIfExists('users');
|
||||||
Schema::dropIfExists('password_reset_tokens');
|
Schema::dropIfExists('password_reset_tokens');
|
||||||
Schema::dropIfExists('sessions');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ return new class extends Migration
|
|||||||
{
|
{
|
||||||
Schema::create('portfolios', function (Blueprint $table) {
|
Schema::create('portfolios', function (Blueprint $table) {
|
||||||
$table->uuid('id')->primary();
|
$table->uuid('id')->primary();
|
||||||
$table->string('title');
|
$table->string('title')->when(config('database.default') != 'sqlite', fn ($ctx) => $ctx->fulltext());
|
||||||
$table->text('notes')->nullable();
|
$table->text('notes')->nullable();
|
||||||
$table->boolean('wishlist')->default(false);
|
$table->boolean('wishlist')->default(false);
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Database\Seeders\MarketDataSeeder;
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
class CreateMarketDataTable extends Migration
|
class CreateMarketDataTable extends Migration
|
||||||
@@ -18,8 +16,8 @@ class CreateMarketDataTable extends Migration
|
|||||||
public function up()
|
public function up()
|
||||||
{
|
{
|
||||||
Schema::create('market_data', function (Blueprint $table) {
|
Schema::create('market_data', function (Blueprint $table) {
|
||||||
$table->string('symbol', 15)->primary();
|
$table->string('symbol', 25)->primary();
|
||||||
$table->string('name')->nullable();
|
$table->string('name')->nullable()->when(config('database.default') != 'sqlite', fn ($ctx) => $ctx->fulltext());
|
||||||
$table->float('market_value', 12, 4)->nullable();
|
$table->float('market_value', 12, 4)->nullable();
|
||||||
$table->float('fifty_two_week_low', 12, 4)->nullable();
|
$table->float('fifty_two_week_low', 12, 4)->nullable();
|
||||||
$table->float('fifty_two_week_high', 12, 4)->nullable();
|
$table->float('fifty_two_week_high', 12, 4)->nullable();
|
||||||
@@ -34,10 +32,6 @@ class CreateMarketDataTable extends Migration
|
|||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
|
|
||||||
Artisan::call('db:seed', [
|
|
||||||
'--class' => MarketDataSeeder::class,
|
|
||||||
'--force' => true,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ class CreateDailyChangeTable extends Migration
|
|||||||
$table->date('date');
|
$table->date('date');
|
||||||
$table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade');
|
$table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade');
|
||||||
$table->float('total_market_value', 12, 4)->nullable();
|
$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->text('annotation')->nullable();
|
||||||
|
|
||||||
$table->primary(['date', 'portfolio_id']);
|
$table->primary(['date', 'portfolio_id']);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Models\MarketData;
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
@@ -19,9 +18,11 @@ class CreateDividendsTable extends Migration
|
|||||||
Schema::create('dividends', function (Blueprint $table) {
|
Schema::create('dividends', function (Blueprint $table) {
|
||||||
$table->uuid('id')->primary();
|
$table->uuid('id')->primary();
|
||||||
$table->date('date');
|
$table->date('date');
|
||||||
$table->foreignIdFor(MarketData::class, 'symbol');
|
$table->string('symbol', 25);
|
||||||
$table->float('dividend_amount', 12, 4);
|
$table->float('dividend_amount', 12, 4);
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['date', 'symbol']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Models\MarketData;
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
@@ -19,9 +18,11 @@ class CreateSplitsTable extends Migration
|
|||||||
Schema::create('splits', function (Blueprint $table) {
|
Schema::create('splits', function (Blueprint $table) {
|
||||||
$table->uuid('id')->primary();
|
$table->uuid('id')->primary();
|
||||||
$table->date('date');
|
$table->date('date');
|
||||||
$table->foreignIdFor(MarketData::class, 'symbol');
|
$table->string('symbol', 25);
|
||||||
$table->float('split_amount', 12, 4);
|
$table->float('split_amount', 12, 4);
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['date', 'symbol']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Models\MarketData;
|
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
@@ -19,7 +18,7 @@ class CreateTransactionsTable extends Migration
|
|||||||
{
|
{
|
||||||
Schema::create('transactions', function (Blueprint $table) {
|
Schema::create('transactions', function (Blueprint $table) {
|
||||||
$table->uuid('id')->primary();
|
$table->uuid('id')->primary();
|
||||||
$table->foreignIdFor(MarketData::class, 'symbol');
|
$table->string('symbol', 25);
|
||||||
$table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade');
|
$table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade');
|
||||||
$table->string('transaction_type', 15);
|
$table->string('transaction_type', 15);
|
||||||
$table->float('quantity', 12, 4);
|
$table->float('quantity', 12, 4);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Models\MarketData;
|
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
@@ -20,7 +19,7 @@ class CreateHoldingsTable extends Migration
|
|||||||
Schema::create('holdings', function (Blueprint $table) {
|
Schema::create('holdings', function (Blueprint $table) {
|
||||||
$table->uuid('id')->primary();
|
$table->uuid('id')->primary();
|
||||||
$table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade');
|
$table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade');
|
||||||
$table->foreignIdFor(MarketData::class, 'symbol');
|
$table->string('symbol', 25)->when(config('database.default') != 'sqlite', fn ($ctx) => $ctx->fulltext());
|
||||||
$table->float('quantity', 12, 4);
|
$table->float('quantity', 12, 4);
|
||||||
$table->float('average_cost_basis', 12, 4)->default(0);
|
$table->float('average_cost_basis', 12, 4)->default(0);
|
||||||
$table->float('total_cost_basis', 12, 4)->default(0);
|
$table->float('total_cost_basis', 12, 4)->default(0);
|
||||||
|
|||||||
@@ -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
|
public function run(): void
|
||||||
{
|
{
|
||||||
// User::factory(10)->create();
|
|
||||||
|
|
||||||
User::factory()->create([
|
User::factory()->create([
|
||||||
'name' => 'Test User',
|
'name' => 'Test User',
|
||||||
'email' => 'test@example.com',
|
'email' => 'test@example.com',
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class MarketDataSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
use WithoutModelEvents;
|
use WithoutModelEvents;
|
||||||
|
|
||||||
|
public array $rows = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the database seeds.
|
* Run the database seeds.
|
||||||
*/
|
*/
|
||||||
@@ -26,7 +28,6 @@ class MarketDataSeeder extends Seeder
|
|||||||
if (($handle = fopen($csvFilePath, 'r')) !== false) {
|
if (($handle = fopen($csvFilePath, 'r')) !== false) {
|
||||||
|
|
||||||
$header = null;
|
$header = null;
|
||||||
$rows = [];
|
|
||||||
$rowCount = 0;
|
$rowCount = 0;
|
||||||
|
|
||||||
while (($row = fgetcsv($handle, 0, ',')) !== false) {
|
while (($row = fgetcsv($handle, 0, ',')) !== false) {
|
||||||
@@ -38,46 +39,54 @@ class MarketDataSeeder extends Seeder
|
|||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
try {
|
|
||||||
$data = array_combine($header, $row);
|
$data = array_combine($header, $row);
|
||||||
|
|
||||||
$rows[] = [
|
$meta_data = json_decode(base64_decode($data['meta_data']), true);
|
||||||
|
$meta_data['source'] = 'market_data_seeder';
|
||||||
|
|
||||||
|
$this->rows[] = [
|
||||||
'symbol' => $data['symbol'],
|
'symbol' => $data['symbol'],
|
||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
'meta_data' => json_encode([
|
'currency' => $data['currency'],
|
||||||
'country' => $data['country'],
|
'meta_data' => json_encode($meta_data),
|
||||||
'first_trade_year' => $data['first_trade_year'],
|
|
||||||
'sector' => $data['sector'],
|
|
||||||
'industry' => $data['industry'],
|
|
||||||
]),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$rowCount++;
|
$rowCount++;
|
||||||
|
|
||||||
if ($rowCount % $chunkSize == 0) {
|
if ($rowCount % $chunkSize == 0) {
|
||||||
DB::table('market_data')->insertOrIgnore($rows);
|
DB::table('market_data')->upsert($this->rows, ['symbol'], ['name', 'currency', 'meta_data']);
|
||||||
$rows = [];
|
$this->rows = [];
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
|
|
||||||
throw new \Exception('Error: '.$e->getMessage());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// final clean up
|
// final clean up
|
||||||
if (! empty($rows)) {
|
if (! empty($this->rows)) {
|
||||||
DB::table('market_data')->insertOrIgnore($rows);
|
|
||||||
|
$this->bulkInsert($this->rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the CSV file
|
// Close the CSV file
|
||||||
fclose($handle);
|
fclose($handle);
|
||||||
|
|
||||||
echo "Imported $rowCount market data items successfully!\n";
|
echo "\n > Imported $rowCount market data items successfully!";
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
echo "Failed to open the CSV.\n";
|
echo "Failed to open the CSV.\n";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function bulkInsert(array $rows)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
|
||||||
|
DB::table('market_data')->insertOrIgnore($rows);
|
||||||
|
$this->rows = [];
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
|
||||||
|
throw new \Exception('Error: '.$e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14465
-35000
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -10,9 +10,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 8000:80
|
- 8000:80
|
||||||
environment: # You can either use these properties OR an .env file. Do not use both!
|
environment: # You can either use these properties OR an .env file. Do not use both!
|
||||||
APP_KEY: "" # Generate a key using `echo base64:$(openssl rand -base64 32)`
|
|
||||||
APP_URL: "http://localhost:8000"
|
APP_URL: "http://localhost:8000"
|
||||||
ASSET_URL: "http://localhost:8000"
|
|
||||||
DB_CONNECTION: mysql
|
DB_CONNECTION: mysql
|
||||||
DB_HOST: investbrain-mysql
|
DB_HOST: investbrain-mysql
|
||||||
DB_PORT: 3306
|
DB_PORT: 3306
|
||||||
@@ -25,7 +23,7 @@ services:
|
|||||||
REDIS_HOST: investbrain-redis
|
REDIS_HOST: investbrain-redis
|
||||||
volumes:
|
volumes:
|
||||||
- investbrain-storage:/var/app/storage # You can use a volume...
|
- investbrain-storage:/var/app/storage # You can use a volume...
|
||||||
# - /path/to/storage:/var/app/storage # ...or you can use a path on host
|
# - /path/to/storage:/var/app/storage:delegated # ...or you can use a path on host
|
||||||
depends_on:
|
depends_on:
|
||||||
- mysql
|
- mysql
|
||||||
- redis
|
- redis
|
||||||
@@ -36,10 +34,12 @@ services:
|
|||||||
container_name: investbrain-redis
|
container_name: investbrain-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
tty: true
|
tty: true
|
||||||
networks:
|
command:
|
||||||
- investbrain-network
|
- --loglevel warning
|
||||||
volumes:
|
volumes:
|
||||||
- investbrain-redis:/data
|
- investbrain-redis:/data
|
||||||
|
networks:
|
||||||
|
- investbrain-network
|
||||||
mysql:
|
mysql:
|
||||||
image: mysql:8.0
|
image: mysql:8.0
|
||||||
container_name: investbrain-mysql
|
container_name: investbrain-mysql
|
||||||
|
|||||||
+44
-16
@@ -1,17 +1,16 @@
|
|||||||
FROM php:8.3-fpm
|
# Stage 1: Build stage
|
||||||
|
FROM php:8.3-fpm AS builder
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
ENV APP_NAME=Investbrain
|
ENV APP_NAME=Investbrain
|
||||||
ENV VITE_APP_NAME=Investbrain
|
ENV VITE_APP_NAME=Investbrain
|
||||||
|
|
||||||
# Set the working directory
|
# Set the working directory
|
||||||
COPY . /var/app
|
|
||||||
WORKDIR /var/app
|
WORKDIR /var/app
|
||||||
|
|
||||||
# Install required packages
|
# Install required packages
|
||||||
RUN apt-get update && apt-get upgrade -y \
|
RUN apt-get update && apt-get upgrade -y \
|
||||||
&& apt-get install -y \
|
&& apt-get install -y \
|
||||||
nginx \
|
|
||||||
libfreetype-dev \
|
libfreetype-dev \
|
||||||
libjpeg62-turbo-dev \
|
libjpeg62-turbo-dev \
|
||||||
libpng-dev \
|
libpng-dev \
|
||||||
@@ -20,22 +19,18 @@ RUN apt-get update && apt-get upgrade -y \
|
|||||||
libicu-dev \
|
libicu-dev \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
binutils libc6-dev \
|
binutils libc6-dev \
|
||||||
supervisor \
|
|
||||||
unzip curl git \
|
unzip curl git \
|
||||||
nodejs npm \
|
nodejs npm \
|
||||||
# Clean up APT
|
|
||||||
&& apt-get -y autoremove \
|
&& apt-get -y autoremove \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
|
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
# Install PHP extensions
|
|
||||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
|
||||||
&& docker-php-ext-install -j$(nproc) \
|
|
||||||
gd pgsql zip pdo_mysql mysqli intl
|
|
||||||
|
|
||||||
# Remove default nginx config
|
# Install PHP extensions
|
||||||
RUN rm /etc/nginx/sites-enabled/default \
|
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||||
&& rm -rf /var/www/html \
|
&& docker-php-ext-install -j$(nproc) gd zip
|
||||||
&& ln -s /var/app /var/www/app
|
|
||||||
|
# Copy application files
|
||||||
|
COPY . .
|
||||||
|
|
||||||
# Install Composer and Node.js Install PHP dependencies and build front end assets
|
# Install Composer and Node.js Install PHP dependencies and build front end assets
|
||||||
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
|
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
|
||||||
@@ -43,8 +38,41 @@ RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local
|
|||||||
&& npm install && npm run build \
|
&& npm install && npm run build \
|
||||||
&& rm -rf node_modules
|
&& rm -rf node_modules
|
||||||
|
|
||||||
|
# Stage 2: Production stage
|
||||||
|
FROM php:8.3-fpm-alpine
|
||||||
|
|
||||||
|
# Set the working directory
|
||||||
|
WORKDIR /var/app
|
||||||
|
|
||||||
|
# Copy necessary files from the builder stage
|
||||||
|
COPY --from=builder /var/app /var/app
|
||||||
|
COPY --from=builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d
|
||||||
|
COPY --from=builder /usr/local/bin/composer /usr/local/bin/composer
|
||||||
|
|
||||||
|
# Install required Alpine packages
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
nginx \
|
||||||
|
supervisor \
|
||||||
|
libpng-dev \
|
||||||
|
libzip-dev \
|
||||||
|
icu-dev \
|
||||||
|
postgresql-dev \
|
||||||
|
freetype-dev \
|
||||||
|
libjpeg-turbo-dev \
|
||||||
|
bash \
|
||||||
|
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||||
|
&& docker-php-ext-install -j$(nproc) \
|
||||||
|
gd pgsql zip pdo_mysql mysqli intl
|
||||||
|
|
||||||
|
# Remove default nginx config
|
||||||
|
RUN rm -rf /var/www/html \
|
||||||
|
&& ln -s /var/app /var/www/app
|
||||||
|
|
||||||
|
# Create required directories for supervisord
|
||||||
|
RUN mkdir -p /var/log/supervisor /var/run/supervisor
|
||||||
|
|
||||||
# Copy over configs
|
# Copy over configs
|
||||||
COPY ./docker/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY ./docker/nginx.conf /etc/nginx/http.d/default.conf
|
||||||
COPY ./docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
COPY ./docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
# Set permissions and link storage
|
# Set permissions and link storage
|
||||||
@@ -59,4 +87,4 @@ EXPOSE 80
|
|||||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 CMD curl -f http://localhost/up || exit 1
|
HEALTHCHECK --interval=30s --timeout=10s --retries=3 CMD curl -f http://localhost/up || exit 1
|
||||||
|
|
||||||
# Run everything else
|
# Run everything else
|
||||||
ENTRYPOINT ["/bin/bash", "./docker/entrypoint.sh"]
|
ENTRYPOINT ["/bin/sh", "./docker/entrypoint.sh"]
|
||||||
|
|||||||
+17
-6
@@ -8,17 +8,23 @@ echo "CiAgKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKi
|
|||||||
echo -e "\n====================== Validating environment... ====================== "
|
echo -e "\n====================== Validating environment... ====================== "
|
||||||
|
|
||||||
# Ensure app storage directory is scaffolded
|
# Ensure app storage directory is scaffolded
|
||||||
mkdir -p storage/{{framework/cache,framework/sessions,framework/views},app,logs}
|
mkdir -p storage/framework/cache \
|
||||||
|
storage/framework/sessions \
|
||||||
|
storage/framework/views \
|
||||||
|
storage/app \
|
||||||
|
storage/logs
|
||||||
|
|
||||||
|
echo -e "\n > Storage directory scaffolding is OK... "
|
||||||
|
|
||||||
# Ensure storage directory is permissioned for www-data
|
# Ensure storage directory is permissioned for www-data
|
||||||
chmod -R 775 storage
|
chmod -R 775 storage
|
||||||
chown -R www-data:www-data storage
|
chown -R www-data:www-data storage
|
||||||
|
|
||||||
echo -e "\n > Storage directory scaffolding is OK... "
|
echo -e "\n > Permissions are OK... "
|
||||||
|
|
||||||
# Ensure app key is generated
|
# Ensure app key exists / generate if required
|
||||||
if [[ -z "$APP_KEY" ]]; then
|
KEY_FILE="storage/app/.key"
|
||||||
echo -e "\n > Oops! The required APP_KEY configuration is missing in your environment! "
|
if [ -z "$APP_KEY" ] && [ ! -s "$KEY_FILE" ]; then
|
||||||
|
|
||||||
draw_box() {
|
draw_box() {
|
||||||
local text="$1"
|
local text="$1"
|
||||||
@@ -30,7 +36,12 @@ if [[ -z "$APP_KEY" ]]; then
|
|||||||
echo "$border"
|
echo "$border"
|
||||||
}
|
}
|
||||||
|
|
||||||
export APP_KEY=$(php artisan key:generate --show)
|
export APP_KEY="$(php artisan key:generate --show)"
|
||||||
|
|
||||||
|
echo -e "\n > Oops! The required APP_KEY configuration is missing! Generated app key and saved in $KEY_FILE"
|
||||||
|
|
||||||
|
echo "$APP_KEY" > "$KEY_FILE"
|
||||||
|
|
||||||
draw_box $APP_KEY
|
draw_box $APP_KEY
|
||||||
else
|
else
|
||||||
echo -e "\n > APP_KEY is OK... "
|
echo -e "\n > APP_KEY is OK... "
|
||||||
|
|||||||
+12
-3
@@ -367,8 +367,11 @@
|
|||||||
"Import starting...": "Import starting...",
|
"Import starting...": "Import starting...",
|
||||||
"Import is in progress...": "Import is in progress...",
|
"Import is in progress...": "Import is in progress...",
|
||||||
"Importing portfolios...": "Importing portfolios...",
|
"Importing portfolios...": "Importing portfolios...",
|
||||||
"Importing transactions...": "Importing transactions...",
|
"Preparing to import transactions...": "Preparing to import transactions...",
|
||||||
"Importing daily changes...": "Importing daily changes...",
|
"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!",
|
"Import completed successfully!": "Import completed successfully!",
|
||||||
"Your import will continue in the background": "Your import will continue in the background",
|
"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?",
|
"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...",
|
"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!",
|
"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 starting...": "Iniciando la importación...",
|
||||||
"Import is in progress...": "La importación está en progreso...",
|
"Import is in progress...": "La importación está en progreso...",
|
||||||
"Importing portfolios...": "Importando portafolios...",
|
"Importing portfolios...": "Importando portafolios...",
|
||||||
"Importing transactions...": "Importando transacciones...",
|
"Preparing to import transactions...": "Preparándose para importar transacciones...",
|
||||||
"Importing daily changes...": "Importando cambios diarios...",
|
"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!",
|
"Import completed successfully!": "¡La importación se completó con éxito!",
|
||||||
"Your import will continue in the background": "La importación continuará en segundo plano",
|
"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?",
|
"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...",
|
"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!",
|
"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" />
|
<x-input id="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" name="password_confirmation" required autocomplete="new-password" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (Laravel\Jetstream\Jetstream::hasTermsAndPrivacyPolicyFeature())
|
@if (! config('investbrain.self_hosted'))
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<label>
|
<label>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@ new class extends Component {
|
|||||||
'model' => config('openai.model'),
|
'model' => config('openai.model'),
|
||||||
'messages' => [
|
'messages' => [
|
||||||
['role' => 'system', 'content' => "Today's date is "
|
['role' => 'system', 'content' => "Today's date is "
|
||||||
.now()->format('Y-m-d')
|
.now()->toDateString()
|
||||||
.".\n\n".$this->system_prompt],
|
.".\n\n".$this->system_prompt],
|
||||||
...array_slice($this->messages, -10)
|
...array_slice($this->messages, -10)
|
||||||
],
|
],
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
<span
|
<span
|
||||||
class=""
|
class=""
|
||||||
style="width:90em;overflow: hidden; white-space: nowrap;"
|
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
|
@php
|
||||||
// 52-week low must be a non-zero
|
// 52-week low must be a non-zero
|
||||||
if (empty($low)) {
|
if (empty($marketData->fifty_two_week_low)) {
|
||||||
$low = 1;
|
$marketData->fifty_two_week_low = 1;
|
||||||
}
|
}
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@for ($x = 0; $x < 10; $x++)
|
@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) {
|
this.data.yaxis.labels.formatter = function (value) {
|
||||||
return `$${value}`
|
return `{{ Number::currencySymbol(auth()->user()->getCurrency()) }}${value}`
|
||||||
}
|
}
|
||||||
|
|
||||||
this.data.tooltip = {
|
this.data.tooltip = {
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
formatter: (value, { series, seriesIndex, dataPointIndex, w }) => {
|
formatter: (value, { series, seriesIndex, dataPointIndex, w }) => {
|
||||||
const firstDataPoint = this.data.series[seriesIndex].data[0][1]
|
const firstDataPoint = this.data.series[seriesIndex].data[0][1]
|
||||||
const percentageChange = ((value - firstDataPoint) / firstDataPoint) * 100;
|
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="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 justify-between items-center px-7 py-3 gap-4 mx-auto">
|
||||||
<div class="flex flex-0 items-center">
|
<div class="flex flex-0 items-center">
|
||||||
|
|||||||
@@ -1,3 +1,40 @@
|
|||||||
|
<?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="
|
||||||
|
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-menu activate-by-route>
|
||||||
|
|
||||||
<x-menu-item title="{{ __('Dashboard') }}" icon="o-home" link="{{ route('dashboard') }}" />
|
<x-menu-item title="{{ __('Dashboard') }}" icon="o-home" link="{{ route('dashboard') }}" />
|
||||||
@@ -22,6 +59,7 @@
|
|||||||
</x-menu>
|
</x-menu>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-3">
|
<div class="px-3">
|
||||||
|
|
||||||
<x-section-border />
|
<x-section-border />
|
||||||
@@ -52,3 +90,5 @@
|
|||||||
|
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
</x-list-item>
|
</x-list-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@use('App\Models\Currency')
|
||||||
|
|
||||||
<x-app-layout>
|
<x-app-layout>
|
||||||
|
|
||||||
@livewire('portfolio-performance-chart', [
|
@livewire('portfolio-performance-chart', [
|
||||||
@@ -7,27 +9,27 @@
|
|||||||
<div class="grid sm:grid-cols-5 gap-5">
|
<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">
|
<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="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>
|
||||||
|
|
||||||
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
<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="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>
|
||||||
|
|
||||||
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
<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="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>
|
||||||
|
|
||||||
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
<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="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>
|
||||||
|
|
||||||
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
<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="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>
|
</x-card>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+5
-5
@@ -3,14 +3,14 @@
|
|||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
use Livewire\Volt\Component;
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
new class extends Component {
|
new class extends Component
|
||||||
|
{
|
||||||
// props
|
// props
|
||||||
public Holding $holding;
|
public Holding $holding;
|
||||||
|
|
||||||
protected $listeners = [
|
protected $listeners = [
|
||||||
'transaction-updated' => '$refresh',
|
'transaction-updated' => '$refresh',
|
||||||
'transaction-saved' => '$refresh'
|
'transaction-saved' => '$refresh',
|
||||||
];
|
];
|
||||||
|
|
||||||
// methods
|
// methods
|
||||||
@@ -27,9 +27,9 @@ new class extends Component {
|
|||||||
$owned = ($dividend->purchased - $dividend->sold);
|
$owned = ($dividend->purchased - $dividend->sold);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
{{ Number::currency($dividend->dividend_amount) }}
|
{{ Number::currency($dividend->dividend_amount, $holding->market_data->currency) }}
|
||||||
x {{ $owned }}
|
x {{ $owned }}
|
||||||
= {{ Number::currency($owned * $dividend->dividend_amount) }}
|
= {{ Number::currency($owned * $dividend->dividend_amount, $holding->market_data->currency) }}
|
||||||
|
|
||||||
</x-slot:value>
|
</x-slot:value>
|
||||||
<x-slot:sub-value>
|
<x-slot:sub-value>
|
||||||
+9
-9
@@ -3,14 +3,14 @@
|
|||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
use Livewire\Volt\Component;
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
new class extends Component {
|
new class extends Component
|
||||||
|
{
|
||||||
// props
|
// props
|
||||||
public Holding $holding;
|
public Holding $holding;
|
||||||
|
|
||||||
protected $listeners = [
|
protected $listeners = [
|
||||||
'transaction-updated' => '$refresh',
|
'transaction-updated' => '$refresh',
|
||||||
'transaction-saved' => '$refresh'
|
'transaction-saved' => '$refresh',
|
||||||
];
|
];
|
||||||
|
|
||||||
// methods
|
// methods
|
||||||
@@ -19,11 +19,11 @@ new class extends Component {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="font-bold text-2xl py-1 flex items-center">
|
<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
|
<x-gain-loss-arrow-badge
|
||||||
:cost-basis="$holding->average_cost_basis"
|
:cost-basis="$holding->average_cost_basis"
|
||||||
:market-value="$holding->market_data->market_value"
|
:market-value="$holding->market_data->market_value_base"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -34,22 +34,22 @@ new class extends Component {
|
|||||||
|
|
||||||
<p>
|
<p>
|
||||||
<span class="font-bold">{{ __('Average Cost Basis') }}: </span>
|
<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>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<span class="font-bold">{{ __('Total Cost Basis') }}: </span>
|
<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>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<span class="font-bold">{{ __('Realized Gain/Loss') }}: </span>
|
<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>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<span class="font-bold">{{ __('Dividends Earned') }}: </span>
|
<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>
|
||||||
|
|
||||||
<p class="pt-2 text-sm" title="{{ \Carbon\Carbon::parse($holding->market_data->updated_at)->toIso8601String() }}">
|
<p class="pt-2 text-sm" title="{{ \Carbon\Carbon::parse($holding->market_data->updated_at)->toIso8601String() }}">
|
||||||
+13
-14
@@ -1,13 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\Holding;
|
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
use App\Models\Transaction;
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Livewire\Volt\Component;
|
use Livewire\Volt\Component;
|
||||||
|
use App\Models\Currency;
|
||||||
|
|
||||||
new class extends Component {
|
new class extends Component
|
||||||
|
{
|
||||||
// props
|
// props
|
||||||
public Portfolio $portfolio;
|
public Portfolio $portfolio;
|
||||||
|
|
||||||
@@ -55,7 +54,6 @@ new class extends Component {
|
|||||||
{
|
{
|
||||||
return $this->redirect(route('holding.show', ['portfolio' => $holding['portfolio_id'], 'symbol' => $holding['symbol']]));
|
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)"
|
@row-click="$wire.goToHolding($event.detail)"
|
||||||
>
|
>
|
||||||
@scope('cell_average_cost_basis', $row)
|
@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
|
@endscope
|
||||||
@scope('cell_total_cost_basis', $row)
|
@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
|
@endscope
|
||||||
@scope('cell_realized_gain_dollars', $row)
|
@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
|
@endscope
|
||||||
@scope('cell_market_gain_dollars', $row)
|
@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
|
@endscope
|
||||||
@scope('cell_market_gain_percent', $row)
|
@scope('cell_market_gain_percent', $row)
|
||||||
<x-gain-loss-arrow-badge
|
<x-gain-loss-arrow-badge
|
||||||
@@ -84,19 +83,19 @@ new class extends Component {
|
|||||||
/>
|
/>
|
||||||
@endscope
|
@endscope
|
||||||
@scope('cell_market_data_market_value', $row)
|
@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
|
@endscope
|
||||||
@scope('cell_market_data_fifty_two_week_low', $row)
|
@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
|
@endscope
|
||||||
@scope('cell_market_data_fifty_two_week_high', $row)
|
@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
|
@endscope
|
||||||
@scope('cell_total_market_value', $row)
|
@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
|
@endscope
|
||||||
@scope('cell_dividends_earned', $row)
|
@scope('cell_dividends_earned', $row)
|
||||||
{{ Number::currency($row->dividends_earned ?? 0) }}
|
{{ Number::currency($row->dividends_earned ?? 0, $row->market_data->currency) }}
|
||||||
@endscope
|
@endscope
|
||||||
@scope('cell_market_data_updated_at', $row)
|
@scope('cell_market_data_updated_at', $row)
|
||||||
{{ \Carbon\Carbon::parse($row->market_data_updated_at)->diffForHumans() }}
|
{{ \Carbon\Carbon::parse($row->market_data_updated_at)->diffForHumans() }}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user