A streaming server that turns a synthetic tick feed into OHLCV bars, recomputes seven TaLibStandard indicators — SMA fast, SMA slow, EMA, RSI, ATR, MACD and Bollinger Bands, eleven output series in all — on every closed bar, and pushes the results to browsers and .NET clients over both transports the issue asks for: raw WebSocket and SignalR.
Everything runs offline. There is no market data provider, no API key, and no network call — the price path comes from a seeded random walk, so the same seed always produces the same prices.
ticks ──► BarAggregator ──► RollingIndicatorEngine ──► IndicatorSnapshot ──┬──► SignalR /hubs/indicators
(feed) 1s..Ns bars window recompute nullable values └──► WebSocket /ws/indicators
# server, with the default 5-second bars
dotnet run --project samples/TechnicalAnalysis.Samples.RealTime
# then open http://localhost:5199The port comes from "Urls" in appsettings.json rather than a launch profile, because
Properties/launchSettings.json is gitignored repo-wide and would not survive a clone. Override it with
--urls http://127.0.0.1:8080 like any other host setting.
Five-second bars take about three minutes before every indicator is warm. For a quicker look, shorten the bar period:
dotnet run --project samples/TechnicalAnalysis.Samples.RealTime -- \
--urls http://127.0.0.1:5199 \
--RealTime:BarSeconds=1 \
--RealTime:TickIntervalMilliseconds=100The console client lives in the sibling project:
# push transport (SignalR groups), default symbol
dotnet run --project samples/TechnicalAnalysis.Samples.RealTime.Client
# a specific symbol, the hub's streaming method, and a time limit
dotnet run --project samples/TechnicalAnalysis.Samples.RealTime.Client -- \
http://127.0.0.1:5199 --symbol GLOBEX --stream --seconds 30| Endpoint | What it does |
|---|---|
GET / |
The live dashboard. One file, no CDN, no npm, no build step. |
GET /health |
Uptime, symbols, ticks published, bars closed, snapshotSubscribers (channel subscribers: raw WebSocket and hub streaming), groupSubscribers (SignalR push subscriptions) and pushesDropped. |
GET /api/symbols |
The symbols this server publishes. |
WS /ws/indicators?symbol=ACME |
Raw socket. Omit symbol for the first configured one. |
/hubs/indicators |
SignalR hub. |
| Method | Kind | Notes |
|---|---|---|
GetSymbols() |
invoke | Returns the symbol list. |
Subscribe(symbol) |
invoke | Joins the symbol's group; the latest snapshot is replayed immediately. |
Unsubscribe(symbol) |
invoke | Leaves the group. |
StreamSnapshots(symbol) |
stream | IAsyncEnumerable<IndicatorSnapshot>, honours the caller's cancellation token. |
snapshot |
client method | Invoked by the server with each closed bar's snapshot. |
Group membership is per connection, so a client that reconnects must call Subscribe again — the
console client shows how.
One session frame on connect, then one snapshot frame per closed bar. Real frames captured from a
running server, wrapped for width and with the always-null sibling properties elided:
{"type":"session","session":{"symbol":"ACME","symbols":["ACME","GLOBEX","INITECH"],"barSeconds":1,
"tickIntervalMilliseconds":100,"windowSize":256,"periods":{"smaFast":10,"smaSlow":30,"ema":20,"rsi":14,
"macdFast":12,"macdSlow":26,"macdSignal":9,"bollinger":20,"bollingerDeviations":2,"atr":14}}}
{"type":"snapshot","snapshot":{"symbol":"ACME","timestamp":"2026-07-27T12:18:36+00:00","close":343.65,
"sequence":308,"smaFast":343.964,"smaSlow":347.01933333333307,"ema":345.0055987766077,
"rsi":43.62551333468498,"macd":-1.3233998227737516,"macdSignal":-1.127486559231894,
"macdHistogram":-0.19591326354185767,"bollingerUpper":348.25611962208296,
"bollingerMiddle":344.9424999999999,"bollingerLower":341.6288803779168,"atr":4.706082652336289E+267,
"signal":"Bearish","barsInWindow":256,"barsRequired":34,"isWarmedUp":true}}That atr is not a typo — see Known library defects.
An unknown symbol gets an error frame followed by a PolicyViolation close.
RollingIndicatorEngine is where a streaming integration usually goes wrong, and the whole sample
exists to get it right.
TA-Lib is a batch API. TAMath.Sma(0, lastIndex, close, 30) allocates an output array of
lastIndex + 1 elements and fills it from index zero — the output is not parallel to the input.
The mapping is:
output[k] describes input index (BegIdx + k), for k in [0, NBElement)
Everything from NBElement onwards is an uninitialised zero that means nothing. So the newest value is
output[NBElement - 1], never output[lastIndex]. Reading output[lastIndex] with a 256-bar window and
a 30-bar lookback lands 29 elements past the real data and returns a zero dressed up as a price — and
because zero is a plausible-looking number, nothing downstream notices.
The engine asserts the mapping instead of assuming it: it computes BegIdx + NBElement - 1 and returns
the value only if that really is the newest bar's index. Otherwise it returns null.
Null means null. An indicator that has not warmed up is reported as null, never as 0 and never as
the previous bar's value. Both of those substitutions are indistinguishable from a real reading once the
number leaves the process. The dashboard renders a null as a dashed rule with a warm-up hairline, so you
can watch each indicator switch on.
The engine keeps a fixed-size ring buffer of the last N closed bars and recomputes every indicator over that window on each new bar. The alternative — keeping each indicator's internal state and advancing it by one bar — is O(1) instead of O(window), and it is what a production feed handler does at scale. It also means reimplementing the exact arithmetic of every indicator you use and keeping it in step with the library forever; the day your EMA seeding differs by an epsilon, your live values and your backtest values disagree and the difference is very hard to find.
Recomputing costs a few microseconds per bar and buys you the guarantee that the number a client sees came from the same library, on the same code path, as the number a backtest sees.
Window size. With the defaults the largest lookback is MACD(12,26,9) at 34 bars, exposed as
barsRequired. The window defaults to 256, roughly seven times that, and the margin is deliberate:
exponentially smoothed indicators have no exact finite lookback. TA-Lib seeds an EMA with a simple
average and then decays it, so the seed's residual weight falls by (1 - 2/(n+1)) per bar. At 256 bars an
EMA(20) retains about 4e-11 of its seed — invisible. A 40-bar window would produce plausible values that
quietly drift from the batch answer. Size the window from the slowest smoothed indicator's decay, not
from its nominal period.
Every subscriber — each browser tab, each console client, each hub stream — gets its own bounded channel
created with BoundedChannelFullMode.DropOldest. A consumer that stops reading loses its oldest queued
frames; it never stalls the producer.
That is the right policy for a market feed and the wrong one for an order feed. A price that is 40 bars
late is worth nothing: a slow consumer wants the current bar, not a replay of the last forty. Wait
would let the slowest subscriber throttle every other subscriber, which is how one wedged client takes
down a whole feed; an unbounded channel trades that stall for unbounded memory. Dropping is not silent —
sequence increments by one per bar, so a client can see the gap, and the console client counts them.
Anything that must not be lost belongs on a different, acknowledged channel.
All of it binds from the RealTime section of appsettings.json and can be overridden on the command
line (--RealTime:BarSeconds=1) or by environment variable (RealTime__BarSeconds=1).
| Key | Default | Meaning |
|---|---|---|
Symbols |
ACME, GLOBEX, INITECH |
Instruments to publish. The first is the page's default. |
TickIntervalMilliseconds |
250 |
Time between synthetic ticks. |
BarSeconds |
5 |
Bar period. Bars align to absolute time, not to server start. |
RandomSeed |
20240613 |
Seeds the price walk. Same seed, same prices. |
WindowSize |
256 |
Bars kept per symbol. Must exceed the largest lookback. |
SubscriberQueueCapacity |
64 |
Frames a subscriber may buffer before the oldest is dropped. |
Volatility |
0.0015 |
Per-tick log-return standard deviation. |
MeanReversion |
0.0025 |
Pull back towards each symbol's base price, per tick. |
Indicators:* |
see file | Periods for SMA fast/slow, EMA, RSI, MACD, Bollinger, ATR. |
Options are validated at startup with ValidateOnStart, so a period below 2 — which TAMath rejects
with RetCode.BadParam and which would therefore null an indicator forever — fails the boot instead.
Symbols deliberately has no code-side default. The configuration binder appends to a collection that
already holds items, so three defaults plus three in appsettings.json bind to six duplicated symbols.
Startup validation rejects an empty list, which turns a missing configuration file into a clear error.
The seed fixes the price path exactly. It does not fix bar contents: tick timestamps come from the real clock, so which ticks land in which bar depends on timer jitter and machine load. Drive the aggregator from a virtual clock if you need bars reproducible to the cent.
Two bugs in src/TechnicalAnalysis.Functions are visible in this sample's output. They are library
issues, not sample issues, and the sample deliberately calls the library the recommended way rather than
working around them. The same list, with the full blast radius, is in
Known library defects.
-
ATRdiverges.Atr/TAFunc.csmultiplies the running average byperiod - 1and adds the new true range, but only divides the stored value byperiod— the accumulator itself is never normalised, so it grows by a factor ofperiod - 1every bar. On a constant series with true range 2.0 andperiod14, ATR reads2, 2, 26.14, 340, 4420, …and reaches 1e27 within 40 bars. Only the first two output elements are correct. Until it is fixed, ignore the ATR column. -
EMAseeds itself low.TAFunc.cs'sTA_INT_EMAseed loop sumsperiod - 1values and then divides byperiod, so the seed is the true average scaled by(period - 1) / period. On a constant series of 100, EMA(20) starts at 95.48 instead of 100. The error decays with the smoothing factor, so a long window hides it in steady state — but it distorts every value for the first few dozen bars, and it propagates to MACD, DEMA, TEMA, T3, APO, PPO and TRIX. MACD over a constant series returns 0.53 where it should return 0.
A third, milder one: Rsi/TAFunc.cs divides by prevGain + prevLoss without the zero guard TA-Lib has,
so a perfectly flat series yields NaN rather than 0. The engine's double.IsFinite check converts
that to null, which is why the dashboard shows a dash rather than NaN.
| Path | Role |
|---|---|
Contracts/ |
Wire types plus the JsonSerializerContext used by all three exits. |
Configuration/RealTimeOptions.cs |
Everything tunable, bound via IOptions. |
Streaming/SyntheticMarketDataFeed.cs |
Seeded tick generator, BackgroundService, fans out to N subscribers. |
Streaming/BarAggregator.cs |
Ticks to OHLCV bars on absolute time boundaries. |
Streaming/BoundedFanout.cs |
The drop-oldest fanout and its race-free subscription handle. |
Streaming/SnapshotBroadcaster.cs |
Per-symbol distribution plus last-snapshot replay. |
Streaming/IndicatorPipeline.cs |
The single loop: tick in, bar out, snapshot out, both transports. The SignalR group send runs on its own bounded pump so a wedged subscriber cannot stall the loop. |
Indicators/RollingIndicatorEngine.cs |
Ring buffer, window recompute, alignment, warm-up nulls. |
Hubs/IndicatorHub.cs |
SignalR push and streaming. |
WebSockets/IndicatorWebSocketHandler.cs |
Raw socket, close handshake, cancellation. |
wwwroot/index.html |
The dashboard. Vanilla JS and canvas, zero dependencies. |