diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e7430a..23ff402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +### v1.19.0 +- Added `client.price_feed_v2.price_feed.retrieve` and `client.price_feed_v2.price_feed_smoothed.retrieve`, wrapping the new `POST /v2/price_feed/price_feed` and `POST /v2/price_feed/price_feed_smoothed` endpoints. Both accept `parcl_ids`, `start_date`, `end_date`, `limit`, `auto_paginate`, and a `property_type` filter (`ALL`, `SINGLE_FAMILY`, or `NEW_CONSTRUCTION`; defaults to `ALL`). The smoothed series is the median of the last 30 daily prints per market. +- `ParclLabsService` accepts `url=None` for POST-only endpoints when `post_url` is provided. +- POST requests now send `limit` and `offset` as query parameters only. Previously an `offset` passed through `params` was misrouted into the JSON body, and `limit` was duplicated there. Auto-pagination no longer re-applies a caller-supplied `offset` to subsequent pages. +- `PropertyTypeService` and `PortfolioSizeService` no longer mutate the caller's `params` dictionary. + ### v1.18.0 - **`property_v2.search.retrieve`: `limit` is now a cap on the total number of properties returned, not a page size.** Pagination is handled internally to satisfy it. Previously, passing *any* explicit `limit` silently disabled auto-pagination, so `limit=1000` returned one page of 1,000 and discarded every remaining match with no error or warning. Calls with `limit <= 50000` are unaffected — same request, same results. - **`limit` above 50,000 now paginates instead of failing.** Previously the request was rejected by the API with `422 limit input should be less than or equal to 50000`. diff --git a/README.md b/README.md index 897a587..9af370f 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,14 @@ Gets the daily price feed for a specified `parcl_id`. ##### Rental Price Feed Gets the daily updated Parcl Labs Rental Price Feed for a given `parcl_id`. +##### Price Feed V2 +Gets the daily price feed for the given `parcl_ids`, filtered by `property_type`: `ALL` (default), `SINGLE_FAMILY`, or `NEW_CONSTRUCTION`. + +##### Price Feed V2 Smoothed +Gets the smoothed price feed, the median of the last 30 daily prints for each market, which removes day-to-day noise. Covers the 112 core Parcl Labs markets from 2011-01-30 and accepts the same `property_type` filter. + +For both v2 endpoints, `limit` is the page size (up to 10,000 rows per request) and a call returns a single page unless `auto_paginate=True` is passed, which follows the pagination links until the full date range is returned. Invalid `property_type` values are rejected by the API. + ```python # get 2 price feeds trading on the Parcl Exchange pricefeed_markets = client.search.markets.retrieve( @@ -370,6 +378,17 @@ rental_price_feeds = client.price_feed.rental_price_feed.retrieve( start_date=start_date, end_date=end_date ) +price_feeds_v2 = client.price_feed_v2.price_feed.retrieve( + parcl_ids=pricefeed_ids, + start_date=start_date, + end_date=end_date, + property_type='ALL' +) +price_feeds_v2_smoothed = client.price_feed_v2.price_feed_smoothed.retrieve( + parcl_ids=pricefeed_ids, + start_date=start_date, + end_date=end_date +) ``` ### Property diff --git a/parcllabs/__version__.py b/parcllabs/__version__.py index 4c0681d..ccdbf7c 100644 --- a/parcllabs/__version__.py +++ b/parcllabs/__version__.py @@ -1 +1 @@ -VERSION = "1.18.0" +VERSION = "1.19.0" diff --git a/parcllabs/common.py b/parcllabs/common.py index 29a580d..1d0cb5d 100644 --- a/parcllabs/common.py +++ b/parcllabs/common.py @@ -10,6 +10,11 @@ ID_COLUMNS = [ResponseColumns.PARCL_ID.value, ResponseColumns.PARCL_PROPERTY_ID.value] DATE_COLUMNS = [ResponseColumns.DATE.value, ResponseColumns.EVENT_DATE.value] +POST_QUERY_PARAMS = [ + ResponseColumns.LIMIT.value, + ResponseColumns.OFFSET.value, +] + DELETE_FROM_OUTPUT = [ ResponseColumns.TOTAL.value, ResponseColumns.LIMIT.value, diff --git a/parcllabs/parcllabs_client.py b/parcllabs/parcllabs_client.py index 55ae83d..544de83 100644 --- a/parcllabs/parcllabs_client.py +++ b/parcllabs/parcllabs_client.py @@ -20,7 +20,7 @@ def __init__(self, client: object) -> None: def add_service( self, name: str, - url: str, + url: str | None, service_class: ParclLabsService, post_url: str | None = None, alias: str | None = None, @@ -60,6 +60,7 @@ def __init__( def _initialize_services(self) -> None: self.price_feed = self._create_price_feed_services() + self.price_feed_v2 = self._create_price_feed_v2_services() self.investor_metrics = self._create_investor_metrics_services() self.market_metrics = self._create_market_metrics_services() self.new_construction_metrics = self._create_new_construction_metrics_services() @@ -97,6 +98,23 @@ def _create_price_feed_services(self) -> ServiceGroup: self._add_services_to_group(group, services) return group + def _create_price_feed_v2_services(self) -> ServiceGroup: + group = self._create_service_group() + services = { + "price_feed": { + "url": None, # POST-only endpoint + "post_url": "/v2/price_feed/price_feed", + "service_class": PropertyTypeService, + }, + "price_feed_smoothed": { + "url": None, # POST-only endpoint + "post_url": "/v2/price_feed/price_feed_smoothed", + "service_class": PropertyTypeService, + }, + } + self._add_services_to_group(group, services) + return group + def _create_investor_metrics_services(self) -> ServiceGroup: group = self._create_service_group() services = { diff --git a/parcllabs/services/metrics/portfolio_size_service.py b/parcllabs/services/metrics/portfolio_size_service.py index 1d1ec2a..fe9eaf4 100644 --- a/parcllabs/services/metrics/portfolio_size_service.py +++ b/parcllabs/services/metrics/portfolio_size_service.py @@ -19,8 +19,7 @@ def retrieve( """ Retrieve portfolio size metrics for given parameters. """ - if params is None: - params = {} + params = dict(params or {}) if portfolio_size: params["portfolio_size"] = portfolio_size.upper() diff --git a/parcllabs/services/metrics/property_type_service.py b/parcllabs/services/metrics/property_type_service.py index aa4a194..b081cbc 100644 --- a/parcllabs/services/metrics/property_type_service.py +++ b/parcllabs/services/metrics/property_type_service.py @@ -19,8 +19,7 @@ def retrieve( """ Retrieve property type metrics for given parameters. """ - if params is None: - params = {} + params = dict(params or {}) if property_type: params["property_type"] = property_type.upper() diff --git a/parcllabs/services/parcllabs_service.py b/parcllabs/services/parcllabs_service.py index 9138b67..bc649e9 100644 --- a/parcllabs/services/parcllabs_service.py +++ b/parcllabs/services/parcllabs_service.py @@ -9,7 +9,12 @@ from requests.exceptions import RequestException from parcllabs.__version__ import VERSION -from parcllabs.common import DELETE_FROM_OUTPUT, GET_METHOD, POST_METHOD +from parcllabs.common import ( + DELETE_FROM_OUTPUT, + GET_METHOD, + POST_METHOD, + POST_QUERY_PARAMS, +) from parcllabs.enums import RequestLimits, RequestMethods, ResponseCodes from parcllabs.exceptions import NotFoundError from parcllabs.services.data_utils import safe_concat_and_format_dtypes @@ -21,14 +26,16 @@ class ParclLabsService: Base class for working with data from the Parcl Labs API. """ - def __init__(self, url: str, client: object, post_url: str | None = None) -> None: + def __init__(self, url: str | None, client: object, post_url: str | None = None) -> None: self.url = url self.post_url = post_url self.client = client if client is None: raise ValueError("Missing required client object.") + if url is None and post_url is None: + raise ValueError("At least one of url or post_url must be provided.") self.api_url = client.api_url - self.full_url = self.api_url + self.url + self.full_url = self.api_url + self.url if url else None self.full_post_url = self.api_url + self.post_url if post_url else None self.api_key = client.api_key self.headers = self._get_headers() @@ -172,15 +179,20 @@ def _fetch( params["limit"] = self.client.limit if self.full_post_url: - # convert the list of parcl_ids into post body params, formatted - # as strings if params.get("limit"): params["limit"] = self._validate_limit(POST_METHOD, params["limit"]) - data = {"parcl_id": [str(pid) for pid in parcl_ids], **params} - params = {"limit": params["limit"]} if params.get("limit") else {} + # limit/offset travel in the query string; everything else, plus the + # parcl_ids formatted as strings, goes in the JSON body + query_params = { + k: v for k, v in params.items() if k in POST_QUERY_PARAMS and v is not None + } + data = { + "parcl_id": [str(pid) for pid in parcl_ids], + **{k: v for k, v in params.items() if k not in POST_QUERY_PARAMS}, + } - return self._fetch_post(params, data, auto_paginate) + return self._fetch_post(query_params, data, auto_paginate) if params.get("limit"): params["limit"] = self._validate_limit(GET_METHOD, params["limit"]) @@ -259,12 +271,15 @@ def _process_and_paginate_response( if auto_paginate and "links" in result and result["links"].get("next") is not None: all_items = result["items"] + # each next link already carries its own offset; re-sending the + # caller's initial offset would override it and repeat pages + next_params = {k: v for k, v in original_params.items() if k != "offset"} while result["links"].get("next") is not None: next_url = result["links"]["next"] if referring_method == "post": - next_response = self._post(next_url, data=data, params=original_params) + next_response = self._post(next_url, data=data, params=next_params) else: - next_response = self._get(next_url, params=original_params) + next_response = self._get(next_url, params=next_params) next_response.raise_for_status() result = next_response.json() all_items.extend(result["items"]) diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index 27df589..3687c07 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -155,3 +155,64 @@ def test_multiple_post_requests_with_bad_parcl_ids(client: ParclLabsClient) -> N assert results.shape[0] == len(TEST_PIDS) * 12 assert results.groupby("parcl_id").size().unique() == 12 + + +def test_price_feed_v2_post_request(client: ParclLabsClient) -> None: + test_pids = PRICEFEED_MARKETS[:3] + start_date = "2024-01-01" + end_date = "2024-01-31" + days = (pd.to_datetime(end_date) - pd.to_datetime(start_date)).days + 1 + + results = client.price_feed_v2.price_feed.retrieve( + parcl_ids=test_pids, + start_date=start_date, + end_date=end_date, + auto_paginate=True, + ) + + assert set(results["parcl_id"].unique()) == set(test_pids) + assert results.shape[0] == len(test_pids) * days + assert results["date"].min().date() == pd.to_datetime(start_date).date() + assert results["date"].max().date() == pd.to_datetime(end_date).date() + + +@pytest.mark.parametrize("service_name", ["price_feed", "price_feed_smoothed"]) +def test_price_feed_v2_forced_pagination(client: ParclLabsClient, service_name: str) -> None: + test_pid = [5826765] # US parcl id + start_date = "2024-01-01" + end_date = "2024-01-05" + days = (pd.to_datetime(end_date) - pd.to_datetime(start_date)).days + 1 + + results = getattr(client.price_feed_v2, service_name).retrieve( + parcl_ids=test_pid, + start_date=start_date, + end_date=end_date, + limit=2, # forces 3 pages for a 5 day window + auto_paginate=True, + ) + + assert results.shape[0] == days + assert results["date"].is_unique + assert results["date"].min().date() == pd.to_datetime(start_date).date() + assert results["date"].max().date() == pd.to_datetime(end_date).date() + + +@pytest.mark.parametrize("service_name", ["price_feed", "price_feed_smoothed"]) +@pytest.mark.parametrize("property_type", ["ALL", "SINGLE_FAMILY", "NEW_CONSTRUCTION"]) +def test_price_feed_v2_property_types( + client: ParclLabsClient, service_name: str, property_type: str +) -> None: + test_pid = [5826765] # US parcl id + start_date = "2024-01-01" + end_date = "2024-01-05" + days = (pd.to_datetime(end_date) - pd.to_datetime(start_date)).days + 1 + + results = getattr(client.price_feed_v2, service_name).retrieve( + parcl_ids=test_pid, + start_date=start_date, + end_date=end_date, + property_type=property_type, + ) + + assert results["parcl_id"].unique() == test_pid[0] + assert results.shape[0] == days diff --git a/tests/test_price_feed_v2.py b/tests/test_price_feed_v2.py new file mode 100644 index 0000000..e36260b --- /dev/null +++ b/tests/test_price_feed_v2.py @@ -0,0 +1,238 @@ +from unittest.mock import Mock, patch + +import pandas as pd +import pytest + +from parcllabs.parcllabs_client import ParclLabsClient, ServiceGroup +from parcllabs.services.metrics.property_type_service import PropertyTypeService +from parcllabs.services.parcllabs_service import ParclLabsService + +API_URL = "https://api.example.com" +SERVICE_PATHS = { + "price_feed": "/v2/price_feed/price_feed", + "price_feed_smoothed": "/v2/price_feed/price_feed_smoothed", +} +PARCL_ID = 2900187 +PAGE_ONE = [ + {"date": "2024-01-01", "price_feed": 250.5, "parcl_id": PARCL_ID}, + {"date": "2024-01-02", "price_feed": 251.0, "parcl_id": PARCL_ID}, +] +PAGE_TWO = [ + {"date": "2024-01-03", "price_feed": 251.5, "parcl_id": PARCL_ID}, + {"date": "2024-01-04", "price_feed": 252.0, "parcl_id": PARCL_ID}, +] +REQUEST_TARGET = "parcllabs.services.parcllabs_service.requests.request" + + +def _page(items: list[dict], next_link: str | None = None) -> dict: + return { + "items": [dict(item) for item in items], # the service extends items in place + "total": len(items), + "limit": 2, + "offset": 0, + "links": { + "first": None, + "last": None, + "self": None, + "next": next_link, + "prev": None, + }, + } + + +def _response(items: list[dict], next_link: str | None = None) -> Mock: + response = Mock() + response.status_code = 200 + response.json.return_value = _page(items, next_link) + return response + + +@pytest.fixture +def client() -> ParclLabsClient: + return ParclLabsClient(api_key="test_api_key", api_url=API_URL) + + +@pytest.fixture(params=list(SERVICE_PATHS)) +def service(request: pytest.FixtureRequest, client: ParclLabsClient) -> PropertyTypeService: + return getattr(client.price_feed_v2, request.param) + + +class TestPriceFeedV2Wiring: + def test_group_lists_both_services(self, client: ParclLabsClient) -> None: + assert client.price_feed_v2.services == list(SERVICE_PATHS) + + @pytest.mark.parametrize(("name", "path"), list(SERVICE_PATHS.items())) + def test_services_are_post_only(self, client: ParclLabsClient, name: str, path: str) -> None: + svc = getattr(client.price_feed_v2, name) + assert isinstance(svc, PropertyTypeService) + assert svc.full_post_url == API_URL + path + assert svc.url is None + assert svc.full_url is None + + +class TestServiceGroupCompatibility: + def test_positional_registration_still_works(self, client: ParclLabsClient) -> None: + group = ServiceGroup(client) + group.add_service("custom", "/custom/{parcl_id}", ParclLabsService) + + assert group.custom.full_url == API_URL + "/custom/{parcl_id}" + assert group.custom.full_post_url is None + assert group.services == ["custom"] + + def test_keyword_registration_with_alias(self, client: ParclLabsClient) -> None: + group = ServiceGroup(client) + group.add_service( + name="custom", + url="/custom/{parcl_id}", + service_class=ParclLabsService, + post_url="/custom", + alias="custom_alias", + ) + + assert group.custom is group.custom_alias + assert group.custom.full_post_url == API_URL + "/custom" + + +class TestPostOnlyServiceInit: + @pytest.fixture + def mock_client(self) -> Mock: + mock_client = Mock() + mock_client.api_url = API_URL + mock_client.api_key = "test_api_key" + return mock_client + + def test_post_url_only(self, mock_client: Mock) -> None: + svc = ParclLabsService(url=None, client=mock_client, post_url="/v2/x") + assert svc.full_url is None + assert svc.full_post_url == API_URL + "/v2/x" + + def test_requires_url_or_post_url(self, mock_client: Mock) -> None: + with pytest.raises(ValueError, match="url or post_url"): + ParclLabsService(url=None, client=mock_client) + + +class TestPriceFeedV2Retrieve: + def test_property_type_sent_in_post_body(self, service: PropertyTypeService) -> None: + with patch.object(service, "_fetch_post", return_value=_page(PAGE_ONE)) as mock_post: + service.retrieve( + parcl_ids=[PARCL_ID, 2900078], + start_date="2024-01-01", + end_date="2024-01-31", + property_type="single_family", + ) + + params, data, auto_paginate = mock_post.call_args.args + assert data == { + "parcl_id": [str(PARCL_ID), "2900078"], + "start_date": "2024-01-01", + "end_date": "2024-01-31", + "property_type": "SINGLE_FAMILY", + } + assert params == {} + assert auto_paginate is False + + def test_property_type_omitted_by_default(self, service: PropertyTypeService) -> None: + with patch.object(service, "_fetch_post", return_value=_page(PAGE_ONE)) as mock_post: + service.retrieve(parcl_ids=[PARCL_ID]) + + _, data, _ = mock_post.call_args.args + assert "property_type" not in data + + def test_limit_and_offset_are_query_params(self, service: PropertyTypeService) -> None: + with patch.object(service, "_fetch_post", return_value=_page(PAGE_ONE)) as mock_post: + service.retrieve(parcl_ids=[PARCL_ID], limit=2, params={"offset": 2}) + + params, data, _ = mock_post.call_args.args + assert params == {"limit": 2, "offset": 2} + assert "limit" not in data + assert "offset" not in data + + def test_does_not_mutate_caller_params(self, client: ParclLabsClient) -> None: + shared_params: dict = {} + daily = client.price_feed_v2.price_feed + smoothed = client.price_feed_v2.price_feed_smoothed + + with patch.object(daily, "_fetch_post", return_value=_page(PAGE_ONE)): + daily.retrieve( + parcl_ids=[PARCL_ID], property_type="SINGLE_FAMILY", params=shared_params + ) + assert shared_params == {} + + with patch.object(smoothed, "_fetch_post", return_value=_page(PAGE_ONE)) as mock_post: + smoothed.retrieve(parcl_ids=[PARCL_ID], params=shared_params) + _, data, _ = mock_post.call_args.args + assert "property_type" not in data + + def test_returns_dataframe(self, service: PropertyTypeService) -> None: + with patch.object(service, "_fetch_post", return_value=_page(PAGE_ONE)): + results = service.retrieve(parcl_ids=[PARCL_ID]) + + assert isinstance(results, pd.DataFrame) + assert len(results) == 2 + assert {"date", "price_feed", "parcl_id"} <= set(results.columns) + assert results["parcl_id"].tolist() == [PARCL_ID, PARCL_ID] + + +class TestPriceFeedV2Pagination: + def test_auto_paginate_follows_next_links(self, service: PropertyTypeService) -> None: + url = API_URL + service.post_url + next_link = f"{url}?limit=2&offset=2" + expected_body = { + "parcl_id": [str(PARCL_ID)], + "start_date": "2024-01-01", + "end_date": "2024-01-04", + "property_type": "SINGLE_FAMILY", + } + + with patch( + REQUEST_TARGET, + side_effect=[_response(PAGE_ONE, next_link), _response(PAGE_TWO)], + ) as mock_request: + results = service.retrieve( + parcl_ids=[PARCL_ID], + start_date="2024-01-01", + end_date="2024-01-04", + property_type="SINGLE_FAMILY", + limit=2, + auto_paginate=True, + ) + + assert mock_request.call_count == 2 + first, second = mock_request.call_args_list + assert first.args == ("POST", url) + assert first.kwargs["params"] == {"limit": 2} + assert first.kwargs["json"] == expected_body + assert second.args == ("POST", next_link) + assert second.kwargs["params"] == {"limit": 2} + assert second.kwargs["json"] == expected_body + assert len(results) == 4 + assert results["date"].is_unique + + def test_auto_paginate_does_not_reapply_initial_offset( + self, service: PropertyTypeService + ) -> None: + next_link = f"{API_URL}{service.post_url}?limit=2&offset=4" + + with patch( + REQUEST_TARGET, + side_effect=[_response(PAGE_ONE, next_link), _response(PAGE_TWO)], + ) as mock_request: + service.retrieve( + parcl_ids=[PARCL_ID], limit=2, params={"offset": 2}, auto_paginate=True + ) + + first, second = mock_request.call_args_list + assert first.kwargs["params"] == {"limit": 2, "offset": 2} + assert second.args[1] == next_link + assert second.kwargs["params"] == {"limit": 2} + + def test_without_auto_paginate_returns_first_page_only( + self, service: PropertyTypeService + ) -> None: + next_link = f"{API_URL}{service.post_url}?limit=2&offset=2" + + with patch(REQUEST_TARGET, return_value=_response(PAGE_ONE, next_link)) as mock_request: + results = service.retrieve(parcl_ids=[PARCL_ID], limit=2) + + assert mock_request.call_count == 1 + assert len(results) == 2