DpExchange.Core.Notice (DpExchangeCore v0.1.8)

Copy Markdown View Source

What a venue package says about itself — distinct from what a venue says about the market, and never carrying market data.

A consumer subscribes; the package never holds a consumer's function. That inversion is the whole design: a package that was handed a sink would be deciding what happens next, which is the consumer's decision, and would be holding a reference into an application it must know nothing about.

ChannelSubjectExample
the data streamwhat the venue says about the market%Types.Quote{}
noticeswhat the package says about itself%Notice{kind: :credentials_rejected}

A consumer may want the second without the first. A monitoring process that never touches market data still needs to know a venue's credentials expired.

Telemetry is measurement; a notice is a condition

The two overlap enough that the line has to be stated rather than discovered. Telemetry is what you aggregate. A notice is what you act on. A request duration is a metric. "Your API key was rejected" is not, and it must not be delivered by a mechanism whose handlers run inside the emitting process and whose delivery is legitimately lossy.

A notice is a prompt to re-read, never the record

Delivery is not guaranteed and a consumer's correctness must not depend on it. This is not a caveat, it is the contract: reporting on the work must never become the reason the work does not happen, so a notice that cannot be delivered is dropped rather than retried or blocked on.

The failure this warns against has already happened once, in the application these packages came from. Three cached copies of a symbol's status were kept in step with fire-and-forget casts — and a cast to a dead or restarting process returns :ok and is silently dropped. Two symbols suspended at 03:14 and 03:27 UTC opened fresh positions at 21:46, because the message that would have stopped them had vanished with a process nobody noticed had restarted.

So: a :catalog_change notice is a reason to re-read list_instruments/1, not the authority that a pair was delisted. A :coverage_change is a reason to call coverage/1. Treat a notice as a nudge and the dropped-message case degrades to latency; treat it as the record and the dropped-message case is silent, wrong state.

It never carries credentials

Not the key, not a fragment of it, not a redacted form. These packages are public and an operational notice is exactly the sort of thing that gets pasted into an issue. A credential notice names which credential failed and how — never its value.

The kinds

:link_up, :link_down, :link_reconnecting, :link_recovered — stated without naming the transport. "The venue link is down" is the fact; whether that link is a WebSocket, an MQTT session or a polling loop is package-internal and not a consumer's concern.

Credentials

:credentials_rejected, :credentials_expiring, :session_refresh_failed. Close to load-bearing: a consumer that cannot learn its keys stopped working finds out from the absence of data, which is the slowest possible signal.

Pressure

:rate_limited — sustained limiting, or a venue returning 429 past the point where retry is working. A single 429 is a metric and belongs in telemetry; a venue that will not stop returning them is a condition.

Coverage

:coverage_change — what makes coverage/1 pushable rather than pollable. Drawn directly from an incident: 325 symbols subscribed and confirmed, 174 actually delivering. A drop from 325 to 174 is an event, not a number to be discovered by asking.

Catalog

:catalog_change — a pair added, removed, or moving :tradable:delisted. The one kind that originates at the venue rather than in the package, and it belongs here rather than on the data stream because it is not market data: a price is what the market says, a delisting is a change in the venue's own shape. A consumer holding a pair needs to know it stopped trading even if it never subscribed to a single quote. It also makes the catalogue pushable rather than diffable, which matters most where diffing is worst — a millions-of-instruments venue cannot be re-pulled on a timer to spot one delisting.

Two honesty constraints. Most venues do not announce a delisting; the pair simply stops appearing, so the package learns it by diffing internally and must say soobserved: true rather than announced. And a vanished pair is not evidence of a delisting: DpExchange.Core.Instrument resolves unrecognised input to :unknown, never to :tradable, and the two must not be conflated.

Refusal and quality

:refusal — a symbol the venue will not carry. :data_quality — a payload that did not parse.

Degradation

:degraded — the venue is answering, but from a slower path than usual.

Summary

Types

What the notice is about.

How much a consumer should care. Not a log level — a call to action.

t()

Functions

Every kind this contract admits.

Types

kind()

@type kind() ::
  :link_up
  | :link_down
  | :link_reconnecting
  | :link_recovered
  | :credentials_rejected
  | :credentials_expiring
  | :session_refresh_failed
  | :rate_limited
  | :coverage_change
  | :catalog_change
  | :refusal
  | :data_quality
  | :degraded

What the notice is about.

Deliberately a closed set. An open one would let each venue package invent its own vocabulary, which is the drift a shared contract exists to prevent — a consumer would be back to matching on venue identity to know what a notice meant.

severity()

@type severity() :: :info | :warning | :error

How much a consumer should care. Not a log level — a call to action.

t()

@type t() :: %DpExchange.Core.Notice{
  at: DateTime.t(),
  details: map(),
  kind: kind(),
  message: String.t() | nil,
  provider: atom() | String.t(),
  severity: severity()
}

Functions

kinds()

@spec kinds() :: [kind()]

Every kind this contract admits.

A venue package must not invent its own: a consumer matching on kinds would be back to matching on venue identity to know what one meant.

new(kind, provider, opts \\ [])

@spec new(kind(), atom() | String.t(), keyword()) :: t()

Builds a notice.

:at defaults to now, which for a notice is correct: unlike market data there is no venue event time to preserve — the package is reporting on itself, and the moment it noticed is the fact.

Raises

On an unknown kind, because a typo would otherwise become a notice no consumer matches on and no one ever sees. And on any credential-shaped key in details: these packages are public, notices get pasted into issues, and a leak is not fixed by a later release.

Examples

iex> notice = DpExchange.Core.Notice.new(:link_down, :some_venue, severity: :warning)
iex> {notice.kind, notice.severity}
{:link_down, :warning}

iex> DpExchange.Core.Notice.new(:not_a_real_kind, :some_venue)
** (ArgumentError) unknown notice kind :not_a_real_kind

iex> DpExchange.Core.Notice.new(:link_down, :v, details: %{api_key: "sk-live-123"})
** (ArgumentError) notice details carry credential-shaped keys: [:api_key]. A notice names WHICH credential failed, never its value — these packages are public and notices get pasted into issues