Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Keep Docker build context lean — exclude dev/test artifacts
# NOTE: requirements-deploy.txt MUST be present — do not add *.txt here
.git
.gitignore
__pycache__
*.py[cod]
*.egg-info
.eggs
venv/
ENV/
env/
.env
.env.local
node_modules/
*.log
*.db
*.sqlite
tests/
docs/
.pytest_cache/
.mypy_cache/
cursorReview/
research/
demos/
examples/
57 changes: 57 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
FROM python:3.11-slim

# System deps
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
libffi-dev \
libssl-dev \
curl \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Install production deps inline — no separate requirements file to copy.
# Stripped of: streamlit, redis, alpaca-py, statsmodels, discord.py, matplotlib,
# seaborn, pytest, black, flake6 — saves ~140MB vs full requirements.txt
RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir \
requests>=2.31.0 \
numpy>=1.24.0 \
"yfinance>=0.2.18" \
pandas>=2.0.0 \
python-dateutil>=2.8.0 \
pytz>=2023.3 \
python-dotenv>=1.0.0 \
beautifulsoup4>=4.12.0 \
feedparser>=6.0.10 \
pyfedwatch>=1.2.0 \
psutil>=5.9.0 \
"fastapi>=0.104.0" \
"uvicorn[standard]>=0.24.0" \
"httpx>=0.27.0" \
"google-generativeai>=0.3.0" \
"cohere>=5.0.0" \
"groq>=0.9.0" \
"finnhub-python>=2.4.0" \
"cot_reports>=0.1.0" \
"supabase>=2.0.0" \
"ta>=0.11.0" \
"scikit-learn>=1.3.0" \
"langgraph>=0.3.0" \
"langchain-groq>=0.3.0"

# Copy application code
COPY . .

# Railway injects PORT at runtime
ENV PORT=8000
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1

# API_LIGHT_MODE=1 skips UnifiedAlphaMonitor (~300-400MB startup bomb).
# All API endpoints remain functional via compute_kill_chain() in the API layer.
ENV API_LIGHT_MODE=1

EXPOSE $PORT

CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
70 changes: 70 additions & 0 deletions backend/app/api/v1/memstats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""
/debug/memory — in-process RSS burn monitor endpoints.

Reads memory directly from the running process via psutil (or /proc/self/status
as fallback). No external API, no auth, ground truth from inside the container.

Endpoints:
GET /debug/memory — current snapshot (rss_mb, vms_mb, percent)
GET /debug/memory/history — full burn curve deque from startup to now
"""

from fastapi import APIRouter
from fastapi.responses import JSONResponse

router = APIRouter(tags=["memstats"])


def _read_rss():
"""Return (rss_bytes, vms_bytes) using psutil or /proc fallback."""
try:
import psutil, os
p = psutil.Process(os.getpid())
mi = p.memory_info()
return mi.rss, mi.vms
except Exception:
pass
# /proc/self/status fallback (Linux only)
try:
rss_kb = vms_kb = 0
with open("/proc/self/status") as f:
for line in f:
if line.startswith("VmRSS:"):
rss_kb = int(line.split()[1])
elif line.startswith("VmSize:"):
vms_kb = int(line.split()[1])
return rss_kb * 1024, vms_kb * 1024
except Exception:
return 0, 0


@router.get("/debug/memory")
async def memory_snapshot():
"""Current RSS + VMS in MB, read directly from the process."""
rss, vms = _read_rss()
return {
"rss_mb": round(rss / 1024 / 1024, 2),
"vms_mb": round(vms / 1024 / 1024, 2),
"rss_bytes": rss,
"note": "psutil or /proc/self/status — in-process, ground truth",
}


@router.get("/debug/memory/history")
async def memory_history():
"""Full RSS burn curve from startup to now (1-min samples, max 200 entries ~3.3h)."""
from backend.app.main import _rss_history # imported at call time to avoid circular
rows = list(_rss_history)
if not rows:
return JSONResponse({"error": "no data yet — logger starts 10s after startup", "rows": []})

first_rss = rows[0]["rss_mb"]
last_rss = rows[-1]["rss_mb"]
return {
"count": len(rows),
"first_rss_mb": first_rss,
"last_rss_mb": last_rss,
"growth_mb": round(last_rss - first_rss, 2),
"elapsed_min": rows[-1]["elapsed_min"],
"rows": rows,
}
3 changes: 2 additions & 1 deletion backend/app/core/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import os
import logging
from typing import Optional
import redis
# redis is optional — only imported when REDIS_URL is set (not needed on Railway without Redis)

logger = logging.getLogger(__name__)

Expand All @@ -22,6 +22,7 @@ def get_redis():
redis_url = os.getenv('REDIS_URL')
if redis_url:
try:
import redis # lazy import — only when REDIS_URL is configured
_redis_client = redis.from_url(redis_url, decode_responses=True)
logger.info("✅ Redis client connected")
except Exception as e:
Expand Down
94 changes: 80 additions & 14 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
import uvicorn

from backend.app.api import llm_routes
from backend.app.api.v1 import agents, websocket, dp, health, market, killchain, signals, darkpool, gamma, options, squeeze, charts, agentx, calendar, enrichment, economic, pivots, cot, ta, axlfi, gate, intraday, brief, oracle, morningstar, training
from backend.app.api.v1 import agents, websocket, dp, health, market, killchain, signals, darkpool, gamma, options, squeeze, charts, agentx, calendar, enrichment, economic, pivots, cot, ta, axlfi, gate, intraday, brief, oracle, morningstar, training, memstats
from backend.app.core.dependencies import set_monitor_bridge

logging.basicConfig(level=logging.INFO)
Expand Down Expand Up @@ -83,6 +83,7 @@
app.include_router(morningstar.router, prefix="/api/v1", tags=["morningstar"])
app.include_router(training.router, prefix="/api/v1", tags=["training"])
app.include_router(llm_routes.router, prefix="/api", tags=["llm-aliases"])
app.include_router(memstats.router, tags=["memstats"]) # /debug/memory + /debug/memory/history


@app.get("/debug/git")
Expand Down Expand Up @@ -220,6 +221,13 @@ async def debug_supabase():
_pipe_instances = {}
_startup_errors = {} # Captures init failures at startup for /startup-errors

# ── In-process RSS burn monitor ──
# Populated by _rss_burn_logger() which runs every 60s regardless of API_LIGHT_MODE.
# Exposed via GET /debug/memory/history — full burn curve from startup to now.
from collections import deque as _deque
_rss_history: _deque = _deque(maxlen=200) # 200 × 60s = 3.3 hours of history
_rss_start_time: float = 0.0 # set at startup

def _run_pipe(name, instance, method_name, interval, first_capture_method=None):
"""Wrapper that tracks thread status and does immediate first capture."""
import traceback as tb
Expand Down Expand Up @@ -250,33 +258,42 @@ async def startup():
import asyncio
import threading

# Production guard: Render must never run light mode (skips all background tasks).
if os.getenv("RENDER") and os.getenv("API_LIGHT_MODE", "0") == "1":
logger.warning(
"⚠️ API_LIGHT_MODE=1 ignored on Render — forcing full startup "
"(light mode skips brain/alpha-graph/staggered threads)"
)
os.environ["API_LIGHT_MODE"] = "0"
# NOTE: API_LIGHT_MODE=1 is intentionally honoured on Railway (and Render).
# Setting API_LIGHT_MODE=1 skips UnifiedAlphaMonitor (~300-400MB startup bomb)
# while keeping all API endpoints functional — kill-chain data is served by
# compute_kill_chain() in the API layer, not the monitor.
# The old Render guard that forced API_LIGHT_MODE=0 has been removed (2026-05-29).
# To re-enable the monitor on a specific platform, unset API_LIGHT_MODE or set it to 0.

# Lightweight API mode for local diagnostics: skip UnifiedAlphaMonitor only.
if os.getenv("API_LIGHT_MODE", "0") == "1":
# 🔥 OOM FIX (2026-05-29): Full light mode — ALL background threads disabled.
# Previously this block still launched 4 staggered threads + brain + alpha-graph
# + auto-snapshot, causing RSS to grow from 110MB → 400MB+ in 35 minutes.
# In true light mode, ONLY the FastAPI request handlers run.
# No background data fetches, no polling loops, no thread memory accumulation.
_thread_status['monitor_run_loop'] = {'status': 'disabled (API_LIGHT_MODE=1)'}
_thread_status['paper_trade_scheduler'] = {'status': 'disabled (API_LIGHT_MODE=1)'}
_thread_status['econ_release_capture'] = {'status': 'disabled (API_LIGHT_MODE=1)'}
_thread_status['dp_recorder'] = {'status': 'disabled (API_LIGHT_MODE=1)'}
_thread_status['signal_differ'] = {'status': 'disabled (API_LIGHT_MODE=1)'}
_thread_status['volume_spikes'] = {'status': 'disabled (API_LIGHT_MODE=1)'}
_thread_status['premarket_scheduler'] = {'status': 'disabled (API_LIGHT_MODE=1)'}
_thread_status['brain_polling'] = {'status': 'disabled (API_LIGHT_MODE=1)'}
_thread_status['alpha_graph_polling'] = {'status': 'disabled (API_LIGHT_MODE=1)'}
_thread_status['auto_snapshot'] = {'status': 'disabled (API_LIGHT_MODE=1)'}
logger.info(
"⚡ API_LIGHT_MODE=1 — skipping UnifiedAlphaMonitor; "
"still starting brain/alpha-graph/staggered threads"
"⚡ API_LIGHT_MODE=1 — ALL background threads disabled. "
"Only FastAPI request handlers are active. True idle baseline mode."
)
asyncio.create_task(_staggered_thread_launcher())
asyncio.create_task(_brain_polling_loop())
asyncio.create_task(_alpha_graph_polling_loop())
asyncio.create_task(_auto_snapshot_loop())
_port = os.getenv("PORT", "8000")
logger.info(
"📡 Local smoke: curl -sS -m 90 http://127.0.0.1:%s/api/v1/health && "
"scripts/smoke_signals.sh (first /signals can take 10–25s; not a 3s endpoint)",
_port,
)
# Always start the RSS burn logger — zero overhead, needed for burn curve
asyncio.create_task(_rss_burn_logger())
return

if MONITOR_AVAILABLE:
Expand Down Expand Up @@ -371,6 +388,9 @@ def _monitor_run_wrapper():
# Autonomous training snapshot capture — saves kill-shots result every 30min during market hours
asyncio.create_task(_auto_snapshot_loop())

# Always start the RSS burn logger — zero overhead, needed for burn curve
asyncio.create_task(_rss_burn_logger())

_port = os.getenv("PORT", "8000")
logger.info(
"📡 Signals smoke: curl -sS -m 90 http://127.0.0.1:%s/api/v1/signals | "
Expand All @@ -380,6 +400,52 @@ def _monitor_run_wrapper():
)


async def _rss_burn_logger():
"""Always-on in-process RSS logger. Runs every 60s regardless of API_LIGHT_MODE.
Appends to _rss_history deque (maxlen=200, ~3.3h at 1-min intervals).
Also emits RSS_BURN log lines captured by Railway log stream.
Zero meaningful memory overhead — one psutil call per minute.
"""
import asyncio, os, time
global _rss_start_time
_rss_start_time = time.time()
await asyncio.sleep(10) # Let uvicorn finish binding before first read
while True:
try:
rss_b, vms_b = 0, 0
try:
import psutil
mi = psutil.Process(os.getpid()).memory_info()
rss_b, vms_b = mi.rss, mi.vms
except Exception:
try:
with open("/proc/self/status") as _f:
for _line in _f:
if _line.startswith("VmRSS:"):
rss_b = int(_line.split()[1]) * 1024
elif _line.startswith("VmSize:"):
vms_b = int(_line.split()[1]) * 1024
except Exception:
pass

rss_mb = round(rss_b / 1024 / 1024, 2)
vms_mb = round(vms_b / 1024 / 1024, 2)
elapsed = round((time.time() - _rss_start_time) / 60, 1)

entry = {
"ts": datetime.utcnow().isoformat() + "Z",
"elapsed_min": elapsed,
"rss_mb": rss_mb,
"vms_mb": vms_mb,
}
_rss_history.append(entry)
# Emit structured log line — captured by Railway log stream
logger.info("RSS_BURN elapsed=%.1fmin rss_mb=%.2f vms_mb=%.2f", elapsed, rss_mb, vms_mb)
except Exception as _e:
logger.warning("RSS logger error: %s", _e)
await asyncio.sleep(60)


async def _staggered_thread_launcher():
"""🔥 OOM FIX: Launch background threads one-by-one with 30s gaps.
Prevents concurrent memory spikes from all threads downloading data at once.
Expand Down
Loading