global: cost basis -> urpd

This commit is contained in:
nym21
2026-04-22 22:23:52 +02:00
parent 84e924b77e
commit 3faa989691
30 changed files with 950 additions and 507 deletions
+3 -1
View File
@@ -15,7 +15,7 @@ use crate::{
Error,
api::{
mempool_space::MempoolSpaceRoutes, metrics::ApiMetricsLegacyRoutes,
series::ApiSeriesRoutes, server::ServerRoutes,
series::ApiSeriesRoutes, server::ServerRoutes, urpd::ApiUrpdRoutes,
},
extended::{ResponseExtended, TransformResponseExtended},
};
@@ -27,6 +27,7 @@ mod metrics;
mod openapi;
mod series;
mod server;
mod urpd;
pub use openapi::*;
@@ -38,6 +39,7 @@ impl ApiRoutes for ApiRouter<AppState> {
fn add_api_routes(self) -> Self {
self.add_server_routes()
.add_series_routes()
.add_urpd_routes()
.add_metrics_legacy_routes()
.add_mempool_space_routes()
.route("/api/server", get(Redirect::temporary("/api#tag/server")))
+19 -1
View File
@@ -175,9 +175,27 @@ All errors return structured JSON with a consistent format:
),
..Default::default()
},
Tag {
name: "URPD".to_string(),
description: Some(
"UTXO Realized Price Distribution. For each (cohort, date) pair, supply is \
grouped by the close price at which each UTXO was last moved. One snapshot is \
emitted per UTC day.\n\n\
Each bucket carries `supply` (BTC), `realized_cap` (USD, = `price_floor * supply`), \
and `unrealized_pnl` (USD, = `(close - price_floor) * supply`, can be negative).\n\n\
Aggregate with the `agg` query parameter (alias `bucket`):\n\
- `raw`: one bucket per rounded price (default).\n\
- `lin200` / `lin500` / `lin1000`: linear buckets, $200 / $500 / $1000 wide.\n\
- `log10` / `log50` / `log100` / `log200`: logarithmic buckets, N bins per price decade.\n\n\
Discovery flow: `GET /api/urpd` (cohorts), `GET /api/urpd/{cohort}` (latest), \
`GET /api/urpd/{cohort}/dates` (history), `GET /api/urpd/{cohort}/{date}` (specific)."
.to_string(),
),
..Default::default()
},
Tag {
name: "Metrics".to_string(),
description: Some("Deprecated use Series".to_string()),
description: Some("Deprecated - use Series".to_string()),
extensions: [("deprecated".to_string(), serde_json::Value::Bool(true))].into(),
..Default::default()
},
@@ -0,0 +1,203 @@
//! Deprecated `/api/series/cost-basis/*` routes.
//! Sunset date: 2027-01-01. Delete this file and its registration in `mod.rs` together.
use std::collections::BTreeMap;
use aide::axum::{ApiRouter, routing::get_with};
use axum::{
extract::{Path, Query as AxumQuery, State},
http::{HeaderMap, Uri},
};
use brk_error::{Error, Result};
use brk_query::Query;
use brk_types::{Bitcoin, Cents, Cohort, Date, Day1, Dollars, Sats, UrpdAggregation, Version};
use rustc_hash::FxHashMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use vecdb::ReadableOptionVec;
use crate::{AppState, CacheStrategy, extended::TransformResponseExtended};
#[derive(Deserialize, JsonSchema)]
pub(super) struct CostBasisParams {
pub cohort: Cohort,
#[schemars(with = "String", example = &"2024-01-01")]
pub date: Date,
}
#[derive(Deserialize, JsonSchema)]
pub(super) struct CostBasisCohortParam {
pub cohort: Cohort,
}
#[derive(Deserialize, JsonSchema)]
pub(super) struct CostBasisQuery {
#[serde(default)]
pub bucket: UrpdAggregation,
#[serde(default)]
pub value: CostBasisValue,
}
/// Value type for the deprecated cost-basis distribution output.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub(super) enum CostBasisValue {
#[default]
Supply,
Realized,
Unrealized,
}
/// Formatted cost basis output.
/// Key: price floor in USD. Value: BTC (for supply) or USD (for realized/unrealized).
type CostBasisFormatted = BTreeMap<Dollars, f64>;
fn cost_basis_formatted(
q: &Query,
cohort: &Cohort,
date: Date,
agg: UrpdAggregation,
value: CostBasisValue,
) -> Result<CostBasisFormatted> {
let raw = q.urpd_raw(cohort, date)?;
let day1 = Day1::try_from(date).map_err(|e| Error::Parse(e.to_string()))?;
let spot_cents = q
.computer()
.prices
.split
.close
.cents
.day1
.collect_one_flat(day1)
.ok_or_else(|| Error::NotFound(format!("No price data for {date}")))?;
let spot = Dollars::from(spot_cents);
let needs_realized = value == CostBasisValue::Realized;
let mut bucketed: FxHashMap<Cents, (Sats, Dollars)> =
FxHashMap::with_capacity_and_hasher(raw.map.len(), Default::default());
for (&price_cents, &sats) in &raw.map {
let price = Cents::from(price_cents);
let key = match agg {
UrpdAggregation::Raw => price,
_ => agg.bucket_floor(price).unwrap_or(price),
};
let entry = bucketed.entry(key).or_insert((Sats::ZERO, Dollars::ZERO));
entry.0 += sats;
if needs_realized {
entry.1 += Dollars::from(price) * sats;
}
}
Ok(bucketed
.into_iter()
.map(|(cents, (sats, realized))| {
let k = Dollars::from(cents);
let v = match value {
CostBasisValue::Supply => f64::from(Bitcoin::from(sats)),
CostBasisValue::Realized => f64::from(realized),
CostBasisValue::Unrealized => f64::from((spot - k) * sats),
};
(k, v)
})
.collect())
}
pub(super) trait ApiCostBasisLegacyRoutes {
fn add_cost_basis_legacy_routes(self) -> Self;
}
impl ApiCostBasisLegacyRoutes for ApiRouter<AppState> {
fn add_cost_basis_legacy_routes(self) -> Self {
self.api_route(
"/api/series/cost-basis",
get_with(
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Static, &uri, |q| q.urpd_cohorts())
.await
},
|op| {
op.id("get_cost_basis_cohorts")
.series_tag()
.deprecated()
.summary("Available cost basis cohorts (deprecated)")
.description(
"**DEPRECATED** - Use `GET /api/urpd` instead.\n\n\
Sunset date: 2027-01-01.",
)
.json_response::<Vec<Cohort>>()
.not_modified()
.server_error()
},
),
)
.api_route(
"/api/series/cost-basis/{cohort}/dates",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<CostBasisCohortParam>,
State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Tip, &uri, move |q| {
q.urpd_dates(&params.cohort)
})
.await
},
|op| {
op.id("get_cost_basis_dates")
.series_tag()
.deprecated()
.summary("Available cost basis dates (deprecated)")
.description(
"**DEPRECATED** - Use `GET /api/urpd/{cohort}/dates` instead.\n\n\
Sunset date: 2027-01-01.",
)
.json_response::<Vec<Date>>()
.not_modified()
.not_found()
.server_error()
},
),
)
.api_route(
"/api/series/cost-basis/{cohort}/{date}",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<CostBasisParams>,
AxumQuery(query): AxumQuery<CostBasisQuery>,
State(state): State<AppState>| {
let strategy = state.date_cache(Version::ONE, params.date);
state
.cached_json(&headers, strategy, &uri, move |q| {
cost_basis_formatted(
q,
&params.cohort,
params.date,
query.bucket,
query.value,
)
})
.await
},
|op| {
op.id("get_cost_basis")
.series_tag()
.deprecated()
.summary("Cost basis distribution (deprecated)")
.description(
"**DEPRECATED** - Use `GET /api/urpd/{cohort}/{date}` instead. \
The new endpoint returns supply, realized cap, and unrealized P&L \
per bucket in one response.\n\n\
Sunset date: 2027-01-01.",
)
.json_response::<CostBasisFormatted>()
.not_modified()
.not_found()
.server_error()
},
),
)
}
}
+6 -85
View File
@@ -9,20 +9,22 @@ use axum::{
};
use brk_traversable::TreeNode;
use brk_types::{
CostBasisFormatted, DataRangeFormat, Date, IndexInfo, PaginatedSeries, Pagination, SearchQuery,
SeriesCount, SeriesData, SeriesInfo, SeriesNameWithIndex, SeriesSelection, Version,
DataRangeFormat, IndexInfo, PaginatedSeries, Pagination, SearchQuery, SeriesCount, SeriesData,
SeriesInfo, SeriesNameWithIndex, SeriesSelection,
};
use crate::{
CacheStrategy,
cache::CACHE_CONTROL,
extended::TransformResponseExtended,
params::{CostBasisCohortParam, CostBasisParams, CostBasisQuery, SeriesParam},
params::SeriesParam,
};
use self::cost_basis::ApiCostBasisLegacyRoutes;
use super::AppState;
mod bulk;
mod cost_basis;
mod data;
pub mod legacy;
@@ -330,87 +332,6 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
.not_modified(),
),
)
// Cost basis distribution endpoints
.api_route(
"/api/series/cost-basis",
get_with(
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Static, &uri, |q| q.cost_basis_cohorts())
.await
},
|op| {
op.id("get_cost_basis_cohorts")
.series_tag()
.summary("Available cost basis cohorts")
.description("List available cohorts for cost basis distribution.")
.json_response::<Vec<String>>()
.not_modified()
.server_error()
},
),
)
.api_route(
"/api/series/cost-basis/{cohort}/dates",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<CostBasisCohortParam>,
State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Tip, &uri, move |q| {
q.cost_basis_dates(&params.cohort)
})
.await
},
|op| {
op.id("get_cost_basis_dates")
.series_tag()
.summary("Available cost basis dates")
.description("List available dates for a cohort's cost basis distribution.")
.json_response::<Vec<Date>>()
.not_modified()
.not_found()
.server_error()
},
),
)
.api_route(
"/api/series/cost-basis/{cohort}/{date}",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<CostBasisParams>,
Query(query): Query<CostBasisQuery>,
State(state): State<AppState>| {
let strategy = state.date_cache(Version::ONE, params.date);
state
.cached_json(&headers, strategy, &uri, move |q| {
q.cost_basis_formatted(
&params.cohort,
params.date,
query.bucket,
query.value,
)
})
.await
},
|op| {
op.id("get_cost_basis")
.series_tag()
.summary("Cost basis distribution")
.description(
"Get the cost basis distribution for a cohort on a specific date.\n\n\
Query params:\n\
- `bucket`: raw (default), lin200, lin500, lin1000, log10, log50, log100\n\
- `value`: supply (default, in BTC), realized (USD), unrealized (USD)",
)
.json_response::<CostBasisFormatted>()
.not_modified()
.not_found()
.server_error()
},
),
)
.add_cost_basis_legacy_routes()
}
}
+135
View File
@@ -0,0 +1,135 @@
use aide::axum::{ApiRouter, routing::get_with};
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, Uri},
};
use brk_types::{Cohort, Date, Urpd, Version};
use crate::{
CacheStrategy,
extended::TransformResponseExtended,
params::{UrpdCohortParam, UrpdParams, UrpdQuery},
};
use super::AppState;
pub trait ApiUrpdRoutes {
fn add_urpd_routes(self) -> Self;
}
impl ApiUrpdRoutes for ApiRouter<AppState> {
fn add_urpd_routes(self) -> Self {
self.api_route(
"/api/urpd",
get_with(
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Static, &uri, |q| q.urpd_cohorts())
.await
},
|op| {
op.id("list_urpd_cohorts")
.urpd_tag()
.summary("Available URPD cohorts")
.description(
"Cohorts for which URPD data is available. Returns names like \
`all`, `sth`, `lth`, `utxos_under_1h_old`.",
)
.json_response::<Vec<Cohort>>()
.not_modified()
.server_error()
},
),
)
.api_route(
"/api/urpd/{cohort}/dates",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<UrpdCohortParam>,
State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Tip, &uri, move |q| {
q.urpd_dates(&params.cohort)
})
.await
},
|op| {
op.id("list_urpd_dates")
.urpd_tag()
.summary("Available URPD dates")
.description(
"Dates for which a URPD snapshot is available for the cohort. \
One entry per UTC day, sorted ascending.",
)
.json_response::<Vec<Date>>()
.not_modified()
.not_found()
.server_error()
},
),
)
.api_route(
"/api/urpd/{cohort}",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<UrpdCohortParam>,
Query(query): Query<UrpdQuery>,
State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Tip, &uri, move |q| {
q.urpd_latest(&params.cohort, query.aggregation)
})
.await
},
|op| {
op.id("get_urpd")
.urpd_tag()
.summary("Latest URPD")
.description(
"URPD for the most recent available date in the cohort. \
The response's `date` field echoes which date was served.\n\n\
See the URPD tag description for the response shape and `agg` options.",
)
.json_response::<Urpd>()
.not_modified()
.not_found()
.server_error()
},
),
)
.api_route(
"/api/urpd/{cohort}/{date}",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<UrpdParams>,
Query(query): Query<UrpdQuery>,
State(state): State<AppState>| {
let strategy = state.date_cache(Version::ONE, params.date);
state
.cached_json(&headers, strategy, &uri, move |q| {
q.urpd_at(&params.cohort, params.date, query.aggregation)
})
.await
},
|op| {
op.id("get_urpd_at")
.urpd_tag()
.summary("URPD at date")
.description(
"URPD for a (cohort, date) pair. Returns \
`{ cohort, date, aggregation, close, total_supply, buckets }` where \
each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`.\n\n\
See the URPD tag description for unit conventions and `agg` options.",
)
.json_response::<Urpd>()
.not_modified()
.not_found()
.server_error()
},
),
)
}
}