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
49 changes: 31 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ should start from the Moomoo sections below.

Current high-level surfaces:

- Web dashboard: `http://localhost:3000`
- API: `http://127.0.0.1:8000`
- Local web dashboard: `http://localhost:42130`
- Local API: `http://127.0.0.1:42180`
- Public live snapshot smoke endpoint: `GET /api/spx/0dte/snapshot/latest`
- Moomoo dashboard collector: `pnpm collector:moomoo-snapshot`
- Moomoo ML research recorder: `pnpm collector:moomoo-research-record`
Expand Down Expand Up @@ -40,6 +40,19 @@ Run:
.venv/bin/python -m pip install -e "apps/api[dev]"
pnpm test

### Local Port Assignments

Use these uncommon ports for local development and test runs so GammaScope does not collide with the usual local web/API/database services:

| Service | Local URL or host port | Source |
| --- | --- | --- |
| Web app | `http://127.0.0.1:42130` | `pnpm dev:web` |
| FastAPI backend | `http://127.0.0.1:42180` | `pnpm dev:api` |
| Postgres | `127.0.0.1:42432` -> container `5432` | `docker compose up -d postgres` |
| Redis | `127.0.0.1:42379` -> container `6379` | `docker compose up -d redis` |

Keep the deployed AMH/Nginx server mappings on the documented production ports, currently web `3000` and API `8000`. The uncommon ports above are the repo defaults for local testing only.

### Verification

pnpm install
Expand All @@ -55,10 +68,10 @@ Run:
Run local services:

docker compose up -d
pnpm dev:api
pnpm dev:web
.venv/bin/python -m uvicorn gammascope_api.main:app --reload --app-dir apps/api

Open the local dashboard at `http://localhost:3000`. The dashboard reads the
Open the local dashboard at `http://localhost:42130`. The dashboard reads the
stable SPX 0DTE analytics contract. With live collector state available it shows
live mode; otherwise it falls back to seeded replay data.

Expand Down Expand Up @@ -150,7 +163,7 @@ For now this keeps the latest collector health, contracts, underlying ticks, and

Replay capture now persists replay-ready analytics snapshots to local Postgres when a valid live collector snapshot is available. The API uses:

GAMMASCOPE_DATABASE_URL=postgresql://gammascope:gammascope@127.0.0.1:5432/gammascope
GAMMASCOPE_DATABASE_URL=postgresql://gammascope:gammascope@127.0.0.1:42432/gammascope
GAMMASCOPE_REPLAY_CAPTURE_INTERVAL_SECONDS=5
GAMMASCOPE_REPLAY_RETENTION_DAYS=20
GAMMASCOPE_SAVED_VIEW_RETENTION_DAYS=90
Expand All @@ -164,15 +177,15 @@ With the API running, publish the mock collector cycle into that ingestion endpo

Then inspect the live-mode analytics snapshot assembled from the ingested collector state:

curl -s http://127.0.0.1:8000/api/spx/0dte/snapshot/latest | python -m json.tool
curl -s http://127.0.0.1:42180/api/spx/0dte/snapshot/latest | python -m json.tool

To test the dashboard against local API state, run the API, publish the mock cycle, then start the web app with the API base URL:

pnpm dev:api
pnpm collector:publish-mock -- --spot 5200.25 --expiry 2026-04-24 --strikes 5190,5200,5210
GAMMASCOPE_API_BASE_URL=http://127.0.0.1:8000 pnpm dev:web
GAMMASCOPE_API_BASE_URL=http://127.0.0.1:42180 pnpm dev:web

Open `http://localhost:3000`. After the mock publish populates API state, the dashboard should show Live mode; if the API is unavailable, the web app falls back to the seeded replay snapshot. After page load, the dashboard connects to `ws://127.0.0.1:8000/ws/spx/0dte` for live snapshot updates and falls back to once-per-second polling if the WebSocket is unavailable. Set `NEXT_PUBLIC_GAMMASCOPE_WS_URL` when the WebSocket endpoint is not on the default local API host.
Open `http://localhost:42130`. After the mock publish populates API state, the dashboard should show Live mode; if the API is unavailable, the web app falls back to the seeded replay snapshot. After page load, the dashboard connects to `ws://127.0.0.1:42180/ws/spx/0dte` for live snapshot updates and falls back to once-per-second polling if the WebSocket is unavailable. Set `NEXT_PUBLIC_GAMMASCOPE_WS_URL` when the WebSocket endpoint is not on the default local API host.

The dashboard also includes lightweight saved views for local testing. Saved views are validated against the shared contract, proxied through the Next.js app, and persisted in Postgres using `GAMMASCOPE_DATABASE_URL` when available. If the Postgres-backed repository is unavailable at runtime, the FastAPI route falls back to in-memory saved views so local dashboard flows keep working.

Expand All @@ -190,14 +203,14 @@ Set private mode when the API may be reachable by non-admin users:

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
curl -s http://127.0.0.1:42180/api/spx/0dte/replay/sessions | python -m json.tool
curl -s "http://127.0.0.1:42180/api/spx/0dte/replay/snapshot?session_id=seed-spx-2026-04-23" | python -m json.tool
curl -s http://127.0.0.1:42180/api/spx/0dte/snapshot/latest | python -m json.tool

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
http://127.0.0.1:42180/api/spx/0dte/collector/state | python -m json.tool

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`.

Expand Down Expand Up @@ -273,13 +286,13 @@ Use `--market-data-type 3` to force delayed streaming during market hours, or `-

After publishing, inspect captured replay sessions:

curl -s http://127.0.0.1:8000/api/spx/0dte/replay/sessions | python -m json.tool
curl -s http://127.0.0.1:42180/api/spx/0dte/replay/sessions | python -m json.tool

Use the captured `session_id` to replay the persisted IBKR snapshot:

curl -s "http://127.0.0.1:8000/api/spx/0dte/replay/snapshot?session_id=<captured-session-id>" | python -m json.tool
curl -s "http://127.0.0.1:42180/api/spx/0dte/replay/snapshot?session_id=<captured-session-id>" | python -m json.tool

Then open `http://localhost:3000`, use the replay controls, and pick the captured session. The seeded replay session remains available as a fallback demo.
Then open `http://localhost:42130`, use the replay controls, and pick the captured session. The seeded replay session remains available as a fallback demo.

### Local Moomoo 0DTE Snapshot

Expand Down Expand Up @@ -411,7 +424,7 @@ and still safely before open during standard-time months.

### SPX 0DTE Exposure Heatmap

The latest-ladder heatmap page is available at `http://localhost:3000/heatmap` when the web app is running.
The latest-ladder heatmap page is available at `http://localhost:42130/heatmap` when the web app is running.

The backend API is:

Expand Down Expand Up @@ -440,14 +453,14 @@ The `.gammascope/` directory is ignored by git and must stay local. Do not commi

For local maintenance testing, run a default-safe dry run of persisted replay and saved-view retention cleanup:

curl -s -X POST "http://127.0.0.1:8000/api/admin/retention/cleanup?dry_run=true" | python -m json.tool
curl -s -X POST "http://127.0.0.1:42180/api/admin/retention/cleanup?dry_run=true" | python -m json.tool

To execute destructive cleanup explicitly:

GAMMASCOPE_ADMIN_TOKEN=local-admin-token pnpm dev:api
curl -s -X POST \
-H "X-GammaScope-Admin-Token: local-admin-token" \
"http://127.0.0.1:8000/api/admin/retention/cleanup?dry_run=false" | python -m json.tool
"http://127.0.0.1:42180/api/admin/retention/cleanup?dry_run=false" | python -m json.tool

If `GAMMASCOPE_ADMIN_TOKEN` is unset or blank, destructive cleanup is disabled and returns `403`. Cleanup only targets Postgres-persisted replay snapshots/sessions and saved views. The seeded replay fixture remains untouched.

Expand Down
2 changes: 1 addition & 1 deletion apps/api/gammascope_api/ingestion/latest_state_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from gammascope_api.ingestion.collector_state import CollectorState, collector_state

DEFAULT_REDIS_URL = "redis://127.0.0.1:6379/0"
DEFAULT_REDIS_URL = "redis://127.0.0.1:42379/0"
DEFAULT_REDIS_TIMEOUT_SECONDS = 0.25
LATEST_STATE_KEY = "gammascope:collector:latest_state"

Expand Down
2 changes: 1 addition & 1 deletion apps/api/gammascope_api/replay/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from gammascope_api.replay.import_repository import PostgresReplayImportRepository, ReplayImportRepository
from gammascope_api.replay.repository import PostgresReplayRepository, ReplayRepository

DEFAULT_DATABASE_URL = "postgresql://gammascope:gammascope@127.0.0.1:5432/gammascope"
DEFAULT_DATABASE_URL = "postgresql://gammascope:gammascope@127.0.0.1:42432/gammascope"
DEFAULT_CAPTURE_INTERVAL_SECONDS = 5

_repository_override: ReplayRepository | None = None
Expand Down
2 changes: 1 addition & 1 deletion apps/api/tests/test_replay_import_real_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from gammascope_api.replay.import_repository import PostgresReplayImportRepository


TEST_DATABASE_URL = "postgresql://gammascope:gammascope@127.0.0.1:5432/gammascope"
TEST_DATABASE_URL = "postgresql://gammascope:gammascope@127.0.0.1:42432/gammascope"


def test_real_april_22_baseline_validates_when_present(
Expand Down
2 changes: 1 addition & 1 deletion apps/api/tests/test_replay_import_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from replay_parquet_fixtures import write_replay_parquet_pair


TEST_DATABASE_URL = "postgresql://gammascope:gammascope@127.0.0.1:5432/gammascope"
TEST_DATABASE_URL = "postgresql://gammascope:gammascope@127.0.0.1:42432/gammascope"
THIS_FILE = Path(__file__)


Expand Down
2 changes: 1 addition & 1 deletion apps/api/tests/test_replay_persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from gammascope_api.replay.repository import PostgresReplayRepository


TEST_DATABASE_URL = "postgresql://gammascope:gammascope@127.0.0.1:5432/gammascope"
TEST_DATABASE_URL = "postgresql://gammascope:gammascope@127.0.0.1:42432/gammascope"


@pytest.fixture()
Expand Down
2 changes: 1 addition & 1 deletion apps/api/tests/test_saved_views_persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from gammascope_api.saved_views.repository import PostgresSavedViewRepository


TEST_DATABASE_URL = "postgresql://gammascope:gammascope@127.0.0.1:5432/gammascope"
TEST_DATABASE_URL = "postgresql://gammascope:gammascope@127.0.0.1:42432/gammascope"


@pytest.fixture()
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/spx/0dte/experimental/latest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import seedExperimentalAnalytics from "../../../../../../../../packages/contracts/fixtures/experimental-analytics.seed.json";
import { verifyAdminRequest } from "../../../../../../lib/adminSession";

const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000";
const DEFAULT_API_BASE_URL = "http://127.0.0.1:42180";
const EXPERIMENTAL_LATEST_PATH = "/api/spx/0dte/experimental/latest";
const ADMIN_TOKEN_HEADER = "X-GammaScope-Admin-Token";

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";

const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000";
const DEFAULT_API_BASE_URL = "http://127.0.0.1:42180";
const EXPERIMENTAL_REPLAY_PATH = "/api/spx/0dte/experimental/replay/snapshot";

function experimentalReplayUrl(apiBaseUrl: string, requestUrl: string): string {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/spx/0dte/heatmap/latest/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { verifyAdminRequest } from "../../../../../../lib/adminSession";

const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000";
const DEFAULT_API_BASE_URL = "http://127.0.0.1:42180";
const HEATMAP_PATH = "/api/spx/0dte/heatmap/latest";
const ADMIN_TOKEN_HEADER = "X-GammaScope-Admin-Token";

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { isReplayTimestampResponse } from "../../../../../../../../lib/clientReplaySource";

const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000";
const DEFAULT_API_BASE_URL = "http://127.0.0.1:42180";
const REPLAY_SESSIONS_PATH = "/api/spx/0dte/replay/sessions";

interface ReplayTimestampsRouteContext {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/spx/0dte/replay/sessions/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { isReplaySessionArray } from "../../../../../../lib/clientReplaySource";

const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000";
const DEFAULT_API_BASE_URL = "http://127.0.0.1:42180";
const REPLAY_SESSIONS_PATH = "/api/spx/0dte/replay/sessions";

function replaySessionsUrl(apiBaseUrl: string): string {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/spx/0dte/replay/snapshot/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { isAnalyticsSnapshot } from "../../../../../../lib/snapshotSource";

const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000";
const DEFAULT_API_BASE_URL = "http://127.0.0.1:42180";
const REPLAY_SNAPSHOT_PATH = "/api/spx/0dte/replay/snapshot";

function replaySnapshotUrl(apiBaseUrl: string, requestUrl: string): string {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/spx/0dte/scenario/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { isAnalyticsSnapshot } from "../../../../../lib/snapshotSource";

const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000";
const DEFAULT_API_BASE_URL = "http://127.0.0.1:42180";
const SCENARIO_PATH = "/api/spx/0dte/scenario";

function scenarioUrl(apiBaseUrl: string): string {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/spx/0dte/snapshot/latest/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { verifyAdminRequest } from "../../../../../../lib/adminSession";

const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000";
const DEFAULT_API_BASE_URL = "http://127.0.0.1:42180";
const SNAPSHOT_PATH = "/api/spx/0dte/snapshot/latest";
const ADMIN_TOKEN_HEADER = "X-GammaScope-Admin-Token";

Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/spx/0dte/status/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { isCollectorHealth } from "../../../../../lib/clientCollectorStatusSource";

const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000";
const DEFAULT_API_BASE_URL = "http://127.0.0.1:42180";
const COLLECTOR_STATUS_PATH = "/api/spx/0dte/status";

function collectorStatusUrl(apiBaseUrl: string): string {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/views/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { isSavedViewArray, type SavedView } from "../../../lib/clientSavedViewsSource";

const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000";
const DEFAULT_API_BASE_URL = "http://127.0.0.1:42180";
const VIEWS_PATH = "/api/views";

function viewsUrl(apiBaseUrl: string): string {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/lib/replayImportProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { verifyAdminRequest } from "./adminSession";
import { isReplayImportResult } from "./replayImportSource";

const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000";
const DEFAULT_API_BASE_URL = "http://127.0.0.1:42180";
const DEFAULT_REPLAY_IMPORT_MAX_BYTES = 100 * 1024 * 1024;
const REPLAY_IMPORT_MULTIPART_OVERHEAD_BYTES = 16 * 1024;

Expand Down
2 changes: 1 addition & 1 deletion apps/web/lib/replayStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { AnalyticsSnapshot } from "./contracts";
import { isAnalyticsSnapshot } from "./snapshotSource";

const REPLAY_WS_PATH = "/ws/spx/0dte/replay";
const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000";
const DEFAULT_API_BASE_URL = "http://127.0.0.1:42180";

type WebSocketLike = {
onclose: (() => void) | null;
Expand Down
2 changes: 1 addition & 1 deletion apps/web/lib/serverBackendFetch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { verifyAdminRequest } from "./adminSession";

export const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000";
export const DEFAULT_API_BASE_URL = "http://127.0.0.1:42180";
export const ADMIN_TOKEN_HEADER = "X-GammaScope-Admin-Token";

export function backendApiUrl(
Expand Down
2 changes: 1 addition & 1 deletion apps/web/lib/snapshotStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { AnalyticsSnapshot } from "./contracts";
import { isAnalyticsSnapshot } from "./snapshotSource";

const SNAPSHOT_WS_PATH = "/ws/spx/0dte";
const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000";
const DEFAULT_API_BASE_URL = "http://127.0.0.1:42180";

type WebSocketLike = {
onclose: (() => void) | null;
Expand Down
10 changes: 5 additions & 5 deletions apps/web/tests/experimentalFlowRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ describe("GET /api/spx/0dte/experimental-flow/latest", () => {
const { GET } = await import("../app/api/spx/0dte/experimental-flow/latest/route");
await GET(new Request("http://localhost/api/spx/0dte/experimental-flow/latest"));

expect(fetcher).toHaveBeenCalledWith("http://127.0.0.1:8000/api/spx/0dte/experimental-flow/latest", {
expect(fetcher).toHaveBeenCalledWith("http://127.0.0.1:42180/api/spx/0dte/experimental-flow/latest", {
cache: "no-store",
headers: {
Accept: "application/json"
Expand All @@ -86,7 +86,7 @@ describe("GET /api/spx/0dte/experimental-flow/latest", () => {
}
}));

expect(fetcher).toHaveBeenCalledWith("http://127.0.0.1:8000/api/spx/0dte/experimental-flow/latest", {
expect(fetcher).toHaveBeenCalledWith("http://127.0.0.1:42180/api/spx/0dte/experimental-flow/latest", {
cache: "no-store",
headers: {
Accept: "application/json",
Expand All @@ -103,7 +103,7 @@ describe("GET /api/spx/0dte/experimental-flow/latest", () => {
const { GET } = await import("../app/api/spx/0dte/experimental-flow/latest/route");
await GET(new Request("http://localhost/api/spx/0dte/experimental-flow/latest"));

expect(fetcher).toHaveBeenCalledWith("http://127.0.0.1:8000/api/spx/0dte/experimental-flow/latest", {
expect(fetcher).toHaveBeenCalledWith("http://127.0.0.1:42180/api/spx/0dte/experimental-flow/latest", {
cache: "no-store",
headers: {
Accept: "application/json"
Expand Down Expand Up @@ -162,7 +162,7 @@ describe("GET /api/spx/0dte/experimental-flow/replay", () => {
expect(response.headers.get("Content-Type")).toBe("text/plain");
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(fetcher).toHaveBeenCalledWith(
"http://127.0.0.1:8000/api/spx/0dte/experimental-flow/replay?session_id=session%2Fa&horizon_minutes=30&at=2026-04-23T15%3A40%3A00Z&source_snapshot_id=snapshot-a",
"http://127.0.0.1:42180/api/spx/0dte/experimental-flow/replay?session_id=session%2Fa&horizon_minutes=30&at=2026-04-23T15%3A40%3A00Z&source_snapshot_id=snapshot-a",
{
cache: "no-store",
headers: {
Expand All @@ -182,7 +182,7 @@ describe("GET /api/spx/0dte/experimental-flow/replay", () => {
));

expect(fetcher).toHaveBeenCalledWith(
"http://127.0.0.1:8000/api/spx/0dte/experimental-flow/replay?session_id=session-a&horizon_minutes=15",
"http://127.0.0.1:42180/api/spx/0dte/experimental-flow/replay?session_id=session-a&horizon_minutes=15",
{
cache: "no-store",
headers: {
Expand Down
Loading
Loading