Compare commits
108 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1111ad4a4 | |||
| f49a4b036e | |||
| 1e41296a09 | |||
| 90a1f72abc | |||
| 44d2716406 | |||
| 712a4c6c57 | |||
| 78f0d21b73 | |||
| 19cac58692 | |||
| 7d77b6fbc8 | |||
| e4e08091af | |||
| 292d43b154 | |||
| eae4422ad8 | |||
| 53d463b8b5 | |||
| 827644bb32 | |||
| 21e8672a12 | |||
| 70910c2f6d | |||
| 9ddea4c6e1 | |||
| 576b22e4c9 | |||
| 0035879a87 | |||
| 97298bcd39 | |||
| 0504058c01 | |||
| 750ccbd68f | |||
| d815700e58 | |||
| 9d809bbbe4 | |||
| 74a26e004f | |||
| 65710e2791 | |||
| ac310735df | |||
| 5611de0e2e | |||
| 4196539169 | |||
| 08cfcceb6a | |||
| e427d5802c | |||
| fc5cc1fee2 | |||
| fb3c19d3bf | |||
| 24aeb72549 | |||
| c799da58e1 | |||
| e24f932c0f | |||
| 7e2bf3430e | |||
| e1c8c2c515 | |||
| ae1e59ce30 | |||
| 03089ed1b3 | |||
| 97b13063d9 | |||
| 9260de5f25 | |||
| 505a24bf99 | |||
| 0e88b8c6f5 | |||
| 519486fe57 | |||
| 4086168515 | |||
| a13bd9f0dc | |||
| 2c3950b522 | |||
| 653f54add6 | |||
| 8e0d792d26 | |||
| 81af737204 | |||
| 81845d47f2 | |||
| cf475657cf | |||
| 90a15ceddb | |||
| 981ce0d62f | |||
| 154b679464 | |||
| ee51cb7e2a | |||
| 40120c7027 | |||
| cfd5b8a4f3 | |||
| 3b93e328d5 | |||
| 1fd858287d | |||
| e370f5bbb7 | |||
| 3e492475c0 | |||
| c454e85ad4 | |||
| 487322abb5 | |||
| f78c521dc4 | |||
| ff9bcd782f | |||
| 1ccf515ca2 | |||
| 1b0f9c134c | |||
| 3589242996 | |||
| 689aa4d50b | |||
| 26370c03c4 | |||
| 80b043219a | |||
| de54b6843d | |||
| 17e5d8b665 | |||
| bd9c828c68 | |||
| f72cd6f5a7 | |||
| 3593697cce | |||
| d53e71dcd5 | |||
| 71e79cfb40 | |||
| 38a65f99c9 | |||
| 26e54fb357 | |||
| 224ed104b9 | |||
| 2702fe27e4 | |||
| dd21227f8f | |||
| 1ef8dd9378 | |||
| eae345f243 | |||
| 6d6f968f42 | |||
| 261c848ffd | |||
| 9bcc80078e | |||
| c4b7d399ea | |||
| ffe53e91c0 | |||
| aeb1b12afe | |||
| fe81ec7ee7 | |||
| f0ecc0fd3d | |||
| 03b75fb683 | |||
| dc93621547 | |||
| 7ab6f79e56 | |||
| 9e48f21c8d | |||
| 10e6de8df4 | |||
| 00fbdec6f1 | |||
| 730903c383 | |||
| 5fc9455908 | |||
| 28e0ad68fc | |||
| ca48d702a7 | |||
| 812b9ed075 | |||
| 93a0595652 | |||
| 8a357e8cab |
+7
-21
@@ -24,6 +24,9 @@ OPENAI_ORGANIZATION=
|
||||
MARKET_DATA_PROVIDER=yahoo
|
||||
ALPHAVANTAGE_API_KEY=
|
||||
FINNHUB_API_KEY=
|
||||
ALPACA_API_KEY=
|
||||
ALPACA_API_SECRET=
|
||||
TWELVEDATA_API_SECRET=
|
||||
|
||||
# Cadence to refresh market data (in minutes)
|
||||
MARKET_DATA_REFRESH=30
|
||||
@@ -40,13 +43,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 +55,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 +73,3 @@ AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
@@ -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,7 @@ 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 }}
|
||||
build-args: |
|
||||
VERSION=${{ github.ref_name }}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
This file was added by Shift #157267 in order to open a
|
||||
Pull Request since no other commits were made.
|
||||
|
||||
You should remove this file.
|
||||
@@ -28,7 +28,7 @@ Investbrain is a smart open-source investment tracker that helps you manage, tra
|
||||
|
||||
## Under the hood
|
||||
|
||||
Investbrain is a Laravel PHP web application that leverages Livewire and Tailwind for its frontend. Most databases should work, including MySQL and SQLite. Out of the box, we feature three market data providers: [Yahoo Finance](https://finance.yahoo.com/), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), and [Alpha Vantage](https://www.alphavantage.co/support/). But we also offer an extensible market data provider interface for intrepid developers to create their own! We also offer integrations with OpenAI and Ollama for our ["chat with your holdings"](#chat-with-your-holdings) capability. Finally, of course we have robust support for i18n, a11y, and dark mode.
|
||||
Investbrain is a Laravel PHP web application that has an extensible market data provider interface. Out of the box, we feature many market data providers. But intrepid developers can [create their own providers](#custom-providers)! We also offer integrations with OpenAI and Ollama for our ["chat with your holdings"](#chat-with-your-holdings) capability. Finally, of course we have robust support for i18n, a11y, and dark mode.
|
||||
|
||||
## Self hosting
|
||||
|
||||
@@ -74,7 +74,7 @@ Always keep in mind the limitations of LLMs. When in doubt, consult a licensed i
|
||||
|
||||
## Market data providers
|
||||
|
||||
Investbrain includes an extensible market data provider interface that allows you to retrieve stock market data from multiple providers, such as Yahoo Finance, Alpha Vantage, or Finnhub. The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails.
|
||||
Investbrain includes an extensible market data provider interface that allows you to retrieve stock market data from multiple providers, such as [Yahoo Finance](https://finance.yahoo.com/), [Twelve Data](https://twelvedata.com), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), [Alpaca](https://alpaca.markets/), and [Alpha Vantage](https://www.alphavantage.co/support/). The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails.
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -92,7 +92,7 @@ Your selected providers should be listed in your environment variables. Each sho
|
||||
MARKET_DATA_PROVIDER=yahoo,alphavantage
|
||||
```
|
||||
|
||||
In the above example, Yahoo Finance will be attempted first and the Alpha Vantage provider will be used as the fallback. If Yahoo Finance fails to retrieve market data, the application will automatically try Alpha Vantage.
|
||||
In the above example, Yahoo Finance will be attempted first. If Yahoo Finance fails to retrieve market data, the application will automatically try Alpha Vantage.
|
||||
|
||||
### Custom providers
|
||||
|
||||
@@ -138,9 +138,12 @@ There are several optional configurations available when installing using the re
|
||||
| APP_URL | The URL where your Investbrain installation will be accessible | http://localhost |
|
||||
| APP_PORT | The HTTP port exposed by the NGINX container | 8000 |
|
||||
| APP_KEY | Must be set during install - encryption key for various security-related functions | `null` |
|
||||
| MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `alphavantage`, or `finnhub`) | yahoo |
|
||||
| MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `twelvedata`, `alphavantage`, `alpaca`, or `finnhub`) | yahoo |
|
||||
| ALPHAVANTAGE_API_KEY | If using the Alpha Vantage provider | `null` |
|
||||
| FINNHUB_API_KEY | If using the Finnhub provider | `null` |
|
||||
| ALPACA_API_KEY | If using the Alpaca provider | `null` |
|
||||
| ALPACA_API_SECRET | If using the Alpaca provider | `null` |
|
||||
| TWELVEDATA_API_SECRET | If using the Twelve Data provider | `null` |
|
||||
| MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 |
|
||||
| APP_TIMEZONE | Timezone for the application, including daily change captures | UTC |
|
||||
| AI_CHAT_ENABLED | Whether to enable AI chat features | `false` |
|
||||
@@ -178,7 +181,7 @@ Easy as that!
|
||||
|
||||
## Command line utilities
|
||||
|
||||
Investbrain comes bundled with several helpful command line utilities to make managing your portfolios and holdings more efficient. Keep in mind these commands are extremely powerful and can make irreversable changes to your holdings.
|
||||
Investbrain comes bundled with several helpful command line utilities to make managing your portfolios and holdings more efficient. Keep in mind these commands are extremely powerful and can make irreversable changes to your holdings. Just to be safe, we recommend backing up your portfolios before using these commands.
|
||||
|
||||
To run these commands, you can use `docker exec` like this:
|
||||
|
||||
@@ -186,16 +189,23 @@ To run these commands, you can use `docker exec` like this:
|
||||
docker exec -it investbrain-app php artisan <replace with command you want to run>
|
||||
```
|
||||
|
||||
Just to be safe, we recommend backing up your portfolios before using these commands:
|
||||
If you need more details on what the command does, you can take a look at the options available using the `help` option:
|
||||
|
||||
```bash
|
||||
<command you want to run> --help
|
||||
```
|
||||
|
||||
|
||||
| Command | Description |
|
||||
| ------------- | ------------- |
|
||||
| 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). |
|
||||
| sync:daily-change | Syncs 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 | Syncs performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
|
||||
| fix:cost-basis-for-sales | Utility to automatically re-calculates cost basis for sale transactions. |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
+2
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,35 @@
|
||||
<?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') {
|
||||
|
||||
$cost_basis = Transaction::where([
|
||||
'portfolio_id' => $model->portfolio_id,
|
||||
'symbol' => $model->symbol,
|
||||
'transaction_type' => 'BUY',
|
||||
])->whereDate('date', '<=', $model->date)
|
||||
->selectRaw('SUM(transactions.cost_basis * transactions.quantity) as total_cost_basis')
|
||||
->selectRaw('SUM(transactions.quantity) as total_quantity')
|
||||
->first();
|
||||
|
||||
$average_cost_basis = empty($cost_basis->total_quantity)
|
||||
? 0
|
||||
: $cost_basis->total_cost_basis / $cost_basis->total_quantity;
|
||||
|
||||
$model->cost_basis = $average_cost_basis ?? 0;
|
||||
}
|
||||
|
||||
return $next($model);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
use function Illuminate\Support\defer;
|
||||
|
||||
class EnsureDailyChangeIsSynced
|
||||
{
|
||||
public function __invoke(Model $model, callable $next)
|
||||
{
|
||||
if (config('app.env') != 'testing') {
|
||||
|
||||
$cacheKey = 'daily_change_synced'.$model->portfolio_id;
|
||||
|
||||
if (
|
||||
! Cache::has($cacheKey)
|
||||
&& $model->date->lessThan(now())
|
||||
&& ($model->date->lessThan($model->portfolio->daily_change()->min('date') ?? now())
|
||||
|| $model->date->lessThan($model->portfolio->transactions()->where('id', '!=', $model->id)->max('date') ?? now())
|
||||
)
|
||||
) {
|
||||
defer(fn () => $model->portfolio->syncDailyChanges());
|
||||
|
||||
Cache::put($cacheKey, now(), now()->addMinutes(5));
|
||||
}
|
||||
}
|
||||
|
||||
return $next($model);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ use App\Traits\WithTrimStrings;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
@@ -32,13 +31,22 @@ class CreateNewUser implements CreatesNewUsers
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
|
||||
'password' => $this->passwordRules(),
|
||||
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
|
||||
'terms' => config('investbrain.self_hosted') ? '' : ['accepted', 'required'],
|
||||
])->validate();
|
||||
|
||||
return User::create([
|
||||
$user = User::make([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
]);
|
||||
|
||||
// ensure first user is flagged as an admin
|
||||
if (User::count() === 0) {
|
||||
$user->admin = true;
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Casts;
|
||||
|
||||
use App\Models\Currency;
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BaseCurrency implements CastsAttributes
|
||||
{
|
||||
/**
|
||||
* Cast the given value to user's display currency
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
|
||||
{
|
||||
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the given value for storage in base currency
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
|
||||
{
|
||||
|
||||
// for market data and transactions the `currency` attribute is available...
|
||||
// but for dividends and other types, need to make sure `market_data` is loaded
|
||||
if (is_null($model?->currency)) {
|
||||
|
||||
$model->loadMarketData();
|
||||
}
|
||||
|
||||
return Currency::convert(
|
||||
(float) $value,
|
||||
$model?->currency ?? $model->market_data?->currency,
|
||||
config('investbrain.base_currency'),
|
||||
$model?->date
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Holding;
|
||||
use App\Models\Portfolio;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
@@ -44,23 +45,13 @@ class CaptureDailyChange extends Command
|
||||
|
||||
$this->line('Capturing daily change for '.$portfolio->title);
|
||||
|
||||
$total_cost_basis = $portfolio->holdings->sum('total_cost_basis');
|
||||
|
||||
$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;
|
||||
});
|
||||
$metrics = Holding::query()
|
||||
->portfolio($portfolio->id)
|
||||
->getPortfolioMetrics(config('investbrain.base_currency'));
|
||||
|
||||
$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_market_value' => $metrics->get('total_market_value'),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Portfolio;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class FixCostBasisForSales extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'fix:cost-basis-for-sales
|
||||
{--portfolio= : The ID of the portfolio to fix.}
|
||||
{--user= : The user ID of transactions to fix.}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Fixes broken costs basis for sale transactions';
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
|
||||
if (empty($this->option('user')) && empty($this->option('portfolio'))) {
|
||||
|
||||
$this->error('Must provide at least a user or portfolio.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$transactions = Transaction::where(['transaction_type' => 'SELL']);
|
||||
|
||||
if ($this->option('user')) {
|
||||
|
||||
$portfolios = Portfolio::fullAccess($this->option('user'))->get('id')
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
|
||||
$transactions->whereIn('portfolio_id', $portfolios);
|
||||
|
||||
} else {
|
||||
|
||||
$transactions->where(['portfolio_id' => $this->option('portfolio')]);
|
||||
}
|
||||
|
||||
$transactions = $transactions->get();
|
||||
|
||||
$this->line("Fixing cost basis for {$transactions->count()} sale transactions...");
|
||||
|
||||
$transactions->chunk(10)->each(function ($chunk) {
|
||||
|
||||
dispatch(function () use ($chunk) {
|
||||
|
||||
$chunk->each(function ($transaction) {
|
||||
|
||||
$cost_basis = Transaction::where([
|
||||
'portfolio_id' => $transaction->portfolio_id,
|
||||
'symbol' => $transaction->symbol,
|
||||
'transaction_type' => 'BUY',
|
||||
])->whereDate('date', '<=', $transaction->date)
|
||||
->selectRaw('SUM(transactions.cost_basis * transactions.quantity) as total_cost_basis')
|
||||
->selectRaw('SUM(transactions.quantity) as total_quantity')
|
||||
->first();
|
||||
|
||||
$average_cost_basis = empty($cost_basis->total_quantity)
|
||||
? 0
|
||||
: $cost_basis->total_cost_basis / $cost_basis->total_quantity;
|
||||
|
||||
$transaction->cost_basis = $average_cost_basis ?? 0;
|
||||
|
||||
$transaction->save();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$this->line('Done!');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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().')');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Exports\Sheets\ConfigSheet;
|
||||
use App\Exports\Sheets\DailyChangesSheet;
|
||||
use App\Exports\Sheets\PortfoliosSheet;
|
||||
use App\Exports\Sheets\TransactionsSheet;
|
||||
@@ -24,6 +25,7 @@ class BackupExport implements WithMultipleSheets
|
||||
new PortfoliosSheet($this->empty),
|
||||
new TransactionsSheet($this->empty),
|
||||
new DailyChangesSheet($this->empty),
|
||||
new ConfigSheet($this->empty),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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';
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,8 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
|
||||
'Portfolio ID',
|
||||
'Total Market Value',
|
||||
'Total Cost Basis',
|
||||
'Total Gain',
|
||||
'Total Dividends Earned',
|
||||
'Realized Gains',
|
||||
'Total Dividends Earned',
|
||||
'Annotation',
|
||||
];
|
||||
}
|
||||
@@ -34,7 +33,7 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
|
||||
*/
|
||||
public function collection()
|
||||
{
|
||||
return $this->empty ? collect() : DailyChange::myDailyChanges()->get();
|
||||
return $this->empty ? collect() : DailyChange::myDailyChanges()->withDailyPerformance()->get();
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
|
||||
@@ -25,6 +25,7 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
|
||||
'Quantity',
|
||||
'Cost Basis',
|
||||
'Sale Price',
|
||||
'Currency',
|
||||
'Split',
|
||||
'Reinvested Dividend',
|
||||
'Date',
|
||||
@@ -38,7 +39,30 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
|
||||
*/
|
||||
public function collection()
|
||||
{
|
||||
return $this->empty ? collect() : Transaction::myTransactions()->get();
|
||||
if ($this->empty) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Transaction::myTransactions()
|
||||
->withMarketData()
|
||||
->get()
|
||||
->map(function ($transaction) {
|
||||
return [
|
||||
'id' => $transaction->id,
|
||||
'symbol' => $transaction->symbol,
|
||||
'portfolio_id' => $transaction->portfolio_id,
|
||||
'transaction_type' => $transaction->transaction_type,
|
||||
'quantity' => $transaction->quantity,
|
||||
'cost_basis' => $transaction->cost_basis,
|
||||
'sale_price' => $transaction->sale_price,
|
||||
'currency' => $transaction->market_data_currency,
|
||||
'split' => $transaction->split,
|
||||
'reinvested_dividend' => $transaction->reinvested_dividend,
|
||||
'date' => $transaction->date,
|
||||
'created_at' => $transaction->created_at,
|
||||
'updated_at' => $transaction->updated_at,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
|
||||
@@ -18,6 +18,7 @@ class TransactionController extends ApiController
|
||||
|
||||
$filters->setQuery(Transaction::query());
|
||||
$filters->setScopes(['myTransactions']);
|
||||
$filters->setEagerRelations(['market_data']);
|
||||
$filters->setSearchableColumns(['symbol']);
|
||||
|
||||
return TransactionResource::collection($filters->paginated());
|
||||
|
||||
@@ -17,16 +17,14 @@ class DashboardController extends Controller
|
||||
$user = $request->user()->load(['portfolios', 'holdings', 'transactions']);
|
||||
|
||||
// get portfolio metrics
|
||||
$metrics = cache()->remember(
|
||||
$metrics = cache()->tags(['metrics-'.$user->id])->remember(
|
||||
'dashboard-metrics-'.$user->id,
|
||||
10,
|
||||
function () {
|
||||
return
|
||||
Holding::query()
|
||||
->myHoldings()
|
||||
->withoutWishlists()
|
||||
->withPortfolioMetrics()
|
||||
->first();
|
||||
return Holding::query()
|
||||
->myHoldings()
|
||||
->withoutWishlists()
|
||||
->getPortfolioMetrics();
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ class HoldingController extends Controller
|
||||
$query->where('transactions.symbol', $symbol);
|
||||
},
|
||||
])
|
||||
->symbol($symbol)
|
||||
->portfolio($portfolio->id)
|
||||
->firstOrFail();
|
||||
->symbol($symbol)
|
||||
->portfolio($portfolio->id)
|
||||
->firstOrFail();
|
||||
|
||||
$formattedTransactions = $holding->getFormattedTransactions();
|
||||
|
||||
|
||||
@@ -29,14 +29,13 @@ class PortfolioController extends Controller
|
||||
$portfolio->load(['transactions', 'holdings']);
|
||||
|
||||
// get portfolio metrics
|
||||
$metrics = cache()->remember(
|
||||
$metrics = cache()->tags(['metrics-'.$request->user()->id])->remember(
|
||||
'portfolio-metrics-'.$portfolio->id,
|
||||
60,
|
||||
function () use ($portfolio) {
|
||||
return Holding::query()
|
||||
->portfolio($portfolio->id)
|
||||
->withPortfolioMetrics()
|
||||
->first();
|
||||
->getPortfolioMetrics();
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Number;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LocalizationMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
if (Auth::check()) {
|
||||
|
||||
$locale = auth()->user()->getLocale();
|
||||
|
||||
app()->setLocale(Str::before($locale, '_'));
|
||||
|
||||
Number::useLocale($locale);
|
||||
Number::useCurrency(auth()->user()->getCurrency());
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SetLocale
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
if (! session()->has('locale')) {
|
||||
session()->put('locale', $request->getPreferredLanguage(
|
||||
config('app.available_locales')
|
||||
));
|
||||
}
|
||||
|
||||
app()->setLocale(session('locale'));
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -30,11 +30,11 @@ class TransactionRequest extends FormRequest
|
||||
'portfolio_id' => ['required', 'exists:portfolios,id'],
|
||||
'symbol' => ['required', 'string', new SymbolValidationRule],
|
||||
'transaction_type' => ['required', 'string', 'in:BUY,SELL'],
|
||||
'date' => ['required', 'date_format:Y-m-d', 'before_or_equal:'.now()->format('Y-m-d')],
|
||||
'date' => ['required', 'date_format:Y-m-d', 'before_or_equal:'.now()->toDateString()],
|
||||
'quantity' => [
|
||||
'required',
|
||||
'numeric',
|
||||
'min:0',
|
||||
'gt:0',
|
||||
new QuantityValidationRule(
|
||||
$this->input('portfolio'),
|
||||
$this->requestOrModelValue('symbol', 'transaction'),
|
||||
@@ -42,6 +42,7 @@ class TransactionRequest extends FormRequest
|
||||
$this->requestOrModelValue('date', 'transaction')
|
||||
),
|
||||
],
|
||||
'currency' => ['required', 'exists:currencies,currency'],
|
||||
'cost_basis' => ['exclude_if:transaction_type,SELL', 'min:0', 'numeric'],
|
||||
'sale_price' => ['exclude_if:transaction_type,BUY', 'min:0', 'numeric'],
|
||||
];
|
||||
@@ -50,6 +51,7 @@ class TransactionRequest extends FormRequest
|
||||
$rules['portfolio_id'][0] = 'sometimes';
|
||||
$rules['symbol'][0] = 'sometimes';
|
||||
$rules['transaction_type'][0] = 'sometimes';
|
||||
$rules['currency'][0] = 'sometimes';
|
||||
$rules['date'][0] = 'sometimes';
|
||||
$rules['quantity'][0] = 'sometimes';
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ class HoldingResource extends JsonResource
|
||||
'portfolio_id' => $this->portfolio_id,
|
||||
'symbol' => $this->symbol,
|
||||
'quantity' => $this->quantity,
|
||||
'currency' => $this->market_data->currency,
|
||||
'reinvest_dividends' => $this->reinvest_dividends,
|
||||
'average_cost_basis' => $this->average_cost_basis,
|
||||
'total_cost_basis' => $this->total_cost_basis,
|
||||
|
||||
@@ -22,6 +22,7 @@ class TransactionResource extends JsonResource
|
||||
'portfolio_id' => $this->portfolio_id,
|
||||
'transaction_type' => $this->transaction_type,
|
||||
'quantity' => $this->quantity,
|
||||
'currency' => $this->market_data->currency,
|
||||
'cost_basis' => $this->cost_basis,
|
||||
'sale_price' => $this->sale_price,
|
||||
'split' => $this->split,
|
||||
|
||||
@@ -22,6 +22,10 @@ class UserResource extends JsonResource
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'profile_photo_url' => $this->profile_photo_url,
|
||||
'options' => [
|
||||
'display_currency' => $this->getCurrency(),
|
||||
'locale' => $this->getLocale(),
|
||||
],
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Console\Commands\RefreshDividendData;
|
||||
use App\Console\Commands\RefreshMarketData;
|
||||
use App\Console\Commands\SyncDailyChange;
|
||||
use App\Console\Commands\SyncHoldingData;
|
||||
use App\Imports\Sheets\ConfigSheet;
|
||||
use App\Imports\Sheets\DailyChangesSheet;
|
||||
use App\Imports\Sheets\PortfoliosSheet;
|
||||
use App\Imports\Sheets\TransactionsSheet;
|
||||
@@ -69,6 +70,7 @@ class BackupImport implements WithEvents, WithMultipleSheets
|
||||
'Portfolios' => new PortfoliosSheet($this->backupImportModel),
|
||||
'Transactions' => new TransactionsSheet($this->backupImportModel),
|
||||
'Daily Changes' => new DailyChangesSheet($this->backupImportModel),
|
||||
'Config' => new ConfigSheet($this->backupImportModel),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
||||
BeforeSheet::class => function (BeforeSheet $event) {
|
||||
DB::commit();
|
||||
$this->backupImport->update([
|
||||
'message' => __('Importing daily changes...'),
|
||||
'message' => __('Preparing to import daily changes...'),
|
||||
]);
|
||||
DB::beginTransaction();
|
||||
},
|
||||
@@ -40,22 +40,23 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
||||
|
||||
public function collection(Collection $dailyChanges)
|
||||
{
|
||||
$dailyChanges->chunk($this->batchSize())->each(function ($chunk) {
|
||||
$totalBatches = count($dailyChanges) / $this->batchSize();
|
||||
|
||||
$dailyChanges->chunk($this->batchSize())->each(function ($chunk, $index) use ($totalBatches) {
|
||||
|
||||
$this->validatePortfolioAccess($chunk);
|
||||
|
||||
$this->backupImport->update([
|
||||
'message' => __('Importing daily changes (Batch :currentBatch of :totalBatches)...', ['currentBatch' => $index + 1, 'totalBatches' => $totalBatches]),
|
||||
]);
|
||||
|
||||
// have to cast to native values
|
||||
$chunk = $chunk->map(function ($dailyChange) {
|
||||
|
||||
return [
|
||||
'total_market_value' => $dailyChange['total_market_value'],
|
||||
'total_cost_basis' => $dailyChange['total_cost_basis'],
|
||||
'total_gain' => $dailyChange['total_gain'],
|
||||
'total_dividends_earned' => $dailyChange['total_dividends_earned'],
|
||||
'realized_gains' => $dailyChange['realized_gains'],
|
||||
'annotation' => $dailyChange['annotation'],
|
||||
'portfolio_id' => $dailyChange['portfolio_id'],
|
||||
'date' => Carbon::parse($dailyChange['date'])->format('Y-m-d'),
|
||||
'date' => Carbon::parse($dailyChange['date'])->toDateString(),
|
||||
];
|
||||
});
|
||||
|
||||
@@ -63,11 +64,6 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
||||
$chunk->toArray(),
|
||||
['portfolio_id', 'date'],
|
||||
[
|
||||
'total_market_value',
|
||||
'total_cost_basis',
|
||||
'total_gain',
|
||||
'total_dividends_earned',
|
||||
'realized_gains',
|
||||
'annotation',
|
||||
'portfolio_id',
|
||||
'date',
|
||||
@@ -86,11 +82,6 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
||||
return [
|
||||
'portfolio_id' => ['required', 'uuid'],
|
||||
'date' => ['required', 'date'],
|
||||
'total_market_value' => ['sometimes', 'nullable', 'numeric'],
|
||||
'total_cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
||||
'total_gain' => ['sometimes', 'nullable', 'numeric'],
|
||||
'total_dividends_earned' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
||||
'realized_gains' => ['sometimes', 'nullable', 'numeric'],
|
||||
'annotation' => ['sometimes', 'nullable', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace App\Imports\Sheets;
|
||||
|
||||
use App\Imports\ValidatesPortfolioAccess;
|
||||
use App\Models\BackupImport;
|
||||
use App\Models\Currency;
|
||||
use App\Models\CurrencyRate;
|
||||
use App\Models\Holding;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -33,7 +35,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
||||
BeforeSheet::class => function (BeforeSheet $event) {
|
||||
DB::commit();
|
||||
$this->backupImport->update([
|
||||
'message' => __('Importing transactions...'),
|
||||
'message' => __('Preparing to import transactions...'),
|
||||
]);
|
||||
DB::beginTransaction();
|
||||
},
|
||||
@@ -43,13 +45,37 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
||||
public function collection(Collection $transactions)
|
||||
{
|
||||
|
||||
$transactions->chunk($this->batchSize())->each(function ($chunk) {
|
||||
// if has any transactions not in base currency, need to sync timeseries conversion rates
|
||||
if ($transactions->where('currency', '!=', config('investbrain.base_currency'))->isNotEmpty()) {
|
||||
|
||||
CurrencyRate::timeSeriesRates('', $transactions->min('date'), $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'],
|
||||
|
||||
@@ -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.'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Interfaces\MarketData;
|
||||
|
||||
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\CarbonInterval;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class AlpacaMarketData implements MarketDataInterface
|
||||
{
|
||||
public PendingRequest $client;
|
||||
|
||||
public string $dataBaseUrl = 'https://data.alpaca.markets/';
|
||||
|
||||
public string $apiBaseUrl = 'https://api.alpaca.markets/';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createNewClient();
|
||||
}
|
||||
|
||||
private function createNewClient()
|
||||
{
|
||||
$this->client = Http::withOptions([
|
||||
'headers' => [
|
||||
'content-type' => 'application/json',
|
||||
'accept' => 'application/json',
|
||||
'Apca-Api-Key-Id' => config('alpaca.key'),
|
||||
'Apca-Api-Secret-Key' => config('alpaca.secret'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function exists(string $symbol): bool
|
||||
{
|
||||
return (bool) $this->quote($symbol);
|
||||
}
|
||||
|
||||
public function quote(string $symbol): Quote
|
||||
{
|
||||
$response = $this->client->baseUrl($this->dataBaseUrl)->get("v2/stocks/{$symbol}/trades/latest");
|
||||
|
||||
$quote = $response->json('trade');
|
||||
|
||||
throw_if(empty(Arr::get($quote, 'p')), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
|
||||
|
||||
$fundamental = cache()->remember(
|
||||
'ap-symbol-'.$symbol,
|
||||
1440,
|
||||
function () use ($symbol) {
|
||||
|
||||
$this->createNewClient();
|
||||
|
||||
$basic = $this->client->baseUrl($this->apiBaseUrl)->get("v2/assets/{$symbol}")->json();
|
||||
$fifty_two_week = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
|
||||
'timeframe' => '12M',
|
||||
'start' => now()->subWeeks(53)->format('Y-m-d'),
|
||||
'end' => now()->subWeeks(1)->format('Y-m-d'), // todo: can't query recent SIP data
|
||||
])->get("v2/stocks/{$symbol}/bars")->json();
|
||||
|
||||
return array_merge($fifty_two_week, $basic);
|
||||
}
|
||||
);
|
||||
|
||||
return new Quote([
|
||||
'name' => Arr::get($fundamental, 'name'),
|
||||
'symbol' => $symbol,
|
||||
'currency' => 'USD', // Alpaca only has US equitities
|
||||
'market_value' => Arr::get($quote, 'p'),
|
||||
'fifty_two_week_high' => Arr::get($fundamental, 'bars.0.h'),
|
||||
'fifty_two_week_low' => Arr::get($fundamental, 'bars.0.l'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function dividends(string $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
|
||||
'symbols' => $symbol,
|
||||
'limit' => 1000,
|
||||
'sort' => 'asc',
|
||||
'types' => 'cash_dividend',
|
||||
'start' => $startDate->format('Y-m-d'),
|
||||
'end' => $endDate->format('Y-m-d'),
|
||||
])->get('v1/corporate-actions');
|
||||
|
||||
$dividends = $response->json('corporate_actions.cash_dividends');
|
||||
|
||||
return collect($dividends)
|
||||
->map(function ($dividend) use ($symbol) {
|
||||
|
||||
return new Dividend([
|
||||
'symbol' => $symbol,
|
||||
'date' => Carbon::parse(Arr::get($dividend, 'ex_date')),
|
||||
'dividend_amount' => Arr::get($dividend, 'rate'),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function splits(string $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
|
||||
'symbols' => $symbol,
|
||||
'limit' => 1000,
|
||||
'sort' => 'asc',
|
||||
'types' => 'forward_split',
|
||||
'start' => $startDate->format('Y-m-d'),
|
||||
'end' => $endDate->format('Y-m-d'),
|
||||
])->get('v1/corporate-actions');
|
||||
|
||||
$splits = $response->json('corporate_actions.forward_splits');
|
||||
|
||||
return collect($splits)
|
||||
->map(function ($split) use ($symbol) {
|
||||
|
||||
return new Split([
|
||||
'symbol' => $symbol,
|
||||
'date' => Carbon::parse(Arr::get($split, 'ex_date')),
|
||||
'split_amount' => Arr::get($split, 'new_rate') / Arr::get($split, 'old_rate'),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function history(string $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
$startDate = Carbon::parse($startDate);
|
||||
$endDate = Carbon::parse($endDate)->subHours(36); // alpaca has sip data limits
|
||||
|
||||
$allHistory = collect();
|
||||
|
||||
$chunks = 1000;
|
||||
|
||||
$period = CarbonInterval::days($chunks)->toPeriod($startDate, $endDate);
|
||||
foreach ($period as $startDate) {
|
||||
|
||||
$chunkEnd = $startDate->copy()->addDays($chunks - 1);
|
||||
|
||||
if ($chunkEnd->gt($endDate)) {
|
||||
$chunkEnd = $endDate;
|
||||
}
|
||||
|
||||
$this->createNewClient();
|
||||
|
||||
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
|
||||
'timeframe' => '1D',
|
||||
'start' => $startDate->format('Y-m-d'),
|
||||
'end' => $chunkEnd->format('Y-m-d'),
|
||||
])->get("v2/stocks/{$symbol}/bars");
|
||||
|
||||
$history = $response->json('bars');
|
||||
|
||||
throw_if(empty($history), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
|
||||
|
||||
$chunkedHistory = collect($history)
|
||||
->mapWithKeys(function ($history) use ($symbol) {
|
||||
|
||||
$date = Carbon::parse($history['t'])->format('Y-m-d');
|
||||
|
||||
return [$date => new Ohlc([
|
||||
'symbol' => $symbol,
|
||||
'date' => $date,
|
||||
'close' => Arr::get($history, 'c'),
|
||||
])];
|
||||
});
|
||||
|
||||
$allHistory = $allHistory->merge($chunkedHistory);
|
||||
}
|
||||
|
||||
return $allHistory;
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
? ((float) 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,12 +140,12 @@ 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,
|
||||
'date' => $date,
|
||||
'close' => Arr::get($history, '4. close'),
|
||||
'close' => (float) Arr::get($history, '4. close'),
|
||||
])];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Interfaces\MarketData\Types\Dividend;
|
||||
use App\Interfaces\MarketData\Types\Ohlc;
|
||||
use App\Interfaces\MarketData\Types\Quote;
|
||||
use App\Interfaces\MarketData\Types\Split;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
@@ -25,6 +26,7 @@ class FakeMarketData implements MarketDataInterface
|
||||
return new Quote([
|
||||
'name' => 'ACME Company Ltd',
|
||||
'symbol' => $symbol,
|
||||
'currency' => 'USD',
|
||||
'market_value' => 230.19,
|
||||
'fifty_two_week_high' => 512.90,
|
||||
'fifty_two_week_low' => 341.20,
|
||||
@@ -34,6 +36,7 @@ class FakeMarketData implements MarketDataInterface
|
||||
'book_value' => 4.7,
|
||||
'last_dividend_date' => now()->subDays(45),
|
||||
'dividend_yield' => 0.033,
|
||||
'meta_data' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -65,7 +68,7 @@ class FakeMarketData implements MarketDataInterface
|
||||
return collect([
|
||||
new Split([
|
||||
'symbol' => $symbol,
|
||||
'date' => now()->subMonths(36),
|
||||
'date' => now()->subMonths(12),
|
||||
'split_amount' => 10,
|
||||
]),
|
||||
]);
|
||||
@@ -73,16 +76,27 @@ class FakeMarketData implements MarketDataInterface
|
||||
|
||||
public function history(string $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
$numDays = Carbon::parse($startDate)->diffInDays($endDate, true);
|
||||
$endDate = now()->isBefore(Carbon::parse(config('investbrain.daily_change_time_of_day')))
|
||||
? now()->subDay()
|
||||
: now();
|
||||
|
||||
for ($i = 0; $i < $numDays; $i++) {
|
||||
$days = CarbonPeriod::create($startDate, $endDate)->filter('isWeekday');
|
||||
|
||||
$date = now()->subDays($i)->format('Y-m-d');
|
||||
$countOfDays = $days->count();
|
||||
|
||||
foreach ($days as $index => $date) {
|
||||
|
||||
$date = $date->toDateString();
|
||||
|
||||
$series[$date] = new Ohlc([
|
||||
'symbol' => $symbol,
|
||||
'date' => $date,
|
||||
'close' => rand(150, 400),
|
||||
'open' => rand(150, 400),
|
||||
'high' => rand(150, 400),
|
||||
'low' => rand(150, 400),
|
||||
'close' => $index == $countOfDays - 1
|
||||
? 230.19 // most recent close should match current market value
|
||||
: rand(150, 400),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,10 @@ class FallbackInterface
|
||||
foreach ($providers as $provider) {
|
||||
|
||||
$provider = trim($provider);
|
||||
$symbol = $arguments[0];
|
||||
|
||||
try {
|
||||
Log::warning("Calling method {$method} ({$provider})");
|
||||
Log::info("Calling method {$method} for {$symbol} ({$provider})");
|
||||
|
||||
if (! in_array($provider, array_keys(config('investbrain.interfaces', [])))) {
|
||||
|
||||
@@ -35,17 +36,17 @@ class FallbackInterface
|
||||
|
||||
$this->latest_error = $e->getMessage();
|
||||
|
||||
Log::warning("Failed calling method {$method} ({$provider}): {$this->latest_error}");
|
||||
Log::error("Failed calling method {$method} for {$symbol} ({$provider}): {$this->latest_error}");
|
||||
}
|
||||
}
|
||||
|
||||
// don't need to throw error if calling exists
|
||||
// don't need to throw error if calling exists method...
|
||||
if ($method == 'exists') {
|
||||
|
||||
// symbol prob just doesn't exist
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new \Exception("Could not get market data: {$this->latest_error}");
|
||||
throw new \Exception("Could not get market data calling method {$method}: {$this->latest_error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Interfaces\MarketData\Types\Dividend;
|
||||
use App\Interfaces\MarketData\Types\Ohlc;
|
||||
use App\Interfaces\MarketData\Types\Quote;
|
||||
use App\Interfaces\MarketData\Types\Split;
|
||||
use Finnhub\ObjectSerializer;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -35,32 +36,46 @@ class FinnhubMarketData implements MarketDataInterface
|
||||
{
|
||||
$quote = $this->client->quote($symbol);
|
||||
|
||||
if (is_null(Arr::get($quote, 'd'))) {
|
||||
throw new \Exception('Could not find ticker on Finnhub');
|
||||
}
|
||||
|
||||
$fundamental = cache()->remember(
|
||||
'fh-symbol-'.$symbol,
|
||||
1440,
|
||||
function () use ($symbol) {
|
||||
return $this->client->companyBasicFinancials($symbol, 'all');
|
||||
|
||||
return array_merge(
|
||||
(array) ObjectSerializer::sanitizeForSerialization($this->client->companyProfile2($symbol)),
|
||||
(array) ObjectSerializer::sanitizeForSerialization($this->client->companyBasicFinancials($symbol, 'all')),
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return new Quote([
|
||||
'name' => Arr::get($fundamental, 'metric.name'),
|
||||
'name' => Arr::get($fundamental, 'name'),
|
||||
'symbol' => $symbol,
|
||||
'currency' => Arr::get($fundamental, 'currency'),
|
||||
'market_value' => Arr::get($quote, 'c'),
|
||||
'fifty_two_week_high' => Arr::get($fundamental, 'metric.52WeekHigh'),
|
||||
'fifty_two_week_low' => Arr::get($fundamental, 'metric.52WeekLow'),
|
||||
'forward_pe' => Arr::get($fundamental, 'metric.forwardPE'), // confirm
|
||||
'trailing_pe' => Arr::get($fundamental, 'metric.trailingPE'), // confirm
|
||||
'market_cap' => Arr::get($fundamental, 'metric.marketCapitalization'), // confirm
|
||||
'book_value' => Arr::get($fundamental, 'metric.bookValuePerShare'), // confirm
|
||||
'last_dividend_date' => Arr::get($fundamental, 'metric.lastDivDate'), // confirm
|
||||
'dividend_yield' => Arr::get($fundamental, 'metric.dividendYield'), // confirm
|
||||
'forward_pe' => Arr::get($fundamental, 'metric.peAnnual'),
|
||||
'trailing_pe' => Arr::get($fundamental, 'metric.peTTM'),
|
||||
'market_cap' => Arr::get($fundamental, 'metric.marketCapitalization', 0) * 1000000,
|
||||
'book_value' => Arr::get($fundamental, 'metric.bookValuePerShareAnnual'),
|
||||
'dividend_yield' => Arr::get($fundamental, 'metric.dividendYieldIndicatedAnnual'),
|
||||
'meta_data' => [
|
||||
'country' => Arr::get($fundamental, 'country'),
|
||||
'exchange' => Arr::get($fundamental, 'exchange'),
|
||||
'first_trade_year' => Arr::get($fundamental, 'ipo') ? Carbon::parse(Arr::get($fundamental, 'ipo'))->format('Y') : null,
|
||||
'source' => 'finnhub',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function dividends($symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
$dividends = $this->client->stockDividends($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d'));
|
||||
$dividends = $this->client->stockDividends($symbol, $startDate->toDateString(), $endDate->toDateString());
|
||||
|
||||
return collect($dividends)->map(function ($dividend) use ($symbol) {
|
||||
|
||||
@@ -75,7 +90,7 @@ class FinnhubMarketData implements MarketDataInterface
|
||||
public function splits($symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
|
||||
$splits = $this->client->stockSplits($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d'));
|
||||
$splits = $this->client->stockSplits($symbol, $startDate->toDateString(), $endDate->toDateString());
|
||||
|
||||
return collect($splits)->map(function ($split) use ($symbol) {
|
||||
|
||||
@@ -96,7 +111,7 @@ class FinnhubMarketData implements MarketDataInterface
|
||||
$closes = Arr::get($history, 'c', []);
|
||||
|
||||
return collect($timestamps)->mapWithKeys(function ($timestamp, $index) use ($symbol, $closes) {
|
||||
$date = Carbon::createFromTimestamp($timestamp)->format('Y-m-d');
|
||||
$date = Carbon::createFromTimestamp($timestamp)->toDateString();
|
||||
|
||||
return [$date => new Ohlc([
|
||||
'symbol' => $symbol,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Interfaces\MarketData;
|
||||
|
||||
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\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class TwelveDataMarketData implements MarketDataInterface
|
||||
{
|
||||
public PendingRequest $client;
|
||||
|
||||
public string $apiBaseUrl = 'https://api.twelvedata.com/';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createNewClient();
|
||||
}
|
||||
|
||||
private function createNewClient()
|
||||
{
|
||||
$this->client = Http::withOptions([
|
||||
'headers' => [
|
||||
'content-type' => 'application/json',
|
||||
'accept' => 'application/json',
|
||||
],
|
||||
])->withQueryParameters([
|
||||
'apikey' => config('twelvedata.secret'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function exists(string $symbol): bool
|
||||
{
|
||||
|
||||
return (bool) $this->quote($symbol);
|
||||
}
|
||||
|
||||
public function quote(string $symbol): Quote
|
||||
{
|
||||
|
||||
$response = $this->client
|
||||
->baseUrl($this->apiBaseUrl)
|
||||
->withQueryParameters(['symbol' => $symbol])
|
||||
->get('price');
|
||||
|
||||
$quote = $response->json();
|
||||
|
||||
throw_if(empty(Arr::get($quote, 'price')), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
|
||||
|
||||
$current_market_value = Arr::get($quote, 'price');
|
||||
|
||||
$fundamental = cache()->remember(
|
||||
'twelve-data-symbol-'.$symbol,
|
||||
1440,
|
||||
function () use ($symbol) {
|
||||
|
||||
$this->createNewClient();
|
||||
|
||||
$response = $this->client
|
||||
->baseUrl($this->apiBaseUrl)
|
||||
->withQueryParameters(['symbol' => $symbol])
|
||||
->get('quote');
|
||||
|
||||
return $response->json();
|
||||
}
|
||||
);
|
||||
|
||||
return new Quote([
|
||||
'name' => Arr::get($fundamental, 'name'),
|
||||
'symbol' => $symbol,
|
||||
'currency' => Arr::get($fundamental, 'currency'),
|
||||
'market_value' => (float) $current_market_value,
|
||||
'fifty_two_week_high' => (float) Arr::get($fundamental, 'fifty_two_week.high'),
|
||||
'fifty_two_week_low' => (float) Arr::get($fundamental, 'fifty_two_week.low'),
|
||||
'meta_data' => [
|
||||
'exchange' => Arr::get($fundamental, 'exchange'),
|
||||
'source' => 'twelvedata',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function dividends(string $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
|
||||
$response = $this->client
|
||||
->baseUrl($this->apiBaseUrl)
|
||||
->withQueryParameters([
|
||||
'symbol' => $symbol,
|
||||
'start_date' => Carbon::parse($startDate)->toDateString(),
|
||||
'end_date' => Carbon::parse($endDate)->toDateString(),
|
||||
])
|
||||
->get('dividends');
|
||||
|
||||
$dividends = $response->json('dividends');
|
||||
|
||||
return collect($dividends)
|
||||
->map(function ($dividend) use ($symbol) {
|
||||
|
||||
return new Dividend([
|
||||
'symbol' => $symbol,
|
||||
'date' => Arr::get($dividend, 'ex_date'),
|
||||
'dividend_amount' => Arr::get($dividend, 'amount'),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function splits(string $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
|
||||
$response = $this->client
|
||||
->baseUrl($this->apiBaseUrl)
|
||||
->withQueryParameters([
|
||||
'symbol' => $symbol,
|
||||
'start_date' => Carbon::parse($startDate)->toDateString(),
|
||||
'end_date' => Carbon::parse($endDate)->toDateString(),
|
||||
])
|
||||
->get('splits');
|
||||
|
||||
$splits = $response->json('splits');
|
||||
|
||||
return collect($splits)
|
||||
->map(function ($split) use ($symbol) {
|
||||
|
||||
return new Split([
|
||||
'symbol' => $symbol,
|
||||
'date' => Arr::get($split, 'date'),
|
||||
'split_amount' => Arr::get($split, 'from_factor') / Arr::get($split, 'to_factor'),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function history(string $symbol, $startDate, $endDate): Collection
|
||||
{
|
||||
|
||||
$response = $this->client
|
||||
->baseUrl($this->apiBaseUrl)
|
||||
->withQueryParameters([
|
||||
'symbol' => $symbol,
|
||||
'interval' => '1day',
|
||||
'start_date' => Carbon::parse($startDate)->toDateString(),
|
||||
'end_date' => Carbon::parse($endDate)->toDateString(),
|
||||
])
|
||||
->get('time_series');
|
||||
|
||||
$history = $response->json('values');
|
||||
|
||||
throw_if(empty($history), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
|
||||
|
||||
return collect($history)
|
||||
->mapWithKeys(function ($history) use ($symbol) {
|
||||
|
||||
$date = Carbon::parse(Arr::get($history, 'datetime'))->toDateString();
|
||||
|
||||
return [$date => new Ohlc([
|
||||
'symbol' => $symbol,
|
||||
'date' => $date,
|
||||
'close' => (float) Arr::get($history, 'close'),
|
||||
])];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class Dividend extends MarketDataType
|
||||
return $this->items['symbol'] ?? '';
|
||||
}
|
||||
|
||||
public function setDividendAmount($dividendAmount): self
|
||||
public function setDividendAmount(int|float $dividendAmount): self
|
||||
{
|
||||
$this->items['dividend_amount'] = (float) $dividendAmount;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Interfaces\MarketData\Types;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -12,24 +13,79 @@ class MarketDataType extends Collection
|
||||
public function __construct($items = [])
|
||||
{
|
||||
|
||||
foreach ($this->getArrayableItems($items) as $key => $value) {
|
||||
$items = $this->getArrayableItems($items);
|
||||
|
||||
$this->{$key} = $value;
|
||||
foreach ($items as $key => $value) {
|
||||
|
||||
$this->validateRequiredTypes($key, $value);
|
||||
|
||||
if (! is_null($value)) {
|
||||
$this->{$key} = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function toArray()
|
||||
{
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
public function __set($key, $value)
|
||||
{
|
||||
$this->{'set'.Str::studly($key)}($value);
|
||||
|
||||
$this->{$this->getSetMethodName($key)}($value);
|
||||
}
|
||||
|
||||
public function __get($key)
|
||||
{
|
||||
return $this->items[$key] ?? null;
|
||||
}
|
||||
|
||||
protected function getSetMethodName($key): string
|
||||
{
|
||||
return 'set'.Str::studly($key);
|
||||
}
|
||||
|
||||
protected function validateRequiredTypes($key, $value, $type = null): void
|
||||
{
|
||||
$method = new \ReflectionMethod($this, $this->getSetMethodName($key));
|
||||
$params = $method->getParameters();
|
||||
|
||||
// no required type
|
||||
if (is_null($type) && is_null($type = $params[0]->getType())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// can`t validate a mixed type
|
||||
if ($type == 'mixed') {
|
||||
return;
|
||||
}
|
||||
|
||||
// has a union type, let's iterate
|
||||
if ($type instanceof \ReflectionUnionType) {
|
||||
|
||||
foreach ($type->getTypes() as $subType) {
|
||||
$expected[] = $subType;
|
||||
|
||||
try {
|
||||
$this->validateRequiredTypes($key, $value, $subType);
|
||||
|
||||
return;
|
||||
} catch (\InvalidArgumentException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check type
|
||||
if ($type instanceof \ReflectionNamedType) {
|
||||
$expected = $type->getName();
|
||||
|
||||
if (get_debug_type($value) == $expected || ($type->allowsNull() && $value === null)) {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (class_exists($expected) && is_subclass_of(get_debug_type($value), $expected)) {
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException("Invalid type for {$key}. Expected ".implode('|', array_map(fn ($t) => $t, Arr::wrap($expected))).' but got '.get_debug_type($value));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class Ohlc extends MarketDataType
|
||||
return $this->items['symbol'] ?? '';
|
||||
}
|
||||
|
||||
public function setOpen($open): self
|
||||
public function setOpen(int|float $open): self
|
||||
{
|
||||
$this->items['open'] = (float) $open;
|
||||
|
||||
@@ -33,7 +33,7 @@ class Ohlc extends MarketDataType
|
||||
return $this->items['open'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setHigh($high): self
|
||||
public function setHigh(int|float $high): self
|
||||
{
|
||||
$this->items['high'] = (float) $high;
|
||||
|
||||
@@ -45,7 +45,7 @@ class Ohlc extends MarketDataType
|
||||
return $this->items['high'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setLow($low): self
|
||||
public function setLow(int|float $low): self
|
||||
{
|
||||
$this->items['low'] = (float) $low;
|
||||
|
||||
@@ -57,7 +57,7 @@ class Ohlc extends MarketDataType
|
||||
return $this->items['low'] ?? 0.0;
|
||||
}
|
||||
|
||||
public function setClose($close): self
|
||||
public function setClose(int|float $close): self
|
||||
{
|
||||
$this->items['close'] = (float) $close;
|
||||
|
||||
|
||||
@@ -5,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'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class Split extends MarketDataType
|
||||
return $this->items['symbol'] ?? '';
|
||||
}
|
||||
|
||||
public function setSplitAmount($splitAmount): self
|
||||
public function setSplitAmount(int|float $splitAmount): self
|
||||
{
|
||||
$this->items['split_amount'] = (float) $splitAmount;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Interfaces\MarketData\Types\Dividend;
|
||||
use App\Interfaces\MarketData\Types\Ohlc;
|
||||
use App\Interfaces\MarketData\Types\Quote;
|
||||
use App\Interfaces\MarketData\Types\Split;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Scheb\YahooFinanceApi\ApiClient;
|
||||
use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance;
|
||||
@@ -20,7 +21,10 @@ class YahooMarketData implements MarketDataInterface
|
||||
{
|
||||
|
||||
// create yahoo finance client factory
|
||||
$this->client = YahooFinance::createApiClient();
|
||||
$this->client = YahooFinance::createApiClient(
|
||||
clientOptions: ['headers' => ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36']],
|
||||
cache: app('cache.psr6')
|
||||
);
|
||||
}
|
||||
|
||||
public function exists(string $symbol): bool
|
||||
@@ -34,9 +38,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 +55,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 +98,7 @@ class YahooMarketData implements MarketDataInterface
|
||||
return collect($this->client->getHistoricalQuoteData($symbol, ApiClient::INTERVAL_1_DAY, $startDate, $endDate))
|
||||
->mapWithKeys(function ($history) use ($symbol) {
|
||||
|
||||
$date = $history->getDate()->format('Y-m-d');
|
||||
$date = Carbon::parse($history->getDate())->toDateString();
|
||||
|
||||
return [$date => new Ohlc([
|
||||
'symbol' => $symbol,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\CurrencyRate;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class QueuedCurrencyRateInsertJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
public $tries = 3;
|
||||
|
||||
public function __construct(
|
||||
protected array $chunk
|
||||
) {
|
||||
$this->chunk = $chunk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
|
||||
CurrencyRate::insertOrIgnore($this->chunk);
|
||||
}
|
||||
}
|
||||
@@ -50,4 +50,9 @@ class BackupImport extends Model
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Number;
|
||||
|
||||
class Currency extends Model
|
||||
{
|
||||
protected $hidden = [];
|
||||
|
||||
protected $primaryKey = 'currency';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $fillable = [
|
||||
'currency',
|
||||
'label',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public static function forHumans(int|float $number, ?string $currency = null, ?string $locale = null): string
|
||||
{
|
||||
$symbol = Number::currencySymbol($currency, $locale);
|
||||
|
||||
return $symbol.Number::forHumans($number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of supported currencies
|
||||
*
|
||||
* @param bool|null $withAliases Whether to include aliases in list of currencies
|
||||
*/
|
||||
public static function list(?bool $withAliases = true): Collection
|
||||
{
|
||||
$aliases = $withAliases ? collect(config('investbrain.currency_aliases'))->map(function ($value, $currency) {
|
||||
return [
|
||||
'currency' => $currency,
|
||||
'label' => $value['label'],
|
||||
];
|
||||
})->values() : collect();
|
||||
|
||||
return $aliases->merge(self::get()->map->only(['currency', 'label']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts between supported currencies
|
||||
*
|
||||
* @param string|null $to (defaults to base currency)
|
||||
*/
|
||||
public static function convert(?float $value, string $from, ?string $to = null, mixed $date = null): float
|
||||
{
|
||||
if (empty($value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Assume converting to base
|
||||
if (empty($to)) {
|
||||
$to = config('investbrain.base_currency');
|
||||
}
|
||||
|
||||
// Get rate
|
||||
[$from, $to] = [
|
||||
cache()->remember($from.'_rate_'.$date, 10, function () use ($from, $date) {
|
||||
return CurrencyRate::historic($from, $date);
|
||||
}),
|
||||
cache()->remember($to.'_rate_'.$date, 10, function () use ($to, $date) {
|
||||
return CurrencyRate::historic($to, $date);
|
||||
}),
|
||||
];
|
||||
|
||||
// get from rate
|
||||
$rate_to_base = 1 / $from;
|
||||
|
||||
// get value in base currency
|
||||
$base_currency_value = $value * $rate_to_base;
|
||||
|
||||
return (float) $base_currency_value * $to;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
<?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)) {
|
||||
|
||||
$i = 1;
|
||||
foreach ($currency as $curr) {
|
||||
|
||||
dispatch(fn () => self::timeSeriesRates($curr, $start, $end))->delay(now()->addSeconds(30 * $i));
|
||||
$i++;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
+112
-7
@@ -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,16 +30,21 @@ class DailyChange extends Model
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'datetime',
|
||||
'total_market_value' => 'float',
|
||||
'total_cost_basis' => 'float',
|
||||
'total_market_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()
|
||||
public function scopeMyDailyChanges($query)
|
||||
{
|
||||
return $this->whereHas('portfolio', function ($query) {
|
||||
return $query->whereHas('portfolio', function ($query) {
|
||||
$query->whereHas('users', function ($query) {
|
||||
return $query->where('id', auth()->id());
|
||||
});
|
||||
@@ -56,6 +58,109 @@ 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']);
|
||||
|
||||
$transactionTotals = DB::table('transactions')
|
||||
->select(['transactions.portfolio_id', 'transactions.date'])
|
||||
->selectRaw("
|
||||
SUM(
|
||||
(CASE WHEN transactions.transaction_type = 'BUY' THEN 1 ELSE -1 END)
|
||||
* transactions.quantity
|
||||
* transactions.cost_basis_base
|
||||
* COALESCE(cr.rate, 1)
|
||||
) AS daily_cost_basis
|
||||
")
|
||||
->selectRaw("
|
||||
SUM(
|
||||
(CASE
|
||||
WHEN transactions.transaction_type = 'SELL'
|
||||
THEN ( transactions.sale_price_base - transactions.cost_basis_base )
|
||||
* transactions.quantity
|
||||
* COALESCE(cr.rate, 1)
|
||||
END)
|
||||
) AS daily_realized_gains
|
||||
")
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join
|
||||
->on(DB::raw('DATE(cr.date)'), '=', DB::raw('DATE(transactions.date)'))
|
||||
->where('cr.currency', $currency);
|
||||
})
|
||||
->groupBy('transactions.portfolio_id', 'transactions.date');
|
||||
|
||||
$cumulativeCostBasis = DB::table(DB::raw("({$transactionTotals->toSql()}) AS transaction_totals"))
|
||||
->mergeBindings($transactionTotals)
|
||||
->select(['portfolio_id', 'date'])
|
||||
->selectRaw('SUM(daily_cost_basis) AS cumulative_cost_basis')
|
||||
->selectRaw('SUM(daily_realized_gains) AS cumulative_realized_gains')
|
||||
->groupBy('portfolio_id', 'date');
|
||||
|
||||
return $query
|
||||
->select(['daily_change.portfolio_id', 'daily_change.date'])
|
||||
->selectRaw('daily_change.total_market_value * COALESCE(cr.rate, 1) AS total_market_value')
|
||||
->selectRaw('SUM(COALESCE(ccb.cumulative_cost_basis, 0)) AS total_cost_basis')
|
||||
->selectRaw('daily_change.total_market_value * COALESCE(cr.rate, 1)
|
||||
- SUM(COALESCE(ccb.cumulative_cost_basis, 0))
|
||||
AS total_market_gain')
|
||||
->selectRaw('SUM(COALESCE(ccb.cumulative_realized_gains, 0)) 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')
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join
|
||||
->on(DB::raw('DATE(cr.date)'), '=', DB::raw('DATE(daily_change.date)'))
|
||||
->where('cr.currency', $currency);
|
||||
})
|
||||
->leftJoinSub($cumulativeCostBasis, 'ccb', function ($join) {
|
||||
$join
|
||||
->on('ccb.portfolio_id', '=', 'daily_change.portfolio_id')
|
||||
->whereRaw('ccb.date <= daily_change.date');
|
||||
})
|
||||
->groupBy(['daily_change.date', 'daily_change.portfolio_id', 'cr.rate'])
|
||||
->orderBy('daily_change.date');
|
||||
}
|
||||
|
||||
public function scopeWithMultipleDailyPerformance($query)
|
||||
{
|
||||
return DB::table(DB::raw("({$query->toSql()}) AS daily_query"))
|
||||
->addBinding($query->getQuery()->getBindings(), 'join')
|
||||
->select('date')
|
||||
->selectRaw('SUM(total_market_value) AS total_market_value')
|
||||
->selectRaw('SUM(total_cost_basis) AS total_cost_basis')
|
||||
->selectRaw('SUM(total_market_gain) AS total_market_gain')
|
||||
->selectRaw('SUM(realized_gain_dollars) AS realized_gain_dollars')
|
||||
->selectRaw('SUM(total_dividends_earned) AS total_dividends_earned')
|
||||
->groupBy('date');
|
||||
}
|
||||
|
||||
public function portfolio()
|
||||
{
|
||||
return $this->belongsTo(Portfolio::class);
|
||||
|
||||
+68
-30
@@ -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,
|
||||
|
||||
+321
-66
@@ -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_market_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,197 @@ 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_market_gain_dollars' => $result->sum('total_market_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();
|
||||
|
||||
$cost_basis_sub = DB::table('transactions')
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join
|
||||
->on('cr.date', '=', 'transactions.date')
|
||||
->where('cr.currency', '=', $currency);
|
||||
})
|
||||
->select([
|
||||
'transactions.id',
|
||||
'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.cost_basis_base',
|
||||
'transactions.date',
|
||||
])
|
||||
->selectRaw("
|
||||
(CASE
|
||||
WHEN
|
||||
transactions.transaction_type = 'BUY'
|
||||
OR SUM(transactions.cost_basis_base) = 0
|
||||
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")
|
||||
->groupBy([
|
||||
'transactions.id',
|
||||
'transactions.symbol',
|
||||
'transactions.date',
|
||||
'transactions.portfolio_id',
|
||||
'transactions.transaction_type',
|
||||
'transactions.cost_basis_base',
|
||||
'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(
|
||||
"CASE WHEN transactions.transaction_type = 'SELL' THEN (transactions.sale_price_base - transactions.cost_basis_base) * transactions.quantity * COALESCE(cr.rate, 1) END AS realized_gain_dollars"
|
||||
)
|
||||
->selectRaw(
|
||||
"CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.cost_basis_base * transactions.quantity * cost_basis_display.rate END AS total_cost_basis"
|
||||
)
|
||||
->selectRaw(
|
||||
"CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity END AS total_purchases"
|
||||
)
|
||||
->groupBy([
|
||||
'transactions.id',
|
||||
'transactions.symbol',
|
||||
'transactions.portfolio_id',
|
||||
'transactions.cost_basis_base',
|
||||
'transactions.quantity',
|
||||
'cost_basis_display.rate',
|
||||
'cr.rate',
|
||||
]);
|
||||
|
||||
$dividends_sub = 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', 'tx.portfolio_id'])
|
||||
->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', 'tx.portfolio_id']);
|
||||
|
||||
return $query->select([
|
||||
'holdings.symbol',
|
||||
'holdings.portfolio_id',
|
||||
'dividends_display.total_dividends_earned',
|
||||
])
|
||||
->groupBy([
|
||||
'holdings.symbol',
|
||||
'holdings.quantity',
|
||||
'holdings.portfolio_id',
|
||||
'cr.rate',
|
||||
'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('
|
||||
SUM(transactions_display.realized_gain_dollars)
|
||||
AS realized_gain_dollars
|
||||
')
|
||||
->selectRaw('
|
||||
(SUM(transactions_display.total_cost_basis) / SUM(transactions_display.total_purchases))
|
||||
* holdings.quantity
|
||||
AS total_cost_basis
|
||||
')
|
||||
->selectRaw('
|
||||
(holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1))
|
||||
- (SUM(transactions_display.total_cost_basis) / SUM(transactions_display.total_purchases))
|
||||
* holdings.quantity
|
||||
AS total_market_gain_dollars
|
||||
')
|
||||
->leftJoinSub($cost_basis_sub, 'transactions_display',
|
||||
function ($join) {
|
||||
$join
|
||||
->on('holdings.symbol', '=', 'transactions_display.symbol')
|
||||
->on('holdings.portfolio_id', '=', 'transactions_display.portfolio_id');
|
||||
}
|
||||
)
|
||||
->leftJoinSub($dividends_sub, 'dividends_display',
|
||||
function ($join) {
|
||||
$join->on('holdings.symbol', '=', 'dividends_display.symbol') // todo: this isnt limiting to port ids
|
||||
->on('holdings.portfolio_id', '=', 'dividends_display.portfolio_id');
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
public function syncTransactionsAndDividends()
|
||||
@@ -191,30 +414,35 @@ 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);
|
||||
// delete holding if no transactions
|
||||
if (empty($query->qty_purchases + $query->qty_sales)) {
|
||||
|
||||
$this->delete();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$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 +464,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 +480,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 +534,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 +573,7 @@ class Holding extends Model
|
||||
{
|
||||
$formattedTransactions = '';
|
||||
foreach ($this->transactions->sortByDesc('date') as $transaction) {
|
||||
$formattedTransactions .= ' * '.$transaction->date->format('Y-m-d')
|
||||
$formattedTransactions .= ' * '.$transaction->date->toDateString()
|
||||
.' '.$transaction->transaction_type
|
||||
.' '.$transaction->quantity
|
||||
.' @ '.$transaction->cost_basis
|
||||
|
||||
@@ -4,9 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Actions\CopyToBaseCurrency;
|
||||
use App\Casts\BaseCurrency;
|
||||
use App\Interfaces\MarketData\MarketDataInterface;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Pipeline;
|
||||
|
||||
class MarketData extends Model
|
||||
{
|
||||
@@ -21,7 +24,9 @@ class MarketData extends Model
|
||||
protected $fillable = [
|
||||
'symbol',
|
||||
'name',
|
||||
'currency',
|
||||
'market_value',
|
||||
'market_value_base',
|
||||
'fifty_two_week_high',
|
||||
'fifty_two_week_low',
|
||||
'forward_pe',
|
||||
@@ -29,21 +34,40 @@ class MarketData extends Model
|
||||
'market_cap',
|
||||
'book_value',
|
||||
'last_dividend_date',
|
||||
'last_dividend_amount',
|
||||
'dividend_yield',
|
||||
'meta_data',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_dividend_date' => 'datetime',
|
||||
'market_value' => 'float',
|
||||
'market_value_base' => BaseCurrency::class,
|
||||
'fifty_two_week_high' => 'float',
|
||||
'fifty_two_week_low' => 'float',
|
||||
'forward_pe' => 'float',
|
||||
'trailing_pe' => 'float',
|
||||
'market_cap' => 'float',
|
||||
'market_cap' => 'integer',
|
||||
'book_value' => 'float',
|
||||
'last_dividend_date' => 'datetime',
|
||||
'last_dividend_amount' => 'float',
|
||||
'dividend_yield' => 'float',
|
||||
'meta_data' => 'json',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::saving(function ($market_data) {
|
||||
|
||||
$market_data = Pipeline::send($market_data)
|
||||
->through([
|
||||
CopyToBaseCurrency::class,
|
||||
])
|
||||
->then(fn (MarketData $market_data) => $market_data);
|
||||
});
|
||||
}
|
||||
|
||||
public function holdings()
|
||||
{
|
||||
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
||||
@@ -54,7 +78,7 @@ class MarketData extends Model
|
||||
return $query->where('symbol', $symbol);
|
||||
}
|
||||
|
||||
public static function getMarketData($symbol, $force = false)
|
||||
public static function getMarketData($symbol, $force = false): self
|
||||
{
|
||||
$market_data = self::firstOrNew([
|
||||
'symbol' => $symbol,
|
||||
|
||||
+22
-25
@@ -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,15 @@ 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
|
||||
$currency_rates = [];
|
||||
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 +167,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 +197,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,19 +204,23 @@ 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',
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
cache()->forget('graph-YTD-'.$this->id);
|
||||
cache()->forget('graph-YTD-'.request()->user()?->id);
|
||||
}
|
||||
|
||||
protected function getMostRecentCloseData($history, $date, $i = 0, $max_attempts = 5)
|
||||
@@ -234,7 +231,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
@@ -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
@@ -4,18 +4,25 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Actions\ConvertToMarketDataCurrency;
|
||||
use App\Actions\CopyToBaseCurrency;
|
||||
use App\Actions\EnsureCostBasisAddedToSale;
|
||||
use App\Actions\EnsureDailyChangeIsSynced;
|
||||
use App\Casts\BaseCurrency;
|
||||
use App\Traits\HasMarketData;
|
||||
use Illuminate\Contracts\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Pipeline;
|
||||
|
||||
class Transaction extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasMarketData;
|
||||
use HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
@@ -23,6 +30,7 @@ class Transaction extends Model
|
||||
'date',
|
||||
'portfolio_id',
|
||||
'transaction_type',
|
||||
'currency',
|
||||
'quantity',
|
||||
'cost_basis',
|
||||
'sale_price',
|
||||
@@ -36,6 +44,11 @@ class Transaction extends Model
|
||||
'date' => 'datetime',
|
||||
'split' => 'boolean',
|
||||
'reinvested_dividend' => 'boolean',
|
||||
'quantity' => 'float',
|
||||
'cost_basis' => 'float',
|
||||
'sale_price' => 'float',
|
||||
'cost_basis_base' => BaseCurrency::class,
|
||||
'sale_price_base' => BaseCurrency::class,
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
@@ -44,17 +57,24 @@ class Transaction extends Model
|
||||
|
||||
static::saving(function ($transaction) {
|
||||
|
||||
if ($transaction->transaction_type == 'SELL') {
|
||||
|
||||
$transaction->ensureCostBasisIsAddedToSale();
|
||||
}
|
||||
$transaction = Pipeline::send($transaction)
|
||||
->through([
|
||||
ConvertToMarketDataCurrency::class,
|
||||
EnsureCostBasisAddedToSale::class,
|
||||
CopyToBaseCurrency::class,
|
||||
])
|
||||
->then(fn (Transaction $transaction) => $transaction);
|
||||
});
|
||||
|
||||
static::saved(function ($transaction) {
|
||||
|
||||
$transaction->syncToHolding();
|
||||
|
||||
$transaction->refreshMarketData();
|
||||
$transaction = Pipeline::send($transaction)
|
||||
->through([
|
||||
EnsureDailyChangeIsSynced::class,
|
||||
])
|
||||
->then(fn (Transaction $transaction) => $transaction);
|
||||
|
||||
cache()->forget('portfolio-metrics-'.$transaction->portfolio_id);
|
||||
});
|
||||
@@ -77,16 +97,6 @@ class Transaction extends Model
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Related market data for transaction
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function market_data(): HasOne
|
||||
{
|
||||
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
|
||||
}
|
||||
|
||||
/**
|
||||
* Related portfolio
|
||||
*
|
||||
@@ -101,6 +111,7 @@ class Transaction extends Model
|
||||
{
|
||||
return $query->withAggregate('market_data', 'name')
|
||||
->withAggregate('market_data', 'market_value')
|
||||
->withAggregate('market_data', 'currency')
|
||||
->withAggregate('market_data', 'fifty_two_week_low')
|
||||
->withAggregate('market_data', 'fifty_two_week_high')
|
||||
->withAggregate('market_data', 'updated_at')
|
||||
@@ -141,28 +152,6 @@ class Transaction extends Model
|
||||
});
|
||||
}
|
||||
|
||||
public function refreshMarketData(): void
|
||||
{
|
||||
MarketData::getMarketData($this->attributes['symbol']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes average cost basis to a sale transaction
|
||||
*/
|
||||
public function ensureCostBasisIsAddedToSale(): Transaction
|
||||
{
|
||||
$average_cost_basis = Transaction::where([
|
||||
'portfolio_id' => $this->portfolio_id,
|
||||
'symbol' => $this->symbol,
|
||||
'transaction_type' => 'BUY',
|
||||
])->whereDate('date', '<=', $this->date)
|
||||
->average('cost_basis');
|
||||
|
||||
$this->cost_basis = $average_cost_basis ?? 0;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs the holding related to this transaction
|
||||
*/
|
||||
@@ -187,8 +176,8 @@ class Transaction extends Model
|
||||
'portfolio_id' => $this->portfolio_id,
|
||||
'symbol' => $this->symbol,
|
||||
'quantity' => $this->quantity,
|
||||
'average_cost_basis' => $this->cost_basis,
|
||||
'total_cost_basis' => $this->quantity * $this->cost_basis,
|
||||
'average_cost_basis' => $this->cost_basis_base,
|
||||
'total_cost_basis' => $this->quantity * $this->cost_basis_base,
|
||||
'splits_synced_at' => now(),
|
||||
])->syncTransactionsAndDividends();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Arr;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Jetstream\HasProfilePhoto;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
@@ -31,6 +32,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -50,6 +52,8 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'admin' => 'boolean',
|
||||
'options' => 'json',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -82,4 +86,26 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
ELSE COALESCE(market_data.market_value - transactions.cost_basis, 0)
|
||||
END AS gain_dollars');
|
||||
}
|
||||
|
||||
public function getCurrency(): string
|
||||
{
|
||||
return Arr::get($this->options, 'display_currency') ?? config('investbrain.base_currency');
|
||||
}
|
||||
|
||||
public function getLocale(): string
|
||||
{
|
||||
$available_locales = Arr::pluck(config('app.available_locales'), 'locale');
|
||||
|
||||
return Arr::get($this->options, 'locale') ?? request()->getPreferredLanguage($available_locales) ?? config('app.locale');
|
||||
}
|
||||
|
||||
public function setOption(mixed $key, ?string $value = null): self
|
||||
{
|
||||
|
||||
$options = is_array($key) ? $key : [$key => $value];
|
||||
|
||||
$this->options = array_merge($this->options ?? [], $options);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Number;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use NumberFormatter;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -26,5 +29,28 @@ class AppServiceProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
JsonResource::withoutWrapping();
|
||||
|
||||
Arr::macro('skipEmptyValues', function (array $array) {
|
||||
|
||||
return Arr::mapWithKeys($array, function (mixed $value, mixed $key) {
|
||||
$result = [];
|
||||
if (! empty($value)) {
|
||||
$result[$key] = $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
});
|
||||
});
|
||||
|
||||
Number::macro('currencySymbol', function (?string $currency = null, ?string $locale = null) {
|
||||
|
||||
$currency = $currency ?? Number::defaultCurrency();
|
||||
|
||||
$locale = $locale ?? Number::defaultLocale();
|
||||
|
||||
$formatter = new NumberFormatter($locale."@currency=$currency", NumberFormatter::CURRENCY);
|
||||
|
||||
return $formatter->getSymbol(NumberFormatter::CURRENCY_SYMBOL);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,13 @@ class VoltServiceProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
Volt::mount([
|
||||
config('livewire.view_path', resource_path('views/livewire')),
|
||||
resource_path('views/pages'),
|
||||
// config('livewire.view_path', resource_path('views/livewire')),
|
||||
resource_path('views/components'),
|
||||
resource_path('views/profile'),
|
||||
resource_path('views/holding'),
|
||||
resource_path('views/transaction'),
|
||||
resource_path('views/portfolio'),
|
||||
resource_path('views/auth'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ declare(strict_types=1);
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Models\Portfolio;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class QuantityValidationRule implements ValidationRule
|
||||
{
|
||||
@@ -20,12 +21,7 @@ class QuantityValidationRule implements ValidationRule
|
||||
protected ?string $symbol,
|
||||
protected ?string $transactionType,
|
||||
protected string|Carbon|null $date
|
||||
) {
|
||||
$this->portfolio = $portfolio;
|
||||
$this->symbol = $symbol;
|
||||
$this->transactionType = $transactionType;
|
||||
$this->date = $date;
|
||||
}
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Validate the attribute.
|
||||
@@ -39,21 +35,21 @@ class QuantityValidationRule implements ValidationRule
|
||||
|
||||
if ($this->transactionType == 'SELL') {
|
||||
|
||||
$purchase_qty = $this->portfolio->transactions()
|
||||
$purchase_qty = (float) $this->portfolio->transactions()
|
||||
->symbol($this->symbol)
|
||||
->buy()
|
||||
->beforeDate($this->date)
|
||||
->whereDate('date', '<', $this->date)
|
||||
->sum('quantity');
|
||||
|
||||
$sales_qty = $this->portfolio->transactions()
|
||||
$sales_qty = (float) $this->portfolio->transactions()
|
||||
->symbol($this->symbol)
|
||||
->sell()
|
||||
->beforeDate($this->date)
|
||||
->whereDate('date', '<', $this->date)
|
||||
->sum('quantity');
|
||||
|
||||
$maxQuantity = $purchase_qty - $sales_qty;
|
||||
|
||||
if (round($value, 3) > round($maxQuantity, 3)) {
|
||||
if (round($value, 4) > round($maxQuantity, 4)) {
|
||||
$fail(__('The quantity must not be greater than the available quantity.'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
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);
|
||||
|
||||
// return $formatter->formatCurrency((float) $amount, 'USD');
|
||||
// }
|
||||
// }
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\MarketData;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
trait HasMarketData
|
||||
{
|
||||
/**
|
||||
* Related market data for model
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function market_data(): HasOne
|
||||
{
|
||||
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully loads related market data as relationship (creates if doesn't exist)
|
||||
*/
|
||||
public function loadMarketData(): void
|
||||
{
|
||||
if (is_null($this->market_data)) {
|
||||
|
||||
$this->setRelation('market_data', MarketData::getMarketData($this->attributes['symbol']));
|
||||
}
|
||||
}
|
||||
|
||||
public function scopeNotBaseCurrency($query): void
|
||||
{
|
||||
$query->with('market_data')
|
||||
->whereRelation(
|
||||
'market_data',
|
||||
'currency',
|
||||
'!=',
|
||||
config('investbrain.base_currency')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,11 @@ class AppLayout extends Component
|
||||
|
||||
<x-partials.nav-bar />
|
||||
|
||||
<x-main with-nav full-width>
|
||||
<x-partials.main with-nav full-width>
|
||||
|
||||
<x-slot:sidebar drawer="main-drawer" class="bg-base-100 lg:bg-inherit">
|
||||
|
||||
<x-partials.side-bar />
|
||||
|
||||
@livewire('partials.side-bar')
|
||||
|
||||
</x-slot:sidebar>
|
||||
|
||||
@@ -34,7 +34,7 @@ class AppLayout extends Component
|
||||
{{ $slot }}
|
||||
</x-slot:content>
|
||||
|
||||
</x-main>
|
||||
</x-partials.main>
|
||||
|
||||
@if(session('toast'))
|
||||
<script lang="text/javascript">
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Middleware\SetLocale;
|
||||
use App\Http\Middleware\LocalizationMiddleware;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
@@ -15,7 +15,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
$middleware->append(SetLocale::class);
|
||||
$middleware->appendToGroup('web', LocalizationMiddleware::class);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
//
|
||||
|
||||
+9
-5
@@ -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",
|
||||
@@ -23,8 +24,9 @@
|
||||
"openai-php/client": "^0.10.3",
|
||||
"predis/predis": "^2.2",
|
||||
"robsontenorio/mary": "^1.35",
|
||||
"scheb/yahoo-finance-api": "^4.11",
|
||||
"scheb/yahoo-finance-api": "^5.0",
|
||||
"staudenmeir/eloquent-has-many-deep": "^1.20",
|
||||
"symfony/cache": "^7.3",
|
||||
"tschucki/alphavantage-laravel": "^0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
@@ -33,7 +35,7 @@
|
||||
"laravel/sail": "^1.26",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.0",
|
||||
"phpunit/phpunit": "^11.0.1"
|
||||
"phpunit/phpunit": "^11.0"
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
@@ -41,6 +43,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,
|
||||
@@ -48,9 +55,6 @@
|
||||
}
|
||||
],
|
||||
"autoload": {
|
||||
"files": [
|
||||
"app/Support/Helpers.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
|
||||
Generated
+1108
-683
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'key' => env('ALPACA_API_KEY'),
|
||||
'secret' => env('ALPACA_API_SECRET'),
|
||||
];
|
||||
+85
-3
@@ -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(
|
||||
|
||||
@@ -11,11 +11,21 @@ return [
|
||||
'interfaces' => [
|
||||
'yahoo' => App\Interfaces\MarketData\YahooMarketData::class,
|
||||
'alphavantage' => App\Interfaces\MarketData\AlphaVantageMarketData::class,
|
||||
'alpaca' => App\Interfaces\MarketData\AlpacaMarketData::class,
|
||||
'finnhub' => App\Interfaces\MarketData\FinnhubMarketData::class,
|
||||
'twelvedata' => App\Interfaces\MarketData\TwelveDataMarketData::class,
|
||||
'fake' => App\Interfaces\MarketData\FakeMarketData::class,
|
||||
],
|
||||
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
|
||||
'daily_change_time_of_day' => env('DAILY_CHANGE_TIME', '23:00'),
|
||||
|
||||
'base_currency' => env('BASE_CURRENCY', 'USD'),
|
||||
|
||||
'currency_aliases' => [
|
||||
'RMB' => ['alias_of' => 'CNY', 'label' => 'Chinese Yuan (Renminbi)', 'adjustment' => 1],
|
||||
'GBX' => ['alias_of' => 'GBP', 'label' => 'British Sterling Pence', 'adjustment' => 100],
|
||||
'ZAC' => ['alias_of' => 'ZAR', 'label' => 'South Africa Rand Cent', 'adjustment' => 100],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -60,7 +60,6 @@ return [
|
||||
*/
|
||||
|
||||
'features' => [
|
||||
! env('SELF_HOSTED', true) ? Features::termsAndPrivacyPolicy() : null,
|
||||
Features::profilePhotos(),
|
||||
Features::api(),
|
||||
Features::accountDeletion(),
|
||||
@@ -77,6 +76,6 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'profile_photo_disk' => 'public',
|
||||
'profile_photo_disk' => env('JETSTREAM_PROFILE_PHOTO_DISK', 'public'),
|
||||
|
||||
];
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'inject_assets' => true,
|
||||
'inject_assets' => false,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
|
||||
@@ -96,6 +96,11 @@ return [
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'sentry' => [
|
||||
'driver' => 'sentry',
|
||||
'level' => env('LOG_LEVEL', 'error'),
|
||||
],
|
||||
|
||||
'stderr' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'secret' => env('TWELVEDATA_API_SECRET'),
|
||||
];
|
||||
@@ -41,28 +41,49 @@ 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 sixMonthsAgo(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'date' => now()->subMonths(6)->toDateString(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function today(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'date' => now()->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 +101,27 @@ class TransactionFactory extends Factory
|
||||
]);
|
||||
}
|
||||
|
||||
public function currency($currency): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'currency' => $currency,
|
||||
]);
|
||||
}
|
||||
|
||||
public function costBasis($cost_basis): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'cost_basis' => $cost_basis,
|
||||
]);
|
||||
}
|
||||
|
||||
public function salePrice($sale_price): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'sale_price' => $sale_price,
|
||||
]);
|
||||
}
|
||||
|
||||
public function buy(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
|
||||
@@ -34,6 +34,10 @@ class UserFactory extends Factory
|
||||
'two_factor_recovery_codes' => null,
|
||||
'remember_token' => Str::random(10),
|
||||
'profile_photo_path' => null,
|
||||
'options' => [
|
||||
'display_currency' => 'USD',
|
||||
'locale' => 'en',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -46,4 +50,14 @@ class UserFactory extends Factory
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's currency.
|
||||
*/
|
||||
public function currency($currency): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => array_merge($attributes['options'], [
|
||||
'currency' => $currency,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ return new class extends Migration
|
||||
$table->string('password');
|
||||
$table->rememberToken();
|
||||
$table->string('profile_photo_path', 2048)->nullable();
|
||||
$table->boolean('admin')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
@@ -38,6 +39,5 @@ return new class extends Migration
|
||||
{
|
||||
Schema::dropIfExists('users');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('sessions');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Currency;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CurrencySeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
|
||||
Currency::insert([
|
||||
['currency' => 'AUD', 'label' => 'Australian Dollar', 'created_at' => now()],
|
||||
['currency' => 'BRL', 'label' => 'Brazilian Real', 'created_at' => now()],
|
||||
['currency' => 'GBP', 'label' => 'British Pound', 'created_at' => now()],
|
||||
['currency' => 'CAD', 'label' => 'Canadian Dollar', 'created_at' => now()],
|
||||
['currency' => 'CNY', 'label' => 'Chinese Yuan', 'created_at' => now()],
|
||||
['currency' => 'CZK', 'label' => 'Czech Koruna', 'created_at' => now()],
|
||||
['currency' => 'DKK', 'label' => 'Danish Krone', 'created_at' => now()],
|
||||
['currency' => 'EUR', 'label' => 'Euro', 'created_at' => now()],
|
||||
['currency' => 'HKD', 'label' => 'Hong Kong Dollar', 'created_at' => now()],
|
||||
['currency' => 'INR', 'label' => 'Indian Rupee', 'created_at' => now()],
|
||||
['currency' => 'JPY', 'label' => 'Japanese Yen', 'created_at' => now()],
|
||||
['currency' => 'NZD', 'label' => 'New Zealand Dollar', 'created_at' => now()],
|
||||
['currency' => 'NOK', 'label' => 'Norwegian Krone', 'created_at' => now()],
|
||||
['currency' => 'SGD', 'label' => 'Singapore Dollar', 'created_at' => now()],
|
||||
['currency' => 'KRW', 'label' => 'South Korean Won', 'created_at' => now()],
|
||||
['currency' => 'ZAR', 'label' => 'South African Rand', 'created_at' => now()],
|
||||
['currency' => 'SEK', 'label' => 'Swedish Krona', 'created_at' => now()],
|
||||
['currency' => 'CHF', 'label' => 'Swiss Franc', 'created_at' => now()],
|
||||
['currency' => 'USD', 'label' => 'United States Dollar', 'created_at' => now()],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,6 @@ class DatabaseSeeder extends Seeder
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+13188
-35000
File diff suppressed because it is too large
Load Diff
+16
-18
@@ -10,12 +10,10 @@ 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
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: investbrain-pgsql
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: investbrain
|
||||
DB_USERNAME: investbrain
|
||||
DB_PASSWORD: investbrain
|
||||
@@ -25,9 +23,9 @@ 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
|
||||
- pgsql
|
||||
- redis
|
||||
networks:
|
||||
- investbrain-network
|
||||
@@ -42,22 +40,22 @@ services:
|
||||
- investbrain-redis:/data
|
||||
networks:
|
||||
- investbrain-network
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: investbrain-mysql
|
||||
pgsql:
|
||||
image: postgres:15-alpine
|
||||
container_name: investbrain-pgsql
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
MYSQL_DATABASE: ${DB_DATABASE:-investbrain}
|
||||
MYSQL_USER: ${DB_USERNAME:-investbrain}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:-investbrain}
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-investbrain}
|
||||
command:
|
||||
- --cte-max-recursion-depth=25000
|
||||
POSTGRES_DB: ${DB_DATABASE:-investbrain}
|
||||
POSTGRES_USER: ${DB_USERNAME:-investbrain}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-investbrain}
|
||||
command: postgres -c log_min_messages=error
|
||||
volumes:
|
||||
- investbrain-mysql:/var/lib/mysql
|
||||
- investbrain-pgsql:/var/lib/postgresql/data
|
||||
networks:
|
||||
- investbrain-network
|
||||
volumes:
|
||||
investbrain-storage:
|
||||
investbrain-redis:
|
||||
investbrain-mysql:
|
||||
investbrain-pgsql:
|
||||
|
||||
+4
-1
@@ -44,6 +44,9 @@ FROM php:8.3-fpm-alpine
|
||||
# Set the working directory
|
||||
WORKDIR /var/app
|
||||
|
||||
ARG VERSION=dev
|
||||
ENV VERSION=$VERSION
|
||||
|
||||
# Copy necessary files from the builder stage
|
||||
COPY --from=builder /var/app /var/app
|
||||
COPY --from=builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d
|
||||
@@ -62,7 +65,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 \
|
||||
|
||||
+18
-5
@@ -3,7 +3,8 @@
|
||||
cd /var/app
|
||||
|
||||
# Starting Investbrain
|
||||
echo "CiAgKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioKICAqICBJSUkgICBOICAgTiAgViAgIFYgIEVFRUVFICBTU1NTICBUVFRUVCAgQkJCQkIgICBSUlJSICAgIEFBQUFBICBJSUkgICBOICAgTiAgKgogICogICBJICAgIE5OICBOICBWICAgViAgRSAgICAgIFMgICAgICAgVCAgICBCICAgIEIgIFIgICBSICAgQSAgIEEgICBJICAgIE5OICBOICAqCiAgKiAgIEkgICAgTiBOIE4gIFYgICBWICBFRUVFICAgU1NTUyAgICBUICAgIEJCQkJCICAgUlJSUiAgICBBQUFBQSAgIEkgICAgTiBOIE4gICoKICAqICAgSSAgICBOICBOTiAgViAgIFYgIEUgICAgICAgICAgUyAgIFQgICAgQiAgICBCICBSICBSICAgIEEgICBBICAgSSAgICBOICBOTiAgKgogICogIElJSSAgIE4gICBOICAgVlZWICAgRUVFRUUgIFNTU1MgICAgVCAgICBCQkJCQiAgIFIgICBSICAgQSAgIEEgIElJSSAgIE4gICBOICAqCiAgKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioKICA=" | base64 -d
|
||||
echo "CuKWhOKWliAgICAgICAg4paXIOKWjCAgICAg4paYICAK4paQIOKWm+KWjOKWjOKWjOKWiOKWjOKWm+KWmOKWnOKWmOKWm+KWjOKWm+KWmOKWgOKWjOKWjOKWm+KWjArilp/ilpbilozilozilprilpjilpnilpbiloTilozilpDilpbilpnilozilowg4paI4paM4paM4paM4paMCg==" | base64 -d
|
||||
printf "%15s$VERSION\n"
|
||||
|
||||
echo -e "\n====================== Validating environment... ====================== "
|
||||
|
||||
@@ -14,11 +15,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 +39,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"
|
||||
|
||||
@@ -52,7 +55,6 @@ RETRIES=12
|
||||
DELAY=5
|
||||
run_migrations() {
|
||||
sleep $DELAY
|
||||
# php artisan migrate --force
|
||||
output=$(php artisan migrate --force 2>/dev/null)
|
||||
if [[ $? -eq 0 ]]; then
|
||||
echo "$output"
|
||||
@@ -70,7 +72,18 @@ until run_migrations; do
|
||||
echo -e "\n > Waiting for database to be ready... retrying in $DELAY seconds. \n"
|
||||
done
|
||||
|
||||
echo -e "\n====================== Cleaning up... ====================== \n"
|
||||
|
||||
# Clear caches
|
||||
echo $(php artisan cache:clear)
|
||||
echo $(php artisan view:clear)
|
||||
echo $(php artisan route:clear)
|
||||
echo $(php artisan event:clear)
|
||||
|
||||
# Re-create caches
|
||||
echo $(php artisan route:cache)
|
||||
echo $(php artisan event:cache)
|
||||
|
||||
echo -e "\n====================== Spinning up Supervisor daemon... ====================== \n"
|
||||
|
||||
exec supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
|
||||
+12
-3
@@ -367,8 +367,11 @@
|
||||
"Import starting...": "Import starting...",
|
||||
"Import is in progress...": "Import is in progress...",
|
||||
"Importing portfolios...": "Importing portfolios...",
|
||||
"Importing transactions...": "Importing transactions...",
|
||||
"Importing daily changes...": "Importing daily changes...",
|
||||
"Preparing to import transactions...": "Preparing to import transactions...",
|
||||
"Importing transactions (Batch :currentBatch of :totalBatches)...": "Importing transactions (Batch :currentBatch of :totalBatches)...",
|
||||
"Preparing to import daily changes...": "Preparing to import daily changes...",
|
||||
"Importing daily changes (Batch :currentBatch of :totalBatches)...": "Importing daily changes (Batch :currentBatch of :totalBatches)...",
|
||||
"Importing configurations...": "Importing configurations...",
|
||||
"Import completed successfully!": "Import completed successfully!",
|
||||
"Your import will continue in the background": "Your import will continue in the background",
|
||||
|
||||
@@ -376,5 +379,11 @@
|
||||
"Hi, how can I help?": "Hi, how can I help?",
|
||||
"Have a question? AI might be able to help...": "Have a question? AI might be able to help...",
|
||||
"Feel free to ask me a question!": "Feel free to ask me a question!",
|
||||
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor."
|
||||
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.",
|
||||
|
||||
"Currency": "Currency",
|
||||
"Locale Options": "Locale Options",
|
||||
"Adjust localization options for your preferred region.": "Adjust localization options for your preferred region.",
|
||||
"Locale": "Locale",
|
||||
"Display Currency": "Display Currency"
|
||||
}
|
||||
+12
-3
@@ -367,8 +367,11 @@
|
||||
"Import starting...": "Iniciando la importación...",
|
||||
"Import is in progress...": "La importación está en progreso...",
|
||||
"Importing portfolios...": "Importando portafolios...",
|
||||
"Importing transactions...": "Importando transacciones...",
|
||||
"Importing daily changes...": "Importando cambios diarios...",
|
||||
"Preparing to import transactions...": "Preparándose para importar transacciones...",
|
||||
"Importing transactions (Batch :currentBatch of :totalBatches)...": "Importando transacciones (Lote :currentBatch de :totalBatches)...",
|
||||
"Preparing to import daily changes...": "Preparing to import cambios diarios...",
|
||||
"Importing daily changes (Batch :currentBatch of :totalBatches)...": "Importando cambios diarios (Lote :currentBatch de :totalBatches)...",
|
||||
"Importing configurations...": "Importando configuraciones...",
|
||||
"Import completed successfully!": "¡La importación se completó con éxito!",
|
||||
"Your import will continue in the background": "La importación continuará en segundo plano",
|
||||
|
||||
@@ -376,5 +379,11 @@
|
||||
"Hi, how can I help?": "Hola, ¿cómo puedo ayudarte?",
|
||||
"Have a question? AI might be able to help...": "¿Tienes una pregunta? La AI podría ayudarte...",
|
||||
"Feel free to ask me a question!": "¡No dudes en hacerme una pregunta!",
|
||||
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Los consejos generados por AI pueden contener errores. Úsalos bajo tu propio riesgo. Consulta siempre a un asesor de inversiones con licencia."
|
||||
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Los consejos generados por AI pueden contener errores. Úsalos bajo tu propio riesgo. Consulta siempre a un asesor de inversiones con licencia.",
|
||||
|
||||
"Currency": "Moneda",
|
||||
"Locale Options": "Opciones de configuración regional",
|
||||
"Adjust localization options for your preferred region.": "Ajusta las opciones de localización para tu región preferida.",
|
||||
"Locale": "Configuración regional",
|
||||
"Display Currency": "Moneda de visualización"
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
<x-input id="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" name="password_confirmation" required autocomplete="new-password" />
|
||||
</div>
|
||||
|
||||
@if (Laravel\Jetstream\Jetstream::hasTermsAndPrivacyPolicyFeature())
|
||||
@if (! config('investbrain.self_hosted'))
|
||||
<div class="mt-4">
|
||||
<label>
|
||||
<div class="flex items-center">
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ new class extends Component {
|
||||
'model' => config('openai.model'),
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => "Today's date is "
|
||||
.now()->format('Y-m-d')
|
||||
.now()->toDateString()
|
||||
.".\n\n".$this->system_prompt],
|
||||
...array_slice($this->messages, -10)
|
||||
],
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user