remember('market.yahoo.quotes.v3', 300, function () use ($cache) { $commodities = [ ['name' => 'Kopi Arabica', 'symbol' => 'KC=F'], ['name' => 'Kakao (Cocoa)', 'symbol' => 'CC=F'], ]; try { $responses = Http::pool(function (Pool $pool) use ($commodities) { return array_map(fn ($commodity) => $pool->as($commodity['symbol']) ->acceptJson()->withHeaders(['User-Agent' => 'Mozilla/5.0']) ->withOptions(['verify' => config('services.yahoo_finance.ca_bundle') ?: true]) ->connectTimeout(3)->timeout(8) ->get('https://query1.finance.yahoo.com/v8/finance/chart/'.rawurlencode($commodity['symbol']), [ 'interval' => '1d', 'range' => '5d', ]), [...$commodities, ['symbol' => 'IDR=X']]); }); } catch (Throwable $exception) { report($exception); $responses = []; } $exchangeRate = $cache->get('market.yahoo.usd_idr'); if (is_array($exchangeRate)) { $exchangeRate['stale'] = true; } $rateResponse = $responses['IDR=X'] ?? null; if ($rateResponse instanceof \Illuminate\Http\Client\Response && $rateResponse->successful()) { $meta = $rateResponse->json('chart.result.0.meta'); if (is_array($meta) && ($meta['symbol'] ?? null) === 'IDR=X' && ($meta['currency'] ?? null) === 'IDR' && is_numeric($meta['regularMarketPrice'] ?? null) && $meta['regularMarketPrice'] > 0 && is_numeric($meta['regularMarketTime'] ?? null)) { $exchangeRate = [ 'value' => (float) $meta['regularMarketPrice'], 'quoted_at' => gmdate('c', (int) $meta['regularMarketTime']), 'source' => 'Yahoo Finance', 'source_url' => 'https://finance.yahoo.com/quote/IDR%3DX/', 'stale' => false, ]; $cache->put('market.yahoo.usd_idr', $exchangeRate, now()->addDays(7)); } } $quotes = array_map(function ($commodity) use ($responses, $cache) { $symbol = $commodity['symbol']; $key = 'market.yahoo.last.'.rawurlencode($symbol); $quote = $commodity + [ 'source' => 'Yahoo Finance', 'source_url' => 'https://finance.yahoo.com/quote/'.rawurlencode($symbol).'/', 'price' => null, 'currency' => null, 'unit' => null, 'quoted_at' => null, 'stale' => false, ]; try { $response = $responses[$symbol] ?? null; if (!$response instanceof \Illuminate\Http\Client\Response || !$response->successful()) { throw new \RuntimeException('Quote unavailable'); } $meta = $response->json('chart.result.0.meta'); if ($response->json('chart.error') || !is_array($meta) || ($meta['symbol'] ?? null) !== $symbol || !is_numeric($meta['regularMarketPrice'] ?? null) || (float) $meta['regularMarketPrice'] <= 0 || !is_numeric($meta['regularMarketTime'] ?? null) || !is_string($meta['currency'] ?? null)) { throw new \RuntimeException('Invalid quote'); } $quote['price'] = (float) $meta['regularMarketPrice']; $quote['currency'] = $meta['currency']; // Yahoo quotes Arabica in US cents/lb; cocoa and robusta in USD/tonne. // Apply the unit only when the API currency matches that convention. $quote['unit'] = match (true) { $symbol === 'KC=F' && $meta['currency'] === 'USX' => 'sen AS / lb', $symbol === 'CC=F' && $meta['currency'] === 'USD' => 'USD / ton metrik', default => $meta['currency'].' (satuan mengikuti sumber)', }; $quote['quoted_at'] = gmdate('c', (int) $meta['regularMarketTime']); $cache->put($key, $quote, now()->addDays(7)); return $quote; } catch (Throwable $exception) { $previous = $cache->get($key); if (is_array($previous)) { $previous['stale'] = true; return $previous; } return $quote; } }, $commodities); return ['quotes' => $quotes, 'exchange_rate' => $exchangeRate]; }); $quotes = $marketData['quotes']; $exchangeRate = $marketData['exchange_rate']; foreach ($quotes as &$quote) { $quote['id'] = 'yahoo:'.$quote['symbol']; $quote['description'] = 'Estimasi konversi harga kontrak berjangka internasional.'; $quote['period'] = $quote['quoted_at']; $quote['frequency'] = 'market'; } unset($quote); $quotes = array_merge($quotes, $this->imfQuotes($cache), app(AlphaVantageMarket::class)->quotes($cache)); $quotes = array_values(array_filter($quotes, fn ($quote) => is_numeric($quote['price']) && is_finite((float) $quote['price']) && $quote['price'] > 0)); foreach ($quotes as &$quote) { // All active providers quote either US cents/lb or USD/metric tonne. $usdPerKg = match ($quote['unit']) { 'sen AS / lb' => $quote['price'] / 100 / 0.45359237, 'USD / ton metrik' => $quote['price'] / 1000, default => null, }; $quote['price_idr_per_kg'] = $exchangeRate && $usdPerKg !== null ? round($usdPerKg * $exchangeRate['value'], 2) : null; unset($quote['usd_per_kg']); // Old Alpha cache entries may contain this field. } unset($quote); return response()->json([ 'quotes' => $quotes, 'exchange_rate' => $exchangeRate, ])->header('Cache-Control', 'no-store'); } private function imfQuotes(Repository $cache): array { // IMF observations are monthly averages; fetch at most once a day. return $cache->remember('market.imf.monthly.v1', 86400, function () use ($cache) { try { $response = Http::withHeaders(['Accept' => 'text/csv', 'User-Agent' => 'Mozilla/5.0']) ->withOptions(['verify' => config('services.yahoo_finance.ca_bundle') ?: true]) ->connectTimeout(3)->timeout(10) ->get('https://api.imf.org/external/sdmx/2.1/data/IMF.RES,PCPS/G001.PCOFFOTM+PCOFFROB+PCOCO.USD.M', [ 'startPeriod' => now()->subYears(2)->format('Y-m'), 'format' => 'csvfile', ])->throw(); $stream = fopen('php://memory', 'r+'); try { fwrite($stream, $response->body()); rewind($stream); $headers = fgetcsv($stream, 0, ',', '"', ''); if (!$headers || array_diff(['COUNTRY', 'INDICATOR', 'DATA_TRANSFORMATION', 'FREQUENCY', 'TIME_PERIOD', 'OBS_VALUE', 'SCALE'], $headers)) { throw new \RuntimeException('Unexpected IMF CSV format'); } $latest = []; while (($values = fgetcsv($stream, 0, ',', '"', '')) !== false) { if (count($values) !== count($headers)) continue; $row = array_combine($headers, $values); if ($row['COUNTRY'] !== 'G001' || $row['DATA_TRANSFORMATION'] !== 'USD' || $row['FREQUENCY'] !== 'M' || $row['SCALE'] !== '0' || !is_numeric($row['OBS_VALUE']) || $row['OBS_VALUE'] <= 0 || !preg_match('/^\d{4}-M(0[1-9]|1[0-2])$/', $row['TIME_PERIOD'])) continue; $indicator = $row['INDICATOR']; if (!isset($latest[$indicator]) || $row['TIME_PERIOD'] > $latest[$indicator]['TIME_PERIOD']) { $latest[$indicator] = $row; } } } finally { fclose($stream); } $definitions = [ 'PCOFFOTM' => ['Kopi Arabica', 'USX', 'sen AS / lb', 'Biji kopi Arabica (Other Milds). Estimasi konversi harga acuan rata-rata bulanan IMF.'], 'PCOFFROB' => ['Kopi Robusta', 'USX', 'sen AS / lb', 'Biji kopi Robusta. Estimasi konversi harga acuan rata-rata bulanan IMF.'], 'PCOCO' => ['Kakao (Cocoa)', 'USD', 'USD / ton metrik', 'Biji kakao. Estimasi konversi harga acuan rata-rata bulanan IMF.'], ]; $quotes = []; foreach ($definitions as $indicator => [$name, $currency, $unit, $description]) { if (!isset($latest[$indicator])) continue; $row = $latest[$indicator]; $quotes[] = [ 'id' => 'imf:'.$indicator, 'symbol' => $indicator, 'name' => $name, 'price' => (float) $row['OBS_VALUE'], 'currency' => $currency, 'unit' => $unit, 'source' => 'IMF ยท PCPS', 'source_url' => 'https://data.imf.org/Datasets/PCPS', 'description' => $description, 'frequency' => 'monthly', 'period' => str_replace('-M', '-', $row['TIME_PERIOD']), 'stale' => false, ]; } if (!$quotes) throw new \RuntimeException('No valid IMF observations'); $cache->put('market.imf.last.v1', $quotes, now()->addDays(30)); return $quotes; } catch (Throwable $exception) { report($exception); return array_map(function ($quote) { $quote['stale'] = true; return $quote; }, $cache->get('market.imf.last.v1', [])); } }); } }