Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 921ac28ba7 | |||
| 2b099b3156 | |||
| ee7668df6f | |||
| a882b5aadb | |||
| bad82fb41b | |||
| 5aca9008cb | |||
| 712a4c6c57 | |||
| 78f0d21b73 | |||
| 19cac58692 | |||
| 7d77b6fbc8 | |||
| e4e08091af | |||
| 292d43b154 |
@@ -0,0 +1,4 @@
|
|||||||
|
This file was added by Shift #157267 in order to open a
|
||||||
|
Pull Request since no other commits were made.
|
||||||
|
|
||||||
|
You should remove this file.
|
||||||
@@ -28,7 +28,7 @@ Investbrain is a smart open-source investment tracker that helps you manage, tra
|
|||||||
|
|
||||||
## Under the hood
|
## Under the hood
|
||||||
|
|
||||||
Investbrain is a Laravel PHP web application that leverages Livewire and Tailwind for its frontend. Most databases should work, including MySQL and SQLite. Out of the box, we feature 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.
|
Investbrain is a Laravel PHP web application that has an extensible market data provider interface. Out of the box, we feature many market data providers. But intrepid developers can [create their own providers](#custom-providers)! We also offer integrations with OpenAI and Ollama for our ["chat with your holdings"](#chat-with-your-holdings) capability. Finally, of course we have robust support for i18n, a11y, and dark mode.
|
||||||
|
|
||||||
## Self hosting
|
## Self hosting
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ Your selected providers should be listed in your environment variables. Each sho
|
|||||||
MARKET_DATA_PROVIDER=yahoo,alphavantage
|
MARKET_DATA_PROVIDER=yahoo,alphavantage
|
||||||
```
|
```
|
||||||
|
|
||||||
In the above example, Yahoo Finance will be attempted first and the Alpha Vantage provider will be used as the fallback. If Yahoo Finance fails to retrieve market data, the application will automatically try Alpha Vantage.
|
In the above example, Yahoo Finance will be attempted first. If Yahoo Finance fails to retrieve market data, the application will automatically try Alpha Vantage.
|
||||||
|
|
||||||
### Custom providers
|
### Custom providers
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ Easy as that!
|
|||||||
|
|
||||||
## Command line utilities
|
## Command line utilities
|
||||||
|
|
||||||
Investbrain comes bundled with several helpful command line utilities to make managing your portfolios and holdings more efficient. Keep in mind these commands are extremely powerful and can make irreversable changes to your holdings.
|
Investbrain comes bundled with several helpful command line utilities to make managing your portfolios and holdings more efficient. Keep in mind these commands are extremely powerful and can make irreversable changes to your holdings. Just to be safe, we recommend backing up your portfolios before using these commands.
|
||||||
|
|
||||||
To run these commands, you can use `docker exec` like this:
|
To run these commands, you can use `docker exec` like this:
|
||||||
|
|
||||||
@@ -189,7 +189,12 @@ To run these commands, you can use `docker exec` like this:
|
|||||||
docker exec -it investbrain-app php artisan <replace with command you want to run>
|
docker exec -it investbrain-app php artisan <replace with command you want to run>
|
||||||
```
|
```
|
||||||
|
|
||||||
Just to be safe, we recommend backing up your portfolios before using these commands:
|
If you need more details on what the command does, you can take a look at the options available using the `help` option:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
<command you want to run> --help
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------------- | ------------- |
|
| ------------- | ------------- |
|
||||||
@@ -198,8 +203,9 @@ Just to be safe, we recommend backing up your portfolios before using these comm
|
|||||||
| refresh:split-data | Refreshes splits data with your configured market data provider. Will also create new transactions to account for any splits. |
|
| refresh:split-data | Refreshes splits data with your configured market data provider. Will also create new transactions to account for any splits. |
|
||||||
| refresh:currency-data | Grabs the latest daily currency exchange rate data and persists to the database. |
|
| refresh:currency-data | Grabs the latest daily currency exchange rate data and persists to the database. |
|
||||||
| capture:daily-change | Captures a snapshot of each portfolio's daily performance. |
|
| capture:daily-change | Captures a snapshot of each portfolio's daily performance. |
|
||||||
| sync:daily-change | Re-calculates daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) |
|
| sync:daily-change | Syncs daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) |
|
||||||
| sync:holdings | Re-calculates performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
|
| sync:holdings | Syncs performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
|
||||||
|
| fix:cost-basis-for-sales | Utility to automatically re-calculates cost basis for sale transactions. |
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class FixCostBasisForSales extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'fix:cost-basis-for-sales
|
||||||
|
{--portfolio= : The ID of the portfolio to fix.}
|
||||||
|
{--user= : The user ID of transactions to fix.}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Fixes broken costs basis for sale transactions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new command instance.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
|
||||||
|
if (empty($this->option('user')) && empty($this->option('portfolio'))) {
|
||||||
|
|
||||||
|
$this->error('Must provide at least a user or portfolio.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$transactions = Transaction::where(['transaction_type' => 'SELL']);
|
||||||
|
|
||||||
|
if ($this->option('user')) {
|
||||||
|
|
||||||
|
$portfolios = Portfolio::fullAccess($this->option('user'))->get('id')
|
||||||
|
->pluck('id')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$transactions->whereIn('portfolio_id', $portfolios);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
$transactions->where(['portfolio_id' => $this->option('portfolio')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$transactions = $transactions->get();
|
||||||
|
|
||||||
|
$this->line("Fixing cost basis for {$transactions->count()} sale transactions...");
|
||||||
|
|
||||||
|
$transactions->chunk(10)->each(function ($chunk) {
|
||||||
|
|
||||||
|
dispatch(function () use ($chunk) {
|
||||||
|
|
||||||
|
$chunk->each(function ($transaction) {
|
||||||
|
|
||||||
|
$cost_basis = Transaction::where([
|
||||||
|
'portfolio_id' => $transaction->portfolio_id,
|
||||||
|
'symbol' => $transaction->symbol,
|
||||||
|
'transaction_type' => 'BUY',
|
||||||
|
])->whereDate('date', '<=', $transaction->date)
|
||||||
|
->selectRaw('SUM(transactions.cost_basis * transactions.quantity) as total_cost_basis')
|
||||||
|
->selectRaw('SUM(transactions.quantity) as total_quantity')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$average_cost_basis = empty($cost_basis->total_quantity)
|
||||||
|
? 0
|
||||||
|
: $cost_basis->total_cost_basis / $cost_basis->total_quantity;
|
||||||
|
|
||||||
|
$transaction->cost_basis = $average_cost_basis ?? 0;
|
||||||
|
|
||||||
|
$transaction->save();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->line('Done!');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,13 @@ use App\Interfaces\MarketData\Types\Dividend;
|
|||||||
use App\Interfaces\MarketData\Types\Ohlc;
|
use App\Interfaces\MarketData\Types\Ohlc;
|
||||||
use App\Interfaces\MarketData\Types\Quote;
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
use App\Interfaces\MarketData\Types\Split;
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
|
use Carbon\CarbonInterval;
|
||||||
use Illuminate\Http\Client\PendingRequest;
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
class AlpacaMarketData implements MarketDataInterface
|
class AlpacaMarketData implements MarketDataInterface
|
||||||
{
|
{
|
||||||
@@ -23,6 +25,11 @@ class AlpacaMarketData implements MarketDataInterface
|
|||||||
public string $apiBaseUrl = 'https://api.alpaca.markets/';
|
public string $apiBaseUrl = 'https://api.alpaca.markets/';
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createNewClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createNewClient()
|
||||||
{
|
{
|
||||||
$this->client = Http::withOptions([
|
$this->client = Http::withOptions([
|
||||||
'headers' => [
|
'headers' => [
|
||||||
@@ -45,15 +52,15 @@ class AlpacaMarketData implements MarketDataInterface
|
|||||||
|
|
||||||
$quote = $response->json('trade');
|
$quote = $response->json('trade');
|
||||||
|
|
||||||
if (is_null(Arr::get($quote, 'p'))) {
|
throw_if(empty(Arr::get($quote, 'p')), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
|
||||||
throw new \Exception('Could not find ticker on Alpaca');
|
|
||||||
}
|
|
||||||
|
|
||||||
$fundamental = cache()->remember(
|
$fundamental = cache()->remember(
|
||||||
'ap-symbol-'.$symbol,
|
'ap-symbol-'.$symbol,
|
||||||
1440,
|
1440,
|
||||||
function () use ($symbol) {
|
function () use ($symbol) {
|
||||||
|
|
||||||
|
$this->createNewClient();
|
||||||
|
|
||||||
$basic = $this->client->baseUrl($this->apiBaseUrl)->get("v2/assets/{$symbol}")->json();
|
$basic = $this->client->baseUrl($this->apiBaseUrl)->get("v2/assets/{$symbol}")->json();
|
||||||
$fifty_two_week = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
|
$fifty_two_week = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
|
||||||
'timeframe' => '12M',
|
'timeframe' => '12M',
|
||||||
@@ -125,16 +132,36 @@ class AlpacaMarketData implements MarketDataInterface
|
|||||||
|
|
||||||
public function history(string $symbol, $startDate, $endDate): Collection
|
public function history(string $symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
|
$startDate = Carbon::parse($startDate);
|
||||||
|
$endDate = Carbon::parse($endDate)->subHours(36); // alpaca has sip data limits
|
||||||
|
|
||||||
|
$allHistory = collect();
|
||||||
|
|
||||||
|
$chunks = 1000;
|
||||||
|
|
||||||
|
$period = CarbonInterval::days($chunks)->toPeriod($startDate, $endDate);
|
||||||
|
foreach ($period as $startDate) {
|
||||||
|
|
||||||
|
$chunkEnd = $startDate->copy()->addDays($chunks - 1);
|
||||||
|
|
||||||
|
if ($chunkEnd->gt($endDate)) {
|
||||||
|
$chunkEnd = $endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->createNewClient();
|
||||||
|
|
||||||
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
|
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
|
||||||
'timeframe' => '1D',
|
'timeframe' => '1D',
|
||||||
'start' => Carbon::parse($startDate)->format('Y-m-d'),
|
'start' => $startDate->format('Y-m-d'),
|
||||||
'end' => Carbon::parse($endDate)->subHours(36)->format('Y-m-d'), // todo: can't query recent SIP data
|
'end' => $chunkEnd->format('Y-m-d'),
|
||||||
])->get("v2/stocks/{$symbol}/bars");
|
])->get("v2/stocks/{$symbol}/bars");
|
||||||
|
|
||||||
$history = $response->json('bars');
|
$history = $response->json('bars');
|
||||||
|
|
||||||
return collect($history)
|
throw_if(empty($history), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
|
||||||
->map(function ($history) use ($symbol) {
|
|
||||||
|
$chunkedHistory = collect($history)
|
||||||
|
->mapWithKeys(function ($history) use ($symbol) {
|
||||||
|
|
||||||
$date = Carbon::parse($history['t'])->format('Y-m-d');
|
$date = Carbon::parse($history['t'])->format('Y-m-d');
|
||||||
|
|
||||||
@@ -144,5 +171,10 @@ class AlpacaMarketData implements MarketDataInterface
|
|||||||
'close' => Arr::get($history, 'c'),
|
'close' => Arr::get($history, 'c'),
|
||||||
])];
|
])];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$allHistory = $allHistory->merge($chunkedHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $allHistory;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ class AlphaVantageMarketData implements MarketDataInterface
|
|||||||
return [$date => new Ohlc([
|
return [$date => new Ohlc([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'close' => Arr::get($history, '4. close'),
|
'close' => (float) Arr::get($history, '4. close'),
|
||||||
])];
|
])];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use Illuminate\Support\Arr;
|
|||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
class TwelveDataMarketData implements MarketDataInterface
|
class TwelveDataMarketData implements MarketDataInterface
|
||||||
{
|
{
|
||||||
@@ -53,9 +54,7 @@ class TwelveDataMarketData implements MarketDataInterface
|
|||||||
|
|
||||||
$quote = $response->json();
|
$quote = $response->json();
|
||||||
|
|
||||||
if (! isset($quote['price'])) {
|
throw_if(empty(Arr::get($quote, 'price')), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
|
||||||
throw new \Exception('Could not find ticker on Twelve Data');
|
|
||||||
}
|
|
||||||
|
|
||||||
$current_market_value = Arr::get($quote, 'price');
|
$current_market_value = Arr::get($quote, 'price');
|
||||||
|
|
||||||
@@ -152,9 +151,11 @@ class TwelveDataMarketData implements MarketDataInterface
|
|||||||
])
|
])
|
||||||
->get('time_series');
|
->get('time_series');
|
||||||
|
|
||||||
$values = $response->json('values');
|
$history = $response->json('values');
|
||||||
|
|
||||||
return collect($values)
|
throw_if(empty($history), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
|
||||||
|
|
||||||
|
return collect($history)
|
||||||
->mapWithKeys(function ($history) use ($symbol) {
|
->mapWithKeys(function ($history) use ($symbol) {
|
||||||
|
|
||||||
$date = Carbon::parse(Arr::get($history, 'datetime'))->toDateString();
|
$date = Carbon::parse(Arr::get($history, 'datetime'))->toDateString();
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace App\Rules;
|
namespace App\Rules;
|
||||||
|
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
use App\Models\Transaction;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class QuantityValidationRule implements ValidationRule
|
class QuantityValidationRule implements ValidationRule
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\Currency;
|
|
||||||
|
|
||||||
if (! function_exists('currency')) {
|
|
||||||
|
|
||||||
// /**
|
|
||||||
// * Returns an instance of the currency model
|
|
||||||
// * */
|
|
||||||
// function currency(): Currency
|
|
||||||
// {
|
|
||||||
// return new Currency;
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
@@ -55,9 +55,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"files": [
|
|
||||||
"app/Support/Helpers.php"
|
|
||||||
],
|
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"App\\": "app/",
|
"App\\": "app/",
|
||||||
"Database\\Factories\\": "database/factories/",
|
"Database\\Factories\\": "database/factories/",
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ return [
|
|||||||
*/
|
*/
|
||||||
'components' => [
|
'components' => [
|
||||||
'spotlight' => [
|
'spotlight' => [
|
||||||
'class' => 'App\Support\Spotlight',
|
'class' => App\Support\Spotlight::class,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ new class extends Component
|
|||||||
|
|
||||||
<x-menu-item title="{{ __('Log Out') }}" icon="o-power" onclick="event.preventDefault(); document.getElementById('logout').submit();" />
|
<x-menu-item title="{{ __('Log Out') }}" icon="o-power" onclick="event.preventDefault(); document.getElementById('logout').submit();" />
|
||||||
<form id="logout" action="{{ route('logout') }}" method="POST" style="display: none;">
|
<form id="logout" action="{{ route('logout') }}" method="POST" style="display: none;">
|
||||||
{{ csrf_field() }}
|
@csrf
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</x-dropdown>
|
</x-dropdown>
|
||||||
|
|||||||
@@ -52,10 +52,10 @@ new class extends Component
|
|||||||
{{ Number::currency($holding->dividends_earned ?? 0, $holding->market_data->currency) }}
|
{{ Number::currency($holding->dividends_earned ?? 0, $holding->market_data->currency) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="pt-2 text-sm" title="{{ \Carbon\Carbon::parse($holding->market_data->updated_at)->toIso8601String() }}">
|
<p class="pt-2 text-sm" title="{{ \Illuminate\Support\Carbon::parse($holding->market_data->updated_at)->toIso8601String() }}">
|
||||||
{{ __('Last Refreshed') }}:
|
{{ __('Last Refreshed') }}:
|
||||||
{{ !is_null($holding->market_data->updated_at)
|
{{ !is_null($holding->market_data->updated_at)
|
||||||
? \Carbon\Carbon::parse($holding->market_data->updated_at)->diffForHumans()
|
? \Illuminate\Support\Carbon::parse($holding->market_data->updated_at)->diffForHumans()
|
||||||
: '' }}
|
: '' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,6 +98,6 @@ new class extends Component
|
|||||||
{{ Number::currency($row->dividends_earned ?? 0, $row->market_data->currency) }}
|
{{ Number::currency($row->dividends_earned ?? 0, $row->market_data->currency) }}
|
||||||
@endscope
|
@endscope
|
||||||
@scope('cell_market_data_updated_at', $row)
|
@scope('cell_market_data_updated_at', $row)
|
||||||
{{ \Carbon\Carbon::parse($row->market_data_updated_at)->diffForHumans() }}
|
{{ \Illuminate\Support\Carbon::parse($row->market_data_updated_at)->diffForHumans() }}
|
||||||
@endscope
|
@endscope
|
||||||
</x-table>
|
</x-table>
|
||||||
|
|||||||
Reference in New Issue
Block a user