Compare commits

..

46 Commits

Author SHA1 Message Date
hackerESQ 3e492475c0 fix: migrations failing on mysql 2025-07-09 21:55:32 -05:00
hackerESQ c454e85ad4 fix: date calculations cause failed tests 2025-07-09 19:37:51 -05:00
David Peng 487322abb5 fix: fix postgresql support (#100)
Fix #81
2025-07-09 19:11:25 -05:00
hackerESQ f78c521dc4 fix: add bp.l to test multicurr seed 2025-05-16 21:12:48 -05:00
hackerESQ ff9bcd782f fix: don't queue market data seed 2025-05-16 20:49:29 -05:00
hackerESQ 1ccf515ca2 fix: reorg migrtion 2025-05-16 19:59:39 -05:00
hackerESQ 1b0f9c134c fix: dispatch time series rates 2025-05-16 19:38:58 -05:00
hackerESQ 3589242996 fix: dispatch time series updates 2025-05-16 19:31:44 -05:00
hackerESQ 689aa4d50b fix: multi currency seeders 2025-05-15 20:05:14 -05:00
hackerESQ 26370c03c4 fix: optimize migration to multi-currency 2025-05-03 13:22:45 -05:00
hackerESQ 80b043219a prevent pre-releases from triggering image build 2025-05-02 20:07:38 -05:00
hackerESQ de54b6843d Fix multi-currency imports (#94) 2025-05-02 18:14:06 -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
hackerESQ 812b9ed075 chore: critical security update for livewire/volt 2025-03-06 19:51:06 -06:00
hackerESQ 93a0595652 Merge pull request #70 from investbrainapp/use-correct-storage-path
refactor: use correct storage path for app key check
2025-03-06 18:36:23 -06:00
hackerESQ 8a357e8cab refactor: use correct storage path for app key check 2025-03-06 18:35:57 -06:00
134 changed files with 17578 additions and 36335 deletions
+4 -21
View File
@@ -40,13 +40,10 @@ LINKEDIN_CLIENT_SECRET=
FACEBOOK_CLIENT_ID=
FACEBOOK_CLIENT_SECRET=
APP_NAME=Investbrain
APP_TIMEZONE=UTC
APP_ENV=production
APP_DEBUG=true
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
SELF_HOSTED=true
FILESYSTEM_DISK=local
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
CACHE_STORE=redis
DB_CONNECTION=mysql
DB_HOST=investbrain-mysql
@@ -55,19 +52,7 @@ DB_DATABASE=investbrain
DB_USERNAME=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_PATH=/tmp/database_server.sock
REDIS_PASSWORD=null
REDIS_PORT=6379
@@ -85,5 +70,3 @@ AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
+12 -6
View File
@@ -43,7 +43,16 @@ jobs:
- name: Extract version from tag
id: extract-version
run: |
echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
VERSION="${GITHUB_REF_NAME#v}"
TAGS="investbrainapp/investbrain:${VERSION},ghcr.io/investbrainapp/investbrain:${VERSION}"
# Conditionally add 'latest' tags unless 'pre-release' is in the version
if [[ "${GITHUB_REF_NAME}" != *alpha* && "${GITHUB_REF_NAME}" != *beta* && "${GITHUB_REF_NAME}" != *rc* ]]; then
TAGS="$TAGS,investbrainapp/investbrain:latest,ghcr.io/investbrainapp/investbrain:latest"
fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
@@ -51,8 +60,5 @@ jobs:
platforms: linux/amd64,linux/arm64
file: ./docker/Dockerfile
push: true
tags: |
investbrainapp/investbrain:latest
investbrainapp/investbrain:${{ env.version }}
ghcr.io/investbrainapp/investbrain:latest
ghcr.io/investbrainapp/investbrain:${{ env.version }}
tags: ${{ steps.extract-version.outputs.tags }}
+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:dividend-data | Refreshes dividend data with your configured market data provider. Will also re-calculate your total dividends earned for each holding. |
| refresh:split-data | Refreshes splits data with your configured market data provider. Will also create new transactions to account for any splits. |
| refresh:currency-data | Grabs the latest daily currency exchange rate data and persists to the database. |
| capture:daily-change | Captures a snapshot of each portfolio's daily performance. |
| sync:daily-change | Re-calculates daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) |
| sync:holdings | Re-calculates performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
+2 -1
View File
@@ -4,7 +4,8 @@
| Version | Supported |
| ------- | ------------------ |
| 1.0.x | :white_check_mark: |
| 1.1.x | :white_check_mark: |
| 1.0.x | :x: |
| < 1.0.0 | :x: |
## 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\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
use Laravel\Jetstream\Jetstream;
class CreateNewUser implements CreatesNewUsers
{
@@ -32,13 +31,22 @@ class CreateNewUser implements CreatesNewUsers
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => $this->passwordRules(),
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
'terms' => config('investbrain.self_hosted') ? '' : ['accepted', 'required'],
])->validate();
return User::create([
$user = User::make([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
// ensure first user is flagged as an admin
if (User::count() === 0) {
$user->admin = true;
}
$user->save();
return $user;
}
}
+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;
use App\Models\Holding;
use App\Models\Portfolio;
use Illuminate\Console\Command;
@@ -44,23 +45,20 @@ class CaptureDailyChange extends Command
$this->line('Capturing daily change for '.$portfolio->title);
$total_cost_basis = $portfolio->holdings->sum('total_cost_basis');
$metrics = Holding::query()
->portfolio($portfolio->id)
->getPortfolioMetrics(config('investbrain.base_currency'));
$total_dividends = $portfolio->holdings->sum('dividends_earned');
$realized_gains = $portfolio->holdings->sum('realized_gain_dollars');
$total_market_value = $portfolio->holdings->sum(function ($holding) {
return $holding->market_data->market_value * $holding->quantity;
});
$total_cost_basis = $metrics->get('total_cost_basis');
$total_market_value = $metrics->get('total_market_value');
$portfolio->daily_change()->create([
'date' => now(),
'total_market_value' => $total_market_value,
'total_cost_basis' => $total_cost_basis,
'total_gain' => $total_market_value - $total_cost_basis,
'total_dividends_earned' => $total_dividends,
'realized_gains' => $realized_gains,
'total_dividends_earned' => $metrics->get('total_dividends_earned'),
'realized_gains' => $metrics->get('realized_gain_dollars'),
]);
});
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\CurrencyRate;
use Illuminate\Console\Command;
class RefreshCurrencyData extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'refresh:currency-data
{--force : Refresh of currency data}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Refresh currency data from data provider';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
CurrencyRate::refreshCurrencyData($this->option('force') ?? false);
}
}
+1 -2
View File
@@ -7,7 +7,6 @@ namespace App\Console\Commands;
use App\Models\Holding;
use App\Models\MarketData;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class RefreshMarketData extends Command
{
@@ -61,7 +60,7 @@ class RefreshMarketData extends Command
try {
MarketData::getMarketData($holding->symbol, $force);
} catch (\Throwable $e) {
Log::error('Could not refresh '.$holding->symbol.' ('.$e->getMessage().')');
$this->line('Could not refresh '.$holding->symbol.' ('.$e->getMessage().')');
}
}
}
+2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Exports;
use App\Exports\Sheets\ConfigSheet;
use App\Exports\Sheets\DailyChangesSheet;
use App\Exports\Sheets\PortfoliosSheet;
use App\Exports\Sheets\TransactionsSheet;
@@ -24,6 +25,7 @@ class BackupExport implements WithMultipleSheets
new PortfoliosSheet($this->empty),
new TransactionsSheet($this->empty),
new DailyChangesSheet($this->empty),
new ConfigSheet($this->empty),
];
}
}
+65
View File
@@ -0,0 +1,65 @@
<?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
$reinvested_holdings = Holding::myHoldings()->where('reinvest_dividends', true)->get(['portfolio_id', 'symbol']);
if ($reinvested_holdings->isNotEmpty()) {
$configs->push([
'key' => 'reinvested_dividends',
'value' => $reinvested_holdings->toJson(),
]);
}
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()
{
return $this->empty ? collect() : DailyChange::myDailyChanges()->get();
return $this->empty ? collect() : DailyChange::myDailyChanges()->withDailyPerformance()->get();
}
public function title(): string
+25 -1
View File
@@ -25,6 +25,7 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
'Quantity',
'Cost Basis',
'Sale Price',
'Currency',
'Split',
'Reinvested Dividend',
'Date',
@@ -38,7 +39,30 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
*/
public function collection()
{
return $this->empty ? collect() : Transaction::myTransactions()->get();
if ($this->empty) {
return collect();
}
return Transaction::myTransactions()
->withMarketData()
->get()
->map(function ($transaction) {
return [
'id' => $transaction->id,
'symbol' => $transaction->symbol,
'portfolio_id' => $transaction->portfolio_id,
'transaction_type' => $transaction->transaction_type,
'quantity' => $transaction->quantity,
'cost_basis' => $transaction->cost_basis,
'sale_price' => $transaction->sale_price,
'currency' => $transaction->market_data_currency,
'split' => $transaction->split,
'reinvested_dividend' => $transaction->reinvested_dividend,
'date' => $transaction->date,
'created_at' => $transaction->created_at,
'updated_at' => $transaction->updated_at,
];
});
}
public function title(): string
@@ -18,6 +18,7 @@ class TransactionController extends ApiController
$filters->setQuery(Transaction::query());
$filters->setScopes(['myTransactions']);
$filters->setEagerRelations(['market_data']);
$filters->setSearchableColumns(['symbol']);
return TransactionResource::collection($filters->paginated());
+5 -7
View File
@@ -17,16 +17,14 @@ class DashboardController extends Controller
$user = $request->user()->load(['portfolios', 'holdings', 'transactions']);
// get portfolio metrics
$metrics = cache()->remember(
$metrics = cache()->tags(['metrics-'.$user->id])->remember(
'dashboard-metrics-'.$user->id,
10,
function () {
return
Holding::query()
->myHoldings()
->withoutWishlists()
->withPortfolioMetrics()
->first();
return Holding::query()
->myHoldings()
->withoutWishlists()
->getPortfolioMetrics();
}
);
+3 -3
View File
@@ -21,9 +21,9 @@ class HoldingController extends Controller
$query->where('transactions.symbol', $symbol);
},
])
->symbol($symbol)
->portfolio($portfolio->id)
->firstOrFail();
->symbol($symbol)
->portfolio($portfolio->id)
->firstOrFail();
$formattedTransactions = $holding->getFormattedTransactions();
+2 -3
View File
@@ -29,14 +29,13 @@ class PortfolioController extends Controller
$portfolio->load(['transactions', 'holdings']);
// get portfolio metrics
$metrics = cache()->remember(
$metrics = cache()->tags(['metrics-'.$request->user()->id])->remember(
'portfolio-metrics-'.$portfolio->id,
60,
function () use ($portfolio) {
return Holding::query()
->portfolio($portfolio->id)
->withPortfolioMetrics()
->first();
->getPortfolioMetrics();
}
);
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
use Illuminate\Support\Number;
use Illuminate\Support\Facades\Auth;
use Illuminate\Foundation\Events\LocaleUpdated;
class LocalizationMiddleware
{
/**
* Handle an incoming request.
*
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
if (Auth::check()) {
$locale = auth()->user()->getLocale();
config(['app.locale' => $locale]);
app('translator')->setLocale(Str::before($locale, '_'));
app('events')->dispatch(new LocaleUpdated($locale));
Number::useLocale($locale);
Number::useCurrency(auth()->user()->getCurrency());
}
return $next($request);
}
}
-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'],
'symbol' => ['required', 'string', new SymbolValidationRule],
'transaction_type' => ['required', 'string', 'in:BUY,SELL'],
'date' => ['required', 'date_format:Y-m-d', 'before_or_equal:'.now()->format('Y-m-d')],
'date' => ['required', 'date_format:Y-m-d', 'before_or_equal:'.now()->toDateString()],
'quantity' => [
'required',
'numeric',
'min:0',
'gt:0',
new QuantityValidationRule(
$this->input('portfolio'),
$this->requestOrModelValue('symbol', 'transaction'),
@@ -42,6 +42,7 @@ class TransactionRequest extends FormRequest
$this->requestOrModelValue('date', 'transaction')
),
],
'currency' => ['required', 'exists:currencies,currency'],
'cost_basis' => ['exclude_if:transaction_type,SELL', 'min:0', 'numeric'],
'sale_price' => ['exclude_if:transaction_type,BUY', 'min:0', 'numeric'],
];
@@ -50,6 +51,7 @@ class TransactionRequest extends FormRequest
$rules['portfolio_id'][0] = 'sometimes';
$rules['symbol'][0] = 'sometimes';
$rules['transaction_type'][0] = 'sometimes';
$rules['currency'][0] = 'sometimes';
$rules['date'][0] = 'sometimes';
$rules['quantity'][0] = 'sometimes';
+1
View File
@@ -21,6 +21,7 @@ class HoldingResource extends JsonResource
'portfolio_id' => $this->portfolio_id,
'symbol' => $this->symbol,
'quantity' => $this->quantity,
'currency' => $this->market_data->currency,
'reinvest_dividends' => $this->reinvest_dividends,
'average_cost_basis' => $this->average_cost_basis,
'total_cost_basis' => $this->total_cost_basis,
@@ -22,6 +22,7 @@ class TransactionResource extends JsonResource
'portfolio_id' => $this->portfolio_id,
'transaction_type' => $this->transaction_type,
'quantity' => $this->quantity,
'currency' => $this->market_data->currency,
'cost_basis' => $this->cost_basis,
'sale_price' => $this->sale_price,
'split' => $this->split,
+4
View File
@@ -22,6 +22,10 @@ class UserResource extends JsonResource
'name' => $this->name,
'email' => $this->email,
'profile_photo_url' => $this->profile_photo_url,
'options' => [
'display_currency' => $this->getCurrency(),
'locale' => $this->getLocale(),
],
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
+2
View File
@@ -8,6 +8,7 @@ use App\Console\Commands\RefreshDividendData;
use App\Console\Commands\RefreshMarketData;
use App\Console\Commands\SyncDailyChange;
use App\Console\Commands\SyncHoldingData;
use App\Imports\Sheets\ConfigSheet;
use App\Imports\Sheets\DailyChangesSheet;
use App\Imports\Sheets\PortfoliosSheet;
use App\Imports\Sheets\TransactionsSheet;
@@ -69,6 +70,7 @@ class BackupImport implements WithEvents, WithMultipleSheets
'Portfolios' => new PortfoliosSheet($this->backupImportModel),
'Transactions' => new TransactionsSheet($this->backupImportModel),
'Daily Changes' => new DailyChangesSheet($this->backupImportModel),
'Config' => new ConfigSheet($this->backupImportModel),
];
}
}
+83
View File
@@ -0,0 +1,83 @@
<?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 'reinvested_dividends':
if (json_validate($config['value'])) {
foreach (json_decode($config['value'], true) as $reinvest) {
Holding::myHoldings($this->backupImport->user->id)
->where('portfolio_id', $reinvest['portfolio_id'])
->where('symbol', $reinvest['symbol'])
->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) {
DB::commit();
$this->backupImport->update([
'message' => __('Importing daily changes...'),
'message' => __('Preparing to import daily changes...'),
]);
DB::beginTransaction();
},
@@ -40,22 +40,23 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
public function collection(Collection $dailyChanges)
{
$dailyChanges->chunk($this->batchSize())->each(function ($chunk) {
$totalBatches = count($dailyChanges) / $this->batchSize();
$dailyChanges->chunk($this->batchSize())->each(function ($chunk, $index) use ($totalBatches) {
$this->validatePortfolioAccess($chunk);
$this->backupImport->update([
'message' => __('Importing daily changes (Batch :currentBatch of :totalBatches)...', ['currentBatch' => $index + 1, 'totalBatches' => $totalBatches]),
]);
// have to cast to native values
$chunk = $chunk->map(function ($dailyChange) {
return [
'total_market_value' => $dailyChange['total_market_value'],
'total_cost_basis' => $dailyChange['total_cost_basis'],
'total_gain' => $dailyChange['total_gain'],
'total_dividends_earned' => $dailyChange['total_dividends_earned'],
'realized_gains' => $dailyChange['realized_gains'],
'annotation' => $dailyChange['annotation'],
'portfolio_id' => $dailyChange['portfolio_id'],
'date' => Carbon::parse($dailyChange['date'])->format('Y-m-d'),
'date' => Carbon::parse($dailyChange['date'])->toDateString(),
];
});
@@ -63,11 +64,6 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
$chunk->toArray(),
['portfolio_id', 'date'],
[
'total_market_value',
'total_cost_basis',
'total_gain',
'total_dividends_earned',
'realized_gains',
'annotation',
'portfolio_id',
'date',
@@ -86,11 +82,6 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
return [
'portfolio_id' => ['required', 'uuid'],
'date' => ['required', 'date'],
'total_market_value' => ['sometimes', 'nullable', 'numeric'],
'total_cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
'total_gain' => ['sometimes', 'nullable', 'numeric'],
'total_dividends_earned' => ['sometimes', 'nullable', 'min:0', 'numeric'],
'realized_gains' => ['sometimes', 'nullable', 'numeric'],
'annotation' => ['sometimes', 'nullable', 'string'],
];
}
+33 -4
View File
@@ -6,6 +6,8 @@ namespace App\Imports\Sheets;
use App\Imports\ValidatesPortfolioAccess;
use App\Models\BackupImport;
use App\Models\Currency;
use App\Models\CurrencyRate;
use App\Models\Holding;
use App\Models\Transaction;
use Illuminate\Support\Carbon;
@@ -33,7 +35,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
BeforeSheet::class => function (BeforeSheet $event) {
DB::commit();
$this->backupImport->update([
'message' => __('Importing transactions...'),
'message' => __('Preparing to import transactions...'),
]);
DB::beginTransaction();
},
@@ -43,13 +45,37 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
public function collection(Collection $transactions)
{
$transactions->chunk($this->batchSize())->each(function ($chunk) {
// if has any transactions not in base currency, need to sync timeseries conversion rates
if ($transactions->where('currency', '!=', config('investbrain.base_currency'))->isNotEmpty()) {
CurrencyRate::timeSeriesRates('', $transactions->min('date'), $transactions->max('date'));
}
$totalBatches = count($transactions) / $this->batchSize();
// chunk transactions
$transactions->chunk($this->batchSize())->each(function ($chunk, $index) use ($totalBatches) {
$this->backupImport->update([
'message' => __('Importing transactions (Batch :currentBatch of :totalBatches)...', ['currentBatch' => $index + 1, 'totalBatches' => $totalBatches]),
]);
$this->validatePortfolioAccess($chunk);
// have to cast to native values
$chunk = $chunk->map(function ($transaction) {
$date = Carbon::parse($transaction['date'])->toDateString();
// if transaction not in base currency, need to convert
if ($transaction['currency'] == config('investbrain.base_currency')) {
$cost_basis_base = $transaction['cost_basis'] ?? 0;
$sale_price_base = $transaction['sale_price'];
} else {
$cost_basis_base = Currency::convert($transaction['cost_basis'], $transaction['currency'], date: $date);
$sale_price_base = Currency::convert($transaction['sale_price'], $transaction['currency'], date: $date);
}
return [
'id' => $transaction['transaction_id'] ?? Str::uuid()->toString(),
'symbol' => strtoupper($transaction['symbol']),
@@ -58,9 +84,11 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
'quantity' => $transaction['quantity'],
'cost_basis' => $transaction['cost_basis'] ?? 0,
'sale_price' => $transaction['sale_price'],
'cost_basis_base' => $cost_basis_base,
'sale_price_base' => $sale_price_base,
'split' => boolval($transaction['split']) ? 1 : 0,
'reinvested_dividend' => boolval($transaction['reinvested_dividend']) ? 1 : 0,
'date' => Carbon::parse($transaction['date'])->format('Y-m-d'),
'date' => $date,
];
});
@@ -81,7 +109,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
]
);
// stub out related holdings
// get unique symbol/portfolio id combination and stub out related holdings
$chunk->unique(fn ($item) => $item['symbol'].$item['portfolio_id'])
->each(function ($holding) {
@@ -112,6 +140,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
'transaction_type' => ['required', 'in:BUY,SELL'],
'date' => ['required', 'date'],
'quantity' => ['required', 'min:0', 'numeric'],
'currency' => ['required', 'string'],
'split' => ['sometimes', 'nullable', 'boolean'],
'reinvested_dividend' => ['sometimes', 'nullable', 'boolean'],
'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
+4 -4
View File
@@ -11,13 +11,13 @@ trait ValidatesPortfolioAccess
public function validatePortfolioAccess($collection)
{
$uniquePortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id');
$countPortfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id)
->whereIn('id', $uniquePortfolios)
$importingPortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id');
$portfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id)
->whereIn('id', $importingPortfolios)
->count();
if (
$countPortfoliosWithAccess < $uniquePortfolios->count()
$importingPortfolios->count() > $portfoliosWithAccess
) {
throw new \Exception(__('You do not have access to that portfolio.'));
}
@@ -23,23 +23,44 @@ class AlphaVantageMarketData implements MarketDataInterface
public function quote(string $symbol): Quote
{
$search = Alphavantage::core()->search($symbol);
$search = Arr::get($search, 'bestMatches.0', null);
if (Arr::get($search, '9. matchScore') !== '1.0000') {
throw new \Exception('Could not find ticker on Alphavantage');
}
$quote = Alphavantage::core()->quoteEndpoint($symbol);
$quote = Arr::get($quote, 'Global Quote', []);
$fundamental = cache()->remember(
'av-symbol-'.$symbol,
1440,
function () use ($symbol) {
return Alphavantage::fundamentals()->overview($symbol);
function () use ($symbol, $search) {
if (Arr::get($search, '3. type') === 'Equity') {
$fundamental = (array) Alphavantage::fundamentals()->overview($symbol);
} else {
$fundamental = (array) Alphavantage::fundamentals()->etfProfile($symbol);
Arr::set($fundamental, 'DividendYield', Arr::get($fundamental, 'dividend_yield'));
Arr::set($fundamental, 'MarketCapitalization', Arr::get($fundamental, 'net_assets'));
Arr::set($fundamental, 'InceptionDate', Arr::get($fundamental, 'inception_date'));
}
return $fundamental;
}
);
return new Quote([
'name' => Arr::get($fundamental, 'Name'),
'name' => Arr::get($search, '2. name'),
'symbol' => $symbol,
'market_value' => Arr::get($quote, '05. price'),
'fifty_two_week_high' => Arr::get($fundamental, '52WeekHigh'),
'fifty_two_week_low' => Arr::get($fundamental, '52WeekLow'),
'market_value' => (float) Arr::get($quote, '05. price'),
'currency' => Arr::get($search, '8. currency'),
'fifty_two_week_high' => (float) Arr::get($fundamental, '52WeekHigh'),
'fifty_two_week_low' => (float) Arr::get($fundamental, '52WeekLow'),
'forward_pe' => Arr::get($fundamental, 'ForwardPE'),
'trailing_pe' => Arr::get($fundamental, 'TrailingPE'),
'market_cap' => Arr::get($fundamental, 'MarketCapitalization'),
@@ -48,8 +69,20 @@ class AlphaVantageMarketData implements MarketDataInterface
? Arr::get($fundamental, 'DividendDate')
: null,
'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None'
? Arr::get($fundamental, 'DividendYield')
? Arr::get($fundamental, 'DividendYield') * 100
: null,
'meta_data' => [
'industry' => Arr::get($fundamental, 'Industry'),
'country' => Arr::get($search, '4. region'),
'exchange' => Arr::get($fundamental, 'Exchange'),
'description' => Arr::get($fundamental, 'Description'),
'asset_type' => Arr::get($search, '3. type'),
'sector' => Arr::get($fundamental, 'Sector'),
'first_trade_year' => Arr::get($fundamental, 'InceptionDate')
? Carbon::parse(Arr::get($fundamental, 'InceptionDate'))->format('Y')
: null,
'source' => 'alphavantage',
],
]);
}
@@ -107,7 +140,7 @@ class AlphaVantageMarketData implements MarketDataInterface
})
->mapWithKeys(function ($history, $date) use ($symbol) {
$date = Carbon::parse($date)->format('Y-m-d');
$date = Carbon::parse($date)->toDateString();
return [$date => new Ohlc([
'symbol' => $symbol,
+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\Quote;
use App\Interfaces\MarketData\Types\Split;
use Carbon\CarbonPeriod;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
@@ -25,6 +26,7 @@ class FakeMarketData implements MarketDataInterface
return new Quote([
'name' => 'ACME Company Ltd',
'symbol' => $symbol,
'currency' => 'USD',
'market_value' => 230.19,
'fifty_two_week_high' => 512.90,
'fifty_two_week_low' => 341.20,
@@ -34,6 +36,7 @@ class FakeMarketData implements MarketDataInterface
'book_value' => 4.7,
'last_dividend_date' => now()->subDays(45),
'dividend_yield' => 0.033,
'meta_data' => [],
]);
}
@@ -65,7 +68,7 @@ class FakeMarketData implements MarketDataInterface
return collect([
new Split([
'symbol' => $symbol,
'date' => now()->subMonths(36),
'date' => now()->subMonths(12),
'split_amount' => 10,
]),
]);
@@ -73,16 +76,27 @@ class FakeMarketData implements MarketDataInterface
public function history(string $symbol, $startDate, $endDate): Collection
{
$numDays = Carbon::parse($startDate)->diffInDays($endDate, true);
$endDate = now()->isBefore(Carbon::parse(config('investbrain.daily_change_time_of_day')))
? now()->subDay()
: now();
for ($i = 0; $i < $numDays; $i++) {
$days = CarbonPeriod::create($startDate, $endDate)->filter('isWeekday');
$date = now()->subDays($i)->format('Y-m-d');
$countOfDays = $days->count();
foreach ($days as $index => $date) {
$date = $date->toDateString();
$series[$date] = new Ohlc([
'symbol' => $symbol,
'date' => $date,
'close' => rand(150, 400),
'open' => rand(150, 400),
'high' => rand(150, 400),
'low' => rand(150, 400),
'close' => $index == $countOfDays - 1
? 230.19 // most recent close should match current market value
: rand(150, 400),
]);
}
@@ -18,9 +18,10 @@ class FallbackInterface
foreach ($providers as $provider) {
$provider = trim($provider);
$symbol = $arguments[0];
try {
Log::warning("Calling method {$method} ({$provider})");
Log::info("Calling method {$method} for {$symbol} ({$provider})");
if (! in_array($provider, array_keys(config('investbrain.interfaces', [])))) {
@@ -35,17 +36,17 @@ class FallbackInterface
$this->latest_error = $e->getMessage();
Log::warning("Failed calling method {$method} ({$provider}): {$this->latest_error}");
Log::error("Failed calling method {$method} for {$symbol} ({$provider}): {$this->latest_error}");
}
}
// don't need to throw error if calling exists
// don't need to throw error if calling exists method...
if ($method == 'exists') {
// symbol prob just doesn't exist
return false;
}
throw new \Exception("Could not get market data: {$this->latest_error}");
throw new \Exception("Could not get market data calling method {$method}: {$this->latest_error}");
}
}
+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\Quote;
use App\Interfaces\MarketData\Types\Split;
use Finnhub\ObjectSerializer;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
@@ -35,32 +36,46 @@ class FinnhubMarketData implements MarketDataInterface
{
$quote = $this->client->quote($symbol);
if (is_null(Arr::get($quote, 'd'))) {
throw new \Exception('Could not find ticker on Finnhub');
}
$fundamental = cache()->remember(
'fh-symbol-'.$symbol,
1440,
function () use ($symbol) {
return $this->client->companyBasicFinancials($symbol, 'all');
return array_merge(
(array) ObjectSerializer::sanitizeForSerialization($this->client->companyProfile2($symbol)),
(array) ObjectSerializer::sanitizeForSerialization($this->client->companyBasicFinancials($symbol, 'all')),
);
}
);
return new Quote([
'name' => Arr::get($fundamental, 'metric.name'),
'name' => Arr::get($fundamental, 'name'),
'symbol' => $symbol,
'currency' => Arr::get($fundamental, 'currency'),
'market_value' => Arr::get($quote, 'c'),
'fifty_two_week_high' => Arr::get($fundamental, 'metric.52WeekHigh'),
'fifty_two_week_low' => Arr::get($fundamental, 'metric.52WeekLow'),
'forward_pe' => Arr::get($fundamental, 'metric.forwardPE'), // confirm
'trailing_pe' => Arr::get($fundamental, 'metric.trailingPE'), // confirm
'market_cap' => Arr::get($fundamental, 'metric.marketCapitalization'), // confirm
'book_value' => Arr::get($fundamental, 'metric.bookValuePerShare'), // confirm
'last_dividend_date' => Arr::get($fundamental, 'metric.lastDivDate'), // confirm
'dividend_yield' => Arr::get($fundamental, 'metric.dividendYield'), // confirm
'forward_pe' => Arr::get($fundamental, 'metric.peAnnual'),
'trailing_pe' => Arr::get($fundamental, 'metric.peTTM'),
'market_cap' => Arr::get($fundamental, 'metric.marketCapitalization', 0) * 1000000,
'book_value' => Arr::get($fundamental, 'metric.bookValuePerShareAnnual'),
'dividend_yield' => Arr::get($fundamental, 'metric.dividendYieldIndicatedAnnual'),
'meta_data' => [
'country' => Arr::get($fundamental, 'country'),
'exchange' => Arr::get($fundamental, 'exchange'),
'first_trade_year' => Arr::get($fundamental, 'ipo') ? Carbon::parse(Arr::get($fundamental, 'ipo'))->format('Y') : null,
'source' => 'finnhub',
],
]);
}
public function dividends($symbol, $startDate, $endDate): Collection
{
$dividends = $this->client->stockDividends($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d'));
$dividends = $this->client->stockDividends($symbol, $startDate->toDateString(), $endDate->toDateString());
return collect($dividends)->map(function ($dividend) use ($symbol) {
@@ -75,7 +90,7 @@ class FinnhubMarketData implements MarketDataInterface
public function splits($symbol, $startDate, $endDate): Collection
{
$splits = $this->client->stockSplits($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d'));
$splits = $this->client->stockSplits($symbol, $startDate->toDateString(), $endDate->toDateString());
return collect($splits)->map(function ($split) use ($symbol) {
@@ -96,7 +111,7 @@ class FinnhubMarketData implements MarketDataInterface
$closes = Arr::get($history, 'c', []);
return collect($timestamps)->mapWithKeys(function ($timestamp, $index) use ($symbol, $closes) {
$date = Carbon::createFromTimestamp($timestamp)->format('Y-m-d');
$date = Carbon::createFromTimestamp($timestamp)->toDateString();
return [$date => new Ohlc([
'symbol' => $symbol,
+1 -1
View File
@@ -21,7 +21,7 @@ class Dividend extends MarketDataType
return $this->items['symbol'] ?? '';
}
public function setDividendAmount($dividendAmount): self
public function setDividendAmount(int|float $dividendAmount): self
{
$this->items['dividend_amount'] = (float) $dividendAmount;
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Interfaces\MarketData\Types;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
@@ -12,24 +13,79 @@ class MarketDataType extends Collection
public function __construct($items = [])
{
foreach ($this->getArrayableItems($items) as $key => $value) {
$items = $this->getArrayableItems($items);
$this->{$key} = $value;
foreach ($items as $key => $value) {
$this->validateRequiredTypes($key, $value);
if (! is_null($value)) {
$this->{$key} = $value;
}
}
}
public function toArray()
{
return $this->items;
}
public function __set($key, $value)
{
$this->{'set'.Str::studly($key)}($value);
$this->{$this->getSetMethodName($key)}($value);
}
public function __get($key)
{
return $this->items[$key] ?? null;
}
protected function getSetMethodName($key): string
{
return 'set'.Str::studly($key);
}
protected function validateRequiredTypes($key, $value, $type = null): void
{
$method = new \ReflectionMethod($this, $this->getSetMethodName($key));
$params = $method->getParameters();
// no required type
if (is_null($type) && is_null($type = $params[0]->getType())) {
return;
}
// can`t validate a mixed type
if ($type == 'mixed') {
return;
}
// has a union type, let's iterate
if ($type instanceof \ReflectionUnionType) {
foreach ($type->getTypes() as $subType) {
$expected[] = $subType;
try {
$this->validateRequiredTypes($key, $value, $subType);
return;
} catch (\InvalidArgumentException) {
}
}
}
// check type
if ($type instanceof \ReflectionNamedType) {
$expected = $type->getName();
if (get_debug_type($value) == $expected || ($type->allowsNull() && $value === null)) {
return;
}
if (class_exists($expected) && is_subclass_of(get_debug_type($value), $expected)) {
return;
}
}
throw new \InvalidArgumentException("Invalid type for {$key}. Expected ".implode('|', array_map(fn ($t) => $t, Arr::wrap($expected))).' but got '.get_debug_type($value));
}
}
+4 -4
View File
@@ -21,7 +21,7 @@ class Ohlc extends MarketDataType
return $this->items['symbol'] ?? '';
}
public function setOpen($open): self
public function setOpen(int|float $open): self
{
$this->items['open'] = (float) $open;
@@ -33,7 +33,7 @@ class Ohlc extends MarketDataType
return $this->items['open'] ?? 0.0;
}
public function setHigh($high): self
public function setHigh(int|float $high): self
{
$this->items['high'] = (float) $high;
@@ -45,7 +45,7 @@ class Ohlc extends MarketDataType
return $this->items['high'] ?? 0.0;
}
public function setLow($low): self
public function setLow(int|float $low): self
{
$this->items['low'] = (float) $low;
@@ -57,7 +57,7 @@ class Ohlc extends MarketDataType
return $this->items['low'] ?? 0.0;
}
public function setClose($close): self
public function setClose(int|float $close): self
{
$this->items['close'] = (float) $close;
+55 -3
View File
@@ -5,13 +5,16 @@ declare(strict_types=1);
namespace App\Interfaces\MarketData\Types;
use DateTime;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
class Quote extends MarketDataType
{
public function setName(string $name): self
public function setName($name): self
{
$this->items['name'] = (string) $name;
if (! empty($name)) {
$this->items['name'] = (string) $name;
}
return $this;
}
@@ -33,7 +36,19 @@ class Quote extends MarketDataType
return $this->items['symbol'] ?? '';
}
public function setMarketValue($marketValue): self
public function setCurrency(string $currency): self
{
$this->items['currency'] = strtoupper((string) $currency);
return $this;
}
public function getCurrency(): string
{
return $this->items['currency'] ?? '';
}
public function setMarketValue(int|float $marketValue): self
{
$this->items['market_value'] = (float) $marketValue;
@@ -95,6 +110,7 @@ class Quote extends MarketDataType
public function setMarketCap($cap): self
{
// return $this;
$this->items['market_cap'] = (int) $cap;
return $this;
@@ -117,6 +133,18 @@ class Quote extends MarketDataType
return $this->items['book_value'] ?? 0.0;
}
public function setLastDividendAmount($value): self
{
$this->items['last_dividend_amount'] = (float) $value;
return $this;
}
public function getLastDividendAmount(): float
{
return $this->items['last_dividend_amount'] ?? 0.0;
}
public function setLastDividendDate(mixed $date): self
{
$this->items['last_dividend_date'] = is_null($date) ? null : Carbon::parse($date)->format('Y-m-d H:i:s');
@@ -140,4 +168,28 @@ class Quote extends MarketDataType
{
return $this->items['dividend_yield'] ?? 0.0;
}
public function setMetaData(array $meta_data): self
{
$defaults = [
'sector' => null,
'industry' => null,
'country' => null,
'exchange' => null,
'description' => null,
'asset_type' => null,
'first_trade_year' => null,
'source' => null,
];
// merges the NEW values with highest priority over previous values and defaults
$this->items['meta_data'] = array_merge($defaults, $this->items['meta_data'] ?? [], Arr::skipEmptyValues($meta_data));
return $this;
}
public function getMetaData(): array
{
return $this->items['meta_data'];
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ class Split extends MarketDataType
return $this->items['symbol'] ?? '';
}
public function setSplitAmount($splitAmount): self
public function setSplitAmount(int|float $splitAmount): self
{
$this->items['split_amount'] = (float) $splitAmount;
+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\Quote;
use App\Interfaces\MarketData\Types\Split;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Scheb\YahooFinanceApi\ApiClient;
use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance;
@@ -34,9 +35,14 @@ class YahooMarketData implements MarketDataInterface
$quote = $this->client->getQuote($symbol);
if (is_null($quote?->getRegularMarketPrice())) {
throw new \Exception('Could not find ticker on Yahoo');
}
return new Quote([
'name' => $quote?->getLongName() ?? $quote?->getShortName(),
'symbol' => $symbol,
'currency' => $quote?->getCurrency(),
'market_value' => $quote?->getRegularMarketPrice(),
'fifty_two_week_high' => $quote?->getFiftyTwoWeekHigh(),
'fifty_two_week_low' => $quote?->getFiftyTwoWeekLow(),
@@ -46,6 +52,11 @@ class YahooMarketData implements MarketDataInterface
'book_value' => $quote?->getBookValue(),
'last_dividend_date' => $quote?->getDividendDate(),
'dividend_yield' => $quote?->getTrailingAnnualDividendYield() * 100,
'meta_data' => [
'exchange' => $quote?->getExchange(),
'asset_type' => $quote?->getQuoteType(),
'source' => 'yahoo',
],
]);
}
@@ -84,7 +95,7 @@ class YahooMarketData implements MarketDataInterface
return collect($this->client->getHistoricalQuoteData($symbol, ApiClient::INTERVAL_1_DAY, $startDate, $endDate))
->mapWithKeys(function ($history) use ($symbol) {
$date = $history->getDate()->format('Y-m-d');
$date = Carbon::parse($history->getDate())->toDateString();
return [$date => new Ohlc([
'symbol' => $symbol,
+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',
];
}
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;
}
}
+296
View File
@@ -0,0 +1,296 @@
<?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|array|null $currency = null, mixed $start = null, mixed $end = null): array
{
if (empty($start)) {
return [];
}
$end = $end ?? now();
$period = CarbonPeriod::create($start, $end);
// No need to send network request - just generate 1s
if ($currency === config('investbrain.base_currency')) {
$dateRange = [];
foreach ($period as $date) {
$dateRange[$date->toDateString()] = 1;
}
return $dateRange;
}
if (is_array($currency)) {
foreach ($currency as $curr) {
dispatch(fn () => self::timeSeriesRates($curr, $start, $end));
}
return [];
}
// handle currency alias
if (! empty($currency)) {
[$currency, $adjustment] = self::getCurrencyAliasAdjustments($currency);
} else {
$currency = Currency::all()->pluck('currency')->toArray();
}
// get rates
$rates = Frankfurter::setSymbols($currency)->timeSeries($period->first(), $period->last());
$rates = collect(Arr::get($rates, 'rates', []))->sortKeys()->toArray();
$datesOnly = array_keys($rates);
// loop through each date
$updates = [];
foreach ($period as $date) {
$lookupDate = self::getNearestPastDate($date, $datesOnly, $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(),
];
}
}
// persist
self::chunkInsert($updates);
if (is_string($currency)) {
return collect($updates)
->whereBetween('date', [$start, $end ?? now()])
->where('currency', $currency)
->mapWithKeys(fn ($rate) => [
$rate['date'] => $rate['rate'] * ($adjustment ?? 1),
])
->toArray();
}
return [];
}
private static function getNearestPastDate(CarbonInterface $date, array $datesOnly, array $rates): ?CarbonInterface
{
// if no dates, nothing to do...
if (empty($datesOnly)) {
return null;
}
$mutableDate = $date->copy();
$weekAgo = $date->copy()->subWeek();
$firstDate = Carbon::parse($datesOnly[0]);
// get rates or find closest valid rate (handles missing weekend rates)
while (! isset($rates[$mutableDate->toDateString()])) {
// prevent runaway infinite loops
if ($mutableDate->lessThan($weekAgo)) {
return null;
}
// is this the start of a range that falls on a weekend?
if ($mutableDate->lessThan($firstDate)) {
return $firstDate;
}
// try the day before then
$mutableDate = $mutableDate->subDay();
}
return $mutableDate;
}
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
{
foreach (array_chunk($updates, 500) as $chunk) {
QueuedCurrencyRateInsertJob::dispatch($chunk);
}
}
protected static function getCurrencyAliasAdjustments(string $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 Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class DailyChange extends Model
{
@@ -22,10 +23,6 @@ class DailyChange extends Model
'portfolio_id',
'date',
'total_market_value',
'total_cost_basis',
'total_gain',
'total_dividends_earned',
'realized_gains',
'notes',
];
@@ -33,11 +30,16 @@ class DailyChange extends Model
protected $casts = [
'date' => 'datetime',
'total_market_value' => 'float',
'total_cost_basis' => 'float',
'total_gain' => 'float',
'realized_gain_dollars' => 'float',
'total_dividends_earned' => 'float',
];
public function scopePortfolio($query, $portfolio)
{
return $query->where('portfolio_id', $portfolio);
return $query->where('daily_change.portfolio_id', $portfolio);
}
public function scopeMyDailyChanges()
@@ -56,6 +58,141 @@ class DailyChange extends Model
});
}
public function scopeWithDailyPerformance($query)
{
$currency = auth()->user()?->getCurrency() ?? config('investbrain.base_currency');
$dividendSub = DB::table('holdings')
->join('dividends', 'dividends.symbol', '=', 'holdings.symbol')
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join->on('cr.date', '=', 'dividends.date')
->where('cr.currency', '=', $currency);
})
->join('transactions as tx', function ($join) {
$join->on('tx.symbol', '=', 'holdings.symbol')
->on('tx.portfolio_id', '=', 'holdings.portfolio_id')
->whereColumn('tx.date', '<=', 'dividends.date');
})
->select(['holdings.portfolio_id', 'dividends.date'])
->selectRaw("
((CASE WHEN tx.transaction_type = 'BUY'
THEN tx.quantity ELSE 0 END)
- (CASE WHEN tx.transaction_type = 'SELL'
THEN tx.quantity ELSE 0 END))
* SUM(
dividends.dividend_amount_base
* COALESCE(cr.rate, 1)
)
AS total_dividends_earned")
->groupBy(['holdings.portfolio_id', 'dividends.date', 'tx.transaction_type', 'tx.quantity']);
$totalCostBasisSub = DB::table('transactions as tx1')
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join->on('cr.date', '=', 'tx1.date')
->where('cr.currency', '=', $currency);
})
->select([
'tx1.portfolio_id',
'tx1.date',
'tx1.symbol',
'tx1.transaction_type',
'tx1.quantity',
])
->selectRaw("(CASE
WHEN tx1.transaction_type = 'BUY'
THEN COALESCE(cr.rate, 1)
ELSE (
SELECT
SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base)
/ SUM(buy.cost_basis_base)
FROM transactions as buy
LEFT JOIN currency_rates as cr2
ON cr2.date = buy.date
AND cr2.currency = '{$currency}'
WHERE buy.symbol = tx1.symbol
AND buy.portfolio_id = tx1.portfolio_id
AND buy.transaction_type = 'BUY'
AND buy.date <= tx1.date
) END)
AS rate")
->selectRaw(
"(CASE
WHEN tx1.transaction_type = 'BUY'
THEN AVG(tx1.cost_basis_base)
ELSE (
SELECT
AVG(-buy.cost_basis_base)
FROM transactions as buy
WHERE buy.symbol = tx1.symbol
AND buy.portfolio_id = tx1.portfolio_id
AND buy.transaction_type = 'BUY'
AND buy.date <= tx1.date
) END)
AS cost_basis_base")
->selectRaw(
"(CASE
WHEN tx1.transaction_type = 'SELL'
THEN tx1.sale_price_base - tx1.cost_basis_base
ELSE 0 END)
* tx1.quantity
* COALESCE(cr.rate, 1)
AS realized_gain_dollars")
->groupBy([
'tx1.portfolio_id',
'tx1.date',
'tx1.symbol',
'tx1.transaction_type',
'tx1.cost_basis_base',
'tx1.quantity',
'cr.rate',
'tx1.sale_price_base',
]);
return $query
->select(['daily_change.date', 'daily_change.portfolio_id'])
->leftJoinSub($totalCostBasisSub, 'cost_basis_display', function ($join) {
$join->on('daily_change.date', '>=', 'cost_basis_display.date')
->whereColumn('daily_change.portfolio_id', '=', 'cost_basis_display.portfolio_id');
})
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join->on('cr.date', '=', 'daily_change.date')
->where('cr.currency', '=', $currency);
})
->selectRaw('
SUM(
cost_basis_display.cost_basis_base
* cost_basis_display.quantity
* cost_basis_display.rate
) as total_cost_basis')
->selectRaw('(
daily_change.total_market_value * COALESCE(cr.rate, 1)
) - SUM(
cost_basis_display.cost_basis_base
* cost_basis_display.quantity
* cost_basis_display.rate
) as total_gain')
->selectRaw('(
daily_change.total_market_value * COALESCE(cr.rate, 1)
) as total_market_value')
->selectRaw('
SUM(
cost_basis_display.realized_gain_dollars
) as realized_gain_dollars')
->selectSub(function ($query) use ($dividendSub) {
$query->fromSub($dividendSub, 'd')
->selectRaw('SUM(d.total_dividends_earned)')
->whereColumn('d.date', '<=', 'daily_change.date')
->whereColumn('d.portfolio_id', '=', 'daily_change.portfolio_id');
}, 'total_dividends_earned')
->groupBy([
'daily_change.date',
'cr.rate',
'daily_change.total_market_value',
'daily_change.portfolio_id',
])
->orderBy('daily_change.date');
}
public function portfolio()
{
return $this->belongsTo(Portfolio::class);
+68 -30
View File
@@ -4,16 +4,24 @@ declare(strict_types=1);
namespace App\Models;
use App\Actions\CopyToBaseCurrency;
use App\Casts\BaseCurrency;
use App\Interfaces\MarketData\MarketDataInterface;
use App\Traits\HasMarketData;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Pipeline;
use Illuminate\Support\Str;
class Dividend extends Model
{
use HasFactory;
use HasMarketData;
use HasUuids;
protected $fillable = [
@@ -25,21 +33,32 @@ class Dividend extends Model
protected $hidden = [];
protected $casts = [
'date' => 'datetime',
'last_dividend_update' => 'datetime',
'date' => 'date',
'last_dividend_update' => 'date',
'dividend_amount' => 'float',
'dividend_amount_base' => BaseCurrency::class,
];
public function marketData()
protected static function boot()
{
return $this->belongsTo(MarketData::class, 'symbol', 'symbol');
parent::boot();
static::saving(function ($dividend) {
$dividend = Pipeline::send($dividend)
->through([
CopyToBaseCurrency::class,
])
->then(fn (Dividend $dividend) => $dividend);
});
}
public function holdings()
public function holdings(): HasMany
{
return $this->hasMany(Holding::class, 'symbol', 'symbol');
}
public function transactions()
public function transactions(): HasMany
{
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
}
@@ -67,7 +86,7 @@ class Dividend extends Model
// nope, refresh forward looking only
if ($dividends_meta->total_dividends) {
$start_date = $dividends_meta->last_dividend_update->addHours(24);
$start_date = $dividends_meta->last_dividend_update;
}
// skip refresh if there's already recent data
@@ -83,20 +102,32 @@ class Dividend extends Model
// ah, we found some dividends...
if ($dividend_data->isNotEmpty()) {
// create mass insert
foreach ($dividend_data as $index => $dividend) {
$dividend_data[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
}
// insert records
(new self)->insert($dividend_data->toArray());
$market_data = MarketData::getMarketData($symbol);
$dividend_data
->chunk(10)
->each(function ($chunk) use ($market_data) {
// get historic conversion rates
$rate_to_base = CurrencyRate::timeSeriesRates($market_data->currency, $chunk->min('date'), $chunk->max('date'));
// create mass insert
foreach ($chunk 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;
$chunk[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
}
// insert records
(new self)->insertOrIgnore($chunk->toArray());
});
// sync to holdings
self::syncHoldings($symbol);
// get market data
$market_data = MarketData::firstOrNew(['symbol' => $symbol]);
// re-invest dividends
self::reinvestDividends($dividend_data, $market_data);
@@ -109,22 +140,28 @@ class Dividend extends Model
public static function syncHoldings(string $symbol): void
{
// group by holdings
$dividends = self::select(['holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount'])
->selectRaw('
(COALESCE(CASE WHEN transactions.transaction_type = "BUY"
AND date(transactions.date) <= date(dividends.date)
THEN transactions.quantity ELSE 0 END, 0)
- COALESCE(CASE WHEN transactions.transaction_type = "SELL"
AND date(transactions.date) <= date(dividends.date)
THEN transactions.quantity ELSE 0 END, 0))
* dividends.dividend_amount
AS total_received
')
->join('transactions', 'transactions.symbol', '=', 'dividends.symbol')
$subQuery = self::select([
'holdings.portfolio_id',
'dividends.date',
'dividends.symbol',
'dividends.dividend_amount',
])->selectRaw("
(COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY'
AND date(transactions.date) <= date(dividends.date)
THEN transactions.quantity ELSE 0 END), 0)
- COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL'
AND date(transactions.date) <= date(dividends.date)
THEN transactions.quantity ELSE 0 END), 0))
* dividends.dividend_amount
AS total_received
")->join('transactions', 'transactions.symbol', '=', 'dividends.symbol')
->join('holdings', 'transactions.portfolio_id', '=', 'holdings.portfolio_id')
->where('dividends.symbol', $symbol)
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'total_received')
->havingRaw('total_received > 0')
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'dividends.dividend_amount_base');
$dividends = DB::table(DB::raw("({$subQuery->toSql()}) as sub"))
->mergeBindings($subQuery->getQuery())
->where('total_received', '>', 0)
->get();
// iterate through holdings and update
@@ -154,6 +191,7 @@ class Dividend extends Model
'date' => $dividend['date'],
'portfolio_id' => $holding->portfolio_id,
'symbol' => $holding->symbol,
'currency' => $holding->market_data->currency,
'transaction_type' => 'BUY',
'reinvested_dividend' => true,
'cost_basis' => 0,
+285 -66
View File
@@ -4,15 +4,18 @@ declare(strict_types=1);
namespace App\Models;
use App\Traits\HasMarketData;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class Holding extends Model
{
use HasFactory;
use HasMarketData;
use HasUuids;
protected $fillable = [
@@ -28,21 +31,24 @@ class Holding extends Model
];
protected $casts = [
'reinvest_dividends' => 'boolean',
'splits_synced_at' => 'datetime',
'first_transaction_date' => 'datetime',
'reinvest_dividends' => 'boolean',
'quantity' => 'float',
'average_cost_basis' => 'float',
'total_cost_basis' => 'float',
'realized_gain_dollars' => 'float',
'dividends_earned' => 'float',
'total_gain_dollars' => 'float',
'market_gain_dollars' => 'float',
'total_market_value' => 'float',
'total_dividends_earned' => 'float',
'market_data_market_value' => 'float',
'market_data_fifty_two_week_low' => 'float',
'market_data_fifty_two_week_high' => 'float',
'market_gain_percent' => 'float',
];
/**
* Market data for holding
*
* @return void
*/
public function market_data()
{
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
}
/**
* Related transactions for holding
*
@@ -61,7 +67,7 @@ class Holding extends Model
public function dividends()
{
return $this->hasMany(Dividend::class, 'symbol', 'symbol')
->select(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount'])
->select(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount', 'dividends.dividend_amount_base'])
->selectRaw("SUM(
CASE WHEN transaction_type = 'BUY'
AND transactions.symbol = dividends.symbol
@@ -91,8 +97,21 @@ class Holding extends Model
THEN transactions.quantity ELSE 0 END)
* dividends.dividend_amount
) AS total_received")
->selectRaw("SUM(
(CASE WHEN transaction_type = 'BUY'
AND transactions.symbol = dividends.symbol
AND transactions.portfolio_id = '$this->portfolio_id'
AND date(transactions.date) <= date(dividends.date)
THEN transactions.quantity ELSE 0 END
- CASE WHEN transaction_type = 'SELL'
AND transactions.symbol = dividends.symbol
AND transactions.portfolio_id = '$this->portfolio_id'
AND date(transactions.date) <= date(dividends.date)
THEN transactions.quantity ELSE 0 END)
* dividends.dividend_amount_base
) AS total_received_base")
->join('transactions', 'transactions.symbol', 'dividends.symbol')
->groupBy(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount'])
->groupBy(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount', 'dividends.dividend_amount_base'])
->orderBy('dividends.date', 'DESC')
->where('dividends.date', '>=', function ($query) {
$query->selectRaw('min(transactions.date)')
@@ -100,7 +119,25 @@ class Holding extends Model
->whereRaw("transactions.portfolio_id = '$this->portfolio_id'")
->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')
->withAggregate('market_data', 'market_value')
->withAggregate('market_data', 'market_value_base')
->withAggregate('market_data', 'fifty_two_week_low')
->withAggregate('market_data', 'fifty_two_week_high')
->withAggregate('market_data', 'updated_at')
->join('market_data', 'holdings.symbol', 'market_data.symbol');
}
/**
* Calculate performance for holding in its local currency
*/
public function scopeWithPerformance($query)
{
return $query->selectRaw('COALESCE(market_data.market_value * holdings.quantity, 0) AS total_market_value')
->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)
@@ -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')
->selectRaw('COALESCE(SUM(holdings.realized_gain_dollars), 0) AS realized_gain_dollars')
->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) AS total_market_value')
->selectRaw('COALESCE(SUM(holdings.total_cost_basis), 0) AS total_cost_basis')
->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) - COALESCE(SUM(holdings.total_cost_basis), 0) AS total_gain_dollars')
// ->selectRaw('COALESCE((@total_gain_dollars / @sum_total_cost_basis) * 100,0) AS total_gain_percent')
->join('market_data', 'market_data.symbol', '=', 'holdings.symbol');
$result = $query->withPortfolioMetrics($currency)->get();
return collect([
'total_cost_basis' => $result->sum('total_cost_basis'),
'total_market_value' => $result->sum('total_market_value'),
'total_gain_dollars' => $result->sum('total_gain_dollars'),
'realized_gain_dollars' => $result->sum('realized_gain_dollars'),
'total_dividends_earned' => $result->sum('total_dividends_earned'),
]);
}
/**
* Scope to collect performance metrics for holdings
*
* @param string $currency Allows casting to specified currency
*/
public function scopeWithPortfolioMetrics($query, $currency = null): mixed
{
$currency = $currency ?? auth()->user()->getCurrency();
return $query->select([
'holdings.symbol',
'holdings.portfolio_id',
'transactions_display.total_cost_basis',
'transactions_display.realized_gain_dollars',
'dividends_display.total_dividends_earned',
])
->groupBy([
'holdings.symbol',
'holdings.quantity',
'holdings.portfolio_id',
'cr.rate',
'transactions_display.total_cost_basis',
'transactions_display.realized_gain_dollars',
'dividends_display.total_dividends_earned',
'market_data.market_value_base',
])
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join->where('cr.currency', '=', $currency);
if (config('database.default') === 'sqlite') {
$join->whereRaw("strftime('%Y-%m-%d', cr.date) = ?", [now()->toDateString()]);
} else {
$join->on('cr.date', '=', DB::raw("'".now()->toDateString()."'"));
}
})
->leftJoin('market_data', function ($join) {
$join->on('market_data.symbol', '=', 'holdings.symbol');
})
->selectRaw(
'holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1) AS total_market_value'
)
->selectRaw('(
holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1)
) - transactions_display.total_cost_basis as total_gain_dollars')
->leftJoinSub(
DB::table('transactions')
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join->on('cr.date', '=', 'transactions.date')
->where('cr.currency', '=', $currency);
})
->select(['transactions.symbol', 'transactions.portfolio_id'])
->leftJoinSub(
DB::table('transactions')
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join
->on('cr.date', '=', 'transactions.date')
->where('cr.currency', '=', $currency);
})
->select([
'transactions.symbol',
'transactions.portfolio_id',
'transactions.quantity',
'transactions.date',
])
->selectRaw(
"(CASE
WHEN transactions.transaction_type = 'BUY'
THEN COALESCE(cr.rate, 1)
ELSE (
SELECT
SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base)
/ SUM(buy.cost_basis_base)
FROM transactions as buy
LEFT JOIN currency_rates as cr2
ON cr2.date = buy.date
AND cr2.currency = '{$currency}'
WHERE buy.symbol = transactions.symbol
AND buy.portfolio_id = transactions.portfolio_id
AND buy.transaction_type = 'BUY'
AND buy.date <= transactions.date
) END)
AS rate"
)
->selectRaw(
"(CASE
WHEN transactions.transaction_type = 'BUY'
THEN AVG(transactions.cost_basis_base)
ELSE (
SELECT
AVG(-buy.cost_basis_base)
FROM transactions as buy
WHERE buy.symbol = transactions.symbol
AND buy.portfolio_id = transactions.portfolio_id
AND buy.transaction_type = 'BUY'
AND buy.date <= transactions.date
) END)
AS cost_basis_base"
)
->groupBy([
'transactions.symbol',
'transactions.date',
'transactions.portfolio_id',
'transactions.transaction_type',
'transactions.quantity',
'cr.rate',
]), 'cost_basis_display', function ($join) {
$join->on('transactions.symbol', '=', 'cost_basis_display.symbol')
->on('transactions.portfolio_id', '=', 'cost_basis_display.portfolio_id')
->on('transactions.date', '=', 'cost_basis_display.date');
})
->selectRaw(
"SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN (transactions.sale_price_base - transactions.cost_basis_base) * transactions.quantity * COALESCE(cr.rate, 1) ELSE 0 END) AS realized_gain_dollars"
)
->selectRaw(
'SUM(cost_basis_display.cost_basis_base * cost_basis_display.quantity * cost_basis_display.rate) AS total_cost_basis'
)
->groupBy(['transactions.symbol', 'transactions.portfolio_id']),
'transactions_display',
function ($join) {
$join->on('holdings.symbol', '=', 'transactions_display.symbol')
->on('holdings.portfolio_id', '=', 'transactions_display.portfolio_id');
}
)
->leftJoinSub(
DB::table('dividends')
->join('transactions as tx', function ($join) {
$join->on('tx.symbol', '=', 'dividends.symbol')
->on('tx.date', '<=', 'dividends.date');
})
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join->on('cr.date', '=', 'dividends.date')
->where('cr.currency', '=', $currency);
})
->select(['dividends.symbol'])
->selectRaw(
"SUM(((CASE WHEN transaction_type = 'BUY' THEN tx.quantity ELSE 0 END) - (CASE WHEN transaction_type = 'SELL' THEN tx.quantity ELSE 0 END)) * dividends.dividend_amount_base * COALESCE(cr.rate, 1)) AS total_dividends_earned"
)
->groupBy(['dividends.symbol']),
'dividends_display',
function ($join) {
$join->on('holdings.symbol', '=', 'dividends_display.symbol');
}
);
}
public function syncTransactionsAndDividends()
@@ -191,30 +386,27 @@ class Holding extends Model
// pull existing transaction data
$query = Transaction::where([
'portfolio_id' => $this->portfolio_id,
'symbol' => $this->symbol,
])->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN quantity ELSE 0 END) AS `qty_purchases`')
->selectRaw('SUM(CASE WHEN transaction_type = "SELL" THEN quantity ELSE 0 END) AS `qty_sales`')
->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN (quantity * cost_basis) ELSE 0 END) AS `total_cost_basis`')
->selectRaw('SUM(CASE WHEN transaction_type = "SELL" THEN (quantity * sale_price) ELSE 0 END) AS `total_sale_price`')
'transactions.symbol' => $this->symbol,
])->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN quantity ELSE 0 END) AS qty_purchases")
->selectRaw("SUM(CASE WHEN transaction_type = 'SELL' THEN quantity ELSE 0 END) AS qty_sales")
->selectRaw("SUM(CASE WHEN transaction_type = 'SELL' THEN (sale_price - cost_basis) * quantity ELSE 0 END) AS realized_gain_dollars")
->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN (quantity * cost_basis) ELSE 0 END) AS total_cost_basis")
->first();
$total_quantity = round($query->qty_purchases - $query->qty_sales, 3);
$total_quantity = round($query->qty_purchases - $query->qty_sales, 4);
$average_cost_basis = (
$query->qty_purchases > 0
&& $total_quantity > 0
)
? $query->total_cost_basis / $query->qty_purchases
: 0;
) ? $query->total_cost_basis / $query->qty_purchases
: 0;
// update holding
$this->fill([
'quantity' => $total_quantity,
'average_cost_basis' => $average_cost_basis,
'total_cost_basis' => $total_quantity * $average_cost_basis,
'realized_gain_dollars' => $query->qty_purchases > 0 && $query->total_sale_price > 0
? $query->total_sale_price - ($query->qty_sales * ($query->total_cost_basis / $query->qty_purchases))
: 0,
'realized_gain_dollars' => $query->realized_gain_dollars ?? 0,
'dividends_earned' => $this->dividends->sum('total_received'),
]);
@@ -236,6 +428,11 @@ class Holding extends Model
return $purchases - $sales;
}
/**
* Method that enables calculating daily performance for a given holding
*
* @return void
*/
public function dailyPerformance(
?\Illuminate\Support\Carbon $start_date = null,
?\Illuminate\Support\Carbon $end_date = null,
@@ -247,12 +444,44 @@ class Holding extends Model
$end_date = now();
}
// MySQL default interval
$date_interval = 'DATE_ADD(date, INTERVAL 1 DAY)';
$castNumberType = 'decimal';
// Use SQLite interval grammar
if (config('database.default') === 'sqlite') {
$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
$max_recursion_var_name = 'cte_max_recursion_depth';
@@ -269,39 +498,29 @@ class Holding extends Model
DB::statement("SET $max_recursion_var_name=1000000;");
}
return DB::table(DB::raw("(
WITH RECURSIVE date_series AS (
SELECT '{$start_date->format('Y-m-d')}' AS date
UNION ALL
SELECT $date_interval
FROM date_series
WHERE date < '{$end_date->format('Y-m-d')}'
)
SELECT date_series.date
FROM date_series
) as date_series")
)
// Extracted query for counting QTY owned
$quantityQuery = "ROUND(CAST(COALESCE(
SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END)
- SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END),
0
) AS {$castNumberType}), 3)";
return $timeSeriesQuery
->select([
'date_series.date',
DB::raw("
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) AS `owned`
"),
{$quantityQuery} AS owned
"),
DB::raw("
COALESCE(CASE
WHEN (
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
WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis
ELSE 0
END)
END, 0) 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`"),
CASE
WHEN ({$quantityQuery}) = 0 THEN 0
ELSE SUM(CASE
WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis_base
ELSE 0
END)
END AS cost_basis
"),
DB::raw("COALESCE(SUM(CASE WHEN transaction_type = 'SELL' THEN ((sale_price_base - cost_basis_base) * quantity) ELSE 0 END), 0) AS realized_gains"),
])
->leftJoin('transactions', function ($join) {
$join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date')
@@ -318,7 +537,7 @@ class Holding extends Model
{
$formattedTransactions = '';
foreach ($this->transactions->sortByDesc('date') as $transaction) {
$formattedTransactions .= ' * '.$transaction->date->format('Y-m-d')
$formattedTransactions .= ' * '.$transaction->date->toDateString()
.' '.$transaction->transaction_type
.' '.$transaction->quantity
.' @ '.$transaction->cost_basis
+27 -3
View File
@@ -4,9 +4,12 @@ declare(strict_types=1);
namespace App\Models;
use App\Actions\CopyToBaseCurrency;
use App\Casts\BaseCurrency;
use App\Interfaces\MarketData\MarketDataInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Pipeline;
class MarketData extends Model
{
@@ -21,7 +24,9 @@ class MarketData extends Model
protected $fillable = [
'symbol',
'name',
'currency',
'market_value',
'market_value_base',
'fifty_two_week_high',
'fifty_two_week_low',
'forward_pe',
@@ -29,21 +34,40 @@ class MarketData extends Model
'market_cap',
'book_value',
'last_dividend_date',
'last_dividend_amount',
'dividend_yield',
'meta_data',
];
protected $casts = [
'last_dividend_date' => 'datetime',
'market_value' => 'float',
'market_value_base' => BaseCurrency::class,
'fifty_two_week_high' => 'float',
'fifty_two_week_low' => 'float',
'forward_pe' => 'float',
'trailing_pe' => 'float',
'market_cap' => 'float',
'market_cap' => 'integer',
'book_value' => 'float',
'last_dividend_date' => 'datetime',
'last_dividend_amount' => 'float',
'dividend_yield' => 'float',
'meta_data' => 'json',
];
protected static function boot()
{
parent::boot();
static::saving(function ($market_data) {
$market_data = Pipeline::send($market_data)
->through([
CopyToBaseCurrency::class,
])
->then(fn (MarketData $market_data) => $market_data);
});
}
public function holdings()
{
return $this->hasMany(Holding::class, 'symbol', 'symbol');
@@ -54,7 +78,7 @@ class MarketData extends Model
return $query->where('symbol', $symbol);
}
public static function getMarketData($symbol, $force = false)
public static function getMarketData($symbol, $force = false): self
{
$market_data = self::firstOrNew([
'symbol' => $symbol,
+18 -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
{
$holdings = $this->holdings()
@@ -147,11 +150,14 @@ class Portfolio extends Model
->groupBy(['holdings.symbol', 'holdings.portfolio_id'])
->get();
$dividends = Dividend::whereIn('symbol', $holdings->pluck('symbol'))->get();
$total_performance = [];
$holdings->each(function ($holding) use (&$total_performance, $dividends) {
// get unique currencies for holdings
foreach ($holdings->groupBy('market_data.currency')->keys() as $currency) {
$currency_rates[$currency] = CurrencyRate::timeSeriesRates($currency, $holdings->min('first_transaction_date'), now());
}
$holdings->each(function ($holding) use (&$total_performance, $currency_rates) {
$period = CarbonPeriod::create(
$holding->first_transaction_date,
@@ -160,34 +166,24 @@ class Portfolio extends Model
: now()
);
$holding->setRelation('dividends', $dividends->where('symbol', $holding->symbol));
$daily_performance = $holding->dailyPerformance($holding->first_transaction_date, now());
$dividends = $holding->dividends->keyBy(function ($dividend, $key) {
return $dividend['date']->format('Y-m-d');
});
$all_history = app(MarketDataInterface::class)->history($holding->symbol, $holding->first_transaction_date, now());
$dividends_earned = 0;
$holding_performance = [];
foreach ($period as $date) {
$date = $date->format('Y-m-d');
$date = $date->toDateString();
$close = $this->getMostRecentCloseData($all_history, $date);
$total_market_value = $daily_performance->get($date)->owned * $close;
$dividends_earned += $daily_performance->get($date)->owned * ($dividends->get($date)?->dividend_amount ?? 0);
if (Carbon::parse($date)->isWeekday()) {
$holding_performance[$date] = [
'date' => $date,
'portfolio_id' => $this->id,
'total_market_value' => $total_market_value,
'total_cost_basis' => $daily_performance->get($date)->cost_basis,
'total_gain' => $total_market_value - $daily_performance->get($date)->cost_basis,
'realized_gains' => $daily_performance->get($date)->realized_gains,
'total_dividends_earned' => $dividends_earned,
'total_market_value' => $total_market_value * (1 / Arr::get($currency_rates[$holding->market_data->currency], $date, 1)),
];
}
}
@@ -200,10 +196,6 @@ class Portfolio extends Model
} else {
$total_performance[$date]['total_market_value'] += $performance['total_market_value'];
$total_performance[$date]['total_cost_basis'] += $performance['total_cost_basis'];
$total_performance[$date]['total_gain'] += $performance['total_gain'];
$total_performance[$date]['realized_gains'] += $performance['realized_gains'];
$total_performance[$date]['total_dividends_earned'] += $performance['total_dividends_earned'];
}
}
});
@@ -211,15 +203,16 @@ class Portfolio extends Model
if (! empty($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(
$total_performance,
['date', 'portfolio_id'],
[
'total_market_value',
'total_cost_basis',
'total_gain',
'realized_gains',
'total_dividends_earned',
]
);
});
@@ -234,7 +227,7 @@ class Portfolio extends Model
$i++;
$date = Carbon::parse($date)->subDay()->format('Y-m-d');
$date = Carbon::parse($date)->subDay()->toDateString();
return $this->getMostRecentCloseData($history, $date, $i);
}
+10 -7
View File
@@ -5,15 +5,18 @@ declare(strict_types=1);
namespace App\Models;
use App\Interfaces\MarketData\MarketDataInterface;
use App\Traits\HasMarketData;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class Split extends Model
{
use HasFactory;
use HasMarketData;
use HasUuids;
protected $fillable = [
@@ -29,12 +32,12 @@ class Split extends Model
'last_date' => 'datetime',
];
public function holdings()
public function holdings(): HasMany
{
return $this->hasMany(Holding::class, 'symbol', 'symbol');
}
public function transactions()
public function transactions(): HasMany
{
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
}
@@ -73,7 +76,7 @@ class Split extends Model
if ($split_data->isNotEmpty()) {
// insert records
(new self)->insert($split_data->map(function ($split) {
(new self)->insertOrIgnore($split_data->map(function ($split) {
return [...$split, ...['id' => Str::uuid()->toString()]];
})->toArray());
@@ -101,7 +104,7 @@ class Split extends Model
->where([
'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)
->join('holdings', 'splits.symbol', 'holdings.symbol')
->orderBy('splits.date', 'ASC')
@@ -114,9 +117,9 @@ class Split extends Model
'symbol' => $split->symbol,
'portfolio_id' => $split->portfolio_id,
])
->whereDate('transactions.date', '<', $split->date->format('Y-m-d'))
->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')
->whereDate('transactions.date', '<', $split->date->toDateString())
->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN quantity ELSE 0 END) -
SUM(CASE WHEN transaction_type = 'SELL' THEN quantity ELSE 0 END) AS qty_owned")
->value('qty_owned');
if ($qty_owned > 0) {
+29 -40
View File
@@ -4,18 +4,25 @@ declare(strict_types=1);
namespace App\Models;
use App\Actions\ConvertToMarketDataCurrency;
use App\Actions\CopyToBaseCurrency;
use App\Actions\EnsureCostBasisAddedToSale;
use App\Actions\EnsureDailyChangeIsSynced;
use App\Casts\BaseCurrency;
use App\Traits\HasMarketData;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Pipeline;
class Transaction extends Model
{
use HasFactory;
use HasMarketData;
use HasUuids;
protected $fillable = [
@@ -23,6 +30,7 @@ class Transaction extends Model
'date',
'portfolio_id',
'transaction_type',
'currency',
'quantity',
'cost_basis',
'sale_price',
@@ -36,6 +44,11 @@ class Transaction extends Model
'date' => 'datetime',
'split' => 'boolean',
'reinvested_dividend' => 'boolean',
'quantity' => 'float',
'cost_basis' => 'float',
'sale_price' => 'float',
'cost_basis_base' => BaseCurrency::class,
'sale_price_base' => BaseCurrency::class,
];
protected static function boot()
@@ -44,17 +57,24 @@ class Transaction extends Model
static::saving(function ($transaction) {
if ($transaction->transaction_type == 'SELL') {
$transaction->ensureCostBasisIsAddedToSale();
}
$transaction = Pipeline::send($transaction)
->through([
ConvertToMarketDataCurrency::class,
EnsureCostBasisAddedToSale::class,
CopyToBaseCurrency::class,
])
->then(fn (Transaction $transaction) => $transaction);
});
static::saved(function ($transaction) {
$transaction->syncToHolding();
$transaction->refreshMarketData();
$transaction = Pipeline::send($transaction)
->through([
EnsureDailyChangeIsSynced::class,
])
->then(fn (Transaction $transaction) => $transaction);
cache()->forget('portfolio-metrics-'.$transaction->portfolio_id);
});
@@ -77,16 +97,6 @@ class Transaction extends Model
);
}
/**
* Related market data for transaction
*
* @return void
*/
public function market_data(): HasOne
{
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
}
/**
* Related portfolio
*
@@ -101,6 +111,7 @@ class Transaction extends Model
{
return $query->withAggregate('market_data', 'name')
->withAggregate('market_data', 'market_value')
->withAggregate('market_data', 'currency')
->withAggregate('market_data', 'fifty_two_week_low')
->withAggregate('market_data', 'fifty_two_week_high')
->withAggregate('market_data', 'updated_at')
@@ -141,28 +152,6 @@ class Transaction extends Model
});
}
public function refreshMarketData(): void
{
MarketData::getMarketData($this->attributes['symbol']);
}
/**
* Writes average cost basis to a sale transaction
*/
public function ensureCostBasisIsAddedToSale(): Transaction
{
$average_cost_basis = Transaction::where([
'portfolio_id' => $this->portfolio_id,
'symbol' => $this->symbol,
'transaction_type' => 'BUY',
])->whereDate('date', '<=', $this->date)
->average('cost_basis');
$this->cost_basis = $average_cost_basis ?? 0;
return $this;
}
/**
* Syncs the holding related to this transaction
*/
@@ -187,8 +176,8 @@ class Transaction extends Model
'portfolio_id' => $this->portfolio_id,
'symbol' => $this->symbol,
'quantity' => $this->quantity,
'average_cost_basis' => $this->cost_basis,
'total_cost_basis' => $this->quantity * $this->cost_basis,
'average_cost_basis' => $this->cost_basis_base,
'total_cost_basis' => $this->quantity * $this->cost_basis_base,
'splits_synced_at' => now(),
])->syncTransactionsAndDividends();
}
+26
View File
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Arr;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Sanctum\HasApiTokens;
@@ -31,6 +32,7 @@ class User extends Authenticatable implements MustVerifyEmail
'name',
'email',
'password',
'options',
];
protected $hidden = [
@@ -50,6 +52,8 @@ class User extends Authenticatable implements MustVerifyEmail
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'admin' => 'boolean',
'options' => 'json',
];
}
@@ -82,4 +86,26 @@ class User extends Authenticatable implements MustVerifyEmail
ELSE COALESCE(market_data.market_value - transactions.cost_basis, 0)
END AS gain_dollars');
}
public function getCurrency(): string
{
return Arr::get($this->options, 'display_currency') ?? config('investbrain.base_currency');
}
public function getLocale(): string
{
$available_locales = Arr::pluck(config('app.available_locales'), 'locale');
return Arr::get($this->options, 'locale') ?? request()->getPreferredLanguage($available_locales) ?? config('app.locale');
}
public function setOption(mixed $key, string $value): self
{
$options = is_array($key) ? $key : [$key => $value];
$this->options = array_merge($this->options ?? [], $options);
return $this;
}
}
+26
View File
@@ -5,7 +5,10 @@ declare(strict_types=1);
namespace App\Providers;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Arr;
use Illuminate\Support\Number;
use Illuminate\Support\ServiceProvider;
use NumberFormatter;
class AppServiceProvider extends ServiceProvider
{
@@ -26,5 +29,28 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void
{
JsonResource::withoutWrapping();
Arr::macro('skipEmptyValues', function (array $array) {
return Arr::mapWithKeys($array, function (mixed $value, mixed $key) {
$result = [];
if (! empty($value)) {
$result[$key] = $value;
}
return $result;
});
});
Number::macro('currencySymbol', function (?string $currency = null, ?string $locale = null) {
$currency = $currency ?? Number::defaultCurrency();
$locale = $locale ?? Number::defaultLocale();
$formatter = new NumberFormatter($locale."@currency=$currency", NumberFormatter::CURRENCY);
return $formatter->getSymbol(NumberFormatter::CURRENCY_SYMBOL);
});
}
}
+7 -2
View File
@@ -23,8 +23,13 @@ class VoltServiceProvider extends ServiceProvider
public function boot(): void
{
Volt::mount([
config('livewire.view_path', resource_path('views/livewire')),
resource_path('views/pages'),
// config('livewire.view_path', resource_path('views/livewire')),
resource_path('views/components'),
resource_path('views/profile'),
resource_path('views/holding'),
resource_path('views/transaction'),
resource_path('views/portfolio'),
resource_path('views/auth'),
]);
}
}
+8 -12
View File
@@ -5,8 +5,9 @@ declare(strict_types=1);
namespace App\Rules;
use App\Models\Portfolio;
use Illuminate\Contracts\Validation\ValidationRule;
use App\Models\Transaction;
use Illuminate\Support\Carbon;
use Illuminate\Contracts\Validation\ValidationRule;
class QuantityValidationRule implements ValidationRule
{
@@ -20,12 +21,7 @@ class QuantityValidationRule implements ValidationRule
protected ?string $symbol,
protected ?string $transactionType,
protected string|Carbon|null $date
) {
$this->portfolio = $portfolio;
$this->symbol = $symbol;
$this->transactionType = $transactionType;
$this->date = $date;
}
) { }
/**
* Validate the attribute.
@@ -39,21 +35,21 @@ class QuantityValidationRule implements ValidationRule
if ($this->transactionType == 'SELL') {
$purchase_qty = $this->portfolio->transactions()
$purchase_qty = (float) $this->portfolio->transactions()
->symbol($this->symbol)
->buy()
->beforeDate($this->date)
->whereDate('date', '<', $this->date)
->sum('quantity');
$sales_qty = $this->portfolio->transactions()
$sales_qty = (float) $this->portfolio->transactions()
->symbol($this->symbol)
->sell()
->beforeDate($this->date)
->whereDate('date', '<', $this->date)
->sum('quantity');
$maxQuantity = $purchase_qty - $sales_qty;
if (round($value, 3) > round($maxQuantity, 3)) {
if (round($value, 4) > round($maxQuantity, 4)) {
$fail(__('The quantity must not be greater than the available quantity.'));
}
}
+11 -12
View File
@@ -2,16 +2,15 @@
declare(strict_types=1);
// if (!function_exists('formatMoney')) {
// /**
// * Returns a formatted string for currency
// *
// * @param int|float $amount
// *
// * */
// function formatMoney(int|float $amount) {
// $formatter = new NumberFormatter('en_US', NumberFormatter::CURRENCY);
use App\Models\Currency;
// return $formatter->formatCurrency((float) $amount, 'USD');
// }
// }
if (! function_exists('currency')) {
// /**
// * Returns an instance of the currency model
// * */
// function currency(): Currency
// {
// return new Currency;
// }
}
+3 -3
View File
@@ -19,7 +19,7 @@ class Spotlight
}
$portfolios = $request->user()->portfolios()
->where('title', 'LIKE', '%'.$request->input('search').'%')
->whereFullText('title', $request->input('search'))
->limit(5)
->get();
$portfolios->each(function ($portfolio) use ($results) {
@@ -35,8 +35,8 @@ class Spotlight
$holdings = $request->user()->holdings()
->where('holdings.quantity', '>', 0)
->where(function ($query) use ($request) {
return $query->where('holdings.symbol', 'LIKE', '%'.$request->input('search').'%')
->orWhere('market_data.name', 'LIKE', '%'.$request->input('search').'%');
return $query->whereFullText('holdings.symbol', $request->input('search'))
->orWhereFullText('market_data.name', $request->input('search'));
})
->limit(5)
->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')
);
}
}
+4 -4
View File
@@ -21,11 +21,11 @@ class AppLayout extends Component
<x-partials.nav-bar />
<x-main with-nav full-width>
<x-partials.main with-nav full-width>
<x-slot:sidebar drawer="main-drawer" class="bg-base-100 lg:bg-inherit">
<x-partials.side-bar />
@livewire('partials.side-bar')
</x-slot:sidebar>
@@ -34,7 +34,7 @@ class AppLayout extends Component
{{ $slot }}
</x-slot:content>
</x-main>
</x-partials.main>
@if(session('toast'))
<script lang="text/javascript">
+2 -2
View File
@@ -2,7 +2,7 @@
declare(strict_types=1);
use App\Http\Middleware\SetLocale;
use App\Http\Middleware\LocalizationMiddleware;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@@ -15,7 +15,7 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->append(SetLocale::class);
$middleware->appendToGroup('web', LocalizationMiddleware::class);
})
->withExceptions(function (Exceptions $exceptions) {
//
+6
View File
@@ -11,6 +11,7 @@
"ext-zip": "*",
"finnhub/client": "master@dev",
"hackeresq/filter-models": "dev-main",
"investbrainapp/frankfurter-client": "dev-main",
"laravel/framework": "^11.35",
"laravel/jetstream": "^5.1",
"laravel/sanctum": "^4.0",
@@ -41,6 +42,11 @@
"no-api": true,
"url": "https://github.com/hackeresq/filter-models"
},
{
"type": "vcs",
"no-api": true,
"url": "https://github.com/investbrainapp/frankfurter-client"
},
{
"type": "vcs",
"no-api": true,
Generated
+407 -384
View File
File diff suppressed because it is too large Load Diff
+85 -3
View File
@@ -79,14 +79,95 @@ return [
| set to any locale for which you plan to have translation strings.
|
*/
'available_locales' => ['en', 'es'],
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
'available_locales' => [
[
'locale' => 'en_AU',
'label' => 'English (Australia)',
'flag' => '',
],
[
'locale' => 'en_BE',
'label' => 'English (Belgium)',
'flag' => '',
],
[
'locale' => 'en_CA',
'label' => 'English (Canada)',
'flag' => '',
],
[
'locale' => 'en_HK',
'label' => 'English (Hong Kong SAR China)',
'flag' => '',
],
[
'locale' => 'en_IN',
'label' => 'English (India)',
'flag' => '',
],
[
'locale' => 'en_IE',
'label' => 'English (Ireland)',
'flag' => '',
],
[
'locale' => 'en_MT',
'label' => 'English (Malta)',
'flag' => '',
],
[
'locale' => 'en_NZ',
'label' => 'English (New Zealand)',
'flag' => '',
],
[
'locale' => 'en_PH',
'label' => 'English (Philippines)',
'flag' => '',
],
[
'locale' => 'en_SG',
'label' => 'English (Singapore)',
'flag' => '',
],
[
'locale' => 'en_ZA',
'label' => 'English (South Africa)',
'flag' => '',
],
[
'locale' => 'en_GB',
'label' => 'English (United Kingdom)',
'flag' => '',
],
[
'locale' => 'en_US',
'label' => 'English (United States)',
'flag' => '',
],
[
'locale' => 'es_419',
'label' => 'Spanish (Latin America)',
'flag' => '',
],
[
'locale' => 'es_ES',
'label' => 'Spanish (Spain)',
'flag' => '',
],
[
'locale' => 'es_US',
'label' => 'Spanish (United States)',
'flag' => '',
],
],
/*
|--------------------------------------------------------------------------
| Encryption Key
@@ -100,7 +181,8 @@ return [
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY') ?: when(file_exists('storage/app/.key'), fn () => trim(file_get_contents('storage/app/.key'))),
'key' => env('APP_KEY')
?: when(file_exists(storage_path('app/.key')), fn () => trim(file_get_contents(storage_path('app/.key')))),
'previous_keys' => [
...array_filter(
+8
View File
@@ -18,4 +18,12 @@ return [
'self_hosted' => env('SELF_HOSTED', true),
'daily_change_time_of_day' => env('DAILY_CHANGE_TIME', '23:00'),
'base_currency' => env('BASE_CURRENCY', 'USD'),
'currency_aliases' => [
'RMB' => ['alias_of' => 'CNY', 'label' => 'Chinese Yuan (Renminbi)', 'adjustment' => 1],
'GBX' => ['alias_of' => 'GBP', 'label' => 'British Sterling Pence', 'adjustment' => 100],
'ZAC' => ['alias_of' => 'ZAR', 'label' => 'South Africa Rand Cent', 'adjustment' => 100],
],
];
+1 -2
View File
@@ -60,7 +60,6 @@ return [
*/
'features' => [
! env('SELF_HOSTED', true) ? Features::termsAndPrivacyPolicy() : null,
Features::profilePhotos(),
Features::api(),
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],
],
'sentry' => [
'driver' => 'sentry',
'level' => env('LOG_LEVEL', 'error'),
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
+32 -4
View File
@@ -41,28 +41,35 @@ class TransactionFactory extends Factory
public function yearsAgo(): static
{
return $this->state(fn (array $attributes) => [
'date' => $this->faker->dateTimeBetween('-5 years', '-3 years')->format('Y-m-d'),
'date' => now()->subYears($this->faker->numberBetween(3, 5))->toDateString(),
]);
}
public function lastYear(): static
{
return $this->state(fn (array $attributes) => [
'date' => now()->subYear()->format('Y-m-d'),
'date' => now()->subYear()->toDateString(),
]);
}
public function lastMonth(): static
{
return $this->state(fn (array $attributes) => [
'date' => now()->subMonth()->format('Y-m-d'),
'date' => now()->subMonth()->toDateString(),
]);
}
public function recent(): static
{
return $this->state(fn (array $attributes) => [
'date' => $this->faker->dateTimeBetween('-2 weeks', 'now')->format('Y-m-d'),
'date' => now()->subDays($this->faker->numberBetween(3, 14))->toDateString(),
]);
}
public function date($date): static
{
return $this->state(fn (array $attributes) => [
'date' => $date,
]);
}
@@ -80,6 +87,27 @@ class TransactionFactory extends Factory
]);
}
public function currency($currency): static
{
return $this->state(fn (array $attributes) => [
'currency' => $currency,
]);
}
public function costBasis($cost_basis): static
{
return $this->state(fn (array $attributes) => [
'cost_basis' => $cost_basis,
]);
}
public function salePrice($sale_price): static
{
return $this->state(fn (array $attributes) => [
'sale_price' => $sale_price,
]);
}
public function buy(): static
{
return $this->state(fn (array $attributes) => [
+14
View File
@@ -34,6 +34,10 @@ class UserFactory extends Factory
'two_factor_recovery_codes' => null,
'remember_token' => Str::random(10),
'profile_photo_path' => null,
'options' => [
'display_currency' => 'USD',
'locale' => 'en',
],
];
}
@@ -46,4 +50,14 @@ class UserFactory extends Factory
'email_verified_at' => null,
]);
}
/**
* Indicate that the model's currency.
*/
public function currency($currency): static
{
return $this->state(fn (array $attributes) => array_merge($attributes['options'], [
'currency' => $currency,
]));
}
}
@@ -21,6 +21,7 @@ return new class extends Migration
$table->string('password');
$table->rememberToken();
$table->string('profile_photo_path', 2048)->nullable();
$table->boolean('admin')->nullable();
$table->timestamps();
});
@@ -38,6 +39,5 @@ return new class extends Migration
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};
@@ -17,7 +17,7 @@ return new class extends Migration
{
Schema::create('portfolios', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('title');
$table->string('title')->when(config('database.default') != 'sqlite', fn ($ctx) => $ctx->fulltext());
$table->text('notes')->nullable();
$table->boolean('wishlist')->default(false);
$table->timestamps();
@@ -2,10 +2,8 @@
declare(strict_types=1);
use Database\Seeders\MarketDataSeeder;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schema;
class CreateMarketDataTable extends Migration
@@ -18,8 +16,8 @@ class CreateMarketDataTable extends Migration
public function up()
{
Schema::create('market_data', function (Blueprint $table) {
$table->string('symbol', 15)->primary();
$table->string('name')->nullable();
$table->string('symbol', 25)->primary();
$table->string('name')->nullable()->when(config('database.default') != 'sqlite', fn ($ctx) => $ctx->fulltext());
$table->float('market_value', 12, 4)->nullable();
$table->float('fifty_two_week_low', 12, 4)->nullable();
$table->float('fifty_two_week_high', 12, 4)->nullable();
@@ -34,10 +32,6 @@ class CreateMarketDataTable extends Migration
$table->timestamps();
});
Artisan::call('db:seed', [
'--class' => MarketDataSeeder::class,
'--force' => true,
]);
}
/**
@@ -20,10 +20,6 @@ class CreateDailyChangeTable extends Migration
$table->date('date');
$table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade');
$table->float('total_market_value', 12, 4)->nullable();
$table->float('total_cost_basis', 12, 4)->nullable();
$table->float('total_gain', 12, 4)->nullable();
$table->float('total_dividends_earned', 12, 4)->nullable();
$table->float('realized_gains', 12, 4)->nullable();
$table->text('annotation')->nullable();
$table->primary(['date', 'portfolio_id']);
@@ -2,7 +2,6 @@
declare(strict_types=1);
use App\Models\MarketData;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@@ -19,9 +18,11 @@ class CreateDividendsTable extends Migration
Schema::create('dividends', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->date('date');
$table->foreignIdFor(MarketData::class, 'symbol');
$table->string('symbol', 25);
$table->float('dividend_amount', 12, 4);
$table->timestamps();
$table->unique(['date', 'symbol']);
});
}
@@ -2,7 +2,6 @@
declare(strict_types=1);
use App\Models\MarketData;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@@ -19,9 +18,11 @@ class CreateSplitsTable extends Migration
Schema::create('splits', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->date('date');
$table->foreignIdFor(MarketData::class, 'symbol');
$table->string('symbol', 25);
$table->float('split_amount', 12, 4);
$table->timestamps();
$table->unique(['date', 'symbol']);
});
}
@@ -2,7 +2,6 @@
declare(strict_types=1);
use App\Models\MarketData;
use App\Models\Portfolio;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
@@ -19,7 +18,7 @@ class CreateTransactionsTable extends Migration
{
Schema::create('transactions', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignIdFor(MarketData::class, 'symbol');
$table->string('symbol', 25);
$table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade');
$table->string('transaction_type', 15);
$table->float('quantity', 12, 4);
@@ -2,7 +2,6 @@
declare(strict_types=1);
use App\Models\MarketData;
use App\Models\Portfolio;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
@@ -20,7 +19,7 @@ class CreateHoldingsTable extends Migration
Schema::create('holdings', function (Blueprint $table) {
$table->uuid('id')->primary();
$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('average_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,171 @@
<?php
declare(strict_types=1);
use App\Models\CurrencyRate;
use App\Models\Holding;
use App\Models\Transaction;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\MarketDataSeeder;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
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) {
$locale = config('app.locale', 'en');
$currency = config('investbrain.base_currency', 'USD');
$default = config('database.default') === 'mysql'
? new Expression("(JSON_OBJECT('locale', '{$locale}', 'display_currency', '{$currency}'))")
: json_encode(['locale' => $locale, 'display_currency' => $currency]);
$table->json('options')->default($default)->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,
]);
Artisan::call('db:seed', [
'--class' => MarketDataSeeder::class,
'--force' => true,
]);
CurrencyRate::timeSeriesRates(
Holding::all()->groupBy('market_data.currency')->keys()->toArray(),
Transaction::min('date')
);
CurrencyRate::refreshCurrencyData();
}
/**
* 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
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
+31 -22
View File
@@ -26,7 +26,6 @@ class MarketDataSeeder extends Seeder
if (($handle = fopen($csvFilePath, 'r')) !== false) {
$header = null;
$rows = [];
$rowCount = 0;
while (($row = fgetcsv($handle, 0, ',')) !== false) {
@@ -38,46 +37,56 @@ class MarketDataSeeder extends Seeder
} else {
try {
$data = array_combine($header, $row);
$data = array_combine($header, $row);
$rows[] = [
'symbol' => $data['symbol'],
'name' => $data['name'],
'meta_data' => json_encode([
'country' => $data['country'],
'first_trade_year' => $data['first_trade_year'],
'sector' => $data['sector'],
'industry' => $data['industry'],
]),
];
$meta_data = json_decode(base64_decode($data['meta_data']), true);
$meta_data['source'] = 'market_data_seeder';
$rowCount++;
$rows[] = [
'symbol' => $data['symbol'],
'name' => $data['name'],
'currency' => $data['currency'],
'meta_data' => json_encode($meta_data),
];
if ($rowCount % $chunkSize == 0) {
DB::table('market_data')->insertOrIgnore($rows);
$rows = [];
}
} catch (\Throwable $e) {
$rowCount++;
throw new \Exception('Error: '.$e->getMessage());
if ($rowCount % $chunkSize == 0) {
$this->bulkInsert($rows);
$rows = [];
}
}
}
// final clean up
if (! empty($rows)) {
DB::table('market_data')->insertOrIgnore($rows);
$this->bulkInsert($rows);
$rows = [];
}
// Close the CSV file
fclose($handle);
echo "Imported $rowCount market data items successfully!\n";
echo "\n > Imported $rowCount market data items successfully!";
} else {
echo "Failed to open the CSV.\n";
}
}
private function bulkInsert($rows): void
{
try {
DB::table('market_data')->upsert($rows, ['symbol'], ['name', 'currency', 'meta_data']);
} catch (\Throwable $e) {
throw new \Exception('Error: '.$e->getMessage());
}
gc_collect_cycles();
}
}
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -10,9 +10,7 @@ services:
ports:
- 8000:80
environment: # You can either use these properties OR an .env file. Do not use both!
APP_KEY: "" # Generate a key using `echo base64:$(openssl rand -base64 32)`
APP_URL: "http://localhost:8000"
ASSET_URL: "http://localhost:8000"
DB_CONNECTION: mysql
DB_HOST: investbrain-mysql
DB_PORT: 3306
@@ -25,7 +23,7 @@ services:
REDIS_HOST: investbrain-redis
volumes:
- investbrain-storage:/var/app/storage # You can use a volume...
# - /path/to/storage:/var/app/storage # ...or you can use a path on host
# - /path/to/storage:/var/app/storage:delegated # ...or you can use a path on host
depends_on:
- mysql
- redis
+1 -1
View File
@@ -62,7 +62,7 @@ RUN apk add --no-cache \
bash \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
gd pgsql zip pdo_mysql mysqli intl
gd pgsql zip pdo_mysql pdo_pgsql mysqli intl
# Remove default nginx config
RUN rm -rf /var/www/html \
+4 -2
View File
@@ -14,11 +14,13 @@ mkdir -p storage/framework/cache \
storage/app \
storage/logs
echo -e "\n > Storage directory scaffolding is OK... "
# Ensure storage directory is permissioned for www-data
chmod -R 775 storage
chown -R www-data:www-data storage
echo -e "\n > Storage directory scaffolding is OK... "
echo -e "\n > Permissions are OK... "
# Ensure app key exists / generate if required
KEY_FILE="storage/app/.key"
@@ -36,7 +38,7 @@ if [ -z "$APP_KEY" ] && [ ! -s "$KEY_FILE" ]; then
export APP_KEY="$(php artisan key:generate --show)"
echo -e "\n > Oops! The required APP_KEY configuration is missing! Generated app key and saved in $KEY_FILE ."
echo -e "\n > Oops! The required APP_KEY configuration is missing! Generated app key and saved in $KEY_FILE"
echo "$APP_KEY" > "$KEY_FILE"
+12 -3
View File
@@ -367,8 +367,11 @@
"Import starting...": "Import starting...",
"Import is in progress...": "Import is in progress...",
"Importing portfolios...": "Importing portfolios...",
"Importing transactions...": "Importing transactions...",
"Importing daily changes...": "Importing daily changes...",
"Preparing to import transactions...": "Preparing to import transactions...",
"Importing transactions (Batch :currentBatch of :totalBatches)...": "Importing transactions (Batch :currentBatch of :totalBatches)...",
"Preparing to import daily changes...": "Preparing to import daily changes...",
"Importing daily changes (Batch :currentBatch of :totalBatches)...": "Importing daily changes (Batch :currentBatch of :totalBatches)...",
"Importing configurations...": "Importing configurations...",
"Import completed successfully!": "Import completed successfully!",
"Your import will continue in the background": "Your import will continue in the background",
@@ -376,5 +379,11 @@
"Hi, how can I help?": "Hi, how can I help?",
"Have a question? AI might be able to help...": "Have a question? AI might be able to help...",
"Feel free to ask me a question!": "Feel free to ask me a question!",
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor."
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.",
"Currency": "Currency",
"Locale Options": "Locale Options",
"Adjust localization options for your preferred region.": "Adjust localization options for your preferred region.",
"Locale": "Locale",
"Display Currency": "Display Currency"
}
+12 -3
View File
@@ -367,8 +367,11 @@
"Import starting...": "Iniciando la importación...",
"Import is in progress...": "La importación está en progreso...",
"Importing portfolios...": "Importando portafolios...",
"Importing transactions...": "Importando transacciones...",
"Importing daily changes...": "Importando cambios diarios...",
"Preparing to import transactions...": "Preparándose para importar transacciones...",
"Importing transactions (Batch :currentBatch of :totalBatches)...": "Importando transacciones (Lote :currentBatch de :totalBatches)...",
"Preparing to import daily changes...": "Preparing to import cambios diarios...",
"Importing daily changes (Batch :currentBatch of :totalBatches)...": "Importando cambios diarios (Lote :currentBatch de :totalBatches)...",
"Importing configurations...": "Importando configuraciones...",
"Import completed successfully!": "¡La importación se completó con éxito!",
"Your import will continue in the background": "La importación continuará en segundo plano",
@@ -376,5 +379,11 @@
"Hi, how can I help?": "Hola, ¿cómo puedo ayudarte?",
"Have a question? AI might be able to help...": "¿Tienes una pregunta? La AI podría ayudarte...",
"Feel free to ask me a question!": "¡No dudes en hacerme una pregunta!",
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Los consejos generados por AI pueden contener errores. Úsalos bajo tu propio riesgo. Consulta siempre a un asesor de inversiones con licencia."
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Los consejos generados por AI pueden contener errores. Úsalos bajo tu propio riesgo. Consulta siempre a un asesor de inversiones con licencia.",
"Currency": "Moneda",
"Locale Options": "Opciones de configuración regional",
"Adjust localization options for your preferred region.": "Ajusta las opciones de localización para tu región preferida.",
"Locale": "Configuración regional",
"Display Currency": "Moneda de visualización"
}
+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" />
</div>
@if (Laravel\Jetstream\Jetstream::hasTermsAndPrivacyPolicyFeature())
@if (! config('investbrain.self_hosted'))
<div class="mt-4">
<label>
<div class="flex items-center">
@@ -73,7 +73,7 @@ new class extends Component {
'model' => config('openai.model'),
'messages' => [
['role' => 'system', 'content' => "Today's date is "
.now()->format('Y-m-d')
.now()->toDateString()
.".\n\n".$this->system_prompt],
...array_slice($this->messages, -10)
],
@@ -1,18 +1,18 @@
<span
class=""
style="width:90em;overflow: hidden; white-space: nowrap;"
title="{{ Number::currency($low ?? 0) }} - {{ Number::currency($high ?? 0) }}"
title="{{ Number::currency($marketData->fifty_two_week_low ?? 0, $marketData->currency) }} - {{ Number::currency($marketData->fifty_two_week_high ?? 0, $marketData->currency) }}"
>
@php
// 52-week low must be a non-zero
if (empty($low)) {
$low = 1;
if (empty($marketData->fifty_two_week_low)) {
$marketData->fifty_two_week_low = 1;
}
@endphp
@for ($x = 0; $x < 10; $x++)
@if ((($current - $low) * 100) / ($high - $low) > ($x * 10))
@if ((($marketData->market_value - $marketData->fifty_two_week_low) * 100) / ($marketData->fifty_two_week_high - $marketData->fifty_two_week_low) > ($x * 10))
&#9679;
@@ -94,7 +94,7 @@
}
this.data.yaxis.labels.formatter = function (value) {
return `$${value}`
return `{{ Number::currencySymbol(auth()->user()->getCurrency()) }}${value}`
}
this.data.tooltip = {
@@ -103,7 +103,7 @@
formatter: (value, { series, seriesIndex, dataPointIndex, w }) => {
const firstDataPoint = this.data.series[seriesIndex].data[0][1]
const percentageChange = ((value - firstDataPoint) / firstDataPoint) * 100;
return `$${parseFloat(value.toFixed(2))} (${percentageChange.toFixed(2)}%)`;
return `${parseFloat(value.toFixed(2))} (${percentageChange.toFixed(2)}%)`;
}
},
}
@@ -0,0 +1,64 @@
@props([
'sidebar' => null,
'content' => null,
'footer' => null,
'fullWidth' => false,
'withNav' => false,
'collapseText' => 'Collapse',
'collapseIcon' => 'o-bars-3-bottom-right',
'collapsible' => false,
'url' => route('mary.toogle-sidebar', absolute: false),
])
<main class="{{ !$fullWidth ? 'max-w-screen-2xl' : '' }} w-full mx-auto">
<div class="drawer {{ $sidebar?->attributes['right'] ? 'drawer-end' : '' }} lg:drawer-open">
<input id="{{ $sidebar?->attributes['drawer'] }}" type="checkbox" class="drawer-toggle" />
<div {{ $content->attributes->class(["drawer-content w-full mx-auto p-5 lg:px-10 lg:py-5"]) }}>
{{-- MAIN CONTENT --}}
{{ $content }}
</div>
{{-- SIDEBAR --}}
@if($sidebar)
<div
x-data="{
collapsed: {{ session('mary-sidebar-collapsed', 'false') }},
collapseText: '{{ $collapseText }}',
toggle() {
this.collapsed = !this.collapsed;
fetch('{{ $url }}?collapsed=' + this.collapsed);
this.$dispatch('sidebar-toggled', this.collapsed);
}
}"
@menu-sub-clicked="if(collapsed) { toggle() }"
@class(["drawer-side z-20 lg:z-auto", "top-0 lg:top-[73px] lg:h-[calc(100vh-73px)]" => $withNav])
>
<label for="{{ $sidebar?->attributes['drawer'] }}" aria-label="close sidebar" class="drawer-overlay"></label>
{{-- SIDEBAR CONTENT --}}
<div>
{{ $sidebar }}
{{-- SIDEBAR COLLAPSE --}}
@if($sidebar->attributes['collapsible'])
<x-mary-menu class="hidden !bg-inherit lg:block">
<x-mary-menu-item
@click="toggle"
icon="{{ $sidebar->attributes['collapse-icon'] ?? $collapseIcon }}"
title="{{ $sidebar->attributes['collapse-text'] ?? $collapseText }}" />
</x-mary-menu>
@endif
</div>
</div>
@endif
{{-- END SIDEBAR--}}
</div>
</main>
{{-- FOOTER --}}
@if($footer)
<footer {{ $footer?->attributes->class(["mx-auto w-full", "max-w-screen-2xl" => !$fullWidth ]) }}>
{{ $footer }}
</footer>
@endif
@@ -1,4 +1,23 @@
<?php
use Livewire\Volt\Component;
new class extends Component
{
// props
/**
* The component's listeners.
*
* @var array
*/
protected $listeners = [
'refresh-navigation-menu' => '$refresh',
];
// methods
}; ?>
<div class="bg-base-100 border-base-300 border-b sticky top-0 z-10">
<div class="flex justify-between items-center px-7 py-3 gap-4 mx-auto">
<div class="flex flex-0 items-center">
@@ -1,54 +1,94 @@
<x-menu activate-by-route>
<?php
<x-menu-item title="{{ __('Dashboard') }}" icon="o-home" link="{{ route('dashboard') }}" />
<x-menu-sub title="{{ __('Portfolios') }}" icon="o-document-duplicate">
@foreach (auth()->user()->portfolios as $portfolio)
<x-menu-item icon="o-document" link="{{ route('portfolio.show', ['portfolio' => $portfolio->id ]) }}" >
<x-slot:title>
{{ $portfolio->title }}
@if($portfolio->wishlist)
<x-badge value="{{ __('Wishlist') }}" class="badge-secondary badge-sm ml-2" />
@endif
</x-slot:title>
</x-menu-item>
@endforeach
use Livewire\Volt\Component;
<x-menu-item title="{{ __('Create Portfolio') }}" icon="o-document-plus" link="{{ route('portfolio.create') }}" />
</x-menu-sub>
<x-menu-item title="{{ __('Transactions') }}" icon="o-banknotes" link="{{ route('transaction.index') }}" />
{{-- <x-menu-item title="{{ __('Reporting') }}" icon="o-chart-bar-square" link="####" /> --}}
new class extends Component
{
// props
</x-menu>
/**
* The component's listeners.
*
* @var array
*/
protected $listeners = [
'refresh-navigation-menu' => '$refresh',
];
</div>
<div class="px-3">
// methods
<x-section-border />
}; ?>
@php
$user = auth()->user();
@endphp
<div class="
flex
flex-col
!transition-all
!duration-100
ease-out
overflow-x-hidden
overflow-y-auto
h-screen
lg:h-[calc(100vh-73px)]
bg-base-100
lg:bg-inherit
{{ session('mary-sidebar-collapsed') == 'true' ? 'w-[70px] [&>*_summary::after]:hidden [&_.mary-hideable]:hidden [&_.display-when-collapsed]:block [&_.hidden-when-collapsed]:hidden' : null }}
{{ session('mary-sidebar-collapsed') != 'true' ? 'w-[270px] [&>*_summary::after]:block [&_.mary-hideable]:block [&_.hidden-when-collapsed]:block [&_.display-when-collapsed]:hidden' : null }}
">
<div class="flex-1">
<x-menu activate-by-route>
<x-list-item :item="$user" avatar="profile_photo_url" value="name" sub-value="email" no-separator no-hover class="mb-3 !-mt-3 rounded">
<x-slot:actions>
<x-dropdown>
<x-slot:trigger>
<x-button icon="o-cog-6-tooth" class="btn-circle btn-ghost btn-xs" />
</x-slot:trigger>
<x-menu-item title="{{ __('Manage Profile') }}" icon="o-user" link="{{ @route('profile.show') }}" />
<x-menu-item title="{{ __('API Tokens') }}" icon="o-command-line" link="{{ @route('api-tokens.index') }}" />
<x-menu-item title="{{ __('Import / Export Data') }}" icon="o-cloud-arrow-down" link="{{ @route('import-export') }}" />
<x-menu-item title="{{ __('Dashboard') }}" icon="o-home" link="{{ route('dashboard') }}" />
<x-menu-sub title="{{ __('Portfolios') }}" icon="o-document-duplicate">
@foreach (auth()->user()->portfolios as $portfolio)
<x-menu-item icon="o-document" link="{{ route('portfolio.show', ['portfolio' => $portfolio->id ]) }}" >
<x-slot:title>
{{ $portfolio->title }}
@if($portfolio->wishlist)
<x-badge value="{{ __('Wishlist') }}" class="badge-secondary badge-sm ml-2" />
@endif
</x-slot:title>
</x-menu-item>
@endforeach
<x-section-border class="py-1" />
<x-menu-item title="{{ __('Create Portfolio') }}" icon="o-document-plus" link="{{ route('portfolio.create') }}" />
</x-menu-sub>
<x-menu-item title="{{ __('Transactions') }}" icon="o-banknotes" link="{{ route('transaction.index') }}" />
{{-- <x-menu-item title="{{ __('Reporting') }}" icon="o-chart-bar-square" link="####" /> --}}
<x-menu-item title="{{ __('Log Out') }}" icon="o-power" onclick="event.preventDefault(); document.getElementById('logout').submit();" />
<form id="logout" action="{{ route('logout') }}" method="POST" style="display: none;">
{{ csrf_field() }}
</form>
</x-menu>
</x-dropdown>
</x-slot:actions>
</x-list-item>
</div>
<div class="px-3">
<x-section-border />
@php
$user = auth()->user();
@endphp
<x-list-item :item="$user" avatar="profile_photo_url" value="name" sub-value="email" no-separator no-hover class="mb-3 !-mt-3 rounded">
<x-slot:actions>
<x-dropdown>
<x-slot:trigger>
<x-button icon="o-cog-6-tooth" class="btn-circle btn-ghost btn-xs" />
</x-slot:trigger>
<x-menu-item title="{{ __('Manage Profile') }}" icon="o-user" link="{{ @route('profile.show') }}" />
<x-menu-item title="{{ __('API Tokens') }}" icon="o-command-line" link="{{ @route('api-tokens.index') }}" />
<x-menu-item title="{{ __('Import / Export Data') }}" icon="o-cloud-arrow-down" link="{{ @route('import-export') }}" />
<x-section-border class="py-1" />
<x-menu-item title="{{ __('Log Out') }}" icon="o-power" onclick="event.preventDefault(); document.getElementById('logout').submit();" />
<form id="logout" action="{{ route('logout') }}" method="POST" style="display: none;">
{{ csrf_field() }}
</form>
</x-dropdown>
</x-slot:actions>
</x-list-item>
</div>
</div>
+7 -5
View File
@@ -1,3 +1,5 @@
@use('App\Models\Currency')
<x-app-layout>
@livewire('portfolio-performance-chart', [
@@ -7,27 +9,27 @@
<div class="grid sm:grid-cols-5 gap-5">
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Market Gain/Loss') }}</div>
<div class="font-black text-xl"> {{ Number::currency($metrics->total_gain_dollars) }} </div>
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_gain_dollars', 0)) }} </div>
</x-card>
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Total Cost Basis') }}</div>
<div class="font-black text-xl"> {{ Number::currency($metrics->total_cost_basis) }} </div>
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_cost_basis', 0)) }} </div>
</x-card>
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Total Market Value') }}</div>
<div class="font-black text-xl"> {{ Number::currency($metrics->total_market_value) }} </div>
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_market_value', 0)) }} </div>
</x-card>
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Realized Gain/Loss') }}</div>
<div class="font-black text-xl"> {{ Number::currency($metrics->realized_gain_dollars) }} </div>
<div class="font-black text-xl"> {{ Number::currency($metrics->get('realized_gain_dollars', 0)) }} </div>
</x-card>
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Dividends Earned') }}</div>
<div class="font-black text-xl"> {{ Number::currency($metrics->total_dividends_earned) }} </div>
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_dividends_earned', 0)) }} </div>
</x-card>
</div>
@@ -3,16 +3,16 @@
use App\Models\Holding;
use Livewire\Volt\Component;
new class extends Component {
new class extends Component
{
// props
public Holding $holding;
protected $listeners = [
'transaction-updated' => '$refresh',
'transaction-saved' => '$refresh'
'transaction-saved' => '$refresh',
];
// methods
}; ?>
@@ -27,9 +27,9 @@ new class extends Component {
$owned = ($dividend->purchased - $dividend->sold);
@endphp
{{ Number::currency($dividend->dividend_amount) }}
{{ Number::currency($dividend->dividend_amount, $holding->market_data->currency) }}
x {{ $owned }}
= {{ Number::currency($owned * $dividend->dividend_amount) }}
= {{ Number::currency($owned * $dividend->dividend_amount, $holding->market_data->currency) }}
</x-slot:value>
<x-slot:sub-value>

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