Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf475657cf | |||
| 90a15ceddb | |||
| 981ce0d62f | |||
| 154b679464 | |||
| ee51cb7e2a | |||
| 40120c7027 | |||
| cfd5b8a4f3 | |||
| 3b93e328d5 | |||
| 1fd858287d | |||
| e370f5bbb7 | |||
| 3e492475c0 | |||
| c454e85ad4 | |||
| 487322abb5 | |||
| f78c521dc4 | |||
| ff9bcd782f | |||
| 1ccf515ca2 | |||
| 1b0f9c134c | |||
| 3589242996 | |||
| 689aa4d50b | |||
| 26370c03c4 | |||
| 80b043219a | |||
| de54b6843d |
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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().')');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -54,11 +54,17 @@ class ConfigSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadi
|
||||
$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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
+62
-37
@@ -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, 500);
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -99,8 +99,11 @@ class DailyChange extends Model
|
||||
'tx1.quantity',
|
||||
])
|
||||
->selectRaw("(CASE
|
||||
WHEN tx1.transaction_type = 'BUY'
|
||||
THEN COALESCE(cr.rate, 1)
|
||||
WHEN
|
||||
tx1.transaction_type = 'BUY'
|
||||
OR SUM(tx1.cost_basis_base) = 0
|
||||
THEN
|
||||
COALESCE(cr.rate, 1)
|
||||
ELSE (
|
||||
SELECT
|
||||
SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base)
|
||||
|
||||
+16
-10
@@ -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,
|
||||
|
||||
@@ -302,8 +302,11 @@ class Holding extends Model
|
||||
])
|
||||
->selectRaw(
|
||||
"(CASE
|
||||
WHEN transactions.transaction_type = 'BUY'
|
||||
THEN COALESCE(cr.rate, 1)
|
||||
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)
|
||||
|
||||
@@ -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)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
@@ -221,7 +221,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',
|
||||
|
||||
@@ -84,7 +84,6 @@ class ImportExportTest extends TestCase
|
||||
]);
|
||||
|
||||
$holding = Holding::create([
|
||||
'id' => '9cf8a662-7347-49fb-b9de-0cc1430a8d1f',
|
||||
'portfolio_id' => '9e792bb8-94e7-4ed3-b8cc-43b50d34c337',
|
||||
'symbol' => 'ACME',
|
||||
'quantity' => 0,
|
||||
|
||||
@@ -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()
|
||||
|
||||
+68
-11
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user