Compare commits

...

2 Commits

Author SHA1 Message Date
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
11 changed files with 92 additions and 68 deletions
+12 -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,5 @@ 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 }}
+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;
}
+11 -5
View File
@@ -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:
+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();
+36 -27
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 $currency, mixed $start = null, mixed $end = null): array
{
if (empty($start)) {
return [];
@@ -134,22 +134,27 @@ class CurrencyRate extends Model
[$currency, $adjustment] = self::getCurrencyAliasAdjustments($currency);
$currencies = Currency::all()->pluck('currency')->toArray();
if (! empty($currency)) {
// call api in chunks
$rates = [];
foreach (collect($period)->chunk(500) as $chunk) {
$currencies = Arr::wrap($currency);
$chunkRates = Frankfurter::setSymbols($currencies)->timeSeries($chunk->min(), $chunk->max());
} else {
$rates = array_merge($rates, Arr::get($chunkRates, 'rates', []));
$currencies = Currency::all()->pluck('currency')->toArray();
}
// get rates
$rates = Frankfurter::setSymbols($currencies)->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;
@@ -181,33 +186,39 @@ class CurrencyRate extends Model
->toArray();
}
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,9 +259,7 @@ 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);
}
+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,
+7 -3
View File
@@ -152,7 +152,12 @@ class Portfolio extends Model
$total_performance = [];
$holdings->each(function ($holding) use (&$total_performance) {
// get unique currencies for holdings
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 +168,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 +183,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)),
];
}
}
Binary file not shown.
-1
View File
@@ -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,
+4 -10
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
@@ -252,11 +248,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);
}