I built SaleCast's forecasting engine - 3 problems nobody anticipates
Production forecasting is not 'train a model and read its predictions'. Three problems surface: no single model fits every product, a good model starts lying silently, and the right price is never in your gut. Here is how I solved them in .NET.
Related projectSaleCast - ML forecasting for multi-channel commerceAsk anyone what forecasting is and you'll get: "you train a model on history, and it predicts what comes next." That's true in a classroom. In production, it's where the trouble starts.
I built the forecasting engine of SaleCast, and I ran into the three problems that summary glosses over. None is a pure data-science problem — they're engineering problems, the ones that decide whether a model is actually useful once it's plugged into a real catalog. Here they are.
In short
Three problems "textbook" forecasting ignores, and their answer in the SaleCast engine: (1) no single algorithm predicts every product well → 12 algorithms compete per product; (2) a model that goes bad raises no error → a drift detector watches its residuals; (3) the right price isn't an intuition → an elasticity regression measures it, always with its uncertainty. What it shows: ML in production in .NET (automatic selection, observability, applied econometrics), not a model trained once.
The context (and the honesty that goes with it)
SaleCast is a multi-channel e-commerce SaaS built for a direct client (status: private, under development). It's a co-developed product: my contribution covers the data connectors (the ingestion that pulls each channel's sales) and the forecasting engine; a second developer built the rest of the platform — source reconciliation, hosts, UI.
The engine has no confirmed client results in production yet. Every number in this article is illustrative, computed on test datasets — not real client gains. I say it once; it holds for every output shown below.
To make it concrete, we follow a fictional-but-credible shop throughout — Atelier Nord: ~5,000 SKUs sold across 8 channels (its Shopify store, Amazon, eBay, Google Shopping…). The throughline: wire the channels → get a clean series per product → derive per-product decisions.
The invisible work: from raw data to a clean series
Before forecasting anything, you need clean data. That's the first half of my
scope: the connectors. Each channel exposes its sales differently (paginated
REST, webhooks, CSV exports, quotas, divergent date and currency formats). A
connector has one contract: turn that chaos into normalized sale events,
then aggregate them into one daily series per product — days with no sale
becoming explicit 0s, not gaps.
That explicit-zeros detail isn't cosmetic: it's what lets you, right after, distinguish a "sold little but regularly" product from a "sold in bursts" one. And as we'll see, everything downstream depends on it. Forecast quality is decided here, in the unglamorous ingestion work — long before any model.
Problem 1 — No single model works
Take three typical products from Atelier Nord's catalog:
- Marine T-shirt XL black: sold every day, smooth curve, summer/winter seasonality.
- LED string lights, 50 bulbs: 90% of sales in November-December, the rest at zero.
- USB-A wall adapter: slow decline, 2 sales a week, some weeks at zero.
Three radically different curves. No single algorithm forecasts all of them correctly. Holt-Winters is good for the T-shirt and catastrophic for the string lights (the zeros break it). Croston is designed for intermittent decline and ridiculous on seasonal data. LightGBM is powerful with hundreds of days of history, useless on 30 raw days.
The industrial answer fits in one sentence: classify each product by demand pattern, have the relevant candidates compete, keep the best — automatically.
Classify before choosing
Before the competition, we compute two very simple statistics on the series: the average interval between two sales (ADI) and the variability of sold quantities (CV², measured on non-zero days so the zeros don't skew it). The cross of the two gives the Syntetos-Boylan classification — four families: smooth, erratic, intermittent, lumpy. Each family steers toward different candidates.
Compete — without competing everything
In total, 12 candidates: 8 statistical methods coded in C# (Naive, SES, Theta, Croston, TSB, Holt-Winters, Seasonal-Naive, MSTL), LightGBM via ML.NET, two time-series foundation models (TiRex and Chronos), and an Ensemble that combines the best.
What makes the competition possible is that they all honour the same contract. The engine knows nothing about the "how" of an algorithm: it just has them forecast the same series and compares.
public interface IForecastAlgorithm
{
// Is this algo applicable to this series? (Holt-Winters needs seasonal
// history; Croston wants intermittency…)
bool CanHandle(ProductSalesHistory history, ForecastProfile profile);
// Produces a forecast over the requested horizon + its intervals.
ForecastResult Forecast(ProductSalesHistory history, ForecastRequest request);
}
Running 12 algos at full depth on thousands of products would cost a fortune in compute. The fix has two stages: we pre-select by pattern first (an intermittent product doesn't send Holt-Winters into the race), then run a fast screening on a few points for everyone, and only pay the full back-test on the 3 finalists. Per the module's internal measurements, ~60% fewer evaluations, with no loss in selection quality.
The idea to keep
You don't ask a human "Holt-Winters or Croston for this product?" across thousands of SKUs — it makes no sense. You classify, screen fast, and only pay the full back-test on the finalists. Per-product competition becomes tractable at scale.
The .NET ↔ Python boundary, owned
A detail that sums up how I work: classical ML stays 100% .NET — the 8
statistics in C#, LightGBM via Microsoft.ML.LightGbm, and even the TiRex
foundation model (xLSTM architecture, ~35M parameters) runs in .NET via an
ONNX export and OnnxRuntime, with no Python runtime. Only Chronos (Amazon),
which has no clean ONNX export, goes through a small Python HTTP sidecar —
and if it's unavailable, the client simply returns null: the competition
continues with the other 11. No product is left without a forecast because a
sidecar crashed.
All-Python is fragile to operate in production; all-.NET is poor on recent foundation models. The mix of pure .NET + a single HTTP sidecar for the strict minimum is the trade-off I chose.
In the end, each product has a traced decision: which algo won, with what
score, by real back-test or not. For Atelier Nord's 3 products, that gives
HoltWinters for the seasonal T-shirt, Ensemble for the extremely
intermittent string lights, Croston for the declining adapter. If a product
wins with the Naive baseline, that's a warning signal (series too short,
corrupted data), not a success.
Problem 2 — A good model starts lying, silently
The model is chosen, it runs, all is well. For weeks. Then the market shifts — a supply shortage, a trend taking off, a competitor slashing prices — and the model, trained on the old world, gets it more and more wrong. It doesn't crash. It raises no error. The numbers look plausible. And the restock computed from its forecasts becomes wrong, without anyone knowing when the model came loose.
A server that crashes, you see in 30 seconds. A model that lies, nothing flags it. It's the quietest bug in production ML — and the one the drift detector solves.
The trick: we don't watch the model, we watch its residuals — the gap between predicted and actual. As long as they oscillate around zero, the model fits. If they start drifting durably to one side, it systematically over- or under-estimates. Two classic tests run in parallel on that stream and fire if either signals drift:
var driftDetected = pageHinkley.Detected || adwinLike.Detected;
var severity = ComputeSeverity(pageHinkley, adwinLike); // High if both
Page-Hinkley (1954, a 70-year-old algorithm that fits in a single pass) catches slow, continuous drifts. A second test, inspired by ADWIN, cuts the residual series into two windows (older 60% / recent 40%) and compares their error: if the recent window is clearly worse, drift. It catches sharp degradations. Each covers what the other misses.
When drift is detected, the product automatically re-enters the full competition (the 12 candidates). If another algorithm beats the old one, it takes its place; otherwise we keep it, but note the instability. No human in the loop.
Implementation honesty
My second test is inspired by ADWIN, it's not the full paper implementation (no adaptive window or formal statistical guarantee) — just a robust, cheap window comparison. Saying so avoids overselling, and it's more useful: a simple detector that runs beats a sophisticated one that only exists on paper.
The lesson: a predictive system that never says "I'm lost" is dangerous. And detected drift isn't panic — it's an observability signal that says "go check this category." Most of the time it's a promo; sometimes a real market shift. Useful in both cases.
Problem 3 — The right price isn't in the intuition
Atelier Nord sells a Gift Box at €30, ~220 a month. The merchant's question: "if I move to €35, how many do I lose?"
The naive calculation — €35 × 220 — assumes quantity doesn't move. Wrong: if the increase drops sales to 150, revenue falls. The whole question is: by how much will quantity recede? The answer isn't in the merchant's head. It's in their sales history — and it can be measured.
The tool fits in one line of microeconomics: in a log-log model, the slope
of the regression of log(quantity) on log(price) is the elasticity
directly. A slope of -1.8 means a 10% price increase pushes demand down ~18%
(elastic product, to avoid); a slope of -0.4, only ~4% (inelastic, increase
probably profitable). The model fits in a few lines of C#, no neural network or
GPU.
The value isn't in the model — it's in the guardrails. Three traps distort everything, and the code refuses to answer rather than lie:
- Not enough history → we return "can't estimate," not a made-up number.
- The price never moved → nothing to measure, we abstain.
- The shop is gaining notoriety (sales AND price rise over time, with no causal link) → we first remove the time trend (detrending) before measuring the price effect.
And above all: we never show elasticity alone. Always with its R², its
t-stat and the number of observations. A β = -1.4 fitted on 6 points with an
R² of 0.12 is noise disguised as signal.
The sentence to NEVER say to a client
"You can raise by 10%, you'll lose 5% of sales." It sounds professional, it's false by construction: it presents an uncertain estimate as a certainty. The right version: "On this product, the estimated elasticity is -0.41 (R² = 0.18, over 284 days). Moderate signal: +10% price ≈ -4% quantity, as long as nothing else changes. I suggest testing for 30 days, then re-measuring." The number is backed, the uncertainty is named, a plan B is set.
What these three problems have in common
None of the three is a model problem. The best forecasting algorithm is useless if it's chosen by hand product by product, if it drifts without being seen, or if it spits out a number without saying how sure it is. What turns a POC into a system that holds in production is:
- Automating the choice — classify, compete, keep the best, trace it.
- Observability — watch the residuals, detect drift, realign on its own.
- Honesty about uncertainty — a number is only worth its confidence interval.
And a quiet throughline: all of it fits in .NET, with a single Python sidecar for the strict minimum. ML.NET + ONNX Runtime cover the essentials; the .NET ML ecosystem is production-viable, provided you own a clean boundary when you have to leave the language.
That's exactly the kind of system I like to build: ML that's accountable, not a black box.
Stack & code
- Forecasting solution = sub-projects
Core,Api,CLI,Desktop(Photino host),Web,Web.Server - 8 statistical methods in C# + LightGBM (
Microsoft.ML.LightGbm) + 2 foundation models (TiRex in ONNX Runtime, Chronos in a Python HTTP sidecar)- softmax Ensemble
- Selection:
DemandClassifier(SBC) →ModelRouter→AlgorithmCompetition(screening + full back-test) - Drift:
DriftDetector— Page-Hinkley + ADWIN-inspired detector, pure C#, no external dependency - Pricing:
PriceElasticityEstimator— log-log regression + detrending + t-stat, in-house OLS, pure C# - ML.NET 5.0.0 + Microsoft.ML.OnnxRuntime 1.24 · Hangfire (background jobs) · Blazor + LumexUI (visualization studio)
For the full product scope (multi-channel reconciliation, connectors, hosts), see the SaleCast project page.
Next step
Sounds like something you need shipped? Let's talk.
I take on critical technical work — from scoping to production, no debt or lock-in once it's handed over. Fastest way to see if it fits: a 30-minute call.
I reply within 24h — often sooner.