From 1e73d49aa190f7c5e6a7e4832a383f5b502d71e3 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 16 Feb 2026 22:01:06 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- web/src/ONBOARDING.md | 27 +- web/src/PRD.md | 13 + web/src/app.py | 1044 ++---------------- web/src/tests/test_app_settings.py | 110 +- web/src/tests/test_settings_schema.py | 31 + web/src/web_core/auth/__init__.py | 1 + web/src/web_core/auth/profile_auth.py | 72 ++ web/src/web_core/auth/profile_store.py | 226 ++++ web/src/web_core/chart_overlays.py | 112 ++ web/src/web_core/live_guide.py | 45 + web/src/web_core/market/__init__.py | 1 + web/src/web_core/market/presets.py | 49 + web/src/web_core/market/symbols.py | 73 ++ web/src/web_core/settings/__init__.py | 1 + web/src/web_core/settings/settings_schema.py | 162 +++ web/src/web_core/ui/__init__.py | 1 + web/src/web_core/ui/help_content.py | 28 + web/src/web_core/ui/login_ui.py | 69 ++ web/src/web_core/ui/sidebar_ui.py | 237 ++++ web/src/web_core/ui/training_ui.py | 83 ++ 20 files changed, 1358 insertions(+), 1027 deletions(-) create mode 100644 web/src/tests/test_settings_schema.py create mode 100644 web/src/web_core/auth/__init__.py create mode 100644 web/src/web_core/auth/profile_auth.py create mode 100644 web/src/web_core/auth/profile_store.py create mode 100644 web/src/web_core/chart_overlays.py create mode 100644 web/src/web_core/live_guide.py create mode 100644 web/src/web_core/market/__init__.py create mode 100644 web/src/web_core/market/presets.py create mode 100644 web/src/web_core/market/symbols.py create mode 100644 web/src/web_core/settings/__init__.py create mode 100644 web/src/web_core/settings/settings_schema.py create mode 100644 web/src/web_core/ui/__init__.py create mode 100644 web/src/web_core/ui/help_content.py create mode 100644 web/src/web_core/ui/login_ui.py create mode 100644 web/src/web_core/ui/sidebar_ui.py create mode 100644 web/src/web_core/ui/training_ui.py diff --git a/web/src/ONBOARDING.md b/web/src/ONBOARDING.md index f3936f1..ad9742b 100644 --- a/web/src/ONBOARDING.md +++ b/web/src/ONBOARDING.md @@ -71,27 +71,30 @@ Tip: sign and notarize before sharing broadly, so macOS trust prompts are reduce 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 + - `Remember me on this browser`: keeps non-PIN profiles signed in across visits - `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: +6. Optional: choose `Market Preset` (`Stocks Swing` or `Crypto Intraday`) for one-click defaults. +7. Optional: add symbols to `Watchlist` and use quick-select buttons. +8. Set `Timeframe` (start with `1d` to avoid noisy intraday data). +9. Set `Period` (try `6mo` initially). +10. Keep `Ignore potentially live last bar` ON. +11. Keep filters OFF for baseline: - `Use previous body range (ignore wicks)` OFF - `Enable volume filter` OFF - `Hide market-closed gaps (stocks)` ON -10. Review top metrics: +12. Review top metrics: - Current Trend - Real Bullish Bars - Real Bearish Bars - Fake Bars -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. +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. ## 5) How To Read The Chart - Candle layer: full price action @@ -236,10 +239,18 @@ streamlit run app.py --server.port 8502 - 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. +### Watchlist or preset not applying as expected +- Watchlist uses comma-separated symbols and deduplicates automatically. +- Preset `Custom` disables forced defaults; other presets re-apply tuned defaults each run. + ### 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). +### Why did auto-login not happen? +- `Remember me` only auto-restores profiles without PIN. +- PIN-protected profiles still require PIN on a fresh/expired session. + ### 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 37a9ce6..0f8ad95 100644 --- a/web/src/PRD.md +++ b/web/src/PRD.md @@ -17,9 +17,11 @@ Provide an analysis-only charting tool that classifies OHLC bars as real/fake, t ## 3. Inputs and Data Pipeline 1. User configures: - `symbol` +- optional `watchlist` (per-profile) - `interval` - `period` - `max_bars` +- optional `market_preset` - filter/toggle settings 2. App fetches OHLCV via Yahoo Finance (`yfinance`). 3. Optional last-bar drop (live-bar guard) for intraday intervals. @@ -46,6 +48,7 @@ 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. +- Login includes `Remember me on this browser`. - 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). @@ -55,6 +58,14 @@ Profile behavior: - 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. +- If `remember=1` is present with a known non-PIN profile, app auto-restores that profile without showing login. +- PIN-protected profiles always require PIN entry after session timeout/reopen. + +Watchlist and preset behavior: +- `watchlist` is saved per profile and supports comma/newline input. +- Watchlist supports one-click symbol quick-select buttons. +- `market_preset` applies tuned defaults for common workflows (`Custom`, `Stocks Swing`, `Crypto Intraday`, `Crypto Swing`). +- Preset application affects defaults for timeframe/period/max bars and related monitoring/gap options. Normalization constraints: - `symbol`: uppercase, non-empty fallback `AAPL` @@ -69,6 +80,8 @@ Normalization constraints: - `show_trade_markers`: boolean, fallback `false` - `focus_chart_on_selected_example`: boolean, fallback `false` - `max_training_examples`: `[5, 100]`, fallback `20` +- `watchlist`: uppercase de-duplicated symbol list, max 40 items +- `market_preset`: one of `Custom`, `Stocks Swing`, `Crypto Intraday`, `Crypto Swing`, fallback `Custom` - booleans normalized from common truthy/falsy strings and numbers ## 5. Classification Rules diff --git a/web/src/app.py b/web/src/app.py index 25610c5..c150b35 100644 --- a/web/src/app.py +++ b/web/src/app.py @@ -1,570 +1,33 @@ from __future__ import annotations -import json -import hashlib import time -from pathlib import Path -from typing import Any import pandas as pd -import plotly.graph_objects as go import streamlit as st -import yfinance as yf -from streamlit_autorefresh import st_autorefresh from web_core.analytics import backtest_signals, simulate_trend_trades +from web_core.chart_overlays import add_example_trade_markers, highlight_selected_trade from web_core.charting import build_figure -from web_core.constants import INTERVAL_OPTIONS, PERIOD_OPTIONS, TREND_BEAR, TREND_BULL, TREND_NEUTRAL +from web_core.constants import TREND_NEUTRAL from web_core.data import fetch_ohlc, maybe_drop_live_bar from web_core.exporting import df_for_export +from web_core.ui.login_ui import render_profile_login +from web_core.live_guide import build_live_decision_guide +from web_core.auth.profile_store import ( + find_existing_profile_id, + first_query_param_value, + is_truthy_flag, + is_profile_session_expired, + list_web_profiles, + mark_profile_login, + normalize_profile_id, + profile_requires_pin, + save_web_settings, +) +from web_core.ui.sidebar_ui import render_sidebar 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: - try: - parsed = int(value) - except (TypeError, ValueError): - return fallback - return min(maximum, max(minimum, parsed)) - - -def _clamp_float(value: Any, fallback: float, minimum: float, maximum: float) -> float: - try: - parsed = float(value) - except (TypeError, ValueError): - return fallback - return min(maximum, max(minimum, parsed)) - - -def _to_bool(value: Any, fallback: bool) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, (int, float)): - return value != 0 - if value is None: - return fallback - - normalized = str(value).strip().lower() - if normalized in {"1", "true", "yes", "y", "on"}: - return True - if normalized in {"0", "false", "no", "n", "off"}: - return False - return fallback - - -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] = { - "symbol": "AAPL", - "interval": "1d", - "period": "6mo", - "max_bars": 500, - "drop_live": True, - "use_body_range": False, - "volume_filter_enabled": False, - "volume_sma_window": 20, - "volume_multiplier": 1.0, - "gray_fake": True, - "hide_market_closed_gaps": True, - "show_live_guide": False, - "show_past_behavior": False, - "show_trade_markers": False, - "focus_chart_on_selected_example": False, - "max_training_examples": 20, - "enable_auto_refresh": False, - "refresh_sec": 60, - } - - symbol = str(raw.get("symbol", defaults["symbol"])).strip().upper() - if not symbol: - symbol = str(defaults["symbol"]) - - interval = str(raw.get("interval", defaults["interval"])) - if interval not in INTERVAL_OPTIONS: - interval = str(defaults["interval"]) - - period = str(raw.get("period", defaults["period"])) - if period not in PERIOD_OPTIONS: - period = str(defaults["period"]) - - max_bars = _clamp_max_bars(raw.get("max_bars"), fallback=int(defaults["max_bars"])) - drop_live = _to_bool(raw.get("drop_live"), fallback=bool(defaults["drop_live"])) - use_body_range = _to_bool(raw.get("use_body_range"), fallback=bool(defaults["use_body_range"])) - volume_filter_enabled = _to_bool( - raw.get("volume_filter_enabled"), fallback=bool(defaults["volume_filter_enabled"]) - ) - volume_sma_window = _clamp_int( - raw.get("volume_sma_window"), - fallback=int(defaults["volume_sma_window"]), - minimum=2, - maximum=100, - ) - volume_multiplier = round( - _clamp_float( - raw.get("volume_multiplier"), - fallback=float(defaults["volume_multiplier"]), - minimum=0.1, - maximum=3.0, - ), - 1, - ) - gray_fake = _to_bool(raw.get("gray_fake"), fallback=bool(defaults["gray_fake"])) - hide_market_closed_gaps = _to_bool( - raw.get("hide_market_closed_gaps"), - fallback=bool(defaults["hide_market_closed_gaps"]), - ) - show_live_guide = _to_bool(raw.get("show_live_guide"), fallback=bool(defaults["show_live_guide"])) - show_past_behavior = _to_bool(raw.get("show_past_behavior"), fallback=bool(defaults["show_past_behavior"])) - show_trade_markers = _to_bool(raw.get("show_trade_markers"), fallback=bool(defaults["show_trade_markers"])) - focus_chart_on_selected_example = _to_bool( - raw.get("focus_chart_on_selected_example"), - fallback=bool(defaults["focus_chart_on_selected_example"]), - ) - max_training_examples = _clamp_int( - raw.get("max_training_examples"), - fallback=int(defaults["max_training_examples"]), - minimum=5, - maximum=100, - ) - enable_auto_refresh = _to_bool(raw.get("enable_auto_refresh"), fallback=bool(defaults["enable_auto_refresh"])) - refresh_sec = _clamp_int( - raw.get("refresh_sec"), - fallback=int(defaults["refresh_sec"]), - minimum=10, - maximum=600, - ) - - return { - "symbol": symbol, - "interval": interval, - "period": period, - "max_bars": max_bars, - "drop_live": drop_live, - "use_body_range": use_body_range, - "volume_filter_enabled": volume_filter_enabled, - "volume_sma_window": volume_sma_window, - "volume_multiplier": volume_multiplier, - "gray_fake": gray_fake, - "hide_market_closed_gaps": hide_market_closed_gaps, - "show_live_guide": show_live_guide, - "show_past_behavior": show_past_behavior, - "show_trade_markers": show_trade_markers, - "focus_chart_on_selected_example": focus_chart_on_selected_example, - "max_training_examples": max_training_examples, - "enable_auto_refresh": enable_auto_refresh, - "refresh_sec": refresh_sec, - } - - -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 None - try: - payload = json.loads(source_path.read_text(encoding="utf-8")) - return payload if isinstance(payload, dict) else None - except Exception: - return 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(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) -def load_help_content() -> tuple[str, bool]: - help_html_paths = [ - Path(__file__).with_name("web_core").joinpath("help.html"), - Path(__file__).with_name("help.html"), - ] - for help_html_path in help_html_paths: - if help_html_path.exists(): - return help_html_path.read_text(encoding="utf-8"), True - - onboarding_path = Path(__file__).with_name("ONBOARDING.md") - if onboarding_path.exists(): - return onboarding_path.read_text(encoding="utf-8"), False - return "Help content not found.", False - - -@st.dialog("Help & Quick Start", width="large") -def help_dialog() -> None: - content, is_html = load_help_content() - st.markdown(content, unsafe_allow_html=is_html) - - -@st.cache_data(show_spinner=False, ttl=3600) -def lookup_symbol_candidates(query: str, max_results: int = 10) -> list[dict[str, str]]: - cleaned = query.strip() - if len(cleaned) < 2: - return [] - - try: - search = yf.Search(cleaned, max_results=max_results) - quotes = getattr(search, "quotes", []) or [] - except Exception: - return [] - - seen_symbols: set[str] = set() - candidates: list[dict[str, str]] = [] - for quote in quotes: - symbol = str(quote.get("symbol", "")).strip().upper() - if not symbol or symbol in seen_symbols: - continue - - name = str(quote.get("shortname") or quote.get("longname") or "").strip() - exchange = str(quote.get("exchDisp") or quote.get("exchange") or "").strip() - type_display = str(quote.get("typeDisp") or quote.get("quoteType") or "").strip() - - seen_symbols.add(symbol) - candidates.append( - { - "symbol": symbol, - "name": name, - "exchange": exchange, - "type": type_display, - } - ) - return candidates - - -@st.cache_data(show_spinner=False, ttl=3600) -def resolve_symbol_identity(symbol: str) -> dict[str, str]: - normalized_symbol = symbol.strip().upper() - if not normalized_symbol: - return {"symbol": "", "name": "", "exchange": ""} - - def _from_quote(quote: dict[str, Any]) -> dict[str, str]: - return { - "symbol": normalized_symbol, - "name": str(quote.get("shortname") or quote.get("longname") or "").strip(), - "exchange": str(quote.get("exchDisp") or quote.get("exchange") or "").strip(), - } - - try: - search = yf.Search(normalized_symbol, max_results=8) - quotes = getattr(search, "quotes", []) or [] - for quote in quotes: - candidate_symbol = str(quote.get("symbol", "")).strip().upper() - if candidate_symbol == normalized_symbol: - return _from_quote(quote) - if quotes: - return _from_quote(quotes[0]) - except Exception: - pass - - try: - info = yf.Ticker(normalized_symbol).info - return { - "symbol": normalized_symbol, - "name": str(info.get("shortName") or info.get("longName") or "").strip(), - "exchange": str(info.get("exchange") or "").strip(), - } - except Exception: - return {"symbol": normalized_symbol, "name": "", "exchange": ""} - - -def build_live_decision_guide( - trend_now: str, - previous_trend: str, - latest_classification: str, -) -> dict[str, str]: - if trend_now == TREND_BULL: - bias = "Long Bias" - action = "Prefer pullback longs and avoid fresh shorts while the bullish trend remains active." - invalidation = "Bullish bias is invalidated only after 2 consecutive real bearish bars confirm a reversal." - elif trend_now == TREND_BEAR: - bias = "Short Bias" - action = "Prefer short setups on pops and avoid fresh longs while the bearish trend remains active." - invalidation = "Bearish bias is invalidated only after 2 consecutive real bullish bars confirm a reversal." - else: - bias = "Stand Aside / Neutral" - action = "Wait for 2 consecutive real bars in one direction before taking directional exposure." - invalidation = "No active trend yet; avoid forcing trades in noisy ranges." - - if trend_now in {TREND_BULL, TREND_BEAR} and trend_now != previous_trend: - confirmation = "Fresh Confirmation" - confirmation_detail = "Latest closed bar confirmed a new active trend." - else: - confirmation = "No New Confirmation" - confirmation_detail = "Latest closed bar did not confirm a new trend reversal." - - classification_hint = { - "real_bull": "Latest bar closed above the previous range.", - "real_bear": "Latest bar closed below the previous range.", - "fake": "Latest bar stayed inside the previous range (noise).", - "unclassified": "Latest bar is unclassified.", - }.get(latest_classification, "Latest bar classification unavailable.") - - return { - "bias": bias, - "action": action, - "invalidation": invalidation, - "confirmation": confirmation, - "confirmation_detail": confirmation_detail, - "classification_hint": classification_hint, - } +from web_core.market.symbols import resolve_symbol_identity +from web_core.ui.training_ui import render_training_panel def main() -> None: @@ -573,254 +36,55 @@ 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") + remember_requested = is_truthy_flag(first_query_param_value(query_params, "remember")) 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 remember_requested and not profile_requires_pin(str(active_profile)): + st.session_state["profile_last_active_at"] = now_epoch + else: + 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 + remembered_profile = first_query_param_value(query_params, "profile") + if remember_requested and remembered_profile: + matched_profile = find_existing_profile_id(remembered_profile, set(list_web_profiles())) + if matched_profile and not profile_requires_pin(matched_profile): + active_profile = matched_profile + st.session_state["active_profile"] = matched_profile st.session_state["profile_last_active_at"] = now_epoch - query_params["profile"] = selected_match - st.rerun() + mark_profile_login(matched_profile, now_epoch=int(now_epoch)) - 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() + if not active_profile: + render_profile_login(now_epoch=now_epoch, query_params=query_params, session_expired=session_expired) 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() - - 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("Find Symbol") - symbol_search_query = st.text_input( - "Search by company or ticker", - value="", - placeholder="Apple, Tesla, Bitcoin...", - help="Type a name (e.g. Apple) and select a result to prefill Symbol.", - ) - symbol_from_search: str | None = None - if symbol_search_query.strip(): - candidates = lookup_symbol_candidates(symbol_search_query) - if candidates: - result_placeholder = "Select a result..." - labels = [result_placeholder] + [ - " | ".join( - [ - candidate["symbol"], - candidate["name"] or "No name", - candidate["exchange"] or "Unknown exchange", - ] - ) - for candidate in candidates - ] - selected_label = st.selectbox("Matches", labels, index=0) - if selected_label != result_placeholder: - selected_index = labels.index(selected_label) - 1 - symbol_from_search = candidates[selected_index]["symbol"] - else: - st.caption("No matches found. Try another company name.") - - symbol = st.text_input( - "Symbol", - value=symbol_from_search or str(effective_defaults["symbol"]), - help="Ticker or pair to analyze, e.g. AAPL, MSFT, BTC-USD.", - ).strip().upper() - interval = st.selectbox( - "Timeframe", - INTERVAL_OPTIONS, - index=INTERVAL_OPTIONS.index(str(effective_defaults["interval"])), - help="Bar size for each candle. Shorter intervals are noisier; 1d is a good default.", - ) - period = st.selectbox( - "Period", - PERIOD_OPTIONS, - index=PERIOD_OPTIONS.index(str(effective_defaults["period"])), - help="How much history to load for trend analysis.", - ) - max_bars = st.number_input( - "Max bars", - min_value=20, - max_value=5000, - value=int(effective_defaults["max_bars"]), - step=10, - help="Limits loaded candles to keep charting responsive. 500 is a solid starting point.", - ) - drop_live = st.checkbox("Ignore potentially live last bar", value=bool(effective_defaults["drop_live"])) - - st.header("Classification Filters") - use_body_range = st.checkbox( - "Use previous body range (ignore wicks)", - value=bool(effective_defaults["use_body_range"]), - ) - volume_filter_enabled = st.checkbox( - "Enable volume filter", - value=bool(effective_defaults["volume_filter_enabled"]), - ) - volume_sma_window = st.slider( - "Volume SMA window", - 2, - 100, - int(effective_defaults["volume_sma_window"]), - ) - volume_multiplier = st.slider( - "Min volume / SMA multiplier", - 0.1, - 3.0, - float(effective_defaults["volume_multiplier"]), - 0.1, - ) - gray_fake = st.checkbox("Gray out fake bars", value=bool(effective_defaults["gray_fake"])) - hide_market_closed_gaps = st.checkbox( - "Hide market-closed gaps (stocks)", - value=bool(effective_defaults["hide_market_closed_gaps"]), - ) - - st.header("Training & Guidance") - show_live_guide = st.checkbox( - "Show live decision guide", - value=bool(effective_defaults["show_live_guide"]), - help="Shows a plain-English interpretation of current trend state and confirmation status.", - ) - show_past_behavior = st.checkbox( - "Show past behavior examples", - value=bool(effective_defaults["show_past_behavior"]), - help="Displays historical example trades based on trend confirmation and reversal signals.", - ) - show_trade_markers = st.checkbox( - "Overlay example entries/exits on chart", - value=bool(effective_defaults["show_trade_markers"]), - help="Adds entry/exit markers for the training examples onto the main chart.", - ) - focus_chart_on_selected_example = st.checkbox( - "Focus chart on selected example", - value=bool(effective_defaults["focus_chart_on_selected_example"]), - help="When a training row is selected, zooms chart around that trade window.", - ) - max_training_examples = st.slider( - "Max training examples", - 5, - 100, - int(effective_defaults["max_training_examples"]), - 5, - ) - - st.header("Monitoring") - enable_auto_refresh = st.checkbox("Auto-refresh", value=bool(effective_defaults["enable_auto_refresh"])) - refresh_sec = st.slider("Refresh interval (seconds)", 10, 600, int(effective_defaults["refresh_sec"]), 10) - - if enable_auto_refresh: - st_autorefresh(interval=refresh_sec * 1000, key="data_refresh") + sidebar_settings = render_sidebar(active_profile=active_profile, query_params=query_params) try: - save_web_settings( - { - "symbol": symbol, - "interval": interval, - "period": period, - "max_bars": int(max_bars), - "drop_live": bool(drop_live), - "use_body_range": bool(use_body_range), - "volume_filter_enabled": bool(volume_filter_enabled), - "volume_sma_window": int(volume_sma_window), - "volume_multiplier": float(volume_multiplier), - "gray_fake": bool(gray_fake), - "hide_market_closed_gaps": bool(hide_market_closed_gaps), - "show_live_guide": bool(show_live_guide), - "show_past_behavior": bool(show_past_behavior), - "show_trade_markers": bool(show_trade_markers), - "focus_chart_on_selected_example": bool(focus_chart_on_selected_example), - "max_training_examples": int(max_training_examples), - "enable_auto_refresh": bool(enable_auto_refresh), - "refresh_sec": int(refresh_sec), - }, - profile_id=active_profile, - ) + save_web_settings(sidebar_settings, profile_id=active_profile) except Exception: - # Non-fatal: app should run even if local settings cannot be saved. pass + symbol = str(sidebar_settings["symbol"]) + interval = str(sidebar_settings["interval"]) + period = str(sidebar_settings["period"]) + max_bars = int(sidebar_settings["max_bars"]) + if not symbol: st.error("Please enter a symbol.") st.stop() @@ -837,7 +101,7 @@ def main() -> None: try: raw = fetch_ohlc(symbol=symbol, interval=interval, period=period) - raw = maybe_drop_live_bar(raw, interval=interval, enabled=drop_live) + raw = maybe_drop_live_bar(raw, interval=interval, enabled=bool(sidebar_settings["drop_live"])) if len(raw) > max_bars: raw = raw.iloc[-max_bars:].copy() @@ -847,13 +111,12 @@ def main() -> None: classified = classify_bars( raw, - use_body_range=use_body_range, - volume_filter_enabled=volume_filter_enabled, - volume_sma_window=volume_sma_window, - volume_multiplier=volume_multiplier, + use_body_range=bool(sidebar_settings["use_body_range"]), + volume_filter_enabled=bool(sidebar_settings["volume_filter_enabled"]), + volume_sma_window=int(sidebar_settings["volume_sma_window"]), + volume_multiplier=float(sidebar_settings["volume_multiplier"]), ) analyzed, events = detect_trends(classified) - except Exception as exc: st.error(f"Data error: {exc}") st.stop() @@ -861,25 +124,17 @@ def main() -> None: latest = analyzed.iloc[-1] trend_now = str(latest["trend_state"]) - bull_count = int((analyzed["classification"] == "real_bull").sum()) - bear_count = int((analyzed["classification"] == "real_bear").sum()) - fake_count = int((analyzed["classification"] == "fake").sum()) - c1, c2, c3, c4 = st.columns(4) c1.metric("Current Trend", trend_now) - c2.metric("Real Bullish Bars", bull_count) - c3.metric("Real Bearish Bars", bear_count) - c4.metric("Fake Bars", fake_count) + c2.metric("Real Bullish Bars", int((analyzed["classification"] == "real_bull").sum())) + c3.metric("Real Bearish Bars", int((analyzed["classification"] == "real_bear").sum())) + c4.metric("Fake Bars", int((analyzed["classification"] == "fake").sum())) previous_trend = str(analyzed.iloc[-2]["trend_state"]) if len(analyzed) > 1 else TREND_NEUTRAL latest_classification = str(latest["classification"]) - live_guide = build_live_decision_guide( - trend_now=trend_now, - previous_trend=previous_trend, - latest_classification=latest_classification, - ) + live_guide = build_live_decision_guide(trend_now, previous_trend, latest_classification) - if show_live_guide: + if bool(sidebar_settings["show_live_guide"]): st.subheader("Live Decision Guide") g1, g2, g3 = st.columns(3) g1.metric("Bias", live_guide["bias"]) @@ -897,184 +152,27 @@ def main() -> None: st.warning(f"Alert: {newest_event}") st.session_state[f"last_event-{alert_key}"] = newest_event - example_trades = simulate_trend_trades(analyzed, max_examples=int(max_training_examples)) - selected_trade: pd.Series | None = None - - if show_past_behavior: - st.subheader("Past Behavior Examples (Training)") - if example_trades.empty: - st.info("No closed example trades yet. Expand the period/timeframe to include more trend reversals.") - else: - wins = int((example_trades["outcome"] == "Win").sum()) - losses = int((example_trades["outcome"] == "Loss").sum()) - total_examples = int(len(example_trades)) - win_rate = round((wins / total_examples) * 100.0, 2) if total_examples else 0.0 - avg_pnl = round(float(example_trades["pnl_pct"].mean()), 2) - - t1, t2, t3, t4 = st.columns(4) - t1.metric("Closed Examples", total_examples) - t2.metric("Wins / Losses", f"{wins} / {losses}") - t3.metric("Example Win Rate", f"{win_rate}%") - t4.metric("Avg P/L per Example", f"{avg_pnl}%") - - latest_example = example_trades.iloc[-1] - st.caption( - "Latest closed example: " - f"{latest_example['direction']} from {latest_example['entry_timestamp']} " - f"to {latest_example['exit_timestamp']} " - f"({latest_example['bars_held']} bars, {latest_example['pnl_pct']}%)." - ) - st.caption("Click a row below to highlight that specific example on the chart.") - st.caption( - "Example method: enter on trend confirmation bar close and exit on opposite trend confirmation." - ) - - 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) - - table_event = st.dataframe( - display_examples, - use_container_width=True, - on_select="rerun", - selection_mode="single-row", - key=f"training-examples-{alert_key}", - ) - selected_rows: list[int] = [] - try: - selected_rows = list(table_event.selection.rows) - except Exception: - selected_rows = [] - - selected_row_state_key = f"selected-training-row-{alert_key}" - if selected_rows: - selected_row_idx = int(selected_rows[0]) - st.session_state[selected_row_state_key] = selected_row_idx - else: - selected_row_idx = int(st.session_state.get(selected_row_state_key, 0)) - - if selected_row_idx < 0 or selected_row_idx >= len(display_examples_raw): - selected_row_idx = 0 - - 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_price = float(selected_trade["entry_price"]) - exit_price = float(selected_trade["exit_price"]) - bars_held = int(selected_trade["bars_held"]) - pnl_pct = float(selected_trade["pnl_pct"]) - outcome = str(selected_trade["outcome"]) - - pnl_text = f"+{pnl_pct}%" if pnl_pct > 0 else f"{pnl_pct}%" - result_text = "profit" if pnl_pct > 0 else ("loss" if pnl_pct < 0 else "flat result") - st.info( - f"Selected example explained: {direction} entry at {entry_ts} ({entry_price}), " - f"exit at {exit_ts} ({exit_price}), held {bars_held} bars, {result_text} {pnl_text}, outcome: {outcome}." - ) + example_trades = simulate_trend_trades(analyzed, max_examples=int(sidebar_settings["max_training_examples"])) + selected_trade = render_training_panel( + show_past_behavior=bool(sidebar_settings["show_past_behavior"]), + example_trades=example_trades, + alert_key=alert_key, + ) fig = build_figure( analyzed, - gray_fake=gray_fake, + gray_fake=bool(sidebar_settings["gray_fake"]), interval=interval, - hide_market_closed_gaps=hide_market_closed_gaps, + hide_market_closed_gaps=bool(sidebar_settings["hide_market_closed_gaps"]), + ) + if bool(sidebar_settings["show_trade_markers"]): + add_example_trade_markers(fig, example_trades) + highlight_selected_trade( + fig, + analyzed, + selected_trade, + focus_chart_on_selected_example=bool(sidebar_settings["focus_chart_on_selected_example"]), ) - if show_trade_markers and not example_trades.empty: - long_entries = example_trades[example_trades["direction"] == "LONG"] - short_entries = example_trades[example_trades["direction"] == "SHORT"] - win_exits = example_trades[example_trades["outcome"] == "Win"] - non_win_exits = example_trades[example_trades["outcome"] != "Win"] - - if not long_entries.empty: - fig.add_trace( - go.Scatter( - x=long_entries["entry_timestamp"], - y=long_entries["entry_price"], - mode="markers", - name="Example Entry (Long)", - marker=dict(color="#1565C0", size=9, symbol="circle"), - ), - row=1, - col=1, - ) - if not short_entries.empty: - fig.add_trace( - go.Scatter( - x=short_entries["entry_timestamp"], - y=short_entries["entry_price"], - mode="markers", - name="Example Entry (Short)", - marker=dict(color="#EF6C00", size=9, symbol="diamond"), - ), - row=1, - col=1, - ) - if not win_exits.empty: - fig.add_trace( - go.Scatter( - x=win_exits["exit_timestamp"], - y=win_exits["exit_price"], - mode="markers", - name="Example Exit (Win)", - marker=dict(color="#2E7D32", size=10, symbol="x"), - ), - row=1, - col=1, - ) - if not non_win_exits.empty: - fig.add_trace( - go.Scatter( - x=non_win_exits["exit_timestamp"], - y=non_win_exits["exit_price"], - mode="markers", - name="Example Exit (Loss/Flat)", - marker=dict(color="#C62828", size=10, symbol="x"), - ), - row=1, - col=1, - ) - - if selected_trade is not None: - selected_entry_ts = pd.Timestamp(selected_trade["entry_timestamp"]) - selected_exit_ts = pd.Timestamp(selected_trade["exit_timestamp"]) - selected_entry_price = float(selected_trade["entry_price"]) - selected_exit_price = float(selected_trade["exit_price"]) - selected_direction = str(selected_trade["direction"]) - selected_outcome = str(selected_trade["outcome"]) - - path_color = "#43A047" if selected_outcome == "Win" else ("#EF6C00" if selected_outcome == "Flat" else "#E53935") - window_fill = "#BBDEFB" if selected_direction == "LONG" else "#FFE0B2" - - fig.add_vrect( - x0=selected_entry_ts, - x1=selected_exit_ts, - fillcolor=window_fill, - opacity=0.18, - line_width=0, - row=1, - col=1, - ) - fig.add_trace( - go.Scatter( - x=[selected_entry_ts, selected_exit_ts], - y=[selected_entry_price, selected_exit_price], - mode="lines+markers", - name="Selected Example Path", - line=dict(color=path_color, width=3, dash="dot"), - marker=dict(color=path_color, size=11, symbol="star"), - ), - row=1, - col=1, - ) - - if focus_chart_on_selected_example: - entry_pos = int(analyzed.index.get_indexer([selected_entry_ts], method="nearest")[0]) - exit_pos = int(analyzed.index.get_indexer([selected_exit_ts], method="nearest")[0]) - left_pos = max(0, min(entry_pos, exit_pos) - 4) - right_pos = min(len(analyzed) - 1, max(entry_pos, exit_pos) + 4) - fig.update_xaxes(range=[analyzed.index[left_pos], analyzed.index[right_pos]]) - st.plotly_chart(fig, use_container_width=True) bt = backtest_signals(analyzed) diff --git a/web/src/tests/test_app_settings.py b/web/src/tests/test_app_settings.py index 04c277c..f637bc7 100644 --- a/web/src/tests/test_app_settings.py +++ b/web/src/tests/test_app_settings.py @@ -1,23 +1,22 @@ from __future__ import annotations import json -from pathlib import Path -import app +from web_core.auth import profile_store as store 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) + monkeypatch.setattr(store, "SETTINGS_PATH", settings_path) + monkeypatch.setattr(store, "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") + store.save_web_settings({"symbol": "AAPL", "period": "1mo"}, profile_id="alice") + store.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") + alice = store.load_web_settings(profile_id="alice") + bob = store.load_web_settings(profile_id="bob") + unknown = store.load_web_settings(profile_id="carol") assert alice["symbol"] == "AAPL" assert alice["period"] == "1mo" @@ -29,18 +28,18 @@ def test_save_and_load_settings_are_isolated_by_profile(tmp_path, monkeypatch) - 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) + monkeypatch.setattr(store, "SETTINGS_PATH", settings_path) + monkeypatch.setattr(store, "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") + loaded = store.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") + store.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"] @@ -51,68 +50,76 @@ def test_load_settings_migrates_legacy_flat_payload_to_default_profile(tmp_path, 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) + monkeypatch.setattr(store, "SETTINGS_PATH", settings_path) + monkeypatch.setattr(store, "LEGACY_SETTINGS_PATH", legacy_path) - app.save_web_settings({"symbol": "AAPL"}, profile_id="zoe") - app.save_web_settings({"symbol": "AAPL"}, profile_id="alice") + store.save_web_settings({"symbol": "AAPL"}, profile_id="zoe") + store.save_web_settings({"symbol": "AAPL"}, profile_id="alice") - profile_ids = app.list_web_profiles() + profile_ids = store.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 + assert store.resolve_login_profile("alice", "bob") == "alice" + assert store.resolve_login_profile("", "bob") == "bob" + assert store.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 + assert store.profile_exists("alice", profiles) is True + assert store.profile_exists(" ", profiles) is True # normalizes to default + assert store.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" + assert store.profile_exists("alice", profiles) is True + assert store.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 + assert store.is_profile_session_expired(1000.0, now_epoch, timeout_sec=900) is True + assert store.is_profile_session_expired(1500.0, now_epoch, timeout_sec=900) is False + assert store.is_profile_session_expired(None, now_epoch, timeout_sec=900) is False + + +def test_is_truthy_flag_parses_common_values() -> None: + assert store.is_truthy_flag("1") is True + assert store.is_truthy_flag("true") is True + assert store.is_truthy_flag("yes") is True + assert store.is_truthy_flag("0") is False + assert store.is_truthy_flag(None) 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) + monkeypatch.setattr(store, "SETTINGS_PATH", settings_path) + monkeypatch.setattr(store, "LEGACY_SETTINGS_PATH", legacy_path) - app.create_profile("alice", pin="1234", now_epoch=1700000000) + store.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 + assert store.profile_requires_pin("alice") is True + assert store.verify_profile_pin("alice", "1234") is True + assert store.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) + monkeypatch.setattr(store, "SETTINGS_PATH", settings_path) + monkeypatch.setattr(store, "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) + store.create_profile("alice", now_epoch=1700000000) + store.save_web_settings({"symbol": "MSFT"}, profile_id="alice") + store.mark_profile_login("alice", now_epoch=1700000010) - audit = app.get_profile_audit("alice") + audit = store.get_profile_audit("alice") assert audit["created_at"] == 1700000000 assert audit["last_login_at"] == 1700000010 assert audit["last_symbol"] == "MSFT" @@ -121,8 +128,8 @@ def test_audit_stamps_update_on_save_and_login(tmp_path, monkeypatch) -> None: 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) + monkeypatch.setattr(store, "SETTINGS_PATH", settings_path) + monkeypatch.setattr(store, "LEGACY_SETTINGS_PATH", legacy_path) old_payload = { "last_profile": "alice", @@ -133,12 +140,23 @@ def test_load_settings_migrates_old_profiles_map_structure(tmp_path, monkeypatch } 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") + alice_settings = store.load_web_settings("alice") + bob_settings = store.load_web_settings("bob") assert alice_settings["symbol"] == "TSLA" assert bob_settings["symbol"] == "MSFT" - app.save_web_settings({"symbol": "AAPL"}, profile_id="alice") + store.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"] + + +def test_watchlist_round_trip_keeps_multiple_symbols(tmp_path, monkeypatch) -> None: + settings_path = tmp_path / "settings.json" + legacy_path = tmp_path / "legacy.json" + monkeypatch.setattr(store, "SETTINGS_PATH", settings_path) + monkeypatch.setattr(store, "LEGACY_SETTINGS_PATH", legacy_path) + + store.save_web_settings({"symbol": "AMD", "watchlist": ["AMD", "TSLA"]}, profile_id="matt") + loaded = store.load_web_settings(profile_id="matt") + assert loaded["watchlist"] == ["AMD", "TSLA"] diff --git a/web/src/tests/test_settings_schema.py b/web/src/tests/test_settings_schema.py new file mode 100644 index 0000000..18b859b --- /dev/null +++ b/web/src/tests/test_settings_schema.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from web_core.settings.settings_schema import normalize_web_settings + + +def test_normalize_watchlist_from_string_and_deduplicates() -> None: + raw = {"watchlist": "aapl, msft\nAAPL\n btc-usd "} + out = normalize_web_settings(raw) + assert out["watchlist"] == ["AAPL", "MSFT", "BTC-USD"] + + +def test_normalize_watchlist_from_list_caps_length() -> None: + raw_list = [f"sym{i}" for i in range(60)] + out = normalize_web_settings({"watchlist": raw_list}) + assert len(out["watchlist"]) == 40 + assert out["watchlist"][0] == "SYM0" + + +def test_market_preset_defaults_to_custom_on_invalid_value() -> None: + out = normalize_web_settings({"market_preset": "not-real"}) + assert out["market_preset"] == "Custom" + + +def test_normalize_watchlist_splits_legacy_escaped_newline_values() -> None: + out = normalize_web_settings({"watchlist": "AAPL\\nMSFT\\NTSLA"}) + assert out["watchlist"] == ["AAPL", "MSFT", "TSLA"] + + +def test_normalize_watchlist_splits_escaped_newlines_inside_list_items() -> None: + out = normalize_web_settings({"watchlist": ["AMD\\NTSLA"]}) + assert out["watchlist"] == ["AMD", "TSLA"] diff --git a/web/src/web_core/auth/__init__.py b/web/src/web_core/auth/__init__.py new file mode 100644 index 0000000..4ceee32 --- /dev/null +++ b/web/src/web_core/auth/__init__.py @@ -0,0 +1 @@ +"""Authentication and profile persistence for web app.""" diff --git a/web/src/web_core/auth/profile_auth.py b/web/src/web_core/auth/profile_auth.py new file mode 100644 index 0000000..356ce1d --- /dev/null +++ b/web/src/web_core/auth/profile_auth.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import hashlib +from typing import Any + +DEFAULT_PROFILE_ID = "default" +PROFILE_SESSION_TIMEOUT_SEC = 1800 + + +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 is_truthy_flag(value: Any) -> bool: + normalized = str(value or "").strip().lower() + return normalized in {"1", "true", "yes", "y", "on"} + + +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 diff --git a/web/src/web_core/auth/profile_store.py b/web/src/web_core/auth/profile_store.py new file mode 100644 index 0000000..60745b9 --- /dev/null +++ b/web/src/web_core/auth/profile_store.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any + +import pandas as pd + +from web_core.auth.profile_auth import ( + DEFAULT_PROFILE_ID, + PROFILE_SESSION_TIMEOUT_SEC, + find_existing_profile_id, + first_query_param_value, + hash_profile_pin, + is_truthy_flag, + is_profile_session_expired, + normalize_pin, + normalize_profile_id, + profile_exists, + resolve_login_profile, +) +from web_core.settings.settings_schema import normalize_web_settings + +SETTINGS_PATH = Path.home() / ".web_local_shell" / "settings.json" +LEGACY_SETTINGS_PATH = Path.home() / ".manesh_trader" / "settings.json" + + +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 _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 None + try: + payload = json.loads(source_path.read_text(encoding="utf-8")) + return payload if isinstance(payload, dict) else None + except Exception: + return 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(): + serialized_profiles[profile_id] = normalize_profile_record(profile_id=profile_id, raw_profile_data=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(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_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) + return isinstance(record, dict) and 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) diff --git a/web/src/web_core/chart_overlays.py b/web/src/web_core/chart_overlays.py new file mode 100644 index 0000000..65353f7 --- /dev/null +++ b/web/src/web_core/chart_overlays.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import pandas as pd +import plotly.graph_objects as go + + +def add_example_trade_markers(fig: go.Figure, example_trades: pd.DataFrame) -> None: + if example_trades.empty: + return + + long_entries = example_trades[example_trades["direction"] == "LONG"] + short_entries = example_trades[example_trades["direction"] == "SHORT"] + win_exits = example_trades[example_trades["outcome"] == "Win"] + non_win_exits = example_trades[example_trades["outcome"] != "Win"] + + if not long_entries.empty: + fig.add_trace( + go.Scatter( + x=long_entries["entry_timestamp"], + y=long_entries["entry_price"], + mode="markers", + name="Example Entry (Long)", + marker=dict(color="#1565C0", size=9, symbol="circle"), + ), + row=1, + col=1, + ) + if not short_entries.empty: + fig.add_trace( + go.Scatter( + x=short_entries["entry_timestamp"], + y=short_entries["entry_price"], + mode="markers", + name="Example Entry (Short)", + marker=dict(color="#EF6C00", size=9, symbol="diamond"), + ), + row=1, + col=1, + ) + if not win_exits.empty: + fig.add_trace( + go.Scatter( + x=win_exits["exit_timestamp"], + y=win_exits["exit_price"], + mode="markers", + name="Example Exit (Win)", + marker=dict(color="#2E7D32", size=10, symbol="x"), + ), + row=1, + col=1, + ) + if not non_win_exits.empty: + fig.add_trace( + go.Scatter( + x=non_win_exits["exit_timestamp"], + y=non_win_exits["exit_price"], + mode="markers", + name="Example Exit (Loss/Flat)", + marker=dict(color="#C62828", size=10, symbol="x"), + ), + row=1, + col=1, + ) + + +def highlight_selected_trade( + fig: go.Figure, + analyzed: pd.DataFrame, + selected_trade: pd.Series | None, + focus_chart_on_selected_example: bool, +) -> None: + if selected_trade is None: + return + + selected_entry_ts = pd.Timestamp(selected_trade["entry_timestamp"]) + selected_exit_ts = pd.Timestamp(selected_trade["exit_timestamp"]) + selected_entry_price = float(selected_trade["entry_price"]) + selected_exit_price = float(selected_trade["exit_price"]) + selected_direction = str(selected_trade["direction"]) + selected_outcome = str(selected_trade["outcome"]) + + path_color = "#43A047" if selected_outcome == "Win" else ("#EF6C00" if selected_outcome == "Flat" else "#E53935") + window_fill = "#BBDEFB" if selected_direction == "LONG" else "#FFE0B2" + + fig.add_vrect( + x0=selected_entry_ts, + x1=selected_exit_ts, + fillcolor=window_fill, + opacity=0.18, + line_width=0, + row=1, + col=1, + ) + fig.add_trace( + go.Scatter( + x=[selected_entry_ts, selected_exit_ts], + y=[selected_entry_price, selected_exit_price], + mode="lines+markers", + name="Selected Example Path", + line=dict(color=path_color, width=3, dash="dot"), + marker=dict(color=path_color, size=11, symbol="star"), + ), + row=1, + col=1, + ) + + if focus_chart_on_selected_example: + entry_pos = int(analyzed.index.get_indexer([selected_entry_ts], method="nearest")[0]) + exit_pos = int(analyzed.index.get_indexer([selected_exit_ts], method="nearest")[0]) + left_pos = max(0, min(entry_pos, exit_pos) - 4) + right_pos = min(len(analyzed) - 1, max(entry_pos, exit_pos) + 4) + fig.update_xaxes(range=[analyzed.index[left_pos], analyzed.index[right_pos]]) diff --git a/web/src/web_core/live_guide.py b/web/src/web_core/live_guide.py new file mode 100644 index 0000000..99f223e --- /dev/null +++ b/web/src/web_core/live_guide.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from web_core.constants import TREND_BEAR, TREND_BULL + + +def build_live_decision_guide( + trend_now: str, + previous_trend: str, + latest_classification: str, +) -> dict[str, str]: + if trend_now == TREND_BULL: + bias = "Long Bias" + action = "Prefer pullback longs and avoid fresh shorts while the bullish trend remains active." + invalidation = "Bullish bias is invalidated only after 2 consecutive real bearish bars confirm a reversal." + elif trend_now == TREND_BEAR: + bias = "Short Bias" + action = "Prefer short setups on pops and avoid fresh longs while the bearish trend remains active." + invalidation = "Bearish bias is invalidated only after 2 consecutive real bullish bars confirm a reversal." + else: + bias = "Stand Aside / Neutral" + action = "Wait for 2 consecutive real bars in one direction before taking directional exposure." + invalidation = "No active trend yet; avoid forcing trades in noisy ranges." + + if trend_now in {TREND_BULL, TREND_BEAR} and trend_now != previous_trend: + confirmation = "Fresh Confirmation" + confirmation_detail = "Latest closed bar confirmed a new active trend." + else: + confirmation = "No New Confirmation" + confirmation_detail = "Latest closed bar did not confirm a new trend reversal." + + classification_hint = { + "real_bull": "Latest bar closed above the previous range.", + "real_bear": "Latest bar closed below the previous range.", + "fake": "Latest bar stayed inside the previous range (noise).", + "unclassified": "Latest bar is unclassified.", + }.get(latest_classification, "Latest bar classification unavailable.") + + return { + "bias": bias, + "action": action, + "invalidation": invalidation, + "confirmation": confirmation, + "confirmation_detail": confirmation_detail, + "classification_hint": classification_hint, + } diff --git a/web/src/web_core/market/__init__.py b/web/src/web_core/market/__init__.py new file mode 100644 index 0000000..1106f5c --- /dev/null +++ b/web/src/web_core/market/__init__.py @@ -0,0 +1 @@ +"""Market-facing helpers (symbols, presets).""" diff --git a/web/src/web_core/market/presets.py b/web/src/web_core/market/presets.py new file mode 100644 index 0000000..e966dd4 --- /dev/null +++ b/web/src/web_core/market/presets.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import Any + +MARKET_PRESET_OPTIONS = [ + "Custom", + "Stocks Swing", + "Crypto Intraday", + "Crypto Swing", +] + + +def apply_market_preset(base: dict[str, Any], preset: str) -> dict[str, Any]: + updated = dict(base) + if preset == "Stocks Swing": + updated.update( + { + "interval": "1d", + "period": "6mo", + "max_bars": 500, + "drop_live": True, + "hide_market_closed_gaps": True, + "enable_auto_refresh": False, + } + ) + elif preset == "Crypto Intraday": + updated.update( + { + "interval": "1h", + "period": "3mo", + "max_bars": 1000, + "drop_live": True, + "hide_market_closed_gaps": False, + "enable_auto_refresh": True, + "refresh_sec": 60, + } + ) + elif preset == "Crypto Swing": + updated.update( + { + "interval": "4h", + "period": "6mo", + "max_bars": 800, + "drop_live": True, + "hide_market_closed_gaps": False, + "enable_auto_refresh": False, + } + ) + return updated diff --git a/web/src/web_core/market/symbols.py b/web/src/web_core/market/symbols.py new file mode 100644 index 0000000..8ec6542 --- /dev/null +++ b/web/src/web_core/market/symbols.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import Any + +import streamlit as st +import yfinance as yf + + +@st.cache_data(show_spinner=False, ttl=3600) +def lookup_symbol_candidates(query: str, max_results: int = 10) -> list[dict[str, str]]: + cleaned = query.strip() + if len(cleaned) < 2: + return [] + + try: + search = yf.Search(cleaned, max_results=max_results) + quotes = getattr(search, "quotes", []) or [] + except Exception: + return [] + + seen_symbols: set[str] = set() + candidates: list[dict[str, str]] = [] + for quote in quotes: + symbol = str(quote.get("symbol", "")).strip().upper() + if not symbol or symbol in seen_symbols: + continue + + seen_symbols.add(symbol) + candidates.append( + { + "symbol": symbol, + "name": str(quote.get("shortname") or quote.get("longname") or "").strip(), + "exchange": str(quote.get("exchDisp") or quote.get("exchange") or "").strip(), + "type": str(quote.get("typeDisp") or quote.get("quoteType") or "").strip(), + } + ) + return candidates + + +@st.cache_data(show_spinner=False, ttl=3600) +def resolve_symbol_identity(symbol: str) -> dict[str, str]: + normalized_symbol = symbol.strip().upper() + if not normalized_symbol: + return {"symbol": "", "name": "", "exchange": ""} + + def _from_quote(quote: dict[str, Any]) -> dict[str, str]: + return { + "symbol": normalized_symbol, + "name": str(quote.get("shortname") or quote.get("longname") or "").strip(), + "exchange": str(quote.get("exchDisp") or quote.get("exchange") or "").strip(), + } + + try: + search = yf.Search(normalized_symbol, max_results=8) + quotes = getattr(search, "quotes", []) or [] + for quote in quotes: + candidate_symbol = str(quote.get("symbol", "")).strip().upper() + if candidate_symbol == normalized_symbol: + return _from_quote(quote) + if quotes: + return _from_quote(quotes[0]) + except Exception: + pass + + try: + info = yf.Ticker(normalized_symbol).info + return { + "symbol": normalized_symbol, + "name": str(info.get("shortName") or info.get("longName") or "").strip(), + "exchange": str(info.get("exchange") or "").strip(), + } + except Exception: + return {"symbol": normalized_symbol, "name": "", "exchange": ""} diff --git a/web/src/web_core/settings/__init__.py b/web/src/web_core/settings/__init__.py new file mode 100644 index 0000000..bfaf243 --- /dev/null +++ b/web/src/web_core/settings/__init__.py @@ -0,0 +1 @@ +"""Settings schema and normalization utilities.""" diff --git a/web/src/web_core/settings/settings_schema.py b/web/src/web_core/settings/settings_schema.py new file mode 100644 index 0000000..0139099 --- /dev/null +++ b/web/src/web_core/settings/settings_schema.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from typing import Any + +from web_core.constants import INTERVAL_OPTIONS, PERIOD_OPTIONS +from web_core.market.presets import MARKET_PRESET_OPTIONS + + +def _clamp_int(value: Any, fallback: int, minimum: int, maximum: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return fallback + return min(maximum, max(minimum, parsed)) + + +def _clamp_float(value: Any, fallback: float, minimum: float, maximum: float) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + return fallback + return min(maximum, max(minimum, parsed)) + + +def _to_bool(value: Any, fallback: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return value != 0 + if value is None: + return fallback + + normalized = str(value).strip().lower() + if normalized in {"1", "true", "yes", "y", "on"}: + return True + if normalized in {"0", "false", "no", "n", "off"}: + return False + return fallback + + +def _clamp_max_bars(value: Any, fallback: int = 500) -> int: + return _clamp_int(value=value, fallback=fallback, minimum=20, maximum=5000) + + +def _normalize_watchlist(raw: Any) -> list[str]: + tokens: list[str] = [] + if isinstance(raw, list): + tokens = [str(item) for item in raw] + elif isinstance(raw, str): + tokens = [raw] + else: + tokens = [str(raw or "")] + + seen: set[str] = set() + symbols: list[str] = [] + for token in tokens: + normalized_token = token.replace("\\n", "\n").replace("\\N", "\n").replace(",", "\n") + for split_token in normalized_token.splitlines(): + symbol = split_token.strip().upper() + if not symbol or symbol in seen: + continue + seen.add(symbol) + symbols.append(symbol) + return symbols[:40] + + +def normalize_web_settings(raw: dict[str, Any] | None) -> dict[str, Any]: + raw = raw or {} + defaults: dict[str, Any] = { + "symbol": "AAPL", + "interval": "1d", + "period": "6mo", + "max_bars": 500, + "drop_live": True, + "use_body_range": False, + "volume_filter_enabled": False, + "volume_sma_window": 20, + "volume_multiplier": 1.0, + "gray_fake": True, + "hide_market_closed_gaps": True, + "show_live_guide": False, + "show_past_behavior": False, + "show_trade_markers": False, + "focus_chart_on_selected_example": False, + "max_training_examples": 20, + "enable_auto_refresh": False, + "refresh_sec": 60, + "watchlist": [], + "market_preset": "Custom", + } + + symbol = str(raw.get("symbol", defaults["symbol"])).strip().upper() + if not symbol: + symbol = str(defaults["symbol"]) + + interval = str(raw.get("interval", defaults["interval"])) + if interval not in INTERVAL_OPTIONS: + interval = str(defaults["interval"]) + + period = str(raw.get("period", defaults["period"])) + if period not in PERIOD_OPTIONS: + period = str(defaults["period"]) + watchlist = _normalize_watchlist(raw.get("watchlist", defaults["watchlist"])) + market_preset = str(raw.get("market_preset", defaults["market_preset"])) + if market_preset not in MARKET_PRESET_OPTIONS: + market_preset = str(defaults["market_preset"]) + + return { + "symbol": symbol, + "interval": interval, + "period": period, + "max_bars": _clamp_max_bars(raw.get("max_bars"), fallback=int(defaults["max_bars"])), + "drop_live": _to_bool(raw.get("drop_live"), fallback=bool(defaults["drop_live"])), + "use_body_range": _to_bool(raw.get("use_body_range"), fallback=bool(defaults["use_body_range"])), + "volume_filter_enabled": _to_bool( + raw.get("volume_filter_enabled"), fallback=bool(defaults["volume_filter_enabled"]) + ), + "volume_sma_window": _clamp_int( + raw.get("volume_sma_window"), + fallback=int(defaults["volume_sma_window"]), + minimum=2, + maximum=100, + ), + "volume_multiplier": round( + _clamp_float( + raw.get("volume_multiplier"), + fallback=float(defaults["volume_multiplier"]), + minimum=0.1, + maximum=3.0, + ), + 1, + ), + "gray_fake": _to_bool(raw.get("gray_fake"), fallback=bool(defaults["gray_fake"])), + "hide_market_closed_gaps": _to_bool( + raw.get("hide_market_closed_gaps"), + fallback=bool(defaults["hide_market_closed_gaps"]), + ), + "show_live_guide": _to_bool(raw.get("show_live_guide"), fallback=bool(defaults["show_live_guide"])), + "show_past_behavior": _to_bool(raw.get("show_past_behavior"), fallback=bool(defaults["show_past_behavior"])), + "show_trade_markers": _to_bool(raw.get("show_trade_markers"), fallback=bool(defaults["show_trade_markers"])), + "focus_chart_on_selected_example": _to_bool( + raw.get("focus_chart_on_selected_example"), + fallback=bool(defaults["focus_chart_on_selected_example"]), + ), + "max_training_examples": _clamp_int( + raw.get("max_training_examples"), + fallback=int(defaults["max_training_examples"]), + minimum=5, + maximum=100, + ), + "enable_auto_refresh": _to_bool( + raw.get("enable_auto_refresh"), fallback=bool(defaults["enable_auto_refresh"]) + ), + "refresh_sec": _clamp_int( + raw.get("refresh_sec"), + fallback=int(defaults["refresh_sec"]), + minimum=10, + maximum=600, + ), + "watchlist": watchlist, + "market_preset": market_preset, + } diff --git a/web/src/web_core/ui/__init__.py b/web/src/web_core/ui/__init__.py new file mode 100644 index 0000000..ec85b4a --- /dev/null +++ b/web/src/web_core/ui/__init__.py @@ -0,0 +1 @@ +"""UI composition helpers for the Streamlit app.""" diff --git a/web/src/web_core/ui/help_content.py b/web/src/web_core/ui/help_content.py new file mode 100644 index 0000000..816810a --- /dev/null +++ b/web/src/web_core/ui/help_content.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from pathlib import Path + +import streamlit as st + + +@st.cache_data(show_spinner=False) +def load_help_content() -> tuple[str, bool]: + app_path = Path(__file__).resolve().parents[1] / "app.py" + help_html_paths = [ + app_path.with_name("web_core").joinpath("help.html"), + app_path.with_name("help.html"), + ] + for help_html_path in help_html_paths: + if help_html_path.exists(): + return help_html_path.read_text(encoding="utf-8"), True + + onboarding_path = app_path.with_name("ONBOARDING.md") + if onboarding_path.exists(): + return onboarding_path.read_text(encoding="utf-8"), False + return "Help content not found.", False + + +@st.dialog("Help & Quick Start", width="large") +def help_dialog() -> None: + content, is_html = load_help_content() + st.markdown(content, unsafe_allow_html=is_html) diff --git a/web/src/web_core/ui/login_ui.py b/web/src/web_core/ui/login_ui.py new file mode 100644 index 0000000..dcd5a2a --- /dev/null +++ b/web/src/web_core/ui/login_ui.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from typing import Any + +import streamlit as st + +from web_core.auth.profile_store import ( + create_profile, + find_existing_profile_id, + first_query_param_value, + is_truthy_flag, + list_web_profiles, + mark_profile_login, + normalize_pin, + normalize_profile_id, + profile_exists, + profile_requires_pin, + verify_profile_pin, +) + + +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.") + + 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() diff --git a/web/src/web_core/ui/sidebar_ui.py b/web/src/web_core/ui/sidebar_ui.py new file mode 100644 index 0000000..be63e5f --- /dev/null +++ b/web/src/web_core/ui/sidebar_ui.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +from typing import Any + +import streamlit as st +from streamlit_autorefresh import st_autorefresh + +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.auth.profile_store import ( + first_query_param_value, + format_epoch, + get_profile_audit, + list_web_profiles, + load_web_settings, + normalize_web_settings, +) + +def _parse_watchlist(raw: str) -> list[str]: + seen: set[str] = set() + symbols: list[str] = [] + normalized_raw = str(raw or "").replace("\\n", "\n").replace("\\N", "\n").replace(",", "\n") + for token in normalized_raw.splitlines(): + normalized = token.strip().upper() + if not normalized or normalized in seen: + continue + seen.add(normalized) + symbols.append(normalized) + return symbols[:40] + + +def render_sidebar(active_profile: str, query_params: Any) -> dict[str, Any]: + 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() + + 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 + market_preset = st.selectbox( + "Preset", + MARKET_PRESET_OPTIONS, + index=preset_index, + help="Applies default timeframes and behavior tuned for common workflows.", + ) + if market_preset != "Custom": + effective_defaults = apply_market_preset(effective_defaults, market_preset) + st.caption(f"Preset applied: {market_preset}") + + st.subheader("Watchlist") + watchlist_raw = st.text_input( + "Watchlist symbols (comma separated)", + value=", ".join(effective_defaults["watchlist"]), + help="Saved per profile. Example: AAPL, MSFT, NVDA, BTC-USD", + ) + watchlist = _parse_watchlist(watchlist_raw) + selected_from_watchlist: str | None = None + if watchlist: + st.caption("Quick select:") + for idx, watch_symbol in enumerate(watchlist[:12]): + if st.button(watch_symbol, use_container_width=True, key=f"watchlist-{watch_symbol}-{idx}"): + selected_from_watchlist = watch_symbol + st.session_state["watchlist_symbol_choice"] = watch_symbol + selected_from_watchlist = st.session_state.get("watchlist_symbol_choice", selected_from_watchlist) + + st.subheader("Find Symbol") + symbol_search_query = st.text_input( + "Search by company or ticker", + value="", + placeholder="Apple, Tesla, Bitcoin...", + help="Type a name (e.g. Apple) and select a result to prefill Symbol.", + ) + symbol_from_search: str | None = None + if symbol_search_query.strip(): + candidates = lookup_symbol_candidates(symbol_search_query) + if candidates: + result_placeholder = "Select a result..." + labels = [result_placeholder] + [ + " | ".join( + [ + candidate["symbol"], + candidate["name"] or "No name", + candidate["exchange"] or "Unknown exchange", + ] + ) + for candidate in candidates + ] + selected_label = st.selectbox("Matches", labels, index=0) + if selected_label != result_placeholder: + selected_index = labels.index(selected_label) - 1 + symbol_from_search = candidates[selected_index]["symbol"] + else: + st.caption("No matches found. Try another company name.") + + symbol = st.text_input( + "Symbol", + value=symbol_from_search or selected_from_watchlist or str(effective_defaults["symbol"]), + help="Ticker or pair to analyze, e.g. AAPL, MSFT, BTC-USD.", + ).strip().upper() + interval = st.selectbox( + "Timeframe", + INTERVAL_OPTIONS, + index=INTERVAL_OPTIONS.index(str(effective_defaults["interval"])), + help="Bar size for each candle. Shorter intervals are noisier; 1d is a good default.", + ) + period = st.selectbox( + "Period", + PERIOD_OPTIONS, + index=PERIOD_OPTIONS.index(str(effective_defaults["period"])), + help="How much history to load for trend analysis.", + ) + max_bars = st.number_input( + "Max bars", + min_value=20, + max_value=5000, + value=int(effective_defaults["max_bars"]), + step=10, + help="Limits loaded candles to keep charting responsive. 500 is a solid starting point.", + ) + drop_live = st.checkbox("Ignore potentially live last bar", value=bool(effective_defaults["drop_live"])) + + st.header("Classification Filters") + use_body_range = st.checkbox( + "Use previous body range (ignore wicks)", + value=bool(effective_defaults["use_body_range"]), + ) + volume_filter_enabled = st.checkbox( + "Enable volume filter", + value=bool(effective_defaults["volume_filter_enabled"]), + ) + volume_sma_window = st.slider("Volume SMA window", 2, 100, int(effective_defaults["volume_sma_window"])) + volume_multiplier = st.slider( + "Min volume / SMA multiplier", + 0.1, + 3.0, + float(effective_defaults["volume_multiplier"]), + 0.1, + ) + gray_fake = st.checkbox("Gray out fake bars", value=bool(effective_defaults["gray_fake"])) + hide_market_closed_gaps = st.checkbox( + "Hide market-closed gaps (stocks)", + value=bool(effective_defaults["hide_market_closed_gaps"]), + ) + + st.header("Training & Guidance") + show_live_guide = st.checkbox( + "Show live decision guide", + value=bool(effective_defaults["show_live_guide"]), + help="Shows a plain-English interpretation of current trend state and confirmation status.", + ) + show_past_behavior = st.checkbox( + "Show past behavior examples", + value=bool(effective_defaults["show_past_behavior"]), + help="Displays historical example trades based on trend confirmation and reversal signals.", + ) + show_trade_markers = st.checkbox( + "Overlay example entries/exits on chart", + value=bool(effective_defaults["show_trade_markers"]), + help="Adds entry/exit markers for the training examples onto the main chart.", + ) + focus_chart_on_selected_example = st.checkbox( + "Focus chart on selected example", + value=bool(effective_defaults["focus_chart_on_selected_example"]), + help="When a training row is selected, zooms chart around that trade window.", + ) + max_training_examples = st.slider( + "Max training examples", + 5, + 100, + int(effective_defaults["max_training_examples"]), + 5, + ) + + st.header("Monitoring") + enable_auto_refresh = st.checkbox("Auto-refresh", value=bool(effective_defaults["enable_auto_refresh"])) + refresh_sec = st.slider("Refresh interval (seconds)", 10, 600, int(effective_defaults["refresh_sec"]), 10) + if enable_auto_refresh: + st_autorefresh(interval=refresh_sec * 1000, key="data_refresh") + + return { + "symbol": symbol, + "interval": interval, + "period": period, + "max_bars": int(max_bars), + "drop_live": bool(drop_live), + "use_body_range": bool(use_body_range), + "volume_filter_enabled": bool(volume_filter_enabled), + "volume_sma_window": int(volume_sma_window), + "volume_multiplier": float(volume_multiplier), + "gray_fake": bool(gray_fake), + "hide_market_closed_gaps": bool(hide_market_closed_gaps), + "show_live_guide": bool(show_live_guide), + "show_past_behavior": bool(show_past_behavior), + "show_trade_markers": bool(show_trade_markers), + "focus_chart_on_selected_example": bool(focus_chart_on_selected_example), + "max_training_examples": int(max_training_examples), + "enable_auto_refresh": bool(enable_auto_refresh), + "refresh_sec": int(refresh_sec), + "watchlist": watchlist, + "market_preset": market_preset, + } diff --git a/web/src/web_core/ui/training_ui.py b/web/src/web_core/ui/training_ui.py new file mode 100644 index 0000000..db1b661 --- /dev/null +++ b/web/src/web_core/ui/training_ui.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import pandas as pd +import streamlit as st + + +def render_training_panel(show_past_behavior: bool, example_trades: pd.DataFrame, alert_key: str) -> pd.Series | None: + selected_trade: pd.Series | None = None + if not show_past_behavior: + return selected_trade + + st.subheader("Past Behavior Examples (Training)") + if example_trades.empty: + st.info("No closed example trades yet. Expand the period/timeframe to include more trend reversals.") + return selected_trade + + wins = int((example_trades["outcome"] == "Win").sum()) + losses = int((example_trades["outcome"] == "Loss").sum()) + total_examples = int(len(example_trades)) + win_rate = round((wins / total_examples) * 100.0, 2) if total_examples else 0.0 + avg_pnl = round(float(example_trades["pnl_pct"].mean()), 2) + + t1, t2, t3, t4 = st.columns(4) + t1.metric("Closed Examples", total_examples) + t2.metric("Wins / Losses", f"{wins} / {losses}") + t3.metric("Example Win Rate", f"{win_rate}%") + t4.metric("Avg P/L per Example", f"{avg_pnl}%") + + latest_example = example_trades.iloc[-1] + st.caption( + "Latest closed example: " + f"{latest_example['direction']} from {latest_example['entry_timestamp']} " + f"to {latest_example['exit_timestamp']} " + f"({latest_example['bars_held']} bars, {latest_example['pnl_pct']}%)." + ) + st.caption("Click a row below to highlight that specific example on the chart.") + st.caption("Example method: enter on trend confirmation bar close and exit on opposite trend confirmation.") + + 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) + + table_event = st.dataframe( + display_examples, + use_container_width=True, + on_select="rerun", + selection_mode="single-row", + key=f"training-examples-{alert_key}", + ) + selected_rows: list[int] = [] + try: + selected_rows = list(table_event.selection.rows) + except Exception: + selected_rows = [] + + selected_row_state_key = f"selected-training-row-{alert_key}" + if selected_rows: + selected_row_idx = int(selected_rows[0]) + st.session_state[selected_row_state_key] = selected_row_idx + else: + selected_row_idx = int(st.session_state.get(selected_row_state_key, 0)) + + if selected_row_idx < 0 or selected_row_idx >= len(display_examples_raw): + selected_row_idx = 0 + + 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_price = float(selected_trade["entry_price"]) + exit_price = float(selected_trade["exit_price"]) + bars_held = int(selected_trade["bars_held"]) + pnl_pct = float(selected_trade["pnl_pct"]) + outcome = str(selected_trade["outcome"]) + + pnl_text = f"+{pnl_pct}%" if pnl_pct > 0 else f"{pnl_pct}%" + result_text = "profit" if pnl_pct > 0 else ("loss" if pnl_pct < 0 else "flat result") + st.info( + f"Selected example explained: {direction} entry at {entry_ts} ({entry_price}), " + f"exit at {exit_ts} ({exit_price}), held {bars_held} bars, {result_text} {pnl_text}, outcome: {outcome}." + ) + return selected_trade