Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-17 12:10:43 -06:00
parent 1b670a80d9
commit 95973ab28d
15 changed files with 446 additions and 83 deletions

View File

@ -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 <<EOF
$combined
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--mode)
MODE="${2:-}"
shift 2
;;
--env-file)
ENV_FILE="${2:-}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Error: env file not found: $ENV_FILE" >&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=<your-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

View File

@ -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`:

View File

@ -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

View File

@ -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],
}

View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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:

View File

@ -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}<br>"
"Open: %{open:.2f}<br>"
"High: %{high:.2f}<br>"
"Low: %{low:.2f}<br>"
"Close: %{close:.2f}<extra>All Bars</extra>"
),
),
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}<br>"
"Open: %{open:.2f}<br>"
"High: %{high:.2f}<br>"
"Low: %{low:.2f}<br>"
"Close: %{close:.2f}<extra>All Bars</extra>"
),
),
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}<br>Close: %{y:.2f}<extra>Real Bullish</extra>",
),
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}<br>Close: %{y:.2f}<extra>Real Bearish</extra>",
),
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}<br>Volume: %{y}<extra>Volume</extra>",
),
row=2,
col=1,

View File

@ -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"])),
}

View File

@ -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"

View File

@ -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)`

View File

@ -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()

View File

@ -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),
}

View File

@ -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"])