diff --git a/web/src/ONBOARDING.md b/web/src/ONBOARDING.md index a9ba6c6..cad6eac 100644 --- a/web/src/ONBOARDING.md +++ b/web/src/ONBOARDING.md @@ -12,6 +12,10 @@ Trend logic: - Reversal requires 2 consecutive opposite real bars - Fake bars do not reverse trend +In-app help popup source: +- Primary: `web/src/web_core/help.html` +- Fallback: this `web/src/ONBOARDING.md` + ## 2) Quick Start (Recommended) From project root: @@ -78,6 +82,8 @@ Tip: sign and notarize before sharing broadly, so macOS trust prompts are reduce - Real Bearish Bars - Fake Bars 7. Read `Trend Events` for starts and reversals. +8. Use `Live Decision Guide` to translate trend state into a practical bias/action/invalidation workflow. +9. Keep `Show past behavior examples` ON while learning to review historical entry/exit outcomes. ## 5) How To Read The Chart - Candle layer: full price action @@ -85,8 +91,85 @@ Tip: sign and notarize before sharing broadly, so macOS trust prompts are reduce - Red triangle-down markers: `real_bear` - Gray candles (if enabled): visually de-emphasized fake bars - Volume bars are colored by trend state +- Optional training overlays: + - blue circle: example long entry + - orange diamond: example short entry + - green X: example winning exit + - red X: example loss/flat exit -## 6) Recommended Settings By Asset +## 6) Training Mode (New) +Use these toggles in sidebar `Training & Guidance`: +- `Show live decision guide`: summarizes current bias, signal status, and invalidation rule. +- `Show past behavior examples`: shows historical hypothetical trades and outcomes. +- `Overlay example entries/exits on chart`: draws example entry/exit markers on candles. +- `Focus chart on selected example`: zooms chart around the clicked training row. +- `Max training examples`: controls how many recent closed examples to include. + +Example-trade method: +- Enter on a confirmed trend-change bar close. +- Exit on the opposite confirmed trend-change bar close. +- Direction is `LONG` in bullish trend and `SHORT` in bearish trend. + +## 6.1) Tutorial: Use This Like a Training Coach +Goal: learn what the model is signaling and what would have happened if you followed it. + +### Step A: Set up a clean training view +1. In sidebar, choose a liquid symbol (`AAPL` or `BTC-USD`). +2. Start with: + - `Timeframe = 1d` (less noise) + - `Period = 6mo` + - `Ignore potentially live last bar = ON` +3. Turn ON: + - `Show live decision guide` + - `Show past behavior examples` + - `Overlay example entries/exits on chart` + - `Focus chart on selected example` (optional but recommended while learning) +4. Set `Max training examples = 20`. + +### Step B: Read current signal (what to do now) +1. Look at `Live Decision Guide`. +2. Use `Bias` as your directional filter: + - `Long Bias`: only evaluate long ideas. + - `Short Bias`: only evaluate short ideas. + - `Stand Aside / Neutral`: wait for clearer confirmation. +3. Read `Signal Status`: + - `Fresh Confirmation`: a new active trend was confirmed on the latest closed bar. + - `No New Confirmation`: no fresh reversal confirmation on the latest closed bar. +4. Use `Invalidation rule` as the condition that would cancel your current bias. + +### Step C: Study historical behavior (what would have happened) +1. Open `Past Behavior Examples (Training)`. +2. Review summary metrics: + - `Closed Examples` + - `Wins / Losses` + - `Example Win Rate` + - `Avg P/L per Example` +3. Read the latest row first, then scan older examples. +4. On chart, compare markers to price structure: + - blue circle: long entry + - orange diamond: short entry + - green X: winning exit + - red X: loss/flat exit +5. Click any row in the examples table: + - app highlights that exact trade path on chart + - app shows a plain-English explanation of that row + +### Step D: Practice loop (recommended) +1. Pick one symbol/timeframe. +2. Hide the table briefly and decide what you would do from `Live Decision Guide`. +3. Re-open table and compare your decision to recent example outcomes. +4. Repeat across different regimes: + - trending periods + - choppy/sideways periods +5. Keep notes on where the model performs best/worst. + +### Step E: Move from training to live monitoring +1. Keep `Show live decision guide` ON. +2. Keep `Ignore potentially live last bar` ON to reduce unfinished-bar noise. +3. Enable `Auto-refresh` only when actively monitoring. +4. Treat this as a decision-support layer, not an execution signal by itself. + +## 7) Recommended Settings By Asset ### Stocks (swing) - Timeframe: `1d` - Period: `6mo` or `1y` @@ -97,7 +180,7 @@ Tip: sign and notarize before sharing broadly, so macOS trust prompts are reduce - Period: `1mo-6mo` - Enable auto-refresh only when monitoring live -## 7) Optional Filters +## 8) Optional Filters ### Ignore Wicks Use when long wicks create false breakouts; compares close to previous body only. @@ -115,13 +198,13 @@ Compresses non-trading time on stock charts: Use OFF for 24/7 markets (for example many crypto workflows) when you want continuous time. -## 8) Exports +## 9) Exports - CSV: `Download classified data (CSV)` - PDF chart: `Download chart (PDF)` If PDF fails, ensure `kaleido` is installed (already in `requirements.txt`). -## 9) Troubleshooting +## 10) Troubleshooting ### App won’t start ```bash ./run.sh --setup-only @@ -145,12 +228,12 @@ streamlit run app.py --server.port 8502 ### Exports crash with timestamp errors - Pull latest project changes (export logic now handles named index columns) -## 10) Safety Notes +## 11) Safety Notes - This app is analysis-only, no trade execution. - Backtest snapshot is diagnostic and simplistic. - Not financial advice. -## 11) Useful Commands +## 12) Useful Commands Setup only: ```bash ./run.sh --setup-only diff --git a/web/src/PRD.md b/web/src/PRD.md index b0205e6..cec2ac7 100644 --- a/web/src/PRD.md +++ b/web/src/PRD.md @@ -12,7 +12,7 @@ Out of scope: - macOS shell/wrapper architecture and packaging ## 2. Product Goal -Provide an analysis-only charting tool that classifies OHLC bars as real/fake, tracks trend state using only real bars, and exposes clear visual + exportable outputs. +Provide an analysis-only charting tool that classifies OHLC bars as real/fake, tracks trend state using only real bars, and exposes clear visual, training-oriented guidance, and exportable outputs. ## 3. Inputs and Data Pipeline 1. User configures: @@ -40,6 +40,11 @@ Normalization constraints: - `volume_sma_window`: `[2, 100]`, fallback `20` - `volume_multiplier`: `[0.1, 3.0]`, rounded to 0.1, fallback `1.0` - `refresh_sec`: `[10, 600]`, fallback `60` +- `show_live_guide`: boolean, fallback `true` +- `show_past_behavior`: boolean, fallback `true` +- `show_trade_markers`: boolean, fallback `true` +- `focus_chart_on_selected_example`: boolean, fallback `false` +- `max_training_examples`: `[5, 100]`, fallback `20` - booleans normalized from common truthy/falsy strings and numbers ## 5. Classification Rules @@ -86,6 +91,13 @@ Important: - `real_bull`: green triangle-up - `real_bear`: red triangle-down - Optional fake-bar de-emphasis via gray candle layer (`gray_fake`). +- Optional example-trade overlay markers (`show_trade_markers`): + - long entry marker + - short entry marker + - winning exit marker + - loss/flat exit marker +- If a past-behavior row is selected, chart highlights that trade window and path. +- Optional focused zoom around selected trade (`focus_chart_on_selected_example`). - Volume subplot colored by trend state. Gap handling (`hide_market_closed_gaps`): @@ -96,7 +108,7 @@ Gap handling (`hide_market_closed_gaps`): ## 8. Help and Onboarding Behavior - Web-only fallback help entry exists in sidebar: - `Help / Quick Start` -- Content source: `web/src/ONBOARDING.md` +- Content source: `web/src/web_core/help.html` (primary), `web/src/ONBOARDING.md` (fallback) - Help appears in a dialog. ## 9. Outputs @@ -105,10 +117,21 @@ Gap handling (`hide_market_closed_gaps`): - real bullish count - real bearish count - fake count +- Live decision guide (optional): + - bias (long/short/neutral) + - signal confirmation status + - latest bar interpretation + - action + invalidation guidance - Trend events table (latest events) - Backtest snapshot: - signal at trend-change rows to active bull/bear states - next-bar close determines win/loss +- 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 + - aggregate example metrics (count, win/loss, win rate, average P/L) + - selectable table rows that drive chart highlight of chosen example + - plain-language explanation for selected example - Exports: - CSV always available - PDF via Plotly image export (requires Kaleido runtime) diff --git a/web/src/app.py b/web/src/app.py index af43560..cd1f12d 100644 --- a/web/src/app.py +++ b/web/src/app.py @@ -5,13 +5,14 @@ from pathlib import Path from typing import Any import pandas as pd +import plotly.graph_objects as go import streamlit as st import yfinance as yf from streamlit_autorefresh import st_autorefresh -from web_core.analytics import backtest_signals +from web_core.analytics import backtest_signals, simulate_trend_trades from web_core.charting import build_figure -from web_core.constants import INTERVAL_OPTIONS, PERIOD_OPTIONS +from web_core.constants import INTERVAL_OPTIONS, PERIOD_OPTIONS, 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.strategy import classify_bars, detect_trends @@ -70,6 +71,11 @@ def normalize_web_settings(raw: dict[str, Any] | None) -> dict[str, Any]: "volume_multiplier": 1.0, "gray_fake": True, "hide_market_closed_gaps": True, + "show_live_guide": True, + "show_past_behavior": True, + "show_trade_markers": True, + "focus_chart_on_selected_example": False, + "max_training_examples": 20, "enable_auto_refresh": False, "refresh_sec": 60, } @@ -112,6 +118,19 @@ def normalize_web_settings(raw: dict[str, Any] | None) -> dict[str, Any]: 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"), @@ -132,6 +151,11 @@ def normalize_web_settings(raw: dict[str, Any] | None) -> dict[str, Any]: "volume_multiplier": volume_multiplier, "gray_fake": gray_fake, "hide_market_closed_gaps": hide_market_closed_gaps, + "show_live_guide": show_live_guide, + "show_past_behavior": show_past_behavior, + "show_trade_markers": show_trade_markers, + "focus_chart_on_selected_example": focus_chart_on_selected_example, + "max_training_examples": max_training_examples, "enable_auto_refresh": enable_auto_refresh, "refresh_sec": refresh_sec, } @@ -156,16 +180,25 @@ def save_web_settings(settings: dict[str, Any]) -> None: @st.cache_data(show_spinner=False) -def load_help_markdown() -> str: +def load_help_content() -> tuple[str, bool]: + help_html_paths = [ + Path(__file__).with_name("web_core").joinpath("help.html"), + Path(__file__).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 = Path(__file__).with_name("ONBOARDING.md") if onboarding_path.exists(): - return onboarding_path.read_text(encoding="utf-8") - return "Help content not found." + 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: - st.markdown(load_help_markdown()) + content, is_html = load_help_content() + st.markdown(content, unsafe_allow_html=is_html) @st.cache_data(show_spinner=False, ttl=3600) @@ -239,6 +272,48 @@ def resolve_symbol_identity(symbol: str) -> dict[str, str]: return {"symbol": normalized_symbol, "name": "", "exchange": ""} +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, + } + + def main() -> None: st.set_page_config(page_title="Real Bars vs Fake Bars Analyzer", layout="wide") st.title("Real Bars vs Fake Bars Trend Analyzer") @@ -354,6 +429,35 @@ def main() -> None: 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) @@ -375,6 +479,11 @@ def main() -> None: "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), } @@ -433,6 +542,25 @@ def main() -> None: c3.metric("Real Bearish Bars", bear_count) c4.metric("Fake Bars", fake_count) + previous_trend = str(analyzed.iloc[-2]["trend_state"]) if len(analyzed) > 1 else TREND_NEUTRAL + latest_classification = str(latest["classification"]) + live_guide = build_live_decision_guide( + trend_now=trend_now, + previous_trend=previous_trend, + latest_classification=latest_classification, + ) + + if show_live_guide: + st.subheader("Live Decision Guide") + g1, g2, g3 = st.columns(3) + g1.metric("Bias", live_guide["bias"]) + g2.metric("Signal Status", live_guide["confirmation"]) + g3.metric("Latest Bar", latest_classification) + st.caption(live_guide["confirmation_detail"]) + st.caption(live_guide["classification_hint"]) + st.info(live_guide["action"]) + st.caption(f"Invalidation rule: {live_guide['invalidation']}") + alert_key = f"{symbol}-{interval}-{period}" newest_event = events[-1].event if events else "" previous_event = st.session_state.get(f"last_event-{alert_key}", "") @@ -440,12 +568,184 @@ def main() -> None: st.warning(f"Alert: {newest_event}") st.session_state[f"last_event-{alert_key}"] = newest_event + example_trades = simulate_trend_trades(analyzed, max_examples=int(max_training_examples)) + selected_trade: pd.Series | None = None + + if show_past_behavior: + 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.") + else: + 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}." + ) + fig = build_figure( analyzed, gray_fake=gray_fake, interval=interval, hide_market_closed_gaps=hide_market_closed_gaps, ) + if show_trade_markers and not example_trades.empty: + 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, + ) + + if selected_trade is not None: + 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]]) + st.plotly_chart(fig, use_container_width=True) bt = backtest_signals(analyzed) diff --git a/web/src/tests/test_analytics.py b/web/src/tests/test_analytics.py new file mode 100644 index 0000000..a796285 --- /dev/null +++ b/web/src/tests/test_analytics.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import pandas as pd + +from web_core.analytics import simulate_trend_trades +from web_core.constants import TREND_BEAR, TREND_BULL, TREND_NEUTRAL + + +def test_simulate_trend_trades_returns_empty_when_insufficient_rows() -> None: + idx = pd.date_range("2025-01-01", periods=3, freq="D") + df = pd.DataFrame( + { + "Close": [100.0, 101.0, 102.0], + "trend_state": [TREND_NEUTRAL, TREND_BULL, TREND_BULL], + }, + index=idx, + ) + + out = simulate_trend_trades(df) + + assert out.empty + + +def test_simulate_trend_trades_closes_trade_on_opposite_signal() -> None: + idx = pd.date_range("2025-01-01", periods=7, freq="D") + df = pd.DataFrame( + { + "Close": [95.0, 98.0, 100.0, 102.0, 104.0, 110.0, 108.0], + "trend_state": [ + TREND_NEUTRAL, + TREND_NEUTRAL, + TREND_BULL, + TREND_BULL, + TREND_BULL, + TREND_BEAR, + TREND_BEAR, + ], + }, + index=idx, + ) + + out = simulate_trend_trades(df) + + assert len(out) == 1 + trade = out.iloc[0] + assert trade["direction"] == "LONG" + assert trade["entry_timestamp"] == idx[2] + assert trade["exit_timestamp"] == idx[5] + assert trade["bars_held"] == 3 + assert trade["pnl_pct"] == 10.0 + assert trade["outcome"] == "Win" + + +def test_simulate_trend_trades_handles_multiple_flips_and_max_examples() -> None: + idx = pd.date_range("2025-01-01", periods=7, freq="D") + df = pd.DataFrame( + { + "Close": [90.0, 100.0, 103.0, 95.0, 92.0, 80.0, 82.0], + "trend_state": [ + TREND_NEUTRAL, + TREND_BULL, + TREND_BULL, + TREND_BEAR, + TREND_BEAR, + TREND_BULL, + TREND_BULL, + ], + }, + index=idx, + ) + + out = simulate_trend_trades(df, max_examples=1) + + assert len(out) == 1 + trade = out.iloc[0] + assert trade["direction"] == "SHORT" + assert trade["entry_timestamp"] == idx[3] + assert trade["exit_timestamp"] == idx[5] + assert trade["bars_held"] == 2 + assert trade["pnl_pct"] == 15.79 + assert trade["outcome"] == "Win" diff --git a/web/src/web_core/analytics.py b/web/src/web_core/analytics.py index d5e30a4..52b46e4 100644 --- a/web/src/web_core/analytics.py +++ b/web/src/web_core/analytics.py @@ -35,3 +35,95 @@ def backtest_signals(df: pd.DataFrame) -> dict[str, float | int]: 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)} + + +def simulate_trend_trades(df: pd.DataFrame, max_examples: int = 20) -> pd.DataFrame: + if len(df) < 4: + return pd.DataFrame( + columns=[ + "direction", + "entry_timestamp", + "exit_timestamp", + "entry_price", + "exit_price", + "bars_held", + "pnl_pct", + "outcome", + ] + ) + + trend_series = df["trend_state"] + trend_change = trend_series != trend_series.shift(1) + signal_idx = df.index[trend_change & trend_series.isin([TREND_BULL, TREND_BEAR])] + + direction_by_trend = { + TREND_BULL: "LONG", + TREND_BEAR: "SHORT", + } + + open_trade: dict[str, object] | None = None + closed_trades: list[dict[str, object]] = [] + + for idx in signal_idx: + pos = int(df.index.get_loc(idx)) + trend_now = str(df.iloc[pos]["trend_state"]) + direction = direction_by_trend[trend_now] + close_price = float(df.iloc[pos]["Close"]) + + if open_trade is None: + open_trade = { + "direction": direction, + "entry_timestamp": idx, + "entry_pos": pos, + "entry_price": close_price, + } + continue + + if open_trade["direction"] == direction: + continue + + entry_price = float(open_trade["entry_price"]) + if str(open_trade["direction"]) == "LONG": + pnl_pct = ((close_price - entry_price) / entry_price) * 100.0 + else: + pnl_pct = ((entry_price - close_price) / entry_price) * 100.0 + + closed_trades.append( + { + "direction": str(open_trade["direction"]), + "entry_timestamp": open_trade["entry_timestamp"], + "exit_timestamp": idx, + "entry_price": round(entry_price, 4), + "exit_price": round(close_price, 4), + "bars_held": pos - int(open_trade["entry_pos"]), + "pnl_pct": round(pnl_pct, 2), + "outcome": "Win" if pnl_pct > 0 else ("Loss" if pnl_pct < 0 else "Flat"), + } + ) + + # Flip into the new direction at the same confirmed reversal bar. + open_trade = { + "direction": direction, + "entry_timestamp": idx, + "entry_pos": pos, + "entry_price": close_price, + } + + if not closed_trades: + return pd.DataFrame( + columns=[ + "direction", + "entry_timestamp", + "exit_timestamp", + "entry_price", + "exit_price", + "bars_held", + "pnl_pct", + "outcome", + ] + ) + + trades_df = pd.DataFrame(closed_trades) + if max_examples > 0 and len(trades_df) > max_examples: + trades_df = trades_df.iloc[-max_examples:].copy() + return trades_df diff --git a/web/src/web_core/help.html b/web/src/web_core/help.html new file mode 100644 index 0000000..9a177f0 --- /dev/null +++ b/web/src/web_core/help.html @@ -0,0 +1,80 @@ +

ManeshTrader Help

+ +

What This Tool Does

+

+ The app classifies each closed candle as real_bull, real_bear, or + fake, then tracks trend state from only the real bars. +

+ + +

Quick Start

+
    +
  1. Choose Symbol (example: AAPL or BTC-USD).
  2. +
  3. Start with Timeframe = 1d and Period = 6mo.
  4. +
  5. Keep Ignore potentially live last bar ON.
  6. +
  7. Enable these in Training & Guidance: + +
  8. +
  9. Set Max training examples to 20.
  10. +
+ +

How To Use The New Training Features

+ +

1) Live Decision Guide (what to do now)

+ + +

2) Past Behavior Examples (what would have happened)

+

+ Examples are hypothetical and use one rule: + enter at trend-confirmation bar close, exit at opposite trend-confirmation bar close. +

+ + +

3) Click A Row To Highlight It On Chart

+
    +
  1. Click any row in the Past Behavior Examples table.
  2. +
  3. The chart highlights that exact entry-to-exit window.
  4. +
  5. A path line shows the selected trade from entry price to exit price.
  6. +
  7. A plain-English explanation appears under the table for the selected row.
  8. +
  9. If Focus chart on selected example is ON, the chart zooms into that trade.
  10. +
+ +

Marker Legend

+ + +

Important Notes

+