From 163b7983283cbc5fdf77942ae3e62e768caae6e8 Mon Sep 17 00:00:00 2001 From: piekstra Date: Fri, 2 Oct 2026 10:17:41 -0400 Subject: [PATCH 1/2] feat(account): portfolio composition report `lofty account portfolio` weights holdings by estimated value across states, cities, and properties, and by rent: each property's share of the projected daily rent, plus the share of properties renting and the share of holdings value that is rent-earning. - Value is each position's `currentValue` (tokens x current price). - Daily rent is the listing's `projected_annual_cash_flow` per issued token, times tokens held, over 365. `monthly_rent` is not used: it reads 0 on occupied, distributing properties. - A property is renting unless its listing is vacant, delinquent, or projects no cash flow. Non-paying properties are excluded from the rent weighting (null share), so renting shares sum to 100%. - A held property whose listing 404s is still weighted by value under an `unknown` location instead of being dropped. Read-only. `--json` emits `account-portfolio/v1`. Pure report logic lives in `commands/portfolio.rs`, tested against synthetic fixtures. --- AGENTS.md | 3 + README.md | 38 ++ src/commands/account.rs | 51 +++ src/commands/mod.rs | 1 + src/commands/portfolio.rs | 431 ++++++++++++++++++ tests/cli_surface.rs | 14 + tests/fixture_shapes.rs | 47 ++ tests/fixtures/README.md | 7 + .../fixtures/account/positions-portfolio.json | 82 ++++ .../fixtures/portfolio/property-listings.json | 82 ++++ 10 files changed, 756 insertions(+) create mode 100644 src/commands/portfolio.rs create mode 100644 tests/fixtures/account/positions-portfolio.json create mode 100644 tests/fixtures/portfolio/property-listings.json diff --git a/AGENTS.md b/AGENTS.md index 9748f0b..1e70d43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,9 @@ Run `make verify` before considering a change done — it's exactly what CI runs - Open orders come only from `orders::open_orders` (the `all=true` list, filtered client-side): the `?propertyId=` query reports a partially filled order's original quantity. + - `portfolio.rs` holds the pure report logic behind `account portfolio` + (holdings weighted by location, property, and rent); `account.rs` only + fetches its inputs. - `src/client.rs` — HTTP against the SDK surface (`/public/v1`) and the internal website API (`--internal`, `/prod`). - `src/config.rs` — non-secret config; the API key is keychain-only. diff --git a/README.md b/README.md index 389e188..4b57b3b 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ $ lofty rewards history --since # your reward payouts $ lofty rewards reconcile # audit payouts: totals, math check, gap detection $ lofty rewards eligibility # are your orders earning right now, and if not why $ lofty account balance|positions|trades +$ lofty account portfolio # holdings weighted by state, city, property, and rent $ lofty account coverage # what's reserved backing your bids vs free to spend $ lofty account breakeven --margin 5 # fee-inclusive sell price (basis hides the buy fee) $ lofty account rebates # maker rebates earned, derived from your fills @@ -171,6 +172,43 @@ $49.81 | +$0.35 | $52.75 | 01SAMPLEPROP00000000000001 | 4 | sell | $2.94 `orders-competitiveness/v1` with a `book` block and a `buy`/`sell` block per property (`null` for a side you have no orders on). +### Example — where is my money, and what pays? + +`portfolio` weights your holdings by **estimated value** (each position's +`currentValue`, tokens × current price) across states, cities, and properties, +and by **rent**: each property's share of your projected daily rent. + +Daily rent is the listing's `projected_annual_cash_flow` per issued token, times +the tokens you hold, over 365 days. A property counts as **renting** unless its +listing marks it vacant (`is_occupied: false`) or delinquent (`is_delinquent`), +or projects no cash flow. Non-paying properties are excluded from the rent +weighting (their share is `-` / `null`, not `0%`), so the renting ones sum to +100%. The headline also gives the share of properties renting and the share of +holdings value that is rent-earning. Numbers below are illustrative. + +```console +$ lofty account portfolio +4 properties, $10000.00 estimated value, $1.4000/day projected rent +renting: 2 of 4 properties (50.0%), 70.0% of holdings value + +BY STATE +PROPERTIES | RENT % | STATE | VALUE | VALUE % +2 | 57.1% | OH | $6000.00 | 60.0% +1 | 42.9% | MI | $3000.00 | 30.0% +1 | 0.0% | TN | $1000.00 | 10.0% +... +BY PROPERTY +LOCATION | PROPERTY | RENT % | RENT/DAY | STATUS | VALUE | VALUE % +Cleveland, OH | 100 Sample St | 57.1% | $0.8000 | renting | $4000.00 | 40.0% +Toledo, OH | 200 Sample St | - | $0.0000 | vacant | $2000.00 | 20.0% +``` + +`--json` emits `account-portfolio/v1` with `totals`, `byState`, `byCity`, and +`properties` (each carrying `valueSharePct`, `dailyRentUsd`, `rentSharePct`, +and `rentStatus`: `renting` | `vacant` | `delinquent` | `no-cash-flow` | +`unknown`). A held property whose listing is gone is still weighted by value, +under an `unknown` location, rather than dropped. + ### Example — what can I actually spend? Lofty funds open orders from your live balances: buys need the USDC and sells diff --git a/src/commands/account.rs b/src/commands/account.rs index d1f2979..d673067 100644 --- a/src/commands/account.rs +++ b/src/commands/account.rs @@ -12,6 +12,17 @@ pub enum Cmd { Balance, /// Token holdings by property, with cost basis and P&L inputs. Positions, + /// Portfolio composition: holdings weighted by state, city, and property + /// (on estimated value), plus each property's share of your daily rent and + /// how much of the portfolio is renting right now. + /// + /// Value is each position's `currentValue` (tokens x current price). Daily + /// rent is the property's projected annual cash flow, per issued token, times + /// the tokens you hold, over 365 days. A property counts as renting unless its + /// listing marks it vacant or delinquent or projects no cash flow; those pay + /// nothing, so they are excluded from the rent weighting rather than shown as + /// a 0% share. + Portfolio, /// Executed trades (completed buys and sells). Trades { /// Filter to one property. @@ -129,6 +140,46 @@ pub fn run(ctx: &Ctx, cmd: &Cmd) -> Result<(), CliError> { }); Ok(()) } + Cmd::Portfolio => { + let positions = client + .get("/public/v1/account/positions", &[])? + .get("positions") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut listings = std::collections::BTreeMap::new(); + for p in &positions { + let held = p + .get("currentTokens") + .and_then(Value::as_f64) + .unwrap_or(0.0); + let Some(pid) = p.get("propertyId").and_then(Value::as_str) else { + continue; + }; + if held <= 0.0 { + continue; + } + match client.get(&format!("/public/v1/properties/{pid}"), &[]) { + // `properties/{id}` wraps its payload in `property`; accept a + // bare listing too, as `venue_fees` does. + Ok(v) => { + let listing = v.get("property").cloned().unwrap_or(v); + listings.insert(pid.to_string(), listing); + } + // A delisted property still counts toward value; the report + // files it under `unknown` instead of failing the whole run. + Err(CliError::NotFound(_)) => { + if !ctx.common.quiet { + eprintln!("{pid}: no listing found — weighted under `unknown`"); + } + } + Err(e) => return Err(e), + } + } + let report = super::portfolio::portfolio(&positions, &listings); + emit(ctx, "account-portfolio", report, super::portfolio::render); + Ok(()) + } Cmd::Trades { property_id, direction, diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 324f42a..58cf7bc 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -8,6 +8,7 @@ pub mod book; pub mod catalog; pub mod competitiveness; pub mod orders; +pub mod portfolio; pub mod properties; pub mod quote; pub mod rewards; diff --git a/src/commands/portfolio.rs b/src/commands/portfolio.rs new file mode 100644 index 0000000..29fd8ac --- /dev/null +++ b/src/commands/portfolio.rs @@ -0,0 +1,431 @@ +//! `lofty account portfolio` — how your holdings are weighted by location, +//! property, and rent. +//! +//! Pure report logic lives here so it is unit-testable without a client; the +//! command in `account.rs` only gathers the inputs (`/account/positions` plus +//! one `/properties/{id}` per held property). + +use std::collections::BTreeMap; + +use pk_cli_core::output; +use serde_json::{json, Value}; + +/// Why a property does or does not pay rent right now. +/// +/// The SDK exposes no rent-payment feed, so this reads the listing flags Lofty +/// itself publishes: a vacant property pays nothing, a delinquent tenant pays +/// nothing, and a property whose projected cash flow is zero or negative has +/// nothing to distribute. `monthly_rent` is deliberately NOT consulted — it is +/// observed as `0` on occupied, distributing properties. +fn rent_status(listing: Option<&Value>) -> &'static str { + let Some(p) = listing else { + return "unknown"; + }; + if p.get("is_occupied").and_then(Value::as_bool) == Some(false) { + "vacant" + } else if p.get("is_delinquent").and_then(Value::as_bool) == Some(true) { + "delinquent" + } else if p + .get("projected_annual_cash_flow") + .and_then(Value::as_f64) + .unwrap_or(0.0) + <= 0.0 + { + "no-cash-flow" + } else { + "renting" + } +} + +/// Percent of `part` in `whole`, or `None` when there is no whole to divide. +fn share(part: f64, whole: f64) -> Option { + (whole > 0.0).then(|| part / whole * 100.0) +} + +/// Build the composition report. Pure (unit-tested). +/// +/// `positions` is the `/account/positions` array; `listings` maps a property id +/// to its `/properties/{id}` record (the inner `property` object). A held +/// property with no listing is still weighted by value, under an `unknown` +/// location, and counted as non-renting — dropping it would silently inflate +/// every other share. +/// +/// Weights by value use each position's `currentValue` (tokens x current +/// price). Your daily rent from a property is its projected annual cash flow, +/// spread over its issued tokens, times the tokens you hold, over 365 days. +pub fn portfolio(positions: &[Value], listings: &BTreeMap) -> Value { + let num = |v: &Value, k: &str| v.get(k).and_then(Value::as_f64).unwrap_or(0.0); + let text = |v: Option<&Value>, k: &str| { + v.and_then(|p| p.get(k)) + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()) + .map(str::to_string) + }; + + struct Row { + id: String, + address: Option, + city: String, + state: String, + tokens: f64, + value: f64, + status: &'static str, + daily_rent: f64, + } + + let mut rows: Vec = Vec::new(); + for p in positions { + let tokens = num(p, "currentTokens"); + if tokens <= 0.0 { + continue; // fully sold — no longer part of the portfolio + } + let id = p + .get("propertyId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let listing = listings.get(&id); + let value = match p.get("currentValue").and_then(Value::as_f64) { + Some(v) => v, + None => tokens * num(p, "currentPrice"), + }; + let status = rent_status(listing); + let daily_rent = match listing { + Some(l) if status == "renting" && num(l, "tokens") > 0.0 => { + num(l, "projected_annual_cash_flow") / 365.0 * tokens / num(l, "tokens") + } + _ => 0.0, + }; + rows.push(Row { + address: text(listing, "address_line1"), + city: text(listing, "city").unwrap_or_else(|| "unknown".into()), + state: text(listing, "state").unwrap_or_else(|| "unknown".into()), + id, + tokens, + value, + status, + daily_rent, + }); + } + + let total_value: f64 = rows.iter().map(|r| r.value).sum(); + let total_rent: f64 = rows.iter().map(|r| r.daily_rent).sum(); + let renting: Vec<&Row> = rows.iter().filter(|r| r.status == "renting").collect(); + let renting_value: f64 = renting.iter().map(|r| r.value).sum(); + + // Group by a key, summing value, then order largest first. + let group = |key: &dyn Fn(&Row) -> (String, Option)| -> Vec { + let mut g: BTreeMap<(String, Option), (usize, f64, f64)> = BTreeMap::new(); + for r in &rows { + let e = g.entry(key(r)).or_insert((0, 0.0, 0.0)); + e.0 += 1; + e.1 += r.value; + e.2 += r.daily_rent; + } + let mut out: Vec = g + .into_iter() + .map(|((name, state), (n, value, rent))| { + let mut o = json!({ + "properties": n, + "valueUsd": value, + "valueSharePct": share(value, total_value), + "dailyRentUsd": rent, + "rentSharePct": share(rent, total_rent), + }); + match state { + Some(st) => { + o["city"] = json!(name); + o["state"] = json!(st); + } + None => o["state"] = json!(name), + } + o + }) + .collect(); + out.sort_by(|a, b| num(b, "valueUsd").total_cmp(&num(a, "valueUsd"))); + out + }; + let by_state = group(&|r| (r.state.clone(), None)); + let by_city = group(&|r| (r.city.clone(), Some(r.state.clone()))); + + let mut properties: Vec = rows + .iter() + .map(|r| { + let renting = r.status == "renting"; + json!({ + "propertyId": r.id, + "address": r.address, + "city": r.city, + "state": r.state, + "tokens": r.tokens, + "valueUsd": r.value, + "valueSharePct": share(r.value, total_value), + "rentStatus": r.status, + "renting": renting, + "dailyRentUsd": r.daily_rent, + // Non-paying properties are excluded from the rent weighting + // rather than shown as 0%, so the renting ones sum to 100. + "rentSharePct": if renting { share(r.daily_rent, total_rent) } else { None }, + }) + }) + .collect(); + properties.sort_by(|a, b| num(b, "valueUsd").total_cmp(&num(a, "valueUsd"))); + + json!({ + "totals": { + "properties": rows.len(), + "valueUsd": total_value, + "dailyRentUsd": total_rent, + "rentingProperties": renting.len(), + "nonRentingProperties": rows.len() - renting.len(), + "rentingPropertySharePct": share(renting.len() as f64, rows.len() as f64), + "rentingValueUsd": renting_value, + "rentingValueSharePct": share(renting_value, total_value), + }, + "byState": by_state, + "byCity": by_city, + "properties": properties, + }) +} + +fn pct(v: &Value, k: &str) -> String { + v.get(k) + .and_then(Value::as_f64) + .map(|p| format!("{p:.1}%")) + .unwrap_or_else(|| "-".into()) +} + +fn usd(v: &Value, k: &str, places: usize) -> String { + format!( + "${:.*}", + places, + v.get(k).and_then(Value::as_f64).unwrap_or(0.0) + ) +} + +/// Human view: headline, then one table per weighting. +pub fn render(v: &Value) { + let t = &v["totals"]; + let count = |k: &str| t.get(k).and_then(Value::as_u64).unwrap_or(0); + println!( + "{} properties, {} estimated value, {}/day projected rent", + count("properties"), + usd(t, "valueUsd", 2), + usd(t, "dailyRentUsd", 4), + ); + println!( + "renting: {} of {} properties ({}), {} of holdings value", + count("rentingProperties"), + count("properties"), + pct(t, "rentingPropertySharePct"), + pct(t, "rentingValueSharePct"), + ); + + let arr = |k: &str| { + v.get(k) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + }; + + println!("\nBY STATE"); + output::table( + &arr("byState") + .iter() + .map(|g| { + json!({ + "state": g["state"], "properties": g["properties"], + "value": usd(g, "valueUsd", 2), "value %": pct(g, "valueSharePct"), + "rent %": pct(g, "rentSharePct"), + }) + }) + .collect::>(), + ); + + println!("\nBY CITY"); + output::table( + &arr("byCity") + .iter() + .map(|g| { + json!({ + "city": format!( + "{}, {}", + g["city"].as_str().unwrap_or("?"), + g["state"].as_str().unwrap_or("?") + ), + "properties": g["properties"], + "value": usd(g, "valueUsd", 2), "value %": pct(g, "valueSharePct"), + "rent %": pct(g, "rentSharePct"), + }) + }) + .collect::>(), + ); + + println!("\nBY PROPERTY"); + output::table( + &arr("properties") + .iter() + .map(|p| { + json!({ + "property": p["address"].as_str().unwrap_or_else(|| p["propertyId"].as_str().unwrap_or("?")), + "location": format!( + "{}, {}", + p["city"].as_str().unwrap_or("?"), + p["state"].as_str().unwrap_or("?") + ), + "value": usd(p, "valueUsd", 2), "value %": pct(p, "valueSharePct"), + "rent/day": usd(p, "dailyRentUsd", 4), "rent %": pct(p, "rentSharePct"), + "status": p["rentStatus"], + }) + }) + .collect::>(), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + const POSITIONS: &str = include_str!("../../tests/fixtures/account/positions-portfolio.json"); + const LISTINGS: &str = include_str!("../../tests/fixtures/portfolio/property-listings.json"); + + fn inputs() -> (Vec, BTreeMap) { + let positions: Value = serde_json::from_str(POSITIONS).unwrap(); + let listings: Value = serde_json::from_str(LISTINGS).unwrap(); + // The fixture is a list of `/properties/{id}` responses, envelope and all. + let listings = listings + .as_array() + .unwrap() + .iter() + .map(|r| { + let p = r["property"].clone(); + (p["id"].as_str().unwrap().to_string(), p) + }) + .collect(); + (positions["positions"].as_array().unwrap().clone(), listings) + } + + fn report() -> Value { + let (p, l) = inputs(); + portfolio(&p, &l) + } + + fn close(a: &Value, b: f64) { + let a = a.as_f64().unwrap_or_else(|| panic!("not a number: {a}")); + assert!((a - b).abs() < 1e-9, "{a} != {b}"); + } + + fn find<'a>(arr: &'a Value, k: &str, want: &str) -> &'a Value { + arr.as_array() + .unwrap() + .iter() + .find(|x| x[k] == want) + .unwrap_or_else(|| panic!("no {k}={want}")) + } + + #[test] + fn weights_holdings_by_state_on_estimated_value() { + // Fixture: OH $4000 + $2000, MI $3000, TN $1000 → $10000 total. + let r = report(); + close(&r["totals"]["valueUsd"], 10_000.0); + close(&find(&r["byState"], "state", "OH")["valueSharePct"], 60.0); + close(&find(&r["byState"], "state", "MI")["valueSharePct"], 30.0); + close(&find(&r["byState"], "state", "TN")["valueSharePct"], 10.0); + // Largest weighting first. + assert_eq!(r["byState"][0]["state"], "OH"); + assert_eq!(r["byState"][0]["properties"], 2); + } + + #[test] + fn cities_are_keyed_with_their_state() { + let r = report(); + let cities = r["byCity"].as_array().unwrap(); + assert_eq!(cities.len(), 4); + let nash = find(&r["byCity"], "city", "Nashville"); + assert_eq!(nash["state"], "TN"); + close(&nash["valueSharePct"], 10.0); + } + + #[test] + fn fully_sold_positions_are_not_holdings() { + // The fixture carries a zero-token position; it must not count anywhere. + let r = report(); + assert_eq!(r["totals"]["properties"], 4); + assert_eq!(r["properties"].as_array().unwrap().len(), 4); + } + + #[test] + fn rent_share_excludes_non_paying_properties() { + // Renting: A ($3650/yr over 1000 tokens, hold 80) and C ($7300/yr over + // 2000 tokens, hold 60). B is vacant, D delinquent. + let r = report(); + let a = find(&r["properties"], "propertyId", "01SAMPLEPROP0000000000000A"); + let c = find(&r["properties"], "propertyId", "01SAMPLEPROP0000000000000C"); + close(&a["dailyRentUsd"], 3650.0 / 365.0 * 80.0 / 1000.0); // $0.80 + close(&c["dailyRentUsd"], 7300.0 / 365.0 * 60.0 / 2000.0); // $0.60 + close(&r["totals"]["dailyRentUsd"], 1.4); + close(&a["rentSharePct"], 0.8 / 1.4 * 100.0); + close(&c["rentSharePct"], 0.6 / 1.4 * 100.0); + for id in ["01SAMPLEPROP0000000000000B", "01SAMPLEPROP0000000000000D"] { + let p = find(&r["properties"], "propertyId", id); + assert!(p["rentSharePct"].is_null(), "{id} should be excluded"); + close(&p["dailyRentUsd"], 0.0); + assert_eq!(p["renting"], false); + } + } + + #[test] + fn reports_why_a_property_is_not_paying() { + let r = report(); + let status = |id: &str| find(&r["properties"], "propertyId", id)["rentStatus"].clone(); + assert_eq!(status("01SAMPLEPROP0000000000000A"), "renting"); + assert_eq!(status("01SAMPLEPROP0000000000000B"), "vacant"); + assert_eq!(status("01SAMPLEPROP0000000000000D"), "delinquent"); + } + + #[test] + fn renting_share_by_count_and_by_value() { + // 2 of 4 properties pay rent; they hold $4000 + $3000 of $10000. + let r = report(); + let t = &r["totals"]; + assert_eq!(t["rentingProperties"], 2); + assert_eq!(t["nonRentingProperties"], 2); + close(&t["rentingPropertySharePct"], 50.0); + close(&t["rentingValueSharePct"], 70.0); + } + + #[test] + fn a_property_with_no_listing_is_still_weighted() { + // Dropping it would inflate every other share; it lands under `unknown` + // and counts as non-renting. + let (positions, mut listings) = inputs(); + listings.remove("01SAMPLEPROP0000000000000D"); + let r = portfolio(&positions, &listings); + close(&r["totals"]["valueUsd"], 10_000.0); + let d = find(&r["properties"], "propertyId", "01SAMPLEPROP0000000000000D"); + assert_eq!(d["rentStatus"], "unknown"); + assert_eq!(d["state"], "unknown"); + close( + &find(&r["byState"], "state", "unknown")["valueSharePct"], + 10.0, + ); + } + + #[test] + fn zero_cash_flow_counts_as_not_renting() { + let mut l = BTreeMap::new(); + l.insert( + "X".to_string(), + json!({"is_occupied": true, "projected_annual_cash_flow": 0, "tokens": 10}), + ); + assert_eq!(rent_status(l.get("X")), "no-cash-flow"); + } + + #[test] + fn an_empty_portfolio_has_no_shares_rather_than_nan() { + let r = portfolio(&[], &BTreeMap::new()); + assert_eq!(r["totals"]["properties"], 0); + assert!(r["totals"]["rentingValueSharePct"].is_null()); + assert!(r["byState"].as_array().unwrap().is_empty()); + } +} diff --git a/tests/cli_surface.rs b/tests/cli_surface.rs index 1b75da0..3070ed1 100644 --- a/tests/cli_surface.rs +++ b/tests/cli_surface.rs @@ -260,3 +260,17 @@ fn orders_competitiveness_help_renders() { .success() .stdout(predicate::str::contains("--property-id")); } + +#[test] +fn account_portfolio_help_renders() { + lofty() + .args(["account", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("portfolio")); + lofty() + .args(["account", "portfolio", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("rent")); +} diff --git a/tests/fixture_shapes.rs b/tests/fixture_shapes.rs index 157149d..ed842d2 100644 --- a/tests/fixture_shapes.rs +++ b/tests/fixture_shapes.rs @@ -324,3 +324,50 @@ fn competitiveness_fixtures_mirror_the_live_order_and_book_shapes() { } } } + +#[test] +fn property_listing_carries_the_fields_portfolio_weights_on() { + // `account portfolio` locates holdings by `city`/`state` and derives rent + // from `projected_annual_cash_flow` per issued `tokens`, gated on the + // occupancy/delinquency flags. A missing numeric reads as 0 and a missing + // flag as "renting", so a rename would silently skew every weighting. + let v = fixture("public/property-get.json"); + let p = v.get("property").expect("property envelope"); + for field in ["city", "state", "address_line1"] { + assert!( + p.get(field).and_then(Value::as_str).is_some(), + "listing missing string `{field}`" + ); + } + for field in ["tokens", "projected_annual_cash_flow"] { + assert!( + p.get(field).and_then(Value::as_f64).is_some(), + "listing missing numeric `{field}`" + ); + } + for field in ["is_occupied", "is_delinquent"] { + assert!( + p.get(field).and_then(Value::as_bool).is_some(), + "listing missing boolean `{field}`" + ); + } + // The synthetic portfolio fixture must mirror those shapes. + let listings = fixture("portfolio/property-listings.json"); + for l in listings.as_array().unwrap() { + let l = &l["property"]; + for field in ["id", "city", "state"] { + assert!(l.get(field).and_then(Value::as_str).is_some()); + } + assert!(l + .get("projected_annual_cash_flow") + .and_then(Value::as_f64) + .is_some()); + assert!(l.get("is_occupied").and_then(Value::as_bool).is_some()); + } + let positions = fixture("account/positions-portfolio.json"); + for p in positions["positions"].as_array().unwrap() { + for field in ["currentTokens", "currentValue", "currentPrice"] { + assert!(p.get(field).and_then(Value::as_f64).is_some()); + } + } +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 4dfaed3..97a2212 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -25,6 +25,13 @@ inspect. Tests load these files; they are never embedded as string literals. 2026-10-02): an ask level reporting quantity 0 and one with no quantity field, pinning how `book::levels` treats each (empty vs. unknown size, counted fail-closed). +- `account/positions-portfolio.json` and `portfolio/property-listings.json` + are **synthetic** (added 2026-10-02) for `account portfolio`: a multi-state + portfolio with one renting, one vacant, one delinquent and one fully sold + position, so every weighting path is exercised. Shapes mirror the live + `/account/positions` and `/properties/{id}` responses (the listings are + trimmed to the fields the report reads, envelope kept); ids are `01SAMPLE…`, + addresses `NNN Sample St`, zip codes `0000N`. - The old-fixtures at the top level (`marketplace.json`, `orderbook.json`, `property-info.json`) are from the internal `/prod` API, kept for the `api --internal` passthrough shapes. diff --git a/tests/fixtures/account/positions-portfolio.json b/tests/fixtures/account/positions-portfolio.json new file mode 100644 index 0000000..bab803d --- /dev/null +++ b/tests/fixtures/account/positions-portfolio.json @@ -0,0 +1,82 @@ +{ + "positions": [ + { + "propertyId": "01SAMPLEPROP0000000000000A", + "currentTokens": 80, + "pendingTokens": 0, + "currentEffectiveTokens": 80, + "costBasis": 5000.0, + "totalSpent": 4000.0, + "totalSellValue": 0, + "currentValue": 4000.0, + "currentPrice": 50.0, + "totalRentEarned": 12.5, + "tokensPurchased": 80, + "tokensSold": 0 + }, + { + "propertyId": "01SAMPLEPROP0000000000000B", + "currentTokens": 40, + "pendingTokens": 0, + "currentEffectiveTokens": 40, + "costBasis": 5000.0, + "totalSpent": 2000.0, + "totalSellValue": 0, + "currentValue": 2000.0, + "currentPrice": 50.0, + "totalRentEarned": 3.25, + "tokensPurchased": 40, + "tokensSold": 0 + }, + { + "propertyId": "01SAMPLEPROP0000000000000C", + "currentTokens": 60, + "pendingTokens": 0, + "currentEffectiveTokens": 60, + "costBasis": 5000.0, + "totalSpent": 3000.0, + "totalSellValue": 0, + "currentValue": 3000.0, + "currentPrice": 50.0, + "totalRentEarned": 9.0, + "tokensPurchased": 60, + "tokensSold": 0 + }, + { + "propertyId": "01SAMPLEPROP0000000000000D", + "currentTokens": 20, + "pendingTokens": 0, + "currentEffectiveTokens": 20, + "costBasis": 5000.0, + "totalSpent": 1000.0, + "totalSellValue": 0, + "currentValue": 1000.0, + "currentPrice": 50.0, + "totalRentEarned": 1.5, + "tokensPurchased": 20, + "tokensSold": 0 + }, + { + "propertyId": "01SAMPLEPROP0000000000000E", + "currentTokens": 0, + "pendingTokens": 0, + "currentEffectiveTokens": 0, + "costBasis": 5000.0, + "totalSpent": 0.0, + "totalSellValue": 520.0, + "currentValue": 0.0, + "currentPrice": 50.0, + "totalRentEarned": 2.0, + "tokensPurchased": 10, + "tokensSold": 10 + } + ], + "totals": { + "totalCurrentTokens": 200, + "totalPurchasedTokens": 210, + "totalCurrentPrincipal": 10000.0, + "totalSellValue": 520.0, + "totalRentEarned": 28.25, + "totalProperties": 5 + } +} diff --git a/tests/fixtures/portfolio/property-listings.json b/tests/fixtures/portfolio/property-listings.json new file mode 100644 index 0000000..7c32fc0 --- /dev/null +++ b/tests/fixtures/portfolio/property-listings.json @@ -0,0 +1,82 @@ +[ + { + "property": { + "id": "01SAMPLEPROP0000000000000A", + "address_line1": "100 Sample St", + "address_line2": "Cleveland, OH 00001", + "city": "Cleveland", + "state": "OH", + "zipcode": "00001", + "market": "cleveland", + "property_type": "single family", + "tokens": 1000, + "numIssued": 1000, + "sale_price": 50000, + "monthly_rent": 0, + "projected_annual_cash_flow": 3650, + "projected_rental_yield": 7.3, + "is_occupied": true, + "is_delinquent": false + } + }, + { + "property": { + "id": "01SAMPLEPROP0000000000000B", + "address_line1": "200 Sample St", + "address_line2": "Toledo, OH 00002", + "city": "Toledo", + "state": "OH", + "zipcode": "00002", + "market": "toledo", + "property_type": "single family", + "tokens": 500, + "numIssued": 500, + "sale_price": 25000, + "monthly_rent": 0, + "projected_annual_cash_flow": 2000, + "projected_rental_yield": 7.3, + "is_occupied": false, + "is_delinquent": false + } + }, + { + "property": { + "id": "01SAMPLEPROP0000000000000C", + "address_line1": "300 Sample St", + "address_line2": "Detroit, MI 00003", + "city": "Detroit", + "state": "MI", + "zipcode": "00003", + "market": "detroit", + "property_type": "single family", + "tokens": 2000, + "numIssued": 2000, + "sale_price": 100000, + "monthly_rent": 0, + "projected_annual_cash_flow": 7300, + "projected_rental_yield": 7.3, + "is_occupied": true, + "is_delinquent": false + } + }, + { + "property": { + "id": "01SAMPLEPROP0000000000000D", + "address_line1": "400 Sample St", + "address_line2": "Nashville, TN 00004", + "city": "Nashville", + "state": "TN", + "zipcode": "00004", + "market": "nashville", + "property_type": "single family", + "tokens": 800, + "numIssued": 800, + "sale_price": 40000, + "monthly_rent": 0, + "projected_annual_cash_flow": 2920, + "projected_rental_yield": 7.3, + "is_occupied": true, + "is_delinquent": true + } + } +] From 3b2e33a1e2e1163e03c44803c65de71b91ac8c6d Mon Sep 17 00:00:00 2001 From: piekstra Date: Fri, 2 Oct 2026 10:25:24 -0400 Subject: [PATCH 2/2] refactor(portfolio): one holding rule, typed rent status Review follow-up. `portfolio::held` is now the single definition of a holding (tokens > 0 and a propertyId); the command uses it to pick which listings to fetch and the report uses it to pick which rows to weight. Rent status is an enum instead of compared strings; the wire names in account-portfolio/v1 are unchanged. --- src/commands/account.rs | 12 +--- src/commands/portfolio.rs | 122 ++++++++++++++++++++++++++------------ 2 files changed, 84 insertions(+), 50 deletions(-) diff --git a/src/commands/account.rs b/src/commands/account.rs index d673067..5e7f999 100644 --- a/src/commands/account.rs +++ b/src/commands/account.rs @@ -148,17 +148,7 @@ pub fn run(ctx: &Ctx, cmd: &Cmd) -> Result<(), CliError> { .cloned() .unwrap_or_default(); let mut listings = std::collections::BTreeMap::new(); - for p in &positions { - let held = p - .get("currentTokens") - .and_then(Value::as_f64) - .unwrap_or(0.0); - let Some(pid) = p.get("propertyId").and_then(Value::as_str) else { - continue; - }; - if held <= 0.0 { - continue; - } + for (pid, _) in super::portfolio::held(&positions) { match client.get(&format!("/public/v1/properties/{pid}"), &[]) { // `properties/{id}` wraps its payload in `property`; accept a // bare listing too, as `venue_fees` does. diff --git a/src/commands/portfolio.rs b/src/commands/portfolio.rs index 29fd8ac..0d8f4d6 100644 --- a/src/commands/portfolio.rs +++ b/src/commands/portfolio.rs @@ -11,32 +11,74 @@ use pk_cli_core::output; use serde_json::{json, Value}; /// Why a property does or does not pay rent right now. -/// -/// The SDK exposes no rent-payment feed, so this reads the listing flags Lofty -/// itself publishes: a vacant property pays nothing, a delinquent tenant pays -/// nothing, and a property whose projected cash flow is zero or negative has -/// nothing to distribute. `monthly_rent` is deliberately NOT consulted — it is -/// observed as `0` on occupied, distributing properties. -fn rent_status(listing: Option<&Value>) -> &'static str { - let Some(p) = listing else { - return "unknown"; - }; - if p.get("is_occupied").and_then(Value::as_bool) == Some(false) { - "vacant" - } else if p.get("is_delinquent").and_then(Value::as_bool) == Some(true) { - "delinquent" - } else if p - .get("projected_annual_cash_flow") - .and_then(Value::as_f64) - .unwrap_or(0.0) - <= 0.0 - { - "no-cash-flow" - } else { - "renting" +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RentStatus { + Renting, + Vacant, + Delinquent, + NoCashFlow, + /// No listing was available to judge from. + Unknown, +} + +impl RentStatus { + /// Classify from the listing flags Lofty publishes. + /// + /// The SDK exposes no rent-payment feed: a vacant property pays nothing, a + /// delinquent tenant pays nothing, and a property whose projected cash flow + /// is zero or negative has nothing to distribute. `monthly_rent` is + /// deliberately NOT consulted — it is observed as `0` on occupied, + /// distributing properties. + fn of(listing: Option<&Value>) -> Self { + let Some(p) = listing else { + return Self::Unknown; + }; + if p.get("is_occupied").and_then(Value::as_bool) == Some(false) { + Self::Vacant + } else if p.get("is_delinquent").and_then(Value::as_bool) == Some(true) { + Self::Delinquent + } else if p + .get("projected_annual_cash_flow") + .and_then(Value::as_f64) + .unwrap_or(0.0) + <= 0.0 + { + Self::NoCashFlow + } else { + Self::Renting + } + } + + fn is_renting(self) -> bool { + self == Self::Renting + } + + /// Wire name in `account-portfolio/v1`. + fn as_str(self) -> &'static str { + match self { + Self::Renting => "renting", + Self::Vacant => "vacant", + Self::Delinquent => "delinquent", + Self::NoCashFlow => "no-cash-flow", + Self::Unknown => "unknown", + } } } +/// The positions that make up the portfolio, with their property ids: those +/// still holding tokens. A fully sold position is history, not a holding, and +/// one without a `propertyId` cannot be located or priced against a listing. +/// +/// The command uses this to decide which listings to fetch and the report uses +/// it to decide which rows to weight, so the two can never disagree. +pub fn held(positions: &[Value]) -> impl Iterator { + positions.iter().filter_map(|p| { + let tokens = p.get("currentTokens").and_then(Value::as_f64)?; + let id = p.get("propertyId").and_then(Value::as_str)?; + (tokens > 0.0).then_some((id, p)) + }) +} + /// Percent of `part` in `whole`, or `None` when there is no whole to divide. fn share(part: f64, whole: f64) -> Option { (whole > 0.0).then(|| part / whole * 100.0) @@ -69,29 +111,22 @@ pub fn portfolio(positions: &[Value], listings: &BTreeMap) -> Val state: String, tokens: f64, value: f64, - status: &'static str, + status: RentStatus, daily_rent: f64, } let mut rows: Vec = Vec::new(); - for p in positions { + for (id, p) in held(positions) { let tokens = num(p, "currentTokens"); - if tokens <= 0.0 { - continue; // fully sold — no longer part of the portfolio - } - let id = p - .get("propertyId") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); + let id = id.to_string(); let listing = listings.get(&id); let value = match p.get("currentValue").and_then(Value::as_f64) { Some(v) => v, None => tokens * num(p, "currentPrice"), }; - let status = rent_status(listing); + let status = RentStatus::of(listing); let daily_rent = match listing { - Some(l) if status == "renting" && num(l, "tokens") > 0.0 => { + Some(l) if status.is_renting() && num(l, "tokens") > 0.0 => { num(l, "projected_annual_cash_flow") / 365.0 * tokens / num(l, "tokens") } _ => 0.0, @@ -110,7 +145,7 @@ pub fn portfolio(positions: &[Value], listings: &BTreeMap) -> Val let total_value: f64 = rows.iter().map(|r| r.value).sum(); let total_rent: f64 = rows.iter().map(|r| r.daily_rent).sum(); - let renting: Vec<&Row> = rows.iter().filter(|r| r.status == "renting").collect(); + let renting: Vec<&Row> = rows.iter().filter(|r| r.status.is_renting()).collect(); let renting_value: f64 = renting.iter().map(|r| r.value).sum(); // Group by a key, summing value, then order largest first. @@ -151,7 +186,7 @@ pub fn portfolio(positions: &[Value], listings: &BTreeMap) -> Val let mut properties: Vec = rows .iter() .map(|r| { - let renting = r.status == "renting"; + let renting = r.status.is_renting(); json!({ "propertyId": r.id, "address": r.address, @@ -160,7 +195,7 @@ pub fn portfolio(positions: &[Value], listings: &BTreeMap) -> Val "tokens": r.tokens, "valueUsd": r.value, "valueSharePct": share(r.value, total_value), - "rentStatus": r.status, + "rentStatus": r.status.as_str(), "renting": renting, "dailyRentUsd": r.daily_rent, // Non-paying properties are excluded from the rent weighting @@ -418,7 +453,16 @@ mod tests { "X".to_string(), json!({"is_occupied": true, "projected_annual_cash_flow": 0, "tokens": 10}), ); - assert_eq!(rent_status(l.get("X")), "no-cash-flow"); + assert_eq!(RentStatus::of(l.get("X")), RentStatus::NoCashFlow); + } + + #[test] + fn holdings_need_tokens_and_a_property_id() { + let (positions, _) = inputs(); + let ids: Vec<&str> = held(&positions).map(|(id, _)| id).collect(); + // E is fully sold; every other fixture position is held. + assert_eq!(ids.len(), 4); + assert!(!ids.contains(&"01SAMPLEPROP0000000000000E")); } #[test]