Compare commits

...

47 Commits

Author SHA1 Message Date
hackerESQ c691ee922a wip test 2025-04-12 21:25:00 -05:00
hackerESQ 3eb9bad840 www 2025-04-12 20:39:12 -05:00
hackerESQ 370f7bb54b wip 2025-04-12 20:31:49 -05:00
hackerESQ 62bf6797e6 wip 2025-04-12 20:29:28 -05:00
hackerESQ c4e3645145 wip 2025-04-12 20:27:06 -05:00
hackerESQ 69e4d0fb3a wip 2025-04-12 20:22:43 -05:00
hackerESQ 20c2cb37cc wip 2025-04-12 20:16:34 -05:00
hackerESQ d2bb065822 wip 2025-04-12 20:11:05 -05:00
hackerESQ 0c00f28d97 wip 2025-04-12 20:06:21 -05:00
hackerESQ 5eab00ee33 wip 2025-04-12 20:03:40 -05:00
hackerESQ 56064ad84e try again 2025-04-12 18:02:23 -05:00
hackerESQ c96ff0e45f wip 2025-04-12 17:51:13 -05:00
hackerESQ 33e0df5ae2 wip 2025-04-12 17:37:26 -05:00
hackerESQ a5a333f784 wip 2025-04-12 17:31:28 -05:00
hackerESQ 89b5505e1d wip 2025-04-12 17:27:38 -05:00
hackerESQ 60923b3c93 wip 2025-04-12 17:16:36 -05:00
hackerESQ 17e5d8b665 fix: increase chunk size 2025-04-12 10:12:30 -05:00
hackerESQ bd9c828c68 fix: use options prop 2025-04-11 21:49:06 -05:00
hackerESQ f72cd6f5a7 fix: set name attribute 2025-04-11 21:45:58 -05:00
hackerESQ 3593697cce fix: user needs to be set from import job 2025-04-11 21:42:38 -05:00
hackerESQ d53e71dcd5 Update README.md 2025-04-11 21:28:05 -05:00
hackerESQ 71e79cfb40 fix: daily change should be synced when before latest transaction 2025-04-11 21:14:53 -05:00
hackerESQ 38a65f99c9 fixes multi currency tests 2025-04-11 20:57:21 -05:00
hackerESQ 26e54fb357 chore: update deps 2025-04-10 21:36:40 -05:00
hackerESQ 224ed104b9 chore: fix deps 2025-04-10 21:33:18 -05:00
hackerESQ 2702fe27e4 chore: remove dev dep 2025-04-10 21:29:59 -05:00
hackerESQ dd21227f8f Feat: Adds multi currency support to API (#90) 2025-04-10 21:24:44 -05:00
hackerESQ 1ef8dd9378 Feat: Adds multi currency to imports and exports (#89)
* Also adds ability for user to export configurations
2025-04-10 20:47:35 -05:00
hackerESQ eae345f243 Feat: Adds multi currency support (#88) 2025-04-09 19:25:15 -05:00
hackerESQ 6d6f968f42 Merge pull request #76 from investbrainapp/dividend-splits-should-be-unique
fix: add unique constraint to split and dividends
2025-03-19 16:17:01 -05:00
hackerESQ 261c848ffd fix: add unique constraint to split and dividends
to prevent duplicate records
2025-03-19 16:16:38 -05:00
hackerESQ 9bcc80078e Update 2021_09_06_014744_create_holdings_table.php 2025-03-19 15:32:38 -05:00
hackerESQ c4b7d399ea Update SECURITY.md 2025-03-17 18:19:12 -05:00
hackerESQ ffe53e91c0 Merge pull request #75 from investbrainapp/simplify-asset-url
feat: simplify self host install by removing asset_url env
2025-03-17 18:18:32 -05:00
hackerESQ aeb1b12afe feat: simplify self host install by removing asset_url env 2025-03-17 18:18:12 -05:00
hackerESQ fe81ec7ee7 fix: adds reinvest column back to holdings table 2025-03-13 20:45:00 -05:00
hackerESQ f0ecc0fd3d fix: create profile photo disk for jetstream 2025-03-12 12:02:34 -05:00
hackerESQ 03b75fb683 adds sentry log driver 2025-03-11 17:55:51 -05:00
hackerESQ dc93621547 simplify example.env 2025-03-10 22:59:15 -05:00
hackerESQ 7ab6f79e56 feat: adds pgsql compatibility (#72) 2025-03-10 21:17:24 -05:00
hackerESQ 9e48f21c8d fix: better pgsql support 2025-03-07 19:30:06 -06:00
hackerESQ 10e6de8df4 chore: clean up market data seed 2025-03-07 19:15:10 -06:00
hackerESQ 00fbdec6f1 fix: improve seeder (and remove symbol dupes) 2025-03-07 18:43:55 -06:00
hackerESQ 730903c383 fix: compatible with pgsql 2025-03-07 17:45:54 -06:00
hackerESQ 5fc9455908 fix: longer exception 2025-03-07 17:27:08 -06:00
hackerESQ 28e0ad68fc fix: truncate exception so meaningful data shows first 2025-03-07 17:20:15 -06:00
hackerESQ ca48d702a7 chore: simplify .env file 2025-03-07 17:07:47 -06:00
130 changed files with 18604 additions and 36143 deletions
+4 -21
View File
@@ -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}"
+1
View File
@@ -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
View File
@@ -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);
}
}
+24
View File
@@ -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);
}
}
+35
View File
@@ -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);
}
}
+11 -3
View File
@@ -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;
} }
} }
+46
View File
@@ -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
);
}
}
+8 -10
View File
@@ -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);
}
}
+2
View File
@@ -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),
]; ];
} }
} }
+64
View File
@@ -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';
}
}
+1 -1
View File
@@ -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 -1
View File
@@ -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());
+3 -5
View File
@@ -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();
} }
); );
+2 -3
View File
@@ -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);
}
}
-29
View File
@@ -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);
}
}
+4 -2
View File
@@ -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';
+1
View File
@@ -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,
+4
View File
@@ -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,
]; ];
+2
View File
@@ -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),
]; ];
} }
} }
+77
View File
@@ -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'],
];
}
}
+9 -18
View File
@@ -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'],
]; ];
} }
+33 -4
View File
@@ -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'],
+4 -4
View File
@@ -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,
+19 -5
View File
@@ -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}");
} }
} }
+26 -11
View File
@@ -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,
+1 -1
View File
@@ -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));
}
} }
+4 -4
View File
@@ -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;
+54 -2
View File
@@ -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'];
}
} }
+1 -1
View File
@@ -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;
+12 -1
View File
@@ -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,
+34
View File
@@ -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);
}
}
+5
View File
@@ -50,4 +50,9 @@ class BackupImport extends Model
'completed_at' => 'datetime', 'completed_at' => 'datetime',
]; ];
} }
public function user()
{
return $this->belongsTo(User::class);
}
} }
+100
View File
@@ -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;
}
}
+290
View File
@@ -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
View File
@@ -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
View File
@@ -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
+279 -60
View File
@@ -4,15 +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\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 = [
@@ -28,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
* *
@@ -61,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
@@ -91,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)')
@@ -100,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");
} }
/** /**
@@ -138,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)
@@ -175,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()
@@ -191,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
@@ -212,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'),
]); ]);
@@ -236,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,
@@ -247,12 +444,44 @@ 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 { }
// Default CTE time series query (for MySQL and SQLite)
$timeSeriesQuery = DB::table(DB::raw("(
WITH RECURSIVE date_series AS (
SELECT '{$start_date->toDateString()}' AS date
UNION ALL
SELECT $date_interval
FROM date_series
WHERE date < '{$end_date->toDateString()}'
)
SELECT date_series.date
FROM date_series
) as date_series"));
// PGSql time series query
if (config('database.default') === 'pgsql') {
$timeSeriesQuery = DB::table(DB::raw("
generate_series(
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 // MySQL default
$max_recursion_var_name = 'cte_max_recursion_depth'; $max_recursion_var_name = 'cte_max_recursion_depth';
@@ -269,39 +498,29 @@ class Holding extends Model
DB::statement("SET $max_recursion_var_name=1000000;"); DB::statement("SET $max_recursion_var_name=1000000;");
} }
return DB::table(DB::raw("( // Extracted query for counting QTY owned
WITH RECURSIVE date_series AS ( $quantityQuery = "ROUND(CAST(COALESCE(
SELECT '{$start_date->format('Y-m-d')}' AS date SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END)
UNION ALL - SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END),
SELECT $date_interval 0
FROM date_series ) AS {$castNumberType}), 3)";
WHERE date < '{$end_date->format('Y-m-d')}'
) return $timeSeriesQuery
SELECT date_series.date
FROM date_series
) as date_series")
)
->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')
@@ -318,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
+27 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
} }
+26
View File
@@ -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;
}
} }
+26
View File
@@ -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);
});
} }
} }
+7 -2
View File
@@ -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'),
]); ]);
} }
} }
+8 -12
View File
@@ -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
View File
@@ -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;
// } // }
}
+3 -3
View File
@@ -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();
+43
View File
@@ -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')
);
}
}
+3 -3
View File
@@ -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
View File
@@ -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) {
// //
+6
View File
@@ -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
+244 -207
View File
File diff suppressed because it is too large Load Diff
+83 -2
View File
@@ -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
+8
View File
@@ -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],
],
]; ];
+1 -2
View File
@@ -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
View File
@@ -116,7 +116,7 @@ return [
| |
*/ */
'inject_assets' => true, 'inject_assets' => false,
/* /*
|--------------------------------------------------------------------------- |---------------------------------------------------------------------------
+5
View File
@@ -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'),
+32 -4
View File
@@ -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) => [
+14
View File
@@ -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');
}
};
+43
View File
@@ -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()],
]);
}
}
-2
View File
@@ -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',
+27 -18
View File
@@ -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());
}
}
} }
File diff suppressed because it is too large Load Diff
-1
View File
@@ -11,7 +11,6 @@ services:
- 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_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
+12 -3
View File
@@ -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
View File
@@ -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"
} }
+1 -1
View File
@@ -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">
@@ -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))
&#9679; &#9679;
@@ -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>
+7 -5
View File
@@ -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>
@@ -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>
@@ -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() }}">
@@ -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() }}
+21 -11
View File
@@ -1,3 +1,5 @@
@use('App\Models\Currency')
<x-app-layout> <x-app-layout>
<div x-data> <div x-data>
@@ -67,48 +69,56 @@
<x-ib-card title="{{ __('Fundamentals') }}" class="md:col-span-4"> <x-ib-card title="{{ __('Fundamentals') }}" class="md:col-span-4">
@if(!empty($holding->market_data->market_cap))
<p> <p>
<span class="font-bold">{{ __('Market Cap') }}: </span> <span class="font-bold">{{ __('Market Cap') }}: </span>
${{ Number::forHumans($holding->market_data->market_cap ?? 0) }} {{ Currency::forHumans($holding->market_data->market_cap, $holding->market_data->currency) }}
</p> </p>
@endif
@if(!empty($holding->market_data->forward_pe))
<p> <p>
<span class="font-bold">{{ __('Forward PE') }}: </span> <span class="font-bold">{{ __('Forward PE') }}: </span>
{{ $holding->market_data->forward_pe }} {{ $holding->market_data->forward_pe }}
</p> </p>
@endif
@if(!empty($holding->market_data->trailing_pe))
<p> <p>
<span class="font-bold">{{ __('Trailing PE') }}: </span> <span class="font-bold">{{ __('Trailing PE') }}: </span>
{{ $holding->market_data->trailing_pe }} {{ $holding->market_data->trailing_pe }}
</p> </p>
@endif
@if(!empty($holding->market_data->book_value))
<p> <p>
<span class="font-bold">{{ __('Book Value') }}: </span> <span class="font-bold">{{ __('Book Value') }}: </span>
{{ $holding->market_data->book_value }} {{ Number::currency($holding->market_data->book_value, $holding->market_data->currency) }}
</p> </p>
@endif
<p> <p>
<span class="font-bold">{{ __('52 week') }}: </span> <span class="font-bold">{{ __('52 week') }}: </span>
<x-fifty-two-week-range <x-fifty-two-week-range :market-data="$holding->market_data" />
:low="$holding->market_data->fifty_two_week_low"
:high="$holding->market_data->fifty_two_week_high"
:current="$holding->market_data->market_value"
/>
</p> </p>
@if(!empty($holding->market_data->dividend_yield))
<p> <p>
<span class="font-bold">{{ __('Dividend Yield') }}: </span> <span class="font-bold">{{ __('Dividend Yield') }}: </span>
{{ Number::percentage( {{ Number::percentage(
$holding->market_data->dividend_yield ?? 0, $holding->market_data->dividend_yield,
$holding->market_data->dividend_yield < 1 ? 2 : 0 $holding->market_data->dividend_yield < 1 ? 2 : 0
) }} ) }}
</p> </p>
@endif
@if(!empty($holding->market_data->last_dividend_date))
<p> <p>
<span class="font-bold">{{ __('Last Dividend Paid') }}: </span> <span class="font-bold">{{ __('Last Dividend Paid') }}: </span>
{{ $holding->market_data?->last_dividend_date?->format('F d, Y') ?? '' }} {{ $holding->market_data->last_dividend_date->format('F d, Y') }}
</p> </p>
@endif
</x-ib-card> </x-ib-card>
@@ -164,7 +174,7 @@
</x-ib-card> </x-ib-card>
@if(config('services.ai_chat_enabled')) @if(config('services.ai_chat_enabled'))
{{-- // TODO: add to system prompt: {{-- // todo: add to system prompt:
// Additionally, here is some recent news about {$this->holding->symbol}: // Additionally, here is some recent news about {$this->holding->symbol}:
// And their latest SEC filings: --}} // And their latest SEC filings: --}}
@livewire('ai-chat-window', [ @livewire('ai-chat-window', [
@@ -209,7 +219,7 @@
* 52 week high: {$holding->market_data->fifty_two_week_high} * 52 week high: {$holding->market_data->fifty_two_week_high}
* Dividend yield: {$holding->market_data->dividend_yield} * Dividend yield: {$holding->market_data->dividend_yield}
This data is current as of today's date: " . now()->format('Y-m-d') . ". Based on this current market data, quantity owned, and average cost basis, you should determine if the {$holding->symbol} holding is making or losing money. This data is current as of today's date: " . now()->toDateString() . ". Based on this current market data, quantity owned, and average cost basis, you should determine if the {$holding->symbol} holding is making or losing money.
Below is the question from the investor. Considering these facts, provide a concise response to the following question (give a direct response). Limit your response to no more than 75 words and consider using a common decision framework. Use github style markdown for any formatting:" Below is the question from the investor. Considering these facts, provide a concise response to the following question (give a direct response). Limit your response to no more than 75 words and consider using a common decision framework. Use github style markdown for any formatting:"
]) ])
View File

Some files were not shown because too many files have changed in this diff Show More