From 95973ab28d0b1780b5fad34e400cd34b7f8922f5 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Tue, 17 Feb 2026 12:10:43 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- deploy_synology.sh | 181 +++++++++++++++++-- web/src/ONBOARDING.md | 5 + web/src/PRD.md | 6 +- web/src/app.py | 12 +- web/src/tests/test_charting.py | 4 + web/src/tests/test_settings_schema.py | 20 ++ web/src/tests/test_time_display.py | 27 +++ web/src/web_core/auth/profile_store.py | 16 +- web/src/web_core/charting.py | 30 +++ web/src/web_core/settings/settings_schema.py | 8 + web/src/web_core/time_display.py | 46 +++++ web/src/web_core/ui/help_content.py | 4 + web/src/web_core/ui/login_ui.py | 90 ++++----- web/src/web_core/ui/sidebar_ui.py | 36 ++-- web/src/web_core/ui/training_ui.py | 44 ++++- 15 files changed, 446 insertions(+), 83 deletions(-) create mode 100644 web/src/tests/test_time_display.py create mode 100644 web/src/web_core/time_display.py diff --git a/deploy_synology.sh b/deploy_synology.sh index 899ea80..56189a1 100755 --- a/deploy_synology.sh +++ b/deploy_synology.sh @@ -9,11 +9,22 @@ fi # ManeshTrader quick deploy helper for Synology Docker host. # Defaults are set for the target provided by the user. +ENV_FILE=".env.deploy_synology" +if [[ -f "$ENV_FILE" ]]; then + # shellcheck disable=SC1090 + set -a; source "$ENV_FILE"; set +a +fi + REMOTE_USER="${REMOTE_USER:-mbrucedogs}" REMOTE_HOST="${REMOTE_HOST:-192.168.1.128}" REMOTE_PORT="${REMOTE_PORT:-25}" REMOTE_BASE="${REMOTE_BASE:-/volume1/docker/maneshtrader}" +LOCAL_BASE="${LOCAL_BASE:-web/src}" SYNO_PASSWORD="${SYNO_PASSWORD:-}" +CONTAINER_NAME="${CONTAINER_NAME:-}" +SUDO_PASSWORD="${SUDO_PASSWORD:-${SYNO_PASSWORD:-}}" +SUDO_MODE="${SUDO_MODE:-auto}" # auto|always|never +SSH_PASS_WARNING_SHOWN="0" MODE="bind" # bind: upload + restart; image: upload + rebuild NO_RESTART="0" @@ -36,17 +47,24 @@ Options: --mode bind|image Deploy mode (default: bind) --files "a b c" Space-separated file list to upload --recent-minutes N Upload files under web/src modified in last N minutes + --git-hours N Upload files changed in last N hours (git history + working tree) + --env-file PATH Load environment variables from file (default: .env.deploy_synology if present) + --sudo-mode MODE Docker privilege mode: auto|always|never (default: auto) --no-restart Upload only; skip remote docker restart/rebuild --dry-run Print actions but do not execute -h, --help Show help Environment overrides: - REMOTE_USER, REMOTE_HOST, REMOTE_PORT, REMOTE_BASE, SYNO_PASSWORD, CONTAINER_NAME + REMOTE_USER, REMOTE_HOST, REMOTE_PORT, REMOTE_BASE, LOCAL_BASE, SYNO_PASSWORD, CONTAINER_NAME, + SUDO_PASSWORD, SUDO_MODE Examples: ./deploy_synology.sh ./deploy_synology.sh --mode image ./deploy_synology.sh --recent-minutes 120 + ./deploy_synology.sh --git-hours 2 + ./deploy_synology.sh --env-file .env.deploy_synology --git-hours 2 + ./deploy_synology.sh --git-hours 2 --sudo-mode always ./deploy_synology.sh --files "web/src/web_core/ui/help_content.py" EOF } @@ -62,8 +80,12 @@ run_cmd() { build_ssh_cmd() { if [[ -n "${SYNO_PASSWORD:-}" ]]; then if ! command -v sshpass >/dev/null 2>&1; then - echo "Error: SYNO_PASSWORD is set but sshpass is not installed." >&2 - exit 1 + if [[ "$SSH_PASS_WARNING_SHOWN" == "0" ]]; then + echo "Warning: SYNO_PASSWORD is set but sshpass is not installed; falling back to interactive ssh/scp prompts." >&2 + SSH_PASS_WARNING_SHOWN="1" + fi + printf "ssh -p '%s' '%s@%s'" "${REMOTE_PORT}" "${REMOTE_USER}" "${REMOTE_HOST}" + return fi printf "SSHPASS='%s' sshpass -e ssh -o StrictHostKeyChecking=accept-new -p '%s' '%s@%s'" \ "${SYNO_PASSWORD}" "${REMOTE_PORT}" "${REMOTE_USER}" "${REMOTE_HOST}" @@ -75,8 +97,12 @@ build_ssh_cmd() { build_scp_cmd() { if [[ -n "${SYNO_PASSWORD:-}" ]]; then if ! command -v sshpass >/dev/null 2>&1; then - echo "Error: SYNO_PASSWORD is set but sshpass is not installed." >&2 - exit 1 + if [[ "$SSH_PASS_WARNING_SHOWN" == "0" ]]; then + echo "Warning: SYNO_PASSWORD is set but sshpass is not installed; falling back to interactive ssh/scp prompts." >&2 + SSH_PASS_WARNING_SHOWN="1" + fi + printf "scp -O -P '%s'" "${REMOTE_PORT}" + return fi printf "SSHPASS='%s' sshpass -e scp -O -o StrictHostKeyChecking=accept-new -P '%s'" \ "${SYNO_PASSWORD}" "${REMOTE_PORT}" @@ -85,12 +111,77 @@ build_scp_cmd() { fi } +to_remote_rel_path() { + local file="$1" + local prefix="${LOCAL_BASE%/}/" + if [[ "$file" == "$prefix"* ]]; then + printf "%s" "${file#$prefix}" + else + printf "%s" "$file" + fi +} + +collect_git_hours_files() { + local hours="$1" + local base_commit + local combined + + if ! command -v git >/dev/null 2>&1; then + echo "Error: git is required for --git-hours." >&2 + exit 1 + fi + if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Error: --git-hours must be run from inside a git repository." >&2 + exit 1 + fi + + base_commit="$(git rev-list -n 1 --before="${hours} hours ago" HEAD 2>/dev/null || true)" + + combined="$( + { + if [[ -n "$base_commit" ]]; then + git diff --name-only --diff-filter=ACMRTUXB "${base_commit}..HEAD" -- web/src + fi + git diff --name-only -- web/src + git diff --cached --name-only -- web/src + git ls-files -m -o --exclude-standard -- web/src + } | sed '/^$/d' | sort -u + )" + + FILES=() + while IFS= read -r candidate; do + [[ -z "$candidate" ]] && continue + FILES+=("$candidate") + done <&2 + exit 1 + fi + # shellcheck disable=SC1090 + set -a; source "$ENV_FILE"; set +a + REMOTE_USER="${REMOTE_USER:-mbrucedogs}" + REMOTE_HOST="${REMOTE_HOST:-192.168.1.128}" + REMOTE_PORT="${REMOTE_PORT:-25}" + REMOTE_BASE="${REMOTE_BASE:-/volume1/docker/maneshtrader}" + LOCAL_BASE="${LOCAL_BASE:-web/src}" + SYNO_PASSWORD="${SYNO_PASSWORD:-}" + CONTAINER_NAME="${CONTAINER_NAME:-}" + SUDO_PASSWORD="${SUDO_PASSWORD:-${SYNO_PASSWORD:-}}" + SUDO_MODE="${SUDO_MODE:-auto}" + shift 2 + ;; --files) read -r -a FILES <<< "${2:-}" shift 2 @@ -110,10 +201,23 @@ $(find web/src -type f -mmin "-$minutes" ! -name ".DS_Store" | sort) EOF shift 2 ;; + --git-hours) + hours="${2:-}" + if ! [[ "$hours" =~ ^[0-9]+$ ]]; then + echo "Error: --git-hours requires an integer." >&2 + exit 1 + fi + collect_git_hours_files "$hours" + shift 2 + ;; --no-restart) NO_RESTART="1" shift ;; + --sudo-mode) + SUDO_MODE="${2:-}" + shift 2 + ;; --dry-run) DRY_RUN="1" shift @@ -134,6 +238,10 @@ if [[ "$MODE" != "bind" && "$MODE" != "image" ]]; then echo "Error: --mode must be 'bind' or 'image'." >&2 exit 1 fi +if [[ "$SUDO_MODE" != "auto" && "$SUDO_MODE" != "always" && "$SUDO_MODE" != "never" ]]; then + echo "Error: --sudo-mode must be auto|always|never." >&2 + exit 1 +fi if [[ ${#FILES[@]} -eq 0 ]]; then echo "Error: No files selected for upload." >&2 @@ -156,7 +264,8 @@ for file in "${FILES[@]}"; do done for file in "${FILES[@]}"; do - remote_path="${REMOTE_BASE}/${file}" + remote_rel="$(to_remote_rel_path "$file")" + remote_path="${REMOTE_BASE}/${remote_rel}" remote_dir="$(dirname "$remote_path")" SSH_CMD="$(build_ssh_cmd)" SCP_CMD="$(build_scp_cmd)" @@ -174,6 +283,8 @@ if [[ "$MODE" == "bind" ]]; then run_cmd "${SSH_CMD} ' set -e cd \"${REMOTE_BASE}\" +SUDO_MODE=\"${SUDO_MODE}\" +SUDO_PASSWORD=\"${SUDO_PASSWORD}\" DOCKER_BIN=\"\" if command -v docker >/dev/null 2>&1; then DOCKER_BIN=\"\$(command -v docker)\" @@ -185,21 +296,42 @@ elif [[ -x /var/packages/ContainerManager/target/usr/bin/docker ]]; then DOCKER_BIN=\"/var/packages/ContainerManager/target/usr/bin/docker\" fi -if [[ -n \"\$DOCKER_BIN\" ]] && \"\$DOCKER_BIN\" compose version >/dev/null 2>&1; then - \"\$DOCKER_BIN\" compose restart +docker_cmd() { + if [[ \"\$USE_SUDO\" == \"1\" ]]; then + if [[ -n \"\$SUDO_PASSWORD\" ]]; then + printf \"%s\\n\" \"\$SUDO_PASSWORD\" | sudo -S -p \"\" \"\$DOCKER_BIN\" \"\$@\" + else + sudo \"\$DOCKER_BIN\" \"\$@\" + fi + else + \"\$DOCKER_BIN\" \"\$@\" + fi +} + +USE_SUDO=\"0\" +if [[ \"\$SUDO_MODE\" == \"always\" ]]; then + USE_SUDO=\"1\" +elif [[ \"\$SUDO_MODE\" == \"auto\" ]]; then + if ! \"\$DOCKER_BIN\" info >/dev/null 2>&1; then + USE_SUDO=\"1\" + fi +fi + +if [[ -n \"\$DOCKER_BIN\" ]] && docker_cmd compose version >/dev/null 2>&1; then + docker_cmd compose restart elif command -v docker-compose >/dev/null 2>&1; then docker-compose restart elif [[ -n \"\$DOCKER_BIN\" ]]; then if [[ -n \"${CONTAINER_NAME:-}\" ]]; then - \"\$DOCKER_BIN\" restart \"${CONTAINER_NAME}\" + docker_cmd restart \"${CONTAINER_NAME}\" else - ids=\$(\"\$DOCKER_BIN\" ps --filter \"name=maneshtrader\" -q) + ids=\$(docker_cmd ps --filter \"name=maneshtrader\" -q) if [[ -z \"\$ids\" ]]; then echo \"No docker compose command found, and no running containers matched name=maneshtrader.\" >&2 echo \"Set CONTAINER_NAME= and rerun.\" >&2 exit 1 fi - \"\$DOCKER_BIN\" restart \$ids + docker_cmd restart \$ids fi else echo \"No docker or docker compose command found on remote host.\" >&2 @@ -212,6 +344,8 @@ else run_cmd "${SSH_CMD} ' set -e cd \"${REMOTE_BASE}\" +SUDO_MODE=\"${SUDO_MODE}\" +SUDO_PASSWORD=\"${SUDO_PASSWORD}\" DOCKER_BIN=\"\" if command -v docker >/dev/null 2>&1; then DOCKER_BIN=\"\$(command -v docker)\" @@ -223,8 +357,29 @@ elif [[ -x /var/packages/ContainerManager/target/usr/bin/docker ]]; then DOCKER_BIN=\"/var/packages/ContainerManager/target/usr/bin/docker\" fi -if [[ -n \"\$DOCKER_BIN\" ]] && \"\$DOCKER_BIN\" compose version >/dev/null 2>&1; then - \"\$DOCKER_BIN\" compose up -d --build +docker_cmd() { + if [[ \"\$USE_SUDO\" == \"1\" ]]; then + if [[ -n \"\$SUDO_PASSWORD\" ]]; then + printf \"%s\\n\" \"\$SUDO_PASSWORD\" | sudo -S -p \"\" \"\$DOCKER_BIN\" \"\$@\" + else + sudo \"\$DOCKER_BIN\" \"\$@\" + fi + else + \"\$DOCKER_BIN\" \"\$@\" + fi +} + +USE_SUDO=\"0\" +if [[ \"\$SUDO_MODE\" == \"always\" ]]; then + USE_SUDO=\"1\" +elif [[ \"\$SUDO_MODE\" == \"auto\" ]]; then + if ! \"\$DOCKER_BIN\" info >/dev/null 2>&1; then + USE_SUDO=\"1\" + fi +fi + +if [[ -n \"\$DOCKER_BIN\" ]] && docker_cmd compose version >/dev/null 2>&1; then + docker_cmd compose up -d --build elif command -v docker-compose >/dev/null 2>&1; then docker-compose up -d --build else diff --git a/web/src/ONBOARDING.md b/web/src/ONBOARDING.md index 4846023..55f96ab 100644 --- a/web/src/ONBOARDING.md +++ b/web/src/ONBOARDING.md @@ -96,6 +96,8 @@ Tip: sign and notarize before sharing broadly, so macOS trust prompts are reduce 13. Read `Trend Events` for starts and reversals. 14. Use `Live Decision Guide` to translate trend state into a practical bias/action/invalidation workflow. 15. Keep `Show past behavior examples` ON while learning to review historical entry/exit outcomes. +16. Set `Display Timezone (US)` to your preferred timezone (default is `America/Chicago`, CST/CDT). +17. Choose `Use 24-hour time` ON for `13:00` style, or OFF for `1:00 PM` style. ## 4.1) Advanced Features (Optional) - `Advanced Signals`: @@ -105,6 +107,9 @@ Tip: sign and notarize before sharing broadly, so macOS trust prompts are reduce - `Regime filter (stand aside in choppy periods)` - `Training & Guidance`: - `Replay mode (hide future bars)` + `Replay bars shown` +- `Monitoring`: + - `Display Timezone (US)`: controls audit/event/training timestamp timezone (`America/Chicago` default) + - `Use 24-hour time`: switches between `13:00` and `1:00 PM` time styles - `Compare Symbols`: - enable panel and provide comma-separated symbols (`AAPL, MSFT, NVDA`) - `Alerts`: diff --git a/web/src/PRD.md b/web/src/PRD.md index aa64a60..9b81aea 100644 --- a/web/src/PRD.md +++ b/web/src/PRD.md @@ -102,6 +102,8 @@ Normalization constraints: - `backtest_take_profit_pct`: `[0.0, 25.0]`, fallback `0.0` (0 disables) - `backtest_min_hold_bars`: `[1, 20]`, fallback `1` - `backtest_max_hold_bars`: `[1, 40]`, fallback `1` and clamped to `>= min_hold` +- `display_timezone`: one of `America/New_York`, `America/Chicago`, `America/Denver`, `America/Los_Angeles`, `America/Phoenix`, `America/Anchorage`, `Pacific/Honolulu`; fallback `America/Chicago` +- `use_24h_time`: boolean, fallback `false` (`false` => `1:00 PM`, `true` => `13:00`) - booleans normalized from common truthy/falsy strings and numbers ## 5. Classification Rules @@ -181,13 +183,13 @@ Gap handling (`hide_market_closed_gaps`): - signal confirmation status - latest bar interpretation - action + invalidation guidance -- Trend events table (latest events) +- Trend events table (latest events), rendered in selected US display timezone and 12h/24h format - Backtest snapshot: - signal at trend-change rows to active bull/bear states - advanced mode supports configurable costs/holds/stop/target - Past behavior examples (optional training panel): - historical examples using trend-confirmation entries and opposite-confirmation exits - - per-example direction, entry/exit timestamps, bars held, P/L%, and outcome + - per-example direction, entry/exit timestamps (rendered in selected US display timezone and 12h/24h format), bars held, P/L%, and outcome - aggregate example metrics (count, win/loss, win rate, average P/L) - selectable table rows that drive chart highlight of chosen example - plain-language explanation for selected example diff --git a/web/src/app.py b/web/src/app.py index 6313c6c..bd3cfc3 100644 --- a/web/src/app.py +++ b/web/src/app.py @@ -37,6 +37,7 @@ from web_core.ui.sidebar_ui import render_sidebar from web_core.strategy import classify_bars, detect_trends from web_core.market.symbols import resolve_symbol_identity from web_core.ui.training_ui import render_training_panel +from web_core.time_display import format_timestamp def main() -> None: @@ -100,6 +101,8 @@ def main() -> None: interval = str(sidebar_settings["interval"]) period = str(sidebar_settings["period"]) max_bars = int(sidebar_settings["max_bars"]) + display_timezone = str(sidebar_settings.get("display_timezone", "America/Chicago")) + use_24h_time = bool(sidebar_settings.get("use_24h_time", False)) if not symbol: st.error("Please enter a symbol.") @@ -234,6 +237,8 @@ def main() -> None: show_past_behavior=bool(sidebar_settings["show_past_behavior"]), example_trades=example_trades, alert_key=alert_key, + display_timezone=display_timezone, + use_24h_time=use_24h_time, ) fig = build_figure( @@ -241,6 +246,8 @@ def main() -> None: gray_fake=bool(sidebar_settings["gray_fake"]), interval=interval, hide_market_closed_gaps=bool(sidebar_settings["hide_market_closed_gaps"]), + display_timezone=display_timezone, + use_24h_time=use_24h_time, ) if bool(sidebar_settings["show_trade_markers"]): add_example_trade_markers(fig, example_trades) @@ -307,7 +314,10 @@ def main() -> None: if events_view: event_df = pd.DataFrame( { - "timestamp": [str(e.timestamp) for e in events_view[-25:]][::-1], + "timestamp": [ + format_timestamp(e.timestamp, display_timezone=display_timezone, use_24h_time=use_24h_time) + for e in events_view[-25:] + ][::-1], "event": [e.event for e in events_view[-25:]][::-1], "trend_after": [e.trend_after for e in events_view[-25:]][::-1], } diff --git a/web/src/tests/test_charting.py b/web/src/tests/test_charting.py index b56033c..8cf1492 100644 --- a/web/src/tests/test_charting.py +++ b/web/src/tests/test_charting.py @@ -65,6 +65,8 @@ def test_build_figure_adds_missing_day_rangebreak_values() -> None: gray_fake=False, interval="1d", hide_market_closed_gaps=True, + display_timezone="America/Chicago", + use_24h_time=False, ) rangebreak_values: list[str] = [] @@ -83,6 +85,8 @@ def test_build_figure_intraday_uses_category_axis_when_hiding_gaps() -> None: gray_fake=False, interval="15m", hide_market_closed_gaps=True, + display_timezone="America/Chicago", + use_24h_time=False, ) assert fig.layout.xaxis.type == "category" diff --git a/web/src/tests/test_settings_schema.py b/web/src/tests/test_settings_schema.py index 18b859b..a963701 100644 --- a/web/src/tests/test_settings_schema.py +++ b/web/src/tests/test_settings_schema.py @@ -29,3 +29,23 @@ def test_normalize_watchlist_splits_legacy_escaped_newline_values() -> None: def test_normalize_watchlist_splits_escaped_newlines_inside_list_items() -> None: out = normalize_web_settings({"watchlist": ["AMD\\NTSLA"]}) assert out["watchlist"] == ["AMD", "TSLA"] + + +def test_display_timezone_defaults_to_central_when_invalid() -> None: + out = normalize_web_settings({"display_timezone": "Europe/London"}) + assert out["display_timezone"] == "America/Chicago" + + +def test_display_timezone_accepts_supported_us_timezone() -> None: + out = normalize_web_settings({"display_timezone": "America/Los_Angeles"}) + assert out["display_timezone"] == "America/Los_Angeles" + + +def test_use_24h_time_defaults_false() -> None: + out = normalize_web_settings({}) + assert out["use_24h_time"] is False + + +def test_use_24h_time_normalizes_truthy_values() -> None: + out = normalize_web_settings({"use_24h_time": "true"}) + assert out["use_24h_time"] is True diff --git a/web/src/tests/test_time_display.py b/web/src/tests/test_time_display.py new file mode 100644 index 0000000..84f2193 --- /dev/null +++ b/web/src/tests/test_time_display.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import pandas as pd + +from web_core.time_display import format_timestamp, normalize_display_timezone + + +def test_normalize_display_timezone_defaults_to_central() -> None: + assert normalize_display_timezone("bad-zone") == "America/Chicago" + + +def test_format_timestamp_converts_utc_to_central() -> None: + stamp = pd.Timestamp("2026-02-17 19:00:00", tz="UTC") + out = format_timestamp(stamp, display_timezone="America/Chicago", use_24h_time=True) + assert out.endswith("CST") + assert "2026-02-17 13:00" in out + + +def test_format_timestamp_12h_mode_uses_am_pm() -> None: + stamp = pd.Timestamp("2026-02-17 19:00:00", tz="UTC") + out = format_timestamp(stamp, display_timezone="America/Chicago", use_24h_time=False) + assert out.endswith("PM CST") + assert "2026-02-17 1:00" in out + + +def test_format_timestamp_returns_na_for_bad_value() -> None: + assert format_timestamp("not-a-timestamp", display_timezone="America/Chicago", use_24h_time=False) == "n/a" diff --git a/web/src/web_core/auth/profile_store.py b/web/src/web_core/auth/profile_store.py index 60745b9..a53f19d 100644 --- a/web/src/web_core/auth/profile_store.py +++ b/web/src/web_core/auth/profile_store.py @@ -21,6 +21,7 @@ from web_core.auth.profile_auth import ( resolve_login_profile, ) from web_core.settings.settings_schema import normalize_web_settings +from web_core.time_display import format_timestamp SETTINGS_PATH = Path.home() / ".web_local_shell" / "settings.json" LEGACY_SETTINGS_PATH = Path.home() / ".manesh_trader" / "settings.json" @@ -33,14 +34,19 @@ def _normalize_epoch(value: Any) -> int | None: return parsed if parsed >= 0 else None -def format_epoch(value: Any) -> str: +def format_epoch( + value: Any, + display_timezone: str = "America/Chicago", + use_24h_time: bool = False, +) -> str: parsed = _normalize_epoch(value) if parsed is None: return "n/a" - try: - return pd.to_datetime(parsed, unit="s", utc=True).strftime("%Y-%m-%d %H:%M UTC") - except Exception: - return "n/a" + return format_timestamp( + pd.to_datetime(parsed, unit="s", utc=True), + display_timezone=display_timezone, + use_24h_time=use_24h_time, + ) def _load_raw_settings_payload() -> dict[str, Any] | None: diff --git a/web/src/web_core/charting.py b/web/src/web_core/charting.py index c20bf8e..8c95c7c 100644 --- a/web/src/web_core/charting.py +++ b/web/src/web_core/charting.py @@ -5,6 +5,7 @@ import plotly.graph_objects as go from plotly.subplots import make_subplots from .constants import TREND_BEAR, TREND_BULL, TREND_NEUTRAL +from .time_display import format_timestamp def _is_intraday_interval(interval: str) -> bool: @@ -53,6 +54,8 @@ def build_figure( *, interval: str, hide_market_closed_gaps: bool, + display_timezone: str, + use_24h_time: bool, ) -> go.Figure: fig = make_subplots( rows=2, @@ -64,6 +67,9 @@ def build_figure( bull_mask = df["classification"] == "real_bull" bear_mask = df["classification"] == "real_bear" + time_labels = [ + format_timestamp(ts, display_timezone=display_timezone, use_24h_time=use_24h_time) for ts in df.index + ] if gray_fake: fig.add_trace( @@ -77,6 +83,14 @@ def build_figure( increasing_line_color="#B0B0B0", decreasing_line_color="#808080", opacity=0.35, + customdata=time_labels, + hovertemplate=( + "Time: %{customdata}
" + "Open: %{open:.2f}
" + "High: %{high:.2f}
" + "Low: %{low:.2f}
" + "Close: %{close:.2f}All Bars" + ), ), row=1, col=1, @@ -93,11 +107,20 @@ def build_figure( increasing_line_color="#2E8B57", decreasing_line_color="#B22222", opacity=0.6, + customdata=time_labels, + hovertemplate=( + "Time: %{customdata}
" + "Open: %{open:.2f}
" + "High: %{high:.2f}
" + "Low: %{low:.2f}
" + "Close: %{close:.2f}All Bars" + ), ), row=1, col=1, ) + bull_time_labels = [time_labels[idx] for idx, is_bull in enumerate(bull_mask) if bool(is_bull)] fig.add_trace( go.Scatter( x=df.index[bull_mask], @@ -105,11 +128,14 @@ def build_figure( mode="markers", name="Real Bullish", marker=dict(color="#00C853", size=9, symbol="triangle-up"), + customdata=bull_time_labels, + hovertemplate="Time: %{customdata}
Close: %{y:.2f}Real Bullish", ), row=1, col=1, ) + bear_time_labels = [time_labels[idx] for idx, is_bear in enumerate(bear_mask) if bool(is_bear)] fig.add_trace( go.Scatter( x=df.index[bear_mask], @@ -117,6 +143,8 @@ def build_figure( mode="markers", name="Real Bearish", marker=dict(color="#D50000", size=9, symbol="triangle-down"), + customdata=bear_time_labels, + hovertemplate="Time: %{customdata}
Close: %{y:.2f}Real Bearish", ), row=1, col=1, @@ -136,6 +164,8 @@ def build_figure( marker_color=trend_color, name="Volume", opacity=0.65, + customdata=time_labels, + hovertemplate="Time: %{customdata}
Volume: %{y}Volume", ), row=2, col=1, diff --git a/web/src/web_core/settings/settings_schema.py b/web/src/web_core/settings/settings_schema.py index 0ac1cf4..2942321 100644 --- a/web/src/web_core/settings/settings_schema.py +++ b/web/src/web_core/settings/settings_schema.py @@ -4,6 +4,7 @@ from typing import Any from web_core.constants import INTERVAL_OPTIONS, PERIOD_OPTIONS from web_core.market.presets import MARKET_PRESET_OPTIONS +from web_core.time_display import DEFAULT_DISPLAY_TIMEZONE, normalize_display_timezone def _clamp_int(value: Any, fallback: int, minimum: int, maximum: int) -> int: @@ -124,6 +125,8 @@ def normalize_web_settings(raw: dict[str, Any] | None) -> dict[str, Any]: "backtest_take_profit_pct": 0.0, "backtest_min_hold_bars": 1, "backtest_max_hold_bars": 1, + "display_timezone": DEFAULT_DISPLAY_TIMEZONE, + "use_24h_time": False, } symbol = str(raw.get("symbol", defaults["symbol"])).strip().upper() @@ -249,4 +252,9 @@ def normalize_web_settings(raw: dict[str, Any] | None) -> dict[str, Any]: ), "backtest_min_hold_bars": backtest_min_hold_bars, "backtest_max_hold_bars": backtest_max_hold_bars, + "display_timezone": normalize_display_timezone( + raw.get("display_timezone"), + fallback=str(defaults["display_timezone"]), + ), + "use_24h_time": _to_bool(raw.get("use_24h_time"), fallback=bool(defaults["use_24h_time"])), } diff --git a/web/src/web_core/time_display.py b/web/src/web_core/time_display.py new file mode 100644 index 0000000..2259942 --- /dev/null +++ b/web/src/web_core/time_display.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import Any + +import pandas as pd + +US_TIMEZONE_OPTIONS: list[str] = [ + "America/New_York", + "America/Chicago", + "America/Denver", + "America/Los_Angeles", + "America/Phoenix", + "America/Anchorage", + "Pacific/Honolulu", +] + +DEFAULT_DISPLAY_TIMEZONE = "America/Chicago" + + +def normalize_display_timezone(value: Any, fallback: str = DEFAULT_DISPLAY_TIMEZONE) -> str: + candidate = str(value or "").strip() + if candidate in US_TIMEZONE_OPTIONS: + return candidate + return fallback if fallback in US_TIMEZONE_OPTIONS else DEFAULT_DISPLAY_TIMEZONE + + +def format_timestamp( + value: Any, + display_timezone: str = DEFAULT_DISPLAY_TIMEZONE, + use_24h_time: bool = False, +) -> str: + try: + ts = pd.Timestamp(value) + except Exception: + return "n/a" + + tz_name = normalize_display_timezone(display_timezone) + try: + if ts.tzinfo is None: + ts = ts.tz_localize("UTC") + converted = ts.tz_convert(tz_name) + if use_24h_time: + return converted.strftime("%Y-%m-%d %H:%M %Z") + return converted.strftime("%Y-%m-%d %I:%M %p %Z").replace(" 0", " ", 1) + except Exception: + return "n/a" diff --git a/web/src/web_core/ui/help_content.py b/web/src/web_core/ui/help_content.py index 2c97043..36c9543 100644 --- a/web/src/web_core/ui/help_content.py +++ b/web/src/web_core/ui/help_content.py @@ -139,6 +139,10 @@ then checking if your guess was right. Why care: keeps data fresh while you watch. - `Refresh interval (seconds)` Why care: lower is faster updates, but more load. +- `Display Timezone (US)` + Why care: shows audit/event/training times in your preferred U.S. timezone (default CST/CDT). +- `Use 24-hour time` + Why care: choose `13:00` style (ON) or `1:00 PM` style (OFF). ### Advanced Signals - `Auto-run advanced panels (slower)` diff --git a/web/src/web_core/ui/login_ui.py b/web/src/web_core/ui/login_ui.py index dcd5a2a..e028627 100644 --- a/web/src/web_core/ui/login_ui.py +++ b/web/src/web_core/ui/login_ui.py @@ -21,49 +21,51 @@ from web_core.auth.profile_store import ( def render_profile_login(now_epoch: float, query_params: Any, session_expired: bool) -> None: existing_profiles = set(list_web_profiles()) - st.subheader("Profile Login") - st.info("Login with an existing profile or create a new one. Settings are isolated per profile.") - if session_expired: - st.warning("Your profile session timed out due to inactivity. Please log in again.") + _, centered_col, _ = st.columns([1, 1.8, 1]) + with centered_col: + st.subheader("Profile Login") + st.info("Login with an existing profile or create a new one. Settings are isolated per profile.") + if session_expired: + st.warning("Your profile session timed out due to inactivity. Please log in again.") - initial_profile = first_query_param_value(query_params, "profile") or "" - remember_default = is_truthy_flag(first_query_param_value(query_params, "remember")) - login_profile = st.text_input("Login profile name", value=initial_profile, placeholder="e.g. matt") - login_pin = st.text_input("PIN (if enabled)", value="", type="password", max_chars=6) - remember_me = st.checkbox("Remember me on this browser", value=remember_default or True) - if st.button("Login", type="primary", use_container_width=True): - selected_match = find_existing_profile_id(login_profile, existing_profiles) - if selected_match is None: - st.error("Profile not found. Enter the exact profile name or create a new one below.") - elif profile_requires_pin(selected_match) and not verify_profile_pin(selected_match, login_pin): - st.error("Incorrect PIN.") - else: - mark_profile_login(selected_match, now_epoch=int(now_epoch)) - st.session_state["active_profile"] = selected_match - st.session_state["profile_last_active_at"] = now_epoch - query_params["profile"] = selected_match - if remember_me: - query_params["remember"] = "1" - elif "remember" in query_params: - del query_params["remember"] - st.rerun() + initial_profile = first_query_param_value(query_params, "profile") or "" + remember_default = is_truthy_flag(first_query_param_value(query_params, "remember")) + login_profile = st.text_input("Login profile name", value=initial_profile, placeholder="e.g. matt") + login_pin = st.text_input("PIN (if enabled)", value="", type="password", max_chars=6) + remember_me = st.checkbox("Remember me on this browser", value=remember_default or True) + if st.button("Login", type="primary", use_container_width=True): + selected_match = find_existing_profile_id(login_profile, existing_profiles) + if selected_match is None: + st.error("Profile not found. Enter the exact profile name or create a new one below.") + elif profile_requires_pin(selected_match) and not verify_profile_pin(selected_match, login_pin): + st.error("Incorrect PIN.") + else: + mark_profile_login(selected_match, now_epoch=int(now_epoch)) + st.session_state["active_profile"] = selected_match + st.session_state["profile_last_active_at"] = now_epoch + query_params["profile"] = selected_match + if remember_me: + query_params["remember"] = "1" + elif "remember" in query_params: + del query_params["remember"] + st.rerun() - st.divider() - create_profile_name = st.text_input("Create profile name", value="", placeholder="e.g. sara") - create_pin = st.text_input("Set PIN (optional, 4-6 digits)", value="", type="password", max_chars=6) - if st.button("Create Profile", use_container_width=True): - selected = normalize_profile_id(create_profile_name) - if profile_exists(selected, existing_profiles): - st.error("That profile already exists (including case-insensitive matches). Use Login instead.") - elif create_pin and normalize_pin(create_pin) is None: - st.error("PIN must be 4-6 digits.") - else: - create_profile(selected, pin=create_pin or None, now_epoch=int(now_epoch)) - st.session_state["active_profile"] = selected - st.session_state["profile_last_active_at"] = now_epoch - query_params["profile"] = selected - if remember_me: - query_params["remember"] = "1" - elif "remember" in query_params: - del query_params["remember"] - st.rerun() + st.divider() + create_profile_name = st.text_input("Create profile name", value="", placeholder="e.g. sara") + create_pin = st.text_input("Set PIN (optional, 4-6 digits)", value="", type="password", max_chars=6) + if st.button("Create Profile", use_container_width=True): + selected = normalize_profile_id(create_profile_name) + if profile_exists(selected, existing_profiles): + st.error("That profile already exists (including case-insensitive matches). Use Login instead.") + elif create_pin and normalize_pin(create_pin) is None: + st.error("PIN must be 4-6 digits.") + else: + create_profile(selected, pin=create_pin or None, now_epoch=int(now_epoch)) + st.session_state["active_profile"] = selected + st.session_state["profile_last_active_at"] = now_epoch + query_params["profile"] = selected + if remember_me: + query_params["remember"] = "1" + elif "remember" in query_params: + del query_params["remember"] + st.rerun() diff --git a/web/src/web_core/ui/sidebar_ui.py b/web/src/web_core/ui/sidebar_ui.py index 419d861..918ff5e 100644 --- a/web/src/web_core/ui/sidebar_ui.py +++ b/web/src/web_core/ui/sidebar_ui.py @@ -9,6 +9,7 @@ from web_core.constants import INTERVAL_OPTIONS, PERIOD_OPTIONS from web_core.ui.help_content import help_dialog from web_core.market.presets import MARKET_PRESET_OPTIONS, apply_market_preset from web_core.market.symbols import lookup_symbol_candidates +from web_core.time_display import US_TIMEZONE_OPTIONS from web_core.auth.profile_store import ( first_query_param_value, format_epoch, @@ -33,6 +34,25 @@ def _parse_watchlist(raw: str) -> list[str]: def render_sidebar(active_profile: str, query_params: Any) -> dict[str, Any]: with st.sidebar: + persisted_settings = load_web_settings(profile_id=active_profile) + query_overrides: dict[str, Any] = {} + for key in persisted_settings: + candidate = first_query_param_value(query_params, key) + if candidate is not None: + query_overrides[key] = candidate + effective_defaults = normalize_web_settings({**persisted_settings, **query_overrides}) + display_timezone = st.selectbox( + "Display Timezone (US)", + US_TIMEZONE_OPTIONS, + index=US_TIMEZONE_OPTIONS.index(str(effective_defaults["display_timezone"])), + help="Controls how timestamps are shown in the sidebar and training/event tables.", + ) + use_24h_time = st.checkbox( + "Use 24-hour time", + value=bool(effective_defaults["use_24h_time"]), + help="On: 13:00 format. Off: 1:00 PM format.", + ) + st.header("Profile") st.success(f"Logged in as: {active_profile}") if st.button("Switch profile", use_container_width=True): @@ -50,9 +70,9 @@ def render_sidebar(active_profile: str, query_params: Any) -> dict[str, Any]: ) st.caption( "Audit: created " - f"{format_epoch(profile_audit.get('created_at'))}, " - f"last login {format_epoch(profile_audit.get('last_login_at'))}, " - f"updated {format_epoch(profile_audit.get('updated_at'))}, " + f"{format_epoch(profile_audit.get('created_at'), display_timezone=display_timezone, use_24h_time=use_24h_time)}, " + f"last login {format_epoch(profile_audit.get('last_login_at'), display_timezone=display_timezone, use_24h_time=use_24h_time)}, " + f"updated {format_epoch(profile_audit.get('updated_at'), display_timezone=display_timezone, use_24h_time=use_24h_time)}, " f"last symbol {str(profile_audit.get('last_symbol') or 'n/a')}" ) st.divider() @@ -62,14 +82,6 @@ def render_sidebar(active_profile: str, query_params: Any) -> dict[str, Any]: help_dialog() st.divider() - persisted_settings = load_web_settings(profile_id=active_profile) - query_overrides: dict[str, Any] = {} - for key in persisted_settings: - candidate = first_query_param_value(query_params, key) - if candidate is not None: - query_overrides[key] = candidate - effective_defaults = normalize_web_settings({**persisted_settings, **query_overrides}) - st.subheader("Market Preset") preset_default = str(effective_defaults["market_preset"]) preset_index = MARKET_PRESET_OPTIONS.index(preset_default) if preset_default in MARKET_PRESET_OPTIONS else 0 @@ -347,4 +359,6 @@ def render_sidebar(active_profile: str, query_params: Any) -> dict[str, Any]: "backtest_take_profit_pct": float(backtest_take_profit_pct), "backtest_min_hold_bars": int(backtest_min_hold_bars), "backtest_max_hold_bars": int(backtest_max_hold_bars), + "display_timezone": str(display_timezone), + "use_24h_time": bool(use_24h_time), } diff --git a/web/src/web_core/ui/training_ui.py b/web/src/web_core/ui/training_ui.py index db1b661..7b7690e 100644 --- a/web/src/web_core/ui/training_ui.py +++ b/web/src/web_core/ui/training_ui.py @@ -3,8 +3,16 @@ from __future__ import annotations import pandas as pd import streamlit as st +from web_core.time_display import format_timestamp -def render_training_panel(show_past_behavior: bool, example_trades: pd.DataFrame, alert_key: str) -> pd.Series | None: + +def render_training_panel( + show_past_behavior: bool, + example_trades: pd.DataFrame, + alert_key: str, + display_timezone: str, + use_24h_time: bool, +) -> pd.Series | None: selected_trade: pd.Series | None = None if not show_past_behavior: return selected_trade @@ -27,10 +35,20 @@ def render_training_panel(show_past_behavior: bool, example_trades: pd.DataFrame t4.metric("Avg P/L per Example", f"{avg_pnl}%") latest_example = example_trades.iloc[-1] + latest_entry_ts = format_timestamp( + latest_example["entry_timestamp"], + display_timezone=display_timezone, + use_24h_time=use_24h_time, + ) + latest_exit_ts = format_timestamp( + latest_example["exit_timestamp"], + display_timezone=display_timezone, + use_24h_time=use_24h_time, + ) st.caption( "Latest closed example: " - f"{latest_example['direction']} from {latest_example['entry_timestamp']} " - f"to {latest_example['exit_timestamp']} " + f"{latest_example['direction']} from {latest_entry_ts} " + f"to {latest_exit_ts} " f"({latest_example['bars_held']} bars, {latest_example['pnl_pct']}%)." ) st.caption("Click a row below to highlight that specific example on the chart.") @@ -38,8 +56,12 @@ def render_training_panel(show_past_behavior: bool, example_trades: pd.DataFrame display_examples_raw = example_trades.iloc[::-1].reset_index(drop=True) display_examples = display_examples_raw.copy() - display_examples["entry_timestamp"] = display_examples["entry_timestamp"].astype(str) - display_examples["exit_timestamp"] = display_examples["exit_timestamp"].astype(str) + display_examples["entry_timestamp"] = display_examples["entry_timestamp"].map( + lambda value: format_timestamp(value, display_timezone=display_timezone, use_24h_time=use_24h_time) + ) + display_examples["exit_timestamp"] = display_examples["exit_timestamp"].map( + lambda value: format_timestamp(value, display_timezone=display_timezone, use_24h_time=use_24h_time) + ) table_event = st.dataframe( display_examples, @@ -66,8 +88,16 @@ def render_training_panel(show_past_behavior: bool, example_trades: pd.DataFrame selected_trade = display_examples_raw.iloc[selected_row_idx] direction = str(selected_trade["direction"]) - entry_ts = str(selected_trade["entry_timestamp"]) - exit_ts = str(selected_trade["exit_timestamp"]) + entry_ts = format_timestamp( + selected_trade["entry_timestamp"], + display_timezone=display_timezone, + use_24h_time=use_24h_time, + ) + exit_ts = format_timestamp( + selected_trade["exit_timestamp"], + display_timezone=display_timezone, + use_24h_time=use_24h_time, + ) entry_price = float(selected_trade["entry_price"]) exit_price = float(selected_trade["exit_price"]) bars_held = int(selected_trade["bars_held"])