diff --git a/README.md b/README.md index 94375a2..56c511a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/ApiClient.php b/src/ApiClient.php index 5f3b90d..8431f0c 100644 --- a/src/ApiClient.php +++ b/src/ApiClient.php @@ -287,7 +287,7 @@ public function stockSummary(string $symbol): array return $this->resultDecoder->transformQuotesSummary($responseBody); } - public function getStockOptions(string $symbol, ?\DateTimeInterface $expiryDate = null): array + public function getOptionChain(string $symbol, ?\DateTimeInterface $expiryDate = null): array { $qs = $this->getRandomQueryServer(); @@ -304,6 +304,6 @@ public function getStockOptions(string $symbol, ?\DateTimeInterface $expiryDate } $responseBody = (string) $this->client->request('GET', $url, ['cookies' => $cookieJar, 'headers' => $this->getHeaders()])->getBody(); - return $this->resultDecoder->transformOptions($responseBody); + return $this->resultDecoder->transformOptionChains($responseBody); } } diff --git a/src/ResultDecoder.php b/src/ResultDecoder.php index a3c93bb..367e794 100644 --- a/src/ResultDecoder.php +++ b/src/ResultDecoder.php @@ -9,6 +9,8 @@ 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; @@ -19,7 +21,20 @@ 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, @@ -301,7 +316,7 @@ public function transformQuotesSummary(string $responseBody): array return $decoded['quoteSummary']['result']; } - public function transformOptions(string $responseBody): array + public function transformOptionChains(string $responseBody): array { $decoded = json_decode($responseBody, true); if (!isset($decoded['optionChain']['result']) || !\is_array($decoded['optionChain']['result'])) { @@ -310,26 +325,88 @@ public function transformOptions(string $responseBody): array $results = $decoded['optionChain']['result']; - // Single element is returned directly in "quote" - return array_map(function (array $item) { - return $this->createOption($item); + // 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::QUOTE_FIELDS_MAP)) { - $type = self::QUOTE_FIELDS_MAP[$field]; - try { + 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('Not a %s in field "%s": %s', $type, $field, $value), ApiException::INVALID_VALUE, $e); } + } 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); + } } diff --git a/src/Results/Option.php b/src/Results/Option.php index 0ef92a8..0d9751a 100644 --- a/src/Results/Option.php +++ b/src/Results/Option.php @@ -6,21 +6,10 @@ class Option implements \JsonSerializable { - private $contractSymbol; - private $strike; - private $currency; - private $lastPrice; - private $change; - private $percentChange; - private $volume; - private $openInterest; - private $bid; - private $ask; - private $contractSize; - private $expiration; - private $lastTradeDate; - private $impliedVolatility; - private $inTheMoney; + private $expirationDate; + private $hasMiniOptions; + private $calls; + private $puts; public function __construct(array $values) { @@ -31,81 +20,35 @@ public function __construct(array $values) public function jsonSerialize(): array { - return get_object_vars($this); + 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 getContractSymbol(): string + public function getExpirationDate(): int { - return $this->contractSymbol; + return $this->expirationDate; } - public function getStrike(): float + public function getHasMiniOptions(): bool { - return $this->strike; + return $this->hasMiniOptions; } - public function getCurrency(): string + public function getCalls(): array { - return $this->currency; + return $this->calls; } - public function getLastPrice(): float + public function getPuts(): array { - return $this->lastPrice; - } - - public function getChange(): float - { - return $this->change; - } - - public function getPercentChange(): float - { - return $this->percentChange; - } - - public function getVolume(): int - { - return $this->volume; - } - - public function getOpenInterest(): int - { - return $this->openInterest; - } - - public function getBid(): float - { - return $this->bid; - } - - public function getAsk(): float - { - return $this->ask; - } - - public function getContractSize(): string - { - return $this->contractSize; - } - - public function getExpiration(): \DateTimeInterface - { - return $this->expiration; - } - - public function getLastTradeDate(): \DateTimeInterface - { - return $this->lastTradeDate; - } - - public function getImpliedVolatility(): float - { - return $this->impliedVolatility; - } - - public function getInTheMoney(): bool - { - return $this->inTheMoney; + return $this->puts; } } diff --git a/src/Results/OptionChain.php b/src/Results/OptionChain.php new file mode 100644 index 0000000..dc9fc63 --- /dev/null +++ b/src/Results/OptionChain.php @@ -0,0 +1,59 @@ + $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; + } +} diff --git a/src/Results/OptionContract.php b/src/Results/OptionContract.php new file mode 100644 index 0000000..afdb23a --- /dev/null +++ b/src/Results/OptionContract.php @@ -0,0 +1,111 @@ + $value) { + $this->{$property} = $value; + } + } + + public function jsonSerialize(): array + { + return get_object_vars($this); + } + + public function getContractSymbol(): string + { + return $this->contractSymbol; + } + + public function getStrike(): float + { + return $this->strike; + } + + public function getCurrency(): string + { + return $this->currency; + } + + public function getLastPrice(): float + { + return $this->lastPrice; + } + + public function getChange(): float + { + return $this->change; + } + + public function getPercentChange(): float + { + return $this->percentChange; + } + + public function getVolume(): int + { + return $this->volume; + } + + public function getOpenInterest(): int + { + return $this->openInterest; + } + + public function getBid(): float + { + return $this->bid; + } + + public function getAsk(): float + { + return $this->ask; + } + + public function getContractSize(): string + { + return $this->contractSize; + } + + public function getExpiration(): \DateTimeInterface + { + return $this->expiration; + } + + public function getLastTradeDate(): \DateTimeInterface + { + return $this->lastTradeDate; + } + + public function getImpliedVolatility(): float + { + return $this->impliedVolatility; + } + + public function getInTheMoney(): bool + { + return $this->inTheMoney; + } +} diff --git a/src/ValueMapper.php b/src/ValueMapper.php index be3e900..3b6db58 100644 --- a/src/ValueMapper.php +++ b/src/ValueMapper.php @@ -8,12 +8,27 @@ class ValueMapper implements ValueMapperInterface { + public function mapArray(array $rawValue, string $type): array + { + return array_map( + /** + * @param mixed $value + * + * @return mixed + */ + function ($value) use ($type) { + return $this->mapValue($value, $type); + }, + $rawValue + ); + } + /** * @param mixed $rawValue * * @return mixed */ - public function mapValue($rawValue, string $type) + public function mapValue($rawValue, string $type, ?string $subType = null) { if (null === $rawValue) { return null; @@ -30,6 +45,12 @@ public function mapValue($rawValue, string $type) return (string) $rawValue; case self::TYPE_BOOL: return $this->mapBoolValue($rawValue); + case self::TYPE_ARRAY: + if (null === $subType) { + throw new \InvalidArgumentException('Subtype must be provided for array type'); + } + + return $this->mapArray($rawValue, $subType); default: throw new \InvalidArgumentException(sprintf('Invalid data type %s', $type)); } @@ -64,7 +85,15 @@ private function mapIntValue($rawValue): int */ private function mapBoolValue($rawValue): bool { - return (bool) $rawValue; + if (is_numeric($rawValue)) { + return (bool) $rawValue; + } + + if (!\is_bool($rawValue)) { + throw new InvalidValueException(ValueMapperInterface::TYPE_BOOL); + } + + return $rawValue; } /** diff --git a/src/ValueMapperInterface.php b/src/ValueMapperInterface.php index ddcfaa4..b229e88 100644 --- a/src/ValueMapperInterface.php +++ b/src/ValueMapperInterface.php @@ -6,16 +6,20 @@ interface ValueMapperInterface { + public const TYPE_ARRAY = 'array'; + public const TYPE_OBJECT = 'object'; public const TYPE_FLOAT = 'float'; public const TYPE_INT = 'int'; public const TYPE_DATE = 'date'; public const TYPE_STRING = 'string'; public const TYPE_BOOL = 'bool'; + public function mapArray(array $rawValue, string $type): array; + /** * @param mixed $rawValue * * @return mixed */ - public function mapValue($rawValue, string $type); + public function mapValue($rawValue, string $type, ?string $subType = null); } diff --git a/tests/ApiClientIntegrationTest.php b/tests/ApiClientIntegrationTest.php index 3322d27..b430483 100644 --- a/tests/ApiClientIntegrationTest.php +++ b/tests/ApiClientIntegrationTest.php @@ -11,6 +11,8 @@ 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; @@ -318,12 +320,42 @@ public function testStockSummary(): void $this->assertEquals(self::APPLE_SYMBOL, $returnValue[0]['quoteType']['symbol']); } - public function testGetStockOptions(): void + public function testgetOptionChain(): void { - $returnValue = $this->client->getStockOptions(self::APPLE_SYMBOL); + $returnValue = $this->client->getOptionChain(self::APPLE_SYMBOL); $this->assertIsArray($returnValue); $this->assertGreaterThan(0, \count($returnValue)); - $this->assertContainsOnlyInstancesOf(Option::class, $returnValue); + $this->assertContainsOnlyInstancesOf(OptionChain::class, $returnValue); + foreach ($returnValue as $optionChain) { + $options = $optionChain->getOptions(); + $this->assertContainsOnlyInstancesOf(Option::class, $options); + foreach ($options as $option) { + $calls = $option->getCalls(); + $this->assertContainsOnlyInstancesOf(OptionContract::class, $calls); + $puts = $option->getPuts(); + $this->assertContainsOnlyInstancesOf(OptionContract::class, $puts); + } + } + } + + public function testGetStockOptions_historicExpiryDate(): void + { + $returnValue = $this->client->getOptionChain(self::APPLE_SYMBOL, new \DateTime('2024-01-04')); + + $this->assertIsArray($returnValue); + $this->assertGreaterThan(0, \count($returnValue)); + $this->assertContainsOnlyInstancesOf(OptionChain::class, $returnValue); + + foreach ($returnValue as $optionChain) { + $options = $optionChain->getOptions(); + $this->assertContainsOnlyInstancesOf(Option::class, $options); + foreach ($options as $option) { + $calls = $option->getCalls(); + $this->assertContainsOnlyInstancesOf(OptionContract::class, $calls); + $puts = $option->getPuts(); + $this->assertContainsOnlyInstancesOf(OptionContract::class, $puts); + } + } } } diff --git a/tests/ResultDecoderTest.php b/tests/ResultDecoderTest.php index b2854de..70accc4 100644 --- a/tests/ResultDecoderTest.php +++ b/tests/ResultDecoderTest.php @@ -9,6 +9,7 @@ use Scheb\YahooFinanceApi\ResultDecoder; use Scheb\YahooFinanceApi\Results\DividendData; use Scheb\YahooFinanceApi\Results\HistoricalData; +use Scheb\YahooFinanceApi\Results\OptionChain; use Scheb\YahooFinanceApi\Results\Quote; use Scheb\YahooFinanceApi\Results\SearchResult; use Scheb\YahooFinanceApi\Results\SplitData; @@ -540,4 +541,444 @@ public function transformSearchResult_jsonWithMissedFieldGiven_createSearchResul $this->resultDecoder->transformSearchResult(json_encode($jsonArray)); } + + /** + * @test + * @dataProvider transformQuotesInvalidResult + */ + public function transformOptionChains_jsonGiven_createArrayOfInvalidResult($responseBody): void + { + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Yahoo Search API returned an invalid result'); + + $this->resultDecoder->transformOptionChains(json_encode($responseBody)); + } + + /** + * @test + */ + public function transformOptionChains_jsonGiven_createArrayOfOptionChain(): void + { + $returnedResult = $this->resultDecoder->transformOptionChains(file_get_contents(__DIR__.'/fixtures/optionChain.json')); + + $this->assertIsArray($returnedResult); + $this->assertCount(1, $returnedResult); + $this->assertContainsOnlyInstancesOf(OptionChain::class, $returnedResult); + + $expectedOptionChainData = [ + [ + 'underlyingSymbol' => 'AAPL', + 'expirationDates' => [ + new \DateTime('@1711065600'), + new \DateTime('@1711584000'), + new \DateTime('@1781740800'), + ], + 'strikes' => [ + 100.0, + 105.0, + 265.0, + ], + 'hasMiniOptions' => false, + 'options' => [ + [ + 'expirationDate' => new \DateTime('@1711065600'), + 'hasMiniOptions' => false, + 'calls' => [ + [ + 'contractSymbol' => 'AAPL240322P00265000', + 'strike' => 256.0, + 'currency' => 'USD', + 'lastPrice' => 93.65, + 'change' => 6.7699966, + 'percentChange' => 7.7744565, + 'volume' => 3, + 'openInterest' => 0, + 'bid' => 90.65, + 'ask' => 94.8, + 'contractSize' => 'REGULAR', + 'expiration' => new \DateTime('@1598590800'), + 'lastTradeDate' => new \DateTime('@1597899600'), + 'impliedVolatility' => 1.642579912109375, + 'inTheMoney' => false, + ], + ], + 'puts' => [ + [ + 'contractSymbol' => 'AAPL240322P00265000', + 'strike' => 265.0, + 'currency' => 'USD', + 'lastPrice' => 93.65, + 'change' => 6.7699966, + 'percentChange' => 7.7744565, + 'volume' => 3, + 'openInterest' => 0, + 'bid' => 90.65, + 'ask' => 94.8, + 'contractSize' => 'REGULAR', + 'expiration' => new \DateTime('@1598590800'), + 'lastTradeDate' => new \DateTime('@1597899600'), + 'impliedVolatility' => 1.642579912109375, + 'inTheMoney' => false, + ], + ], + ], + ], + ], + ]; + + $this->assertEquals($expectedOptionChainData[0], $returnedResult[0]->jsonSerialize()); + } + + /** + * @test + */ + public function transformOptionChains_jsonWithNullGiven_createArrayOfOptionChain(): void + { + $returnedResult = $this->resultDecoder->transformOptionChains(file_get_contents(__DIR__.'/fixtures/nullOptionChain.json')); + + $this->assertIsArray($returnedResult); + $this->assertCount(1, $returnedResult); + $this->assertContainsOnlyInstancesOf(OptionChain::class, $returnedResult); + + $expectedOptionChainData = [ + [ + 'underlyingSymbol' => null, + 'expirationDates' => [], + 'strikes' => [], + 'hasMiniOptions' => false, + 'options' => [ + [ + 'expirationDate' => null, + 'hasMiniOptions' => false, + 'calls' => [], + 'puts' => [], + ], + ], + ], + ]; + + $this->assertEquals($expectedOptionChainData[0], $returnedResult[0]->jsonSerialize()); + } + + /** + * @test + */ + public function transformOptionChains_jsonWithInvalidFloatGivenInOptionChain_apiExceptionThrown(): void + { + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Not a float in field "strikes": ["invalid_float",105,265]'); + + $this->resultDecoder->transformOptionChains(file_get_contents(__DIR__.'/fixtures/invalidFloatOptionChain.json')); + } + + /** + * @test + */ + public function transformOptionChains_jsonWithInvalidDateTimeGivenInOptionsChain_apiExceptionThrown(): void + { + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Not a date in field "expirationDates": ["invalid_date_time",1711584000,1781740800]'); + + $this->resultDecoder->transformOptionChains(file_get_contents(__DIR__.'/fixtures/invalidDateTimeOptionChain.json')); + } + + /** + * @test + */ + public function transformOptionChains_jsonWithInvalidBooleanGivenInOptionsChain_apiExceptionThrown(): void + { + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Not a bool in field "hasMiniOptions": "invalid_boolean"'); + + $this->resultDecoder->transformOptionChains(file_get_contents(__DIR__.'/fixtures/invalidBooleanOptionChain.json')); + } + + /** + * @test + */ + public function transformOptionChains_jsonWithInvalidArrayGivenInOptionsChain_apiExceptionThrownForInvalidData(): void + { + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Not a array in field "options": "invalid_array"'); + + $this->resultDecoder->transformOptionChains(file_get_contents(__DIR__.'/fixtures/invalidArrayOptionChain.json')); + } + + /** + * @test + */ + public function transformOptionChains_jsonGiven_createArrayOfOptions(): void + { + $returnedResult = $this->resultDecoder->transformOptionChains(file_get_contents(__DIR__.'/fixtures/optionChain.json')); + + $this->assertIsArray($returnedResult); + $this->assertCount(1, $returnedResult); + $this->assertContainsOnlyInstancesOf(OptionChain::class, $returnedResult); + + $expectedOptionChainData = [ + [ + 'underlyingSymbol' => 'AAPL', + 'expirationDates' => [ + new \DateTime('@1711065600'), + new \DateTime('@1711584000'), + new \DateTime('@1781740800'), + ], + 'strikes' => [ + 100.0, + 105.0, + 265.0, + ], + 'hasMiniOptions' => false, + 'options' => [ + [ + 'expirationDate' => new \DateTime('@1711065600'), + 'hasMiniOptions' => false, + 'calls' => [ + [ + 'contractSymbol' => 'AAPL240322P00265000', + 'strike' => 256.0, + 'currency' => 'USD', + 'lastPrice' => 93.65, + 'change' => 6.7699966, + 'percentChange' => 7.7744565, + 'volume' => 3, + 'openInterest' => 0, + 'bid' => 90.65, + 'ask' => 94.8, + 'contractSize' => 'REGULAR', + 'expiration' => new \DateTime('@1598590800'), + 'lastTradeDate' => new \DateTime('@1597899600'), + 'impliedVolatility' => 1.642579912109375, + 'inTheMoney' => false, + ], + ], + 'puts' => [ + [ + 'contractSymbol' => 'AAPL240322P00265000', + 'strike' => 265.0, + 'currency' => 'USD', + 'lastPrice' => 93.65, + 'change' => 6.7699966, + 'percentChange' => 7.7744565, + 'volume' => 3, + 'openInterest' => 0, + 'bid' => 90.65, + 'ask' => 94.8, + 'contractSize' => 'REGULAR', + 'expiration' => new \DateTime('@1598590800'), + 'lastTradeDate' => new \DateTime('@1597899600'), + 'impliedVolatility' => 1.642579912109375, + 'inTheMoney' => false, + ], + ], + ], + ], + ], + ]; + + $this->assertEquals($expectedOptionChainData[0], $returnedResult[0]->jsonSerialize()); + } + + /** + * @test + */ + public function transformOptionChains_jsonWithNullGiven_HandleNullResult(): void + { + $returnedResult = $this->resultDecoder->transformOptionChains(file_get_contents(__DIR__.'/fixtures/nullOptionChain.json')); + + $this->assertIsArray($returnedResult); + $this->assertCount(1, $returnedResult); + $this->assertContainsOnlyInstancesOf(OptionChain::class, $returnedResult); + + $expectedOptionChainData = [ + [ + 'underlyingSymbol' => null, + 'expirationDates' => [], + 'strikes' => [], + 'hasMiniOptions' => false, + 'options' => [ + [ + 'expirationDate' => null, + 'hasMiniOptions' => false, + 'calls' => [], + 'puts' => [], + ], + ], + ], + ]; + + $this->assertEquals($expectedOptionChainData[0], $returnedResult[0]->jsonSerialize()); + } + + /** + * @test + */ + public function transformOptionChains_jsonWithInvalidArrayGivenInOption_apiExceptionThrown(): void + { + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Not a array in field "calls": ""'); + + $this->resultDecoder->transformOptionChains(file_get_contents(__DIR__.'/fixtures/invalidArrayOption.json')); + } + + /** + * @test + */ + public function transformOptionChains_jsonWithInvalidDateTimeGivenInOption_apiExceptionThrown(): void + { + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Not a date in field "expirationDate": "invalid_date_time"'); + + $this->resultDecoder->transformOptionChains(file_get_contents(__DIR__.'/fixtures/invalidDateTimeOption.json')); + } + + /** + * @test + */ + public function transformOptionChains_jsonWithInvalidBooleanGivenInOption_apiExceptionThrown(): void + { + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Not a bool in field "hasMiniOptions": "invalid_boolean"'); + + $this->resultDecoder->transformOptionChains(file_get_contents(__DIR__.'/fixtures/invalidBooleanOption.json')); + } + + /** + * @test + */ + public function transformOptionChains_jsonGiven_createArrayOfOptionContracts(): void + { + $returnedResult = $this->resultDecoder->transformOptionChains(file_get_contents(__DIR__.'/fixtures/optionChain.json')); + + $this->assertIsArray($returnedResult); + $this->assertCount(1, $returnedResult); + $this->assertContainsOnlyInstancesOf(OptionChain::class, $returnedResult); + + $expectedOptionChainData = [ + [ + 'underlyingSymbol' => 'AAPL', + 'expirationDates' => [ + new \DateTime('@1711065600'), + new \DateTime('@1711584000'), + new \DateTime('@1781740800'), + ], + 'strikes' => [ + 100.0, + 105.0, + 265.0, + ], + 'hasMiniOptions' => false, + 'options' => [ + [ + 'expirationDate' => new \DateTime('@1711065600'), + 'hasMiniOptions' => false, + 'calls' => [ + [ + 'contractSymbol' => 'AAPL240322P00265000', + 'strike' => 256.0, + 'currency' => 'USD', + 'lastPrice' => 93.65, + 'change' => 6.7699966, + 'percentChange' => 7.7744565, + 'volume' => 3, + 'openInterest' => 0, + 'bid' => 90.65, + 'ask' => 94.8, + 'contractSize' => 'REGULAR', + 'expiration' => new \DateTime('@1598590800'), + 'lastTradeDate' => new \DateTime('@1597899600'), + 'impliedVolatility' => 1.642579912109375, + 'inTheMoney' => false, + ], + ], + 'puts' => [ + [ + 'contractSymbol' => 'AAPL240322P00265000', + 'strike' => 265.0, + 'currency' => 'USD', + 'lastPrice' => 93.65, + 'change' => 6.7699966, + 'percentChange' => 7.7744565, + 'volume' => 3, + 'openInterest' => 0, + 'bid' => 90.65, + 'ask' => 94.8, + 'contractSize' => 'REGULAR', + 'expiration' => new \DateTime('@1598590800'), + 'lastTradeDate' => new \DateTime('@1597899600'), + 'impliedVolatility' => 1.642579912109375, + 'inTheMoney' => false, + ], + ], + ], + ], + ], + ]; + + $this->assertEquals($expectedOptionChainData[0], $returnedResult[0]->jsonSerialize()); + } + + /** + * @test + */ + public function transformOptionChains_jsonWithNullGiven_createArrayOfOptionContracts(): void + { + $returnedResult = $this->resultDecoder->transformOptionChains(file_get_contents(__DIR__.'/fixtures/nullOptionChain.json')); + + $this->assertIsArray($returnedResult); + $this->assertCount(1, $returnedResult); + $this->assertContainsOnlyInstancesOf(OptionChain::class, $returnedResult); + + $expectedOptionChainData = [ + [ + 'underlyingSymbol' => null, + 'expirationDates' => [], + 'strikes' => [], + 'hasMiniOptions' => false, + 'options' => [ + [ + 'expirationDate' => null, + 'hasMiniOptions' => false, + 'calls' => [], + 'puts' => [], + ], + ], + ], + ]; + + $this->assertEquals($expectedOptionChainData[0], $returnedResult[0]->jsonSerialize()); + } + + /** + * @test + */ + public function transformOptionChains_jsonWithInvalidFloatGiven_apiExceptionThrown(): void + { + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Not a float in field "percentChange": "7.7744565%"'); + + $this->resultDecoder->transformOptionChains(file_get_contents(__DIR__.'/fixtures/invalidFloatOptionContract.json')); + } + + /** + * @test + */ + public function transformOptionChains_jsonWithInvalidDateTimeGiven_apiExceptionThrown(): void + { + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Not a date in field "expiration": "invalid_date_time"'); + + $this->resultDecoder->transformOptionChains(file_get_contents(__DIR__.'/fixtures/invalidDateTimeOptionContract.json')); + } + + /** + * @test + */ + public function transformOptionChains_jsonWithInvalidBooleanGiven_apiExceptionThrown(): void + { + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Not a bool in field "inTheMoney": "invalid_boolean"'); + + $this->resultDecoder->transformOptionChains(file_get_contents(__DIR__.'/fixtures/invalidBooleanOptionContract.json')); + } } diff --git a/tests/fixtures/invalidArrayOption.json b/tests/fixtures/invalidArrayOption.json new file mode 100644 index 0000000..02fa5ee --- /dev/null +++ b/tests/fixtures/invalidArrayOption.json @@ -0,0 +1,47 @@ +{ + "optionChain": { + "result": [ + { + "underlyingSymbol": "AAPL", + "expirationDates": [ + 1711065600, + 1711584000, + 1781740800 + ], + "strikes": [ + 100.0, + 105.0, + 265.0 + ], + "hasMiniOptions": false, + "options": [ + { + "expirationDate": 1711065600, + "hasMiniOptions": false, + "calls": "", + "puts": [ + { + "contractSymbol": "AAPL240322P00265000", + "strike": 265.0, + "currency": "USD", + "lastPrice": 93.65, + "change": 6.7699966, + "percentChange": 7.7744565, + "volume": 3, + "openInterest": 0, + "bid": 90.65, + "ask": 94.8, + "contractSize": "REGULAR", + "expiration": 1598590800, + "lastTradeDate": 1597899600, + "impliedVolatility": 1.642579912109375, + "inTheMoney": false + } + ] + } + ] + } + ], + "error": null + } +} \ No newline at end of file diff --git a/tests/fixtures/invalidArrayOptionChain.json b/tests/fixtures/invalidArrayOptionChain.json new file mode 100644 index 0000000..6220c2c --- /dev/null +++ b/tests/fixtures/invalidArrayOptionChain.json @@ -0,0 +1,22 @@ +{ + "optionChain": { + "result": [ + { + "underlyingSymbol": "AAPL", + "expirationDates": [ + 1711065600, + 1711584000, + 1781740800 + ], + "strikes": [ + 100.0, + 105.0, + 265.0 + ], + "hasMiniOptions": false, + "options": "invalid_array" + } + ], + "error": null + } +} \ No newline at end of file diff --git a/tests/fixtures/invalidBooleanOption.json b/tests/fixtures/invalidBooleanOption.json new file mode 100644 index 0000000..f8eb1c3 --- /dev/null +++ b/tests/fixtures/invalidBooleanOption.json @@ -0,0 +1,65 @@ +{ + "optionChain": { + "result": [ + { + "underlyingSymbol": "AAPL", + "expirationDates": [ + 1711065600, + 1711584000, + 1781740800 + ], + "strikes": [ + 100.0, + 105.0, + 265.0 + ], + "hasMiniOptions": false, + "options": [ + { + "expirationDate": 1711065600, + "hasMiniOptions": "invalid_boolean", + "calls": [ + { + "contractSymbol": "AAPL240322P00265000", + "strike": 256.0, + "currency": "USD", + "lastPrice": 93.65, + "change": 6.7699966, + "percentChange": 7.7744565, + "volume": 3, + "openInterest": 0, + "bid": 90.65, + "ask": 94.8, + "contractSize": "REGULAR", + "expiration": 1598590800, + "lastTradeDate": 1597899600, + "impliedVolatility": 1.642579912109375, + "inTheMoney": false + } + ], + "puts": [ + { + "contractSymbol": "AAPL240322P00265000", + "strike": 265.0, + "currency": "USD", + "lastPrice": 93.65, + "change": 6.7699966, + "percentChange": 7.7744565, + "volume": 3, + "openInterest": 0, + "bid": 90.65, + "ask": 94.8, + "contractSize": "REGULAR", + "expiration": 1598590800, + "lastTradeDate": 1597899600, + "impliedVolatility": 1.642579912109375, + "inTheMoney": "invalid_boolean" + } + ] + } + ] + } + ], + "error": null + } +} \ No newline at end of file diff --git a/tests/fixtures/invalidBooleanOptionChain.json b/tests/fixtures/invalidBooleanOptionChain.json new file mode 100644 index 0000000..599bf65 --- /dev/null +++ b/tests/fixtures/invalidBooleanOptionChain.json @@ -0,0 +1,65 @@ +{ + "optionChain": { + "result": [ + { + "underlyingSymbol": "AAPL", + "expirationDates": [ + 1711065600, + 1711584000, + 1781740800 + ], + "strikes": [ + 100.0, + 105.0, + 265.0 + ], + "hasMiniOptions": "invalid_boolean", + "options": [ + { + "expirationDate": 1711065600, + "hasMiniOptions": false, + "calls": [ + { + "contractSymbol": "AAPL240322P00265000", + "strike": 256.0, + "currency": "USD", + "lastPrice": 93.65, + "change": 6.7699966, + "percentChange": 7.7744565, + "volume": 3, + "openInterest": 0, + "bid": 90.65, + "ask": 94.8, + "contractSize": "REGULAR", + "expiration": 1598590800, + "lastTradeDate": 1597899600, + "impliedVolatility": 1.642579912109375, + "inTheMoney": false + } + ], + "puts": [ + { + "contractSymbol": "AAPL240322P00265000", + "strike": 265.0, + "currency": "USD", + "lastPrice": 93.65, + "change": 6.7699966, + "percentChange": 7.7744565, + "volume": 3, + "openInterest": 0, + "bid": 90.65, + "ask": 94.8, + "contractSize": "REGULAR", + "expiration": 1598590800, + "lastTradeDate": 1597899600, + "impliedVolatility": 1.642579912109375, + "inTheMoney": "invalid_boolean" + } + ] + } + ] + } + ], + "error": null + } +} \ No newline at end of file diff --git a/tests/fixtures/invalidBooleanOptionContract.json b/tests/fixtures/invalidBooleanOptionContract.json new file mode 100644 index 0000000..4ca06c3 --- /dev/null +++ b/tests/fixtures/invalidBooleanOptionContract.json @@ -0,0 +1,65 @@ +{ + "optionChain": { + "result": [ + { + "underlyingSymbol": "AAPL", + "expirationDates": [ + 1711065600, + 1711584000, + 1781740800 + ], + "strikes": [ + 100.0, + 105.0, + 265.0 + ], + "hasMiniOptions": false, + "options": [ + { + "expirationDate": 1711065600, + "hasMiniOptions": false, + "calls": [ + { + "contractSymbol": "AAPL240322P00265000", + "strike": 256.0, + "currency": "USD", + "lastPrice": 93.65, + "change": 6.7699966, + "percentChange": 7.7744565, + "volume": 3, + "openInterest": 0, + "bid": 90.65, + "ask": 94.8, + "contractSize": "REGULAR", + "expiration": 1598590800, + "lastTradeDate": 1597899600, + "impliedVolatility": 1.642579912109375, + "inTheMoney": false + } + ], + "puts": [ + { + "contractSymbol": "AAPL240322P00265000", + "strike": 265.0, + "currency": "USD", + "lastPrice": 93.65, + "change": 6.7699966, + "percentChange": 7.7744565, + "volume": 3, + "openInterest": 0, + "bid": 90.65, + "ask": 94.8, + "contractSize": "REGULAR", + "expiration": 1598590800, + "lastTradeDate": 1597899600, + "impliedVolatility": 1.642579912109375, + "inTheMoney": "invalid_boolean" + } + ] + } + ] + } + ], + "error": null + } +} \ No newline at end of file diff --git a/tests/fixtures/invalidDateTimeOption.json b/tests/fixtures/invalidDateTimeOption.json new file mode 100644 index 0000000..9d10fc7 --- /dev/null +++ b/tests/fixtures/invalidDateTimeOption.json @@ -0,0 +1,65 @@ +{ + "optionChain": { + "result": [ + { + "underlyingSymbol": "AAPL", + "expirationDates": [ + 1700541200, + 1711584000, + 1781740800 + ], + "strikes": [ + 100.0, + 105.0, + 265.0 + ], + "hasMiniOptions": false, + "options": [ + { + "expirationDate": "invalid_date_time", + "hasMiniOptions": false, + "calls": [ + { + "contractSymbol": "AAPL240322P00265000", + "strike": 256.0, + "currency": "USD", + "lastPrice": 93.65, + "change": 6.7699966, + "percentChange": 7.7744565, + "volume": 3, + "openInterest": 0, + "bid": 90.65, + "ask": 94.8, + "contractSize": "REGULAR", + "expiration": "invalid_date_time", + "lastTradeDate": 1597899600, + "impliedVolatility": 1.642579912109375, + "inTheMoney": false + } + ], + "puts": [ + { + "contractSymbol": "AAPL240322P00265000", + "strike": 265.0, + "currency": "USD", + "lastPrice": 93.65, + "change": 6.7699966, + "percentChange": 7.7744565, + "volume": 3, + "openInterest": 0, + "bid": 90.65, + "ask": 94.8, + "contractSize": "REGULAR", + "expiration": 1598590800, + "lastTradeDate": 1597899600, + "impliedVolatility": 1.642579912109375, + "inTheMoney": false + } + ] + } + ] + } + ], + "error": null + } +} \ No newline at end of file diff --git a/tests/fixtures/invalidDateTimeOptionChain.json b/tests/fixtures/invalidDateTimeOptionChain.json new file mode 100644 index 0000000..54d4074 --- /dev/null +++ b/tests/fixtures/invalidDateTimeOptionChain.json @@ -0,0 +1,65 @@ +{ + "optionChain": { + "result": [ + { + "underlyingSymbol": "AAPL", + "expirationDates": [ + "invalid_date_time", + 1711584000, + 1781740800 + ], + "strikes": [ + 100.0, + 105.0, + 265.0 + ], + "hasMiniOptions": false, + "options": [ + { + "expirationDate": 1711065600, + "hasMiniOptions": false, + "calls": [ + { + "contractSymbol": "AAPL240322P00265000", + "strike": 256.0, + "currency": "USD", + "lastPrice": 93.65, + "change": 6.7699966, + "percentChange": 7.7744565, + "volume": 3, + "openInterest": 0, + "bid": 90.65, + "ask": 94.8, + "contractSize": "REGULAR", + "expiration": "invalid_date_time", + "lastTradeDate": 1597899600, + "impliedVolatility": 1.642579912109375, + "inTheMoney": false + } + ], + "puts": [ + { + "contractSymbol": "AAPL240322P00265000", + "strike": 265.0, + "currency": "USD", + "lastPrice": 93.65, + "change": 6.7699966, + "percentChange": 7.7744565, + "volume": 3, + "openInterest": 0, + "bid": 90.65, + "ask": 94.8, + "contractSize": "REGULAR", + "expiration": 1598590800, + "lastTradeDate": 1597899600, + "impliedVolatility": 1.642579912109375, + "inTheMoney": false + } + ] + } + ] + } + ], + "error": null + } +} \ No newline at end of file diff --git a/tests/fixtures/invalidDateTimeOptionContract.json b/tests/fixtures/invalidDateTimeOptionContract.json new file mode 100644 index 0000000..9397e6f --- /dev/null +++ b/tests/fixtures/invalidDateTimeOptionContract.json @@ -0,0 +1,65 @@ +{ + "optionChain": { + "result": [ + { + "underlyingSymbol": "AAPL", + "expirationDates": [ + 1711065600, + 1711584000, + 1781740800 + ], + "strikes": [ + 100.0, + 105.0, + 265.0 + ], + "hasMiniOptions": false, + "options": [ + { + "expirationDate": 1711065600, + "hasMiniOptions": false, + "calls": [ + { + "contractSymbol": "AAPL240322P00265000", + "strike": 256.0, + "currency": "USD", + "lastPrice": 93.65, + "change": 6.7699966, + "percentChange": 7.7744565, + "volume": 3, + "openInterest": 0, + "bid": 90.65, + "ask": 94.8, + "contractSize": "REGULAR", + "expiration": "invalid_date_time", + "lastTradeDate": 1597899600, + "impliedVolatility": 1.642579912109375, + "inTheMoney": false + } + ], + "puts": [ + { + "contractSymbol": "AAPL240322P00265000", + "strike": 265.0, + "currency": "USD", + "lastPrice": 93.65, + "change": 6.7699966, + "percentChange": 7.7744565, + "volume": 3, + "openInterest": 0, + "bid": 90.65, + "ask": 94.8, + "contractSize": "REGULAR", + "expiration": 1598590800, + "lastTradeDate": 1597899600, + "impliedVolatility": 1.642579912109375, + "inTheMoney": false + } + ] + } + ] + } + ], + "error": null + } +} \ No newline at end of file diff --git a/tests/fixtures/invalidFloatOptionChain.json b/tests/fixtures/invalidFloatOptionChain.json new file mode 100644 index 0000000..e2d3681 --- /dev/null +++ b/tests/fixtures/invalidFloatOptionChain.json @@ -0,0 +1,65 @@ +{ + "optionChain": { + "result": [ + { + "underlyingSymbol": "AAPL", + "expirationDates": [ + 1711065600, + 1711584000, + 1781740800 + ], + "strikes": [ + "invalid_float", + 105.0, + 265.0 + ], + "hasMiniOptions": false, + "options": [ + { + "expirationDate": 1711065600, + "hasMiniOptions": false, + "calls": [ + { + "contractSymbol": "AAPL240322P00265000", + "strike": 256.0, + "currency": "USD", + "lastPrice": 93.65, + "change": 6.7699966, + "percentChange": "7.7744565%", + "volume": 3, + "openInterest": 0, + "bid": 90.65, + "ask": 94.8, + "contractSize": "REGULAR", + "expiration": 1598590800, + "lastTradeDate": 1597899600, + "impliedVolatility": 1.642579912109375, + "inTheMoney": false + } + ], + "puts": [ + { + "contractSymbol": "AAPL240322P00265000", + "strike": 265.0, + "currency": "USD", + "lastPrice": 93.65, + "change": 6.7699966, + "percentChange": 7.7744565, + "volume": 3, + "openInterest": 0, + "bid": 90.65, + "ask": 94.8, + "contractSize": "REGULAR", + "expiration": 1598590800, + "lastTradeDate": 1597899600, + "impliedVolatility": 1.642579912109375, + "inTheMoney": false + } + ] + } + ] + } + ], + "error": null + } +} \ No newline at end of file diff --git a/tests/fixtures/invalidFloatOptionContract.json b/tests/fixtures/invalidFloatOptionContract.json new file mode 100644 index 0000000..6532fe5 --- /dev/null +++ b/tests/fixtures/invalidFloatOptionContract.json @@ -0,0 +1,65 @@ +{ + "optionChain": { + "result": [ + { + "underlyingSymbol": "AAPL", + "expirationDates": [ + 1711065600, + 1711584000, + 1781740800 + ], + "strikes": [ + 100.0, + 105.0, + 265.0 + ], + "hasMiniOptions": false, + "options": [ + { + "expirationDate": 1711065600, + "hasMiniOptions": false, + "calls": [ + { + "contractSymbol": "AAPL240322P00265000", + "strike": 256.0, + "currency": "USD", + "lastPrice": 93.65, + "change": 6.7699966, + "percentChange": "7.7744565%", + "volume": 3, + "openInterest": 0, + "bid": 90.65, + "ask": 94.8, + "contractSize": "REGULAR", + "expiration": 1598590800, + "lastTradeDate": 1597899600, + "impliedVolatility": 1.642579912109375, + "inTheMoney": false + } + ], + "puts": [ + { + "contractSymbol": "AAPL240322P00265000", + "strike": 265.0, + "currency": "USD", + "lastPrice": 93.65, + "change": 6.7699966, + "percentChange": 7.7744565, + "volume": 3, + "openInterest": 0, + "bid": 90.65, + "ask": 94.8, + "contractSize": "REGULAR", + "expiration": 1598590800, + "lastTradeDate": 1597899600, + "impliedVolatility": 1.642579912109375, + "inTheMoney": false + } + ] + } + ] + } + ], + "error": null + } +} \ No newline at end of file diff --git a/tests/fixtures/nullOptionChain.json b/tests/fixtures/nullOptionChain.json new file mode 100644 index 0000000..ec6a262 --- /dev/null +++ b/tests/fixtures/nullOptionChain.json @@ -0,0 +1,21 @@ +{ + "optionChain": { + "result": [ + { + "underlyingSymbol": null, + "expirationDates": [], + "strikes": [], + "hasMiniOptions": false, + "options": [ + { + "expirationDate": null, + "hasMiniOptions": false, + "calls": [], + "puts": [] + } + ] + } + ], + "error": null + } +} \ No newline at end of file diff --git a/tests/fixtures/optionChain.json b/tests/fixtures/optionChain.json new file mode 100644 index 0000000..f65e4b9 --- /dev/null +++ b/tests/fixtures/optionChain.json @@ -0,0 +1,65 @@ +{ + "optionChain": { + "result": [ + { + "underlyingSymbol": "AAPL", + "expirationDates": [ + 1711065600, + 1711584000, + 1781740800 + ], + "strikes": [ + 100.0, + 105.0, + 265.0 + ], + "hasMiniOptions": false, + "options": [ + { + "expirationDate": 1711065600, + "hasMiniOptions": false, + "calls": [ + { + "contractSymbol": "AAPL240322P00265000", + "strike": 256.0, + "currency": "USD", + "lastPrice": 93.65, + "change": 6.7699966, + "percentChange": 7.7744565, + "volume": 3, + "openInterest": 0, + "bid": 90.65, + "ask": 94.8, + "contractSize": "REGULAR", + "expiration": 1598590800, + "lastTradeDate": 1597899600, + "impliedVolatility": 1.642579912109375, + "inTheMoney": false + } + ], + "puts": [ + { + "contractSymbol": "AAPL240322P00265000", + "strike": 265.0, + "currency": "USD", + "lastPrice": 93.65, + "change": 6.7699966, + "percentChange": 7.7744565, + "volume": 3, + "openInterest": 0, + "bid": 90.65, + "ask": 94.8, + "contractSize": "REGULAR", + "expiration": 1598590800, + "lastTradeDate": 1597899600, + "impliedVolatility": 1.642579912109375, + "inTheMoney": false + } + ] + } + ] + } + ], + "error": null + } +} \ No newline at end of file