Skip to content
Merged
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
29 changes: 29 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
.git
.worktrees
.gammascope
.idea
.vscode
.superpowers
.DS_Store

.env
.env.*

node_modules
**/node_modules
.next
**/.next
coverage
dist
*.tsbuildinfo

__pycache__
*.py[cod]
.pytest_cache
.ruff_cache
.mypy_cache
.venv
venv
*.egg-info

*.log
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
.env
.env.*
!.env.example
ops/amh-nginx/gammascope.production.env
ops/amh-nginx/gammascope.collector-client.env

# Node
node_modules/
Expand Down
13 changes: 5 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

GammaScope is being built in slices. The first slice establishes the local monorepo, shared contracts, seeded replay data, and smoke-testable API/web surfaces.

Deployment notes for the current Moomoo-backed dashboard and heatmap stack are in [docs/deployment.md](docs/deployment.md).
Deployment notes for the current Moomoo-backed dashboard and heatmap stack are in [docs/deployment.md](docs/deployment.md). For the AMH/Nginx remote server layout where your computer publishes Moomoo data to a server-hosted backend and frontend, use [docs/amh-nginx-server-setup.md](docs/amh-nginx-server-setup.md).

Run:

Expand Down Expand Up @@ -93,21 +93,18 @@ Set private mode when the API may be reachable by non-admin users:

`GAMMASCOPE_PRIVATE_MODE=true` is also accepted. Truthy values are `1`, `true`, `yes`, `on`, and `enabled`.

In private mode, public replay remains open:
In private mode, public viewing remains open. Live snapshots, live status, scenarios, live WebSocket updates, replay, heatmap, and experimental analytics do not require an admin token:

curl -s http://127.0.0.1:8000/api/spx/0dte/replay/sessions | python -m json.tool
curl -s "http://127.0.0.1:8000/api/spx/0dte/replay/snapshot?session_id=seed-spx-2026-04-23" | python -m json.tool
curl -s http://127.0.0.1:8000/api/spx/0dte/snapshot/latest | python -m json.tool

Live collector state requires the admin token:
Collector ingestion, raw collector state, replay imports, and maintenance/admin operations require the admin token:

curl -s -H "X-GammaScope-Admin-Token: local-admin-token" \
http://127.0.0.1:8000/api/spx/0dte/collector/state | python -m json.tool

The live WebSocket accepts the same header, or `admin_token` as a query parameter for simple local clients:

ws://127.0.0.1:8000/ws/spx/0dte?admin_token=local-admin-token

Without a valid admin token, private-mode latest snapshot, status, and scenario requests use seeded replay/fallback data instead of live collector state. Saved-view public requests list only `owner_scope: "public_demo"`; creating or listing admin scoped views requires the admin token. If `GAMMASCOPE_ADMIN_TOKEN` is unset or blank, private admin operations return `403`.
Saved-view public requests list only `owner_scope: "public_demo"`; creating or listing admin scoped views requires the admin token. If `GAMMASCOPE_ADMIN_TOKEN` is unset or blank, private admin operations return `403`.

### Local IBKR Health Probe

Expand Down
20 changes: 20 additions & 0 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app/apps/api:/app/services/collector

WORKDIR /app

RUN python -m pip install --no-cache-dir --upgrade pip

COPY apps/api/pyproject.toml apps/api/pyproject.toml
COPY apps/api/gammascope_api apps/api/gammascope_api
COPY packages/contracts/fixtures packages/contracts/fixtures
COPY services/collector/gammascope_collector services/collector/gammascope_collector

RUN python -m pip install --no-cache-dir ./apps/api moomoo-api pandas

EXPOSE 8000

CMD ["python", "-m", "uvicorn", "gammascope_api.main:app", "--app-dir", "apps/api", "--host", "0.0.0.0", "--port", "8000"]
11 changes: 3 additions & 8 deletions apps/api/gammascope_api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,13 @@
import os
import secrets

from fastapi import HTTPException, WebSocket
from fastapi import HTTPException


ADMIN_TOKEN_ENV = "GAMMASCOPE_ADMIN_TOKEN"
PRIVATE_MODE_ENABLED_ENV = "GAMMASCOPE_PRIVATE_MODE_ENABLED"
PRIVATE_MODE_LEGACY_ENV = "GAMMASCOPE_PRIVATE_MODE"
ADMIN_TOKEN_HEADER = "X-GammaScope-Admin-Token"
ADMIN_TOKEN_QUERY_PARAM = "admin_token"

_TRUTHY_VALUES = {"1", "true", "yes", "on", "enabled"}

Expand Down Expand Up @@ -40,12 +39,8 @@ def require_private_mode_admin_token(token: str | None) -> None:
require_admin_token(token)


def can_read_live_state(token: str | None) -> bool:
return not private_mode_enabled() or is_valid_admin_token(token)


def websocket_admin_token(websocket: WebSocket) -> str | None:
return websocket.headers.get(ADMIN_TOKEN_HEADER) or websocket.query_params.get(ADMIN_TOKEN_QUERY_PARAM)
def can_read_live_state(_token: str | None) -> bool:
return True


def _truthy_env(name: str) -> bool:
Expand Down
5 changes: 0 additions & 5 deletions apps/api/gammascope_api/routes/stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect

from gammascope_api.auth import is_valid_admin_token, private_mode_enabled, websocket_admin_token
from gammascope_api.fixtures import load_json_fixture
from gammascope_api.ingestion.live_snapshot_service import get_live_snapshot_service
from gammascope_api.routes.replay import replay_stream_snapshots, seed_replay_snapshots
Expand All @@ -19,10 +18,6 @@

@router.websocket("/ws/spx/0dte")
async def stream_spx_0dte(websocket: WebSocket) -> None:
if private_mode_enabled() and not is_valid_admin_token(websocket_admin_token(websocket)):
await websocket.close(code=1008)
return

await websocket.accept()
try:
while True:
Expand Down
10 changes: 5 additions & 5 deletions apps/api/tests/test_heatmap_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ def test_latest_heatmap_route_fallback_does_not_write_configured_repository() ->
assert repository.snapshot_upserts == 0


def test_latest_heatmap_route_private_fallback_does_not_write_configured_repository(monkeypatch) -> None:
def test_latest_heatmap_route_private_mode_returns_public_live_heatmap(monkeypatch) -> None:
monkeypatch.setenv("GAMMASCOPE_PRIVATE_MODE_ENABLED", "true")
monkeypatch.setenv("GAMMASCOPE_ADMIN_TOKEN", "local-admin-token")
repository = _RecordingHeatmapRepository()
Expand All @@ -162,10 +162,10 @@ def test_latest_heatmap_route_private_fallback_does_not_write_configured_reposit
response = client.get("/api/spx/0dte/heatmap/latest")

assert response.status_code == 200
assert response.json()["sessionId"] != "moomoo-spx-0dte-live"
assert response.json()["persistenceStatus"] == "skipped"
assert repository.baseline_upserts == 0
assert repository.snapshot_upserts == 0
assert response.json()["sessionId"] == "moomoo-spx-0dte-live"
assert response.json()["persistenceStatus"] == "persisted"
assert repository.baseline_upserts == 1
assert repository.snapshot_upserts == 1


class _RecordingHeatmapRepository(InMemoryHeatmapRepository):
Expand Down
31 changes: 16 additions & 15 deletions apps/api/tests/test_private_mode.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import pytest
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect

from gammascope_api.ingestion.collector_state import collector_state
from gammascope_api.ingestion.latest_state_cache import (
Expand Down Expand Up @@ -120,7 +119,7 @@ def test_collector_ingest_validation_errors_keep_body_locations(
assert all("url" not in error for error in response.json()["detail"])


def test_private_mode_latest_snapshot_hides_live_state_without_admin_token(monkeypatch: pytest.MonkeyPatch) -> None:
def test_private_mode_latest_snapshot_is_public_without_admin_token(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GAMMASCOPE_PRIVATE_MODE_ENABLED", "true")
monkeypatch.setenv("GAMMASCOPE_ADMIN_TOKEN", "local-admin-token")

Expand All @@ -138,14 +137,14 @@ def test_private_mode_latest_snapshot_hides_live_state_without_admin_token(monke
)

assert public_response.status_code == 200
assert public_response.json()["mode"] == "replay"
assert public_response.json()["session_id"] == "seed-spx-2026-04-23"
assert public_response.json()["mode"] == "live"
assert public_response.json()["session_id"] == "private-live-session"
assert admin_response.status_code == 200
assert admin_response.json()["mode"] == "live"
assert admin_response.json()["session_id"] == "private-live-session"


def test_private_mode_status_and_scenario_hide_live_state_without_admin_token(monkeypatch: pytest.MonkeyPatch) -> None:
def test_private_mode_status_and_scenario_are_public_without_admin_token(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GAMMASCOPE_PRIVATE_MODE_ENABLED", "true")
monkeypatch.setenv("GAMMASCOPE_ADMIN_TOKEN", "local-admin-token")

Expand Down Expand Up @@ -180,9 +179,9 @@ def test_private_mode_status_and_scenario_hide_live_state_without_admin_token(mo
)

assert public_status_response.status_code == 200
assert public_status_response.json()["message"] != "Mock live cycle"
assert public_status_response.json()["message"] == "Mock live cycle"
assert public_scenario_response.status_code == 200
assert public_scenario_response.json()["session_id"] == "seed-spx-2026-04-23"
assert public_scenario_response.json()["session_id"] == "private-scenario-session"
assert admin_scenario_response.status_code == 200
assert admin_scenario_response.json()["session_id"] == "private-scenario-session"

Expand All @@ -206,20 +205,22 @@ def test_private_mode_keeps_replay_rest_open(monkeypatch: pytest.MonkeyPatch) ->
assert snapshot_response.json()["mode"] == "replay"


def test_private_mode_live_websocket_requires_token_and_accepts_query_token(monkeypatch: pytest.MonkeyPatch) -> None:
def test_private_mode_live_websocket_is_public_without_admin_token(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GAMMASCOPE_PRIVATE_MODE_ENABLED", "true")
monkeypatch.setenv("GAMMASCOPE_ADMIN_TOKEN", "local-admin-token")

with pytest.raises(WebSocketDisconnect) as disconnect:
with client.websocket_connect("/ws/spx/0dte") as websocket:
websocket.receive_json()
for event in _live_events("private-websocket-session"):
assert client.post(
"/api/spx/0dte/collector/events",
json=event,
headers={"X-GammaScope-Admin-Token": "local-admin-token"},
).status_code == 200

with client.websocket_connect("/ws/spx/0dte?admin_token=local-admin-token") as websocket:
with client.websocket_connect("/ws/spx/0dte") as websocket:
payload = websocket.receive_json()

assert disconnect.value.code == 1008
assert payload["mode"] == "replay"
assert payload["session_id"] == "seed-spx-2026-04-23"
assert payload["mode"] == "live"
assert payload["session_id"] == "private-websocket-session"


def test_private_mode_keeps_replay_websocket_public(monkeypatch: pytest.MonkeyPatch) -> None:
Expand Down
26 changes: 26 additions & 0 deletions apps/web/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
FROM node:22-slim

ENV PNPM_HOME=/pnpm \
PATH=/pnpm:$PATH \
NEXT_TELEMETRY_DISABLED=1

WORKDIR /app

RUN corepack enable

COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/package.json
COPY packages/contracts/package.json packages/contracts/package.json
RUN pnpm install --frozen-lockfile

COPY apps/web apps/web
COPY packages/contracts packages/contracts

ARG NEXT_PUBLIC_GAMMASCOPE_WS_URL=http://127.0.0.1:8000
ENV NEXT_PUBLIC_GAMMASCOPE_WS_URL=$NEXT_PUBLIC_GAMMASCOPE_WS_URL

RUN pnpm --filter @gammascope/web build

EXPOSE 3000

CMD ["pnpm", "--filter", "@gammascope/web", "exec", "next", "start", "--hostname", "0.0.0.0", "--port", "3000"]
51 changes: 46 additions & 5 deletions apps/web/app/api/spx/0dte/snapshot/latest/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,51 @@
import { NextResponse } from "next/server";
import { loadDashboardSnapshot } from "../../../../../../lib/serverSnapshotSource";
import { verifyAdminRequest } from "../../../../../../lib/adminSession";

export async function GET(request: Request) {
const response = NextResponse.json(await loadDashboardSnapshot({
requestHeaders: request.headers
}));
const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000";
const SNAPSHOT_PATH = "/api/spx/0dte/snapshot/latest";
const ADMIN_TOKEN_HEADER = "X-GammaScope-Admin-Token";

function snapshotUrl(apiBaseUrl: string): string {
return `${apiBaseUrl.replace(/\/+$/, "")}${SNAPSHOT_PATH}`;
}

function noStoreJson(payload: unknown, init?: ResponseInit) {
const response = NextResponse.json(payload, init);
response.headers.set("Cache-Control", "no-store");
return response;
}

function upstreamHeaders(request: Request): HeadersInit {
const headers: Record<string, string> = {
Accept: "application/json"
};
const adminToken = process.env.GAMMASCOPE_ADMIN_TOKEN?.trim();

if (adminToken && verifyAdminRequest(request, { csrf: false }).ok) {
headers[ADMIN_TOKEN_HEADER] = adminToken;
}

return headers;
}

export async function GET(request: Request): Promise<Response> {
const apiBaseUrl = process.env.GAMMASCOPE_API_BASE_URL ?? DEFAULT_API_BASE_URL;

try {
const upstreamResponse = await fetch(snapshotUrl(apiBaseUrl), {
cache: "no-store",
headers: upstreamHeaders(request)
});

const response = new Response(await upstreamResponse.text(), {
status: upstreamResponse.status,
headers: {
"Content-Type": upstreamResponse.headers.get("Content-Type") ?? "application/json"
}
});
response.headers.set("Cache-Control", "no-store");
return response;
} catch {
return noStoreJson({ error: "Snapshot API unavailable" }, { status: 502 });
}
}
11 changes: 10 additions & 1 deletion apps/web/lib/snapshotStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,16 @@ export function snapshotWebSocketUrl(apiBaseUrl = DEFAULT_API_BASE_URL): string
}

export function clientSnapshotWebSocketUrl(): string {
return process.env.NEXT_PUBLIC_GAMMASCOPE_WS_URL || snapshotWebSocketUrl();
const configuredUrl = process.env.NEXT_PUBLIC_GAMMASCOPE_WS_URL;
if (!configuredUrl) {
return snapshotWebSocketUrl();
}

const protocol = new URL(configuredUrl).protocol;
if (protocol === "ws:" || protocol === "wss:") {
return configuredUrl;
}
return snapshotWebSocketUrl(configuredUrl);
}

export function startSnapshotStream({
Expand Down
Loading
Loading