From 34e1fc7e3ae706685efa88ec0355dbc4471d0d29 Mon Sep 17 00:00:00 2001
From: JARVIS <jarvis@local>
Date: Mon, 29 Jun 2026 09:58:46 +0200
Subject: [PATCH] Add TradingView paper signal and research pipeline

---
 docs/global_strategy_policy.md                |  63 ++++
 docs/telegram_alert_setup.md                  |  31 ++
 docs/tradingview_alert_templates.md           | 127 +++++++
 .../tradingview_chatgpt55_integration_plan.md |  60 ++++
 docs/tradingview_live_readiness_test_plan.md  |  61 ++++
 paper_scorecard_report.py                     | 125 ++++++-
 src/signals/__init__.py                       |   0
 src/signals/tradingview.py                    | 282 +++++++++++++++
 src/strategies/global_strategy_policy.py      | 207 +++++++++++
 src/tools/analyze_trade_timing.py             | 337 ++++++++++++++++++
 src/tools/hyperliquid_daily_report.py         |  57 +++
 src/tools/tradingview_alert_proof.py          |  76 ++++
 src/tools/tradingview_community_ideas.py      | 262 ++++++++++++++
 .../tradingview_community_track_record.py     | 176 +++++++++
 src/tools/tradingview_operational_gates.py    |  74 ++++
 src/tools/tradingview_paper_bridge.py         | 324 +++++++++++++++++
 src/tools/tradingview_promotion_gates.py      |  91 +++++
 src/tools/tradingview_strategy_performance.py | 110 ++++++
 src/tools/tradingview_webhook.py              |  80 +++++
 src/tools/tradingview_webhook_server.py       | 130 +++++++
 src/tools/v76_paper_runtime.py                |  69 +++-
 telegram_alerts.py                            |  32 +-
 tests/test_global_strategy_policy.py          | 159 +++++++++
 ...st_hyperliquid_daily_report_tradingview.py | 112 ++++++
 tests/test_paper_scorecard_report.py          |  16 +
 tests/test_telegram_paper.py                  |  22 +-
 tests/test_trade_timing_analysis.py           |  38 ++
 tests/test_tradingview_alert_proof.py         |  46 +++
 tests/test_tradingview_community_ideas.py     |  72 ++++
 ...test_tradingview_community_track_record.py |  78 ++++
 tests/test_tradingview_operational_gates.py   |  50 +++
 tests/test_tradingview_paper_bridge.py        | 119 +++++++
 tests/test_tradingview_promotion_gates.py     |  44 +++
 tests/test_tradingview_signal_ingestion.py    | 152 ++++++++
 .../test_tradingview_strategy_performance.py  |  51 +++
 tests/test_tradingview_webhook_server.py      | 122 +++++++
 tests/test_v76_paper_runtime_and_replay.py    |  40 +++
 37 files changed, 3886 insertions(+), 9 deletions(-)
 create mode 100644 docs/global_strategy_policy.md
 create mode 100644 docs/telegram_alert_setup.md
 create mode 100644 docs/tradingview_alert_templates.md
 create mode 100644 docs/tradingview_chatgpt55_integration_plan.md
 create mode 100644 docs/tradingview_live_readiness_test_plan.md
 create mode 100644 src/signals/__init__.py
 create mode 100644 src/signals/tradingview.py
 create mode 100644 src/strategies/global_strategy_policy.py
 create mode 100644 src/tools/analyze_trade_timing.py
 create mode 100644 src/tools/tradingview_alert_proof.py
 create mode 100644 src/tools/tradingview_community_ideas.py
 create mode 100644 src/tools/tradingview_community_track_record.py
 create mode 100644 src/tools/tradingview_operational_gates.py
 create mode 100644 src/tools/tradingview_paper_bridge.py
 create mode 100644 src/tools/tradingview_promotion_gates.py
 create mode 100644 src/tools/tradingview_strategy_performance.py
 create mode 100644 src/tools/tradingview_webhook.py
 create mode 100644 src/tools/tradingview_webhook_server.py
 create mode 100644 tests/test_global_strategy_policy.py
 create mode 100644 tests/test_hyperliquid_daily_report_tradingview.py
 create mode 100644 tests/test_trade_timing_analysis.py
 create mode 100644 tests/test_tradingview_alert_proof.py
 create mode 100644 tests/test_tradingview_community_ideas.py
 create mode 100644 tests/test_tradingview_community_track_record.py
 create mode 100644 tests/test_tradingview_operational_gates.py
 create mode 100644 tests/test_tradingview_paper_bridge.py
 create mode 100644 tests/test_tradingview_promotion_gates.py
 create mode 100644 tests/test_tradingview_signal_ingestion.py
 create mode 100644 tests/test_tradingview_strategy_performance.py
 create mode 100644 tests/test_tradingview_webhook_server.py

diff --git a/docs/global_strategy_policy.md b/docs/global_strategy_policy.md
new file mode 100644
index 0000000..8ce8d09
--- /dev/null
+++ b/docs/global_strategy_policy.md
@@ -0,0 +1,63 @@
+# Global Strategy Policy
+
+This document summarizes the production-facing doctrine implemented in `src/strategies/global_strategy_policy.py`.
+
+## Purpose
+
+The global strategy policy is a side-effect-free gate that decides whether a strategy candidate may progress toward tiny live preview. It does not place orders. It combines learnings from:
+
+- negative Mainnet tiny-live fills,
+- paper-scorecard and simulator-artifact reviews,
+- fee-aware anti-chase work,
+- copy-observation runs,
+- v76 strict/base paper results.
+
+## Core posture
+
+Default is **no trade**. A candidate must pass all evidence, market, timing, cost, promotion, and safety gates.
+
+## Promotion requirements
+
+- At least 30 closed paper/shadow trades.
+- Total net PnL positive.
+- Last 20 closed trades positive.
+- Win rate >=45% or profit factor >=1.25.
+- Top coin share <=35%.
+- Short-scalp share <=25%.
+- Not replay-only.
+- Simulator sanity OK.
+
+## Entry requirements
+
+- Long only when BTC and ETH regime are bullish.
+- Short only when BTC and ETH regime are bearish.
+- Choppy market means no trade.
+- Expected move vs realistic roundtrip cost >=4.
+- Anti-chase check confirmed.
+- Retest/reclaim confirmed.
+
+## Copy policy
+
+Direct copy execution stays blocked until the observation track has complete and PnL-evaluable shadow evidence. With zero allowed copy signals, copy is research-only. Later, copy may become a confirmation layer for independent strategy setups.
+
+## Live safety requirements
+
+- Reconcile clean.
+- Stops confirmed.
+- Alerts confirmed.
+- Kill switch off only after deliberate review.
+- Max effective leverage 1x.
+- Max one initial open position.
+
+## Scorecard integration
+
+`paper_scorecard_report.py` now appends a `global_policy=...` line for each scored paper strategy. This prevents a strategy with attractive raw scorecard metrics from being presented as live-ready when the global policy still blocks it for missing anti-chase, cost, promotion, reconcile, stop, or alert evidence.
+
+The report reads optional `strategy_policy_context.json` from the runtime root for explicit safety context. Without that file, safety gates default closed.
+
+## Verification
+
+See:
+
+- `tests/test_global_strategy_policy.py`
+- `tests/test_paper_scorecard_report.py`
diff --git a/docs/telegram_alert_setup.md b/docs/telegram_alert_setup.md
new file mode 100644
index 0000000..3f7077d
--- /dev/null
+++ b/docs/telegram_alert_setup.md
@@ -0,0 +1,31 @@
+# Telegram Alert Setup
+
+CryptoTradingBot alert delivery reads Telegram credentials from environment variables or this secret file:
+
+```text
+~/.hermes/secrets/crypto_agent_telegram.env
+```
+
+Required content:
+
+```dotenv
+CTB_TELEGRAM_ALERTS=true
+CTB_TELEGRAM_BOT_TOKEN=<telegram-bot-token>
+CTB_TELEGRAM_CHAT_ID=<target-chat-id>
+CTB_TELEGRAM_THREAD_ID=6579
+CTB_TELEGRAM_TIMEOUT=10
+```
+
+Notes:
+
+- Do not commit this file.
+- The bot requires a forum topic thread ID; current command topic is `6579`.
+- Run smoke:
+
+```bash
+python3 -m src.tools.telegram_alert_smoke --json
+python3 -m src.tools.tradingview_alert_proof --runtime-dir runtime --json
+python3 -m src.tools.tradingview_operational_gates --runtime-dir runtime --json
+```
+
+Operational Gates can only reach 5/5 after a real, non-dry-run alert smoke returns `status=ok` and all planned alerts have `sent=true`.
diff --git a/docs/tradingview_alert_templates.md b/docs/tradingview_alert_templates.md
new file mode 100644
index 0000000..20da8c0
--- /dev/null
+++ b/docs/tradingview_alert_templates.md
@@ -0,0 +1,127 @@
+# TradingView Alert Templates for JARVIS/ChatGPT 5.5 HyperLiquid Stack
+
+These templates are for TradingView alerts that feed the CryptoTradingBot **paper-only signal journal**.
+They must not contain exchange API keys or wallet secrets.
+
+## Webhook Endpoint
+
+Local/Tailnet receiver path:
+
+```text
+POST /webhook/tradingview
+Header: X-CTB-Webhook-Secret: <secret from runtime secret file/env>
+Content-Type: application/json
+```
+
+## Required JSON Fields
+
+| Field | Example | Notes |
+|---|---|---|
+| `source` | `tradingview` | fixed |
+| `strategy` | `gaussian_channel_v1` | must be allowlisted in receiver |
+| `symbol` | `{{ticker}}` or `BTCUSDT.P` | normalized to HyperLiquid coin, e.g. `BTC` |
+| `side` | `long` / `short` | signal direction |
+| `action` | `entry` / `exit` | desired signal action |
+| `timeframe` | `{{interval}}` | chart timeframe |
+| `price` | `{{close}}` | validated positive decimal |
+| `timestamp` | `{{time}}` | TradingView time placeholder or ISO string |
+| `signal_id` | see examples | duplicate protection |
+| `reason` | free text | optional, sanitized |
+
+## Template: Entry Long
+
+```json
+{
+  "source": "tradingview",
+  "strategy": "gaussian_channel_v1",
+  "symbol": "{{ticker}}",
+  "side": "long",
+  "action": "entry",
+  "timeframe": "{{interval}}",
+  "price": "{{close}}",
+  "timestamp": "{{time}}",
+  "signal_id": "tv-{{ticker}}-{{interval}}-long-entry-{{time}}",
+  "reason": "indicator long entry"
+}
+```
+
+## Template: Exit Long
+
+```json
+{
+  "source": "tradingview",
+  "strategy": "gaussian_channel_v1",
+  "symbol": "{{ticker}}",
+  "side": "long",
+  "action": "exit",
+  "timeframe": "{{interval}}",
+  "price": "{{close}}",
+  "timestamp": "{{time}}",
+  "signal_id": "tv-{{ticker}}-{{interval}}-long-exit-{{time}}",
+  "reason": "indicator long exit"
+}
+```
+
+## Template: Entry Short
+
+```json
+{
+  "source": "tradingview",
+  "strategy": "gaussian_channel_v1",
+  "symbol": "{{ticker}}",
+  "side": "short",
+  "action": "entry",
+  "timeframe": "{{interval}}",
+  "price": "{{close}}",
+  "timestamp": "{{time}}",
+  "signal_id": "tv-{{ticker}}-{{interval}}-short-entry-{{time}}",
+  "reason": "indicator short entry"
+}
+```
+
+## Template: Exit Short
+
+```json
+{
+  "source": "tradingview",
+  "strategy": "gaussian_channel_v1",
+  "symbol": "{{ticker}}",
+  "side": "short",
+  "action": "exit",
+  "timeframe": "{{interval}}",
+  "price": "{{close}}",
+  "timestamp": "{{time}}",
+  "signal_id": "tv-{{ticker}}-{{interval}}-short-exit-{{time}}",
+  "reason": "indicator short exit"
+}
+```
+
+## Decision Flow
+
+TradingView is only the **impulse source**. A signal does not mean buy/sell.
+
+```text
+TradingView impulse
+  -> webhook auth + validation
+  -> signal journal
+  -> paper/shadow candidate
+  -> bot risk checks: coin, strategy, market regime, cost/edge, liquidity, spread, funding, stops, exposure, loss gates, kill-switch, reconcile
+  -> paper fill or blocked reason
+  -> daily report + ChatGPT 5.5 analyst summary
+```
+
+## Initial Allowed Strategy IDs
+
+Suggested first IDs:
+
+- `gaussian_channel_v1` — external/TradingView trend channel signal source.
+- `squeeze_breakout_1h` — internal-compatible breakout impulse.
+- `ema_vwap_reclaim_1h` — trend pullback/reclaim impulse.
+- `regime_filter_btc_4h` — context-only regime signal; should not create entries directly.
+
+## Safety Notes
+
+- Alerts are accepted into `runtime/signals/signal_journal.jsonl` only.
+- Rejected alerts are audited in `runtime/signals/rejected_signal_journal.jsonl`.
+- No TradingView alert may directly call HyperLiquid.
+- Promotion from paper to live requires separate explicit gates and approval.
diff --git a/docs/tradingview_chatgpt55_integration_plan.md b/docs/tradingview_chatgpt55_integration_plan.md
new file mode 100644
index 0000000..be576b4
--- /dev/null
+++ b/docs/tradingview_chatgpt55_integration_plan.md
@@ -0,0 +1,60 @@
+# TradingView + ChatGPT 5.5/JARVIS Integration Plan
+
+## Decision
+
+Do **not** make SIGNUM the core dependency. Use ChatGPT 5.5/JARVIS with the existing direct HyperLiquid stack. TradingView becomes an authenticated signal source that is journaled and evaluated through paper/shadow first.
+
+## Target Flow
+
+```text
+TradingView alert / internal strategy / copy-watch
+  -> authenticated webhook or local signal ingestion
+  -> normalized SignalEnvelope
+  -> signal_journal.jsonl
+  -> paper/shadow scoring
+  -> ChatGPT 5.5 analyst report
+  -> confirm/tiny-live only after hard gates are green
+  -> direct HyperLiquid executor
+```
+
+## Safety Rules
+
+- No TradingView alert may place a live order directly.
+- Webhook ingestion is side-effect-free: validate, normalize, persist, and return an acceptance/rejection payload.
+- Secrets are never stored in raw payloads or reports.
+- Default mode is `paper_signal`.
+- Live requires existing hard gates: clean reconcile, confirmed stops, alert delivery, loss gates, kill-switch off, and explicit live-autonomy approval.
+
+## Implementation Phases
+
+1. **Signal schema + journal**
+   - Define `TradingViewSignal` and `SignalEnvelope`.
+   - Normalize symbols such as `BTCUSDT`, `BTCUSD`, `BTC.P` to HyperLiquid coin `BTC`.
+   - Persist raw and normalized signal records under runtime only.
+
+2. **Webhook receiver**
+   - Accept JSON POST payloads.
+   - Require an `X-CTB-Webhook-Secret` header or a payload secret.
+   - Validate strategy allowlist, coin allowlist, action, side, timestamp, and duplicate IDs.
+   - Return accepted/rejected JSON with no trading side effects.
+
+3. **Paper/shadow integration**
+   - Convert accepted signals into paper/shadow candidates.
+   - Track gross/net PnL, fees/slippage assumptions, block reasons, coin leakage, and strategy ID.
+
+4. **ChatGPT 5.5 analyst job**
+   - Provide only summarized, sanitized signal/performance data.
+   - Ask for observation and recommendations, not direct execution.
+
+5. **Reports**
+   - Add TradingView section to the daily report: received, accepted, rejected, top reject reasons, best/worst strategy, and recommendation.
+
+6. **Confirm/tiny-live later**
+   - Promote only after 30-50+ closed paper/shadow trades and all safety gates are green.
+
+## Initial Acceptance Criteria
+
+- Tests prove valid alerts are accepted and invalid alerts are rejected.
+- A CLI/local handler can ingest a sample TradingView payload and append to `signal_journal.jsonl`.
+- The journal record explicitly shows `execution_mode=paper_signal` and `live_order_allowed=false`.
+- No HyperLiquid signed action is reachable from the webhook handler.
diff --git a/docs/tradingview_live_readiness_test_plan.md b/docs/tradingview_live_readiness_test_plan.md
new file mode 100644
index 0000000..2de0887
--- /dev/null
+++ b/docs/tradingview_live_readiness_test_plan.md
@@ -0,0 +1,61 @@
+# TradingView Strategy Live-Readiness Test Plan
+
+Purpose: move the TradingView -> JARVIS -> HyperLiquid path from paper-only toward live only after evidence and safety gates are green.
+
+## Current enforced status
+
+- TradingView can only create `paper_signal` events.
+- Paper Bridge can create simulated entries/exits and PnL.
+- Promotion Gates can only return `paper_only` or `shadow_candidate_not_live`.
+- `live_allowed` remains false until explicit live release work and user approval.
+
+## Fast promotion ladder
+
+1. **Paper signal plumbing**
+   - Verify TradingView webhook auth, allowlists, duplicate rejection, accepted/rejected journals.
+   - Required: zero unexpected `live_order_allowed=true` flags.
+
+2. **Paper execution quality**
+   - Run Paper Bridge on real TradingView alerts.
+   - Track entries, exits, fees, net PnL, blockers, strategy/coin performance.
+   - Required: enough closed trades, positive net PnL after fees/slippage, acceptable winrate.
+
+3. **Operational readiness**
+   - Reconcile clean.
+   - Alerts proven.
+   - Loss gates active.
+   - Kill-switch ready.
+   - Stop handling ready.
+   - Required: Operational Gates green 5/5.
+
+4. **Shadow mode**
+   - Same decisions as live, but no signed orders.
+   - Compare intended fills vs market prices and paper fills.
+   - Required: stable decisions, no stale/replayed signals, no exposure leakage.
+
+5. **Confirm mode**
+   - Bot prepares order candidate and asks user approval.
+   - Required: reduce-only stop attached, reconcile clean before/after, audit line written.
+
+6. **Tiny live**
+   - User-approved tiny notional only.
+   - Hard caps: max loss, max open trades, max notional, kill-switch, stop required.
+   - Immediate rollback to paper if any gate fails.
+
+## Current blockers from latest runtime
+
+- `alerts_proven_missing`: preflight evidence says Telegram alerts are not yet proven.
+- `sample_too_small`: no real closed TradingView paper sample yet.
+- `net_pnl_not_positive`: no positive paper evidence yet.
+- `winrate_below_threshold`: no closed sample yet.
+
+## Live decision rule
+
+No live release until:
+
+```text
+Operational Gates = 5/5
+Promotion Gates = shadow_candidate_not_live
+Confirm-mode smoke = passed
+User explicitly approves tiny-live
+```
diff --git a/paper_scorecard_report.py b/paper_scorecard_report.py
index 332958b..b1c477a 100644
--- a/paper_scorecard_report.py
+++ b/paper_scorecard_report.py
@@ -3,10 +3,20 @@ from __future__ import annotations
 import argparse
 import json
 from collections import Counter, defaultdict
+from decimal import Decimal
 from pathlib import Path
 
 from config import BotConfig, RuntimePaths
 from dashboard import _read_journal_rows, _score_strategy
+from src.execution.order_intent import OrderIntent
+from src.strategies.global_strategy_policy import (
+    CopyObservationEvidence,
+    GlobalStrategyContext,
+    MarketRegimeContext,
+    PromotionEvidence,
+    StrategyCandidate,
+    evaluate_global_strategy,
+)
 
 V76_RUNTIME_ROOT = Path("runtime/experiments")
 
@@ -112,6 +122,116 @@ def _read_jsonl(path: Path) -> list[dict]:
     return rows
 
 
+def _as_decimal(value: object, default: str = "0") -> Decimal:
+    try:
+        return Decimal(str(value))
+    except Exception:
+        return Decimal(default)
+
+
+def _read_policy_context(root: Path) -> GlobalStrategyContext:
+    path = root / "strategy_policy_context.json"
+    raw: dict[str, object] = {}
+    if path.exists():
+        try:
+            loaded = json.loads(path.read_text(encoding="utf-8"))
+            raw = loaded if isinstance(loaded, dict) else {}
+        except json.JSONDecodeError:
+            raw = {}
+    market_raw = raw.get("market_regime") if isinstance(raw.get("market_regime"), dict) else {}
+    market = MarketRegimeContext(
+        btc_trend=str(market_raw.get("btc_trend", "bullish")),
+        eth_trend=str(market_raw.get("eth_trend", "bullish")),
+        choppy=bool(market_raw.get("choppy", False)),
+    )
+    copy_raw = raw.get("copy_observation") if isinstance(raw.get("copy_observation"), dict) else {}
+    copy = CopyObservationEvidence(
+        cycles_completed=int(copy_raw.get("cycles_completed", 0) or 0),
+        expected_cycles=int(copy_raw.get("expected_cycles", 84) or 84),
+        allowed_signals=int(copy_raw.get("allowed_signals", 0) or 0),
+        shadow_decisions=int(copy_raw.get("shadow_decisions", 0) or 0),
+        data_quality_ok=bool(copy_raw.get("data_quality_ok", True)),
+    )
+    return GlobalStrategyContext(
+        market_regime=market,
+        copy_observation=copy,
+        reconcile_clean=bool(raw.get("reconcile_clean", False)),
+        stops_confirmed=bool(raw.get("stops_confirmed", False)),
+        alerts_confirmed=bool(raw.get("alerts_confirmed", False)),
+        kill_switch_active=bool(raw.get("kill_switch_active", False)),
+        live_entries_blocked=bool(raw.get("live_entries_blocked", True)),
+    )
+
+
+def _promotion_from_rows(journal_rows: tuple[dict, ...], sc) -> PromotionEvidence:
+    exits = [row for row in journal_rows if str(row.get("event") or row.get("event_type") or "").lower() == "exit"]
+    pnls = [_as_decimal(row.get("net_pnl_usd", row.get("realized_pnl_usd", "0"))) for row in exits]
+    last20 = sum(pnls[-20:], Decimal("0")) if pnls else Decimal("0")
+    return PromotionEvidence(
+        closed_trades=sc.sample_size,
+        total_net_pnl=Decimal(str(sc.total_pnl_usd)),
+        last_20_net_pnl=last20,
+        win_rate_pct=Decimal(str(sc.win_rate_pct)),
+        profit_factor=Decimal(str(sc.profit_factor)),
+        top_coin_share_pct=Decimal(str(sc.coin_leakage_pct)),
+        short_scalp_share_pct=Decimal("0"),
+        max_drawdown_usd=Decimal(str(abs(sc.max_drawdown_usd))),
+        replay_only=False,
+        simulator_sanity_ok=True,
+    )
+
+
+def _avg_expected_move_vs_cost(signal_rows: list[dict]) -> Decimal:
+    values = [_as_decimal(row.get("expected_move_vs_cost")) for row in signal_rows if row.get("expected_move_vs_cost") is not None]
+    if not values:
+        return Decimal("0")
+    return sum(values, Decimal("0")) / Decimal(len(values))
+
+
+def _candidate_intent(strategy_id: str) -> OrderIntent:
+    return OrderIntent(
+        strategy_id=strategy_id,
+        symbol="BTC/USDC:USDC",
+        coin="BTC",
+        side="buy",
+        reduce_only=False,
+        order_type="market",
+        tif="Ioc",
+        size=Decimal("0.001"),
+        price=None,
+        trigger_price=None,
+        stop_loss=Decimal("99000"),
+        take_profit=None,
+        client_order_id=f"scorecard-{strategy_id}",
+        reason="scorecard_policy_candidate",
+        risk_usd=Decimal("0.10"),
+        estimated_notional_usd=Decimal("10"),
+    )
+
+
+def _policy_line(strategy_id: str, root: Path, journal_rows: tuple[dict, ...], sc) -> str:
+    signals = _read_jsonl(root / "experiments" / strategy_id / "Tradeanalyse" / "signal_journal.jsonl")
+    avg_ratio = _avg_expected_move_vs_cost(signals)
+    final_decisions = {str(row.get("final_decision") or "") for row in signals}
+    strategy_family = "copy" if strategy_id.startswith("copy") else "v76_anti_chase"
+    candidate = StrategyCandidate(
+        strategy_id=strategy_id,
+        family=strategy_family,
+        intent=_candidate_intent(strategy_id),
+        promotion=_promotion_from_rows(journal_rows, sc),
+        expected_move_vs_cost=avg_ratio,
+        anti_chase_ok=avg_ratio >= Decimal("4") and (not signals or "blocked:anti_chase" not in final_decisions),
+        retest_confirmed=avg_ratio >= Decimal("4") and (not signals or "blocked:no_retest" not in final_decisions),
+        max_effective_leverage=Decimal("1"),
+        max_open_positions=1,
+    )
+    decision = evaluate_global_strategy(candidate, _read_policy_context(root))
+    status = decision.status.replace("tiny_live_preview_ready", "Tiny-live-preview-ready").replace("blocked", "Blocked").replace("research", "Research").replace("candidate", "Candidate")
+    reasons = "; ".join(decision.reasons) if decision.reasons else "all gates green"
+    return f"   global_policy={status}, recommended_mode={decision.recommended_mode}, reasons={reasons}"
+
+
+
 def _v76_learning_block() -> list[str]:
     variants = [
         ("strict", V76_RUNTIME_ROOT / "candidate_v76_strict_live_candidate"),
@@ -179,7 +299,7 @@ def build_paper_scorecard_report(*, runtime_dir: str | Path | None = None, max_b
         journal_rows = _read_journal_rows(exp_dir / "Tradeanalyse" / "trade_journal.jsonl")
         sc = _score_strategy(journal_rows=journal_rows, open_unrealized_pnl_usd=0.0, open_position_count=0, research_note="")
         ext = _net_scorecard_extensions(root, exp_dir.name)
-        rows.append((exp_dir.name, sc, ext))
+        rows.append((exp_dir.name, sc, ext, journal_rows))
     rows.sort(key=lambda item: (item[1].score, item[1].total_pnl_usd), reverse=True)
     if max_bots is not None:
         rows = rows[:max_bots]
@@ -188,7 +308,7 @@ def build_paper_scorecard_report(*, runtime_dir: str | Path | None = None, max_b
         lines.append("No paper strategy experiments found.")
         lines.extend(_v76_learning_block())
         return "\n".join(lines)
-    for idx, (strategy_id, sc, ext) in enumerate(rows, start=1):
+    for idx, (strategy_id, sc, ext, journal_rows) in enumerate(rows, start=1):
         lines.append(
             f"{idx}. {strategy_id}: status={sc.status}, score={sc.score}, "
             f"running=unknown-fast-mode, closed={sc.sample_size}, "
@@ -208,6 +328,7 @@ def build_paper_scorecard_report(*, runtime_dir: str | Path | None = None, max_b
             f"stops_missing_count={ext['stops_missing_count']}, open_exposure_mismatch={ext['open_exposure_mismatch']}, "
             f"pnl_by_coin={ext['pnl_by_coin']}, trade_share_by_coin={ext['trade_share_by_coin']}"
         )
+        lines.append(_policy_line(strategy_id, root, journal_rows, sc))
     lines.extend(_v76_learning_block())
     return "\n".join(lines)
 
diff --git a/src/signals/__init__.py b/src/signals/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/signals/tradingview.py b/src/signals/tradingview.py
new file mode 100644
index 0000000..3be8e97
--- /dev/null
+++ b/src/signals/tradingview.py
@@ -0,0 +1,282 @@
+from __future__ import annotations
+
+import hashlib
+import hmac
+import json
+import re
+from dataclasses import asdict, dataclass
+from datetime import datetime, timezone
+from decimal import Decimal, InvalidOperation
+from pathlib import Path
+from typing import Any, Mapping
+
+SECRET_KEYS = {"secret", "webhook_secret", "token", "api_key", "apikey", "password"}
+VALID_SIDES = {"long", "short"}
+VALID_ACTIONS = {"entry", "exit"}
+
+
+@dataclass(frozen=True)
+class TradingViewWebhookConfig:
+    runtime_dir: Path
+    secret: str
+    allowed_strategies: set[str]
+    allowed_coins: set[str]
+    journal_relative_path: str = "signals/signal_journal.jsonl"
+    rejected_journal_relative_path: str = "signals/rejected_signal_journal.jsonl"
+
+    def journal_path(self) -> Path:
+        return self.runtime_dir / self.journal_relative_path
+
+    def rejected_journal_path(self) -> Path:
+        return self.runtime_dir / self.rejected_journal_relative_path
+
+
+@dataclass(frozen=True)
+class TradingViewSignal:
+    source: str
+    signal_id: str
+    strategy_id: str
+    coin: str
+    side: str
+    action: str
+    timeframe: str
+    price: str | None
+    timestamp: str
+    execution_mode: str = "paper_signal"
+    live_order_allowed: bool = False
+    reason: str | None = None
+    received_at: str | None = None
+
+    def to_journal_row(self) -> dict[str, Any]:
+        row = asdict(self)
+        row["received_at"] = self.received_at or datetime.now(timezone.utc).isoformat()
+        return row
+
+
+class TradingViewSignalError(ValueError):
+    def __init__(self, reason: str):
+        super().__init__(reason)
+        self.reason = reason
+
+
+def _now() -> str:
+    return datetime.now(timezone.utc).isoformat()
+
+
+def _clean_str(value: Any) -> str:
+    return str(value or "").strip()
+
+
+def normalize_symbol(symbol: str) -> str:
+    raw = _clean_str(symbol).upper()
+    raw = raw.replace("PERP", "")
+    raw = raw.replace(".P", "")
+    raw = raw.replace("-PERP", "")
+    raw = raw.replace("/", "")
+    raw = raw.replace(":", "")
+    for suffix in ("USDT", "USDC", "USD"):
+        if raw.endswith(suffix):
+            raw = raw[: -len(suffix)]
+            break
+    raw = re.sub(r"[^A-Z0-9]", "", raw)
+    return raw
+
+
+def _validate_timestamp(value: str) -> str:
+    ts = _clean_str(value)
+    if not ts:
+        raise TradingViewSignalError("missing_timestamp")
+    if ts.endswith("Z"):
+        parse_value = ts[:-1] + "+00:00"
+    else:
+        parse_value = ts
+    try:
+        datetime.fromisoformat(parse_value)
+    except ValueError as exc:
+        raise TradingViewSignalError("invalid_timestamp") from exc
+    return ts
+
+
+def _validate_price(value: Any) -> str | None:
+    if value is None or _clean_str(value) == "":
+        return None
+    try:
+        price = Decimal(str(value))
+    except InvalidOperation as exc:
+        raise TradingViewSignalError("invalid_price") from exc
+    if price <= 0:
+        raise TradingViewSignalError("invalid_price")
+    return str(value)
+
+
+def _make_signal_id(payload: Mapping[str, Any], coin: str, strategy: str, side: str, action: str, timestamp: str) -> str:
+    explicit = _clean_str(payload.get("signal_id") or payload.get("id"))
+    if explicit:
+        return explicit
+    base = f"tradingview|{strategy}|{coin}|{side}|{action}|{timestamp}|{_clean_str(payload.get('timeframe'))}"
+    digest = hashlib.sha256(base.encode("utf-8")).hexdigest()[:16]
+    return f"tv-{coin.lower()}-{digest}"
+
+
+def normalize_tradingview_payload(
+    payload: Mapping[str, Any],
+    *,
+    allowed_strategies: set[str],
+    allowed_coins: set[str],
+) -> TradingViewSignal:
+    source = _clean_str(payload.get("source") or "tradingview").lower()
+    if source != "tradingview":
+        raise TradingViewSignalError("invalid_source")
+
+    strategy = _clean_str(payload.get("strategy") or payload.get("strategy_id"))
+    if not strategy:
+        raise TradingViewSignalError("missing_strategy")
+    if strategy not in allowed_strategies:
+        raise TradingViewSignalError("strategy_not_allowed")
+
+    coin = normalize_symbol(_clean_str(payload.get("symbol") or payload.get("coin")))
+    if not coin:
+        raise TradingViewSignalError("missing_symbol")
+    if coin not in allowed_coins:
+        raise TradingViewSignalError("coin_not_allowed")
+
+    side = _clean_str(payload.get("side")).lower()
+    if side not in VALID_SIDES:
+        raise TradingViewSignalError("invalid_side")
+
+    action = _clean_str(payload.get("action") or payload.get("signal")).lower()
+    if action not in VALID_ACTIONS:
+        raise TradingViewSignalError("invalid_action")
+
+    timeframe = _clean_str(payload.get("timeframe"))
+    if not timeframe:
+        raise TradingViewSignalError("missing_timeframe")
+
+    timestamp = _validate_timestamp(_clean_str(payload.get("timestamp") or payload.get("time")))
+    price = _validate_price(payload.get("price"))
+    signal_id = _make_signal_id(payload, coin, strategy, side, action, timestamp)
+    reason = _clean_str(payload.get("reason")) or None
+
+    return TradingViewSignal(
+        source="tradingview",
+        signal_id=signal_id,
+        strategy_id=strategy,
+        coin=coin,
+        side=side,
+        action=action,
+        timeframe=timeframe,
+        price=price,
+        timestamp=timestamp,
+        execution_mode="paper_signal",
+        live_order_allowed=False,
+        reason=reason,
+        received_at=_now(),
+    )
+
+
+def _sanitize_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
+    sanitized: dict[str, Any] = {}
+    for key, value in payload.items():
+        lowered = str(key).lower()
+        if lowered in SECRET_KEYS or "secret" in lowered or "token" in lowered or "password" in lowered:
+            continue
+        sanitized[str(key)] = value
+    return sanitized
+
+
+def _authorized(payload: Mapping[str, Any], headers: Mapping[str, str], expected_secret: str) -> bool:
+    header_secret = headers.get("X-CTB-Webhook-Secret") or headers.get("x-ctb-webhook-secret")
+    payload_secret = payload.get("secret") or payload.get("webhook_secret")
+    supplied = _clean_str(header_secret or payload_secret)
+    return bool(supplied) and hmac.compare_digest(supplied, expected_secret)
+
+
+def _read_existing_signal_ids(journal_path: Path) -> set[str]:
+    if not journal_path.exists():
+        return set()
+    ids: set[str] = set()
+    for line in journal_path.read_text(encoding="utf-8", errors="replace").splitlines():
+        if not line.strip():
+            continue
+        try:
+            row = json.loads(line)
+        except json.JSONDecodeError:
+            continue
+        signal_id = _clean_str(row.get("signal_id"))
+        if signal_id:
+            ids.add(signal_id)
+    return ids
+
+
+def append_signal_journal(signal: TradingViewSignal, config: TradingViewWebhookConfig, raw_payload: Mapping[str, Any]) -> Path:
+    journal = config.journal_path()
+    journal.parent.mkdir(parents=True, exist_ok=True)
+    row = signal.to_journal_row()
+    row["raw_payload_sanitized"] = _sanitize_payload(raw_payload)
+    row["mainnet_signed_action"] = False
+    row["order_intent_created"] = False
+    with journal.open("a", encoding="utf-8") as fh:
+        fh.write(json.dumps(row, sort_keys=True, ensure_ascii=False) + "\n")
+    return journal
+
+
+def append_rejected_signal_journal(
+    *,
+    reason: str,
+    config: TradingViewWebhookConfig,
+    raw_payload: Mapping[str, Any],
+) -> Path:
+    journal = config.rejected_journal_path()
+    journal.parent.mkdir(parents=True, exist_ok=True)
+    row = {
+        "timestamp": _now(),
+        "source": "tradingview",
+        "accepted": False,
+        "reason": reason,
+        "live_order_allowed": False,
+        "mainnet_signed_action": False,
+        "order_intent_created": False,
+        "raw_payload_sanitized": _sanitize_payload(raw_payload),
+    }
+    with journal.open("a", encoding="utf-8") as fh:
+        fh.write(json.dumps(row, sort_keys=True, ensure_ascii=False) + "\n")
+    return journal
+
+
+def handle_tradingview_payload(
+    payload: Mapping[str, Any],
+    *,
+    headers: Mapping[str, str],
+    config: TradingViewWebhookConfig,
+) -> dict[str, Any]:
+    base_reject = {"accepted": False, "live_order_allowed": False, "execution_mode": "paper_signal"}
+    if not _authorized(payload, headers, config.secret):
+        return {**base_reject, "reason": "unauthorized"}
+
+    try:
+        signal = normalize_tradingview_payload(
+            payload,
+            allowed_strategies=config.allowed_strategies,
+            allowed_coins=config.allowed_coins,
+        )
+    except TradingViewSignalError as exc:
+        append_rejected_signal_journal(reason=exc.reason, config=config, raw_payload=payload)
+        return {**base_reject, "reason": exc.reason}
+
+    if signal.signal_id in _read_existing_signal_ids(config.journal_path()):
+        append_rejected_signal_journal(reason="duplicate_signal", config=config, raw_payload=payload)
+        return {**base_reject, "reason": "duplicate_signal", "signal_id": signal.signal_id}
+
+    journal = append_signal_journal(signal, config, payload)
+    return {
+        "accepted": True,
+        "reason": "accepted_for_paper_signal_journal",
+        "signal_id": signal.signal_id,
+        "coin": signal.coin,
+        "strategy_id": signal.strategy_id,
+        "side": signal.side,
+        "action": signal.action,
+        "execution_mode": signal.execution_mode,
+        "live_order_allowed": False,
+        "journal_path": str(journal),
+    }
diff --git a/src/strategies/global_strategy_policy.py b/src/strategies/global_strategy_policy.py
new file mode 100644
index 0000000..11884cf
--- /dev/null
+++ b/src/strategies/global_strategy_policy.py
@@ -0,0 +1,207 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from decimal import Decimal
+from typing import Any, Literal
+
+from src.execution.order_intent import OrderIntent
+
+StrategyFamily = Literal[
+    "v76_anti_chase",
+    "market_regime",
+    "copy",
+    "copy_confirmation",
+    "research_sampler",
+]
+
+
+@dataclass(frozen=True)
+class MarketRegimeContext:
+    btc_trend: str
+    eth_trend: str
+    choppy: bool = False
+
+    @property
+    def long_allowed(self) -> bool:
+        return not self.choppy and self.btc_trend == "bullish" and self.eth_trend == "bullish"
+
+    @property
+    def short_allowed(self) -> bool:
+        return not self.choppy and self.btc_trend == "bearish" and self.eth_trend == "bearish"
+
+
+@dataclass(frozen=True)
+class CopyObservationEvidence:
+    cycles_completed: int
+    expected_cycles: int
+    allowed_signals: int
+    shadow_decisions: int
+    data_quality_ok: bool
+
+    @property
+    def complete(self) -> bool:
+        return self.expected_cycles > 0 and self.cycles_completed >= self.expected_cycles
+
+
+@dataclass(frozen=True)
+class PromotionEvidence:
+    closed_trades: int
+    total_net_pnl: Decimal
+    last_20_net_pnl: Decimal
+    win_rate_pct: Decimal
+    profit_factor: Decimal
+    top_coin_share_pct: Decimal
+    short_scalp_share_pct: Decimal
+    max_drawdown_usd: Decimal
+    replay_only: bool = False
+    simulator_sanity_ok: bool = True
+
+
+@dataclass(frozen=True)
+class StrategyCandidate:
+    strategy_id: str
+    family: StrategyFamily
+    intent: OrderIntent | None
+    promotion: PromotionEvidence
+    expected_move_vs_cost: Decimal = Decimal("0")
+    anti_chase_ok: bool = False
+    retest_confirmed: bool = False
+    copy_confirmation_ok: bool = False
+    max_effective_leverage: Decimal = Decimal("1")
+    max_open_positions: int = 1
+
+
+@dataclass(frozen=True)
+class GlobalStrategyContext:
+    market_regime: MarketRegimeContext
+    copy_observation: CopyObservationEvidence
+    reconcile_clean: bool
+    stops_confirmed: bool
+    alerts_confirmed: bool
+    kill_switch_active: bool
+    live_entries_blocked: bool = True
+
+
+@dataclass(frozen=True)
+class GlobalStrategyDecision:
+    allowed: bool
+    status: str
+    recommended_mode: str
+    reasons: tuple[str, ...] = ()
+    risk_limits: dict[str, Any] = field(default_factory=dict)
+
+
+def _promotion_blockers(evidence: PromotionEvidence) -> list[str]:
+    reasons: list[str] = []
+    if evidence.closed_trades < 30:
+        reasons.append("sample_too_small")
+    if evidence.total_net_pnl <= 0:
+        reasons.append("paper_total_pnl_not_positive")
+    if evidence.last_20_net_pnl <= 0:
+        reasons.append("last_20_not_positive")
+    if evidence.win_rate_pct < Decimal("45") and evidence.profit_factor < Decimal("1.25"):
+        reasons.append("winrate_and_profit_factor_below_gate")
+    if evidence.top_coin_share_pct > Decimal("35"):
+        reasons.append("coin_concentration")
+    if evidence.short_scalp_share_pct > Decimal("25"):
+        reasons.append("short_scalp_dominance")
+    if evidence.replay_only:
+        reasons.append("replay_only_not_promotable")
+    if not evidence.simulator_sanity_ok:
+        reasons.append("simulator_sanity_failed")
+    return reasons
+
+
+def evaluate_global_strategy(candidate: StrategyCandidate, context: GlobalStrategyContext) -> GlobalStrategyDecision:
+    """Evaluate whether a strategy may progress from research/paper to tiny live preview.
+
+    This is intentionally conservative. It combines the hard lessons from live fills,
+    paper artefact reviews, copy observation, and v76 paper work into one no-trade-first
+    gate. It never executes orders; it only returns a decision and reasons.
+    """
+
+    reasons: list[str] = []
+    intent = candidate.intent
+
+    if intent is None:
+        reasons.append("no_order_intent")
+    else:
+        if context.market_regime.choppy:
+            reasons.append("market_regime_choppy_no_trade")
+        elif intent.side == "buy" and not context.market_regime.long_allowed:
+            reasons.append("market_regime_blocks_long")
+        elif intent.side == "sell" and not context.market_regime.short_allowed:
+            reasons.append("market_regime_blocks_short")
+
+    if candidate.family == "copy":
+        # Direct copy execution remains blocked until observation produces enough
+        # actual allowed, PnL-evaluable shadow entries. Current no-allowed runs
+        # are useful research, not live permission.
+        if context.copy_observation.allowed_signals <= 0:
+            reasons.append("copy_observation_has_no_allowed_signals")
+        if not context.copy_observation.complete:
+            reasons.append("copy_observation_incomplete")
+        if not context.copy_observation.data_quality_ok:
+            reasons.append("copy_data_quality_not_ok")
+    elif candidate.family == "copy_confirmation":
+        if not candidate.copy_confirmation_ok:
+            reasons.append("copy_confirmation_missing")
+    else:
+        if candidate.expected_move_vs_cost < Decimal("4"):
+            reasons.append("expected_move_below_cost_hurdle")
+        if not candidate.anti_chase_ok:
+            reasons.append("anti_chase_not_confirmed")
+        if not candidate.retest_confirmed:
+            reasons.append("retest_not_confirmed")
+
+    reasons.extend(_promotion_blockers(candidate.promotion))
+
+    if not context.reconcile_clean:
+        reasons.append("reconcile_not_clean")
+    if not context.stops_confirmed:
+        reasons.append("stops_not_confirmed")
+    if not context.alerts_confirmed:
+        reasons.append("alerts_not_confirmed")
+    if context.kill_switch_active:
+        reasons.append("kill_switch_active")
+    if candidate.max_effective_leverage > Decimal("1"):
+        reasons.append("leverage_above_tiny_live_cap")
+    if candidate.max_open_positions > 1:
+        reasons.append("too_many_initial_open_positions")
+
+    allowed = not reasons
+    if allowed:
+        return GlobalStrategyDecision(
+            allowed=True,
+            status="tiny_live_preview_ready",
+            recommended_mode="tiny_live_preview",
+            risk_limits={
+                "max_effective_leverage": str(candidate.max_effective_leverage),
+                "max_open_positions": candidate.max_open_positions,
+                "daily_loss_cap_required": True,
+                "reduce_only_stop_required_after_fill": True,
+                "no_averaging_down": True,
+            },
+        )
+
+    if candidate.family == "copy":
+        mode = "copy_research_only"
+        status = "research"
+    elif any(r in reasons for r in ("reconcile_not_clean", "stops_not_confirmed", "alerts_not_confirmed", "kill_switch_active")):
+        mode = "paper_shadow_only"
+        status = "blocked"
+    else:
+        mode = "paper_shadow_only"
+        status = "candidate" if not _promotion_blockers(candidate.promotion) else "research"
+
+    return GlobalStrategyDecision(
+        allowed=False,
+        status=status,
+        recommended_mode=mode,
+        reasons=tuple(dict.fromkeys(reasons)),
+        risk_limits={
+            "max_effective_leverage": "1",
+            "max_open_positions": 1,
+            "live_entries_allowed": False,
+        },
+    )
diff --git a/src/tools/analyze_trade_timing.py b/src/tools/analyze_trade_timing.py
new file mode 100644
index 0000000..242c2a8
--- /dev/null
+++ b/src/tools/analyze_trade_timing.py
@@ -0,0 +1,337 @@
+from __future__ import annotations
+
+import argparse
+import json
+from collections import Counter, defaultdict
+from dataclasses import dataclass
+from datetime import datetime, timezone, timedelta
+from decimal import Decimal
+from pathlib import Path
+from statistics import mean
+from typing import Any
+
+from src.hyperliquid.market_data import HyperliquidMarketData
+
+
+DEFAULT_STRATEGY_ID = "candidate_v76_fee_aware_anti_chase"
+DEFAULT_RUNTIME_ROOT = Path("runtime/experiments")
+
+
+@dataclass(frozen=True)
+class Candle:
+    ts: datetime
+    open: Decimal
+    high: Decimal
+    low: Decimal
+    close: Decimal
+    volume: Decimal
+
+
+def _parse_ts(value: str) -> datetime:
+    return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
+
+
+def _d(value: Any, default: str = "0") -> Decimal:
+    try:
+        return Decimal(str(value))
+    except Exception:
+        return Decimal(default)
+
+
+def _pct(new: Decimal, old: Decimal) -> Decimal:
+    if old == 0:
+        return Decimal("0")
+    return (new / old - Decimal("1")) * Decimal("100")
+
+
+def _parse_candle(row: dict[str, Any]) -> Candle:
+    # Hyperliquid candleSnapshot rows commonly use t/o/h/l/c/v.
+    ts_raw = row.get("t") or row.get("T") or row.get("time") or row.get("timestamp")
+    if isinstance(ts_raw, str) and ts_raw.isdigit():
+        ts_raw = int(ts_raw)
+    if isinstance(ts_raw, (int, float)):
+        ts = datetime.fromtimestamp(float(ts_raw) / 1000, tz=timezone.utc)
+    else:
+        ts = _parse_ts(str(ts_raw))
+    return Candle(
+        ts=ts,
+        open=_d(row.get("o") or row.get("open")),
+        high=_d(row.get("h") or row.get("high")),
+        low=_d(row.get("l") or row.get("low")),
+        close=_d(row.get("c") or row.get("close")),
+        volume=_d(row.get("v") or row.get("volume")),
+    )
+
+
+def _read_jsonl(path: Path) -> list[dict[str, Any]]:
+    if not path.exists():
+        return []
+    rows: list[dict[str, Any]] = []
+    for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
+        if not line.strip():
+            continue
+        try:
+            rows.append(json.loads(line))
+        except json.JSONDecodeError:
+            continue
+    return rows
+
+
+def rsi(closes: list[Decimal], period: int = 14) -> Decimal | None:
+    if len(closes) <= period:
+        return None
+    gains: list[Decimal] = []
+    losses: list[Decimal] = []
+    for prev, cur in zip(closes[-period - 1 : -1], closes[-period:]):
+        change = cur - prev
+        if change >= 0:
+            gains.append(change)
+            losses.append(Decimal("0"))
+        else:
+            gains.append(Decimal("0"))
+            losses.append(abs(change))
+    avg_gain = sum(gains, Decimal("0")) / Decimal(period)
+    avg_loss = sum(losses, Decimal("0")) / Decimal(period)
+    if avg_loss == 0:
+        return Decimal("100")
+    rs = avg_gain / avg_loss
+    return Decimal("100") - (Decimal("100") / (Decimal("1") + rs))
+
+
+def sma(values: list[Decimal], period: int) -> Decimal | None:
+    if len(values) < period:
+        return None
+    return sum(values[-period:], Decimal("0")) / Decimal(period)
+
+
+def candle_pattern(pre: list[Candle], event_price: Decimal) -> str:
+    if len(pre) < 3:
+        return "insufficient_candles"
+    last = pre[-1]
+    body = abs(last.close - last.open)
+    rng = max(last.high - last.low, Decimal("0.00000001"))
+    upper_wick = last.high - max(last.open, last.close)
+    lower_wick = min(last.open, last.close) - last.low
+    if upper_wick / rng > Decimal("0.55") and last.close < last.open:
+        return "bearish_rejection_wick"
+    if lower_wick / rng > Decimal("0.55") and last.close > last.open:
+        return "bullish_reclaim_wick"
+    if body / rng < Decimal("0.20"):
+        return "indecision_doji_like"
+    if event_price >= max(c.high for c in pre[-6:]):
+        return "local_high_breakout_or_chase"
+    if event_price <= min(c.low for c in pre[-6:]):
+        return "local_low_breakdown"
+    return "normal_candle"
+
+
+def assess_entry(entry_price: Decimal, pre: list[Candle], post: list[Candle]) -> dict[str, Any]:
+    closes_pre = [c.close for c in pre]
+    closes_post = [c.close for c in post]
+    highs_post = [c.high for c in post]
+    lows_post = [c.low for c in post]
+    pre_move = _pct(pre[-1].close, pre[0].close) if len(pre) >= 2 else Decimal("0")
+    post_move = _pct(closes_post[-1], entry_price) if closes_post else Decimal("0")
+    mfe = _pct(max(highs_post), entry_price) if highs_post else Decimal("0")
+    mae = _pct(min(lows_post), entry_price) if lows_post else Decimal("0")
+    rsi_pre = rsi(closes_pre)
+    sma_fast = sma(closes_pre, 5)
+    sma_slow = sma(closes_pre, 20)
+    pattern = candle_pattern(pre, entry_price)
+    flags: list[str] = []
+    near_recent_high = pre and entry_price >= max(c.high for c in pre[-6:]) * Decimal("0.995")
+    if pre_move > Decimal("1.0") and (near_recent_high or pattern in {"local_high_breakout_or_chase", "bearish_rejection_wick"}):
+        flags.append("entry_likely_chased_after_pre_move")
+    if rsi_pre is not None and rsi_pre > Decimal("70"):
+        flags.append("entry_overbought_rsi")
+    if mae < Decimal("-0.60") and abs(mae) > mfe:
+        flags.append("entry_poor_immediate_adverse_excursion")
+    if mfe > Decimal("0.80") and mae > Decimal("-0.40"):
+        flags.append("entry_good_follow_through")
+    if sma_fast is not None and sma_slow is not None and sma_fast < sma_slow:
+        flags.append("entry_against_short_trend")
+    return {
+        "pre_move_pct": str(round(pre_move, 4)),
+        "post_move_pct": str(round(post_move, 4)),
+        "mfe_pct": str(round(mfe, 4)),
+        "mae_pct": str(round(mae, 4)),
+        "rsi_pre": str(round(rsi_pre, 2)) if rsi_pre is not None else None,
+        "sma_fast_gt_slow": (sma_fast is not None and sma_slow is not None and sma_fast > sma_slow),
+        "pattern": pattern,
+        "quality": "good" if "entry_good_follow_through" in flags and not any(f.startswith("entry_poor") or f.endswith("chased_after_pre_move") for f in flags) else ("bad" if flags else "neutral"),
+        "flags": flags,
+    }
+
+
+def assess_exit(exit_price: Decimal, pre: list[Candle], post: list[Candle], exit_reason: str) -> dict[str, Any]:
+    closes_pre = [c.close for c in pre]
+    closes_post = [c.close for c in post]
+    highs_post = [c.high for c in post]
+    lows_post = [c.low for c in post]
+    pre_move = _pct(pre[-1].close, pre[0].close) if len(pre) >= 2 else Decimal("0")
+    post_move = _pct(closes_post[-1], exit_price) if closes_post else Decimal("0")
+    missed_upside = _pct(max(highs_post), exit_price) if highs_post else Decimal("0")
+    avoided_drawdown = _pct(min(lows_post), exit_price) if lows_post else Decimal("0")
+    rsi_pre = rsi(closes_pre)
+    pattern = candle_pattern(pre, exit_price)
+    flags: list[str] = []
+    if exit_reason == "profit_target" and missed_upside > Decimal("0.80"):
+        flags.append("exit_too_early_missed_followthrough")
+    if exit_reason == "profit_target" and avoided_drawdown < Decimal("-0.60"):
+        flags.append("exit_good_profit_protection")
+    if exit_reason == "stop_loss" and post_move < Decimal("-0.40"):
+        flags.append("exit_good_stop_avoided_more_downside")
+    if exit_reason == "stop_loss" and missed_upside > Decimal("0.80"):
+        flags.append("exit_possible_stop_too_tight_or_bad_entry")
+    if rsi_pre is not None and rsi_pre > Decimal("70") and exit_reason == "profit_target":
+        flags.append("exit_into_overbought_strength")
+    return {
+        "pre_move_pct": str(round(pre_move, 4)),
+        "post_move_pct": str(round(post_move, 4)),
+        "missed_upside_pct": str(round(missed_upside, 4)),
+        "avoided_drawdown_pct": str(round(avoided_drawdown, 4)),
+        "rsi_pre": str(round(rsi_pre, 2)) if rsi_pre is not None else None,
+        "pattern": pattern,
+        "quality": "good" if any(f.startswith("exit_good") or f == "exit_into_overbought_strength" for f in flags) and not any("too_early" in f or "too_tight" in f for f in flags) else ("bad" if flags else "neutral"),
+        "flags": flags,
+    }
+
+
+def _window(candles: list[Candle], ts: datetime, before: timedelta, after: timedelta) -> tuple[list[Candle], list[Candle]]:
+    pre = [c for c in candles if ts - before <= c.ts <= ts]
+    post = [c for c in candles if ts < c.ts <= ts + after]
+    return pre, post
+
+
+def _fetch_candles(md: HyperliquidMarketData, coin: str, start: datetime, end: datetime, interval: str) -> list[Candle]:
+    raw = md.get_candles(coin, interval, int(start.timestamp() * 1000), int(end.timestamp() * 1000))
+    return sorted((_parse_candle(row) for row in raw), key=lambda c: c.ts)
+
+
+def analyze_trade_timing(
+    *,
+    strategy_id: str = DEFAULT_STRATEGY_ID,
+    runtime_root: Path = DEFAULT_RUNTIME_ROOT,
+    interval: str = "15m",
+    before_hours: int = 4,
+    after_hours: int = 4,
+    env: str = "mainnet",
+) -> dict[str, Any]:
+    runtime_dir = runtime_root / strategy_id
+    trades = _read_jsonl(runtime_dir / "trade_journal.jsonl")
+    events = [r for r in trades if r.get("event") in {"entry", "exit"}]
+    if not events:
+        return {"strategy_id": strategy_id, "events_analyzed": 0, "entries": [], "exits": [], "learnings": ["no_trade_events_available"]}
+    md = HyperliquidMarketData(env=env)
+    by_coin: dict[str, list[dict[str, Any]]] = defaultdict(list)
+    for row in events:
+        by_coin[str(row.get("coin") or "").upper()].append(row)
+    before = timedelta(hours=before_hours)
+    after = timedelta(hours=after_hours)
+    entries: list[dict[str, Any]] = []
+    exits: list[dict[str, Any]] = []
+    fetch_errors: dict[str, str] = {}
+    for coin, rows in by_coin.items():
+        timestamps = [_parse_ts(str(r["timestamp"])) for r in rows if r.get("timestamp")]
+        if not timestamps:
+            continue
+        try:
+            candles = _fetch_candles(md, coin, min(timestamps) - before - timedelta(hours=1), max(timestamps) + after + timedelta(hours=1), interval)
+        except Exception as exc:
+            fetch_errors[coin] = f"{type(exc).__name__}: {str(exc)[:160]}"
+            continue
+        for row in rows:
+            ts = _parse_ts(str(row["timestamp"]))
+            pre, post = _window(candles, ts, before, after)
+            if row.get("event") == "entry":
+                entry_price = _d(row.get("entry_price"))
+                entries.append({"timestamp": ts.isoformat(), "coin": coin, "entry_price": str(entry_price), **assess_entry(entry_price, pre, post)})
+            else:
+                exit_price = _d(row.get("exit_price"))
+                exits.append({"timestamp": ts.isoformat(), "coin": coin, "exit_price": str(exit_price), "exit_reason": str(row.get("exit_reason") or "unknown"), "net_pnl_usd": str(row.get("net_pnl_usd") or row.get("realized_pnl_usd") or "0"), **assess_exit(exit_price, pre, post, str(row.get("exit_reason") or "unknown"))})
+    flags = Counter(flag for e in entries + exits for flag in e.get("flags", []))
+    qualities = Counter([e.get("quality", "unknown") for e in entries + exits])
+    learnings: list[str] = []
+    if flags.get("entry_likely_chased_after_pre_move", 0):
+        learnings.append("tighten_anti_chase: require pullback/reclaim after strong pre-entry move")
+    if flags.get("entry_poor_immediate_adverse_excursion", 0):
+        learnings.append("improve_entry_timing: wait for confirmation candle or reduce stop distance risk")
+    if flags.get("exit_possible_stop_too_tight_or_bad_entry", 0):
+        learnings.append("separate_stop_problem_from_entry_problem: check whether stop was tight or entry was late")
+    if flags.get("exit_too_early_missed_followthrough", 0):
+        learnings.append("test_trailing_exit: partial take-profit plus trailing stop may retain upside")
+    if flags.get("exit_good_profit_protection", 0):
+        learnings.append("keep_profit_target_as_protection_when_post_exit_drawdown_is_common")
+    if not learnings:
+        learnings.append("sample_too_small_or_neutral: continue paper until >=30 closed trades")
+    return {
+        "strategy_id": strategy_id,
+        "interval": interval,
+        "window": {"before_hours": before_hours, "after_hours": after_hours},
+        "events_analyzed": len(entries) + len(exits),
+        "entry_count": len(entries),
+        "exit_count": len(exits),
+        "quality_counts": dict(qualities),
+        "flag_counts": dict(flags.most_common()),
+        "fetch_errors": fetch_errors,
+        "entries": entries,
+        "exits": exits,
+        "learnings": learnings,
+        "mainnet_signed_action": False,
+        "paper_trading_analysis_only": True,
+    }
+
+
+def render_markdown(payload: dict[str, Any]) -> str:
+    lines = [
+        "# Trade Timing Analysis",
+        "",
+        f"Strategy: `{payload['strategy_id']}`",
+        f"Mode: read-only chart/timing analysis, interval `{payload['interval']}`, window ±{payload['window']['before_hours']}h/{payload['window']['after_hours']}h",
+        "",
+        "## Summary",
+        f"- Events analyzed: {payload['events_analyzed']} ({payload['entry_count']} entries, {payload['exit_count']} exits)",
+        f"- Quality counts: {payload['quality_counts']}",
+        f"- Flag counts: {payload['flag_counts']}",
+        "",
+        "## Learnings",
+    ]
+    for learning in payload["learnings"]:
+        lines.append(f"- {learning}")
+    if payload.get("fetch_errors"):
+        lines += ["", "## Fetch errors"]
+        for coin, err in payload["fetch_errors"].items():
+            lines.append(f"- {coin}: {err}")
+    lines += ["", "## Entries"]
+    for e in payload["entries"][-20:]:
+        lines.append(f"- {e['timestamp']} {e['coin']} entry={e['entry_price']} quality={e['quality']} pre={e['pre_move_pct']}% post={e['post_move_pct']}% MFE={e['mfe_pct']}% MAE={e['mae_pct']}% RSI={e['rsi_pre']} pattern={e['pattern']} flags={e['flags']}")
+    lines += ["", "## Exits"]
+    for e in payload["exits"][-20:]:
+        lines.append(f"- {e['timestamp']} {e['coin']} {e['exit_reason']} exit={e['exit_price']} net={e['net_pnl_usd']} quality={e['quality']} pre={e['pre_move_pct']}% post={e['post_move_pct']}% missed_upside={e['missed_upside_pct']}% avoided_drawdown={e['avoided_drawdown_pct']}% RSI={e['rsi_pre']} pattern={e['pattern']} flags={e['flags']}")
+    lines += ["", "## Safety", "- Read-only: no orders, no signing, no live execution."]
+    return "\n".join(lines)
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description="Analyze before/after chart timing around paper entries and exits.")
+    parser.add_argument("--strategy-id", default=DEFAULT_STRATEGY_ID)
+    parser.add_argument("--runtime-root", default=str(DEFAULT_RUNTIME_ROOT))
+    parser.add_argument("--interval", default="15m")
+    parser.add_argument("--before-hours", type=int, default=4)
+    parser.add_argument("--after-hours", type=int, default=4)
+    parser.add_argument("--output", default="runtime/reports/trade_timing_analysis_latest.md")
+    parser.add_argument("--json", action="store_true")
+    args = parser.parse_args(argv)
+    payload = analyze_trade_timing(strategy_id=args.strategy_id, runtime_root=Path(args.runtime_root), interval=args.interval, before_hours=args.before_hours, after_hours=args.after_hours)
+    out = Path(args.output)
+    out.parent.mkdir(parents=True, exist_ok=True)
+    out.write_text(render_markdown(payload), encoding="utf-8")
+    if args.json:
+        print(json.dumps(payload, indent=2, sort_keys=True))
+    else:
+        print(str(out))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/src/tools/hyperliquid_daily_report.py b/src/tools/hyperliquid_daily_report.py
index 3f9e8c6..4e8dd78 100644
--- a/src/tools/hyperliquid_daily_report.py
+++ b/src/tools/hyperliquid_daily_report.py
@@ -10,6 +10,12 @@ from typing import Any
 
 from paper_scorecard_report import build_paper_scorecard_report
 from src.tools.hl_reconcile_watchdog import _load_json_from_reconcile, assess as assess_reconcile
+from src.tools.tradingview_community_ideas import summarize_community_ideas
+from src.tools.tradingview_community_track_record import summarize_track_record
+from src.tools.tradingview_operational_gates import format_operational_gate_report, load_operational_gates
+from src.tools.tradingview_paper_bridge import summarize_tradingview_paper_bridge
+from src.tools.tradingview_promotion_gates import evaluate_promotion_gates, format_promotion_gate_report
+from src.tools.tradingview_strategy_performance import recommend_strategy_actions, summarize_strategy_performance
 from src.tools.v76_paper_runtime import BASE_ID, RESEARCH_ID, STRICT_ID, supervisor_status
 
 ROOT = Path("runtime/experiments")
@@ -73,6 +79,56 @@ def _variant_stats(strategy_id: str) -> dict[str, Any]:
     }
 
 
+def summarize_tradingview_signals(runtime_dir: str | Path = "runtime") -> dict[str, Any]:
+    runtime = Path(runtime_dir)
+    rows = _read_jsonl(runtime / "signals" / "signal_journal.jsonl")
+    rejected_rows = _read_jsonl(runtime / "signals" / "rejected_signal_journal.jsonl")
+    tv_rows = [row for row in rows if str(row.get("source") or "").lower() == "tradingview"]
+    tv_rejected = [row for row in rejected_rows if str(row.get("source") or "").lower() == "tradingview"]
+    by_strategy = Counter(str(row.get("strategy_id") or "unknown") for row in tv_rows)
+    by_coin = Counter(str(row.get("coin") or "UNKNOWN").upper() for row in tv_rows)
+    by_action = Counter(str(row.get("action") or "unknown") for row in tv_rows)
+    reject_reasons = Counter(str(row.get("reason") or "unknown") for row in tv_rejected)
+    live_order_allowed_count = sum(1 for row in tv_rows if row.get("live_order_allowed") is True)
+    live_order_allowed_count += sum(1 for row in tv_rejected if row.get("live_order_allowed") is True)
+    paper_signal_count = sum(1 for row in tv_rows if row.get("execution_mode") == "paper_signal")
+    return {
+        "received": len(tv_rows),
+        "rejected": len(tv_rejected),
+        "paper_signal": paper_signal_count,
+        "live_order_allowed_count": live_order_allowed_count,
+        "by_strategy": dict(by_strategy.most_common(5)),
+        "by_coin": dict(by_coin.most_common(8)),
+        "by_action": dict(by_action.most_common(5)),
+        "reject_reasons": dict(reject_reasons.most_common(5)),
+    }
+
+
+def build_tradingview_report_lines(runtime_dir: str | Path = "runtime") -> list[str]:
+    summary = summarize_tradingview_signals(runtime_dir)
+    bridge = summarize_tradingview_paper_bridge(runtime_dir)
+    perf = summarize_strategy_performance(runtime_dir)
+    recs = recommend_strategy_actions(perf)
+    ops = load_operational_gates(runtime_dir)
+    promo = evaluate_promotion_gates(perf, operational_gates=ops["gates"])
+    community = summarize_community_ideas(runtime_dir)
+    track_record = summarize_track_record(runtime_dir)
+    live_count = summary["live_order_allowed_count"] + bridge["live_order_allowed_count"] + community["live_order_allowed_count"]
+    live_status = "BLOCKIERT — unerwartetes live_order_allowed Flag gefunden" if live_count else "nein"
+    return [
+        f"TradingView Signals: empfangen={summary['received']}, abgelehnt={summary['rejected']}, paper_signal={summary['paper_signal']}, live_flags={summary['live_order_allowed_count']}",
+        f"TradingView Strategien: {summary['by_strategy'] or 'keine'}; Coins: {summary['by_coin'] or 'keine'}; Actions: {summary['by_action'] or 'keine'}; Rejects: {summary['reject_reasons'] or 'keine'}",
+        f"TradingView Paper Bridge: entries={bridge['paper_entries']}, exits={bridge['paper_exits']}, closed_pnl={bridge['closed_net_pnl_usd']} USDC, blocked={bridge['blocked'] or 'keine'}, live_flags={bridge['live_order_allowed_count']}",
+        f"Bridge Strategien: {bridge['by_strategy'] or 'keine'}; Coins: {bridge['by_coin'] or 'keine'}",
+        f"Strategy Performance: closed={perf['overall']['closed_trades']}, net_pnl={perf['overall']['net_pnl_usd']} USDC, recs={recs or 'keine'}",
+        f"TradingView Community Ideas: total={community['total']}, coins={community['by_coin'] or 'keine'}, bias={community['by_bias'] or 'keine'}, mode=research_only, live_flags={community['live_order_allowed_count']}",
+        f"Community Track Record: evaluated={track_record['evaluated']}, by_author={track_record['by_author'] or 'noch keine'}, recommendation={track_record['recommendation']}",
+        format_operational_gate_report(ops),
+        format_promotion_gate_report(promo),
+        f"Live-Freigabe aus TradingView: {live_status}",
+    ]
+
+
 def build_report() -> str:
     now = datetime.now(timezone.utc).isoformat()
     reconcile_raw = _load_json_from_reconcile("mainnet")
@@ -100,6 +156,7 @@ def build_report() -> str:
         f"Blockierte Signale strict: {strict['top_block_reasons']}",
         f"Marktregime: {strict['market_regime']}",
         f"API/Data Quality: {api_quality}; block_new_entries={rec['block_new_entries']}",
+        *build_tradingview_report_lines("runtime"),
         f"Prozesse: {sup}",
         "Gut funktioniert: v76 strict/base bleiben Paper-only netto positiv und ex-WLD positiv.",
         "Schlecht/Watch: research_probe blockiert weiterhin alles; Live-Autonomie ist technisch noch nicht final freigegeben.",
diff --git a/src/tools/tradingview_alert_proof.py b/src/tools/tradingview_alert_proof.py
new file mode 100644
index 0000000..9245586
--- /dev/null
+++ b/src/tools/tradingview_alert_proof.py
@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+import argparse
+import json
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+
+def _load_json(path: Path) -> dict[str, Any]:
+    if not path.exists():
+        return {}
+    try:
+        return json.loads(path.read_text(encoding="utf-8"))
+    except (json.JSONDecodeError, OSError):
+        return {}
+
+
+def assess_alert_proof(alert_report_path: str | Path) -> dict[str, Any]:
+    path = Path(alert_report_path)
+    payload = _load_json(path)
+    raw_alerts = payload.get("alerts")
+    alerts: list[Any] = raw_alerts if isinstance(raw_alerts, list) else []
+    blockers: list[str] = []
+    if payload.get("status") != "ok":
+        blockers.append("alert_smoke_not_ok")
+    if payload.get("secrets_sent") is True:
+        blockers.append("secrets_sent_true")
+    if not alerts:
+        blockers.append("no_alerts_in_smoke")
+    for item in alerts:
+        name = str(item.get("name") or "unknown") if isinstance(item, dict) else "unknown"
+        sent = item.get("sent") if isinstance(item, dict) else False
+        if sent is not True:
+            blockers.append(f"alert_not_sent:{name}")
+    return {
+        "timestamp": datetime.now(timezone.utc).isoformat(),
+        "alerts_proven": not blockers,
+        "blockers": blockers,
+        "alert_report_path": str(path),
+        "alerts_checked": len(alerts),
+        "status": payload.get("status"),
+    }
+
+
+def update_preflight_alert_evidence(*, runtime_dir: str | Path = "runtime", alert_report_path: str | Path | None = None) -> dict[str, Any]:
+    runtime = Path(runtime_dir)
+    if alert_report_path is None:
+        candidates = sorted((runtime / "reports").glob("telegram_alert_smoke_*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
+        alert_report_path = candidates[0] if candidates else runtime / "reports" / "telegram_alert_smoke_latest.json"
+    proof = assess_alert_proof(alert_report_path)
+    preflight_path = runtime / "reports" / "preflight_evidence_latest.json"
+    preflight = _load_json(preflight_path)
+    preflight["telegram_alerts_tested"] = bool(proof["alerts_proven"])
+    preflight["telegram_alert_proof"] = proof
+    preflight_path.parent.mkdir(parents=True, exist_ok=True)
+    preflight_path.write_text(json.dumps(preflight, indent=2, sort_keys=True, default=str), encoding="utf-8")
+    return proof
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description="Assess Telegram alert smoke evidence and update preflight evidence.")
+    parser.add_argument("--runtime-dir", default="runtime")
+    parser.add_argument("--alert-report-path")
+    parser.add_argument("--json", action="store_true")
+    args = parser.parse_args(argv)
+    proof = update_preflight_alert_evidence(runtime_dir=args.runtime_dir, alert_report_path=args.alert_report_path)
+    if args.json:
+        print(json.dumps(proof, indent=2, sort_keys=True))
+    else:
+        print(f"Alert Proof: proven={proof['alerts_proven']} blockers={proof['blockers'] or 'keine'}")
+    return 0 if proof["alerts_proven"] else 2
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/src/tools/tradingview_community_ideas.py b/src/tools/tradingview_community_ideas.py
new file mode 100644
index 0000000..479bd43
--- /dev/null
+++ b/src/tools/tradingview_community_ideas.py
@@ -0,0 +1,262 @@
+from __future__ import annotations
+
+import argparse
+import html
+import json
+import re
+import urllib.request
+from collections import Counter
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+IDEAS_PATH = "research/tradingview_community_ideas.jsonl"
+DEFAULT_URLS = {
+    "BTC": "https://www.tradingview.com/symbols/BTCUSDT/ideas/?exchange=BINANCE&sort=recent",
+    "ETH": "https://www.tradingview.com/symbols/ETHUSDT/ideas/?exchange=BINANCE&sort=recent",
+    "XRP": "https://www.tradingview.com/symbols/XRPUSDT/ideas/?exchange=BINANCE&sort=recent",
+}
+
+BULLISH_TERMS = {
+    "bullish", "long", "rally", "breakout", "upside", "pump", "accumulation", "support",
+    "demand", "reclaim", "higher", "bounce", "buy", "moon", "continuation",
+}
+BEARISH_TERMS = {
+    "bearish", "short", "dump", "breakdown", "downside", "resistance", "supply", "sell",
+    "lower", "crash", "correction", "rejection", "drop", "fall", "risk",
+}
+
+
+def _now() -> str:
+    return datetime.now(timezone.utc).isoformat()
+
+
+def _clean_text(value: str) -> str:
+    text = re.sub(r"<[^>]+>", " ", value)
+    text = html.unescape(text)
+    text = re.sub(r"\s+", " ", text).strip()
+    return text
+
+
+def _normalize_idea_url(url: str) -> str:
+    url = html.unescape(url).split("#", 1)[0]
+    if url.startswith("/"):
+        url = "https://www.tradingview.com" + url
+    return url.rstrip("/") + "/"
+
+
+def classify_idea_bias(text: str) -> str:
+    words = set(re.findall(r"[a-zA-Z]+", text.lower()))
+    bull = len(words & BULLISH_TERMS)
+    bear = len(words & BEARISH_TERMS)
+    if bull and bear:
+        directional = words & ((BULLISH_TERMS | BEARISH_TERMS) - {"support", "resistance", "risk"})
+        if not directional:
+            return "neutral"
+        # Require material imbalance; otherwise call it mixed/noisy.
+        if bull > bear * 2:
+            return "bullish"
+        if bear > bull * 2:
+            return "bearish"
+        return "mixed"
+    if bull:
+        return "bullish"
+    if bear:
+        return "bearish"
+    return "neutral"
+
+
+def _idea_row(*, coin: str, source_url: str, url: str, title: str, excerpt: str, author: str | None = None) -> dict[str, Any]:
+    combined = f"{title} {excerpt}"
+    return {
+        "timestamp": _now(),
+        "source": "tradingview_community_ideas",
+        "source_url": source_url,
+        "coin": coin.upper(),
+        "author": author or "unknown",
+        "title": title[:220],
+        "excerpt": excerpt[:500],
+        "url": url,
+        "bias": classify_idea_bias(combined),
+        "research_only": True,
+        "unverified_author_track_record": True,
+        "execution_mode": "research_context",
+        "live_order_allowed": False,
+        "mainnet_signed_action": False,
+        "order_intent_created": False,
+    }
+
+
+def parse_ideas_html(html_text: str, *, coin: str, source_url: str, limit: int = 20) -> list[dict[str, Any]]:
+    """Parse public TradingView idea cards from HTML.
+
+    This is intentionally research-only. The output is never an execution signal.
+    TradingView page structure changes often, so this parser first tries full
+    <article> cards, then falls back to chart-link grouping.
+    """
+    rows: list[dict[str, Any]] = []
+    seen_urls: set[str] = set()
+    articles = re.findall(r"<article\b.*?</article>", html_text, re.I | re.S)
+    for article in articles:
+        title_match = re.search(r"<a\b[^>]*href=[\"']([^\"']+/chart/[^\"']+)[\"'][^>]*data-qa-id=[\"']ui-lib-card-link-title[\"'][^>]*>(.*?)</a>", article, re.I | re.S)
+        if not title_match:
+            continue
+        url = _normalize_idea_url(title_match.group(1))
+        if url in seen_urls:
+            continue
+        title = _clean_text(title_match.group(2))
+        excerpt_match = re.search(r"<a\b[^>]*href=[\"'][^\"']+/chart/[^\"']+[\"'][^>]*data-qa-id=[\"']ui-lib-card-link-paragraph[\"'][^>]*>(.*?)</a>", article, re.I | re.S)
+        excerpt = _clean_text(excerpt_match.group(1)) if excerpt_match else ""
+        author_match = re.search(r"href=[\"'](?:https://www\.tradingview\.com)?/u/([^/\"']+)/[\"'][^>]*>(.*?)</a>", article, re.I | re.S)
+        author = _clean_text(author_match.group(2)).removeprefix("by ").strip() if author_match else None
+        rows.append(_idea_row(coin=coin, source_url=source_url, url=url, title=title, excerpt=excerpt, author=author))
+        seen_urls.add(url)
+        if len(rows) >= limit:
+            return rows
+
+    anchors = re.findall(r"<a\b[^>]*href=[\"']([^\"']+/chart/[^\"']+)[\"'][^>]*>(.*?)</a>", html_text, re.I | re.S)
+    grouped: dict[str, list[str]] = {}
+    order: list[str] = []
+    for href, raw_text in anchors:
+        url = _normalize_idea_url(href)
+        if "tradingview.com/chart/" not in url or url in seen_urls:
+            continue
+        text = _clean_text(raw_text)
+        if not text or text.lower() in {"comments", "comment", "1 1"}:
+            continue
+        if url not in grouped:
+            grouped[url] = []
+            order.append(url)
+        if text not in grouped[url]:
+            grouped[url].append(text)
+
+    for url in order:
+        texts = grouped[url]
+        if not texts:
+            continue
+        title = texts[0][:220]
+        excerpt = next((t for t in texts[1:] if len(t) >= 30), "")[:500]
+        rows.append(_idea_row(coin=coin, source_url=source_url, url=url, title=title, excerpt=excerpt))
+        if len(rows) >= limit:
+            break
+    return rows
+
+
+def fetch_ideas_page(url: str, *, timeout: int = 20) -> str:
+    request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 CTB research-only"})
+    with urllib.request.urlopen(request, timeout=timeout) as response:
+        return response.read().decode("utf-8", errors="replace")
+
+
+def fetch_hyperliquid_mids(*, timeout: int = 10) -> dict[str, str]:
+    request = urllib.request.Request(
+        "https://api.hyperliquid.xyz/info",
+        data=json.dumps({"type": "allMids"}).encode("utf-8"),
+        headers={"Content-Type": "application/json", "User-Agent": "CTB research-only"},
+        method="POST",
+    )
+    with urllib.request.urlopen(request, timeout=timeout) as response:
+        data = json.loads(response.read().decode("utf-8", errors="replace"))
+    return {str(coin).upper(): str(price) for coin, price in data.items()}
+
+
+def attach_price_snapshot(ideas: list[dict[str, Any]], prices: dict[str, str]) -> list[dict[str, Any]]:
+    enriched: list[dict[str, Any]] = []
+    for idea in ideas:
+        row = dict(idea)
+        coin = str(row.get("coin") or "").upper()
+        price = prices.get(coin)
+        if price:
+            row["price_at_capture_usd"] = price
+            row["price_source"] = "hyperliquid_all_mids"
+        enriched.append(row)
+    return enriched
+
+
+def write_ideas_snapshot(ideas: list[dict[str, Any]], *, runtime_dir: str | Path = "runtime") -> Path:
+    path = Path(runtime_dir) / IDEAS_PATH
+    path.parent.mkdir(parents=True, exist_ok=True)
+    seen = {str(row.get("url")) for row in _read_jsonl(path)}
+    with path.open("a", encoding="utf-8") as fh:
+        for idea in ideas:
+            if str(idea.get("url")) in seen:
+                continue
+            fh.write(json.dumps(idea, sort_keys=True, ensure_ascii=False) + "\n")
+            seen.add(str(idea.get("url")))
+    return path
+
+
+def _read_jsonl(path: Path) -> list[dict[str, Any]]:
+    if not path.exists():
+        return []
+    rows: list[dict[str, Any]] = []
+    for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
+        if not line.strip():
+            continue
+        try:
+            rows.append(json.loads(line))
+        except json.JSONDecodeError:
+            continue
+    return rows
+
+
+def summarize_community_ideas(runtime_dir: str | Path = "runtime", *, recent_limit: int = 200) -> dict[str, Any]:
+    rows = _read_jsonl(Path(runtime_dir) / IDEAS_PATH)[-recent_limit:]
+    by_coin = Counter(str(row.get("coin") or "UNKNOWN").upper() for row in rows)
+    by_bias = Counter(str(row.get("bias") or "unknown") for row in rows)
+    live_flags = sum(1 for row in rows if row.get("live_order_allowed") is True or row.get("mainnet_signed_action") is True)
+    latest = [
+        {"coin": row.get("coin"), "bias": row.get("bias"), "title": row.get("title"), "url": row.get("url")}
+        for row in rows[-5:]
+    ]
+    return {
+        "total": len(rows),
+        "by_coin": dict(by_coin.most_common()),
+        "by_bias": dict(by_bias.most_common()),
+        "live_order_allowed_count": live_flags,
+        "latest": latest,
+        "recommendation": "research_only_never_trade_directly",
+    }
+
+
+def collect_community_ideas(*, runtime_dir: str | Path = "runtime", urls: dict[str, str] | None = None, limit_per_coin: int = 10) -> dict[str, Any]:
+    urls = urls or DEFAULT_URLS
+    collected: list[dict[str, Any]] = []
+    errors: dict[str, str] = {}
+    prices: dict[str, str] = {}
+    try:
+        prices = fetch_hyperliquid_mids()
+    except Exception as exc:
+        errors["hyperliquid_prices"] = exc.__class__.__name__
+    for coin, url in urls.items():
+        try:
+            html_text = fetch_ideas_page(url)
+            ideas = parse_ideas_html(html_text, coin=coin, source_url=url, limit=limit_per_coin)
+            collected.extend(attach_price_snapshot(ideas, prices))
+        except Exception as exc:  # network/parser collector should fail soft
+            errors[coin] = exc.__class__.__name__
+    path = write_ideas_snapshot(collected, runtime_dir=runtime_dir)
+    summary = summarize_community_ideas(runtime_dir)
+    return {"status": "ok" if collected or not errors else "error", "collected": len(collected), "errors": errors, "journal": str(path), "summary": summary}
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description="Collect TradingView community ideas as research-only context.")
+    parser.add_argument("--runtime-dir", default="runtime")
+    parser.add_argument("--coin-url", action="append", default=[], help="COIN=URL, repeatable")
+    parser.add_argument("--limit-per-coin", type=int, default=10)
+    parser.add_argument("--summary", action="store_true")
+    args = parser.parse_args(argv)
+    if args.summary:
+        print(json.dumps(summarize_community_ideas(args.runtime_dir), indent=2, sort_keys=True))
+        return 0
+    urls = DEFAULT_URLS.copy()
+    for item in args.coin_url:
+        coin, url = item.split("=", 1)
+        urls[coin.upper()] = url
+    print(json.dumps(collect_community_ideas(runtime_dir=args.runtime_dir, urls=urls, limit_per_coin=args.limit_per_coin), indent=2, sort_keys=True, ensure_ascii=False))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/src/tools/tradingview_community_track_record.py b/src/tools/tradingview_community_track_record.py
new file mode 100644
index 0000000..6284b2c
--- /dev/null
+++ b/src/tools/tradingview_community_track_record.py
@@ -0,0 +1,176 @@
+from __future__ import annotations
+
+import argparse
+import json
+import urllib.request
+from collections import defaultdict
+from datetime import datetime, timedelta, timezone
+from decimal import Decimal, InvalidOperation
+from pathlib import Path
+from typing import Any
+
+IDEAS_PATH = "research/tradingview_community_ideas.jsonl"
+EVAL_PATH = "research/tradingview_community_idea_evaluations.jsonl"
+DEFAULT_HORIZONS_HOURS = (24, 168)
+
+
+def _read_jsonl(path: Path) -> list[dict[str, Any]]:
+    if not path.exists():
+        return []
+    rows: list[dict[str, Any]] = []
+    for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
+        if not line.strip():
+            continue
+        try:
+            rows.append(json.loads(line))
+        except json.JSONDecodeError:
+            continue
+    return rows
+
+
+def _decimal(value: Any) -> Decimal | None:
+    try:
+        return Decimal(str(value))
+    except (InvalidOperation, TypeError, ValueError):
+        return None
+
+
+def _parse_time(value: Any) -> datetime | None:
+    text = str(value or "")
+    if not text:
+        return None
+    if text.endswith("Z"):
+        text = text[:-1] + "+00:00"
+    try:
+        dt = datetime.fromisoformat(text)
+    except ValueError:
+        return None
+    if dt.tzinfo is None:
+        dt = dt.replace(tzinfo=timezone.utc)
+    return dt.astimezone(timezone.utc)
+
+
+def fetch_hyperliquid_mids(*, timeout: int = 10) -> dict[str, str]:
+    request = urllib.request.Request(
+        "https://api.hyperliquid.xyz/info",
+        data=json.dumps({"type": "allMids"}).encode("utf-8"),
+        headers={"Content-Type": "application/json", "User-Agent": "CTB research-only"},
+        method="POST",
+    )
+    with urllib.request.urlopen(request, timeout=timeout) as response:
+        data = json.loads(response.read().decode("utf-8", errors="replace"))
+    return {str(coin).upper(): str(price) for coin, price in data.items()}
+
+
+def _direction_correct(bias: str, return_pct: Decimal) -> bool | None:
+    if bias == "bullish":
+        return return_pct > 0
+    if bias == "bearish":
+        return return_pct < 0
+    return None
+
+
+def evaluate_due_ideas(
+    runtime_dir: str | Path = "runtime",
+    *,
+    current_prices: dict[str, str] | None = None,
+    horizons_hours: tuple[int, ...] = DEFAULT_HORIZONS_HOURS,
+    now: datetime | None = None,
+) -> dict[str, Any]:
+    runtime = Path(runtime_dir)
+    now = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
+    prices = current_prices if current_prices is not None else fetch_hyperliquid_mids()
+    ideas = _read_jsonl(runtime / IDEAS_PATH)
+    eval_path = runtime / EVAL_PATH
+    existing = _read_jsonl(eval_path)
+    done = {(str(row.get("url")), int(row.get("horizon_hours") or 0)) for row in existing}
+    new_rows: list[dict[str, Any]] = []
+
+    for idea in ideas:
+        url = str(idea.get("url") or "")
+        coin = str(idea.get("coin") or "").upper()
+        bias = str(idea.get("bias") or "neutral")
+        if not url or bias not in {"bullish", "bearish"}:
+            continue
+        entry_price = _decimal(idea.get("price_at_capture_usd"))
+        current_price = _decimal(prices.get(coin))
+        captured_at = _parse_time(idea.get("timestamp"))
+        if not entry_price or entry_price <= 0 or not current_price or current_price <= 0 or captured_at is None:
+            continue
+        for horizon in horizons_hours:
+            if (url, horizon) in done:
+                continue
+            if now < captured_at + timedelta(hours=horizon):
+                continue
+            ret = (current_price - entry_price) / entry_price * Decimal("100")
+            correct = _direction_correct(bias, ret)
+            row = {
+                "timestamp": now.isoformat(),
+                "source": "tradingview_community_idea_track_record",
+                "url": url,
+                "author": idea.get("author") or "unknown",
+                "coin": coin,
+                "bias": bias,
+                "title": idea.get("title"),
+                "captured_at": captured_at.isoformat(),
+                "horizon_hours": horizon,
+                "entry_price_usd": str(entry_price),
+                "evaluation_price_usd": str(current_price),
+                "return_pct": str(ret.quantize(Decimal("0.01"))),
+                "direction_correct": correct,
+                "research_only": True,
+                "live_order_allowed": False,
+                "mainnet_signed_action": False,
+            }
+            new_rows.append(row)
+            done.add((url, horizon))
+
+    if new_rows:
+        eval_path.parent.mkdir(parents=True, exist_ok=True)
+        with eval_path.open("a", encoding="utf-8") as fh:
+            for row in new_rows:
+                fh.write(json.dumps(row, sort_keys=True, ensure_ascii=False) + "\n")
+    return {"status": "ok", "evaluated_new": len(new_rows), "journal": str(eval_path), "summary": summarize_track_record(runtime)}
+
+
+def summarize_track_record(runtime_dir: str | Path = "runtime") -> dict[str, Any]:
+    rows = _read_jsonl(Path(runtime_dir) / EVAL_PATH)
+    buckets: dict[str, dict[str, Any]] = defaultdict(lambda: {"evaluated": 0, "correct": 0})
+    by_coin: dict[str, dict[str, Any]] = defaultdict(lambda: {"evaluated": 0, "correct": 0})
+    for row in rows:
+        correct = row.get("direction_correct") is True
+        author = str(row.get("author") or "unknown")
+        coin = str(row.get("coin") or "UNKNOWN").upper()
+        for bucket in (buckets[author], by_coin[coin]):
+            bucket["evaluated"] += 1
+            if correct:
+                bucket["correct"] += 1
+
+    def finish(bucket: dict[str, Any]) -> dict[str, Any]:
+        evaluated = int(bucket["evaluated"])
+        correct = int(bucket["correct"])
+        hitrate = Decimal(correct) / Decimal(evaluated) * Decimal("100") if evaluated else Decimal("0")
+        return {"evaluated": evaluated, "correct": correct, "hitrate_pct": str(hitrate.quantize(Decimal("0.01")))}
+
+    return {
+        "evaluated": len(rows),
+        "by_author": {k: finish(v) for k, v in sorted(buckets.items())},
+        "by_coin": {k: finish(v) for k, v in sorted(by_coin.items())},
+        "recommendation": "research_only_until_author_track_record_is_statistically_significant",
+    }
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description="Evaluate TradingView community idea forward returns as research-only track record.")
+    parser.add_argument("--runtime-dir", default="runtime")
+    parser.add_argument("--summary", action="store_true")
+    args = parser.parse_args(argv)
+    if args.summary:
+        print(json.dumps(summarize_track_record(args.runtime_dir), indent=2, sort_keys=True))
+        return 0
+    print(json.dumps(evaluate_due_ideas(args.runtime_dir), indent=2, sort_keys=True))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/src/tools/tradingview_operational_gates.py b/src/tools/tradingview_operational_gates.py
new file mode 100644
index 0000000..8f432e0
--- /dev/null
+++ b/src/tools/tradingview_operational_gates.py
@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+from typing import Any
+
+GATE_NAMES = (
+    "reconcile_clean",
+    "alerts_proven",
+    "loss_gates_active",
+    "kill_switch_ready",
+    "stop_handling_ready",
+)
+
+
+def _load_json(path: Path) -> dict[str, Any]:
+    if not path.exists():
+        return {}
+    try:
+        return json.loads(path.read_text(encoding="utf-8"))
+    except (json.JSONDecodeError, OSError):
+        return {}
+
+
+def load_operational_gates(runtime_dir: str | Path = "runtime") -> dict[str, Any]:
+    runtime = Path(runtime_dir)
+    reconcile = _load_json(runtime / "reports" / "hl_reconcile_watchdog_latest.json")
+    preflight = _load_json(runtime / "reports" / "preflight_evidence_latest.json")
+    limits = _load_json(runtime / "config" / "tiny_autonomous_live_limits.json")
+    watchdog = reconcile.get("watchdog") if isinstance(reconcile.get("watchdog"), dict) else {}
+    block_if = set(str(x) for x in (limits.get("block_new_entries_if") or []))
+    gates = {
+        "reconcile_clean": bool(watchdog) and watchdog.get("critical") is False and watchdog.get("block_new_entries") is False and int(watchdog.get("stops_missing_count") or 0) == 0,
+        "alerts_proven": preflight.get("telegram_alerts_tested") is True,
+        "loss_gates_active": bool(limits.get("max_daily_loss_usdc")) and bool(limits.get("max_weekly_loss_usdc")) and {"daily_loss_exceeded", "weekly_loss_exceeded"}.issubset(block_if),
+        "kill_switch_ready": "kill_switch_active" in block_if,
+        "stop_handling_ready": preflight.get("position_without_stop_impossible") is True and preflight.get("testnet_fill_stop") is True and "position_without_stop" in block_if,
+    }
+    blockers = [f"{name}_missing" for name in GATE_NAMES if gates.get(name) is not True]
+    return {
+        "gates": gates,
+        "blockers": blockers,
+        "sources": {
+            "reconcile_report": str(runtime / "reports" / "hl_reconcile_watchdog_latest.json"),
+            "preflight_report": str(runtime / "reports" / "preflight_evidence_latest.json"),
+            "limits_config": str(runtime / "config" / "tiny_autonomous_live_limits.json"),
+        },
+    }
+
+
+def format_operational_gate_report(payload: dict[str, Any]) -> str:
+    blockers = payload.get("blockers") or []
+    blocker_text = ",".join(blockers[:5]) if blockers else "keine"
+    green = sum(1 for value in (payload.get("gates") or {}).values() if value is True)
+    total = len(payload.get("gates") or GATE_NAMES)
+    return f"Operational Gates: green={green}/{total}, blocker={blocker_text}"
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description="Load live-readiness operational gates from runtime reports.")
+    parser.add_argument("--runtime-dir", default="runtime")
+    parser.add_argument("--json", action="store_true")
+    args = parser.parse_args(argv)
+    result = load_operational_gates(args.runtime_dir)
+    if args.json:
+        print(json.dumps(result, indent=2, sort_keys=True))
+    else:
+        print(format_operational_gate_report(result))
+    return 0 if not result["blockers"] else 2
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/src/tools/tradingview_paper_bridge.py b/src/tools/tradingview_paper_bridge.py
new file mode 100644
index 0000000..66366db
--- /dev/null
+++ b/src/tools/tradingview_paper_bridge.py
@@ -0,0 +1,324 @@
+from __future__ import annotations
+
+import argparse
+import json
+from collections import Counter
+from dataclasses import asdict
+from datetime import datetime, timezone
+from decimal import Decimal, ROUND_DOWN, InvalidOperation
+from pathlib import Path
+from typing import Any
+
+from src.execution.cost_model import CostModel
+from src.execution.order_intent import OrderIntent
+from src.execution.paper_executor import PaperExecutor
+from src.risk.pretrade_risk_gate import PretradeRiskGate, RiskContext
+
+BRIDGE_ID = "tradingview_paper_bridge"
+
+
+def _json_default(value: Any) -> Any:
+    if isinstance(value, Decimal):
+        return str(value)
+    if isinstance(value, datetime):
+        return value.isoformat()
+    return str(value)
+
+
+def _append_jsonl(path: Path, row: dict[str, Any]) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    with path.open("a", encoding="utf-8") as fh:
+        fh.write(json.dumps(row, default=_json_default, sort_keys=True, ensure_ascii=False) + "\n")
+
+
+def _read_jsonl(path: Path) -> list[dict[str, Any]]:
+    if not path.exists():
+        return []
+    rows: list[dict[str, Any]] = []
+    for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
+        if not line.strip():
+            continue
+        try:
+            rows.append(json.loads(line))
+        except json.JSONDecodeError:
+            continue
+    return rows
+
+
+def _to_decimal(value: Any, default: str = "0") -> Decimal:
+    try:
+        return Decimal(str(value))
+    except (InvalidOperation, ValueError, TypeError):
+        return Decimal(default)
+
+
+def _quantize_size(notional: Decimal, price: Decimal) -> Decimal:
+    if price <= 0:
+        return Decimal("0")
+    return (notional / price).quantize(Decimal("0.00000001"), rounding=ROUND_DOWN)
+
+
+def _bridge_dir(runtime_dir: Path) -> Path:
+    return runtime_dir / "experiments" / BRIDGE_ID
+
+
+def _processed_ids(path: Path) -> set[str]:
+    return {str(row.get("signal_id")) for row in _read_jsonl(path) if row.get("signal_id")}
+
+
+def _decision_row(signal: dict[str, Any], *, final_decision: str, block_reason: str | None = None, intent: OrderIntent | None = None) -> dict[str, Any]:
+    row: dict[str, Any] = {
+        "timestamp": datetime.now(timezone.utc).isoformat(),
+        "source": "tradingview",
+        "signal_id": signal.get("signal_id"),
+        "strategy_id": signal.get("strategy_id"),
+        "coin": str(signal.get("coin") or "").upper(),
+        "side": signal.get("side"),
+        "action": signal.get("action"),
+        "price": signal.get("price"),
+        "execution_mode": "paper_bridge",
+        "paper_trading": True,
+        "live_order_allowed": False,
+        "mainnet_signed_action": False,
+        "order_intent_created": intent is not None,
+        "final_decision": final_decision,
+    }
+    if block_reason:
+        row["block_reason"] = [block_reason]
+    if intent is not None:
+        row["order_intent"] = asdict(intent)
+    return row
+
+
+def _close_paper_position(signal: dict[str, Any], open_positions: dict[str, Any], bridge_dir: Path) -> tuple[bool, str | None]:
+    coin = str(signal.get("coin") or "").upper()
+    position = open_positions.get(coin)
+    if not isinstance(position, dict):
+        return False, "exit_without_paper_position"
+    exit_price = _to_decimal(signal.get("price"))
+    if exit_price <= 0:
+        return False, "invalid_price"
+    entry_price = _to_decimal(position.get("entry_price"))
+    size = _to_decimal(position.get("size"))
+    entry_fee = _to_decimal(position.get("entry_fee_usd"))
+    side = str(position.get("side") or signal.get("side") or "").lower()
+    if entry_price <= 0 or size <= 0:
+        return False, "invalid_open_position_state"
+    exit_fee = exit_price * size * Decimal("0.00045")
+    gross = (exit_price - entry_price) * size if side == "long" else (entry_price - exit_price) * size
+    net = gross - entry_fee - exit_fee
+    row = {
+        "timestamp": datetime.now(timezone.utc).isoformat(),
+        "event": "paper_exit",
+        "source": "tradingview",
+        "signal_id": signal.get("signal_id"),
+        "entry_signal_id": position.get("signal_id"),
+        "strategy_id": position.get("strategy_id") or signal.get("strategy_id"),
+        "coin": coin,
+        "side": side,
+        "action": "exit",
+        "paper_trading": True,
+        "live_order_allowed": False,
+        "mainnet_signed_action": False,
+        "entry_price": str(entry_price),
+        "exit_price": str(exit_price),
+        "size": str(size),
+        "gross_pnl_usd": str(gross),
+        "entry_fee_usd": str(entry_fee),
+        "exit_fee_usd": str(exit_fee),
+        "net_pnl_usd": str(net),
+    }
+    _append_jsonl(bridge_dir / "trade_journal.jsonl", row)
+    del open_positions[coin]
+    return True, None
+
+
+def _build_entry_intent(signal: dict[str, Any], *, wallet_equity_usdc: Decimal, risk_pct: Decimal, stop_distance_pct: Decimal) -> tuple[OrderIntent | None, str | None]:
+    price = _to_decimal(signal.get("price"))
+    if price <= 0:
+        return None, "invalid_price"
+    side = str(signal.get("side") or "").lower()
+    if side not in {"long", "short"}:
+        return None, "invalid_side"
+    coin = str(signal.get("coin") or "").upper()
+    strategy_id = str(signal.get("strategy_id") or "")
+    if not coin or not strategy_id:
+        return None, "missing_coin_or_strategy"
+    notional = (wallet_equity_usdc * Decimal("0.15")).quantize(Decimal("0.01"), rounding=ROUND_DOWN)
+    risk_usd = (wallet_equity_usdc * risk_pct / Decimal("100")).quantize(Decimal("0.00000001"), rounding=ROUND_DOWN)
+    size = _quantize_size(notional, price)
+    if size <= 0:
+        return None, "invalid_size"
+    order_side = "buy" if side == "long" else "sell"
+    if side == "long":
+        stop_loss = price * (Decimal("1") - stop_distance_pct / Decimal("100"))
+    else:
+        stop_loss = price * (Decimal("1") + stop_distance_pct / Decimal("100"))
+    intent = OrderIntent(
+        strategy_id=strategy_id,
+        symbol=f"{coin}/USDC:USDC",
+        coin=coin,
+        side=order_side,
+        reduce_only=False,
+        order_type="market",
+        tif="Ioc",
+        size=size,
+        price=None,
+        trigger_price=None,
+        stop_loss=stop_loss.quantize(Decimal("0.00000001"), rounding=ROUND_DOWN),
+        take_profit=None,
+        client_order_id=f"tv-paper-{signal.get('signal_id')}",
+        reason=f"tradingview_{side}_entry",
+        risk_usd=risk_usd,
+        estimated_notional_usd=notional,
+    )
+    return intent, None
+
+
+def process_pending_tradingview_signals(
+    *,
+    runtime_dir: str | Path = "runtime",
+    wallet_equity_usdc: Decimal = Decimal("75"),
+    risk_pct: Decimal = Decimal("0.15"),
+    stop_distance_pct: Decimal = Decimal("1.00"),
+    expected_move_pct: Decimal = Decimal("1.20"),
+    max_parallel_positions: int = 3,
+) -> dict[str, Any]:
+    runtime = Path(runtime_dir)
+    bridge_dir = _bridge_dir(runtime)
+    signals = _read_jsonl(runtime / "signals" / "signal_journal.jsonl")
+    processed_path = bridge_dir / "processed_signal_ids.jsonl"
+    processed = _processed_ids(processed_path)
+    state_path = bridge_dir / "state.json"
+    state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {"open_positions": {}}
+    open_positions = state.setdefault("open_positions", {})
+    executor = PaperExecutor(cost_model=CostModel())
+    gate = PretradeRiskGate(min_order_notional_usd=Decimal("10"), max_parallel_positions=max_parallel_positions, max_order_notional_usd=wallet_equity_usdc * Decimal("0.25"), max_risk_usd=wallet_equity_usdc * Decimal("0.005"))
+    result = {"processed": 0, "paper_trades": 0, "paper_exits": 0, "blocked": {}, "mainnet_signed_action": False, "live_order_allowed": False}
+    blocked: Counter[str] = Counter()
+
+    for signal in signals:
+        if str(signal.get("source") or "").lower() != "tradingview":
+            continue
+        signal_id = str(signal.get("signal_id") or "")
+        if not signal_id or signal_id in processed:
+            continue
+        result["processed"] += 1
+        processed.add(signal_id)
+        block_reason: str | None = None
+        intent: OrderIntent | None = None
+        if signal.get("live_order_allowed") is True or signal.get("mainnet_signed_action") is True:
+            block_reason = "unexpected_live_flag"
+        elif signal.get("execution_mode") != "paper_signal":
+            block_reason = "not_paper_signal"
+        elif str(signal.get("action") or "").lower() == "exit":
+            closed, close_block = _close_paper_position(signal, open_positions, bridge_dir)
+            if closed:
+                _append_jsonl(bridge_dir / "signal_decision_journal.jsonl", _decision_row(signal, final_decision="paper_exited"))
+                result["paper_exits"] += 1
+            else:
+                block_reason = close_block or "exit_failed"
+        elif str(signal.get("action") or "").lower() != "entry":
+            block_reason = "unsupported_action"
+        elif str(signal.get("coin") or "").upper() in open_positions:
+            block_reason = "paper_position_already_open"
+        else:
+            intent, block_reason = _build_entry_intent(signal, wallet_equity_usdc=wallet_equity_usdc, risk_pct=risk_pct, stop_distance_pct=stop_distance_pct)
+            if intent is not None:
+                risk = gate.evaluate(intent, RiskContext(open_positions=len(open_positions), kill_switch_active=False, daily_loss_exceeded=False))
+                if not risk.allowed:
+                    block_reason = ";".join(risk.reasons) or "risk_gate_blocked"
+                    intent = None
+        if block_reason:
+            blocked[block_reason] += 1
+            _append_jsonl(bridge_dir / "signal_decision_journal.jsonl", _decision_row(signal, final_decision=f"blocked:{block_reason}", block_reason=block_reason, intent=intent))
+        elif intent is not None:
+            mark = _to_decimal(signal.get("price"))
+            fill = executor.execute(intent, mark_price=mark, half_spread_pct=Decimal("0.015"), expected_move_pct=expected_move_pct, depth_penalty_pct=Decimal("0.02"), hold_hours=Decimal("4"))
+            if fill.blocked_by_cost:
+                blocked[fill.blocked_reason] += 1
+                _append_jsonl(bridge_dir / "signal_decision_journal.jsonl", _decision_row(signal, final_decision=f"blocked:{fill.blocked_reason}", block_reason=fill.blocked_reason, intent=intent))
+            else:
+                open_positions[intent.coin] = {
+                    "signal_id": signal_id,
+                    "strategy_id": intent.strategy_id,
+                    "side": str(signal.get("side") or ""),
+                    "entry_price": str(fill.fill_price),
+                    "size": str(intent.size),
+                    "stop_loss": str(intent.stop_loss),
+                    "entry_fee_usd": str(fill.fee_usd),
+                    "timestamp": datetime.now(timezone.utc).isoformat(),
+                }
+                trade_row = {
+                    "timestamp": datetime.now(timezone.utc).isoformat(),
+                    "event": "paper_entry",
+                    "source": "tradingview",
+                    "signal_id": signal_id,
+                    "strategy_id": intent.strategy_id,
+                    "coin": intent.coin,
+                    "side": str(signal.get("side") or ""),
+                    "action": "entry",
+                    "paper_trading": True,
+                    "live_order_allowed": False,
+                    "mainnet_signed_action": False,
+                    "entry_price": str(fill.fill_price),
+                    "size": str(intent.size),
+                    "stop_loss": str(intent.stop_loss),
+                    "estimated_notional_usd": str(intent.estimated_notional_usd),
+                    "risk_usd": str(intent.risk_usd),
+                    "entry_fee_usd": str(fill.fee_usd),
+                    "estimated_roundtrip_cost_pct": str(fill.estimated_roundtrip_cost_pct),
+                    "net_pnl_usd": "0",
+                }
+                _append_jsonl(bridge_dir / "trade_journal.jsonl", trade_row)
+                _append_jsonl(bridge_dir / "signal_decision_journal.jsonl", _decision_row(signal, final_decision="paper_entered", intent=intent))
+                result["paper_trades"] += 1
+        _append_jsonl(processed_path, {"timestamp": datetime.now(timezone.utc).isoformat(), "signal_id": signal_id, "mainnet_signed_action": False})
+
+    state_path.parent.mkdir(parents=True, exist_ok=True)
+    state_path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
+    result["blocked"] = dict(blocked)
+    return result
+
+
+def summarize_tradingview_paper_bridge(runtime_dir: str | Path = "runtime") -> dict[str, Any]:
+    bridge_dir = _bridge_dir(Path(runtime_dir))
+    trades = _read_jsonl(bridge_dir / "trade_journal.jsonl")
+    decisions = _read_jsonl(bridge_dir / "signal_decision_journal.jsonl")
+    entries = [row for row in trades if row.get("event") == "paper_entry"]
+    exits = [row for row in trades if row.get("event") == "paper_exit"]
+    closed_net = sum((_to_decimal(row.get("net_pnl_usd")) for row in exits), Decimal("0"))
+    blockers: Counter[str] = Counter()
+    for row in decisions:
+        final = str(row.get("final_decision") or "")
+        if final.startswith("blocked:"):
+            blockers[final.split(":", 1)[1]] += 1
+    by_strategy = Counter(str(row.get("strategy_id") or "unknown") for row in entries)
+    by_coin = Counter(str(row.get("coin") or "UNKNOWN").upper() for row in entries)
+    live_count = sum(1 for row in entries + decisions if row.get("live_order_allowed") is True or row.get("mainnet_signed_action") is True)
+    return {
+        "paper_entries": len(entries),
+        "paper_exits": len(exits),
+        "closed_net_pnl_usd": str(closed_net),
+        "blocked": dict(blockers.most_common(5)),
+        "by_strategy": dict(by_strategy.most_common(5)),
+        "by_coin": dict(by_coin.most_common(8)),
+        "live_order_allowed_count": live_count,
+    }
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description="Process accepted TradingView signals into paper-only bridge decisions.")
+    parser.add_argument("--runtime-dir", default="runtime")
+    parser.add_argument("--json", action="store_true")
+    args = parser.parse_args(argv)
+    result = process_pending_tradingview_signals(runtime_dir=args.runtime_dir)
+    if args.json:
+        print(json.dumps(result, indent=2, sort_keys=True))
+    else:
+        print(f"TradingView paper bridge processed={result['processed']} paper_trades={result['paper_trades']} blocked={result['blocked']}")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/src/tools/tradingview_promotion_gates.py b/src/tools/tradingview_promotion_gates.py
new file mode 100644
index 0000000..bcd97da
--- /dev/null
+++ b/src/tools/tradingview_promotion_gates.py
@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+import argparse
+import json
+from decimal import Decimal, InvalidOperation
+from typing import Any
+
+from src.tools.tradingview_strategy_performance import summarize_strategy_performance
+
+REQUIRED_OPERATIONAL_GATES = (
+    "reconcile_clean",
+    "alerts_proven",
+    "loss_gates_active",
+    "kill_switch_ready",
+    "stop_handling_ready",
+)
+
+
+def _decimal(value: Any) -> Decimal:
+    try:
+        return Decimal(str(value))
+    except (InvalidOperation, ValueError, TypeError):
+        return Decimal("0")
+
+
+def evaluate_promotion_gates(
+    performance_summary: dict[str, Any],
+    *,
+    operational_gates: dict[str, bool] | None = None,
+    min_closed_trades: int = 20,
+    min_net_pnl_usd: Decimal = Decimal("0"),
+    min_winrate_pct: Decimal = Decimal("50"),
+) -> dict[str, Any]:
+    gates = operational_gates or {}
+    overall = performance_summary.get("overall", {})
+    closed = int(overall.get("closed_trades") or 0)
+    net_pnl = _decimal(overall.get("net_pnl_usd"))
+    winrate = _decimal(overall.get("winrate_pct"))
+    blockers: list[str] = []
+    for gate in REQUIRED_OPERATIONAL_GATES:
+        if gates.get(gate) is not True:
+            blockers.append(f"{gate}_missing")
+    if closed < min_closed_trades:
+        blockers.append("sample_too_small")
+    if net_pnl <= min_net_pnl_usd:
+        blockers.append("net_pnl_not_positive")
+    if winrate < min_winrate_pct:
+        blockers.append("winrate_below_threshold")
+    shadow_candidate = not blockers
+    return {
+        "final_status": "shadow_candidate_not_live" if shadow_candidate else "paper_only",
+        "shadow_candidate": shadow_candidate,
+        "live_allowed": False,
+        "blockers": blockers,
+        "closed_trades": closed,
+        "net_pnl_usd": str(net_pnl),
+        "winrate_pct": str(winrate),
+        "required_operational_gates": list(REQUIRED_OPERATIONAL_GATES),
+    }
+
+
+def format_promotion_gate_report(result: dict[str, Any]) -> str:
+    blockers = result.get("blockers") or []
+    blocker_text = ",".join(blockers[:4]) if blockers else "keine"
+    return (
+        "Promotion Gates: "
+        f"status={result.get('final_status')}, "
+        f"closed={result.get('closed_trades')}, "
+        f"net_pnl={result.get('net_pnl_usd')} USDC, "
+        f"winrate={result.get('winrate_pct')}%, "
+        f"blocker={blocker_text}, "
+        "live=nein"
+    )
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description="Evaluate TradingView paper promotion gates.")
+    parser.add_argument("--runtime-dir", default="runtime")
+    parser.add_argument("--json", action="store_true")
+    args = parser.parse_args(argv)
+    perf = summarize_strategy_performance(args.runtime_dir)
+    result = evaluate_promotion_gates(perf)
+    if args.json:
+        print(json.dumps(result, indent=2, sort_keys=True))
+    else:
+        print(format_promotion_gate_report(result))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/src/tools/tradingview_strategy_performance.py b/src/tools/tradingview_strategy_performance.py
new file mode 100644
index 0000000..d120b46
--- /dev/null
+++ b/src/tools/tradingview_strategy_performance.py
@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+import argparse
+import json
+from collections import defaultdict
+from decimal import Decimal, InvalidOperation
+from pathlib import Path
+from typing import Any
+
+BRIDGE_ID = "tradingview_paper_bridge"
+
+
+def _read_jsonl(path: Path) -> list[dict[str, Any]]:
+    if not path.exists():
+        return []
+    rows: list[dict[str, Any]] = []
+    for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
+        if not line.strip():
+            continue
+        try:
+            rows.append(json.loads(line))
+        except json.JSONDecodeError:
+            continue
+    return rows
+
+
+def _decimal(value: Any) -> Decimal:
+    try:
+        return Decimal(str(value))
+    except (InvalidOperation, ValueError, TypeError):
+        return Decimal("0")
+
+
+def _fmt(value: Decimal, places: str = "0.01") -> str:
+    return str(value.quantize(Decimal(places)))
+
+
+def _empty_bucket() -> dict[str, Any]:
+    return {"closed_trades": 0, "wins": 0, "losses": 0, "net_pnl": Decimal("0")}
+
+
+def _finalize(bucket: dict[str, Any]) -> dict[str, Any]:
+    closed = int(bucket["closed_trades"])
+    wins = int(bucket["wins"])
+    losses = int(bucket["losses"])
+    net = bucket["net_pnl"]
+    winrate = (Decimal(wins) / Decimal(closed) * Decimal("100")) if closed else Decimal("0")
+    return {
+        "closed_trades": closed,
+        "wins": wins,
+        "losses": losses,
+        "winrate_pct": _fmt(winrate),
+        "net_pnl_usd": _fmt(net),
+    }
+
+
+def summarize_strategy_performance(runtime_dir: str | Path = "runtime") -> dict[str, Any]:
+    journal = Path(runtime_dir) / "experiments" / BRIDGE_ID / "trade_journal.jsonl"
+    exits = [row for row in _read_jsonl(journal) if row.get("event") == "paper_exit"]
+    overall = _empty_bucket()
+    by_strategy: dict[str, dict[str, Any]] = defaultdict(_empty_bucket)
+    by_coin: dict[str, dict[str, Any]] = defaultdict(_empty_bucket)
+    for row in exits:
+        pnl = _decimal(row.get("net_pnl_usd"))
+        strategy = str(row.get("strategy_id") or "unknown")
+        coin = str(row.get("coin") or "UNKNOWN").upper()
+        for bucket in (overall, by_strategy[strategy], by_coin[coin]):
+            bucket["closed_trades"] += 1
+            bucket["net_pnl"] += pnl
+            if pnl > 0:
+                bucket["wins"] += 1
+            elif pnl < 0:
+                bucket["losses"] += 1
+    return {
+        "overall": _finalize(overall),
+        "strategies": {k: _finalize(v) for k, v in sorted(by_strategy.items())},
+        "coins": {k: _finalize(v) for k, v in sorted(by_coin.items())},
+    }
+
+
+def recommend_strategy_actions(summary: dict[str, Any], *, min_closed_trades: int = 20) -> dict[str, str]:
+    recs: dict[str, str] = {}
+    for strategy, stats in summary.get("strategies", {}).items():
+        closed = int(stats.get("closed_trades") or 0)
+        pnl = _decimal(stats.get("net_pnl_usd"))
+        winrate = _decimal(stats.get("winrate_pct"))
+        if closed < min_closed_trades:
+            recs[strategy] = "continue_paper_too_few_trades"
+        elif pnl > 0 and winrate >= Decimal("50"):
+            recs[strategy] = "shadow_candidate_not_live"
+        elif pnl < 0 or winrate < Decimal("35"):
+            recs[strategy] = "pause_or_rework"
+        else:
+            recs[strategy] = "continue_paper_watch"
+    return recs
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description="Summarize TradingView paper strategy performance.")
+    parser.add_argument("--runtime-dir", default="runtime")
+    parser.add_argument("--min-closed-trades", type=int, default=20)
+    args = parser.parse_args(argv)
+    summary = summarize_strategy_performance(args.runtime_dir)
+    summary["recommendations"] = recommend_strategy_actions(summary, min_closed_trades=args.min_closed_trades)
+    print(json.dumps(summary, indent=2, sort_keys=True))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/src/tools/tradingview_webhook.py b/src/tools/tradingview_webhook.py
new file mode 100644
index 0000000..f55a10d
--- /dev/null
+++ b/src/tools/tradingview_webhook.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+import argparse
+import json
+import os
+from pathlib import Path
+from typing import Any
+
+from src.signals.tradingview import TradingViewWebhookConfig, handle_tradingview_payload
+
+
+def _split_csv(values: list[str] | None, env_value: str | None = None) -> set[str]:
+    items: list[str] = []
+    for value in values or []:
+        items.extend(part.strip() for part in value.split(","))
+    if env_value:
+        items.extend(part.strip() for part in env_value.split(","))
+    return {item for item in items if item}
+
+
+def _load_secret(args: argparse.Namespace) -> str:
+    if args.secret:
+        return args.secret
+    if args.secret_file:
+        return Path(args.secret_file).read_text(encoding="utf-8").strip()
+    env_secret = os.environ.get("CTB_TRADINGVIEW_WEBHOOK_SECRET")
+    if env_secret:
+        return env_secret.strip()
+    raise SystemExit("missing webhook secret: use --secret-file or CTB_TRADINGVIEW_WEBHOOK_SECRET")
+
+
+def _load_payload(args: argparse.Namespace) -> dict[str, Any]:
+    if args.payload_file:
+        return json.loads(Path(args.payload_file).read_text(encoding="utf-8"))
+    return json.loads(args.payload_json)
+
+
+def build_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(description="Ingest a TradingView alert into the paper-only signal journal.")
+    parser.add_argument("--payload-file")
+    parser.add_argument("--payload-json", default="{}")
+    parser.add_argument("--runtime-dir", default="runtime")
+    parser.add_argument("--secret")
+    parser.add_argument("--secret-file")
+    parser.add_argument("--header-secret", help="Testing/local CLI equivalent of X-CTB-Webhook-Secret.")
+    parser.add_argument("--allowed-strategy", action="append", default=[])
+    parser.add_argument("--allowed-coin", action="append", default=[])
+    parser.add_argument("--json", action="store_true")
+    return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = build_parser()
+    args = parser.parse_args(argv)
+    payload = _load_payload(args)
+    allowed_strategies = _split_csv(args.allowed_strategy, os.environ.get("CTB_TRADINGVIEW_ALLOWED_STRATEGIES"))
+    allowed_coins = {coin.upper() for coin in _split_csv(args.allowed_coin, os.environ.get("CTB_TRADINGVIEW_ALLOWED_COINS"))}
+    if not allowed_strategies:
+        raise SystemExit("missing allowed strategies")
+    if not allowed_coins:
+        raise SystemExit("missing allowed coins")
+    cfg = TradingViewWebhookConfig(
+        runtime_dir=Path(args.runtime_dir),
+        secret=_load_secret(args),
+        allowed_strategies=allowed_strategies,
+        allowed_coins=allowed_coins,
+    )
+    headers = {}
+    if args.header_secret:
+        headers["X-CTB-Webhook-Secret"] = args.header_secret
+    result = handle_tradingview_payload(payload, headers=headers, config=cfg)
+    if args.json:
+        print(json.dumps(result, indent=2, sort_keys=True))
+    else:
+        print(f"TradingView signal accepted={result.get('accepted')} reason={result.get('reason')}")
+    return 0 if result.get("accepted") else 2
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/src/tools/tradingview_webhook_server.py b/src/tools/tradingview_webhook_server.py
new file mode 100644
index 0000000..45b9937
--- /dev/null
+++ b/src/tools/tradingview_webhook_server.py
@@ -0,0 +1,130 @@
+from __future__ import annotations
+
+import argparse
+import json
+import os
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from typing import Any
+
+from src.signals.tradingview import TradingViewWebhookConfig, handle_tradingview_payload
+from src.tools.tradingview_webhook import _split_csv
+
+
+def _load_secret(args: argparse.Namespace) -> str:
+    if args.secret:
+        return args.secret
+    if args.secret_file:
+        return Path(args.secret_file).read_text(encoding="utf-8").strip()
+    env_secret = os.environ.get("CTB_TRADINGVIEW_WEBHOOK_SECRET")
+    if env_secret:
+        return env_secret.strip()
+    raise SystemExit("missing webhook secret: use --secret-file or CTB_TRADINGVIEW_WEBHOOK_SECRET")
+
+
+def _config_from_args(args: argparse.Namespace) -> TradingViewWebhookConfig:
+    allowed_strategies = _split_csv(args.allowed_strategy, os.environ.get("CTB_TRADINGVIEW_ALLOWED_STRATEGIES"))
+    allowed_coins = {coin.upper() for coin in _split_csv(args.allowed_coin, os.environ.get("CTB_TRADINGVIEW_ALLOWED_COINS"))}
+    if not allowed_strategies:
+        raise SystemExit("missing allowed strategies")
+    if not allowed_coins:
+        raise SystemExit("missing allowed coins")
+    return TradingViewWebhookConfig(
+        runtime_dir=Path(args.runtime_dir),
+        secret=_load_secret(args),
+        allowed_strategies=allowed_strategies,
+        allowed_coins=allowed_coins,
+    )
+
+
+def build_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(description="Run a paper-only TradingView webhook receiver.")
+    parser.add_argument("--host", default="127.0.0.1")
+    parser.add_argument("--port", type=int, default=8765)
+    parser.add_argument("--runtime-dir", default="runtime")
+    parser.add_argument("--secret")
+    parser.add_argument("--secret-file")
+    parser.add_argument("--allowed-strategy", action="append", default=[])
+    parser.add_argument("--allowed-coin", action="append", default=[])
+    return parser
+
+
+def _send_json(handler: BaseHTTPRequestHandler, status: int, payload: dict[str, Any]) -> None:
+    body = json.dumps(payload, sort_keys=True).encode("utf-8")
+    handler.send_response(status)
+    handler.send_header("Content-Type", "application/json")
+    handler.send_header("Content-Length", str(len(body)))
+    handler.end_headers()
+    handler.wfile.write(body)
+
+
+def _headers_dict(handler: BaseHTTPRequestHandler) -> dict[str, str]:
+    headers: dict[str, str] = {}
+    for key, value in handler.headers.items():
+        headers[str(key)] = str(value)
+        headers[str(key).lower()] = str(value)
+    return headers
+
+
+def make_handler(config: TradingViewWebhookConfig):
+    class TradingViewWebhookHandler(BaseHTTPRequestHandler):
+        server_version = "CTBTradingViewWebhook/1.0"
+
+        def log_message(self, format: str, *args: Any) -> None:  # keep secrets/payloads out of stdout by default
+            return
+
+        def do_GET(self) -> None:  # noqa: N802 - stdlib callback name
+            if self.path == "/healthz":
+                _send_json(self, 200, {"status": "ok", "mode": "paper_signal", "live_order_allowed": False})
+                return
+            _send_json(self, 404, {"status": "not_found", "live_order_allowed": False})
+
+        def do_POST(self) -> None:  # noqa: N802 - stdlib callback name
+            if self.path != "/webhook/tradingview":
+                _send_json(self, 404, {"accepted": False, "reason": "not_found", "live_order_allowed": False})
+                return
+            length_header = self.headers.get("Content-Length") or "0"
+            try:
+                length = int(length_header)
+            except ValueError:
+                _send_json(self, 400, {"accepted": False, "reason": "invalid_content_length", "live_order_allowed": False})
+                return
+            if length <= 0 or length > 65536:
+                _send_json(self, 400, {"accepted": False, "reason": "invalid_body_size", "live_order_allowed": False})
+                return
+            raw = self.rfile.read(length)
+            try:
+                payload = json.loads(raw.decode("utf-8"))
+            except (UnicodeDecodeError, json.JSONDecodeError):
+                _send_json(self, 400, {"accepted": False, "reason": "invalid_json", "live_order_allowed": False})
+                return
+            if not isinstance(payload, dict):
+                _send_json(self, 400, {"accepted": False, "reason": "invalid_payload", "live_order_allowed": False})
+                return
+            result = handle_tradingview_payload(payload, headers=_headers_dict(self), config=config)
+            _send_json(self, 200 if result.get("accepted") else 403, result)
+
+    return TradingViewWebhookHandler
+
+
+def make_server(args: argparse.Namespace) -> ThreadingHTTPServer:
+    config = _config_from_args(args)
+    return ThreadingHTTPServer((args.host, args.port), make_handler(config))
+
+
+def main(argv: list[str] | None = None) -> int:
+    args = build_parser().parse_args(argv)
+    server = make_server(args)
+    host, port = server.server_address[:2]
+    print(f"TradingView webhook receiver listening on http://{host}:{port}/webhook/tradingview mode=paper_signal live_order_allowed=false", flush=True)
+    try:
+        server.serve_forever()
+    except KeyboardInterrupt:
+        pass
+    finally:
+        server.server_close()
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/src/tools/v76_paper_runtime.py b/src/tools/v76_paper_runtime.py
index 5602ac8..7993700 100644
--- a/src/tools/v76_paper_runtime.py
+++ b/src/tools/v76_paper_runtime.py
@@ -115,6 +115,68 @@ def _anti_chase_context(coin: str, mid: Decimal, prev: Decimal | None, dq_allowe
     )
 
 
+def _to_decimal(value: Any, default: str = "0") -> Decimal:
+    try:
+        return Decimal(str(value))
+    except Exception:
+        return Decimal(default)
+
+
+def _close_anti_chase_positions(*, state: dict[str, Any], mids: dict[str, Decimal], runtime_dir: Path, strategy_id: str, research: bool) -> tuple[int, set[str]]:
+    open_positions = state.setdefault("open_positions", {})
+    if not isinstance(open_positions, dict) or not open_positions:
+        state["open_positions"] = {} if not isinstance(open_positions, dict) else open_positions
+        return 0, set()
+    closed = 0
+    closed_coins: set[str] = set()
+    for coin, pos in list(open_positions.items()):
+        mid = mids.get(str(coin).upper())
+        if mid is None or not isinstance(pos, dict):
+            continue
+        entry = _to_decimal(pos.get("entry"))
+        size = _to_decimal(pos.get("size"))
+        stop_loss = _to_decimal(pos.get("stop_loss"))
+        if entry <= 0 or size <= 0:
+            continue
+        exit_reason = None
+        if stop_loss > 0 and mid <= stop_loss:
+            exit_reason = "stop_loss"
+        elif mid >= entry * Decimal("1.012"):
+            exit_reason = "profit_target"
+        if exit_reason is None:
+            continue
+        entry_fee = _to_decimal(pos.get("entry_fee_usd"))
+        spread_cost = _to_decimal(pos.get("spread_cost_usd"))
+        slippage_cost = _to_decimal(pos.get("slippage_cost_usd"))
+        exit_fee = (mid * size * Decimal("0.0004")).quantize(Decimal("0.00000001"))
+        gross = (mid - entry) * size
+        net = gross - entry_fee - exit_fee - spread_cost - slippage_cost
+        _append_jsonl(runtime_dir / "trade_journal.jsonl", {
+            "timestamp": datetime.now(timezone.utc).isoformat(),
+            "event": "exit",
+            "strategy_id": strategy_id,
+            "coin": str(coin).upper(),
+            "paper_trading": True,
+            "research": research,
+            "entry_price": str(entry),
+            "exit_price": str(mid),
+            "size": str(size),
+            "exit_reason": exit_reason,
+            "realized_pnl_usd": str(net),
+            "net_pnl_usd": str(net),
+            "gross_pnl_usd": str(gross),
+            "entry_fee_usd": str(entry_fee),
+            "exit_fee_usd": str(exit_fee),
+            "spread_cost_usd": str(spread_cost),
+            "slippage_cost_usd": str(slippage_cost),
+            "mainnet_signed_action": False,
+        })
+        del open_positions[coin]
+        closed += 1
+        closed_coins.add(str(coin).upper())
+    return closed, closed_coins
+
+
 def scan_once(strategy_id: str, coins: list[str], *, env: str = "mainnet") -> dict[str, Any]:
     if os.getenv("CTB_LIVE_TRADING_ALLOWED", "").lower() == "true":
         raise PermissionError("v76 paper runtime refuses CTB_LIVE_TRADING_ALLOWED=true")
@@ -145,6 +207,9 @@ def scan_once(strategy_id: str, coins: list[str], *, env: str = "mainnet") -> di
     dq_gate = DataQualityGate(max_spread_pct=params["max_spread"])
     cost_model = CostModel()
     executor = PaperExecutor(cost_model=cost_model)
+    closed_positions, closed_this_tick = (0, set())
+    if params.get("anti_chase"):
+        closed_positions, closed_this_tick = _close_anti_chase_positions(state=state, mids=mids, runtime_dir=runtime_dir, strategy_id=strategy_id, research=bool(params["research"]))
     recent = deque(state.get("recent_trade_coins", []), maxlen=30)
     top_coin, top_count = Counter(recent).most_common(1)[0] if recent else (None, 0)
     top_share = Decimal(str(round(top_count / len(recent) * 100, 4))) if recent else Decimal("0")
@@ -186,6 +251,8 @@ def scan_once(strategy_id: str, coins: list[str], *, env: str = "mainnet") -> di
             reasons.append("leakage")
         if params.get("anti_chase") and coin in state.get("open_positions", {}):
             reasons.append("already_open")
+        if params.get("anti_chase") and coin in closed_this_tick:
+            reasons.append("closed_this_tick")
         if intent:
             risk = PretradeRiskGate(Decimal("10"), 1, Decimal("15"), Decimal("0.50")).evaluate(intent, RiskContext(0, False, False))
             risk_gate_result = {"allowed": risk.allowed, "reasons": list(risk.reasons)}
@@ -215,7 +282,7 @@ def scan_once(strategy_id: str, coins: list[str], *, env: str = "mainnet") -> di
         signals += 1
     state["recent_trade_coins"] = list(recent)
     state_path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
-    return {"status": "ok", "strategy_id": strategy_id, "runtime_dir": str(runtime_dir), "signals_seen": signals, "trades": trades, "blocked": dict(blocked), "mainnet_signed_action": False, "paper_trading": True}
+    return {"status": "ok", "strategy_id": strategy_id, "runtime_dir": str(runtime_dir), "signals_seen": signals, "trades": trades, "closed_positions": closed_positions, "blocked": dict(blocked), "mainnet_signed_action": False, "paper_trading": True}
 
 
 def supervisor_status(strategy_ids: list[str]) -> dict[str, Any]:
diff --git a/telegram_alerts.py b/telegram_alerts.py
index 2f41299..8d6ad8e 100644
--- a/telegram_alerts.py
+++ b/telegram_alerts.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 import os
 from dataclasses import dataclass
+from pathlib import Path
 from typing import Any, Callable
 
 import requests
@@ -9,6 +10,23 @@ import requests
 from alerting import AlertPlan
 
 
+def _parse_env_file(path: Path) -> dict[str, str]:
+    values: dict[str, str] = {}
+    if not path.exists():
+        return values
+    for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
+        stripped = line.strip()
+        if not stripped or stripped.startswith("#") or "=" not in stripped:
+            continue
+        key, value = stripped.split("=", 1)
+        values[key.strip()] = value.strip().strip('"').strip("'")
+    return values
+
+
+def _env_value(key: str, file_values: dict[str, str]) -> str | None:
+    return os.getenv(key) or file_values.get(key)
+
+
 @dataclass(frozen=True)
 class TelegramAlertConfig:
     enabled: bool = False
@@ -19,14 +37,18 @@ class TelegramAlertConfig:
 
     @classmethod
     def from_env(cls) -> "TelegramAlertConfig":
-        enabled = os.getenv("CTB_TELEGRAM_ALERTS", "false").strip().lower() in {"1", "true", "yes", "on", "y"}
-        thread_raw = os.getenv("CTB_TELEGRAM_THREAD_ID")
+        env_file = Path(os.getenv("CTB_TELEGRAM_ENV_FILE", str(Path.home() / ".hermes" / "secrets" / "crypto_agent_telegram.env")))
+        file_values = _parse_env_file(env_file)
+        enabled_raw = _env_value("CTB_TELEGRAM_ALERTS", file_values) or "false"
+        enabled = enabled_raw.strip().lower() in {"1", "true", "yes", "on", "y"}
+        thread_raw = _env_value("CTB_TELEGRAM_THREAD_ID", file_values)
+        timeout_raw = _env_value("CTB_TELEGRAM_TIMEOUT", file_values) or "10"
         return cls(
             enabled=enabled,
-            bot_token=os.getenv("CTB_TELEGRAM_BOT_TOKEN"),
-            chat_id=os.getenv("CTB_TELEGRAM_CHAT_ID"),
+            bot_token=_env_value("CTB_TELEGRAM_BOT_TOKEN", file_values),
+            chat_id=_env_value("CTB_TELEGRAM_CHAT_ID", file_values),
             thread_id=int(thread_raw) if thread_raw else None,
-            timeout_seconds=int(os.getenv("CTB_TELEGRAM_TIMEOUT", "10")),
+            timeout_seconds=int(timeout_raw),
         )
 
 
diff --git a/tests/test_global_strategy_policy.py b/tests/test_global_strategy_policy.py
new file mode 100644
index 0000000..c5c89e0
--- /dev/null
+++ b/tests/test_global_strategy_policy.py
@@ -0,0 +1,159 @@
+from decimal import Decimal
+from typing import Literal
+
+from src.execution.order_intent import OrderIntent
+from src.strategies.global_strategy_policy import (
+    CopyObservationEvidence,
+    GlobalStrategyContext,
+    MarketRegimeContext,
+    PromotionEvidence,
+    StrategyCandidate,
+    evaluate_global_strategy,
+)
+
+
+def make_intent(side: Literal["buy", "sell"] = "buy", strategy_id="candidate_v76_strict_live_candidate"):
+    return OrderIntent(
+        strategy_id=strategy_id,
+        symbol="BTC/USDC:USDC",
+        coin="BTC",
+        side=side,
+        reduce_only=False,
+        order_type="market",
+        tif="Ioc",
+        size=Decimal("0.001"),
+        price=None,
+        trigger_price=None,
+        stop_loss=Decimal("99000"),
+        take_profit=None,
+        client_order_id="test-1",
+        reason="confirmed_retest",
+        risk_usd=Decimal("0.10"),
+        estimated_notional_usd=Decimal("10"),
+    )
+
+
+def passing_promotion(**overrides):
+    data = dict(
+        closed_trades=35,
+        total_net_pnl=Decimal("4.2"),
+        last_20_net_pnl=Decimal("1.1"),
+        win_rate_pct=Decimal("48"),
+        profit_factor=Decimal("1.31"),
+        top_coin_share_pct=Decimal("25"),
+        short_scalp_share_pct=Decimal("10"),
+        max_drawdown_usd=Decimal("1.2"),
+        replay_only=False,
+        simulator_sanity_ok=True,
+    )
+    data.update(overrides)
+    return PromotionEvidence(**data)
+
+
+def base_context(**overrides):
+    data = dict(
+        market_regime=MarketRegimeContext(btc_trend="bullish", eth_trend="bullish", choppy=False),
+        copy_observation=CopyObservationEvidence(cycles_completed=69, expected_cycles=84, allowed_signals=0, shadow_decisions=2157, data_quality_ok=True),
+        reconcile_clean=True,
+        stops_confirmed=True,
+        alerts_confirmed=True,
+        kill_switch_active=False,
+        live_entries_blocked=True,
+    )
+    data.update(overrides)
+    return GlobalStrategyContext(**data)
+
+
+def test_global_policy_blocks_copy_executor_when_observation_has_zero_allowed_signals():
+    candidate = StrategyCandidate(
+        strategy_id="copy_direct_executor",
+        family="copy",
+        intent=make_intent(strategy_id="copy_direct_executor"),
+        promotion=passing_promotion(),
+    )
+
+    decision = evaluate_global_strategy(candidate, base_context())
+
+    assert decision.allowed is False
+    assert decision.status == "research"
+    assert "copy_observation_has_no_allowed_signals" in decision.reasons
+    assert decision.recommended_mode == "copy_research_only"
+
+
+def test_global_policy_blocks_long_trade_in_bearish_or_choppy_regime():
+    candidate = StrategyCandidate(
+        strategy_id="candidate_v76_strict_live_candidate",
+        family="v76_anti_chase",
+        intent=make_intent(side="buy"),
+        promotion=passing_promotion(),
+    )
+
+    bearish = base_context(market_regime=MarketRegimeContext(btc_trend="bearish", eth_trend="bearish", choppy=False))
+    choppy = base_context(market_regime=MarketRegimeContext(btc_trend="mixed", eth_trend="bullish", choppy=True))
+
+    assert "market_regime_blocks_long" in evaluate_global_strategy(candidate, bearish).reasons
+    assert "market_regime_choppy_no_trade" in evaluate_global_strategy(candidate, choppy).reasons
+
+
+def test_global_policy_requires_cost_hurdle_and_anti_chase_confirmation():
+    candidate = StrategyCandidate(
+        strategy_id="candidate_v76_strict_live_candidate",
+        family="v76_anti_chase",
+        intent=make_intent(),
+        promotion=passing_promotion(),
+        expected_move_vs_cost=Decimal("2.9"),
+        anti_chase_ok=False,
+        retest_confirmed=False,
+    )
+
+    decision = evaluate_global_strategy(candidate, base_context())
+
+    assert decision.allowed is False
+    assert "expected_move_below_cost_hurdle" in decision.reasons
+    assert "anti_chase_not_confirmed" in decision.reasons
+    assert "retest_not_confirmed" in decision.reasons
+
+
+def test_global_policy_blocks_live_until_promotion_and_safety_gates_are_green():
+    candidate = StrategyCandidate(
+        strategy_id="candidate_v76_strict_live_candidate",
+        family="v76_anti_chase",
+        intent=make_intent(),
+        promotion=passing_promotion(closed_trades=12, total_net_pnl=Decimal("-0.1"), replay_only=True),
+        expected_move_vs_cost=Decimal("4.5"),
+        anti_chase_ok=True,
+        retest_confirmed=True,
+    )
+    context = base_context(reconcile_clean=False, stops_confirmed=False, alerts_confirmed=False)
+
+    decision = evaluate_global_strategy(candidate, context)
+
+    assert decision.allowed is False
+    assert "sample_too_small" in decision.reasons
+    assert "paper_total_pnl_not_positive" in decision.reasons
+    assert "replay_only_not_promotable" in decision.reasons
+    assert "reconcile_not_clean" in decision.reasons
+    assert "stops_not_confirmed" in decision.reasons
+    assert "alerts_not_confirmed" in decision.reasons
+
+
+def test_global_policy_allows_tiny_live_preview_only_when_every_gate_is_green():
+    candidate = StrategyCandidate(
+        strategy_id="candidate_v76_strict_live_candidate",
+        family="v76_anti_chase",
+        intent=make_intent(),
+        promotion=passing_promotion(),
+        expected_move_vs_cost=Decimal("4.5"),
+        anti_chase_ok=True,
+        retest_confirmed=True,
+        max_effective_leverage=Decimal("1"),
+        max_open_positions=1,
+    )
+
+    decision = evaluate_global_strategy(candidate, base_context())
+
+    assert decision.allowed is True
+    assert decision.status == "tiny_live_preview_ready"
+    assert decision.recommended_mode == "tiny_live_preview"
+    assert decision.risk_limits["max_effective_leverage"] == "1"
+    assert decision.risk_limits["max_open_positions"] == 1
diff --git a/tests/test_hyperliquid_daily_report_tradingview.py b/tests/test_hyperliquid_daily_report_tradingview.py
new file mode 100644
index 0000000..a3a0ec4
--- /dev/null
+++ b/tests/test_hyperliquid_daily_report_tradingview.py
@@ -0,0 +1,112 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from src.tools.hyperliquid_daily_report import summarize_tradingview_signals, build_tradingview_report_lines
+from src.tools.tradingview_paper_bridge import process_pending_tradingview_signals
+
+
+def _append(path: Path, row: dict) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    with path.open("a", encoding="utf-8") as fh:
+        fh.write(json.dumps(row) + "\n")
+
+
+def test_summarize_tradingview_signals_counts_paper_only_runtime(tmp_path):
+    journal = tmp_path / "signals" / "signal_journal.jsonl"
+    _append(journal, {
+        "source": "tradingview",
+        "strategy_id": "gaussian_channel_v1",
+        "coin": "BTC",
+        "action": "entry",
+        "side": "long",
+        "execution_mode": "paper_signal",
+        "live_order_allowed": False,
+    })
+    _append(journal, {
+        "source": "tradingview",
+        "strategy_id": "squeeze_breakout_1h",
+        "coin": "ETH",
+        "action": "exit",
+        "side": "short",
+        "execution_mode": "paper_signal",
+        "live_order_allowed": False,
+    })
+
+    summary = summarize_tradingview_signals(tmp_path)
+
+    assert summary["received"] == 2
+    assert summary["rejected"] == 0
+    assert summary["paper_signal"] == 2
+    assert summary["live_order_allowed_count"] == 0
+    assert summary["by_strategy"] == {"gaussian_channel_v1": 1, "squeeze_breakout_1h": 1}
+    assert summary["by_coin"] == {"BTC": 1, "ETH": 1}
+
+
+def test_tradingview_report_lines_state_no_live_when_no_signals(tmp_path):
+    lines = build_tradingview_report_lines(tmp_path)
+
+    assert "TradingView Signals: empfangen=0" in "\n".join(lines)
+    assert "Live-Freigabe aus TradingView: nein" in "\n".join(lines)
+
+
+def test_tradingview_report_lines_warn_if_any_live_flag_appears(tmp_path):
+    journal = tmp_path / "signals" / "signal_journal.jsonl"
+    _append(journal, {
+        "source": "tradingview",
+        "strategy_id": "bad_live_flag",
+        "coin": "BTC",
+        "action": "entry",
+        "side": "long",
+        "execution_mode": "paper_signal",
+        "live_order_allowed": True,
+    })
+
+    lines = build_tradingview_report_lines(tmp_path)
+
+    joined = "\n".join(lines)
+    assert "empfangen=1" in joined
+    assert "Live-Freigabe aus TradingView: BLOCKIERT" in joined
+
+
+def test_summarize_tradingview_rejected_reasons(tmp_path):
+    reject_journal = tmp_path / "signals" / "rejected_signal_journal.jsonl"
+    _append(reject_journal, {"source": "tradingview", "reason": "strategy_not_allowed", "live_order_allowed": False})
+    _append(reject_journal, {"source": "tradingview", "reason": "duplicate_signal", "live_order_allowed": False})
+    _append(reject_journal, {"source": "tradingview", "reason": "duplicate_signal", "live_order_allowed": False})
+
+    summary = summarize_tradingview_signals(tmp_path)
+    lines = build_tradingview_report_lines(tmp_path)
+
+    assert summary["rejected"] == 3
+    assert summary["reject_reasons"] == {"duplicate_signal": 2, "strategy_not_allowed": 1}
+    assert "abgelehnt=3" in "\n".join(lines)
+    assert "Rejects: {'duplicate_signal': 2, 'strategy_not_allowed': 1}" in "\n".join(lines)
+
+
+def test_tradingview_report_lines_include_paper_bridge_summary(tmp_path):
+    journal = tmp_path / "signals" / "signal_journal.jsonl"
+    _append(journal, {
+        "source": "tradingview",
+        "signal_id": "tv-report-1",
+        "strategy_id": "gaussian_channel_v1",
+        "coin": "BTC",
+        "side": "long",
+        "action": "entry",
+        "timeframe": "1h",
+        "price": "60000",
+        "timestamp": "2026-06-28T18:00:00Z",
+        "execution_mode": "paper_signal",
+        "live_order_allowed": False,
+        "mainnet_signed_action": False,
+    })
+    process_pending_tradingview_signals(runtime_dir=tmp_path)
+
+    joined = "\n".join(build_tradingview_report_lines(tmp_path))
+
+    assert "TradingView Paper Bridge: entries=1" in joined
+    assert "Bridge Strategien: {'gaussian_channel_v1': 1}" in joined
+    assert "Promotion Gates:" in joined
+    assert "Operational Gates:" in joined
+    assert "live=nein" in joined
diff --git a/tests/test_paper_scorecard_report.py b/tests/test_paper_scorecard_report.py
index 1d4c147..6d9b848 100644
--- a/tests/test_paper_scorecard_report.py
+++ b/tests/test_paper_scorecard_report.py
@@ -28,4 +28,20 @@ def test_paper_scorecard_report_is_read_only_and_ranks_by_score(tmp_path: Path)
     assert report.index("strong") < report.index("weak")
     assert "Live-preview-ready" in report
     assert "Blocked" in report
+    assert "global_policy=Blocked" in report
+    assert "expected_move_below_cost_hurdle" in report
     assert "orders" not in report.lower()
+
+
+def test_paper_scorecard_report_can_mark_global_policy_tiny_ready_with_explicit_evidence(tmp_path: Path) -> None:
+    runtime = tmp_path / "state"
+    _write_journal(runtime / "experiments" / "strong" / "Tradeanalyse" / "trade_journal.jsonl", "strong", [0.8, 0.6, -0.1, 0.5, 0.7, -0.1] * 5)
+    signal_path = runtime / "experiments" / "strong" / "Tradeanalyse" / "signal_journal.jsonl"
+    signal_path.write_text("\n".join(json.dumps({"expected_move_vs_cost": "4.5", "final_decision": "paper_entered"}) for _ in range(30)) + "\n", encoding="utf-8")
+    evidence = runtime / "strategy_policy_context.json"
+    evidence.write_text(json.dumps({"reconcile_clean": True, "stops_confirmed": True, "alerts_confirmed": True, "kill_switch_active": False, "market_regime": {"btc_trend": "bullish", "eth_trend": "bullish", "choppy": False}}), encoding="utf-8")
+
+    report = build_paper_scorecard_report(runtime_dir=runtime)
+
+    assert "global_policy=Tiny-live-preview-ready" in report
+    assert "recommended_mode=tiny_live_preview" in report
diff --git a/tests/test_telegram_paper.py b/tests/test_telegram_paper.py
index 0c03e56..5577546 100644
--- a/tests/test_telegram_paper.py
+++ b/tests/test_telegram_paper.py
@@ -3,9 +3,10 @@ from pathlib import Path
 from alerting import AlertPlan
 
 
-def test_telegram_config_defaults_disabled(monkeypatch):
+def test_telegram_config_defaults_disabled(monkeypatch, tmp_path):
     from telegram_alerts import TelegramAlertConfig
 
+    monkeypatch.setenv("CTB_TELEGRAM_ENV_FILE", str(tmp_path / "missing.env"))
     monkeypatch.delenv("CTB_TELEGRAM_ALERTS", raising=False)
     cfg = TelegramAlertConfig.from_env()
 
@@ -30,6 +31,25 @@ def test_telegram_config_reads_topic_env(monkeypatch):
     assert cfg.thread_id == 6579
 
 
+def test_telegram_config_can_load_secret_env_file(monkeypatch, tmp_path):
+    from telegram_alerts import TelegramAlertConfig
+
+    env_file = tmp_path / "crypto_agent_telegram.env"
+    env_file.write_text("CTB_TELEGRAM_ALERTS=true\nCTB_TELEGRAM_BOT_TOKEN=file-token\nCTB_TELEGRAM_CHAT_ID=-100456\nCTB_TELEGRAM_THREAD_ID=6579\n", encoding="utf-8")
+    monkeypatch.setenv("CTB_TELEGRAM_ENV_FILE", str(env_file))
+    monkeypatch.delenv("CTB_TELEGRAM_BOT_TOKEN", raising=False)
+    monkeypatch.delenv("CTB_TELEGRAM_CHAT_ID", raising=False)
+    monkeypatch.delenv("CTB_TELEGRAM_THREAD_ID", raising=False)
+    monkeypatch.delenv("CTB_TELEGRAM_ALERTS", raising=False)
+
+    cfg = TelegramAlertConfig.from_env()
+
+    assert cfg.enabled is True
+    assert cfg.bot_token == "file-token"
+    assert cfg.chat_id == "-100456"
+    assert cfg.thread_id == 6579
+
+
 def test_telegram_disabled_returns_skipped_without_sender():
     from telegram_alerts import TelegramAlertConfig, send_telegram_alert
 
diff --git a/tests/test_trade_timing_analysis.py b/tests/test_trade_timing_analysis.py
new file mode 100644
index 0000000..5c6632f
--- /dev/null
+++ b/tests/test_trade_timing_analysis.py
@@ -0,0 +1,38 @@
+from decimal import Decimal
+from datetime import datetime, timezone, timedelta
+
+from src.tools.analyze_trade_timing import Candle, assess_entry, assess_exit, rsi
+
+
+def c(i, o, h, l, close):
+    return Candle(datetime(2026, 1, 1, tzinfo=timezone.utc) + timedelta(minutes=15*i), Decimal(str(o)), Decimal(str(h)), Decimal(str(l)), Decimal(str(close)), Decimal("1000"))
+
+
+def test_entry_flags_chased_pre_move_and_adverse_excursion():
+    pre = [c(0, 100, 101, 99, 100), c(1, 101, 103, 100, 102), c(2, 102, 104, 101, 103.5), c(3, 103.5, 105, 103, 104.5)]
+    post = [c(4, 104.5, 104.7, 103.6, 103.8), c(5, 103.8, 104.0, 102.9, 103.0)]
+    result = assess_entry(Decimal("104.5"), pre, post)
+    assert "entry_likely_chased_after_pre_move" in result["flags"]
+    assert "entry_poor_immediate_adverse_excursion" in result["flags"]
+    assert result["quality"] == "bad"
+
+
+def test_entry_good_follow_through():
+    pre = [c(i, 100, 100.3, 99.8, 100 + (0.02 if i % 2 == 0 else -0.01)) for i in range(20)]
+    post = [c(20, 102, 103.5, 101.9, 103), c(21, 103, 104, 102.8, 103.7)]
+    result = assess_entry(Decimal("102"), pre, post)
+    assert "entry_good_follow_through" in result["flags"]
+    assert result["quality"] == "good"
+
+
+def test_exit_flags_too_early_when_followthrough_after_profit_target():
+    pre = [c(i, 100+i*0.1, 100+i*0.1+0.2, 99+i*0.1, 100+i*0.1+0.1) for i in range(20)]
+    post = [c(20, 102, 103.5, 101.8, 103.2), c(21, 103.2, 104.0, 103, 103.8)]
+    result = assess_exit(Decimal("102"), pre, post, "profit_target")
+    assert "exit_too_early_missed_followthrough" in result["flags"]
+    assert result["quality"] == "bad"
+
+
+def test_rsi_returns_high_value_on_consistent_gains():
+    values = [Decimal(i) for i in range(1, 20)]
+    assert rsi(values) == Decimal("100")
diff --git a/tests/test_tradingview_alert_proof.py b/tests/test_tradingview_alert_proof.py
new file mode 100644
index 0000000..2851ee8
--- /dev/null
+++ b/tests/test_tradingview_alert_proof.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from src.tools.tradingview_alert_proof import assess_alert_proof, update_preflight_alert_evidence
+
+
+def _write(path: Path, payload: dict) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(json.dumps(payload), encoding="utf-8")
+
+
+def test_alert_proof_accepts_all_sent_smoke(tmp_path):
+    report = tmp_path / "reports" / "telegram_alert_smoke_latest.json"
+    _write(report, {"status": "ok", "secrets_sent": False, "alerts": [{"name": "info", "sent": True}, {"name": "kill_switch", "sent": True}]})
+
+    proof = assess_alert_proof(report)
+
+    assert proof["alerts_proven"] is True
+    assert proof["blockers"] == []
+
+
+def test_alert_proof_blocks_disabled_or_partial_smoke(tmp_path):
+    report = tmp_path / "reports" / "telegram_alert_smoke_latest.json"
+    _write(report, {"status": "skipped", "secrets_sent": False, "alerts": [{"name": "info", "sent": False, "reason": "disabled"}]})
+
+    proof = assess_alert_proof(report)
+
+    assert proof["alerts_proven"] is False
+    assert "alert_smoke_not_ok" in proof["blockers"]
+    assert "alert_not_sent:info" in proof["blockers"]
+
+
+def test_update_preflight_alert_evidence_sets_boolean_and_details(tmp_path):
+    preflight = tmp_path / "reports" / "preflight_evidence_latest.json"
+    _write(preflight, {"tests_green": True, "telegram_alerts_tested": False})
+    report = tmp_path / "reports" / "telegram_alert_smoke_latest.json"
+    _write(report, {"status": "ok", "secrets_sent": False, "alerts": [{"name": "info", "sent": True}]})
+
+    result = update_preflight_alert_evidence(runtime_dir=tmp_path, alert_report_path=report)
+    updated = json.loads(preflight.read_text(encoding="utf-8"))
+
+    assert result["alerts_proven"] is True
+    assert updated["telegram_alerts_tested"] is True
+    assert updated["telegram_alert_proof"]["alerts_proven"] is True
diff --git a/tests/test_tradingview_community_ideas.py b/tests/test_tradingview_community_ideas.py
new file mode 100644
index 0000000..1c3c617
--- /dev/null
+++ b/tests/test_tradingview_community_ideas.py
@@ -0,0 +1,72 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from src.tools.tradingview_community_ideas import (
+    attach_price_snapshot,
+    classify_idea_bias,
+    parse_ideas_html,
+    summarize_community_ideas,
+    write_ideas_snapshot,
+)
+
+
+HTML = """
+<html><body>
+<a href="https://www.tradingview.com/chart/BTCUSDT/abc-BTC-breakout-next/">BTC breakout toward 70000 next?</a>
+<a href="https://www.tradingview.com/chart/BTCUSDT/abc-BTC-breakout-next/">Bitcoin holds demand zone and could rally after breakout confirmation.</a>
+<a href="https://www.tradingview.com/chart/BTCUSDT/abc-BTC-breakout-next/#chart-view-comment-form">comments</a>
+<a href="https://www.tradingview.com/chart/BTCUSDT/def-BTC-dump-risk/">BTC downside risk below support</a>
+<a href="https://www.tradingview.com/chart/BTCUSDT/def-BTC-dump-risk/">Breakdown can trigger bearish continuation and short setup.</a>
+<a href="https://www.tradingview.com/symbols/BTCUSDT/">BTCUSDT</a>
+</body></html>
+"""
+
+
+def test_parse_ideas_html_dedupes_chart_links_and_keeps_research_only() -> None:
+    ideas = parse_ideas_html(HTML, coin="BTC", source_url="https://example.test", limit=10)
+
+    assert len(ideas) == 2
+    assert ideas[0]["coin"] == "BTC"
+    assert ideas[0]["author"] == "unknown"
+    assert ideas[0]["research_only"] is True
+    assert ideas[0]["live_order_allowed"] is False
+    assert ideas[0]["mainnet_signed_action"] is False
+    assert ideas[0]["title"] == "BTC breakout toward 70000 next?"
+    assert "demand zone" in ideas[0]["excerpt"]
+    assert ideas[0]["bias"] == "bullish"
+    assert ideas[1]["bias"] == "bearish"
+
+
+def test_classify_idea_bias_handles_mixed_and_neutral() -> None:
+    assert classify_idea_bias("strong rally breakout long") == "bullish"
+    assert classify_idea_bias("bearish breakdown short downside") == "bearish"
+    assert classify_idea_bias("support and resistance range watch") == "neutral"
+    assert classify_idea_bias("rally but downside risk") == "mixed"
+
+
+def test_attach_price_snapshot_uses_hyperliquid_coin_mids() -> None:
+    ideas = parse_ideas_html(HTML, coin="BTC", source_url="https://example.test", limit=1)
+    enriched = attach_price_snapshot(ideas, {"BTC": "60000.5"})
+
+    assert enriched[0]["price_at_capture_usd"] == "60000.5"
+    assert enriched[0]["price_source"] == "hyperliquid_all_mids"
+    assert enriched[0]["live_order_allowed"] is False
+
+
+def test_write_and_summarize_community_ideas(tmp_path: Path) -> None:
+    ideas = parse_ideas_html(HTML, coin="BTC", source_url="https://example.test", limit=10)
+    path = write_ideas_snapshot(ideas, runtime_dir=tmp_path)
+
+    assert path == tmp_path / "research" / "tradingview_community_ideas.jsonl"
+    rows = [json.loads(line) for line in path.read_text().splitlines()]
+    assert len(rows) == 2
+    assert all(row["research_only"] is True for row in rows)
+
+    summary = summarize_community_ideas(tmp_path)
+    assert summary["total"] == 2
+    assert summary["by_coin"] == {"BTC": 2}
+    assert summary["by_bias"] == {"bullish": 1, "bearish": 1}
+    assert summary["live_order_allowed_count"] == 0
+    assert summary["recommendation"] == "research_only_never_trade_directly"
diff --git a/tests/test_tradingview_community_track_record.py b/tests/test_tradingview_community_track_record.py
new file mode 100644
index 0000000..4e9aef7
--- /dev/null
+++ b/tests/test_tradingview_community_track_record.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime, timezone
+from pathlib import Path
+
+from src.tools.tradingview_community_track_record import evaluate_due_ideas, summarize_track_record
+
+
+def _append(path: Path, row: dict) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    with path.open("a", encoding="utf-8") as fh:
+        fh.write(json.dumps(row) + "\n")
+
+
+def test_evaluate_due_ideas_scores_bullish_and_bearish_without_live_flags(tmp_path: Path) -> None:
+    ideas = tmp_path / "research" / "tradingview_community_ideas.jsonl"
+    _append(ideas, {
+        "timestamp": "2026-06-27T00:00:00+00:00",
+        "url": "https://www.tradingview.com/chart/BTCUSDT/a/",
+        "author": "alice",
+        "coin": "BTC",
+        "bias": "bullish",
+        "title": "BTC long",
+        "price_at_capture_usd": "100",
+        "research_only": True,
+        "live_order_allowed": False,
+    })
+    _append(ideas, {
+        "timestamp": "2026-06-27T00:00:00+00:00",
+        "url": "https://www.tradingview.com/chart/ETHUSDT/b/",
+        "author": "bob",
+        "coin": "ETH",
+        "bias": "bearish",
+        "title": "ETH short",
+        "price_at_capture_usd": "200",
+        "research_only": True,
+        "live_order_allowed": False,
+    })
+
+    result = evaluate_due_ideas(
+        tmp_path,
+        current_prices={"BTC": "110", "ETH": "180"},
+        horizons_hours=(24,),
+        now=datetime(2026, 6, 29, tzinfo=timezone.utc),
+    )
+
+    assert result["evaluated_new"] == 2
+    summary = summarize_track_record(tmp_path)
+    assert summary["evaluated"] == 2
+    assert summary["by_author"]["alice"]["hitrate_pct"] == "100.00"
+    assert summary["by_author"]["bob"]["hitrate_pct"] == "100.00"
+
+    rows = [json.loads(line) for line in (tmp_path / "research" / "tradingview_community_idea_evaluations.jsonl").read_text().splitlines()]
+    assert all(row["research_only"] is True for row in rows)
+    assert all(row["live_order_allowed"] is False for row in rows)
+    assert all(row["mainnet_signed_action"] is False for row in rows)
+
+
+def test_evaluate_due_ideas_waits_until_horizon_and_dedupes(tmp_path: Path) -> None:
+    ideas = tmp_path / "research" / "tradingview_community_ideas.jsonl"
+    _append(ideas, {
+        "timestamp": "2026-06-29T00:00:00+00:00",
+        "url": "https://www.tradingview.com/chart/BTCUSDT/a/",
+        "author": "alice",
+        "coin": "BTC",
+        "bias": "bullish",
+        "title": "BTC long",
+        "price_at_capture_usd": "100",
+    })
+
+    early = evaluate_due_ideas(tmp_path, current_prices={"BTC": "110"}, horizons_hours=(24,), now=datetime(2026, 6, 29, 12, tzinfo=timezone.utc))
+    assert early["evaluated_new"] == 0
+
+    due = evaluate_due_ideas(tmp_path, current_prices={"BTC": "110"}, horizons_hours=(24,), now=datetime(2026, 6, 30, 1, tzinfo=timezone.utc))
+    again = evaluate_due_ideas(tmp_path, current_prices={"BTC": "120"}, horizons_hours=(24,), now=datetime(2026, 6, 30, 2, tzinfo=timezone.utc))
+    assert due["evaluated_new"] == 1
+    assert again["evaluated_new"] == 0
diff --git a/tests/test_tradingview_operational_gates.py b/tests/test_tradingview_operational_gates.py
new file mode 100644
index 0000000..1d5d6ce
--- /dev/null
+++ b/tests/test_tradingview_operational_gates.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from src.tools.tradingview_operational_gates import load_operational_gates, format_operational_gate_report
+
+
+def _write(path: Path, payload: dict) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(json.dumps(payload), encoding="utf-8")
+
+
+def test_load_operational_gates_all_green_from_runtime_reports(tmp_path):
+    _write(tmp_path / "reports" / "hl_reconcile_watchdog_latest.json", {"watchdog": {"critical": False, "block_new_entries": False, "stops_missing_count": 0, "status": "ok"}})
+    _write(tmp_path / "reports" / "preflight_evidence_latest.json", {"telegram_alerts_tested": True, "position_without_stop_impossible": True, "testnet_fill_stop": True})
+    _write(tmp_path / "config" / "tiny_autonomous_live_limits.json", {"max_daily_loss_usdc": 2, "max_weekly_loss_usdc": 5, "block_new_entries_if": ["daily_loss_exceeded", "weekly_loss_exceeded", "kill_switch_active", "position_without_stop"]})
+
+    gates = load_operational_gates(tmp_path)
+
+    assert gates["gates"] == {
+        "reconcile_clean": True,
+        "alerts_proven": True,
+        "loss_gates_active": True,
+        "kill_switch_ready": True,
+        "stop_handling_ready": True,
+    }
+    assert gates["blockers"] == []
+
+
+def test_load_operational_gates_blocks_dirty_reconcile_and_missing_alerts(tmp_path):
+    _write(tmp_path / "reports" / "hl_reconcile_watchdog_latest.json", {"watchdog": {"critical": True, "block_new_entries": True, "stops_missing_count": 1, "status": "critical"}})
+    _write(tmp_path / "reports" / "preflight_evidence_latest.json", {"telegram_alerts_tested": False, "position_without_stop_impossible": False, "testnet_fill_stop": False})
+    _write(tmp_path / "config" / "tiny_autonomous_live_limits.json", {"block_new_entries_if": []})
+
+    gates = load_operational_gates(tmp_path)
+
+    assert gates["gates"]["reconcile_clean"] is False
+    assert gates["gates"]["alerts_proven"] is False
+    assert "reconcile_clean_missing" in gates["blockers"]
+    assert "alerts_proven_missing" in gates["blockers"]
+
+
+def test_operational_gate_report_is_short(tmp_path):
+    gates = {"gates": {"reconcile_clean": False, "alerts_proven": False}, "blockers": ["reconcile_clean_missing", "alerts_proven_missing"]}
+
+    line = format_operational_gate_report(gates)
+
+    assert line.startswith("Operational Gates:")
+    assert "reconcile_clean_missing" in line
diff --git a/tests/test_tradingview_paper_bridge.py b/tests/test_tradingview_paper_bridge.py
new file mode 100644
index 0000000..ee2309e
--- /dev/null
+++ b/tests/test_tradingview_paper_bridge.py
@@ -0,0 +1,119 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from src.tools.tradingview_paper_bridge import process_pending_tradingview_signals, summarize_tradingview_paper_bridge
+
+
+def _append(path: Path, row: dict) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    with path.open("a", encoding="utf-8") as fh:
+        fh.write(json.dumps(row) + "\n")
+
+
+def _signal(signal_id: str = "tv-1", **overrides) -> dict:
+    row = {
+        "source": "tradingview",
+        "signal_id": signal_id,
+        "strategy_id": "gaussian_channel_v1",
+        "coin": "BTC",
+        "side": "long",
+        "action": "entry",
+        "timeframe": "1h",
+        "price": "60000",
+        "timestamp": "2026-06-28T18:00:00Z",
+        "execution_mode": "paper_signal",
+        "live_order_allowed": False,
+        "mainnet_signed_action": False,
+        "order_intent_created": False,
+    }
+    row.update(overrides)
+    return row
+
+
+def test_process_pending_tradingview_signal_creates_paper_trade(tmp_path):
+    _append(tmp_path / "signals" / "signal_journal.jsonl", _signal())
+
+    result = process_pending_tradingview_signals(runtime_dir=tmp_path)
+
+    assert result["processed"] == 1
+    assert result["paper_trades"] == 1
+    trade_rows = [json.loads(line) for line in (tmp_path / "experiments" / "tradingview_paper_bridge" / "trade_journal.jsonl").read_text(encoding="utf-8").splitlines()]
+    assert len(trade_rows) == 1
+    trade = trade_rows[0]
+    assert trade["event"] == "paper_entry"
+    assert trade["source"] == "tradingview"
+    assert trade["coin"] == "BTC"
+    assert trade["paper_trading"] is True
+    assert trade["mainnet_signed_action"] is False
+    assert trade["live_order_allowed"] is False
+    assert trade["estimated_notional_usd"] == "11.25"
+
+
+def test_bridge_is_idempotent_for_processed_signal(tmp_path):
+    _append(tmp_path / "signals" / "signal_journal.jsonl", _signal())
+
+    first = process_pending_tradingview_signals(runtime_dir=tmp_path)
+    second = process_pending_tradingview_signals(runtime_dir=tmp_path)
+
+    assert first["paper_trades"] == 1
+    assert second["processed"] == 0
+    trade_rows = (tmp_path / "experiments" / "tradingview_paper_bridge" / "trade_journal.jsonl").read_text(encoding="utf-8").splitlines()
+    assert len(trade_rows) == 1
+
+
+def test_bridge_blocks_unexpected_live_flag(tmp_path):
+    _append(tmp_path / "signals" / "signal_journal.jsonl", _signal(live_order_allowed=True))
+
+    result = process_pending_tradingview_signals(runtime_dir=tmp_path)
+
+    assert result["paper_trades"] == 0
+    assert result["blocked"]["unexpected_live_flag"] == 1
+    decision_rows = [json.loads(line) for line in (tmp_path / "experiments" / "tradingview_paper_bridge" / "signal_decision_journal.jsonl").read_text(encoding="utf-8").splitlines()]
+    assert decision_rows[0]["final_decision"] == "blocked:unexpected_live_flag"
+    assert decision_rows[0]["mainnet_signed_action"] is False
+
+
+def test_bridge_blocks_exit_without_open_position(tmp_path):
+    _append(tmp_path / "signals" / "signal_journal.jsonl", _signal(action="exit"))
+
+    result = process_pending_tradingview_signals(runtime_dir=tmp_path)
+
+    assert result["paper_trades"] == 0
+    assert result["paper_exits"] == 0
+    assert result["blocked"]["exit_without_paper_position"] == 1
+
+
+def test_bridge_closes_open_paper_position_and_records_pnl(tmp_path):
+    _append(tmp_path / "signals" / "signal_journal.jsonl", _signal("entry-1", price="60000"))
+    _append(tmp_path / "signals" / "signal_journal.jsonl", _signal("exit-1", action="exit", price="60600"))
+
+    result = process_pending_tradingview_signals(runtime_dir=tmp_path)
+
+    assert result["paper_trades"] == 1
+    assert result["paper_exits"] == 1
+    trade_rows = [json.loads(line) for line in (tmp_path / "experiments" / "tradingview_paper_bridge" / "trade_journal.jsonl").read_text(encoding="utf-8").splitlines()]
+    assert [row["event"] for row in trade_rows] == ["paper_entry", "paper_exit"]
+    exit_row = trade_rows[1]
+    assert exit_row["coin"] == "BTC"
+    assert exit_row["exit_price"] == "60600"
+    assert float(exit_row["net_pnl_usd"]) > 0
+    state = json.loads((tmp_path / "experiments" / "tradingview_paper_bridge" / "state.json").read_text(encoding="utf-8"))
+    assert state["open_positions"] == {}
+
+
+def test_summarize_tradingview_paper_bridge_counts_trades_and_blockers(tmp_path):
+    _append(tmp_path / "signals" / "signal_journal.jsonl", _signal("tv-1"))
+    _append(tmp_path / "signals" / "signal_journal.jsonl", _signal("tv-2", live_order_allowed=True))
+    process_pending_tradingview_signals(runtime_dir=tmp_path)
+
+    summary = summarize_tradingview_paper_bridge(tmp_path)
+
+    assert summary["paper_entries"] == 1
+    assert summary["paper_exits"] == 0
+    assert summary["closed_net_pnl_usd"] == "0"
+    assert summary["blocked"] == {"unexpected_live_flag": 1}
+    assert summary["by_strategy"] == {"gaussian_channel_v1": 1}
+    assert summary["by_coin"] == {"BTC": 1}
+    assert summary["live_order_allowed_count"] == 0
diff --git a/tests/test_tradingview_promotion_gates.py b/tests/test_tradingview_promotion_gates.py
new file mode 100644
index 0000000..46028ea
--- /dev/null
+++ b/tests/test_tradingview_promotion_gates.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from src.tools.tradingview_promotion_gates import evaluate_promotion_gates, format_promotion_gate_report
+
+
+def _perf(closed: int = 30, pnl: str = "1.25", winrate: str = "55.00") -> dict:
+    return {
+        "overall": {"closed_trades": closed, "net_pnl_usd": pnl, "winrate_pct": winrate},
+        "strategies": {"gaussian_channel_v1": {"closed_trades": closed, "net_pnl_usd": pnl, "winrate_pct": winrate}},
+        "coins": {"BTC": {"closed_trades": closed, "net_pnl_usd": pnl, "winrate_pct": winrate}},
+    }
+
+
+def test_promotion_gates_keep_strategy_in_paper_when_sample_too_small():
+    result = evaluate_promotion_gates(_perf(closed=5), operational_gates={"reconcile_clean": True, "alerts_proven": True, "loss_gates_active": True, "kill_switch_ready": True, "stop_handling_ready": True})
+
+    assert result["final_status"] == "paper_only"
+    assert "sample_too_small" in result["blockers"]
+    assert result["live_allowed"] is False
+
+
+def test_promotion_gates_allow_shadow_candidate_but_never_live():
+    result = evaluate_promotion_gates(_perf(), operational_gates={"reconcile_clean": True, "alerts_proven": True, "loss_gates_active": True, "kill_switch_ready": True, "stop_handling_ready": True})
+
+    assert result["final_status"] == "shadow_candidate_not_live"
+    assert result["live_allowed"] is False
+    assert result["shadow_candidate"] is True
+
+
+def test_promotion_gates_block_when_operational_gate_missing():
+    result = evaluate_promotion_gates(_perf(), operational_gates={"reconcile_clean": False, "alerts_proven": True, "loss_gates_active": True, "kill_switch_ready": True, "stop_handling_ready": True})
+
+    assert result["final_status"] == "paper_only"
+    assert "reconcile_clean_missing" in result["blockers"]
+
+
+def test_promotion_gate_report_is_short_and_explicit():
+    result = evaluate_promotion_gates(_perf(closed=30), operational_gates={"reconcile_clean": True, "alerts_proven": True, "loss_gates_active": True, "kill_switch_ready": True, "stop_handling_ready": True})
+
+    line = format_promotion_gate_report(result)
+
+    assert "Promotion Gates:" in line
+    assert "shadow_candidate_not_live" in line
+    assert "live=nein" in line
diff --git a/tests/test_tradingview_signal_ingestion.py b/tests/test_tradingview_signal_ingestion.py
new file mode 100644
index 0000000..afeaf4d
--- /dev/null
+++ b/tests/test_tradingview_signal_ingestion.py
@@ -0,0 +1,152 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from src.signals.tradingview import (
+    TradingViewWebhookConfig,
+    handle_tradingview_payload,
+    normalize_tradingview_payload,
+)
+
+
+def _payload(**overrides):
+    payload = {
+        "source": "tradingview",
+        "strategy": "gaussian_channel_v1",
+        "symbol": "BTCUSDT",
+        "side": "long",
+        "action": "entry",
+        "timeframe": "1h",
+        "price": "61234.5",
+        "timestamp": "2026-06-28T18:00:00Z",
+        "signal_id": "tv-btc-1h-20260628T1800",
+    }
+    payload.update(overrides)
+    return payload
+
+
+def test_normalize_tradingview_payload_maps_symbol_and_defaults_to_paper_signal():
+    signal = normalize_tradingview_payload(
+        _payload(symbol="BTCUSDT.P"),
+        allowed_strategies={"gaussian_channel_v1"},
+        allowed_coins={"BTC", "ETH"},
+    )
+
+    assert signal.coin == "BTC"
+    assert signal.strategy_id == "gaussian_channel_v1"
+    assert signal.side == "long"
+    assert signal.action == "entry"
+    assert signal.execution_mode == "paper_signal"
+    assert signal.live_order_allowed is False
+
+
+def test_invalid_strategy_is_rejected_without_signal_journal_but_with_rejection_audit(tmp_path):
+    cfg = TradingViewWebhookConfig(
+        runtime_dir=tmp_path,
+        secret="expected-secret",
+        allowed_strategies={"squeeze_breakout_1h"},
+        allowed_coins={"BTC"},
+    )
+
+    result = handle_tradingview_payload(
+        _payload(strategy="unknown_strategy", secret="payload-secret-should-not-be-written"),
+        headers={"X-CTB-Webhook-Secret": "expected-secret"},
+        config=cfg,
+    )
+
+    assert result["accepted"] is False
+    assert result["reason"] == "strategy_not_allowed"
+    assert not (tmp_path / "signals" / "signal_journal.jsonl").exists()
+    reject_journal = tmp_path / "signals" / "rejected_signal_journal.jsonl"
+    row = json.loads(reject_journal.read_text(encoding="utf-8").splitlines()[0])
+    assert row["reason"] == "strategy_not_allowed"
+    assert row["source"] == "tradingview"
+    assert row["live_order_allowed"] is False
+    assert "secret" not in json.dumps(row).lower()
+
+
+def test_webhook_requires_secret_and_never_allows_live_order(tmp_path):
+    cfg = TradingViewWebhookConfig(
+        runtime_dir=tmp_path,
+        secret="expected-secret",
+        allowed_strategies={"gaussian_channel_v1"},
+        allowed_coins={"BTC"},
+    )
+
+    result = handle_tradingview_payload(_payload(), headers={}, config=cfg)
+
+    assert result["accepted"] is False
+    assert result["reason"] == "unauthorized"
+    assert result["live_order_allowed"] is False
+    assert not (tmp_path / "signals" / "rejected_signal_journal.jsonl").exists()
+
+
+def test_accepted_webhook_appends_sanitized_signal_journal(tmp_path):
+    cfg = TradingViewWebhookConfig(
+        runtime_dir=tmp_path,
+        secret="expected-secret",
+        allowed_strategies={"gaussian_channel_v1"},
+        allowed_coins={"BTC"},
+    )
+
+    result = handle_tradingview_payload(
+        _payload(secret="payload-secret-should-not-be-written"),
+        headers={"X-CTB-Webhook-Secret": "expected-secret"},
+        config=cfg,
+    )
+
+    assert result["accepted"] is True
+    assert result["coin"] == "BTC"
+    assert result["execution_mode"] == "paper_signal"
+    assert result["live_order_allowed"] is False
+
+    journal = tmp_path / "signals" / "signal_journal.jsonl"
+    rows = [json.loads(line) for line in journal.read_text(encoding="utf-8").splitlines()]
+    assert len(rows) == 1
+    row = rows[0]
+    assert row["source"] == "tradingview"
+    assert row["coin"] == "BTC"
+    assert row["strategy_id"] == "gaussian_channel_v1"
+    assert row["execution_mode"] == "paper_signal"
+    assert row["live_order_allowed"] is False
+    assert "secret" not in json.dumps(row).lower()
+
+
+def test_duplicate_signal_id_is_rejected(tmp_path):
+    cfg = TradingViewWebhookConfig(
+        runtime_dir=tmp_path,
+        secret="expected-secret",
+        allowed_strategies={"gaussian_channel_v1"},
+        allowed_coins={"BTC"},
+    )
+    headers = {"X-CTB-Webhook-Secret": "expected-secret"}
+
+    first = handle_tradingview_payload(_payload(), headers=headers, config=cfg)
+    second = handle_tradingview_payload(_payload(), headers=headers, config=cfg)
+
+    assert first["accepted"] is True
+    assert second["accepted"] is False
+    assert second["reason"] == "duplicate_signal"
+
+
+def test_cli_ingests_payload_file(tmp_path):
+    payload_path = tmp_path / "payload.json"
+    payload_path.write_text(json.dumps(_payload()), encoding="utf-8")
+    secret_path = tmp_path / "secret.txt"
+    secret_path.write_text("expected-secret\n", encoding="utf-8")
+
+    from src.tools.tradingview_webhook import main
+
+    exit_code = main([
+        "--payload-file", str(payload_path),
+        "--runtime-dir", str(tmp_path / "runtime"),
+        "--secret-file", str(secret_path),
+        "--header-secret", "expected-secret",
+        "--allowed-strategy", "gaussian_channel_v1",
+        "--allowed-coin", "BTC",
+        "--json",
+    ])
+
+    assert exit_code == 0
+    assert (tmp_path / "runtime" / "signals" / "signal_journal.jsonl").exists()
diff --git a/tests/test_tradingview_strategy_performance.py b/tests/test_tradingview_strategy_performance.py
new file mode 100644
index 0000000..527d7e6
--- /dev/null
+++ b/tests/test_tradingview_strategy_performance.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from src.tools.tradingview_strategy_performance import summarize_strategy_performance, recommend_strategy_actions
+
+
+def _append(path: Path, row: dict) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    with path.open("a", encoding="utf-8") as fh:
+        fh.write(json.dumps(row) + "\n")
+
+
+def test_strategy_performance_summarizes_winrate_and_pnl(tmp_path):
+    journal = tmp_path / "experiments" / "tradingview_paper_bridge" / "trade_journal.jsonl"
+    _append(journal, {"event": "paper_exit", "strategy_id": "gaussian_channel_v1", "coin": "BTC", "net_pnl_usd": "0.50"})
+    _append(journal, {"event": "paper_exit", "strategy_id": "gaussian_channel_v1", "coin": "BTC", "net_pnl_usd": "-0.10"})
+    _append(journal, {"event": "paper_exit", "strategy_id": "squeeze_breakout_1h", "coin": "ETH", "net_pnl_usd": "0.20"})
+
+    summary = summarize_strategy_performance(tmp_path)
+
+    assert summary["overall"]["closed_trades"] == 3
+    assert summary["overall"]["net_pnl_usd"] == "0.60"
+    assert summary["strategies"]["gaussian_channel_v1"]["closed_trades"] == 2
+    assert summary["strategies"]["gaussian_channel_v1"]["wins"] == 1
+    assert summary["strategies"]["gaussian_channel_v1"]["winrate_pct"] == "50.00"
+    assert summary["coins"]["BTC"]["net_pnl_usd"] == "0.40"
+
+
+def test_strategy_recommendation_requires_sample_size_before_shadow(tmp_path):
+    journal = tmp_path / "experiments" / "tradingview_paper_bridge" / "trade_journal.jsonl"
+    _append(journal, {"event": "paper_exit", "strategy_id": "small_sample", "coin": "BTC", "net_pnl_usd": "0.50"})
+
+    summary = summarize_strategy_performance(tmp_path)
+    recs = recommend_strategy_actions(summary, min_closed_trades=3)
+
+    assert recs["small_sample"] == "continue_paper_too_few_trades"
+
+
+def test_strategy_recommendation_flags_positive_and_negative_strategies(tmp_path):
+    journal = tmp_path / "experiments" / "tradingview_paper_bridge" / "trade_journal.jsonl"
+    for i, pnl in enumerate(["0.30", "0.20", "0.10"]):
+        _append(journal, {"event": "paper_exit", "strategy_id": "good", "coin": "BTC", "net_pnl_usd": pnl})
+    for i, pnl in enumerate(["-0.30", "-0.20", "0.01"]):
+        _append(journal, {"event": "paper_exit", "strategy_id": "bad", "coin": "SOL", "net_pnl_usd": pnl})
+
+    recs = recommend_strategy_actions(summarize_strategy_performance(tmp_path), min_closed_trades=3)
+
+    assert recs["good"] == "shadow_candidate_not_live"
+    assert recs["bad"] == "pause_or_rework"
diff --git a/tests/test_tradingview_webhook_server.py b/tests/test_tradingview_webhook_server.py
new file mode 100644
index 0000000..5d30912
--- /dev/null
+++ b/tests/test_tradingview_webhook_server.py
@@ -0,0 +1,122 @@
+from __future__ import annotations
+
+import json
+import threading
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+from src.tools.tradingview_webhook import main as cli_main
+
+
+def test_http_server_accepts_post_and_journals_paper_signal(tmp_path):
+    from src.tools.tradingview_webhook_server import build_parser, make_server
+
+    secret_file = tmp_path / "secret.txt"
+    secret_file.write_text("expected-secret\n", encoding="utf-8")
+    args = build_parser().parse_args([
+        "--host", "127.0.0.1",
+        "--port", "0",
+        "--runtime-dir", str(tmp_path / "runtime"),
+        "--secret-file", str(secret_file),
+        "--allowed-strategy", "gaussian_channel_v1",
+        "--allowed-coin", "BTC",
+    ])
+    server = make_server(args)
+    thread = threading.Thread(target=server.serve_forever, daemon=True)
+    thread.start()
+    try:
+        port = server.server_address[1]
+        payload = json.dumps({
+            "source": "tradingview",
+            "strategy": "gaussian_channel_v1",
+            "symbol": "BTCUSDT.P",
+            "side": "long",
+            "action": "entry",
+            "timeframe": "1h",
+            "price": "61234.5",
+            "timestamp": "2026-06-28T18:00:00Z",
+            "signal_id": "http-smoke-1",
+        }).encode("utf-8")
+        req = urllib.request.Request(
+            f"http://127.0.0.1:{port}/webhook/tradingview",
+            data=payload,
+            method="POST",
+            headers={"Content-Type": "application/json", "X-CTB-Webhook-Secret": "expected-secret"},
+        )
+        with urllib.request.urlopen(req, timeout=5) as resp:
+            body = json.loads(resp.read().decode("utf-8"))
+            assert resp.status == 200
+        assert body["accepted"] is True
+        assert body["live_order_allowed"] is False
+        journal = tmp_path / "runtime" / "signals" / "signal_journal.jsonl"
+        row = json.loads(journal.read_text(encoding="utf-8").splitlines()[0])
+        assert row["execution_mode"] == "paper_signal"
+        assert row["mainnet_signed_action"] is False
+    finally:
+        server.shutdown()
+        server.server_close()
+
+
+def test_http_server_rejects_wrong_path_without_journal(tmp_path):
+    from src.tools.tradingview_webhook_server import build_parser, make_server
+
+    secret_file = tmp_path / "secret.txt"
+    secret_file.write_text("expected-secret\n", encoding="utf-8")
+    args = build_parser().parse_args([
+        "--host", "127.0.0.1",
+        "--port", "0",
+        "--runtime-dir", str(tmp_path / "runtime"),
+        "--secret-file", str(secret_file),
+        "--allowed-strategy", "gaussian_channel_v1",
+        "--allowed-coin", "BTC",
+    ])
+    server = make_server(args)
+    thread = threading.Thread(target=server.serve_forever, daemon=True)
+    thread.start()
+    try:
+        port = server.server_address[1]
+        req = urllib.request.Request(
+            f"http://127.0.0.1:{port}/wrong",
+            data=b"{}",
+            method="POST",
+            headers={"Content-Type": "application/json", "X-CTB-Webhook-Secret": "expected-secret"},
+        )
+        try:
+            urllib.request.urlopen(req, timeout=5)
+            raise AssertionError("expected HTTPError")
+        except urllib.error.HTTPError as exc:
+            assert exc.code == 404
+        assert not (tmp_path / "runtime" / "signals" / "signal_journal.jsonl").exists()
+    finally:
+        server.shutdown()
+        server.server_close()
+
+
+def test_cli_still_returns_nonzero_for_unauthorized_payload(tmp_path):
+    payload_path = tmp_path / "payload.json"
+    payload_path.write_text(json.dumps({
+        "source": "tradingview",
+        "strategy": "gaussian_channel_v1",
+        "symbol": "BTCUSDT",
+        "side": "long",
+        "action": "entry",
+        "timeframe": "1h",
+        "price": "1",
+        "timestamp": "2026-06-28T18:00:00Z",
+        "signal_id": "unauth-1",
+    }), encoding="utf-8")
+    secret_path = tmp_path / "secret.txt"
+    secret_path.write_text("expected-secret\n", encoding="utf-8")
+
+    code = cli_main([
+        "--payload-file", str(payload_path),
+        "--runtime-dir", str(tmp_path / "runtime"),
+        "--secret-file", str(secret_path),
+        "--header-secret", "wrong-secret",
+        "--allowed-strategy", "gaussian_channel_v1",
+        "--allowed-coin", "BTC",
+        "--json",
+    ])
+    assert code == 2
+    assert not (tmp_path / "runtime" / "signals" / "signal_journal.jsonl").exists()
diff --git a/tests/test_v76_paper_runtime_and_replay.py b/tests/test_v76_paper_runtime_and_replay.py
index 25231cc..3fcc661 100644
--- a/tests/test_v76_paper_runtime_and_replay.py
+++ b/tests/test_v76_paper_runtime_and_replay.py
@@ -86,6 +86,46 @@ def test_fee_aware_anti_chase_does_not_reopen_same_coin_every_tick(tmp_path, mon
     assert signals[-1]["final_decision"] == "blocked:already_open"
 
 
+def test_fee_aware_anti_chase_closes_open_paper_position_on_profit_target(tmp_path, monkeypatch):
+    monkeypatch.chdir(tmp_path)
+    monkeypatch.delenv("CTB_LIVE_TRADING_ALLOWED", raising=False)
+    runtime = Path("runtime/experiments") / v76_paper_runtime.ANTI_CHASE_ID
+    runtime.mkdir(parents=True, exist_ok=True)
+    (runtime / "state.json").write_text(json.dumps({
+        "prev_mids": {"BTC": "100"},
+        "recent_trade_coins": [],
+        "open_positions": {
+            "BTC": {
+                "entry": "100",
+                "size": "0.10",
+                "stop_loss": "99",
+                "opened_at": "2026-06-26T00:00:00+00:00",
+                "strategy_id": v76_paper_runtime.ANTI_CHASE_ID,
+                "entry_fee_usd": "0.004",
+                "spread_cost_usd": "0.001",
+                "slippage_cost_usd": "0.001",
+            }
+        },
+    }))
+
+    class FakeMarketData:
+        def __init__(self, env: str):
+            assert env == "mainnet"
+        def get_all_mids(self):
+            return {"BTC": "101.30"}
+
+    monkeypatch.setattr(v76_paper_runtime, "HyperliquidMarketData", FakeMarketData)
+    payload = v76_paper_runtime.scan_once(v76_paper_runtime.ANTI_CHASE_ID, ["BTC"])
+
+    assert payload["closed_positions"] == 1
+    state = json.loads((runtime / "state.json").read_text())
+    assert "BTC" not in state.get("open_positions", {})
+    trades = [json.loads(line) for line in (runtime / "trade_journal.jsonl").read_text().splitlines()]
+    assert trades[-1]["event"] == "exit"
+    assert trades[-1]["exit_reason"] == "profit_target"
+    assert float(trades[-1]["net_pnl_usd"]) > 0
+
+
 def test_v76_runtime_degraded_market_data_does_not_crash_or_trade(tmp_path, monkeypatch):
     monkeypatch.chdir(tmp_path)
     monkeypatch.delenv("CTB_LIVE_TRADING_ALLOWED", raising=False)
-- 
2.43.0

