Most personal-finance and trading apps make one quiet assumption: you always have a fast, stable connection to their servers. If you don't, you get a spinner, a blank screen, or worse, a stale number presented as if it were live.
I'm building Vicquant, a financial advisor and quant-analytics app that flips that assumption. The architecture offline design to runs the local AI model on everyone device for budgeting and debt planning, no internet required and layers in live market data, quant signals, and backtesting when you do have a connection. This article covers the engineering behind making that hybrid model actually reliable, not just "works most of the time."
The core problem
Once you decide an app must work offline and online, you inherit a new category of bugs that a purely cloud app never has to think about:
What happens when the network drops mid-request?
What happens when a free-tier data API rate-limits you?
What happens when a provider is down for an hour?
How do you show data from an hour ago without pretending it's from now?
None of these are exotic problems. They're just usually ignored, because "assume the internet works" is the default.
The first Design Decision: Python computes, the AI explains
Small local language models are surprisingly capable at conversation, but they are unreliable at arithmetic off-by-one errors, dropped decimal places, confidently wrong totals. For a finance app, a wrong number is worse than no number.
So every calculation needs a debt payoff schedules, savings projections, risk metrics and it runs in deterministic Python code first. The model's only job is to explain the result in plain language. It never generates a number from scratch. This single rule eliminates an entire class of trust-destroying bugs.
Design decision 2: every external call assumes failure
The data layer is built around a simple idea: assume every network call will eventually fail, and design for what happens next, not just the happy path. Concretely, that means four pieces working together:
Retry with backoff and jitter; A single timeout shouldn't be treated as a real failure. It's retried with an increasing delay, plus a little randomness (jitter) so many clients don't all retry at the exact same moment and overwhelm the provider again.
A circuit breaker per data provider; If a provider fails repeatedly, the app stops calling it for a cool-down period instead of retrying forever and wasting time on a dead endpoint. After the cool-down, one trial call checks if it's healthy again.
A rate limiter per provider; Free-tier market-data APIs have strict call limits. A simple per-provider minimum interval between calls keeps you under the limit without needing to track quotas manually.
A provider fallback chain; If provider A is down, the app automatically tries provider B, then C. The rest of the app never knows or cares which one actually answered, it just gets a quote back, or a clear failure.
def get_quote(self, symbol, target_currency=None):
if self._connectivity.is_online():
quote = self._try_providers(symbol) # walks the fallback chain
if quote is not None:
self._cache.put(f"quote:{symbol}", quote.to_dict())
return self._maybe_convert(quote, target_currency)
return self._serve_from_cache(f"quote:{symbol}", symbol, target_currency)
Design decision 3: never hide staleness
When every provider fails and the app falls back to cache, that data gets returned with stale=True and an age_seconds field. The UI is required to show a "last updated 3 hours ago" badge whenever that flag is set. This is a small technical detail with an outsized trust impact, a user who sees an honest "this is old" beats a user who later discovers a number quietly lied to them.
Design decision 4: not every error deserves a retry
A "symbol not found" error and a "server timed out" error look similar on the surface but mean opposite things. One is a permanent fact; the other is transient. The error hierarchy distinguishes them explicitly, so a typo'd ticker fails fast instead of retrying three times against every provider in the fallback chain for no reason.
class ProviderError(DataLayerError):
"""Retryable: timeout, 5xx, temporary provider trouble."""
class SymbolNotFound(DataLayerError):
"""Not retryable: the symbol simply doesn't exist."""
I'm currently building the backtesting engine, every quant signal has to prove itself against two years of historical data, with realistic transaction costs, before it's allowed to appear live in the app.
Top comments (0)