Sable Private Benchmark
Compare performance without handing over the portfolio.
A participant submits a portfolio — holdings plus a valuation series — and Sable returns return, volatility, maximum drawdown, a Sharpe-like ratio, concentration, asset-class exposure, benchmark-relative performance and a peer percentile. No participant ever sees another participant's holdings, metrics, identity, or membership.
This is privacy infrastructure for financial analytics. It is not investment advice, and Sable does not verify anyone's book — see What Sable checks, and what it does not.
What is stored, and for how long
Sable Private Benchmark is a deliberate, disclosed exception to the privacy contract: raw holdings are stored, because a benchmark you can refresh against a moving cohort cannot recompute what it does not hold.
They are stored the way hosted-agent code, Relay content and batch files are: AES-GCM-sealed with the master key (ciphertext at rest), opened in-frame only to compute a result, never logged, and destroyed on the retention policy you choose at submission.
retention | Sealed holdings live for | Refresh |
|---|---|---|
ephemeral | The one request that computed the first result | Refuses with 409 — nothing is kept, which is the point |
period (default) | 30 days | Works |
retained | Up to 365 days | Works |
There is no "forever" option. When the retention date passes, a leader-gated
sweeper sets both ciphertext columns to NULL and removes the portfolio from
the peer surface — so a purged book stops influencing anyone else's percentile
in the same moment it stops existing. What survives is the content-free row: a
sha256 fingerprint of the exact submission, counts, and the derived metrics, so
your history and your signed results remain readable.
There is deliberately no column anywhere for a symbol, a quantity, or a position value in the clear.
Anti-leakage: the four controls, named exactly
A peer percentile computed over a small cohort is a disclosure of the peers. Four controls stand between a legitimate comparison and a reconstruction, and they are named on every result Sable returns:
1. k-anonymity by minimum cohort size. No peer figure is released unless at
least 5 other participants match the cohort and filter set. Below that the
peer block comes back suppressed: true with every figure null. It is
suppressed, never approximated, never widened to reach the minimum, and never
softened into "roughly". Your own metrics are unaffected — they involve no other
participant.
2. Query-set overlap control. Two queries whose cohorts differ by one member subtract to that member's figures. So a peer answer is released only when its member set differs from every set already released to your account by at least the minimum cohort size — or not at all. This has a real cost, stated plainly: a refresh whose cohort gained a single member has its peer block suppressed. That is the price of not leaking the participant who joined. (A suppressed query published nothing, so it is not compared against — only released answers can be differenced.)
3. Deterministic output bucketing. Percentiles are reported as a half-open
5-point bucket (60 means "60th to 65th"); peer rates are rounded to 50 bps.
The rounding is a pure function of the value, so a hundred identical queries
return a hundred identical answers. This is why the bucketing is deterministic
rather than noisy: randomised noise on a repeatable query is averaged away, and
a fixed bucket is not.
Peer medians and quartiles are nearest-rank, never interpolated, so a published figure is always an observed member value rather than a linear combination of two private ones.
4. Query rate limits and probing detection. Peer-touching calculations are
capped per account per rolling 24 hours. Repeated overlap suppressions are the
signature of someone walking a cohort one member at a time: after a small number
of them, further peer answers are refused outright with 429 and the operator
is alerted.
Cohort sizes are published as bands (5-9, 10-24, …), never exact counts —
an exact count that ticks by one tells a watcher that one specific participant
joined.
What is NOT implemented, and not claimed
Sable Private Benchmark does not use multi-party computation, homomorphic
encryption, or differential privacy. No noise is added to any output and no
privacy budget is spent or tracked. The four controls above are what is running,
and GET /v1/benchmarks/cohorts returns both lists so you can check the claim
against the deployment rather than against this page.
The privacy ladder
| Tier | Behaviour |
|---|---|
standard (default) | The normal contract above: sealed at rest, opened in-frame, purged on your retention policy |
aggregate | As standard, plus retention is forced to ephemeral — only the metric block leaves, and nothing is kept |
confidential | Refused with 501. Benchmark arithmetic runs in the gateway process, not in an attested enclave. Sable's Intel TDX path serves inference models only. The request is refused rather than silently downgraded, because a result computed outside an enclave must never carry a confidential label |
Metrics, and what each one means
Money is integer micro-USD end to end. Ratios are reported as integer basis
points (bps, 1/100th of a percent) or as thousandths (_x1000), and every
division rounds half away from zero.
| Field | Definition |
|---|---|
total_return_bps | (last − first) / first over the submitted series |
annualized_return_bps | Geometric: (1 + total)^(periods_per_year / periods) − 1. Omitted when the cadence is irregular |
volatility_bps | Sample standard deviation (n−1) of simple periodic returns, scaled by sqrt(periods_per_year). Requires 8 observations |
max_drawdown_bps | Largest peak-to-trough decline. Always ≥ 0 |
sharpe_like_x1000 | (annualised return − your declared risk-free rate) / annualised volatility. Sharpe-LIKE, not Sharpe: the risk-free rate is whatever you declared, the excess is arithmetic, and the denominator is the sample stdev of simple returns |
concentration_hhi | Herfindahl–Hirschman index on the 0..10 000 scale. 10 000 is a single position; 10 000/n is n equal positions |
top1_weight_bps, top5_weight_bps | Combined weight of the largest 1 and 5 constituents |
asset_class_exposure_bps | Exposure by class, summing to exactly 10 000. Weights are floored and the rounding remainder is assigned to the largest constituent |
excess_return_bps, tracking_error_bps, beta_x1000 | Against a reference series you supply. reference_source reads participant-supplied; Sable does not operate a market-data feed and never presents a reference as an index it maintains |
A metric that cannot be computed from what you submitted is null and the
reason appears in integrity.limitations. It is never a substituted zero.
Observation cadence (daily / weekly / monthly / quarterly / annual) is inferred
from the median gap between dates. A series whose largest gap exceeds three
times the median — or whose dates are unsorted — is irregular: volatility is
reported un-annualised and labelled, rather than annualised as if the hole
were not there.
Integrity envelope
Every result carries what a reader needs in order to know how much to trust it:
methodology_version, benchmark_version, calculated_at, the input period,
the inferred cadence, input freshness (fresh, with age and limit),
included_assets and excluded_assets, a list of limitations, and the
privacy_controls in force.
Methodology is versioned and history is never rewritten. A result computed
under bm-1.0.0 still reads as bm-1.0.0 after a newer methodology ships;
POST /v1/benchmarks/:id/refresh writes a new version rather than restating an
old one. GET /v1/benchmarks/:id/history returns every version with the
methodology each was computed under, alongside the current one.
A submission whose last observation is older than 35 days is reported STALE
and fires benchmark.data_stale. The figures still compute — they are labelled,
not extrapolated and not silently used.
Signing and metering
Each calculation writes one metered usage_events row (kind: benchmark_run, unit: calculations) and takes a pre-authorisation hold, so
spend caps, spend windows, the circuit breaker and per-key rate limits all bind
exactly as they do on inference.
The result is signed with the same secp256k1 / EIP-191 key every Sable receipt uses and verifies through the public endpoint:
curl -X POST https://api.buildsable.com/v1/receipts/verify \
-H 'content-type: application/json' \
-d '{"receipt":"<receipt>","signature":"<signature>"}'
The signed payload carries your own metrics as well as the bucketed peer
block, because a benchmark result nobody can verify is not a product. That means
sharing the receipt publicly (POST /v1/receipts/:id/share) discloses those
figures — by your choice, not by default.
API
All five endpoints are API-key authed (Authorization: Bearer sk-sable_…).
POST /v1/benchmarks/portfolio
GET /v1/benchmarks/cohorts
GET /v1/benchmarks/:benchmarkId
GET /v1/benchmarks/:benchmarkId/history
POST /v1/benchmarks/:benchmarkId/refresh
Submit a portfolio
curl -X POST https://api.buildsable.com/v1/benchmarks/portfolio \
-H "authorization: Bearer $SABLE_KEY" \
-H 'content-type: application/json' \
-d '{
"label": "Treasury Q2",
"cohort_key": "digital-asset-treasury",
"retention": "period",
"risk_free_bps": 400,
"holdings": [
{"symbol":"BTC","asset_class":"crypto","value_micro_usd":6000000000},
{"symbol":"USDC","asset_class":"cash","value_micro_usd":4000000000}
],
"series": [
{"date":"2026-01-31","value_micro_usd":9000000000},
{"date":"2026-02-28","value_micro_usd":9400000000}
]
}'
Optional fields: benchmark_series (your reference series), filters (a flat
map of cohort attributes, canonicalised to lowercase), privacy_tier,
contribute_to_peers (default true), and sable_run_id to chain the result
receipt into an agent run.
A suppressed peer block
{
"peer": {
"suppressed": true,
"suppression_reason": "cohort_below_minimum: fewer than 5 other participants match this cohort and filter set, so no peer figure is released. It is suppressed, not approximated.",
"cohort_key": "digital-asset-treasury",
"cohort_size_band": "<5",
"min_cohort_size": 5,
"return_percentile_bucket": null,
"peer_median_return_bps": null
}
}
Every peer figure is null. Treat that as an answer, not as an error — and do
not render it as a zero or a dash without the reason beside it.
A released peer block
{
"peer": {
"suppressed": false,
"cohort_size_band": "5-9",
"bucket_width_percentile": 5,
"bucket_width_bps": 50,
"return_percentile_bucket": 60,
"peer_p25_return_bps": 1150,
"peer_median_return_bps": 1500,
"peer_p75_return_bps": 2200,
"excluded_stale_peers": 0
}
}
return_percentile_bucket: 60 means "between the 60th and 65th percentile of
the cohort". Every basis-point figure is a multiple of 50.
Webhooks
Subscribe on the webhooks page. All four are content-free: ids, the cohort key, a banded cohort size, the methodology version, and whether the peer block was suppressed — never a metric, never a peer, never a member.
| Event | Fires when |
|---|---|
benchmark.completed | A submitted portfolio has been benchmarked |
benchmark.updated | A refresh produced a new result version |
benchmark.data_stale | The submission's last observation is past the freshness limit |
benchmark.cohort_changed | A portfolio left a cohort — including because its retention expired |
What Sable checks, and what it does not
Sable checks the arithmetic. It does not check the book. Every figure is
computed from what you submitted; the freshness status of a self-declared
portfolio is PARTIALLY_VERIFIED at best, and STALE or UNVERIFIED when the
dates say so. Sable does not value your positions, confirm that you hold them,
or price them against a market feed.
A percentile is a position, not a judgement. It says where your submitted figures sit among the submitted figures of other participants in the cohort you chose. It does not say whether the cohort is the right comparison, whether the other participants measured the same way, or whether any of it was a good idea.
Suppression is not a data problem. When Sable withholds a peer figure it is because releasing it would identify someone. That is the product working, and it does not become available by asking differently — asking differently is exactly what the overlap control exists to refuse.