Compare commits

...

65 Commits

Author SHA1 Message Date
hackerESQ 70910c2f6d docs: adds alpaca 2025-08-25 21:05:42 -05:00
hackerESQ 9ddea4c6e1 fix: add exception for 404 2025-08-25 21:00:49 -05:00
hackerESQ 576b22e4c9 fix: hard code USD 2025-08-25 21:00:49 -05:00
hackerESQ 0035879a87 feat: add alpaca provider 2025-08-25 21:00:49 -05:00
hackerESQ 97298bcd39 Delete holding if no related transactions
resolves #63
2025-08-25 20:23:59 -05:00
hackerESQ 0504058c01 fix: auth tests failing if env shows self hosted 2025-08-25 20:21:22 -05:00
hackerESQ 750ccbd68f fix: locale setting 2025-08-25 19:58:53 -05:00
hackerESQ d815700e58 fix: simplify logo 2025-08-25 19:39:55 -05:00
hackerESQ 9d809bbbe4 test loop once? 2025-08-22 20:49:12 -05:00
hackerESQ 74a26e004f round graph 2025-08-22 20:38:54 -05:00
hackerESQ 65710e2791 dividend earnings not shared between portfolios 2025-08-22 16:37:33 -05:00
hackerESQ ac310735df wip 2025-08-21 21:46:53 -05:00
hackerESQ 5611de0e2e cleanup 2025-08-21 21:09:52 -05:00
hackerESQ 4196539169 cleanup 2025-08-21 21:09:48 -05:00
hackerESQ 08cfcceb6a clean up capture daily change command 2025-08-21 21:04:56 -05:00
hackerESQ e427d5802c wip 2025-08-21 20:54:14 -05:00
hackerESQ fc5cc1fee2 wip 2025-08-21 20:12:59 -05:00
hackerESQ fb3c19d3bf wip 2025-08-21 19:51:48 -05:00
hackerESQ 24aeb72549 temp remove dividends 2025-08-20 18:31:15 -05:00
hackerESQ c799da58e1 qip 2025-08-19 21:54:18 -05:00
hackerESQ e24f932c0f wip 2025-08-19 21:47:27 -05:00
hackerESQ 7e2bf3430e wip 2025-08-19 21:34:35 -05:00
hackerESQ e1c8c2c515 wip 2025-08-11 21:51:54 -05:00
hackerESQ ae1e59ce30 wip 2025-08-11 21:21:16 -05:00
hackerESQ 03089ed1b3 wip 2025-08-11 20:39:54 -05:00
hackerESQ 97b13063d9 wip 2025-08-11 19:58:17 -05:00
hackerESQ 9260de5f25 wip 2025-08-05 21:43:55 -05:00
hackerESQ 505a24bf99 chore: clean up 2025-07-23 21:29:44 -05:00
hackerESQ 0e88b8c6f5 Merge branch 'main' of https://github.com/investbrainapp/investbrain 2025-07-22 21:52:32 -05:00
hackerESQ 519486fe57 fix: settings for user localiation 2025-07-22 21:52:04 -05:00
hackerESQ 4086168515 fix: settings for user localiation 2025-07-22 21:51:54 -05:00
hackerESQ a13bd9f0dc fix: double counting cr 2025-07-22 20:20:59 -05:00
hackerESQ 2c3950b522 fix: holding calculations 2025-07-21 20:36:36 -05:00
hackerESQ 653f54add6 feat: adds today() method 2025-07-21 20:28:57 -05:00
hackerESQ 8e0d792d26 fix: calculate proper cost basis 2025-07-21 20:28:39 -05:00
hackerESQ 81af737204 fix: cost basis calculations on daily change queries 2025-07-17 22:00:30 -05:00
hackerESQ 81845d47f2 fix: cost basis for holding calculations 2025-07-17 20:38:29 -05:00
hackerESQ cf475657cf feat: add version number to docker image 2025-07-16 17:07:25 -05:00
hackerESQ 90a15ceddb fix: set default 2025-07-14 21:20:47 -05:00
hackerESQ 981ce0d62f fix: null coalesce 2025-07-14 21:20:25 -05:00
hackerESQ 154b679464 chore: update yahoo dep 2025-07-14 21:20:08 -05:00
hackerESQ ee51cb7e2a fix: division by zero error 2025-07-12 00:40:37 -05:00
hackerESQ 40120c7027 fix: delay queued currency rates filling 2025-07-11 22:38:09 -05:00
hackerESQ cfd5b8a4f3 feat: default to pgsql 2025-07-11 22:13:16 -05:00
hackerESQ 3b93e328d5 feat: fancy ascii art 2025-07-11 21:43:36 -05:00
hackerESQ 1fd858287d fix: clear and re-create caches 2025-07-11 21:42:11 -05:00
hackerESQ e370f5bbb7 fix: clear cache after every reload 2025-07-11 21:33:58 -05:00
hackerESQ 3e492475c0 fix: migrations failing on mysql 2025-07-09 21:55:32 -05:00
hackerESQ c454e85ad4 fix: date calculations cause failed tests 2025-07-09 19:37:51 -05:00
David Peng 487322abb5 fix: fix postgresql support (#100)
Fix #81
2025-07-09 19:11:25 -05:00
hackerESQ f78c521dc4 fix: add bp.l to test multicurr seed 2025-05-16 21:12:48 -05:00
hackerESQ ff9bcd782f fix: don't queue market data seed 2025-05-16 20:49:29 -05:00
hackerESQ 1ccf515ca2 fix: reorg migrtion 2025-05-16 19:59:39 -05:00
hackerESQ 1b0f9c134c fix: dispatch time series rates 2025-05-16 19:38:58 -05:00
hackerESQ 3589242996 fix: dispatch time series updates 2025-05-16 19:31:44 -05:00
hackerESQ 689aa4d50b fix: multi currency seeders 2025-05-15 20:05:14 -05:00
hackerESQ 26370c03c4 fix: optimize migration to multi-currency 2025-05-03 13:22:45 -05:00
hackerESQ 80b043219a prevent pre-releases from triggering image build 2025-05-02 20:07:38 -05:00
hackerESQ de54b6843d Fix multi-currency imports (#94) 2025-05-02 18:14:06 -05:00
hackerESQ 17e5d8b665 fix: increase chunk size 2025-04-12 10:12:30 -05:00
hackerESQ bd9c828c68 fix: use options prop 2025-04-11 21:49:06 -05:00
hackerESQ f72cd6f5a7 fix: set name attribute 2025-04-11 21:45:58 -05:00
hackerESQ 3593697cce fix: user needs to be set from import job 2025-04-11 21:42:38 -05:00
hackerESQ d53e71dcd5 Update README.md 2025-04-11 21:28:05 -05:00
hackerESQ 71e79cfb40 fix: daily change should be synced when before latest transaction 2025-04-11 21:14:53 -05:00
45 changed files with 863 additions and 1720 deletions
+2
View File
@@ -24,6 +24,8 @@ OPENAI_ORGANIZATION=
MARKET_DATA_PROVIDER=yahoo
ALPHAVANTAGE_API_KEY=
FINNHUB_API_KEY=
ALPACA_API_KEY=
ALPACA_API_SECRET=
# Cadence to refresh market data (in minutes)
MARKET_DATA_REFRESH=30
+14 -6
View File
@@ -43,7 +43,16 @@ jobs:
- name: Extract version from tag
id: extract-version
run: |
echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
VERSION="${GITHUB_REF_NAME#v}"
TAGS="investbrainapp/investbrain:${VERSION},ghcr.io/investbrainapp/investbrain:${VERSION}"
# Conditionally add 'latest' tags unless 'pre-release' is in the version
if [[ "${GITHUB_REF_NAME}" != *alpha* && "${GITHUB_REF_NAME}" != *beta* && "${GITHUB_REF_NAME}" != *rc* ]]; then
TAGS="$TAGS,investbrainapp/investbrain:latest,ghcr.io/investbrainapp/investbrain:latest"
fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
@@ -51,8 +60,7 @@ jobs:
platforms: linux/amd64,linux/arm64
file: ./docker/Dockerfile
push: true
tags: |
investbrainapp/investbrain:latest
investbrainapp/investbrain:${{ env.version }}
ghcr.io/investbrainapp/investbrain:latest
ghcr.io/investbrainapp/investbrain:${{ env.version }}
tags: ${{ steps.extract-version.outputs.tags }}
build-args: |
VERSION=${{ github.ref_name }}
+4 -3
View File
@@ -28,7 +28,7 @@ Investbrain is a smart open-source investment tracker that helps you manage, tra
## Under the hood
Investbrain is a Laravel PHP web application that leverages Livewire and Tailwind for its frontend. Most databases should work, including MySQL and SQLite. Out of the box, we feature three market data providers: [Yahoo Finance](https://finance.yahoo.com/), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), and [Alpha Vantage](https://www.alphavantage.co/support/). But we also offer an extensible market data provider interface for intrepid developers to create their own! We also offer integrations with OpenAI and Ollama for our ["chat with your holdings"](#chat-with-your-holdings) capability. Finally, of course we have robust support for i18n, a11y, and dark mode.
Investbrain is a Laravel PHP web application that 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/), [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,7 +138,7 @@ 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`, `alphavantage`, `alpaca`, or `finnhub`) | yahoo |
| ALPHAVANTAGE_API_KEY | If using the Alpha Vantage provider | `null` |
| FINNHUB_API_KEY | If using the Finnhub provider | `null` |
| MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 |
@@ -193,6 +193,7 @@ Just to be safe, we recommend backing up your portfolios before using these comm
| refresh:market-data | Refreshes market data with your configured market data provider. |
| refresh:dividend-data | Refreshes dividend data with your configured market data provider. Will also re-calculate your total dividends earned for each holding. |
| refresh:split-data | Refreshes splits data with your configured market data provider. Will also create new transactions to account for any splits. |
| refresh:currency-data | Grabs the latest daily currency exchange rate data and persists to the database. |
| capture:daily-change | Captures a snapshot of each portfolio's daily performance. |
| sync:daily-change | Re-calculates daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) |
| sync:holdings | Re-calculates performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
+8 -2
View File
@@ -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;
}
+1 -1
View File
@@ -21,7 +21,7 @@ class EnsureDailyChangeIsSynced
! Cache::has($cacheKey)
&& $model->date->lessThan(now())
&& ($model->date->lessThan($model->portfolio->daily_change()->min('date') ?? now())
|| $model->date->lessThan($model->portfolio->transactions()->where('id', '!=', $model->id)->min('date') ?? now())
|| $model->date->lessThan($model->portfolio->transactions()->where('id', '!=', $model->id)->max('date') ?? now())
)
) {
defer(fn () => $model->portfolio->syncDailyChanges());
+1 -8
View File
@@ -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'),
]);
});
}
+1 -2
View File
@@ -7,7 +7,6 @@ namespace App\Console\Commands;
use App\Models\Holding;
use App\Models\MarketData;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class RefreshMarketData extends Command
{
@@ -61,7 +60,7 @@ class RefreshMarketData extends Command
try {
MarketData::getMarketData($holding->symbol, $force);
} catch (\Throwable $e) {
Log::error('Could not refresh '.$holding->symbol.' ('.$e->getMessage().')');
$this->line('Could not refresh '.$holding->symbol.' ('.$e->getMessage().')');
}
}
}
+4 -3
View File
@@ -47,12 +47,13 @@ class ConfigSheet implements FromCollection, WithHeadings, WithTitle
]);
// 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([
'key' => 'reinvested_dividends',
'value' => $holding->id,
'value' => $reinvested_holdings->toJson(),
]);
});
}
return $configs;
}
+1 -2
View File
@@ -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);
}
}
+17 -13
View File
@@ -36,31 +36,35 @@ class ConfigSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadi
public function collection(Collection $configs)
{
$user = auth()->user();
foreach ($configs as $config) {
switch ($config['key']) {
case 'name':
$user->name = $config['value'];
$user->save();
$this->backupImport->user->setAttribute('name', $config['value']);
$this->backupImport->user->save();
break;
case 'locale':
$user->setOption('locale', $config['value']);
$user->save();
$this->backupImport->user->setOption('locale', $config['value']);
$this->backupImport->user->save();
break;
case 'display_currency':
$user->setOption('display_currency', $config['value']);
$user->save();
$this->backupImport->user->setOption('display_currency', $config['value']);
$this->backupImport->user->save();
break;
case 'reinvest_dividends':
Holding::myHoldings()->where('id', $config['value'])->update([
'reinvest_dividends' => true,
]);
case 'reinvested_dividends':
if (json_validate($config['value'])) {
foreach (json_decode($config['value'], true) as $reinvest) {
Holding::myHoldings($this->backupImport->user->id)
->where('portfolio_id', $reinvest['portfolio_id'])
->where('symbol', $reinvest['symbol'])
->update([
'reinvest_dividends' => true,
]);
}
}
break;
default:
+1 -1
View File
@@ -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 ($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();
@@ -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'),
])];
});
}
}
+5
View File
@@ -50,4 +50,9 @@ class BackupImport extends Model
'completed_at' => 'datetime',
];
}
public function user()
{
return $this->belongsTo(User::class);
}
}
+62 -37
View File
@@ -111,7 +111,7 @@ class CurrencyRate extends Model
*
* @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)) {
return [];
@@ -132,24 +132,40 @@ class CurrencyRate extends Model
return $dateRange;
}
[$currency, $adjustment] = self::getCurrencyAliasAdjustments($currency);
if (is_array($currency)) {
$currencies = Currency::all()->pluck('currency')->toArray();
$i = 1;
foreach ($currency as $curr) {
// call api in chunks
$rates = [];
foreach (collect($period)->chunk(500) as $chunk) {
dispatch(fn () => self::timeSeriesRates($curr, $start, $end))->delay(now()->addSeconds(30 * $i));
$i++;
}
$chunkRates = Frankfurter::setSymbols($currencies)->timeSeries($chunk->min(), $chunk->max());
$rates = array_merge($rates, Arr::get($chunkRates, 'rates', []));
return [];
}
// handle currency alias
if (! empty($currency)) {
[$currency, $adjustment] = self::getCurrencyAliasAdjustments($currency);
} else {
$currency = Currency::all()->pluck('currency')->toArray();
}
// get rates
$rates = Frankfurter::setSymbols($currency)->timeSeries($period->first(), $period->last());
$rates = collect(Arr::get($rates, 'rates', []))->sortKeys()->toArray();
$datesOnly = array_keys($rates);
// loop through each date
$updates = [];
foreach ($period as $date) {
$lookupDate = self::getNearestPastDate($date, $rates);
$lookupDate = self::getNearestPastDate($date, $datesOnly, $rates);
if (is_null($lookupDate)) {
continue;
@@ -172,42 +188,53 @@ class CurrencyRate extends Model
// persist
self::chunkInsert($updates);
return collect($updates)
->whereBetween('date', [$start, $end ?? now()])
->where('currency', $currency)
->mapWithKeys(fn ($rate) => [
$rate['date'] => $rate['rate'] * $adjustment,
])
->toArray();
if (is_string($currency)) {
return collect($updates)
->whereBetween('date', [$start, $end ?? now()])
->where('currency', $currency)
->mapWithKeys(fn ($rate) => [
$rate['date'] => $rate['rate'] * ($adjustment ?? 1),
])
->toArray();
}
return [];
}
private static function getNearestPastDate(CarbonInterface $date, array $rates): ?CarbonInterface
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)
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?
if ($date->lessThan($first_date = Carbon::parse($datesWithRates[0]))) {
if ($mutableDate->lessThan($firstDate)) {
$date = $first_date;
break;
return $firstDate;
}
// try the day before then
$date = Carbon::parse($date)->subDay();
// prevent runaway infinite loops
if ($date->lessThan($date->copy()->subWeek())) {
$date = null;
break;
}
$mutableDate = $mutableDate->subDay();
}
return $date;
return $mutableDate;
}
public static function refreshCurrencyData($force = false): void
@@ -248,15 +275,13 @@ class CurrencyRate extends Model
public static function chunkInsert(array $updates): void
{
$chunks = array_chunk($updates, 250);
foreach ($chunks as $chunk) {
foreach (array_chunk($updates, 500) as $chunk) {
QueuedCurrencyRateInsertJob::dispatch($chunk);
}
}
protected static function getCurrencyAliasAdjustments($currency)
protected static function getCurrencyAliasAdjustments(string $currency)
{
$adjustment = 1;
+65 -97
View File
@@ -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);
+16 -10
View File
@@ -105,20 +105,25 @@ class Dividend extends Model
$market_data = MarketData::getMarketData($symbol);
// get historic conversion rates
$rate_to_base = CurrencyRate::timeSeriesRates($market_data->currency, $start_date, $end_date);
$dividend_data
->chunk(10)
->each(function ($chunk) use ($market_data) {
// create mass insert
foreach ($dividend_data as $index => $dividend) {
$rate_to_base_date = 1 / Arr::get($rate_to_base, Carbon::parse(Arr::get($dividend, 'date'))->toDateString(), 1);
// get historic conversion rates
$rate_to_base = CurrencyRate::timeSeriesRates($market_data->currency, $chunk->min('date'), $chunk->max('date'));
$dividend['dividend_amount_base'] = $dividend['dividend_amount'] * $rate_to_base_date;
// create mass insert
foreach ($chunk as $index => $dividend) {
$rate_to_base_date = 1 / Arr::get($rate_to_base, Carbon::parse(Arr::get($dividend, 'date'))->toDateString(), 1);
$dividend_data[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
}
$dividend['dividend_amount_base'] = $dividend['dividend_amount'] * $rate_to_base_date;
// insert records
(new self)->insertOrIgnore($dividend_data->toArray());
$chunk[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
}
// insert records
(new self)->insertOrIgnore($chunk->toArray());
});
// sync to holdings
self::syncHoldings($symbol);
@@ -186,6 +191,7 @@ class Dividend extends Model
'date' => $dividend['date'],
'portfolio_id' => $holding->portfolio_id,
'symbol' => $holding->symbol,
'currency' => $holding->market_data->currency,
'transaction_type' => 'BUY',
'reinvested_dividend' => true,
'cost_basis' => 0,
+143 -107
View File
@@ -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 = (
+11 -3
View File
@@ -152,7 +152,13 @@ class Portfolio extends Model
$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(
$holding->first_transaction_date,
@@ -163,7 +169,6 @@ class Portfolio extends Model
$daily_performance = $holding->dailyPerformance($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 = [];
@@ -179,7 +184,7 @@ class Portfolio extends Model
$holding_performance[$date] = [
'date' => $date,
'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)
+2 -2
View File
@@ -99,12 +99,12 @@ 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];
$this->user->options = array_merge($this->user->options ?? [], $options);
$this->options = array_merge($this->options ?? [], $options);
return $this;
}
+1 -1
View File
@@ -24,7 +24,7 @@
"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",
"tschucki/alphavantage-laravel": "^0.0"
},
Generated
+13 -11
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "42c893f20ccd0482c1c5a18aa3790acc",
"content-hash": "13310769a8c74dcffeb66fc87ab4e371",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -6104,28 +6104,30 @@
},
{
"name": "scheb/yahoo-finance-api",
"version": "v4.12.0",
"version": "v5.0.0",
"source": {
"type": "git",
"url": "https://github.com/scheb/yahoo-finance-api.git",
"reference": "08f47997d123389567e2c7fa7cafc94d4c4e2515"
"reference": "72561695bdccfb6318c985cf439254f646c3b127"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/scheb/yahoo-finance-api/zipball/08f47997d123389567e2c7fa7cafc94d4c4e2515",
"reference": "08f47997d123389567e2c7fa7cafc94d4c4e2515",
"url": "https://api.github.com/repos/scheb/yahoo-finance-api/zipball/72561695bdccfb6318c985cf439254f646c3b127",
"reference": "72561695bdccfb6318c985cf439254f646c3b127",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/guzzle": "^6.0|^7.0",
"php": ">=7.1.3"
"guzzlehttp/guzzle": "^7",
"php": ">=8.1",
"psr/cache": "^2|^3"
},
"require-dev": {
"escapestudios/symfony2-coding-standard": "^3.9",
"phpunit/phpunit": "^7.5 || ^8 || ^9",
"phpunit/phpunit": "^10.5|^11|^12",
"squizlabs/php_codesniffer": "^3.5",
"vimeo/psalm": "^3.11|^4.0"
"symfony/cache": "^6|^7",
"vimeo/psalm": "^6.0"
},
"type": "library",
"autoload": {
@@ -6153,9 +6155,9 @@
],
"support": {
"issues": "https://github.com/scheb/yahoo-finance-api/issues",
"source": "https://github.com/scheb/yahoo-finance-api/tree/v4.12.0"
"source": "https://github.com/scheb/yahoo-finance-api/tree/v5.0.0"
},
"time": "2025-03-27T15:44:16+00:00"
"time": "2025-07-05T20:58:45+00:00"
},
{
"name": "spatie/laravel-package-tools",
+8
View File
@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
return [
'key' => env('ALPACA_API_KEY'),
'secret' => env('ALPACA_API_SECRET'),
];
+1
View File
@@ -11,6 +11,7 @@ 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,
'fake' => App\Interfaces\MarketData\FakeMarketData::class,
],
+14
View File
@@ -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) => [
@@ -3,10 +3,12 @@
declare(strict_types=1);
use App\Models\CurrencyRate;
use App\Models\Holding;
use App\Models\Transaction;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\MarketDataSeeder;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
@@ -23,10 +25,15 @@ return new class extends Migration
* Add options column to users table
*/
Schema::table('users', function (Blueprint $table) {
$table->json('options')->default(json_encode([
'locale' => config('app.locale', 'en'),
'display_currency' => config('investbrain.base_currency', 'USD'),
]))->after('profile_photo_path');
$locale = config('app.locale', 'en');
$currency = config('investbrain.base_currency', 'USD');
$default = config('database.default') === 'mysql'
? new Expression("(JSON_OBJECT('locale', '{$locale}', 'display_currency', '{$currency}'))")
: json_encode(['locale' => $locale, 'display_currency' => $currency]);
$table->json('options')->default($default)->after('profile_photo_path');
});
/**
@@ -96,17 +103,17 @@ return new class extends Migration
'--force' => true,
]);
CurrencyRate::timeSeriesRates(
'', // use fake currency to force
Transaction::min('date')
);
CurrencyRate::refreshCurrencyData();
Artisan::call('db:seed', [
'--class' => MarketDataSeeder::class,
'--force' => true,
]);
CurrencyRate::timeSeriesRates(
Holding::all()->groupBy('market_data.currency')->keys()->toArray(),
Transaction::min('date')
);
CurrencyRate::refreshCurrencyData();
}
/**
+10 -10
View File
@@ -12,8 +12,6 @@ class MarketDataSeeder extends Seeder
{
use WithoutModelEvents;
public array $rows = [];
/**
* Run the database seeds.
*/
@@ -44,7 +42,7 @@ class MarketDataSeeder extends Seeder
$meta_data = json_decode(base64_decode($data['meta_data']), true);
$meta_data['source'] = 'market_data_seeder';
$this->rows[] = [
$rows[] = [
'symbol' => $data['symbol'],
'name' => $data['name'],
'currency' => $data['currency'],
@@ -54,16 +52,17 @@ class MarketDataSeeder extends Seeder
$rowCount++;
if ($rowCount % $chunkSize == 0) {
DB::table('market_data')->upsert($this->rows, ['symbol'], ['name', 'currency', 'meta_data']);
$this->rows = [];
$this->bulkInsert($rows);
$rows = [];
}
}
}
// final clean up
if (! empty($this->rows)) {
if (! empty($rows)) {
$this->bulkInsert($this->rows);
$this->bulkInsert($rows);
$rows = [];
}
// Close the CSV file
@@ -77,16 +76,17 @@ class MarketDataSeeder extends Seeder
}
}
public function bulkInsert(array $rows)
private function bulkInsert($rows): void
{
try {
DB::table('market_data')->insertOrIgnore($rows);
$this->rows = [];
DB::table('market_data')->upsert($rows, ['symbol'], ['name', 'currency', 'meta_data']);
} catch (\Throwable $e) {
throw new \Exception('Error: '.$e->getMessage());
}
gc_collect_cycles();
}
}
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -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:
+4 -1
View File
@@ -44,6 +44,9 @@ FROM php:8.3-fpm-alpine
# Set the working directory
WORKDIR /var/app
ARG VERSION=dev
ENV VERSION=$VERSION
# Copy necessary files from the builder stage
COPY --from=builder /var/app /var/app
COPY --from=builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d
@@ -62,7 +65,7 @@ RUN apk add --no-cache \
bash \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
gd pgsql zip pdo_mysql mysqli intl
gd pgsql zip pdo_mysql pdo_pgsql mysqli intl
# Remove default nginx config
RUN rm -rf /var/www/html \
+14 -3
View File
@@ -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>
+1 -1
View File
@@ -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();
}
+1 -1
View File
@@ -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" />
Binary file not shown.
+5 -2
View File
@@ -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);
}
+5 -4
View File
@@ -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');
@@ -221,7 +222,7 @@ class DailyChangeTest extends TestCase
$second_transaction = Transaction::create([
'symbol' => 'AAPL',
'portfolio_id' => $portfolio->id,
'date' => now()->subYears(3),
'date' => now()->subDays(1080), // 3 years
'quantity' => 1,
'cost_basis' => 39.89,
'transaction_type' => 'BUY',
@@ -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([
+19
View File
@@ -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);
}
}
+77
View File
@@ -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');
}
}
+4 -3
View File
@@ -78,19 +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([
'id' => '9cf8a662-7347-49fb-b9de-0cc1430a8d1f',
'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([
+1 -1
View File
@@ -21,7 +21,7 @@ class MarketDataTest extends TestCase
'--force' => true,
]);
$this->assertEquals(14464, MarketData::count('symbol'));
$this->assertEquals(13187, MarketData::count('symbol'));
}
public function test_can_get_quote_from_provider()
+108 -42
View File
@@ -133,11 +133,8 @@ class MultiCurrencyTest extends TestCase
$portfolio = Portfolio::factory()->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')
->andReturnSelf()
->times($expected_num_calls);
->andReturnSelf();
Frankfurter::expects('timeSeries')
->andReturn(['rates' => [
now()->subDays(3)->toDateString() => [
@@ -152,8 +149,7 @@ class MultiCurrencyTest extends TestCase
now()->toDateString() => [
'ZZZ' => .01,
],
]])
->times($expected_num_calls);
]]);
CurrencyRate::timeSeriesRates(
'', // use fake currency to force
@@ -229,8 +225,71 @@ class MultiCurrencyTest extends TestCase
->andReturn(['rates' => $results]);
$result = CurrencyRate::timeSeriesRates('ZZZ', $start, $end);
$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()
@@ -252,11 +311,9 @@ class MultiCurrencyTest extends TestCase
});
Frankfurter::expects('setSymbols')
->andReturnSelf()
->times(4);
->andReturnSelf();
Frankfurter::expects('timeSeries')
->andReturn(['rates' => $results])
->times(4);
->andReturn(['rates' => $results]);
CurrencyRate::timeSeriesRates('ZZZ', $start, $end);
}
@@ -487,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)
@@ -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_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 ?? [], [
@@ -527,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)
@@ -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_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