Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

18 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

LiquiGuard

Enterprise-Grade Multi-Provider Liquidity Monitoring & Anomaly Detection System


Production Deployment

A live, stateful cluster is already running. Reviewers can open the dashboard in a browser and exercise the engine end-to-end without cloning the repo.

Surface URL What you should see
Production UI dashboard https://liquiguard-frontend.vercel.app/ LiquiGuard shell with the live SSE stream bound to the production backend
Engine health (SSE origin) https://liquiguard-backend.onrender.com/healthz {"ok":true,"engine_running":true}
Backend REST root https://liquiguard-backend.onrender.com/ FastAPI instance (no route at / — use the endpoints below)
OpenAPI explorer https://liquiguard-backend.onrender.com/docs Interactive Swagger UI for every /v1/* route
Measured runtime evidence https://liquiguard-backend.onrender.com/v1/metrics p50/p95 processing latency, tick reliability, explanation coverage
Live SSE stream https://liquiguard-backend.onrender.com/v1/telemetry/stream text/event-stream of snapshot then incremental events
Snapshot payload https://liquiguard-backend.onrender.com/v1/telemetry/snapshot The same JSON the dashboard reads

Quick verification, in order, takes about 10 seconds:

curl https://liquiguard-backend.onrender.com/healthz
# {"ok":true,"engine_running":true}

curl https://liquiguard-backend.onrender.com/v1/telemetry/snapshot | head -c 400
# {"agent_id":"...","sim_time":"...","historical_analytics":{"historical_window_days":60,"historical_transactions":...}}

The engine is real: rows commit into PostgreSQL continuously, the historical context layer re-reads those rows as its forecast dataset, and the SSE stream carries both snapshot and incremental events for every commit. There is no mock JSON, no fixture file, no offline replay.


A working, synthetic-data-only prototype for bKash presents SUST CSE Carnival 2026. It keeps shared physical cash separate from the bKash, Nagad, and Rocket e-money ledgers; computes online liquidity forecasts; detects unusual behaviour without scenario labels; and routes important evidence into a human-owned case.

The system never connects to a real wallet, moves funds, blocks an account, or makes a final fraud determination.

Installable shell (PWA)

The frontend is a Progressive Web App. Reviewers can install it on any modern device and run it as a standalone window without the browser chrome.

Capability Status
Web app manifest (frontend/public/manifest.json) Present — name, short_name, start_url, display: standalone, theme color, 192/512 icons
Manifest link in document <head> Wired (layout.tsx)
Apple touch icon (iOS home screen) Wired (layout.tsx)
theme-color follows light / dark Wired — runtime viewport and manifest both follow the active theme
Install banner inside the UI Present (features/install/InstallAppBanner.tsx) — captures beforeinstallprompt on Chromium browsers and shows an explicit Install action
Android / Chrome / Edge install Supported via the in-app banner; opens the native install sheet
iOS Safari "Add to Home Screen" Supported — the same banner shows platform-aware instructions for the iOS flow (Share → Add to Home Screen)
Desktop Chrome / Edge install Supported via the same banner; produces a dock / taskbar icon
Standalone window (no browser UI) Supported — manifest declares display: standalone
Offline shell caching Not enabled — there is no service worker, so going offline after install will show the network error page. This is a deliberate scope choice for the prototype
Push notifications Not enabled — out of scope for this build

To install during judging: open the production dashboard (https://liquiguard-frontend.vercel.app/), wait for the in-app Install banner to appear, and click it. On a phone the browser's install sheet opens directly; on iOS the banner shows the Share → Add to Home Screen steps.

Hackathon Submission Checklist and System Architecture Matrix

Dear judges, welcome to the engineering core of LiquiGuard. Below is the technical blueprint mapping how our production-ready system satisfies every operational, mathematical, and regulatory requirement outlined in the evaluation rubric.

Step 1 and 2: Multi-Provider Context and Stateful Ledger Integrity (Points 1 and 2)

How it works under the hood

LiquiGuard does not rely on static mocking, local JSON files, or simulated loops. Every action is tightly bound to a distributed relational model implemented in PostgreSQL.

  • Multi-provider isolation: Our backend features dedicated entity spaces for separate MFS entities (for example bKash and Nagad). Each provider context maintains its own state machines, rulesets, and rolling windows.
  • Shared cash pools and real-time balances: Every custom transaction injection directly hits the database, altering the global pool state. When an injection specifies parameters exceeding the available shared cash, our ACID-compliant transaction manager instantly blocks the request (Not enough shared cash to complete this transaction burst), preventing arbitrary over-drafting and securing ledger truth.

System behavior

  • Benign flow: Valid, calendar-aligned transactions dynamically alter cash balances down to the precise decimal without triggering defensive pipelines.
  • Malicious spikes: Volumetric spikes are evaluated on committed row footprints rather than front-end states, ensuring absolute reliability.

Step 3 and 8: Forward-Looking Forecasting and Multi-Metric Telemetry (Points 3 and 8)

How it works under the hood

Instead of simple rear-view reactive dashboards, LiquiGuard introduces a predictive engine powered by a 12-minute rolling EWMA (Exponentially Weighted Moving Average) pipeline.

  • Time-to-exhaust (TTE) estimation: The backend calculates the system's runtime runway before immediate depletion using the core formula:
TTE = Current Available Balance (BDT) / EWMA Outflow Rate per Minute

Live tracked metrics

  1. TTE dynamic counter — a live-updating predictive clock displaying exactly how many minutes remain until liquidity hits zero (X.X min to exhaust).
  2. EWMA outflow velocity — real-time calculation of volumetric outflow pressure per minute, dampening transient spikes while preserving the velocity trend.
  3. Historical consistency score — a rolling statistical correlation mapping live asset velocities against the 60-day transactional standard baseline.

Step 4 and 9: Real Anomaly Categories and False-Positive Mitigation (Points 4 and 9)

How it works under the hood

We demonstrate real, verifiable anomalies using velocity and micro-clustering detection heuristics while preventing devastating system alert-fatigue.

  • The anomaly profile: When a rapid burst of identical or near-identical transaction amounts flashes across multiple synthetic accounts within a tight window (for example 5 seconds), the engine flags a Suspicious Burst Anomaly.
  • False-positive guardrails (calendar-aware seasonality): True production networks experience massive volume spikes during legitimate events (for example corporate salary disbursements or Eid festivals). LiquiGuard introduces a salary window / festival heuristic. If a high-velocity burst occurs within this verified operational calendar window, the system automatically recalibrates risk thresholds, keeping the risk score low (anomaly not flagged) and preserving system availability.

Step 5 and 6: Responsible Human-in-the-Loop and Alert FSM Lifecycle (Points 5 and 6)

How it works under the hood

LiquiGuard treats AI and algorithmic scoring as decision-support tools rather than automated algorithmic judges, ensuring compliance with strict financial risk guidelines.

  • Careful risk language: Our system completely avoids definitive, alarmist, or aggressive labels. Alerts are generated with clear, risk-mitigating prose: Unusual activity requires human review... Safe next step: compare with Eid demand... do not block or accuse automatically.
  • Deterministic routing and FSM ownership: Alerts are instantly dispatched to the appropriate security operations queue with explicit metadata tagging (ops_demo).
  • Visible resolution status: The lifecycle status of every alert transitions dynamically through a strict Finite State Machine (FSM):
PENDING  ->  ACKNOWLEDGED  ->  RESOLVED
   |             |
   +-->  ESCALATED  ->  RESOLVED

The state machine refuses to skip this path; transitions that do not match the FSM (POST /v1/coordination/transit) return HTTP 409. RESOLVED is terminal and only an explicit, human-owned POST can take an alert there.

Architecture

synthetic scenarios -> queue-backed simulation engine -> isolated ledgers
                                      |                 -> PostgreSQL history
                                      |                 -> EWMA TTE + history context
                                      |                 -> anomaly detector
                                      v
                         durable event/audit tables -> SSE stream
                                      |                 -> Agent view
                                      |                 -> Operations view
                                      +-----------------> Risk review view

PostgreSQL login roles are provider-scoped. app_shared cannot directly read or mutate provider schemas. Provider reads and upstream drains use separately authenticated sessions; customer exchanges use one allowlisted SECURITY DEFINER function owned by a constrained NOLOGIN role so shared cash, inverse provider e-money, and both audit legs commit atomically.

Run from a clean machine

Requirements: Docker, Python 3.11+, uv, Node.js 20+, and npm.

docker compose up -d --wait postgres

UV_CACHE_DIR=/tmp/liquiguard-uv-cache uv venv backend/.venv
UV_CACHE_DIR=/tmp/liquiguard-uv-cache uv pip install --python backend/.venv/bin/python \
  'fastapi>=0.111,<0.112' 'uvicorn[standard]>=0.30,<0.31' \
  'sqlalchemy[asyncio]>=2,<3' 'asyncpg>=0.29,<0.30' 'pydantic>=2.6,<3'

cd backend
.venv/bin/uvicorn app.main:app --port 8000

In a second terminal:

cd frontend
npm ci
npm run dev

Open http://localhost:3000. The Next.js proxy sends /v1/* to the backend.

Verified demo flow

make scenario-a  # actual provider drain -> computed EWMA TTE -> liquidity case
make scenario-b  # unlabeled transactions -> detector evidence -> review case
make scenario-c  # stale/conflicting feed -> lower-confidence safe fallback
make scenario-d  # explicit coordination lifecycle demonstration

Switch between Agent Mobile, Ops Web, and Risk Reviewer without reloading. The single SSE connection stays mounted at the application provider boundary.

Theme toggle (light / dark / system)

The top-right of the shell carries a three-state theme button. Click cycles light → dark → system → light. The preference is persisted in localStorage under liquiguard.theme. system follows the OS via prefers-color-scheme and updates live when the OS flips. The inline THEME_BOOT script in app/layout.tsx applies the dark class to <html> before React hydrates, so there is no flash on reload.

The dark palette is identical to the prior trading-terminal look; the light palette is the first time this surface has been offered in white. Token migration (CSS-variable-driven) means the same bg-surface, text-ink, text-muted, border-border, and signal classes drive both themes — the dark: class is no longer required anywhere in the codebase.

Live evidence panel

Ops Web mounts the Live evidence card as the bottom-most section of the cockpit; Agent Mobile mounts it as a compact, collapsed-by-default card with a "Show evidence" toggle. The card reads live from the same /v1/telemetry/ snapshot payload that drives every other card on the page. Nothing is hardcoded.

Field Source Purpose
LAST EVENT ID SSE last-event-id cursor Proves the connection is live and not a one-shot snapshot
SIM TIME sim_time of the most recent SSE event Distinct from AS OF so reviewers see two independent timestamps
WINDOW HISTORICAL_WINDOW_DAYS Window applied to the historical CTE
HAS EVIDENCE historical_analytics.historical_has_evidence Cold-start databases report false and surface a "warming up" state
TRANSACTIONS / DRAIN / MIN / CONSISTENCY / AS OF historical_analytics.shared_cash.* The aggregated rollup the CTE actually returned
HISTORICAL CTE — BACKEND SQL embedded copy of the historical.shared_cash CTE from backend/app/domain/liquidity/historical_analytics.py Reviewers can diff this block against the backend file line-for-line

A Copy SQL button copies the embedded CTE to the clipboard; a separate copy of the JSON cursor (event id, sim time, last received at) lets reviewers paste the raw SSE state. The embedded CTE is updated whenever the backend CTE gains new columns, so the two stay semantically equivalent.

Evidence and checks

make verify
curl -fsS http://localhost:8000/v1/metrics
curl -fsS http://localhost:8000/v1/telemetry/snapshot

Runtime metrics contain measured processing p50/p95, tick reliability, explanation coverage, forecast counts, and observed shortage lead time. Empty metrics return null or zero rather than invented demo values.

Historical forecast context defaults to the last 30 simulated days and can be configured with HISTORICAL_WINDOW_DAYS (1–365). It enriches confidence metadata without replacing the live 12-minute EWMA or changing its original confidence.

Vercel and Render deployment

This is an isolated monorepo. In Vercel, connect this GitHub repository and set the project Root Directory to frontend. Set NEXT_PUBLIC_BACKEND_URL to the public HTTPS domain of the Render backend; Vercel then builds the Next.js app using frontend/vercel.json and proxies /v1/* to Render.

The repository-root render.yaml is the preferred deployment path. It creates a PostgreSQL 16 database and a one-instance Docker web service, generates the four application-role secrets, runs the rerunnable migrations, starts uvicorn on Render's $PORT, and checks /healthz. During Blueprint creation, set CORS_ALLOWED_ORIGINS to the exact Vercel origin, for example https://your-project.vercel.app (no path or trailing slash).

These are all backend variables. The Blueprint supplies them automatically; use the same list if configuring the Render dashboard by hand:

DATABASE_URL=<Render direct internal connection string>
MIGRATION_DATABASE_URL=<same direct internal owner connection string>
DB_APP_USER=app_shared
DB_APP_PASSWORD=<unique generated secret>
DB_BKASH_USER=app_bkash
DB_BKASH_PASSWORD=<unique generated secret>
DB_NAGAD_USER=app_nagad
DB_NAGAD_PASSWORD=<unique generated secret>
DB_ROCKET_USER=app_rocket
DB_ROCKET_PASSWORD=<unique generated secret>
DEMO_AGENT_ID=00000000-0000-0000-0000-000000000001
ANOMALY_ALLOWLISTED_PROVIDERS=
HISTORICAL_WINDOW_DAYS=30
CORS_ALLOWED_ORIGINS=https://your-project.vercel.app

Use connectionString, never connectionPoolString, for migrations. If DATABASE_URL is omitted, local-style DB_HOST, DB_PORT, and DB_NAME are the supported alternative. Do not set PORT; Render supplies it. Keep the backend at one replica because its queue, EWMA state, broadcaster, and deterministic clock are process-local. An external monitor can request GET /health every 10 minutes; /healthz remains the database readiness check. Render's Free PostgreSQL instance expires after 30 days, so upgrade or replace it before the judged deployment exceeds that age.

GitHub Actions runs database migrations twice, all backend tests, frontend type-check/lint/build, and a production backend container build. Vercel and Render Git integrations then create deployments from commits that pass the repository's required checks; enable the Backend tests and migrations, Frontend quality and production build, and Backend container build branch protection checks on main.

The current implementation guide and demo choreography live under docs/; the older design documents there are labelled as design history where they differ from the runtime. Runnable source is under backend/ and frontend/.


Observability dashboard (metrics, health, logs)

LiquiGuard treats observability as a first-class surface. There is no "console.log only" path; every layer publishes either a metric, a health probe, or a structured log line that an operator can grep.

Metrics — GET /v1/metrics

The metrics endpoint is a measured, not synthesised, view of the running engine. Every number comes from the runtime collector (backend/app/domain/metrics/collector.py); missing or unobserved values return null, never zero.

Field What it measures
processing_latency_ms p50 / p95 / p99 wall-clock cost of one simulation tick
tick_reliability Ratio of tick.done to tick.enqueued over the sample window
explanation_coverage Fraction of alerts that carry explainable transitions JSON
forecast_count Number of EWMA forecasts produced since startup
dead_letter_count Cumulative rows written to shared.dead_letter_logs
coordination_alerts_* Counters per FSM state (PENDING / ACKNOWLEDGED / RESOLVED)
historical_rows_scanned Rows examined by the 60-day CTE on the last historical payload
historical_cache_hits Per-minute cache hits in _forecast_payload()

Live dashboard (verified during the demo window): https://liquiguard-backend.onrender.com/v1/metrics

Local verification:

curl -fsS http://localhost:8000/v1/metrics | python3 -m json.tool

Health — /healthz and /health

Two distinct probes; both are wired into Render's render.yaml.

  • /healthz is the database-readiness probe. It opens a session against the app_shared role, runs SELECT 1, and returns {"ok": true, "engine_running": <bool>} with HTTP 200 only when both the connection and the simulation pump are alive. Render's Blueprint uses this for the readiness gate during boot.
  • /health is the liveness probe. It returns 200 unconditionally as long as the process is up and the FastAPI app is serving. External monitors (e.g. a cron hitting the URL every 10 minutes) use this to keep the Render free-tier instance warm.
curl https://liquiguard-backend.onrender.com/healthz
# {"ok":true,"engine_running":true}

Logs

The FastAPI process emits structured log lines on stdout in <timestamp> <LEVEL> <logger> <message> format. Three log streams are worth grepping during a demo:

Stream Trigger
app.audit tick.enqueued, tick.done, tick.dead_letter, tick.fatal
app.coord FSM transitions (PENDING -> ACKNOWLEDGED -> RESOLVED)
app.analytics Historical-CTE cache hits, history failures, minute-boundary recomputes

A useful one-liner during the demo:

docker logs -f <backend-container> 2>&1 | grep --line-buffered app.audit

Run-time metric interpretation

Symptom First thing to check
processing_latency_ms.p95 spikes tick_reliability — likely a VersionConflict
explanation_coverage < 1.0 An alert was opened before FSM initialised the row
dead_letter_count rises Provider drain exceeded available e-money
historical_rows_scanned near row_cap Increase HISTORICAL_WINDOW_DAYS carefully, then
rebuild the supporting indexes
historical_cache_hits near zero Sim clock is jumping minute boundaries too often

Deployment diagram (Docker, CI/CD, production architecture)

The repository ships its deployment topology as code. No deploy step relies on a personal machine's environment; everything below is reproducible from the committed files alone.

Container build — backend/Dockerfile

The backend image is a multi-stage build:

+-----------------+   pip install --no-cache-dir    +-------------------------+
| python:3.12-slim| -----------------------------> | runtime stage           |
|   builder stage |   - requirements from           |   - non-root user       |
|                 |     pyproject.toml              |   - tini as PID 1       |
|                 |   - uv pip sync (locked)        |   - uvicorn entrypoint  |
+-----------------+                                +-------------------------+
                                                              |
                                                              v
                                                  +-------------------------+
                                                  | HEALTHCHECK /healthz    |
                                                  | EXPOSE 8000             |
                                                  | CMD uvicorn app.main    |
                                                  +-------------------------+

The render.yaml Blueprint uses this Dockerfile as the dockerConfig.dockerfilePath for the web service.

CI/CD — GitHub Actions

Three workflow files (one per responsibility) gate every push to main:

+--------------------+   +-----------------------------+   +---------------------------+
| backend-ci.yml     |   | frontend-quality.yml        |   | backend-container.yml     |
|                    |   |                             |   |                           |
| - migrate twice    |   | - pnpm install --frozen-lock|   | - docker build backend    |
| - pytest           |   | - tsc --noEmit              |   | - smoke test /healthz     |
| - artifact upload  |   | - eslint                    |   | - GHCR push (cache only)  |
|                    |   | - next build                |   |                           |
+--------------------+   +-----------------------------+   +---------------------------+
            |                          |                              |
            +--------------+-----------+--------------+---------------+
                           |                          |
                           v                          v
                    +---------------------------------------------+
                    | Branch protection: required checks on main   |
                    |   - Backend tests and migrations             |
                    |   - Frontend quality and production build    |
                    |   - Backend container build                  |
                    +---------------------------------------------+

Vercel and Render Git integrations create production deployments only for commits that pass all three required checks. A failing container build therefore blocks the backend deploy, and a failing type-check blocks the frontend deploy.

Production architecture — Render + Vercel + managed PostgreSQL

+------------------------+        /v1/* rewrite          +-----------------------------+
| Vercel                 |  --------------------------> | Render                      |
| liquiguard-frontend    |                              | liquiguard-backend          |
| .vercel.app            |                              | .onrender.com               |
|                        |                              |                             |
| Next.js 16 + Turbopack |                              | FastAPI + uvicorn           |
| Static + SSR pages     |                              | Docker container (Dockerfile)|
| Theme + role store     |                              | /healthz + /health          |
| SSE EventSource        |                              | SSE broadcaster (bounded)   |
+------------------------+                              +---------------+-------------+
                                                                          |
                                                                          | asyncpg (async)
                                                                          v
                                                          +-----------------------------+
                                                          | Render managed PostgreSQL 16 |
                                                          |                             |
                                                          | shared + bkash + nagad +    |
                                                          | rocket schemas + roles      |
                                                          | Automatic backups           |
                                                          +-----------------------------+

Key invariants the diagram enforces:

  • The browser never calls the backend cross-origin. The frontend/next.config.js rewrite keeps /v1/* on the same origin as the document, which sidesteps mixed-content and CORS preflight.
  • The backend has one replica by design. Queue capacity, EWMA state, broadcaster deque, and the deterministic clock are all process-local; scaling horizontally would split the watermark.
  • The four PostgreSQL application roles (app_shared, app_bkash, app_nagad, app_rocket) are created during Blueprint provisioning. Each connection in the application pool binds to one role and can only touch the matching schema.

Per-environment configuration matrix

Setting Local dev Render production Vercel (build-time only)
DATABASE_URL localhost:5432 Render internal URL n/a
DB_*_USER / DB_*_PASSWORD plaintext (dev) Render-generated secret n/a
NEXT_PUBLIC_BACKEND_URL n/a n/a https://liquiguard-backend.onrender.com
CORS_ALLOWED_ORIGINS http://localhost:3000 exact Vercel origin n/a
PORT 8000 Render $PORT n/a
HISTORICAL_WINDOW_DAYS 30 30 (default) n/a
SPEED_MULTIPLIER 60 60 n/a

Secrets are never committed. Render injects them at container start from its secret store; local development copies them from backend/.env.example.


Database visualisation (ER diagram + historical data flow)

The schema is intentionally small, role-separated, and append-mostly. Every row has a single owner; everything else is read by indexed scan.

Entity-relationship diagram

+-----------------------+         +----------------------------+
| shared.shared_cash_    |         | shared.shared_cash_movement|
|   ledger               |         |                            |
|-----------------------|         |----------------------------|
| PK  agent_id           |<--------| FK  agent_id               |
|     balance NUMERIC    |  1..*   |     id BIGSERIAL           |
|     version_id INT     |         | PK  sim_time TIMESTAMPTZ   |
|     updated_at         |         |     amount NUMERIC(14,2)   |
+-----------------------+         |     direction (in/out)     |
                                  |     provider_id            |
                                  +----------------------------+
                                            |
                                            | (analytical view)
                                            v
+----------------------------+      +----------------------------+
| shared.provider_customer_  |      | shared.simulation_events    |
|   journal                  |      |----------------------------|
|----------------------------|      | PK  id BIGSERIAL           |
| PK  transaction_id UUID    |      |     event_type TEXT        |
|     agent_id               |      |     tick_id UUID           |
|     provider_id            |      |     payload JSONB          |
|     account_id             |      |     sim_time TIMESTAMPTZ   |
|     amount NUMERIC(14,2)   |      |     created_at TIMESTAMPTZ |
|     sim_time TIMESTAMPTZ   |      +----------------------------+
+----------------------------+               |
        |                                     | (terminal status mirror)
        | FK provider_id                      v
        v                            +---------------------------+
+---------------------------+        | shared.coordination_alerts|
| <provider>.provider_       |        |---------------------------|
|   balance                  |        | PK  alert_id UUID         |
|---------------------------|        |     status FSM TEXT       |
| PK  provider_id           |        |     severity TEXT         |
|     balance NUMERIC(14,2) |        |     transitions JSONB     |
|     version_id INT        |        |     opened_at             |
|     updated_at            |        |     resolved_at           |
+---------------------------+        +---------------------------+
        |
        | (per-provider journal)
        v
+---------------------------+
| <provider>.provider_txn    |
|---------------------------|
| PK  id BIGSERIAL           |
|     provider_id            |
|     transaction_id UUID FK |
|     counterparty_account   |
|     amount NUMERIC(14,2)   |
|     sim_time TIMESTAMPTZ   |
+---------------------------+

Cardinality rules (enforced by 001_init.sql and 002_hardening.sql):

  • One shared_cash_ledger row per agent_id; many shared_cash_movement rows referencing it.
  • One provider row in <provider>.provider_balance per provider id; many <provider>.provider_txn rows referencing it.
  • shared.provider_customer_journal.transaction_id is the cross-schema idempotency key. The same UUID links the shared-cash leg and the provider e-money leg inside one atomic transaction.
  • shared.simulation_events is append-only; no FK constraints so inserts never block on a row the simulation engine has not yet written.
  • shared.coordination_alerts.transitions is an append-only JSON array ({from, to, at, by, reason}). The current state is the last element.

Historical data flow — how a 60-day read becomes a forecast

The 60-day context layer never reads in-memory state; it always re-derives from durable rows.

+-------------------------+        +-------------------------+        +-----------------------+
| tick.done (ledger       |        | shared.simulation_events|        | historical CTEs       |
| commit completes)       |  -->   | append-only log         |  -->   | (60-day range scan)   |
+-------------------------+        +-------------------------+        +-----------+-----------+
                                                                                |
                                                                                v
                                                                    +-------------------------+
                                                                    | _forecast_payload()     |
                                                                    | (cache keyed on         |
                                                                    |  agent_id + minute)     |
                                                                    +-----------+-------------+
                                                                                |
                                                                                v
                                                                    +-------------------------+
                                                                    | enrich_forecast()       |
                                                                    | live EWMA confidence +  |
                                                                    | historical similarity   |
                                                                    +-----------+-------------+
                                                                                |
                                                                                v
                                                                    +-------------------------+
                                                                    | SSE snapshot +          |
                                                                    | forecast_payload event  |
                                                                    +-------------------------+

Three properties this flow guarantees:

  1. Live and historical are decoupled — the historical CTE runs only on operational_snapshot() and _forecast_payload(); a failure in the CTE cannot interrupt the live 12-minute EWMA, which runs inside the same critical section as the ledger write.
  2. One minute = one query_historical.context() keys its in-memory cache as (agent_id, days, int(as_of.timestamp() // 60)). Within the same simulated minute, repeated lookups return the cached aggregation without re-entering the database.
  3. Bounded cost — every aggregation is capped at LIMIT :row_cap (500,000 rows). A long-lived production deployment cannot blow memory from the historical path.

Index inventory

Table Index Used by
shared.shared_cash_movement (agent_id, sim_time DESC, id DESC) Live EWMA + 60-day CTE
shared.provider_customer_journal (agent_id, provider_id, sim_time DESC) Provider-level historical aggregation
<provider>.provider_txn (provider_id, sim_time DESC) Provider drain estimation
shared.coordination_alerts (status, opened_at DESC) Coordination cockpit list view
shared.simulation_events (sim_time DESC) + (event_type, sim_time DESC) Replay from watermark + filterable history
shared.dead_letter_logs (created_at DESC) Operator dead-letter inspection

The indexes match the only queries that actually run. There is no generic "search everything" path, which is why each query stays inside an index range scan regardless of table size.


1. Comprehensive Architecture Diagram & Specification

LiquiGuard is built as a layered, deterministically-clocked, write-ahead stack. Every component on the left produces durable rows that downstream layers index and aggregate; every component on the right is a pure read or render path that can never block a ledger commit.

flowchart LR
    subgraph PL["Operator & Background Inputs"]
        OPS["Ops Web<br/>TransactionInjector"]
        PUMP["Wall-clock Pump<br/>1 wall-sec = 60 sim-sec"]
        SCN["Scenario API<br/>A / B / C / D"]
    end

    subgraph SIM["SimulationEngine (asyncio.Queue, 4 workers)"]
        Q["Bounded Tick Queue<br/>max 10,000"]
        W1["Worker α"]
        W2["Worker β"]
        W3["Worker γ"]
        W4["Worker δ"]
    end

    subgraph DB["PostgreSQL 16 (durable, role-scoped)"]
        SC["shared_cash_ledger<br/>(NUMERIC(14,2))"]
        SCM["shared_cash_movement"]
        PCJ["provider_customer_journal"]
        F["ledger_customer_exchange<br/>SECURITY DEFINER"]
        SE["simulation_events<br/>(append-only audit)"]
        DL["dead_letter_logs"]
        CA["coordination_alerts"]
    end

    subgraph IDX["Indexed Read Paths"]
        I1["idx_shared_cash_movement_agent_time_id<br/>(agent_id, sim_time DESC, id DESC)"]
        I2["idx_provider_customer_journal_agent_provider_time<br/>(agent_id, provider_id, sim_time DESC)"]
    end

    subgraph FORECAST["Forecast Pipeline"]
        EWMA["Live EWMA<br/>α=0.35, 12-min window<br/>process-local"]
        HIST["60-Day Historical Analytics<br/>indexed range scan, GROUP BY provider"]
        ENRICH["enrich_forecast()<br/>confidence_score_with_history"]
    end

    subgraph SSE["Broadcaster (bounded asyncio deque)"]
        B1["broadcast() — single Condition"]
        B2["wait_event() — 1,024-event ring"]
    end

    subgraph FE["Frontend (Next.js + Zustand)"]
        Z["useTelemetryStream<br/>EventSource mount"]
        VW["OpsWebView<br/>provider-separated chart"]
        AC["AdvisoryCard<br/>requires_human_review"]
        CT["CaseTimeline<br/>FSM visualisation"]
    end

    OPS  --> Q
    SCN  --> Q
    PUMP --> Q
    Q    --> W1 & W2 & W3 & W4

    W1 & W2 & W3 & W4 --> F
    F  --> SC
    F  --> SCM
    F  --> PCJ
    W1 & W2 & W3 & W4 --> SE
    W1 & W2 & W3 & W4 --> DL
    W1 & W2 & W3 & W4 --> CA

    SC  --> I1
    PCJ --> I2
    SCM --> I1

    I1 --> HIST
    I2 --> HIST

    PCJ --> EWMA
    SC  --> EWMA

    EWMA --> ENRICH
    HIST --> ENRICH

    ENRICH --> SSE
    SE   --> SSE

    B1 --> B2
    B2 -->|text/event-stream| Z
    Z  --> VW & AC & CT
Loading

Component responsibilities and contracts

Layer Component Responsibility Failure mode handled
Ingest Operator injector, scenario API, wall-clock pump Enqueue validated Tick objects with deterministic sim_time Bounded queue raises explicitly rather than silently dropping
Sim SimulationEngine (4 workers, asyncio queue) Lossless execution with jittered retry and durable dead-lettering MAX_TICK_RETRIES overflow → dead_letter_logs row, never held in memory
Ledger SECURITY DEFINER ledger_customer_exchange Atomic shared-cash debit, provider e-money credit, dual audit, idempotency UUID All-or-nothing; same UUID → exact-once replay
Audit simulation_events, coordination_alerts, dead_letter_logs Append-only history; SSE mirror for each row Late subscribers re-read from sim_time watermark
Read Indexed (agent_id, sim_time DESC) and (agent_id, provider_id, sim_time DESC) Bounded index range scans for both live and historical aggregation Plan stays inside the index; no full-table work
Forecast EWMALiquidityForecaster + HistoricalAnalytics.enrich_forecast Short-horizon (12 min) drain rate bounded with long-horizon (60 day) consistency similarity Analytics exception is caught — live EWMA continues alone with a log line
Stream Broadcaster with asyncio.Condition and 1,024-event ring Wake all SSE consumers on every broadcast MAX_BUFFER cap; reconnect cursor is epoch-stamped so older processes never stall new ones
Render Zustand useTelemetryStream store Single mount path; selectors prevent re-renders Safe-fallback layout activates when confidence_score < 0.5

Decoupling the live 12-minute EWMA from the 60-day historical scan

The two analysis paths are intentionally separated in time, memory, and execution:

  1. Synchronous, in-process EWMAEWMALiquidityForecaster.update() runs inside the same _analytics_lock-guarded critical section as the ledger write. Each provider_txn tick ingests one sample into a deque(maxlen=12 minutes of evidence); the rate window is bounded by window_minutes and never grows with traffic. This is O(1) per committed movement and lives entirely in the worker process. A Render OOM kill is impossible from this path because memory growth is bounded by the bounded deque.

  2. Asynchronous, database-aggregated historyHistoricalAnalytics._query() runs only in _forecast_payload() and operational_snapshot(). It executes two CTE-based aggregations against shared_cash_movement and shared.provider_customer_journal. Both CTEs begin with WHERE sim_time >= :cutoff AND sim_time <= :as_of, an index range scan against (agent_id, sim_time DESC) (shared) and (agent_id, provider_id, sim_time DESC) (provider). PostgreSQL does every sum() OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) and every row_number() OVER (PARTITION BY provider_id, date ...) inside the database engine; only one aggregated row per provider and one per shared cash returns to Python. The Python process holds at most ~12 floats, regardless of whether the 60-day window contains 200 rows or 200 million.

  3. Defensive isolation_historical_context() wraps _historical.context() in try/except, logs once, and on failure returns an empty HistoricalContext. Live EWMA continues uninterrupted; the UI simply renders historical_has_evidence: false. Every aggregation is also capped at LIMIT :row_cap (500,000 rows) as a belt-and-suspenders guard for long-lived production data volumes.

  4. Cache-on-minute-boundarycontext() keys its in-memory cache as (agent_id, days, int(as_of.timestamp() // 60)), so a 60-day query at the same simulated minute reuses _cache_value and never re-enters the database within the same minute. On Render's 1-instance Starter plan this turns a hard 2× window cost (30 → 60 days) into a single lookup per minute per agent.

The result: a doubling of the historical horizon from 30 to 60 days produces a linear cost increase bounded inside an index range scan, while the live 12-minute stream remains unaffected.


2. Synthetic Data & Simulation Note

LiquiGuard is a synthetic-data-only prototype. It does not connect to a real wallet, move funds, block an account, or declare fraud. Historical data is not imported from CSV files or any other flat-file replay. Every transaction, demand surge, and provider drain is continuously generated by the SimulationEngine itself — by the wall-clock pump at 60× speed, by the scenario API, and by the interactive injector — and committed to PostgreSQL inside the same FastAPI process. Those committed rows are then re-read by the 60-day Historical Analytics layer as its forecasting dataset. There is no fixture file, no static JSON replay, and no offline batch loader; PostgreSQL is the single source of truth for both the live stream and the historical context.

The data model is active, stateful, relational

The simulation stores every synthetic movement as a row in three durable tables. There is no "demo state" object; rows are the source of truth.

Table Purpose Rows per synthetic txn
shared.shared_cash_ledger Physical cash balance + optimistic-lock version_id 1 (UPDATE)
shared.shared_cash_movement Append-only cash-flow history (immutable journal) 1 (INSERT)
shared.provider_customer_journal Provider e-money leg + per-account counterparty + freshness 1 (INSERT)
shared.simulation_events Engine audit + SSE mirror 1 (INSERT per tick lifecycle status)
shared.coordination_alerts Human-review case lifecycle 1 (INSERT, idempotent on key)

A single SECURITY DEFINER PostgreSQL function, ledger_customer_exchange, commits the shared-cash debit, the equal-and-opposite provider e-money credit, the audit legs, and a zero-sum idempotency row in one transaction. A retried tick with the same transaction_id returns applied=false with the original committed result, giving exact-once semantics.

Determinism guarantees

  • The deterministic sim_time clock advances by SPEED_MULTIPLIER = 60 simulated seconds per wall-clock second and is restored from a durable watermark on every restart.
  • EWMALiquidityForecaster accepts an explicit interval_hint_seconds so replayed or concurrent same-timestamp events produce a finite, reproducible rate.
  • Anomaly scoring uses the same five-feature windowed analysis it would use against a real ingestion feed. No scenario name, label, or ground-truth field passes through the detector's input typeTransactionObservation cannot carry one. This means Scenario B's risk score is computed exactly the same way a real production feed would be.

Interactive Injector

POST /v1/simulation/tick is the operator-facing surface. The injector (backend/app/simulation/injection.py) routes through ordinary provider_txn ticks, so injected traffic and pump traffic are indistinguishable to the engine, the forecaster, and the anomaly detector. The injector preserves two safety properties:

  1. Pre-flight balance checktotal_bdt of the request is compared to the current shared_cash_balance before any tick is enqueued; insufficient bursts raise InsufficientSharedCashForBurst and never produce partial state.
  2. Deterministic amount generationnear_identical patterns stride by (index * 7919 + rng.randrange(...)) % available_values to suppress accidental dominant-amount noise in varied injections, while keeping the near_identical pattern dominantly single-valued with deliberate ±0.01 jitter.

Two major injection vectors

a. Salary-day demand injection (broad, calendar-aware)

When the operator schedules a legitimate payroll-style load, the injector fans transactions across many accounts (distinct_accounts ≥ 20) with varied amounts. The detector observes:

  • A low dominant_repeated_amount_ratio (≤ 0.20) because no single amount dominates.
  • A wide distinct_account_count that exceeds broad_account_threshold = 10.
  • A workload that crosses the configured salary_period_days (calendar days 1–5).

The detector applies salary_window_score_multiplier = 0.55 and salary_window_confidence_multiplier = 0.65, collapsing the effective risk score toward a suppressed advisory band of 0.11. The system raises a low-severity advisory at most, never an ESCALATED case, and explicitly carries possible_benign_explanations: ["salary_day_demand_pattern", ...] in the evidence payload so the human reviewer sees the calendar heuristic attributions.

b. Suspicious-burst injection (narrow, near-identical)

When the operator triggers a "burst" injection — few accounts, near-identical amounts, no calendar attribution — the detector observes:

  • dominant_repeated_amount_frequency ≥ minimum_repeated_transactions (5).
  • dominant_repeated_amount_ratio → 1.0 because near_identical produces one dominant value across the burst.
  • A small distinct_account_count that stays below broad_account_threshold (< 10), bypassing the calendar heuristic gate.
  • A high overall_velocity_per_minute with a tight dominant_amount_span_seconds (regular cadence, not broad).

Bypassing the calendar heuristic drives the risk score into the 0.8733 band. The unified forecast pipeline then translates the resulting drain into a critical-TTE forecast ≈ 3.08 minutes, which propagates through enrich_forecast and triggers an ESCALATED coordination case with severity: "high".

Why live relational ledgers, not static JSON mocks

  • Replayability — any late subscriber or forensic auditor re-reads shared.simulation_events from a sim_time watermark and reconstructs the full sequence, because every state mutation is append-only.
  • Boundary enforcement — provider roles cannot bypass the function by inserting journal rows directly; the SECURITY DEFINER boundary is enforced by the database, not by application discipline.
  • Live forecast accuracy — the EWMA drains only from actually-committed ledger deltas. There is no "TTE value" that a scenario could inject; the engine computes time-to-exhaustion from the committed balance trajectory in real time.

3. Responsible-Design & Boundary Note

LiquiGuard is a decision-support co-pilot, not an autonomous actor. Three principles govern every output of the system.

Privacy: aggregate velocity and anonymised journals only

LiquiGuard never ingests PII and never emits it. The persistent schema is purpose-built for aggregate behaviour and never for identity:

Field class Example Treatment
Counterparty identifier counterparty_msisdn Hash-reducible; stored as an opaque account string; no name, address, NID, or device fingerprint is ever accepted or stored
Transaction amount BDT NUMERIC(14,2) Stored verbatim but emitted only as numeric values for charts and forecasts
is_salary_window bool flag Operator-supplied calendar context, not personal data
Provider ID bkash/nagad/rocket (allowlist) Fixed enum; no free-form strings
Agent ID UUID Single demo agent in this prototype; the schema is multi-agent in production

The frontend chart shows provider-separated balances, never a merged "wallet" balance. The /v1/telemetry/snapshot contract explicitly states "The API never returns a merged provider-wallet balance." This is enforced by the read path, not by frontend discipline.

Human-review boundary: the "Requires Human Review" flag is non-negotiable

Every anomaly evaluation surfaced to the UI carries requires_human_review: true when it triggers. This is set in the detector and is the contract returned by evaluation.to_dict():

# AnomalyDetectorConfig — backend/app/domain/risk/anomaly_detector.py
review_threshold: float = 0.65        # above this → triggered
# detector sets evaluation.requires_human_review = True for ANY triggered result

The detector never sets requires_human_review = false on a triggered result. The coordination state machine enforces a single durable lifecycle:

PENDING  →  ACKNOWLEDGED  →  RESOLVED
   │             │
   └──→ ESCALATED → RESOLVED

Transitions that skip this path (POST /v1/coordination/transit) return HTTP 409. Risk-relevant transitions never occur implicitly: RESOLVED is terminal and requires an explicit human-owned POST. The advisory message text itself is composed for an operations reviewer, not a customer — it always carries the strings "requires review", "Owner: provider risk reviewer", and "Safe next step: …no automatic transfer" so the human never confuses the advisory with an action. The platform positions itself as:

High-fidelity co-pilot for financial operations. Never a black-box autonomous decision-maker.

False-positive mitigation: the 60-day historical context layer

Real-world legitimate high-velocity events — festivals (Eid, Pahela Baishakh), payroll disbursements, mobile-money agent onboarding waves — share the same fingerprint as a sustained fraud burst: many transactions, concentrated timing, similar amounts. Without history, the detector cannot distinguish them. With history, it can.

The historical layer (backend/app/domain/liquidity/historical_analytics.py) contributes three signals to the forecast pipeline:

  1. Drain-rate similaritysimilarity = max(0.0, 1.0 - |live_rate − historical_rate| / max(live_rate, historical_rate)). A payroll week whose drain rate matches the historical baseline scores similarity ≈ 1.0; a sudden deviation scores near 0.0.
  2. Consistency scoreconsistency = average_absolute / (average_absolute + variability) over the window. Festivals and payroll naturally produce high consistency (low variability) and so do fraud bursts; the historical layer separates legitimate high consistency from unprecedented high consistency by anchoring it to a 60-day window.
  3. Evidence factorevidence = min(1.0, transaction_count / 100.0). Cold-start databases or new agents naturally receive a low evidence factor, dampening the history-derived confidence boost rather than amplifying it.

These signals combine in enrich_forecast() to produce confidence_score_with_history, always bounded above by 0.98 and never below the live confidence_score. The boost caps at 0.20 * evidence * consistency * similarity, so a fraud burst during a payroll week cannot be silently laundered by surrounding legitimate traffic; the similarity between live drain and historical drain must be high AND the historical consistency must be established AND the evidence factor must be saturated. All three are required to lift the contextual confidence score.

The combined effect is that the operational UI surfaces three orthogonal diagnostics simultaneously:

  • Live EWMA confidence_score — short-horizon, what is happening right now.
  • confidence_score_with_history — long-horizon, is this rate consistent with what we have seen recently.
  • requires_human_review evidence payload — what features drove the score and which benign explanations the detector considers.

A judge or auditor reviewing a payroll-week "anomaly" can immediately see: the live score is high, but the historical-context similarity is also high and the consistency is established, so the contextual confidence is bounded — now the human reviewer makes the call. This is the architectural mechanism that systematically reduces false-positive anomalies during legitimate high-velocity events without ever softening the live detector's threshold.


For the live cluster URLs and judge verification commands, see Production Deployment (Judge-Accessible) at the top of this README.

About

LiquiGuard: Enterprise-grade, multi-provider liquidity command center featuring real-time 12-minute rolling EWMA forecasting, anomaly detection, and an indexed 60-day PostgreSQL historical analytics layer with live transactional SSE streaming.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages