Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/add stock options functionality #50

Merged
merged 3 commits into from Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Expand Up @@ -93,6 +93,10 @@ $quote = $client->getQuote("AAPL");

// Returns an array of Scheb\YahooFinanceApi\Results\Quote
$quotes = $client->getQuotes(["AAPL", "GOOG"]);

// Returns an array of Scheb\YahooFinanceApi\Results\OptionChain
$optionChain = $client->getOptionChain("AAPL");
$optionChain = $client->getOptionChain("AAPL", new \DateTime("2021-01-01"));
```

Version Guidance
Expand Down
58 changes: 47 additions & 11 deletions src/ApiClient.php
Expand Up @@ -197,6 +197,28 @@ public function getExchangeRates(array $currencyPairs): array
return $this->fetchQuotes($currencySymbols);
}

private function getCookies(): CookieJar
{
$cookieJar = new CookieJar();

// Initialize session cookies
$initialUrl = 'https://fc.yahoo.com';
$this->client->request('GET', $initialUrl, ['cookies' => $cookieJar, 'http_errors' => false, 'headers' => $this->getHeaders()]);

return $cookieJar;
}

/**
* Get the crumb value from the Yahoo Finance API.
*/
private function getCrumb(int $qs, CookieJar $cookies): string
{
// Get crumb value
$initialUrl = 'https://query'.(string) $qs.'.finance.yahoo.com/v1/test/getcrumb';

return (string) $this->client->request('GET', $initialUrl, ['cookies' => $cookies, 'headers' => $this->getHeaders()])->getBody();
}

/**
* Fetch quote data from API.
*
Expand All @@ -205,15 +227,12 @@ public function getExchangeRates(array $currencyPairs): array
private function fetchQuotes(array $symbols)
{
$qs = $this->getRandomQueryServer();
$cookieJar = new CookieJar();

// Initialize session cookies
$initialUrl = 'https://fc.yahoo.com';
$this->client->request('GET', $initialUrl, ['cookies' => $cookieJar, 'http_errors' => false, 'headers' => $this->getHeaders()]);
$cookieJar = $this->getCookies();

// Get crumb value
$initialUrl = 'https://query'.$qs.'.finance.yahoo.com/v1/test/getcrumb';
$crumb = (string) $this->client->request('GET', $initialUrl, ['cookies' => $cookieJar, 'headers' => $this->getHeaders()])->getBody();
$crumb = $this->getCrumb($qs, $cookieJar);

// Fetch quotes
$url = 'https://query'.$qs.'.finance.yahoo.com/v7/finance/quote?crumb='.$crumb.'&symbols='.urlencode(implode(',', $symbols));
Expand Down Expand Up @@ -253,21 +272,38 @@ private function getRandomQueryServer(): int
public function stockSummary(string $symbol): array
{
$qs = $this->getRandomQueryServer();
$cookieJar = new CookieJar();

// Initialize session cookies
$initialUrl = 'https://fc.yahoo.com';
$this->client->request('GET', $initialUrl, ['cookies' => $cookieJar, 'http_errors' => false, 'headers' => $this->getHeaders()]);
$cookieJar = $this->getCookies();

// Get crumb value
$initialUrl = 'https://query'.$qs.'.finance.yahoo.com/v1/test/getcrumb';
$crumb = (string) $this->client->request('GET', $initialUrl, ['cookies' => $cookieJar, 'headers' => $this->getHeaders()])->getBody();
$crumb = $this->getCrumb($qs, $cookieJar);

// Fetch quotes
$modules = 'financialData,quoteType,defaultKeyStatistics,assetProfile,summaryDetail';
$url = 'https://query'.$qs.'.finance.yahoo.com/v10/finance/quoteSummary/'.$symbol.'?crumb='.$crumb.'&modules='.$modules;
$responseBody = (string) $this->client->request('GET', $url, ['cookies' => $cookieJar, 'headers' => $this->getHeaders()])->getBody();

return $this->resultDecoder->transformQuotesSumamary($responseBody);
return $this->resultDecoder->transformQuotesSummary($responseBody);
}

public function getOptionChain(string $symbol, ?\DateTimeInterface $expiryDate = null): array
{
$qs = $this->getRandomQueryServer();

// Initialize session cookies
$cookieJar = $this->getCookies();

// Get crumb value
$crumb = $this->getCrumb($qs, $cookieJar);

// Fetch options
$url = 'https://query'.$qs.'.finance.yahoo.com/v7/finance/options/'.$symbol.'?crumb='.$crumb;
if ($expiryDate) {
$url .= '&date='.(string) $expiryDate->getTimestamp();
}
$responseBody = (string) $this->client->request('GET', $url, ['cookies' => $cookieJar, 'headers' => $this->getHeaders()])->getBody();

return $this->resultDecoder->transformOptionChains($responseBody);
}
}
129 changes: 128 additions & 1 deletion src/ResultDecoder.php
Expand Up @@ -8,6 +8,9 @@
use Scheb\YahooFinanceApi\Exception\InvalidValueException;
use Scheb\YahooFinanceApi\Results\DividendData;
use Scheb\YahooFinanceApi\Results\HistoricalData;
use Scheb\YahooFinanceApi\Results\Option;
use Scheb\YahooFinanceApi\Results\OptionChain;
use Scheb\YahooFinanceApi\Results\OptionContract;
use Scheb\YahooFinanceApi\Results\Quote;
use Scheb\YahooFinanceApi\Results\SearchResult;
use Scheb\YahooFinanceApi\Results\SplitData;
Expand All @@ -18,6 +21,36 @@ class ResultDecoder
public const DIVIDEND_DATA_HEADER_LINE = ['Date', 'Dividends'];
public const SPLIT_DATA_HEADER_LINE = ['Date', 'Stock Splits'];
public const SEARCH_RESULT_FIELDS = ['symbol', 'name', 'exch', 'type', 'exchDisp', 'typeDisp'];
public const OPTION_CHAIN_FIELDS_MAP = [
'underlyingSymbol' => ValueMapperInterface::TYPE_STRING,
'expirationDates' => ValueMapperInterface::TYPE_ARRAY,
'strikes' => ValueMapperInterface::TYPE_ARRAY,
'hasMiniOptions' => ValueMapperInterface::TYPE_BOOL,
'options' => ValueMapperInterface::TYPE_ARRAY,
];
public const OPTION_FIELDS_MAP = [
'expirationDate' => ValueMapperInterface::TYPE_DATE,
'hasMiniOptions' => ValueMapperInterface::TYPE_BOOL,
'calls' => ValueMapperInterface::TYPE_ARRAY,
'puts' => ValueMapperInterface::TYPE_ARRAY,
];
public const OPTION_CONTRACT_FIELDS_MAP = [
'contractSymbol' => ValueMapperInterface::TYPE_STRING,
'strike' => ValueMapperInterface::TYPE_FLOAT,
'currency' => ValueMapperInterface::TYPE_STRING,
'lastPrice' => ValueMapperInterface::TYPE_FLOAT,
'change' => ValueMapperInterface::TYPE_FLOAT,
'percentChange' => ValueMapperInterface::TYPE_FLOAT,
'volume' => ValueMapperInterface::TYPE_INT,
'openInterest' => ValueMapperInterface::TYPE_INT,
'bid' => ValueMapperInterface::TYPE_FLOAT,
'ask' => ValueMapperInterface::TYPE_FLOAT,
'contractSize' => ValueMapperInterface::TYPE_STRING,
'expiration' => ValueMapperInterface::TYPE_DATE,
'lastTradeDate' => ValueMapperInterface::TYPE_DATE,
'impliedVolatility' => ValueMapperInterface::TYPE_FLOAT,
'inTheMoney' => ValueMapperInterface::TYPE_BOOL,
];
public const QUOTE_FIELDS_MAP = [
'ask' => ValueMapperInterface::TYPE_FLOAT,
'askSize' => ValueMapperInterface::TYPE_INT,
Expand Down Expand Up @@ -273,7 +306,7 @@ private function createQuote(array $json): Quote
return new Quote($mappedValues);
}

public function transformQuotesSumamary(string $responseBody): array
public function transformQuotesSummary(string $responseBody): array
{
$decoded = json_decode($responseBody, true);
if (!isset($decoded['quoteSummary']['result']) || !\is_array($decoded['quoteSummary']['result'])) {
Expand All @@ -282,4 +315,98 @@ public function transformQuotesSumamary(string $responseBody): array

return $decoded['quoteSummary']['result'];
}

public function transformOptionChains(string $responseBody): array
{
$decoded = json_decode($responseBody, true);
if (!isset($decoded['optionChain']['result']) || !\is_array($decoded['optionChain']['result'])) {
throw new ApiException('Yahoo Search API returned an invalid result.', ApiException::INVALID_RESPONSE);
}

$results = $decoded['optionChain']['result'];

// Single element is returned directly in "OptionChain"
$final = array_map(function (array $item) {
return $this->createOptionChain($item);
}, $results);

return $final;
}

private function createOptionChain(array $json): OptionChain
{
$mappedValues = [];
foreach ($json as $field => $value) {
if (!\array_key_exists($field, self::OPTION_CHAIN_FIELDS_MAP)) {
continue;
}
$type = self::OPTION_CHAIN_FIELDS_MAP[$field];
try {
if ('options' === $field) {
if (!\is_array($value)) {
throw new InvalidValueException($type);
}

$mappedValues[$field] = array_map(function (array $option): Option {
return $this->createOption($option);
}, $value);
} elseif ('expirationDates' === $field) {
$mappedValues[$field] = $this->valueMapper->mapValue($value, $type, ValueMapperInterface::TYPE_DATE);
} elseif ('strikes' === $field) {
$mappedValues[$field] = $this->valueMapper->mapValue($value, $type, ValueMapperInterface::TYPE_FLOAT);
} else {
$mappedValues[$field] = $this->valueMapper->mapValue($value, $type);
}
} catch (InvalidValueException $e) {
throw new ApiException(sprintf('%s in field "%s": %s', $e->getMessage(), $field, json_encode($value)), ApiException::INVALID_VALUE, $e);
}
}

return new OptionChain($mappedValues);
}

private function createOption(array $json): Option
{
$mappedValues = [];
foreach ($json as $field => $value) {
if (!\array_key_exists($field, self::OPTION_FIELDS_MAP)) {
continue;
}
$type = self::OPTION_FIELDS_MAP[$field];
try {
if ('calls' === $field || 'puts' === $field) {
if (!\is_array($value)) {
throw new InvalidValueException($type);
}

$mappedValues[$field] = array_map(function (array $optionContract): OptionContract {
return $this->createOptionContract($optionContract);
}, $value);
} else {
$mappedValues[$field] = $this->valueMapper->mapValue($value, $type);
}
} catch (InvalidValueException $e) {
throw new ApiException(sprintf('%s in field "%s": %s', $e->getMessage(), $field, json_encode($value)), ApiException::INVALID_VALUE, $e);
}
}

return new Option($mappedValues);
}

private function createOptionContract(array $values): OptionContract
{
$mappedValues = [];
foreach ($values as $property => $value) {
if (!\array_key_exists($property, self::OPTION_CONTRACT_FIELDS_MAP)) {
continue;
}
try {
$mappedValues[$property] = $this->valueMapper->mapValue($value, self::OPTION_CONTRACT_FIELDS_MAP[$property]);
} catch (InvalidValueException $e) {
throw new ApiException(sprintf('%s in field "%s": %s', $e->getMessage(), $property, json_encode($value)), ApiException::INVALID_VALUE, $e);
}
}

return new OptionContract($mappedValues);
}
}
54 changes: 54 additions & 0 deletions src/Results/Option.php
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace Scheb\YahooFinanceApi\Results;

class Option implements \JsonSerializable
{
private $expirationDate;
private $hasMiniOptions;
private $calls;
private $puts;

public function __construct(array $values)
{
foreach ($values as $property => $value) {
$this->{$property} = $value;
}
}

public function jsonSerialize(): array
{
return [
'expirationDate' => $this->expirationDate,
'hasMiniOptions' => $this->hasMiniOptions,
'calls' => array_map(function (OptionContract $optionContract): array {
return $optionContract->jsonSerialize();
}, $this->calls),
'puts' => array_map(function (OptionContract $optionContract): array {
return $optionContract->jsonSerialize();
}, $this->puts),
];
}

public function getExpirationDate(): int
{
return $this->expirationDate;
}

public function getHasMiniOptions(): bool
{
return $this->hasMiniOptions;
}

public function getCalls(): array
{
return $this->calls;
}

public function getPuts(): array
{
return $this->puts;
}
}
59 changes: 59 additions & 0 deletions src/Results/OptionChain.php
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

namespace Scheb\YahooFinanceApi\Results;

class OptionChain implements \JsonSerializable
{
private $underlyingSymbol;
private $expirationDates;
private $strikes;
private $hasMiniOptions;
private $options;

public function __construct(array $values)
{
foreach ($values as $property => $value) {
$this->{$property} = $value;
}
}

public function jsonSerialize(): array
{
return [
'underlyingSymbol' => $this->underlyingSymbol,
'expirationDates' => $this->expirationDates,
'strikes' => $this->strikes,
'hasMiniOptions' => $this->hasMiniOptions,
'options' => array_map(function (Option $option): array {
return $option->jsonSerialize();
}, $this->options),
];
}

public function getUnderlyingSymbol(): string
{
return $this->underlyingSymbol;
}

public function getExpirationDates(): array
{
return $this->expirationDates;
}

public function getStrikes(): array
{
return $this->strikes;
}

public function getHasMiniOptions(): bool
{
return $this->hasMiniOptions;
}

public function getOptions(): array
{
return $this->options;
}
}