mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-13 12:08:13 -07:00
global: cost basis -> urpd
This commit is contained in:
@@ -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")))
|
||||
|
||||
@@ -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(¶ms.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,
|
||||
¶ms.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()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms.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(
|
||||
¶ms.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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¶ms.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(¶ms.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(¶ms.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()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user