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

This commit is contained in:
Matt Bruce 2026-02-16 21:37:49 -06:00
parent 45835d808f
commit 1f32527658
8 changed files with 749 additions and 30 deletions

16
web/src/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
COPY requirements.txt /app/requirements.txt
RUN pip install -r /app/requirements.txt
COPY . /app
EXPOSE 8501
CMD ["streamlit", "run", "app.py", "--server.address=0.0.0.0", "--server.port=8501", "--server.headless=true"]

View File

@ -68,22 +68,30 @@ APP_BUNDLE_PATH="dist-standalone/<timestamp>/dist/ManeshTrader.app" ./scripts/cr
Tip: sign and notarize before sharing broadly, so macOS trust prompts are reduced. Tip: sign and notarize before sharing broadly, so macOS trust prompts are reduced.
## 4) First Session Walkthrough ## 4) First Session Walkthrough
1. Set `Symbol` (examples: `AAPL`, `MSFT`, `BTC-USD`, `ETH-USD`). 1. At app start, complete `Profile Login`:
2. Set `Timeframe` (start with `1d` to avoid noisy intraday data). - `Login profile name`: type an existing profile, then click `Login`
3. Set `Period` (try `6mo` initially). - `PIN (if enabled)`: enter PIN for protected profiles
4. Keep `Ignore potentially live last bar` ON. - `Create profile name`: type a new unique profile, then click `Create Profile`
5. Keep filters OFF for baseline: - `Set PIN (optional, 4-6 digits)`: add lightweight profile protection
2. After login, use sidebar `Switch profile` to change users.
3. Note: profile names are case-insensitive for uniqueness (`Matt` and `matt` count as the same profile).
4. Sidebar `Profile` section shows audit stamps for created time, last login, last settings update, and last symbol.
5. Set `Symbol` (examples: `AAPL`, `MSFT`, `BTC-USD`, `ETH-USD`).
6. Set `Timeframe` (start with `1d` to avoid noisy intraday data).
7. Set `Period` (try `6mo` initially).
8. Keep `Ignore potentially live last bar` ON.
9. Keep filters OFF for baseline:
- `Use previous body range (ignore wicks)` OFF - `Use previous body range (ignore wicks)` OFF
- `Enable volume filter` OFF - `Enable volume filter` OFF
- `Hide market-closed gaps (stocks)` ON - `Hide market-closed gaps (stocks)` ON
6. Review top metrics: 10. Review top metrics:
- Current Trend - Current Trend
- Real Bullish Bars - Real Bullish Bars
- Real Bearish Bars - Real Bearish Bars
- Fake Bars - Fake Bars
7. Read `Trend Events` for starts and reversals. 11. Read `Trend Events` for starts and reversals.
8. Use `Live Decision Guide` to translate trend state into a practical bias/action/invalidation workflow. 12. Use `Live Decision Guide` to translate trend state into a practical bias/action/invalidation workflow.
9. Keep `Show past behavior examples` ON while learning to review historical entry/exit outcomes. 13. Keep `Show past behavior examples` ON while learning to review historical entry/exit outcomes.
## 5) How To Read The Chart ## 5) How To Read The Chart
- Candle layer: full price action - Candle layer: full price action
@ -222,6 +230,16 @@ streamlit run app.py --server.port 8502
- Verify ticker format (`BTC-USD`, not `BTCUSD`) - Verify ticker format (`BTC-USD`, not `BTCUSD`)
- Use compatible `Timeframe` + `Period` - Use compatible `Timeframe` + `Period`
### My settings changed unexpectedly
- Confirm you are logged into your own profile.
- Use sidebar `Switch profile` if needed.
- Share profile-specific links by including `?profile=<your-name>` in the app URL.
- If you were inactive for more than 30 minutes, the app will require login again.
### PIN login fails
- Ensure the profile name is correct (name matching is case-insensitive).
- PIN must be exactly what was set during profile creation (4-6 digits).
### I still see some time gaps ### I still see some time gaps
- For stocks, keep `Hide market-closed gaps (stocks)` ON. - For stocks, keep `Hide market-closed gaps (stocks)` ON.
- Daily charts remove weekends; intraday removes weekends + closed hours. - Daily charts remove weekends; intraday removes weekends + closed hours.

View File

@ -32,6 +32,30 @@ Persisted settings path:
- Primary: `~/.web_local_shell/settings.json` - Primary: `~/.web_local_shell/settings.json`
- Legacy fallback read: `~/.manesh_trader/settings.json` - Legacy fallback read: `~/.manesh_trader/settings.json`
Storage format:
- Current format stores per-profile settings:
- `last_profile`: most recently used profile id
- `profiles`: object map of `profile_id -> profile record`
- profile record fields:
- `settings`: normalized settings payload
- `pin_hash`: optional SHA-256 hash for 4-6 digit PIN
- `audit`: metadata (`created_at`, `updated_at`, `last_login_at`, `last_symbol`)
- Legacy single-profile payloads are read as profile `default` and migrated to current format on next save.
Profile behavior:
- App enforces a profile login gate before data/settings UI is shown.
- Login requires typing an existing profile name.
- Login checks PIN when profile has one configured.
- Create Profile requires typing a new profile name.
- Create Profile accepts optional 4-6 digit PIN.
- Profile-name uniqueness is case-insensitive (for example `Matt` and `matt` are treated as duplicates).
- Sidebar shows active profile with a `Switch profile` control.
- Sidebar shows profile audit stamps (created/updated/last login/last symbol).
- Query param `profile` selects active profile.
- Save/load are scoped to active profile to avoid cross-user overwrites.
- If profile has no saved settings, defaults are used.
- Session activity is tracked in-memory; after 30 minutes of inactivity the app clears active profile and returns to login screen.
Normalization constraints: Normalization constraints:
- `symbol`: uppercase, non-empty fallback `AAPL` - `symbol`: uppercase, non-empty fallback `AAPL`
- `interval`: must be one of `INTERVAL_OPTIONS`, fallback `1d` - `interval`: must be one of `INTERVAL_OPTIONS`, fallback `1d`

177
web/src/SYNOLOGY.md Normal file
View File

@ -0,0 +1,177 @@
# Synology Deployment Guide (ManeshTrader Web)
This guide documents the exact deployment flow that worked for this project on Synology NAS.
## App Location
This project runs from:
- NAS folder: `/volume1/docker/maneshtrader`
- Container port: `8501`
- URL: `http://<NAS_IP>:8501`
## Prerequisites (One-Time)
On Synology DSM:
1. Install `Container Manager` from Package Center.
2. Enable SSH:
- `Control Panel -> Terminal & SNMP -> Enable SSH service`
3. Ensure your user can run Docker:
- Add user to `administrators` group.
- Grant `Container Manager` app permission for that user.
Notes:
- Store data only in shared folders (use `/volume1/...` paths).
- If Docker commands fail with socket permission errors, use `sudo -i` (see Troubleshooting).
## Required Files in `web/src`
These files must exist (already added in this repo):
- `Dockerfile`
- `docker-compose.yml`
- `app.py`
- `requirements.txt`
- `web_core/`
## Initial Deploy
Run from your Mac (repo root), using your SSH port `25`:
```bash
rsync -avz --delete -e "ssh -p 25" \
--exclude ".pytest_cache" --exclude "__pycache__" \
web/src/ mbrucedogs@192.168.1.128:/volume1/docker/maneshtrader/
```
Then SSH to Synology:
```bash
ssh -p 25 mbrucedogs@192.168.1.128
```
Start the container:
```sh
sudo -i
cd /volume1/docker/maneshtrader
mkdir -p data
docker compose up -d --build
```
If your DSM uses legacy compose command:
```sh
docker-compose up -d --build
```
## Verify It Is Running
```sh
sudo -i
docker ps --filter name=maneshtrader
docker logs --tail 100 maneshtrader
```
Open in browser:
- `http://192.168.1.128:8501`
## Update Workflow (After Code Changes)
You do **not** need to redo full setup. Just:
1. Sync updated source.
2. Rebuild/restart compose service.
From Mac:
```bash
rsync -avz --delete -e "ssh -p 25" \
web/src/ mbrucedogs@192.168.1.128:/volume1/docker/maneshtrader/
```
On Synology:
```sh
sudo -i
cd /volume1/docker/maneshtrader
docker compose up -d --build
```
## Useful Operations
Restart app:
```sh
sudo -i
cd /volume1/docker/maneshtrader
docker compose restart
```
Stop app:
```sh
sudo -i
cd /volume1/docker/maneshtrader
docker compose down
```
Follow logs live:
```sh
sudo -i
docker logs -f maneshtrader
```
## Troubleshooting
### Error: `permission denied while trying to connect to the Docker daemon socket`
Cause:
- SSH user does not have Docker daemon access.
Fix:
1. Run commands as root shell:
```sh
sudo -i
```
2. If `sudo` is unavailable, in DSM:
- Add user to `administrators`.
- Allow `Container Manager` permission.
- Reconnect SSH.
Do **not** run insecure socket permission changes like:
```sh
chmod 666 /var/run/docker.sock
```
### `docker compose` not found
Try legacy command:
```sh
docker-compose up -d --build
```
Or extend PATH:
```sh
export PATH=$PATH:/usr/local/bin:/var/packages/ContainerManager/target/usr/bin
```
## Optional: One-Liner Update From Mac
```bash
rsync -avz --delete -e "ssh -p 25" \
web/src/ mbrucedogs@192.168.1.128:/volume1/docker/maneshtrader/ && \
ssh -p 25 mbrucedogs@192.168.1.128 \
"sudo -i sh -lc 'cd /volume1/docker/maneshtrader && docker compose up -d --build'"
```

View File

@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json import json
import hashlib
import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -19,6 +21,8 @@ from web_core.strategy import classify_bars, detect_trends
SETTINGS_PATH = Path.home() / ".web_local_shell" / "settings.json" SETTINGS_PATH = Path.home() / ".web_local_shell" / "settings.json"
LEGACY_SETTINGS_PATH = Path.home() / ".manesh_trader" / "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: def _clamp_int(value: Any, fallback: int, minimum: int, maximum: int) -> int:
@ -57,6 +61,85 @@ def _clamp_max_bars(value: Any, fallback: int = 500) -> int:
return _clamp_int(value=value, fallback=fallback, minimum=20, maximum=5000) 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]: def normalize_web_settings(raw: dict[str, Any] | None) -> dict[str, Any]:
raw = raw or {} raw = raw or {}
defaults: dict[str, Any] = { defaults: dict[str, Any] = {
@ -161,22 +244,192 @@ def normalize_web_settings(raw: dict[str, Any] | None) -> dict[str, Any]:
} }
def load_web_settings() -> dict[str, Any]: def _load_raw_settings_payload() -> dict[str, Any] | None:
source_path = SETTINGS_PATH if SETTINGS_PATH.exists() else LEGACY_SETTINGS_PATH source_path = SETTINGS_PATH if SETTINGS_PATH.exists() else LEGACY_SETTINGS_PATH
if not source_path.exists(): if not source_path.exists():
return normalize_web_settings(None) return None
try: try:
payload = json.loads(source_path.read_text(encoding="utf-8")) payload = json.loads(source_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict): return payload if isinstance(payload, dict) else None
return normalize_web_settings(None)
return normalize_web_settings(payload)
except Exception: except Exception:
return normalize_web_settings(None) return None
def save_web_settings(settings: dict[str, Any]) -> None: def normalize_profile_record(profile_id: str, raw_profile_data: Any) -> dict[str, Any]:
now_epoch = int(time.time())
fallback_settings = normalize_web_settings(None)
settings: dict[str, Any]
pin_hash = ""
raw_audit: Any = {}
if isinstance(raw_profile_data, dict) and isinstance(raw_profile_data.get("settings"), dict):
settings = normalize_web_settings(raw_profile_data.get("settings"))
pin_hash = str(raw_profile_data.get("pin_hash") or "").strip()
if not pin_hash and isinstance(raw_profile_data.get("auth"), dict):
pin_hash = str(raw_profile_data["auth"].get("pin_hash") or "").strip()
raw_audit = raw_profile_data.get("audit")
elif isinstance(raw_profile_data, dict):
settings = normalize_web_settings(raw_profile_data)
else:
settings = fallback_settings
created_at = now_epoch
updated_at = now_epoch
last_login_at = None
last_symbol = str(settings.get("symbol", fallback_settings["symbol"]))
if isinstance(raw_audit, dict):
created_at = _normalize_epoch(raw_audit.get("created_at")) or now_epoch
updated_at = _normalize_epoch(raw_audit.get("updated_at")) or created_at
last_login_at = _normalize_epoch(raw_audit.get("last_login_at"))
raw_last_symbol = str(raw_audit.get("last_symbol") or "").strip().upper()
if raw_last_symbol:
last_symbol = raw_last_symbol
return {
"settings": settings,
"pin_hash": pin_hash,
"audit": {
"created_at": created_at,
"updated_at": updated_at,
"last_login_at": last_login_at,
"last_symbol": last_symbol,
},
}
def _write_settings_store(profile_records: dict[str, dict[str, Any]], last_profile: str) -> None:
serialized_profiles: dict[str, dict[str, Any]] = {}
for profile_id, record in profile_records.items():
normalized_record = normalize_profile_record(profile_id=profile_id, raw_profile_data=record)
serialized_profiles[profile_id] = normalized_record
payload = {
"last_profile": normalize_profile_id(last_profile),
"profiles": serialized_profiles,
}
SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
SETTINGS_PATH.write_text(json.dumps(normalize_web_settings(settings), indent=2), encoding="utf-8") SETTINGS_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def _load_profile_records() -> tuple[dict[str, dict[str, Any]], str]:
payload = _load_raw_settings_payload()
if not payload:
return {}, DEFAULT_PROFILE_ID
raw_profiles = payload.get("profiles")
if isinstance(raw_profiles, dict):
profile_records: dict[str, dict[str, Any]] = {}
for raw_profile_id, raw_profile_data in raw_profiles.items():
if isinstance(raw_profile_data, dict):
profile_id = normalize_profile_id(raw_profile_id)
profile_records[profile_id] = normalize_profile_record(profile_id, raw_profile_data)
last_profile = normalize_profile_id(payload.get("last_profile"))
if profile_records and last_profile not in profile_records:
last_profile = next(iter(profile_records))
return profile_records, last_profile
# Legacy format: a single flat settings payload.
legacy_record = normalize_profile_record(profile_id=DEFAULT_PROFILE_ID, raw_profile_data=payload)
return {DEFAULT_PROFILE_ID: legacy_record}, DEFAULT_PROFILE_ID
def list_web_profiles() -> list[str]:
profile_records, _ = _load_profile_records()
profile_ids = list(profile_records.keys())
if DEFAULT_PROFILE_ID not in profile_ids:
profile_ids.append(DEFAULT_PROFILE_ID)
return sorted(profile_ids, key=lambda item: (item != DEFAULT_PROFILE_ID, item.lower()))
def load_web_settings(profile_id: str | None = None) -> dict[str, Any]:
profile_records, last_profile = _load_profile_records()
selected_profile = normalize_profile_id(profile_id if profile_id is not None else last_profile)
if selected_profile not in profile_records:
return normalize_web_settings(None)
return normalize_web_settings(profile_records[selected_profile].get("settings"))
def profile_requires_pin(profile_id: str) -> bool:
selected = normalize_profile_id(profile_id)
profile_records, _ = _load_profile_records()
record = profile_records.get(selected)
if not isinstance(record, dict):
return False
return bool(str(record.get("pin_hash") or "").strip())
def verify_profile_pin(profile_id: str, pin: Any) -> bool:
selected = normalize_profile_id(profile_id)
profile_records, _ = _load_profile_records()
record = profile_records.get(selected)
if not isinstance(record, dict):
return False
stored_hash = str(record.get("pin_hash") or "").strip()
if not stored_hash:
return True
normalized_pin = normalize_pin(pin)
if normalized_pin is None:
return False
return stored_hash == hash_profile_pin(selected, normalized_pin)
def get_profile_audit(profile_id: str) -> dict[str, Any]:
selected = normalize_profile_id(profile_id)
profile_records, _ = _load_profile_records()
record = profile_records.get(selected)
if not isinstance(record, dict):
return {}
audit = record.get("audit")
return audit if isinstance(audit, dict) else {}
def create_profile(profile_id: str, pin: Any = None, now_epoch: int | None = None) -> None:
selected = normalize_profile_id(profile_id)
profile_records, _ = _load_profile_records()
epoch = int(now_epoch if now_epoch is not None else time.time())
normalized_pin = normalize_pin(pin)
profile_records[selected] = {
"settings": normalize_web_settings(None),
"pin_hash": hash_profile_pin(selected, normalized_pin) if normalized_pin else "",
"audit": {
"created_at": epoch,
"updated_at": epoch,
"last_login_at": epoch,
"last_symbol": "AAPL",
},
}
_write_settings_store(profile_records, last_profile=selected)
def mark_profile_login(profile_id: str, now_epoch: int | None = None) -> None:
selected = normalize_profile_id(profile_id)
profile_records, _ = _load_profile_records()
epoch = int(now_epoch if now_epoch is not None else time.time())
existing = profile_records.get(selected, normalize_profile_record(selected, {}))
normalized = normalize_profile_record(selected, existing)
normalized["audit"]["last_login_at"] = epoch
profile_records[selected] = normalized
_write_settings_store(profile_records, last_profile=selected)
def save_web_settings(settings: dict[str, Any], profile_id: str | None = None) -> None:
selected_profile = normalize_profile_id(profile_id)
now_epoch = int(time.time())
profile_records, _ = _load_profile_records()
existing = profile_records.get(selected_profile, normalize_profile_record(selected_profile, {}))
normalized_existing = normalize_profile_record(selected_profile, existing)
normalized_settings = normalize_web_settings(settings)
normalized_existing["settings"] = normalized_settings
normalized_existing["audit"]["updated_at"] = now_epoch
normalized_existing["audit"]["last_symbol"] = str(normalized_settings.get("symbol", "AAPL"))
profile_records[selected_profile] = normalized_existing
_write_settings_store(profile_records, last_profile=selected_profile)
@st.cache_data(show_spinner=False) @st.cache_data(show_spinner=False)
@ -320,27 +573,102 @@ def main() -> None:
st.caption( st.caption(
"Price-action tool that classifies closed bars, filters fake bars, and tracks trend persistence using only real bars." "Price-action tool that classifies closed bars, filters fake bars, and tracks trend persistence using only real bars."
) )
query_params = st.query_params
now_epoch = time.time()
active_profile = st.session_state.get("active_profile")
last_active = st.session_state.get("profile_last_active_at")
session_expired = False
if active_profile and is_profile_session_expired(last_active, now_epoch):
session_expired = True
st.session_state.pop("active_profile", None)
st.session_state.pop("profile_last_active_at", None)
active_profile = None
if "profile" in query_params:
del query_params["profile"]
if not active_profile:
active_profile = None
if active_profile is None:
existing_profiles = set(list_web_profiles())
st.subheader("Profile Login")
st.info("Login with an existing profile or create a new one. Settings are isolated per profile.")
if session_expired:
st.warning("Your profile session timed out due to inactivity. Please log in again.")
initial_profile = first_query_param_value(query_params, "profile") or ""
login_profile = st.text_input("Login profile name", value=initial_profile, placeholder="e.g. matt")
login_pin = st.text_input("PIN (if enabled)", value="", type="password", max_chars=6)
if st.button("Login", type="primary", use_container_width=True):
selected_match = find_existing_profile_id(login_profile, existing_profiles)
if selected_match is None:
st.error("Profile not found. Enter the exact profile name or create a new one below.")
elif profile_requires_pin(selected_match) and not verify_profile_pin(selected_match, login_pin):
st.error("Incorrect PIN.")
else:
mark_profile_login(selected_match, now_epoch=int(now_epoch))
st.session_state["active_profile"] = selected_match
st.session_state["profile_last_active_at"] = now_epoch
query_params["profile"] = selected_match
st.rerun()
st.divider()
create_profile_name = st.text_input("Create profile name", value="", placeholder="e.g. sara")
create_pin = st.text_input("Set PIN (optional, 4-6 digits)", value="", type="password", max_chars=6)
if st.button("Create Profile", use_container_width=True):
selected = normalize_profile_id(create_profile_name)
if profile_exists(selected, existing_profiles):
st.error("That profile already exists (including case-insensitive matches). Use Login instead.")
elif create_pin and normalize_pin(create_pin) is None:
st.error("PIN must be 4-6 digits.")
else:
create_profile(selected, pin=create_pin or None, now_epoch=int(now_epoch))
st.session_state["active_profile"] = selected
st.session_state["profile_last_active_at"] = now_epoch
query_params["profile"] = selected
st.rerun()
st.stop()
active_profile = normalize_profile_id(active_profile)
st.session_state["active_profile"] = active_profile
st.session_state["profile_last_active_at"] = now_epoch
with st.sidebar: 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") st.header("Data Settings")
if st.button("Help / Quick Start", use_container_width=True): if st.button("Help / Quick Start", use_container_width=True):
help_dialog() help_dialog()
st.divider() st.divider()
query_params = st.query_params persisted_settings = load_web_settings(profile_id=active_profile)
persisted_settings = load_web_settings()
def first_query_value(key: str) -> str | None:
raw = query_params.get(key)
if raw is None:
return None
if isinstance(raw, list):
return str(raw[0]) if raw else None
return str(raw)
query_overrides: dict[str, Any] = {} query_overrides: dict[str, Any] = {}
for key in persisted_settings: for key in persisted_settings:
candidate = first_query_value(key) candidate = first_query_param_value(query_params, key)
if candidate is not None: if candidate is not None:
query_overrides[key] = candidate query_overrides[key] = candidate
effective_defaults = normalize_web_settings({**persisted_settings, **query_overrides}) effective_defaults = normalize_web_settings({**persisted_settings, **query_overrides})
@ -486,7 +814,8 @@ def main() -> None:
"max_training_examples": int(max_training_examples), "max_training_examples": int(max_training_examples),
"enable_auto_refresh": bool(enable_auto_refresh), "enable_auto_refresh": bool(enable_auto_refresh),
"refresh_sec": int(refresh_sec), "refresh_sec": int(refresh_sec),
} },
profile_id=active_profile,
) )
except Exception: except Exception:
# Non-fatal: app should run even if local settings cannot be saved. # Non-fatal: app should run even if local settings cannot be saved.
@ -561,7 +890,7 @@ def main() -> None:
st.info(live_guide["action"]) st.info(live_guide["action"])
st.caption(f"Invalidation rule: {live_guide['invalidation']}") st.caption(f"Invalidation rule: {live_guide['invalidation']}")
alert_key = f"{symbol}-{interval}-{period}" alert_key = f"{active_profile}-{symbol}-{interval}-{period}"
newest_event = events[-1].event if events else "" newest_event = events[-1].event if events else ""
previous_event = st.session_state.get(f"last_event-{alert_key}", "") previous_event = st.session_state.get(f"last_event-{alert_key}", "")
if newest_event and newest_event != previous_event: if newest_event and newest_event != previous_event:

View File

@ -0,0 +1,11 @@
services:
maneshtrader:
build:
context: .
dockerfile: Dockerfile
container_name: maneshtrader
restart: unless-stopped
ports:
- "8501:8501"
volumes:
- ./data:/root/.web_local_shell

View File

@ -0,0 +1,144 @@
from __future__ import annotations
import json
from pathlib import Path
import app
def test_save_and_load_settings_are_isolated_by_profile(tmp_path, monkeypatch) -> None:
settings_path = tmp_path / "settings.json"
legacy_path = tmp_path / "legacy.json"
monkeypatch.setattr(app, "SETTINGS_PATH", settings_path)
monkeypatch.setattr(app, "LEGACY_SETTINGS_PATH", legacy_path)
app.save_web_settings({"symbol": "AAPL", "period": "1mo"}, profile_id="alice")
app.save_web_settings({"symbol": "MSFT", "period": "3mo"}, profile_id="bob")
alice = app.load_web_settings(profile_id="alice")
bob = app.load_web_settings(profile_id="bob")
unknown = app.load_web_settings(profile_id="carol")
assert alice["symbol"] == "AAPL"
assert alice["period"] == "1mo"
assert bob["symbol"] == "MSFT"
assert bob["period"] == "3mo"
assert unknown["symbol"] == "AAPL" # fallback default
def test_load_settings_migrates_legacy_flat_payload_to_default_profile(tmp_path, monkeypatch) -> None:
settings_path = tmp_path / "settings.json"
legacy_path = tmp_path / "legacy.json"
monkeypatch.setattr(app, "SETTINGS_PATH", settings_path)
monkeypatch.setattr(app, "LEGACY_SETTINGS_PATH", legacy_path)
legacy_payload = {"symbol": "TSLA", "interval": "1h", "max_bars": 9999}
legacy_path.write_text(json.dumps(legacy_payload), encoding="utf-8")
loaded = app.load_web_settings(profile_id="default")
assert loaded["symbol"] == "TSLA"
assert loaded["interval"] == "1h"
assert loaded["max_bars"] == 5000 # normalized max cap
app.save_web_settings({"symbol": "TSLA"}, profile_id="default")
stored = json.loads(settings_path.read_text(encoding="utf-8"))
assert "profiles" in stored
assert "default" in stored["profiles"]
assert "settings" in stored["profiles"]["default"]
assert "audit" in stored["profiles"]["default"]
def test_list_web_profiles_includes_saved_profiles(tmp_path, monkeypatch) -> None:
settings_path = tmp_path / "settings.json"
legacy_path = tmp_path / "legacy.json"
monkeypatch.setattr(app, "SETTINGS_PATH", settings_path)
monkeypatch.setattr(app, "LEGACY_SETTINGS_PATH", legacy_path)
app.save_web_settings({"symbol": "AAPL"}, profile_id="zoe")
app.save_web_settings({"symbol": "AAPL"}, profile_id="alice")
profile_ids = app.list_web_profiles()
assert "default" in profile_ids
assert "alice" in profile_ids
assert "zoe" in profile_ids
def test_resolve_login_profile_prefers_session_then_query() -> None:
assert app.resolve_login_profile("alice", "bob") == "alice"
assert app.resolve_login_profile("", "bob") == "bob"
assert app.resolve_login_profile(None, None) is None
def test_profile_exists_normalizes_input() -> None:
profiles = {"default", "alice"}
assert app.profile_exists("alice", profiles) is True
assert app.profile_exists(" ", profiles) is True # normalizes to default
assert app.profile_exists("bob", profiles) is False
def test_profile_exists_is_case_insensitive() -> None:
profiles = {"default", "Alice"}
assert app.profile_exists("alice", profiles) is True
assert app.find_existing_profile_id("ALICE", profiles) == "Alice"
def test_is_profile_session_expired_after_timeout() -> None:
now_epoch = 2000.0
assert app.is_profile_session_expired(1000.0, now_epoch, timeout_sec=900) is True
assert app.is_profile_session_expired(1500.0, now_epoch, timeout_sec=900) is False
assert app.is_profile_session_expired(None, now_epoch, timeout_sec=900) is False
def test_create_profile_with_pin_requires_pin_for_verification(tmp_path, monkeypatch) -> None:
settings_path = tmp_path / "settings.json"
legacy_path = tmp_path / "legacy.json"
monkeypatch.setattr(app, "SETTINGS_PATH", settings_path)
monkeypatch.setattr(app, "LEGACY_SETTINGS_PATH", legacy_path)
app.create_profile("alice", pin="1234", now_epoch=1700000000)
assert app.profile_requires_pin("alice") is True
assert app.verify_profile_pin("alice", "1234") is True
assert app.verify_profile_pin("alice", "9999") is False
def test_audit_stamps_update_on_save_and_login(tmp_path, monkeypatch) -> None:
settings_path = tmp_path / "settings.json"
legacy_path = tmp_path / "legacy.json"
monkeypatch.setattr(app, "SETTINGS_PATH", settings_path)
monkeypatch.setattr(app, "LEGACY_SETTINGS_PATH", legacy_path)
app.create_profile("alice", now_epoch=1700000000)
app.save_web_settings({"symbol": "MSFT"}, profile_id="alice")
app.mark_profile_login("alice", now_epoch=1700000010)
audit = app.get_profile_audit("alice")
assert audit["created_at"] == 1700000000
assert audit["last_login_at"] == 1700000010
assert audit["last_symbol"] == "MSFT"
def test_load_settings_migrates_old_profiles_map_structure(tmp_path, monkeypatch) -> None:
settings_path = tmp_path / "settings.json"
legacy_path = tmp_path / "legacy.json"
monkeypatch.setattr(app, "SETTINGS_PATH", settings_path)
monkeypatch.setattr(app, "LEGACY_SETTINGS_PATH", legacy_path)
old_payload = {
"last_profile": "alice",
"profiles": {
"alice": {"symbol": "TSLA", "interval": "1d"},
"bob": {"symbol": "MSFT", "interval": "1h"},
},
}
settings_path.write_text(json.dumps(old_payload), encoding="utf-8")
alice_settings = app.load_web_settings("alice")
bob_settings = app.load_web_settings("bob")
assert alice_settings["symbol"] == "TSLA"
assert bob_settings["symbol"] == "MSFT"
app.save_web_settings({"symbol": "AAPL"}, profile_id="alice")
stored = json.loads(settings_path.read_text(encoding="utf-8"))
assert isinstance(stored["profiles"]["alice"], dict)
assert "settings" in stored["profiles"]["alice"]