Compare commits

...

18 Commits

Author SHA1 Message Date
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
17 changed files with 135 additions and 1143 deletions
@@ -61,4 +61,6 @@ jobs:
file: ./docker/Dockerfile file: ./docker/Dockerfile
push: true push: true
tags: ${{ steps.extract-version.outputs.tags }} tags: ${{ steps.extract-version.outputs.tags }}
build-args: |
VERSION=${{ github.ref_name }}
+13 -1
View File
@@ -111,7 +111,7 @@ class CurrencyRate extends Model
* *
* @return array<string, float> * @return array<string, float>
*/ */
public static function timeSeriesRates(?string $currency = null, 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)) { if (empty($start)) {
return []; return [];
@@ -132,6 +132,18 @@ class CurrencyRate extends Model
return $dateRange; return $dateRange;
} }
if (is_array($currency)) {
$i = 1;
foreach ($currency as $curr) {
dispatch(fn () => self::timeSeriesRates($curr, $start, $end))->delay(now()->addSeconds(30 * $i));
$i++;
}
return [];
}
// handle currency alias // handle currency alias
if (! empty($currency)) { if (! empty($currency)) {
+5 -2
View File
@@ -99,8 +99,11 @@ class DailyChange extends Model
'tx1.quantity', 'tx1.quantity',
]) ])
->selectRaw("(CASE ->selectRaw("(CASE
WHEN tx1.transaction_type = 'BUY' WHEN
THEN COALESCE(cr.rate, 1) tx1.transaction_type = 'BUY'
OR SUM(tx1.cost_basis_base) = 0
THEN
COALESCE(cr.rate, 1)
ELSE ( ELSE (
SELECT SELECT
SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base) SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base)
+5 -2
View File
@@ -302,8 +302,11 @@ class Holding extends Model
]) ])
->selectRaw( ->selectRaw(
"(CASE "(CASE
WHEN transactions.transaction_type = 'BUY' WHEN
THEN COALESCE(cr.rate, 1) transactions.transaction_type = 'BUY'
OR SUM(transactions.cost_basis_base) = 0
THEN
COALESCE(cr.rate, 1)
ELSE ( ELSE (
SELECT SELECT
SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base) SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base)
+1
View File
@@ -153,6 +153,7 @@ class Portfolio extends Model
$total_performance = []; $total_performance = [];
// get unique currencies for holdings // get unique currencies for holdings
$currency_rates = [];
foreach ($holdings->groupBy('market_data.currency')->keys() as $currency) { foreach ($holdings->groupBy('market_data.currency')->keys() as $currency) {
$currency_rates[$currency] = CurrencyRate::timeSeriesRates($currency, $holdings->min('first_transaction_date'), now()); $currency_rates[$currency] = CurrencyRate::timeSeriesRates($currency, $holdings->min('first_transaction_date'), now());
} }
+1 -1
View File
@@ -24,7 +24,7 @@
"openai-php/client": "^0.10.3", "openai-php/client": "^0.10.3",
"predis/predis": "^2.2", "predis/predis": "^2.2",
"robsontenorio/mary": "^1.35", "robsontenorio/mary": "^1.35",
"scheb/yahoo-finance-api": "^4.11", "scheb/yahoo-finance-api": "^5.0",
"staudenmeir/eloquent-has-many-deep": "^1.20", "staudenmeir/eloquent-has-many-deep": "^1.20",
"tschucki/alphavantage-laravel": "^0.0" "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", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "42c893f20ccd0482c1c5a18aa3790acc", "content-hash": "13310769a8c74dcffeb66fc87ab4e371",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@@ -6104,28 +6104,30 @@
}, },
{ {
"name": "scheb/yahoo-finance-api", "name": "scheb/yahoo-finance-api",
"version": "v4.12.0", "version": "v5.0.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/scheb/yahoo-finance-api.git", "url": "https://github.com/scheb/yahoo-finance-api.git",
"reference": "08f47997d123389567e2c7fa7cafc94d4c4e2515" "reference": "72561695bdccfb6318c985cf439254f646c3b127"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/scheb/yahoo-finance-api/zipball/08f47997d123389567e2c7fa7cafc94d4c4e2515", "url": "https://api.github.com/repos/scheb/yahoo-finance-api/zipball/72561695bdccfb6318c985cf439254f646c3b127",
"reference": "08f47997d123389567e2c7fa7cafc94d4c4e2515", "reference": "72561695bdccfb6318c985cf439254f646c3b127",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-json": "*", "ext-json": "*",
"guzzlehttp/guzzle": "^6.0|^7.0", "guzzlehttp/guzzle": "^7",
"php": ">=7.1.3" "php": ">=8.1",
"psr/cache": "^2|^3"
}, },
"require-dev": { "require-dev": {
"escapestudios/symfony2-coding-standard": "^3.9", "escapestudios/symfony2-coding-standard": "^3.9",
"phpunit/phpunit": "^7.5 || ^8 || ^9", "phpunit/phpunit": "^10.5|^11|^12",
"squizlabs/php_codesniffer": "^3.5", "squizlabs/php_codesniffer": "^3.5",
"vimeo/psalm": "^3.11|^4.0" "symfony/cache": "^6|^7",
"vimeo/psalm": "^6.0"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -6153,9 +6155,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/scheb/yahoo-finance-api/issues", "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", "name": "spatie/laravel-package-tools",
@@ -8,6 +8,7 @@ use App\Models\Transaction;
use Database\Seeders\CurrencySeeder; use Database\Seeders\CurrencySeeder;
use Database\Seeders\MarketDataSeeder; use Database\Seeders\MarketDataSeeder;
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -24,10 +25,15 @@ return new class extends Migration
* Add options column to users table * Add options column to users table
*/ */
Schema::table('users', function (Blueprint $table) { Schema::table('users', function (Blueprint $table) {
$table->json('options')->default(json_encode([
'locale' => config('app.locale', 'en'), $locale = config('app.locale', 'en');
'display_currency' => config('investbrain.base_currency', 'USD'), $currency = config('investbrain.base_currency', 'USD');
]))->after('profile_photo_path');
$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');
}); });
/** /**
@@ -97,22 +103,17 @@ return new class extends Migration
'--force' => true, '--force' => true,
]); ]);
Holding::all()->groupBy('market_data.currency')->keys()->each(
fn ($currency) => dispatch(
function () use ($currency) {
CurrencyRate::timeSeriesRates(
$currency,
Transaction::min('date')
);
})
);
CurrencyRate::refreshCurrencyData();
Artisan::call('db:seed', [ Artisan::call('db:seed', [
'--class' => MarketDataSeeder::class, '--class' => MarketDataSeeder::class,
'--force' => true, '--force' => true,
]); ]);
CurrencyRate::timeSeriesRates(
Holding::all()->groupBy('market_data.currency')->keys()->toArray(),
Transaction::min('date')
);
CurrencyRate::refreshCurrencyData();
} }
/** /**
+10 -12
View File
@@ -12,8 +12,6 @@ class MarketDataSeeder extends Seeder
{ {
use WithoutModelEvents; use WithoutModelEvents;
public array $rows = [];
/** /**
* Run the database seeds. * Run the database seeds.
*/ */
@@ -44,7 +42,7 @@ class MarketDataSeeder extends Seeder
$meta_data = json_decode(base64_decode($data['meta_data']), true); $meta_data = json_decode(base64_decode($data['meta_data']), true);
$meta_data['source'] = 'market_data_seeder'; $meta_data['source'] = 'market_data_seeder';
$this->rows[] = [ $rows[] = [
'symbol' => $data['symbol'], 'symbol' => $data['symbol'],
'name' => $data['name'], 'name' => $data['name'],
'currency' => $data['currency'], 'currency' => $data['currency'],
@@ -54,15 +52,17 @@ class MarketDataSeeder extends Seeder
$rowCount++; $rowCount++;
if ($rowCount % $chunkSize == 0) { if ($rowCount % $chunkSize == 0) {
$this->bulkInsert($this->rows); $this->bulkInsert($rows);
$rows = [];
} }
} }
} }
// final clean up // final clean up
if (! empty($this->rows)) { if (! empty($rows)) {
$this->bulkInsert($this->rows); $this->bulkInsert($rows);
$rows = [];
} }
// Close the CSV file // Close the CSV file
@@ -76,19 +76,17 @@ class MarketDataSeeder extends Seeder
} }
} }
public function bulkInsert($rows) private function bulkInsert($rows): void
{ {
try { try {
dispatch( DB::table('market_data')->upsert($rows, ['symbol'], ['name', 'currency', 'meta_data']);
fn () => DB::table('market_data')->upsert($rows, ['symbol'], ['name', 'currency', 'meta_data'])
);
$this->rows = [];
} catch (\Throwable $e) { } catch (\Throwable $e) {
throw new \Exception('Error: '.$e->getMessage()); 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 - 8000:80
environment: # You can either use these properties OR an .env file. Do not use both! environment: # You can either use these properties OR an .env file. Do not use both!
APP_URL: "http://localhost:8000" APP_URL: "http://localhost:8000"
DB_CONNECTION: mysql DB_CONNECTION: pgsql
DB_HOST: investbrain-mysql DB_HOST: investbrain-pgsql
DB_PORT: 3306 DB_PORT: 5432
DB_DATABASE: investbrain DB_DATABASE: investbrain
DB_USERNAME: investbrain DB_USERNAME: investbrain
DB_PASSWORD: investbrain DB_PASSWORD: investbrain
@@ -25,7 +25,7 @@ services:
- investbrain-storage:/var/app/storage # You can use a volume... - 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 # - /path/to/storage:/var/app/storage:delegated # ...or you can use a path on host
depends_on: depends_on:
- mysql - pgsql
- redis - redis
networks: networks:
- investbrain-network - investbrain-network
@@ -40,22 +40,22 @@ services:
- investbrain-redis:/data - investbrain-redis:/data
networks: networks:
- investbrain-network - investbrain-network
mysql: pgsql:
image: mysql:8.0 image: postgres:15-alpine
container_name: investbrain-mysql container_name: investbrain-pgsql
restart: unless-stopped restart: unless-stopped
ports:
- "5432:5432"
environment: environment:
MYSQL_DATABASE: ${DB_DATABASE:-investbrain} POSTGRES_DB: ${DB_DATABASE:-investbrain}
MYSQL_USER: ${DB_USERNAME:-investbrain} POSTGRES_USER: ${DB_USERNAME:-investbrain}
MYSQL_PASSWORD: ${DB_PASSWORD:-investbrain} POSTGRES_PASSWORD: ${DB_PASSWORD:-investbrain}
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-investbrain} command: postgres -c log_min_messages=error
command:
- --cte-max-recursion-depth=25000
volumes: volumes:
- investbrain-mysql:/var/lib/mysql - investbrain-pgsql:/var/lib/postgresql/data
networks: networks:
- investbrain-network - investbrain-network
volumes: volumes:
investbrain-storage: investbrain-storage:
investbrain-redis: investbrain-redis:
investbrain-mysql: investbrain-pgsql:
+4 -1
View File
@@ -44,6 +44,9 @@ FROM php:8.3-fpm-alpine
# Set the working directory # Set the working directory
WORKDIR /var/app WORKDIR /var/app
ARG VERSION=dev
ENV VERSION=$VERSION
# Copy necessary files from the builder stage # Copy necessary files from the builder stage
COPY --from=builder /var/app /var/app COPY --from=builder /var/app /var/app
COPY --from=builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d 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 \ bash \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \ && 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 # Remove default nginx config
RUN rm -rf /var/www/html \ RUN rm -rf /var/www/html \
+14 -3
View File
@@ -3,7 +3,8 @@
cd /var/app cd /var/app
# Starting Investbrain # 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... ====================== " echo -e "\n====================== Validating environment... ====================== "
@@ -54,7 +55,6 @@ RETRIES=12
DELAY=5 DELAY=5
run_migrations() { run_migrations() {
sleep $DELAY sleep $DELAY
# php artisan migrate --force
output=$(php artisan migrate --force 2>/dev/null) output=$(php artisan migrate --force 2>/dev/null)
if [[ $? -eq 0 ]]; then if [[ $? -eq 0 ]]; then
echo "$output" echo "$output"
@@ -72,7 +72,18 @@ until run_migrations; do
echo -e "\n > Waiting for database to be ready... retrying in $DELAY seconds. \n" echo -e "\n > Waiting for database to be ready... retrying in $DELAY seconds. \n"
done 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" echo -e "\n====================== Spinning up Supervisor daemon... ====================== \n"
exec supervisord -c /etc/supervisor/conf.d/supervisord.conf exec supervisord -c /etc/supervisor/conf.d/supervisord.conf
@@ -97,7 +97,7 @@ new class extends Component
$transaction->transaction_type == 'BUY' $transaction->transaction_type == 'BUY'
? $transaction->cost_basis ? $transaction->cost_basis
: $transaction->sale_price, : $transaction->sale_price,
$transaction->market_data->currency $transaction->market_data?->currency
) }}) ) }})
<x-loading x-show="loading" x-cloak class="text-gray-400 ml-2" /> <x-loading x-show="loading" x-cloak class="text-gray-400 ml-2" />
+1 -1
View File
@@ -221,7 +221,7 @@ class DailyChangeTest extends TestCase
$second_transaction = Transaction::create([ $second_transaction = Transaction::create([
'symbol' => 'AAPL', 'symbol' => 'AAPL',
'portfolio_id' => $portfolio->id, 'portfolio_id' => $portfolio->id,
'date' => now()->subYears(3), 'date' => now()->subDays(1080), // 3 years
'quantity' => 1, 'quantity' => 1,
'cost_basis' => 39.89, 'cost_basis' => 39.89,
'transaction_type' => 'BUY', 'transaction_type' => 'BUY',
+1 -1
View File
@@ -21,7 +21,7 @@ class MarketDataTest extends TestCase
'--force' => true, '--force' => true,
]); ]);
$this->assertEquals(14262, MarketData::count('symbol')); $this->assertEquals(13187, MarketData::count('symbol'));
} }
public function test_can_get_quote_from_provider() public function test_can_get_quote_from_provider()
+31
View File
@@ -261,6 +261,37 @@ class MultiCurrencyTest extends TestCase
$this->assertEquals(count($period), count($result)); $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() public function test_time_series_rate_calls_are_chunked()
{ {