# `DpExchange.Core.Types.Candle`
[🔗](https://github.com/DistortionPoint/dp-exchange-core/blob/v0.3.76/lib/dp_exchange/core/types/candle.ex#L1)

One OHLC bar over an interval.

## Why this type exists

`get_historical_prices/4` declared `[Types.Quote.t()]` and the venues returned bare maps
with their own keys. Both are wrong in the same direction: **a bar is not a quote.** A
quote is one price at an instant; a bar is four prices and a volume over a span. Flattening
a bar into a quote either discards the open, high and low, or keeps the close and calls it
"the price" — a plausible number with the wrong meaning, which is this family's recurring
defect.

The untyped map was the more immediate problem: with no type, each venue package chose its
own keys and nothing compared them. A consumer switching venues got a different map and no
error.

## `:opened_at`, not `:timestamp`

**Venues disagree about whether a bar is stamped at its open or its close**, and the
difference is one whole interval. A daily series stamped at the close and joined to one
stamped at the open is misaligned by a day, and every value in both is correct — which is
why nothing catches it.

So the field is named for what it holds. A venue that publishes close-stamped bars
subtracts the interval on the way in; a venue whose convention is unclear is a venue whose
candles this package should not be shipping.

## `:volume` is `nil` when the venue publishes none

Never `0`. One venue in this family reports no crypto volume anywhere, and a `0` there
would claim a genuinely flat interval — a much stronger statement than "not reported", and
one a consumer might act on.

# `t`

```elixir
@type t() :: %DpExchange.Core.Types.Candle{
  close: Decimal.t(),
  high: Decimal.t(),
  low: Decimal.t(),
  open: Decimal.t(),
  opened_at: DateTime.t(),
  provider: atom(),
  symbol: String.t(),
  timeframe: String.t(),
  volume: Decimal.t() | nil
}
```

# `coherent?`

```elixir
@spec coherent?(t()) :: boolean()
```

Whether the bar's own values are internally consistent — high is the highest, low the
lowest.

A bar failing this is malformed at the venue, and worth catching at the boundary rather
than discovering downstream: a high below the close will silently corrupt any range,
breakout or volatility calculation built on the series, and none of those will error.

# `new`

```elixir
@spec new(keyword() | map()) :: t()
```

Builds a `t:t/0`, failing closed if a required field is absent or `nil`.

`@enforce_keys` guards presence, not `nil` — a `%__MODULE__{open: nil, ...}` literal
builds fine despite `open`'s typespec forbidding it, and blows up later inside `Decimal`
instead of at the boundary. This is the exact defect that motivated
`DpExchange.Core.Types.Validate`, written up there in full.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
