A locally-hosted halal-DCA research pipeline. FastAPI + LangGraph backend, React + strict-TypeScript frontend (Vite), two interchangeable LLM providers (OpenRouter and LMStudio), and a user-managed portfolio the pipeline reads but never writes.
investment-app/
├── apps/
│ └── web/ # React + strict TS frontend (Vite)
│ ├── src/
│ │ ├── components/ # TopBar, Sidebar, ResultsPanel, …
│ │ ├── hooks/ # useRun (WS lifecycle, ETA tracking)
│ │ ├── lib/ # api, storage, format, eta, nodes
│ │ ├── state/ # Settings context provider
│ │ ├── types/api.ts # backend-mirroring type definitions
│ │ ├── App.tsx
│ │ └── main.tsx
│ ├── package.json
│ ├── tsconfig.json # strict + noUncheckedIndexedAccess
│ └── vite.config.ts
├── backend/ # FastAPI + LangGraph (Python ≥ 3.10)
│ ├── main.py # WS server + REST + static mount
│ ├── graph.py # LangGraph workflows (one per mode)
│ ├── agents.py # System prompts (every prompt sees user portfolio)
│ ├── llm.py # OpenRouter / LMStudio client
│ ├── search.py # DuckDuckGo (default) + optional Tavily
│ ├── storage.py # User portfolio CRUD with atomic writes
│ ├── runs.py # Run registry + on-disk history with quota
│ ├── pipeline_config.py # Halal rules + AUM-tier loader
│ ├── config.py # Env-driven runtime configuration
│ ├── security.py # Auth, run-id validation, SSRF guard, rate limit
│ ├── logging_setup.py # Structured logging (text or JSON)
│ ├── hardware.py # RAM/GPU detection + ctx-length recommendation
│ └── py.typed # PEP 561 marker
├── data/ # Persistent portfolio + run-history (created on first run)
├── docs/
│ ├── ARCHITECTURE.md
│ ├── DEPLOYMENT.md
│ └── PUBLISHING.md
├── scripts/
│ ├── start.sh # macOS / Linux / WSL launcher
│ └── start.bat # Windows launcher
├── tests/ # pytest smoke + unit tests
├── .github/workflows/ci.yml # Lint + typecheck + tests on every push
├── compose.yml # Single-service compose for laptop / NAS
├── Dockerfile # Multi-stage: web-builder + py-builder + runtime
├── pyproject.toml # Pinned deps, strict ruff + mypy config
├── .env.example # Annotated reference for all NEBU_* env vars
├── .gitignore
├── .dockerignore
├── CHANGELOG.md
├── CONTRIBUTING.md
└── LICENSE # MIT
# Windows
scripts\start.bat
# macOS / Linux / WSL
chmod +x scripts/start.sh && scripts/start.shThe launcher prepares .venv, installs the backend, builds the frontend
bundle if missing, then starts uvicorn. Open http://127.0.0.1:8000.
Both launchers detect
uvand use it when present; otherwise they fall back topython -m venv+ pip. Frontend builds need Node.js 20+.
# 1. Backend
uv venv .venv
source .venv/bin/activate # .venv\Scripts\activate.bat on Windows
uv pip install -e ".[dev]" # add ".[dev]" for tests + lint + mypy
# 2. Frontend
(cd apps/web && npm install && npm run build)
# 3. Run
python -m uvicorn backend.main:app --host 127.0.0.1 --port 8000Two terminals, the frontend gets HMR and the backend gets --reload:
# Terminal A
python -m uvicorn backend.main:app --host 127.0.0.1 --port 8000 --reload
# Terminal B (proxies /api → 127.0.0.1:8000, including WebSockets)
cd apps/web && npm run devOpen http://127.0.0.1:5173.
| # | Mode | Pipeline |
|---|---|---|
| 1 | EGP Safe — halal Egyptian funds/ETFs | Consolidation → Halal + Macro → Selection → Rebalance + Purification → Audit |
| 2 | US Safe — halal US ETFs (SPUS, HLAL, UMMA, SPRE, SPSK) | same six layers |
| 3 | Single Stock — adversarial Bull/Bear council, gated at $25k AUM | Preconditions → Universe → Bull + Bear → Scenario → Selector → Audit |
| 4 | Financial Q&A — open question, web-cited answer | Plan → Search → Synthesize |
The Consolidation Advisor is a Step-0 hard gate. When it returns
CONSOLIDATE or DCA_EXISTING, the analysis chain is short-circuited and
the Selection is synthesized deterministically from the user's existing halal
core. Single-stock mode refuses to run until AUM ≥ $25,000.
The sidebar Treasury is the single source of truth for what you hold,
stored at data/portfolio.json. The pipeline never writes to it.
- You add positions manually after you've placed a real trade.
- Every mode reads your portfolio fresh on every request.
- FX is captured per-trade — there is no global mutable FX setting.
- Each position carries: name, ticker, type, market, qty, unit cost, currency,
fx_rate_at_purchase(required for non-USD), halal status, and notes.
Position records survive a partial-write crash thanks to a tmp-file +
os.replace write path with a data/portfolio.backup.json snapshot.
Both providers go through the same OpenAI-compatible chat/completions
endpoint, so prompts and graph wiring are identical.
- Get an API key from https://openrouter.ai/keys.
- Settings drawer → OpenRouter → paste the key. The key lives in
sessionStorageby default (cleared on tab close); tick "Remember key on this device" to mirror it tolocalStorage.
- Install LMStudio, download a JSON-friendly model.
- Developer → Start Server on port
1234. - Settings drawer → LMStudio → set model id and base URL
(
http://localhost:1234/v1). Click Detect for a context-length recommendation based on your RAM/VRAM.
The backend's SSRF guard restricts LMStudio base_url to loopback + private
LAN ranges. Override with NEBU_LMSTUDIO_HOSTS if you have a remote gateway
on a public host.
The default is local-only and conservative; opt into wider exposure
explicitly. See docs/DEPLOYMENT.md for the full env
matrix.
- Bind:
127.0.0.1:8000by default. SetNEBU_HOST=0.0.0.0to expose on the LAN — and setNEBU_AUTH_TOKENfirst. - CORS limited to the bound origin by default.
- TrustedHost auto-enabled when
NEBU_HOSTis non-localhost. - Rate limit per-IP — 6 runs/minute and 30 probes/minute by default.
- Run-id validation: every
{run_id}is hex-only — no path traversal. - Atomic writes: portfolio + run history use tmp + rename.
- CSP served on every response. Frame-ancestors
none, scripts'self', fonts whitelisted to Google Fonts. - Logging: human-readable text by default; set
NEBU_LOG_JSON=1for line-delimited JSON.
# Python
ruff check backend/
mypy backend/ # strict on the backend package
pytest -q
# Frontend
cd apps/web
npm run typecheck # tsc -b --noEmit, strict TS
npm run lint
npm run buildCI runs the same set on every push — see .github/workflows/ci.yml.
docker compose up -d
docker compose logs -fThe compose file binds 127.0.0.1:8000 on the host. Edit the port mapping
and set NEBU_AUTH_TOKEN before exposing publicly. The nebu-data volume
holds the portfolio + run history.
GET / React bundle
GET /api/health liveness + writability probe (no auth)
GET /api/modes mode metadata
GET /api/openrouter-models curated dropdown of common models
POST /api/probe round-trip test of an LLM config
GET /api/hardware RAM/GPU/CPU snapshot
GET /api/lmstudio/models models loaded in LMStudio
POST /api/recommend-context hardware-aware ctx-length suggestion
GET /api/portfolio read user portfolio + aggregates
POST /api/portfolio/positions add a position
PUT /api/portfolio/positions/{id} update a position
DELETE /api/portfolio/positions/{id} remove a position
PUT /api/portfolio/settings update base_currency / notes
PUT /api/portfolio/prices bulk price update
PUT /api/portfolio/fx bulk FX update
GET /api/portfolio/concentration AUM tier + breaches (no LLM)
POST /api/runs start a pipeline run
GET /api/runs in-memory active runs
GET /api/runs/history on-disk finished-run index
DELETE /api/runs/history wipe history
GET /api/runs/{run_id} run + events
POST /api/runs/{run_id}/cancel cancel an in-flight run
GET /api/runs/{run_id}/export?format=md Markdown export
DELETE /api/runs/{run_id} delete a finished run
WS /api/runs/{run_id}/stream live event stream (?since=N to resume)
WebSocket events stream in order: pipeline_start → node_start / activity → search → result → done / error. The connection survives reloads because runs
live in the backend; the React client re-attaches with a since=N offset.
If you keep the app inside the parent investing/ repo, it picks up
pipeline/halal-rules.json and injects the canonical AAOIFI / MSCI screens
into every Halal-Screener prompt. Drop the folder anywhere else and the app
falls back to embedded defaults — it stays portable.
MIT — see LICENSE.