Compare commits

...

11 Commits

Author SHA1 Message Date
hackerESQ d1111ad4a4 chore: upgrade deps 2025-08-28 21:55:34 -05:00
hackerESQ f49a4b036e chore: upgrade to laravel 12 2025-08-28 21:31:37 -05:00
hackerESQ 1e41296a09 chore: clean up 2025-08-28 21:25:09 -05:00
hackerESQ 90a1f72abc chore: cleanup old files 2025-08-28 21:23:43 -05:00
Shift 44d2716406 Add .shift to open Pull Request 2025-08-29 02:19:56 +00:00
hackerESQ 712a4c6c57 docs: further clarification 2025-08-28 20:58:25 -05:00
hackerESQ 78f0d21b73 docs: clarify commands and update intro 2025-08-28 18:08:28 -05:00
hackerESQ 19cac58692 Fix: do not gracefully fail when symbol not found 2025-08-28 16:03:02 -05:00
hackerESQ 7d77b6fbc8 feat: add fix command for cost basis for sale transaction 2025-08-27 20:22:13 -05:00
hackerESQ e4e08091af fix: need to chunk alpaca history requests 2025-08-27 20:03:25 -05:00
hackerESQ 292d43b154 feat: adds cost basis fixer 2025-08-26 23:03:23 -05:00
9 changed files with 176 additions and 51 deletions
+4
View File
@@ -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.
+12 -6
View 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!');
}
}
+50 -18
View File
@@ -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,24 +132,49 @@ class AlpacaMarketData implements MarketDataInterface
public function history(string $symbol, $startDate, $endDate): Collection public function history(string $symbol, $startDate, $endDate): Collection
{ {
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([ $startDate = Carbon::parse($startDate);
'timeframe' => '1D', $endDate = Carbon::parse($endDate)->subHours(36); // alpaca has sip data limits
'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'); $allHistory = collect();
return collect($history) $chunks = 1000;
->map(function ($history) use ($symbol) {
$date = Carbon::parse($history['t'])->format('Y-m-d'); $period = CarbonInterval::days($chunks)->toPeriod($startDate, $endDate);
foreach ($period as $startDate) {
return [$date => new Ohlc([ $chunkEnd = $startDate->copy()->addDays($chunks - 1);
'symbol' => $symbol,
'date' => $date, if ($chunkEnd->gt($endDate)) {
'close' => Arr::get($history, 'c'), $chunkEnd = $endDate;
])]; }
});
$this->createNewClient();
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
'timeframe' => '1D',
'start' => $startDate->format('Y-m-d'),
'end' => $chunkEnd->format('Y-m-d'),
])->get("v2/stocks/{$symbol}/bars");
$history = $response->json('bars');
throw_if(empty($history), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
$chunkedHistory = collect($history)
->mapWithKeys(function ($history) use ($symbol) {
$date = Carbon::parse($history['t'])->format('Y-m-d');
return [$date => new Ohlc([
'symbol' => $symbol,
'date' => $date,
'close' => Arr::get($history, 'c'),
])];
});
$allHistory = $allHistory->merge($chunkedHistory);
}
return $allHistory;
} }
} }
@@ -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();
-16
View File
@@ -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;
// }
}
+1 -4
View File
@@ -35,7 +35,7 @@
"laravel/sail": "^1.26", "laravel/sail": "^1.26",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.0", "nunomaduro/collision": "^8.0",
"phpunit/phpunit": "^11.0.1" "phpunit/phpunit": "^11.0"
}, },
"repositories": [ "repositories": [
{ {
@@ -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/",
@@ -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>