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
38 changes: 38 additions & 0 deletions algolens-api/algolens/adapters/http/sim.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Simulation mode: GET /api/sim, for the local simulator under sim/.

Registered only when SIM_MODE=1 (sim/docker-compose.sim.yml sets it). The
frontend asks this route once on load; a 404 (every real deployment) means no
banner. With SIM_MODE=1 it answers the server's clock, which in the simulator
is the moved sim clock, so the banner shows the time the desk rules use.

No login needed: the banner also shows on the login page. It reveals only the
server time.
"""

import os
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

from flask import Blueprint, jsonify

NEW_YORK = ZoneInfo("America/New_York")

sim_bp = Blueprint("sim", __name__)


def sim_mode_enabled(environ=None):
env = os.environ if environ is None else environ
return (env.get("SIM_MODE") or "").strip() == "1"


def sim_state(now):
return {
"sim": True,
"serverTime": now.astimezone(timezone.utc).isoformat(),
"newYork": now.astimezone(NEW_YORK).isoformat(),
}


@sim_bp.route("", methods=["GET"])
def get_sim():
return jsonify(sim_state(datetime.now(timezone.utc))), 200
5 changes: 5 additions & 0 deletions algolens-api/algolens/infrastructure/config/app_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from algolens.adapters.http.auth import auth_bp
from algolens.adapters.http.desk import desk_bp
from algolens.adapters.http.portfolio import portfolio_bp
from algolens.adapters.http.sim import sim_bp, sim_mode_enabled
from algolens.infrastructure.db.postgres import get_db_connection
from extensions import limiter

Expand Down Expand Up @@ -205,6 +206,10 @@ def token_verification_failed_callback(jwt_header, jwt_payload):
app.register_blueprint(portfolio_bp, url_prefix="/portfolio")
app.register_blueprint(desk_bp, url_prefix="/portfolio/desk")
app.register_blueprint(api_bp, url_prefix="/api")
if sim_mode_enabled():
# Local simulator only (sim/): the banner's clock. Absent otherwise.
app.logger.warning("[SIM_MODE] SIM_MODE=1: GET /api/sim is on (simulator)")
app.register_blueprint(sim_bp, url_prefix="/api/sim")

@app.route("/health/live", methods=["GET"])
def liveness_check():
Expand Down
38 changes: 38 additions & 0 deletions algolens-api/tests/test_sim_mode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""GET /api/sim exists only with SIM_MODE=1 (the local simulator, sim/)."""

from datetime import datetime, timezone

from algolens.adapters.http.sim import sim_mode_enabled, sim_state
from algolens.infrastructure.config.app_factory import create_app


def test_sim_mode_flag():
assert sim_mode_enabled({"SIM_MODE": "1"})
assert not sim_mode_enabled({})
assert not sim_mode_enabled({"SIM_MODE": "0"})
assert not sim_mode_enabled({"SIM_MODE": "true"})


def test_sim_state_reports_utc_and_new_york():
state = sim_state(datetime(2026, 10, 15, 13, 29, 5, tzinfo=timezone.utc))
assert state == {
"sim": True,
"serverTime": "2026-10-15T13:29:05+00:00",
"newYork": "2026-10-15T09:29:05-04:00",
}


def test_route_absent_without_sim_mode(monkeypatch):
monkeypatch.delenv("SIM_MODE", raising=False)
app = create_app()
assert app.test_client().get("/api/sim").status_code == 404


def test_route_present_with_sim_mode(monkeypatch):
monkeypatch.setenv("SIM_MODE", "1")
app = create_app()
resp = app.test_client().get("/api/sim")
assert resp.status_code == 200
body = resp.get_json()
assert body["sim"] is True
datetime.fromisoformat(body["serverTime"])
2 changes: 2 additions & 0 deletions algolens-frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { QtHome } from './components/qt/QtHome';
import { InvestorHome } from './components/investor/InvestorHome';
import { AdminHome } from './components/admin/AdminHome';
import { ScreenFrame } from './components/ScreenFrame';
import { SimBanner } from './components/SimBanner';
import { AccessPending } from './components/AccessPending';
import { ThemeProvider, useTheme } from './adapters/react/ThemeContext';
import { AuthProvider, useAuth } from './adapters/react/AuthContext';
Expand Down Expand Up @@ -93,6 +94,7 @@ export default function App() {
<ScreenProvider>
<AppContent />
</ScreenProvider>
<SimBanner />
</AuthProvider>
</ThemeProvider>
);
Expand Down
66 changes: 66 additions & 0 deletions algolens-frontend/src/components/SimBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { SIM_POLL_MS, simBannerText, simOffsetMs } from '../domain/sim/simClock';
import { fetchSimState } from '../infrastructure/api/simApi';

/**
* A fixed banner shown only by the local simulator (backend SIM_MODE=1). On a
* real deployment GET /api/sim is a 404 and this renders nothing and stops.
*/
export function SimBanner() {
const [offsetMs, setOffsetMs] = useState<number | null>(null);
const [now, setNow] = useState(() => Date.now());

useEffect(() => {
let stopped = false;
let poll: ReturnType<typeof setInterval> | undefined;
const refresh = async () => {
const state = await fetchSimState();
if (stopped) return;
if (!state) {
if (poll) clearInterval(poll);
return;
}
const offset = simOffsetMs(state.serverTime, Date.now());
if (Number.isFinite(offset)) setOffsetMs(offset);
};
refresh().then(() => {
if (!stopped) poll = setInterval(refresh, SIM_POLL_MS);
});
return () => {
stopped = true;
if (poll) clearInterval(poll);
};
}, []);

useEffect(() => {
if (offsetMs === null) return;
const tick = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(tick);
}, [offsetMs]);

if (offsetMs === null) return null;
return (
// Inline styles: index.css is a precompiled Tailwind build, so a class it
// does not already contain would do nothing.
<div
role="status"
style={{
position: 'fixed',
top: 0,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 9999,
pointerEvents: 'none',
padding: '2px 12px',
borderRadius: '0 0 6px 6px',
background: '#f59e0b',
color: '#000',
fontSize: 12,
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
{simBannerText(now + offsetMs)}
</div>
);
}
21 changes: 21 additions & 0 deletions algolens-frontend/src/domain/sim/simClock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { newYorkClock, simBannerText, simOffsetMs } from './simClock';

describe('sim banner clock', () => {
it('formats an instant in New York time', () => {
expect(newYorkClock(Date.parse('2026-10-15T13:29:05Z'))).toBe('2026-10-15 09:29:05');
expect(newYorkClock(Date.parse('2026-12-01T15:01:00Z'))).toBe('2026-12-01 10:01:00');
});

it('says SIMULATION with the clock', () => {
expect(simBannerText(Date.parse('2026-10-15T14:01:00Z'))).toBe(
'SIMULATION — clock 2026-10-15 10:01:00 New York',
);
});

it('keeps the server offset so the banner can tick', () => {
const browser = Date.parse('2026-10-14T20:00:00Z');
expect(simOffsetMs('2026-10-15T13:30:00+00:00', browser)).toBe(17.5 * 3600 * 1000);
expect(simOffsetMs('not a time', browser)).toBeNaN();
});
});
39 changes: 39 additions & 0 deletions algolens-frontend/src/domain/sim/simClock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* The simulator banner (sim/ in the repo). The backend answers GET /api/sim
* only when SIM_MODE=1; its serverTime is the moved sim clock. The banner
* keeps the offset between that clock and the browser's, so it can tick
* between polls.
*/

export const SIM_POLL_MS = 10_000;

export interface SimState {
sim: boolean;
serverTime: string;
}

/** Server clock minus browser clock, in ms (NaN when serverTime is unreadable). */
export function simOffsetMs(serverTime: string, browserNowMs: number): number {
const server = Date.parse(serverTime);
return Number.isFinite(server) ? server - browserNowMs : Number.NaN;
}

/** "2026-10-15 09:29:05" in New York for an epoch in ms. */
export function newYorkClock(epochMs: number): string {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
}).formatToParts(new Date(epochMs));
const get = (type: string) => parts.find(p => p.type === type)?.value ?? '';
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`;
}

export function simBannerText(epochMs: number): string {
return `SIMULATION — clock ${newYorkClock(epochMs)} New York`;
}
14 changes: 14 additions & 0 deletions algolens-frontend/src/infrastructure/api/simApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { SimState } from '../../domain/sim/simClock';
import { API_BASE_URL } from './httpClient';

/** GET /api/sim: the sim clock, or null when this is not the simulator (404) or it fails. */
export async function fetchSimState(): Promise<SimState | null> {
try {
const response = await fetch(`${API_BASE_URL}/api/sim`, { credentials: 'include' });
if (!response.ok) return null;
const data = (await response.json()) as SimState;
return data && data.sim === true && typeof data.serverTime === 'string' ? data : null;
} catch {
return null;
}
}
7 changes: 7 additions & 0 deletions sim/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Copy to sim/.env to override the simulator's defaults (docker compose reads it).
# ENGINE_IMAGE=trade-ngin:rpc-0b9dbf6e
# PG_IMAGE=postgres:16
# SIM_FLASK_ENV=development
# QT_CATCHUP_PORTFOLIOS=qt_conservative,qt_conservative_model
# QT_CATCHUP_ENABLED=1
# QT_EVENING_RUN=0
3 changes: 3 additions & 0 deletions sim/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Scripts and SQL run inside Linux containers or Git Bash: keep LF on Windows checkouts.
* text=auto eol=lf
*.ps1 text eol=crlf
5 changes: 5 additions & 0 deletions sim/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Prod snapshots (snapshot.sh) and the clock state are local only.
/data/*
!/data/.gitkeep
/clock/faketime.rc
/clock/day
Loading
Loading