Compare commits
80 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 |
@@ -24,6 +24,9 @@ OPENAI_ORGANIZATION=
|
|||||||
MARKET_DATA_PROVIDER=yahoo
|
MARKET_DATA_PROVIDER=yahoo
|
||||||
ALPHAVANTAGE_API_KEY=
|
ALPHAVANTAGE_API_KEY=
|
||||||
FINNHUB_API_KEY=
|
FINNHUB_API_KEY=
|
||||||
|
ALPACA_API_KEY=
|
||||||
|
ALPACA_API_SECRET=
|
||||||
|
TWELVEDATA_API_SECRET=
|
||||||
|
|
||||||
# Cadence to refresh market data (in minutes)
|
# Cadence to refresh market data (in minutes)
|
||||||
MARKET_DATA_REFRESH=30
|
MARKET_DATA_REFRESH=30
|
||||||
|
|||||||
@@ -43,7 +43,16 @@ jobs:
|
|||||||
- name: Extract version from tag
|
- name: Extract version from tag
|
||||||
id: extract-version
|
id: extract-version
|
||||||
run: |
|
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
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -51,8 +60,7 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
file: ./docker/Dockerfile
|
file: ./docker/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: ${{ steps.extract-version.outputs.tags }}
|
||||||
investbrainapp/investbrain:latest
|
build-args: |
|
||||||
investbrainapp/investbrain:${{ env.version }}
|
VERSION=${{ github.ref_name }}
|
||||||
ghcr.io/investbrainapp/investbrain:latest
|
|
||||||
ghcr.io/investbrainapp/investbrain:${{ env.version }}
|
|
||||||
|
|||||||
@@ -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
|
## 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
|
## Self hosting
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ Always keep in mind the limitations of LLMs. When in doubt, consult a licensed i
|
|||||||
|
|
||||||
## Market data providers
|
## 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
|
### Configuration
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ Your selected providers should be listed in your environment variables. Each sho
|
|||||||
MARKET_DATA_PROVIDER=yahoo,alphavantage
|
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
|
### 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_URL | The URL where your Investbrain installation will be accessible | http://localhost |
|
||||||
| APP_PORT | The HTTP port exposed by the NGINX container | 8000 |
|
| 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` |
|
| 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` |
|
| ALPHAVANTAGE_API_KEY | If using the Alpha Vantage provider | `null` |
|
||||||
| FINNHUB_API_KEY | If using the Finnhub 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 |
|
| MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 |
|
||||||
| APP_TIMEZONE | Timezone for the application, including daily change captures | UTC |
|
| APP_TIMEZONE | Timezone for the application, including daily change captures | UTC |
|
||||||
| AI_CHAT_ENABLED | Whether to enable AI chat features | `false` |
|
| AI_CHAT_ENABLED | Whether to enable AI chat features | `false` |
|
||||||
@@ -178,7 +181,7 @@ Easy as that!
|
|||||||
|
|
||||||
## Command line utilities
|
## 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:
|
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>
|
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 |
|
| Command | Description |
|
||||||
| ------------- | ------------- |
|
| ------------- | ------------- |
|
||||||
| refresh:market-data | Refreshes market data with your configured market data provider. |
|
| refresh:market-data | Refreshes market data with your configured market data provider. |
|
||||||
| refresh:dividend-data | Refreshes dividend data with your configured market data provider. Will also re-calculate your total dividends earned for each holding. |
|
| refresh:dividend-data | Refreshes dividend data with your configured market data provider. Will also re-calculate your total dividends earned for each holding. |
|
||||||
| refresh:split-data | Refreshes splits data with your configured market data provider. Will also create new transactions to account for any splits. |
|
| refresh:split-data | Refreshes splits data with your configured market data provider. Will also create new transactions to account for any splits. |
|
||||||
|
| refresh:currency-data | Grabs the latest daily currency exchange rate data and persists to the database. |
|
||||||
| capture:daily-change | Captures a snapshot of each portfolio's daily performance. |
|
| capture:daily-change | Captures a snapshot of each portfolio's daily performance. |
|
||||||
| sync:daily-change | Re-calculates daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) |
|
| sync:daily-change | 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 | Re-calculates performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
|
| 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
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,18 @@ class EnsureCostBasisAddedToSale
|
|||||||
// cost basis is required for sales to calculate realized gains
|
// cost basis is required for sales to calculate realized gains
|
||||||
if ($model->transaction_type == 'SELL') {
|
if ($model->transaction_type == 'SELL') {
|
||||||
|
|
||||||
$average_cost_basis = Transaction::where([
|
$cost_basis = Transaction::where([
|
||||||
'portfolio_id' => $model->portfolio_id,
|
'portfolio_id' => $model->portfolio_id,
|
||||||
'symbol' => $model->symbol,
|
'symbol' => $model->symbol,
|
||||||
'transaction_type' => 'BUY',
|
'transaction_type' => 'BUY',
|
||||||
])->whereDate('date', '<=', $model->date)
|
])->whereDate('date', '<=', $model->date)
|
||||||
->average('cost_basis');
|
->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;
|
$model->cost_basis = $average_cost_basis ?? 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class EnsureDailyChangeIsSynced
|
|||||||
! Cache::has($cacheKey)
|
! Cache::has($cacheKey)
|
||||||
&& $model->date->lessThan(now())
|
&& $model->date->lessThan(now())
|
||||||
&& ($model->date->lessThan($model->portfolio->daily_change()->min('date') ?? now())
|
&& ($model->date->lessThan($model->portfolio->daily_change()->min('date') ?? now())
|
||||||
|| $model->date->lessThan($model->portfolio->transactions()->where('id', '!=', $model->id)->min('date') ?? now())
|
|| $model->date->lessThan($model->portfolio->transactions()->where('id', '!=', $model->id)->max('date') ?? now())
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
defer(fn () => $model->portfolio->syncDailyChanges());
|
defer(fn () => $model->portfolio->syncDailyChanges());
|
||||||
|
|||||||
@@ -49,16 +49,9 @@ class CaptureDailyChange extends Command
|
|||||||
->portfolio($portfolio->id)
|
->portfolio($portfolio->id)
|
||||||
->getPortfolioMetrics(config('investbrain.base_currency'));
|
->getPortfolioMetrics(config('investbrain.base_currency'));
|
||||||
|
|
||||||
$total_cost_basis = $metrics->get('total_cost_basis');
|
|
||||||
$total_market_value = $metrics->get('total_market_value');
|
|
||||||
|
|
||||||
$portfolio->daily_change()->create([
|
$portfolio->daily_change()->create([
|
||||||
'date' => now(),
|
'date' => now(),
|
||||||
'total_market_value' => $total_market_value,
|
'total_market_value' => $metrics->get('total_market_value'),
|
||||||
'total_cost_basis' => $total_cost_basis,
|
|
||||||
'total_gain' => $total_market_value - $total_cost_basis,
|
|
||||||
'total_dividends_earned' => $metrics->get('total_dividends_earned'),
|
|
||||||
'realized_gains' => $metrics->get('realized_gain_dollars'),
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ namespace App\Console\Commands;
|
|||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
use App\Models\MarketData;
|
use App\Models\MarketData;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class RefreshMarketData extends Command
|
class RefreshMarketData extends Command
|
||||||
{
|
{
|
||||||
@@ -61,7 +60,7 @@ class RefreshMarketData extends Command
|
|||||||
try {
|
try {
|
||||||
MarketData::getMarketData($holding->symbol, $force);
|
MarketData::getMarketData($holding->symbol, $force);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('Could not refresh '.$holding->symbol.' ('.$e->getMessage().')');
|
$this->line('Could not refresh '.$holding->symbol.' ('.$e->getMessage().')');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,12 +47,13 @@ class ConfigSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// reinvested holdings
|
// reinvested holdings
|
||||||
Holding::myHoldings()->where('reinvest_dividends', true)->get()->each(function ($holding) use (&$configs) {
|
$reinvested_holdings = Holding::myHoldings()->where('reinvest_dividends', true)->get(['portfolio_id', 'symbol']);
|
||||||
|
if ($reinvested_holdings->isNotEmpty()) {
|
||||||
$configs->push([
|
$configs->push([
|
||||||
'key' => 'reinvested_dividends',
|
'key' => 'reinvested_dividends',
|
||||||
'value' => $holding->id,
|
'value' => $reinvested_holdings->toJson(),
|
||||||
]);
|
]);
|
||||||
});
|
}
|
||||||
|
|
||||||
return $configs;
|
return $configs;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,8 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
'Portfolio ID',
|
'Portfolio ID',
|
||||||
'Total Market Value',
|
'Total Market Value',
|
||||||
'Total Cost Basis',
|
'Total Cost Basis',
|
||||||
'Total Gain',
|
|
||||||
'Total Dividends Earned',
|
|
||||||
'Realized Gains',
|
'Realized Gains',
|
||||||
|
'Total Dividends Earned',
|
||||||
'Annotation',
|
'Annotation',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Number;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Foundation\Events\LocaleUpdated;
|
use Illuminate\Support\Number;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class LocalizationMiddleware
|
class LocalizationMiddleware
|
||||||
{
|
{
|
||||||
@@ -24,9 +23,7 @@ class LocalizationMiddleware
|
|||||||
|
|
||||||
$locale = auth()->user()->getLocale();
|
$locale = auth()->user()->getLocale();
|
||||||
|
|
||||||
config(['app.locale' => $locale]);
|
app()->setLocale(Str::before($locale, '_'));
|
||||||
app('translator')->setLocale(Str::before($locale, '_'));
|
|
||||||
app('events')->dispatch(new LocaleUpdated($locale));
|
|
||||||
|
|
||||||
Number::useLocale($locale);
|
Number::useLocale($locale);
|
||||||
Number::useCurrency(auth()->user()->getCurrency());
|
Number::useCurrency(auth()->user()->getCurrency());
|
||||||
|
|||||||
@@ -36,31 +36,35 @@ class ConfigSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadi
|
|||||||
|
|
||||||
public function collection(Collection $configs)
|
public function collection(Collection $configs)
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
foreach ($configs as $config) {
|
foreach ($configs as $config) {
|
||||||
|
|
||||||
switch ($config['key']) {
|
switch ($config['key']) {
|
||||||
case 'name':
|
case 'name':
|
||||||
$user->name = $config['value'];
|
$this->backupImport->user->setAttribute('name', $config['value']);
|
||||||
$user->save();
|
$this->backupImport->user->save();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'locale':
|
case 'locale':
|
||||||
$user->setOption('locale', $config['value']);
|
$this->backupImport->user->setOption('locale', $config['value']);
|
||||||
$user->save();
|
$this->backupImport->user->save();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'display_currency':
|
case 'display_currency':
|
||||||
$user->setOption('display_currency', $config['value']);
|
$this->backupImport->user->setOption('display_currency', $config['value']);
|
||||||
$user->save();
|
$this->backupImport->user->save();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'reinvest_dividends':
|
case 'reinvested_dividends':
|
||||||
|
if (json_validate($config['value'])) {
|
||||||
Holding::myHoldings()->where('id', $config['value'])->update([
|
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,
|
'reinvest_dividends' => true,
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
// if has any transactions not in base currency, need to sync timeseries conversion rates
|
// if has any transactions not in base currency, need to sync timeseries conversion rates
|
||||||
if ($transactions->where('currency', '!=', config('investbrain.base_currency'))->isNotEmpty()) {
|
if ($transactions->where('currency', '!=', config('investbrain.base_currency'))->isNotEmpty()) {
|
||||||
|
|
||||||
CurrencyRate::timeSeriesRates('', $transactions->min('date'));
|
CurrencyRate::timeSeriesRates('', $transactions->min('date'), $transactions->max('date'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$totalBatches = count($transactions) / $this->batchSize();
|
$totalBatches = count($transactions) / $this->batchSize();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ class AlphaVantageMarketData implements MarketDataInterface
|
|||||||
? Arr::get($fundamental, 'DividendDate')
|
? Arr::get($fundamental, 'DividendDate')
|
||||||
: null,
|
: null,
|
||||||
'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None'
|
'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None'
|
||||||
? Arr::get($fundamental, 'DividendYield') * 100
|
? ((float) Arr::get($fundamental, 'DividendYield')) * 100
|
||||||
: null,
|
: null,
|
||||||
'meta_data' => [
|
'meta_data' => [
|
||||||
'industry' => Arr::get($fundamental, 'Industry'),
|
'industry' => Arr::get($fundamental, 'Industry'),
|
||||||
@@ -145,7 +145,7 @@ class AlphaVantageMarketData implements MarketDataInterface
|
|||||||
return [$date => new Ohlc([
|
return [$date => new Ohlc([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'close' => Arr::get($history, '4. close'),
|
'close' => (float) Arr::get($history, '4. close'),
|
||||||
])];
|
])];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 @@ class YahooMarketData implements MarketDataInterface
|
|||||||
{
|
{
|
||||||
|
|
||||||
// create yahoo finance client factory
|
// 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
|
public function exists(string $symbol): bool
|
||||||
|
|||||||
@@ -50,4 +50,9 @@ class BackupImport extends Model
|
|||||||
'completed_at' => 'datetime',
|
'completed_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-31
@@ -111,7 +111,7 @@ class CurrencyRate extends Model
|
|||||||
*
|
*
|
||||||
* @return array<string, float>
|
* @return array<string, float>
|
||||||
*/
|
*/
|
||||||
public static function timeSeriesRates(string $currency, mixed $start = null, mixed $end = null): array
|
public static function timeSeriesRates(string|array|null $currency = null, mixed $start = null, mixed $end = null): array
|
||||||
{
|
{
|
||||||
if (empty($start)) {
|
if (empty($start)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -132,24 +132,40 @@ class CurrencyRate extends Model
|
|||||||
return $dateRange;
|
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);
|
[$currency, $adjustment] = self::getCurrencyAliasAdjustments($currency);
|
||||||
|
|
||||||
$currencies = Currency::all()->pluck('currency')->toArray();
|
} else {
|
||||||
|
|
||||||
// call api in chunks
|
$currency = Currency::all()->pluck('currency')->toArray();
|
||||||
$rates = [];
|
|
||||||
foreach (collect($period)->chunk(500) as $chunk) {
|
|
||||||
|
|
||||||
$chunkRates = Frankfurter::setSymbols($currencies)->timeSeries($chunk->min(), $chunk->max());
|
|
||||||
|
|
||||||
$rates = array_merge($rates, Arr::get($chunkRates, 'rates', []));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// loop through each date
|
||||||
$updates = [];
|
$updates = [];
|
||||||
foreach ($period as $date) {
|
foreach ($period as $date) {
|
||||||
|
|
||||||
$lookupDate = self::getNearestPastDate($date, $rates);
|
$lookupDate = self::getNearestPastDate($date, $datesOnly, $rates);
|
||||||
|
|
||||||
if (is_null($lookupDate)) {
|
if (is_null($lookupDate)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -172,42 +188,53 @@ class CurrencyRate extends Model
|
|||||||
// persist
|
// persist
|
||||||
self::chunkInsert($updates);
|
self::chunkInsert($updates);
|
||||||
|
|
||||||
|
if (is_string($currency)) {
|
||||||
|
|
||||||
return collect($updates)
|
return collect($updates)
|
||||||
->whereBetween('date', [$start, $end ?? now()])
|
->whereBetween('date', [$start, $end ?? now()])
|
||||||
->where('currency', $currency)
|
->where('currency', $currency)
|
||||||
->mapWithKeys(fn ($rate) => [
|
->mapWithKeys(fn ($rate) => [
|
||||||
$rate['date'] => $rate['rate'] * $adjustment,
|
$rate['date'] => $rate['rate'] * ($adjustment ?? 1),
|
||||||
])
|
])
|
||||||
->toArray();
|
->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function getNearestPastDate(CarbonInterface $date, array $rates): ?CarbonInterface
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function getNearestPastDate(CarbonInterface $date, array $datesOnly, array $rates): ?CarbonInterface
|
||||||
{
|
{
|
||||||
$datesWithRates = array_keys($rates);
|
|
||||||
sort($datesWithRates);
|
// 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)
|
// get rates or find closest valid rate (handles missing weekend rates)
|
||||||
while (! isset($rates[$date->toDateString()])) {
|
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?
|
// is this the start of a range that falls on a weekend?
|
||||||
if ($date->lessThan($first_date = Carbon::parse($datesWithRates[0]))) {
|
if ($mutableDate->lessThan($firstDate)) {
|
||||||
|
|
||||||
$date = $first_date;
|
return $firstDate;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// try the day before then
|
// try the day before then
|
||||||
$date = Carbon::parse($date)->subDay();
|
$mutableDate = $mutableDate->subDay();
|
||||||
|
|
||||||
// prevent runaway infinite loops
|
|
||||||
if ($date->lessThan($date->copy()->subWeek())) {
|
|
||||||
|
|
||||||
$date = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $date;
|
return $mutableDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function refreshCurrencyData($force = false): void
|
public static function refreshCurrencyData($force = false): void
|
||||||
@@ -248,15 +275,13 @@ class CurrencyRate extends Model
|
|||||||
public static function chunkInsert(array $updates): void
|
public static function chunkInsert(array $updates): void
|
||||||
{
|
{
|
||||||
|
|
||||||
$chunks = array_chunk($updates, 250);
|
foreach (array_chunk($updates, 500) as $chunk) {
|
||||||
|
|
||||||
foreach ($chunks as $chunk) {
|
|
||||||
|
|
||||||
QueuedCurrencyRateInsertJob::dispatch($chunk);
|
QueuedCurrencyRateInsertJob::dispatch($chunk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function getCurrencyAliasAdjustments($currency)
|
protected static function getCurrencyAliasAdjustments(string $currency)
|
||||||
{
|
{
|
||||||
$adjustment = 1;
|
$adjustment = 1;
|
||||||
|
|
||||||
|
|||||||
+66
-98
@@ -32,7 +32,7 @@ class DailyChange extends Model
|
|||||||
'date' => 'datetime',
|
'date' => 'datetime',
|
||||||
'total_market_value' => 'float',
|
'total_market_value' => 'float',
|
||||||
'total_cost_basis' => 'float',
|
'total_cost_basis' => 'float',
|
||||||
'total_gain' => 'float',
|
'total_market_gain' => 'float',
|
||||||
'realized_gain_dollars' => 'float',
|
'realized_gain_dollars' => 'float',
|
||||||
'total_dividends_earned' => 'float',
|
'total_dividends_earned' => 'float',
|
||||||
];
|
];
|
||||||
@@ -42,9 +42,9 @@ class DailyChange extends Model
|
|||||||
return $query->where('daily_change.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) {
|
$query->whereHas('users', function ($query) {
|
||||||
return $query->where('id', auth()->id());
|
return $query->where('id', auth()->id());
|
||||||
});
|
});
|
||||||
@@ -86,113 +86,81 @@ class DailyChange extends Model
|
|||||||
AS total_dividends_earned")
|
AS total_dividends_earned")
|
||||||
->groupBy(['holdings.portfolio_id', 'dividends.date', 'tx.transaction_type', 'tx.quantity']);
|
->groupBy(['holdings.portfolio_id', 'dividends.date', 'tx.transaction_type', 'tx.quantity']);
|
||||||
|
|
||||||
$totalCostBasisSub = DB::table('transactions as tx1')
|
$transactionTotals = DB::table('transactions')
|
||||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
->select(['transactions.portfolio_id', 'transactions.date'])
|
||||||
$join->on('cr.date', '=', 'tx1.date')
|
->selectRaw("
|
||||||
->where('cr.currency', '=', $currency);
|
SUM(
|
||||||
})
|
(CASE WHEN transactions.transaction_type = 'BUY' THEN 1 ELSE -1 END)
|
||||||
->select([
|
* transactions.quantity
|
||||||
'tx1.portfolio_id',
|
* transactions.cost_basis_base
|
||||||
'tx1.date',
|
|
||||||
'tx1.symbol',
|
|
||||||
'tx1.transaction_type',
|
|
||||||
'tx1.quantity',
|
|
||||||
])
|
|
||||||
->selectRaw("(CASE
|
|
||||||
WHEN tx1.transaction_type = 'BUY'
|
|
||||||
THEN COALESCE(cr.rate, 1)
|
|
||||||
ELSE (
|
|
||||||
SELECT
|
|
||||||
SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base)
|
|
||||||
/ SUM(buy.cost_basis_base)
|
|
||||||
FROM transactions as buy
|
|
||||||
LEFT JOIN currency_rates as cr2
|
|
||||||
ON cr2.date = buy.date
|
|
||||||
AND cr2.currency = '{$currency}'
|
|
||||||
WHERE buy.symbol = tx1.symbol
|
|
||||||
AND buy.portfolio_id = tx1.portfolio_id
|
|
||||||
AND buy.transaction_type = 'BUY'
|
|
||||||
AND buy.date <= tx1.date
|
|
||||||
) END)
|
|
||||||
AS rate")
|
|
||||||
->selectRaw(
|
|
||||||
"(CASE
|
|
||||||
WHEN tx1.transaction_type = 'BUY'
|
|
||||||
THEN AVG(tx1.cost_basis_base)
|
|
||||||
ELSE (
|
|
||||||
SELECT
|
|
||||||
AVG(-buy.cost_basis_base)
|
|
||||||
FROM transactions as buy
|
|
||||||
WHERE buy.symbol = tx1.symbol
|
|
||||||
AND buy.portfolio_id = tx1.portfolio_id
|
|
||||||
AND buy.transaction_type = 'BUY'
|
|
||||||
AND buy.date <= tx1.date
|
|
||||||
) END)
|
|
||||||
AS cost_basis_base")
|
|
||||||
->selectRaw(
|
|
||||||
"(CASE
|
|
||||||
WHEN tx1.transaction_type = 'SELL'
|
|
||||||
THEN tx1.sale_price_base - tx1.cost_basis_base
|
|
||||||
ELSE 0 END)
|
|
||||||
* tx1.quantity
|
|
||||||
* COALESCE(cr.rate, 1)
|
* COALESCE(cr.rate, 1)
|
||||||
AS realized_gain_dollars")
|
) AS daily_cost_basis
|
||||||
->groupBy([
|
")
|
||||||
'tx1.portfolio_id',
|
->selectRaw("
|
||||||
'tx1.date',
|
SUM(
|
||||||
'tx1.symbol',
|
(CASE
|
||||||
'tx1.transaction_type',
|
WHEN transactions.transaction_type = 'SELL'
|
||||||
'tx1.cost_basis_base',
|
THEN ( transactions.sale_price_base - transactions.cost_basis_base )
|
||||||
'tx1.quantity',
|
* transactions.quantity
|
||||||
'cr.rate',
|
* COALESCE(cr.rate, 1)
|
||||||
'tx1.sale_price_base',
|
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
|
return $query
|
||||||
->select(['daily_change.date', 'daily_change.portfolio_id'])
|
->select(['daily_change.portfolio_id', 'daily_change.date'])
|
||||||
->leftJoinSub($totalCostBasisSub, 'cost_basis_display', function ($join) {
|
->selectRaw('daily_change.total_market_value * COALESCE(cr.rate, 1) AS total_market_value')
|
||||||
$join->on('daily_change.date', '>=', 'cost_basis_display.date')
|
->selectRaw('SUM(COALESCE(ccb.cumulative_cost_basis, 0)) AS total_cost_basis')
|
||||||
->whereColumn('daily_change.portfolio_id', '=', 'cost_basis_display.portfolio_id');
|
->selectRaw('daily_change.total_market_value * COALESCE(cr.rate, 1)
|
||||||
})
|
- SUM(COALESCE(ccb.cumulative_cost_basis, 0))
|
||||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
AS total_market_gain')
|
||||||
$join->on('cr.date', '=', 'daily_change.date')
|
->selectRaw('SUM(COALESCE(ccb.cumulative_realized_gains, 0)) AS realized_gain_dollars')
|
||||||
->where('cr.currency', '=', $currency);
|
|
||||||
})
|
|
||||||
->selectRaw('
|
|
||||||
SUM(
|
|
||||||
cost_basis_display.cost_basis_base
|
|
||||||
* cost_basis_display.quantity
|
|
||||||
* cost_basis_display.rate
|
|
||||||
) as total_cost_basis')
|
|
||||||
->selectRaw('(
|
|
||||||
daily_change.total_market_value * COALESCE(cr.rate, 1)
|
|
||||||
) - SUM(
|
|
||||||
cost_basis_display.cost_basis_base
|
|
||||||
* cost_basis_display.quantity
|
|
||||||
* cost_basis_display.rate
|
|
||||||
) as total_gain')
|
|
||||||
->selectRaw('(
|
|
||||||
daily_change.total_market_value * COALESCE(cr.rate, 1)
|
|
||||||
) as total_market_value')
|
|
||||||
->selectRaw('
|
|
||||||
SUM(
|
|
||||||
cost_basis_display.realized_gain_dollars
|
|
||||||
) as realized_gain_dollars')
|
|
||||||
->selectSub(function ($query) use ($dividendSub) {
|
->selectSub(function ($query) use ($dividendSub) {
|
||||||
$query->fromSub($dividendSub, 'd')
|
$query->fromSub($dividendSub, 'd')
|
||||||
->selectRaw('SUM(d.total_dividends_earned)')
|
->selectRaw('SUM(d.total_dividends_earned)')
|
||||||
->whereColumn('d.date', '<=', 'daily_change.date')
|
->whereColumn('d.date', '<=', 'daily_change.date')
|
||||||
->whereColumn('d.portfolio_id', '=', 'daily_change.portfolio_id');
|
->whereColumn('d.portfolio_id', '=', 'daily_change.portfolio_id');
|
||||||
}, 'total_dividends_earned')
|
}, 'total_dividends_earned')
|
||||||
->groupBy([
|
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||||
'daily_change.date',
|
$join
|
||||||
'cr.rate',
|
->on(DB::raw('DATE(cr.date)'), '=', DB::raw('DATE(daily_change.date)'))
|
||||||
'daily_change.total_market_value',
|
->where('cr.currency', $currency);
|
||||||
'daily_change.portfolio_id',
|
})
|
||||||
])
|
->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');
|
->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()
|
public function portfolio()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Portfolio::class);
|
return $this->belongsTo(Portfolio::class);
|
||||||
|
|||||||
+10
-4
@@ -105,20 +105,25 @@ class Dividend extends Model
|
|||||||
|
|
||||||
$market_data = MarketData::getMarketData($symbol);
|
$market_data = MarketData::getMarketData($symbol);
|
||||||
|
|
||||||
|
$dividend_data
|
||||||
|
->chunk(10)
|
||||||
|
->each(function ($chunk) use ($market_data) {
|
||||||
|
|
||||||
// get historic conversion rates
|
// get historic conversion rates
|
||||||
$rate_to_base = CurrencyRate::timeSeriesRates($market_data->currency, $start_date, $end_date);
|
$rate_to_base = CurrencyRate::timeSeriesRates($market_data->currency, $chunk->min('date'), $chunk->max('date'));
|
||||||
|
|
||||||
// create mass insert
|
// create mass insert
|
||||||
foreach ($dividend_data as $index => $dividend) {
|
foreach ($chunk as $index => $dividend) {
|
||||||
$rate_to_base_date = 1 / Arr::get($rate_to_base, Carbon::parse(Arr::get($dividend, 'date'))->toDateString(), 1);
|
$rate_to_base_date = 1 / Arr::get($rate_to_base, Carbon::parse(Arr::get($dividend, 'date'))->toDateString(), 1);
|
||||||
|
|
||||||
$dividend['dividend_amount_base'] = $dividend['dividend_amount'] * $rate_to_base_date;
|
$dividend['dividend_amount_base'] = $dividend['dividend_amount'] * $rate_to_base_date;
|
||||||
|
|
||||||
$dividend_data[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
|
$chunk[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
|
||||||
}
|
}
|
||||||
|
|
||||||
// insert records
|
// insert records
|
||||||
(new self)->insertOrIgnore($dividend_data->toArray());
|
(new self)->insertOrIgnore($chunk->toArray());
|
||||||
|
});
|
||||||
|
|
||||||
// sync to holdings
|
// sync to holdings
|
||||||
self::syncHoldings($symbol);
|
self::syncHoldings($symbol);
|
||||||
@@ -186,6 +191,7 @@ class Dividend extends Model
|
|||||||
'date' => $dividend['date'],
|
'date' => $dividend['date'],
|
||||||
'portfolio_id' => $holding->portfolio_id,
|
'portfolio_id' => $holding->portfolio_id,
|
||||||
'symbol' => $holding->symbol,
|
'symbol' => $holding->symbol,
|
||||||
|
'currency' => $holding->market_data->currency,
|
||||||
'transaction_type' => 'BUY',
|
'transaction_type' => 'BUY',
|
||||||
'reinvested_dividend' => true,
|
'reinvested_dividend' => true,
|
||||||
'cost_basis' => 0,
|
'cost_basis' => 0,
|
||||||
|
|||||||
+123
-87
@@ -39,7 +39,7 @@ class Holding extends Model
|
|||||||
'total_cost_basis' => 'float',
|
'total_cost_basis' => 'float',
|
||||||
'realized_gain_dollars' => 'float',
|
'realized_gain_dollars' => 'float',
|
||||||
'dividends_earned' => 'float',
|
'dividends_earned' => 'float',
|
||||||
'total_gain_dollars' => 'float',
|
'total_market_gain_dollars' => 'float',
|
||||||
'market_gain_dollars' => 'float',
|
'market_gain_dollars' => 'float',
|
||||||
'total_market_value' => 'float',
|
'total_market_value' => 'float',
|
||||||
'total_dividends_earned' => 'float',
|
'total_dividends_earned' => 'float',
|
||||||
@@ -228,7 +228,7 @@ class Holding extends Model
|
|||||||
return collect([
|
return collect([
|
||||||
'total_cost_basis' => $result->sum('total_cost_basis'),
|
'total_cost_basis' => $result->sum('total_cost_basis'),
|
||||||
'total_market_value' => $result->sum('total_market_value'),
|
'total_market_value' => $result->sum('total_market_value'),
|
||||||
'total_gain_dollars' => $result->sum('total_gain_dollars'),
|
'total_market_gain_dollars' => $result->sum('total_market_gain_dollars'),
|
||||||
'realized_gain_dollars' => $result->sum('realized_gain_dollars'),
|
'realized_gain_dollars' => $result->sum('realized_gain_dollars'),
|
||||||
'total_dividends_earned' => $result->sum('total_dividends_earned'),
|
'total_dividends_earned' => $result->sum('total_dividends_earned'),
|
||||||
]);
|
]);
|
||||||
@@ -243,50 +243,17 @@ class Holding extends Model
|
|||||||
{
|
{
|
||||||
$currency = $currency ?? auth()->user()->getCurrency();
|
$currency = $currency ?? auth()->user()->getCurrency();
|
||||||
|
|
||||||
return $query->select([
|
$cost_basis_sub = DB::table('transactions')
|
||||||
'holdings.symbol',
|
|
||||||
'holdings.portfolio_id',
|
|
||||||
'transactions_display.total_cost_basis',
|
|
||||||
'transactions_display.realized_gain_dollars',
|
|
||||||
'dividends_display.total_dividends_earned',
|
|
||||||
])
|
|
||||||
->groupBy([
|
|
||||||
'holdings.symbol',
|
|
||||||
'holdings.quantity',
|
|
||||||
'holdings.portfolio_id',
|
|
||||||
'cr.rate',
|
|
||||||
'transactions_display.total_cost_basis',
|
|
||||||
'transactions_display.realized_gain_dollars',
|
|
||||||
'dividends_display.total_dividends_earned',
|
|
||||||
'market_data.market_value_base',
|
|
||||||
])
|
|
||||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||||
$join->where('cr.currency', '=', $currency);
|
$join
|
||||||
|
->on('cr.date', '=', 'transactions.date')
|
||||||
if (config('database.default') === 'sqlite') {
|
|
||||||
|
|
||||||
$join->whereRaw("strftime('%Y-%m-%d', cr.date) = ?", [now()->toDateString()]);
|
|
||||||
} else {
|
|
||||||
|
|
||||||
$join->on('cr.date', '=', DB::raw("'".now()->toDateString()."'"));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
->leftJoin('market_data', function ($join) {
|
|
||||||
$join->on('market_data.symbol', '=', 'holdings.symbol');
|
|
||||||
})
|
|
||||||
->selectRaw(
|
|
||||||
'holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1) AS total_market_value'
|
|
||||||
)
|
|
||||||
->selectRaw('(
|
|
||||||
holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1)
|
|
||||||
) - transactions_display.total_cost_basis as total_gain_dollars')
|
|
||||||
->leftJoinSub(
|
|
||||||
DB::table('transactions')
|
|
||||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
|
||||||
$join->on('cr.date', '=', 'transactions.date')
|
|
||||||
->where('cr.currency', '=', $currency);
|
->where('cr.currency', '=', $currency);
|
||||||
})
|
})
|
||||||
->select(['transactions.symbol', 'transactions.portfolio_id'])
|
->select([
|
||||||
|
'transactions.id',
|
||||||
|
'transactions.symbol',
|
||||||
|
'transactions.portfolio_id',
|
||||||
|
])
|
||||||
->leftJoinSub(
|
->leftJoinSub(
|
||||||
DB::table('transactions')
|
DB::table('transactions')
|
||||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||||
@@ -298,12 +265,16 @@ class Holding extends Model
|
|||||||
'transactions.symbol',
|
'transactions.symbol',
|
||||||
'transactions.portfolio_id',
|
'transactions.portfolio_id',
|
||||||
'transactions.quantity',
|
'transactions.quantity',
|
||||||
|
'transactions.cost_basis_base',
|
||||||
'transactions.date',
|
'transactions.date',
|
||||||
])
|
])
|
||||||
->selectRaw(
|
->selectRaw("
|
||||||
"(CASE
|
(CASE
|
||||||
WHEN transactions.transaction_type = 'BUY'
|
WHEN
|
||||||
THEN COALESCE(cr.rate, 1)
|
transactions.transaction_type = 'BUY'
|
||||||
|
OR SUM(transactions.cost_basis_base) = 0
|
||||||
|
THEN
|
||||||
|
COALESCE(cr.rate, 1)
|
||||||
ELSE (
|
ELSE (
|
||||||
SELECT
|
SELECT
|
||||||
SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base)
|
SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base)
|
||||||
@@ -317,68 +288,125 @@ class Holding extends Model
|
|||||||
AND buy.transaction_type = 'BUY'
|
AND buy.transaction_type = 'BUY'
|
||||||
AND buy.date <= transactions.date
|
AND buy.date <= transactions.date
|
||||||
) END)
|
) END)
|
||||||
AS rate"
|
AS rate")
|
||||||
)
|
|
||||||
->selectRaw(
|
|
||||||
"(CASE
|
|
||||||
WHEN transactions.transaction_type = 'BUY'
|
|
||||||
THEN AVG(transactions.cost_basis_base)
|
|
||||||
ELSE (
|
|
||||||
SELECT
|
|
||||||
AVG(-buy.cost_basis_base)
|
|
||||||
FROM transactions as buy
|
|
||||||
WHERE buy.symbol = transactions.symbol
|
|
||||||
AND buy.portfolio_id = transactions.portfolio_id
|
|
||||||
AND buy.transaction_type = 'BUY'
|
|
||||||
AND buy.date <= transactions.date
|
|
||||||
) END)
|
|
||||||
AS cost_basis_base"
|
|
||||||
)
|
|
||||||
->groupBy([
|
->groupBy([
|
||||||
|
'transactions.id',
|
||||||
'transactions.symbol',
|
'transactions.symbol',
|
||||||
'transactions.date',
|
'transactions.date',
|
||||||
'transactions.portfolio_id',
|
'transactions.portfolio_id',
|
||||||
'transactions.transaction_type',
|
'transactions.transaction_type',
|
||||||
|
'transactions.cost_basis_base',
|
||||||
'transactions.quantity',
|
'transactions.quantity',
|
||||||
'cr.rate',
|
'cr.rate',
|
||||||
]), 'cost_basis_display', function ($join) {
|
]),
|
||||||
$join->on('transactions.symbol', '=', 'cost_basis_display.symbol')
|
'cost_basis_display',
|
||||||
->on('transactions.portfolio_id', '=', 'cost_basis_display.portfolio_id')
|
|
||||||
->on('transactions.date', '=', 'cost_basis_display.date');
|
|
||||||
})
|
|
||||||
->selectRaw(
|
|
||||||
"SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN (transactions.sale_price_base - transactions.cost_basis_base) * transactions.quantity * COALESCE(cr.rate, 1) ELSE 0 END) AS realized_gain_dollars"
|
|
||||||
)
|
|
||||||
->selectRaw(
|
|
||||||
'SUM(cost_basis_display.cost_basis_base * cost_basis_display.quantity * cost_basis_display.rate) AS total_cost_basis'
|
|
||||||
)
|
|
||||||
->groupBy(['transactions.symbol', 'transactions.portfolio_id']),
|
|
||||||
'transactions_display',
|
|
||||||
function ($join) {
|
function ($join) {
|
||||||
$join->on('holdings.symbol', '=', 'transactions_display.symbol')
|
$join
|
||||||
->on('holdings.portfolio_id', '=', 'transactions_display.portfolio_id');
|
->on('transactions.symbol', '=', 'cost_basis_display.symbol')
|
||||||
|
->on(
|
||||||
|
'transactions.portfolio_id',
|
||||||
|
'=',
|
||||||
|
'cost_basis_display.portfolio_id'
|
||||||
|
)
|
||||||
|
->on('transactions.date', '=', 'cost_basis_display.date');
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
->leftJoinSub(
|
->selectRaw(
|
||||||
DB::table('dividends')
|
"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('transactions as tx', function ($join) {
|
||||||
$join->on('tx.symbol', '=', 'dividends.symbol')
|
$join
|
||||||
|
->on('tx.symbol', '=', 'dividends.symbol')
|
||||||
->on('tx.date', '<=', 'dividends.date');
|
->on('tx.date', '<=', 'dividends.date');
|
||||||
})
|
})
|
||||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||||
$join->on('cr.date', '=', 'dividends.date')
|
$join
|
||||||
|
->on('cr.date', '=', 'dividends.date')
|
||||||
->where('cr.currency', '=', $currency);
|
->where('cr.currency', '=', $currency);
|
||||||
})
|
})
|
||||||
->select(['dividends.symbol'])
|
->select(['dividends.symbol', 'tx.portfolio_id'])
|
||||||
->selectRaw(
|
->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"
|
"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']),
|
->groupBy(['dividends.symbol', 'tx.portfolio_id']);
|
||||||
'dividends_display',
|
|
||||||
|
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) {
|
function ($join) {
|
||||||
$join->on('holdings.symbol', '=', 'dividends_display.symbol');
|
$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()
|
public function syncTransactionsAndDividends()
|
||||||
@@ -393,6 +421,14 @@ class Holding extends Model
|
|||||||
->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN (quantity * cost_basis) ELSE 0 END) AS total_cost_basis")
|
->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN (quantity * cost_basis) ELSE 0 END) AS total_cost_basis")
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
// 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);
|
$total_quantity = round($query->qty_purchases - $query->qty_sales, 4);
|
||||||
|
|
||||||
$average_cost_basis = (
|
$average_cost_basis = (
|
||||||
|
|||||||
@@ -152,7 +152,13 @@ class Portfolio extends Model
|
|||||||
|
|
||||||
$total_performance = [];
|
$total_performance = [];
|
||||||
|
|
||||||
$holdings->each(function ($holding) use (&$total_performance) {
|
// 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(
|
$period = CarbonPeriod::create(
|
||||||
$holding->first_transaction_date,
|
$holding->first_transaction_date,
|
||||||
@@ -163,7 +169,6 @@ class Portfolio extends Model
|
|||||||
|
|
||||||
$daily_performance = $holding->dailyPerformance($holding->first_transaction_date, now());
|
$daily_performance = $holding->dailyPerformance($holding->first_transaction_date, now());
|
||||||
$all_history = app(MarketDataInterface::class)->history($holding->symbol, $holding->first_transaction_date, now());
|
$all_history = app(MarketDataInterface::class)->history($holding->symbol, $holding->first_transaction_date, now());
|
||||||
$currency_rates = CurrencyRate::timeSeriesRates($holding->market_data->currency, $holding->first_transaction_date, now());
|
|
||||||
|
|
||||||
$holding_performance = [];
|
$holding_performance = [];
|
||||||
|
|
||||||
@@ -179,7 +184,7 @@ class Portfolio extends Model
|
|||||||
$holding_performance[$date] = [
|
$holding_performance[$date] = [
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'portfolio_id' => $this->id,
|
'portfolio_id' => $this->id,
|
||||||
'total_market_value' => $total_market_value * (1 / Arr::get($currency_rates, $date, 1)),
|
'total_market_value' => $total_market_value * (1 / Arr::get($currency_rates[$holding->market_data->currency], $date, 1)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,6 +218,9 @@ class Portfolio extends Model
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cache()->forget('graph-YTD-'.$this->id);
|
||||||
|
cache()->forget('graph-YTD-'.request()->user()?->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getMostRecentCloseData($history, $date, $i = 0, $max_attempts = 5)
|
protected function getMostRecentCloseData($history, $date, $i = 0, $max_attempts = 5)
|
||||||
|
|||||||
+2
-2
@@ -99,12 +99,12 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
return Arr::get($this->options, 'locale') ?? request()->getPreferredLanguage($available_locales) ?? config('app.locale');
|
return Arr::get($this->options, 'locale') ?? request()->getPreferredLanguage($available_locales) ?? config('app.locale');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setOption(mixed $key, string $value): self
|
public function setOption(mixed $key, ?string $value = null): self
|
||||||
{
|
{
|
||||||
|
|
||||||
$options = is_array($key) ? $key : [$key => $value];
|
$options = is_array($key) ? $key : [$key => $value];
|
||||||
|
|
||||||
$this->user->options = array_merge($this->user->options ?? [], $options);
|
$this->options = array_merge($this->options ?? [], $options);
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\Currency;
|
|
||||||
|
|
||||||
if (! function_exists('currency')) {
|
|
||||||
|
|
||||||
// /**
|
|
||||||
// * Returns an instance of the currency model
|
|
||||||
// * */
|
|
||||||
// function currency(): Currency
|
|
||||||
// {
|
|
||||||
// return new Currency;
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
+3
-5
@@ -24,8 +24,9 @@
|
|||||||
"openai-php/client": "^0.10.3",
|
"openai-php/client": "^0.10.3",
|
||||||
"predis/predis": "^2.2",
|
"predis/predis": "^2.2",
|
||||||
"robsontenorio/mary": "^1.35",
|
"robsontenorio/mary": "^1.35",
|
||||||
"scheb/yahoo-finance-api": "^4.11",
|
"scheb/yahoo-finance-api": "^5.0",
|
||||||
"staudenmeir/eloquent-has-many-deep": "^1.20",
|
"staudenmeir/eloquent-has-many-deep": "^1.20",
|
||||||
|
"symfony/cache": "^7.3",
|
||||||
"tschucki/alphavantage-laravel": "^0.0"
|
"tschucki/alphavantage-laravel": "^0.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
@@ -34,7 +35,7 @@
|
|||||||
"laravel/sail": "^1.26",
|
"laravel/sail": "^1.26",
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
"nunomaduro/collision": "^8.0",
|
"nunomaduro/collision": "^8.0",
|
||||||
"phpunit/phpunit": "^11.0.1"
|
"phpunit/phpunit": "^11.0"
|
||||||
},
|
},
|
||||||
"repositories": [
|
"repositories": [
|
||||||
{
|
{
|
||||||
@@ -54,9 +55,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"files": [
|
|
||||||
"app/Support/Helpers.php"
|
|
||||||
],
|
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"App\\": "app/",
|
"App\\": "app/",
|
||||||
"Database\\Factories\\": "database/factories/",
|
"Database\\Factories\\": "database/factories/",
|
||||||
|
|||||||
Generated
+945
-543
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'),
|
||||||
|
];
|
||||||
@@ -11,7 +11,9 @@ return [
|
|||||||
'interfaces' => [
|
'interfaces' => [
|
||||||
'yahoo' => App\Interfaces\MarketData\YahooMarketData::class,
|
'yahoo' => App\Interfaces\MarketData\YahooMarketData::class,
|
||||||
'alphavantage' => App\Interfaces\MarketData\AlphaVantageMarketData::class,
|
'alphavantage' => App\Interfaces\MarketData\AlphaVantageMarketData::class,
|
||||||
|
'alpaca' => App\Interfaces\MarketData\AlpacaMarketData::class,
|
||||||
'finnhub' => App\Interfaces\MarketData\FinnhubMarketData::class,
|
'finnhub' => App\Interfaces\MarketData\FinnhubMarketData::class,
|
||||||
|
'twelvedata' => App\Interfaces\MarketData\TwelveDataMarketData::class,
|
||||||
'fake' => App\Interfaces\MarketData\FakeMarketData::class,
|
'fake' => App\Interfaces\MarketData\FakeMarketData::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'secret' => env('TWELVEDATA_API_SECRET'),
|
||||||
|
];
|
||||||
@@ -59,6 +59,20 @@ class TransactionFactory extends Factory
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
public function recent(): static
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Models\CurrencyRate;
|
use App\Models\CurrencyRate;
|
||||||
|
use App\Models\Holding;
|
||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
use Database\Seeders\CurrencySeeder;
|
use Database\Seeders\CurrencySeeder;
|
||||||
use Database\Seeders\MarketDataSeeder;
|
use Database\Seeders\MarketDataSeeder;
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Query\Expression;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -23,10 +25,15 @@ return new class extends Migration
|
|||||||
* Add options column to users table
|
* Add options column to users table
|
||||||
*/
|
*/
|
||||||
Schema::table('users', function (Blueprint $table) {
|
Schema::table('users', function (Blueprint $table) {
|
||||||
$table->json('options')->default(json_encode([
|
|
||||||
'locale' => config('app.locale', 'en'),
|
$locale = config('app.locale', 'en');
|
||||||
'display_currency' => config('investbrain.base_currency', 'USD'),
|
$currency = config('investbrain.base_currency', 'USD');
|
||||||
]))->after('profile_photo_path');
|
|
||||||
|
$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');
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,17 +103,17 @@ return new class extends Migration
|
|||||||
'--force' => true,
|
'--force' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
CurrencyRate::timeSeriesRates(
|
|
||||||
'', // use fake currency to force
|
|
||||||
Transaction::min('date')
|
|
||||||
);
|
|
||||||
|
|
||||||
CurrencyRate::refreshCurrencyData();
|
|
||||||
|
|
||||||
Artisan::call('db:seed', [
|
Artisan::call('db:seed', [
|
||||||
'--class' => MarketDataSeeder::class,
|
'--class' => MarketDataSeeder::class,
|
||||||
'--force' => true,
|
'--force' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
CurrencyRate::timeSeriesRates(
|
||||||
|
Holding::all()->groupBy('market_data.currency')->keys()->toArray(),
|
||||||
|
Transaction::min('date')
|
||||||
|
);
|
||||||
|
|
||||||
|
CurrencyRate::refreshCurrencyData();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ class MarketDataSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
use WithoutModelEvents;
|
use WithoutModelEvents;
|
||||||
|
|
||||||
public array $rows = [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the database seeds.
|
* Run the database seeds.
|
||||||
*/
|
*/
|
||||||
@@ -44,7 +42,7 @@ class MarketDataSeeder extends Seeder
|
|||||||
$meta_data = json_decode(base64_decode($data['meta_data']), true);
|
$meta_data = json_decode(base64_decode($data['meta_data']), true);
|
||||||
$meta_data['source'] = 'market_data_seeder';
|
$meta_data['source'] = 'market_data_seeder';
|
||||||
|
|
||||||
$this->rows[] = [
|
$rows[] = [
|
||||||
'symbol' => $data['symbol'],
|
'symbol' => $data['symbol'],
|
||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
'currency' => $data['currency'],
|
'currency' => $data['currency'],
|
||||||
@@ -54,16 +52,17 @@ class MarketDataSeeder extends Seeder
|
|||||||
$rowCount++;
|
$rowCount++;
|
||||||
|
|
||||||
if ($rowCount % $chunkSize == 0) {
|
if ($rowCount % $chunkSize == 0) {
|
||||||
DB::table('market_data')->upsert($this->rows, ['symbol'], ['name', 'currency', 'meta_data']);
|
$this->bulkInsert($rows);
|
||||||
$this->rows = [];
|
$rows = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// final clean up
|
// final clean up
|
||||||
if (! empty($this->rows)) {
|
if (! empty($rows)) {
|
||||||
|
|
||||||
$this->bulkInsert($this->rows);
|
$this->bulkInsert($rows);
|
||||||
|
$rows = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the CSV file
|
// Close the CSV file
|
||||||
@@ -77,16 +76,17 @@ class MarketDataSeeder extends Seeder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function bulkInsert(array $rows)
|
private function bulkInsert($rows): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
||||||
DB::table('market_data')->insertOrIgnore($rows);
|
DB::table('market_data')->upsert($rows, ['symbol'], ['name', 'currency', 'meta_data']);
|
||||||
$this->rows = [];
|
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|
||||||
throw new \Exception('Error: '.$e->getMessage());
|
throw new \Exception('Error: '.$e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gc_collect_cycles();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+15
-15
@@ -11,9 +11,9 @@ services:
|
|||||||
- 8000:80
|
- 8000:80
|
||||||
environment: # You can either use these properties OR an .env file. Do not use both!
|
environment: # You can either use these properties OR an .env file. Do not use both!
|
||||||
APP_URL: "http://localhost:8000"
|
APP_URL: "http://localhost:8000"
|
||||||
DB_CONNECTION: mysql
|
DB_CONNECTION: pgsql
|
||||||
DB_HOST: investbrain-mysql
|
DB_HOST: investbrain-pgsql
|
||||||
DB_PORT: 3306
|
DB_PORT: 5432
|
||||||
DB_DATABASE: investbrain
|
DB_DATABASE: investbrain
|
||||||
DB_USERNAME: investbrain
|
DB_USERNAME: investbrain
|
||||||
DB_PASSWORD: investbrain
|
DB_PASSWORD: investbrain
|
||||||
@@ -25,7 +25,7 @@ services:
|
|||||||
- investbrain-storage:/var/app/storage # You can use a volume...
|
- investbrain-storage:/var/app/storage # You can use a volume...
|
||||||
# - /path/to/storage:/var/app/storage:delegated # ...or you can use a path on host
|
# - /path/to/storage:/var/app/storage:delegated # ...or you can use a path on host
|
||||||
depends_on:
|
depends_on:
|
||||||
- mysql
|
- pgsql
|
||||||
- redis
|
- redis
|
||||||
networks:
|
networks:
|
||||||
- investbrain-network
|
- investbrain-network
|
||||||
@@ -40,22 +40,22 @@ services:
|
|||||||
- investbrain-redis:/data
|
- investbrain-redis:/data
|
||||||
networks:
|
networks:
|
||||||
- investbrain-network
|
- investbrain-network
|
||||||
mysql:
|
pgsql:
|
||||||
image: mysql:8.0
|
image: postgres:15-alpine
|
||||||
container_name: investbrain-mysql
|
container_name: investbrain-pgsql
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
MYSQL_DATABASE: ${DB_DATABASE:-investbrain}
|
POSTGRES_DB: ${DB_DATABASE:-investbrain}
|
||||||
MYSQL_USER: ${DB_USERNAME:-investbrain}
|
POSTGRES_USER: ${DB_USERNAME:-investbrain}
|
||||||
MYSQL_PASSWORD: ${DB_PASSWORD:-investbrain}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-investbrain}
|
||||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-investbrain}
|
command: postgres -c log_min_messages=error
|
||||||
command:
|
|
||||||
- --cte-max-recursion-depth=25000
|
|
||||||
volumes:
|
volumes:
|
||||||
- investbrain-mysql:/var/lib/mysql
|
- investbrain-pgsql:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- investbrain-network
|
- investbrain-network
|
||||||
volumes:
|
volumes:
|
||||||
investbrain-storage:
|
investbrain-storage:
|
||||||
investbrain-redis:
|
investbrain-redis:
|
||||||
investbrain-mysql:
|
investbrain-pgsql:
|
||||||
|
|||||||
+4
-1
@@ -44,6 +44,9 @@ FROM php:8.3-fpm-alpine
|
|||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /var/app
|
WORKDIR /var/app
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
ENV VERSION=$VERSION
|
||||||
|
|
||||||
# Copy necessary files from the builder stage
|
# Copy necessary files from the builder stage
|
||||||
COPY --from=builder /var/app /var/app
|
COPY --from=builder /var/app /var/app
|
||||||
COPY --from=builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d
|
COPY --from=builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d
|
||||||
@@ -62,7 +65,7 @@ RUN apk add --no-cache \
|
|||||||
bash \
|
bash \
|
||||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||||
&& docker-php-ext-install -j$(nproc) \
|
&& 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
|
# Remove default nginx config
|
||||||
RUN rm -rf /var/www/html \
|
RUN rm -rf /var/www/html \
|
||||||
|
|||||||
+14
-3
@@ -3,7 +3,8 @@
|
|||||||
cd /var/app
|
cd /var/app
|
||||||
|
|
||||||
# Starting Investbrain
|
# 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... ====================== "
|
echo -e "\n====================== Validating environment... ====================== "
|
||||||
|
|
||||||
@@ -54,7 +55,6 @@ RETRIES=12
|
|||||||
DELAY=5
|
DELAY=5
|
||||||
run_migrations() {
|
run_migrations() {
|
||||||
sleep $DELAY
|
sleep $DELAY
|
||||||
# php artisan migrate --force
|
|
||||||
output=$(php artisan migrate --force 2>/dev/null)
|
output=$(php artisan migrate --force 2>/dev/null)
|
||||||
if [[ $? -eq 0 ]]; then
|
if [[ $? -eq 0 ]]; then
|
||||||
echo "$output"
|
echo "$output"
|
||||||
@@ -72,7 +72,18 @@ until run_migrations; do
|
|||||||
echo -e "\n > Waiting for database to be ready... retrying in $DELAY seconds. \n"
|
echo -e "\n > Waiting for database to be ready... retrying in $DELAY seconds. \n"
|
||||||
done
|
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"
|
echo -e "\n====================== Spinning up Supervisor daemon... ====================== \n"
|
||||||
|
|
||||||
exec supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
exec supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -26,7 +26,7 @@ new class extends Component
|
|||||||
<x-icon name="o-bars-3" class="cursor-pointer" />
|
<x-icon name="o-bars-3" class="cursor-pointer" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="hidden md:block" style="height:3.1em">
|
<div class="hidden md:block" style="height:2.5em">
|
||||||
<x-application-logo />
|
<x-application-logo />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ new class extends Component
|
|||||||
|
|
||||||
<x-menu-item title="{{ __('Log Out') }}" icon="o-power" onclick="event.preventDefault(); document.getElementById('logout').submit();" />
|
<x-menu-item title="{{ __('Log Out') }}" icon="o-power" onclick="event.preventDefault(); document.getElementById('logout').submit();" />
|
||||||
<form id="logout" action="{{ route('logout') }}" method="POST" style="display: none;">
|
<form id="logout" action="{{ route('logout') }}" method="POST" style="display: none;">
|
||||||
{{ csrf_field() }}
|
@csrf
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</x-dropdown>
|
</x-dropdown>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<div class="grid sm:grid-cols-5 gap-5">
|
<div class="grid sm:grid-cols-5 gap-5">
|
||||||
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
||||||
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Market Gain/Loss') }}</div>
|
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Market Gain/Loss') }}</div>
|
||||||
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_gain_dollars', 0)) }} </div>
|
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_market_gain_dollars', 0)) }} </div>
|
||||||
</x-card>
|
</x-card>
|
||||||
|
|
||||||
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use Livewire\Volt\Component;
|
|||||||
new class extends Component
|
new class extends Component
|
||||||
{
|
{
|
||||||
// props
|
// props
|
||||||
public ?Portfolio $portfolio;
|
public ?Portfolio $portfolio = null;
|
||||||
|
|
||||||
public string $name = 'portfolio';
|
public string $name = 'portfolio';
|
||||||
|
|
||||||
@@ -53,45 +53,49 @@ new class extends Component
|
|||||||
$dailyChangeQuery->whereDate('daily_change.date', '>=', now()->{$filterMethod['method']}(...$filterMethod['args']));
|
$dailyChangeQuery->whereDate('daily_change.date', '>=', now()->{$filterMethod['method']}(...$filterMethod['args']));
|
||||||
}
|
}
|
||||||
|
|
||||||
$dailyChange = $dailyChangeQuery->get();
|
$dailyChange = cache()->remember(
|
||||||
|
'graph-'.$this->scope.'-'.(isset($this->portfolio) ? $this->portfolio->id : request()->user()->id),
|
||||||
|
10,
|
||||||
|
function () use ($dailyChangeQuery) {
|
||||||
|
return $dailyChangeQuery->withMultipleDailyPerformance()->get();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
$dailyChange = $dailyChange
|
$marketValueData = [];
|
||||||
->sortBy('date')
|
$costBasisData = [];
|
||||||
->groupBy('date')
|
$marketGainData = [];
|
||||||
->map(function ($group) {
|
|
||||||
return (object) [
|
foreach ($dailyChange as $data) {
|
||||||
'date' => $group->first()->date->toDateString(),
|
$date = $data->date;
|
||||||
'total_market_value' => $group->sum('total_market_value'),
|
$marketValueData[] = [$date, round($data->total_market_value, 2)];
|
||||||
'total_cost_basis' => $group->sum('total_cost_basis'),
|
$costBasisData[] = [$date, round($data->total_cost_basis, 2)];
|
||||||
'total_gain' => $group->sum('total_gain'),
|
$marketGainData[] = [$date, round($data->total_market_gain, 2)];
|
||||||
'realized_gain_dollars' => $group->sum('realized_gain_dollars'),
|
// $dividendSeries[] = [$date, round($data->total_dividends_earned, 2)];
|
||||||
'total_dividends_earned' => $group->sum('total_dividends_earned'),
|
// $realizedGainSeries[] = [$date, round($data->realized_gains, 2)];
|
||||||
];
|
}
|
||||||
})
|
|
||||||
->values();
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'series' => [
|
'series' => [
|
||||||
[
|
[
|
||||||
'name' => __('Market Value'),
|
'name' => __('Market Value'),
|
||||||
'data' => $dailyChange->map(fn ($data) => [$data->date, $data->total_market_value])->toArray(),
|
'data' => $marketValueData,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => __('Cost Basis'),
|
'name' => __('Cost Basis'),
|
||||||
'data' => $dailyChange->map(fn ($data) => [$data->date, $data->total_cost_basis])->toArray(),
|
'data' => $costBasisData,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => __('Market Gain'),
|
'name' => __('Market Gain'),
|
||||||
'data' => $dailyChange->map(fn ($data) => [$data->date, $data->total_gain])->toArray(),
|
'data' => $marketGainData,
|
||||||
],
|
],
|
||||||
|
|
||||||
// [
|
// [
|
||||||
// 'name' => __('Dividends Earned'),
|
// 'name' => __('Dividends Earned'),
|
||||||
// 'data' => $dailyChange->map(fn($data) => [$data->date, $data->total_dividends_earned])->toArray()
|
// 'data' => $dividendSeries
|
||||||
// ],
|
// ],
|
||||||
// [
|
// [
|
||||||
// 'name' => __('Realized Gains'),
|
// 'name' => __('Realized Gains'),
|
||||||
// 'data' => $dailyChange->map(fn($data) => [$data->date, $data->realized_gains])->toArray()
|
// 'data' => $realizedGainSeries
|
||||||
// ],
|
// ],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@@ -101,6 +105,8 @@ new class extends Component
|
|||||||
{
|
{
|
||||||
$this->scope = $scope;
|
$this->scope = $scope;
|
||||||
|
|
||||||
|
cache()->forget('graph-'.$this->scope.'-'.(isset($this->portfolio) ? $this->portfolio->id : request()->user()->id));
|
||||||
|
|
||||||
$this->chartSeries = $this->generatePerformanceData();
|
$this->chartSeries = $this->generatePerformanceData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
|
|
||||||
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
||||||
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Market Gain/Loss') }}</div>
|
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Market Gain/Loss') }}</div>
|
||||||
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_gain_dollars', 0)) }} </div>
|
<div class="font-black text-xl"> {{ Number::currency($metrics->get('total_market_gain_dollars', 0)) }} </div>
|
||||||
</x-card>
|
</x-card>
|
||||||
|
|
||||||
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ new class extends Component
|
|||||||
$transaction->transaction_type == 'BUY'
|
$transaction->transaction_type == 'BUY'
|
||||||
? $transaction->cost_basis
|
? $transaction->cost_basis
|
||||||
: $transaction->sale_price,
|
: $transaction->sale_price,
|
||||||
$transaction->market_data->currency
|
$transaction->market_data?->currency
|
||||||
) }})
|
) }})
|
||||||
|
|
||||||
<x-loading x-show="loading" x-cloak class="text-gray-400 ml-2" />
|
<x-loading x-show="loading" x-cloak class="text-gray-400 ml-2" />
|
||||||
|
|||||||
Binary file not shown.
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace Tests;
|
namespace Tests;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
class AuthenticationTest extends TestCase
|
class AuthenticationTest extends TestCase
|
||||||
@@ -14,15 +13,17 @@ class AuthenticationTest extends TestCase
|
|||||||
|
|
||||||
public function test_first_user_is_admin(): void
|
public function test_first_user_is_admin(): void
|
||||||
{
|
{
|
||||||
$this->post('/register', [
|
$response = $this->post('/register', [
|
||||||
'name' => 'should_be_admin',
|
'name' => 'should_be_admin',
|
||||||
'email' => 'should_be_admin@example.net',
|
'email' => 'should_be_admin@example.net',
|
||||||
'password' => 'password',
|
'password' => 'password',
|
||||||
'password_confirmation' => 'password',
|
'password_confirmation' => 'password',
|
||||||
|
'terms' => 1,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$should_be_admin = User::where(['email' => 'should_be_admin@example.net'])->first();
|
$should_be_admin = User::where(['email' => 'should_be_admin@example.net'])->first();
|
||||||
|
|
||||||
|
$this->assertModelExists($should_be_admin);
|
||||||
$this->assertTrue($should_be_admin->admin);
|
$this->assertTrue($should_be_admin->admin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,10 +36,12 @@ class AuthenticationTest extends TestCase
|
|||||||
'email' => 'not_admin@example.net',
|
'email' => 'not_admin@example.net',
|
||||||
'password' => 'password',
|
'password' => 'password',
|
||||||
'password_confirmation' => 'password',
|
'password_confirmation' => 'password',
|
||||||
|
'terms' => 1,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$not_admin = User::where(['email' => 'not_admin@example.net'])->first();
|
$not_admin = User::where(['email' => 'not_admin@example.net'])->first();
|
||||||
|
|
||||||
|
$this->assertModelExists($not_admin);
|
||||||
$this->assertNotTrue($not_admin->admin);
|
$this->assertNotTrue($not_admin->admin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ class DailyChangeTest extends TestCase
|
|||||||
$daily_change = DailyChange::withDailyPerformance()
|
$daily_change = DailyChange::withDailyPerformance()
|
||||||
->portfolio($this->portfolio->id)
|
->portfolio($this->portfolio->id)
|
||||||
->whereDate('daily_change.date', '=', $sale_transaction->date->copy()->nextWeekday())
|
->whereDate('daily_change.date', '=', $sale_transaction->date->copy()->nextWeekday())
|
||||||
|
// ->withMultipleDailyPerformance()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
$realized_gain = ($sale_transaction->sale_price - $sale_transaction->cost_basis) * $sale_transaction->quantity;
|
$realized_gain = ($sale_transaction->sale_price - $sale_transaction->cost_basis) * $sale_transaction->quantity;
|
||||||
@@ -206,13 +207,13 @@ class DailyChangeTest extends TestCase
|
|||||||
$portfolio = Portfolio::factory()->create();
|
$portfolio = Portfolio::factory()->create();
|
||||||
|
|
||||||
// 1. test daily change will fill to the date of first transaction
|
// 1. test daily change will fill to the date of first transaction
|
||||||
$first_transaction = Transaction::factory(5)->buy()->lastMonth()->portfolio($portfolio->id)->symbol('AAPL')->create();
|
$transactions = Transaction::factory(5)->buy()->lastMonth()->portfolio($portfolio->id)->symbol('AAPL')->create();
|
||||||
|
|
||||||
$portfolio->syncDailyChanges();
|
$portfolio->syncDailyChanges();
|
||||||
|
|
||||||
$first_date = DailyChange::min('date');
|
$first_date = DailyChange::min('date');
|
||||||
|
|
||||||
$this->assertEquals($first_transaction->min('date')->toDateString(), $first_date);
|
$this->assertEquals($transactions->min('date')->next(Carbon::MONDAY)->startOfDay()->toDateString(), $first_date);
|
||||||
|
|
||||||
// 2. test daily change will fill when new transaction pre-dates earliest daily change
|
// 2. test daily change will fill when new transaction pre-dates earliest daily change
|
||||||
config()->set('app.env', 'local');
|
config()->set('app.env', 'local');
|
||||||
@@ -221,7 +222,7 @@ class DailyChangeTest extends TestCase
|
|||||||
$second_transaction = Transaction::create([
|
$second_transaction = Transaction::create([
|
||||||
'symbol' => 'AAPL',
|
'symbol' => 'AAPL',
|
||||||
'portfolio_id' => $portfolio->id,
|
'portfolio_id' => $portfolio->id,
|
||||||
'date' => now()->subYears(3),
|
'date' => now()->subDays(1080), // 3 years
|
||||||
'quantity' => 1,
|
'quantity' => 1,
|
||||||
'cost_basis' => 39.89,
|
'cost_basis' => 39.89,
|
||||||
'transaction_type' => 'BUY',
|
'transaction_type' => 'BUY',
|
||||||
@@ -229,7 +230,7 @@ class DailyChangeTest extends TestCase
|
|||||||
|
|
||||||
$second_date = DailyChange::min('date');
|
$second_date = DailyChange::min('date');
|
||||||
|
|
||||||
$this->assertEquals($second_transaction->date->toDateString(), $second_date);
|
$this->assertEquals($second_transaction->date->next(Carbon::MONDAY)->toDateString(), $second_date);
|
||||||
|
|
||||||
// 3. test daily change will fill when new transaction is between earliest daily change and earliest transaction
|
// 3. test daily change will fill when new transaction is between earliest daily change and earliest transaction
|
||||||
$third_transaction = Transaction::create([
|
$third_transaction = Transaction::create([
|
||||||
|
|||||||
@@ -70,4 +70,23 @@ class DividendsTest extends TestCase
|
|||||||
|
|
||||||
$this->assertEquals(3, $dividend_count);
|
$this->assertEquals(3, $dividend_count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_dividend_earnings_are_not_shared_between_portfolios(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($user = User::factory()->create());
|
||||||
|
|
||||||
|
$portfolioOne = Portfolio::factory()->create();
|
||||||
|
Transaction::factory()->buy()->yearsAgo()->portfolio($portfolioOne->id)->symbol('ACME')->create();
|
||||||
|
|
||||||
|
$portfolioTwo = Portfolio::factory()->create();
|
||||||
|
Transaction::factory(2)->buy()->sixMonthsAgo()->portfolio($portfolioTwo->id)->symbol('ACME')->create();
|
||||||
|
|
||||||
|
Dividend::refreshDividendData('ACME');
|
||||||
|
|
||||||
|
$holdingOne = Holding::query()->portfolio($portfolioOne->id)->symbol('ACME')->first();
|
||||||
|
$holdingTwo = Holding::query()->portfolio($portfolioTwo->id)->symbol('ACME')->first();
|
||||||
|
|
||||||
|
$this->assertEquals(4.95, $holdingOne->dividends_earned);
|
||||||
|
$this->assertEquals(8, $holdingTwo->dividends_earned);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests;
|
||||||
|
|
||||||
|
use App\Models\Holding;
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
class HoldingsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_calculates_cost_basis(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($user = User::factory()->create());
|
||||||
|
|
||||||
|
$portfolio = Portfolio::factory()->create();
|
||||||
|
|
||||||
|
Transaction::factory()->buy()->lastYear()->costBasis(200)->portfolio($portfolio->id)->symbol('AAPL')->create();
|
||||||
|
Transaction::factory()->buy()->lastMonth()->costBasis(300)->portfolio($portfolio->id)->symbol('AAPL')->create();
|
||||||
|
$holding = Holding::query()->getPortfolioMetrics();
|
||||||
|
$this->assertEquals(500, $holding->get('total_cost_basis'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_calculates_cost_basis_after_multiple_sales(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($user = User::factory()->create());
|
||||||
|
|
||||||
|
$portfolio = Portfolio::factory()->create();
|
||||||
|
|
||||||
|
Transaction::factory()->buy()->lastYear()->costBasis(200)->portfolio($portfolio->id)->symbol('AAPL')->create();
|
||||||
|
Transaction::factory()->buy()->lastMonth()->costBasis(300)->portfolio($portfolio->id)->symbol('AAPL')->create();
|
||||||
|
|
||||||
|
Transaction::factory()->sell()->recent()->costBasis(250)->portfolio($portfolio->id)->symbol('AAPL')->create();
|
||||||
|
$holding = Holding::query()->getPortfolioMetrics();
|
||||||
|
$this->assertEquals(250, $holding->get('total_cost_basis'));
|
||||||
|
|
||||||
|
Transaction::factory()->sell()->recent()->costBasis(250)->portfolio($portfolio->id)->symbol('AAPL')->create();
|
||||||
|
$holding = Holding::query()->getPortfolioMetrics();
|
||||||
|
$this->assertEquals(0, $holding->get('total_cost_basis'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_calculates_cost_bases_on_same_day_buy_sell_transaction(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($user = User::factory()->create());
|
||||||
|
|
||||||
|
$portfolio = Portfolio::factory()->create();
|
||||||
|
|
||||||
|
Transaction::factory(2)->buy()->lastYear()->costBasis(100)->portfolio($portfolio->id)->symbol('AAPL')->create();
|
||||||
|
Transaction::factory(2)->buy()->lastYear()->costBasis(300)->portfolio($portfolio->id)->symbol('AAPL')->create();
|
||||||
|
|
||||||
|
Transaction::factory()->sell()->lastYear()->portfolio($portfolio->id)->symbol('AAPL')->create();
|
||||||
|
Transaction::factory()->sell()->recent()->portfolio($portfolio->id)->symbol('AAPL')->create();
|
||||||
|
|
||||||
|
$holding = Holding::query()->getPortfolioMetrics();
|
||||||
|
$this->assertEquals(400, $holding->get('total_cost_basis'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_holding_on_sync_if_no_transactions(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($user = User::factory()->create());
|
||||||
|
|
||||||
|
$portfolio = Portfolio::factory()->create();
|
||||||
|
|
||||||
|
$transaction = Transaction::factory()->buy()->lastYear()->costBasis(100)->portfolio($portfolio->id)->symbol('AAPL')->create();
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('holdings', 1);
|
||||||
|
|
||||||
|
$transaction->delete();
|
||||||
|
|
||||||
|
$this->assertDatabaseEmpty('holdings');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,19 +78,20 @@ class ImportExportTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->actingAs($user = User::factory()->create());
|
$this->actingAs($user = User::factory()->create());
|
||||||
|
|
||||||
Portfolio::create([
|
$portfolio = Portfolio::create([
|
||||||
'id' => '9e792bb8-94e7-4ed3-b8cc-43b50d34c337',
|
'id' => '9e792bb8-94e7-4ed3-b8cc-43b50d34c337',
|
||||||
'title' => 'Test Portfolio',
|
'title' => 'Test Portfolio',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$holding = Holding::create([
|
$holding = Holding::create([
|
||||||
'id' => '9cf8a662-7347-49fb-b9de-0cc1430a8d1f',
|
'portfolio_id' => $portfolio->id,
|
||||||
'portfolio_id' => '9e792bb8-94e7-4ed3-b8cc-43b50d34c337',
|
|
||||||
'symbol' => 'ACME',
|
'symbol' => 'ACME',
|
||||||
'quantity' => 0,
|
'quantity' => 0,
|
||||||
'reinvest_dividends' => false,
|
'reinvest_dividends' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Transaction::factory()->buy()->lastYear()->costBasis(100)->portfolio($portfolio->id)->symbol('AAPL')->create();
|
||||||
|
|
||||||
$this->assertEquals(false, $holding->reinvest_dividends);
|
$this->assertEquals(false, $holding->reinvest_dividends);
|
||||||
|
|
||||||
BackupImportModel::create([
|
BackupImportModel::create([
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class MarketDataTest extends TestCase
|
|||||||
'--force' => true,
|
'--force' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertEquals(14464, MarketData::count('symbol'));
|
$this->assertEquals(13187, MarketData::count('symbol'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_can_get_quote_from_provider()
|
public function test_can_get_quote_from_provider()
|
||||||
|
|||||||
+108
-42
@@ -133,11 +133,8 @@ class MultiCurrencyTest extends TestCase
|
|||||||
$portfolio = Portfolio::factory()->create();
|
$portfolio = Portfolio::factory()->create();
|
||||||
$transaction = Transaction::factory()->buy()->lastYear()->portfolio($portfolio->id)->symbol('ACME')->create();
|
$transaction = Transaction::factory()->buy()->lastYear()->portfolio($portfolio->id)->symbol('ACME')->create();
|
||||||
|
|
||||||
$expected_num_calls = count(collect(CarbonPeriod::create($transaction->date, now()))->chunk(500));
|
|
||||||
|
|
||||||
Frankfurter::expects('setSymbols')
|
Frankfurter::expects('setSymbols')
|
||||||
->andReturnSelf()
|
->andReturnSelf();
|
||||||
->times($expected_num_calls);
|
|
||||||
Frankfurter::expects('timeSeries')
|
Frankfurter::expects('timeSeries')
|
||||||
->andReturn(['rates' => [
|
->andReturn(['rates' => [
|
||||||
now()->subDays(3)->toDateString() => [
|
now()->subDays(3)->toDateString() => [
|
||||||
@@ -152,8 +149,7 @@ class MultiCurrencyTest extends TestCase
|
|||||||
now()->toDateString() => [
|
now()->toDateString() => [
|
||||||
'ZZZ' => .01,
|
'ZZZ' => .01,
|
||||||
],
|
],
|
||||||
]])
|
]]);
|
||||||
->times($expected_num_calls);
|
|
||||||
|
|
||||||
CurrencyRate::timeSeriesRates(
|
CurrencyRate::timeSeriesRates(
|
||||||
'', // use fake currency to force
|
'', // use fake currency to force
|
||||||
@@ -229,8 +225,71 @@ class MultiCurrencyTest extends TestCase
|
|||||||
->andReturn(['rates' => $results]);
|
->andReturn(['rates' => $results]);
|
||||||
|
|
||||||
$result = CurrencyRate::timeSeriesRates('ZZZ', $start, $end);
|
$result = CurrencyRate::timeSeriesRates('ZZZ', $start, $end);
|
||||||
|
|
||||||
$this->assertEquals(count($period) - 1, count($result));
|
$this->assertEquals(count($period) - 1, count($result));
|
||||||
|
|
||||||
|
$result = CurrencyRate::all();
|
||||||
|
$this->assertEquals(count($period), count($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_get_time_series_rates_with_null_currency()
|
||||||
|
{
|
||||||
|
|
||||||
|
$start = now()->subWeeks(2);
|
||||||
|
$end = now();
|
||||||
|
|
||||||
|
$period = CarbonPeriod::create($start, $end);
|
||||||
|
|
||||||
|
// mock response from Frankfurter
|
||||||
|
$results = [];
|
||||||
|
collect($period->copy()->filter('isWeekday'))->each(function ($date) use (&$results) {
|
||||||
|
$date = $date->toDateString();
|
||||||
|
|
||||||
|
$results[$date] = [
|
||||||
|
'FOO' => random_int(10, 150) / 1000,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
Frankfurter::expects('setSymbols')
|
||||||
|
->andReturnSelf();
|
||||||
|
Frankfurter::expects('timeSeries')
|
||||||
|
->andReturn(['rates' => $results]);
|
||||||
|
|
||||||
|
$result = CurrencyRate::timeSeriesRates(null, $start, $end);
|
||||||
|
$this->assertEquals(0, count($result));
|
||||||
|
|
||||||
|
$result = CurrencyRate::all();
|
||||||
|
$this->assertEquals(count($period), count($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_get_time_series_rates_with_currencies()
|
||||||
|
{
|
||||||
|
|
||||||
|
$start = now()->subWeeks(2);
|
||||||
|
$end = now();
|
||||||
|
|
||||||
|
$period = CarbonPeriod::create($start, $end);
|
||||||
|
|
||||||
|
// mock response from Frankfurter
|
||||||
|
$results = [];
|
||||||
|
collect($period->copy()->filter('isWeekday'))->each(function ($date) use (&$results) {
|
||||||
|
$date = $date->toDateString();
|
||||||
|
|
||||||
|
$results[$date] = [
|
||||||
|
'FOO' => random_int(10, 150) / 1000,
|
||||||
|
'BAR' => random_int(10, 150) / 1000,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
Frankfurter::expects('setSymbols')
|
||||||
|
->andReturnSelf();
|
||||||
|
Frankfurter::expects('timeSeries')
|
||||||
|
->andReturn(['rates' => $results]);
|
||||||
|
|
||||||
|
$result = CurrencyRate::timeSeriesRates(null, $start, $end);
|
||||||
|
$this->assertEquals(0, count($result));
|
||||||
|
|
||||||
|
$result = CurrencyRate::all();
|
||||||
|
$this->assertEquals(count($period) * 2, count($result));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_time_series_rate_calls_are_chunked()
|
public function test_time_series_rate_calls_are_chunked()
|
||||||
@@ -252,11 +311,9 @@ class MultiCurrencyTest extends TestCase
|
|||||||
});
|
});
|
||||||
|
|
||||||
Frankfurter::expects('setSymbols')
|
Frankfurter::expects('setSymbols')
|
||||||
->andReturnSelf()
|
->andReturnSelf();
|
||||||
->times(4);
|
|
||||||
Frankfurter::expects('timeSeries')
|
Frankfurter::expects('timeSeries')
|
||||||
->andReturn(['rates' => $results])
|
->andReturn(['rates' => $results]);
|
||||||
->times(4);
|
|
||||||
|
|
||||||
CurrencyRate::timeSeriesRates('ZZZ', $start, $end);
|
CurrencyRate::timeSeriesRates('ZZZ', $start, $end);
|
||||||
}
|
}
|
||||||
@@ -487,28 +544,20 @@ class MultiCurrencyTest extends TestCase
|
|||||||
|
|
||||||
$this->actingAs($user = User::factory()->create());
|
$this->actingAs($user = User::factory()->create());
|
||||||
|
|
||||||
|
$monthAgo = now()->subMonth()->toDateString();
|
||||||
|
$fiveWeeksAgo = now()->subWeeks(5)->toDateString();
|
||||||
|
$fiveDaysAgo = now()->subDays(5)->toDateString();
|
||||||
|
|
||||||
$portfolio = Portfolio::factory()->create();
|
$portfolio = Portfolio::factory()->create();
|
||||||
Transaction::factory(5)->buy()->lastMonth()->portfolio($portfolio->id)->symbol('AAPL')->create();
|
Transaction::factory(5)->buy()->costBasis(100)->date($monthAgo)->portfolio($portfolio->id)->symbol('AAPL')->create();
|
||||||
Transaction::factory(5)->buy()->lastMonth()->portfolio($portfolio->id)->symbol('ACME')->create();
|
Transaction::factory(5)->buy()->costBasis(190)->date($fiveWeeksAgo)->portfolio($portfolio->id)->symbol('ACME')->create();
|
||||||
Transaction::factory()->sell()->recent()->portfolio($portfolio->id)->symbol('ACME')->create();
|
Transaction::factory()->sell()->date($fiveDaysAgo)->portfolio($portfolio->id)->symbol('ACME')->create();
|
||||||
|
|
||||||
$portfolio->syncDailyChanges();
|
$portfolio->syncDailyChanges();
|
||||||
|
|
||||||
$dailyChange = DailyChange::withDailyPerformance()
|
$dailyChange = DailyChange::withDailyPerformance()
|
||||||
->portfolio($portfolio->id)
|
->portfolio($portfolio->id)
|
||||||
->get()
|
->get();
|
||||||
->sortBy('date')
|
|
||||||
->groupBy('date')
|
|
||||||
->map(function ($group) {
|
|
||||||
return (object) [
|
|
||||||
'date' => $group->first()->date->toDateString(),
|
|
||||||
'total_market_value' => $group->sum('total_market_value'),
|
|
||||||
'total_cost_basis' => $group->sum('total_cost_basis'),
|
|
||||||
'total_gain' => $group->sum('total_gain'),
|
|
||||||
'realized_gain_dollars' => $group->sum('realized_gain_dollars'),
|
|
||||||
'total_dividends_earned' => $group->sum('total_dividends_earned'),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
$metrics = Holding::query()
|
$metrics = Holding::query()
|
||||||
->portfolio($portfolio->id)
|
->portfolio($portfolio->id)
|
||||||
@@ -516,8 +565,37 @@ class MultiCurrencyTest extends TestCase
|
|||||||
|
|
||||||
$this->assertEqualsWithDelta($metrics->get('total_market_value'), $dailyChange->last()->total_market_value, 0.01);
|
$this->assertEqualsWithDelta($metrics->get('total_market_value'), $dailyChange->last()->total_market_value, 0.01);
|
||||||
$this->assertEqualsWithDelta($metrics->get('total_cost_basis'), $dailyChange->last()->total_cost_basis, 0.01);
|
$this->assertEqualsWithDelta($metrics->get('total_cost_basis'), $dailyChange->last()->total_cost_basis, 0.01);
|
||||||
|
$this->assertEqualsWithDelta(Holding::get()->sum('total_cost_basis'), $dailyChange->last()->total_cost_basis, 0.01);
|
||||||
$this->assertEqualsWithDelta($metrics->get('realized_gain_dollars'), $dailyChange->last()->realized_gain_dollars, 0.01);
|
$this->assertEqualsWithDelta($metrics->get('realized_gain_dollars'), $dailyChange->last()->realized_gain_dollars, 0.01);
|
||||||
$this->assertEqualsWithDelta($metrics->get('total_market_value') - $metrics->get('total_cost_basis'), $dailyChange->last()->total_gain, 0.01);
|
$this->assertEqualsWithDelta($metrics->get('total_market_value') - $metrics->get('total_cost_basis'), $dailyChange->last()->total_market_gain, 0.01);
|
||||||
|
|
||||||
|
// add currency rates
|
||||||
|
$rates = collect([[
|
||||||
|
'currency' => 'GBP',
|
||||||
|
'rate' => .88,
|
||||||
|
'date' => $fiveWeeksAgo,
|
||||||
|
], [
|
||||||
|
'currency' => 'GBP',
|
||||||
|
'rate' => .88,
|
||||||
|
'date' => $fiveDaysAgo,
|
||||||
|
], [
|
||||||
|
'currency' => 'GBP',
|
||||||
|
'rate' => .88,
|
||||||
|
'date' => $monthAgo,
|
||||||
|
], [
|
||||||
|
'currency' => 'GBP',
|
||||||
|
'rate' => .88,
|
||||||
|
'date' => now()->subDay()->toDateString(),
|
||||||
|
], [
|
||||||
|
'currency' => 'GBP',
|
||||||
|
'rate' => .88,
|
||||||
|
'date' => now()->toDateString(),
|
||||||
|
], [
|
||||||
|
'currency' => 'GBP',
|
||||||
|
'rate' => .88,
|
||||||
|
'date' => now()->addDay()->toDateString(),
|
||||||
|
]]);
|
||||||
|
$rates->each(fn ($rate) => CurrencyRate::create($rate));
|
||||||
|
|
||||||
// switch user display currency
|
// switch user display currency
|
||||||
$user->options = array_merge($user->options ?? [], [
|
$user->options = array_merge($user->options ?? [], [
|
||||||
@@ -527,19 +605,7 @@ class MultiCurrencyTest extends TestCase
|
|||||||
|
|
||||||
$dailyChange = DailyChange::withDailyPerformance()
|
$dailyChange = DailyChange::withDailyPerformance()
|
||||||
->portfolio($portfolio->id)
|
->portfolio($portfolio->id)
|
||||||
->get()
|
->get();
|
||||||
->sortBy('date')
|
|
||||||
->groupBy('date')
|
|
||||||
->map(function ($group) {
|
|
||||||
return (object) [
|
|
||||||
'date' => $group->first()->date->toDateString(),
|
|
||||||
'total_market_value' => $group->sum('total_market_value'),
|
|
||||||
'total_cost_basis' => $group->sum('total_cost_basis'),
|
|
||||||
'total_gain' => $group->sum('total_gain'),
|
|
||||||
'realized_gain_dollars' => $group->sum('realized_gain_dollars'),
|
|
||||||
'total_dividends_earned' => $group->sum('total_dividends_earned'),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
$metrics = Holding::query()
|
$metrics = Holding::query()
|
||||||
->portfolio($portfolio->id)
|
->portfolio($portfolio->id)
|
||||||
@@ -548,7 +614,7 @@ class MultiCurrencyTest extends TestCase
|
|||||||
$this->assertEqualsWithDelta($metrics->get('total_market_value'), $dailyChange->last()->total_market_value, 0.01);
|
$this->assertEqualsWithDelta($metrics->get('total_market_value'), $dailyChange->last()->total_market_value, 0.01);
|
||||||
$this->assertEqualsWithDelta($metrics->get('total_cost_basis'), $dailyChange->last()->total_cost_basis, 0.01);
|
$this->assertEqualsWithDelta($metrics->get('total_cost_basis'), $dailyChange->last()->total_cost_basis, 0.01);
|
||||||
$this->assertEqualsWithDelta($metrics->get('realized_gain_dollars'), $dailyChange->last()->realized_gain_dollars, 0.01);
|
$this->assertEqualsWithDelta($metrics->get('realized_gain_dollars'), $dailyChange->last()->realized_gain_dollars, 0.01);
|
||||||
$this->assertEqualsWithDelta($metrics->get('total_market_value') - $metrics->get('total_cost_basis'), $dailyChange->last()->total_gain, 0.01);
|
$this->assertEqualsWithDelta($metrics->get('total_market_value') - $metrics->get('total_cost_basis'), $dailyChange->last()->total_market_gain, 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_multi_currency_import_calculates_correct_holding_data(): void
|
public function test_multi_currency_import_calculates_correct_holding_data(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user