Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
1f32527658
commit
1e73d49aa1
@ -71,27 +71,30 @@ Tip: sign and notarize before sharing broadly, so macOS trust prompts are reduce
|
|||||||
1. At app start, complete `Profile Login`:
|
1. At app start, complete `Profile Login`:
|
||||||
- `Login profile name`: type an existing profile, then click `Login`
|
- `Login profile name`: type an existing profile, then click `Login`
|
||||||
- `PIN (if enabled)`: enter PIN for protected profiles
|
- `PIN (if enabled)`: enter PIN for protected profiles
|
||||||
|
- `Remember me on this browser`: keeps non-PIN profiles signed in across visits
|
||||||
- `Create profile name`: type a new unique profile, then click `Create Profile`
|
- `Create profile name`: type a new unique profile, then click `Create Profile`
|
||||||
- `Set PIN (optional, 4-6 digits)`: add lightweight profile protection
|
- `Set PIN (optional, 4-6 digits)`: add lightweight profile protection
|
||||||
2. After login, use sidebar `Switch profile` to change users.
|
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).
|
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.
|
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`).
|
5. Set `Symbol` (examples: `AAPL`, `MSFT`, `BTC-USD`, `ETH-USD`).
|
||||||
6. Set `Timeframe` (start with `1d` to avoid noisy intraday data).
|
6. Optional: choose `Market Preset` (`Stocks Swing` or `Crypto Intraday`) for one-click defaults.
|
||||||
7. Set `Period` (try `6mo` initially).
|
7. Optional: add symbols to `Watchlist` and use quick-select buttons.
|
||||||
8. Keep `Ignore potentially live last bar` ON.
|
8. Set `Timeframe` (start with `1d` to avoid noisy intraday data).
|
||||||
9. Keep filters OFF for baseline:
|
9. Set `Period` (try `6mo` initially).
|
||||||
|
10. Keep `Ignore potentially live last bar` ON.
|
||||||
|
11. Keep filters OFF for baseline:
|
||||||
- `Use previous body range (ignore wicks)` OFF
|
- `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
|
||||||
10. Review top metrics:
|
12. Review top metrics:
|
||||||
- Current Trend
|
- Current Trend
|
||||||
- Real Bullish Bars
|
- Real Bullish Bars
|
||||||
- Real Bearish Bars
|
- Real Bearish Bars
|
||||||
- Fake Bars
|
- Fake Bars
|
||||||
11. Read `Trend Events` for starts and reversals.
|
13. Read `Trend Events` for starts and reversals.
|
||||||
12. Use `Live Decision Guide` to translate trend state into a practical bias/action/invalidation workflow.
|
14. 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.
|
15. 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
|
||||||
@ -236,10 +239,18 @@ streamlit run app.py --server.port 8502
|
|||||||
- Share profile-specific links by including `?profile=<your-name>` in the app URL.
|
- 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.
|
- If you were inactive for more than 30 minutes, the app will require login again.
|
||||||
|
|
||||||
|
### Watchlist or preset not applying as expected
|
||||||
|
- Watchlist uses comma-separated symbols and deduplicates automatically.
|
||||||
|
- Preset `Custom` disables forced defaults; other presets re-apply tuned defaults each run.
|
||||||
|
|
||||||
### PIN login fails
|
### PIN login fails
|
||||||
- Ensure the profile name is correct (name matching is case-insensitive).
|
- Ensure the profile name is correct (name matching is case-insensitive).
|
||||||
- PIN must be exactly what was set during profile creation (4-6 digits).
|
- PIN must be exactly what was set during profile creation (4-6 digits).
|
||||||
|
|
||||||
|
### Why did auto-login not happen?
|
||||||
|
- `Remember me` only auto-restores profiles without PIN.
|
||||||
|
- PIN-protected profiles still require PIN on a fresh/expired session.
|
||||||
|
|
||||||
### I still see some time gaps
|
### 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.
|
||||||
|
|||||||
@ -17,9 +17,11 @@ Provide an analysis-only charting tool that classifies OHLC bars as real/fake, t
|
|||||||
## 3. Inputs and Data Pipeline
|
## 3. Inputs and Data Pipeline
|
||||||
1. User configures:
|
1. User configures:
|
||||||
- `symbol`
|
- `symbol`
|
||||||
|
- optional `watchlist` (per-profile)
|
||||||
- `interval`
|
- `interval`
|
||||||
- `period`
|
- `period`
|
||||||
- `max_bars`
|
- `max_bars`
|
||||||
|
- optional `market_preset`
|
||||||
- filter/toggle settings
|
- filter/toggle settings
|
||||||
2. App fetches OHLCV via Yahoo Finance (`yfinance`).
|
2. App fetches OHLCV via Yahoo Finance (`yfinance`).
|
||||||
3. Optional last-bar drop (live-bar guard) for intraday intervals.
|
3. Optional last-bar drop (live-bar guard) for intraday intervals.
|
||||||
@ -46,6 +48,7 @@ Profile behavior:
|
|||||||
- App enforces a profile login gate before data/settings UI is shown.
|
- App enforces a profile login gate before data/settings UI is shown.
|
||||||
- Login requires typing an existing profile name.
|
- Login requires typing an existing profile name.
|
||||||
- Login checks PIN when profile has one configured.
|
- Login checks PIN when profile has one configured.
|
||||||
|
- Login includes `Remember me on this browser`.
|
||||||
- Create Profile requires typing a new profile name.
|
- Create Profile requires typing a new profile name.
|
||||||
- Create Profile accepts optional 4-6 digit PIN.
|
- Create Profile accepts optional 4-6 digit PIN.
|
||||||
- Profile-name uniqueness is case-insensitive (for example `Matt` and `matt` are treated as duplicates).
|
- Profile-name uniqueness is case-insensitive (for example `Matt` and `matt` are treated as duplicates).
|
||||||
@ -55,6 +58,14 @@ Profile behavior:
|
|||||||
- Save/load are scoped to active profile to avoid cross-user overwrites.
|
- Save/load are scoped to active profile to avoid cross-user overwrites.
|
||||||
- If profile has no saved settings, defaults are used.
|
- 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.
|
- Session activity is tracked in-memory; after 30 minutes of inactivity the app clears active profile and returns to login screen.
|
||||||
|
- If `remember=1` is present with a known non-PIN profile, app auto-restores that profile without showing login.
|
||||||
|
- PIN-protected profiles always require PIN entry after session timeout/reopen.
|
||||||
|
|
||||||
|
Watchlist and preset behavior:
|
||||||
|
- `watchlist` is saved per profile and supports comma/newline input.
|
||||||
|
- Watchlist supports one-click symbol quick-select buttons.
|
||||||
|
- `market_preset` applies tuned defaults for common workflows (`Custom`, `Stocks Swing`, `Crypto Intraday`, `Crypto Swing`).
|
||||||
|
- Preset application affects defaults for timeframe/period/max bars and related monitoring/gap options.
|
||||||
|
|
||||||
Normalization constraints:
|
Normalization constraints:
|
||||||
- `symbol`: uppercase, non-empty fallback `AAPL`
|
- `symbol`: uppercase, non-empty fallback `AAPL`
|
||||||
@ -69,6 +80,8 @@ Normalization constraints:
|
|||||||
- `show_trade_markers`: boolean, fallback `false`
|
- `show_trade_markers`: boolean, fallback `false`
|
||||||
- `focus_chart_on_selected_example`: boolean, fallback `false`
|
- `focus_chart_on_selected_example`: boolean, fallback `false`
|
||||||
- `max_training_examples`: `[5, 100]`, fallback `20`
|
- `max_training_examples`: `[5, 100]`, fallback `20`
|
||||||
|
- `watchlist`: uppercase de-duplicated symbol list, max 40 items
|
||||||
|
- `market_preset`: one of `Custom`, `Stocks Swing`, `Crypto Intraday`, `Crypto Swing`, fallback `Custom`
|
||||||
- booleans normalized from common truthy/falsy strings and numbers
|
- booleans normalized from common truthy/falsy strings and numbers
|
||||||
|
|
||||||
## 5. Classification Rules
|
## 5. Classification Rules
|
||||||
|
|||||||
1028
web/src/app.py
1028
web/src/app.py
File diff suppressed because it is too large
Load Diff
@ -1,23 +1,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import app
|
from web_core.auth import profile_store as store
|
||||||
|
|
||||||
|
|
||||||
def test_save_and_load_settings_are_isolated_by_profile(tmp_path, monkeypatch) -> None:
|
def test_save_and_load_settings_are_isolated_by_profile(tmp_path, monkeypatch) -> None:
|
||||||
settings_path = tmp_path / "settings.json"
|
settings_path = tmp_path / "settings.json"
|
||||||
legacy_path = tmp_path / "legacy.json"
|
legacy_path = tmp_path / "legacy.json"
|
||||||
monkeypatch.setattr(app, "SETTINGS_PATH", settings_path)
|
monkeypatch.setattr(store, "SETTINGS_PATH", settings_path)
|
||||||
monkeypatch.setattr(app, "LEGACY_SETTINGS_PATH", legacy_path)
|
monkeypatch.setattr(store, "LEGACY_SETTINGS_PATH", legacy_path)
|
||||||
|
|
||||||
app.save_web_settings({"symbol": "AAPL", "period": "1mo"}, profile_id="alice")
|
store.save_web_settings({"symbol": "AAPL", "period": "1mo"}, profile_id="alice")
|
||||||
app.save_web_settings({"symbol": "MSFT", "period": "3mo"}, profile_id="bob")
|
store.save_web_settings({"symbol": "MSFT", "period": "3mo"}, profile_id="bob")
|
||||||
|
|
||||||
alice = app.load_web_settings(profile_id="alice")
|
alice = store.load_web_settings(profile_id="alice")
|
||||||
bob = app.load_web_settings(profile_id="bob")
|
bob = store.load_web_settings(profile_id="bob")
|
||||||
unknown = app.load_web_settings(profile_id="carol")
|
unknown = store.load_web_settings(profile_id="carol")
|
||||||
|
|
||||||
assert alice["symbol"] == "AAPL"
|
assert alice["symbol"] == "AAPL"
|
||||||
assert alice["period"] == "1mo"
|
assert alice["period"] == "1mo"
|
||||||
@ -29,18 +28,18 @@ def test_save_and_load_settings_are_isolated_by_profile(tmp_path, monkeypatch) -
|
|||||||
def test_load_settings_migrates_legacy_flat_payload_to_default_profile(tmp_path, monkeypatch) -> None:
|
def test_load_settings_migrates_legacy_flat_payload_to_default_profile(tmp_path, monkeypatch) -> None:
|
||||||
settings_path = tmp_path / "settings.json"
|
settings_path = tmp_path / "settings.json"
|
||||||
legacy_path = tmp_path / "legacy.json"
|
legacy_path = tmp_path / "legacy.json"
|
||||||
monkeypatch.setattr(app, "SETTINGS_PATH", settings_path)
|
monkeypatch.setattr(store, "SETTINGS_PATH", settings_path)
|
||||||
monkeypatch.setattr(app, "LEGACY_SETTINGS_PATH", legacy_path)
|
monkeypatch.setattr(store, "LEGACY_SETTINGS_PATH", legacy_path)
|
||||||
|
|
||||||
legacy_payload = {"symbol": "TSLA", "interval": "1h", "max_bars": 9999}
|
legacy_payload = {"symbol": "TSLA", "interval": "1h", "max_bars": 9999}
|
||||||
legacy_path.write_text(json.dumps(legacy_payload), encoding="utf-8")
|
legacy_path.write_text(json.dumps(legacy_payload), encoding="utf-8")
|
||||||
|
|
||||||
loaded = app.load_web_settings(profile_id="default")
|
loaded = store.load_web_settings(profile_id="default")
|
||||||
assert loaded["symbol"] == "TSLA"
|
assert loaded["symbol"] == "TSLA"
|
||||||
assert loaded["interval"] == "1h"
|
assert loaded["interval"] == "1h"
|
||||||
assert loaded["max_bars"] == 5000 # normalized max cap
|
assert loaded["max_bars"] == 5000 # normalized max cap
|
||||||
|
|
||||||
app.save_web_settings({"symbol": "TSLA"}, profile_id="default")
|
store.save_web_settings({"symbol": "TSLA"}, profile_id="default")
|
||||||
stored = json.loads(settings_path.read_text(encoding="utf-8"))
|
stored = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||||
assert "profiles" in stored
|
assert "profiles" in stored
|
||||||
assert "default" in stored["profiles"]
|
assert "default" in stored["profiles"]
|
||||||
@ -51,68 +50,76 @@ def test_load_settings_migrates_legacy_flat_payload_to_default_profile(tmp_path,
|
|||||||
def test_list_web_profiles_includes_saved_profiles(tmp_path, monkeypatch) -> None:
|
def test_list_web_profiles_includes_saved_profiles(tmp_path, monkeypatch) -> None:
|
||||||
settings_path = tmp_path / "settings.json"
|
settings_path = tmp_path / "settings.json"
|
||||||
legacy_path = tmp_path / "legacy.json"
|
legacy_path = tmp_path / "legacy.json"
|
||||||
monkeypatch.setattr(app, "SETTINGS_PATH", settings_path)
|
monkeypatch.setattr(store, "SETTINGS_PATH", settings_path)
|
||||||
monkeypatch.setattr(app, "LEGACY_SETTINGS_PATH", legacy_path)
|
monkeypatch.setattr(store, "LEGACY_SETTINGS_PATH", legacy_path)
|
||||||
|
|
||||||
app.save_web_settings({"symbol": "AAPL"}, profile_id="zoe")
|
store.save_web_settings({"symbol": "AAPL"}, profile_id="zoe")
|
||||||
app.save_web_settings({"symbol": "AAPL"}, profile_id="alice")
|
store.save_web_settings({"symbol": "AAPL"}, profile_id="alice")
|
||||||
|
|
||||||
profile_ids = app.list_web_profiles()
|
profile_ids = store.list_web_profiles()
|
||||||
assert "default" in profile_ids
|
assert "default" in profile_ids
|
||||||
assert "alice" in profile_ids
|
assert "alice" in profile_ids
|
||||||
assert "zoe" in profile_ids
|
assert "zoe" in profile_ids
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_login_profile_prefers_session_then_query() -> None:
|
def test_resolve_login_profile_prefers_session_then_query() -> None:
|
||||||
assert app.resolve_login_profile("alice", "bob") == "alice"
|
assert store.resolve_login_profile("alice", "bob") == "alice"
|
||||||
assert app.resolve_login_profile("", "bob") == "bob"
|
assert store.resolve_login_profile("", "bob") == "bob"
|
||||||
assert app.resolve_login_profile(None, None) is None
|
assert store.resolve_login_profile(None, None) is None
|
||||||
|
|
||||||
|
|
||||||
def test_profile_exists_normalizes_input() -> None:
|
def test_profile_exists_normalizes_input() -> None:
|
||||||
profiles = {"default", "alice"}
|
profiles = {"default", "alice"}
|
||||||
assert app.profile_exists("alice", profiles) is True
|
assert store.profile_exists("alice", profiles) is True
|
||||||
assert app.profile_exists(" ", profiles) is True # normalizes to default
|
assert store.profile_exists(" ", profiles) is True # normalizes to default
|
||||||
assert app.profile_exists("bob", profiles) is False
|
assert store.profile_exists("bob", profiles) is False
|
||||||
|
|
||||||
|
|
||||||
def test_profile_exists_is_case_insensitive() -> None:
|
def test_profile_exists_is_case_insensitive() -> None:
|
||||||
profiles = {"default", "Alice"}
|
profiles = {"default", "Alice"}
|
||||||
assert app.profile_exists("alice", profiles) is True
|
assert store.profile_exists("alice", profiles) is True
|
||||||
assert app.find_existing_profile_id("ALICE", profiles) == "Alice"
|
assert store.find_existing_profile_id("ALICE", profiles) == "Alice"
|
||||||
|
|
||||||
|
|
||||||
def test_is_profile_session_expired_after_timeout() -> None:
|
def test_is_profile_session_expired_after_timeout() -> None:
|
||||||
now_epoch = 2000.0
|
now_epoch = 2000.0
|
||||||
assert app.is_profile_session_expired(1000.0, now_epoch, timeout_sec=900) is True
|
assert store.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 store.is_profile_session_expired(1500.0, now_epoch, timeout_sec=900) is False
|
||||||
assert app.is_profile_session_expired(None, now_epoch, timeout_sec=900) is False
|
assert store.is_profile_session_expired(None, now_epoch, timeout_sec=900) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_truthy_flag_parses_common_values() -> None:
|
||||||
|
assert store.is_truthy_flag("1") is True
|
||||||
|
assert store.is_truthy_flag("true") is True
|
||||||
|
assert store.is_truthy_flag("yes") is True
|
||||||
|
assert store.is_truthy_flag("0") is False
|
||||||
|
assert store.is_truthy_flag(None) is False
|
||||||
|
|
||||||
|
|
||||||
def test_create_profile_with_pin_requires_pin_for_verification(tmp_path, monkeypatch) -> None:
|
def test_create_profile_with_pin_requires_pin_for_verification(tmp_path, monkeypatch) -> None:
|
||||||
settings_path = tmp_path / "settings.json"
|
settings_path = tmp_path / "settings.json"
|
||||||
legacy_path = tmp_path / "legacy.json"
|
legacy_path = tmp_path / "legacy.json"
|
||||||
monkeypatch.setattr(app, "SETTINGS_PATH", settings_path)
|
monkeypatch.setattr(store, "SETTINGS_PATH", settings_path)
|
||||||
monkeypatch.setattr(app, "LEGACY_SETTINGS_PATH", legacy_path)
|
monkeypatch.setattr(store, "LEGACY_SETTINGS_PATH", legacy_path)
|
||||||
|
|
||||||
app.create_profile("alice", pin="1234", now_epoch=1700000000)
|
store.create_profile("alice", pin="1234", now_epoch=1700000000)
|
||||||
|
|
||||||
assert app.profile_requires_pin("alice") is True
|
assert store.profile_requires_pin("alice") is True
|
||||||
assert app.verify_profile_pin("alice", "1234") is True
|
assert store.verify_profile_pin("alice", "1234") is True
|
||||||
assert app.verify_profile_pin("alice", "9999") is False
|
assert store.verify_profile_pin("alice", "9999") is False
|
||||||
|
|
||||||
|
|
||||||
def test_audit_stamps_update_on_save_and_login(tmp_path, monkeypatch) -> None:
|
def test_audit_stamps_update_on_save_and_login(tmp_path, monkeypatch) -> None:
|
||||||
settings_path = tmp_path / "settings.json"
|
settings_path = tmp_path / "settings.json"
|
||||||
legacy_path = tmp_path / "legacy.json"
|
legacy_path = tmp_path / "legacy.json"
|
||||||
monkeypatch.setattr(app, "SETTINGS_PATH", settings_path)
|
monkeypatch.setattr(store, "SETTINGS_PATH", settings_path)
|
||||||
monkeypatch.setattr(app, "LEGACY_SETTINGS_PATH", legacy_path)
|
monkeypatch.setattr(store, "LEGACY_SETTINGS_PATH", legacy_path)
|
||||||
|
|
||||||
app.create_profile("alice", now_epoch=1700000000)
|
store.create_profile("alice", now_epoch=1700000000)
|
||||||
app.save_web_settings({"symbol": "MSFT"}, profile_id="alice")
|
store.save_web_settings({"symbol": "MSFT"}, profile_id="alice")
|
||||||
app.mark_profile_login("alice", now_epoch=1700000010)
|
store.mark_profile_login("alice", now_epoch=1700000010)
|
||||||
|
|
||||||
audit = app.get_profile_audit("alice")
|
audit = store.get_profile_audit("alice")
|
||||||
assert audit["created_at"] == 1700000000
|
assert audit["created_at"] == 1700000000
|
||||||
assert audit["last_login_at"] == 1700000010
|
assert audit["last_login_at"] == 1700000010
|
||||||
assert audit["last_symbol"] == "MSFT"
|
assert audit["last_symbol"] == "MSFT"
|
||||||
@ -121,8 +128,8 @@ def test_audit_stamps_update_on_save_and_login(tmp_path, monkeypatch) -> None:
|
|||||||
def test_load_settings_migrates_old_profiles_map_structure(tmp_path, monkeypatch) -> None:
|
def test_load_settings_migrates_old_profiles_map_structure(tmp_path, monkeypatch) -> None:
|
||||||
settings_path = tmp_path / "settings.json"
|
settings_path = tmp_path / "settings.json"
|
||||||
legacy_path = tmp_path / "legacy.json"
|
legacy_path = tmp_path / "legacy.json"
|
||||||
monkeypatch.setattr(app, "SETTINGS_PATH", settings_path)
|
monkeypatch.setattr(store, "SETTINGS_PATH", settings_path)
|
||||||
monkeypatch.setattr(app, "LEGACY_SETTINGS_PATH", legacy_path)
|
monkeypatch.setattr(store, "LEGACY_SETTINGS_PATH", legacy_path)
|
||||||
|
|
||||||
old_payload = {
|
old_payload = {
|
||||||
"last_profile": "alice",
|
"last_profile": "alice",
|
||||||
@ -133,12 +140,23 @@ def test_load_settings_migrates_old_profiles_map_structure(tmp_path, monkeypatch
|
|||||||
}
|
}
|
||||||
settings_path.write_text(json.dumps(old_payload), encoding="utf-8")
|
settings_path.write_text(json.dumps(old_payload), encoding="utf-8")
|
||||||
|
|
||||||
alice_settings = app.load_web_settings("alice")
|
alice_settings = store.load_web_settings("alice")
|
||||||
bob_settings = app.load_web_settings("bob")
|
bob_settings = store.load_web_settings("bob")
|
||||||
assert alice_settings["symbol"] == "TSLA"
|
assert alice_settings["symbol"] == "TSLA"
|
||||||
assert bob_settings["symbol"] == "MSFT"
|
assert bob_settings["symbol"] == "MSFT"
|
||||||
|
|
||||||
app.save_web_settings({"symbol": "AAPL"}, profile_id="alice")
|
store.save_web_settings({"symbol": "AAPL"}, profile_id="alice")
|
||||||
stored = json.loads(settings_path.read_text(encoding="utf-8"))
|
stored = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||||
assert isinstance(stored["profiles"]["alice"], dict)
|
assert isinstance(stored["profiles"]["alice"], dict)
|
||||||
assert "settings" in stored["profiles"]["alice"]
|
assert "settings" in stored["profiles"]["alice"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_watchlist_round_trip_keeps_multiple_symbols(tmp_path, monkeypatch) -> None:
|
||||||
|
settings_path = tmp_path / "settings.json"
|
||||||
|
legacy_path = tmp_path / "legacy.json"
|
||||||
|
monkeypatch.setattr(store, "SETTINGS_PATH", settings_path)
|
||||||
|
monkeypatch.setattr(store, "LEGACY_SETTINGS_PATH", legacy_path)
|
||||||
|
|
||||||
|
store.save_web_settings({"symbol": "AMD", "watchlist": ["AMD", "TSLA"]}, profile_id="matt")
|
||||||
|
loaded = store.load_web_settings(profile_id="matt")
|
||||||
|
assert loaded["watchlist"] == ["AMD", "TSLA"]
|
||||||
|
|||||||
31
web/src/tests/test_settings_schema.py
Normal file
31
web/src/tests/test_settings_schema.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from web_core.settings.settings_schema import normalize_web_settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_watchlist_from_string_and_deduplicates() -> None:
|
||||||
|
raw = {"watchlist": "aapl, msft\nAAPL\n btc-usd "}
|
||||||
|
out = normalize_web_settings(raw)
|
||||||
|
assert out["watchlist"] == ["AAPL", "MSFT", "BTC-USD"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_watchlist_from_list_caps_length() -> None:
|
||||||
|
raw_list = [f"sym{i}" for i in range(60)]
|
||||||
|
out = normalize_web_settings({"watchlist": raw_list})
|
||||||
|
assert len(out["watchlist"]) == 40
|
||||||
|
assert out["watchlist"][0] == "SYM0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_market_preset_defaults_to_custom_on_invalid_value() -> None:
|
||||||
|
out = normalize_web_settings({"market_preset": "not-real"})
|
||||||
|
assert out["market_preset"] == "Custom"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_watchlist_splits_legacy_escaped_newline_values() -> None:
|
||||||
|
out = normalize_web_settings({"watchlist": "AAPL\\nMSFT\\NTSLA"})
|
||||||
|
assert out["watchlist"] == ["AAPL", "MSFT", "TSLA"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_watchlist_splits_escaped_newlines_inside_list_items() -> None:
|
||||||
|
out = normalize_web_settings({"watchlist": ["AMD\\NTSLA"]})
|
||||||
|
assert out["watchlist"] == ["AMD", "TSLA"]
|
||||||
1
web/src/web_core/auth/__init__.py
Normal file
1
web/src/web_core/auth/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Authentication and profile persistence for web app."""
|
||||||
72
web/src/web_core/auth/profile_auth.py
Normal file
72
web/src/web_core/auth/profile_auth.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
DEFAULT_PROFILE_ID = "default"
|
||||||
|
PROFILE_SESSION_TIMEOUT_SEC = 1800
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_profile_id(value: Any) -> str:
|
||||||
|
profile_id = str(value or "").strip()
|
||||||
|
return profile_id if profile_id else DEFAULT_PROFILE_ID
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_key(value: Any) -> str:
|
||||||
|
return normalize_profile_id(value).casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_pin(value: Any) -> str | None:
|
||||||
|
pin = str(value or "").strip()
|
||||||
|
if not pin:
|
||||||
|
return None
|
||||||
|
if not pin.isdigit():
|
||||||
|
return None
|
||||||
|
if len(pin) < 4 or len(pin) > 6:
|
||||||
|
return None
|
||||||
|
return pin
|
||||||
|
|
||||||
|
|
||||||
|
def hash_profile_pin(profile_id: str, pin: str) -> str:
|
||||||
|
digest_input = f"{_profile_key(profile_id)}:{pin}"
|
||||||
|
return hashlib.sha256(digest_input.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def first_query_param_value(query_params: Any, key: str) -> str | None:
|
||||||
|
raw = query_params.get(key)
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if isinstance(raw, list):
|
||||||
|
return str(raw[0]) if raw else None
|
||||||
|
return str(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def is_truthy_flag(value: Any) -> bool:
|
||||||
|
normalized = str(value or "").strip().lower()
|
||||||
|
return normalized in {"1", "true", "yes", "y", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_login_profile(session_profile: Any, query_profile: Any) -> str | None:
|
||||||
|
if str(session_profile or "").strip():
|
||||||
|
return normalize_profile_id(session_profile)
|
||||||
|
if str(query_profile or "").strip():
|
||||||
|
return normalize_profile_id(query_profile)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_existing_profile_id(profile_id: Any, available_profiles: set[str]) -> str | None:
|
||||||
|
requested_key = _profile_key(profile_id)
|
||||||
|
for existing in available_profiles:
|
||||||
|
if _profile_key(existing) == requested_key:
|
||||||
|
return existing
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def profile_exists(profile_id: Any, available_profiles: set[str]) -> bool:
|
||||||
|
return find_existing_profile_id(profile_id, available_profiles) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def is_profile_session_expired(last_active: Any, now_epoch: float, timeout_sec: int = PROFILE_SESSION_TIMEOUT_SEC) -> bool:
|
||||||
|
if not isinstance(last_active, (int, float)):
|
||||||
|
return False
|
||||||
|
return (now_epoch - float(last_active)) > timeout_sec
|
||||||
226
web/src/web_core/auth/profile_store.py
Normal file
226
web/src/web_core/auth/profile_store.py
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from web_core.auth.profile_auth import (
|
||||||
|
DEFAULT_PROFILE_ID,
|
||||||
|
PROFILE_SESSION_TIMEOUT_SEC,
|
||||||
|
find_existing_profile_id,
|
||||||
|
first_query_param_value,
|
||||||
|
hash_profile_pin,
|
||||||
|
is_truthy_flag,
|
||||||
|
is_profile_session_expired,
|
||||||
|
normalize_pin,
|
||||||
|
normalize_profile_id,
|
||||||
|
profile_exists,
|
||||||
|
resolve_login_profile,
|
||||||
|
)
|
||||||
|
from web_core.settings.settings_schema import normalize_web_settings
|
||||||
|
|
||||||
|
SETTINGS_PATH = Path.home() / ".web_local_shell" / "settings.json"
|
||||||
|
LEGACY_SETTINGS_PATH = Path.home() / ".manesh_trader" / "settings.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_epoch(value: Any) -> int | None:
|
||||||
|
if not isinstance(value, (int, float)):
|
||||||
|
return None
|
||||||
|
parsed = int(value)
|
||||||
|
return parsed if parsed >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def format_epoch(value: Any) -> str:
|
||||||
|
parsed = _normalize_epoch(value)
|
||||||
|
if parsed is None:
|
||||||
|
return "n/a"
|
||||||
|
try:
|
||||||
|
return pd.to_datetime(parsed, unit="s", utc=True).strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
except Exception:
|
||||||
|
return "n/a"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_raw_settings_payload() -> dict[str, Any] | None:
|
||||||
|
source_path = SETTINGS_PATH if SETTINGS_PATH.exists() else LEGACY_SETTINGS_PATH
|
||||||
|
if not source_path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = json.loads(source_path.read_text(encoding="utf-8"))
|
||||||
|
return payload if isinstance(payload, dict) else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_profile_record(profile_id: str, raw_profile_data: Any) -> dict[str, Any]:
|
||||||
|
now_epoch = int(time.time())
|
||||||
|
fallback_settings = normalize_web_settings(None)
|
||||||
|
settings: dict[str, Any]
|
||||||
|
pin_hash = ""
|
||||||
|
raw_audit: Any = {}
|
||||||
|
|
||||||
|
if isinstance(raw_profile_data, dict) and isinstance(raw_profile_data.get("settings"), dict):
|
||||||
|
settings = normalize_web_settings(raw_profile_data.get("settings"))
|
||||||
|
pin_hash = str(raw_profile_data.get("pin_hash") or "").strip()
|
||||||
|
if not pin_hash and isinstance(raw_profile_data.get("auth"), dict):
|
||||||
|
pin_hash = str(raw_profile_data["auth"].get("pin_hash") or "").strip()
|
||||||
|
raw_audit = raw_profile_data.get("audit")
|
||||||
|
elif isinstance(raw_profile_data, dict):
|
||||||
|
settings = normalize_web_settings(raw_profile_data)
|
||||||
|
else:
|
||||||
|
settings = fallback_settings
|
||||||
|
|
||||||
|
created_at = now_epoch
|
||||||
|
updated_at = now_epoch
|
||||||
|
last_login_at = None
|
||||||
|
last_symbol = str(settings.get("symbol", fallback_settings["symbol"]))
|
||||||
|
if isinstance(raw_audit, dict):
|
||||||
|
created_at = _normalize_epoch(raw_audit.get("created_at")) or now_epoch
|
||||||
|
updated_at = _normalize_epoch(raw_audit.get("updated_at")) or created_at
|
||||||
|
last_login_at = _normalize_epoch(raw_audit.get("last_login_at"))
|
||||||
|
raw_last_symbol = str(raw_audit.get("last_symbol") or "").strip().upper()
|
||||||
|
if raw_last_symbol:
|
||||||
|
last_symbol = raw_last_symbol
|
||||||
|
|
||||||
|
return {
|
||||||
|
"settings": settings,
|
||||||
|
"pin_hash": pin_hash,
|
||||||
|
"audit": {
|
||||||
|
"created_at": created_at,
|
||||||
|
"updated_at": updated_at,
|
||||||
|
"last_login_at": last_login_at,
|
||||||
|
"last_symbol": last_symbol,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_settings_store(profile_records: dict[str, dict[str, Any]], last_profile: str) -> None:
|
||||||
|
serialized_profiles: dict[str, dict[str, Any]] = {}
|
||||||
|
for profile_id, record in profile_records.items():
|
||||||
|
serialized_profiles[profile_id] = normalize_profile_record(profile_id=profile_id, raw_profile_data=record)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"last_profile": normalize_profile_id(last_profile),
|
||||||
|
"profiles": serialized_profiles,
|
||||||
|
}
|
||||||
|
SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
SETTINGS_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_profile_records() -> tuple[dict[str, dict[str, Any]], str]:
|
||||||
|
payload = _load_raw_settings_payload()
|
||||||
|
if not payload:
|
||||||
|
return {}, DEFAULT_PROFILE_ID
|
||||||
|
|
||||||
|
raw_profiles = payload.get("profiles")
|
||||||
|
if isinstance(raw_profiles, dict):
|
||||||
|
profile_records: dict[str, dict[str, Any]] = {}
|
||||||
|
for raw_profile_id, raw_profile_data in raw_profiles.items():
|
||||||
|
if isinstance(raw_profile_data, dict):
|
||||||
|
profile_id = normalize_profile_id(raw_profile_id)
|
||||||
|
profile_records[profile_id] = normalize_profile_record(profile_id, raw_profile_data)
|
||||||
|
|
||||||
|
last_profile = normalize_profile_id(payload.get("last_profile"))
|
||||||
|
if profile_records and last_profile not in profile_records:
|
||||||
|
last_profile = next(iter(profile_records))
|
||||||
|
return profile_records, last_profile
|
||||||
|
|
||||||
|
legacy_record = normalize_profile_record(profile_id=DEFAULT_PROFILE_ID, raw_profile_data=payload)
|
||||||
|
return {DEFAULT_PROFILE_ID: legacy_record}, DEFAULT_PROFILE_ID
|
||||||
|
|
||||||
|
|
||||||
|
def list_web_profiles() -> list[str]:
|
||||||
|
profile_records, _ = _load_profile_records()
|
||||||
|
profile_ids = list(profile_records.keys())
|
||||||
|
if DEFAULT_PROFILE_ID not in profile_ids:
|
||||||
|
profile_ids.append(DEFAULT_PROFILE_ID)
|
||||||
|
return sorted(profile_ids, key=lambda item: (item != DEFAULT_PROFILE_ID, item.lower()))
|
||||||
|
|
||||||
|
|
||||||
|
def load_web_settings(profile_id: str | None = None) -> dict[str, Any]:
|
||||||
|
profile_records, last_profile = _load_profile_records()
|
||||||
|
selected_profile = normalize_profile_id(profile_id if profile_id is not None else last_profile)
|
||||||
|
if selected_profile not in profile_records:
|
||||||
|
return normalize_web_settings(None)
|
||||||
|
return normalize_web_settings(profile_records[selected_profile].get("settings"))
|
||||||
|
|
||||||
|
|
||||||
|
def profile_requires_pin(profile_id: str) -> bool:
|
||||||
|
selected = normalize_profile_id(profile_id)
|
||||||
|
profile_records, _ = _load_profile_records()
|
||||||
|
record = profile_records.get(selected)
|
||||||
|
return isinstance(record, dict) and bool(str(record.get("pin_hash") or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def verify_profile_pin(profile_id: str, pin: Any) -> bool:
|
||||||
|
selected = normalize_profile_id(profile_id)
|
||||||
|
profile_records, _ = _load_profile_records()
|
||||||
|
record = profile_records.get(selected)
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
stored_hash = str(record.get("pin_hash") or "").strip()
|
||||||
|
if not stored_hash:
|
||||||
|
return True
|
||||||
|
|
||||||
|
normalized_pin = normalize_pin(pin)
|
||||||
|
if normalized_pin is None:
|
||||||
|
return False
|
||||||
|
return stored_hash == hash_profile_pin(selected, normalized_pin)
|
||||||
|
|
||||||
|
|
||||||
|
def get_profile_audit(profile_id: str) -> dict[str, Any]:
|
||||||
|
selected = normalize_profile_id(profile_id)
|
||||||
|
profile_records, _ = _load_profile_records()
|
||||||
|
record = profile_records.get(selected)
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
return {}
|
||||||
|
audit = record.get("audit")
|
||||||
|
return audit if isinstance(audit, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def create_profile(profile_id: str, pin: Any = None, now_epoch: int | None = None) -> None:
|
||||||
|
selected = normalize_profile_id(profile_id)
|
||||||
|
profile_records, _ = _load_profile_records()
|
||||||
|
epoch = int(now_epoch if now_epoch is not None else time.time())
|
||||||
|
normalized_pin = normalize_pin(pin)
|
||||||
|
|
||||||
|
profile_records[selected] = {
|
||||||
|
"settings": normalize_web_settings(None),
|
||||||
|
"pin_hash": hash_profile_pin(selected, normalized_pin) if normalized_pin else "",
|
||||||
|
"audit": {
|
||||||
|
"created_at": epoch,
|
||||||
|
"updated_at": epoch,
|
||||||
|
"last_login_at": epoch,
|
||||||
|
"last_symbol": "AAPL",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_write_settings_store(profile_records, last_profile=selected)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_profile_login(profile_id: str, now_epoch: int | None = None) -> None:
|
||||||
|
selected = normalize_profile_id(profile_id)
|
||||||
|
profile_records, _ = _load_profile_records()
|
||||||
|
epoch = int(now_epoch if now_epoch is not None else time.time())
|
||||||
|
existing = profile_records.get(selected, normalize_profile_record(selected, {}))
|
||||||
|
normalized = normalize_profile_record(selected, existing)
|
||||||
|
normalized["audit"]["last_login_at"] = epoch
|
||||||
|
profile_records[selected] = normalized
|
||||||
|
_write_settings_store(profile_records, last_profile=selected)
|
||||||
|
|
||||||
|
|
||||||
|
def save_web_settings(settings: dict[str, Any], profile_id: str | None = None) -> None:
|
||||||
|
selected_profile = normalize_profile_id(profile_id)
|
||||||
|
now_epoch = int(time.time())
|
||||||
|
profile_records, _ = _load_profile_records()
|
||||||
|
existing = profile_records.get(selected_profile, normalize_profile_record(selected_profile, {}))
|
||||||
|
normalized_existing = normalize_profile_record(selected_profile, existing)
|
||||||
|
normalized_settings = normalize_web_settings(settings)
|
||||||
|
|
||||||
|
normalized_existing["settings"] = normalized_settings
|
||||||
|
normalized_existing["audit"]["updated_at"] = now_epoch
|
||||||
|
normalized_existing["audit"]["last_symbol"] = str(normalized_settings.get("symbol", "AAPL"))
|
||||||
|
profile_records[selected_profile] = normalized_existing
|
||||||
|
_write_settings_store(profile_records, last_profile=selected_profile)
|
||||||
112
web/src/web_core/chart_overlays.py
Normal file
112
web/src/web_core/chart_overlays.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
|
|
||||||
|
def add_example_trade_markers(fig: go.Figure, example_trades: pd.DataFrame) -> None:
|
||||||
|
if example_trades.empty:
|
||||||
|
return
|
||||||
|
|
||||||
|
long_entries = example_trades[example_trades["direction"] == "LONG"]
|
||||||
|
short_entries = example_trades[example_trades["direction"] == "SHORT"]
|
||||||
|
win_exits = example_trades[example_trades["outcome"] == "Win"]
|
||||||
|
non_win_exits = example_trades[example_trades["outcome"] != "Win"]
|
||||||
|
|
||||||
|
if not long_entries.empty:
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=long_entries["entry_timestamp"],
|
||||||
|
y=long_entries["entry_price"],
|
||||||
|
mode="markers",
|
||||||
|
name="Example Entry (Long)",
|
||||||
|
marker=dict(color="#1565C0", size=9, symbol="circle"),
|
||||||
|
),
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
|
)
|
||||||
|
if not short_entries.empty:
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=short_entries["entry_timestamp"],
|
||||||
|
y=short_entries["entry_price"],
|
||||||
|
mode="markers",
|
||||||
|
name="Example Entry (Short)",
|
||||||
|
marker=dict(color="#EF6C00", size=9, symbol="diamond"),
|
||||||
|
),
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
|
)
|
||||||
|
if not win_exits.empty:
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=win_exits["exit_timestamp"],
|
||||||
|
y=win_exits["exit_price"],
|
||||||
|
mode="markers",
|
||||||
|
name="Example Exit (Win)",
|
||||||
|
marker=dict(color="#2E7D32", size=10, symbol="x"),
|
||||||
|
),
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
|
)
|
||||||
|
if not non_win_exits.empty:
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=non_win_exits["exit_timestamp"],
|
||||||
|
y=non_win_exits["exit_price"],
|
||||||
|
mode="markers",
|
||||||
|
name="Example Exit (Loss/Flat)",
|
||||||
|
marker=dict(color="#C62828", size=10, symbol="x"),
|
||||||
|
),
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def highlight_selected_trade(
|
||||||
|
fig: go.Figure,
|
||||||
|
analyzed: pd.DataFrame,
|
||||||
|
selected_trade: pd.Series | None,
|
||||||
|
focus_chart_on_selected_example: bool,
|
||||||
|
) -> None:
|
||||||
|
if selected_trade is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_entry_ts = pd.Timestamp(selected_trade["entry_timestamp"])
|
||||||
|
selected_exit_ts = pd.Timestamp(selected_trade["exit_timestamp"])
|
||||||
|
selected_entry_price = float(selected_trade["entry_price"])
|
||||||
|
selected_exit_price = float(selected_trade["exit_price"])
|
||||||
|
selected_direction = str(selected_trade["direction"])
|
||||||
|
selected_outcome = str(selected_trade["outcome"])
|
||||||
|
|
||||||
|
path_color = "#43A047" if selected_outcome == "Win" else ("#EF6C00" if selected_outcome == "Flat" else "#E53935")
|
||||||
|
window_fill = "#BBDEFB" if selected_direction == "LONG" else "#FFE0B2"
|
||||||
|
|
||||||
|
fig.add_vrect(
|
||||||
|
x0=selected_entry_ts,
|
||||||
|
x1=selected_exit_ts,
|
||||||
|
fillcolor=window_fill,
|
||||||
|
opacity=0.18,
|
||||||
|
line_width=0,
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
|
)
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=[selected_entry_ts, selected_exit_ts],
|
||||||
|
y=[selected_entry_price, selected_exit_price],
|
||||||
|
mode="lines+markers",
|
||||||
|
name="Selected Example Path",
|
||||||
|
line=dict(color=path_color, width=3, dash="dot"),
|
||||||
|
marker=dict(color=path_color, size=11, symbol="star"),
|
||||||
|
),
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
if focus_chart_on_selected_example:
|
||||||
|
entry_pos = int(analyzed.index.get_indexer([selected_entry_ts], method="nearest")[0])
|
||||||
|
exit_pos = int(analyzed.index.get_indexer([selected_exit_ts], method="nearest")[0])
|
||||||
|
left_pos = max(0, min(entry_pos, exit_pos) - 4)
|
||||||
|
right_pos = min(len(analyzed) - 1, max(entry_pos, exit_pos) + 4)
|
||||||
|
fig.update_xaxes(range=[analyzed.index[left_pos], analyzed.index[right_pos]])
|
||||||
45
web/src/web_core/live_guide.py
Normal file
45
web/src/web_core/live_guide.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from web_core.constants import TREND_BEAR, TREND_BULL
|
||||||
|
|
||||||
|
|
||||||
|
def build_live_decision_guide(
|
||||||
|
trend_now: str,
|
||||||
|
previous_trend: str,
|
||||||
|
latest_classification: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
if trend_now == TREND_BULL:
|
||||||
|
bias = "Long Bias"
|
||||||
|
action = "Prefer pullback longs and avoid fresh shorts while the bullish trend remains active."
|
||||||
|
invalidation = "Bullish bias is invalidated only after 2 consecutive real bearish bars confirm a reversal."
|
||||||
|
elif trend_now == TREND_BEAR:
|
||||||
|
bias = "Short Bias"
|
||||||
|
action = "Prefer short setups on pops and avoid fresh longs while the bearish trend remains active."
|
||||||
|
invalidation = "Bearish bias is invalidated only after 2 consecutive real bullish bars confirm a reversal."
|
||||||
|
else:
|
||||||
|
bias = "Stand Aside / Neutral"
|
||||||
|
action = "Wait for 2 consecutive real bars in one direction before taking directional exposure."
|
||||||
|
invalidation = "No active trend yet; avoid forcing trades in noisy ranges."
|
||||||
|
|
||||||
|
if trend_now in {TREND_BULL, TREND_BEAR} and trend_now != previous_trend:
|
||||||
|
confirmation = "Fresh Confirmation"
|
||||||
|
confirmation_detail = "Latest closed bar confirmed a new active trend."
|
||||||
|
else:
|
||||||
|
confirmation = "No New Confirmation"
|
||||||
|
confirmation_detail = "Latest closed bar did not confirm a new trend reversal."
|
||||||
|
|
||||||
|
classification_hint = {
|
||||||
|
"real_bull": "Latest bar closed above the previous range.",
|
||||||
|
"real_bear": "Latest bar closed below the previous range.",
|
||||||
|
"fake": "Latest bar stayed inside the previous range (noise).",
|
||||||
|
"unclassified": "Latest bar is unclassified.",
|
||||||
|
}.get(latest_classification, "Latest bar classification unavailable.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"bias": bias,
|
||||||
|
"action": action,
|
||||||
|
"invalidation": invalidation,
|
||||||
|
"confirmation": confirmation,
|
||||||
|
"confirmation_detail": confirmation_detail,
|
||||||
|
"classification_hint": classification_hint,
|
||||||
|
}
|
||||||
1
web/src/web_core/market/__init__.py
Normal file
1
web/src/web_core/market/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Market-facing helpers (symbols, presets)."""
|
||||||
49
web/src/web_core/market/presets.py
Normal file
49
web/src/web_core/market/presets.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
MARKET_PRESET_OPTIONS = [
|
||||||
|
"Custom",
|
||||||
|
"Stocks Swing",
|
||||||
|
"Crypto Intraday",
|
||||||
|
"Crypto Swing",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_market_preset(base: dict[str, Any], preset: str) -> dict[str, Any]:
|
||||||
|
updated = dict(base)
|
||||||
|
if preset == "Stocks Swing":
|
||||||
|
updated.update(
|
||||||
|
{
|
||||||
|
"interval": "1d",
|
||||||
|
"period": "6mo",
|
||||||
|
"max_bars": 500,
|
||||||
|
"drop_live": True,
|
||||||
|
"hide_market_closed_gaps": True,
|
||||||
|
"enable_auto_refresh": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif preset == "Crypto Intraday":
|
||||||
|
updated.update(
|
||||||
|
{
|
||||||
|
"interval": "1h",
|
||||||
|
"period": "3mo",
|
||||||
|
"max_bars": 1000,
|
||||||
|
"drop_live": True,
|
||||||
|
"hide_market_closed_gaps": False,
|
||||||
|
"enable_auto_refresh": True,
|
||||||
|
"refresh_sec": 60,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif preset == "Crypto Swing":
|
||||||
|
updated.update(
|
||||||
|
{
|
||||||
|
"interval": "4h",
|
||||||
|
"period": "6mo",
|
||||||
|
"max_bars": 800,
|
||||||
|
"drop_live": True,
|
||||||
|
"hide_market_closed_gaps": False,
|
||||||
|
"enable_auto_refresh": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return updated
|
||||||
73
web/src/web_core/market/symbols.py
Normal file
73
web/src/web_core/market/symbols.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import streamlit as st
|
||||||
|
import yfinance as yf
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(show_spinner=False, ttl=3600)
|
||||||
|
def lookup_symbol_candidates(query: str, max_results: int = 10) -> list[dict[str, str]]:
|
||||||
|
cleaned = query.strip()
|
||||||
|
if len(cleaned) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
search = yf.Search(cleaned, max_results=max_results)
|
||||||
|
quotes = getattr(search, "quotes", []) or []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
seen_symbols: set[str] = set()
|
||||||
|
candidates: list[dict[str, str]] = []
|
||||||
|
for quote in quotes:
|
||||||
|
symbol = str(quote.get("symbol", "")).strip().upper()
|
||||||
|
if not symbol or symbol in seen_symbols:
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen_symbols.add(symbol)
|
||||||
|
candidates.append(
|
||||||
|
{
|
||||||
|
"symbol": symbol,
|
||||||
|
"name": str(quote.get("shortname") or quote.get("longname") or "").strip(),
|
||||||
|
"exchange": str(quote.get("exchDisp") or quote.get("exchange") or "").strip(),
|
||||||
|
"type": str(quote.get("typeDisp") or quote.get("quoteType") or "").strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(show_spinner=False, ttl=3600)
|
||||||
|
def resolve_symbol_identity(symbol: str) -> dict[str, str]:
|
||||||
|
normalized_symbol = symbol.strip().upper()
|
||||||
|
if not normalized_symbol:
|
||||||
|
return {"symbol": "", "name": "", "exchange": ""}
|
||||||
|
|
||||||
|
def _from_quote(quote: dict[str, Any]) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"symbol": normalized_symbol,
|
||||||
|
"name": str(quote.get("shortname") or quote.get("longname") or "").strip(),
|
||||||
|
"exchange": str(quote.get("exchDisp") or quote.get("exchange") or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
search = yf.Search(normalized_symbol, max_results=8)
|
||||||
|
quotes = getattr(search, "quotes", []) or []
|
||||||
|
for quote in quotes:
|
||||||
|
candidate_symbol = str(quote.get("symbol", "")).strip().upper()
|
||||||
|
if candidate_symbol == normalized_symbol:
|
||||||
|
return _from_quote(quote)
|
||||||
|
if quotes:
|
||||||
|
return _from_quote(quotes[0])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = yf.Ticker(normalized_symbol).info
|
||||||
|
return {
|
||||||
|
"symbol": normalized_symbol,
|
||||||
|
"name": str(info.get("shortName") or info.get("longName") or "").strip(),
|
||||||
|
"exchange": str(info.get("exchange") or "").strip(),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {"symbol": normalized_symbol, "name": "", "exchange": ""}
|
||||||
1
web/src/web_core/settings/__init__.py
Normal file
1
web/src/web_core/settings/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Settings schema and normalization utilities."""
|
||||||
162
web/src/web_core/settings/settings_schema.py
Normal file
162
web/src/web_core/settings/settings_schema.py
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from web_core.constants import INTERVAL_OPTIONS, PERIOD_OPTIONS
|
||||||
|
from web_core.market.presets import MARKET_PRESET_OPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_int(value: Any, fallback: int, minimum: int, maximum: int) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return fallback
|
||||||
|
return min(maximum, max(minimum, parsed))
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_float(value: Any, fallback: float, minimum: float, maximum: float) -> float:
|
||||||
|
try:
|
||||||
|
parsed = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return fallback
|
||||||
|
return min(maximum, max(minimum, parsed))
|
||||||
|
|
||||||
|
|
||||||
|
def _to_bool(value: Any, fallback: bool) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return value != 0
|
||||||
|
if value is None:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
normalized = str(value).strip().lower()
|
||||||
|
if normalized in {"1", "true", "yes", "y", "on"}:
|
||||||
|
return True
|
||||||
|
if normalized in {"0", "false", "no", "n", "off"}:
|
||||||
|
return False
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_max_bars(value: Any, fallback: int = 500) -> int:
|
||||||
|
return _clamp_int(value=value, fallback=fallback, minimum=20, maximum=5000)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_watchlist(raw: Any) -> list[str]:
|
||||||
|
tokens: list[str] = []
|
||||||
|
if isinstance(raw, list):
|
||||||
|
tokens = [str(item) for item in raw]
|
||||||
|
elif isinstance(raw, str):
|
||||||
|
tokens = [raw]
|
||||||
|
else:
|
||||||
|
tokens = [str(raw or "")]
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
symbols: list[str] = []
|
||||||
|
for token in tokens:
|
||||||
|
normalized_token = token.replace("\\n", "\n").replace("\\N", "\n").replace(",", "\n")
|
||||||
|
for split_token in normalized_token.splitlines():
|
||||||
|
symbol = split_token.strip().upper()
|
||||||
|
if not symbol or symbol in seen:
|
||||||
|
continue
|
||||||
|
seen.add(symbol)
|
||||||
|
symbols.append(symbol)
|
||||||
|
return symbols[:40]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_web_settings(raw: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
raw = raw or {}
|
||||||
|
defaults: dict[str, Any] = {
|
||||||
|
"symbol": "AAPL",
|
||||||
|
"interval": "1d",
|
||||||
|
"period": "6mo",
|
||||||
|
"max_bars": 500,
|
||||||
|
"drop_live": True,
|
||||||
|
"use_body_range": False,
|
||||||
|
"volume_filter_enabled": False,
|
||||||
|
"volume_sma_window": 20,
|
||||||
|
"volume_multiplier": 1.0,
|
||||||
|
"gray_fake": True,
|
||||||
|
"hide_market_closed_gaps": True,
|
||||||
|
"show_live_guide": False,
|
||||||
|
"show_past_behavior": False,
|
||||||
|
"show_trade_markers": False,
|
||||||
|
"focus_chart_on_selected_example": False,
|
||||||
|
"max_training_examples": 20,
|
||||||
|
"enable_auto_refresh": False,
|
||||||
|
"refresh_sec": 60,
|
||||||
|
"watchlist": [],
|
||||||
|
"market_preset": "Custom",
|
||||||
|
}
|
||||||
|
|
||||||
|
symbol = str(raw.get("symbol", defaults["symbol"])).strip().upper()
|
||||||
|
if not symbol:
|
||||||
|
symbol = str(defaults["symbol"])
|
||||||
|
|
||||||
|
interval = str(raw.get("interval", defaults["interval"]))
|
||||||
|
if interval not in INTERVAL_OPTIONS:
|
||||||
|
interval = str(defaults["interval"])
|
||||||
|
|
||||||
|
period = str(raw.get("period", defaults["period"]))
|
||||||
|
if period not in PERIOD_OPTIONS:
|
||||||
|
period = str(defaults["period"])
|
||||||
|
watchlist = _normalize_watchlist(raw.get("watchlist", defaults["watchlist"]))
|
||||||
|
market_preset = str(raw.get("market_preset", defaults["market_preset"]))
|
||||||
|
if market_preset not in MARKET_PRESET_OPTIONS:
|
||||||
|
market_preset = str(defaults["market_preset"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"symbol": symbol,
|
||||||
|
"interval": interval,
|
||||||
|
"period": period,
|
||||||
|
"max_bars": _clamp_max_bars(raw.get("max_bars"), fallback=int(defaults["max_bars"])),
|
||||||
|
"drop_live": _to_bool(raw.get("drop_live"), fallback=bool(defaults["drop_live"])),
|
||||||
|
"use_body_range": _to_bool(raw.get("use_body_range"), fallback=bool(defaults["use_body_range"])),
|
||||||
|
"volume_filter_enabled": _to_bool(
|
||||||
|
raw.get("volume_filter_enabled"), fallback=bool(defaults["volume_filter_enabled"])
|
||||||
|
),
|
||||||
|
"volume_sma_window": _clamp_int(
|
||||||
|
raw.get("volume_sma_window"),
|
||||||
|
fallback=int(defaults["volume_sma_window"]),
|
||||||
|
minimum=2,
|
||||||
|
maximum=100,
|
||||||
|
),
|
||||||
|
"volume_multiplier": round(
|
||||||
|
_clamp_float(
|
||||||
|
raw.get("volume_multiplier"),
|
||||||
|
fallback=float(defaults["volume_multiplier"]),
|
||||||
|
minimum=0.1,
|
||||||
|
maximum=3.0,
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
"gray_fake": _to_bool(raw.get("gray_fake"), fallback=bool(defaults["gray_fake"])),
|
||||||
|
"hide_market_closed_gaps": _to_bool(
|
||||||
|
raw.get("hide_market_closed_gaps"),
|
||||||
|
fallback=bool(defaults["hide_market_closed_gaps"]),
|
||||||
|
),
|
||||||
|
"show_live_guide": _to_bool(raw.get("show_live_guide"), fallback=bool(defaults["show_live_guide"])),
|
||||||
|
"show_past_behavior": _to_bool(raw.get("show_past_behavior"), fallback=bool(defaults["show_past_behavior"])),
|
||||||
|
"show_trade_markers": _to_bool(raw.get("show_trade_markers"), fallback=bool(defaults["show_trade_markers"])),
|
||||||
|
"focus_chart_on_selected_example": _to_bool(
|
||||||
|
raw.get("focus_chart_on_selected_example"),
|
||||||
|
fallback=bool(defaults["focus_chart_on_selected_example"]),
|
||||||
|
),
|
||||||
|
"max_training_examples": _clamp_int(
|
||||||
|
raw.get("max_training_examples"),
|
||||||
|
fallback=int(defaults["max_training_examples"]),
|
||||||
|
minimum=5,
|
||||||
|
maximum=100,
|
||||||
|
),
|
||||||
|
"enable_auto_refresh": _to_bool(
|
||||||
|
raw.get("enable_auto_refresh"), fallback=bool(defaults["enable_auto_refresh"])
|
||||||
|
),
|
||||||
|
"refresh_sec": _clamp_int(
|
||||||
|
raw.get("refresh_sec"),
|
||||||
|
fallback=int(defaults["refresh_sec"]),
|
||||||
|
minimum=10,
|
||||||
|
maximum=600,
|
||||||
|
),
|
||||||
|
"watchlist": watchlist,
|
||||||
|
"market_preset": market_preset,
|
||||||
|
}
|
||||||
1
web/src/web_core/ui/__init__.py
Normal file
1
web/src/web_core/ui/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""UI composition helpers for the Streamlit app."""
|
||||||
28
web/src/web_core/ui/help_content.py
Normal file
28
web/src/web_core/ui/help_content.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(show_spinner=False)
|
||||||
|
def load_help_content() -> tuple[str, bool]:
|
||||||
|
app_path = Path(__file__).resolve().parents[1] / "app.py"
|
||||||
|
help_html_paths = [
|
||||||
|
app_path.with_name("web_core").joinpath("help.html"),
|
||||||
|
app_path.with_name("help.html"),
|
||||||
|
]
|
||||||
|
for help_html_path in help_html_paths:
|
||||||
|
if help_html_path.exists():
|
||||||
|
return help_html_path.read_text(encoding="utf-8"), True
|
||||||
|
|
||||||
|
onboarding_path = app_path.with_name("ONBOARDING.md")
|
||||||
|
if onboarding_path.exists():
|
||||||
|
return onboarding_path.read_text(encoding="utf-8"), False
|
||||||
|
return "Help content not found.", False
|
||||||
|
|
||||||
|
|
||||||
|
@st.dialog("Help & Quick Start", width="large")
|
||||||
|
def help_dialog() -> None:
|
||||||
|
content, is_html = load_help_content()
|
||||||
|
st.markdown(content, unsafe_allow_html=is_html)
|
||||||
69
web/src/web_core/ui/login_ui.py
Normal file
69
web/src/web_core/ui/login_ui.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
from web_core.auth.profile_store import (
|
||||||
|
create_profile,
|
||||||
|
find_existing_profile_id,
|
||||||
|
first_query_param_value,
|
||||||
|
is_truthy_flag,
|
||||||
|
list_web_profiles,
|
||||||
|
mark_profile_login,
|
||||||
|
normalize_pin,
|
||||||
|
normalize_profile_id,
|
||||||
|
profile_exists,
|
||||||
|
profile_requires_pin,
|
||||||
|
verify_profile_pin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_profile_login(now_epoch: float, query_params: Any, session_expired: bool) -> None:
|
||||||
|
existing_profiles = set(list_web_profiles())
|
||||||
|
st.subheader("Profile Login")
|
||||||
|
st.info("Login with an existing profile or create a new one. Settings are isolated per profile.")
|
||||||
|
if session_expired:
|
||||||
|
st.warning("Your profile session timed out due to inactivity. Please log in again.")
|
||||||
|
|
||||||
|
initial_profile = first_query_param_value(query_params, "profile") or ""
|
||||||
|
remember_default = is_truthy_flag(first_query_param_value(query_params, "remember"))
|
||||||
|
login_profile = st.text_input("Login profile name", value=initial_profile, placeholder="e.g. matt")
|
||||||
|
login_pin = st.text_input("PIN (if enabled)", value="", type="password", max_chars=6)
|
||||||
|
remember_me = st.checkbox("Remember me on this browser", value=remember_default or True)
|
||||||
|
if st.button("Login", type="primary", use_container_width=True):
|
||||||
|
selected_match = find_existing_profile_id(login_profile, existing_profiles)
|
||||||
|
if selected_match is None:
|
||||||
|
st.error("Profile not found. Enter the exact profile name or create a new one below.")
|
||||||
|
elif profile_requires_pin(selected_match) and not verify_profile_pin(selected_match, login_pin):
|
||||||
|
st.error("Incorrect PIN.")
|
||||||
|
else:
|
||||||
|
mark_profile_login(selected_match, now_epoch=int(now_epoch))
|
||||||
|
st.session_state["active_profile"] = selected_match
|
||||||
|
st.session_state["profile_last_active_at"] = now_epoch
|
||||||
|
query_params["profile"] = selected_match
|
||||||
|
if remember_me:
|
||||||
|
query_params["remember"] = "1"
|
||||||
|
elif "remember" in query_params:
|
||||||
|
del query_params["remember"]
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
create_profile_name = st.text_input("Create profile name", value="", placeholder="e.g. sara")
|
||||||
|
create_pin = st.text_input("Set PIN (optional, 4-6 digits)", value="", type="password", max_chars=6)
|
||||||
|
if st.button("Create Profile", use_container_width=True):
|
||||||
|
selected = normalize_profile_id(create_profile_name)
|
||||||
|
if profile_exists(selected, existing_profiles):
|
||||||
|
st.error("That profile already exists (including case-insensitive matches). Use Login instead.")
|
||||||
|
elif create_pin and normalize_pin(create_pin) is None:
|
||||||
|
st.error("PIN must be 4-6 digits.")
|
||||||
|
else:
|
||||||
|
create_profile(selected, pin=create_pin or None, now_epoch=int(now_epoch))
|
||||||
|
st.session_state["active_profile"] = selected
|
||||||
|
st.session_state["profile_last_active_at"] = now_epoch
|
||||||
|
query_params["profile"] = selected
|
||||||
|
if remember_me:
|
||||||
|
query_params["remember"] = "1"
|
||||||
|
elif "remember" in query_params:
|
||||||
|
del query_params["remember"]
|
||||||
|
st.rerun()
|
||||||
237
web/src/web_core/ui/sidebar_ui.py
Normal file
237
web/src/web_core/ui/sidebar_ui.py
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import streamlit as st
|
||||||
|
from streamlit_autorefresh import st_autorefresh
|
||||||
|
|
||||||
|
from web_core.constants import INTERVAL_OPTIONS, PERIOD_OPTIONS
|
||||||
|
from web_core.ui.help_content import help_dialog
|
||||||
|
from web_core.market.presets import MARKET_PRESET_OPTIONS, apply_market_preset
|
||||||
|
from web_core.market.symbols import lookup_symbol_candidates
|
||||||
|
from web_core.auth.profile_store import (
|
||||||
|
first_query_param_value,
|
||||||
|
format_epoch,
|
||||||
|
get_profile_audit,
|
||||||
|
list_web_profiles,
|
||||||
|
load_web_settings,
|
||||||
|
normalize_web_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_watchlist(raw: str) -> list[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
symbols: list[str] = []
|
||||||
|
normalized_raw = str(raw or "").replace("\\n", "\n").replace("\\N", "\n").replace(",", "\n")
|
||||||
|
for token in normalized_raw.splitlines():
|
||||||
|
normalized = token.strip().upper()
|
||||||
|
if not normalized or normalized in seen:
|
||||||
|
continue
|
||||||
|
seen.add(normalized)
|
||||||
|
symbols.append(normalized)
|
||||||
|
return symbols[:40]
|
||||||
|
|
||||||
|
|
||||||
|
def render_sidebar(active_profile: str, query_params: Any) -> dict[str, Any]:
|
||||||
|
with st.sidebar:
|
||||||
|
st.header("Profile")
|
||||||
|
st.success(f"Logged in as: {active_profile}")
|
||||||
|
if st.button("Switch profile", use_container_width=True):
|
||||||
|
st.session_state.pop("active_profile", None)
|
||||||
|
if "profile" in query_params:
|
||||||
|
del query_params["profile"]
|
||||||
|
st.rerun()
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
available_profiles = list_web_profiles()
|
||||||
|
profile_audit = get_profile_audit(active_profile)
|
||||||
|
st.caption(
|
||||||
|
"Profiles found: "
|
||||||
|
+ ", ".join(available_profiles + ([active_profile] if active_profile not in available_profiles else []))
|
||||||
|
)
|
||||||
|
st.caption(
|
||||||
|
"Audit: created "
|
||||||
|
f"{format_epoch(profile_audit.get('created_at'))}, "
|
||||||
|
f"last login {format_epoch(profile_audit.get('last_login_at'))}, "
|
||||||
|
f"updated {format_epoch(profile_audit.get('updated_at'))}, "
|
||||||
|
f"last symbol {str(profile_audit.get('last_symbol') or 'n/a')}"
|
||||||
|
)
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
st.header("Data Settings")
|
||||||
|
if st.button("Help / Quick Start", use_container_width=True):
|
||||||
|
help_dialog()
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
persisted_settings = load_web_settings(profile_id=active_profile)
|
||||||
|
query_overrides: dict[str, Any] = {}
|
||||||
|
for key in persisted_settings:
|
||||||
|
candidate = first_query_param_value(query_params, key)
|
||||||
|
if candidate is not None:
|
||||||
|
query_overrides[key] = candidate
|
||||||
|
effective_defaults = normalize_web_settings({**persisted_settings, **query_overrides})
|
||||||
|
|
||||||
|
st.subheader("Market Preset")
|
||||||
|
preset_default = str(effective_defaults["market_preset"])
|
||||||
|
preset_index = MARKET_PRESET_OPTIONS.index(preset_default) if preset_default in MARKET_PRESET_OPTIONS else 0
|
||||||
|
market_preset = st.selectbox(
|
||||||
|
"Preset",
|
||||||
|
MARKET_PRESET_OPTIONS,
|
||||||
|
index=preset_index,
|
||||||
|
help="Applies default timeframes and behavior tuned for common workflows.",
|
||||||
|
)
|
||||||
|
if market_preset != "Custom":
|
||||||
|
effective_defaults = apply_market_preset(effective_defaults, market_preset)
|
||||||
|
st.caption(f"Preset applied: {market_preset}")
|
||||||
|
|
||||||
|
st.subheader("Watchlist")
|
||||||
|
watchlist_raw = st.text_input(
|
||||||
|
"Watchlist symbols (comma separated)",
|
||||||
|
value=", ".join(effective_defaults["watchlist"]),
|
||||||
|
help="Saved per profile. Example: AAPL, MSFT, NVDA, BTC-USD",
|
||||||
|
)
|
||||||
|
watchlist = _parse_watchlist(watchlist_raw)
|
||||||
|
selected_from_watchlist: str | None = None
|
||||||
|
if watchlist:
|
||||||
|
st.caption("Quick select:")
|
||||||
|
for idx, watch_symbol in enumerate(watchlist[:12]):
|
||||||
|
if st.button(watch_symbol, use_container_width=True, key=f"watchlist-{watch_symbol}-{idx}"):
|
||||||
|
selected_from_watchlist = watch_symbol
|
||||||
|
st.session_state["watchlist_symbol_choice"] = watch_symbol
|
||||||
|
selected_from_watchlist = st.session_state.get("watchlist_symbol_choice", selected_from_watchlist)
|
||||||
|
|
||||||
|
st.subheader("Find Symbol")
|
||||||
|
symbol_search_query = st.text_input(
|
||||||
|
"Search by company or ticker",
|
||||||
|
value="",
|
||||||
|
placeholder="Apple, Tesla, Bitcoin...",
|
||||||
|
help="Type a name (e.g. Apple) and select a result to prefill Symbol.",
|
||||||
|
)
|
||||||
|
symbol_from_search: str | None = None
|
||||||
|
if symbol_search_query.strip():
|
||||||
|
candidates = lookup_symbol_candidates(symbol_search_query)
|
||||||
|
if candidates:
|
||||||
|
result_placeholder = "Select a result..."
|
||||||
|
labels = [result_placeholder] + [
|
||||||
|
" | ".join(
|
||||||
|
[
|
||||||
|
candidate["symbol"],
|
||||||
|
candidate["name"] or "No name",
|
||||||
|
candidate["exchange"] or "Unknown exchange",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for candidate in candidates
|
||||||
|
]
|
||||||
|
selected_label = st.selectbox("Matches", labels, index=0)
|
||||||
|
if selected_label != result_placeholder:
|
||||||
|
selected_index = labels.index(selected_label) - 1
|
||||||
|
symbol_from_search = candidates[selected_index]["symbol"]
|
||||||
|
else:
|
||||||
|
st.caption("No matches found. Try another company name.")
|
||||||
|
|
||||||
|
symbol = st.text_input(
|
||||||
|
"Symbol",
|
||||||
|
value=symbol_from_search or selected_from_watchlist or str(effective_defaults["symbol"]),
|
||||||
|
help="Ticker or pair to analyze, e.g. AAPL, MSFT, BTC-USD.",
|
||||||
|
).strip().upper()
|
||||||
|
interval = st.selectbox(
|
||||||
|
"Timeframe",
|
||||||
|
INTERVAL_OPTIONS,
|
||||||
|
index=INTERVAL_OPTIONS.index(str(effective_defaults["interval"])),
|
||||||
|
help="Bar size for each candle. Shorter intervals are noisier; 1d is a good default.",
|
||||||
|
)
|
||||||
|
period = st.selectbox(
|
||||||
|
"Period",
|
||||||
|
PERIOD_OPTIONS,
|
||||||
|
index=PERIOD_OPTIONS.index(str(effective_defaults["period"])),
|
||||||
|
help="How much history to load for trend analysis.",
|
||||||
|
)
|
||||||
|
max_bars = st.number_input(
|
||||||
|
"Max bars",
|
||||||
|
min_value=20,
|
||||||
|
max_value=5000,
|
||||||
|
value=int(effective_defaults["max_bars"]),
|
||||||
|
step=10,
|
||||||
|
help="Limits loaded candles to keep charting responsive. 500 is a solid starting point.",
|
||||||
|
)
|
||||||
|
drop_live = st.checkbox("Ignore potentially live last bar", value=bool(effective_defaults["drop_live"]))
|
||||||
|
|
||||||
|
st.header("Classification Filters")
|
||||||
|
use_body_range = st.checkbox(
|
||||||
|
"Use previous body range (ignore wicks)",
|
||||||
|
value=bool(effective_defaults["use_body_range"]),
|
||||||
|
)
|
||||||
|
volume_filter_enabled = st.checkbox(
|
||||||
|
"Enable volume filter",
|
||||||
|
value=bool(effective_defaults["volume_filter_enabled"]),
|
||||||
|
)
|
||||||
|
volume_sma_window = st.slider("Volume SMA window", 2, 100, int(effective_defaults["volume_sma_window"]))
|
||||||
|
volume_multiplier = st.slider(
|
||||||
|
"Min volume / SMA multiplier",
|
||||||
|
0.1,
|
||||||
|
3.0,
|
||||||
|
float(effective_defaults["volume_multiplier"]),
|
||||||
|
0.1,
|
||||||
|
)
|
||||||
|
gray_fake = st.checkbox("Gray out fake bars", value=bool(effective_defaults["gray_fake"]))
|
||||||
|
hide_market_closed_gaps = st.checkbox(
|
||||||
|
"Hide market-closed gaps (stocks)",
|
||||||
|
value=bool(effective_defaults["hide_market_closed_gaps"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
st.header("Training & Guidance")
|
||||||
|
show_live_guide = st.checkbox(
|
||||||
|
"Show live decision guide",
|
||||||
|
value=bool(effective_defaults["show_live_guide"]),
|
||||||
|
help="Shows a plain-English interpretation of current trend state and confirmation status.",
|
||||||
|
)
|
||||||
|
show_past_behavior = st.checkbox(
|
||||||
|
"Show past behavior examples",
|
||||||
|
value=bool(effective_defaults["show_past_behavior"]),
|
||||||
|
help="Displays historical example trades based on trend confirmation and reversal signals.",
|
||||||
|
)
|
||||||
|
show_trade_markers = st.checkbox(
|
||||||
|
"Overlay example entries/exits on chart",
|
||||||
|
value=bool(effective_defaults["show_trade_markers"]),
|
||||||
|
help="Adds entry/exit markers for the training examples onto the main chart.",
|
||||||
|
)
|
||||||
|
focus_chart_on_selected_example = st.checkbox(
|
||||||
|
"Focus chart on selected example",
|
||||||
|
value=bool(effective_defaults["focus_chart_on_selected_example"]),
|
||||||
|
help="When a training row is selected, zooms chart around that trade window.",
|
||||||
|
)
|
||||||
|
max_training_examples = st.slider(
|
||||||
|
"Max training examples",
|
||||||
|
5,
|
||||||
|
100,
|
||||||
|
int(effective_defaults["max_training_examples"]),
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
|
||||||
|
st.header("Monitoring")
|
||||||
|
enable_auto_refresh = st.checkbox("Auto-refresh", value=bool(effective_defaults["enable_auto_refresh"]))
|
||||||
|
refresh_sec = st.slider("Refresh interval (seconds)", 10, 600, int(effective_defaults["refresh_sec"]), 10)
|
||||||
|
if enable_auto_refresh:
|
||||||
|
st_autorefresh(interval=refresh_sec * 1000, key="data_refresh")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"symbol": symbol,
|
||||||
|
"interval": interval,
|
||||||
|
"period": period,
|
||||||
|
"max_bars": int(max_bars),
|
||||||
|
"drop_live": bool(drop_live),
|
||||||
|
"use_body_range": bool(use_body_range),
|
||||||
|
"volume_filter_enabled": bool(volume_filter_enabled),
|
||||||
|
"volume_sma_window": int(volume_sma_window),
|
||||||
|
"volume_multiplier": float(volume_multiplier),
|
||||||
|
"gray_fake": bool(gray_fake),
|
||||||
|
"hide_market_closed_gaps": bool(hide_market_closed_gaps),
|
||||||
|
"show_live_guide": bool(show_live_guide),
|
||||||
|
"show_past_behavior": bool(show_past_behavior),
|
||||||
|
"show_trade_markers": bool(show_trade_markers),
|
||||||
|
"focus_chart_on_selected_example": bool(focus_chart_on_selected_example),
|
||||||
|
"max_training_examples": int(max_training_examples),
|
||||||
|
"enable_auto_refresh": bool(enable_auto_refresh),
|
||||||
|
"refresh_sec": int(refresh_sec),
|
||||||
|
"watchlist": watchlist,
|
||||||
|
"market_preset": market_preset,
|
||||||
|
}
|
||||||
83
web/src/web_core/ui/training_ui.py
Normal file
83
web/src/web_core/ui/training_ui.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
|
||||||
|
def render_training_panel(show_past_behavior: bool, example_trades: pd.DataFrame, alert_key: str) -> pd.Series | None:
|
||||||
|
selected_trade: pd.Series | None = None
|
||||||
|
if not show_past_behavior:
|
||||||
|
return selected_trade
|
||||||
|
|
||||||
|
st.subheader("Past Behavior Examples (Training)")
|
||||||
|
if example_trades.empty:
|
||||||
|
st.info("No closed example trades yet. Expand the period/timeframe to include more trend reversals.")
|
||||||
|
return selected_trade
|
||||||
|
|
||||||
|
wins = int((example_trades["outcome"] == "Win").sum())
|
||||||
|
losses = int((example_trades["outcome"] == "Loss").sum())
|
||||||
|
total_examples = int(len(example_trades))
|
||||||
|
win_rate = round((wins / total_examples) * 100.0, 2) if total_examples else 0.0
|
||||||
|
avg_pnl = round(float(example_trades["pnl_pct"].mean()), 2)
|
||||||
|
|
||||||
|
t1, t2, t3, t4 = st.columns(4)
|
||||||
|
t1.metric("Closed Examples", total_examples)
|
||||||
|
t2.metric("Wins / Losses", f"{wins} / {losses}")
|
||||||
|
t3.metric("Example Win Rate", f"{win_rate}%")
|
||||||
|
t4.metric("Avg P/L per Example", f"{avg_pnl}%")
|
||||||
|
|
||||||
|
latest_example = example_trades.iloc[-1]
|
||||||
|
st.caption(
|
||||||
|
"Latest closed example: "
|
||||||
|
f"{latest_example['direction']} from {latest_example['entry_timestamp']} "
|
||||||
|
f"to {latest_example['exit_timestamp']} "
|
||||||
|
f"({latest_example['bars_held']} bars, {latest_example['pnl_pct']}%)."
|
||||||
|
)
|
||||||
|
st.caption("Click a row below to highlight that specific example on the chart.")
|
||||||
|
st.caption("Example method: enter on trend confirmation bar close and exit on opposite trend confirmation.")
|
||||||
|
|
||||||
|
display_examples_raw = example_trades.iloc[::-1].reset_index(drop=True)
|
||||||
|
display_examples = display_examples_raw.copy()
|
||||||
|
display_examples["entry_timestamp"] = display_examples["entry_timestamp"].astype(str)
|
||||||
|
display_examples["exit_timestamp"] = display_examples["exit_timestamp"].astype(str)
|
||||||
|
|
||||||
|
table_event = st.dataframe(
|
||||||
|
display_examples,
|
||||||
|
use_container_width=True,
|
||||||
|
on_select="rerun",
|
||||||
|
selection_mode="single-row",
|
||||||
|
key=f"training-examples-{alert_key}",
|
||||||
|
)
|
||||||
|
selected_rows: list[int] = []
|
||||||
|
try:
|
||||||
|
selected_rows = list(table_event.selection.rows)
|
||||||
|
except Exception:
|
||||||
|
selected_rows = []
|
||||||
|
|
||||||
|
selected_row_state_key = f"selected-training-row-{alert_key}"
|
||||||
|
if selected_rows:
|
||||||
|
selected_row_idx = int(selected_rows[0])
|
||||||
|
st.session_state[selected_row_state_key] = selected_row_idx
|
||||||
|
else:
|
||||||
|
selected_row_idx = int(st.session_state.get(selected_row_state_key, 0))
|
||||||
|
|
||||||
|
if selected_row_idx < 0 or selected_row_idx >= len(display_examples_raw):
|
||||||
|
selected_row_idx = 0
|
||||||
|
|
||||||
|
selected_trade = display_examples_raw.iloc[selected_row_idx]
|
||||||
|
direction = str(selected_trade["direction"])
|
||||||
|
entry_ts = str(selected_trade["entry_timestamp"])
|
||||||
|
exit_ts = str(selected_trade["exit_timestamp"])
|
||||||
|
entry_price = float(selected_trade["entry_price"])
|
||||||
|
exit_price = float(selected_trade["exit_price"])
|
||||||
|
bars_held = int(selected_trade["bars_held"])
|
||||||
|
pnl_pct = float(selected_trade["pnl_pct"])
|
||||||
|
outcome = str(selected_trade["outcome"])
|
||||||
|
|
||||||
|
pnl_text = f"+{pnl_pct}%" if pnl_pct > 0 else f"{pnl_pct}%"
|
||||||
|
result_text = "profit" if pnl_pct > 0 else ("loss" if pnl_pct < 0 else "flat result")
|
||||||
|
st.info(
|
||||||
|
f"Selected example explained: {direction} entry at {entry_ts} ({entry_price}), "
|
||||||
|
f"exit at {exit_ts} ({exit_price}), held {bars_held} bars, {result_text} {pnl_text}, outcome: {outcome}."
|
||||||
|
)
|
||||||
|
return selected_trade
|
||||||
Loading…
Reference in New Issue
Block a user