Compare commits

..

16 Commits

Author SHA1 Message Date
hackerESQ c691ee922a wip test 2025-04-12 21:25:00 -05:00
hackerESQ 3eb9bad840 www 2025-04-12 20:39:12 -05:00
hackerESQ 370f7bb54b wip 2025-04-12 20:31:49 -05:00
hackerESQ 62bf6797e6 wip 2025-04-12 20:29:28 -05:00
hackerESQ c4e3645145 wip 2025-04-12 20:27:06 -05:00
hackerESQ 69e4d0fb3a wip 2025-04-12 20:22:43 -05:00
hackerESQ 20c2cb37cc wip 2025-04-12 20:16:34 -05:00
hackerESQ d2bb065822 wip 2025-04-12 20:11:05 -05:00
hackerESQ 0c00f28d97 wip 2025-04-12 20:06:21 -05:00
hackerESQ 5eab00ee33 wip 2025-04-12 20:03:40 -05:00
hackerESQ 56064ad84e try again 2025-04-12 18:02:23 -05:00
hackerESQ c96ff0e45f wip 2025-04-12 17:51:13 -05:00
hackerESQ 33e0df5ae2 wip 2025-04-12 17:37:26 -05:00
hackerESQ a5a333f784 wip 2025-04-12 17:31:28 -05:00
hackerESQ 89b5505e1d wip 2025-04-12 17:27:38 -05:00
hackerESQ 60923b3c93 wip 2025-04-12 17:16:36 -05:00
15 changed files with 1424 additions and 212 deletions
+6 -12
View File
@@ -43,16 +43,7 @@ jobs:
- name: Extract version from tag - name: Extract version from tag
id: extract-version id: extract-version
run: | run: |
VERSION="${GITHUB_REF_NAME#v}" echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
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 - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -60,5 +51,8 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
file: ./docker/Dockerfile file: ./docker/Dockerfile
push: true push: true
tags: ${{ steps.extract-version.outputs.tags }} tags: |
investbrainapp/investbrain:latest
investbrainapp/investbrain:${{ env.version }}
ghcr.io/investbrainapp/investbrain:latest
ghcr.io/investbrainapp/investbrain:${{ env.version }}
+2 -1
View File
@@ -7,6 +7,7 @@ namespace App\Console\Commands;
use App\Models\Holding; use App\Models\Holding;
use App\Models\MarketData; use App\Models\MarketData;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class RefreshMarketData extends Command class RefreshMarketData extends Command
{ {
@@ -60,7 +61,7 @@ class RefreshMarketData extends Command
try { try {
MarketData::getMarketData($holding->symbol, $force); MarketData::getMarketData($holding->symbol, $force);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->line('Could not refresh '.$holding->symbol.' ('.$e->getMessage().')'); Log::error('Could not refresh '.$holding->symbol.' ('.$e->getMessage().')');
} }
} }
} }
+3 -4
View File
@@ -47,13 +47,12 @@ class ConfigSheet implements FromCollection, WithHeadings, WithTitle
]); ]);
// reinvested holdings // reinvested holdings
$reinvested_holdings = Holding::myHoldings()->where('reinvest_dividends', true)->get(['portfolio_id', 'symbol']); Holding::myHoldings()->where('reinvest_dividends', true)->get()->each(function ($holding) use (&$configs) {
if ($reinvested_holdings->isNotEmpty()) {
$configs->push([ $configs->push([
'key' => 'reinvested_dividends', 'key' => 'reinvested_dividends',
'value' => $reinvested_holdings->toJson(), 'value' => $holding->id,
]); ]);
} });
return $configs; return $configs;
} }
+3 -9
View File
@@ -54,17 +54,11 @@ class ConfigSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadi
$this->backupImport->user->save(); $this->backupImport->user->save();
break; break;
case 'reinvested_dividends': case 'reinvest_dividends':
if (json_validate($config['value'])) {
foreach (json_decode($config['value'], true) as $reinvest) { Holding::myHoldings()->where('id', $config['value'])->update([
Holding::myHoldings($this->backupImport->user->id)
->where('portfolio_id', $reinvest['portfolio_id'])
->where('symbol', $reinvest['symbol'])
->update([
'reinvest_dividends' => true, 'reinvest_dividends' => true,
]); ]);
}
}
break; break;
default: 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 has any transactions not in base currency, need to sync timeseries conversion rates
if ($transactions->where('currency', '!=', config('investbrain.base_currency'))->isNotEmpty()) { if ($transactions->where('currency', '!=', config('investbrain.base_currency'))->isNotEmpty()) {
CurrencyRate::timeSeriesRates('', $transactions->min('date'), $transactions->max('date')); CurrencyRate::timeSeriesRates('', $transactions->min('date'));
} }
$totalBatches = count($transactions) / $this->batchSize(); $totalBatches = count($transactions) / $this->batchSize();
+45 -51
View File
@@ -111,7 +111,7 @@ class CurrencyRate extends Model
* *
* @return array<string, float> * @return array<string, float>
*/ */
public static function timeSeriesRates(string|array|null $currency = null, mixed $start = null, mixed $end = null): array public static function timeSeriesRates(string $currency, mixed $start = null, mixed $end = null): array
{ {
if (empty($start)) { if (empty($start)) {
return []; return [];
@@ -119,51 +119,47 @@ class CurrencyRate extends Model
$end = $end ?? now(); $end = $end ?? now();
dump('Creating period');
$period = CarbonPeriod::create($start, $end); $period = CarbonPeriod::create($start, $end);
// No need to send network request - just generate 1s // No need to send network request - just generate 1s
if ($currency === config('investbrain.base_currency')) { if ($currency === config('investbrain.base_currency')) {
dump('same curr');
$dateRange = []; $dateRange = [];
foreach ($period as $date) { foreach ($period as $date) {
$dateRange[$date->toDateString()] = 1; $dateRange[$date->toDateString()] = 1;
} }
return $dateRange; return $dateRange;
} }
if (is_array($currency)) { dump('diff curr');
foreach ($currency as $curr) {
dispatch(fn () => self::timeSeriesRates($curr, $start, $end));
}
return [];
}
// handle currency alias
if (! empty($currency)) {
[$currency, $adjustment] = self::getCurrencyAliasAdjustments($currency); [$currency, $adjustment] = self::getCurrencyAliasAdjustments($currency);
} else { $currencies = Currency::all()->pluck('currency')->toArray();
$currency = Currency::all()->pluck('currency')->toArray(); dump('got currencies');
}
// get rates // call api in chunks
$rates = Frankfurter::setSymbols($currency)->timeSeries($period->first(), $period->last()); foreach (collect($period)->chunk(500) as $chunk) {
$rates = collect(Arr::get($rates, 'rates', []))->sortKeys()->toArray(); dump('calling frankf time series');
$datesOnly = array_keys($rates); $chunkRates = Frankfurter::setSymbols($currencies)->timeSeries($chunk->min(), $chunk->max());
$rates = Arr::get($chunkRates, 'rates', []);
// loop through each date // loop through each date
$updates = []; $updates = [];
foreach ($period as $date) {
$lookupDate = self::getNearestPastDate($date, $datesOnly, $rates); foreach ($chunk as $date) {
$lookupDate = self::getNearestPastDate($date, $rates);
if (is_null($lookupDate)) { if (is_null($lookupDate)) {
continue; continue;
@@ -181,58 +177,54 @@ class CurrencyRate extends Model
'created_at' => now()->toDateTimeString(), 'created_at' => now()->toDateTimeString(),
]; ];
} }
}
dump('inserting');
// persist // persist
self::chunkInsert($updates); self::chunkInsert($updates);
if (is_string($currency)) { }
}
dump('done');
return collect($updates) return collect($updates)
->whereBetween('date', [$start, $end ?? now()]) ->whereBetween('date', [$start, $end ?? now()])
->where('currency', $currency) ->where('currency', $currency)
->mapWithKeys(fn ($rate) => [ ->mapWithKeys(fn ($rate) => [
$rate['date'] => $rate['rate'] * ($adjustment ?? 1), $rate['date'] => $rate['rate'] * $adjustment,
]) ])
->toArray(); ->toArray();
} }
return []; private static function getNearestPastDate(CarbonInterface $date, array $rates): ?CarbonInterface
}
private static function getNearestPastDate(CarbonInterface $date, array $datesOnly, array $rates): ?CarbonInterface
{ {
// if no dates, nothing to do... $datesWithRates = array_keys($rates);
if (empty($datesOnly)) { sort($datesWithRates);
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) // get rates or find closest valid rate (handles missing weekend rates)
while (! isset($rates[$mutableDate->toDateString()])) { while (! isset($rates[$date->toDateString()])) {
// prevent runaway infinite loops
if ($mutableDate->lessThan($weekAgo)) {
return null;
}
// is this the start of a range that falls on a weekend? // is this the start of a range that falls on a weekend?
if ($mutableDate->lessThan($firstDate)) { if ($date->lessThan($first_date = Carbon::parse($datesWithRates[0]))) {
return $firstDate; $date = $first_date;
break;
} }
// try the day before then // try the day before then
$mutableDate = $mutableDate->subDay(); $date = Carbon::parse($date)->subDay();
// prevent runaway infinite loops
if ($date->lessThan($date->copy()->subWeek())) {
$date = null;
break;
}
} }
return $mutableDate; return $date;
} }
public static function refreshCurrencyData($force = false): void public static function refreshCurrencyData($force = false): void
@@ -273,13 +265,15 @@ class CurrencyRate extends Model
public static function chunkInsert(array $updates): void public static function chunkInsert(array $updates): void
{ {
foreach (array_chunk($updates, 500) as $chunk) { $chunks = array_chunk($updates, 500);
foreach ($chunks as $chunk) {
QueuedCurrencyRateInsertJob::dispatch($chunk); QueuedCurrencyRateInsertJob::dispatch($chunk);
} }
} }
protected static function getCurrencyAliasAdjustments(string $currency) protected static function getCurrencyAliasAdjustments($currency)
{ {
$adjustment = 1; $adjustment = 1;
+23 -9
View File
@@ -95,36 +95,49 @@ class Dividend extends Model
return; return;
} }
dump('1. getting div data for '.$symbol);
try {
// get some data // get some data
if ($dividend_data = collect() && $start_date && $end_date) { if ($dividend_data = collect() && $start_date && $end_date) {
$dividend_data = app(MarketDataInterface::class)->dividends($symbol, $start_date, $end_date); $dividend_data = app(MarketDataInterface::class)->dividends($symbol, $start_date, $end_date);
} }
} catch (\Throwable $e) {
dump('exception: '.$e->getMessage());
}
dump('2. got div data for '.$symbol);
// ah, we found some dividends... // ah, we found some dividends...
if ($dividend_data->isNotEmpty()) { if ($dividend_data->isNotEmpty()) {
$dividend_data = $dividend_data->sortBy('date');
dump('3. getting mkt data for '.$symbol);
$market_data = MarketData::getMarketData($symbol); $market_data = MarketData::getMarketData($symbol);
$dividend_data dump('4. got market data for '.$symbol);
->chunk(10)
->each(function ($chunk) use ($market_data) { // todo: use this for start_date - $dividend_data->first()->get('date')
// get historic conversion rates // get historic conversion rates
$rate_to_base = CurrencyRate::timeSeriesRates($market_data->currency, $chunk->min('date'), $chunk->max('date')); $rate_to_base = CurrencyRate::timeSeriesRates($market_data->currency, $dividend_data->first()->get('date'), $end_date);
dump('5. got time series for '.$symbol);
// create mass insert // create mass insert
foreach ($chunk as $index => $dividend) { foreach ($dividend_data as $index => $dividend) {
$rate_to_base_date = 1 / Arr::get($rate_to_base, Carbon::parse(Arr::get($dividend, 'date'))->toDateString(), 1); $rate_to_base_date = 1 / Arr::get($rate_to_base, Carbon::parse(Arr::get($dividend, 'date'))->toDateString(), 1);
$dividend['dividend_amount_base'] = $dividend['dividend_amount'] * $rate_to_base_date; $dividend['dividend_amount_base'] = $dividend['dividend_amount'] * $rate_to_base_date;
$chunk[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]]; $dividend_data[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
} }
// insert records // insert records
(new self)->insertOrIgnore($chunk->toArray()); (new self)->insertOrIgnore($dividend_data->toArray());
});
dump('6. inserted for '.$symbol);
// sync to holdings // sync to holdings
self::syncHoldings($symbol); self::syncHoldings($symbol);
@@ -134,7 +147,9 @@ class Dividend extends Model
// sync last dividend amount to market data table // sync last dividend amount to market data table
$market_data->last_dividend_amount = $dividend_data->sortByDesc('date')->first()['dividend_amount']; $market_data->last_dividend_amount = $dividend_data->sortByDesc('date')->first()['dividend_amount'];
$market_data->save(); $market_data->save();
} }
} }
public static function syncHoldings(string $symbol): void public static function syncHoldings(string $symbol): void
@@ -191,7 +206,6 @@ class Dividend extends Model
'date' => $dividend['date'], 'date' => $dividend['date'],
'portfolio_id' => $holding->portfolio_id, 'portfolio_id' => $holding->portfolio_id,
'symbol' => $holding->symbol, 'symbol' => $holding->symbol,
'currency' => $holding->market_data->currency,
'transaction_type' => 'BUY', 'transaction_type' => 'BUY',
'reinvested_dividend' => true, 'reinvested_dividend' => true,
'cost_basis' => 0, 'cost_basis' => 0,
+3 -7
View File
@@ -152,12 +152,7 @@ class Portfolio extends Model
$total_performance = []; $total_performance = [];
// get unique currencies for holdings $holdings->each(function ($holding) use (&$total_performance) {
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( $period = CarbonPeriod::create(
$holding->first_transaction_date, $holding->first_transaction_date,
@@ -168,6 +163,7 @@ class Portfolio extends Model
$daily_performance = $holding->dailyPerformance($holding->first_transaction_date, now()); $daily_performance = $holding->dailyPerformance($holding->first_transaction_date, now());
$all_history = app(MarketDataInterface::class)->history($holding->symbol, $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 = []; $holding_performance = [];
@@ -183,7 +179,7 @@ class Portfolio extends Model
$holding_performance[$date] = [ $holding_performance[$date] = [
'date' => $date, 'date' => $date,
'portfolio_id' => $this->id, 'portfolio_id' => $this->id,
'total_market_value' => $total_market_value * (1 / Arr::get($currency_rates[$holding->market_data->currency], $date, 1)), 'total_market_value' => $total_market_value * (1 / Arr::get($currency_rates, $date, 1)),
]; ];
} }
} }
@@ -3,7 +3,6 @@
declare(strict_types=1); declare(strict_types=1);
use App\Models\CurrencyRate; use App\Models\CurrencyRate;
use App\Models\Holding;
use App\Models\Transaction; use App\Models\Transaction;
use Database\Seeders\CurrencySeeder; use Database\Seeders\CurrencySeeder;
use Database\Seeders\MarketDataSeeder; use Database\Seeders\MarketDataSeeder;
@@ -97,17 +96,17 @@ return new class extends Migration
'--force' => true, '--force' => true,
]); ]);
Artisan::call('db:seed', [
'--class' => MarketDataSeeder::class,
'--force' => true,
]);
CurrencyRate::timeSeriesRates( CurrencyRate::timeSeriesRates(
Holding::all()->groupBy('market_data.currency')->keys()->toArray(), '', // use fake currency to force
Transaction::min('date') Transaction::min('date')
); );
CurrencyRate::refreshCurrencyData(); CurrencyRate::refreshCurrencyData();
Artisan::call('db:seed', [
'--class' => MarketDataSeeder::class,
'--force' => true,
]);
} }
/** /**
+10 -10
View File
@@ -12,6 +12,8 @@ class MarketDataSeeder extends Seeder
{ {
use WithoutModelEvents; use WithoutModelEvents;
public array $rows = [];
/** /**
* Run the database seeds. * Run the database seeds.
*/ */
@@ -42,7 +44,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';
$rows[] = [ $this->rows[] = [
'symbol' => $data['symbol'], 'symbol' => $data['symbol'],
'name' => $data['name'], 'name' => $data['name'],
'currency' => $data['currency'], 'currency' => $data['currency'],
@@ -52,17 +54,16 @@ class MarketDataSeeder extends Seeder
$rowCount++; $rowCount++;
if ($rowCount % $chunkSize == 0) { if ($rowCount % $chunkSize == 0) {
$this->bulkInsert($rows); DB::table('market_data')->upsert($this->rows, ['symbol'], ['name', 'currency', 'meta_data']);
$rows = []; $this->rows = [];
} }
} }
} }
// final clean up // final clean up
if (! empty($rows)) { if (! empty($this->rows)) {
$this->bulkInsert($rows); $this->bulkInsert($this->rows);
$rows = [];
} }
// Close the CSV file // Close the CSV file
@@ -76,17 +77,16 @@ class MarketDataSeeder extends Seeder
} }
} }
private function bulkInsert($rows): void public function bulkInsert(array $rows)
{ {
try { try {
DB::table('market_data')->upsert($rows, ['symbol'], ['name', 'currency', 'meta_data']); DB::table('market_data')->insertOrIgnore($rows);
$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
Binary file not shown.
+1
View File
@@ -84,6 +84,7 @@ class ImportExportTest extends TestCase
]); ]);
$holding = Holding::create([ $holding = Holding::create([
'id' => '9cf8a662-7347-49fb-b9de-0cc1430a8d1f',
'portfolio_id' => '9e792bb8-94e7-4ed3-b8cc-43b50d34c337', 'portfolio_id' => '9e792bb8-94e7-4ed3-b8cc-43b50d34c337',
'symbol' => 'ACME', 'symbol' => 'ACME',
'quantity' => 0, 'quantity' => 0,
+1 -1
View File
@@ -21,7 +21,7 @@ class MarketDataTest extends TestCase
'--force' => true, '--force' => true,
]); ]);
$this->assertEquals(13187, MarketData::count('symbol')); $this->assertEquals(14464, MarketData::count('symbol'));
} }
public function test_can_get_quote_from_provider() public function test_can_get_quote_from_provider()
+11 -68
View File
@@ -133,8 +133,11 @@ class MultiCurrencyTest extends TestCase
$portfolio = Portfolio::factory()->create(); $portfolio = Portfolio::factory()->create();
$transaction = Transaction::factory()->buy()->lastYear()->portfolio($portfolio->id)->symbol('ACME')->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') Frankfurter::expects('setSymbols')
->andReturnSelf(); ->andReturnSelf()
->times($expected_num_calls);
Frankfurter::expects('timeSeries') Frankfurter::expects('timeSeries')
->andReturn(['rates' => [ ->andReturn(['rates' => [
now()->subDays(3)->toDateString() => [ now()->subDays(3)->toDateString() => [
@@ -149,7 +152,8 @@ class MultiCurrencyTest extends TestCase
now()->toDateString() => [ now()->toDateString() => [
'ZZZ' => .01, 'ZZZ' => .01,
], ],
]]); ]])
->times($expected_num_calls);
CurrencyRate::timeSeriesRates( CurrencyRate::timeSeriesRates(
'', // use fake currency to force '', // use fake currency to force
@@ -225,71 +229,8 @@ class MultiCurrencyTest extends TestCase
->andReturn(['rates' => $results]); ->andReturn(['rates' => $results]);
$result = CurrencyRate::timeSeriesRates('ZZZ', $start, $end); $result = CurrencyRate::timeSeriesRates('ZZZ', $start, $end);
$this->assertEquals(count($period) - 1, count($result)); $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() public function test_time_series_rate_calls_are_chunked()
@@ -311,9 +252,11 @@ class MultiCurrencyTest extends TestCase
}); });
Frankfurter::expects('setSymbols') Frankfurter::expects('setSymbols')
->andReturnSelf(); ->andReturnSelf()
->times(4);
Frankfurter::expects('timeSeries') Frankfurter::expects('timeSeries')
->andReturn(['rates' => $results]); ->andReturn(['rates' => $results])
->times(4);
CurrencyRate::timeSeriesRates('ZZZ', $start, $end); CurrencyRate::timeSeriesRates('ZZZ', $start, $end);
} }