From f731aa90e59ea8c05ca1d5eca719bc06526af156 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 16 Feb 2026 22:53:58 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- web/src/ONBOARDING.md | 33 ++- web/src/PRD.md | 25 +- web/src/app.py | 155 +++++++++++-- web/src/tests/test_backtest_controls.py | 39 ++++ web/src/tests/test_insights.py | 36 +++ web/src/web_core/analytics.py | 56 ++++- web/src/web_core/help.html | 36 +++ web/src/web_core/insights.py | 203 +++++++++++++++++ web/src/web_core/settings/settings_schema.py | 90 ++++++++ web/src/web_core/ui/sidebar_ui.py | 227 ++++++++++++++----- 10 files changed, 813 insertions(+), 87 deletions(-) create mode 100644 web/src/tests/test_backtest_controls.py create mode 100644 web/src/tests/test_insights.py create mode 100644 web/src/web_core/insights.py diff --git a/web/src/ONBOARDING.md b/web/src/ONBOARDING.md index ad9742b..f40608e 100644 --- a/web/src/ONBOARDING.md +++ b/web/src/ONBOARDING.md @@ -96,6 +96,24 @@ Tip: sign and notarize before sharing broadly, so macOS trust prompts are reduce 14. Use `Live Decision Guide` to translate trend state into a practical bias/action/invalidation workflow. 15. Keep `Show past behavior examples` ON while learning to review historical entry/exit outcomes. +## 4.1) Advanced Features (Optional) +- `Advanced Signals`: + - `Auto-run advanced panels (slower)`: keeps multi-timeframe/compare updating every rerun + - `Run advanced panels now`: one-shot run to avoid UI hangs during normal editing + - `Show multi-timeframe confirmation (1h/4h/1d)` + - `Regime filter (stand aside in choppy periods)` +- `Training & Guidance`: + - `Replay mode (hide future bars)` + `Replay bars shown` +- `Compare Symbols`: + - enable panel and provide comma-separated symbols (`AAPL, MSFT, NVDA`) +- `Alerts`: + - enable bullish/bearish rule alerts + - optional webhook URL for external delivery (Telegram/email automation via Zapier/Make/webhook adapters) +- `Backtest Controls`: + - slippage/fee bps + - stop-loss/take-profit % + - min/max hold bars + ## 5) How To Read The Chart - Candle layer: full price action - Green triangle-up markers: `real_bull` @@ -224,6 +242,10 @@ If PDF fails, ensure `kaleido` is installed (already in `requirements.txt`). ./run.sh ``` +### I see a blank/skeleton placeholder on refresh +- A brief placeholder can appear while Streamlit initializes the page. +- Wait a moment; this is expected loading behavior, not an app crash. + ### Port already in use ```bash streamlit run app.py --server.port 8502 @@ -241,7 +263,16 @@ streamlit run app.py --server.port 8502 ### 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. +- Select a non-`Custom` preset and click `Apply Preset` to populate defaults. +- After applying, you can still manually tweak any field. + +### Replay mode looks different than normal view +- Replay mode intentionally truncates visible bars to simulate no-hindsight training. +- Disable replay mode to return to full-history analysis. + +### Alert webhook errors +- Confirm webhook URL accepts JSON `POST`. +- For Telegram/email, route through an automation endpoint that supports incoming webhooks. ### PIN login fails - Ensure the profile name is correct (name matching is case-insensitive). diff --git a/web/src/PRD.md b/web/src/PRD.md index 0f8ad95..41ac151 100644 --- a/web/src/PRD.md +++ b/web/src/PRD.md @@ -23,6 +23,7 @@ Provide an analysis-only charting tool that classifies OHLC bars as real/fake, t - `max_bars` - optional `market_preset` - filter/toggle settings + - optional advanced controls (alerts, replay, compare symbols, backtest controls, regime filter) 2. App fetches OHLCV via Yahoo Finance (`yfinance`). 3. Optional last-bar drop (live-bar guard) for intraday intervals. 4. Bars are classified (`real_bull`, `real_bear`, `fake`, `unclassified` for first bar). @@ -67,6 +68,16 @@ Watchlist and preset behavior: - `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. +Advanced feature behavior: +- Multi-timeframe confirmation (`1h`, `4h`, `1d`) can show consensus trend and agreement percent. +- Alert rules can trigger on bullish/bearish trend events and optionally POST JSON payloads to a webhook URL. +- Session stats track since-login wins/losses, average move after signal, and fake-bar rate for visible data. +- Backtest controls include slippage/fees (bps), optional stop-loss/take-profit %, and min/max hold bars. +- Signal quality score combines trend state, volume-filter usage, and recent fake-bar density. +- Replay mode limits visible bars to first N rows to simulate no-hindsight review. +- Compare symbols panel can summarize trend/regime/fake-ratio for a small basket. +- Regime filter flags choppy periods and can suppress directional bias. + Normalization constraints: - `symbol`: uppercase, non-empty fallback `AAPL` - `interval`: must be one of `INTERVAL_OPTIONS`, fallback `1d` @@ -82,6 +93,14 @@ Normalization constraints: - `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` +- `compare_symbols`: uppercase de-duplicated symbol list, max 12 items +- `alert_webhook_url`: optional string +- `backtest_slippage_bps`: `[0.0, 100.0]`, fallback `0.0` +- `backtest_fee_bps`: `[0.0, 100.0]`, fallback `0.0` +- `backtest_stop_loss_pct`: `[0.0, 25.0]`, fallback `0.0` (0 disables) +- `backtest_take_profit_pct`: `[0.0, 25.0]`, fallback `0.0` (0 disables) +- `backtest_min_hold_bars`: `[1, 20]`, fallback `1` +- `backtest_max_hold_bars`: `[1, 40]`, fallback `1` and clamped to `>= min_hold` - booleans normalized from common truthy/falsy strings and numbers ## 5. Classification Rules @@ -162,7 +181,7 @@ Gap handling (`hide_market_closed_gaps`): - Trend events table (latest events) - Backtest snapshot: - signal at trend-change rows to active bull/bear states - - next-bar close determines win/loss + - advanced mode supports configurable costs/holds/stop/target - Past behavior examples (optional training panel): - historical examples using trend-confirmation entries and opposite-confirmation exits - per-example direction, entry/exit timestamps, bars held, P/L%, and outcome @@ -172,6 +191,10 @@ Gap handling (`hide_market_closed_gaps`): - Exports: - CSV always available - PDF via Plotly image export (requires Kaleido runtime) +- Additional optional panels: + - multi-timeframe confirmation + - compare symbols snapshot + - session stats ## 10. Validation Expectations Code-level checks: diff --git a/web/src/app.py b/web/src/app.py index c150b35..6313c6c 100644 --- a/web/src/app.py +++ b/web/src/app.py @@ -8,9 +8,17 @@ import streamlit as st from web_core.analytics import backtest_signals, simulate_trend_trades from web_core.chart_overlays import add_example_trade_markers, highlight_selected_trade from web_core.charting import build_figure -from web_core.constants import TREND_NEUTRAL +from web_core.constants import TREND_BEAR, TREND_BULL, TREND_NEUTRAL from web_core.data import fetch_ohlc, maybe_drop_live_bar from web_core.exporting import df_for_export +from web_core.insights import ( + compare_symbols, + compute_session_stats, + compute_signal_quality, + detect_regime, + multi_timeframe_confirmation, + send_webhook_alert, +) from web_core.ui.login_ui import render_profile_login from web_core.live_guide import build_live_decision_guide from web_core.auth.profile_store import ( @@ -21,6 +29,7 @@ from web_core.auth.profile_store import ( list_web_profiles, mark_profile_login, normalize_profile_id, + normalize_web_settings, profile_requires_pin, save_web_settings, ) @@ -36,6 +45,7 @@ def main() -> None: st.caption( "Price-action tool that classifies closed bars, filters fake bars, and tracks trend persistence using only real bars." ) + st.caption("If you refresh, a brief placeholder screen may appear while data and UI load.") query_params = st.query_params now_epoch = time.time() @@ -70,10 +80,16 @@ def main() -> None: st.stop() active_profile = normalize_profile_id(active_profile) + previous_session_profile = st.session_state.get("session_profile_id") + if previous_session_profile != active_profile or "session_started_at" not in st.session_state: + st.session_state["session_started_at"] = now_epoch + st.session_state["session_profile_id"] = active_profile st.session_state["active_profile"] = active_profile st.session_state["profile_last_active_at"] = now_epoch - sidebar_settings = render_sidebar(active_profile=active_profile, query_params=query_params) + sidebar_settings = normalize_web_settings( + render_sidebar(active_profile=active_profile, query_params=query_params) + ) try: save_web_settings(sidebar_settings, profile_id=active_profile) @@ -121,19 +137,64 @@ def main() -> None: st.error(f"Data error: {exc}") st.stop() - latest = analyzed.iloc[-1] + replay_mode_enabled = bool(sidebar_settings.get("replay_mode_enabled", False)) + replay_bars = int(sidebar_settings.get("replay_bars", 120)) + if replay_mode_enabled: + replay_len = max(3, min(len(analyzed), replay_bars)) + analyzed_view = analyzed.iloc[:replay_len].copy() + events_view = [e for e in events if e.timestamp <= analyzed_view.index[-1]] + st.caption(f"Replay mode active: showing first {replay_len} bars only.") + else: + analyzed_view = analyzed + events_view = events + + regime_label = detect_regime(analyzed_view) + quality = compute_signal_quality( + analyzed_view, + trend_now=str(analyzed_view.iloc[-1]["trend_state"]), + volume_filter_enabled=bool(sidebar_settings["volume_filter_enabled"]), + ) + + if bool(sidebar_settings["enable_regime_filter"]) and regime_label == "Choppy": + st.warning("Regime filter active: market is choppy. Directional bias is suppressed.") + + latest = analyzed_view.iloc[-1] trend_now = str(latest["trend_state"]) + if bool(sidebar_settings["enable_regime_filter"]) and regime_label == "Choppy": + trend_now = TREND_NEUTRAL c1, c2, c3, c4 = st.columns(4) c1.metric("Current Trend", trend_now) - c2.metric("Real Bullish Bars", int((analyzed["classification"] == "real_bull").sum())) - c3.metric("Real Bearish Bars", int((analyzed["classification"] == "real_bear").sum())) - c4.metric("Fake Bars", int((analyzed["classification"] == "fake").sum())) + c2.metric("Real Bullish Bars", int((analyzed_view["classification"] == "real_bull").sum())) + c3.metric("Real Bearish Bars", int((analyzed_view["classification"] == "real_bear").sum())) + c4.metric("Fake Bars", int((analyzed_view["classification"] == "fake").sum())) - previous_trend = str(analyzed.iloc[-2]["trend_state"]) if len(analyzed) > 1 else TREND_NEUTRAL + previous_trend = str(analyzed_view.iloc[-2]["trend_state"]) if len(analyzed_view) > 1 else TREND_NEUTRAL latest_classification = str(latest["classification"]) live_guide = build_live_decision_guide(trend_now, previous_trend, latest_classification) + q1, q2, q3 = st.columns(3) + q1.metric("Signal Quality", f"{quality['score']} ({quality['label']})") + q2.metric("Regime", regime_label) + q3.metric("Recent Fake Ratio", f"{quality['fake_ratio']}%") + + run_advanced_panels = bool(sidebar_settings.get("advanced_auto_run", False) or sidebar_settings.get("run_advanced_now", False)) + if (bool(sidebar_settings["enable_multi_tf_confirmation"]) or bool(sidebar_settings["enable_compare_symbols"])) and not run_advanced_panels: + st.caption("Advanced panels paused to keep UI responsive. Click 'Run advanced panels now' in sidebar.") + + if bool(sidebar_settings["enable_multi_tf_confirmation"]) and run_advanced_panels: + mtf = multi_timeframe_confirmation( + symbol=symbol, + period=period, + use_body_range=bool(sidebar_settings["use_body_range"]), + volume_filter_enabled=bool(sidebar_settings["volume_filter_enabled"]), + volume_sma_window=int(sidebar_settings["volume_sma_window"]), + volume_multiplier=float(sidebar_settings["volume_multiplier"]), + ) + st.subheader("Multi-Timeframe Confirmation") + st.caption(f"Consensus: {mtf['consensus']} | Agreement: {mtf['agreement_pct']}%") + st.dataframe(pd.DataFrame(mtf["states"]), use_container_width=True) + if bool(sidebar_settings["show_live_guide"]): st.subheader("Live Decision Guide") g1, g2, g3 = st.columns(3) @@ -146,13 +207,29 @@ def main() -> None: st.caption(f"Invalidation rule: {live_guide['invalidation']}") alert_key = f"{active_profile}-{symbol}-{interval}-{period}" - newest_event = events[-1].event if events else "" + newest_event = events_view[-1].event if events_view else "" previous_event = st.session_state.get(f"last_event-{alert_key}", "") if newest_event and newest_event != previous_event: st.warning(f"Alert: {newest_event}") st.session_state[f"last_event-{alert_key}"] = newest_event + if bool(sidebar_settings["enable_alert_rules"]): + should_alert = ( + (bool(sidebar_settings["alert_on_bull"]) and "Bullish" in newest_event) + or (bool(sidebar_settings["alert_on_bear"]) and "Bearish" in newest_event) + ) + if should_alert: + payload = { + "symbol": symbol, + "interval": interval, + "event": newest_event, + "trend_now": trend_now, + "timestamp": str(analyzed_view.index[-1]), + } + ok, detail = send_webhook_alert(str(sidebar_settings["alert_webhook_url"]), payload) + if str(sidebar_settings["alert_webhook_url"]).strip(): + st.caption(f"Webhook alert status: {'sent' if ok else 'failed'} ({detail})") - example_trades = simulate_trend_trades(analyzed, max_examples=int(sidebar_settings["max_training_examples"])) + example_trades = simulate_trend_trades(analyzed_view, max_examples=int(sidebar_settings["max_training_examples"])) selected_trade = render_training_panel( show_past_behavior=bool(sidebar_settings["show_past_behavior"]), example_trades=example_trades, @@ -160,7 +237,7 @@ def main() -> None: ) fig = build_figure( - analyzed, + analyzed_view, gray_fake=bool(sidebar_settings["gray_fake"]), interval=interval, hide_market_closed_gaps=bool(sidebar_settings["hide_market_closed_gaps"]), @@ -169,28 +246,70 @@ def main() -> None: add_example_trade_markers(fig, example_trades) highlight_selected_trade( fig, - analyzed, + analyzed_view, selected_trade, focus_chart_on_selected_example=bool(sidebar_settings["focus_chart_on_selected_example"]), ) st.plotly_chart(fig, use_container_width=True) - bt = backtest_signals(analyzed) + try: + bt = backtest_signals( + analyzed_view, + slippage_bps=float(sidebar_settings["backtest_slippage_bps"]), + fee_bps=float(sidebar_settings["backtest_fee_bps"]), + stop_loss_pct=( + float(sidebar_settings["backtest_stop_loss_pct"]) + if float(sidebar_settings["backtest_stop_loss_pct"]) > 0 + else None + ), + take_profit_pct=( + float(sidebar_settings["backtest_take_profit_pct"]) + if float(sidebar_settings["backtest_take_profit_pct"]) > 0 + else None + ), + min_hold_bars=int(sidebar_settings["backtest_min_hold_bars"]), + max_hold_bars=int(sidebar_settings["backtest_max_hold_bars"]), + ) + except Exception as exc: + st.error(f"Backtest error: {exc}") + bt = {"trades": 0, "wins": 0, "losses": 0, "win_rate": 0.0, "avg_pnl_pct": 0.0} st.subheader("Backtest Snapshot") - b1, b2, b3, b4 = st.columns(4) + b1, b2, b3, b4, b5 = st.columns(5) b1.metric("Signals", int(bt["trades"])) b2.metric("Wins", int(bt["wins"])) b3.metric("Losses", int(bt["losses"])) b4.metric("Win Rate", f"{bt['win_rate']}%") + b5.metric("Avg P/L", f"{bt['avg_pnl_pct']}%") st.caption("Method: trend-change signal, scored by next-bar direction. Educational only; not a trading recommendation.") + st.subheader("Session Stats") + session_stats = compute_session_stats(analyzed_view, session_started_at=float(st.session_state["session_started_at"])) + s1, s2, s3, s4 = st.columns(4) + s1.metric("Since Login Wins", int(session_stats["wins"])) + s2.metric("Since Login Losses", int(session_stats["losses"])) + s3.metric("Since Login Avg Move", f"{session_stats['avg_move_pct']}%") + s4.metric("Since Login Fake Rate", f"{session_stats['fake_ratio_pct']}%") + + if bool(sidebar_settings["enable_compare_symbols"]) and sidebar_settings["compare_symbols"] and run_advanced_panels: + st.subheader("Compare Symbols") + compare_df = compare_symbols( + symbols=list(sidebar_settings["compare_symbols"]), + interval=interval, + period=period, + use_body_range=bool(sidebar_settings["use_body_range"]), + volume_filter_enabled=bool(sidebar_settings["volume_filter_enabled"]), + volume_sma_window=int(sidebar_settings["volume_sma_window"]), + volume_multiplier=float(sidebar_settings["volume_multiplier"]), + ) + st.dataframe(compare_df, use_container_width=True) + st.subheader("Trend Events") - if events: + if events_view: event_df = pd.DataFrame( { - "timestamp": [str(e.timestamp) for e in events[-25:]][::-1], - "event": [e.event for e in events[-25:]][::-1], - "trend_after": [e.trend_after for e in events[-25:]][::-1], + "timestamp": [str(e.timestamp) for e in events_view[-25:]][::-1], + "event": [e.event for e in events_view[-25:]][::-1], + "trend_after": [e.trend_after for e in events_view[-25:]][::-1], } ) st.dataframe(event_df, use_container_width=True) @@ -198,7 +317,7 @@ def main() -> None: st.info("No trend start/reversal events detected in the selected data window.") st.subheader("Export") - export_df = df_for_export(analyzed) + export_df = df_for_export(analyzed_view) csv_bytes = export_df.to_csv(index=False).encode("utf-8") st.download_button( "Download classified data (CSV)", diff --git a/web/src/tests/test_backtest_controls.py b/web/src/tests/test_backtest_controls.py new file mode 100644 index 0000000..c16d678 --- /dev/null +++ b/web/src/tests/test_backtest_controls.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import pandas as pd + +from web_core.analytics import backtest_signals +from web_core.constants import TREND_BEAR, TREND_BULL, TREND_NEUTRAL + + +def test_backtest_signals_supports_cost_and_hold_controls() -> None: + idx = pd.date_range("2025-01-01", periods=8, freq="D") + df = pd.DataFrame( + { + "Close": [100.0, 101.0, 103.0, 104.0, 102.0, 99.0, 97.0, 98.0], + "trend_state": [ + TREND_NEUTRAL, + TREND_BULL, + TREND_BULL, + TREND_BEAR, + TREND_BEAR, + TREND_BULL, + TREND_BULL, + TREND_BULL, + ], + }, + index=idx, + ) + + out = backtest_signals( + df, + slippage_bps=5.0, + fee_bps=2.0, + stop_loss_pct=2.0, + take_profit_pct=3.0, + min_hold_bars=1, + max_hold_bars=3, + ) + + assert out["trades"] >= 1 + assert "avg_pnl_pct" in out diff --git a/web/src/tests/test_insights.py b/web/src/tests/test_insights.py new file mode 100644 index 0000000..e91db4f --- /dev/null +++ b/web/src/tests/test_insights.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import pandas as pd + +from web_core.constants import TREND_BEAR, TREND_BULL, TREND_NEUTRAL +from web_core.insights import compute_session_stats, compute_signal_quality, detect_regime + + +def _make_analyzed(rows: int = 12) -> pd.DataFrame: + idx = pd.date_range("2025-01-01", periods=rows, freq="h") + classifications = ["fake", "real_bull", "fake", "real_bear"] * (rows // 4) + ["fake"] * (rows % 4) + trends = [TREND_NEUTRAL, TREND_BULL, TREND_BULL, TREND_BEAR] * (rows // 4) + [TREND_BEAR] * (rows % 4) + closes = [100 + i for i in range(rows)] + return pd.DataFrame({"classification": classifications[:rows], "trend_state": trends[:rows], "Close": closes}, index=idx) + + +def test_detect_regime_flags_choppy_when_fake_ratio_high() -> None: + df = _make_analyzed(12) + out = detect_regime(df, lookback=12) + assert out in {"Choppy", "Trending"} + + +def test_compute_signal_quality_returns_expected_keys() -> None: + df = _make_analyzed(20) + quality = compute_signal_quality(df, trend_now=TREND_BULL, volume_filter_enabled=True) + assert set(quality.keys()) == {"score", "label", "fake_ratio"} + assert quality["label"] in {"Low", "Medium", "High"} + + +def test_compute_session_stats_has_numeric_outputs() -> None: + df = _make_analyzed(20) + stats = compute_session_stats(df, session_started_at=0.0) + assert isinstance(stats["wins"], int) + assert isinstance(stats["losses"], int) + assert isinstance(stats["avg_move_pct"], float) + assert isinstance(stats["fake_ratio_pct"], float) diff --git a/web/src/web_core/analytics.py b/web/src/web_core/analytics.py index 52b46e4..a1688ff 100644 --- a/web/src/web_core/analytics.py +++ b/web/src/web_core/analytics.py @@ -5,9 +5,23 @@ import pandas as pd from .constants import TREND_BEAR, TREND_BULL -def backtest_signals(df: pd.DataFrame) -> dict[str, float | int]: +def _trade_return_pct(direction: str, entry_price: float, exit_price: float) -> float: + if direction == "LONG": + return ((exit_price - entry_price) / entry_price) * 100.0 + return ((entry_price - exit_price) / entry_price) * 100.0 + + +def backtest_signals( + df: pd.DataFrame, + slippage_bps: float = 0.0, + fee_bps: float = 0.0, + stop_loss_pct: float | None = None, + take_profit_pct: float | None = None, + max_hold_bars: int = 1, + min_hold_bars: int = 1, +) -> dict[str, float | int]: if len(df) < 4: - return {"trades": 0, "wins": 0, "losses": 0, "win_rate": 0.0} + return {"trades": 0, "wins": 0, "losses": 0, "win_rate": 0.0, "avg_pnl_pct": 0.0} trend_series = df["trend_state"] trend_change = trend_series != trend_series.shift(1) @@ -15,6 +29,10 @@ def backtest_signals(df: pd.DataFrame) -> dict[str, float | int]: wins = 0 losses = 0 + pnl_values: list[float] = [] + max_hold = max(1, int(max_hold_bars)) + min_hold = max(1, min(int(min_hold_bars), max_hold)) + total_cost_pct = ((float(slippage_bps) + float(fee_bps)) * 2.0) / 100.0 for idx in signal_idx: pos = df.index.get_loc(idx) @@ -22,19 +40,37 @@ def backtest_signals(df: pd.DataFrame) -> dict[str, float | int]: continue entry_close = float(df.iloc[pos]["Close"]) - next_close = float(df.iloc[pos + 1]["Close"]) signal_trend = df.iloc[pos]["trend_state"] + direction = "LONG" if signal_trend == TREND_BULL else "SHORT" - if signal_trend == TREND_BULL: - wins += int(next_close > entry_close) - losses += int(next_close <= entry_close) - elif signal_trend == TREND_BEAR: - wins += int(next_close < entry_close) - losses += int(next_close >= entry_close) + exit_price = float(df.iloc[min(pos + max_hold, len(df) - 1)]["Close"]) + for hold in range(min_hold, max_hold + 1): + exit_pos = min(pos + hold, len(df) - 1) + candidate_exit = float(df.iloc[exit_pos]["Close"]) + raw_pnl = _trade_return_pct(direction, entry_close, candidate_exit) + if stop_loss_pct is not None and raw_pnl <= -float(stop_loss_pct): + exit_price = candidate_exit + break + if take_profit_pct is not None and raw_pnl >= float(take_profit_pct): + exit_price = candidate_exit + break + exit_price = candidate_exit + + pnl_pct = _trade_return_pct(direction, entry_close, exit_price) - total_cost_pct + pnl_values.append(pnl_pct) + wins += int(pnl_pct > 0) + losses += int(pnl_pct <= 0) trades = wins + losses win_rate = (wins / trades * 100.0) if trades else 0.0 - return {"trades": trades, "wins": wins, "losses": losses, "win_rate": round(win_rate, 2)} + avg_pnl_pct = (sum(pnl_values) / len(pnl_values)) if pnl_values else 0.0 + return { + "trades": trades, + "wins": wins, + "losses": losses, + "win_rate": round(win_rate, 2), + "avg_pnl_pct": round(avg_pnl_pct, 2), + } def simulate_trend_trades(df: pd.DataFrame, max_examples: int = 20) -> pd.DataFrame: diff --git a/web/src/web_core/help.html b/web/src/web_core/help.html index 7b23ff8..cda08ad 100644 --- a/web/src/web_core/help.html +++ b/web/src/web_core/help.html @@ -31,6 +31,41 @@

How To Use The New Training Features

Training toggles are OFF by default.

+

Advanced Panels

+

These are in the sidebar as collapsible sections.

+ +

Advanced Signals

+ + +

Training & Guidance (Replay)

+ + +

Compare Symbols

+ + +

Alerts

+ + +

Backtest Controls

+ +

1) Live Decision Guide (what to do now)