Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 <a id="property"></a>
Expand Down
2 changes: 1 addition & 1 deletion parcllabs/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VERSION = "1.18.0"
VERSION = "1.19.0"
5 changes: 5 additions & 0 deletions parcllabs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 19 additions & 1 deletion parcllabs/parcllabs_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 = {
Expand Down
3 changes: 1 addition & 2 deletions parcllabs/services/metrics/portfolio_size_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 1 addition & 2 deletions parcllabs/services/metrics/property_type_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
35 changes: 25 additions & 10 deletions parcllabs/services/parcllabs_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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"])

Expand Down Expand Up @@ -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"])
Expand Down
61 changes: 61 additions & 0 deletions tests/integration/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading