Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -24,6 +24,9 @@ OPENAI_ORGANIZATION=
|
||||
MARKET_DATA_PROVIDER=yahoo
|
||||
ALPHAVANTAGE_API_KEY=
|
||||
FINNHUB_API_KEY=
|
||||
ALPACA_API_KEY=
|
||||
ALPACA_API_SECRET=
|
||||
TWELVEDATA_API_SECRET=
|
||||
|
||||
# Cadence to refresh market data (in minutes)
|
||||
MARKET_DATA_REFRESH=30
|
||||
|
||||
@@ -61,4 +61,6 @@ jobs:
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.extract-version.outputs.tags }}
|
||||
build-args: |
|
||||
VERSION=${{ github.ref_name }}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Investbrain is a smart open-source investment tracker that helps you manage, tra
|
||||
|
||||
## Under the hood
|
||||
|
||||
Investbrain is a Laravel PHP web application that leverages Livewire and Tailwind for its frontend. Most databases should work, including MySQL and SQLite. Out of the box, we feature three market data providers: [Yahoo Finance](https://finance.yahoo.com/), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), and [Alpha Vantage](https://www.alphavantage.co/support/). But we also offer an extensible market data provider interface for intrepid developers to create their own! We also offer integrations with OpenAI and Ollama for our ["chat with your holdings"](#chat-with-your-holdings) capability. Finally, of course we have robust support for i18n, a11y, and dark mode.
|
||||
Investbrain is a Laravel PHP web application that leverages Livewire and Tailwind for its frontend. Most databases should work, including MySQL and SQLite. Out of the box, we feature many market data providers. 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.
|
||||
|
||||
## Self hosting
|
||||
|
||||
@@ -74,7 +74,7 @@ Always keep in mind the limitations of LLMs. When in doubt, consult a licensed i
|
||||
|
||||
## Market data providers
|
||||
|
||||
Investbrain includes an extensible market data provider interface that allows you to retrieve stock market data from multiple providers, such as Yahoo Finance, Alpha Vantage, or Finnhub. The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails.
|
||||
Investbrain includes an extensible market data provider interface that allows you to retrieve stock market data from multiple providers, such as [Yahoo Finance](https://finance.yahoo.com/), [Twelve Data](https://twelvedata.com), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), [Alpaca](https://alpaca.markets/), and [Alpha Vantage](https://www.alphavantage.co/support/). The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails.
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -138,9 +138,12 @@ There are several optional configurations available when installing using the re
|
||||
| APP_URL | The URL where your Investbrain installation will be accessible | http://localhost |
|
||||
| APP_PORT | The HTTP port exposed by the NGINX container | 8000 |
|
||||
| APP_KEY | Must be set during install - encryption key for various security-related functions | `null` |
|
||||
| MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `alphavantage`, or `finnhub`) | yahoo |
|
||||
| MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `twelvedata`, `alphavantage`, `alpaca`, or `finnhub`) | yahoo |
|
||||
| ALPHAVANTAGE_API_KEY | If using the Alpha Vantage provider | `null` |
|
||||
| FINNHUB_API_KEY | If using the Finnhub provider | `null` |
|
||||
| ALPACA_API_KEY | If using the Alpaca provider | `null` |
|
||||
| ALPACA_API_SECRET | If using the Alpaca provider | `null` |
|
||||
| TWELVEDATA_API_SECRET | If using the Twelve Data provider | `null` |
|
||||
| MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 |
|
||||
| APP_TIMEZONE | Timezone for the application, including daily change captures | UTC |
|
||||
| AI_CHAT_ENABLED | Whether to enable AI chat features | `false` |
|
||||
|
||||
@@ -14,12 +14,18 @@ class EnsureCostBasisAddedToSale
|
||||
// cost basis is required for sales to calculate realized gains
|
||||
if ($model->transaction_type == 'SELL') {
|
||||
|
||||
$average_cost_basis = Transaction::where([
|
||||
$cost_basis = Transaction::where([
|
||||
'portfolio_id' => $model->portfolio_id,
|
||||
'symbol' => $model->symbol,
|
||||
'transaction_type' => 'BUY',
|
||||
])->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;
|
||||
}
|
||||
|
||||
@@ -49,16 +49,9 @@ class CaptureDailyChange extends Command
|
||||
->portfolio($portfolio->id)
|
||||
->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([
|
||||
'date' => now(),
|
||||
'total_market_value' => $total_market_value,
|
||||
'total_cost_basis' => $total_cost_basis,
|
||||
'total_gain' => $total_market_value - $total_cost_basis,
|
||||
'total_dividends_earned' => $metrics->get('total_dividends_earned'),
|
||||
'realized_gains' => $metrics->get('realized_gain_dollars'),
|
||||
'total_market_value' => $metrics->get('total_market_value'),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,9 +22,8 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
|
||||
'Portfolio ID',
|
||||
'Total Market Value',
|
||||
'Total Cost Basis',
|
||||
'Total Gain',
|
||||
'Total Dividends Earned',
|
||||
'Realized Gains',
|
||||
'Total Dividends Earned',
|
||||
'Annotation',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Number;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Foundation\Events\LocaleUpdated;
|
||||
use Illuminate\Support\Number;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LocalizationMiddleware
|
||||
{
|
||||
@@ -24,14 +23,12 @@ class LocalizationMiddleware
|
||||
|
||||
$locale = auth()->user()->getLocale();
|
||||
|
||||
config(['app.locale' => $locale]);
|
||||
app('translator')->setLocale(Str::before($locale, '_'));
|
||||
app('events')->dispatch(new LocaleUpdated($locale));
|
||||
app()->setLocale(Str::before($locale, '_'));
|
||||
|
||||
Number::useLocale($locale);
|
||||
Number::useCurrency(auth()->user()->getCurrency());
|
||||
}
|
||||
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<?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;
|
||||
|
||||
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->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');
|
||||
|
||||
if (is_null(Arr::get($quote, 'p'))) {
|
||||
throw new \Exception('Could not find ticker on Alpaca');
|
||||
}
|
||||
|
||||
$fundamental = cache()->remember(
|
||||
'ap-symbol-'.$symbol,
|
||||
1440,
|
||||
function () use ($symbol) {
|
||||
|
||||
$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
|
||||
{
|
||||
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
|
||||
'timeframe' => '1D',
|
||||
'start' => Carbon::parse($startDate)->format('Y-m-d'),
|
||||
'end' => Carbon::parse($endDate)->subHours(36)->format('Y-m-d'), // todo: can't query recent SIP data
|
||||
])->get("v2/stocks/{$symbol}/bars");
|
||||
|
||||
$history = $response->json('bars');
|
||||
|
||||
return collect($history)
|
||||
->map(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'),
|
||||
])];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ class AlphaVantageMarketData implements MarketDataInterface
|
||||
? Arr::get($fundamental, 'DividendDate')
|
||||
: null,
|
||||
'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None'
|
||||
? Arr::get($fundamental, 'DividendYield') * 100
|
||||
? ((float) Arr::get($fundamental, 'DividendYield')) * 100
|
||||
: null,
|
||||
'meta_data' => [
|
||||
'industry' => Arr::get($fundamental, 'Industry'),
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<?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;
|
||||
|
||||
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();
|
||||
|
||||
if (! isset($quote['price'])) {
|
||||
throw new \Exception('Could not find ticker on Twelve Data');
|
||||
}
|
||||
|
||||
$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');
|
||||
|
||||
$values = $response->json('values');
|
||||
|
||||
return collect($values)
|
||||
->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
|
||||
$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
|
||||
|
||||
@@ -134,9 +134,11 @@ class CurrencyRate extends Model
|
||||
|
||||
if (is_array($currency)) {
|
||||
|
||||
$i = 1;
|
||||
foreach ($currency as $curr) {
|
||||
|
||||
dispatch(fn () => self::timeSeriesRates($curr, $start, $end));
|
||||
dispatch(fn () => self::timeSeriesRates($curr, $start, $end))->delay(now()->addSeconds(30 * $i));
|
||||
$i++;
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
+65
-97
@@ -32,7 +32,7 @@ class DailyChange extends Model
|
||||
'date' => 'datetime',
|
||||
'total_market_value' => 'float',
|
||||
'total_cost_basis' => 'float',
|
||||
'total_gain' => 'float',
|
||||
'total_market_gain' => 'float',
|
||||
'realized_gain_dollars' => 'float',
|
||||
'total_dividends_earned' => 'float',
|
||||
];
|
||||
@@ -42,9 +42,9 @@ class DailyChange extends Model
|
||||
return $query->where('daily_change.portfolio_id', $portfolio);
|
||||
}
|
||||
|
||||
public function scopeMyDailyChanges()
|
||||
public function scopeMyDailyChanges($query)
|
||||
{
|
||||
return $this->whereHas('portfolio', function ($query) {
|
||||
return $query->whereHas('portfolio', function ($query) {
|
||||
$query->whereHas('users', function ($query) {
|
||||
return $query->where('id', auth()->id());
|
||||
});
|
||||
@@ -86,113 +86,81 @@ class DailyChange extends Model
|
||||
AS total_dividends_earned")
|
||||
->groupBy(['holdings.portfolio_id', 'dividends.date', 'tx.transaction_type', 'tx.quantity']);
|
||||
|
||||
$totalCostBasisSub = DB::table('transactions as tx1')
|
||||
$transactionTotals = DB::table('transactions')
|
||||
->select(['transactions.portfolio_id', 'transactions.date'])
|
||||
->selectRaw("
|
||||
SUM(
|
||||
(CASE WHEN transactions.transaction_type = 'BUY' THEN 1 ELSE -1 END)
|
||||
* transactions.quantity
|
||||
* transactions.cost_basis_base
|
||||
* COALESCE(cr.rate, 1)
|
||||
) AS daily_cost_basis
|
||||
")
|
||||
->selectRaw("
|
||||
SUM(
|
||||
(CASE
|
||||
WHEN transactions.transaction_type = 'SELL'
|
||||
THEN ( transactions.sale_price_base - transactions.cost_basis_base )
|
||||
* transactions.quantity
|
||||
* COALESCE(cr.rate, 1)
|
||||
END)
|
||||
) AS daily_realized_gains
|
||||
")
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join->on('cr.date', '=', 'tx1.date')
|
||||
->where('cr.currency', '=', $currency);
|
||||
$join
|
||||
->on(DB::raw('DATE(cr.date)'), '=', DB::raw('DATE(transactions.date)'))
|
||||
->where('cr.currency', $currency);
|
||||
})
|
||||
->select([
|
||||
'tx1.portfolio_id',
|
||||
'tx1.date',
|
||||
'tx1.symbol',
|
||||
'tx1.transaction_type',
|
||||
'tx1.quantity',
|
||||
])
|
||||
->selectRaw("(CASE
|
||||
WHEN tx1.transaction_type = 'BUY'
|
||||
THEN COALESCE(cr.rate, 1)
|
||||
ELSE (
|
||||
SELECT
|
||||
SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base)
|
||||
/ SUM(buy.cost_basis_base)
|
||||
FROM transactions as buy
|
||||
LEFT JOIN currency_rates as cr2
|
||||
ON cr2.date = buy.date
|
||||
AND cr2.currency = '{$currency}'
|
||||
WHERE buy.symbol = tx1.symbol
|
||||
AND buy.portfolio_id = tx1.portfolio_id
|
||||
AND buy.transaction_type = 'BUY'
|
||||
AND buy.date <= tx1.date
|
||||
) END)
|
||||
AS rate")
|
||||
->selectRaw(
|
||||
"(CASE
|
||||
WHEN tx1.transaction_type = 'BUY'
|
||||
THEN AVG(tx1.cost_basis_base)
|
||||
ELSE (
|
||||
SELECT
|
||||
AVG(-buy.cost_basis_base)
|
||||
FROM transactions as buy
|
||||
WHERE buy.symbol = tx1.symbol
|
||||
AND buy.portfolio_id = tx1.portfolio_id
|
||||
AND buy.transaction_type = 'BUY'
|
||||
AND buy.date <= tx1.date
|
||||
) END)
|
||||
AS cost_basis_base")
|
||||
->selectRaw(
|
||||
"(CASE
|
||||
WHEN tx1.transaction_type = 'SELL'
|
||||
THEN tx1.sale_price_base - tx1.cost_basis_base
|
||||
ELSE 0 END)
|
||||
* tx1.quantity
|
||||
* COALESCE(cr.rate, 1)
|
||||
AS realized_gain_dollars")
|
||||
->groupBy([
|
||||
'tx1.portfolio_id',
|
||||
'tx1.date',
|
||||
'tx1.symbol',
|
||||
'tx1.transaction_type',
|
||||
'tx1.cost_basis_base',
|
||||
'tx1.quantity',
|
||||
'cr.rate',
|
||||
'tx1.sale_price_base',
|
||||
]);
|
||||
->groupBy('transactions.portfolio_id', 'transactions.date');
|
||||
|
||||
$cumulativeCostBasis = DB::table(DB::raw("({$transactionTotals->toSql()}) AS transaction_totals"))
|
||||
->mergeBindings($transactionTotals)
|
||||
->select(['portfolio_id', 'date'])
|
||||
->selectRaw('SUM(daily_cost_basis) AS cumulative_cost_basis')
|
||||
->selectRaw('SUM(daily_realized_gains) AS cumulative_realized_gains')
|
||||
->groupBy('portfolio_id', 'date');
|
||||
|
||||
return $query
|
||||
->select(['daily_change.date', 'daily_change.portfolio_id'])
|
||||
->leftJoinSub($totalCostBasisSub, 'cost_basis_display', function ($join) {
|
||||
$join->on('daily_change.date', '>=', 'cost_basis_display.date')
|
||||
->whereColumn('daily_change.portfolio_id', '=', 'cost_basis_display.portfolio_id');
|
||||
})
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join->on('cr.date', '=', 'daily_change.date')
|
||||
->where('cr.currency', '=', $currency);
|
||||
})
|
||||
->selectRaw('
|
||||
SUM(
|
||||
cost_basis_display.cost_basis_base
|
||||
* cost_basis_display.quantity
|
||||
* cost_basis_display.rate
|
||||
) as total_cost_basis')
|
||||
->selectRaw('(
|
||||
daily_change.total_market_value * COALESCE(cr.rate, 1)
|
||||
) - SUM(
|
||||
cost_basis_display.cost_basis_base
|
||||
* cost_basis_display.quantity
|
||||
* cost_basis_display.rate
|
||||
) as total_gain')
|
||||
->selectRaw('(
|
||||
daily_change.total_market_value * COALESCE(cr.rate, 1)
|
||||
) as total_market_value')
|
||||
->selectRaw('
|
||||
SUM(
|
||||
cost_basis_display.realized_gain_dollars
|
||||
) as realized_gain_dollars')
|
||||
->select(['daily_change.portfolio_id', 'daily_change.date'])
|
||||
->selectRaw('daily_change.total_market_value * COALESCE(cr.rate, 1) AS total_market_value')
|
||||
->selectRaw('SUM(COALESCE(ccb.cumulative_cost_basis, 0)) AS total_cost_basis')
|
||||
->selectRaw('daily_change.total_market_value * COALESCE(cr.rate, 1)
|
||||
- SUM(COALESCE(ccb.cumulative_cost_basis, 0))
|
||||
AS total_market_gain')
|
||||
->selectRaw('SUM(COALESCE(ccb.cumulative_realized_gains, 0)) AS realized_gain_dollars')
|
||||
->selectSub(function ($query) use ($dividendSub) {
|
||||
$query->fromSub($dividendSub, 'd')
|
||||
->selectRaw('SUM(d.total_dividends_earned)')
|
||||
->whereColumn('d.date', '<=', 'daily_change.date')
|
||||
->whereColumn('d.portfolio_id', '=', 'daily_change.portfolio_id');
|
||||
}, 'total_dividends_earned')
|
||||
->groupBy([
|
||||
'daily_change.date',
|
||||
'cr.rate',
|
||||
'daily_change.total_market_value',
|
||||
'daily_change.portfolio_id',
|
||||
])
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join
|
||||
->on(DB::raw('DATE(cr.date)'), '=', DB::raw('DATE(daily_change.date)'))
|
||||
->where('cr.currency', $currency);
|
||||
})
|
||||
->leftJoinSub($cumulativeCostBasis, 'ccb', function ($join) {
|
||||
$join
|
||||
->on('ccb.portfolio_id', '=', 'daily_change.portfolio_id')
|
||||
->whereRaw('ccb.date <= daily_change.date');
|
||||
})
|
||||
->groupBy(['daily_change.date', 'daily_change.portfolio_id', 'cr.rate'])
|
||||
->orderBy('daily_change.date');
|
||||
}
|
||||
|
||||
public function scopeWithMultipleDailyPerformance($query)
|
||||
{
|
||||
return DB::table(DB::raw("({$query->toSql()}) AS daily_query"))
|
||||
->addBinding($query->getQuery()->getBindings(), 'join')
|
||||
->select('date')
|
||||
->selectRaw('SUM(total_market_value) AS total_market_value')
|
||||
->selectRaw('SUM(total_cost_basis) AS total_cost_basis')
|
||||
->selectRaw('SUM(total_market_gain) AS total_market_gain')
|
||||
->selectRaw('SUM(realized_gain_dollars) AS realized_gain_dollars')
|
||||
->selectRaw('SUM(total_dividends_earned) AS total_dividends_earned')
|
||||
->groupBy('date');
|
||||
}
|
||||
|
||||
public function portfolio()
|
||||
{
|
||||
return $this->belongsTo(Portfolio::class);
|
||||
|
||||
+143
-107
@@ -39,7 +39,7 @@ class Holding extends Model
|
||||
'total_cost_basis' => 'float',
|
||||
'realized_gain_dollars' => 'float',
|
||||
'dividends_earned' => 'float',
|
||||
'total_gain_dollars' => 'float',
|
||||
'total_market_gain_dollars' => 'float',
|
||||
'market_gain_dollars' => 'float',
|
||||
'total_market_value' => 'float',
|
||||
'total_dividends_earned' => 'float',
|
||||
@@ -228,7 +228,7 @@ class Holding extends Model
|
||||
return collect([
|
||||
'total_cost_basis' => $result->sum('total_cost_basis'),
|
||||
'total_market_value' => $result->sum('total_market_value'),
|
||||
'total_gain_dollars' => $result->sum('total_gain_dollars'),
|
||||
'total_market_gain_dollars' => $result->sum('total_market_gain_dollars'),
|
||||
'realized_gain_dollars' => $result->sum('realized_gain_dollars'),
|
||||
'total_dividends_earned' => $result->sum('total_dividends_earned'),
|
||||
]);
|
||||
@@ -243,11 +243,113 @@ class Holding extends Model
|
||||
{
|
||||
$currency = $currency ?? auth()->user()->getCurrency();
|
||||
|
||||
$cost_basis_sub = DB::table('transactions')
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join
|
||||
->on('cr.date', '=', 'transactions.date')
|
||||
->where('cr.currency', '=', $currency);
|
||||
})
|
||||
->select([
|
||||
'transactions.id',
|
||||
'transactions.symbol',
|
||||
'transactions.portfolio_id',
|
||||
])
|
||||
->leftJoinSub(
|
||||
DB::table('transactions')
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join
|
||||
->on('cr.date', '=', 'transactions.date')
|
||||
->where('cr.currency', '=', $currency);
|
||||
})
|
||||
->select([
|
||||
'transactions.symbol',
|
||||
'transactions.portfolio_id',
|
||||
'transactions.quantity',
|
||||
'transactions.cost_basis_base',
|
||||
'transactions.date',
|
||||
])
|
||||
->selectRaw("
|
||||
(CASE
|
||||
WHEN
|
||||
transactions.transaction_type = 'BUY'
|
||||
OR SUM(transactions.cost_basis_base) = 0
|
||||
THEN
|
||||
COALESCE(cr.rate, 1)
|
||||
ELSE (
|
||||
SELECT
|
||||
SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base)
|
||||
/ SUM(buy.cost_basis_base)
|
||||
FROM transactions as buy
|
||||
LEFT JOIN currency_rates as cr2
|
||||
ON cr2.date = buy.date
|
||||
AND cr2.currency = '{$currency}'
|
||||
WHERE buy.symbol = transactions.symbol
|
||||
AND buy.portfolio_id = transactions.portfolio_id
|
||||
AND buy.transaction_type = 'BUY'
|
||||
AND buy.date <= transactions.date
|
||||
) END)
|
||||
AS rate")
|
||||
->groupBy([
|
||||
'transactions.id',
|
||||
'transactions.symbol',
|
||||
'transactions.date',
|
||||
'transactions.portfolio_id',
|
||||
'transactions.transaction_type',
|
||||
'transactions.cost_basis_base',
|
||||
'transactions.quantity',
|
||||
'cr.rate',
|
||||
]),
|
||||
'cost_basis_display',
|
||||
function ($join) {
|
||||
$join
|
||||
->on('transactions.symbol', '=', 'cost_basis_display.symbol')
|
||||
->on(
|
||||
'transactions.portfolio_id',
|
||||
'=',
|
||||
'cost_basis_display.portfolio_id'
|
||||
)
|
||||
->on('transactions.date', '=', 'cost_basis_display.date');
|
||||
}
|
||||
)
|
||||
->selectRaw(
|
||||
"CASE WHEN transactions.transaction_type = 'SELL' THEN (transactions.sale_price_base - transactions.cost_basis_base) * transactions.quantity * COALESCE(cr.rate, 1) END AS realized_gain_dollars"
|
||||
)
|
||||
->selectRaw(
|
||||
"CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.cost_basis_base * transactions.quantity * cost_basis_display.rate END AS total_cost_basis"
|
||||
)
|
||||
->selectRaw(
|
||||
"CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity END AS total_purchases"
|
||||
)
|
||||
->groupBy([
|
||||
'transactions.id',
|
||||
'transactions.symbol',
|
||||
'transactions.portfolio_id',
|
||||
'transactions.cost_basis_base',
|
||||
'transactions.quantity',
|
||||
'cost_basis_display.rate',
|
||||
'cr.rate',
|
||||
]);
|
||||
|
||||
$dividends_sub = DB::table('dividends')
|
||||
->join('transactions as tx', function ($join) {
|
||||
$join
|
||||
->on('tx.symbol', '=', 'dividends.symbol')
|
||||
->on('tx.date', '<=', 'dividends.date');
|
||||
})
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join
|
||||
->on('cr.date', '=', 'dividends.date')
|
||||
->where('cr.currency', '=', $currency);
|
||||
})
|
||||
->select(['dividends.symbol', 'tx.portfolio_id'])
|
||||
->selectRaw(
|
||||
"SUM(((CASE WHEN transaction_type = 'BUY' THEN tx.quantity ELSE 0 END) - (CASE WHEN transaction_type = 'SELL' THEN tx.quantity ELSE 0 END)) * dividends.dividend_amount_base * COALESCE(cr.rate, 1)) AS total_dividends_earned"
|
||||
)
|
||||
->groupBy(['dividends.symbol', 'tx.portfolio_id']);
|
||||
|
||||
return $query->select([
|
||||
'holdings.symbol',
|
||||
'holdings.portfolio_id',
|
||||
'transactions_display.total_cost_basis',
|
||||
'transactions_display.realized_gain_dollars',
|
||||
'dividends_display.total_dividends_earned',
|
||||
])
|
||||
->groupBy([
|
||||
@@ -255,8 +357,6 @@ class Holding extends Model
|
||||
'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',
|
||||
])
|
||||
@@ -264,121 +364,49 @@ class Holding extends Model
|
||||
$join->where('cr.currency', '=', $currency);
|
||||
|
||||
if (config('database.default') === 'sqlite') {
|
||||
|
||||
$join->whereRaw("strftime('%Y-%m-%d', cr.date) = ?", [now()->toDateString()]);
|
||||
$join->whereRaw("strftime('%Y-%m-%d', cr.date) = ?", [
|
||||
now()->toDateString(),
|
||||
]);
|
||||
} else {
|
||||
|
||||
$join->on('cr.date', '=', DB::raw("'".now()->toDateString()."'"));
|
||||
}
|
||||
})
|
||||
->leftJoin('market_data', function ($join) {
|
||||
$join->on('market_data.symbol', '=', 'holdings.symbol');
|
||||
})
|
||||
->selectRaw(
|
||||
'holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1) AS total_market_value'
|
||||
)
|
||||
->selectRaw('(
|
||||
holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1)
|
||||
) - transactions_display.total_cost_basis as total_gain_dollars')
|
||||
->leftJoinSub(
|
||||
DB::table('transactions')
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join->on('cr.date', '=', 'transactions.date')
|
||||
->where('cr.currency', '=', $currency);
|
||||
})
|
||||
->select(['transactions.symbol', 'transactions.portfolio_id'])
|
||||
->leftJoinSub(
|
||||
DB::table('transactions')
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join
|
||||
->on('cr.date', '=', 'transactions.date')
|
||||
->where('cr.currency', '=', $currency);
|
||||
})
|
||||
->select([
|
||||
'transactions.symbol',
|
||||
'transactions.portfolio_id',
|
||||
'transactions.quantity',
|
||||
'transactions.date',
|
||||
])
|
||||
->selectRaw(
|
||||
"(CASE
|
||||
WHEN transactions.transaction_type = 'BUY'
|
||||
THEN COALESCE(cr.rate, 1)
|
||||
ELSE (
|
||||
SELECT
|
||||
SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base)
|
||||
/ SUM(buy.cost_basis_base)
|
||||
FROM transactions as buy
|
||||
LEFT JOIN currency_rates as cr2
|
||||
ON cr2.date = buy.date
|
||||
AND cr2.currency = '{$currency}'
|
||||
WHERE buy.symbol = transactions.symbol
|
||||
AND buy.portfolio_id = transactions.portfolio_id
|
||||
AND buy.transaction_type = 'BUY'
|
||||
AND buy.date <= transactions.date
|
||||
) END)
|
||||
AS rate"
|
||||
)
|
||||
->selectRaw(
|
||||
"(CASE
|
||||
WHEN transactions.transaction_type = 'BUY'
|
||||
THEN AVG(transactions.cost_basis_base)
|
||||
ELSE (
|
||||
SELECT
|
||||
AVG(-buy.cost_basis_base)
|
||||
FROM transactions as buy
|
||||
WHERE buy.symbol = transactions.symbol
|
||||
AND buy.portfolio_id = transactions.portfolio_id
|
||||
AND buy.transaction_type = 'BUY'
|
||||
AND buy.date <= transactions.date
|
||||
) END)
|
||||
AS cost_basis_base"
|
||||
)
|
||||
->groupBy([
|
||||
'transactions.symbol',
|
||||
'transactions.date',
|
||||
'transactions.portfolio_id',
|
||||
'transactions.transaction_type',
|
||||
'transactions.quantity',
|
||||
'cr.rate',
|
||||
]), 'cost_basis_display', function ($join) {
|
||||
$join->on('transactions.symbol', '=', 'cost_basis_display.symbol')
|
||||
->on('transactions.portfolio_id', '=', 'cost_basis_display.portfolio_id')
|
||||
->on('transactions.date', '=', 'cost_basis_display.date');
|
||||
})
|
||||
->selectRaw(
|
||||
"SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN (transactions.sale_price_base - transactions.cost_basis_base) * transactions.quantity * COALESCE(cr.rate, 1) ELSE 0 END) AS realized_gain_dollars"
|
||||
)
|
||||
->selectRaw(
|
||||
'SUM(cost_basis_display.cost_basis_base * cost_basis_display.quantity * cost_basis_display.rate) AS total_cost_basis'
|
||||
)
|
||||
->groupBy(['transactions.symbol', 'transactions.portfolio_id']),
|
||||
'transactions_display',
|
||||
->selectRaw('
|
||||
holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1)
|
||||
AS total_market_value
|
||||
')
|
||||
->selectRaw('
|
||||
SUM(transactions_display.realized_gain_dollars)
|
||||
AS realized_gain_dollars
|
||||
')
|
||||
->selectRaw('
|
||||
(SUM(transactions_display.total_cost_basis) / SUM(transactions_display.total_purchases))
|
||||
* holdings.quantity
|
||||
AS total_cost_basis
|
||||
')
|
||||
->selectRaw('
|
||||
(holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1))
|
||||
- (SUM(transactions_display.total_cost_basis) / SUM(transactions_display.total_purchases))
|
||||
* holdings.quantity
|
||||
AS total_market_gain_dollars
|
||||
')
|
||||
->leftJoinSub($cost_basis_sub, 'transactions_display',
|
||||
function ($join) {
|
||||
$join->on('holdings.symbol', '=', 'transactions_display.symbol')
|
||||
$join
|
||||
->on('holdings.symbol', '=', 'transactions_display.symbol')
|
||||
->on('holdings.portfolio_id', '=', 'transactions_display.portfolio_id');
|
||||
}
|
||||
)
|
||||
->leftJoinSub(
|
||||
DB::table('dividends')
|
||||
->join('transactions as tx', function ($join) {
|
||||
$join->on('tx.symbol', '=', 'dividends.symbol')
|
||||
->on('tx.date', '<=', 'dividends.date');
|
||||
})
|
||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||
$join->on('cr.date', '=', 'dividends.date')
|
||||
->where('cr.currency', '=', $currency);
|
||||
})
|
||||
->select(['dividends.symbol'])
|
||||
->selectRaw(
|
||||
"SUM(((CASE WHEN transaction_type = 'BUY' THEN tx.quantity ELSE 0 END) - (CASE WHEN transaction_type = 'SELL' THEN tx.quantity ELSE 0 END)) * dividends.dividend_amount_base * COALESCE(cr.rate, 1)) AS total_dividends_earned"
|
||||
)
|
||||
->groupBy(['dividends.symbol']),
|
||||
'dividends_display',
|
||||
->leftJoinSub($dividends_sub, 'dividends_display',
|
||||
function ($join) {
|
||||
$join->on('holdings.symbol', '=', 'dividends_display.symbol');
|
||||
$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()
|
||||
@@ -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")
|
||||
->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);
|
||||
|
||||
$average_cost_basis = (
|
||||
|
||||
@@ -153,6 +153,7 @@ class Portfolio extends Model
|
||||
$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());
|
||||
}
|
||||
@@ -217,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)
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
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];
|
||||
|
||||
+2
-1
@@ -24,8 +24,9 @@
|
||||
"openai-php/client": "^0.10.3",
|
||||
"predis/predis": "^2.2",
|
||||
"robsontenorio/mary": "^1.35",
|
||||
"scheb/yahoo-finance-api": "^4.11",
|
||||
"scheb/yahoo-finance-api": "^5.0",
|
||||
"staudenmeir/eloquent-has-many-deep": "^1.20",
|
||||
"symfony/cache": "^7.3",
|
||||
"tschucki/alphavantage-laravel": "^0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
|
||||
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' => [
|
||||
'yahoo' => App\Interfaces\MarketData\YahooMarketData::class,
|
||||
'alphavantage' => App\Interfaces\MarketData\AlphaVantageMarketData::class,
|
||||
'alpaca' => App\Interfaces\MarketData\AlpacaMarketData::class,
|
||||
'finnhub' => App\Interfaces\MarketData\FinnhubMarketData::class,
|
||||
'twelvedata' => App\Interfaces\MarketData\TwelveDataMarketData::class,
|
||||
'fake' => App\Interfaces\MarketData\FakeMarketData::class,
|
||||
],
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
|
||||
+15
-15
@@ -11,9 +11,9 @@ services:
|
||||
- 8000:80
|
||||
environment: # You can either use these properties OR an .env file. Do not use both!
|
||||
APP_URL: "http://localhost:8000"
|
||||
DB_CONNECTION: mysql
|
||||
DB_HOST: investbrain-mysql
|
||||
DB_PORT: 3306
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: investbrain-pgsql
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: investbrain
|
||||
DB_USERNAME: investbrain
|
||||
DB_PASSWORD: investbrain
|
||||
@@ -25,7 +25,7 @@ services:
|
||||
- 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
|
||||
depends_on:
|
||||
- mysql
|
||||
- pgsql
|
||||
- redis
|
||||
networks:
|
||||
- investbrain-network
|
||||
@@ -40,22 +40,22 @@ services:
|
||||
- investbrain-redis:/data
|
||||
networks:
|
||||
- investbrain-network
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: investbrain-mysql
|
||||
pgsql:
|
||||
image: postgres:15-alpine
|
||||
container_name: investbrain-pgsql
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
MYSQL_DATABASE: ${DB_DATABASE:-investbrain}
|
||||
MYSQL_USER: ${DB_USERNAME:-investbrain}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:-investbrain}
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-investbrain}
|
||||
command:
|
||||
- --cte-max-recursion-depth=25000
|
||||
POSTGRES_DB: ${DB_DATABASE:-investbrain}
|
||||
POSTGRES_USER: ${DB_USERNAME:-investbrain}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-investbrain}
|
||||
command: postgres -c log_min_messages=error
|
||||
volumes:
|
||||
- investbrain-mysql:/var/lib/mysql
|
||||
- investbrain-pgsql:/var/lib/postgresql/data
|
||||
networks:
|
||||
- investbrain-network
|
||||
volumes:
|
||||
investbrain-storage:
|
||||
investbrain-redis:
|
||||
investbrain-mysql:
|
||||
investbrain-pgsql:
|
||||
|
||||
@@ -44,6 +44,9 @@ FROM php:8.3-fpm-alpine
|
||||
# Set the working directory
|
||||
WORKDIR /var/app
|
||||
|
||||
ARG VERSION=dev
|
||||
ENV VERSION=$VERSION
|
||||
|
||||
# Copy necessary files from the builder stage
|
||||
COPY --from=builder /var/app /var/app
|
||||
COPY --from=builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d
|
||||
|
||||
+14
-3
@@ -3,7 +3,8 @@
|
||||
cd /var/app
|
||||
|
||||
# Starting Investbrain
|
||||
echo "CiAgKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioKICAqICBJSUkgICBOICAgTiAgViAgIFYgIEVFRUVFICBTU1NTICBUVFRUVCAgQkJCQkIgICBSUlJSICAgIEFBQUFBICBJSUkgICBOICAgTiAgKgogICogICBJICAgIE5OICBOICBWICAgViAgRSAgICAgIFMgICAgICAgVCAgICBCICAgIEIgIFIgICBSICAgQSAgIEEgICBJICAgIE5OICBOICAqCiAgKiAgIEkgICAgTiBOIE4gIFYgICBWICBFRUVFICAgU1NTUyAgICBUICAgIEJCQkJCICAgUlJSUiAgICBBQUFBQSAgIEkgICAgTiBOIE4gICoKICAqICAgSSAgICBOICBOTiAgViAgIFYgIEUgICAgICAgICAgUyAgIFQgICAgQiAgICBCICBSICBSICAgIEEgICBBICAgSSAgICBOICBOTiAgKgogICogIElJSSAgIE4gICBOICAgVlZWICAgRUVFRUUgIFNTU1MgICAgVCAgICBCQkJCQiAgIFIgICBSICAgQSAgIEEgIElJSSAgIE4gICBOICAqCiAgKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioKICA=" | base64 -d
|
||||
echo "CuKWhOKWliAgICAgICAg4paXIOKWjCAgICAg4paYICAK4paQIOKWm+KWjOKWjOKWjOKWiOKWjOKWm+KWmOKWnOKWmOKWm+KWjOKWm+KWmOKWgOKWjOKWjOKWm+KWjArilp/ilpbilozilozilprilpjilpnilpbiloTilozilpDilpbilpnilozilowg4paI4paM4paM4paM4paMCg==" | base64 -d
|
||||
printf "%15s$VERSION\n"
|
||||
|
||||
echo -e "\n====================== Validating environment... ====================== "
|
||||
|
||||
@@ -54,7 +55,6 @@ RETRIES=12
|
||||
DELAY=5
|
||||
run_migrations() {
|
||||
sleep $DELAY
|
||||
# php artisan migrate --force
|
||||
output=$(php artisan migrate --force 2>/dev/null)
|
||||
if [[ $? -eq 0 ]]; then
|
||||
echo "$output"
|
||||
@@ -72,7 +72,18 @@ until run_migrations; do
|
||||
echo -e "\n > Waiting for database to be ready... retrying in $DELAY seconds. \n"
|
||||
done
|
||||
|
||||
echo -e "\n====================== Cleaning up... ====================== \n"
|
||||
|
||||
# Clear caches
|
||||
echo $(php artisan cache:clear)
|
||||
echo $(php artisan view:clear)
|
||||
echo $(php artisan route:clear)
|
||||
echo $(php artisan event:clear)
|
||||
|
||||
# Re-create caches
|
||||
echo $(php artisan route:cache)
|
||||
echo $(php artisan event:cache)
|
||||
|
||||
echo -e "\n====================== Spinning up Supervisor daemon... ====================== \n"
|
||||
|
||||
exec supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
|
||||
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" />
|
||||
</label>
|
||||
|
||||
<div class="hidden md:block" style="height:3.1em">
|
||||
<div class="hidden md:block" style="height:2.5em">
|
||||
<x-application-logo />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="grid sm:grid-cols-5 gap-5">
|
||||
<x-card class="col-span-5 sm:col-span-1 bg-slate-100 dark:bg-base-200 rounded-lg">
|
||||
<div class="text-sm text-gray-400 whitespace-nowrap truncate">{{ __('Market Gain/Loss') }}</div>
|
||||
<div class="font-black text-xl"> {{ Number::currency($metrics->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 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
|
||||
{
|
||||
// props
|
||||
public ?Portfolio $portfolio;
|
||||
public ?Portfolio $portfolio = null;
|
||||
|
||||
public string $name = 'portfolio';
|
||||
|
||||
@@ -53,45 +53,49 @@ new class extends Component
|
||||
$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
|
||||
->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'),
|
||||
];
|
||||
})
|
||||
->values();
|
||||
$marketValueData = [];
|
||||
$costBasisData = [];
|
||||
$marketGainData = [];
|
||||
|
||||
foreach ($dailyChange as $data) {
|
||||
$date = $data->date;
|
||||
$marketValueData[] = [$date, round($data->total_market_value, 2)];
|
||||
$costBasisData[] = [$date, round($data->total_cost_basis, 2)];
|
||||
$marketGainData[] = [$date, round($data->total_market_gain, 2)];
|
||||
// $dividendSeries[] = [$date, round($data->total_dividends_earned, 2)];
|
||||
// $realizedGainSeries[] = [$date, round($data->realized_gains, 2)];
|
||||
}
|
||||
|
||||
return [
|
||||
'series' => [
|
||||
[
|
||||
'name' => __('Market Value'),
|
||||
'data' => $dailyChange->map(fn ($data) => [$data->date, $data->total_market_value])->toArray(),
|
||||
'data' => $marketValueData,
|
||||
],
|
||||
[
|
||||
'name' => __('Cost Basis'),
|
||||
'data' => $dailyChange->map(fn ($data) => [$data->date, $data->total_cost_basis])->toArray(),
|
||||
'data' => $costBasisData,
|
||||
],
|
||||
[
|
||||
'name' => __('Market Gain'),
|
||||
'data' => $dailyChange->map(fn ($data) => [$data->date, $data->total_gain])->toArray(),
|
||||
'data' => $marketGainData,
|
||||
],
|
||||
|
||||
// [
|
||||
// 'name' => __('Dividends Earned'),
|
||||
// 'data' => $dailyChange->map(fn($data) => [$data->date, $data->total_dividends_earned])->toArray()
|
||||
// 'data' => $dividendSeries
|
||||
// ],
|
||||
// [
|
||||
// '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;
|
||||
|
||||
cache()->forget('graph-'.$this->scope.'-'.(isset($this->portfolio) ? $this->portfolio->id : request()->user()->id));
|
||||
|
||||
$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">
|
||||
<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 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->cost_basis
|
||||
: $transaction->sale_price,
|
||||
$transaction->market_data->currency
|
||||
$transaction->market_data?->currency
|
||||
) }})
|
||||
|
||||
<x-loading x-show="loading" x-cloak class="text-gray-400 ml-2" />
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace Tests;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
class AuthenticationTest extends TestCase
|
||||
@@ -14,15 +13,17 @@ class AuthenticationTest extends TestCase
|
||||
|
||||
public function test_first_user_is_admin(): void
|
||||
{
|
||||
$this->post('/register', [
|
||||
$response = $this->post('/register', [
|
||||
'name' => 'should_be_admin',
|
||||
'email' => 'should_be_admin@example.net',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
'terms' => 1,
|
||||
]);
|
||||
|
||||
$should_be_admin = User::where(['email' => 'should_be_admin@example.net'])->first();
|
||||
|
||||
$this->assertModelExists($should_be_admin);
|
||||
$this->assertTrue($should_be_admin->admin);
|
||||
}
|
||||
|
||||
@@ -35,10 +36,12 @@ class AuthenticationTest extends TestCase
|
||||
'email' => 'not_admin@example.net',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
'terms' => 1,
|
||||
]);
|
||||
|
||||
$not_admin = User::where(['email' => 'not_admin@example.net'])->first();
|
||||
|
||||
$this->assertModelExists($not_admin);
|
||||
$this->assertNotTrue($not_admin->admin);
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ class DailyChangeTest extends TestCase
|
||||
$daily_change = DailyChange::withDailyPerformance()
|
||||
->portfolio($this->portfolio->id)
|
||||
->whereDate('daily_change.date', '=', $sale_transaction->date->copy()->nextWeekday())
|
||||
// ->withMultipleDailyPerformance()
|
||||
->first();
|
||||
|
||||
$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();
|
||||
|
||||
// 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();
|
||||
|
||||
$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
|
||||
config()->set('app.env', 'local');
|
||||
@@ -229,7 +230,7 @@ class DailyChangeTest extends TestCase
|
||||
|
||||
$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
|
||||
$third_transaction = Transaction::create([
|
||||
|
||||
@@ -70,4 +70,23 @@ class DividendsTest extends TestCase
|
||||
|
||||
$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,18 +78,20 @@ class ImportExportTest extends TestCase
|
||||
{
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
|
||||
Portfolio::create([
|
||||
$portfolio = Portfolio::create([
|
||||
'id' => '9e792bb8-94e7-4ed3-b8cc-43b50d34c337',
|
||||
'title' => 'Test Portfolio',
|
||||
]);
|
||||
|
||||
$holding = Holding::create([
|
||||
'portfolio_id' => '9e792bb8-94e7-4ed3-b8cc-43b50d34c337',
|
||||
'portfolio_id' => $portfolio->id,
|
||||
'symbol' => 'ACME',
|
||||
'quantity' => 0,
|
||||
'reinvest_dividends' => false,
|
||||
]);
|
||||
|
||||
Transaction::factory()->buy()->lastYear()->costBasis(100)->portfolio($portfolio->id)->symbol('AAPL')->create();
|
||||
|
||||
$this->assertEquals(false, $holding->reinvest_dividends);
|
||||
|
||||
BackupImportModel::create([
|
||||
|
||||
+40
-31
@@ -544,28 +544,20 @@ class MultiCurrencyTest extends TestCase
|
||||
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
|
||||
$monthAgo = now()->subMonth()->toDateString();
|
||||
$fiveWeeksAgo = now()->subWeeks(5)->toDateString();
|
||||
$fiveDaysAgo = now()->subDays(5)->toDateString();
|
||||
|
||||
$portfolio = Portfolio::factory()->create();
|
||||
Transaction::factory(5)->buy()->lastMonth()->portfolio($portfolio->id)->symbol('AAPL')->create();
|
||||
Transaction::factory(5)->buy()->lastMonth()->portfolio($portfolio->id)->symbol('ACME')->create();
|
||||
Transaction::factory()->sell()->recent()->portfolio($portfolio->id)->symbol('ACME')->create();
|
||||
Transaction::factory(5)->buy()->costBasis(100)->date($monthAgo)->portfolio($portfolio->id)->symbol('AAPL')->create();
|
||||
Transaction::factory(5)->buy()->costBasis(190)->date($fiveWeeksAgo)->portfolio($portfolio->id)->symbol('ACME')->create();
|
||||
Transaction::factory()->sell()->date($fiveDaysAgo)->portfolio($portfolio->id)->symbol('ACME')->create();
|
||||
|
||||
$portfolio->syncDailyChanges();
|
||||
|
||||
$dailyChange = DailyChange::withDailyPerformance()
|
||||
->portfolio($portfolio->id)
|
||||
->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'),
|
||||
];
|
||||
});
|
||||
->get();
|
||||
|
||||
$metrics = Holding::query()
|
||||
->portfolio($portfolio->id)
|
||||
@@ -573,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_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('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
|
||||
$user->options = array_merge($user->options ?? [], [
|
||||
@@ -584,19 +605,7 @@ class MultiCurrencyTest extends TestCase
|
||||
|
||||
$dailyChange = DailyChange::withDailyPerformance()
|
||||
->portfolio($portfolio->id)
|
||||
->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'),
|
||||
];
|
||||
});
|
||||
->get();
|
||||
|
||||
$metrics = Holding::query()
|
||||
->portfolio($portfolio->id)
|
||||
@@ -605,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_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('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
|
||||
|
||||
Reference in New Issue
Block a user