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