fine
This commit is contained in:
parent
6df2812a4e
commit
9e4e65fa2a
35 changed files with 1172 additions and 70 deletions
|
|
@ -12,6 +12,16 @@ use crate::utils::{normalize_postcode, GridIndex, InternedColumn};
|
|||
|
||||
const GRID_CELL_SIZE: f32 = 0.01;
|
||||
|
||||
/// One observation on a listing's accruing asking-price timeline, recovered from
|
||||
/// the scraper's forward-only store (finder/price_history.py). `reason` is one of
|
||||
/// "listed" | "reduced" | "increased"; `date` is `YYYY-MM-DD`.
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct PriceHistoryPoint {
|
||||
pub date: String,
|
||||
pub price: i64,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct ActualListing {
|
||||
pub lat: f32,
|
||||
|
|
@ -32,6 +42,10 @@ pub struct ActualListing {
|
|||
pub listing_status: Option<String>,
|
||||
pub listing_date_iso: Option<String>,
|
||||
pub features: Vec<String>,
|
||||
/// Accrued asking-price observations, oldest -> newest. Empty until the
|
||||
/// scraper's forward-only store has recorded at least one run for this id.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub price_history: Vec<PriceHistoryPoint>,
|
||||
}
|
||||
|
||||
pub struct ActualListingData {
|
||||
|
|
@ -54,6 +68,8 @@ pub struct ActualListingData {
|
|||
pub listing_status: InternedColumn,
|
||||
pub listing_date_iso: Vec<Option<String>>,
|
||||
pub features: Vec<Vec<String>>,
|
||||
/// Per-row accrued asking-price series (empty when absent from the parquet).
|
||||
pub price_history: Vec<Vec<PriceHistoryPoint>>,
|
||||
/// Row-major feature matrix aligned with PropertyData::feature_names.
|
||||
///
|
||||
/// Rows start from a best-effort address/postcode join to the historical property
|
||||
|
|
@ -102,6 +118,7 @@ impl ActualListingData {
|
|||
let listing_status_raw = extract_opt_str(&df, "Listing status")?;
|
||||
let listing_date_iso = extract_opt_datetime_iso(&df, "Listing date")?;
|
||||
let features = extract_str_list(&df, "Listing features")?;
|
||||
let price_history = extract_price_history(&df)?;
|
||||
|
||||
let postcode: Vec<String> = postcode_raw.iter().map(|s| normalize_postcode(s)).collect();
|
||||
|
||||
|
|
@ -146,6 +163,7 @@ impl ActualListingData {
|
|||
listing_status,
|
||||
listing_date_iso,
|
||||
features,
|
||||
price_history,
|
||||
filter_feature_data,
|
||||
poi_filter_feature_data,
|
||||
grid,
|
||||
|
|
@ -172,6 +190,7 @@ impl ActualListingData {
|
|||
listing_status: opt_from_interned(&self.listing_status, row),
|
||||
listing_date_iso: self.listing_date_iso[row].clone(),
|
||||
features: self.features[row].clone(),
|
||||
price_history: self.price_history[row].clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -629,6 +648,64 @@ fn extract_str_list(df: &DataFrame, name: &str) -> Result<Vec<Vec<String>>> {
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// Extract `price_history: List<Struct{date: Utf8, price: Int64, reason: Utf8}>`.
|
||||
///
|
||||
/// Schema-gated: a parquet written before the column existed simply yields an
|
||||
/// empty series per row (the column is dropped from serialized listings). A
|
||||
/// null/empty list is likewise an empty series.
|
||||
fn extract_price_history(df: &DataFrame) -> Result<Vec<Vec<PriceHistoryPoint>>> {
|
||||
let row_count = df.height();
|
||||
let Ok(column) = df.column("price_history") else {
|
||||
return Ok(vec![Vec::new(); row_count]);
|
||||
};
|
||||
let list_ca = column
|
||||
.list()
|
||||
.context("price_history is not a list column")?;
|
||||
|
||||
let mut out: Vec<Vec<PriceHistoryPoint>> = Vec::with_capacity(row_count);
|
||||
for row in 0..row_count {
|
||||
let mut points = Vec::new();
|
||||
if let Some(inner) = list_ca.get_as_series(row) {
|
||||
if !inner.is_empty() {
|
||||
let structs = inner
|
||||
.struct_()
|
||||
.context("price_history inner is not a struct")?;
|
||||
let dates_s = structs
|
||||
.field_by_name("date")
|
||||
.context("Missing 'date' field in price_history struct")?;
|
||||
let date_ca = dates_s.str().context("price_history.date is not a string")?;
|
||||
let prices_s = structs
|
||||
.field_by_name("price")
|
||||
.context("Missing 'price' field in price_history struct")?;
|
||||
let prices_i64 = prices_s
|
||||
.cast(&DataType::Int64)
|
||||
.context("price_history.price is not castable to Int64")?;
|
||||
let price_ca = prices_i64.i64().context("price_history.price is not Int64")?;
|
||||
let reasons_s = structs
|
||||
.field_by_name("reason")
|
||||
.context("Missing 'reason' field in price_history struct")?;
|
||||
let reason_ca = reasons_s
|
||||
.str()
|
||||
.context("price_history.reason is not a string")?;
|
||||
for idx in 0..inner.len() {
|
||||
let (Some(date), Some(price), Some(reason)) =
|
||||
(date_ca.get(idx), price_ca.get(idx), reason_ca.get(idx))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
points.push(PriceHistoryPoint {
|
||||
date: date.to_string(),
|
||||
price,
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(points);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -663,4 +740,30 @@ mod tests {
|
|||
assert!(any_listing.lon.is_finite());
|
||||
assert!(!any_listing.listing_url.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_price_history_from_parquet() {
|
||||
let path = PathBuf::from("src/data/testdata/listings_price_history.parquet");
|
||||
if !path.exists() {
|
||||
eprintln!("price_history fixture not present; skipping");
|
||||
return;
|
||||
}
|
||||
let data = ActualListingData::load_inner(&path, None).expect("listings load");
|
||||
assert_eq!(data.price_history.len(), data.lat.len());
|
||||
|
||||
// Row 0: two points, listed -> reduced, oldest first.
|
||||
let r0 = data.listing_at(0);
|
||||
assert_eq!(r0.price_history.len(), 2);
|
||||
assert_eq!(r0.price_history[0].date, "2026-07-01");
|
||||
assert_eq!(r0.price_history[0].price, 500_000);
|
||||
assert_eq!(r0.price_history[0].reason, "listed");
|
||||
assert_eq!(r0.price_history[1].price, 480_000);
|
||||
assert_eq!(r0.price_history[1].reason, "reduced");
|
||||
|
||||
// Row 1: a single "listed" point.
|
||||
assert_eq!(data.listing_at(1).price_history.len(), 1);
|
||||
// Row 2 (empty list) and row 3 (null) carry no points.
|
||||
assert!(data.listing_at(2).price_history.is_empty());
|
||||
assert!(data.listing_at(3).price_history.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue