Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
45835d808f
commit
1f32527658
Binary file not shown.
16
web/src/Dockerfile
Normal file
16
web/src/Dockerfile
Normal 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"]
|
||||
@ -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.
|
||||
|
||||
## 4) First Session Walkthrough
|
||||
1. Set `Symbol` (examples: `AAPL`, `MSFT`, `BTC-USD`, `ETH-USD`).
|
||||
2. Set `Timeframe` (start with `1d` to avoid noisy intraday data).
|
||||
3. Set `Period` (try `6mo` initially).
|
||||
4. Keep `Ignore potentially live last bar` ON.
|
||||
5. Keep filters OFF for baseline:
|
||||
1. At app start, complete `Profile Login`:
|
||||
- `Login profile name`: type an existing profile, then click `Login`
|
||||
- `PIN (if enabled)`: enter PIN for protected profiles
|
||||
- `Create profile name`: type a new unique profile, then click `Create Profile`
|
||||
- `Set PIN (optional, 4-6 digits)`: add lightweight profile protection
|
||||
2. After login, use sidebar `Switch profile` to change users.
|
||||
3. Note: profile names are case-insensitive for uniqueness (`Matt` and `matt` count as the same profile).
|
||||
4. Sidebar `Profile` section shows audit stamps for created time, last login, last settings update, and last symbol.
|
||||
5. Set `Symbol` (examples: `AAPL`, `MSFT`, `BTC-USD`, `ETH-USD`).
|
||||
6. Set `Timeframe` (start with `1d` to avoid noisy intraday data).
|
||||
7. Set `Period` (try `6mo` initially).
|
||||
8. Keep `Ignore potentially live last bar` ON.
|
||||
9. Keep filters OFF for baseline:
|
||||
- `Use previous body range (ignore wicks)` OFF
|
||||
- `Enable volume filter` OFF
|
||||
- `Hide market-closed gaps (stocks)` ON
|
||||
6. Review top metrics:
|
||||
10. Review top metrics:
|
||||
- Current Trend
|
||||
- Real Bullish Bars
|
||||
- Real Bearish Bars
|
||||
- Fake Bars
|
||||
7. Read `Trend Events` for starts and reversals.
|
||||
8. Use `Live Decision Guide` to translate trend state into a practical bias/action/invalidation workflow.
|
||||
9. Keep `Show past behavior examples` ON while learning to review historical entry/exit outcomes.
|
||||
11. Read `Trend Events` for starts and reversals.
|
||||
12. Use `Live Decision Guide` to translate trend state into a practical bias/action/invalidation workflow.
|
||||
13. Keep `Show past behavior examples` ON while learning to review historical entry/exit outcomes.
|
||||
|
||||
## 5) How To Read The Chart
|
||||
- Candle layer: full price action
|
||||
@ -222,6 +230,16 @@ streamlit run app.py --server.port 8502
|
||||
- Verify ticker format (`BTC-USD`, not `BTCUSD`)
|
||||
- Use compatible `Timeframe` + `Period`
|
||||
|
||||
### My settings changed unexpectedly
|
||||
- Confirm you are logged into your own profile.
|
||||
- Use sidebar `Switch profile` if needed.
|
||||
- Share profile-specific links by including `?profile=<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
|
||||
- For stocks, keep `Hide market-closed gaps (stocks)` ON.
|
||||
- Daily charts remove weekends; intraday removes weekends + closed hours.
|
||||
|
||||
@ -32,6 +32,30 @@ Persisted settings path:
|
||||
- Primary: `~/.web_local_shell/settings.json`
|
||||
- Legacy fallback read: `~/.manesh_trader/settings.json`
|
||||
|
||||
Storage format:
|
||||
- Current format stores per-profile settings:
|
||||
- `last_profile`: most recently used profile id
|
||||
- `profiles`: object map of `profile_id -> profile record`
|
||||
- profile record fields:
|
||||
- `settings`: normalized settings payload
|
||||
- `pin_hash`: optional SHA-256 hash for 4-6 digit PIN
|
||||
- `audit`: metadata (`created_at`, `updated_at`, `last_login_at`, `last_symbol`)
|
||||
- Legacy single-profile payloads are read as profile `default` and migrated to current format on next save.
|
||||
|
||||
Profile behavior:
|
||||
- App enforces a profile login gate before data/settings UI is shown.
|
||||
- Login requires typing an existing profile name.
|
||||
- Login checks PIN when profile has one configured.
|
||||
- Create Profile requires typing a new profile name.
|
||||
- Create Profile accepts optional 4-6 digit PIN.
|
||||
- Profile-name uniqueness is case-insensitive (for example `Matt` and `matt` are treated as duplicates).
|
||||
- Sidebar shows active profile with a `Switch profile` control.
|
||||
- Sidebar shows profile audit stamps (created/updated/last login/last symbol).
|
||||
- Query param `profile` selects active profile.
|
||||
- Save/load are scoped to active profile to avoid cross-user overwrites.
|
||||
- If profile has no saved settings, defaults are used.
|
||||
- Session activity is tracked in-memory; after 30 minutes of inactivity the app clears active profile and returns to login screen.
|
||||
|
||||
Normalization constraints:
|
||||
- `symbol`: uppercase, non-empty fallback `AAPL`
|
||||
- `interval`: must be one of `INTERVAL_OPTIONS`, fallback `1d`
|
||||
|
||||
177
web/src/SYNOLOGY.md
Normal file
177
web/src/SYNOLOGY.md
Normal 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'"
|
||||
```
|
||||
371
web/src/app.py
371
web/src/app.py
@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@ -19,6 +21,8 @@ from web_core.strategy import classify_bars, detect_trends
|
||||
|
||||
SETTINGS_PATH = Path.home() / ".web_local_shell" / "settings.json"
|
||||
LEGACY_SETTINGS_PATH = Path.home() / ".manesh_trader" / "settings.json"
|
||||
DEFAULT_PROFILE_ID = "default"
|
||||
PROFILE_SESSION_TIMEOUT_SEC = 1800
|
||||
|
||||
|
||||
def _clamp_int(value: Any, fallback: int, minimum: int, maximum: int) -> int:
|
||||
@ -57,6 +61,85 @@ def _clamp_max_bars(value: Any, fallback: int = 500) -> int:
|
||||
return _clamp_int(value=value, fallback=fallback, minimum=20, maximum=5000)
|
||||
|
||||
|
||||
def normalize_profile_id(value: Any) -> str:
|
||||
profile_id = str(value or "").strip()
|
||||
return profile_id if profile_id else DEFAULT_PROFILE_ID
|
||||
|
||||
|
||||
def _profile_key(value: Any) -> str:
|
||||
return normalize_profile_id(value).casefold()
|
||||
|
||||
|
||||
def normalize_pin(value: Any) -> str | None:
|
||||
pin = str(value or "").strip()
|
||||
if not pin:
|
||||
return None
|
||||
if not pin.isdigit():
|
||||
return None
|
||||
if len(pin) < 4 or len(pin) > 6:
|
||||
return None
|
||||
return pin
|
||||
|
||||
|
||||
def hash_profile_pin(profile_id: str, pin: str) -> str:
|
||||
digest_input = f"{_profile_key(profile_id)}:{pin}"
|
||||
return hashlib.sha256(digest_input.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def first_query_param_value(query_params: Any, key: str) -> str | None:
|
||||
raw = query_params.get(key)
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, list):
|
||||
return str(raw[0]) if raw else None
|
||||
return str(raw)
|
||||
|
||||
|
||||
def resolve_login_profile(session_profile: Any, query_profile: Any) -> str | None:
|
||||
if str(session_profile or "").strip():
|
||||
return normalize_profile_id(session_profile)
|
||||
|
||||
if str(query_profile or "").strip():
|
||||
return normalize_profile_id(query_profile)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_existing_profile_id(profile_id: Any, available_profiles: set[str]) -> str | None:
|
||||
requested_key = _profile_key(profile_id)
|
||||
for existing in available_profiles:
|
||||
if _profile_key(existing) == requested_key:
|
||||
return existing
|
||||
return None
|
||||
|
||||
|
||||
def profile_exists(profile_id: Any, available_profiles: set[str]) -> bool:
|
||||
return find_existing_profile_id(profile_id, available_profiles) is not None
|
||||
|
||||
|
||||
def is_profile_session_expired(last_active: Any, now_epoch: float, timeout_sec: int = PROFILE_SESSION_TIMEOUT_SEC) -> bool:
|
||||
if not isinstance(last_active, (int, float)):
|
||||
return False
|
||||
return (now_epoch - float(last_active)) > timeout_sec
|
||||
|
||||
|
||||
def _normalize_epoch(value: Any) -> int | None:
|
||||
if not isinstance(value, (int, float)):
|
||||
return None
|
||||
parsed = int(value)
|
||||
return parsed if parsed >= 0 else None
|
||||
|
||||
|
||||
def _format_epoch(value: Any) -> str:
|
||||
parsed = _normalize_epoch(value)
|
||||
if parsed is None:
|
||||
return "n/a"
|
||||
try:
|
||||
return pd.to_datetime(parsed, unit="s", utc=True).strftime("%Y-%m-%d %H:%M UTC")
|
||||
except Exception:
|
||||
return "n/a"
|
||||
|
||||
|
||||
def normalize_web_settings(raw: dict[str, Any] | None) -> dict[str, Any]:
|
||||
raw = raw or {}
|
||||
defaults: dict[str, Any] = {
|
||||
@ -161,22 +244,192 @@ def normalize_web_settings(raw: dict[str, Any] | None) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def load_web_settings() -> dict[str, Any]:
|
||||
def _load_raw_settings_payload() -> dict[str, Any] | None:
|
||||
source_path = SETTINGS_PATH if SETTINGS_PATH.exists() else LEGACY_SETTINGS_PATH
|
||||
if not source_path.exists():
|
||||
return normalize_web_settings(None)
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(source_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
return normalize_web_settings(None)
|
||||
return normalize_web_settings(payload)
|
||||
return payload if isinstance(payload, dict) else None
|
||||
except Exception:
|
||||
return normalize_web_settings(None)
|
||||
return None
|
||||
|
||||
|
||||
def save_web_settings(settings: dict[str, Any]) -> None:
|
||||
def normalize_profile_record(profile_id: str, raw_profile_data: Any) -> dict[str, Any]:
|
||||
now_epoch = int(time.time())
|
||||
fallback_settings = normalize_web_settings(None)
|
||||
settings: dict[str, Any]
|
||||
pin_hash = ""
|
||||
raw_audit: Any = {}
|
||||
|
||||
if isinstance(raw_profile_data, dict) and isinstance(raw_profile_data.get("settings"), dict):
|
||||
settings = normalize_web_settings(raw_profile_data.get("settings"))
|
||||
pin_hash = str(raw_profile_data.get("pin_hash") or "").strip()
|
||||
if not pin_hash and isinstance(raw_profile_data.get("auth"), dict):
|
||||
pin_hash = str(raw_profile_data["auth"].get("pin_hash") or "").strip()
|
||||
raw_audit = raw_profile_data.get("audit")
|
||||
elif isinstance(raw_profile_data, dict):
|
||||
settings = normalize_web_settings(raw_profile_data)
|
||||
else:
|
||||
settings = fallback_settings
|
||||
|
||||
created_at = now_epoch
|
||||
updated_at = now_epoch
|
||||
last_login_at = None
|
||||
last_symbol = str(settings.get("symbol", fallback_settings["symbol"]))
|
||||
if isinstance(raw_audit, dict):
|
||||
created_at = _normalize_epoch(raw_audit.get("created_at")) or now_epoch
|
||||
updated_at = _normalize_epoch(raw_audit.get("updated_at")) or created_at
|
||||
last_login_at = _normalize_epoch(raw_audit.get("last_login_at"))
|
||||
raw_last_symbol = str(raw_audit.get("last_symbol") or "").strip().upper()
|
||||
if raw_last_symbol:
|
||||
last_symbol = raw_last_symbol
|
||||
|
||||
return {
|
||||
"settings": settings,
|
||||
"pin_hash": pin_hash,
|
||||
"audit": {
|
||||
"created_at": created_at,
|
||||
"updated_at": updated_at,
|
||||
"last_login_at": last_login_at,
|
||||
"last_symbol": last_symbol,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _write_settings_store(profile_records: dict[str, dict[str, Any]], last_profile: str) -> None:
|
||||
serialized_profiles: dict[str, dict[str, Any]] = {}
|
||||
for profile_id, record in profile_records.items():
|
||||
normalized_record = normalize_profile_record(profile_id=profile_id, raw_profile_data=record)
|
||||
serialized_profiles[profile_id] = normalized_record
|
||||
|
||||
payload = {
|
||||
"last_profile": normalize_profile_id(last_profile),
|
||||
"profiles": serialized_profiles,
|
||||
}
|
||||
|
||||
SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
SETTINGS_PATH.write_text(json.dumps(normalize_web_settings(settings), indent=2), encoding="utf-8")
|
||||
SETTINGS_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _load_profile_records() -> tuple[dict[str, dict[str, Any]], str]:
|
||||
payload = _load_raw_settings_payload()
|
||||
if not payload:
|
||||
return {}, DEFAULT_PROFILE_ID
|
||||
|
||||
raw_profiles = payload.get("profiles")
|
||||
if isinstance(raw_profiles, dict):
|
||||
profile_records: dict[str, dict[str, Any]] = {}
|
||||
for raw_profile_id, raw_profile_data in raw_profiles.items():
|
||||
if isinstance(raw_profile_data, dict):
|
||||
profile_id = normalize_profile_id(raw_profile_id)
|
||||
profile_records[profile_id] = normalize_profile_record(profile_id, raw_profile_data)
|
||||
|
||||
last_profile = normalize_profile_id(payload.get("last_profile"))
|
||||
if profile_records and last_profile not in profile_records:
|
||||
last_profile = next(iter(profile_records))
|
||||
return profile_records, last_profile
|
||||
|
||||
# Legacy format: a single flat settings payload.
|
||||
legacy_record = normalize_profile_record(profile_id=DEFAULT_PROFILE_ID, raw_profile_data=payload)
|
||||
return {DEFAULT_PROFILE_ID: legacy_record}, DEFAULT_PROFILE_ID
|
||||
|
||||
|
||||
def list_web_profiles() -> list[str]:
|
||||
profile_records, _ = _load_profile_records()
|
||||
profile_ids = list(profile_records.keys())
|
||||
if DEFAULT_PROFILE_ID not in profile_ids:
|
||||
profile_ids.append(DEFAULT_PROFILE_ID)
|
||||
return sorted(profile_ids, key=lambda item: (item != DEFAULT_PROFILE_ID, item.lower()))
|
||||
|
||||
|
||||
def load_web_settings(profile_id: str | None = None) -> dict[str, Any]:
|
||||
profile_records, last_profile = _load_profile_records()
|
||||
selected_profile = normalize_profile_id(profile_id if profile_id is not None else last_profile)
|
||||
if selected_profile not in profile_records:
|
||||
return normalize_web_settings(None)
|
||||
return normalize_web_settings(profile_records[selected_profile].get("settings"))
|
||||
|
||||
|
||||
def profile_requires_pin(profile_id: str) -> bool:
|
||||
selected = normalize_profile_id(profile_id)
|
||||
profile_records, _ = _load_profile_records()
|
||||
record = profile_records.get(selected)
|
||||
if not isinstance(record, dict):
|
||||
return False
|
||||
return bool(str(record.get("pin_hash") or "").strip())
|
||||
|
||||
|
||||
def verify_profile_pin(profile_id: str, pin: Any) -> bool:
|
||||
selected = normalize_profile_id(profile_id)
|
||||
profile_records, _ = _load_profile_records()
|
||||
record = profile_records.get(selected)
|
||||
if not isinstance(record, dict):
|
||||
return False
|
||||
|
||||
stored_hash = str(record.get("pin_hash") or "").strip()
|
||||
if not stored_hash:
|
||||
return True
|
||||
|
||||
normalized_pin = normalize_pin(pin)
|
||||
if normalized_pin is None:
|
||||
return False
|
||||
return stored_hash == hash_profile_pin(selected, normalized_pin)
|
||||
|
||||
|
||||
def get_profile_audit(profile_id: str) -> dict[str, Any]:
|
||||
selected = normalize_profile_id(profile_id)
|
||||
profile_records, _ = _load_profile_records()
|
||||
record = profile_records.get(selected)
|
||||
if not isinstance(record, dict):
|
||||
return {}
|
||||
audit = record.get("audit")
|
||||
return audit if isinstance(audit, dict) else {}
|
||||
|
||||
|
||||
def create_profile(profile_id: str, pin: Any = None, now_epoch: int | None = None) -> None:
|
||||
selected = normalize_profile_id(profile_id)
|
||||
profile_records, _ = _load_profile_records()
|
||||
epoch = int(now_epoch if now_epoch is not None else time.time())
|
||||
normalized_pin = normalize_pin(pin)
|
||||
|
||||
profile_records[selected] = {
|
||||
"settings": normalize_web_settings(None),
|
||||
"pin_hash": hash_profile_pin(selected, normalized_pin) if normalized_pin else "",
|
||||
"audit": {
|
||||
"created_at": epoch,
|
||||
"updated_at": epoch,
|
||||
"last_login_at": epoch,
|
||||
"last_symbol": "AAPL",
|
||||
},
|
||||
}
|
||||
_write_settings_store(profile_records, last_profile=selected)
|
||||
|
||||
|
||||
def mark_profile_login(profile_id: str, now_epoch: int | None = None) -> None:
|
||||
selected = normalize_profile_id(profile_id)
|
||||
profile_records, _ = _load_profile_records()
|
||||
epoch = int(now_epoch if now_epoch is not None else time.time())
|
||||
existing = profile_records.get(selected, normalize_profile_record(selected, {}))
|
||||
normalized = normalize_profile_record(selected, existing)
|
||||
normalized["audit"]["last_login_at"] = epoch
|
||||
profile_records[selected] = normalized
|
||||
_write_settings_store(profile_records, last_profile=selected)
|
||||
|
||||
|
||||
def save_web_settings(settings: dict[str, Any], profile_id: str | None = None) -> None:
|
||||
selected_profile = normalize_profile_id(profile_id)
|
||||
now_epoch = int(time.time())
|
||||
profile_records, _ = _load_profile_records()
|
||||
existing = profile_records.get(selected_profile, normalize_profile_record(selected_profile, {}))
|
||||
normalized_existing = normalize_profile_record(selected_profile, existing)
|
||||
normalized_settings = normalize_web_settings(settings)
|
||||
|
||||
normalized_existing["settings"] = normalized_settings
|
||||
normalized_existing["audit"]["updated_at"] = now_epoch
|
||||
normalized_existing["audit"]["last_symbol"] = str(normalized_settings.get("symbol", "AAPL"))
|
||||
profile_records[selected_profile] = normalized_existing
|
||||
_write_settings_store(profile_records, last_profile=selected_profile)
|
||||
|
||||
|
||||
@st.cache_data(show_spinner=False)
|
||||
@ -320,27 +573,102 @@ def main() -> None:
|
||||
st.caption(
|
||||
"Price-action tool that classifies closed bars, filters fake bars, and tracks trend persistence using only real bars."
|
||||
)
|
||||
query_params = st.query_params
|
||||
now_epoch = time.time()
|
||||
active_profile = st.session_state.get("active_profile")
|
||||
last_active = st.session_state.get("profile_last_active_at")
|
||||
|
||||
session_expired = False
|
||||
if active_profile and is_profile_session_expired(last_active, now_epoch):
|
||||
session_expired = True
|
||||
st.session_state.pop("active_profile", None)
|
||||
st.session_state.pop("profile_last_active_at", None)
|
||||
active_profile = None
|
||||
if "profile" in query_params:
|
||||
del query_params["profile"]
|
||||
|
||||
if not active_profile:
|
||||
active_profile = None
|
||||
|
||||
if active_profile is None:
|
||||
existing_profiles = set(list_web_profiles())
|
||||
st.subheader("Profile Login")
|
||||
st.info("Login with an existing profile or create a new one. Settings are isolated per profile.")
|
||||
if session_expired:
|
||||
st.warning("Your profile session timed out due to inactivity. Please log in again.")
|
||||
|
||||
initial_profile = first_query_param_value(query_params, "profile") or ""
|
||||
login_profile = st.text_input("Login profile name", value=initial_profile, placeholder="e.g. matt")
|
||||
login_pin = st.text_input("PIN (if enabled)", value="", type="password", max_chars=6)
|
||||
if st.button("Login", type="primary", use_container_width=True):
|
||||
selected_match = find_existing_profile_id(login_profile, existing_profiles)
|
||||
if selected_match is None:
|
||||
st.error("Profile not found. Enter the exact profile name or create a new one below.")
|
||||
elif profile_requires_pin(selected_match) and not verify_profile_pin(selected_match, login_pin):
|
||||
st.error("Incorrect PIN.")
|
||||
else:
|
||||
mark_profile_login(selected_match, now_epoch=int(now_epoch))
|
||||
st.session_state["active_profile"] = selected_match
|
||||
st.session_state["profile_last_active_at"] = now_epoch
|
||||
query_params["profile"] = selected_match
|
||||
st.rerun()
|
||||
|
||||
st.divider()
|
||||
create_profile_name = st.text_input("Create profile name", value="", placeholder="e.g. sara")
|
||||
create_pin = st.text_input("Set PIN (optional, 4-6 digits)", value="", type="password", max_chars=6)
|
||||
if st.button("Create Profile", use_container_width=True):
|
||||
selected = normalize_profile_id(create_profile_name)
|
||||
if profile_exists(selected, existing_profiles):
|
||||
st.error("That profile already exists (including case-insensitive matches). Use Login instead.")
|
||||
elif create_pin and normalize_pin(create_pin) is None:
|
||||
st.error("PIN must be 4-6 digits.")
|
||||
else:
|
||||
create_profile(selected, pin=create_pin or None, now_epoch=int(now_epoch))
|
||||
st.session_state["active_profile"] = selected
|
||||
st.session_state["profile_last_active_at"] = now_epoch
|
||||
query_params["profile"] = selected
|
||||
st.rerun()
|
||||
st.stop()
|
||||
|
||||
active_profile = normalize_profile_id(active_profile)
|
||||
st.session_state["active_profile"] = active_profile
|
||||
st.session_state["profile_last_active_at"] = now_epoch
|
||||
|
||||
with st.sidebar:
|
||||
st.header("Profile")
|
||||
st.success(f"Logged in as: {active_profile}")
|
||||
if st.button("Switch profile", use_container_width=True):
|
||||
st.session_state.pop("active_profile", None)
|
||||
if "profile" in query_params:
|
||||
del query_params["profile"]
|
||||
st.rerun()
|
||||
st.divider()
|
||||
|
||||
available_profiles = list_web_profiles()
|
||||
profile_audit = get_profile_audit(active_profile)
|
||||
st.caption(
|
||||
"Profiles found: "
|
||||
+ ", ".join(available_profiles + ([active_profile] if active_profile not in available_profiles else []))
|
||||
)
|
||||
st.caption(
|
||||
"Audit: created "
|
||||
f"{_format_epoch(profile_audit.get('created_at'))}, "
|
||||
f"last login {_format_epoch(profile_audit.get('last_login_at'))}, "
|
||||
f"updated {_format_epoch(profile_audit.get('updated_at'))}, "
|
||||
f"last symbol {str(profile_audit.get('last_symbol') or 'n/a')}"
|
||||
)
|
||||
st.divider()
|
||||
|
||||
st.header("Data Settings")
|
||||
if st.button("Help / Quick Start", use_container_width=True):
|
||||
help_dialog()
|
||||
st.divider()
|
||||
|
||||
query_params = st.query_params
|
||||
persisted_settings = load_web_settings()
|
||||
|
||||
def first_query_value(key: str) -> str | None:
|
||||
raw = query_params.get(key)
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, list):
|
||||
return str(raw[0]) if raw else None
|
||||
return str(raw)
|
||||
persisted_settings = load_web_settings(profile_id=active_profile)
|
||||
|
||||
query_overrides: dict[str, Any] = {}
|
||||
for key in persisted_settings:
|
||||
candidate = first_query_value(key)
|
||||
candidate = first_query_param_value(query_params, key)
|
||||
if candidate is not None:
|
||||
query_overrides[key] = candidate
|
||||
effective_defaults = normalize_web_settings({**persisted_settings, **query_overrides})
|
||||
@ -486,7 +814,8 @@ def main() -> None:
|
||||
"max_training_examples": int(max_training_examples),
|
||||
"enable_auto_refresh": bool(enable_auto_refresh),
|
||||
"refresh_sec": int(refresh_sec),
|
||||
}
|
||||
},
|
||||
profile_id=active_profile,
|
||||
)
|
||||
except Exception:
|
||||
# Non-fatal: app should run even if local settings cannot be saved.
|
||||
@ -561,7 +890,7 @@ def main() -> None:
|
||||
st.info(live_guide["action"])
|
||||
st.caption(f"Invalidation rule: {live_guide['invalidation']}")
|
||||
|
||||
alert_key = f"{symbol}-{interval}-{period}"
|
||||
alert_key = f"{active_profile}-{symbol}-{interval}-{period}"
|
||||
newest_event = events[-1].event if events else ""
|
||||
previous_event = st.session_state.get(f"last_event-{alert_key}", "")
|
||||
if newest_event and newest_event != previous_event:
|
||||
|
||||
11
web/src/docker-compose.yml
Normal file
11
web/src/docker-compose.yml
Normal 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
|
||||
144
web/src/tests/test_app_settings.py
Normal file
144
web/src/tests/test_app_settings.py
Normal 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"]
|
||||
Loading…
Reference in New Issue
Block a user