Procházet zdrojové kódy

research: add eth conservative portfolio decision report

lxy před 1 měsícem
rodič
revize
e3d1338058

+ 14 - 0
reports/eth-exploration/README.md

@@ -157,3 +157,17 @@ Reports:
 - `reports/eth-exploration/eth-focused-portfolio-conservative-*.csv`
 
 Core conclusion: taker-entry TWAP is negative. No ETH taker-entry TWAP candidate in this grid had positive taker_taker net Calmar across 3y/1y/6m/3m. ETH-focused conservative portfolio has qualified combinations: the lowest-DD qualified maker_taker portfolio has annualized 7.43% with DD 7.28%, and the highest-return qualified maker_taker portfolio has annualized 13.25% with DD 16.13%.
+
+## Final decision
+
+Scripts:
+- `scripts/build_eth_conservative_portfolio_report.py`
+- `scripts/build_eth_focused_portfolio_signal_intent.py`
+
+Reports:
+- `reports/eth-exploration/eth-conservative-portfolio-final.md`
+- `reports/eth-exploration/eth-conservative-portfolio-final.json`
+- `reports/eth-exploration/eth-focused-portfolio-signal-intent.md`
+- `reports/eth-exploration/eth-focused-portfolio-signal-intent.json`
+
+Core conclusion: prioritize the ETH-focused conservative portfolio for quasi-live/read-only intent tracking. Standalone ETH TWAP is not approved for live trading under conservative maker-fill assumptions. The current portfolio signal intent has no active signal and produced no order intent.

+ 156 - 0
reports/eth-exploration/eth-conservative-portfolio-final.json

@@ -0,0 +1,156 @@
+{
+  "candidates": [
+    {
+      "decision": "Primary next item to watch in paper/demo. It is the cleanest qualified portfolio by conservative sort, but it contains a maker-dependent TWAP leg, so real funds should wait for live fill evidence.",
+      "legs": [
+        "eth_btc_rsi_filter:15m:eth-btc-rsi-filter-et50-l3.0-x55.0-bt480-bm240-br0.0",
+        "btc_lead_eth_lag_15m:15m:btc-lead-eth-lag-lb8-br0.018-gap0.006-mh8-sl0.006-tp0.018",
+        "eth_robust_twap:15m:rsi2-long-guarded-price-twap-o0.0030-0.0060-0.0090-v4-t60-l3.0-x50.0-sl0.012-mh48-fb0.0005-ps0.0000-mm25"
+      ],
+      "metrics": {
+        "cost_model": "maker_taker",
+        "max_horizon_drawdown": 0.06650656143862783,
+        "min_horizon_total_return": 0.014041380756578459,
+        "net_annualized_return": 0.07431265319093083,
+        "net_calmar": 1.0203012338692994,
+        "net_max_drawdown": 0.07283403246423034,
+        "net_total_return": 0.5746697040909923,
+        "scope": "all_legs",
+        "worst_month_return": -0.04435071071689678
+      },
+      "minimum_next_step": "Run quasi-live read-only/order-intent tracking for all legs and record per-leg signal, fill/miss, slippage, and portfolio equity for at least the next signal cycle set.",
+      "name": "all_legs-risk-3-c0124-eth_btc_rsi_filter+btc_lead_eth_lag_15m+eth_robust_twap",
+      "needs_forward_or_demo_live": true,
+      "priority": 1,
+      "real_live_now": false,
+      "status": "candidate",
+      "title": "Lowest-drawdown ETH-focused conservative portfolio",
+      "weights": [
+        "eth_btc_rsi_filter:15m:eth-btc-rsi-filter-et50-l3.0-x55.0-bt480-bm240-br0.0=0.48537471",
+        "btc_lead_eth_lag_15m:15m:btc-lead-eth-lag-lb8-br0.018-gap0.006-mh8-sl0.006-tp0.018=0.06171009",
+        "eth_robust_twap:15m:rsi2-long-guarded-price-twap-o0.0030-0.0060-0.0090-v4-t60-l3.0-x50.0-sl0.012-mh48-fb0.0005-ps0.0000-mm25=0.45291520"
+      ]
+    },
+    {
+      "decision": "Best fallback if maker-fill uncertainty is treated as disqualifying. It keeps qualified portfolio behavior without the robust TWAP maker-dependent leg.",
+      "legs": [
+        "eth_btc_rsi_filter:15m:eth-btc-rsi-filter-et50-l3.0-x55.0-bt120-bm240-br0.0",
+        "btc_lead_eth_lag_15m:15m:btc-lead-eth-lag-lb8-br0.018-gap0.006-mh8-sl0.006-tp0.018"
+      ],
+      "metrics": {
+        "cost_model": "maker_taker",
+        "max_horizon_drawdown": 0.07437399760660594,
+        "min_horizon_total_return": 0.01722844178015248,
+        "net_annualized_return": 0.0900769354075468,
+        "net_calmar": 0.8611995911104812,
+        "net_max_drawdown": 0.10459472616724809,
+        "net_total_return": 0.7268826579747691,
+        "scope": "no_maker_dependent",
+        "worst_month_return": -0.04507919732709942
+      },
+      "minimum_next_step": "Shadow the two legs side by side with the primary portfolio and compare realized signal overlap and drawdown path.",
+      "name": "no_maker_dependent-risk-2-c0250-eth_btc_rsi_filter+btc_lead_eth_lag_15m",
+      "needs_forward_or_demo_live": true,
+      "priority": 2,
+      "real_live_now": false,
+      "status": "candidate",
+      "title": "Simpler no-maker-dependent conservative portfolio",
+      "weights": [
+        "eth_btc_rsi_filter:15m:eth-btc-rsi-filter-et50-l3.0-x55.0-bt120-bm240-br0.0=0.89432334",
+        "btc_lead_eth_lag_15m:15m:btc-lead-eth-lag-lb8-br0.018-gap0.006-mh8-sl0.006-tp0.018=0.10567666"
+      ]
+    },
+    {
+      "decision": "Return-oriented qualified variant. It is less conservative than priority 1 because drawdown is materially higher.",
+      "legs": [
+        "eth_btc_rsi_filter:15m:eth-btc-rsi-filter-et50-l3.0-x55.0-bt480-bm240-br0.0",
+        "btc_lead_eth_lag_15m:15m:btc-lead-eth-lag-lb16-br0.024-gap0.006-mh32-sl0.006-tp0.018"
+      ],
+      "metrics": {
+        "cost_model": "maker_taker",
+        "max_horizon_drawdown": 0.08384178749425959,
+        "min_horizon_total_return": 0.021522808059551757,
+        "net_annualized_return": 0.13247985480488844,
+        "net_calmar": 0.8214839555775263,
+        "net_max_drawdown": 0.1612689498138176,
+        "net_total_return": 1.1990870259436797,
+        "scope": "all_legs",
+        "worst_month_return": -0.056624480428019486
+      },
+      "minimum_next_step": "Keep as a benchmark portfolio in the same quasi-live tracker; do not allocate before it beats priority 1 on realized drawdown-adjusted behavior.",
+      "name": "all_legs-risk-2-c0020-eth_btc_rsi_filter+btc_lead_eth_lag_15m",
+      "needs_forward_or_demo_live": true,
+      "priority": 3,
+      "real_live_now": false,
+      "status": "candidate",
+      "title": "Highest-return qualified conservative portfolio",
+      "weights": [
+        "eth_btc_rsi_filter:15m:eth-btc-rsi-filter-et50-l3.0-x55.0-bt480-bm240-br0.0=0.89038020",
+        "btc_lead_eth_lag_15m:15m:btc-lead-eth-lag-lb16-br0.024-gap0.006-mh32-sl0.006-tp0.018=0.10961980"
+      ]
+    },
+    {
+      "decision": "Keep as secondary watchlist, not primary allocation.",
+      "metrics": {
+        "net_annualized_return": 0.3305936472652691,
+        "net_calmar": 1.4902378159621057,
+        "net_max_drawdown": 0.22183952368155146,
+        "net_total_return": 5.132970549912916,
+        "trades": 88.0,
+        "worst_month": "2020-09",
+        "worst_month_return": -0.10650826274141434
+      },
+      "minimum_next_step": "Run shadow/demo signal intent logging only; require fresh forward trades before any capital allocation because the acceptable-risk versions have few trades.",
+      "name": "bb-squeeze-l96-bw960-q0.25-sl0.01-tpnone-long-btc-up-momo-vc0.006-dd0.25-cd24",
+      "needs_forward_or_demo_live": true,
+      "priority": 4,
+      "real_live_now": false,
+      "status": "watchlist",
+      "title": "BB squeeze risk candidate"
+    }
+  ],
+  "current_signal_intent": {
+    "completed": true,
+    "latest_confirmed_candle_utc": "2026-04-29 17:00:00",
+    "orders_produced": 0,
+    "signal": false
+  },
+  "generated_from_existing_outputs_only": true,
+  "rejected_or_deprioritized": [
+    {
+      "minimum_next_step": "Use only as a portfolio leg in shadow/demo tracking until actual maker fill and slippage data contradicts the conservative stress result.",
+      "name": "ETH robust TWAP standalone under conservative maker assumptions",
+      "nearest_miss": {
+        "min_horizon_total_return": -0.0370903494987372,
+        "name": "rsi2-long-guarded-price-twap-o0.0040-0.0080-0.0120-v2-t50-l2.0-x55.0-sl0.01-mh48-fb0.0010-ps0.0005-mm25",
+        "net_annualized_return": -0.007233930330402,
+        "net_max_drawdown": 0.0663208029770467,
+        "net_total_return": -0.0450550473736928,
+        "trades": 59.0,
+        "worst_365_total_return": -0.0355991314254146
+      },
+      "reason": "Independent validation matched the closed-trade report, but conservative maker fill/slippage assumptions had no qualified candidate with all 3y/1y/6m/3m horizons positive.",
+      "status": "do_not_trade_standalone"
+    },
+    {
+      "eligible_rows": 0,
+      "minimum_next_step": "No live work. Drop from the next real/paper-live shortlist.",
+      "name": "ETH taker-entry TWAP",
+      "reason": "The taker-entry search produced no eligible taker_taker candidate with positive Calmar across 3y/1y/6m/3m.",
+      "status": "rejected"
+    }
+  ],
+  "report": "eth-conservative-portfolio-final",
+  "source_files": [
+    "eth-focused-portfolio-conservative-qualified.csv",
+    "eth-focused-portfolio-conservative-report.md",
+    "eth-twap-conservative-summary.md",
+    "eth-robust-twap-validation-summary.md",
+    "eth-robust-twap-fill-slippage-summary.md",
+    "eth-twap-taker-entry-summary.md",
+    "eth-bb-squeeze-risk-10y-report.md",
+    "eth-signal-intent-readonly.md",
+    "eth-signal-intent-readonly.json"
+  ],
+  "topline_decision": "Watch the conservative portfolio layer next, not the standalone ETH TWAP. Use quasi-live/demo read-only intent first; real funds are not the minimum next step."
+}

+ 84 - 0
reports/eth-exploration/eth-conservative-portfolio-final.md

@@ -0,0 +1,84 @@
+# ETH conservative portfolio final decision report
+
+This report only consolidates existing ETH exploration outputs. It does not run a new search.
+
+## Topline
+
+Watch the conservative portfolio layer next, not the standalone ETH TWAP. Use quasi-live/demo read-only intent first; real funds are not the minimum next step.
+
+The next thing to watch is the portfolio layer: qualified ETH-focused conservative portfolios exist, while standalone ETH TWAP is not stable enough under conservative maker-fill assumptions and taker-entry TWAP is rejected.
+
+## Recommended priority
+
+| Priority | Candidate | Return | Ann. | MDD | Worst month | Risk | Real live now | Minimum next step |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| 1 | Lowest-drawdown ETH-focused conservative portfolio | 57.47% | 7.43% | 7.28% | -4.44% | 1.020 | No | Run quasi-live read-only/order-intent tracking for all legs and record per-leg signal, fill/miss, slippage, and portfolio equity for at least the next signal cycle set. |
+| 2 | Simpler no-maker-dependent conservative portfolio | 72.69% | 9.01% | 10.46% | -4.51% | 0.861 | No | Shadow the two legs side by side with the primary portfolio and compare realized signal overlap and drawdown path. |
+| 3 | Highest-return qualified conservative portfolio | 119.91% | 13.25% | 16.13% | -5.66% | 0.821 | No | Keep as a benchmark portfolio in the same quasi-live tracker; do not allocate before it beats priority 1 on realized drawdown-adjusted behavior. |
+| 4 | BB squeeze risk candidate | 513.30% | 33.06% | 22.18% | -10.65% | 1.490 | No | Run shadow/demo signal intent logging only; require fresh forward trades before any capital allocation because the acceptable-risk versions have few trades. |
+
+## Candidate notes
+
+### P1 Lowest-drawdown ETH-focused conservative portfolio
+
+- Name: `all_legs-risk-3-c0124-eth_btc_rsi_filter+btc_lead_eth_lag_15m+eth_robust_twap`
+- Decision: Primary next item to watch in paper/demo. It is the cleanest qualified portfolio by conservative sort, but it contains a maker-dependent TWAP leg, so real funds should wait for live fill evidence.
+- Needs live/quasi-live evidence: Yes
+- Real funds now: No
+
+Legs and weights:
+- `eth_btc_rsi_filter:15m:eth-btc-rsi-filter-et50-l3.0-x55.0-bt480-bm240-br0.0` at `0.48537471`
+- `btc_lead_eth_lag_15m:15m:btc-lead-eth-lag-lb8-br0.018-gap0.006-mh8-sl0.006-tp0.018` at `0.06171009`
+- `eth_robust_twap:15m:rsi2-long-guarded-price-twap-o0.0030-0.0060-0.0090-v4-t60-l3.0-x50.0-sl0.012-mh48-fb0.0005-ps0.0000-mm25` at `0.45291520`
+
+### P2 Simpler no-maker-dependent conservative portfolio
+
+- Name: `no_maker_dependent-risk-2-c0250-eth_btc_rsi_filter+btc_lead_eth_lag_15m`
+- Decision: Best fallback if maker-fill uncertainty is treated as disqualifying. It keeps qualified portfolio behavior without the robust TWAP maker-dependent leg.
+- Needs live/quasi-live evidence: Yes
+- Real funds now: No
+
+Legs and weights:
+- `eth_btc_rsi_filter:15m:eth-btc-rsi-filter-et50-l3.0-x55.0-bt120-bm240-br0.0` at `0.89432334`
+- `btc_lead_eth_lag_15m:15m:btc-lead-eth-lag-lb8-br0.018-gap0.006-mh8-sl0.006-tp0.018` at `0.10567666`
+
+### P3 Highest-return qualified conservative portfolio
+
+- Name: `all_legs-risk-2-c0020-eth_btc_rsi_filter+btc_lead_eth_lag_15m`
+- Decision: Return-oriented qualified variant. It is less conservative than priority 1 because drawdown is materially higher.
+- Needs live/quasi-live evidence: Yes
+- Real funds now: No
+
+Legs and weights:
+- `eth_btc_rsi_filter:15m:eth-btc-rsi-filter-et50-l3.0-x55.0-bt480-bm240-br0.0` at `0.89038020`
+- `btc_lead_eth_lag_15m:15m:btc-lead-eth-lag-lb16-br0.024-gap0.006-mh32-sl0.006-tp0.018` at `0.10961980`
+
+### P4 BB squeeze risk candidate
+
+- Name: `bb-squeeze-l96-bw960-q0.25-sl0.01-tpnone-long-btc-up-momo-vc0.006-dd0.25-cd24`
+- Decision: Keep as secondary watchlist, not primary allocation.
+- Needs live/quasi-live evidence: Yes
+- Real funds now: No
+
+## Deprioritized or rejected
+
+| Item | Status | Key reason | Minimum next step |
+| --- | --- | --- | --- |
+| ETH robust TWAP standalone under conservative maker assumptions | do_not_trade_standalone | Independent validation matched the closed-trade report, but conservative maker fill/slippage assumptions had no qualified candidate with all 3y/1y/6m/3m horizons positive. | Use only as a portfolio leg in shadow/demo tracking until actual maker fill and slippage data contradicts the conservative stress result. |
+| ETH taker-entry TWAP | rejected | The taker-entry search produced no eligible taker_taker candidate with positive Calmar across 3y/1y/6m/3m. | No live work. Drop from the next real/paper-live shortlist. |
+
+## Signal intent status
+
+Readonly signal intent was completed. Latest confirmed candle was `2026-04-29 17:00:00 UTC`; signal was false and no order intent was produced.
+
+## Source outputs
+
+- `eth-focused-portfolio-conservative-qualified.csv`
+- `eth-focused-portfolio-conservative-report.md`
+- `eth-twap-conservative-summary.md`
+- `eth-robust-twap-validation-summary.md`
+- `eth-robust-twap-fill-slippage-summary.md`
+- `eth-twap-taker-entry-summary.md`
+- `eth-bb-squeeze-risk-10y-report.md`
+- `eth-signal-intent-readonly.md`
+- `eth-signal-intent-readonly.json`

+ 147 - 0
reports/eth-exploration/eth-focused-portfolio-signal-intent.json

@@ -0,0 +1,147 @@
+{
+  "created_at": "2026-04-29T18:25:25Z",
+  "legs": [
+    {
+      "bar": "15m",
+      "decision_candle_time": "2026-04-29T16:30:00Z",
+      "decision_candle_ts": 1777480200000,
+      "dry_run_action": "hold",
+      "entry_rule": "btc_close > btc_sma and btc_momentum >= minimum and eth_close > eth_sma and eth_rsi2 <= threshold",
+      "exit_signal": true,
+      "family": "eth_btc_rsi_filter",
+      "indicators": {
+        "btc_close": 75856.0,
+        "btc_momentum": -0.040101233786776325,
+        "btc_sma": 77331.09999999992,
+        "eth_close": 2266.75,
+        "eth_rsi2": 10.743438715792621,
+        "eth_sma": 2315.6154000000006
+      },
+      "latest_local_candle_time": "2026-04-29T16:45:00Z",
+      "latest_local_candle_ts": 1777481100000,
+      "leg_id": "eth_btc_rsi_filter_15m",
+      "needs_cancel": false,
+      "needs_order": false,
+      "params": {
+        "btc_min_momentum": 0.0,
+        "btc_momentum_lookback": 240,
+        "btc_trend_sma": 480,
+        "eth_exit_rsi": 55.0,
+        "eth_rsi_threshold": 3.0,
+        "eth_trend_sma": 50
+      },
+      "risk_limits": {
+        "leverage": 3,
+        "max_hold_bars": null,
+        "max_weight": 0.80314757,
+        "stop_loss_pct": null,
+        "take_profit_pct": null
+      },
+      "signal": false,
+      "suggested_weight": 0.80314757,
+      "symbol": "ETH-USDT-SWAP"
+    },
+    {
+      "bar": "15m",
+      "decision_candle_time": "2026-04-29T16:30:00Z",
+      "decision_candle_ts": 1777480200000,
+      "dry_run_action": "hold",
+      "entry_rule": "btc_return >= threshold and btc_return - eth_return >= lag_gap",
+      "exit_signal": false,
+      "family": "btc_lead_eth_lag",
+      "indicators": {
+        "btc_close": 75856.0,
+        "btc_return": -0.009247165769813437,
+        "eth_close": 2266.75,
+        "eth_return": -0.014409384796664093,
+        "return_gap": 0.005162219026850656
+      },
+      "latest_local_candle_time": "2026-04-29T16:45:00Z",
+      "latest_local_candle_ts": 1777481100000,
+      "leg_id": "btc_lead_eth_lag_15m",
+      "needs_cancel": false,
+      "needs_order": false,
+      "params": {
+        "btc_return_threshold": 0.018,
+        "lag_gap": 0.006,
+        "lead_lookback": 8,
+        "max_hold_bars": 8,
+        "stop_loss_pct": 0.006,
+        "take_profit_pct": 0.018
+      },
+      "risk_limits": {
+        "leverage": 3,
+        "max_hold_bars": 8,
+        "max_weight": 0.09459139,
+        "stop_loss_pct": 0.006,
+        "take_profit_pct": 0.018
+      },
+      "signal": false,
+      "suggested_weight": 0.09459139,
+      "symbol": "ETH-USDT-SWAP"
+    },
+    {
+      "bar": "5m",
+      "decision_candle_time": "2026-04-29T01:55:00Z",
+      "decision_candle_ts": 1777427700000,
+      "dry_run_action": "hold",
+      "entry_rule": "btc_return >= threshold and btc_return - eth_return >= lag_gap",
+      "exit_signal": false,
+      "family": "btc_lead_eth_lag",
+      "indicators": {
+        "btc_close": 76534.3,
+        "btc_return": 0.0028355077904398396,
+        "eth_close": 2291.56,
+        "eth_return": 0.0025067480958775867,
+        "return_gap": 0.00032875969456225285
+      },
+      "latest_local_candle_time": "2026-04-29T02:00:00Z",
+      "latest_local_candle_ts": 1777428000000,
+      "leg_id": "btc_lead_eth_lag_5m",
+      "needs_cancel": false,
+      "needs_order": false,
+      "params": {
+        "btc_return_threshold": 0.012,
+        "lag_gap": 0.006,
+        "lead_lookback": 16,
+        "max_hold_bars": 8,
+        "stop_loss_pct": 0.006,
+        "take_profit_pct": 0.018
+      },
+      "risk_limits": {
+        "leverage": 3,
+        "max_hold_bars": 8,
+        "max_weight": 0.10226104,
+        "stop_loss_pct": 0.006,
+        "take_profit_pct": 0.018
+      },
+      "signal": false,
+      "suggested_weight": 0.10226104,
+      "symbol": "ETH-USDT-SWAP"
+    }
+  ],
+  "mode": "dry_run_readonly_portfolio_signal_intent",
+  "order_client": null,
+  "portfolio": {
+    "active_signal_count": 0,
+    "active_suggested_weight": 0,
+    "basis": "ETH/BTC RSI filter + BTC lead ETH lag 5m/15m",
+    "direction": "long_only",
+    "dry_run_action": "hold",
+    "leverage": 3,
+    "name": "eth_focused_conservative_signal_intent",
+    "needs_cancel": false,
+    "needs_order": false,
+    "symbol": "ETH-USDT-SWAP"
+  },
+  "private_key_required": false,
+  "risk_limits": {
+    "execution": "intent_only",
+    "leg_weights_sum": 1.0,
+    "no_cancel_submission": true,
+    "no_order_submission": true,
+    "no_position_state_assumed": true,
+    "portfolio_max_gross_weight": 1.0
+  },
+  "submitted_orders": 0
+}

+ 172 - 0
reports/eth-exploration/eth-focused-portfolio-signal-intent.md

@@ -0,0 +1,172 @@
+# ETH-focused portfolio signal intent
+
+Dry-run only. No order or cancel request was submitted.
+
+## Portfolio
+
+- Created at: `2026-04-29T18:25:25Z`
+- Direction: `long_only`
+- Active signal count: `0`
+- Active suggested weight: `0.00000000`
+- Needs order: `False`
+- Needs cancel: `False`
+
+## Legs
+
+| Leg | Bar | Signal | Weight | Action | Decision candle |
+| --- | --- | --- | --- | --- | --- |
+| `eth_btc_rsi_filter_15m` | `15m` | `False` | `0.80314757` | `hold` | `2026-04-29T16:30:00Z` |
+| `btc_lead_eth_lag_15m` | `15m` | `False` | `0.09459139` | `hold` | `2026-04-29T16:30:00Z` |
+| `btc_lead_eth_lag_5m` | `5m` | `False` | `0.10226104` | `hold` | `2026-04-29T01:55:00Z` |
+
+## Intent JSON
+
+```json
+{
+  "created_at": "2026-04-29T18:25:25Z",
+  "legs": [
+    {
+      "bar": "15m",
+      "decision_candle_time": "2026-04-29T16:30:00Z",
+      "decision_candle_ts": 1777480200000,
+      "dry_run_action": "hold",
+      "entry_rule": "btc_close > btc_sma and btc_momentum >= minimum and eth_close > eth_sma and eth_rsi2 <= threshold",
+      "exit_signal": true,
+      "family": "eth_btc_rsi_filter",
+      "indicators": {
+        "btc_close": 75856.0,
+        "btc_momentum": -0.040101233786776325,
+        "btc_sma": 77331.09999999992,
+        "eth_close": 2266.75,
+        "eth_rsi2": 10.743438715792621,
+        "eth_sma": 2315.6154000000006
+      },
+      "latest_local_candle_time": "2026-04-29T16:45:00Z",
+      "latest_local_candle_ts": 1777481100000,
+      "leg_id": "eth_btc_rsi_filter_15m",
+      "needs_cancel": false,
+      "needs_order": false,
+      "params": {
+        "btc_min_momentum": 0.0,
+        "btc_momentum_lookback": 240,
+        "btc_trend_sma": 480,
+        "eth_exit_rsi": 55.0,
+        "eth_rsi_threshold": 3.0,
+        "eth_trend_sma": 50
+      },
+      "risk_limits": {
+        "leverage": 3,
+        "max_hold_bars": null,
+        "max_weight": 0.80314757,
+        "stop_loss_pct": null,
+        "take_profit_pct": null
+      },
+      "signal": false,
+      "suggested_weight": 0.80314757,
+      "symbol": "ETH-USDT-SWAP"
+    },
+    {
+      "bar": "15m",
+      "decision_candle_time": "2026-04-29T16:30:00Z",
+      "decision_candle_ts": 1777480200000,
+      "dry_run_action": "hold",
+      "entry_rule": "btc_return >= threshold and btc_return - eth_return >= lag_gap",
+      "exit_signal": false,
+      "family": "btc_lead_eth_lag",
+      "indicators": {
+        "btc_close": 75856.0,
+        "btc_return": -0.009247165769813437,
+        "eth_close": 2266.75,
+        "eth_return": -0.014409384796664093,
+        "return_gap": 0.005162219026850656
+      },
+      "latest_local_candle_time": "2026-04-29T16:45:00Z",
+      "latest_local_candle_ts": 1777481100000,
+      "leg_id": "btc_lead_eth_lag_15m",
+      "needs_cancel": false,
+      "needs_order": false,
+      "params": {
+        "btc_return_threshold": 0.018,
+        "lag_gap": 0.006,
+        "lead_lookback": 8,
+        "max_hold_bars": 8,
+        "stop_loss_pct": 0.006,
+        "take_profit_pct": 0.018
+      },
+      "risk_limits": {
+        "leverage": 3,
+        "max_hold_bars": 8,
+        "max_weight": 0.09459139,
+        "stop_loss_pct": 0.006,
+        "take_profit_pct": 0.018
+      },
+      "signal": false,
+      "suggested_weight": 0.09459139,
+      "symbol": "ETH-USDT-SWAP"
+    },
+    {
+      "bar": "5m",
+      "decision_candle_time": "2026-04-29T01:55:00Z",
+      "decision_candle_ts": 1777427700000,
+      "dry_run_action": "hold",
+      "entry_rule": "btc_return >= threshold and btc_return - eth_return >= lag_gap",
+      "exit_signal": false,
+      "family": "btc_lead_eth_lag",
+      "indicators": {
+        "btc_close": 76534.3,
+        "btc_return": 0.0028355077904398396,
+        "eth_close": 2291.56,
+        "eth_return": 0.0025067480958775867,
+        "return_gap": 0.00032875969456225285
+      },
+      "latest_local_candle_time": "2026-04-29T02:00:00Z",
+      "latest_local_candle_ts": 1777428000000,
+      "leg_id": "btc_lead_eth_lag_5m",
+      "needs_cancel": false,
+      "needs_order": false,
+      "params": {
+        "btc_return_threshold": 0.012,
+        "lag_gap": 0.006,
+        "lead_lookback": 16,
+        "max_hold_bars": 8,
+        "stop_loss_pct": 0.006,
+        "take_profit_pct": 0.018
+      },
+      "risk_limits": {
+        "leverage": 3,
+        "max_hold_bars": 8,
+        "max_weight": 0.10226104,
+        "stop_loss_pct": 0.006,
+        "take_profit_pct": 0.018
+      },
+      "signal": false,
+      "suggested_weight": 0.10226104,
+      "symbol": "ETH-USDT-SWAP"
+    }
+  ],
+  "mode": "dry_run_readonly_portfolio_signal_intent",
+  "order_client": null,
+  "portfolio": {
+    "active_signal_count": 0,
+    "active_suggested_weight": 0,
+    "basis": "ETH/BTC RSI filter + BTC lead ETH lag 5m/15m",
+    "direction": "long_only",
+    "dry_run_action": "hold",
+    "leverage": 3,
+    "name": "eth_focused_conservative_signal_intent",
+    "needs_cancel": false,
+    "needs_order": false,
+    "symbol": "ETH-USDT-SWAP"
+  },
+  "private_key_required": false,
+  "risk_limits": {
+    "execution": "intent_only",
+    "leg_weights_sum": 1.0,
+    "no_cancel_submission": true,
+    "no_order_submission": true,
+    "no_position_state_assumed": true,
+    "portfolio_max_gross_weight": 1.0
+  },
+  "submitted_orders": 0
+}
+```

+ 304 - 0
scripts/build_eth_conservative_portfolio_report.py

@@ -0,0 +1,304 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import csv
+import json
+from pathlib import Path
+from typing import Any
+
+
+ROOT = Path(__file__).resolve().parents[1]
+REPORT_DIR = ROOT / "reports" / "eth-exploration"
+MD_OUT = REPORT_DIR / "eth-conservative-portfolio-final.md"
+JSON_OUT = REPORT_DIR / "eth-conservative-portfolio-final.json"
+
+
+def read_csv(name: str) -> list[dict[str, str]]:
+    with (REPORT_DIR / name).open(newline="", encoding="utf-8") as handle:
+        return list(csv.DictReader(handle))
+
+
+def f(row: dict[str, str], key: str) -> float:
+    value = row.get(key, "")
+    return float(value) if value not in ("", None) else 0.0
+
+
+def pct(value: float) -> str:
+    return f"{value * 100:.2f}%"
+
+
+def num(value: float) -> str:
+    return f"{value:.3f}"
+
+
+def pick(rows: list[dict[str, str]], **where: str) -> list[dict[str, str]]:
+    return [row for row in rows if all(row.get(key) == value for key, value in where.items())]
+
+
+def public_metrics(row: dict[str, str]) -> dict[str, Any]:
+    keys = [
+        "cost_model",
+        "scope",
+        "trades",
+        "net_total_return",
+        "net_annualized_return",
+        "net_max_drawdown",
+        "net_calmar",
+        "worst_month",
+        "worst_month_return",
+        "min_horizon_total_return",
+        "max_horizon_drawdown",
+    ]
+    out: dict[str, Any] = {}
+    for key in keys:
+        if key not in row:
+            continue
+        value = row[key]
+        if key in {
+            "trades",
+            "net_total_return",
+            "net_annualized_return",
+            "net_max_drawdown",
+            "net_calmar",
+            "worst_month_return",
+            "min_horizon_total_return",
+            "max_horizon_drawdown",
+        }:
+            out[key] = f(row, key)
+        else:
+            out[key] = value
+    return out
+
+
+def candidate_from_portfolio(priority: int, title: str, row: dict[str, str], decision: str, next_step: str, real_live_now: bool) -> dict[str, Any]:
+    return {
+        "priority": priority,
+        "title": title,
+        "name": row["portfolio"],
+        "status": "candidate",
+        "real_live_now": real_live_now,
+        "needs_forward_or_demo_live": True,
+        "minimum_next_step": next_step,
+        "decision": decision,
+        "metrics": public_metrics(row),
+        "legs": row["legs"].split(";"),
+        "weights": row["weights"].split(";"),
+    }
+
+
+def candidate_from_bb(priority: int, row: dict[str, str]) -> dict[str, Any]:
+    return {
+        "priority": priority,
+        "title": "BB squeeze risk candidate",
+        "name": row["name"],
+        "status": "watchlist",
+        "real_live_now": False,
+        "needs_forward_or_demo_live": True,
+        "minimum_next_step": "Run shadow/demo signal intent logging only; require fresh forward trades before any capital allocation because the acceptable-risk versions have few trades.",
+        "decision": "Keep as secondary watchlist, not primary allocation.",
+        "metrics": public_metrics(row),
+    }
+
+
+def build_payload() -> dict[str, Any]:
+    portfolios = read_csv("eth-focused-portfolio-conservative-qualified.csv")
+    bb = read_csv("eth-bb-squeeze-risk-10y-summary.csv")
+    twap_cons = read_csv("eth-twap-conservative-ranked.csv")
+    taker_top = read_csv("eth-twap-taker-entry-top15.csv")
+
+    maker_qualified = pick(portfolios, cost_model="maker_taker", scope="all_legs")
+    no_maker = pick(portfolios, cost_model="maker_taker", scope="no_maker_dependent")
+    all_legs_low_dd = maker_qualified[0]
+    all_legs_high_return = sorted(maker_qualified, key=lambda row: f(row, "net_annualized_return"), reverse=True)[0]
+    no_maker_low_dd = no_maker[0]
+
+    bb_primary = [
+        row
+        for row in bb
+        if row.get("cost") == "maker_taker"
+        and f(row, "net_max_drawdown") <= 0.45
+        and f(row, "worst_month_return") >= -0.25
+        and f(row, "net_calmar") > 1.0
+    ]
+    bb_primary.sort(key=lambda row: (f(row, "net_calmar"), f(row, "net_annualized_return")), reverse=True)
+
+    twap_nearest = twap_cons[0]
+    candidates = [
+        candidate_from_portfolio(
+            1,
+            "Lowest-drawdown ETH-focused conservative portfolio",
+            all_legs_low_dd,
+            "Primary next item to watch in paper/demo. It is the cleanest qualified portfolio by conservative sort, but it contains a maker-dependent TWAP leg, so real funds should wait for live fill evidence.",
+            "Run quasi-live read-only/order-intent tracking for all legs and record per-leg signal, fill/miss, slippage, and portfolio equity for at least the next signal cycle set.",
+            False,
+        ),
+        candidate_from_portfolio(
+            2,
+            "Simpler no-maker-dependent conservative portfolio",
+            no_maker_low_dd,
+            "Best fallback if maker-fill uncertainty is treated as disqualifying. It keeps qualified portfolio behavior without the robust TWAP maker-dependent leg.",
+            "Shadow the two legs side by side with the primary portfolio and compare realized signal overlap and drawdown path.",
+            False,
+        ),
+        candidate_from_portfolio(
+            3,
+            "Highest-return qualified conservative portfolio",
+            all_legs_high_return,
+            "Return-oriented qualified variant. It is less conservative than priority 1 because drawdown is materially higher.",
+            "Keep as a benchmark portfolio in the same quasi-live tracker; do not allocate before it beats priority 1 on realized drawdown-adjusted behavior.",
+            False,
+        ),
+    ]
+    if bb_primary:
+        candidates.append(candidate_from_bb(4, bb_primary[0]))
+
+    rejected = [
+        {
+            "name": "ETH robust TWAP standalone under conservative maker assumptions",
+            "status": "do_not_trade_standalone",
+            "reason": "Independent validation matched the closed-trade report, but conservative maker fill/slippage assumptions had no qualified candidate with all 3y/1y/6m/3m horizons positive.",
+            "nearest_miss": {
+                "name": twap_nearest["name"],
+                "trades": f(twap_nearest, "trades"),
+                "net_total_return": f(twap_nearest, "net_total_return"),
+                "net_annualized_return": f(twap_nearest, "net_annualized_return"),
+                "net_max_drawdown": f(twap_nearest, "net_max_drawdown"),
+                "min_horizon_total_return": f(twap_nearest, "min_horizon_total_return"),
+                "worst_365_total_return": f(twap_nearest, "worst_365_total_return"),
+            },
+            "minimum_next_step": "Use only as a portfolio leg in shadow/demo tracking until actual maker fill and slippage data contradicts the conservative stress result.",
+        },
+        {
+            "name": "ETH taker-entry TWAP",
+            "status": "rejected",
+            "reason": "The taker-entry search produced no eligible taker_taker candidate with positive Calmar across 3y/1y/6m/3m.",
+            "eligible_rows": len(taker_top),
+            "minimum_next_step": "No live work. Drop from the next real/paper-live shortlist.",
+        },
+    ]
+
+    return {
+        "report": "eth-conservative-portfolio-final",
+        "generated_from_existing_outputs_only": True,
+        "source_files": [
+            "eth-focused-portfolio-conservative-qualified.csv",
+            "eth-focused-portfolio-conservative-report.md",
+            "eth-twap-conservative-summary.md",
+            "eth-robust-twap-validation-summary.md",
+            "eth-robust-twap-fill-slippage-summary.md",
+            "eth-twap-taker-entry-summary.md",
+            "eth-bb-squeeze-risk-10y-report.md",
+            "eth-signal-intent-readonly.md",
+            "eth-signal-intent-readonly.json",
+        ],
+        "topline_decision": "Watch the conservative portfolio layer next, not the standalone ETH TWAP. Use quasi-live/demo read-only intent first; real funds are not the minimum next step.",
+        "candidates": candidates,
+        "rejected_or_deprioritized": rejected,
+        "current_signal_intent": {
+            "completed": True,
+            "latest_confirmed_candle_utc": "2026-04-29 17:00:00",
+            "signal": False,
+            "orders_produced": 0,
+        },
+    }
+
+
+def md_table(rows: list[list[str]]) -> str:
+    header = rows[0]
+    body = rows[1:]
+    lines = [
+        "| " + " | ".join(header) + " |",
+        "| " + " | ".join(["---"] * len(header)) + " |",
+    ]
+    lines.extend("| " + " | ".join(row) + " |" for row in body)
+    return "\n".join(lines)
+
+
+def render_markdown(payload: dict[str, Any]) -> str:
+    rows = [["Priority", "Candidate", "Return", "Ann.", "MDD", "Worst month", "Risk", "Real live now", "Minimum next step"]]
+    for item in payload["candidates"]:
+        m = item["metrics"]
+        rows.append(
+            [
+                str(item["priority"]),
+                item["title"],
+                pct(float(m.get("net_total_return", 0.0))),
+                pct(float(m.get("net_annualized_return", 0.0))),
+                pct(float(m.get("net_max_drawdown", 0.0))),
+                pct(float(m.get("worst_month_return", 0.0))) if "worst_month_return" in m else "",
+                num(float(m.get("net_calmar", 0.0))),
+                "No" if not item["real_live_now"] else "Yes",
+                item["minimum_next_step"],
+            ]
+        )
+
+    rejected_rows = [["Item", "Status", "Key reason", "Minimum next step"]]
+    for item in payload["rejected_or_deprioritized"]:
+        rejected_rows.append([item["name"], item["status"], item["reason"], item["minimum_next_step"]])
+
+    lines = [
+        "# ETH conservative portfolio final decision report",
+        "",
+        "This report only consolidates existing ETH exploration outputs. It does not run a new search.",
+        "",
+        "## Topline",
+        "",
+        payload["topline_decision"],
+        "",
+        "The next thing to watch is the portfolio layer: qualified ETH-focused conservative portfolios exist, while standalone ETH TWAP is not stable enough under conservative maker-fill assumptions and taker-entry TWAP is rejected.",
+        "",
+        "## Recommended priority",
+        "",
+        md_table(rows),
+        "",
+        "## Candidate notes",
+        "",
+    ]
+    for item in payload["candidates"]:
+        lines.extend(
+            [
+                f"### P{item['priority']} {item['title']}",
+                "",
+                f"- Name: `{item['name']}`",
+                f"- Decision: {item['decision']}",
+                f"- Needs live/quasi-live evidence: {'Yes' if item['needs_forward_or_demo_live'] else 'No'}",
+                f"- Real funds now: {'Yes' if item['real_live_now'] else 'No'}",
+                "",
+            ]
+        )
+        if "legs" in item:
+            lines.append("Legs and weights:")
+            for leg, weight in zip(item["legs"], item["weights"]):
+                lines.append(f"- `{leg}` at `{weight.split('=')[-1]}`")
+            lines.append("")
+
+    lines.extend(
+        [
+            "## Deprioritized or rejected",
+            "",
+            md_table(rejected_rows),
+            "",
+            "## Signal intent status",
+            "",
+            "Readonly signal intent was completed. Latest confirmed candle was `2026-04-29 17:00:00 UTC`; signal was false and no order intent was produced.",
+            "",
+            "## Source outputs",
+            "",
+        ]
+    )
+    lines.extend(f"- `{name}`" for name in payload["source_files"])
+    lines.append("")
+    return "\n".join(lines)
+
+
+def main() -> int:
+    payload = build_payload()
+    JSON_OUT.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+    MD_OUT.write_text(render_markdown(payload), encoding="utf-8")
+    print(MD_OUT)
+    print(JSON_OUT)
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 323 - 0
scripts/build_eth_focused_portfolio_signal_intent.py

@@ -0,0 +1,323 @@
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import math
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+CANDLE_DIR = ROOT / "data" / "okx-candles"
+REPORT_DIR = ROOT / "reports" / "eth-exploration"
+JSON_REPORT = REPORT_DIR / "eth-focused-portfolio-signal-intent.json"
+MARKDOWN_REPORT = REPORT_DIR / "eth-focused-portfolio-signal-intent.md"
+ETH = "ETH-USDT-SWAP"
+BTC = "BTC-USDT-SWAP"
+LEVERAGE = 3
+
+
+@dataclass(frozen=True)
+class Candle:
+    ts: int
+    open: float
+    high: float
+    low: float
+    close: float
+    volume: float
+
+
+@dataclass(frozen=True)
+class LegSpec:
+    leg_id: str
+    family: str
+    bar: str
+    weight: float
+    params: dict[str, float | int]
+
+
+LEGS = (
+    LegSpec(
+        leg_id="eth_btc_rsi_filter_15m",
+        family="eth_btc_rsi_filter",
+        bar="15m",
+        weight=0.80314757,
+        params={
+            "eth_trend_sma": 50,
+            "eth_rsi_threshold": 3.0,
+            "eth_exit_rsi": 55.0,
+            "btc_trend_sma": 480,
+            "btc_momentum_lookback": 240,
+            "btc_min_momentum": 0.0,
+        },
+    ),
+    LegSpec(
+        leg_id="btc_lead_eth_lag_15m",
+        family="btc_lead_eth_lag",
+        bar="15m",
+        weight=0.09459139,
+        params={
+            "lead_lookback": 8,
+            "btc_return_threshold": 0.018,
+            "lag_gap": 0.006,
+            "max_hold_bars": 8,
+            "stop_loss_pct": 0.006,
+            "take_profit_pct": 0.018,
+        },
+    ),
+    LegSpec(
+        leg_id="btc_lead_eth_lag_5m",
+        family="btc_lead_eth_lag",
+        bar="5m",
+        weight=0.10226104,
+        params={
+            "lead_lookback": 16,
+            "btc_return_threshold": 0.012,
+            "lag_gap": 0.006,
+            "max_hold_bars": 8,
+            "stop_loss_pct": 0.006,
+            "take_profit_pct": 0.018,
+        },
+    ),
+)
+
+
+def iso_text(ts: int) -> str:
+    return datetime.fromtimestamp(ts / 1000, UTC).isoformat().replace("+00:00", "Z")
+
+
+def load_candles(symbol: str, bar: str) -> list[Candle]:
+    path = CANDLE_DIR / symbol / f"{bar}.csv"
+    candles: list[Candle] = []
+    with path.open("r", encoding="utf-8", newline="") as handle:
+        for row in csv.DictReader(handle):
+            candles.append(
+                Candle(
+                    ts=int(row["ts"]),
+                    open=float(row["open"]),
+                    high=float(row["high"]),
+                    low=float(row["low"]),
+                    close=float(row["close"]),
+                    volume=float(row["volume"]),
+                )
+            )
+    return sorted(candles, key=lambda candle: candle.ts)
+
+
+def align_pair(eth: list[Candle], btc: list[Candle]) -> tuple[list[Candle], list[Candle]]:
+    btc_by_ts = {candle.ts: candle for candle in btc}
+    eth_aligned: list[Candle] = []
+    btc_aligned: list[Candle] = []
+    for candle in eth:
+        btc_candle = btc_by_ts.get(candle.ts)
+        if btc_candle is not None:
+            eth_aligned.append(candle)
+            btc_aligned.append(btc_candle)
+    return eth_aligned, btc_aligned
+
+
+def sma(values: list[float], length: int, index: int) -> float:
+    if index + 1 < length:
+        return math.nan
+    return sum(values[index + 1 - length : index + 1]) / length
+
+
+def rsi(values: list[float], length: int) -> list[float]:
+    output = [math.nan] * len(values)
+    if len(values) <= length:
+        return output
+    gains = [0.0]
+    losses = [0.0]
+    for previous, current in zip(values, values[1:]):
+        delta = current - previous
+        gains.append(max(delta, 0.0))
+        losses.append(max(-delta, 0.0))
+    average_gain = sum(gains[1 : length + 1]) / length
+    average_loss = sum(losses[1 : length + 1]) / length
+    for index in range(length, len(values)):
+        if index > length:
+            average_gain = ((average_gain * (length - 1)) + gains[index]) / length
+            average_loss = ((average_loss * (length - 1)) + losses[index]) / length
+        if average_loss == 0.0:
+            output[index] = 100.0 if average_gain > 0.0 else 50.0
+        else:
+            relative_strength = average_gain / average_loss
+            output[index] = 100.0 - (100.0 / (1.0 + relative_strength))
+    return output
+
+
+def eth_btc_rsi_filter_signal(spec: LegSpec, eth: list[Candle], btc: list[Candle], index: int) -> dict[str, object]:
+    eth_closes = [candle.close for candle in eth]
+    btc_closes = [candle.close for candle in btc]
+    eth_trend_sma = int(spec.params["eth_trend_sma"])
+    btc_trend_sma = int(spec.params["btc_trend_sma"])
+    btc_momentum_lookback = int(spec.params["btc_momentum_lookback"])
+    eth_trend = sma(eth_closes, eth_trend_sma, index)
+    btc_trend = sma(btc_closes, btc_trend_sma, index)
+    eth_rsi = rsi(eth_closes[: index + 1], 2)[-1]
+    btc_momentum = btc[index].close / btc[index - btc_momentum_lookback].close - 1.0
+    btc_risk_on = btc[index].close > btc_trend and btc_momentum >= float(spec.params["btc_min_momentum"])
+    eth_pullback = eth[index].close > eth_trend and eth_rsi <= float(spec.params["eth_rsi_threshold"])
+    signal = btc_risk_on and eth_pullback
+    exit_signal = eth_rsi >= float(spec.params["eth_exit_rsi"]) or btc[index].close < btc_trend
+    return {
+        "signal": signal,
+        "entry_rule": "btc_close > btc_sma and btc_momentum >= minimum and eth_close > eth_sma and eth_rsi2 <= threshold",
+        "exit_signal": exit_signal,
+        "indicators": {
+            "eth_close": eth[index].close,
+            "eth_sma": eth_trend,
+            "eth_rsi2": eth_rsi,
+            "btc_close": btc[index].close,
+            "btc_sma": btc_trend,
+            "btc_momentum": btc_momentum,
+        },
+    }
+
+
+def btc_lead_eth_lag_signal(spec: LegSpec, eth: list[Candle], btc: list[Candle], index: int) -> dict[str, object]:
+    lead_lookback = int(spec.params["lead_lookback"])
+    btc_return = btc[index].close / btc[index - lead_lookback].close - 1.0
+    eth_return = eth[index].close / eth[index - lead_lookback].close - 1.0
+    return_gap = btc_return - eth_return
+    signal = btc_return >= float(spec.params["btc_return_threshold"]) and return_gap >= float(spec.params["lag_gap"])
+    return {
+        "signal": signal,
+        "entry_rule": "btc_return >= threshold and btc_return - eth_return >= lag_gap",
+        "exit_signal": False,
+        "indicators": {
+            "eth_close": eth[index].close,
+            "btc_close": btc[index].close,
+            "btc_return": btc_return,
+            "eth_return": eth_return,
+            "return_gap": return_gap,
+        },
+    }
+
+
+def evaluate_leg(spec: LegSpec, data: dict[tuple[str, str], list[Candle]]) -> dict[str, object]:
+    eth, btc = align_pair(data[(ETH, spec.bar)], data[(BTC, spec.bar)])
+    index = len(eth) - 2
+    if spec.family == "eth_btc_rsi_filter":
+        decision = eth_btc_rsi_filter_signal(spec, eth, btc, index)
+    else:
+        decision = btc_lead_eth_lag_signal(spec, eth, btc, index)
+    stop_loss_pct = spec.params.get("stop_loss_pct")
+    take_profit_pct = spec.params.get("take_profit_pct")
+    return {
+        "leg_id": spec.leg_id,
+        "family": spec.family,
+        "symbol": ETH,
+        "bar": spec.bar,
+        "decision_candle_ts": eth[index].ts,
+        "decision_candle_time": iso_text(eth[index].ts),
+        "latest_local_candle_ts": eth[-1].ts,
+        "latest_local_candle_time": iso_text(eth[-1].ts),
+        "suggested_weight": spec.weight,
+        "signal": decision["signal"],
+        "needs_order": bool(decision["signal"]),
+        "needs_cancel": False,
+        "dry_run_action": "would_open_long" if decision["signal"] else "hold",
+        "risk_limits": {
+            "leverage": LEVERAGE,
+            "max_weight": spec.weight,
+            "stop_loss_pct": stop_loss_pct,
+            "take_profit_pct": take_profit_pct,
+            "max_hold_bars": spec.params.get("max_hold_bars"),
+        },
+        "params": spec.params,
+        "entry_rule": decision["entry_rule"],
+        "exit_signal": decision["exit_signal"],
+        "indicators": decision["indicators"],
+    }
+
+
+def build_payload() -> dict[str, object]:
+    bars = sorted({leg.bar for leg in LEGS})
+    data = {(symbol, bar): load_candles(symbol, bar) for symbol in (ETH, BTC) for bar in bars}
+    legs = [evaluate_leg(spec, data) for spec in LEGS]
+    active_weight = sum(float(leg["suggested_weight"]) for leg in legs if leg["signal"])
+    return {
+        "mode": "dry_run_readonly_portfolio_signal_intent",
+        "created_at": datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"),
+        "submitted_orders": 0,
+        "order_client": None,
+        "private_key_required": False,
+        "portfolio": {
+            "name": "eth_focused_conservative_signal_intent",
+            "symbol": ETH,
+            "direction": "long_only",
+            "basis": "ETH/BTC RSI filter + BTC lead ETH lag 5m/15m",
+            "leverage": LEVERAGE,
+            "active_signal_count": sum(1 for leg in legs if leg["signal"]),
+            "active_suggested_weight": active_weight,
+            "needs_order": any(bool(leg["needs_order"]) for leg in legs),
+            "needs_cancel": any(bool(leg["needs_cancel"]) for leg in legs),
+            "dry_run_action": "would_open_or_rebalance_long" if active_weight > 0.0 else "hold",
+        },
+        "risk_limits": {
+            "portfolio_max_gross_weight": 1.0,
+            "leg_weights_sum": sum(spec.weight for spec in LEGS),
+            "no_order_submission": True,
+            "no_cancel_submission": True,
+            "no_position_state_assumed": True,
+            "execution": "intent_only",
+        },
+        "legs": legs,
+    }
+
+
+def markdown_report(payload: dict[str, object]) -> str:
+    lines = [
+        "# ETH-focused portfolio signal intent",
+        "",
+        "Dry-run only. No order or cancel request was submitted.",
+        "",
+        "## Portfolio",
+        "",
+        f"- Created at: `{payload['created_at']}`",
+        f"- Direction: `{payload['portfolio']['direction']}`",
+        f"- Active signal count: `{payload['portfolio']['active_signal_count']}`",
+        f"- Active suggested weight: `{payload['portfolio']['active_suggested_weight']:.8f}`",
+        f"- Needs order: `{payload['portfolio']['needs_order']}`",
+        f"- Needs cancel: `{payload['portfolio']['needs_cancel']}`",
+        "",
+        "## Legs",
+        "",
+        "| Leg | Bar | Signal | Weight | Action | Decision candle |",
+        "| --- | --- | --- | --- | --- | --- |",
+    ]
+    for leg in payload["legs"]:
+        lines.append(
+            f"| `{leg['leg_id']}` | `{leg['bar']}` | `{leg['signal']}` | `{leg['suggested_weight']:.8f}` | `{leg['dry_run_action']}` | `{leg['decision_candle_time']}` |"
+        )
+    lines.extend(
+        [
+            "",
+            "## Intent JSON",
+            "",
+            "```json",
+            json.dumps(payload, indent=2, sort_keys=True),
+            "```",
+        ]
+    )
+    return "\n".join(lines) + "\n"
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Build a read-only ETH-focused portfolio signal/intent payload.")
+    parser.add_argument("--no-write", action="store_true")
+    args = parser.parse_args()
+    payload = build_payload()
+    if not args.no_write:
+        REPORT_DIR.mkdir(parents=True, exist_ok=True)
+        JSON_REPORT.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+        MARKDOWN_REPORT.write_text(markdown_report(payload), encoding="utf-8")
+    print(json.dumps(payload, indent=2, sort_keys=True))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())