diff --git a/mac/src/App/EmbeddedBackend/WebBackend b/mac/src/App/EmbeddedBackend/WebBackend index 773ec1f..fa56c7e 100755 Binary files a/mac/src/App/EmbeddedBackend/WebBackend and b/mac/src/App/EmbeddedBackend/WebBackend differ diff --git a/web/src/Dockerfile b/web/src/Dockerfile new file mode 100644 index 0000000..029ed5f --- /dev/null +++ b/web/src/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +COPY requirements.txt /app/requirements.txt +RUN pip install -r /app/requirements.txt + +COPY . /app + +EXPOSE 8501 + +CMD ["streamlit", "run", "app.py", "--server.address=0.0.0.0", "--server.port=8501", "--server.headless=true"] diff --git a/web/src/ONBOARDING.md b/web/src/ONBOARDING.md index 7f19987..f3936f1 100644 --- a/web/src/ONBOARDING.md +++ b/web/src/ONBOARDING.md @@ -68,22 +68,30 @@ APP_BUNDLE_PATH="dist-standalone//dist/ManeshTrader.app" ./scripts/cr Tip: sign and notarize before sharing broadly, so macOS trust prompts are reduced. ## 4) First Session Walkthrough -1. Set `Symbol` (examples: `AAPL`, `MSFT`, `BTC-USD`, `ETH-USD`). -2. Set `Timeframe` (start with `1d` to avoid noisy intraday data). -3. Set `Period` (try `6mo` initially). -4. Keep `Ignore potentially live last bar` ON. -5. Keep filters OFF for baseline: +1. At app start, complete `Profile Login`: + - `Login profile name`: type an existing profile, then click `Login` + - `PIN (if enabled)`: enter PIN for protected profiles + - `Create profile name`: type a new unique profile, then click `Create Profile` + - `Set PIN (optional, 4-6 digits)`: add lightweight profile protection +2. After login, use sidebar `Switch profile` to change users. +3. Note: profile names are case-insensitive for uniqueness (`Matt` and `matt` count as the same profile). +4. Sidebar `Profile` section shows audit stamps for created time, last login, last settings update, and last symbol. +5. Set `Symbol` (examples: `AAPL`, `MSFT`, `BTC-USD`, `ETH-USD`). +6. Set `Timeframe` (start with `1d` to avoid noisy intraday data). +7. Set `Period` (try `6mo` initially). +8. Keep `Ignore potentially live last bar` ON. +9. Keep filters OFF for baseline: - `Use previous body range (ignore wicks)` OFF - `Enable volume filter` OFF - `Hide market-closed gaps (stocks)` ON -6. Review top metrics: +10. Review top metrics: - Current Trend - Real Bullish Bars - Real Bearish Bars - Fake Bars -7. Read `Trend Events` for starts and reversals. -8. Use `Live Decision Guide` to translate trend state into a practical bias/action/invalidation workflow. -9. Keep `Show past behavior examples` ON while learning to review historical entry/exit outcomes. +11. Read `Trend Events` for starts and reversals. +12. Use `Live Decision Guide` to translate trend state into a practical bias/action/invalidation workflow. +13. Keep `Show past behavior examples` ON while learning to review historical entry/exit outcomes. ## 5) How To Read The Chart - Candle layer: full price action @@ -222,6 +230,16 @@ streamlit run app.py --server.port 8502 - Verify ticker format (`BTC-USD`, not `BTCUSD`) - Use compatible `Timeframe` + `Period` +### My settings changed unexpectedly +- Confirm you are logged into your own profile. +- Use sidebar `Switch profile` if needed. +- Share profile-specific links by including `?profile=` in the app URL. +- If you were inactive for more than 30 minutes, the app will require login again. + +### PIN login fails +- Ensure the profile name is correct (name matching is case-insensitive). +- PIN must be exactly what was set during profile creation (4-6 digits). + ### I still see some time gaps - For stocks, keep `Hide market-closed gaps (stocks)` ON. - Daily charts remove weekends; intraday removes weekends + closed hours. diff --git a/web/src/PRD.md b/web/src/PRD.md index 121afa9..37a9ce6 100644 --- a/web/src/PRD.md +++ b/web/src/PRD.md @@ -32,6 +32,30 @@ Persisted settings path: - Primary: `~/.web_local_shell/settings.json` - Legacy fallback read: `~/.manesh_trader/settings.json` +Storage format: +- Current format stores per-profile settings: + - `last_profile`: most recently used profile id + - `profiles`: object map of `profile_id -> profile record` + - profile record fields: + - `settings`: normalized settings payload + - `pin_hash`: optional SHA-256 hash for 4-6 digit PIN + - `audit`: metadata (`created_at`, `updated_at`, `last_login_at`, `last_symbol`) +- Legacy single-profile payloads are read as profile `default` and migrated to current format on next save. + +Profile behavior: +- App enforces a profile login gate before data/settings UI is shown. +- Login requires typing an existing profile name. +- Login checks PIN when profile has one configured. +- Create Profile requires typing a new profile name. +- Create Profile accepts optional 4-6 digit PIN. +- Profile-name uniqueness is case-insensitive (for example `Matt` and `matt` are treated as duplicates). +- Sidebar shows active profile with a `Switch profile` control. +- Sidebar shows profile audit stamps (created/updated/last login/last symbol). +- Query param `profile` selects active profile. +- Save/load are scoped to active profile to avoid cross-user overwrites. +- If profile has no saved settings, defaults are used. +- Session activity is tracked in-memory; after 30 minutes of inactivity the app clears active profile and returns to login screen. + Normalization constraints: - `symbol`: uppercase, non-empty fallback `AAPL` - `interval`: must be one of `INTERVAL_OPTIONS`, fallback `1d` diff --git a/web/src/SYNOLOGY.md b/web/src/SYNOLOGY.md new file mode 100644 index 0000000..a3ef2bf --- /dev/null +++ b/web/src/SYNOLOGY.md @@ -0,0 +1,177 @@ +# Synology Deployment Guide (ManeshTrader Web) + +This guide documents the exact deployment flow that worked for this project on Synology NAS. + +## App Location + +This project runs from: + +- NAS folder: `/volume1/docker/maneshtrader` +- Container port: `8501` +- URL: `http://:8501` + +## Prerequisites (One-Time) + +On Synology DSM: + +1. Install `Container Manager` from Package Center. +2. Enable SSH: + - `Control Panel -> Terminal & SNMP -> Enable SSH service` +3. Ensure your user can run Docker: + - Add user to `administrators` group. + - Grant `Container Manager` app permission for that user. + +Notes: + +- Store data only in shared folders (use `/volume1/...` paths). +- If Docker commands fail with socket permission errors, use `sudo -i` (see Troubleshooting). + +## Required Files in `web/src` + +These files must exist (already added in this repo): + +- `Dockerfile` +- `docker-compose.yml` +- `app.py` +- `requirements.txt` +- `web_core/` + +## Initial Deploy + +Run from your Mac (repo root), using your SSH port `25`: + +```bash +rsync -avz --delete -e "ssh -p 25" \ + --exclude ".pytest_cache" --exclude "__pycache__" \ + web/src/ mbrucedogs@192.168.1.128:/volume1/docker/maneshtrader/ +``` + +Then SSH to Synology: + +```bash +ssh -p 25 mbrucedogs@192.168.1.128 +``` + +Start the container: + +```sh +sudo -i +cd /volume1/docker/maneshtrader +mkdir -p data +docker compose up -d --build +``` + +If your DSM uses legacy compose command: + +```sh +docker-compose up -d --build +``` + +## Verify It Is Running + +```sh +sudo -i +docker ps --filter name=maneshtrader +docker logs --tail 100 maneshtrader +``` + +Open in browser: + +- `http://192.168.1.128:8501` + +## Update Workflow (After Code Changes) + +You do **not** need to redo full setup. Just: + +1. Sync updated source. +2. Rebuild/restart compose service. + +From Mac: + +```bash +rsync -avz --delete -e "ssh -p 25" \ + web/src/ mbrucedogs@192.168.1.128:/volume1/docker/maneshtrader/ +``` + +On Synology: + +```sh +sudo -i +cd /volume1/docker/maneshtrader +docker compose up -d --build +``` + +## Useful Operations + +Restart app: + +```sh +sudo -i +cd /volume1/docker/maneshtrader +docker compose restart +``` + +Stop app: + +```sh +sudo -i +cd /volume1/docker/maneshtrader +docker compose down +``` + +Follow logs live: + +```sh +sudo -i +docker logs -f maneshtrader +``` + +## Troubleshooting + +### Error: `permission denied while trying to connect to the Docker daemon socket` + +Cause: + +- SSH user does not have Docker daemon access. + +Fix: + +1. Run commands as root shell: + +```sh +sudo -i +``` + +2. If `sudo` is unavailable, in DSM: + - Add user to `administrators`. + - Allow `Container Manager` permission. + - Reconnect SSH. + +Do **not** run insecure socket permission changes like: + +```sh +chmod 666 /var/run/docker.sock +``` + +### `docker compose` not found + +Try legacy command: + +```sh +docker-compose up -d --build +``` + +Or extend PATH: + +```sh +export PATH=$PATH:/usr/local/bin:/var/packages/ContainerManager/target/usr/bin +``` + +## Optional: One-Liner Update From Mac + +```bash +rsync -avz --delete -e "ssh -p 25" \ + web/src/ mbrucedogs@192.168.1.128:/volume1/docker/maneshtrader/ && \ +ssh -p 25 mbrucedogs@192.168.1.128 \ + "sudo -i sh -lc 'cd /volume1/docker/maneshtrader && docker compose up -d --build'" +``` diff --git a/web/src/app.py b/web/src/app.py index fcf7630..25610c5 100644 --- a/web/src/app.py +++ b/web/src/app.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +import hashlib +import time from pathlib import Path from typing import Any @@ -19,6 +21,8 @@ from web_core.strategy import classify_bars, detect_trends SETTINGS_PATH = Path.home() / ".web_local_shell" / "settings.json" LEGACY_SETTINGS_PATH = Path.home() / ".manesh_trader" / "settings.json" +DEFAULT_PROFILE_ID = "default" +PROFILE_SESSION_TIMEOUT_SEC = 1800 def _clamp_int(value: Any, fallback: int, minimum: int, maximum: int) -> int: @@ -57,6 +61,85 @@ def _clamp_max_bars(value: Any, fallback: int = 500) -> int: return _clamp_int(value=value, fallback=fallback, minimum=20, maximum=5000) +def normalize_profile_id(value: Any) -> str: + profile_id = str(value or "").strip() + return profile_id if profile_id else DEFAULT_PROFILE_ID + + +def _profile_key(value: Any) -> str: + return normalize_profile_id(value).casefold() + + +def normalize_pin(value: Any) -> str | None: + pin = str(value or "").strip() + if not pin: + return None + if not pin.isdigit(): + return None + if len(pin) < 4 or len(pin) > 6: + return None + return pin + + +def hash_profile_pin(profile_id: str, pin: str) -> str: + digest_input = f"{_profile_key(profile_id)}:{pin}" + return hashlib.sha256(digest_input.encode("utf-8")).hexdigest() + + +def first_query_param_value(query_params: Any, key: str) -> str | None: + raw = query_params.get(key) + if raw is None: + return None + if isinstance(raw, list): + return str(raw[0]) if raw else None + return str(raw) + + +def resolve_login_profile(session_profile: Any, query_profile: Any) -> str | None: + if str(session_profile or "").strip(): + return normalize_profile_id(session_profile) + + if str(query_profile or "").strip(): + return normalize_profile_id(query_profile) + + return None + + +def find_existing_profile_id(profile_id: Any, available_profiles: set[str]) -> str | None: + requested_key = _profile_key(profile_id) + for existing in available_profiles: + if _profile_key(existing) == requested_key: + return existing + return None + + +def profile_exists(profile_id: Any, available_profiles: set[str]) -> bool: + return find_existing_profile_id(profile_id, available_profiles) is not None + + +def is_profile_session_expired(last_active: Any, now_epoch: float, timeout_sec: int = PROFILE_SESSION_TIMEOUT_SEC) -> bool: + if not isinstance(last_active, (int, float)): + return False + return (now_epoch - float(last_active)) > timeout_sec + + +def _normalize_epoch(value: Any) -> int | None: + if not isinstance(value, (int, float)): + return None + parsed = int(value) + return parsed if parsed >= 0 else None + + +def _format_epoch(value: Any) -> 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" + + def normalize_web_settings(raw: dict[str, Any] | None) -> dict[str, Any]: raw = raw or {} defaults: dict[str, Any] = { @@ -161,22 +244,192 @@ def normalize_web_settings(raw: dict[str, Any] | None) -> dict[str, Any]: } -def load_web_settings() -> dict[str, Any]: +def _load_raw_settings_payload() -> dict[str, Any] | None: source_path = SETTINGS_PATH if SETTINGS_PATH.exists() else LEGACY_SETTINGS_PATH if not source_path.exists(): - return normalize_web_settings(None) + return None try: payload = json.loads(source_path.read_text(encoding="utf-8")) - if not isinstance(payload, dict): - return normalize_web_settings(None) - return normalize_web_settings(payload) + return payload if isinstance(payload, dict) else None except Exception: - return normalize_web_settings(None) + return None -def save_web_settings(settings: dict[str, Any]) -> None: +def normalize_profile_record(profile_id: str, raw_profile_data: Any) -> dict[str, Any]: + now_epoch = int(time.time()) + fallback_settings = normalize_web_settings(None) + settings: dict[str, Any] + pin_hash = "" + raw_audit: Any = {} + + if isinstance(raw_profile_data, dict) and isinstance(raw_profile_data.get("settings"), dict): + settings = normalize_web_settings(raw_profile_data.get("settings")) + pin_hash = str(raw_profile_data.get("pin_hash") or "").strip() + if not pin_hash and isinstance(raw_profile_data.get("auth"), dict): + pin_hash = str(raw_profile_data["auth"].get("pin_hash") or "").strip() + raw_audit = raw_profile_data.get("audit") + elif isinstance(raw_profile_data, dict): + settings = normalize_web_settings(raw_profile_data) + else: + settings = fallback_settings + + created_at = now_epoch + updated_at = now_epoch + last_login_at = None + last_symbol = str(settings.get("symbol", fallback_settings["symbol"])) + if isinstance(raw_audit, dict): + created_at = _normalize_epoch(raw_audit.get("created_at")) or now_epoch + updated_at = _normalize_epoch(raw_audit.get("updated_at")) or created_at + last_login_at = _normalize_epoch(raw_audit.get("last_login_at")) + raw_last_symbol = str(raw_audit.get("last_symbol") or "").strip().upper() + if raw_last_symbol: + last_symbol = raw_last_symbol + + return { + "settings": settings, + "pin_hash": pin_hash, + "audit": { + "created_at": created_at, + "updated_at": updated_at, + "last_login_at": last_login_at, + "last_symbol": last_symbol, + }, + } + + +def _write_settings_store(profile_records: dict[str, dict[str, Any]], last_profile: str) -> None: + serialized_profiles: dict[str, dict[str, Any]] = {} + for profile_id, record in profile_records.items(): + normalized_record = normalize_profile_record(profile_id=profile_id, raw_profile_data=record) + serialized_profiles[profile_id] = normalized_record + + payload = { + "last_profile": normalize_profile_id(last_profile), + "profiles": serialized_profiles, + } + SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) - SETTINGS_PATH.write_text(json.dumps(normalize_web_settings(settings), indent=2), encoding="utf-8") + SETTINGS_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + +def _load_profile_records() -> tuple[dict[str, dict[str, Any]], str]: + payload = _load_raw_settings_payload() + if not payload: + return {}, DEFAULT_PROFILE_ID + + raw_profiles = payload.get("profiles") + if isinstance(raw_profiles, dict): + profile_records: dict[str, dict[str, Any]] = {} + for raw_profile_id, raw_profile_data in raw_profiles.items(): + if isinstance(raw_profile_data, dict): + profile_id = normalize_profile_id(raw_profile_id) + profile_records[profile_id] = normalize_profile_record(profile_id, raw_profile_data) + + last_profile = normalize_profile_id(payload.get("last_profile")) + if profile_records and last_profile not in profile_records: + last_profile = next(iter(profile_records)) + return profile_records, last_profile + + # Legacy format: a single flat settings payload. + legacy_record = normalize_profile_record(profile_id=DEFAULT_PROFILE_ID, raw_profile_data=payload) + return {DEFAULT_PROFILE_ID: legacy_record}, DEFAULT_PROFILE_ID + + +def list_web_profiles() -> list[str]: + profile_records, _ = _load_profile_records() + profile_ids = list(profile_records.keys()) + if DEFAULT_PROFILE_ID not in profile_ids: + profile_ids.append(DEFAULT_PROFILE_ID) + return sorted(profile_ids, key=lambda item: (item != DEFAULT_PROFILE_ID, item.lower())) + + +def load_web_settings(profile_id: str | None = None) -> dict[str, Any]: + profile_records, last_profile = _load_profile_records() + selected_profile = normalize_profile_id(profile_id if profile_id is not None else last_profile) + if selected_profile not in profile_records: + return normalize_web_settings(None) + return normalize_web_settings(profile_records[selected_profile].get("settings")) + + +def profile_requires_pin(profile_id: str) -> bool: + selected = normalize_profile_id(profile_id) + profile_records, _ = _load_profile_records() + record = profile_records.get(selected) + if not isinstance(record, dict): + return False + return bool(str(record.get("pin_hash") or "").strip()) + + +def verify_profile_pin(profile_id: str, pin: Any) -> bool: + selected = normalize_profile_id(profile_id) + profile_records, _ = _load_profile_records() + record = profile_records.get(selected) + if not isinstance(record, dict): + return False + + stored_hash = str(record.get("pin_hash") or "").strip() + if not stored_hash: + return True + + normalized_pin = normalize_pin(pin) + if normalized_pin is None: + return False + return stored_hash == hash_profile_pin(selected, normalized_pin) + + +def get_profile_audit(profile_id: str) -> dict[str, Any]: + selected = normalize_profile_id(profile_id) + profile_records, _ = _load_profile_records() + record = profile_records.get(selected) + if not isinstance(record, dict): + return {} + audit = record.get("audit") + return audit if isinstance(audit, dict) else {} + + +def create_profile(profile_id: str, pin: Any = None, now_epoch: int | None = None) -> None: + selected = normalize_profile_id(profile_id) + profile_records, _ = _load_profile_records() + epoch = int(now_epoch if now_epoch is not None else time.time()) + normalized_pin = normalize_pin(pin) + + profile_records[selected] = { + "settings": normalize_web_settings(None), + "pin_hash": hash_profile_pin(selected, normalized_pin) if normalized_pin else "", + "audit": { + "created_at": epoch, + "updated_at": epoch, + "last_login_at": epoch, + "last_symbol": "AAPL", + }, + } + _write_settings_store(profile_records, last_profile=selected) + + +def mark_profile_login(profile_id: str, now_epoch: int | None = None) -> None: + selected = normalize_profile_id(profile_id) + profile_records, _ = _load_profile_records() + epoch = int(now_epoch if now_epoch is not None else time.time()) + existing = profile_records.get(selected, normalize_profile_record(selected, {})) + normalized = normalize_profile_record(selected, existing) + normalized["audit"]["last_login_at"] = epoch + profile_records[selected] = normalized + _write_settings_store(profile_records, last_profile=selected) + + +def save_web_settings(settings: dict[str, Any], profile_id: str | None = None) -> None: + selected_profile = normalize_profile_id(profile_id) + now_epoch = int(time.time()) + profile_records, _ = _load_profile_records() + existing = profile_records.get(selected_profile, normalize_profile_record(selected_profile, {})) + normalized_existing = normalize_profile_record(selected_profile, existing) + normalized_settings = normalize_web_settings(settings) + + normalized_existing["settings"] = normalized_settings + normalized_existing["audit"]["updated_at"] = now_epoch + normalized_existing["audit"]["last_symbol"] = str(normalized_settings.get("symbol", "AAPL")) + profile_records[selected_profile] = normalized_existing + _write_settings_store(profile_records, last_profile=selected_profile) @st.cache_data(show_spinner=False) @@ -320,27 +573,102 @@ def main() -> None: st.caption( "Price-action tool that classifies closed bars, filters fake bars, and tracks trend persistence using only real bars." ) + query_params = st.query_params + now_epoch = time.time() + active_profile = st.session_state.get("active_profile") + last_active = st.session_state.get("profile_last_active_at") + + session_expired = False + if active_profile and is_profile_session_expired(last_active, now_epoch): + session_expired = True + st.session_state.pop("active_profile", None) + st.session_state.pop("profile_last_active_at", None) + active_profile = None + if "profile" in query_params: + del query_params["profile"] + + if not active_profile: + active_profile = None + + if active_profile is 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.") + + initial_profile = first_query_param_value(query_params, "profile") or "" + 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) + 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 + 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 + st.rerun() + st.stop() + + active_profile = normalize_profile_id(active_profile) + st.session_state["active_profile"] = active_profile + st.session_state["profile_last_active_at"] = now_epoch with st.sidebar: + st.header("Profile") + st.success(f"Logged in as: {active_profile}") + if st.button("Switch profile", use_container_width=True): + st.session_state.pop("active_profile", None) + if "profile" in query_params: + del query_params["profile"] + st.rerun() + st.divider() + + available_profiles = list_web_profiles() + profile_audit = get_profile_audit(active_profile) + st.caption( + "Profiles found: " + + ", ".join(available_profiles + ([active_profile] if active_profile not in available_profiles else [])) + ) + 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"last symbol {str(profile_audit.get('last_symbol') or 'n/a')}" + ) + st.divider() + st.header("Data Settings") if st.button("Help / Quick Start", use_container_width=True): help_dialog() st.divider() - query_params = st.query_params - persisted_settings = load_web_settings() - - def first_query_value(key: str) -> str | None: - raw = query_params.get(key) - if raw is None: - return None - if isinstance(raw, list): - return str(raw[0]) if raw else None - return str(raw) + persisted_settings = load_web_settings(profile_id=active_profile) query_overrides: dict[str, Any] = {} for key in persisted_settings: - candidate = first_query_value(key) + 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}) @@ -486,7 +814,8 @@ def main() -> None: "max_training_examples": int(max_training_examples), "enable_auto_refresh": bool(enable_auto_refresh), "refresh_sec": int(refresh_sec), - } + }, + profile_id=active_profile, ) except Exception: # Non-fatal: app should run even if local settings cannot be saved. @@ -561,7 +890,7 @@ def main() -> None: st.info(live_guide["action"]) st.caption(f"Invalidation rule: {live_guide['invalidation']}") - alert_key = f"{symbol}-{interval}-{period}" + alert_key = f"{active_profile}-{symbol}-{interval}-{period}" newest_event = events[-1].event if events else "" previous_event = st.session_state.get(f"last_event-{alert_key}", "") if newest_event and newest_event != previous_event: diff --git a/web/src/docker-compose.yml b/web/src/docker-compose.yml new file mode 100644 index 0000000..0129459 --- /dev/null +++ b/web/src/docker-compose.yml @@ -0,0 +1,11 @@ +services: + maneshtrader: + build: + context: . + dockerfile: Dockerfile + container_name: maneshtrader + restart: unless-stopped + ports: + - "8501:8501" + volumes: + - ./data:/root/.web_local_shell diff --git a/web/src/tests/test_app_settings.py b/web/src/tests/test_app_settings.py new file mode 100644 index 0000000..04c277c --- /dev/null +++ b/web/src/tests/test_app_settings.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import app + + +def test_save_and_load_settings_are_isolated_by_profile(tmp_path, monkeypatch) -> None: + settings_path = tmp_path / "settings.json" + legacy_path = tmp_path / "legacy.json" + monkeypatch.setattr(app, "SETTINGS_PATH", settings_path) + monkeypatch.setattr(app, "LEGACY_SETTINGS_PATH", legacy_path) + + app.save_web_settings({"symbol": "AAPL", "period": "1mo"}, profile_id="alice") + app.save_web_settings({"symbol": "MSFT", "period": "3mo"}, profile_id="bob") + + alice = app.load_web_settings(profile_id="alice") + bob = app.load_web_settings(profile_id="bob") + unknown = app.load_web_settings(profile_id="carol") + + assert alice["symbol"] == "AAPL" + assert alice["period"] == "1mo" + assert bob["symbol"] == "MSFT" + assert bob["period"] == "3mo" + assert unknown["symbol"] == "AAPL" # fallback default + + +def test_load_settings_migrates_legacy_flat_payload_to_default_profile(tmp_path, monkeypatch) -> None: + settings_path = tmp_path / "settings.json" + legacy_path = tmp_path / "legacy.json" + monkeypatch.setattr(app, "SETTINGS_PATH", settings_path) + monkeypatch.setattr(app, "LEGACY_SETTINGS_PATH", legacy_path) + + legacy_payload = {"symbol": "TSLA", "interval": "1h", "max_bars": 9999} + legacy_path.write_text(json.dumps(legacy_payload), encoding="utf-8") + + loaded = app.load_web_settings(profile_id="default") + assert loaded["symbol"] == "TSLA" + assert loaded["interval"] == "1h" + assert loaded["max_bars"] == 5000 # normalized max cap + + app.save_web_settings({"symbol": "TSLA"}, profile_id="default") + stored = json.loads(settings_path.read_text(encoding="utf-8")) + assert "profiles" in stored + assert "default" in stored["profiles"] + assert "settings" in stored["profiles"]["default"] + assert "audit" in stored["profiles"]["default"] + + +def test_list_web_profiles_includes_saved_profiles(tmp_path, monkeypatch) -> None: + settings_path = tmp_path / "settings.json" + legacy_path = tmp_path / "legacy.json" + monkeypatch.setattr(app, "SETTINGS_PATH", settings_path) + monkeypatch.setattr(app, "LEGACY_SETTINGS_PATH", legacy_path) + + app.save_web_settings({"symbol": "AAPL"}, profile_id="zoe") + app.save_web_settings({"symbol": "AAPL"}, profile_id="alice") + + profile_ids = app.list_web_profiles() + assert "default" in profile_ids + assert "alice" in profile_ids + assert "zoe" in profile_ids + + +def test_resolve_login_profile_prefers_session_then_query() -> None: + assert app.resolve_login_profile("alice", "bob") == "alice" + assert app.resolve_login_profile("", "bob") == "bob" + assert app.resolve_login_profile(None, None) is None + + +def test_profile_exists_normalizes_input() -> None: + profiles = {"default", "alice"} + assert app.profile_exists("alice", profiles) is True + assert app.profile_exists(" ", profiles) is True # normalizes to default + assert app.profile_exists("bob", profiles) is False + + +def test_profile_exists_is_case_insensitive() -> None: + profiles = {"default", "Alice"} + assert app.profile_exists("alice", profiles) is True + assert app.find_existing_profile_id("ALICE", profiles) == "Alice" + + +def test_is_profile_session_expired_after_timeout() -> None: + now_epoch = 2000.0 + assert app.is_profile_session_expired(1000.0, now_epoch, timeout_sec=900) is True + assert app.is_profile_session_expired(1500.0, now_epoch, timeout_sec=900) is False + assert app.is_profile_session_expired(None, now_epoch, timeout_sec=900) is False + + +def test_create_profile_with_pin_requires_pin_for_verification(tmp_path, monkeypatch) -> None: + settings_path = tmp_path / "settings.json" + legacy_path = tmp_path / "legacy.json" + monkeypatch.setattr(app, "SETTINGS_PATH", settings_path) + monkeypatch.setattr(app, "LEGACY_SETTINGS_PATH", legacy_path) + + app.create_profile("alice", pin="1234", now_epoch=1700000000) + + assert app.profile_requires_pin("alice") is True + assert app.verify_profile_pin("alice", "1234") is True + assert app.verify_profile_pin("alice", "9999") is False + + +def test_audit_stamps_update_on_save_and_login(tmp_path, monkeypatch) -> None: + settings_path = tmp_path / "settings.json" + legacy_path = tmp_path / "legacy.json" + monkeypatch.setattr(app, "SETTINGS_PATH", settings_path) + monkeypatch.setattr(app, "LEGACY_SETTINGS_PATH", legacy_path) + + app.create_profile("alice", now_epoch=1700000000) + app.save_web_settings({"symbol": "MSFT"}, profile_id="alice") + app.mark_profile_login("alice", now_epoch=1700000010) + + audit = app.get_profile_audit("alice") + assert audit["created_at"] == 1700000000 + assert audit["last_login_at"] == 1700000010 + assert audit["last_symbol"] == "MSFT" + + +def test_load_settings_migrates_old_profiles_map_structure(tmp_path, monkeypatch) -> None: + settings_path = tmp_path / "settings.json" + legacy_path = tmp_path / "legacy.json" + monkeypatch.setattr(app, "SETTINGS_PATH", settings_path) + monkeypatch.setattr(app, "LEGACY_SETTINGS_PATH", legacy_path) + + old_payload = { + "last_profile": "alice", + "profiles": { + "alice": {"symbol": "TSLA", "interval": "1d"}, + "bob": {"symbol": "MSFT", "interval": "1h"}, + }, + } + settings_path.write_text(json.dumps(old_payload), encoding="utf-8") + + alice_settings = app.load_web_settings("alice") + bob_settings = app.load_web_settings("bob") + assert alice_settings["symbol"] == "TSLA" + assert bob_settings["symbol"] == "MSFT" + + app.save_web_settings({"symbol": "AAPL"}, profile_id="alice") + stored = json.loads(settings_path.read_text(encoding="utf-8")) + assert isinstance(stored["profiles"]["alice"], dict) + assert "settings" in stored["profiles"]["alice"]